diff --git a/.gitignore b/.gitignore index 6fbfafabac..5eb2da999f 100644 --- a/.gitignore +++ b/.gitignore @@ -43,7 +43,3 @@ out/ build/ gradle-build/ .gradle/ - -# nodejs -node_modules/ -package-lock.json diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index fb602ee2af..eb1a55be0e 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=31c55713e40233a8303827ceb42ca48a47267a0ad4bab9177123121e71524c26 -distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip +distributionSha256Sum=f397b287023acdba1e9f6fc5ea72d22dd63669d59ed4a289a29b1a76eee151c6 +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java index 57ffc4df6f..118b5bc88b 100644 --- a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java +++ b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java @@ -43,6 +43,7 @@ import java.util.Collection; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; @@ -68,6 +69,10 @@ import org.opensearch.OpenSearchSecurityException; import org.opensearch.SpecialPermission; import org.opensearch.Version; +import org.opensearch.accesscontrol.resources.EntityType; +import org.opensearch.accesscontrol.resources.ResourceService; +import org.opensearch.accesscontrol.resources.ResourceSharing; +import org.opensearch.accesscontrol.resources.ShareWith; import org.opensearch.action.ActionRequest; import org.opensearch.action.search.PitService; import org.opensearch.action.search.SearchScrollAction; @@ -120,6 +125,8 @@ import org.opensearch.plugins.IdentityPlugin; import org.opensearch.plugins.MapperPlugin; import org.opensearch.plugins.Plugin; +import org.opensearch.plugins.ResourceAccessControlPlugin; +import org.opensearch.plugins.ResourcePlugin; import org.opensearch.plugins.SecureHttpTransportSettingsProvider; import org.opensearch.plugins.SecureSettingsFactory; import org.opensearch.plugins.SecureTransportSettingsProvider; @@ -173,6 +180,10 @@ import org.opensearch.security.privileges.RestLayerPrivilegesEvaluator; import org.opensearch.security.privileges.dlsfls.DlsFlsBaseContext; import org.opensearch.security.resolver.IndexResolverReplacer; +import org.opensearch.security.resources.ResourceAccessHandler; +import org.opensearch.security.resources.ResourceSharingIndexHandler; +import org.opensearch.security.resources.ResourceSharingIndexListener; +import org.opensearch.security.resources.ResourceSharingIndexManagementRepository; import org.opensearch.security.rest.DashboardsInfoAction; import org.opensearch.security.rest.SecurityConfigUpdateAction; import org.opensearch.security.rest.SecurityHealthAction; @@ -230,9 +241,10 @@ public final class OpenSearchSecurityPlugin extends OpenSearchSecuritySSLPlugin implements ClusterPlugin, MapperPlugin, + IdentityPlugin, + ResourceAccessControlPlugin, // CS-SUPPRESS-SINGLE: RegexpSingleline get Extensions Settings - ExtensionAwarePlugin, - IdentityPlugin + ExtensionAwarePlugin // CS-ENFORCE-SINGLE { @@ -268,6 +280,9 @@ public final class OpenSearchSecurityPlugin extends OpenSearchSecuritySSLPlugin private volatile OpensearchDynamicSetting transportPassiveAuthSetting; private volatile PasswordHasher passwordHasher; private volatile DlsFlsBaseContext dlsFlsBaseContext; + private ResourceSharingIndexManagementRepository rmr; + private ResourceAccessHandler resourceAccessHandler; + private final Set indicesToListen = new HashSet<>(); public static boolean isActionTraceEnabled() { @@ -708,6 +723,14 @@ public void onIndexModule(IndexModule indexModule) { dlsFlsBaseContext ) ); + + if (this.indicesToListen.contains(indexModule.getIndex().getName())) { + ResourceSharingIndexListener resourceSharingIndexListener = ResourceSharingIndexListener.getInstance(); + resourceSharingIndexListener.initialize(threadPool, localClient); + indexModule.addIndexOperationListener(resourceSharingIndexListener); + log.warn("Security plugin started listening to operations on index {}", indexModule.getIndex().getName()); + } + indexModule.forceQueryCacheProvider((indexSettings, nodeCache) -> new QueryCache() { @Override @@ -1183,7 +1206,7 @@ public Collection createComponents( // NOTE: We need to create DefaultInterClusterRequestEvaluator before creating ConfigurationRepository since the latter requires // security index to be accessible which means - // communciation with other nodes is already up. However for the communication to be up, there needs to be trusted nodes_dn. Hence + // communication with other nodes is already up. However for the communication to be up, there needs to be trusted nodes_dn. Hence // the base values from opensearch.yml // is used to first establish trust between same cluster nodes and there after dynamic config is loaded if enabled. if (DEFAULT_INTERCLUSTER_REQUEST_EVALUATOR_CLASS.equals(className)) { @@ -1191,6 +1214,12 @@ public Collection createComponents( e.subscribeForChanges(dcf); } + final var resourceSharingIndex = ConfigConstants.OPENSEARCH_RESOURCE_SHARING_INDEX; + ResourceSharingIndexHandler rsIndexHandler = new ResourceSharingIndexHandler(resourceSharingIndex, localClient, threadPool); + resourceAccessHandler = new ResourceAccessHandler(threadPool, rsIndexHandler, adminDns); + + rmr = ResourceSharingIndexManagementRepository.create(rsIndexHandler); + components.add(adminDns); components.add(cr); components.add(xffResolver); @@ -1367,7 +1396,7 @@ public List> getSettings() { settings.add(Setting.simpleString(ConfigConstants.SECURITY_CONFIG_INDEX_NAME, Property.NodeScope, Property.Filtered)); settings.add(Setting.groupSetting(ConfigConstants.SECURITY_AUTHCZ_IMPERSONATION_DN + ".", Property.NodeScope)); // not filtered - // here + // here settings.add(Setting.simpleString(ConfigConstants.SECURITY_CERT_OID, Property.NodeScope, Property.Filtered)); @@ -1383,8 +1412,8 @@ public List> getSettings() { );// not filtered here settings.add(Setting.boolSetting(ConfigConstants.SECURITY_NODES_DN_DYNAMIC_CONFIG_ENABLED, false, Property.NodeScope));// not - // filtered - // here + // filtered + // here settings.add( Setting.boolSetting( @@ -1428,8 +1457,8 @@ public List> getSettings() { Setting.boolSetting(ConfigConstants.SECURITY_DFM_EMPTY_OVERRIDES_ALL, false, Property.NodeScope, Property.Filtered) ); settings.add(Setting.groupSetting(ConfigConstants.SECURITY_AUTHCZ_REST_IMPERSONATION_USERS + ".", Property.NodeScope)); // not - // filtered - // here + // filtered + // here settings.add(Setting.simpleString(ConfigConstants.SECURITY_ROLES_MAPPING_RESOLUTION, Property.NodeScope, Property.Filtered)); settings.add( @@ -2065,6 +2094,17 @@ public void onNodeStarted(DiscoveryNode localNode) { if (!SSLConfig.isSslOnlyMode() && !client && !disabled && !useClusterStateToInitSecurityConfig(settings)) { cr.initOnNodeStart(); } + + // create resource sharing index if absent + rmr.createResourceSharingIndexIfAbsent(); + + for (ResourcePlugin resourcePlugin : OpenSearchSecurityPlugin.GuiceHolder.getResourceService().listResourcePlugins()) { + String resourceIndex = resourcePlugin.getResourceIndex(); + + this.indicesToListen.add(resourceIndex); + log.info("Preparing to listen to index: {} of plugin: {}", resourceIndex, resourcePlugin); + } + final Set securityModules = ReflectionHelper.getModulesLoaded(); log.info("{} OpenSearch Security modules loaded so far: {}", securityModules.size(), securityModules); } @@ -2082,6 +2122,7 @@ public Collection> getGuiceServiceClasses() final List> services = new ArrayList<>(1); services.add(GuiceHolder.class); + log.info("Guice service classes loaded"); return services; } @@ -2166,12 +2207,48 @@ private void tryAddSecurityProvider() { }); } + @Override + public Set getAccessibleResourcesForCurrentUser(String systemIndexName, Class clazz) { + return this.resourceAccessHandler.getAccessibleResourcesForCurrentUser(systemIndexName, clazz); + } + + @Override + public boolean hasPermission(String resourceId, String systemIndexName, String scope) { + return this.resourceAccessHandler.hasPermission(resourceId, systemIndexName, scope); + } + + @Override + public ResourceSharing shareWith(String resourceId, String systemIndexName, ShareWith shareWith) { + return this.resourceAccessHandler.shareWith(resourceId, systemIndexName, shareWith); + } + + @Override + public ResourceSharing revokeAccess( + String resourceId, + String systemIndexName, + Map> entities, + Set scopes + ) { + return this.resourceAccessHandler.revokeAccess(resourceId, systemIndexName, entities, scopes); + } + + @Override + public boolean deleteResourceSharingRecord(String resourceId, String systemIndexName) { + return this.resourceAccessHandler.deleteResourceSharingRecord(resourceId, systemIndexName); + } + + @Override + public boolean deleteAllResourceSharingRecordsForCurrentUser() { + return this.resourceAccessHandler.deleteAllResourceSharingRecordsForCurrentUser(); + } + public static class GuiceHolder implements LifecycleComponent { private static RepositoriesService repositoriesService; private static RemoteClusterService remoteClusterService; private static IndicesService indicesService; private static PitService pitService; + private static ResourceService resourceService; // CS-SUPPRESS-SINGLE: RegexpSingleline Extensions manager used to allow/disallow TLS connections to extensions private static ExtensionsManager extensionsManager; @@ -2182,13 +2259,15 @@ public GuiceHolder( final TransportService remoteClusterService, IndicesService indicesService, PitService pitService, - ExtensionsManager extensionsManager + ExtensionsManager extensionsManager, + ResourceService resourceService ) { GuiceHolder.repositoriesService = repositoriesService; GuiceHolder.remoteClusterService = remoteClusterService.getRemoteClusterService(); GuiceHolder.indicesService = indicesService; GuiceHolder.pitService = pitService; GuiceHolder.extensionsManager = extensionsManager; + GuiceHolder.resourceService = resourceService; } // CS-ENFORCE-SINGLE @@ -2214,6 +2293,10 @@ public static ExtensionsManager getExtensionsManager() { } // CS-ENFORCE-SINGLE + public static ResourceService getResourceService() { + return resourceService; + } + @Override public void close() {} diff --git a/src/main/java/org/opensearch/security/auth/BackendRegistry.java b/src/main/java/org/opensearch/security/auth/BackendRegistry.java index 0b00bcf943..eb9bb504fd 100644 --- a/src/main/java/org/opensearch/security/auth/BackendRegistry.java +++ b/src/main/java/org/opensearch/security/auth/BackendRegistry.java @@ -224,6 +224,7 @@ public boolean authenticate(final SecurityRequestChannel request) { if (adminDns.isAdminDN(sslPrincipal)) { // PKI authenticated REST call threadContext.putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, new User(sslPrincipal)); + threadContext.putPersistent(ConfigConstants.OPENDISTRO_SECURITY_USER, new User(sslPrincipal)); auditLog.logSucceededLogin(sslPrincipal, true, null, request); return true; } @@ -389,6 +390,8 @@ public boolean authenticate(final SecurityRequestChannel request) { final User impersonatedUser = impersonate(request, authenticatedUser); threadPool.getThreadContext() .putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, impersonatedUser == null ? authenticatedUser : impersonatedUser); + threadPool.getThreadContext() + .putPersistent(ConfigConstants.OPENDISTRO_SECURITY_USER, impersonatedUser == null ? authenticatedUser : impersonatedUser); auditLog.logSucceededLogin( (impersonatedUser == null ? authenticatedUser : impersonatedUser).getName(), false, @@ -422,6 +425,7 @@ public boolean authenticate(final SecurityRequestChannel request) { anonymousUser.setRequestedTenant(tenant); threadPool.getThreadContext().putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, anonymousUser); + threadPool.getThreadContext().putPersistent(ConfigConstants.OPENDISTRO_SECURITY_USER, anonymousUser); auditLog.logSucceededLogin(anonymousUser.getName(), false, null, request); if (isDebugEnabled) { log.debug("Anonymous User is authenticated"); diff --git a/src/main/java/org/opensearch/security/filter/SecurityFilter.java b/src/main/java/org/opensearch/security/filter/SecurityFilter.java index 3323c9e38a..b2ede030a7 100644 --- a/src/main/java/org/opensearch/security/filter/SecurityFilter.java +++ b/src/main/java/org/opensearch/security/filter/SecurityFilter.java @@ -345,6 +345,7 @@ private void ap log.info("Transport auth in passive mode and no user found. Injecting default user"); user = User.DEFAULT_TRANSPORT_USER; threadContext.putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, user); + threadContext.putPersistent(ConfigConstants.OPENDISTRO_SECURITY_USER, user); } else { log.error( "No user found for " diff --git a/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java b/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java new file mode 100644 index 0000000000..41b999c009 --- /dev/null +++ b/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java @@ -0,0 +1,343 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.resources; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.accesscontrol.resources.EntityType; +import org.opensearch.accesscontrol.resources.ResourceSharing; +import org.opensearch.accesscontrol.resources.ShareWith; +import org.opensearch.accesscontrol.resources.SharedWithScope; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.security.configuration.AdminDNs; +import org.opensearch.security.support.ConfigConstants; +import org.opensearch.security.user.User; +import org.opensearch.threadpool.ThreadPool; + +/** + * This class handles resource access permissions for users and roles. + * It provides methods to check if a user has permission to access a resource + * based on the resource sharing configuration. + */ +public class ResourceAccessHandler { + private static final Logger LOGGER = LogManager.getLogger(ResourceAccessHandler.class); + + private final ThreadContext threadContext; + private final ResourceSharingIndexHandler resourceSharingIndexHandler; + private final AdminDNs adminDNs; + + public ResourceAccessHandler( + final ThreadPool threadPool, + final ResourceSharingIndexHandler resourceSharingIndexHandler, + AdminDNs adminDns + ) { + super(); + this.threadContext = threadPool.getThreadContext(); + this.resourceSharingIndexHandler = resourceSharingIndexHandler; + this.adminDNs = adminDns; + } + + /** + * Returns a set of accessible resources for the current user within the specified resource index. + * + * @param resourceIndex The resource index to check for accessible resources. + * @return A set of accessible resource IDs. + */ + public Set getAccessibleResourcesForCurrentUser(String resourceIndex, Class clazz) { + if (areArgumentsInvalid(resourceIndex, clazz)) { + return Collections.emptySet(); + } + final User user = threadContext.getPersistent(ConfigConstants.OPENDISTRO_SECURITY_USER); + if (user == null) { + LOGGER.info("Unable to fetch user details "); + return Collections.emptySet(); + } + + LOGGER.info("Listing accessible resources within a resource index {} for : {}", resourceIndex, user.getName()); + + // check if user is admin, if yes all resources should be accessible + if (adminDNs.isAdmin(user)) { + return loadAllResources(resourceIndex, clazz); + } + + Set result = new HashSet<>(); + + // 0. Own resources + result.addAll(loadOwnResources(resourceIndex, user.getName(), clazz)); + + // 1. By username + result.addAll(loadSharedWithResources(resourceIndex, Set.of(user.getName()), EntityType.USERS.toString(), clazz)); + + // 2. By roles + Set roles = user.getSecurityRoles(); + result.addAll(loadSharedWithResources(resourceIndex, roles, EntityType.ROLES.toString(), clazz)); + + // 3. By backend_roles + Set backendRoles = user.getRoles(); + result.addAll(loadSharedWithResources(resourceIndex, backendRoles, EntityType.BACKEND_ROLES.toString(), clazz)); + + return result; + } + + /** + * Checks whether current user has given permission (scope) to access given resource. + * + * @param resourceId The resource ID to check access for. + * @param resourceIndex The resource index containing the resource. + * @param scope The permission scope to check. + * @return True if the user has the specified permission, false otherwise. + */ + public boolean hasPermission(String resourceId, String resourceIndex, String scope) { + if (areArgumentsInvalid(resourceId, resourceIndex, scope)) { + return false; + } + final User user = threadContext.getPersistent(ConfigConstants.OPENDISTRO_SECURITY_USER); + + LOGGER.info("Checking if {} has {} permission to resource {}", user.getName(), scope, resourceId); + + // check if user is admin, if yes the user has permission + if (adminDNs.isAdmin(user)) { + return true; + } + + Set userRoles = user.getSecurityRoles(); + Set userBackendRoles = user.getRoles(); + + ResourceSharing document = this.resourceSharingIndexHandler.fetchDocumentById(resourceIndex, resourceId); + if (document == null) { + LOGGER.warn("Resource {} not found in index {}", resourceId, resourceIndex); + return false; // If the document doesn't exist, no permissions can be granted + } + + if (isSharedWithEveryone(document) + || isOwnerOfResource(document, user.getName()) + || isSharedWithEntity(document, EntityType.USERS, Set.of(user.getName()), scope) + || isSharedWithEntity(document, EntityType.ROLES, userRoles, scope) + || isSharedWithEntity(document, EntityType.BACKEND_ROLES, userBackendRoles, scope)) { + LOGGER.info("User {} has {} access to {}", user.getName(), scope, resourceId); + return true; + } + + LOGGER.info("User {} does not have {} access to {} ", user.getName(), scope, resourceId); + return false; + } + + /** + * Shares a resource with the specified users, roles, and backend roles. + * @param resourceId The resource ID to share. + * @param resourceIndex The index where resource is store + * @param shareWith The users, roles, and backend roles as well as scope to share the resource with. + * @return The updated ResourceSharing document. + */ + public ResourceSharing shareWith(String resourceId, String resourceIndex, ShareWith shareWith) { + if (areArgumentsInvalid(resourceId, resourceIndex, shareWith)) { + return null; + } + final User user = threadContext.getPersistent(ConfigConstants.OPENDISTRO_SECURITY_USER); + LOGGER.info("Sharing resource {} created by {} with {}", resourceId, user.getName(), shareWith.toString()); + + // check if user is admin, if yes the user has permission + boolean isAdmin = adminDNs.isAdmin(user); + + return this.resourceSharingIndexHandler.updateResourceSharingInfo(resourceId, resourceIndex, user.getName(), shareWith, isAdmin); + } + + /** + * Revokes access to a resource for the specified users, roles, and backend roles. + * @param resourceId The resource ID to revoke access from. + * @param resourceIndex The index where resource is store + * @param revokeAccess The users, roles, and backend roles to revoke access for. + * @param scopes The permission scopes to revoke access for. + * @return The updated ResourceSharing document. + */ + public ResourceSharing revokeAccess( + String resourceId, + String resourceIndex, + Map> revokeAccess, + Set scopes + ) { + if (areArgumentsInvalid(resourceId, resourceIndex, revokeAccess, scopes)) { + return null; + } + final User user = threadContext.getPersistent(ConfigConstants.OPENDISTRO_SECURITY_USER); + LOGGER.info("User {} revoking access to resource {} for {} for scopes {} ", user.getName(), resourceId, revokeAccess, scopes); + + // check if user is admin, if yes the user has permission + boolean isAdmin = adminDNs.isAdmin(user); + + return this.resourceSharingIndexHandler.revokeAccess(resourceId, resourceIndex, revokeAccess, scopes, user.getName(), isAdmin); + } + + /** + * Deletes a resource sharing record by its ID and the resource index it belongs to. + * @param resourceId The resource ID to delete. + * @param resourceIndex The resource index containing the resource. + * @return True if the record was successfully deleted, false otherwise. + */ + public boolean deleteResourceSharingRecord(String resourceId, String resourceIndex) { + if (areArgumentsInvalid(resourceId, resourceIndex)) { + return false; + } + final User user = threadContext.getPersistent(ConfigConstants.OPENDISTRO_SECURITY_USER); + LOGGER.info("Deleting resource sharing record for resource {} in {} created by {}", resourceId, resourceIndex, user.getName()); + + ResourceSharing document = this.resourceSharingIndexHandler.fetchDocumentById(resourceIndex, resourceId); + if (document == null) { + LOGGER.info("Document {} does not exist in index {}", resourceId, resourceIndex); + return false; + } + if (!(adminDNs.isAdmin(user) || isOwnerOfResource(document, user.getName()))) { + LOGGER.info("User {} does not have access to delete the record {} ", user.getName(), resourceId); + return false; + } + return this.resourceSharingIndexHandler.deleteResourceSharingRecord(resourceId, resourceIndex); + } + + /** + * Deletes all resource sharing records for the current user. + * @return True if all records were successfully deleted, false otherwise. + */ + public boolean deleteAllResourceSharingRecordsForCurrentUser() { + + final User user = threadContext.getPersistent(ConfigConstants.OPENDISTRO_SECURITY_USER); + LOGGER.info("Deleting all resource sharing records for resource {}", user.getName()); + + return this.resourceSharingIndexHandler.deleteAllRecordsForUser(user.getName()); + } + + /** + * Loads all resources within the specified resource index. + * + * @param resourceIndex The resource index to load resources from. + * @return A set of resource IDs. + */ + private Set loadAllResources(String resourceIndex, Class clazz) { + return this.resourceSharingIndexHandler.fetchAllDocuments(resourceIndex, clazz); + } + + /** + * Loads resources owned by the specified user within the given resource index. + * + * @param resourceIndex The resource index to load resources from. + * @param userName The username of the owner. + * @return A set of resource IDs owned by the user. + */ + private Set loadOwnResources(String resourceIndex, String userName, Class clazz) { + return this.resourceSharingIndexHandler.fetchDocumentsByField(resourceIndex, "created_by.user", userName, clazz); + } + + /** + * Loads resources shared with the specified entities within the given resource index. + * + * @param resourceIndex The resource index to load resources from. + * @param entities The set of entities to check for shared resources. + * @param entityType The type of entity (e.g., users, roles, backend_roles). + * @return A set of resource IDs shared with the specified entities. + */ + private Set loadSharedWithResources(String resourceIndex, Set entities, String entityType, Class clazz) { + return this.resourceSharingIndexHandler.fetchDocumentsForAllScopes(resourceIndex, entities, entityType, clazz); + } + + /** + * Checks if the given resource is owned by the specified user. + * + * @param document The ResourceSharing document to check. + * @param userName The username to check ownership against. + * @return True if the resource is owned by the user, false otherwise. + */ + private boolean isOwnerOfResource(ResourceSharing document, String userName) { + return document.getCreatedBy() != null && document.getCreatedBy().getUser().equals(userName); + } + + /** + * Checks if the given resource is shared with the specified entities and scope. + * + * @param document The ResourceSharing document to check. + * @param entityType The type of entity (e.g., users, roles, backend_roles). + * @param entities The set of entities to check for sharing. + * @param scope The permission scope to check. + * @return True if the resource is shared with the entities and scope, false otherwise. + */ + private boolean isSharedWithEntity(ResourceSharing document, EntityType entityType, Set entities, String scope) { + for (String entity : entities) { + if (checkSharing(document, entityType, entity, scope)) { + return true; + } + } + return false; + } + + /** + * Checks if the given resource is shared with everyone. + * + * @param document The ResourceSharing document to check. + * @return True if the resource is shared with everyone, false otherwise. + */ + private boolean isSharedWithEveryone(ResourceSharing document) { + return document.getShareWith() != null + && document.getShareWith().getSharedWithScopes().stream().anyMatch(sharedWithScope -> sharedWithScope.getScope().equals("*")); + } + + /** + * Checks if the given resource is shared with the specified entity and scope. + * + * @param document The ResourceSharing document to check. + * @param entityType The type of entity (e.g., users, roles, backend_roles). + * @param identifier The identifier of the entity to check for sharing. + * @param scope The permission scope to check. + * @return True if the resource is shared with the entity and scope, false otherwise. + */ + private boolean checkSharing(ResourceSharing document, EntityType entityType, String identifier, String scope) { + if (document.getShareWith() == null) { + return false; + } + + return document.getShareWith() + .getSharedWithScopes() + .stream() + .filter(sharedWithScope -> sharedWithScope.getScope().equals(scope)) + .findFirst() + .map(sharedWithScope -> { + SharedWithScope.SharedWithPerScope scopePermissions = sharedWithScope.getSharedWithPerScope(); + + return switch (entityType) { + case EntityType.USERS -> scopePermissions.getUsers().contains(identifier); + case EntityType.ROLES -> scopePermissions.getRoles().contains(identifier); + case EntityType.BACKEND_ROLES -> scopePermissions.getBackendRoles().contains(identifier); + }; + }) + .orElse(false); // Return false if no matching scope is found + } + + private boolean areArgumentsInvalid(Object... args) { + if (args == null) { + return true; + } + for (Object arg : args) { + if (arg == null) { + return true; + } + // Additional check for String type arguments + if (arg instanceof String && ((String) arg).trim().isEmpty()) { + return true; + } + } + return false; + } + +} diff --git a/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java b/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java new file mode 100644 index 0000000000..839af57f9c --- /dev/null +++ b/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java @@ -0,0 +1,1177 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + */ +package org.opensearch.security.resources; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.Callable; + +import com.fasterxml.jackson.core.type.TypeReference; +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.accesscontrol.resources.CreatedBy; +import org.opensearch.accesscontrol.resources.EntityType; +import org.opensearch.accesscontrol.resources.ResourceSharing; +import org.opensearch.accesscontrol.resources.ShareWith; +import org.opensearch.action.admin.indices.create.CreateIndexRequest; +import org.opensearch.action.admin.indices.create.CreateIndexResponse; +import org.opensearch.action.get.MultiGetItemResponse; +import org.opensearch.action.get.MultiGetRequest; +import org.opensearch.action.get.MultiGetResponse; +import org.opensearch.action.index.IndexRequest; +import org.opensearch.action.index.IndexResponse; +import org.opensearch.action.search.ClearScrollRequest; +import org.opensearch.action.search.SearchRequest; +import org.opensearch.action.search.SearchResponse; +import org.opensearch.action.search.SearchScrollAction; +import org.opensearch.action.search.SearchScrollRequest; +import org.opensearch.action.support.WriteRequest; +import org.opensearch.client.Client; +import org.opensearch.common.unit.TimeValue; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.common.xcontent.LoggingDeprecationHandler; +import org.opensearch.common.xcontent.XContentFactory; +import org.opensearch.common.xcontent.XContentType; +import org.opensearch.core.action.ActionListener; +import org.opensearch.core.xcontent.NamedXContentRegistry; +import org.opensearch.core.xcontent.ToXContent; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.core.xcontent.XContentParser; +import org.opensearch.index.query.BoolQueryBuilder; +import org.opensearch.index.query.MultiMatchQueryBuilder; +import org.opensearch.index.query.QueryBuilders; +import org.opensearch.index.reindex.BulkByScrollResponse; +import org.opensearch.index.reindex.DeleteByQueryAction; +import org.opensearch.index.reindex.DeleteByQueryRequest; +import org.opensearch.index.reindex.UpdateByQueryAction; +import org.opensearch.index.reindex.UpdateByQueryRequest; +import org.opensearch.script.Script; +import org.opensearch.script.ScriptType; +import org.opensearch.search.Scroll; +import org.opensearch.search.SearchHit; +import org.opensearch.search.builder.SearchSourceBuilder; +import org.opensearch.security.DefaultObjectMapper; +import org.opensearch.threadpool.ThreadPool; + +import static org.opensearch.common.xcontent.XContentFactory.jsonBuilder; + +/** + * This class handles the creation and management of the resource sharing index. + * It provides methods to create the index, index resource sharing entries along with updates and deletion, retrieve shared resources. + */ +public class ResourceSharingIndexHandler { + + private static final Logger LOGGER = LogManager.getLogger(ResourceSharingIndexHandler.class); + + private final Client client; + + private final String resourceSharingIndex; + + private final ThreadPool threadPool; + + public ResourceSharingIndexHandler(final String indexName, final Client client, ThreadPool threadPool) { + this.resourceSharingIndex = indexName; + this.client = client; + this.threadPool = threadPool; + } + + public final static Map INDEX_SETTINGS = Map.of("index.number_of_shards", 1, "index.auto_expand_replicas", "0-all"); + + /** + * Creates the resource sharing index if it doesn't already exist. + * This method initializes the index with predefined mappings and settings + * for storing resource sharing information. + * The index will be created with the following structure: + * - source_idx (keyword): The source index containing the original document + * - resource_id (keyword): The ID of the shared resource + * - created_by (object): Information about the user who created the sharing + * - user (keyword): Username of the creator + * - share_with (object): Access control configuration for shared resources + * - [group_name] (object): Name of the access group + * - users (array): List of users with access + * - roles (array): List of roles with access + * - backend_roles (array): List of backend roles with access + * + * @throws RuntimeException if there are issues reading/writing index settings + * or communicating with the cluster + */ + + public void createResourceSharingIndexIfAbsent(Callable callable) { + // TODO: Once stashContext is replaced with switchContext this call will have to be modified + try (ThreadContext.StoredContext ctx = this.threadPool.getThreadContext().stashContext()) { + CreateIndexRequest cir = new CreateIndexRequest(resourceSharingIndex).settings(INDEX_SETTINGS).waitForActiveShards(1); + ActionListener cirListener = ActionListener.wrap(response -> { + LOGGER.info("Resource sharing index {} created.", resourceSharingIndex); + callable.call(); + }, (failResponse) -> { + /* Index already exists, ignore and continue */ + LOGGER.info("Index {} already exists.", resourceSharingIndex); + try { + callable.call(); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + this.client.admin().indices().create(cir, cirListener); + } + } + + /** + * Creates or updates a resource sharing record in the dedicated resource sharing index. + * This method handles the persistence of sharing metadata for resources, including + * the creator information and sharing permissions. + * + * @param resourceId The unique identifier of the resource being shared + * @param resourceIndex The source index where the original resource is stored + * @param createdBy Object containing information about the user creating/updating the sharing + * @param shareWith Object containing the sharing permissions' configuration. Can be null for initial creation. + * When provided, it should contain the access control settings for different groups: + * { + * "group_name": { + * "users": ["user1", "user2"], + * "roles": ["role1", "role2"], + * "backend_roles": ["backend_role1"] + * } + * } + * + * @return ResourceSharing Returns resourceSharing object if the operation was successful, null otherwise + * @throws IOException if there are issues with index operations or JSON processing + */ + public ResourceSharing indexResourceSharing(String resourceId, String resourceIndex, CreatedBy createdBy, ShareWith shareWith) + throws IOException { + try { + ResourceSharing entry = new ResourceSharing(resourceIndex, resourceId, createdBy, shareWith); + + IndexRequest ir = client.prepareIndex(resourceSharingIndex) + .setRefreshPolicy(WriteRequest.RefreshPolicy.IMMEDIATE) + .setSource(entry.toXContent(jsonBuilder(), ToXContent.EMPTY_PARAMS)) + .request(); + + ActionListener irListener = ActionListener.wrap(idxResponse -> { + LOGGER.info("Successfully created {} entry.", resourceSharingIndex); + }, (failResponse) -> { + LOGGER.error(failResponse.getMessage()); + LOGGER.info("Failed to create {} entry.", resourceSharingIndex); + }); + client.index(ir, irListener); + return entry; + } catch (Exception e) { + LOGGER.info("Failed to create {} entry.", resourceSharingIndex, e); + return null; + } + } + + /** + * Fetches all resource sharing records that match the specified system index. This method retrieves + * a list of resource IDs associated with the given system index from the resource sharing index. + * + *

