Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
- Performance optimizations for building internal authorization data structures upon config updates ([#5988](https://github.com/opensearch-project/security/pull/5988))
- Make encryption_key optional for obo token authenticator ([#6017](https://github.com/opensearch-project/security/pull/6017)
- Enable basic authentication for gRPC transport ([#6005](https://github.com/opensearch-project/security/pull/6005))
- [Resource Sharing] Allow specifying default access level in resource access levels yml file ([#6018](https://github.com/opensearch-project/security/pull/6018))

### Bug Fixes
- Fix audit log writing errors for rollover-enabled alias indices ([#5878](https://github.com/opensearch-project/security/pull/5878)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,40 @@ public void testMigrateAPIWithSuperAdmin_noDefaultOwner() {
}
}

@Test
public void testMigrateAPIWithSuperAdmin_noDefaultAccessLevel_usesRegisteredDefault() {
String resourceId = createSampleResource();
String resourceIdNoUser = createSampleResourceNoUser();
clearResourceSharingEntries();

try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) {
// omit default_access_level entirely — should fall back to sample_read_only from resource-access-levels.yml
TestRestClient.HttpResponse migrateResponse = client.postJson(
RESOURCE_SHARING_MIGRATION_ENDPOINT,
migrationPayload_missingDefaultAccessLevel()
);
migrateResponse.assertStatusCode(HttpStatus.SC_OK);
assertThat(
migrateResponse.bodyAsMap().get("summary"),
equalTo("Migration complete. migrated 2; skippedNoType 0; skippedExisting 0; failed 0")
);

TestRestClient.HttpResponse sharingResponse = client.get(RESOURCE_SHARING_INDEX + "/_search");
sharingResponse.assertStatusCode(HttpStatus.SC_OK);
ArrayNode hitsNode = (ArrayNode) sharingResponse.bodyAsJsonNode().get("hits").get("hits");
assertThat(hitsNode.size(), equalTo(2));

List<ObjectNode> actualHits = new ArrayList<>();
hitsNode.forEach(node -> actualHits.add((ObjectNode) node));

// registered default is sample_read_only
assertThat(
actualHits,
containsInAnyOrder(expectedHits(resourceId, resourceIdNoUser, "sample_read_only").toArray(new ObjectNode[0]))
);
}
}

@Test
public void testMigrateAPIWithSuperAdmin_noDefaultAccessLevel() {
createSampleResource();
Expand All @@ -384,7 +418,8 @@ public void testMigrateAPIWithSuperAdmin_noDefaultAccessLevel() {
RESOURCE_SHARING_MIGRATION_ENDPOINT,
migrationPayload_missingDefaultAccessLevel()
);
assertThat(migrateResponse, RestMatchers.isBadRequest("/missing_mandatory_keys/keys", "default_access_level"));
// default_access_level is optional; sample plugin has sample_read_only registered as default in resource-access-levels.yml
migrateResponse.assertStatusCode(HttpStatus.SC_OK);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
resource_types:
sample-resource:
sample_read_only:
default: true
allowed_actions:
- "cluster:admin/sample-resource-plugin/get"

Expand All @@ -14,6 +15,7 @@ resource_types:
- "cluster:admin/security/resource/share"
sample-resource-group:
sample_group_read_only:
default: true
allowed_actions:
- "cluster:admin/sample-resource-plugin/group/get"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,13 @@
import org.yaml.snakeyaml.Yaml;

/**
* Helper class to load `resource-action-groups.yml` file for all resource sharing extensions.
* Helper class to load `resource-access-levels.yml` file for all resource sharing extensions.
*/
public class ResourceActionGroupsHelper {
public static final Logger log = LogManager.getLogger(ResourceActionGroupsHelper.class);

/**
* Loads action-groups config from the {@code resource-action-groups.yml} file from each resource sharing extension
* Loads action-groups config from the {@code resource-access-levels.yml} file from each resource sharing extension
* @param resourcePluginInfo will store the loaded action-groups config
*
* Sample yml file:
Expand All @@ -42,9 +42,9 @@ public class ResourceActionGroupsHelper {
public static void loadActionGroupsConfig(ResourcePluginInfo resourcePluginInfo) {
var exts = resourcePluginInfo.getResourceSharingExtensions();
for (var ext : exts) {
URL url = ext.getClass().getClassLoader().getResource("resource-action-groups.yml");
URL url = ext.getClass().getClassLoader().getResource("resource-access-levels.yml");
if (url == null) {
log.info("resource-action-groups.yml not found for {}", ext.getClass().getName());
log.info("resource-access-levels.yml not found for {}", ext.getClass().getName());
continue;
}

Expand All @@ -53,7 +53,7 @@ public static void loadActionGroupsConfig(ResourcePluginInfo resourcePluginInfo)

Map<String, Object> root = new Yaml().load(yaml);
if (root == null) {
log.info("Empty resource-action-groups.yml for {}", ext.getClass().getName());
log.info("Empty resource-access-levels.yml for {}", ext.getClass().getName());
continue;
}

Expand All @@ -73,10 +73,22 @@ public static void loadActionGroupsConfig(ResourcePluginInfo resourcePluginInfo)
continue; // no fallback
}

SecurityDynamicConfiguration<ActionGroupsV7> cfg = SecurityDynamicConfiguration.fromMap(
(Map<String, Object>) typeMapRaw,
CType.ACTIONGROUPS
);
// Extract default access level and strip the "default" key before passing to SecurityDynamicConfiguration
String defaultAccessLevel = null;
Map<String, Object> typeMap = new java.util.LinkedHashMap<>((Map<String, Object>) typeMapRaw);
for (Map.Entry<String, Object> entry : typeMap.entrySet()) {
if (entry.getValue() instanceof Map<?, ?> levelCfg) {
Object isDefault = levelCfg.get("default");
if (Boolean.TRUE.equals(isDefault)) {
defaultAccessLevel = entry.getKey();
// remove the "default" key so Jackson doesn't choke on it
((Map<String, Object>) levelCfg).remove("default");
break;
}
}
}

SecurityDynamicConfiguration<ActionGroupsV7> cfg = SecurityDynamicConfiguration.fromMap(typeMap, CType.ACTIONGROUPS);

// prune groups that ended up empty after normalization
cfg.getCEntries()
Expand All @@ -88,13 +100,13 @@ public static void loadActionGroupsConfig(ResourcePluginInfo resourcePluginInfo)
);

// Publish to ResourcePluginInfo → used by UI and authZ
resourcePluginInfo.registerAccessLevels(resType, cfg);
resourcePluginInfo.registerAccessLevels(resType, cfg, defaultAccessLevel);

log.info("Registered {} action-groups for {}", cfg.getCEntries().size(), resType);
}

} catch (Exception e) {
log.warn("Failed loading/parsing resource-action-groups.yml for {}: {}", ext.getClass().getName(), e.toString());
log.warn("Failed loading/parsing resource-access-levels.yml for {}: {}", ext.getClass().getName(), e.toString());
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ public class ResourcePluginInfo {
// AuthZ: resolved (flattened) groups per type
private final Map<String, FlattenedActionGroups> typeToFlattened = new HashMap<>();

// default access level per type (for migration)
private final Map<String, String> typeToDefaultAccessLevel = new HashMap<>();

// cache current protected types and their indices
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); // make the updates/reads thread-safe

Expand Down Expand Up @@ -208,14 +211,21 @@ public XContentBuilder toXContent(XContentBuilder b, Params p) throws IOExceptio
}
}

public void registerAccessLevels(String resourceType, SecurityDynamicConfiguration<ActionGroupsV7> accessLevels) {
public void registerAccessLevels(
String resourceType,
SecurityDynamicConfiguration<ActionGroupsV7> accessLevels,
String defaultAccessLevel
) {
if (resourceType == null || accessLevels == null) return;
lock.writeLock().lock();
try {
FlattenedActionGroups flattened = new FlattenedActionGroups(accessLevels);
typeToFlattened.put(resourceType, flattened);
typeToAccessLevels.computeIfAbsent(resourceType, k -> new LinkedHashSet<>())
.addAll(accessLevels.getCEntries().keySet().stream().toList());
if (defaultAccessLevel != null) {
typeToDefaultAccessLevel.put(resourceType, defaultAccessLevel);
}
} finally {
lock.writeLock().unlock();
}
Expand All @@ -230,6 +240,15 @@ public FlattenedActionGroups flattenedForType(String resourceType) {
}
}

public String getDefaultAccessLevel(String resourceType) {
lock.readLock().lock();
try {
return typeToDefaultAccessLevel.get(resourceType);
} finally {
lock.readLock().unlock();
}
}

public ResourceProvider getResourceProvider(String type) {
lock.readLock().lock();
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@
* username_path: "/path/to/username/node", // path to user-name in resource document in the plugin index
* backend_roles_path: "/path/to/user_backend-roles/node" // path to backend-roles in resource document in the plugin index
* default_owner: "<user-name>" // default owner when username_path is not available
* default_access_level: "<some-default-access-level>" // default access-level at which sharing records should be created
* default_access_level: "<some-default-access-level>" // optional: overrides the default access-level defined in resource-access-levels.yml
* }
* - Response:
* 200 OK Migration Complete. migrated %d; skippedNoType %s; skippedExisting %s; failed %d // migrate -> successful migration count, skippedNoType -> records with no type, skippedExisting -> records that were already migrated, failed -> records that failed to migrate
Expand Down Expand Up @@ -163,7 +163,7 @@ private ValidationResult<ValidationResultArg> loadCurrentSharingInfo(RestRequest
JsonNode defaultOwnerNode = body.get("default_owner");
String defaultOwner = (defaultOwnerNode != null && !defaultOwnerNode.isNull()) ? defaultOwnerNode.asText() : null;

// Raw JSON for default_access_level
// Raw JSON for default_access_level — optional if all types have a registered default
JsonNode defaultAccessNode = body.get("default_access_level");

// Convert after structural validation
Expand Down Expand Up @@ -251,8 +251,15 @@ private ValidationResult<ValidationResultArg> loadCurrentSharingInfo(RestRequest
String type;
if (typePath != null) {
type = rec.at("/" + typePath.replace(".", "/")).asText(null);
} else {
} else if (!typeToDefaultAccessLevel.isEmpty()) {
type = typeToDefaultAccessLevel.keySet().iterator().next();
} else {
// no type field and no explicit access level map — infer from the single registered type for this index
type = resourcePluginInfo.currentProtectedTypes()
.stream()
.filter(t -> sourceIndex.equals(resourcePluginInfo.indexByType(t)))
.findFirst()
.orElse(null);
}

results.add(new SourceDoc(id, username, backendRoles, type));
Expand All @@ -267,6 +274,21 @@ private ValidationResult<ValidationResultArg> loadCurrentSharingInfo(RestRequest
ClearScrollRequest clear = new ClearScrollRequest();
clear.addScrollId(scrollId);
client.clearScroll(clear).actionGet();
// fail fast if any type in the results has no resolvable access level
for (SourceDoc doc : results) {
String type = doc.type();
if (!typeToDefaultAccessLevel.containsKey(type) && resourcePluginInfo.getDefaultAccessLevel(type) == null) {
return ValidationResult.error(
RestStatus.BAD_REQUEST,
badRequestMessage(
"No default access level available for resource type ["
+ type
+ "]. Either mark a default in resource-access-levels.yml or supply default_access_level in the request."
)
);
}
}

return ValidationResult.success(new ValidationResultArg(sourceIndex, defaultOwner, typeToDefaultAccessLevel, results));
}
}
Expand Down Expand Up @@ -328,8 +350,12 @@ private ValidationResult<MigrationStats> createNewSharingRecords(ValidationResul
List<String> backendRoles = doc.backendRoles;
ShareWith shareWith = null;
if (!backendRoles.isEmpty()) {
String accessLevel = sourceInfo.typeToDefaultAccessLevel.get(doc.type);
if (accessLevel == null) {
accessLevel = resourcePluginInfo.getDefaultAccessLevel(doc.type);
}
Recipients recipients = new Recipients(Map.of(Recipient.BACKEND_ROLES, new HashSet<>(backendRoles)));
shareWith = new ShareWith(Map.of(sourceInfo.typeToDefaultAccessLevel.get(doc.type), recipients));
shareWith = new ShareWith(Map.of(accessLevel, recipients));
}

// 5) index the new record
Expand Down Expand Up @@ -420,13 +446,7 @@ public Settings settings() {

@Override
public Set<String> mandatoryKeys() {
return ImmutableSet.of(
"source_index",
"username_path",
"backend_roles_path",
"default_owner",
"default_access_level"
);
return ImmutableSet.of("source_index", "username_path", "backend_roles_path", "default_owner");
}

@Override
Expand Down
Loading