diff --git a/CHANGELOG.md b/CHANGELOG.md index dad1df15f6..e1f84c2392 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) 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..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 @@ -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,21 @@ private ValidationResult 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)); } } @@ -328,8 +350,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 +446,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