The method executes the following steps: + *

    + *
  1. Creates a search request with term query matching the system index
  2. + *
  3. Applies source filtering to only fetch resource_id field
  4. + *
  5. Executes the search with a limit of 10000 documents
  6. + *
  7. Processes the results to extract resource IDs
  8. + *
+ * + *

Example query structure: + *

+        * {
+        *   "query": {
+        *     "term": {
+        *       "source_idx": "system_index_name"
+        *     }
+        *   },
+        *   "_source": ["resource_id"],
+        *   "size": 10000
+        * }
+        * 
+ * + * @param pluginIndex The source index to match against the source_idx field + * @return Set containing resource IDs that belong to the specified system index. + * Returns an empty list if: + *
    + *
  • No matching documents are found
  • + *
  • An error occurs during the search operation
  • + *
  • The system index parameter is invalid
  • + *
+ * + * @apiNote This method: + *
    + *
  • Uses source filtering for optimal performance
  • + *
  • Performs exact matching on the source_idx field
  • + *
  • Returns an empty list instead of throwing exceptions
  • + *
+ */ + public Set fetchAllDocuments(String pluginIndex, Class clazz) { + LOGGER.debug("Fetching all documents from {} where source_idx = {}", resourceSharingIndex, pluginIndex); + + try { + SearchRequest searchRequest = new SearchRequest(resourceSharingIndex); + + SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder(); + searchSourceBuilder.query(QueryBuilders.termQuery("source_idx.keyword", pluginIndex)); + searchSourceBuilder.size(10000); // TODO check what size should be set here. + + searchSourceBuilder.fetchSource(new String[] { "resource_id" }, null); + + searchRequest.source(searchSourceBuilder); + + SearchResponse searchResponse = client.search(searchRequest).actionGet(); + + Set resourceIds = new HashSet<>(); + + SearchHit[] hits = searchResponse.getHits().getHits(); + for (SearchHit hit : hits) { + Map sourceAsMap = hit.getSourceAsMap(); + if (sourceAsMap != null && sourceAsMap.containsKey("resource_id")) { + resourceIds.add(sourceAsMap.get("resource_id").toString()); + } + } + + LOGGER.debug("Found {} documents in {} for source_idx: {}", resourceIds.size(), resourceSharingIndex, pluginIndex); + + return resourceIds.isEmpty() ? Set.of() : getResourcesFromIds(resourceIds, pluginIndex, clazz); + + } catch (Exception e) { + LOGGER.error("Failed to fetch documents from {} for source_idx: {}", resourceSharingIndex, pluginIndex, e); + return Set.of(); + } + } + + /** + * Fetches documents that match the specified system index and have specific access type values. + * This method uses scroll API to handle large result sets efficiently. + * + *

The method executes the following steps: + *

    + *
  1. Validates the entityType parameter
  2. + *
  3. Creates a scrolling search request with a compound query
  4. + *
  5. Processes results in batches using scroll API
  6. + *
  7. Collects all matching resource IDs
  8. + *
  9. Cleans up scroll context
  10. + *
+ * + *

Example query structure: + *

+    * {
+    *   "query": {
+    *     "bool": {
+    *       "must": [
+    *         { "term": { "source_idx": "system_index_name" } },
+    *         {
+    *           "bool": {
+    *             "should": [
+    *               {
+    *                 "nested": {
+    *                   "path": "share_with.*.entityType",
+    *                   "query": {
+    *                     "term": { "share_with.*.entityType": "entity_value" }
+    *                   }
+    *                 }
+    *               }
+    *             ],
+    *             "minimum_should_match": 1
+    *           }
+    *         }
+    *       ]
+    *     }
+    *   },
+    *   "_source": ["resource_id"],
+    *   "size": 1000
+    * }
+    * 
+ * + * @param pluginIndex The source index to match against the source_idx field + * @param entities Set of values to match in the specified entityType field + * @param entityType The type of association with the resource. Must be one of: + *
    + *
  • "users" - for user-based access
  • + *
  • "roles" - for role-based access
  • + *
  • "backend_roles" - for backend role-based access
  • + *
+ * @param clazz Class to deserialize each document from Response into + * @return Set List of resource IDs that match the criteria. The list may be empty + * if no matches are found + * + * @throws RuntimeException if the search operation fails + * + * @apiNote This method: + *
    + *
  • Uses scroll API with 1-minute timeout
  • + *
  • Processes results in batches of 1000 documents
  • + *
  • Performs source filtering for optimization
  • + *
  • Uses nested queries for accessing array elements
  • + *
  • Properly cleans up scroll context after use
  • + *
+ */ + + public Set fetchDocumentsForAllScopes(String pluginIndex, Set entities, String entityType, Class clazz) { + // "*" must match all scopes + return fetchDocumentsForAGivenScope(pluginIndex, entities, entityType, "*", clazz); + } + + /** + * Fetches documents that match the specified system index and have specific access type values for a given scope. + * This method uses scroll API to handle large result sets efficiently. + * + *

The method executes the following steps: + *

    + *
  1. Validates the entityType parameter
  2. + *
  3. Creates a scrolling search request with a compound query
  4. + *
  5. Processes results in batches using scroll API
  6. + *
  7. Collects all matching resource IDs
  8. + *
  9. Cleans up scroll context
  10. + *
+ * + *

Example query structure: + *

+     * {
+     *   "query": {
+     *     "bool": {
+     *       "must": [
+     *         { "term": { "source_idx": "system_index_name" } },
+     *         {
+     *           "bool": {
+     *             "should": [
+     *               {
+     *                 "nested": {
+     *                   "path": "share_with.scope.entityType",
+     *                   "query": {
+     *                     "term": { "share_with.scope.entityType": "entity_value" }
+     *                   }
+     *                 }
+     *               }
+     *             ],
+     *             "minimum_should_match": 1
+     *           }
+     *         }
+     *       ]
+     *     }
+     *   },
+     *   "_source": ["resource_id"],
+     *   "size": 1000
+     * }
+     * 
+ * + * @param pluginIndex The source index to match against the source_idx field + * @param entities Set of values to match in the specified entityType field + * @param entityType The type of association with the resource. Must be one of: + *
    + *
  • "users" - for user-based access
  • + *
  • "roles" - for role-based access
  • + *
  • "backend_roles" - for backend role-based access
  • + *
+ * @param scope The scope of the access. Should be implementation of {@link org.opensearch.accesscontrol.resources.ResourceAccessScope} + * @param clazz Class to deserialize each document from Response into + * @return Set List of resource IDs that match the criteria. The list may be empty + * if no matches are found + * + * @throws RuntimeException if the search operation fails + * + * @apiNote This method: + *
    + *
  • Uses scroll API with 1-minute timeout
  • + *
  • Processes results in batches of 1000 documents
  • + *
  • Performs source filtering for optimization
  • + *
  • Uses nested queries for accessing array elements
  • + *
  • Properly cleans up scroll context after use
  • + *
+ */ + public Set fetchDocumentsForAGivenScope( + String pluginIndex, + Set entities, + String entityType, + String scope, + Class clazz + ) { + LOGGER.debug( + "Fetching documents from index: {}, where share_with.{}.{} contains any of {}", + pluginIndex, + scope, + entityType, + entities + ); + + Set resourceIds = new HashSet<>(); + final Scroll scroll = new Scroll(TimeValue.timeValueMinutes(1L)); + + try { + SearchRequest searchRequest = new SearchRequest(resourceSharingIndex); + searchRequest.scroll(scroll); + + BoolQueryBuilder boolQuery = QueryBuilders.boolQuery().must(QueryBuilders.termQuery("source_idx.keyword", pluginIndex)); + + BoolQueryBuilder shouldQuery = QueryBuilders.boolQuery(); + if ("*".equals(scope)) { + for (String entity : entities) { + shouldQuery.should( + QueryBuilders.multiMatchQuery(entity, "share_with.*." + entityType + ".keyword") + .type(MultiMatchQueryBuilder.Type.BEST_FIELDS) + ); + } + } else { + for (String entity : entities) { + shouldQuery.should(QueryBuilders.termQuery("share_with." + scope + "." + entityType + ".keyword", entity)); + } + } + shouldQuery.minimumShouldMatch(1); + + boolQuery.must(QueryBuilders.existsQuery("share_with")).must(shouldQuery); + + executeSearchRequest(resourceIds, scroll, searchRequest, boolQuery); + + LOGGER.debug("Found {} documents matching the criteria in {}", resourceIds.size(), resourceSharingIndex); + + return resourceIds.isEmpty() ? Set.of() : getResourcesFromIds(resourceIds, pluginIndex, clazz); + + } catch (Exception e) { + LOGGER.error( + "Failed to fetch documents from {} for criteria - pluginIndex: {}, scope: {}, entityType: {}, entities: {}", + resourceSharingIndex, + pluginIndex, + scope, + entityType, + entities, + e + ); + throw new RuntimeException("Failed to fetch documents: " + e.getMessage(), e); + } + } + + /** + * Fetches documents from the resource sharing index that match a specific field value. + * This method uses scroll API to efficiently handle large result sets and performs exact + * matching on both system index and the specified field. + * + *

The method executes the following steps: + *

    + *
  1. Validates input parameters for null/empty values
  2. + *
  3. Creates a scrolling search request with a bool query
  4. + *
  5. Processes results in batches using scroll API
  6. + *
  7. Extracts resource IDs from matching documents
  8. + *
  9. Cleans up scroll context after completion
  10. + *
+ * + *

Example query structure: + *

+     * {
+     *   "query": {
+     *     "bool": {
+     *       "must": [
+     *         { "term": { "source_idx": "system_index_value" } },
+     *         { "term": { "field_name": "field_value" } }
+     *       ]
+     *     }
+     *   },
+     *   "_source": ["resource_id"],
+     *   "size": 1000
+     * }
+     * 
+ * + * @param pluginIndex The source index to match against the source_idx field + * @param field The field name to search in. Must be a valid field in the index mapping + * @param value The value to match for the specified field. Performs exact term matching + * @param clazz Class to deserialize each document from Response into + * @return Set List of resource IDs that match the criteria. Returns an empty list + * if no matches are found + * + * @throws IllegalArgumentException if any parameter is null or empty + * @throws RuntimeException if the search operation fails, wrapping the underlying exception + * + * @apiNote This method: + *
    + *
  • Uses scroll API with 1-minute timeout for handling large result sets
  • + *
  • Performs exact term matching (not analyzed) on field values
  • + *
  • Processes results in batches of 1000 documents
  • + *
  • Uses source filtering to only fetch resource_id field
  • + *
  • Automatically cleans up scroll context after use
  • + *
+ * + * Example usage: + *
+     * Set resources = fetchDocumentsByField("myIndex", "status", "active");
+     * 
+ */ + public Set fetchDocumentsByField(String pluginIndex, String field, String value, Class clazz) { + if (StringUtils.isBlank(pluginIndex) || StringUtils.isBlank(field) || StringUtils.isBlank(value)) { + throw new IllegalArgumentException("pluginIndex, field, and value must not be null or empty"); + } + + LOGGER.debug("Fetching documents from index: {}, where {} = {}", pluginIndex, field, value); + + Set resourceIds = new HashSet<>(); + final Scroll scroll = new Scroll(TimeValue.timeValueMinutes(1L)); + + try { + SearchRequest searchRequest = new SearchRequest(resourceSharingIndex); + searchRequest.scroll(scroll); + + BoolQueryBuilder boolQuery = QueryBuilders.boolQuery() + .must(QueryBuilders.termQuery("source_idx.keyword", pluginIndex)) + .must(QueryBuilders.termQuery(field + ".keyword", value)); + + executeSearchRequest(resourceIds, scroll, searchRequest, boolQuery); + + LOGGER.info("Found {} documents in {} where {} = {}", resourceIds.size(), resourceSharingIndex, field, value); + + return resourceIds.isEmpty() ? Set.of() : getResourcesFromIds(resourceIds, pluginIndex, clazz); + } catch (Exception e) { + LOGGER.error("Failed to fetch documents from {} where {} = {}", resourceSharingIndex, field, value, e); + throw new RuntimeException("Failed to fetch documents: " + e.getMessage(), e); + } + } + + /** + * Fetches a specific resource sharing document by its resource ID and system index. + * This method performs an exact match search and parses the result into a ResourceSharing object. + * + *

The method executes the following steps: + *

    + *
  1. Validates input parameters for null/empty values
  2. + *
  3. Creates a search request with a bool query for exact matching
  4. + *
  5. Executes the search with a limit of 1 document
  6. + *
  7. Parses the result using XContent parser if found
  8. + *
  9. Returns null if no matching document exists
  10. + *
+ * + *

Example query structure: + *

+    * {
+    *   "query": {
+    *     "bool": {
+    *       "must": [
+    *         { "term": { "source_idx": "system_index_name" } },
+    *         { "term": { "resource_id": "resource_id_value" } }
+    *       ]
+    *     }
+    *   },
+    *   "size": 1
+    * }
+    * 
+ * + * @param pluginIndex The source index to match against the source_idx field + * @param resourceId The resource ID to fetch. Must exactly match the resource_id field + * @return ResourceSharing object if a matching document is found, null if no document + * matches the criteria + * + * @throws IllegalArgumentException if pluginIndexName or resourceId is null or empty + * @throws RuntimeException if the search operation fails or parsing errors occur, + * wrapping the underlying exception + * + * @apiNote This method: + *
    + *
  • Uses term queries for exact matching
  • + *
  • Expects only one matching document per resource ID
  • + *
  • Uses XContent parsing for consistent object creation
  • + *
  • Returns null instead of throwing exceptions for non-existent documents
  • + *
  • Provides detailed logging for troubleshooting
  • + *
+ * + * Example usage: + *
+    * ResourceSharing sharing = fetchDocumentById("myIndex", "resource123");
+    * if (sharing != null) {
+    *     // Process the resource sharing object
+    * }
+    * 
+ */ + + public ResourceSharing fetchDocumentById(String pluginIndex, String resourceId) { + if (StringUtils.isBlank(pluginIndex) || StringUtils.isBlank(resourceId)) { + throw new IllegalArgumentException("pluginIndexName and resourceId must not be null or empty"); + } + + LOGGER.debug("Fetching document from index: {}, with resourceId: {}", pluginIndex, resourceId); + + try { + SearchRequest searchRequest = new SearchRequest(resourceSharingIndex); + + BoolQueryBuilder boolQuery = QueryBuilders.boolQuery() + .must(QueryBuilders.termQuery("source_idx.keyword", pluginIndex)) + .must(QueryBuilders.termQuery("resource_id.keyword", resourceId)); + + SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder().query(boolQuery).size(1); // We only need one document since + // a resource must have only one + // sharing entry + + searchRequest.source(searchSourceBuilder); + + SearchResponse searchResponse = client.search(searchRequest).actionGet(); + + SearchHit[] hits = searchResponse.getHits().getHits(); + if (hits.length == 0) { + LOGGER.debug("No document found for resourceId: {} in index: {}", resourceId, pluginIndex); + return null; + } + + SearchHit hit = hits[0]; + try ( + XContentParser parser = XContentType.JSON.xContent() + .createParser(NamedXContentRegistry.EMPTY, LoggingDeprecationHandler.INSTANCE, hit.getSourceAsString()) + ) { + + parser.nextToken(); + + ResourceSharing resourceSharing = ResourceSharing.fromXContent(parser); + + LOGGER.debug("Successfully fetched document for resourceId: {} from index: {}", resourceId, pluginIndex); + + return resourceSharing; + } + + } catch (Exception e) { + LOGGER.error("Failed to fetch document for resourceId: {} from index: {}", resourceId, pluginIndex, e); + throw new RuntimeException("Failed to fetch document: " + e.getMessage(), e); + } + } + + /** + * Helper method to execute a search request and collect resource IDs from the results. + * @param resourceIds List to collect resource IDs + * @param scroll Search Scroll + * @param searchRequest Request to execute + * @param boolQuery Query to execute with the request + */ + private void executeSearchRequest(Set resourceIds, Scroll scroll, SearchRequest searchRequest, BoolQueryBuilder boolQuery) { + SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder().query(boolQuery) + .size(1000) + .fetchSource(new String[] { "resource_id" }, null); + + searchRequest.source(searchSourceBuilder); + + SearchResponse searchResponse = client.search(searchRequest).actionGet(); + String scrollId = searchResponse.getScrollId(); + SearchHit[] hits = searchResponse.getHits().getHits(); + + while (hits != null && hits.length > 0) { + for (SearchHit hit : hits) { + Map sourceAsMap = hit.getSourceAsMap(); + if (sourceAsMap != null && sourceAsMap.containsKey("resource_id")) { + resourceIds.add(sourceAsMap.get("resource_id").toString()); + } + } + + SearchScrollRequest scrollRequest = new SearchScrollRequest(scrollId); + scrollRequest.scroll(scroll); + searchResponse = client.execute(SearchScrollAction.INSTANCE, scrollRequest).actionGet(); + scrollId = searchResponse.getScrollId(); + hits = searchResponse.getHits().getHits(); + } + + ClearScrollRequest clearScrollRequest = new ClearScrollRequest(); + clearScrollRequest.addScrollId(scrollId); + client.clearScroll(clearScrollRequest).actionGet(); + } + + /** + * Updates the sharing configuration for an existing resource in the resource sharing index. + * NOTE: This method only grants new access. To remove access use {@link #revokeAccess(String, String, Map, Set, String, boolean)} + * This method modifies the sharing permissions for a specific resource identified by its + * resource ID and source index. + * + * @param resourceId The unique identifier of the resource whose sharing configuration needs to be updated + * @param sourceIdx The source index where the original resource is stored + * @param requestUserName The user requesting to share the resource + * @param shareWith Updated sharing configuration object containing access control settings: + * { + * "scope": { + * "users": ["user1", "user2"], + * "roles": ["role1", "role2"], + * "backend_roles": ["backend_role1"] + * } + * } + * @param isAdmin Boolean indicating whether the user requesting to share is an admin or not + * @return ResourceSharing Returns resourceSharing object if the update was successful, null otherwise + * @throws RuntimeException if there's an error during the update operation + */ + public ResourceSharing updateResourceSharingInfo( + String resourceId, + String sourceIdx, + String requestUserName, + ShareWith shareWith, + boolean isAdmin + ) { + XContentBuilder builder; + Map shareWithMap; + try { + builder = XContentFactory.jsonBuilder(); + shareWith.toXContent(builder, ToXContent.EMPTY_PARAMS); + String json = builder.toString(); + shareWithMap = DefaultObjectMapper.readValue(json, new TypeReference<>() { + }); + + } catch (IOException e) { + LOGGER.error("Failed to build json content", e); + return null; + } + + // Check if the user requesting to share is the owner of the resource + // TODO Add a way for users who are not creators to be able to share the resource + ResourceSharing currentSharingInfo = fetchDocumentById(sourceIdx, resourceId); + if (!isAdmin && currentSharingInfo != null && !currentSharingInfo.getCreatedBy().getUser().equals(requestUserName)) { + LOGGER.error("User {} is not authorized to share resource {}", requestUserName, resourceId); + return null; + } + + CreatedBy createdBy; + if (currentSharingInfo == null) { + createdBy = new CreatedBy(requestUserName); + } else { + createdBy = currentSharingInfo.getCreatedBy(); + } + + // Atomic operation + Script updateScript = new Script(ScriptType.INLINE, "painless", """ + if (ctx._source.share_with == null) { + ctx._source.share_with = [:]; + } + + for (def entry : params.shareWith.entrySet()) { + def scopeName = entry.getKey(); + def newScope = entry.getValue(); + + if (!ctx._source.share_with.containsKey(scopeName)) { + def newScopeEntry = [:]; + for (def field : newScope.entrySet()) { + if (field.getValue() != null && !field.getValue().isEmpty()) { + newScopeEntry[field.getKey()] = new HashSet(field.getValue()); + } + } + ctx._source.share_with[scopeName] = newScopeEntry; + } else { + def existingScope = ctx._source.share_with[scopeName]; + + for (def field : newScope.entrySet()) { + def fieldName = field.getKey(); + def newValues = field.getValue(); + + if (newValues != null && !newValues.isEmpty()) { + if (!existingScope.containsKey(fieldName)) { + existingScope[fieldName] = new HashSet(); + } + + for (def value : newValues) { + if (!existingScope[fieldName].contains(value)) { + existingScope[fieldName].add(value); + } + } + } + } + } + } + """, Collections.singletonMap("shareWith", shareWithMap)); + + boolean success = updateByQueryResourceSharing(sourceIdx, resourceId, updateScript); + return success ? new ResourceSharing(resourceId, sourceIdx, createdBy, shareWith) : null; + } + + /** + * Updates resource sharing entries that match the specified source index and resource ID + * using the provided update script. This method performs an update-by-query operation + * in the resource sharing index. + * + *

The method executes the following steps: + *

    + *
  1. Creates a bool query to match exact source index and resource ID
  2. + *
  3. Constructs an update-by-query request with the query and update script
  4. + *
  5. Executes the update operation
  6. + *
  7. Returns success/failure status based on update results
  8. + *
+ * + *

Example document matching structure: + *

+     * {
+     *   "source_idx": "source_index_name",
+     *   "resource_id": "resource_id_value",
+     *   "share_with": {
+     *     // sharing configuration to be updated
+     *   }
+     * }
+     * 
+ * + * @param sourceIdx The source index to match in the query (exact match) + * @param resourceId The resource ID to match in the query (exact match) + * @param updateScript The script containing the update operations to be performed. + * This script defines how the matching documents should be modified + * @return boolean true if at least one document was updated, false if no documents + * were found or update failed + * + * @apiNote This method: + *
    + *
  • Uses term queries for exact matching of source_idx and resource_id
  • + *
  • Returns false for both "no matching documents" and "operation failure" cases
  • + *
  • Logs the complete update request for debugging purposes
  • + *
  • Provides detailed logging for success and failure scenarios
  • + *
+ * + * @implNote The update operation uses a bool query with two must clauses: + *
+     * {
+     *   "query": {
+     *     "bool": {
+     *       "must": [
+     *         { "term": { "source_idx.keyword": sourceIdx } },
+     *         { "term": { "resource_id.keyword": resourceId } }
+     *       ]
+     *     }
+     *   }
+     * }
+     * 
+ */ + private boolean updateByQueryResourceSharing(String sourceIdx, String resourceId, Script updateScript) { + try { + BoolQueryBuilder query = QueryBuilders.boolQuery() + .must(QueryBuilders.termQuery("source_idx.keyword", sourceIdx)) + .must(QueryBuilders.termQuery("resource_id.keyword", resourceId)); + + UpdateByQueryRequest ubq = new UpdateByQueryRequest(resourceSharingIndex).setQuery(query) + .setScript(updateScript) + .setRefresh(true); + + BulkByScrollResponse response = client.execute(UpdateByQueryAction.INSTANCE, ubq).actionGet(); + + if (response.getUpdated() > 0) { + LOGGER.info("Successfully updated {} documents in {}.", response.getUpdated(), resourceSharingIndex); + return true; + } else { + LOGGER.info( + "No documents found to update in {} for source_idx: {} and resource_id: {}", + resourceSharingIndex, + sourceIdx, + resourceId + ); + return false; + } + + } catch (Exception e) { + LOGGER.error("Failed to update documents in {}.", resourceSharingIndex, e); + return false; + } + } + + /** + * Revokes access for specified entities from a resource sharing document. This method removes the specified + * entities (users, roles, or backend roles) from the existing sharing configuration while preserving other + * sharing settings. + * + *

The method performs the following steps: + *

    + *
  1. Fetches the existing document
  2. + *
  3. Removes specified entities from their respective lists in all sharing groups
  4. + *
  5. Updates the document if modifications were made
  6. + *
  7. Returns the updated resource sharing configuration
  8. + *
+ * + *

Example document structure: + *

+     * {
+     *   "source_idx": "system_index_name",
+     *   "resource_id": "resource_id",
+     *   "share_with": {
+     *     "scope": {
+     *       "users": ["user1", "user2"],
+     *       "roles": ["role1", "role2"],
+     *       "backend_roles": ["backend_role1"]
+     *     }
+     *   }
+     * }
+     * 
+ * + * @param resourceId The ID of the resource from which to revoke access + * @param sourceIdx The name of the system index where the resource exists + * @param revokeAccess A map containing entity types (USER, ROLE, BACKEND_ROLE) and their corresponding + * values to be removed from the sharing configuration + * @param scopes A list of scopes to revoke access from. If null or empty, access is revoked from all scopes + * @param requestUserName The user trying to revoke the accesses + * @param isAdmin Boolean indicating whether the user is an admin or not + * @return The updated ResourceSharing object after revoking access, or null if the document doesn't exist + * @throws IllegalArgumentException if resourceId, sourceIdx is null/empty, or if revokeAccess is null/empty + * @throws RuntimeException if the update operation fails or encounters an error + * + * @see EntityType + * @see ResourceSharing + * + * @apiNote This method modifies the existing document. If no modifications are needed (i.e., specified + * entities don't exist in the current configuration), the original document is returned unchanged. + * @example + *
+     * Map> revokeAccess = new HashMap<>();
+     * revokeAccess.put(EntityType.USER, Set.of("user1", "user2"));
+     * revokeAccess.put(EntityType.ROLE, Set.of("role1"));
+     * ResourceSharing updated = revokeAccess("resourceId", "pluginIndex", revokeAccess);
+     * 
+ */ + public ResourceSharing revokeAccess( + String resourceId, + String sourceIdx, + Map> revokeAccess, + Set scopes, + String requestUserName, + boolean isAdmin + ) { + if (StringUtils.isBlank(resourceId) || StringUtils.isBlank(sourceIdx) || revokeAccess == null || revokeAccess.isEmpty()) { + throw new IllegalArgumentException("resourceId, sourceIdx, and revokeAccess must not be null or empty"); + } + + // TODO Check if access can be revoked by non-creator + ResourceSharing currentSharingInfo = fetchDocumentById(sourceIdx, resourceId); + if (!isAdmin && currentSharingInfo != null && !currentSharingInfo.getCreatedBy().getUser().equals(requestUserName)) { + LOGGER.error("User {} is not authorized to revoke access to resource {}", requestUserName, resourceId); + return null; + } + + LOGGER.debug("Revoking access for resource {} in {} for entities: {} and scopes: {}", resourceId, sourceIdx, revokeAccess, scopes); + + try { + Map revoke = new HashMap<>(); + for (Map.Entry> entry : revokeAccess.entrySet()) { + revoke.put(entry.getKey().name().toLowerCase(), new ArrayList<>(entry.getValue())); + } + + List scopesToUse = scopes != null ? new ArrayList<>(scopes) : new ArrayList<>(); + + Script revokeScript = new Script(ScriptType.INLINE, "painless", """ + if (ctx._source.share_with != null) { + Set scopesToProcess = new HashSet(params.scopes.isEmpty() ? ctx._source.share_with.keySet() : params.scopes); + + for (def scopeName : scopesToProcess) { + if (ctx._source.share_with.containsKey(scopeName)) { + def existingScope = ctx._source.share_with.get(scopeName); + + for (def entry : params.revokeAccess.entrySet()) { + def entityType = entry.getKey(); + def entitiesToRemove = entry.getValue(); + + if (existingScope.containsKey(entityType) && existingScope[entityType] != null) { + if (!(existingScope[entityType] instanceof HashSet)) { + existingScope[entityType] = new HashSet(existingScope[entityType]); + } + + existingScope[entityType].removeAll(entitiesToRemove); + + if (existingScope[entityType].isEmpty()) { + existingScope.remove(entityType); + } + } + } + + if (existingScope.isEmpty()) { + ctx._source.share_with.remove(scopeName); + } + } + } + } + """, Map.of("revokeAccess", revoke, "scopes", scopesToUse)); + + boolean success = updateByQueryResourceSharing(sourceIdx, resourceId, revokeScript); + + return success ? fetchDocumentById(sourceIdx, resourceId) : null; + + } catch (Exception e) { + LOGGER.error("Failed to revoke access for resource: {} in index: {}", resourceId, sourceIdx, e); + throw new RuntimeException("Failed to revoke access: " + e.getMessage(), e); + } + } + + /** + * Deletes resource sharing records that match the specified source index and resource ID. + * This method performs a delete-by-query operation in the resource sharing index. + * + *

The method executes the following steps: + *

    + *
  1. Creates a delete-by-query request with a bool query
  2. + *
  3. Matches documents based on exact source index and resource ID
  4. + *
  5. Executes the delete operation with immediate refresh
  6. + *
  7. Returns the success/failure status based on deletion results
  8. + *
+ * + *

Example document structure that will be deleted: + *

+     * {
+     *   "source_idx": "source_index_name",
+     *   "resource_id": "resource_id_value",
+     *   "share_with": {
+     *     // sharing configuration
+     *   }
+     * }
+     * 
+ * + * @param sourceIdx The source index to match in the query (exact match) + * @param resourceId The resource ID to match in the query (exact match) + * @return boolean true if at least one document was deleted, false if no documents were found or deletion failed + * + * @implNote The delete operation uses a bool query with two must clauses to ensure exact matching: + *
+     * {
+     *   "query": {
+     *     "bool": {
+     *       "must": [
+     *         { "term": { "source_idx": sourceIdx } },
+     *         { "term": { "resource_id": resourceId } }
+     *       ]
+     *     }
+     *   }
+     * }
+     * 
+ */ + public boolean deleteResourceSharingRecord(String resourceId, String sourceIdx) { + LOGGER.debug("Deleting documents from {} where source_idx = {} and resource_id = {}", resourceSharingIndex, sourceIdx, resourceId); + + try { + DeleteByQueryRequest dbq = new DeleteByQueryRequest(resourceSharingIndex).setQuery( + QueryBuilders.boolQuery() + .must(QueryBuilders.termQuery("source_idx.keyword", sourceIdx)) + .must(QueryBuilders.termQuery("resource_id.keyword", resourceId)) + ).setRefresh(true); + + BulkByScrollResponse response = client.execute(DeleteByQueryAction.INSTANCE, dbq).actionGet(); + + if (response.getDeleted() > 0) { + LOGGER.info("Successfully deleted {} documents from {}", response.getDeleted(), resourceSharingIndex); + return true; + } else { + LOGGER.info( + "No documents found to delete in {} for source_idx: {} and resource_id: {}", + resourceSharingIndex, + sourceIdx, + resourceId + ); + return false; + } + + } catch (Exception e) { + LOGGER.error("Failed to delete documents from {}", resourceSharingIndex, e); + return false; + } + } + + /** + * Deletes all resource sharing records that were created by a specific user. + * This method performs a delete-by-query operation to remove all documents where + * the created_by.user field matches the specified username. + * + *

The method executes the following steps: + *

    + *
  1. Validates the input username parameter
  2. + *
  3. Creates a delete-by-query request with term query matching
  4. + *
  5. Executes the delete operation with immediate refresh
  6. + *
  7. Returns the operation status based on number of deleted documents
  8. + *
+ * + *

Example query structure: + *

+    * {
+    *   "query": {
+    *     "term": {
+    *       "created_by.user": "username"
+    *     }
+    *   }
+    * }
+    * 
+ * + * @param name The username to match against the created_by.user field + * @return boolean indicating whether the deletion was successful: + *
    + *
  • true - if one or more documents were deleted
  • + *
  • false - if no documents were found
  • + *
  • false - if the operation failed due to an error
  • + *
+ * + * @throws IllegalArgumentException if name is null or empty + * + * + * @implNote Implementation details: + *
    + *
  • Uses DeleteByQueryRequest for efficient bulk deletion
  • + *
  • Sets refresh=true for immediate consistency
  • + *
  • Uses term query for exact username matching
  • + *
  • Implements comprehensive error handling and logging
  • + *
+ * + * Example usage: + *
+    * boolean success = deleteAllRecordsForUser("john.doe");
+    * if (success) {
+    *     // Records were successfully deleted
+    * } else {
+    *     // No matching records found or operation failed
+    * }
+    * 
+ */ + public boolean deleteAllRecordsForUser(String name) { + if (StringUtils.isBlank(name)) { + throw new IllegalArgumentException("Username must not be null or empty"); + } + + LOGGER.debug("Deleting all records for user {}", name); + + try { + DeleteByQueryRequest deleteRequest = new DeleteByQueryRequest(resourceSharingIndex).setQuery( + QueryBuilders.termQuery("created_by.user", name) + ).setRefresh(true); + + BulkByScrollResponse response = client.execute(DeleteByQueryAction.INSTANCE, deleteRequest).actionGet(); + + long deletedDocs = response.getDeleted(); + + if (deletedDocs > 0) { + LOGGER.info("Successfully deleted {} documents created by user {}", deletedDocs, name); + return true; + } else { + LOGGER.info("No documents found for user {}", name); + return false; + } + + } catch (Exception e) { + LOGGER.error("Failed to delete documents for user {}", name, e); + return false; + } + } + + /** + * Fetches all documents from the specified resource index and deserializes them into the specified class. + * + * @param resourceIndex The resource index to fetch documents from. + * @param clazz The class to deserialize the documents into. + * @return A set of deserialized documents. + */ + private Set getResourcesFromIds(Set resourceIds, String resourceIndex, Class clazz) { + Set result = new HashSet<>(); + // stashing Context to avoid permission issues in-case resourceIndex is a system index + try (ThreadContext.StoredContext ctx = this.threadPool.getThreadContext().stashContext()) { + MultiGetRequest request = new MultiGetRequest(); + for (String id : resourceIds) { + request.add(new MultiGetRequest.Item(resourceIndex, id)); + } + + MultiGetResponse response = client.multiGet(request).actionGet(); + + for (MultiGetItemResponse itemResponse : response.getResponses()) { + if (!itemResponse.isFailed() && itemResponse.getResponse().isExists()) { + String sourceAsString = itemResponse.getResponse().getSourceAsString(); + T resource = DefaultObjectMapper.readValue(sourceAsString, clazz); + result.add(resource); + } + } + } catch (Exception e) { + LOGGER.error("Failed to fetch resources with ids {} from index {}", resourceIds, resourceIndex, e); + } + + return result; + } +} diff --git a/src/main/java/org/opensearch/security/resources/ResourceSharingIndexListener.java b/src/main/java/org/opensearch/security/resources/ResourceSharingIndexListener.java new file mode 100644 index 0000000000..2fad56fc1b --- /dev/null +++ b/src/main/java/org/opensearch/security/resources/ResourceSharingIndexListener.java @@ -0,0 +1,130 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.security.resources; + +import java.io.IOException; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.accesscontrol.resources.CreatedBy; +import org.opensearch.accesscontrol.resources.ResourceSharing; +import org.opensearch.client.Client; +import org.opensearch.core.index.shard.ShardId; +import org.opensearch.index.engine.Engine; +import org.opensearch.index.shard.IndexingOperationListener; +import org.opensearch.security.support.ConfigConstants; +import org.opensearch.security.user.User; +import org.opensearch.threadpool.ThreadPool; + +/** + * This class implements an index operation listener for operations performed on resources stored in plugin's indices + * These indices are defined on bootstrap and configured to listen in OpenSearchSecurityPlugin.java + */ +public class ResourceSharingIndexListener implements IndexingOperationListener { + + private final static Logger log = LogManager.getLogger(ResourceSharingIndexListener.class); + + private static final ResourceSharingIndexListener INSTANCE = new ResourceSharingIndexListener(); + private ResourceSharingIndexHandler resourceSharingIndexHandler; + + private boolean initialized; + + private ThreadPool threadPool; + + private ResourceSharingIndexListener() {} + + public static ResourceSharingIndexListener getInstance() { + + return ResourceSharingIndexListener.INSTANCE; + + } + + /** + * Initializes the ResourceSharingIndexListener with the provided ThreadPool and Client. + * This method is called during the plugin's initialization process. + * + * @param threadPool The ThreadPool instance to be used for executing operations. + * @param client The Client instance to be used for interacting with OpenSearch. + */ + public void initialize(ThreadPool threadPool, Client client) { + + if (initialized) { + return; + } + + initialized = true; + this.threadPool = threadPool; + this.resourceSharingIndexHandler = new ResourceSharingIndexHandler( + ConfigConstants.OPENSEARCH_RESOURCE_SHARING_INDEX, + client, + threadPool + ); + + } + + public boolean isInitialized() { + return initialized; + } + + /** + * This method is called after an index operation is performed. + * It creates a resource sharing entry in the dedicated resource sharing index. + * @param shardId The shard ID of the index where the operation was performed. + * @param index The index where the operation was performed. + * @param result The result of the index operation. + */ + @Override + public void postIndex(ShardId shardId, Engine.Index index, Engine.IndexResult result) { + + String resourceIndex = shardId.getIndexName(); + log.info("postIndex called on {}", resourceIndex); + + String resourceId = index.id(); + + User user = threadPool.getThreadContext().getPersistent(ConfigConstants.OPENDISTRO_SECURITY_USER); + + try { + ResourceSharing sharing = this.resourceSharingIndexHandler.indexResourceSharing( + resourceId, + resourceIndex, + new CreatedBy(user.getName()), + null + ); + log.info("Successfully created a resource sharing entry {}", sharing); + } catch (IOException e) { + log.info("Failed to create a resource sharing entry for resource: {}", resourceId); + } + } + + /** + * This method is called after a delete operation is performed. + * It deletes the corresponding resource sharing entry from the dedicated resource sharing index. + * @param shardId The shard ID of the index where the delete operation was performed. + * @param delete The delete operation that was performed. + * @param result The result of the delete operation. + */ + @Override + public void postDelete(ShardId shardId, Engine.Delete delete, Engine.DeleteResult result) { + + String resourceIndex = shardId.getIndexName(); + log.info("postDelete called on {}", resourceIndex); + + String resourceId = delete.id(); + + boolean success = this.resourceSharingIndexHandler.deleteResourceSharingRecord(resourceId, resourceIndex); + if (success) { + log.info("Successfully deleted resource sharing entries for resource {}", resourceId); + } else { + log.info("Failed to delete resource sharing entry for resource {}", resourceId); + } + + } + +} diff --git a/src/main/java/org/opensearch/security/resources/ResourceSharingIndexManagementRepository.java b/src/main/java/org/opensearch/security/resources/ResourceSharingIndexManagementRepository.java new file mode 100644 index 0000000000..60cb48145f --- /dev/null +++ b/src/main/java/org/opensearch/security/resources/ResourceSharingIndexManagementRepository.java @@ -0,0 +1,39 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.resources; + +public class ResourceSharingIndexManagementRepository { + + private final ResourceSharingIndexHandler resourceSharingIndexHandler; + + protected ResourceSharingIndexManagementRepository(final ResourceSharingIndexHandler resourceSharingIndexHandler) { + this.resourceSharingIndexHandler = resourceSharingIndexHandler; + } + + public static ResourceSharingIndexManagementRepository create(ResourceSharingIndexHandler resourceSharingIndexHandler) { + + return new ResourceSharingIndexManagementRepository(resourceSharingIndexHandler); + } + + /** + * Creates the resource sharing index if it doesn't already exist. + * This method is called during the initialization phase of the repository. + * It ensures that the index is set up with the necessary mappings and settings + * before any operations are performed on the index. + */ + public void createResourceSharingIndexIfAbsent() { + // TODO check if this should be wrapped in an atomic completable future + + this.resourceSharingIndexHandler.createResourceSharingIndexIfAbsent(() -> null); + } + +} diff --git a/src/main/java/org/opensearch/security/resources/package-info.java b/src/main/java/org/opensearch/security/resources/package-info.java new file mode 100644 index 0000000000..855bdf81af --- /dev/null +++ b/src/main/java/org/opensearch/security/resources/package-info.java @@ -0,0 +1,12 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.resources; diff --git a/src/main/java/org/opensearch/security/support/ConfigConstants.java b/src/main/java/org/opensearch/security/support/ConfigConstants.java index f35afc6489..456e9586ca 100644 --- a/src/main/java/org/opensearch/security/support/ConfigConstants.java +++ b/src/main/java/org/opensearch/security/support/ConfigConstants.java @@ -370,6 +370,9 @@ public enum RolesMappingResolution { // Variable for initial admin password support public static final String OPENSEARCH_INITIAL_ADMIN_PASSWORD = "OPENSEARCH_INITIAL_ADMIN_PASSWORD"; + // Resource sharing index + public static final String OPENSEARCH_RESOURCE_SHARING_INDEX = ".opensearch_resource_sharing"; + public static Set getSettingAsSet( final Settings settings, final String key, diff --git a/src/test/java/org/opensearch/security/auditlog/compliance/ComplianceAuditlogTest.java b/src/test/java/org/opensearch/security/auditlog/compliance/ComplianceAuditlogTest.java index 202b211526..ba5b987533 100644 --- a/src/test/java/org/opensearch/security/auditlog/compliance/ComplianceAuditlogTest.java +++ b/src/test/java/org/opensearch/security/auditlog/compliance/ComplianceAuditlogTest.java @@ -475,11 +475,15 @@ public void testWriteLogDiffsEnabledAndLogRequestBodyDisabled() throws Exception ); updateAuditConfig(AuditTestUtils.createAuditPayload(auditConfig)); + try (Client tc = getClient()) { + rh.executePutRequest("emp", "{\"settings\": {\"index\": {\"number_of_shards\": 1, \"number_of_replicas\": 0}}}"); + } + List messages = TestAuditlogImpl.doThenWaitForMessages(() -> { try (Client tc = getClient()) { rh.executePutRequest("emp/_doc/0?refresh", "{\"name\" : \"Criag\", \"title\" : \"Software Engineer\"}"); } - }, 7); + }, 3); AuditMessage complianceDocWriteMessage = messages.stream() .filter(m -> m.getCategory().equals(AuditCategory.COMPLIANCE_DOC_WRITE)) diff --git a/src/test/java/org/opensearch/security/transport/SecurityInterceptorTests.java b/src/test/java/org/opensearch/security/transport/SecurityInterceptorTests.java index d12fafb247..0f7d5c59c5 100644 --- a/src/test/java/org/opensearch/security/transport/SecurityInterceptorTests.java +++ b/src/test/java/org/opensearch/security/transport/SecurityInterceptorTests.java @@ -20,6 +20,7 @@ import org.junit.Test; import org.opensearch.Version; +import org.opensearch.accesscontrol.resources.ResourceService; import org.opensearch.action.search.PitService; import org.opensearch.cluster.ClusterName; import org.opensearch.cluster.node.DiscoveryNode; @@ -171,7 +172,8 @@ public void setup() { transportService, mock(IndicesService.class), mock(PitService.class), - mock(ExtensionsManager.class) + mock(ExtensionsManager.class), + mock(ResourceService.class) ); // CS-ENFORCE-SINGLE