From cc1d37f56f2faf190da771c03ec85eec1a61ec48 Mon Sep 17 00:00:00 2001 From: Craig Perkins Date: Tue, 17 Mar 2026 14:25:47 -0400 Subject: [PATCH 1/3] Allow specifying default access level in resouce access levels yml file Signed-off-by: Craig Perkins --- .../securityapis/MigrateApiTests.java | 37 +++++++++++++++- ...-groups.yml => resource-access-levels.yml} | 2 + .../resources/ResourceActionGroupsHelper.java | 34 +++++++++----- .../resources/ResourcePluginInfo.java | 21 ++++++++- .../MigrateResourceSharingInfoApiAction.java | 44 ++++++++++++++----- 5 files changed, 114 insertions(+), 24 deletions(-) rename sample-resource-plugin/src/main/resources/{resource-action-groups.yml => resource-access-levels.yml} (95%) diff --git a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/securityapis/MigrateApiTests.java b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/securityapis/MigrateApiTests.java index 063e97e6ef..96bf7120c2 100644 --- a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/securityapis/MigrateApiTests.java +++ b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/securityapis/MigrateApiTests.java @@ -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 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(); @@ -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); } } diff --git a/sample-resource-plugin/src/main/resources/resource-action-groups.yml b/sample-resource-plugin/src/main/resources/resource-access-levels.yml similarity index 95% rename from sample-resource-plugin/src/main/resources/resource-action-groups.yml rename to sample-resource-plugin/src/main/resources/resource-access-levels.yml index c373634be9..6ef0ef8e88 100644 --- a/sample-resource-plugin/src/main/resources/resource-action-groups.yml +++ b/sample-resource-plugin/src/main/resources/resource-access-levels.yml @@ -1,6 +1,7 @@ resource_types: sample-resource: sample_read_only: + default: true allowed_actions: - "cluster:admin/sample-resource-plugin/get" @@ -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" diff --git a/src/main/java/org/opensearch/security/resources/ResourceActionGroupsHelper.java b/src/main/java/org/opensearch/security/resources/ResourceActionGroupsHelper.java index 48c131969b..00f141f95e 100644 --- a/src/main/java/org/opensearch/security/resources/ResourceActionGroupsHelper.java +++ b/src/main/java/org/opensearch/security/resources/ResourceActionGroupsHelper.java @@ -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: @@ -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; } @@ -53,7 +53,7 @@ public static void loadActionGroupsConfig(ResourcePluginInfo resourcePluginInfo) Map 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; } @@ -73,10 +73,22 @@ public static void loadActionGroupsConfig(ResourcePluginInfo resourcePluginInfo) continue; // no fallback } - SecurityDynamicConfiguration cfg = SecurityDynamicConfiguration.fromMap( - (Map) typeMapRaw, - CType.ACTIONGROUPS - ); + // Extract default access level and strip the "default" key before passing to SecurityDynamicConfiguration + String defaultAccessLevel = null; + Map typeMap = new java.util.LinkedHashMap<>((Map) typeMapRaw); + for (Map.Entry 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) levelCfg).remove("default"); + break; + } + } + } + + SecurityDynamicConfiguration cfg = SecurityDynamicConfiguration.fromMap(typeMap, CType.ACTIONGROUPS); // prune groups that ended up empty after normalization cfg.getCEntries() @@ -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()); } } } diff --git a/src/main/java/org/opensearch/security/resources/ResourcePluginInfo.java b/src/main/java/org/opensearch/security/resources/ResourcePluginInfo.java index 5c3deeae6a..a6fa4921ac 100644 --- a/src/main/java/org/opensearch/security/resources/ResourcePluginInfo.java +++ b/src/main/java/org/opensearch/security/resources/ResourcePluginInfo.java @@ -57,6 +57,9 @@ public class ResourcePluginInfo { // AuthZ: resolved (flattened) groups per type private final Map typeToFlattened = new HashMap<>(); + // default access level per type (for migration) + private final Map typeToDefaultAccessLevel = new HashMap<>(); + // cache current protected types and their indices private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); // make the updates/reads thread-safe @@ -208,7 +211,11 @@ public XContentBuilder toXContent(XContentBuilder b, Params p) throws IOExceptio } } - public void registerAccessLevels(String resourceType, SecurityDynamicConfiguration accessLevels) { + public void registerAccessLevels( + String resourceType, + SecurityDynamicConfiguration accessLevels, + String defaultAccessLevel + ) { if (resourceType == null || accessLevels == null) return; lock.writeLock().lock(); try { @@ -216,6 +223,9 @@ public void registerAccessLevels(String resourceType, SecurityDynamicConfigurati 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(); } @@ -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 { diff --git a/src/main/java/org/opensearch/security/resources/api/migrate/MigrateResourceSharingInfoApiAction.java b/src/main/java/org/opensearch/security/resources/api/migrate/MigrateResourceSharingInfoApiAction.java index d91e1aa78e..773c2a7cf7 100644 --- a/src/main/java/org/opensearch/security/resources/api/migrate/MigrateResourceSharingInfoApiAction.java +++ b/src/main/java/org/opensearch/security/resources/api/migrate/MigrateResourceSharingInfoApiAction.java @@ -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: "" // default owner when username_path is not available - * default_access_level: "" // default access-level at which sharing records should be created + * 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 @@ -163,7 +163,7 @@ private ValidationResult 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 @@ -251,8 +251,15 @@ private ValidationResult 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)); @@ -267,6 +274,23 @@ private ValidationResult loadCurrentSharingInfo(RestRequest ClearScrollRequest clear = new ClearScrollRequest(); clear.addScrollId(scrollId); client.clearScroll(clear).actionGet(); + // fail fast if any doc has backend roles but no access level can be resolved for its type + for (SourceDoc doc : results) { + if (!doc.backendRoles().isEmpty()) { + 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)); } } @@ -328,8 +352,12 @@ private ValidationResult createNewSharingRecords(ValidationResul List 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 @@ -420,13 +448,7 @@ public Settings settings() { @Override public Set 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 From 46068f697af5e29f045b3c710dbf6f632ba3c52e Mon Sep 17 00:00:00 2001 From: Craig Perkins Date: Tue, 17 Mar 2026 14:28:42 -0400 Subject: [PATCH 2/3] Add to CHANGELOG Signed-off-by: Craig Perkins --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19f38f2eb4..2307e19cd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Make security plugin aware of FIPS build param (-Pcrypto.standard=FIPS-140-3) ([#5952](https://github.com/opensearch-project/security/pull/5952)) - Hardens input validation for resource sharing APIs ([#5831](https://github.com/opensearch-project/security/pull/5831) - Optimize getFieldFilter to only return a predicate when index has FLS restrictions for user ([#5777](https://github.com/opensearch-project/security/pull/5777)) +- [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) From b54b7cf65fbb2d03498cd638763729a55bb539cd Mon Sep 17 00:00:00 2001 From: Craig Perkins Date: Tue, 17 Mar 2026 21:26:01 -0400 Subject: [PATCH 3/3] Fix block Signed-off-by: Craig Perkins --- .../MigrateResourceSharingInfoApiAction.java | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/src/main/java/org/opensearch/security/resources/api/migrate/MigrateResourceSharingInfoApiAction.java b/src/main/java/org/opensearch/security/resources/api/migrate/MigrateResourceSharingInfoApiAction.java index 773c2a7cf7..91fa950145 100644 --- a/src/main/java/org/opensearch/security/resources/api/migrate/MigrateResourceSharingInfoApiAction.java +++ b/src/main/java/org/opensearch/security/resources/api/migrate/MigrateResourceSharingInfoApiAction.java @@ -274,20 +274,18 @@ private ValidationResult loadCurrentSharingInfo(RestRequest ClearScrollRequest clear = new ClearScrollRequest(); clear.addScrollId(scrollId); client.clearScroll(clear).actionGet(); - // fail fast if any doc has backend roles but no access level can be resolved for its type + // fail fast if any type in the results has no resolvable access level for (SourceDoc doc : results) { - if (!doc.backendRoles().isEmpty()) { - 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." - ) - ); - } + 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." + ) + ); } }