From 9f695fa65336bd07638638567cb872367854d978 Mon Sep 17 00:00:00 2001 From: Derek Ho Date: Tue, 24 Dec 2024 12:20:02 -0500 Subject: [PATCH 01/23] Naive cluster permission authz and authc based on token validity Signed-off-by: Derek Ho --- .../security/filter/SecurityRestFilter.java | 9 + .../security/http/ApiTokenAuthenticator.java | 286 ++++++++++++++++++ .../security/privileges/ActionPrivileges.java | 5 + .../PrivilegesEvaluationContext.java | 10 + .../privileges/PrivilegesEvaluator.java | 10 + .../securityconf/DynamicConfigModelV7.java | 18 ++ 6 files changed, 338 insertions(+) create mode 100644 src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java diff --git a/src/main/java/org/opensearch/security/filter/SecurityRestFilter.java b/src/main/java/org/opensearch/security/filter/SecurityRestFilter.java index c9d10ee2fa..e1efe65409 100644 --- a/src/main/java/org/opensearch/security/filter/SecurityRestFilter.java +++ b/src/main/java/org/opensearch/security/filter/SecurityRestFilter.java @@ -76,6 +76,8 @@ public class SecurityRestFilter { protected final Logger log = LogManager.getLogger(this.getClass()); + public static final String API_TOKEN_CLUSTERPERM_KEY = "security.api_token.clusterperm"; + public static final String API_TOKEN_INDEXPERM_KEY = "security.api_token.indexperm"; private final BackendRegistry registry; private final RestLayerPrivilegesEvaluator evaluator; private final AuditLog auditLog; @@ -232,6 +234,13 @@ void authorizeRequest(RestHandler original, SecurityRequestChannel request, User .addAll(route.actionNames() != null ? route.actionNames() : Collections.emptySet()) .add(route.name()) .build(); + + log.info("API token context value: " + threadContext.getTransient(API_TOKEN_CLUSTERPERM_KEY).toString()); + + if (threadContext.getTransient(API_TOKEN_CLUSTERPERM_KEY) != null) { + return; + } + pres = evaluator.evaluate(user, route.name(), actionNames); if (log.isDebugEnabled()) { diff --git a/src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java b/src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java new file mode 100644 index 0000000000..bc6b25116d --- /dev/null +++ b/src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java @@ -0,0 +1,286 @@ +/* + * 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.http; + +import java.security.AccessController; +import java.security.PrivilegedAction; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Map.Entry; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.hc.core5.http.HttpHeaders; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.OpenSearchException; +import org.opensearch.OpenSearchSecurityException; +import org.opensearch.SpecialPermission; +import org.opensearch.common.settings.Settings; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.security.DefaultObjectMapper; +import org.opensearch.security.auth.HTTPAuthenticator; +import org.opensearch.security.authtoken.jwt.EncryptionDecryptionUtil; +import org.opensearch.security.filter.SecurityRequest; +import org.opensearch.security.filter.SecurityResponse; +import org.opensearch.security.ssl.util.ExceptionUtils; +import org.opensearch.security.user.AuthCredentials; +import org.opensearch.security.util.KeyUtils; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtParser; +import io.jsonwebtoken.JwtParserBuilder; +import io.jsonwebtoken.security.WeakKeyException; + +import static org.opensearch.security.OpenSearchSecurityPlugin.LEGACY_OPENDISTRO_PREFIX; +import static org.opensearch.security.OpenSearchSecurityPlugin.PLUGINS_PREFIX; +import static org.opensearch.security.filter.SecurityRestFilter.API_TOKEN_CLUSTERPERM_KEY; +import static org.opensearch.security.util.AuthTokenUtils.isAccessToRestrictedEndpoints; + +public class ApiTokenAuthenticator implements HTTPAuthenticator { + + private static final int MINIMUM_SIGNING_KEY_BIT_LENGTH = 512; + private static final String REGEX_PATH_PREFIX = "/(" + LEGACY_OPENDISTRO_PREFIX + "|" + PLUGINS_PREFIX + ")/" + "(.*)"; + private static final Pattern PATTERN_PATH_PREFIX = Pattern.compile(REGEX_PATH_PREFIX); + + protected final Logger log = LogManager.getLogger(this.getClass()); + + private static final Pattern BEARER = Pattern.compile("^\\s*Bearer\\s.*", Pattern.CASE_INSENSITIVE); + private static final String BEARER_PREFIX = "bearer "; + + private final JwtParser jwtParser; + private final String encryptionKey; + private final Boolean apiTokenEnabled; + private final String clusterName; + + private final EncryptionDecryptionUtil encryptionUtil; + + @SuppressWarnings("removal") + public ApiTokenAuthenticator(Settings settings, String clusterName) { + String apiTokenEnabledSetting = settings.get("enabled", "true"); + apiTokenEnabled = Boolean.parseBoolean(apiTokenEnabledSetting); + encryptionKey = settings.get("encryption_key"); + + final SecurityManager sm = System.getSecurityManager(); + if (sm != null) { + sm.checkPermission(new SpecialPermission()); + } + jwtParser = AccessController.doPrivileged(new PrivilegedAction() { + @Override + public JwtParser run() { + JwtParserBuilder builder = initParserBuilder(settings.get("signing_key")); + return builder.build(); + } + }); + this.clusterName = clusterName; + this.encryptionUtil = new EncryptionDecryptionUtil(encryptionKey); + } + + private JwtParserBuilder initParserBuilder(final String signingKey) { + if (signingKey == null) { + throw new OpenSearchSecurityException("Unable to find api token authenticator signing_key"); + } + + final int signingKeyLengthBits = signingKey.length() * 8; + if (signingKeyLengthBits < MINIMUM_SIGNING_KEY_BIT_LENGTH) { + throw new OpenSearchSecurityException( + "Signing key size was " + + signingKeyLengthBits + + " bits, which is not secure enough. Please use a signing_key with a size >= " + + MINIMUM_SIGNING_KEY_BIT_LENGTH + + " bits." + ); + } + JwtParserBuilder jwtParserBuilder = KeyUtils.createJwtParserBuilderFromSigningKey(signingKey, log); + + return jwtParserBuilder; + } + + private String extractSecurityRolesFromClaims(Claims claims) { + Object cp = claims.get("cp"); + Object ip = claims.get("ip"); + String rolesClaim = ""; + + if (cp != null) { + rolesClaim = encryptionUtil.decrypt(cp.toString()); + } else { + log.warn("This is a malformed Api Token"); + } + + return rolesClaim; + } + + private String[] extractBackendRolesFromClaims(Claims claims) { + Object backendRolesObject = claims.get("br"); + String[] backendRoles; + + if (backendRolesObject == null) { + backendRoles = new String[0]; + } else { + // Extracting roles based on the compatibility mode + backendRoles = Arrays.stream(backendRolesObject.toString().split(",")).map(String::trim).toArray(String[]::new); + } + + return backendRoles; + } + + @Override + @SuppressWarnings("removal") + public AuthCredentials extractCredentials(final SecurityRequest request, final ThreadContext context) + throws OpenSearchSecurityException { + final SecurityManager sm = System.getSecurityManager(); + + if (sm != null) { + sm.checkPermission(new SpecialPermission()); + } + + AuthCredentials creds = AccessController.doPrivileged(new PrivilegedAction() { + @Override + public AuthCredentials run() { + return extractCredentials0(request, context); + } + }); + + return creds; + } + + private AuthCredentials extractCredentials0(final SecurityRequest request, final ThreadContext context) { + if (!apiTokenEnabled) { + log.error("Api token authentication is disabled"); + return null; + } + + String jwtToken = extractJwtFromHeader(request); + if (jwtToken == null) { + return null; + } + + if (!isRequestAllowed(request)) { + return null; + } + + try { + final Claims claims = jwtParser.parseClaimsJws(jwtToken).getBody(); + + final String subject = claims.getSubject(); + if (subject == null) { + log.error("Valid jwt on behalf of token with no subject"); + return null; + } + + final Set audience = claims.getAudience(); + if (audience == null || audience.isEmpty()) { + log.error("Valid jwt on behalf of token with no audience"); + return null; + } + + final String issuer = claims.getIssuer(); + if (!clusterName.equals(issuer)) { + log.error("The issuer of this OBO does not match the current cluster identifier"); + return null; + } + + String clusterPermissions = extractSecurityRolesFromClaims(claims); + String[] backendRoles = extractBackendRolesFromClaims(claims); + + final AuthCredentials ac = new AuthCredentials(subject, List.of(), backendRoles).markComplete(); + + for (Entry claim : claims.entrySet()) { + String key = "attr.jwt." + claim.getKey(); + Object value = claim.getValue(); + + if (value instanceof Collection) { + try { + // Convert the list to a JSON array string + String jsonValue = DefaultObjectMapper.writeValueAsString(value, false); + ac.addAttribute(key, jsonValue); + } catch (Exception e) { + log.warn("Failed to convert list claim to JSON for key: " + key, e); + // Fallback to string representation + ac.addAttribute(key, String.valueOf(value)); + } + } else { + ac.addAttribute(key, String.valueOf(value)); + } + } + + context.putTransient(API_TOKEN_CLUSTERPERM_KEY, clusterPermissions); + + return ac; + + } catch (WeakKeyException e) { + log.error("Cannot authenticate user with JWT because of ", e); + return null; + } catch (Exception e) { + if (log.isDebugEnabled()) { + log.debug("Invalid or expired JWT token.", e); + } + } + + // Return null for the authentication failure + return null; + } + + private String extractJwtFromHeader(SecurityRequest request) { + String jwtToken = request.header(HttpHeaders.AUTHORIZATION); + + if (jwtToken == null || jwtToken.isEmpty()) { + logDebug("No JWT token found in '{}' header", HttpHeaders.AUTHORIZATION); + return null; + } + + if (!BEARER.matcher(jwtToken).matches() || !jwtToken.toLowerCase().contains(BEARER_PREFIX)) { + logDebug("No Bearer scheme found in header"); + return null; + } + + jwtToken = jwtToken.substring(jwtToken.toLowerCase().indexOf(BEARER_PREFIX) + BEARER_PREFIX.length()); + + return jwtToken; + } + + private void logDebug(String message, Object... args) { + if (log.isDebugEnabled()) { + log.debug(message, args); + } + } + + public Boolean isRequestAllowed(final SecurityRequest request) { + Matcher matcher = PATTERN_PATH_PREFIX.matcher(request.path()); + final String suffix = matcher.matches() ? matcher.group(2) : null; + if (isAccessToRestrictedEndpoints(request, suffix)) { + final OpenSearchException exception = ExceptionUtils.invalidUsageOfOBOTokenException(); + log.error(exception.toString()); + return false; + } + return true; + } + + @Override + public Optional reRequestAuthentication(final SecurityRequest response, AuthCredentials creds) { + return Optional.empty(); + } + + @Override + public String getType() { + return "onbehalfof_jwt"; + } + + @Override + public boolean supportsImpersonation() { + return false; + } +} diff --git a/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java b/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java index 87ac32d090..7f019c86db 100644 --- a/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java +++ b/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java @@ -407,6 +407,11 @@ PrivilegesEvaluatorResponse providesPrivilege(PrivilegesEvaluationContext contex } } + // 4: Evaluate api tokens + if (context.getClusterPermissions().contains(action)) { + return PrivilegesEvaluatorResponse.ok(); + } + return PrivilegesEvaluatorResponse.insufficient(action); } diff --git a/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluationContext.java b/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluationContext.java index f7e5d6de7d..686f38a686 100644 --- a/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluationContext.java +++ b/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluationContext.java @@ -11,6 +11,7 @@ package org.opensearch.security.privileges; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.function.Supplier; @@ -45,6 +46,7 @@ public class PrivilegesEvaluationContext { private final IndexResolverReplacer indexResolverReplacer; private final IndexNameExpressionResolver indexNameExpressionResolver; private final Supplier clusterStateSupplier; + private List clusterPermissions; /** * This caches the ready to use WildcardMatcher instances for the current request. Many index patterns have @@ -172,4 +174,12 @@ public String toString() { + mappedRoles + '}'; } + + public void setClusterPermissions(List clusterPermissions) { + this.clusterPermissions = clusterPermissions; + } + + public List getClusterPermissions() { + return clusterPermissions; + } } diff --git a/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluator.java b/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluator.java index 36666972ec..faea38b81c 100644 --- a/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluator.java +++ b/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluator.java @@ -109,6 +109,7 @@ import org.greenrobot.eventbus.Subscribe; import static org.opensearch.security.OpenSearchSecurityPlugin.traceAction; +import static org.opensearch.security.filter.SecurityRestFilter.API_TOKEN_CLUSTERPERM_KEY; import static org.opensearch.security.support.ConfigConstants.OPENDISTRO_SECURITY_USER_INFO_THREAD_CONTEXT; public class PrivilegesEvaluator { @@ -342,6 +343,15 @@ public PrivilegesEvaluatorResponse evaluate(PrivilegesEvaluationContext context) context.setMappedRoles(mappedRoles); } + // Extract cluster and index permissions from the api token thread context + // TODO: Add decryption here to make sure it is not injectable by anyone? + // TODO: This is only a naive implementation that does not support * + final String apiTokenClusterPermissions = threadContext.getTransient(API_TOKEN_CLUSTERPERM_KEY); + if (apiTokenClusterPermissions != null) { + List clusterPermissions = Arrays.asList(apiTokenClusterPermissions.split(",")); + context.setClusterPermissions(clusterPermissions); + } + // Add the security roles for this user so that they can be used for DLS parameter substitution. user.addSecurityRoles(mappedRoles); setUserInfoInThreadContext(user); diff --git a/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java b/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java index 9c90e2341f..b57b422653 100644 --- a/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java +++ b/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java @@ -59,6 +59,7 @@ import org.opensearch.security.auth.internal.InternalAuthenticationBackend; import org.opensearch.security.auth.internal.NoOpAuthenticationBackend; import org.opensearch.security.configuration.ClusterInfoHolder; +import org.opensearch.security.http.ApiTokenAuthenticator; import org.opensearch.security.http.OnBehalfOfAuthenticator; import org.opensearch.security.securityconf.impl.DashboardSignInOption; import org.opensearch.security.securityconf.impl.v7.ConfigV7; @@ -377,6 +378,23 @@ private void buildAAA() { } } + /* + * If the Api token authentication is configured: + * Add the ApiToken authbackend in to the auth domains + * Challenge: false - no need to iterate through the auth domains again when ApiToken authentication failed + * order: -2 - prioritize the Api token authentication when it gets enabled + */ + Settings apiTokenSettings = getDynamicApiTokenSettings(); + if (!isKeyNull(apiTokenSettings, "signing_key") && !isKeyNull(apiTokenSettings, "encryption_key")) { + final AuthDomain _ad = new AuthDomain( + new NoOpAuthenticationBackend(Settings.EMPTY, null), + new ApiTokenAuthenticator(getDynamicApiTokenSettings(), this.cih.getClusterName()), + false, + -2 + ); + restAuthDomains0.add(_ad); + } + /* * If the OnBehalfOf (OBO) authentication is configured: * Add the OBO authbackend in to the auth domains From d3fcc4aadcd83b8c087337189dfbad49ce675f8c Mon Sep 17 00:00:00 2001 From: Derek Ho Date: Tue, 24 Dec 2024 16:16:46 -0500 Subject: [PATCH 02/23] Crude index permissions authz Signed-off-by: Derek Ho --- .../security/authtoken/jwt/JwtVendor.java | 15 ++- .../security/filter/SecurityRestFilter.java | 9 +- .../security/http/ApiTokenAuthenticator.java | 107 +++++++++++++++--- .../security/privileges/ActionPrivileges.java | 9 ++ .../PrivilegesEvaluationContext.java | 18 +++ .../privileges/PrivilegesEvaluator.java | 17 +++ .../securityconf/DynamicConfigModelV7.java | 1 + 7 files changed, 146 insertions(+), 30 deletions(-) diff --git a/src/main/java/org/opensearch/security/authtoken/jwt/JwtVendor.java b/src/main/java/org/opensearch/security/authtoken/jwt/JwtVendor.java index 75ce45912a..575bba7964 100644 --- a/src/main/java/org/opensearch/security/authtoken/jwt/JwtVendor.java +++ b/src/main/java/org/opensearch/security/authtoken/jwt/JwtVendor.java @@ -15,7 +15,6 @@ import java.security.AccessController; import java.security.PrivilegedAction; import java.text.ParseException; -import java.util.ArrayList; import java.util.Base64; import java.util.Date; import java.util.List; @@ -30,6 +29,7 @@ import org.opensearch.common.settings.Settings; import org.opensearch.common.xcontent.XContentFactory; import org.opensearch.core.xcontent.ToXContent; +import org.opensearch.core.xcontent.XContentBuilder; import org.opensearch.security.action.apitokens.ApiToken; import com.nimbusds.jose.JOSEException; @@ -184,12 +184,17 @@ public ExpiringBearerAuthToken createJwt( } if (indexPermissions != null) { - List permissionStrings = new ArrayList<>(); + XContentBuilder builder = XContentFactory.jsonBuilder(); + builder.startArray(); for (ApiToken.IndexPermission permission : indexPermissions) { - permissionStrings.add(permission.toXContent(XContentFactory.jsonBuilder(), ToXContent.EMPTY_PARAMS).toString()); + // Add each permission to the array + permission.toXContent(builder, ToXContent.EMPTY_PARAMS); } - final String listOfIndexPermissions = String.join(",", permissionStrings); - claimsBuilder.claim("ip", encryptString(listOfIndexPermissions)); + builder.endArray(); + + // Encrypt the entire JSON array + String jsonArray = builder.toString(); + claimsBuilder.claim("ip", encryptString(jsonArray)); } final JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.parse(signingKey.getAlgorithm().getName())).build(); diff --git a/src/main/java/org/opensearch/security/filter/SecurityRestFilter.java b/src/main/java/org/opensearch/security/filter/SecurityRestFilter.java index e1efe65409..04a2489b7b 100644 --- a/src/main/java/org/opensearch/security/filter/SecurityRestFilter.java +++ b/src/main/java/org/opensearch/security/filter/SecurityRestFilter.java @@ -77,7 +77,8 @@ public class SecurityRestFilter { protected final Logger log = LogManager.getLogger(this.getClass()); public static final String API_TOKEN_CLUSTERPERM_KEY = "security.api_token.clusterperm"; - public static final String API_TOKEN_INDEXPERM_KEY = "security.api_token.indexperm"; + public static final String API_TOKEN_INDEXACTIONS_KEY = "security.api_token.indexactions"; + public static final String API_TOKEN_INDICES_KEY = "security.api_token.indices"; private final BackendRegistry registry; private final RestLayerPrivilegesEvaluator evaluator; private final AuditLog auditLog; @@ -235,12 +236,6 @@ void authorizeRequest(RestHandler original, SecurityRequestChannel request, User .add(route.name()) .build(); - log.info("API token context value: " + threadContext.getTransient(API_TOKEN_CLUSTERPERM_KEY).toString()); - - if (threadContext.getTransient(API_TOKEN_CLUSTERPERM_KEY) != null) { - return; - } - pres = evaluator.evaluate(user, route.name(), actionNames); if (log.isDebugEnabled()) { diff --git a/src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java b/src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java index bc6b25116d..0d37c51355 100644 --- a/src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java +++ b/src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java @@ -11,9 +11,10 @@ package org.opensearch.security.http; +import java.io.IOException; import java.security.AccessController; import java.security.PrivilegedAction; -import java.util.Arrays; +import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.Map.Entry; @@ -31,7 +32,12 @@ import org.opensearch.SpecialPermission; import org.opensearch.common.settings.Settings; import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.common.xcontent.XContentType; +import org.opensearch.core.xcontent.DeprecationHandler; +import org.opensearch.core.xcontent.NamedXContentRegistry; +import org.opensearch.core.xcontent.XContentParser; import org.opensearch.security.DefaultObjectMapper; +import org.opensearch.security.action.apitokens.ApiToken; import org.opensearch.security.auth.HTTPAuthenticator; import org.opensearch.security.authtoken.jwt.EncryptionDecryptionUtil; import org.opensearch.security.filter.SecurityRequest; @@ -48,6 +54,8 @@ import static org.opensearch.security.OpenSearchSecurityPlugin.LEGACY_OPENDISTRO_PREFIX; import static org.opensearch.security.OpenSearchSecurityPlugin.PLUGINS_PREFIX; import static org.opensearch.security.filter.SecurityRestFilter.API_TOKEN_CLUSTERPERM_KEY; +import static org.opensearch.security.filter.SecurityRestFilter.API_TOKEN_INDEXACTIONS_KEY; +import static org.opensearch.security.filter.SecurityRestFilter.API_TOKEN_INDICES_KEY; import static org.opensearch.security.util.AuthTokenUtils.isAccessToRestrictedEndpoints; public class ApiTokenAuthenticator implements HTTPAuthenticator { @@ -109,32 +117,88 @@ private JwtParserBuilder initParserBuilder(final String signingKey) { return jwtParserBuilder; } - private String extractSecurityRolesFromClaims(Claims claims) { + private String extractClusterPermissionsFromClaims(Claims claims) { Object cp = claims.get("cp"); - Object ip = claims.get("ip"); - String rolesClaim = ""; + String clusterPermissions = ""; if (cp != null) { - rolesClaim = encryptionUtil.decrypt(cp.toString()); + clusterPermissions = encryptionUtil.decrypt(cp.toString()); } else { log.warn("This is a malformed Api Token"); } - return rolesClaim; + return clusterPermissions; } - private String[] extractBackendRolesFromClaims(Claims claims) { - Object backendRolesObject = claims.get("br"); - String[] backendRoles; + private String extractAllowedActionsFromClaims(Claims claims) throws IOException { + Object ip = claims.get("ip"); + + if (ip != null) { + String decryptedPermissions = encryptionUtil.decrypt(ip.toString()); + + try ( + XContentParser parser = XContentType.JSON.xContent() + .createParser(NamedXContentRegistry.EMPTY, DeprecationHandler.THROW_UNSUPPORTED_OPERATION, decryptedPermissions) + ) { + + // Use built-in array parsing + List permissions = new ArrayList<>(); + + // Move to start of array + parser.nextToken(); // START_ARRAY + while (parser.nextToken() != XContentParser.Token.END_ARRAY) { + permissions.add(ApiToken.IndexPermission.fromXContent(parser)); + } + // Get first permission's actions + if (!permissions.isEmpty() && !permissions.get(0).getAllowedActions().isEmpty()) { + return permissions.get(0).getAllowedActions().get(0); + } + + return ""; + } catch (Exception e) { + log.error("Error extracting allowed actions", e); + return ""; + } + + } + + return ""; + } + + private String extractIndicesFromClaims(Claims claims) throws IOException { + Object ip = claims.get("ip"); + + if (ip != null) { + String decryptedPermissions = encryptionUtil.decrypt(ip.toString()); + + try ( + XContentParser parser = XContentType.JSON.xContent() + .createParser(NamedXContentRegistry.EMPTY, DeprecationHandler.THROW_UNSUPPORTED_OPERATION, decryptedPermissions) + ) { + + // Use built-in array parsing + List permissions = new ArrayList<>(); + + // Move to start of array + parser.nextToken(); // START_ARRAY + while (parser.nextToken() != XContentParser.Token.END_ARRAY) { + permissions.add(ApiToken.IndexPermission.fromXContent(parser)); + } + + // Get first permission's actions + if (!permissions.isEmpty() && !permissions.get(0).getIndexPatterns().isEmpty()) { + return permissions.get(0).getIndexPatterns().get(0); + } + + return ""; + } catch (Exception e) { + log.error("Error extracting indices", e); + return ""; + } - if (backendRolesObject == null) { - backendRoles = new String[0]; - } else { - // Extracting roles based on the compatibility mode - backendRoles = Arrays.stream(backendRolesObject.toString().split(",")).map(String::trim).toArray(String[]::new); } - return backendRoles; + return ""; } @Override @@ -193,10 +257,15 @@ private AuthCredentials extractCredentials0(final SecurityRequest request, final return null; } - String clusterPermissions = extractSecurityRolesFromClaims(claims); - String[] backendRoles = extractBackendRolesFromClaims(claims); + log.info("before extraction"); + + String clusterPermissions = extractClusterPermissionsFromClaims(claims); + String allowedActions = extractAllowedActionsFromClaims(claims); + String indices = extractIndicesFromClaims(claims); + + log.info(clusterPermissions + allowedActions + indices); - final AuthCredentials ac = new AuthCredentials(subject, List.of(), backendRoles).markComplete(); + final AuthCredentials ac = new AuthCredentials(subject, List.of(), new String[0]).markComplete(); for (Entry claim : claims.entrySet()) { String key = "attr.jwt." + claim.getKey(); @@ -218,6 +287,8 @@ private AuthCredentials extractCredentials0(final SecurityRequest request, final } context.putTransient(API_TOKEN_CLUSTERPERM_KEY, clusterPermissions); + context.putTransient(API_TOKEN_INDEXACTIONS_KEY, allowedActions); + context.putTransient(API_TOKEN_INDICES_KEY, indices); return ac; diff --git a/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java b/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java index 7f019c86db..d32df0eb56 100644 --- a/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java +++ b/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java @@ -14,6 +14,7 @@ import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -161,6 +162,14 @@ public PrivilegesEvaluatorResponse hasIndexPrivilege( return response; } + // API Token Authz + // TODO: this is very naive implementation + if (context.getIndices() != null && new HashSet<>(context.getIndices()).containsAll(resolvedIndices.getAllIndices())) { + if (new HashSet<>(context.getAllowedActions()).containsAll(actions)) { + return PrivilegesEvaluatorResponse.ok(); + } + } + if (!resolvedIndices.isLocalAll() && resolvedIndices.getAllIndices().isEmpty()) { // This is necessary for requests which operate on remote indices. // Access control for the remote indices will be performed on the remote cluster. diff --git a/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluationContext.java b/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluationContext.java index 686f38a686..65b69f5b53 100644 --- a/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluationContext.java +++ b/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluationContext.java @@ -47,6 +47,8 @@ public class PrivilegesEvaluationContext { private final IndexNameExpressionResolver indexNameExpressionResolver; private final Supplier clusterStateSupplier; private List clusterPermissions; + private List allowedActions; + private List indices; /** * This caches the ready to use WildcardMatcher instances for the current request. Many index patterns have @@ -182,4 +184,20 @@ public void setClusterPermissions(List clusterPermissions) { public List getClusterPermissions() { return clusterPermissions; } + + public List getAllowedActions() { + return allowedActions; + } + + public void setAllowedActions(List allowedActions) { + this.allowedActions = allowedActions; + } + + public List getIndices() { + return indices; + } + + public void setIndices(List indices) { + this.indices = indices; + } } diff --git a/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluator.java b/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluator.java index faea38b81c..0871633a25 100644 --- a/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluator.java +++ b/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluator.java @@ -110,6 +110,8 @@ import static org.opensearch.security.OpenSearchSecurityPlugin.traceAction; import static org.opensearch.security.filter.SecurityRestFilter.API_TOKEN_CLUSTERPERM_KEY; +import static org.opensearch.security.filter.SecurityRestFilter.API_TOKEN_INDEXACTIONS_KEY; +import static org.opensearch.security.filter.SecurityRestFilter.API_TOKEN_INDICES_KEY; import static org.opensearch.security.support.ConfigConstants.OPENDISTRO_SECURITY_USER_INFO_THREAD_CONTEXT; public class PrivilegesEvaluator { @@ -352,6 +354,21 @@ public PrivilegesEvaluatorResponse evaluate(PrivilegesEvaluationContext context) context.setClusterPermissions(clusterPermissions); } + final String apiTokenIndexAllowedActions = threadContext.getTransient(API_TOKEN_INDEXACTIONS_KEY); + if (apiTokenIndexAllowedActions != null) { + List allowedactions = Arrays.asList(apiTokenIndexAllowedActions.split(",")); + context.setAllowedActions(allowedactions); + } + + final String apiTokenIndices = threadContext.getTransient(API_TOKEN_INDICES_KEY); + if (apiTokenIndices != null) { + List indices = Arrays.asList(apiTokenIndices.split(",")); + context.setIndices(indices); + } + + log.info("API Tokens actions" + apiTokenIndexAllowedActions); + log.info("API Tokens indices" + apiTokenIndices); + // Add the security roles for this user so that they can be used for DLS parameter substitution. user.addSecurityRoles(mappedRoles); setUserInfoInThreadContext(user); diff --git a/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java b/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java index b57b422653..a6e4ff4734 100644 --- a/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java +++ b/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java @@ -386,6 +386,7 @@ private void buildAAA() { */ Settings apiTokenSettings = getDynamicApiTokenSettings(); if (!isKeyNull(apiTokenSettings, "signing_key") && !isKeyNull(apiTokenSettings, "encryption_key")) { + log.info("we initialized the api tokenauthenticator"); final AuthDomain _ad = new AuthDomain( new NoOpAuthenticationBackend(Settings.EMPTY, null), new ApiTokenAuthenticator(getDynamicApiTokenSettings(), this.cih.getClusterName()), From 6904317c7ff2d55916943449a031e300bbc7c809 Mon Sep 17 00:00:00 2001 From: Derek Ho Date: Thu, 26 Dec 2024 11:18:00 -0500 Subject: [PATCH 03/23] Fix tests Signed-off-by: Derek Ho --- .../opensearch/security/privileges/ActionPrivileges.java | 2 +- .../opensearch/security/authtoken/jwt/JwtVendorTest.java | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java b/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java index d32df0eb56..2a0f572f0e 100644 --- a/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java +++ b/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java @@ -417,7 +417,7 @@ PrivilegesEvaluatorResponse providesPrivilege(PrivilegesEvaluationContext contex } // 4: Evaluate api tokens - if (context.getClusterPermissions().contains(action)) { + if (context.getClusterPermissions() != null && context.getClusterPermissions().contains(action)) { return PrivilegesEvaluatorResponse.ok(); } diff --git a/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java b/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java index 48aae6f9b8..ee11c17e13 100644 --- a/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java +++ b/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java @@ -289,8 +289,9 @@ public void testCreateJwtForApiTokenSuccess() throws Exception { ApiToken.IndexPermission indexPermission = new ApiToken.IndexPermission(List.of("*"), List.of("read")); final List indexPermissions = List.of(indexPermission); final String expectedClusterPermissions = "cluster:admin/*"; - final String expectedIndexPermissions = indexPermission.toXContent(XContentFactory.jsonBuilder(), ToXContent.EMPTY_PARAMS) - .toString(); + final String expectedIndexPermissions = "[" + + indexPermission.toXContent(XContentFactory.jsonBuilder(), ToXContent.EMPTY_PARAMS).toString() + + "]"; LongSupplier currentTime = () -> (long) 100; String claimsEncryptionKey = "1234567890123456"; @@ -319,6 +320,7 @@ public void testCreateJwtForApiTokenSuccess() throws Exception { encryptionUtil.decrypt(signedJWT.getJWTClaimsSet().getClaims().get("cp").toString()), equalTo(expectedClusterPermissions) ); + assertThat(encryptionUtil.decrypt(signedJWT.getJWTClaimsSet().getClaims().get("ip").toString()), equalTo(expectedIndexPermissions)); XContentParser parser = XContentType.JSON.xContent() @@ -327,6 +329,9 @@ public void testCreateJwtForApiTokenSuccess() throws Exception { DeprecationHandler.THROW_UNSUPPORTED_OPERATION, encryptionUtil.decrypt(signedJWT.getJWTClaimsSet().getClaims().get("ip").toString()) ); + // Parse first item of the list + parser.nextToken(); + parser.nextToken(); ApiToken.IndexPermission indexPermission1 = ApiToken.IndexPermission.fromXContent(parser); // Index permission deserialization works as expected From 17bca93a6524ff59eda1f070b8f018e294c7ec46 Mon Sep 17 00:00:00 2001 From: Derek Ho Date: Thu, 26 Dec 2024 12:09:00 -0500 Subject: [PATCH 04/23] Revert mis-merge in abstractauditlog Signed-off-by: Derek Ho --- .../auditlog/impl/AbstractAuditLog.java | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/src/main/java/org/opensearch/security/auditlog/impl/AbstractAuditLog.java b/src/main/java/org/opensearch/security/auditlog/impl/AbstractAuditLog.java index 8bf8f63dde..9a16cd8bfd 100644 --- a/src/main/java/org/opensearch/security/auditlog/impl/AbstractAuditLog.java +++ b/src/main/java/org/opensearch/security/auditlog/impl/AbstractAuditLog.java @@ -584,22 +584,24 @@ public void logDocumentWritten(ShardId shardId, GetResult originalResult, Index originalSource = "{}"; } if (securityIndicesMatcher.test(shardId.getIndexName())) { - try ( - XContentParser parser = XContentHelper.createParser( - NamedXContentRegistry.EMPTY, - THROW_UNSUPPORTED_OPERATION, - originalResult.internalSourceRef(), - XContentType.JSON - ) - ) { - Object base64 = parser.map().values().iterator().next(); - if (base64 instanceof String) { - originalSource = (new String(BaseEncoding.base64().decode((String) base64), StandardCharsets.UTF_8)); - } else { - originalSource = XContentHelper.convertToJson(originalResult.internalSourceRef(), false, XContentType.JSON); + if (originalSource == null) { + try ( + XContentParser parser = XContentHelper.createParser( + NamedXContentRegistry.EMPTY, + THROW_UNSUPPORTED_OPERATION, + originalResult.internalSourceRef(), + XContentType.JSON + ) + ) { + Object base64 = parser.map().values().iterator().next(); + if (base64 instanceof String) { + originalSource = (new String(BaseEncoding.base64().decode((String) base64), StandardCharsets.UTF_8)); + } else { + originalSource = XContentHelper.convertToJson(originalResult.internalSourceRef(), false, XContentType.JSON); + } + } catch (Exception e) { + log.error(e.toString()); } - } catch (Exception e) { - log.error(e.toString()); } try ( @@ -640,7 +642,7 @@ public void logDocumentWritten(ShardId shardId, GetResult originalResult, Index } } - if (!complianceConfig.shouldLogWriteMetadataOnly()) { + if (!complianceConfig.shouldLogWriteMetadataOnly() && !complianceConfig.shouldLogDiffsForWrite()) { if (securityIndicesMatcher.test(shardId.getIndexName())) { // current source, normally not null or empty try ( From 92d4e60302b70f2ed0a6ca4b37010f9399443581 Mon Sep 17 00:00:00 2001 From: Derek Ho Date: Fri, 27 Dec 2024 12:34:12 -0500 Subject: [PATCH 05/23] Add allowlist for authc, add basic test showing it works Signed-off-by: Derek Ho --- .../security/OpenSearchSecurityPlugin.java | 10 ++ .../apitokens/ApiTokenIndexListenerCache.java | 112 ++++++++++++++++++ .../security/http/ApiTokenAuthenticator.java | 22 ++-- .../securityconf/DynamicConfigModelV7.java | 1 - .../security/ssl/util/ExceptionUtils.java | 4 + .../security/util/AuthTokenUtils.java | 4 + .../apitokens/ApiTokenAuthenticatorTest.java | 99 ++++++++++++++++ 7 files changed, 242 insertions(+), 10 deletions(-) create mode 100644 src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexListenerCache.java create mode 100644 src/test/java/org/opensearch/security/action/apitokens/ApiTokenAuthenticatorTest.java diff --git a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java index 063088fcc9..efe51d2e74 100644 --- a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java +++ b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java @@ -132,6 +132,7 @@ import org.opensearch.search.internal.SearchContext; import org.opensearch.search.query.QuerySearchResult; import org.opensearch.security.action.apitokens.ApiTokenAction; +import org.opensearch.security.action.apitokens.ApiTokenIndexListenerCache; import org.opensearch.security.action.configupdate.ConfigUpdateAction; import org.opensearch.security.action.configupdate.TransportConfigUpdateAction; import org.opensearch.security.action.onbehalf.CreateOnBehalfOfTokenAction; @@ -717,6 +718,15 @@ public void onIndexModule(IndexModule indexModule) { dlsFlsBaseContext ) ); + + // TODO: Is there a higher level approach that makes more sense here? Does this cover unsuccessful index ops? + if (ConfigConstants.OPENSEARCH_API_TOKENS_INDEX.equals(indexModule.getIndex().getName())) { + ApiTokenIndexListenerCache apiTokenIndexListenerCacher = ApiTokenIndexListenerCache.getInstance(); + apiTokenIndexListenerCacher.initialize(); + indexModule.addIndexOperationListener(apiTokenIndexListenerCacher); + log.warn("Security plugin started listening to operations on index {}", ConfigConstants.OPENSEARCH_API_TOKENS_INDEX); + } + indexModule.forceQueryCacheProvider((indexSettings, nodeCache) -> new QueryCache() { @Override diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexListenerCache.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexListenerCache.java new file mode 100644 index 0000000000..68ec995c60 --- /dev/null +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexListenerCache.java @@ -0,0 +1,112 @@ +/* + * 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.action.apitokens; + +import java.io.IOException; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.common.xcontent.LoggingDeprecationHandler; +import org.opensearch.common.xcontent.XContentType; +import org.opensearch.core.common.bytes.BytesReference; +import org.opensearch.core.index.shard.ShardId; +import org.opensearch.core.xcontent.NamedXContentRegistry; +import org.opensearch.core.xcontent.XContentParser; +import org.opensearch.index.engine.Engine; +import org.opensearch.index.shard.IndexingOperationListener; + +/** + * This class implements an index operation listener for operations performed on api tokens + * These indices are defined on bootstrap and configured to listen in OpenSearchSecurityPlugin.java + */ +public class ApiTokenIndexListenerCache implements IndexingOperationListener { + + private final static Logger log = LogManager.getLogger(ApiTokenIndexListenerCache.class); + + private static final ApiTokenIndexListenerCache INSTANCE = new ApiTokenIndexListenerCache(); + private final ConcurrentHashMap idToJtiMap = new ConcurrentHashMap<>(); + + private Set jtis = new HashSet(); + + private boolean initialized; + + private ApiTokenIndexListenerCache() {} + + public static ApiTokenIndexListenerCache getInstance() { + return ApiTokenIndexListenerCache.INSTANCE; + } + + /** + * Initializes the ApiTokenIndexListenerCache. + * This method is called during the plugin's initialization process. + * + */ + public void initialize() { + + if (initialized) { + return; + } + + initialized = true; + + } + + public boolean isInitialized() { + return initialized; + } + + /** + * This method is called after an index operation is performed. + * It adds the JTI of the indexed document to the cache and maps the document ID to the JTI (for deletion handling). + * @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) { + BytesReference sourceRef = index.source(); + + try { + XContentParser parser = XContentType.JSON.xContent() + .createParser(NamedXContentRegistry.EMPTY, LoggingDeprecationHandler.INSTANCE, sourceRef.streamInput()); + + ApiToken token = ApiToken.fromXContent(parser); + jtis.add(token.getJti()); + idToJtiMap.put(index.id(), token.getJti()); + + } catch (IOException e) { + log.error("Failed to parse indexed document", e); + } + } + + /** + * This method is called after a delete operation is performed. + * It deletes the corresponding document id in the map and the corresponding jti from the cache. + * @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 docId = delete.id(); + String jti = idToJtiMap.remove(docId); + if (jti != null) { + jtis.remove(jti); + log.debug("Removed token with ID {} and JTI {} from cache", docId, jti); + } + } + + public Set getJtis() { + return jtis; + } +} diff --git a/src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java b/src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java index 0d37c51355..cae78a8415 100644 --- a/src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java +++ b/src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java @@ -38,6 +38,7 @@ import org.opensearch.core.xcontent.XContentParser; import org.opensearch.security.DefaultObjectMapper; import org.opensearch.security.action.apitokens.ApiToken; +import org.opensearch.security.action.apitokens.ApiTokenIndexListenerCache; import org.opensearch.security.auth.HTTPAuthenticator; import org.opensearch.security.authtoken.jwt.EncryptionDecryptionUtil; import org.opensearch.security.filter.SecurityRequest; @@ -226,6 +227,7 @@ private AuthCredentials extractCredentials0(final SecurityRequest request, final log.error("Api token authentication is disabled"); return null; } + ApiTokenIndexListenerCache cache = ApiTokenIndexListenerCache.getInstance(); String jwtToken = extractJwtFromHeader(request); if (jwtToken == null) { @@ -236,35 +238,37 @@ private AuthCredentials extractCredentials0(final SecurityRequest request, final return null; } + // TODO: handle revocation different from deletion? + if (!cache.getJtis().contains(encryptionUtil.encrypt(jwtToken))) { + log.debug("Token is not allowlisted"); + return null; + } + try { final Claims claims = jwtParser.parseClaimsJws(jwtToken).getBody(); final String subject = claims.getSubject(); if (subject == null) { - log.error("Valid jwt on behalf of token with no subject"); + log.error("Valid jwt api token with no subject"); return null; } final Set audience = claims.getAudience(); if (audience == null || audience.isEmpty()) { - log.error("Valid jwt on behalf of token with no audience"); + log.error("Valid jwt api token with no audience"); return null; } final String issuer = claims.getIssuer(); if (!clusterName.equals(issuer)) { - log.error("The issuer of this OBO does not match the current cluster identifier"); + log.error("The issuer of this api token does not match the current cluster identifier"); return null; } - log.info("before extraction"); - String clusterPermissions = extractClusterPermissionsFromClaims(claims); String allowedActions = extractAllowedActionsFromClaims(claims); String indices = extractIndicesFromClaims(claims); - log.info(clusterPermissions + allowedActions + indices); - final AuthCredentials ac = new AuthCredentials(subject, List.of(), new String[0]).markComplete(); for (Entry claim : claims.entrySet()) { @@ -333,7 +337,7 @@ public Boolean isRequestAllowed(final SecurityRequest request) { Matcher matcher = PATTERN_PATH_PREFIX.matcher(request.path()); final String suffix = matcher.matches() ? matcher.group(2) : null; if (isAccessToRestrictedEndpoints(request, suffix)) { - final OpenSearchException exception = ExceptionUtils.invalidUsageOfOBOTokenException(); + final OpenSearchException exception = ExceptionUtils.invalidUsageOfApiTokenException(); log.error(exception.toString()); return false; } @@ -347,7 +351,7 @@ public Optional reRequestAuthentication(final SecurityRequest @Override public String getType() { - return "onbehalfof_jwt"; + return "apitoken_jwt"; } @Override diff --git a/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java b/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java index a6e4ff4734..b57b422653 100644 --- a/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java +++ b/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java @@ -386,7 +386,6 @@ private void buildAAA() { */ Settings apiTokenSettings = getDynamicApiTokenSettings(); if (!isKeyNull(apiTokenSettings, "signing_key") && !isKeyNull(apiTokenSettings, "encryption_key")) { - log.info("we initialized the api tokenauthenticator"); final AuthDomain _ad = new AuthDomain( new NoOpAuthenticationBackend(Settings.EMPTY, null), new ApiTokenAuthenticator(getDynamicApiTokenSettings(), this.cih.getClusterName()), diff --git a/src/main/java/org/opensearch/security/ssl/util/ExceptionUtils.java b/src/main/java/org/opensearch/security/ssl/util/ExceptionUtils.java index 4683075f1d..32a70a468f 100644 --- a/src/main/java/org/opensearch/security/ssl/util/ExceptionUtils.java +++ b/src/main/java/org/opensearch/security/ssl/util/ExceptionUtils.java @@ -68,6 +68,10 @@ public static OpenSearchException invalidUsageOfOBOTokenException() { return new OpenSearchException("On-Behalf-Of Token is not allowed to be used for accessing this endpoint."); } + public static OpenSearchException invalidUsageOfApiTokenException() { + return new OpenSearchException("Api Tokens are not allowed to be used for accessing this endpoint."); + } + public static OpenSearchException createJwkCreationException() { return new OpenSearchException("An error occurred during the creation of Jwk."); } diff --git a/src/main/java/org/opensearch/security/util/AuthTokenUtils.java b/src/main/java/org/opensearch/security/util/AuthTokenUtils.java index 3884bf75fe..caccb91407 100644 --- a/src/main/java/org/opensearch/security/util/AuthTokenUtils.java +++ b/src/main/java/org/opensearch/security/util/AuthTokenUtils.java @@ -20,6 +20,7 @@ public class AuthTokenUtils { private static final String ON_BEHALF_OF_SUFFIX = "api/generateonbehalfoftoken"; private static final String ACCOUNT_SUFFIX = "api/account"; + private static final String API_TOKEN_SUFFIX = "api/apitokens"; public static Boolean isAccessToRestrictedEndpoints(final SecurityRequest request, final String suffix) { if (suffix == null) { @@ -28,6 +29,9 @@ public static Boolean isAccessToRestrictedEndpoints(final SecurityRequest reques switch (suffix) { case ON_BEHALF_OF_SUFFIX: return request.method() == POST; + case API_TOKEN_SUFFIX: + // Don't want to allow any api token access + return true; case ACCOUNT_SUFFIX: return request.method() == PUT; default: diff --git a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenAuthenticatorTest.java b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenAuthenticatorTest.java new file mode 100644 index 0000000000..358bf746d2 --- /dev/null +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenAuthenticatorTest.java @@ -0,0 +1,99 @@ +/* + * 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.action.apitokens; + +import org.apache.logging.log4j.Logger; +import org.junit.Before; +import org.junit.Test; + +import org.opensearch.common.settings.Settings; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.security.filter.SecurityRequest; +import org.opensearch.security.http.ApiTokenAuthenticator; +import org.opensearch.security.user.AuthCredentials; + +import org.mockito.Mock; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class ApiTokenAuthenticatorTest { + + private ApiTokenAuthenticator authenticator; + private ApiTokenIndexListenerCache cache; + private String testJti = "test-jti"; + @Mock + private Logger log; + + @Before + public void setUp() { + // Setup basic settings + Settings settings = Settings.builder() + .put("enabled", "true") + .put("signing_key", "U3VwZXJTZWNyZXRLZXlUaGF0SXNFeGFjdGx5NjRCeXRlc0xvbmdBbmRXaWxsV29ya1dpdGhIUzUxMkFsZ29yaXRobSEhCgo=") + .put("encryption_key", "MTIzNDU2Nzg5MDEyMzQ1Ng==") + .build(); + + authenticator = new ApiTokenAuthenticator(settings, "opensearch-cluster"); + cache = ApiTokenIndexListenerCache.getInstance(); + } + + @Test + public void testAuthenticationFailsWhenJtiNotInCache() { + String testJti = "test-jti-not-in-cache"; + ApiTokenIndexListenerCache cache = ApiTokenIndexListenerCache.getInstance(); + assertFalse(cache.getJtis().contains(testJti)); + + SecurityRequest request = mock(SecurityRequest.class); + when(request.header("Authorization")).thenReturn("Bearer " + testJti); + when(request.path()).thenReturn("/test"); + + ThreadContext threadContext = new ThreadContext(Settings.EMPTY); + + AuthCredentials credentials = authenticator.extractCredentials(request, threadContext); + + // It should return null when JTI is not in cache + assertNull("Should return null when JTI is not in allowlist cache", credentials); + } + + @Test + public void testExtractCredentialsPassWhenJtiInCache() { + // Given: A JTI that is in the cache + String testJti = + "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZXN0LXRva2VuIiwiYXVkIjoidGVzdC10b2tlbiIsIm5iZiI6MTczNTMxODI5NywiaXAiOiJnaGlWTXVnWlBtcHZJMjZIT2hmUTRnaEJ1Qkh0Y2x6c1REYVlxQjVBRklyTkE4SzJCVTdxc2toMVBCOEMzQWpTdVBaREM0THVSM2pjZkdpLzlkU2ZicDBuQTNGMkhtSi9jaDA3cDY2ZWJ6OD0iLCJpc3MiOiJvcGVuc2VhcmNoLWNsdXN0ZXIiLCJleHAiOjE3Mzc5MTAyOTcsImlhdCI6MTczNTMxODI5NywiY3AiOiI5T0tzeGhUWmdaZjlTdWpKa0cxV1ViM1QvUVU2eGJmU3Noa25ZZFVIa2hzPSJ9.xdoDZiGBbqaqcH2evoMEV5384oTyRg04_gO3akQpO4c502c8bV8W5TF_5SxUvkXKDeuQEBFH-4c44VVhCnUQIw"; + String encryptedTestJti = + "k3JQNRXR57Y4V4W1LNkpEP+QzcDra5+/fFfQrr0Rncr/RhYAnYEDgN9RKcJ2Wmjf63NCQa9+HjeFcn0H2wKhMm7pl9bd3ya9FO+LUD7t9Sj+IKBsThVo93sUmnxJh/llglMsKsKQVkuY+YKa6A6dT8bMqmt7kIrer7w8TRENr9J8x41TGb/cDDWDvJLME7QkFzJjMxYDgKNiEevMbOpC8yjIZdK08jPe3Thq+xm+JYruoYeyI5g8QjkJA9ZOs1f6eXTAvPxhseuPqgIKykRE25fuWjl5n9tJ9W+jpl+iET7zzOLXSPEU5UepL/COkVd6xW63Ay72oMOewqveDXzyR8S8LAfgRuKgYZWms7yT37XcGg0c6Y7M62KVPo+1XQ+FGLtty3eDKwaSopFqLNcISFMiPml9XYv7V1AndJGINbH4KUDyeSQYUh4d+sOxjg9prGzW0nvKE22jzyQlW9t0wpDiB0visInvKVZAqKLPUp0x0pFbAVV12sJJkw6DFkD6+VL+8d2L/Z8kxJXO3uHHjhO3u3RWAe6UhLGncLhJciH57MEw8zFdNturr+tJREL5WbWyiEzKTOBzO8R5Ec92XyCDshIXzVxQv/QOM5meFxPcrkBAgKa6ztWCCmQqa2M1MdKkwKUGn3w6ixOTZ55nZQ=="; + ApiTokenIndexListenerCache cache = ApiTokenIndexListenerCache.getInstance(); + cache.getJtis().add(encryptedTestJti); + assertTrue(cache.getJtis().contains(encryptedTestJti)); + + // Create a mock request with the JWT token and path + SecurityRequest request = mock(SecurityRequest.class); + when(request.header("Authorization")).thenReturn("Bearer " + testJti); + when(request.path()).thenReturn("/test"); + + // Create ThreadContext + Settings settings = Settings.builder().build(); + ThreadContext threadContext = new ThreadContext(settings); + + AuthCredentials ac = authenticator.extractCredentials(request, threadContext); + + // Verify the exception message if needed + assertNotNull("Should return null when JTI is not in allowlist cache", ac); + + } + +} From 22cfbe87fce8a11394adfbfd4f09e31f8491101a Mon Sep 17 00:00:00 2001 From: Derek Ho Date: Fri, 27 Dec 2024 15:37:02 -0500 Subject: [PATCH 06/23] Add more extensive tests for authenticator, switch to list of indexPermissions Signed-off-by: Derek Ho --- .../security/filter/SecurityRestFilter.java | 3 +- .../security/http/ApiTokenAuthenticator.java | 95 ++----------- .../security/privileges/ActionPrivileges.java | 13 +- .../PrivilegesEvaluationContext.java | 20 +-- .../privileges/PrivilegesEvaluator.java | 20 +-- .../apitokens/ApiTokenAuthenticatorTest.java | 129 +++++++++++++++--- .../authtoken/jwt/AuthTokenUtilsTest.java | 11 ++ 7 files changed, 156 insertions(+), 135 deletions(-) diff --git a/src/main/java/org/opensearch/security/filter/SecurityRestFilter.java b/src/main/java/org/opensearch/security/filter/SecurityRestFilter.java index 04a2489b7b..c214075a42 100644 --- a/src/main/java/org/opensearch/security/filter/SecurityRestFilter.java +++ b/src/main/java/org/opensearch/security/filter/SecurityRestFilter.java @@ -77,8 +77,7 @@ public class SecurityRestFilter { protected final Logger log = LogManager.getLogger(this.getClass()); public static final String API_TOKEN_CLUSTERPERM_KEY = "security.api_token.clusterperm"; - public static final String API_TOKEN_INDEXACTIONS_KEY = "security.api_token.indexactions"; - public static final String API_TOKEN_INDICES_KEY = "security.api_token.indices"; + public static final String API_TOKEN_INDEXPERM_KEY = "security.api_token.indexactions"; private final BackendRegistry registry; private final RestLayerPrivilegesEvaluator evaluator; private final AuditLog auditLog; diff --git a/src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java b/src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java index cae78a8415..3b5715ecf0 100644 --- a/src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java +++ b/src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java @@ -15,11 +15,8 @@ import java.security.AccessController; import java.security.PrivilegedAction; import java.util.ArrayList; -import java.util.Collection; import java.util.List; -import java.util.Map.Entry; import java.util.Optional; -import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -36,7 +33,6 @@ import org.opensearch.core.xcontent.DeprecationHandler; import org.opensearch.core.xcontent.NamedXContentRegistry; import org.opensearch.core.xcontent.XContentParser; -import org.opensearch.security.DefaultObjectMapper; import org.opensearch.security.action.apitokens.ApiToken; import org.opensearch.security.action.apitokens.ApiTokenIndexListenerCache; import org.opensearch.security.auth.HTTPAuthenticator; @@ -55,8 +51,7 @@ import static org.opensearch.security.OpenSearchSecurityPlugin.LEGACY_OPENDISTRO_PREFIX; import static org.opensearch.security.OpenSearchSecurityPlugin.PLUGINS_PREFIX; import static org.opensearch.security.filter.SecurityRestFilter.API_TOKEN_CLUSTERPERM_KEY; -import static org.opensearch.security.filter.SecurityRestFilter.API_TOKEN_INDEXACTIONS_KEY; -import static org.opensearch.security.filter.SecurityRestFilter.API_TOKEN_INDICES_KEY; +import static org.opensearch.security.filter.SecurityRestFilter.API_TOKEN_INDEXPERM_KEY; import static org.opensearch.security.util.AuthTokenUtils.isAccessToRestrictedEndpoints; public class ApiTokenAuthenticator implements HTTPAuthenticator { @@ -65,7 +60,7 @@ public class ApiTokenAuthenticator implements HTTPAuthenticator { private static final String REGEX_PATH_PREFIX = "/(" + LEGACY_OPENDISTRO_PREFIX + "|" + PLUGINS_PREFIX + ")/" + "(.*)"; private static final Pattern PATTERN_PATH_PREFIX = Pattern.compile(REGEX_PATH_PREFIX); - protected final Logger log = LogManager.getLogger(this.getClass()); + public Logger log = LogManager.getLogger(this.getClass()); private static final Pattern BEARER = Pattern.compile("^\\s*Bearer\\s.*", Pattern.CASE_INSENSITIVE); private static final String BEARER_PREFIX = "bearer "; @@ -131,7 +126,7 @@ private String extractClusterPermissionsFromClaims(Claims claims) { return clusterPermissions; } - private String extractAllowedActionsFromClaims(Claims claims) throws IOException { + private List extractIndexPermissionFromClaims(Claims claims) throws IOException { Object ip = claims.get("ip"); if (ip != null) { @@ -150,56 +145,15 @@ private String extractAllowedActionsFromClaims(Claims claims) throws IOException while (parser.nextToken() != XContentParser.Token.END_ARRAY) { permissions.add(ApiToken.IndexPermission.fromXContent(parser)); } - // Get first permission's actions - if (!permissions.isEmpty() && !permissions.get(0).getAllowedActions().isEmpty()) { - return permissions.get(0).getAllowedActions().get(0); - } - - return ""; - } catch (Exception e) { - log.error("Error extracting allowed actions", e); - return ""; - } - - } - - return ""; - } - - private String extractIndicesFromClaims(Claims claims) throws IOException { - Object ip = claims.get("ip"); - - if (ip != null) { - String decryptedPermissions = encryptionUtil.decrypt(ip.toString()); - - try ( - XContentParser parser = XContentType.JSON.xContent() - .createParser(NamedXContentRegistry.EMPTY, DeprecationHandler.THROW_UNSUPPORTED_OPERATION, decryptedPermissions) - ) { - - // Use built-in array parsing - List permissions = new ArrayList<>(); - - // Move to start of array - parser.nextToken(); // START_ARRAY - while (parser.nextToken() != XContentParser.Token.END_ARRAY) { - permissions.add(ApiToken.IndexPermission.fromXContent(parser)); - } - - // Get first permission's actions - if (!permissions.isEmpty() && !permissions.get(0).getIndexPatterns().isEmpty()) { - return permissions.get(0).getIndexPatterns().get(0); - } - - return ""; + return permissions; } catch (Exception e) { - log.error("Error extracting indices", e); - return ""; + log.error("Error extracting index permissions", e); + return List.of(); } } - return ""; + return List.of(); } @Override @@ -253,12 +207,6 @@ private AuthCredentials extractCredentials0(final SecurityRequest request, final return null; } - final Set audience = claims.getAudience(); - if (audience == null || audience.isEmpty()) { - log.error("Valid jwt api token with no audience"); - return null; - } - final String issuer = claims.getIssuer(); if (!clusterName.equals(issuer)) { log.error("The issuer of this api token does not match the current cluster identifier"); @@ -266,33 +214,12 @@ private AuthCredentials extractCredentials0(final SecurityRequest request, final } String clusterPermissions = extractClusterPermissionsFromClaims(claims); - String allowedActions = extractAllowedActionsFromClaims(claims); - String indices = extractIndicesFromClaims(claims); - - final AuthCredentials ac = new AuthCredentials(subject, List.of(), new String[0]).markComplete(); - - for (Entry claim : claims.entrySet()) { - String key = "attr.jwt." + claim.getKey(); - Object value = claim.getValue(); - - if (value instanceof Collection) { - try { - // Convert the list to a JSON array string - String jsonValue = DefaultObjectMapper.writeValueAsString(value, false); - ac.addAttribute(key, jsonValue); - } catch (Exception e) { - log.warn("Failed to convert list claim to JSON for key: " + key, e); - // Fallback to string representation - ac.addAttribute(key, String.valueOf(value)); - } - } else { - ac.addAttribute(key, String.valueOf(value)); - } - } + List indexPermissions = extractIndexPermissionFromClaims(claims); + + final AuthCredentials ac = new AuthCredentials(subject, List.of(), "").markComplete(); context.putTransient(API_TOKEN_CLUSTERPERM_KEY, clusterPermissions); - context.putTransient(API_TOKEN_INDEXACTIONS_KEY, allowedActions); - context.putTransient(API_TOKEN_INDICES_KEY, indices); + context.putTransient(API_TOKEN_INDEXPERM_KEY, indexPermissions); return ac; diff --git a/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java b/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java index 2a0f572f0e..56c798f6f5 100644 --- a/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java +++ b/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java @@ -36,6 +36,7 @@ import org.opensearch.common.settings.Settings; import org.opensearch.core.common.unit.ByteSizeUnit; import org.opensearch.core.common.unit.ByteSizeValue; +import org.opensearch.security.action.apitokens.ApiToken; import org.opensearch.security.resolver.IndexResolverReplacer; import org.opensearch.security.securityconf.FlattenedActionGroups; import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; @@ -164,8 +165,16 @@ public PrivilegesEvaluatorResponse hasIndexPrivilege( // API Token Authz // TODO: this is very naive implementation - if (context.getIndices() != null && new HashSet<>(context.getIndices()).containsAll(resolvedIndices.getAllIndices())) { - if (new HashSet<>(context.getAllowedActions()).containsAll(actions)) { + if (context.getIndexPermissions() != null) { + List indexPermissions = context.getIndexPermissions(); + + boolean hasPermission = indexPermissions.stream().anyMatch(permission -> { + boolean hasAllActions = new HashSet<>(permission.getAllowedActions()).containsAll(actions); + boolean hasAllIndices = new HashSet<>(permission.getIndexPatterns()).containsAll(resolvedIndices.getAllIndices()); + return hasAllActions && hasAllIndices; + }); + + if (hasPermission) { return PrivilegesEvaluatorResponse.ok(); } } diff --git a/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluationContext.java b/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluationContext.java index 65b69f5b53..b41bc366da 100644 --- a/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluationContext.java +++ b/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluationContext.java @@ -21,6 +21,7 @@ import org.opensearch.cluster.ClusterState; import org.opensearch.cluster.metadata.IndexAbstraction; import org.opensearch.cluster.metadata.IndexNameExpressionResolver; +import org.opensearch.security.action.apitokens.ApiToken; import org.opensearch.security.resolver.IndexResolverReplacer; import org.opensearch.security.support.WildcardMatcher; import org.opensearch.security.user.User; @@ -47,8 +48,7 @@ public class PrivilegesEvaluationContext { private final IndexNameExpressionResolver indexNameExpressionResolver; private final Supplier clusterStateSupplier; private List clusterPermissions; - private List allowedActions; - private List indices; + private List indexPermissions; /** * This caches the ready to use WildcardMatcher instances for the current request. Many index patterns have @@ -185,19 +185,11 @@ public List getClusterPermissions() { return clusterPermissions; } - public List getAllowedActions() { - return allowedActions; + public List getIndexPermissions() { + return indexPermissions; } - public void setAllowedActions(List allowedActions) { - this.allowedActions = allowedActions; - } - - public List getIndices() { - return indices; - } - - public void setIndices(List indices) { - this.indices = indices; + public void setIndexPermissions(List indexPermissions) { + this.indexPermissions = indexPermissions; } } diff --git a/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluator.java b/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluator.java index 0871633a25..a5af967861 100644 --- a/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluator.java +++ b/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluator.java @@ -86,6 +86,7 @@ import org.opensearch.core.xcontent.NamedXContentRegistry; import org.opensearch.index.reindex.ReindexAction; import org.opensearch.script.mustache.RenderSearchTemplateAction; +import org.opensearch.security.action.apitokens.ApiToken; import org.opensearch.security.auditlog.AuditLog; import org.opensearch.security.configuration.ClusterInfoHolder; import org.opensearch.security.configuration.ConfigurationRepository; @@ -110,8 +111,7 @@ import static org.opensearch.security.OpenSearchSecurityPlugin.traceAction; import static org.opensearch.security.filter.SecurityRestFilter.API_TOKEN_CLUSTERPERM_KEY; -import static org.opensearch.security.filter.SecurityRestFilter.API_TOKEN_INDEXACTIONS_KEY; -import static org.opensearch.security.filter.SecurityRestFilter.API_TOKEN_INDICES_KEY; +import static org.opensearch.security.filter.SecurityRestFilter.API_TOKEN_INDEXPERM_KEY; import static org.opensearch.security.support.ConfigConstants.OPENDISTRO_SECURITY_USER_INFO_THREAD_CONTEXT; public class PrivilegesEvaluator { @@ -354,21 +354,11 @@ public PrivilegesEvaluatorResponse evaluate(PrivilegesEvaluationContext context) context.setClusterPermissions(clusterPermissions); } - final String apiTokenIndexAllowedActions = threadContext.getTransient(API_TOKEN_INDEXACTIONS_KEY); - if (apiTokenIndexAllowedActions != null) { - List allowedactions = Arrays.asList(apiTokenIndexAllowedActions.split(",")); - context.setAllowedActions(allowedactions); + final List apiTokenIndexPermissions = threadContext.getTransient(API_TOKEN_INDEXPERM_KEY); + if (apiTokenIndexPermissions != null) { + context.setIndexPermissions(apiTokenIndexPermissions); } - final String apiTokenIndices = threadContext.getTransient(API_TOKEN_INDICES_KEY); - if (apiTokenIndices != null) { - List indices = Arrays.asList(apiTokenIndices.split(",")); - context.setIndices(indices); - } - - log.info("API Tokens actions" + apiTokenIndexAllowedActions); - log.info("API Tokens indices" + apiTokenIndices); - // Add the security roles for this user so that they can be used for DLS parameter substitution. user.addSecurityRoles(mappedRoles); setUserInfoInThreadContext(user); diff --git a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenAuthenticatorTest.java b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenAuthenticatorTest.java index 358bf746d2..67293e0347 100644 --- a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenAuthenticatorTest.java +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenAuthenticatorTest.java @@ -14,6 +14,7 @@ import org.apache.logging.log4j.Logger; import org.junit.Before; import org.junit.Test; +import org.junit.runner.RunWith; import org.opensearch.common.settings.Settings; import org.opensearch.common.util.concurrent.ThreadContext; @@ -21,26 +22,31 @@ import org.opensearch.security.http.ApiTokenAuthenticator; import org.opensearch.security.user.AuthCredentials; +import io.jsonwebtoken.ExpiredJwtException; import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +@RunWith(MockitoJUnitRunner.class) public class ApiTokenAuthenticatorTest { private ApiTokenAuthenticator authenticator; - private ApiTokenIndexListenerCache cache; - private String testJti = "test-jti"; @Mock private Logger log; + private ThreadContext threadcontext; + @Before public void setUp() { - // Setup basic settings Settings settings = Settings.builder() .put("enabled", "true") .put("signing_key", "U3VwZXJTZWNyZXRLZXlUaGF0SXNFeGFjdGx5NjRCeXRlc0xvbmdBbmRXaWxsV29ya1dpdGhIUzUxMkFsZ29yaXRobSEhCgo=") @@ -48,7 +54,9 @@ public void setUp() { .build(); authenticator = new ApiTokenAuthenticator(settings, "opensearch-cluster"); - cache = ApiTokenIndexListenerCache.getInstance(); + authenticator.log = log; + when(log.isDebugEnabled()).thenReturn(true); + threadcontext = new ThreadContext(Settings.EMPTY); } @Test @@ -61,39 +69,124 @@ public void testAuthenticationFailsWhenJtiNotInCache() { when(request.header("Authorization")).thenReturn("Bearer " + testJti); when(request.path()).thenReturn("/test"); - ThreadContext threadContext = new ThreadContext(Settings.EMPTY); - - AuthCredentials credentials = authenticator.extractCredentials(request, threadContext); + AuthCredentials credentials = authenticator.extractCredentials(request, threadcontext); - // It should return null when JTI is not in cache assertNull("Should return null when JTI is not in allowlist cache", credentials); } @Test public void testExtractCredentialsPassWhenJtiInCache() { - // Given: A JTI that is in the cache String testJti = - "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZXN0LXRva2VuIiwiYXVkIjoidGVzdC10b2tlbiIsIm5iZiI6MTczNTMxODI5NywiaXAiOiJnaGlWTXVnWlBtcHZJMjZIT2hmUTRnaEJ1Qkh0Y2x6c1REYVlxQjVBRklyTkE4SzJCVTdxc2toMVBCOEMzQWpTdVBaREM0THVSM2pjZkdpLzlkU2ZicDBuQTNGMkhtSi9jaDA3cDY2ZWJ6OD0iLCJpc3MiOiJvcGVuc2VhcmNoLWNsdXN0ZXIiLCJleHAiOjE3Mzc5MTAyOTcsImlhdCI6MTczNTMxODI5NywiY3AiOiI5T0tzeGhUWmdaZjlTdWpKa0cxV1ViM1QvUVU2eGJmU3Noa25ZZFVIa2hzPSJ9.xdoDZiGBbqaqcH2evoMEV5384oTyRg04_gO3akQpO4c502c8bV8W5TF_5SxUvkXKDeuQEBFH-4c44VVhCnUQIw"; + "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZXN0LXRva2VuIiwiYXVkIjoidGVzdC10b2tlbiIsIm5iZiI6MTczNTMyNjM0NywiaXAiOiJnaGlWTXVnWlBtcHZJMjZIT2hmUTRnaEJ1Qkh0Y2x6c1REYVlxQjVBRklyTkE4SzJCVTdxc2toMVBCOEMzQWpTdVBaREM0THVSM2pjZkdpLzlkU2ZicDBuQTNGMkhtSi9jaDA3cDY2ZWJ6OD0iLCJpc3MiOiJvcGVuc2VhcmNoLWNsdXN0ZXIiLCJleHAiOjIyMTg3NjU2NDMzMCwiaWF0IjoxNzM1MzI2MzQ3LCJjcCI6IjlPS3N4aFRaZ1pmOVN1akprRzFXVWIzVC9RVTZ4YmZTc2hrbllkVUhraHM9In0.kqMSnn5YwhLmeiI_8iIBQ5uhmI52n2MNniAa52Zpfs3TiE_PXKiNbDNs08hNqzGYW772gT7lfvp6kZnFxQ4v2Q"; String encryptedTestJti = - "k3JQNRXR57Y4V4W1LNkpEP+QzcDra5+/fFfQrr0Rncr/RhYAnYEDgN9RKcJ2Wmjf63NCQa9+HjeFcn0H2wKhMm7pl9bd3ya9FO+LUD7t9Sj+IKBsThVo93sUmnxJh/llglMsKsKQVkuY+YKa6A6dT8bMqmt7kIrer7w8TRENr9J8x41TGb/cDDWDvJLME7QkFzJjMxYDgKNiEevMbOpC8yjIZdK08jPe3Thq+xm+JYruoYeyI5g8QjkJA9ZOs1f6eXTAvPxhseuPqgIKykRE25fuWjl5n9tJ9W+jpl+iET7zzOLXSPEU5UepL/COkVd6xW63Ay72oMOewqveDXzyR8S8LAfgRuKgYZWms7yT37XcGg0c6Y7M62KVPo+1XQ+FGLtty3eDKwaSopFqLNcISFMiPml9XYv7V1AndJGINbH4KUDyeSQYUh4d+sOxjg9prGzW0nvKE22jzyQlW9t0wpDiB0visInvKVZAqKLPUp0x0pFbAVV12sJJkw6DFkD6+VL+8d2L/Z8kxJXO3uHHjhO3u3RWAe6UhLGncLhJciH57MEw8zFdNturr+tJREL5WbWyiEzKTOBzO8R5Ec92XyCDshIXzVxQv/QOM5meFxPcrkBAgKa6ztWCCmQqa2M1MdKkwKUGn3w6ixOTZ55nZQ=="; + "k3JQNRXR57Y4V4W1LNkpEP+QzcDra5+/fFfQrr0Rncr/RhYAnYEDgN9RKcJ2Wmjf63NCQa9+HjeFcn0H2wKhMm7pl9bd3ya9FO+LUD7t9Sih4DOjUt0t7ee4ROC0eRK5glMsKsKQVkuY+YKa6A6dT8bMqmt7kIrer7w8TRENr9J8x41TGb/cDDWDvJLME7QkFzJjMxYDgKNiEevMbOpC8yjIZdK08jPe3Thq+xm+JYruoYeyI5g8QjkJA9ZOs1f6eXTAvPxhseuPqgIKykRE25fuWjl5n9tJ9W+jpl+iET7zzOLXSPEU5UepL/COkVd6xW63Ay72oMOewqveDXzyR8S8LAfgRuKgYZWms7yT37XcGg0c6Y7M62KVPo+1XQ+F+K5bgddkd8G+I9KHf561jIMzBcIodgGRj659954W16D1C92+PF/YWPQoTv2hVK4f60H82ga1YSiz3r9UrFV8d7gLJwtyJT9HNPuXO2VZ7xPhre+n1Wv7No0kH2S/r3nqKK6Bk/kn1ZbAmjLxuw13c95lIir6avlKE7XX4PiQDfcGeAyeXOw/36kLW8wH7kjXWdBspld1AiI4fCOaszNXF+7gcuTxIhECl+mEyrJbMI88EWllq+LbydiOrVLFXXRMiCbvj+VTYjzimgJPp+Vuvg=="; ApiTokenIndexListenerCache cache = ApiTokenIndexListenerCache.getInstance(); cache.getJtis().add(encryptedTestJti); assertTrue(cache.getJtis().contains(encryptedTestJti)); - // Create a mock request with the JWT token and path SecurityRequest request = mock(SecurityRequest.class); when(request.header("Authorization")).thenReturn("Bearer " + testJti); when(request.path()).thenReturn("/test"); - // Create ThreadContext - Settings settings = Settings.builder().build(); - ThreadContext threadContext = new ThreadContext(settings); + AuthCredentials ac = authenticator.extractCredentials(request, threadcontext); - AuthCredentials ac = authenticator.extractCredentials(request, threadContext); + assertNotNull("Should not be null when JTI is in allowlist cache", ac); + } + + @Test + public void testExtractCredentialsFailWhenTokenIsExpired() { + String testJti = + "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZXN0LXRva2VuIiwiYXVkIjoidGVzdC10b2tlbiIsIm5iZiI6MTczNTMyNjU4MiwiaXAiOiJnaGlWTXVnWlBtcHZJMjZIT2hmUTRnaEJ1Qkh0Y2x6c1REYVlxQjVBRklyTkE4SzJCVTdxc2toMVBCOEMzQWpTdVBaREM0THVSM2pjZkdpLzlkU2ZicDBuQTNGMkhtSi9jaDA3cDY2ZWJ6OD0iLCJpc3MiOiJvcGVuc2VhcmNoLWNsdXN0ZXIiLCJleHAiOjI5MDI5NDksImlhdCI6MTczNTMyNjU4MiwiY3AiOiI5T0tzeGhUWmdaZjlTdWpKa0cxV1ViM1QvUVU2eGJmU3Noa25ZZFVIa2hzPSJ9.-f45IAU4jE8EbDuthsPFm-TxtJCk8Q_uRmnG4sEkfLtjmp8mHUbSaS109YRGxKDVr3uEMgFwvkSKEFt7DHhf9A"; + String encryptedTestJti = + "k3JQNRXR57Y4V4W1LNkpEP+QzcDra5+/fFfQrr0Rncr/RhYAnYEDgN9RKcJ2Wmjf63NCQa9+HjeFcn0H2wKhMm7pl9bd3ya9FO+LUD7t9ShsbOyBUkpFSVuQwrXLatY+glMsKsKQVkuY+YKa6A6dT8bMqmt7kIrer7w8TRENr9J8x41TGb/cDDWDvJLME7QkFzJjMxYDgKNiEevMbOpC8yjIZdK08jPe3Thq+xm+JYruoYeyI5g8QjkJA9ZOs1f6eXTAvPxhseuPqgIKykRE25fuWjl5n9tJ9W+jpl+iET7zzOLXSPEU5UepL/COkVd6xW63Ay72oMOewqveDXzyR8S8LAfgRuKgYZWms7yT37XcGg0c6Y7M62KVPo+1XQ+Fu193YtvS4vqt9G8jHiq51VCRxNHYVlAsratxzvECD8AKBilR9/7dUKyOQDBIzPG4ws+kgI680SgdMgGuLANQPGzal9US8GsWzTbQWCgtObaSVKB02U4gh16wvy3XrXtPz2Z0ZAxoU2Z8opX8hcvB5MG5UUEf+tpgTtVPcbuJyCL42yD3FIc3v/LCYlG/hFvflXBx5c1r+4Tij8Qc/NkYb7/03xiJsVH6eduSqR9M0QBpLm7xg2TgqVMvC/+n96x2V3lS4via4lAK6xuYeRY0ng=="; + ApiTokenIndexListenerCache cache = ApiTokenIndexListenerCache.getInstance(); + cache.getJtis().add(encryptedTestJti); + assertTrue(cache.getJtis().contains(encryptedTestJti)); + + SecurityRequest request = mock(SecurityRequest.class); + when(request.header("Authorization")).thenReturn("Bearer " + testJti); + when(request.path()).thenReturn("/test"); + + AuthCredentials ac = authenticator.extractCredentials(request, threadcontext); + + assertNull("Should return null when JTI is expired", ac); + verify(log).debug(eq("Invalid or expired JWT token."), any(ExpiredJwtException.class)); + + } + + @Test + public void testExtractCredentialsFailWhenIssuerDoesNotMatch() { + String testJti = + "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZXN0LXRva2VuIiwiYXVkIjoidGVzdC10b2tlbiIsIm5iZiI6MTczNTMyNjM0NywiaXAiOiJnaGlWTXVnWlBtcHZJMjZIT2hmUTRnaEJ1Qkh0Y2x6c1REYVlxQjVBRklyTkE4SzJCVTdxc2toMVBCOEMzQWpTdVBaREM0THVSM2pjZkdpLzlkU2ZicDBuQTNGMkhtSi9jaDA3cDY2ZWJ6OD0iLCJpc3MiOiJvcGVuc2VhcmNoLWNsdXN0ZXIiLCJleHAiOjIyMTg3NjU2NDMzMCwiaWF0IjoxNzM1MzI2MzQ3LCJjcCI6IjlPS3N4aFRaZ1pmOVN1akprRzFXVWIzVC9RVTZ4YmZTc2hrbllkVUhraHM9In0.kqMSnn5YwhLmeiI_8iIBQ5uhmI52n2MNniAa52Zpfs3TiE_PXKiNbDNs08hNqzGYW772gT7lfvp6kZnFxQ4v2Q"; + String encryptedTestJti = + "k3JQNRXR57Y4V4W1LNkpEP+QzcDra5+/fFfQrr0Rncr/RhYAnYEDgN9RKcJ2Wmjf63NCQa9+HjeFcn0H2wKhMm7pl9bd3ya9FO+LUD7t9Sih4DOjUt0t7ee4ROC0eRK5glMsKsKQVkuY+YKa6A6dT8bMqmt7kIrer7w8TRENr9J8x41TGb/cDDWDvJLME7QkFzJjMxYDgKNiEevMbOpC8yjIZdK08jPe3Thq+xm+JYruoYeyI5g8QjkJA9ZOs1f6eXTAvPxhseuPqgIKykRE25fuWjl5n9tJ9W+jpl+iET7zzOLXSPEU5UepL/COkVd6xW63Ay72oMOewqveDXzyR8S8LAfgRuKgYZWms7yT37XcGg0c6Y7M62KVPo+1XQ+F+K5bgddkd8G+I9KHf561jIMzBcIodgGRj659954W16D1C92+PF/YWPQoTv2hVK4f60H82ga1YSiz3r9UrFV8d7gLJwtyJT9HNPuXO2VZ7xPhre+n1Wv7No0kH2S/r3nqKK6Bk/kn1ZbAmjLxuw13c95lIir6avlKE7XX4PiQDfcGeAyeXOw/36kLW8wH7kjXWdBspld1AiI4fCOaszNXF+7gcuTxIhECl+mEyrJbMI88EWllq+LbydiOrVLFXXRMiCbvj+VTYjzimgJPp+Vuvg=="; + ApiTokenIndexListenerCache cache = ApiTokenIndexListenerCache.getInstance(); + cache.getJtis().add(encryptedTestJti); + assertTrue(cache.getJtis().contains(encryptedTestJti)); + + SecurityRequest request = mock(SecurityRequest.class); + when(request.header("Authorization")).thenReturn("Bearer " + testJti); + when(request.path()).thenReturn("/test"); + + Settings settings = Settings.builder() + .put("enabled", "true") + .put("signing_key", "U3VwZXJTZWNyZXRLZXlUaGF0SXNFeGFjdGx5NjRCeXRlc0xvbmdBbmRXaWxsV29ya1dpdGhIUzUxMkFsZ29yaXRobSEhCgo=") + .put("encryption_key", "MTIzNDU2Nzg5MDEyMzQ1Ng==") + .build(); + + authenticator = new ApiTokenAuthenticator(settings, "opensearch-cluster-name-mismatch"); + authenticator.log = log; + + AuthCredentials ac = authenticator.extractCredentials(request, threadcontext); + + assertNull("Should return null when issuer does not match cluster", ac); + verify(log).error(eq("The issuer of this api token does not match the current cluster identifier")); + } + + @Test + public void testExtractCredentialsFailWhenAccessingRestrictedEndpoint() { + String testJti = + "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZXN0LXRva2VuIiwiYXVkIjoidGVzdC10b2tlbiIsIm5iZiI6MTczNTMyNjM0NywiaXAiOiJnaGlWTXVnWlBtcHZJMjZIT2hmUTRnaEJ1Qkh0Y2x6c1REYVlxQjVBRklyTkE4SzJCVTdxc2toMVBCOEMzQWpTdVBaREM0THVSM2pjZkdpLzlkU2ZicDBuQTNGMkhtSi9jaDA3cDY2ZWJ6OD0iLCJpc3MiOiJvcGVuc2VhcmNoLWNsdXN0ZXIiLCJleHAiOjIyMTg3NjU2NDMzMCwiaWF0IjoxNzM1MzI2MzQ3LCJjcCI6IjlPS3N4aFRaZ1pmOVN1akprRzFXVWIzVC9RVTZ4YmZTc2hrbllkVUhraHM9In0.kqMSnn5YwhLmeiI_8iIBQ5uhmI52n2MNniAa52Zpfs3TiE_PXKiNbDNs08hNqzGYW772gT7lfvp6kZnFxQ4v2Q"; + String encryptedTestJti = + "k3JQNRXR57Y4V4W1LNkpEP+QzcDra5+/fFfQrr0Rncr/RhYAnYEDgN9RKcJ2Wmjf63NCQa9+HjeFcn0H2wKhMm7pl9bd3ya9FO+LUD7t9Sih4DOjUt0t7ee4ROC0eRK5glMsKsKQVkuY+YKa6A6dT8bMqmt7kIrer7w8TRENr9J8x41TGb/cDDWDvJLME7QkFzJjMxYDgKNiEevMbOpC8yjIZdK08jPe3Thq+xm+JYruoYeyI5g8QjkJA9ZOs1f6eXTAvPxhseuPqgIKykRE25fuWjl5n9tJ9W+jpl+iET7zzOLXSPEU5UepL/COkVd6xW63Ay72oMOewqveDXzyR8S8LAfgRuKgYZWms7yT37XcGg0c6Y7M62KVPo+1XQ+F+K5bgddkd8G+I9KHf561jIMzBcIodgGRj659954W16D1C92+PF/YWPQoTv2hVK4f60H82ga1YSiz3r9UrFV8d7gLJwtyJT9HNPuXO2VZ7xPhre+n1Wv7No0kH2S/r3nqKK6Bk/kn1ZbAmjLxuw13c95lIir6avlKE7XX4PiQDfcGeAyeXOw/36kLW8wH7kjXWdBspld1AiI4fCOaszNXF+7gcuTxIhECl+mEyrJbMI88EWllq+LbydiOrVLFXXRMiCbvj+VTYjzimgJPp+Vuvg=="; + ApiTokenIndexListenerCache cache = ApiTokenIndexListenerCache.getInstance(); + cache.getJtis().add(encryptedTestJti); + assertTrue(cache.getJtis().contains(encryptedTestJti)); - // Verify the exception message if needed - assertNotNull("Should return null when JTI is not in allowlist cache", ac); + SecurityRequest request = mock(SecurityRequest.class); + when(request.header("Authorization")).thenReturn("Bearer " + testJti); + when(request.path()).thenReturn("/_plugins/_security/api/apitokens"); + + AuthCredentials ac = authenticator.extractCredentials(request, threadcontext); + + assertNull("Should return null when JTI is being used to access restricted endpoint", ac); + verify(log).error("OpenSearchException[Api Tokens are not allowed to be used for accessing this endpoint.]"); } + @Test + public void testAuthenticatorNotEnabled() { + String encryptedTestJti = + "k3JQNRXR57Y4V4W1LNkpEP+QzcDra5+/fFfQrr0Rncr/RhYAnYEDgN9RKcJ2Wmjf63NCQa9+HjeFcn0H2wKhMm7pl9bd3ya9FO+LUD7t9Sih4DOjUt0t7ee4ROC0eRK5glMsKsKQVkuY+YKa6A6dT8bMqmt7kIrer7w8TRENr9J8x41TGb/cDDWDvJLME7QkFzJjMxYDgKNiEevMbOpC8yjIZdK08jPe3Thq+xm+JYruoYeyI5g8QjkJA9ZOs1f6eXTAvPxhseuPqgIKykRE25fuWjl5n9tJ9W+jpl+iET7zzOLXSPEU5UepL/COkVd6xW63Ay72oMOewqveDXzyR8S8LAfgRuKgYZWms7yT37XcGg0c6Y7M62KVPo+1XQ+F+K5bgddkd8G+I9KHf561jIMzBcIodgGRj659954W16D1C92+PF/YWPQoTv2hVK4f60H82ga1YSiz3r9UrFV8d7gLJwtyJT9HNPuXO2VZ7xPhre+n1Wv7No0kH2S/r3nqKK6Bk/kn1ZbAmjLxuw13c95lIir6avlKE7XX4PiQDfcGeAyeXOw/36kLW8wH7kjXWdBspld1AiI4fCOaszNXF+7gcuTxIhECl+mEyrJbMI88EWllq+LbydiOrVLFXXRMiCbvj+VTYjzimgJPp+Vuvg=="; + ApiTokenIndexListenerCache cache = ApiTokenIndexListenerCache.getInstance(); + cache.getJtis().add(encryptedTestJti); + assertTrue(cache.getJtis().contains(encryptedTestJti)); + + SecurityRequest request = mock(SecurityRequest.class); + + Settings settings = Settings.builder() + .put("enabled", "false") + .put("signing_key", "U3VwZXJTZWNyZXRLZXlUaGF0SXNFeGFjdGx5NjRCeXRlc0xvbmdBbmRXaWxsV29ya1dpdGhIUzUxMkFsZ29yaXRobSEhCgo=") + .put("encryption_key", "MTIzNDU2Nzg5MDEyMzQ1Ng==") + .build(); + ThreadContext threadContext = new ThreadContext(settings); + + authenticator = new ApiTokenAuthenticator(settings, "opensearch-cluster-name-mismatch"); + authenticator.log = log; + + AuthCredentials ac = authenticator.extractCredentials(request, threadContext); + + assertNull("Should return null when api tokens auth is not enabled", ac); + verify(log).error(eq("Api token authentication is disabled")); + } } diff --git a/src/test/java/org/opensearch/security/authtoken/jwt/AuthTokenUtilsTest.java b/src/test/java/org/opensearch/security/authtoken/jwt/AuthTokenUtilsTest.java index e0026155de..2ab7b9da8e 100644 --- a/src/test/java/org/opensearch/security/authtoken/jwt/AuthTokenUtilsTest.java +++ b/src/test/java/org/opensearch/security/authtoken/jwt/AuthTokenUtilsTest.java @@ -27,6 +27,17 @@ public class AuthTokenUtilsTest { + @Test + public void testIsAccessToRestrictedEndpointsForApiToken() { + NamedXContentRegistry namedXContentRegistry = new NamedXContentRegistry(Collections.emptyList()); + + FakeRestRequest request = new FakeRestRequest.Builder(namedXContentRegistry).withPath("/api/apitokens") + .withMethod(RestRequest.Method.POST) + .build(); + + assertTrue(AuthTokenUtils.isAccessToRestrictedEndpoints(SecurityRequestFactory.from(request), "api/generateonbehalfoftoken")); + } + @Test public void testIsAccessToRestrictedEndpointsForOnBehalfOfToken() { NamedXContentRegistry namedXContentRegistry = new NamedXContentRegistry(Collections.emptyList()); From 665b9e916a3b43561f3ec2111040e7c1aa6e0b78 Mon Sep 17 00:00:00 2001 From: Derek Ho Date: Mon, 30 Dec 2024 15:17:54 -0500 Subject: [PATCH 07/23] Directly store permissions in the cache Signed-off-by: Derek Ho --- .../apitokens/ApiTokenIndexListenerCache.java | 10 +-- .../action/apitokens/Permissions.java | 40 +++++++++++ .../security/filter/SecurityRestFilter.java | 3 - .../security/http/ApiTokenAuthenticator.java | 69 ++----------------- .../security/privileges/ActionPrivileges.java | 54 +++++++++++---- .../PrivilegesEvaluationContext.java | 23 ++----- .../privileges/PrivilegesEvaluator.java | 17 ----- .../apitokens/ApiTokenAuthenticatorTest.java | 22 +++--- 8 files changed, 106 insertions(+), 132 deletions(-) create mode 100644 src/main/java/org/opensearch/security/action/apitokens/Permissions.java diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexListenerCache.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexListenerCache.java index 68ec995c60..8b87f2fa03 100644 --- a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexListenerCache.java +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexListenerCache.java @@ -9,8 +9,7 @@ package org.opensearch.security.action.apitokens; import java.io.IOException; -import java.util.HashSet; -import java.util.Set; +import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import org.apache.logging.log4j.LogManager; @@ -36,7 +35,7 @@ public class ApiTokenIndexListenerCache implements IndexingOperationListener { private static final ApiTokenIndexListenerCache INSTANCE = new ApiTokenIndexListenerCache(); private final ConcurrentHashMap idToJtiMap = new ConcurrentHashMap<>(); - private Set jtis = new HashSet(); + private Map jtis = new ConcurrentHashMap<>(); private boolean initialized; @@ -81,7 +80,7 @@ public void postIndex(ShardId shardId, Engine.Index index, Engine.IndexResult re .createParser(NamedXContentRegistry.EMPTY, LoggingDeprecationHandler.INSTANCE, sourceRef.streamInput()); ApiToken token = ApiToken.fromXContent(parser); - jtis.add(token.getJti()); + jtis.put(token.getJti(), new Permissions(token.getClusterPermissions(), token.getIndexPermissions())); idToJtiMap.put(index.id(), token.getJti()); } catch (IOException e) { @@ -106,7 +105,8 @@ public void postDelete(ShardId shardId, Engine.Delete delete, Engine.DeleteResul } } - public Set getJtis() { + public Map getJtis() { return jtis; } + } diff --git a/src/main/java/org/opensearch/security/action/apitokens/Permissions.java b/src/main/java/org/opensearch/security/action/apitokens/Permissions.java new file mode 100644 index 0000000000..cb1478b9ae --- /dev/null +++ b/src/main/java/org/opensearch/security/action/apitokens/Permissions.java @@ -0,0 +1,40 @@ +/* + * 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.action.apitokens; + +import java.util.List; + +public class Permissions { + private List clusterPerm; + private List indexPermission; + + // Constructor + public Permissions(List clusterPerm, List indexPermission) { + this.clusterPerm = clusterPerm; + this.indexPermission = indexPermission; + } + + // Getters and setters + public List getClusterPerm() { + return clusterPerm; + } + + public void setClusterPerm(List clusterPerm) { + this.clusterPerm = clusterPerm; + } + + public List getIndexPermission() { + return indexPermission; + } + + public void setIndexPermission(List indexPermission) { + this.indexPermission = indexPermission; + } + +} diff --git a/src/main/java/org/opensearch/security/filter/SecurityRestFilter.java b/src/main/java/org/opensearch/security/filter/SecurityRestFilter.java index c214075a42..c9d10ee2fa 100644 --- a/src/main/java/org/opensearch/security/filter/SecurityRestFilter.java +++ b/src/main/java/org/opensearch/security/filter/SecurityRestFilter.java @@ -76,8 +76,6 @@ public class SecurityRestFilter { protected final Logger log = LogManager.getLogger(this.getClass()); - public static final String API_TOKEN_CLUSTERPERM_KEY = "security.api_token.clusterperm"; - public static final String API_TOKEN_INDEXPERM_KEY = "security.api_token.indexactions"; private final BackendRegistry registry; private final RestLayerPrivilegesEvaluator evaluator; private final AuditLog auditLog; @@ -234,7 +232,6 @@ void authorizeRequest(RestHandler original, SecurityRequestChannel request, User .addAll(route.actionNames() != null ? route.actionNames() : Collections.emptySet()) .add(route.name()) .build(); - pres = evaluator.evaluate(user, route.name(), actionNames); if (log.isDebugEnabled()) { diff --git a/src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java b/src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java index 3b5715ecf0..61ba2cd8e3 100644 --- a/src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java +++ b/src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java @@ -11,10 +11,8 @@ package org.opensearch.security.http; -import java.io.IOException; import java.security.AccessController; import java.security.PrivilegedAction; -import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.regex.Matcher; @@ -29,11 +27,6 @@ import org.opensearch.SpecialPermission; import org.opensearch.common.settings.Settings; import org.opensearch.common.util.concurrent.ThreadContext; -import org.opensearch.common.xcontent.XContentType; -import org.opensearch.core.xcontent.DeprecationHandler; -import org.opensearch.core.xcontent.NamedXContentRegistry; -import org.opensearch.core.xcontent.XContentParser; -import org.opensearch.security.action.apitokens.ApiToken; import org.opensearch.security.action.apitokens.ApiTokenIndexListenerCache; import org.opensearch.security.auth.HTTPAuthenticator; import org.opensearch.security.authtoken.jwt.EncryptionDecryptionUtil; @@ -50,8 +43,6 @@ import static org.opensearch.security.OpenSearchSecurityPlugin.LEGACY_OPENDISTRO_PREFIX; import static org.opensearch.security.OpenSearchSecurityPlugin.PLUGINS_PREFIX; -import static org.opensearch.security.filter.SecurityRestFilter.API_TOKEN_CLUSTERPERM_KEY; -import static org.opensearch.security.filter.SecurityRestFilter.API_TOKEN_INDEXPERM_KEY; import static org.opensearch.security.util.AuthTokenUtils.isAccessToRestrictedEndpoints; public class ApiTokenAuthenticator implements HTTPAuthenticator { @@ -113,49 +104,6 @@ private JwtParserBuilder initParserBuilder(final String signingKey) { return jwtParserBuilder; } - private String extractClusterPermissionsFromClaims(Claims claims) { - Object cp = claims.get("cp"); - String clusterPermissions = ""; - - if (cp != null) { - clusterPermissions = encryptionUtil.decrypt(cp.toString()); - } else { - log.warn("This is a malformed Api Token"); - } - - return clusterPermissions; - } - - private List extractIndexPermissionFromClaims(Claims claims) throws IOException { - Object ip = claims.get("ip"); - - if (ip != null) { - String decryptedPermissions = encryptionUtil.decrypt(ip.toString()); - - try ( - XContentParser parser = XContentType.JSON.xContent() - .createParser(NamedXContentRegistry.EMPTY, DeprecationHandler.THROW_UNSUPPORTED_OPERATION, decryptedPermissions) - ) { - - // Use built-in array parsing - List permissions = new ArrayList<>(); - - // Move to start of array - parser.nextToken(); // START_ARRAY - while (parser.nextToken() != XContentParser.Token.END_ARRAY) { - permissions.add(ApiToken.IndexPermission.fromXContent(parser)); - } - return permissions; - } catch (Exception e) { - log.error("Error extracting index permissions", e); - return List.of(); - } - - } - - return List.of(); - } - @Override @SuppressWarnings("removal") public AuthCredentials extractCredentials(final SecurityRequest request, final ThreadContext context) @@ -193,8 +141,8 @@ private AuthCredentials extractCredentials0(final SecurityRequest request, final } // TODO: handle revocation different from deletion? - if (!cache.getJtis().contains(encryptionUtil.encrypt(jwtToken))) { - log.debug("Token is not allowlisted"); + if (!cache.getJtis().containsKey(encryptionUtil.encrypt(jwtToken))) { + log.error("Token is not allowlisted"); return null; } @@ -213,13 +161,8 @@ private AuthCredentials extractCredentials0(final SecurityRequest request, final return null; } - String clusterPermissions = extractClusterPermissionsFromClaims(claims); - List indexPermissions = extractIndexPermissionFromClaims(claims); - - final AuthCredentials ac = new AuthCredentials(subject, List.of(), "").markComplete(); - - context.putTransient(API_TOKEN_CLUSTERPERM_KEY, clusterPermissions); - context.putTransient(API_TOKEN_INDEXPERM_KEY, indexPermissions); + final AuthCredentials ac = new AuthCredentials("apitoken_" + subject + ":" + encryptionUtil.encrypt(jwtToken), List.of(), "") + .markComplete(); return ac; @@ -227,9 +170,7 @@ private AuthCredentials extractCredentials0(final SecurityRequest request, final log.error("Cannot authenticate user with JWT because of ", e); return null; } catch (Exception e) { - if (log.isDebugEnabled()) { - log.debug("Invalid or expired JWT token.", e); - } + log.error("Invalid or expired JWT token.", e); } // Return null for the authentication failure diff --git a/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java b/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java index 56c798f6f5..b9ff9f125e 100644 --- a/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java +++ b/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java @@ -165,17 +165,23 @@ public PrivilegesEvaluatorResponse hasIndexPrivilege( // API Token Authz // TODO: this is very naive implementation - if (context.getIndexPermissions() != null) { - List indexPermissions = context.getIndexPermissions(); - - boolean hasPermission = indexPermissions.stream().anyMatch(permission -> { - boolean hasAllActions = new HashSet<>(permission.getAllowedActions()).containsAll(actions); - boolean hasAllIndices = new HashSet<>(permission.getIndexPatterns()).containsAll(resolvedIndices.getAllIndices()); - return hasAllActions && hasAllIndices; - }); - - if (hasPermission) { - return PrivilegesEvaluatorResponse.ok(); + if (context.getUser().getName().startsWith("apitoken")) { + String jti = context.getUser().getName().split(":")[1]; + if (context.getApiTokenIndexListenerCache().getJtis().get(jti) != null) { + List indexPermissions = context.getApiTokenIndexListenerCache() + .getJtis() + .get(jti) + .getIndexPermission(); + + boolean hasPermission = indexPermissions.stream().anyMatch(permission -> { + boolean hasAllActions = new HashSet<>(permission.getAllowedActions()).containsAll(actions); + boolean hasAllIndices = new HashSet<>(permission.getIndexPatterns()).containsAll(resolvedIndices.getAllIndices()); + return hasAllActions && hasAllIndices; + }); + + if (hasPermission) { + return PrivilegesEvaluatorResponse.ok(); + } } } @@ -426,8 +432,14 @@ PrivilegesEvaluatorResponse providesPrivilege(PrivilegesEvaluationContext contex } // 4: Evaluate api tokens - if (context.getClusterPermissions() != null && context.getClusterPermissions().contains(action)) { - return PrivilegesEvaluatorResponse.ok(); + if (context.getUser().getName().startsWith("apitoken")) { + String jti = context.getUser().getName().split(":")[1]; + log.info(context.getApiTokenIndexListenerCache().getJtis().get(jti).getClusterPerm().toString()); + + if (context.getApiTokenIndexListenerCache().getJtis().get(jti) != null + && context.getApiTokenIndexListenerCache().getJtis().get(jti).getClusterPerm().contains(action)) { + return PrivilegesEvaluatorResponse.ok(); + } } return PrivilegesEvaluatorResponse.insufficient(action); @@ -463,6 +475,14 @@ PrivilegesEvaluatorResponse providesExplicitPrivilege(PrivilegesEvaluationContex } } + if (context.getUser().getName().startsWith("apitoken")) { + String jti = context.getUser().getName().split(":")[1]; + if (context.getApiTokenIndexListenerCache().getJtis().get(jti) != null + && context.getApiTokenIndexListenerCache().getJtis().get(jti).getClusterPerm().contains(action)) { + return PrivilegesEvaluatorResponse.ok(); + } + } + return PrivilegesEvaluatorResponse.insufficient(action); } @@ -499,6 +519,14 @@ PrivilegesEvaluatorResponse providesAnyPrivilege(PrivilegesEvaluationContext con } } + if (context.getUser().getName().startsWith("apitoken")) { + String jti = context.getUser().getName().split(":")[1]; + if (context.getApiTokenIndexListenerCache().getJtis().get(jti) != null + && context.getApiTokenIndexListenerCache().getJtis().get(jti).getClusterPerm().stream().anyMatch(actions::contains)) { + return PrivilegesEvaluatorResponse.ok(); + } + } + if (actions.size() == 1) { return PrivilegesEvaluatorResponse.insufficient(actions.iterator().next()); } else { diff --git a/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluationContext.java b/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluationContext.java index b41bc366da..c0352484da 100644 --- a/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluationContext.java +++ b/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluationContext.java @@ -11,7 +11,6 @@ package org.opensearch.security.privileges; import java.util.HashMap; -import java.util.List; import java.util.Map; import java.util.function.Supplier; @@ -21,7 +20,7 @@ import org.opensearch.cluster.ClusterState; import org.opensearch.cluster.metadata.IndexAbstraction; import org.opensearch.cluster.metadata.IndexNameExpressionResolver; -import org.opensearch.security.action.apitokens.ApiToken; +import org.opensearch.security.action.apitokens.ApiTokenIndexListenerCache; import org.opensearch.security.resolver.IndexResolverReplacer; import org.opensearch.security.support.WildcardMatcher; import org.opensearch.security.user.User; @@ -47,9 +46,7 @@ public class PrivilegesEvaluationContext { private final IndexResolverReplacer indexResolverReplacer; private final IndexNameExpressionResolver indexNameExpressionResolver; private final Supplier clusterStateSupplier; - private List clusterPermissions; - private List indexPermissions; - + private final ApiTokenIndexListenerCache apiTokenIndexListenerCache = ApiTokenIndexListenerCache.getInstance(); /** * This caches the ready to use WildcardMatcher instances for the current request. Many index patterns have * to be executed several times per request (for example first for action privileges, later for DLS). Thus, @@ -177,19 +174,7 @@ public String toString() { + '}'; } - public void setClusterPermissions(List clusterPermissions) { - this.clusterPermissions = clusterPermissions; - } - - public List getClusterPermissions() { - return clusterPermissions; - } - - public List getIndexPermissions() { - return indexPermissions; - } - - public void setIndexPermissions(List indexPermissions) { - this.indexPermissions = indexPermissions; + public ApiTokenIndexListenerCache getApiTokenIndexListenerCache() { + return apiTokenIndexListenerCache; } } diff --git a/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluator.java b/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluator.java index a5af967861..36666972ec 100644 --- a/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluator.java +++ b/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluator.java @@ -86,7 +86,6 @@ import org.opensearch.core.xcontent.NamedXContentRegistry; import org.opensearch.index.reindex.ReindexAction; import org.opensearch.script.mustache.RenderSearchTemplateAction; -import org.opensearch.security.action.apitokens.ApiToken; import org.opensearch.security.auditlog.AuditLog; import org.opensearch.security.configuration.ClusterInfoHolder; import org.opensearch.security.configuration.ConfigurationRepository; @@ -110,8 +109,6 @@ import org.greenrobot.eventbus.Subscribe; import static org.opensearch.security.OpenSearchSecurityPlugin.traceAction; -import static org.opensearch.security.filter.SecurityRestFilter.API_TOKEN_CLUSTERPERM_KEY; -import static org.opensearch.security.filter.SecurityRestFilter.API_TOKEN_INDEXPERM_KEY; import static org.opensearch.security.support.ConfigConstants.OPENDISTRO_SECURITY_USER_INFO_THREAD_CONTEXT; public class PrivilegesEvaluator { @@ -345,20 +342,6 @@ public PrivilegesEvaluatorResponse evaluate(PrivilegesEvaluationContext context) context.setMappedRoles(mappedRoles); } - // Extract cluster and index permissions from the api token thread context - // TODO: Add decryption here to make sure it is not injectable by anyone? - // TODO: This is only a naive implementation that does not support * - final String apiTokenClusterPermissions = threadContext.getTransient(API_TOKEN_CLUSTERPERM_KEY); - if (apiTokenClusterPermissions != null) { - List clusterPermissions = Arrays.asList(apiTokenClusterPermissions.split(",")); - context.setClusterPermissions(clusterPermissions); - } - - final List apiTokenIndexPermissions = threadContext.getTransient(API_TOKEN_INDEXPERM_KEY); - if (apiTokenIndexPermissions != null) { - context.setIndexPermissions(apiTokenIndexPermissions); - } - // Add the security roles for this user so that they can be used for DLS parameter substitution. user.addSecurityRoles(mappedRoles); setUserInfoInThreadContext(user); diff --git a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenAuthenticatorTest.java b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenAuthenticatorTest.java index 67293e0347..93109b49d3 100644 --- a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenAuthenticatorTest.java +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenAuthenticatorTest.java @@ -63,7 +63,7 @@ public void setUp() { public void testAuthenticationFailsWhenJtiNotInCache() { String testJti = "test-jti-not-in-cache"; ApiTokenIndexListenerCache cache = ApiTokenIndexListenerCache.getInstance(); - assertFalse(cache.getJtis().contains(testJti)); + assertFalse(cache.getJtis().containsKey(testJti)); SecurityRequest request = mock(SecurityRequest.class); when(request.header("Authorization")).thenReturn("Bearer " + testJti); @@ -81,8 +81,8 @@ public void testExtractCredentialsPassWhenJtiInCache() { String encryptedTestJti = "k3JQNRXR57Y4V4W1LNkpEP+QzcDra5+/fFfQrr0Rncr/RhYAnYEDgN9RKcJ2Wmjf63NCQa9+HjeFcn0H2wKhMm7pl9bd3ya9FO+LUD7t9Sih4DOjUt0t7ee4ROC0eRK5glMsKsKQVkuY+YKa6A6dT8bMqmt7kIrer7w8TRENr9J8x41TGb/cDDWDvJLME7QkFzJjMxYDgKNiEevMbOpC8yjIZdK08jPe3Thq+xm+JYruoYeyI5g8QjkJA9ZOs1f6eXTAvPxhseuPqgIKykRE25fuWjl5n9tJ9W+jpl+iET7zzOLXSPEU5UepL/COkVd6xW63Ay72oMOewqveDXzyR8S8LAfgRuKgYZWms7yT37XcGg0c6Y7M62KVPo+1XQ+F+K5bgddkd8G+I9KHf561jIMzBcIodgGRj659954W16D1C92+PF/YWPQoTv2hVK4f60H82ga1YSiz3r9UrFV8d7gLJwtyJT9HNPuXO2VZ7xPhre+n1Wv7No0kH2S/r3nqKK6Bk/kn1ZbAmjLxuw13c95lIir6avlKE7XX4PiQDfcGeAyeXOw/36kLW8wH7kjXWdBspld1AiI4fCOaszNXF+7gcuTxIhECl+mEyrJbMI88EWllq+LbydiOrVLFXXRMiCbvj+VTYjzimgJPp+Vuvg=="; ApiTokenIndexListenerCache cache = ApiTokenIndexListenerCache.getInstance(); - cache.getJtis().add(encryptedTestJti); - assertTrue(cache.getJtis().contains(encryptedTestJti)); + cache.getJtis().put(encryptedTestJti, null); + assertTrue(cache.getJtis().containsKey(encryptedTestJti)); SecurityRequest request = mock(SecurityRequest.class); when(request.header("Authorization")).thenReturn("Bearer " + testJti); @@ -100,8 +100,8 @@ public void testExtractCredentialsFailWhenTokenIsExpired() { String encryptedTestJti = "k3JQNRXR57Y4V4W1LNkpEP+QzcDra5+/fFfQrr0Rncr/RhYAnYEDgN9RKcJ2Wmjf63NCQa9+HjeFcn0H2wKhMm7pl9bd3ya9FO+LUD7t9ShsbOyBUkpFSVuQwrXLatY+glMsKsKQVkuY+YKa6A6dT8bMqmt7kIrer7w8TRENr9J8x41TGb/cDDWDvJLME7QkFzJjMxYDgKNiEevMbOpC8yjIZdK08jPe3Thq+xm+JYruoYeyI5g8QjkJA9ZOs1f6eXTAvPxhseuPqgIKykRE25fuWjl5n9tJ9W+jpl+iET7zzOLXSPEU5UepL/COkVd6xW63Ay72oMOewqveDXzyR8S8LAfgRuKgYZWms7yT37XcGg0c6Y7M62KVPo+1XQ+Fu193YtvS4vqt9G8jHiq51VCRxNHYVlAsratxzvECD8AKBilR9/7dUKyOQDBIzPG4ws+kgI680SgdMgGuLANQPGzal9US8GsWzTbQWCgtObaSVKB02U4gh16wvy3XrXtPz2Z0ZAxoU2Z8opX8hcvB5MG5UUEf+tpgTtVPcbuJyCL42yD3FIc3v/LCYlG/hFvflXBx5c1r+4Tij8Qc/NkYb7/03xiJsVH6eduSqR9M0QBpLm7xg2TgqVMvC/+n96x2V3lS4via4lAK6xuYeRY0ng=="; ApiTokenIndexListenerCache cache = ApiTokenIndexListenerCache.getInstance(); - cache.getJtis().add(encryptedTestJti); - assertTrue(cache.getJtis().contains(encryptedTestJti)); + cache.getJtis().put(encryptedTestJti, null); + assertTrue(cache.getJtis().containsKey(encryptedTestJti)); SecurityRequest request = mock(SecurityRequest.class); when(request.header("Authorization")).thenReturn("Bearer " + testJti); @@ -121,8 +121,8 @@ public void testExtractCredentialsFailWhenIssuerDoesNotMatch() { String encryptedTestJti = "k3JQNRXR57Y4V4W1LNkpEP+QzcDra5+/fFfQrr0Rncr/RhYAnYEDgN9RKcJ2Wmjf63NCQa9+HjeFcn0H2wKhMm7pl9bd3ya9FO+LUD7t9Sih4DOjUt0t7ee4ROC0eRK5glMsKsKQVkuY+YKa6A6dT8bMqmt7kIrer7w8TRENr9J8x41TGb/cDDWDvJLME7QkFzJjMxYDgKNiEevMbOpC8yjIZdK08jPe3Thq+xm+JYruoYeyI5g8QjkJA9ZOs1f6eXTAvPxhseuPqgIKykRE25fuWjl5n9tJ9W+jpl+iET7zzOLXSPEU5UepL/COkVd6xW63Ay72oMOewqveDXzyR8S8LAfgRuKgYZWms7yT37XcGg0c6Y7M62KVPo+1XQ+F+K5bgddkd8G+I9KHf561jIMzBcIodgGRj659954W16D1C92+PF/YWPQoTv2hVK4f60H82ga1YSiz3r9UrFV8d7gLJwtyJT9HNPuXO2VZ7xPhre+n1Wv7No0kH2S/r3nqKK6Bk/kn1ZbAmjLxuw13c95lIir6avlKE7XX4PiQDfcGeAyeXOw/36kLW8wH7kjXWdBspld1AiI4fCOaszNXF+7gcuTxIhECl+mEyrJbMI88EWllq+LbydiOrVLFXXRMiCbvj+VTYjzimgJPp+Vuvg=="; ApiTokenIndexListenerCache cache = ApiTokenIndexListenerCache.getInstance(); - cache.getJtis().add(encryptedTestJti); - assertTrue(cache.getJtis().contains(encryptedTestJti)); + cache.getJtis().put(encryptedTestJti, null); + assertTrue(cache.getJtis().containsKey(encryptedTestJti)); SecurityRequest request = mock(SecurityRequest.class); when(request.header("Authorization")).thenReturn("Bearer " + testJti); @@ -150,8 +150,8 @@ public void testExtractCredentialsFailWhenAccessingRestrictedEndpoint() { String encryptedTestJti = "k3JQNRXR57Y4V4W1LNkpEP+QzcDra5+/fFfQrr0Rncr/RhYAnYEDgN9RKcJ2Wmjf63NCQa9+HjeFcn0H2wKhMm7pl9bd3ya9FO+LUD7t9Sih4DOjUt0t7ee4ROC0eRK5glMsKsKQVkuY+YKa6A6dT8bMqmt7kIrer7w8TRENr9J8x41TGb/cDDWDvJLME7QkFzJjMxYDgKNiEevMbOpC8yjIZdK08jPe3Thq+xm+JYruoYeyI5g8QjkJA9ZOs1f6eXTAvPxhseuPqgIKykRE25fuWjl5n9tJ9W+jpl+iET7zzOLXSPEU5UepL/COkVd6xW63Ay72oMOewqveDXzyR8S8LAfgRuKgYZWms7yT37XcGg0c6Y7M62KVPo+1XQ+F+K5bgddkd8G+I9KHf561jIMzBcIodgGRj659954W16D1C92+PF/YWPQoTv2hVK4f60H82ga1YSiz3r9UrFV8d7gLJwtyJT9HNPuXO2VZ7xPhre+n1Wv7No0kH2S/r3nqKK6Bk/kn1ZbAmjLxuw13c95lIir6avlKE7XX4PiQDfcGeAyeXOw/36kLW8wH7kjXWdBspld1AiI4fCOaszNXF+7gcuTxIhECl+mEyrJbMI88EWllq+LbydiOrVLFXXRMiCbvj+VTYjzimgJPp+Vuvg=="; ApiTokenIndexListenerCache cache = ApiTokenIndexListenerCache.getInstance(); - cache.getJtis().add(encryptedTestJti); - assertTrue(cache.getJtis().contains(encryptedTestJti)); + cache.getJtis().put(encryptedTestJti, null); + assertTrue(cache.getJtis().containsKey(encryptedTestJti)); SecurityRequest request = mock(SecurityRequest.class); when(request.header("Authorization")).thenReturn("Bearer " + testJti); @@ -169,8 +169,8 @@ public void testAuthenticatorNotEnabled() { String encryptedTestJti = "k3JQNRXR57Y4V4W1LNkpEP+QzcDra5+/fFfQrr0Rncr/RhYAnYEDgN9RKcJ2Wmjf63NCQa9+HjeFcn0H2wKhMm7pl9bd3ya9FO+LUD7t9Sih4DOjUt0t7ee4ROC0eRK5glMsKsKQVkuY+YKa6A6dT8bMqmt7kIrer7w8TRENr9J8x41TGb/cDDWDvJLME7QkFzJjMxYDgKNiEevMbOpC8yjIZdK08jPe3Thq+xm+JYruoYeyI5g8QjkJA9ZOs1f6eXTAvPxhseuPqgIKykRE25fuWjl5n9tJ9W+jpl+iET7zzOLXSPEU5UepL/COkVd6xW63Ay72oMOewqveDXzyR8S8LAfgRuKgYZWms7yT37XcGg0c6Y7M62KVPo+1XQ+F+K5bgddkd8G+I9KHf561jIMzBcIodgGRj659954W16D1C92+PF/YWPQoTv2hVK4f60H82ga1YSiz3r9UrFV8d7gLJwtyJT9HNPuXO2VZ7xPhre+n1Wv7No0kH2S/r3nqKK6Bk/kn1ZbAmjLxuw13c95lIir6avlKE7XX4PiQDfcGeAyeXOw/36kLW8wH7kjXWdBspld1AiI4fCOaszNXF+7gcuTxIhECl+mEyrJbMI88EWllq+LbydiOrVLFXXRMiCbvj+VTYjzimgJPp+Vuvg=="; ApiTokenIndexListenerCache cache = ApiTokenIndexListenerCache.getInstance(); - cache.getJtis().add(encryptedTestJti); - assertTrue(cache.getJtis().contains(encryptedTestJti)); + cache.getJtis().put(encryptedTestJti, null); + assertTrue(cache.getJtis().containsKey(encryptedTestJti)); SecurityRequest request = mock(SecurityRequest.class); From e39df0d01ee58bbdb0f9238cb7c23acc6494fd07 Mon Sep 17 00:00:00 2001 From: Derek Ho Date: Mon, 30 Dec 2024 15:51:11 -0500 Subject: [PATCH 08/23] Remove permissions from jti Signed-off-by: Derek Ho --- .../action/apitokens/ApiTokenRepository.java | 2 +- .../security/authtoken/jwt/JwtVendor.java | 34 ++---------------- .../security/http/ApiTokenAuthenticator.java | 4 ++- .../identity/SecurityTokenManager.java | 11 ++---- .../apitokens/ApiTokenAuthenticatorTest.java | 12 ++++--- .../apitokens/ApiTokenRepositoryTest.java | 4 +-- .../security/authtoken/jwt/JwtVendorTest.java | 36 +------------------ .../identity/SecurityTokenManagerTest.java | 8 ++--- 8 files changed, 22 insertions(+), 89 deletions(-) diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenRepository.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenRepository.java index ce81aceb4b..be336f3582 100644 --- a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenRepository.java +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenRepository.java @@ -49,7 +49,7 @@ public String createApiToken( ) { apiTokenIndexHandler.createApiTokenIndexIfAbsent(); // TODO: Add validation on whether user is creating a token with a subset of their permissions - ExpiringBearerAuthToken token = securityTokenManager.issueApiToken(name, expiration, clusterPermissions, indexPermissions); + ExpiringBearerAuthToken token = securityTokenManager.issueApiToken(name, expiration); ApiToken apiToken = new ApiToken( name, securityTokenManager.encryptToken(token.getCompleteToken()), diff --git a/src/main/java/org/opensearch/security/authtoken/jwt/JwtVendor.java b/src/main/java/org/opensearch/security/authtoken/jwt/JwtVendor.java index 575bba7964..0c91b3c093 100644 --- a/src/main/java/org/opensearch/security/authtoken/jwt/JwtVendor.java +++ b/src/main/java/org/opensearch/security/authtoken/jwt/JwtVendor.java @@ -11,7 +11,6 @@ package org.opensearch.security.authtoken.jwt; -import java.io.IOException; import java.security.AccessController; import java.security.PrivilegedAction; import java.text.ParseException; @@ -27,10 +26,6 @@ import org.opensearch.OpenSearchException; import org.opensearch.common.collect.Tuple; import org.opensearch.common.settings.Settings; -import org.opensearch.common.xcontent.XContentFactory; -import org.opensearch.core.xcontent.ToXContent; -import org.opensearch.core.xcontent.XContentBuilder; -import org.opensearch.security.action.apitokens.ApiToken; import com.nimbusds.jose.JOSEException; import com.nimbusds.jose.JWSAlgorithm; @@ -157,14 +152,8 @@ public ExpiringBearerAuthToken createJwt( } @SuppressWarnings("removal") - public ExpiringBearerAuthToken createJwt( - final String issuer, - final String subject, - final String audience, - final long expiration, - final List clusterPermissions, - final List indexPermissions - ) throws JOSEException, ParseException, IOException { + public ExpiringBearerAuthToken createJwt(final String issuer, final String subject, final String audience, final long expiration) + throws JOSEException, ParseException { final long currentTimeMs = timeProvider.getAsLong(); final Date now = new Date(currentTimeMs); @@ -178,25 +167,6 @@ public ExpiringBearerAuthToken createJwt( final Date expiryTime = new Date(expiration); claimsBuilder.expirationTime(expiryTime); - if (clusterPermissions != null) { - final String listOfClusterPermissions = String.join(",", clusterPermissions); - claimsBuilder.claim("cp", encryptString(listOfClusterPermissions)); - } - - if (indexPermissions != null) { - XContentBuilder builder = XContentFactory.jsonBuilder(); - builder.startArray(); - for (ApiToken.IndexPermission permission : indexPermissions) { - // Add each permission to the array - permission.toXContent(builder, ToXContent.EMPTY_PARAMS); - } - builder.endArray(); - - // Encrypt the entire JSON array - String jsonArray = builder.toString(); - claimsBuilder.claim("ip", encryptString(jsonArray)); - } - final JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.parse(signingKey.getAlgorithm().getName())).build(); final SignedJWT signedJwt = AccessController.doPrivileged( diff --git a/src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java b/src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java index 61ba2cd8e3..0da8d5447d 100644 --- a/src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java +++ b/src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java @@ -170,7 +170,9 @@ private AuthCredentials extractCredentials0(final SecurityRequest request, final log.error("Cannot authenticate user with JWT because of ", e); return null; } catch (Exception e) { - log.error("Invalid or expired JWT token.", e); + if (log.isDebugEnabled()) { + log.debug("Invalid or expired JWT token.", e); + } } // Return null for the authentication failure diff --git a/src/main/java/org/opensearch/security/identity/SecurityTokenManager.java b/src/main/java/org/opensearch/security/identity/SecurityTokenManager.java index ca5a17b6f7..aeee248f25 100644 --- a/src/main/java/org/opensearch/security/identity/SecurityTokenManager.java +++ b/src/main/java/org/opensearch/security/identity/SecurityTokenManager.java @@ -12,7 +12,6 @@ package org.opensearch.security.identity; import java.util.ArrayList; -import java.util.List; import java.util.Optional; import java.util.Set; @@ -28,7 +27,6 @@ import org.opensearch.identity.tokens.AuthToken; import org.opensearch.identity.tokens.OnBehalfOfClaims; import org.opensearch.identity.tokens.TokenManager; -import org.opensearch.security.action.apitokens.ApiToken; import org.opensearch.security.authtoken.jwt.ExpiringBearerAuthToken; import org.opensearch.security.authtoken.jwt.JwtVendor; import org.opensearch.security.securityconf.ConfigModel; @@ -141,16 +139,11 @@ public ExpiringBearerAuthToken issueOnBehalfOfToken(final Subject subject, final } } - public ExpiringBearerAuthToken issueApiToken( - final String name, - final Long expiration, - final List clusterPermissions, - final List indexPermissions - ) { + public ExpiringBearerAuthToken issueApiToken(final String name, final Long expiration) { final User user = threadPool.getThreadContext().getTransient(ConfigConstants.OPENDISTRO_SECURITY_USER); try { - return apiTokenJwtVendor.createJwt(cs.getClusterName().value(), name, name, expiration, clusterPermissions, indexPermissions); + return apiTokenJwtVendor.createJwt(cs.getClusterName().value(), name, name, expiration); } catch (final Exception ex) { logger.error("Error creating Api Token for " + user.getName(), ex); throw new OpenSearchSecurityException("Unable to generate Api Token"); diff --git a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenAuthenticatorTest.java b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenAuthenticatorTest.java index 93109b49d3..3de70d1302 100644 --- a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenAuthenticatorTest.java +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenAuthenticatorTest.java @@ -11,6 +11,8 @@ package org.opensearch.security.action.apitokens; +import java.util.List; + import org.apache.logging.log4j.Logger; import org.junit.Before; import org.junit.Test; @@ -81,7 +83,7 @@ public void testExtractCredentialsPassWhenJtiInCache() { String encryptedTestJti = "k3JQNRXR57Y4V4W1LNkpEP+QzcDra5+/fFfQrr0Rncr/RhYAnYEDgN9RKcJ2Wmjf63NCQa9+HjeFcn0H2wKhMm7pl9bd3ya9FO+LUD7t9Sih4DOjUt0t7ee4ROC0eRK5glMsKsKQVkuY+YKa6A6dT8bMqmt7kIrer7w8TRENr9J8x41TGb/cDDWDvJLME7QkFzJjMxYDgKNiEevMbOpC8yjIZdK08jPe3Thq+xm+JYruoYeyI5g8QjkJA9ZOs1f6eXTAvPxhseuPqgIKykRE25fuWjl5n9tJ9W+jpl+iET7zzOLXSPEU5UepL/COkVd6xW63Ay72oMOewqveDXzyR8S8LAfgRuKgYZWms7yT37XcGg0c6Y7M62KVPo+1XQ+F+K5bgddkd8G+I9KHf561jIMzBcIodgGRj659954W16D1C92+PF/YWPQoTv2hVK4f60H82ga1YSiz3r9UrFV8d7gLJwtyJT9HNPuXO2VZ7xPhre+n1Wv7No0kH2S/r3nqKK6Bk/kn1ZbAmjLxuw13c95lIir6avlKE7XX4PiQDfcGeAyeXOw/36kLW8wH7kjXWdBspld1AiI4fCOaszNXF+7gcuTxIhECl+mEyrJbMI88EWllq+LbydiOrVLFXXRMiCbvj+VTYjzimgJPp+Vuvg=="; ApiTokenIndexListenerCache cache = ApiTokenIndexListenerCache.getInstance(); - cache.getJtis().put(encryptedTestJti, null); + cache.getJtis().put(encryptedTestJti, new Permissions(List.of(), List.of())); assertTrue(cache.getJtis().containsKey(encryptedTestJti)); SecurityRequest request = mock(SecurityRequest.class); @@ -100,7 +102,7 @@ public void testExtractCredentialsFailWhenTokenIsExpired() { String encryptedTestJti = "k3JQNRXR57Y4V4W1LNkpEP+QzcDra5+/fFfQrr0Rncr/RhYAnYEDgN9RKcJ2Wmjf63NCQa9+HjeFcn0H2wKhMm7pl9bd3ya9FO+LUD7t9ShsbOyBUkpFSVuQwrXLatY+glMsKsKQVkuY+YKa6A6dT8bMqmt7kIrer7w8TRENr9J8x41TGb/cDDWDvJLME7QkFzJjMxYDgKNiEevMbOpC8yjIZdK08jPe3Thq+xm+JYruoYeyI5g8QjkJA9ZOs1f6eXTAvPxhseuPqgIKykRE25fuWjl5n9tJ9W+jpl+iET7zzOLXSPEU5UepL/COkVd6xW63Ay72oMOewqveDXzyR8S8LAfgRuKgYZWms7yT37XcGg0c6Y7M62KVPo+1XQ+Fu193YtvS4vqt9G8jHiq51VCRxNHYVlAsratxzvECD8AKBilR9/7dUKyOQDBIzPG4ws+kgI680SgdMgGuLANQPGzal9US8GsWzTbQWCgtObaSVKB02U4gh16wvy3XrXtPz2Z0ZAxoU2Z8opX8hcvB5MG5UUEf+tpgTtVPcbuJyCL42yD3FIc3v/LCYlG/hFvflXBx5c1r+4Tij8Qc/NkYb7/03xiJsVH6eduSqR9M0QBpLm7xg2TgqVMvC/+n96x2V3lS4via4lAK6xuYeRY0ng=="; ApiTokenIndexListenerCache cache = ApiTokenIndexListenerCache.getInstance(); - cache.getJtis().put(encryptedTestJti, null); + cache.getJtis().put(encryptedTestJti, new Permissions(List.of(), List.of())); assertTrue(cache.getJtis().containsKey(encryptedTestJti)); SecurityRequest request = mock(SecurityRequest.class); @@ -121,7 +123,7 @@ public void testExtractCredentialsFailWhenIssuerDoesNotMatch() { String encryptedTestJti = "k3JQNRXR57Y4V4W1LNkpEP+QzcDra5+/fFfQrr0Rncr/RhYAnYEDgN9RKcJ2Wmjf63NCQa9+HjeFcn0H2wKhMm7pl9bd3ya9FO+LUD7t9Sih4DOjUt0t7ee4ROC0eRK5glMsKsKQVkuY+YKa6A6dT8bMqmt7kIrer7w8TRENr9J8x41TGb/cDDWDvJLME7QkFzJjMxYDgKNiEevMbOpC8yjIZdK08jPe3Thq+xm+JYruoYeyI5g8QjkJA9ZOs1f6eXTAvPxhseuPqgIKykRE25fuWjl5n9tJ9W+jpl+iET7zzOLXSPEU5UepL/COkVd6xW63Ay72oMOewqveDXzyR8S8LAfgRuKgYZWms7yT37XcGg0c6Y7M62KVPo+1XQ+F+K5bgddkd8G+I9KHf561jIMzBcIodgGRj659954W16D1C92+PF/YWPQoTv2hVK4f60H82ga1YSiz3r9UrFV8d7gLJwtyJT9HNPuXO2VZ7xPhre+n1Wv7No0kH2S/r3nqKK6Bk/kn1ZbAmjLxuw13c95lIir6avlKE7XX4PiQDfcGeAyeXOw/36kLW8wH7kjXWdBspld1AiI4fCOaszNXF+7gcuTxIhECl+mEyrJbMI88EWllq+LbydiOrVLFXXRMiCbvj+VTYjzimgJPp+Vuvg=="; ApiTokenIndexListenerCache cache = ApiTokenIndexListenerCache.getInstance(); - cache.getJtis().put(encryptedTestJti, null); + cache.getJtis().put(encryptedTestJti, new Permissions(List.of(), List.of())); assertTrue(cache.getJtis().containsKey(encryptedTestJti)); SecurityRequest request = mock(SecurityRequest.class); @@ -150,7 +152,7 @@ public void testExtractCredentialsFailWhenAccessingRestrictedEndpoint() { String encryptedTestJti = "k3JQNRXR57Y4V4W1LNkpEP+QzcDra5+/fFfQrr0Rncr/RhYAnYEDgN9RKcJ2Wmjf63NCQa9+HjeFcn0H2wKhMm7pl9bd3ya9FO+LUD7t9Sih4DOjUt0t7ee4ROC0eRK5glMsKsKQVkuY+YKa6A6dT8bMqmt7kIrer7w8TRENr9J8x41TGb/cDDWDvJLME7QkFzJjMxYDgKNiEevMbOpC8yjIZdK08jPe3Thq+xm+JYruoYeyI5g8QjkJA9ZOs1f6eXTAvPxhseuPqgIKykRE25fuWjl5n9tJ9W+jpl+iET7zzOLXSPEU5UepL/COkVd6xW63Ay72oMOewqveDXzyR8S8LAfgRuKgYZWms7yT37XcGg0c6Y7M62KVPo+1XQ+F+K5bgddkd8G+I9KHf561jIMzBcIodgGRj659954W16D1C92+PF/YWPQoTv2hVK4f60H82ga1YSiz3r9UrFV8d7gLJwtyJT9HNPuXO2VZ7xPhre+n1Wv7No0kH2S/r3nqKK6Bk/kn1ZbAmjLxuw13c95lIir6avlKE7XX4PiQDfcGeAyeXOw/36kLW8wH7kjXWdBspld1AiI4fCOaszNXF+7gcuTxIhECl+mEyrJbMI88EWllq+LbydiOrVLFXXRMiCbvj+VTYjzimgJPp+Vuvg=="; ApiTokenIndexListenerCache cache = ApiTokenIndexListenerCache.getInstance(); - cache.getJtis().put(encryptedTestJti, null); + cache.getJtis().put(encryptedTestJti, new Permissions(List.of(), List.of())); assertTrue(cache.getJtis().containsKey(encryptedTestJti)); SecurityRequest request = mock(SecurityRequest.class); @@ -169,7 +171,7 @@ public void testAuthenticatorNotEnabled() { String encryptedTestJti = "k3JQNRXR57Y4V4W1LNkpEP+QzcDra5+/fFfQrr0Rncr/RhYAnYEDgN9RKcJ2Wmjf63NCQa9+HjeFcn0H2wKhMm7pl9bd3ya9FO+LUD7t9Sih4DOjUt0t7ee4ROC0eRK5glMsKsKQVkuY+YKa6A6dT8bMqmt7kIrer7w8TRENr9J8x41TGb/cDDWDvJLME7QkFzJjMxYDgKNiEevMbOpC8yjIZdK08jPe3Thq+xm+JYruoYeyI5g8QjkJA9ZOs1f6eXTAvPxhseuPqgIKykRE25fuWjl5n9tJ9W+jpl+iET7zzOLXSPEU5UepL/COkVd6xW63Ay72oMOewqveDXzyR8S8LAfgRuKgYZWms7yT37XcGg0c6Y7M62KVPo+1XQ+F+K5bgddkd8G+I9KHf561jIMzBcIodgGRj659954W16D1C92+PF/YWPQoTv2hVK4f60H82ga1YSiz3r9UrFV8d7gLJwtyJT9HNPuXO2VZ7xPhre+n1Wv7No0kH2S/r3nqKK6Bk/kn1ZbAmjLxuw13c95lIir6avlKE7XX4PiQDfcGeAyeXOw/36kLW8wH7kjXWdBspld1AiI4fCOaszNXF+7gcuTxIhECl+mEyrJbMI88EWllq+LbydiOrVLFXXRMiCbvj+VTYjzimgJPp+Vuvg=="; ApiTokenIndexListenerCache cache = ApiTokenIndexListenerCache.getInstance(); - cache.getJtis().put(encryptedTestJti, null); + cache.getJtis().put(encryptedTestJti, new Permissions(List.of(), List.of())); assertTrue(cache.getJtis().containsKey(encryptedTestJti)); SecurityRequest request = mock(SecurityRequest.class); diff --git a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenRepositoryTest.java b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenRepositoryTest.java index 03a2e2c30e..a6dae60400 100644 --- a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenRepositoryTest.java +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenRepositoryTest.java @@ -84,13 +84,13 @@ public void testCreateApiToken() { String encryptedToken = "encrypted-token"; ExpiringBearerAuthToken bearerToken = mock(ExpiringBearerAuthToken.class); when(bearerToken.getCompleteToken()).thenReturn(completeToken); - when(securityTokenManager.issueApiToken(any(), any(), any(), any())).thenReturn(bearerToken); + when(securityTokenManager.issueApiToken(any(), any())).thenReturn(bearerToken); when(securityTokenManager.encryptToken(completeToken)).thenReturn(encryptedToken); String result = repository.createApiToken(tokenName, clusterPermissions, indexPermissions, expiration); verify(apiTokenIndexHandler).createApiTokenIndexIfAbsent(); - verify(securityTokenManager).issueApiToken(any(), any(), any(), any()); + verify(securityTokenManager).issueApiToken(any(), any()); verify(securityTokenManager).encryptToken(completeToken); verify(apiTokenIndexHandler).indexTokenMetadata( argThat( diff --git a/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java b/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java index ee11c17e13..ec37898687 100644 --- a/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java +++ b/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java @@ -32,11 +32,7 @@ import org.opensearch.common.collect.Tuple; import org.opensearch.common.settings.Settings; import org.opensearch.common.xcontent.XContentFactory; -import org.opensearch.common.xcontent.XContentType; -import org.opensearch.core.xcontent.DeprecationHandler; -import org.opensearch.core.xcontent.NamedXContentRegistry; import org.opensearch.core.xcontent.ToXContent; -import org.opensearch.core.xcontent.XContentParser; import org.opensearch.security.action.apitokens.ApiToken; import org.opensearch.security.support.ConfigConstants; @@ -297,14 +293,7 @@ public void testCreateJwtForApiTokenSuccess() throws Exception { String claimsEncryptionKey = "1234567890123456"; Settings settings = Settings.builder().put("signing_key", signingKeyB64Encoded).put("encryption_key", claimsEncryptionKey).build(); final JwtVendor jwtVendor = new JwtVendor(settings, Optional.of(currentTime)); - final ExpiringBearerAuthToken authToken = jwtVendor.createJwt( - issuer, - subject, - audience, - Long.MAX_VALUE, - clusterPermissions, - indexPermissions - ); + final ExpiringBearerAuthToken authToken = jwtVendor.createJwt(issuer, subject, audience, Long.MAX_VALUE); SignedJWT signedJWT = SignedJWT.parse(authToken.getCompleteToken()); @@ -314,29 +303,6 @@ public void testCreateJwtForApiTokenSuccess() throws Exception { assertThat(signedJWT.getJWTClaimsSet().getClaims().get("iat"), is(notNullValue())); // Allow for millisecond to second conversion flexibility assertThat(((Date) signedJWT.getJWTClaimsSet().getClaims().get("exp")).getTime() / 1000, equalTo(Long.MAX_VALUE / 1000)); - - EncryptionDecryptionUtil encryptionUtil = new EncryptionDecryptionUtil(claimsEncryptionKey); - assertThat( - encryptionUtil.decrypt(signedJWT.getJWTClaimsSet().getClaims().get("cp").toString()), - equalTo(expectedClusterPermissions) - ); - - assertThat(encryptionUtil.decrypt(signedJWT.getJWTClaimsSet().getClaims().get("ip").toString()), equalTo(expectedIndexPermissions)); - - XContentParser parser = XContentType.JSON.xContent() - .createParser( - NamedXContentRegistry.EMPTY, - DeprecationHandler.THROW_UNSUPPORTED_OPERATION, - encryptionUtil.decrypt(signedJWT.getJWTClaimsSet().getClaims().get("ip").toString()) - ); - // Parse first item of the list - parser.nextToken(); - parser.nextToken(); - ApiToken.IndexPermission indexPermission1 = ApiToken.IndexPermission.fromXContent(parser); - - // Index permission deserialization works as expected - assertThat(indexPermission1.getIndexPatterns(), equalTo(indexPermission.getIndexPatterns())); - assertThat(indexPermission1.getAllowedActions(), equalTo(indexPermission.getAllowedActions())); } @Test diff --git a/src/test/java/org/opensearch/security/identity/SecurityTokenManagerTest.java b/src/test/java/org/opensearch/security/identity/SecurityTokenManagerTest.java index 7ecbb6da34..f6679a95b7 100644 --- a/src/test/java/org/opensearch/security/identity/SecurityTokenManagerTest.java +++ b/src/test/java/org/opensearch/security/identity/SecurityTokenManagerTest.java @@ -261,8 +261,8 @@ public void issueApiToken_success() throws Exception { createMockJwtVendorInTokenManager(); final ExpiringBearerAuthToken authToken = mock(ExpiringBearerAuthToken.class); - when(jwtVendor.createJwt(anyString(), anyString(), anyString(), anyLong(), any(), any())).thenReturn(authToken); - final AuthToken returnedToken = tokenManager.issueApiToken("elmo", Long.MAX_VALUE, List.of("*"), List.of()); + when(jwtVendor.createJwt(anyString(), anyString(), anyString(), anyLong())).thenReturn(authToken); + final AuthToken returnedToken = tokenManager.issueApiToken("elmo", Long.MAX_VALUE); assertThat(returnedToken, equalTo(authToken)); @@ -282,8 +282,8 @@ public void encryptCallsJwtEncrypt() throws Exception { createMockJwtVendorInTokenManager(); final ExpiringBearerAuthToken authToken = mock(ExpiringBearerAuthToken.class); - when(jwtVendor.createJwt(anyString(), anyString(), anyString(), anyLong(), any(), any())).thenReturn(authToken); - final AuthToken returnedToken = tokenManager.issueApiToken("elmo", Long.MAX_VALUE, List.of("*"), List.of()); + when(jwtVendor.createJwt(anyString(), anyString(), anyString(), anyLong())).thenReturn(authToken); + final AuthToken returnedToken = tokenManager.issueApiToken("elmo", Long.MAX_VALUE); assertThat(returnedToken, equalTo(authToken)); From ad6397425d5df8889f70f884e1325431983a7f4f Mon Sep 17 00:00:00 2001 From: Derek Ho Date: Mon, 30 Dec 2024 17:24:06 -0500 Subject: [PATCH 09/23] Onboard onto clusterPrivileges Signed-off-by: Derek Ho --- .../security/privileges/ActionPrivileges.java | 85 ++++++++++++------- 1 file changed, 56 insertions(+), 29 deletions(-) diff --git a/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java b/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java index b9ff9f125e..45a17d1b97 100644 --- a/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java +++ b/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java @@ -13,6 +13,7 @@ import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -323,6 +324,8 @@ static class ClusterPrivileges { private final ImmutableSet wellKnownClusterActions; + private final FlattenedActionGroups actionGroups; + /** * Creates pre-computed cluster privileges based on the given parameters. *

@@ -399,6 +402,7 @@ static class ClusterPrivileges { this.rolesWithWildcardPermissions = rolesWithWildcardPermissions.build(); this.rolesToActionMatcher = rolesToActionMatcher.build(); this.wellKnownClusterActions = wellKnownClusterActions; + this.actionGroups = actionGroups; } /** @@ -432,17 +436,60 @@ PrivilegesEvaluatorResponse providesPrivilege(PrivilegesEvaluationContext contex } // 4: Evaluate api tokens - if (context.getUser().getName().startsWith("apitoken")) { - String jti = context.getUser().getName().split(":")[1]; - log.info(context.getApiTokenIndexListenerCache().getJtis().get(jti).getClusterPerm().toString()); + return providesClusterPrivilegeForApiToken(context, Set.of(action), false); + } - if (context.getApiTokenIndexListenerCache().getJtis().get(jti) != null - && context.getApiTokenIndexListenerCache().getJtis().get(jti).getClusterPerm().contains(action)) { + /** + * Evaluates cluster privileges for api tokens. It does so by checking exact match, regex match, * match, and action group match in a non-optimized, naive way. + * First it expands all action groups to get all the actions and patterns of actions. Then it checks * if not an explicit check, then for exact match, then for pattern match. + */ + PrivilegesEvaluatorResponse providesClusterPrivilegeForApiToken( + PrivilegesEvaluationContext context, + Set actions, + Boolean explicit + ) { + String userName = context.getUser().getName(); + String jti = context.getUser().getName().split(":")[1]; + if (userName.startsWith("apitoken") && context.getApiTokenIndexListenerCache().getJtis().get(jti) != null) { + List clusterPermissions = context.getApiTokenIndexListenerCache().getJtis().get(jti).getClusterPerm(); + // Expand the action groups + ImmutableSet resolvedClusterPermissions = actionGroups.resolve( + context.getApiTokenIndexListenerCache().getJtis().get(jti).getClusterPerm() + ); + log.info(resolvedClusterPermissions); + + // Check for wildcard permission + if (!explicit) { + if (resolvedClusterPermissions.contains("*")) { + return PrivilegesEvaluatorResponse.ok(); + } + } + + // Check for exact match + if (!Collections.disjoint(resolvedClusterPermissions, actions)) { return PrivilegesEvaluatorResponse.ok(); } - } - return PrivilegesEvaluatorResponse.insufficient(action); + // Check for pattern matches (like "cluster:*") + for (String permission : resolvedClusterPermissions) { + // Skip exact matches as we already checked those + if (!permission.contains("*")) { + continue; + } + + WildcardMatcher permissionMatcher = WildcardMatcher.from(permission); + for (String action : actions) { + if (permissionMatcher.test(action)) { + return PrivilegesEvaluatorResponse.ok(); + } + } + } + } + if (actions.size() == 1) { + return PrivilegesEvaluatorResponse.insufficient(actions.iterator().next()); + } else { + return PrivilegesEvaluatorResponse.insufficient("any of " + actions); + } } /** @@ -475,15 +522,7 @@ PrivilegesEvaluatorResponse providesExplicitPrivilege(PrivilegesEvaluationContex } } - if (context.getUser().getName().startsWith("apitoken")) { - String jti = context.getUser().getName().split(":")[1]; - if (context.getApiTokenIndexListenerCache().getJtis().get(jti) != null - && context.getApiTokenIndexListenerCache().getJtis().get(jti).getClusterPerm().contains(action)) { - return PrivilegesEvaluatorResponse.ok(); - } - } - - return PrivilegesEvaluatorResponse.insufficient(action); + return providesClusterPrivilegeForApiToken(context, Set.of(action), true); } /** @@ -519,19 +558,7 @@ PrivilegesEvaluatorResponse providesAnyPrivilege(PrivilegesEvaluationContext con } } - if (context.getUser().getName().startsWith("apitoken")) { - String jti = context.getUser().getName().split(":")[1]; - if (context.getApiTokenIndexListenerCache().getJtis().get(jti) != null - && context.getApiTokenIndexListenerCache().getJtis().get(jti).getClusterPerm().stream().anyMatch(actions::contains)) { - return PrivilegesEvaluatorResponse.ok(); - } - } - - if (actions.size() == 1) { - return PrivilegesEvaluatorResponse.insufficient(actions.iterator().next()); - } else { - return PrivilegesEvaluatorResponse.insufficient("any of " + actions); - } + return providesClusterPrivilegeForApiToken(context, actions, false); } } From 73eb2ab50ef28019d7f3d064c8fdddeb21597ade Mon Sep 17 00:00:00 2001 From: Derek Ho Date: Tue, 31 Dec 2024 15:11:12 -0500 Subject: [PATCH 10/23] Add index permissions api token eval Signed-off-by: Derek Ho --- .../security/privileges/ActionPrivileges.java | 172 +++++++++++------- 1 file changed, 111 insertions(+), 61 deletions(-) diff --git a/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java b/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java index 45a17d1b97..08b2d6aa5c 100644 --- a/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java +++ b/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java @@ -164,28 +164,6 @@ public PrivilegesEvaluatorResponse hasIndexPrivilege( return response; } - // API Token Authz - // TODO: this is very naive implementation - if (context.getUser().getName().startsWith("apitoken")) { - String jti = context.getUser().getName().split(":")[1]; - if (context.getApiTokenIndexListenerCache().getJtis().get(jti) != null) { - List indexPermissions = context.getApiTokenIndexListenerCache() - .getJtis() - .get(jti) - .getIndexPermission(); - - boolean hasPermission = indexPermissions.stream().anyMatch(permission -> { - boolean hasAllActions = new HashSet<>(permission.getAllowedActions()).containsAll(actions); - boolean hasAllIndices = new HashSet<>(permission.getIndexPatterns()).containsAll(resolvedIndices.getAllIndices()); - return hasAllActions && hasAllIndices; - }); - - if (hasPermission) { - return PrivilegesEvaluatorResponse.ok(); - } - } - } - if (!resolvedIndices.isLocalAll() && resolvedIndices.getAllIndices().isEmpty()) { // This is necessary for requests which operate on remote indices. // Access control for the remote indices will be performed on the remote cluster. @@ -436,54 +414,55 @@ PrivilegesEvaluatorResponse providesPrivilege(PrivilegesEvaluationContext contex } // 4: Evaluate api tokens - return providesClusterPrivilegeForApiToken(context, Set.of(action), false); + return apiTokenProvidesClusterPrivilege(context, Set.of(action), false); } /** * Evaluates cluster privileges for api tokens. It does so by checking exact match, regex match, * match, and action group match in a non-optimized, naive way. * First it expands all action groups to get all the actions and patterns of actions. Then it checks * if not an explicit check, then for exact match, then for pattern match. */ - PrivilegesEvaluatorResponse providesClusterPrivilegeForApiToken( + PrivilegesEvaluatorResponse apiTokenProvidesClusterPrivilege( PrivilegesEvaluationContext context, Set actions, Boolean explicit ) { String userName = context.getUser().getName(); - String jti = context.getUser().getName().split(":")[1]; - if (userName.startsWith("apitoken") && context.getApiTokenIndexListenerCache().getJtis().get(jti) != null) { - List clusterPermissions = context.getApiTokenIndexListenerCache().getJtis().get(jti).getClusterPerm(); - // Expand the action groups - ImmutableSet resolvedClusterPermissions = actionGroups.resolve( - context.getApiTokenIndexListenerCache().getJtis().get(jti).getClusterPerm() - ); - log.info(resolvedClusterPermissions); + if (userName.startsWith("apitoken") && userName.contains(":")) { + String jti = context.getUser().getName().split(":")[1]; + if (context.getApiTokenIndexListenerCache().getJtis().get(jti) != null) { + // Expand the action groups + ImmutableSet resolvedClusterPermissions = actionGroups.resolve( + context.getApiTokenIndexListenerCache().getJtis().get(jti).getClusterPerm() + ); - // Check for wildcard permission - if (!explicit) { - if (resolvedClusterPermissions.contains("*")) { - return PrivilegesEvaluatorResponse.ok(); + // Check for wildcard permission + if (!explicit) { + if (resolvedClusterPermissions.contains("*")) { + return PrivilegesEvaluatorResponse.ok(); + } } - } - - // Check for exact match - if (!Collections.disjoint(resolvedClusterPermissions, actions)) { - return PrivilegesEvaluatorResponse.ok(); - } - // Check for pattern matches (like "cluster:*") - for (String permission : resolvedClusterPermissions) { - // Skip exact matches as we already checked those - if (!permission.contains("*")) { - continue; + // Check for exact match + if (!Collections.disjoint(resolvedClusterPermissions, actions)) { + return PrivilegesEvaluatorResponse.ok(); } - WildcardMatcher permissionMatcher = WildcardMatcher.from(permission); - for (String action : actions) { - if (permissionMatcher.test(action)) { - return PrivilegesEvaluatorResponse.ok(); + // Check for pattern matches (like "cluster:*") + for (String permission : resolvedClusterPermissions) { + // Skip exact matches as we already checked those + if (!permission.contains("*")) { + continue; + } + + WildcardMatcher permissionMatcher = WildcardMatcher.from(permission); + for (String action : actions) { + if (permissionMatcher.test(action)) { + return PrivilegesEvaluatorResponse.ok(); + } } } } + } if (actions.size() == 1) { return PrivilegesEvaluatorResponse.insufficient(actions.iterator().next()); @@ -522,7 +501,7 @@ PrivilegesEvaluatorResponse providesExplicitPrivilege(PrivilegesEvaluationContex } } - return providesClusterPrivilegeForApiToken(context, Set.of(action), true); + return apiTokenProvidesClusterPrivilege(context, Set.of(action), true); } /** @@ -558,7 +537,7 @@ PrivilegesEvaluatorResponse providesAnyPrivilege(PrivilegesEvaluationContext con } } - return providesClusterPrivilegeForApiToken(context, actions, false); + return apiTokenProvidesClusterPrivilege(context, actions, false); } } @@ -617,6 +596,8 @@ static class IndexPrivileges { */ private final ImmutableMap> rolesToExplicitActionToIndexPattern; + private final FlattenedActionGroups actionGroups; + /** * Creates pre-computed index privileges based on the given parameters. *

@@ -754,6 +735,7 @@ static class IndexPrivileges { this.wellKnownIndexActions = wellKnownIndexActions; this.explicitlyRequiredIndexActions = explicitlyRequiredIndexActions; + this.actionGroups = actionGroups; } /** @@ -856,13 +838,7 @@ PrivilegesEvaluatorResponse providesPrivilege( return PrivilegesEvaluatorResponse.partiallyOk(availableIndices, checkTable).evaluationExceptions(exceptions); } - return PrivilegesEvaluatorResponse.insufficient(checkTable) - .reason( - resolvedIndices.getAllIndices().size() == 1 - ? "Insufficient permissions for the referenced index" - : "None of " + resolvedIndices.getAllIndices().size() + " referenced indices has sufficient permissions" - ) - .evaluationExceptions(exceptions); + return apiTokenProvidesIndexPrivilege(checkTable, context, exceptions, resolvedIndices, actions, false); } /** @@ -928,8 +904,82 @@ PrivilegesEvaluatorResponse providesExplicitPrivilege( } } + return apiTokenProvidesIndexPrivilege(checkTable, context, exceptions, resolvedIndices, actions, true); + } + + PrivilegesEvaluatorResponse apiTokenProvidesIndexPrivilege( + CheckTable checkTable, + PrivilegesEvaluationContext context, + List exceptions, + IndexResolverReplacer.Resolved resolvedIndices, + Set actions, + Boolean explicit + ) { + String userName = context.getUser().getName(); + if (userName.startsWith("apitoken") && userName.contains(":")) { + String jti = context.getUser().getName().split(":")[1]; + if (context.getApiTokenIndexListenerCache().getJtis().get(jti) != null) { + List indexPermissions = context.getApiTokenIndexListenerCache() + .getJtis() + .get(jti) + .getIndexPermission(); + + for (String concreteIndex : resolvedIndices.getAllIndices()) { + boolean indexHasAllPermissions = false; + + // Check each index permission + for (ApiToken.IndexPermission indexPermission : indexPermissions) { + // First check if this permission applies to this index + boolean indexMatched = false; + for (String pattern : indexPermission.getIndexPatterns()) { + if (WildcardMatcher.from(pattern).test(concreteIndex)) { + indexMatched = true; + break; + } + } + + if (!indexMatched) { + continue; + } + + // Index matched, now check if this permission covers all actions + Set remainingActions = new HashSet<>(actions); + ImmutableSet resolvedIndexPermissions = actionGroups.resolve(indexPermission.getAllowedActions()); + + for (String permission : resolvedIndexPermissions) { + // Skip global wildcard if explicit is true + if (explicit && permission.equals("*")) { + continue; + } + + WildcardMatcher permissionMatcher = WildcardMatcher.from(permission); + remainingActions.removeIf(action -> permissionMatcher.test(action)); + + if (remainingActions.isEmpty()) { + indexHasAllPermissions = true; + break; + } + } + + if (indexHasAllPermissions) { + break; // Found a permission that covers all actions for this index + } + } + + if (!indexHasAllPermissions) { + return PrivilegesEvaluatorResponse.insufficient("Insufficient permissions for index"); + } + } + // If we get here, all indices had sufficient permissions + return PrivilegesEvaluatorResponse.ok(); + } + } return PrivilegesEvaluatorResponse.insufficient(checkTable) - .reason("No explicit privileges have been provided for the referenced indices.") + .reason( + resolvedIndices.getAllIndices().size() == 1 + ? "Insufficient permissions for the referenced index" + : "None of " + resolvedIndices.getAllIndices().size() + " referenced indices has sufficient permissions" + ) .evaluationExceptions(exceptions); } } From 641822660162ffb781324bbf8ae7871f206596a7 Mon Sep 17 00:00:00 2001 From: Derek Ho Date: Tue, 31 Dec 2024 16:49:42 -0500 Subject: [PATCH 11/23] Add testing for cluster and index priv Signed-off-by: Derek Ho --- .../privileges/ActionPrivilegesTest.java | 116 +++++++++++++++++- .../security/privileges/ActionPrivileges.java | 31 +++-- 2 files changed, 136 insertions(+), 11 deletions(-) diff --git a/src/integrationTest/java/org/opensearch/security/privileges/ActionPrivilegesTest.java b/src/integrationTest/java/org/opensearch/security/privileges/ActionPrivilegesTest.java index 7807dae748..ecd76b127c 100644 --- a/src/integrationTest/java/org/opensearch/security/privileges/ActionPrivilegesTest.java +++ b/src/integrationTest/java/org/opensearch/security/privileges/ActionPrivilegesTest.java @@ -38,15 +38,19 @@ import org.opensearch.common.util.concurrent.ThreadContext; import org.opensearch.core.common.unit.ByteSizeUnit; import org.opensearch.core.common.unit.ByteSizeValue; +import org.opensearch.security.action.apitokens.ApiToken; +import org.opensearch.security.action.apitokens.Permissions; import org.opensearch.security.resolver.IndexResolverReplacer; import org.opensearch.security.securityconf.FlattenedActionGroups; import org.opensearch.security.securityconf.impl.CType; import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; +import org.opensearch.security.securityconf.impl.v7.ActionGroupsV7; import org.opensearch.security.securityconf.impl.v7.RoleV7; import org.opensearch.security.user.User; import org.opensearch.security.util.MockIndexMetadataBuilder; import static org.hamcrest.MatcherAssert.assertThat; +import static org.opensearch.security.privileges.ActionPrivilegesTest.IndexPrivileges.IndicesAndAliases.resolved; import static org.opensearch.security.privileges.PrivilegeEvaluatorResponseMatcher.isAllowed; import static org.opensearch.security.privileges.PrivilegeEvaluatorResponseMatcher.isForbidden; import static org.opensearch.security.privileges.PrivilegeEvaluatorResponseMatcher.isPartiallyOk; @@ -258,6 +262,69 @@ public void hasAny_wildcard() throws Exception { isForbidden(missingPrivileges("cluster:whatever")) ); } + + @Test + public void apiToken_explicit_failsWithWildcard() throws Exception { + SecurityDynamicConfiguration roles = SecurityDynamicConfiguration.fromYaml("test_role:\n" + // + " cluster_permissions:\n" + // + " - '*'", CType.ROLES); + ActionPrivileges subject = new ActionPrivileges(roles, FlattenedActionGroups.EMPTY, null, Settings.EMPTY); + String token = "blah"; + PrivilegesEvaluationContext context = ctxWithUserName("apitoken_test:" + token); + context.getApiTokenIndexListenerCache().getJtis().put(token, new Permissions(List.of("*"), List.of())); + // Explicit fails + assertThat( + subject.hasExplicitClusterPrivilege(context, "cluster:whatever"), + isForbidden(missingPrivileges("cluster:whatever")) + ); + // Not explicit succeeds + assertThat(subject.hasClusterPrivilege(context, "cluster:whatever"), isAllowed()); + // Any succeeds + assertThat(subject.hasAnyClusterPrivilege(context, ImmutableSet.of("cluster:whatever")), isAllowed()); + } + + @Test + public void apiToken_succeedsWithExactMatch() throws Exception { + SecurityDynamicConfiguration roles = SecurityDynamicConfiguration.fromYaml("test_role:\n" + // + " cluster_permissions:\n" + // + " - '*'", CType.ROLES); + ActionPrivileges subject = new ActionPrivileges(roles, FlattenedActionGroups.EMPTY, null, Settings.EMPTY); + String token = "blah"; + PrivilegesEvaluationContext context = ctxWithUserName("apitoken_test:" + token); + context.getApiTokenIndexListenerCache().getJtis().put(token, new Permissions(List.of("cluster:whatever"), List.of())); + // Explicit succeeds + assertThat(subject.hasExplicitClusterPrivilege(context, "cluster:whatever"), isAllowed()); + // Not explicit succeeds + assertThat(subject.hasClusterPrivilege(context, "cluster:whatever"), isAllowed()); + // Any succeeds + assertThat(subject.hasAnyClusterPrivilege(context, ImmutableSet.of("cluster:whatever")), isAllowed()); + // Any succeeds + assertThat(subject.hasAnyClusterPrivilege(context, ImmutableSet.of("cluster:whatever", "cluster:other")), isAllowed()); + } + + @Test + public void apiToken_succeedsWithActionGroupsExapnded() throws Exception { + SecurityDynamicConfiguration roles = SecurityDynamicConfiguration.fromYaml("test_role:\n" + // + " cluster_permissions:\n" + // + " - '*'", CType.ROLES); + + SecurityDynamicConfiguration config = SecurityDynamicConfiguration.fromYaml( + "CLUSTER_ALL:\n allowed_actions:\n - \"cluster:*\"", + CType.ACTIONGROUPS + ); + + FlattenedActionGroups actionGroups = new FlattenedActionGroups(config); + ActionPrivileges subject = new ActionPrivileges(roles, actionGroups, null, Settings.EMPTY); + String token = "blah"; + PrivilegesEvaluationContext context = ctxWithUserName("apitoken_test:" + token); + context.getApiTokenIndexListenerCache().getJtis().put(token, new Permissions(List.of("CLUSTER_ALL"), List.of())); + // Explicit succeeds + assertThat(subject.hasExplicitClusterPrivilege(context, "cluster:whatever"), isAllowed()); + // Not explicit succeeds + assertThat(subject.hasClusterPrivilege(context, "cluster:whatever"), isAllowed()); + // Any succeeds + assertThat(subject.hasClusterPrivilege(context, "cluster:monitor/main"), isAllowed()); + } } /** @@ -292,6 +359,20 @@ public void positive_full() throws Exception { assertThat(result, isAllowed()); } + @Test + public void apiTokens_positive_full() throws Exception { + String token = "blah"; + PrivilegesEvaluationContext context = ctxWithUserName("apitoken_test:" + token); + context.getApiTokenIndexListenerCache() + .getJtis() + .put( + token, + new Permissions(List.of("index_a11"), List.of(new ApiToken.IndexPermission(List.of("index_a11"), List.of("*")))) + ); + PrivilegesEvaluatorResponse result = subject.hasIndexPrivilege(context, requiredActions, resolved("index_a11")); + assertThat(result, isAllowed()); + } + @Test public void positive_partial() throws Exception { PrivilegesEvaluationContext ctx = ctx("test_role"); @@ -346,6 +427,18 @@ public void negative_wrongRole() throws Exception { assertThat(result, isForbidden(missingPrivileges(requiredActions))); } + @Test + public void apiToken_negative_noPermissions() throws Exception { + String token = "blah"; + PrivilegesEvaluationContext context = ctxWithUserName("apitoken_test:" + token); + context.getApiTokenIndexListenerCache() + .getJtis() + .put(token, new Permissions(List.of(), List.of(new ApiToken.IndexPermission(List.of(), List.of())))); + + PrivilegesEvaluatorResponse result = subject.hasIndexPrivilege(context, requiredActions, resolved("index_a11")); + assertThat(result, isForbidden(missingPrivileges(requiredActions))); + } + @Test public void negative_wrongAction() throws Exception { PrivilegesEvaluationContext ctx = ctx("test_role"); @@ -375,6 +468,23 @@ public void positive_hasExplicit_full() { } } + @Test + public void apiTokens_positive_hasExplicit_full() { + String token = "blah"; + PrivilegesEvaluationContext context = ctxWithUserName("apitoken_test:" + token); + context.getApiTokenIndexListenerCache() + .getJtis() + .put( + token, + new Permissions(List.of("index_a11"), List.of(new ApiToken.IndexPermission(List.of("index_a11"), List.of("*")))) + ); + + PrivilegesEvaluatorResponse result = subject.hasExplicitIndexPrivilege(context, requiredActions, resolved("index_a11")); + + assertThat(result, isForbidden(missingPrivileges(requiredActions))); + + } + private boolean covers(PrivilegesEvaluationContext ctx, String... indices) { for (String index : indices) { if (!indexSpec.covers(ctx.getUser(), index)) { @@ -1017,7 +1127,11 @@ static SecurityDynamicConfiguration createRoles(int numberOfRoles, int n } static PrivilegesEvaluationContext ctx(String... roles) { - User user = new User("test_user"); + return ctxWithUserName("test-user", roles); + } + + static PrivilegesEvaluationContext ctxWithUserName(String userName, String... roles) { + User user = new User(userName); user.addAttributes(ImmutableMap.of("attrs.dept_no", "a11")); return new PrivilegesEvaluationContext( user, diff --git a/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java b/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java index 08b2d6aa5c..d722231796 100644 --- a/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java +++ b/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java @@ -431,7 +431,7 @@ PrivilegesEvaluatorResponse apiTokenProvidesClusterPrivilege( String jti = context.getUser().getName().split(":")[1]; if (context.getApiTokenIndexListenerCache().getJtis().get(jti) != null) { // Expand the action groups - ImmutableSet resolvedClusterPermissions = actionGroups.resolve( + Set resolvedClusterPermissions = actionGroups.resolve( context.getApiTokenIndexListenerCache().getJtis().get(jti).getClusterPerm() ); @@ -449,15 +449,18 @@ PrivilegesEvaluatorResponse apiTokenProvidesClusterPrivilege( // Check for pattern matches (like "cluster:*") for (String permission : resolvedClusterPermissions) { - // Skip exact matches as we already checked those - if (!permission.contains("*")) { - continue; - } + // skip pure *, which was evaluated above + if (permission != "*") { + // Skip exact matches as we already checked those + if (!permission.contains("*")) { + continue; + } - WildcardMatcher permissionMatcher = WildcardMatcher.from(permission); - for (String action : actions) { - if (permissionMatcher.test(action)) { - return PrivilegesEvaluatorResponse.ok(); + WildcardMatcher permissionMatcher = WildcardMatcher.from(permission); + for (String action : actions) { + if (permissionMatcher.test(action)) { + return PrivilegesEvaluatorResponse.ok(); + } } } } @@ -967,7 +970,15 @@ PrivilegesEvaluatorResponse apiTokenProvidesIndexPrivilege( } if (!indexHasAllPermissions) { - return PrivilegesEvaluatorResponse.insufficient("Insufficient permissions for index"); + return PrivilegesEvaluatorResponse.insufficient(checkTable) + .reason( + resolvedIndices.getAllIndices().size() == 1 + ? "Insufficient permissions for the referenced index" + : "None of " + + resolvedIndices.getAllIndices().size() + + " referenced indices has sufficient permissions" + ) + .evaluationExceptions(exceptions); } } // If we get here, all indices had sufficient permissions From bc8aacf42eb1ee1d85b8bfa46c798430d24f7291 Mon Sep 17 00:00:00 2001 From: Derek Ho Date: Mon, 6 Jan 2025 12:42:34 -0500 Subject: [PATCH 12/23] Use transport action Signed-off-by: Derek Ho --- .../security/OpenSearchSecurityPlugin.java | 12 +- .../action/apitokens/ApiTokenAction.java | 87 +++++++--- .../apitokens/ApiTokenIndexListenerCache.java | 162 +++++++++++------- .../apitokens/ApiTokenUpdateAction.java | 24 +++ .../apitokens/ApiTokenUpdateNodeResponse.java | 28 +++ .../apitokens/ApiTokenUpdateRequest.java | 35 ++++ .../apitokens/ApiTokenUpdateResponse.java | 60 +++++++ .../TransportApiTokenUpdateAction.java | 104 +++++++++++ .../security/http/ApiTokenAuthenticator.java | 2 +- .../security/privileges/ActionPrivileges.java | 9 +- .../apitokens/ApiTokenAuthenticatorTest.java | 24 +-- 11 files changed, 427 insertions(+), 120 deletions(-) create mode 100644 src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateAction.java create mode 100644 src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateNodeResponse.java create mode 100644 src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateRequest.java create mode 100644 src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateResponse.java create mode 100644 src/main/java/org/opensearch/security/action/apitokens/TransportApiTokenUpdateAction.java diff --git a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java index efe51d2e74..048fa1fea9 100644 --- a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java +++ b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java @@ -133,6 +133,8 @@ import org.opensearch.search.query.QuerySearchResult; import org.opensearch.security.action.apitokens.ApiTokenAction; import org.opensearch.security.action.apitokens.ApiTokenIndexListenerCache; +import org.opensearch.security.action.apitokens.ApiTokenUpdateAction; +import org.opensearch.security.action.apitokens.TransportApiTokenUpdateAction; import org.opensearch.security.action.configupdate.ConfigUpdateAction; import org.opensearch.security.action.configupdate.TransportConfigUpdateAction; import org.opensearch.security.action.onbehalf.CreateOnBehalfOfTokenAction; @@ -686,6 +688,7 @@ public UnaryOperator getRestHandlerWrapper(final ThreadContext thre List> actions = new ArrayList<>(1); if (!disabled && !SSLConfig.isSslOnlyMode()) { actions.add(new ActionHandler<>(ConfigUpdateAction.INSTANCE, TransportConfigUpdateAction.class)); + actions.add(new ActionHandler<>(ApiTokenUpdateAction.INSTANCE, TransportApiTokenUpdateAction.class)); // external storage does not support reload and does not provide SSL certs info if (!ExternalSecurityKeyStore.hasExternalSslContext(settings)) { actions.add(new ActionHandler<>(CertificatesActionType.INSTANCE, TransportCertificatesInfoNodesAction.class)); @@ -719,14 +722,6 @@ public void onIndexModule(IndexModule indexModule) { ) ); - // TODO: Is there a higher level approach that makes more sense here? Does this cover unsuccessful index ops? - if (ConfigConstants.OPENSEARCH_API_TOKENS_INDEX.equals(indexModule.getIndex().getName())) { - ApiTokenIndexListenerCache apiTokenIndexListenerCacher = ApiTokenIndexListenerCache.getInstance(); - apiTokenIndexListenerCacher.initialize(); - indexModule.addIndexOperationListener(apiTokenIndexListenerCacher); - log.warn("Security plugin started listening to operations on index {}", ConfigConstants.OPENSEARCH_API_TOKENS_INDEX); - } - indexModule.forceQueryCacheProvider((indexSettings, nodeCache) -> new QueryCache() { @Override @@ -1105,6 +1100,7 @@ public Collection createComponents( adminDns = new AdminDNs(settings); cr = ConfigurationRepository.create(settings, this.configPath, threadPool, localClient, clusterService, auditLog); + ApiTokenIndexListenerCache.getInstance().initialize(clusterService, localClient); this.passwordHasher = PasswordHasherFactory.createPasswordHasher(settings); diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java index e2e373812f..75bf3ffa01 100644 --- a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java @@ -20,14 +20,18 @@ import java.util.stream.Collectors; import com.google.common.collect.ImmutableList; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.opensearch.client.Client; import org.opensearch.client.node.NodeClient; import org.opensearch.cluster.service.ClusterService; +import org.opensearch.core.action.ActionListener; import org.opensearch.core.rest.RestStatus; import org.opensearch.core.xcontent.XContentBuilder; import org.opensearch.rest.BaseRestHandler; import org.opensearch.rest.BytesRestResponse; +import org.opensearch.rest.RestChannel; import org.opensearch.rest.RestHandler; import org.opensearch.rest.RestRequest; import org.opensearch.security.identity.SecurityTokenManager; @@ -48,6 +52,7 @@ public class ApiTokenAction extends BaseRestHandler { private final ApiTokenRepository apiTokenRepository; + public Logger log = LogManager.getLogger(this.getClass()); private static final List ROUTES = addRoutesPrefix( ImmutableList.of( @@ -133,20 +138,32 @@ private RestChannelConsumer handlePost(RestRequest request, NodeClient client) { (Long) requestBody.getOrDefault(EXPIRATION_FIELD, Instant.now().toEpochMilli() + TimeUnit.DAYS.toMillis(30)) ); - builder.startObject(); - builder.field("Api Token: ", token); - builder.endObject(); - - response = new BytesRestResponse(RestStatus.OK, builder); + // Then trigger the update action + ApiTokenUpdateRequest updateRequest = new ApiTokenUpdateRequest(); + client.execute(ApiTokenUpdateAction.INSTANCE, updateRequest, new ActionListener() { + @Override + public void onResponse(ApiTokenUpdateResponse updateResponse) { + try { + XContentBuilder builder = channel.newBuilder(); + builder.startObject(); + builder.field("Api Token: ", token); + builder.endObject(); + + BytesRestResponse response = new BytesRestResponse(RestStatus.OK, builder); + channel.sendResponse(response); + } catch (IOException e) { + sendErrorResponse(channel, RestStatus.INTERNAL_SERVER_ERROR, "Failed to send response after token creation"); + } + } + + @Override + public void onFailure(Exception e) { + sendErrorResponse(channel, RestStatus.INTERNAL_SERVER_ERROR, "Failed to propagate token creation"); + } + }); } catch (final Exception exception) { - builder.startObject() - .field("error", "An unexpected error occurred. Please check the input and try again.") - .field("message", exception.getMessage()) - .endObject(); - response = new BytesRestResponse(RestStatus.INTERNAL_SERVER_ERROR, builder); + sendErrorResponse(channel, RestStatus.INTERNAL_SERVER_ERROR, exception.getMessage()); } - builder.close(); - channel.sendResponse(response); }; } @@ -239,22 +256,46 @@ private RestChannelConsumer handleDelete(RestRequest request, NodeClient client) validateRequestParameters(requestBody); apiTokenRepository.deleteApiToken((String) requestBody.get(NAME_FIELD)); - builder.startObject(); - builder.field("message", "token " + requestBody.get(NAME_FIELD) + " deleted successfully."); - builder.endObject(); - - response = new BytesRestResponse(RestStatus.OK, builder); + ApiTokenUpdateRequest updateRequest = new ApiTokenUpdateRequest(); + client.execute(ApiTokenUpdateAction.INSTANCE, updateRequest, new ActionListener() { + @Override + public void onResponse(ApiTokenUpdateResponse updateResponse) { + try { + XContentBuilder builder = channel.newBuilder(); + builder.startObject(); + builder.field("message", "token " + requestBody.get(NAME_FIELD) + " deleted successfully."); + builder.endObject(); + + BytesRestResponse response = new BytesRestResponse(RestStatus.OK, builder); + channel.sendResponse(response); + } catch (Exception e) { + sendErrorResponse(channel, RestStatus.INTERNAL_SERVER_ERROR, "Failed to send response after token update"); + } + } + + @Override + public void onFailure(Exception e) { + sendErrorResponse(channel, RestStatus.INTERNAL_SERVER_ERROR, "Failed to propagate token deletion"); + } + }); } catch (final ApiTokenException exception) { - builder.startObject().field("error", exception.getMessage()).endObject(); - response = new BytesRestResponse(RestStatus.NOT_FOUND, builder); + sendErrorResponse(channel, RestStatus.NOT_FOUND, exception.getMessage()); } catch (final Exception exception) { - builder.startObject().field("error", exception.getMessage()).endObject(); - response = new BytesRestResponse(RestStatus.INTERNAL_SERVER_ERROR, builder); + sendErrorResponse(channel, RestStatus.INTERNAL_SERVER_ERROR, exception.getMessage()); } - builder.close(); - channel.sendResponse(response); }; } + private void sendErrorResponse(RestChannel channel, RestStatus status, String errorMessage) { + try { + XContentBuilder builder = channel.newBuilder(); + builder.startObject().field("error", errorMessage).endObject(); + BytesRestResponse response = new BytesRestResponse(status, builder); + channel.sendResponse(response); + } catch (Exception e) { + log.error("Failed to send error response", e); + } + } + } diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexListenerCache.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexListenerCache.java index 8b87f2fa03..a27c1e06db 100644 --- a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexListenerCache.java +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexListenerCache.java @@ -8,105 +8,137 @@ package org.opensearch.security.action.apitokens; -import java.io.IOException; +import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import org.opensearch.common.xcontent.LoggingDeprecationHandler; -import org.opensearch.common.xcontent.XContentType; -import org.opensearch.core.common.bytes.BytesReference; -import org.opensearch.core.index.shard.ShardId; -import org.opensearch.core.xcontent.NamedXContentRegistry; -import org.opensearch.core.xcontent.XContentParser; -import org.opensearch.index.engine.Engine; -import org.opensearch.index.shard.IndexingOperationListener; - -/** - * This class implements an index operation listener for operations performed on api tokens - * These indices are defined on bootstrap and configured to listen in OpenSearchSecurityPlugin.java - */ -public class ApiTokenIndexListenerCache implements IndexingOperationListener { +import org.opensearch.client.Client; +import org.opensearch.cluster.ClusterChangedEvent; +import org.opensearch.cluster.ClusterStateListener; +import org.opensearch.cluster.metadata.IndexMetadata; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.core.rest.RestStatus; +import org.opensearch.index.query.QueryBuilders; +import org.opensearch.security.support.ConfigConstants; - private final static Logger log = LogManager.getLogger(ApiTokenIndexListenerCache.class); +public class ApiTokenIndexListenerCache implements ClusterStateListener { + private static final Logger log = LogManager.getLogger(ApiTokenIndexListenerCache.class); private static final ApiTokenIndexListenerCache INSTANCE = new ApiTokenIndexListenerCache(); - private final ConcurrentHashMap idToJtiMap = new ConcurrentHashMap<>(); - private Map jtis = new ConcurrentHashMap<>(); + private final ConcurrentHashMap idToJtiMap = new ConcurrentHashMap<>(); + private final Map jtis = new ConcurrentHashMap<>(); - private boolean initialized; + private final AtomicBoolean initialized = new AtomicBoolean(false); + private ClusterService clusterService; + private Client client; private ApiTokenIndexListenerCache() {} public static ApiTokenIndexListenerCache getInstance() { - return ApiTokenIndexListenerCache.INSTANCE; + return INSTANCE; + } + + public void initialize(ClusterService clusterService, Client client) { + if (initialized.compareAndSet(false, true)) { + this.clusterService = clusterService; + this.client = client; + + // Register as cluster state listener + this.clusterService.addListener(this); + } } - /** - * Initializes the ApiTokenIndexListenerCache. - * This method is called during the plugin's initialization process. - * - */ - public void initialize() { + @Override + public void clusterChanged(ClusterChangedEvent event) { + // Reload cache if the security index has changed + IndexMetadata securityIndex = event.state().metadata().index(getSecurityIndexName()); + if (securityIndex != null) { + reloadApiTokensFromIndex(); + } + } - if (initialized) { + void reloadApiTokensFromIndex() { + if (!initialized.get()) { + log.debug("Cache not yet initialized or client is null, skipping reload"); return; } - initialized = true; + if (clusterService.state() != null && clusterService.state().blocks().hasGlobalBlockWithStatus(RestStatus.SERVICE_UNAVAILABLE)) { + log.debug("Cluster not yet ready, skipping API tokens cache reload"); + return; + } + try { + // Clear existing caches + log.info("Reloading API tokens cache from index: {}", jtis.entrySet().toString()); + + idToJtiMap.clear(); + jtis.clear(); + + // Search request to get all API tokens from the security index + client.prepareSearch(getSecurityIndexName()) + .setQuery(QueryBuilders.matchAllQuery()) + .execute() + .actionGet() + .getHits() + .forEach(hit -> { + // Parse the document and update the cache + Map source = hit.getSourceAsMap(); + String id = hit.getId(); + String jti = (String) source.get("jti"); + Permissions permissions = parsePermissions(source); + + idToJtiMap.put(id, jti); + jtis.put(jti, permissions); + }); + + log.debug("Successfully reloaded API tokens cache"); + } catch (Exception e) { + log.error("Failed to reload API tokens cache", e); + } } - public boolean isInitialized() { - return initialized; + private String getSecurityIndexName() { + // Return the name of your security index + return ConfigConstants.OPENSEARCH_API_TOKENS_INDEX; } - /** - * This method is called after an index operation is performed. - * It adds the JTI of the indexed document to the cache and maps the document ID to the JTI (for deletion handling). - * @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) { - BytesReference sourceRef = index.source(); - - try { - XContentParser parser = XContentType.JSON.xContent() - .createParser(NamedXContentRegistry.EMPTY, LoggingDeprecationHandler.INSTANCE, sourceRef.streamInput()); + @SuppressWarnings("unchecked") + private Permissions parsePermissions(Map source) { + // Implement parsing logic for permissions from the document + return new Permissions( + (List) source.get(ApiToken.CLUSTER_PERMISSIONS_FIELD), + (List) source.get(ApiToken.INDEX_PERMISSIONS_FIELD) + ); + } - ApiToken token = ApiToken.fromXContent(parser); - jtis.put(token.getJti(), new Permissions(token.getClusterPermissions(), token.getIndexPermissions())); - idToJtiMap.put(index.id(), token.getJti()); + // Getter methods for cached data + public String getJtiForId(String id) { + return idToJtiMap.get(id); + } - } catch (IOException e) { - log.error("Failed to parse indexed document", e); - } + public Permissions getPermissionsForJti(String jti) { + return jtis.get(jti); } - /** - * This method is called after a delete operation is performed. - * It deletes the corresponding document id in the map and the corresponding jti from the cache. - * @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 docId = delete.id(); - String jti = idToJtiMap.remove(docId); - if (jti != null) { - jtis.remove(jti); - log.debug("Removed token with ID {} and JTI {} from cache", docId, jti); - } + // Method to check if a token is valid + public boolean isValidToken(String jti) { + return jtis.containsKey(jti); } public Map getJtis() { return jtis; } + // Cleanup method + public void close() { + if (clusterService != null) { + clusterService.removeListener(this); + } + } } diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateAction.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateAction.java new file mode 100644 index 0000000000..c9d324c52f --- /dev/null +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateAction.java @@ -0,0 +1,24 @@ +/* + * 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.action.apitokens; + +import org.opensearch.action.ActionType; + +public class ApiTokenUpdateAction extends ActionType { + + public static final ApiTokenUpdateAction INSTANCE = new ApiTokenUpdateAction(); + public static final String NAME = "cluster:admin/opendistro_security/apitoken/update"; + + protected ApiTokenUpdateAction() { + super(NAME, ApiTokenUpdateResponse::new); + } +} diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateNodeResponse.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateNodeResponse.java new file mode 100644 index 0000000000..429310d966 --- /dev/null +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateNodeResponse.java @@ -0,0 +1,28 @@ +/* + * 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.action.apitokens; + +import java.io.IOException; + +import org.opensearch.action.support.nodes.BaseNodeResponse; +import org.opensearch.cluster.node.DiscoveryNode; +import org.opensearch.core.common.io.stream.StreamInput; + +public class ApiTokenUpdateNodeResponse extends BaseNodeResponse { + public ApiTokenUpdateNodeResponse(StreamInput in) throws IOException { + super(in); + } + + public ApiTokenUpdateNodeResponse(DiscoveryNode node) { + super(node); + } +} diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateRequest.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateRequest.java new file mode 100644 index 0000000000..f78c0370d5 --- /dev/null +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateRequest.java @@ -0,0 +1,35 @@ +/* + * 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.action.apitokens; + +import java.io.IOException; + +import org.opensearch.action.support.nodes.BaseNodesRequest; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; + +public class ApiTokenUpdateRequest extends BaseNodesRequest { + + public ApiTokenUpdateRequest(StreamInput in) throws IOException { + super(in); + } + + public ApiTokenUpdateRequest() throws IOException { + super(new String[0]); + } + + @Override + public void writeTo(final StreamOutput out) throws IOException { + super.writeTo(out); + } + +} diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateResponse.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateResponse.java new file mode 100644 index 0000000000..99d94bd578 --- /dev/null +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenUpdateResponse.java @@ -0,0 +1,60 @@ +/* + * 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.action.apitokens; + +import java.io.IOException; +import java.util.List; + +import org.opensearch.action.FailedNodeException; +import org.opensearch.action.support.nodes.BaseNodesResponse; +import org.opensearch.cluster.ClusterName; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.xcontent.ToXContentObject; +import org.opensearch.core.xcontent.XContentBuilder; + +public class ApiTokenUpdateResponse extends BaseNodesResponse implements ToXContentObject { + + public ApiTokenUpdateResponse(StreamInput in) throws IOException { + super(in); + } + + public ApiTokenUpdateResponse( + final ClusterName clusterName, + List nodes, + List failures + ) { + super(clusterName, nodes, failures); + } + + @Override + public List readNodesFrom(final StreamInput in) throws IOException { + return in.readList(ApiTokenUpdateNodeResponse::new); + } + + @Override + public void writeNodesTo(final StreamOutput out, List nodes) throws IOException { + out.writeList(nodes); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject("ApiTokenupdate_response"); + builder.field("nodes", getNodesMap()); + builder.field("node_size", getNodes().size()); + builder.field("has_failures", hasFailures()); + builder.field("failures_size", failures().size()); + builder.endObject(); + + return builder; + } +} diff --git a/src/main/java/org/opensearch/security/action/apitokens/TransportApiTokenUpdateAction.java b/src/main/java/org/opensearch/security/action/apitokens/TransportApiTokenUpdateAction.java new file mode 100644 index 0000000000..f47bdfad81 --- /dev/null +++ b/src/main/java/org/opensearch/security/action/apitokens/TransportApiTokenUpdateAction.java @@ -0,0 +1,104 @@ +/* + * 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.action.apitokens; + +import java.io.IOException; +import java.util.List; + +import org.opensearch.action.FailedNodeException; +import org.opensearch.action.support.ActionFilters; +import org.opensearch.action.support.nodes.TransportNodesAction; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.inject.Inject; +import org.opensearch.common.settings.Settings; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.threadpool.ThreadPool; +import org.opensearch.transport.TransportRequest; +import org.opensearch.transport.TransportService; + +public class TransportApiTokenUpdateAction extends TransportNodesAction< + ApiTokenUpdateRequest, + ApiTokenUpdateResponse, + TransportApiTokenUpdateAction.NodeApiTokenUpdateRequest, + ApiTokenUpdateNodeResponse> { + + private final ApiTokenIndexListenerCache apiTokenCache; + private final ClusterService clusterService; + + @Inject + public TransportApiTokenUpdateAction( + Settings settings, + ThreadPool threadPool, + ClusterService clusterService, + TransportService transportService, + ActionFilters actionFilters + ) { + super( + ApiTokenUpdateAction.NAME, + threadPool, + clusterService, + transportService, + actionFilters, + ApiTokenUpdateRequest::new, + TransportApiTokenUpdateAction.NodeApiTokenUpdateRequest::new, + ThreadPool.Names.MANAGEMENT, + ApiTokenUpdateNodeResponse.class + ); + this.apiTokenCache = ApiTokenIndexListenerCache.getInstance(); + this.clusterService = clusterService; + } + + public static class NodeApiTokenUpdateRequest extends TransportRequest { + ApiTokenUpdateRequest request; + + public NodeApiTokenUpdateRequest(ApiTokenUpdateRequest request) { + this.request = request; + } + + public NodeApiTokenUpdateRequest(StreamInput streamInput) throws IOException { + super(streamInput); + this.request = new ApiTokenUpdateRequest(streamInput); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + super.writeTo(out); + request.writeTo(out); + } + } + + @Override + protected ApiTokenUpdateNodeResponse newNodeResponse(StreamInput in) throws IOException { + return new ApiTokenUpdateNodeResponse(in); + } + + @Override + protected ApiTokenUpdateResponse newResponse( + ApiTokenUpdateRequest request, + List responses, + List failures + ) { + return new ApiTokenUpdateResponse(this.clusterService.getClusterName(), responses, failures); + } + + @Override + protected NodeApiTokenUpdateRequest newNodeRequest(ApiTokenUpdateRequest request) { + return new NodeApiTokenUpdateRequest(request); + } + + @Override + protected ApiTokenUpdateNodeResponse nodeOperation(final NodeApiTokenUpdateRequest request) { + apiTokenCache.reloadApiTokensFromIndex(); + return new ApiTokenUpdateNodeResponse(clusterService.localNode()); + } +} diff --git a/src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java b/src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java index 0da8d5447d..482cb39ff0 100644 --- a/src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java +++ b/src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java @@ -141,7 +141,7 @@ private AuthCredentials extractCredentials0(final SecurityRequest request, final } // TODO: handle revocation different from deletion? - if (!cache.getJtis().containsKey(encryptionUtil.encrypt(jwtToken))) { + if (!cache.isValidToken(encryptionUtil.encrypt(jwtToken))) { log.error("Token is not allowlisted"); return null; } diff --git a/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java b/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java index d722231796..13d515ab10 100644 --- a/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java +++ b/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java @@ -429,10 +429,10 @@ PrivilegesEvaluatorResponse apiTokenProvidesClusterPrivilege( String userName = context.getUser().getName(); if (userName.startsWith("apitoken") && userName.contains(":")) { String jti = context.getUser().getName().split(":")[1]; - if (context.getApiTokenIndexListenerCache().getJtis().get(jti) != null) { + if (context.getApiTokenIndexListenerCache().isValidToken(jti)) { // Expand the action groups Set resolvedClusterPermissions = actionGroups.resolve( - context.getApiTokenIndexListenerCache().getJtis().get(jti).getClusterPerm() + context.getApiTokenIndexListenerCache().getPermissionsForJti(jti).getClusterPerm() ); // Check for wildcard permission @@ -921,10 +921,9 @@ PrivilegesEvaluatorResponse apiTokenProvidesIndexPrivilege( String userName = context.getUser().getName(); if (userName.startsWith("apitoken") && userName.contains(":")) { String jti = context.getUser().getName().split(":")[1]; - if (context.getApiTokenIndexListenerCache().getJtis().get(jti) != null) { + if (context.getApiTokenIndexListenerCache().isValidToken(jti)) { List indexPermissions = context.getApiTokenIndexListenerCache() - .getJtis() - .get(jti) + .getPermissionsForJti(jti) .getIndexPermission(); for (String concreteIndex : resolvedIndices.getAllIndices()) { diff --git a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenAuthenticatorTest.java b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenAuthenticatorTest.java index 3de70d1302..0ee0ef3e5c 100644 --- a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenAuthenticatorTest.java +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenAuthenticatorTest.java @@ -31,7 +31,6 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.any; import static org.mockito.Mockito.eq; import static org.mockito.Mockito.mock; @@ -65,7 +64,7 @@ public void setUp() { public void testAuthenticationFailsWhenJtiNotInCache() { String testJti = "test-jti-not-in-cache"; ApiTokenIndexListenerCache cache = ApiTokenIndexListenerCache.getInstance(); - assertFalse(cache.getJtis().containsKey(testJti)); + assertFalse(cache.isValidToken(testJti)); SecurityRequest request = mock(SecurityRequest.class); when(request.header("Authorization")).thenReturn("Bearer " + testJti); @@ -82,9 +81,7 @@ public void testExtractCredentialsPassWhenJtiInCache() { "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZXN0LXRva2VuIiwiYXVkIjoidGVzdC10b2tlbiIsIm5iZiI6MTczNTMyNjM0NywiaXAiOiJnaGlWTXVnWlBtcHZJMjZIT2hmUTRnaEJ1Qkh0Y2x6c1REYVlxQjVBRklyTkE4SzJCVTdxc2toMVBCOEMzQWpTdVBaREM0THVSM2pjZkdpLzlkU2ZicDBuQTNGMkhtSi9jaDA3cDY2ZWJ6OD0iLCJpc3MiOiJvcGVuc2VhcmNoLWNsdXN0ZXIiLCJleHAiOjIyMTg3NjU2NDMzMCwiaWF0IjoxNzM1MzI2MzQ3LCJjcCI6IjlPS3N4aFRaZ1pmOVN1akprRzFXVWIzVC9RVTZ4YmZTc2hrbllkVUhraHM9In0.kqMSnn5YwhLmeiI_8iIBQ5uhmI52n2MNniAa52Zpfs3TiE_PXKiNbDNs08hNqzGYW772gT7lfvp6kZnFxQ4v2Q"; String encryptedTestJti = "k3JQNRXR57Y4V4W1LNkpEP+QzcDra5+/fFfQrr0Rncr/RhYAnYEDgN9RKcJ2Wmjf63NCQa9+HjeFcn0H2wKhMm7pl9bd3ya9FO+LUD7t9Sih4DOjUt0t7ee4ROC0eRK5glMsKsKQVkuY+YKa6A6dT8bMqmt7kIrer7w8TRENr9J8x41TGb/cDDWDvJLME7QkFzJjMxYDgKNiEevMbOpC8yjIZdK08jPe3Thq+xm+JYruoYeyI5g8QjkJA9ZOs1f6eXTAvPxhseuPqgIKykRE25fuWjl5n9tJ9W+jpl+iET7zzOLXSPEU5UepL/COkVd6xW63Ay72oMOewqveDXzyR8S8LAfgRuKgYZWms7yT37XcGg0c6Y7M62KVPo+1XQ+F+K5bgddkd8G+I9KHf561jIMzBcIodgGRj659954W16D1C92+PF/YWPQoTv2hVK4f60H82ga1YSiz3r9UrFV8d7gLJwtyJT9HNPuXO2VZ7xPhre+n1Wv7No0kH2S/r3nqKK6Bk/kn1ZbAmjLxuw13c95lIir6avlKE7XX4PiQDfcGeAyeXOw/36kLW8wH7kjXWdBspld1AiI4fCOaszNXF+7gcuTxIhECl+mEyrJbMI88EWllq+LbydiOrVLFXXRMiCbvj+VTYjzimgJPp+Vuvg=="; - ApiTokenIndexListenerCache cache = ApiTokenIndexListenerCache.getInstance(); - cache.getJtis().put(encryptedTestJti, new Permissions(List.of(), List.of())); - assertTrue(cache.getJtis().containsKey(encryptedTestJti)); + ApiTokenIndexListenerCache.getInstance().getJtis().put(encryptedTestJti, new Permissions(List.of(), List.of())); SecurityRequest request = mock(SecurityRequest.class); when(request.header("Authorization")).thenReturn("Bearer " + testJti); @@ -101,9 +98,7 @@ public void testExtractCredentialsFailWhenTokenIsExpired() { "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZXN0LXRva2VuIiwiYXVkIjoidGVzdC10b2tlbiIsIm5iZiI6MTczNTMyNjU4MiwiaXAiOiJnaGlWTXVnWlBtcHZJMjZIT2hmUTRnaEJ1Qkh0Y2x6c1REYVlxQjVBRklyTkE4SzJCVTdxc2toMVBCOEMzQWpTdVBaREM0THVSM2pjZkdpLzlkU2ZicDBuQTNGMkhtSi9jaDA3cDY2ZWJ6OD0iLCJpc3MiOiJvcGVuc2VhcmNoLWNsdXN0ZXIiLCJleHAiOjI5MDI5NDksImlhdCI6MTczNTMyNjU4MiwiY3AiOiI5T0tzeGhUWmdaZjlTdWpKa0cxV1ViM1QvUVU2eGJmU3Noa25ZZFVIa2hzPSJ9.-f45IAU4jE8EbDuthsPFm-TxtJCk8Q_uRmnG4sEkfLtjmp8mHUbSaS109YRGxKDVr3uEMgFwvkSKEFt7DHhf9A"; String encryptedTestJti = "k3JQNRXR57Y4V4W1LNkpEP+QzcDra5+/fFfQrr0Rncr/RhYAnYEDgN9RKcJ2Wmjf63NCQa9+HjeFcn0H2wKhMm7pl9bd3ya9FO+LUD7t9ShsbOyBUkpFSVuQwrXLatY+glMsKsKQVkuY+YKa6A6dT8bMqmt7kIrer7w8TRENr9J8x41TGb/cDDWDvJLME7QkFzJjMxYDgKNiEevMbOpC8yjIZdK08jPe3Thq+xm+JYruoYeyI5g8QjkJA9ZOs1f6eXTAvPxhseuPqgIKykRE25fuWjl5n9tJ9W+jpl+iET7zzOLXSPEU5UepL/COkVd6xW63Ay72oMOewqveDXzyR8S8LAfgRuKgYZWms7yT37XcGg0c6Y7M62KVPo+1XQ+Fu193YtvS4vqt9G8jHiq51VCRxNHYVlAsratxzvECD8AKBilR9/7dUKyOQDBIzPG4ws+kgI680SgdMgGuLANQPGzal9US8GsWzTbQWCgtObaSVKB02U4gh16wvy3XrXtPz2Z0ZAxoU2Z8opX8hcvB5MG5UUEf+tpgTtVPcbuJyCL42yD3FIc3v/LCYlG/hFvflXBx5c1r+4Tij8Qc/NkYb7/03xiJsVH6eduSqR9M0QBpLm7xg2TgqVMvC/+n96x2V3lS4via4lAK6xuYeRY0ng=="; - ApiTokenIndexListenerCache cache = ApiTokenIndexListenerCache.getInstance(); - cache.getJtis().put(encryptedTestJti, new Permissions(List.of(), List.of())); - assertTrue(cache.getJtis().containsKey(encryptedTestJti)); + ApiTokenIndexListenerCache.getInstance().getJtis().put(encryptedTestJti, new Permissions(List.of(), List.of())); SecurityRequest request = mock(SecurityRequest.class); when(request.header("Authorization")).thenReturn("Bearer " + testJti); @@ -122,9 +117,7 @@ public void testExtractCredentialsFailWhenIssuerDoesNotMatch() { "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZXN0LXRva2VuIiwiYXVkIjoidGVzdC10b2tlbiIsIm5iZiI6MTczNTMyNjM0NywiaXAiOiJnaGlWTXVnWlBtcHZJMjZIT2hmUTRnaEJ1Qkh0Y2x6c1REYVlxQjVBRklyTkE4SzJCVTdxc2toMVBCOEMzQWpTdVBaREM0THVSM2pjZkdpLzlkU2ZicDBuQTNGMkhtSi9jaDA3cDY2ZWJ6OD0iLCJpc3MiOiJvcGVuc2VhcmNoLWNsdXN0ZXIiLCJleHAiOjIyMTg3NjU2NDMzMCwiaWF0IjoxNzM1MzI2MzQ3LCJjcCI6IjlPS3N4aFRaZ1pmOVN1akprRzFXVWIzVC9RVTZ4YmZTc2hrbllkVUhraHM9In0.kqMSnn5YwhLmeiI_8iIBQ5uhmI52n2MNniAa52Zpfs3TiE_PXKiNbDNs08hNqzGYW772gT7lfvp6kZnFxQ4v2Q"; String encryptedTestJti = "k3JQNRXR57Y4V4W1LNkpEP+QzcDra5+/fFfQrr0Rncr/RhYAnYEDgN9RKcJ2Wmjf63NCQa9+HjeFcn0H2wKhMm7pl9bd3ya9FO+LUD7t9Sih4DOjUt0t7ee4ROC0eRK5glMsKsKQVkuY+YKa6A6dT8bMqmt7kIrer7w8TRENr9J8x41TGb/cDDWDvJLME7QkFzJjMxYDgKNiEevMbOpC8yjIZdK08jPe3Thq+xm+JYruoYeyI5g8QjkJA9ZOs1f6eXTAvPxhseuPqgIKykRE25fuWjl5n9tJ9W+jpl+iET7zzOLXSPEU5UepL/COkVd6xW63Ay72oMOewqveDXzyR8S8LAfgRuKgYZWms7yT37XcGg0c6Y7M62KVPo+1XQ+F+K5bgddkd8G+I9KHf561jIMzBcIodgGRj659954W16D1C92+PF/YWPQoTv2hVK4f60H82ga1YSiz3r9UrFV8d7gLJwtyJT9HNPuXO2VZ7xPhre+n1Wv7No0kH2S/r3nqKK6Bk/kn1ZbAmjLxuw13c95lIir6avlKE7XX4PiQDfcGeAyeXOw/36kLW8wH7kjXWdBspld1AiI4fCOaszNXF+7gcuTxIhECl+mEyrJbMI88EWllq+LbydiOrVLFXXRMiCbvj+VTYjzimgJPp+Vuvg=="; - ApiTokenIndexListenerCache cache = ApiTokenIndexListenerCache.getInstance(); - cache.getJtis().put(encryptedTestJti, new Permissions(List.of(), List.of())); - assertTrue(cache.getJtis().containsKey(encryptedTestJti)); + ApiTokenIndexListenerCache.getInstance().getJtis().put(encryptedTestJti, new Permissions(List.of(), List.of())); SecurityRequest request = mock(SecurityRequest.class); when(request.header("Authorization")).thenReturn("Bearer " + testJti); @@ -151,9 +144,7 @@ public void testExtractCredentialsFailWhenAccessingRestrictedEndpoint() { "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZXN0LXRva2VuIiwiYXVkIjoidGVzdC10b2tlbiIsIm5iZiI6MTczNTMyNjM0NywiaXAiOiJnaGlWTXVnWlBtcHZJMjZIT2hmUTRnaEJ1Qkh0Y2x6c1REYVlxQjVBRklyTkE4SzJCVTdxc2toMVBCOEMzQWpTdVBaREM0THVSM2pjZkdpLzlkU2ZicDBuQTNGMkhtSi9jaDA3cDY2ZWJ6OD0iLCJpc3MiOiJvcGVuc2VhcmNoLWNsdXN0ZXIiLCJleHAiOjIyMTg3NjU2NDMzMCwiaWF0IjoxNzM1MzI2MzQ3LCJjcCI6IjlPS3N4aFRaZ1pmOVN1akprRzFXVWIzVC9RVTZ4YmZTc2hrbllkVUhraHM9In0.kqMSnn5YwhLmeiI_8iIBQ5uhmI52n2MNniAa52Zpfs3TiE_PXKiNbDNs08hNqzGYW772gT7lfvp6kZnFxQ4v2Q"; String encryptedTestJti = "k3JQNRXR57Y4V4W1LNkpEP+QzcDra5+/fFfQrr0Rncr/RhYAnYEDgN9RKcJ2Wmjf63NCQa9+HjeFcn0H2wKhMm7pl9bd3ya9FO+LUD7t9Sih4DOjUt0t7ee4ROC0eRK5glMsKsKQVkuY+YKa6A6dT8bMqmt7kIrer7w8TRENr9J8x41TGb/cDDWDvJLME7QkFzJjMxYDgKNiEevMbOpC8yjIZdK08jPe3Thq+xm+JYruoYeyI5g8QjkJA9ZOs1f6eXTAvPxhseuPqgIKykRE25fuWjl5n9tJ9W+jpl+iET7zzOLXSPEU5UepL/COkVd6xW63Ay72oMOewqveDXzyR8S8LAfgRuKgYZWms7yT37XcGg0c6Y7M62KVPo+1XQ+F+K5bgddkd8G+I9KHf561jIMzBcIodgGRj659954W16D1C92+PF/YWPQoTv2hVK4f60H82ga1YSiz3r9UrFV8d7gLJwtyJT9HNPuXO2VZ7xPhre+n1Wv7No0kH2S/r3nqKK6Bk/kn1ZbAmjLxuw13c95lIir6avlKE7XX4PiQDfcGeAyeXOw/36kLW8wH7kjXWdBspld1AiI4fCOaszNXF+7gcuTxIhECl+mEyrJbMI88EWllq+LbydiOrVLFXXRMiCbvj+VTYjzimgJPp+Vuvg=="; - ApiTokenIndexListenerCache cache = ApiTokenIndexListenerCache.getInstance(); - cache.getJtis().put(encryptedTestJti, new Permissions(List.of(), List.of())); - assertTrue(cache.getJtis().containsKey(encryptedTestJti)); + ApiTokenIndexListenerCache.getInstance().getJtis().put(encryptedTestJti, new Permissions(List.of(), List.of())); SecurityRequest request = mock(SecurityRequest.class); when(request.header("Authorization")).thenReturn("Bearer " + testJti); @@ -163,16 +154,13 @@ public void testExtractCredentialsFailWhenAccessingRestrictedEndpoint() { assertNull("Should return null when JTI is being used to access restricted endpoint", ac); verify(log).error("OpenSearchException[Api Tokens are not allowed to be used for accessing this endpoint.]"); - } @Test public void testAuthenticatorNotEnabled() { String encryptedTestJti = "k3JQNRXR57Y4V4W1LNkpEP+QzcDra5+/fFfQrr0Rncr/RhYAnYEDgN9RKcJ2Wmjf63NCQa9+HjeFcn0H2wKhMm7pl9bd3ya9FO+LUD7t9Sih4DOjUt0t7ee4ROC0eRK5glMsKsKQVkuY+YKa6A6dT8bMqmt7kIrer7w8TRENr9J8x41TGb/cDDWDvJLME7QkFzJjMxYDgKNiEevMbOpC8yjIZdK08jPe3Thq+xm+JYruoYeyI5g8QjkJA9ZOs1f6eXTAvPxhseuPqgIKykRE25fuWjl5n9tJ9W+jpl+iET7zzOLXSPEU5UepL/COkVd6xW63Ay72oMOewqveDXzyR8S8LAfgRuKgYZWms7yT37XcGg0c6Y7M62KVPo+1XQ+F+K5bgddkd8G+I9KHf561jIMzBcIodgGRj659954W16D1C92+PF/YWPQoTv2hVK4f60H82ga1YSiz3r9UrFV8d7gLJwtyJT9HNPuXO2VZ7xPhre+n1Wv7No0kH2S/r3nqKK6Bk/kn1ZbAmjLxuw13c95lIir6avlKE7XX4PiQDfcGeAyeXOw/36kLW8wH7kjXWdBspld1AiI4fCOaszNXF+7gcuTxIhECl+mEyrJbMI88EWllq+LbydiOrVLFXXRMiCbvj+VTYjzimgJPp+Vuvg=="; - ApiTokenIndexListenerCache cache = ApiTokenIndexListenerCache.getInstance(); - cache.getJtis().put(encryptedTestJti, new Permissions(List.of(), List.of())); - assertTrue(cache.getJtis().containsKey(encryptedTestJti)); + ApiTokenIndexListenerCache.getInstance().getJtis().put(encryptedTestJti, new Permissions(List.of(), List.of())); SecurityRequest request = mock(SecurityRequest.class); From b90bae9de87bd8be3e697fa1732f6c573c10acf3 Mon Sep 17 00:00:00 2001 From: Derek Ho Date: Mon, 6 Jan 2025 14:30:09 -0500 Subject: [PATCH 13/23] Cleanup tests and constants Signed-off-by: Derek Ho --- .../apitokens/ApiTokenIndexListenerCache.java | 11 -- .../security/http/ApiTokenAuthenticator.java | 6 +- .../security/privileges/ActionPrivileges.java | 10 +- .../apitokens/ApiTokenAuthenticatorTest.java | 112 ++++++++++++------ 4 files changed, 81 insertions(+), 58 deletions(-) diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexListenerCache.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexListenerCache.java index a27c1e06db..501638e9d4 100644 --- a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexListenerCache.java +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexListenerCache.java @@ -74,13 +74,9 @@ void reloadApiTokensFromIndex() { } try { - // Clear existing caches - log.info("Reloading API tokens cache from index: {}", jtis.entrySet().toString()); - idToJtiMap.clear(); jtis.clear(); - // Search request to get all API tokens from the security index client.prepareSearch(getSecurityIndexName()) .setQuery(QueryBuilders.matchAllQuery()) .execute() @@ -104,24 +100,17 @@ void reloadApiTokensFromIndex() { } private String getSecurityIndexName() { - // Return the name of your security index return ConfigConstants.OPENSEARCH_API_TOKENS_INDEX; } @SuppressWarnings("unchecked") private Permissions parsePermissions(Map source) { - // Implement parsing logic for permissions from the document return new Permissions( (List) source.get(ApiToken.CLUSTER_PERMISSIONS_FIELD), (List) source.get(ApiToken.INDEX_PERMISSIONS_FIELD) ); } - // Getter methods for cached data - public String getJtiForId(String id) { - return idToJtiMap.get(id); - } - public Permissions getPermissionsForJti(String jti) { return jtis.get(jti); } diff --git a/src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java b/src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java index 482cb39ff0..86086eee1e 100644 --- a/src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java +++ b/src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java @@ -60,6 +60,7 @@ public class ApiTokenAuthenticator implements HTTPAuthenticator { private final String encryptionKey; private final Boolean apiTokenEnabled; private final String clusterName; + public static final String API_TOKEN_USER_PREFIX = "apitoken:"; private final EncryptionDecryptionUtil encryptionUtil; @@ -161,10 +162,7 @@ private AuthCredentials extractCredentials0(final SecurityRequest request, final return null; } - final AuthCredentials ac = new AuthCredentials("apitoken_" + subject + ":" + encryptionUtil.encrypt(jwtToken), List.of(), "") - .markComplete(); - - return ac; + return new AuthCredentials(API_TOKEN_USER_PREFIX + encryptionUtil.encrypt(jwtToken), List.of(), "").markComplete(); } catch (WeakKeyException e) { log.error("Cannot authenticate user with JWT because of ", e); diff --git a/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java b/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java index 13d515ab10..a3bb2dc3ad 100644 --- a/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java +++ b/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java @@ -49,6 +49,8 @@ import com.selectivem.collections.DeduplicatingCompactSubSetBuilder; import com.selectivem.collections.ImmutableCompactSubSet; +import static org.opensearch.security.http.ApiTokenAuthenticator.API_TOKEN_USER_PREFIX; + /** * This class converts role configuration into pre-computed, optimized data structures for checking privileges. *

@@ -427,8 +429,8 @@ PrivilegesEvaluatorResponse apiTokenProvidesClusterPrivilege( Boolean explicit ) { String userName = context.getUser().getName(); - if (userName.startsWith("apitoken") && userName.contains(":")) { - String jti = context.getUser().getName().split(":")[1]; + if (userName.startsWith(API_TOKEN_USER_PREFIX)) { + String jti = context.getUser().getName().split(API_TOKEN_USER_PREFIX)[1]; if (context.getApiTokenIndexListenerCache().isValidToken(jti)) { // Expand the action groups Set resolvedClusterPermissions = actionGroups.resolve( @@ -919,8 +921,8 @@ PrivilegesEvaluatorResponse apiTokenProvidesIndexPrivilege( Boolean explicit ) { String userName = context.getUser().getName(); - if (userName.startsWith("apitoken") && userName.contains(":")) { - String jti = context.getUser().getName().split(":")[1]; + if (userName.startsWith(API_TOKEN_USER_PREFIX)) { + String jti = context.getUser().getName().split(API_TOKEN_USER_PREFIX)[1]; if (context.getApiTokenIndexListenerCache().isValidToken(jti)) { List indexPermissions = context.getApiTokenIndexListenerCache() .getPermissionsForJti(jti) diff --git a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenAuthenticatorTest.java b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenAuthenticatorTest.java index 0ee0ef3e5c..916b7d86fc 100644 --- a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenAuthenticatorTest.java +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenAuthenticatorTest.java @@ -11,6 +11,11 @@ package org.opensearch.security.action.apitokens; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Base64; +import java.util.Date; import java.util.List; import org.apache.logging.log4j.Logger; @@ -20,11 +25,14 @@ import org.opensearch.common.settings.Settings; import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.security.authtoken.jwt.EncryptionDecryptionUtil; import org.opensearch.security.filter.SecurityRequest; import org.opensearch.security.http.ApiTokenAuthenticator; import org.opensearch.security.user.AuthCredentials; import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; @@ -45,13 +53,17 @@ public class ApiTokenAuthenticatorTest { private Logger log; private ThreadContext threadcontext; + private final String signingKey = Base64.getEncoder() + .encodeToString("jwt signing key long enough for secure api token authentication testing".getBytes(StandardCharsets.UTF_8)); + private final String encryptionKey = Base64.getEncoder().encodeToString("123456678910".getBytes(StandardCharsets.UTF_8)); + private final EncryptionDecryptionUtil encryptionUtil = new EncryptionDecryptionUtil(encryptionKey); @Before public void setUp() { Settings settings = Settings.builder() .put("enabled", "true") - .put("signing_key", "U3VwZXJTZWNyZXRLZXlUaGF0SXNFeGFjdGx5NjRCeXRlc0xvbmdBbmRXaWxsV29ya1dpdGhIUzUxMkFsZ29yaXRobSEhCgo=") - .put("encryption_key", "MTIzNDU2Nzg5MDEyMzQ1Ng==") + .put("signing_key", signingKey) + .put("encryption_key", encryptionKey) .build(); authenticator = new ApiTokenAuthenticator(settings, "opensearch-cluster"); @@ -77,14 +89,20 @@ public void testAuthenticationFailsWhenJtiNotInCache() { @Test public void testExtractCredentialsPassWhenJtiInCache() { - String testJti = - "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZXN0LXRva2VuIiwiYXVkIjoidGVzdC10b2tlbiIsIm5iZiI6MTczNTMyNjM0NywiaXAiOiJnaGlWTXVnWlBtcHZJMjZIT2hmUTRnaEJ1Qkh0Y2x6c1REYVlxQjVBRklyTkE4SzJCVTdxc2toMVBCOEMzQWpTdVBaREM0THVSM2pjZkdpLzlkU2ZicDBuQTNGMkhtSi9jaDA3cDY2ZWJ6OD0iLCJpc3MiOiJvcGVuc2VhcmNoLWNsdXN0ZXIiLCJleHAiOjIyMTg3NjU2NDMzMCwiaWF0IjoxNzM1MzI2MzQ3LCJjcCI6IjlPS3N4aFRaZ1pmOVN1akprRzFXVWIzVC9RVTZ4YmZTc2hrbllkVUhraHM9In0.kqMSnn5YwhLmeiI_8iIBQ5uhmI52n2MNniAa52Zpfs3TiE_PXKiNbDNs08hNqzGYW772gT7lfvp6kZnFxQ4v2Q"; - String encryptedTestJti = - "k3JQNRXR57Y4V4W1LNkpEP+QzcDra5+/fFfQrr0Rncr/RhYAnYEDgN9RKcJ2Wmjf63NCQa9+HjeFcn0H2wKhMm7pl9bd3ya9FO+LUD7t9Sih4DOjUt0t7ee4ROC0eRK5glMsKsKQVkuY+YKa6A6dT8bMqmt7kIrer7w8TRENr9J8x41TGb/cDDWDvJLME7QkFzJjMxYDgKNiEevMbOpC8yjIZdK08jPe3Thq+xm+JYruoYeyI5g8QjkJA9ZOs1f6eXTAvPxhseuPqgIKykRE25fuWjl5n9tJ9W+jpl+iET7zzOLXSPEU5UepL/COkVd6xW63Ay72oMOewqveDXzyR8S8LAfgRuKgYZWms7yT37XcGg0c6Y7M62KVPo+1XQ+F+K5bgddkd8G+I9KHf561jIMzBcIodgGRj659954W16D1C92+PF/YWPQoTv2hVK4f60H82ga1YSiz3r9UrFV8d7gLJwtyJT9HNPuXO2VZ7xPhre+n1Wv7No0kH2S/r3nqKK6Bk/kn1ZbAmjLxuw13c95lIir6avlKE7XX4PiQDfcGeAyeXOw/36kLW8wH7kjXWdBspld1AiI4fCOaszNXF+7gcuTxIhECl+mEyrJbMI88EWllq+LbydiOrVLFXXRMiCbvj+VTYjzimgJPp+Vuvg=="; - ApiTokenIndexListenerCache.getInstance().getJtis().put(encryptedTestJti, new Permissions(List.of(), List.of())); + String token = Jwts.builder() + .setIssuer("opensearch-cluster") + .setSubject("test-token") + .setAudience("test-token") + .setIssuedAt(Date.from(Instant.now())) + .setExpiration(Date.from(Instant.now().plus(1, ChronoUnit.DAYS))) + .signWith(SignatureAlgorithm.HS512, signingKey) + .compact(); + + String encryptedToken = encryptionUtil.encrypt(token); + ApiTokenIndexListenerCache.getInstance().getJtis().put(encryptedToken, new Permissions(List.of(), List.of())); SecurityRequest request = mock(SecurityRequest.class); - when(request.header("Authorization")).thenReturn("Bearer " + testJti); + when(request.header("Authorization")).thenReturn("Bearer " + token); when(request.path()).thenReturn("/test"); AuthCredentials ac = authenticator.extractCredentials(request, threadcontext); @@ -94,14 +112,20 @@ public void testExtractCredentialsPassWhenJtiInCache() { @Test public void testExtractCredentialsFailWhenTokenIsExpired() { - String testJti = - "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZXN0LXRva2VuIiwiYXVkIjoidGVzdC10b2tlbiIsIm5iZiI6MTczNTMyNjU4MiwiaXAiOiJnaGlWTXVnWlBtcHZJMjZIT2hmUTRnaEJ1Qkh0Y2x6c1REYVlxQjVBRklyTkE4SzJCVTdxc2toMVBCOEMzQWpTdVBaREM0THVSM2pjZkdpLzlkU2ZicDBuQTNGMkhtSi9jaDA3cDY2ZWJ6OD0iLCJpc3MiOiJvcGVuc2VhcmNoLWNsdXN0ZXIiLCJleHAiOjI5MDI5NDksImlhdCI6MTczNTMyNjU4MiwiY3AiOiI5T0tzeGhUWmdaZjlTdWpKa0cxV1ViM1QvUVU2eGJmU3Noa25ZZFVIa2hzPSJ9.-f45IAU4jE8EbDuthsPFm-TxtJCk8Q_uRmnG4sEkfLtjmp8mHUbSaS109YRGxKDVr3uEMgFwvkSKEFt7DHhf9A"; - String encryptedTestJti = - "k3JQNRXR57Y4V4W1LNkpEP+QzcDra5+/fFfQrr0Rncr/RhYAnYEDgN9RKcJ2Wmjf63NCQa9+HjeFcn0H2wKhMm7pl9bd3ya9FO+LUD7t9ShsbOyBUkpFSVuQwrXLatY+glMsKsKQVkuY+YKa6A6dT8bMqmt7kIrer7w8TRENr9J8x41TGb/cDDWDvJLME7QkFzJjMxYDgKNiEevMbOpC8yjIZdK08jPe3Thq+xm+JYruoYeyI5g8QjkJA9ZOs1f6eXTAvPxhseuPqgIKykRE25fuWjl5n9tJ9W+jpl+iET7zzOLXSPEU5UepL/COkVd6xW63Ay72oMOewqveDXzyR8S8LAfgRuKgYZWms7yT37XcGg0c6Y7M62KVPo+1XQ+Fu193YtvS4vqt9G8jHiq51VCRxNHYVlAsratxzvECD8AKBilR9/7dUKyOQDBIzPG4ws+kgI680SgdMgGuLANQPGzal9US8GsWzTbQWCgtObaSVKB02U4gh16wvy3XrXtPz2Z0ZAxoU2Z8opX8hcvB5MG5UUEf+tpgTtVPcbuJyCL42yD3FIc3v/LCYlG/hFvflXBx5c1r+4Tij8Qc/NkYb7/03xiJsVH6eduSqR9M0QBpLm7xg2TgqVMvC/+n96x2V3lS4via4lAK6xuYeRY0ng=="; - ApiTokenIndexListenerCache.getInstance().getJtis().put(encryptedTestJti, new Permissions(List.of(), List.of())); + String token = Jwts.builder() + .setIssuer("opensearch-cluster") + .setSubject("test-token") + .setAudience("test-token") + .setIssuedAt(Date.from(Instant.now())) + .setExpiration(Date.from(Instant.now().minus(1, ChronoUnit.DAYS))) + .signWith(SignatureAlgorithm.HS512, signingKey) + .compact(); + + String encryptedToken = encryptionUtil.encrypt(token); + ApiTokenIndexListenerCache.getInstance().getJtis().put(encryptedToken, new Permissions(List.of(), List.of())); SecurityRequest request = mock(SecurityRequest.class); - when(request.header("Authorization")).thenReturn("Bearer " + testJti); + when(request.header("Authorization")).thenReturn("Bearer " + token); when(request.path()).thenReturn("/test"); AuthCredentials ac = authenticator.extractCredentials(request, threadcontext); @@ -113,25 +137,22 @@ public void testExtractCredentialsFailWhenTokenIsExpired() { @Test public void testExtractCredentialsFailWhenIssuerDoesNotMatch() { - String testJti = - "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZXN0LXRva2VuIiwiYXVkIjoidGVzdC10b2tlbiIsIm5iZiI6MTczNTMyNjM0NywiaXAiOiJnaGlWTXVnWlBtcHZJMjZIT2hmUTRnaEJ1Qkh0Y2x6c1REYVlxQjVBRklyTkE4SzJCVTdxc2toMVBCOEMzQWpTdVBaREM0THVSM2pjZkdpLzlkU2ZicDBuQTNGMkhtSi9jaDA3cDY2ZWJ6OD0iLCJpc3MiOiJvcGVuc2VhcmNoLWNsdXN0ZXIiLCJleHAiOjIyMTg3NjU2NDMzMCwiaWF0IjoxNzM1MzI2MzQ3LCJjcCI6IjlPS3N4aFRaZ1pmOVN1akprRzFXVWIzVC9RVTZ4YmZTc2hrbllkVUhraHM9In0.kqMSnn5YwhLmeiI_8iIBQ5uhmI52n2MNniAa52Zpfs3TiE_PXKiNbDNs08hNqzGYW772gT7lfvp6kZnFxQ4v2Q"; - String encryptedTestJti = - "k3JQNRXR57Y4V4W1LNkpEP+QzcDra5+/fFfQrr0Rncr/RhYAnYEDgN9RKcJ2Wmjf63NCQa9+HjeFcn0H2wKhMm7pl9bd3ya9FO+LUD7t9Sih4DOjUt0t7ee4ROC0eRK5glMsKsKQVkuY+YKa6A6dT8bMqmt7kIrer7w8TRENr9J8x41TGb/cDDWDvJLME7QkFzJjMxYDgKNiEevMbOpC8yjIZdK08jPe3Thq+xm+JYruoYeyI5g8QjkJA9ZOs1f6eXTAvPxhseuPqgIKykRE25fuWjl5n9tJ9W+jpl+iET7zzOLXSPEU5UepL/COkVd6xW63Ay72oMOewqveDXzyR8S8LAfgRuKgYZWms7yT37XcGg0c6Y7M62KVPo+1XQ+F+K5bgddkd8G+I9KHf561jIMzBcIodgGRj659954W16D1C92+PF/YWPQoTv2hVK4f60H82ga1YSiz3r9UrFV8d7gLJwtyJT9HNPuXO2VZ7xPhre+n1Wv7No0kH2S/r3nqKK6Bk/kn1ZbAmjLxuw13c95lIir6avlKE7XX4PiQDfcGeAyeXOw/36kLW8wH7kjXWdBspld1AiI4fCOaszNXF+7gcuTxIhECl+mEyrJbMI88EWllq+LbydiOrVLFXXRMiCbvj+VTYjzimgJPp+Vuvg=="; - ApiTokenIndexListenerCache.getInstance().getJtis().put(encryptedTestJti, new Permissions(List.of(), List.of())); + String token = Jwts.builder() + .setIssuer("not-opensearch-cluster") + .setSubject("test-token") + .setAudience("test-token") + .setIssuedAt(Date.from(Instant.now())) + .setExpiration(Date.from(Instant.now().plus(1, ChronoUnit.DAYS))) + .signWith(SignatureAlgorithm.HS512, signingKey) + .compact(); + + String encryptedToken = encryptionUtil.encrypt(token); + ApiTokenIndexListenerCache.getInstance().getJtis().put(encryptedToken, new Permissions(List.of(), List.of())); SecurityRequest request = mock(SecurityRequest.class); - when(request.header("Authorization")).thenReturn("Bearer " + testJti); + when(request.header("Authorization")).thenReturn("Bearer " + token); when(request.path()).thenReturn("/test"); - Settings settings = Settings.builder() - .put("enabled", "true") - .put("signing_key", "U3VwZXJTZWNyZXRLZXlUaGF0SXNFeGFjdGx5NjRCeXRlc0xvbmdBbmRXaWxsV29ya1dpdGhIUzUxMkFsZ29yaXRobSEhCgo=") - .put("encryption_key", "MTIzNDU2Nzg5MDEyMzQ1Ng==") - .build(); - - authenticator = new ApiTokenAuthenticator(settings, "opensearch-cluster-name-mismatch"); - authenticator.log = log; - AuthCredentials ac = authenticator.extractCredentials(request, threadcontext); assertNull("Should return null when issuer does not match cluster", ac); @@ -140,14 +161,20 @@ public void testExtractCredentialsFailWhenIssuerDoesNotMatch() { @Test public void testExtractCredentialsFailWhenAccessingRestrictedEndpoint() { - String testJti = - "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZXN0LXRva2VuIiwiYXVkIjoidGVzdC10b2tlbiIsIm5iZiI6MTczNTMyNjM0NywiaXAiOiJnaGlWTXVnWlBtcHZJMjZIT2hmUTRnaEJ1Qkh0Y2x6c1REYVlxQjVBRklyTkE4SzJCVTdxc2toMVBCOEMzQWpTdVBaREM0THVSM2pjZkdpLzlkU2ZicDBuQTNGMkhtSi9jaDA3cDY2ZWJ6OD0iLCJpc3MiOiJvcGVuc2VhcmNoLWNsdXN0ZXIiLCJleHAiOjIyMTg3NjU2NDMzMCwiaWF0IjoxNzM1MzI2MzQ3LCJjcCI6IjlPS3N4aFRaZ1pmOVN1akprRzFXVWIzVC9RVTZ4YmZTc2hrbllkVUhraHM9In0.kqMSnn5YwhLmeiI_8iIBQ5uhmI52n2MNniAa52Zpfs3TiE_PXKiNbDNs08hNqzGYW772gT7lfvp6kZnFxQ4v2Q"; - String encryptedTestJti = - "k3JQNRXR57Y4V4W1LNkpEP+QzcDra5+/fFfQrr0Rncr/RhYAnYEDgN9RKcJ2Wmjf63NCQa9+HjeFcn0H2wKhMm7pl9bd3ya9FO+LUD7t9Sih4DOjUt0t7ee4ROC0eRK5glMsKsKQVkuY+YKa6A6dT8bMqmt7kIrer7w8TRENr9J8x41TGb/cDDWDvJLME7QkFzJjMxYDgKNiEevMbOpC8yjIZdK08jPe3Thq+xm+JYruoYeyI5g8QjkJA9ZOs1f6eXTAvPxhseuPqgIKykRE25fuWjl5n9tJ9W+jpl+iET7zzOLXSPEU5UepL/COkVd6xW63Ay72oMOewqveDXzyR8S8LAfgRuKgYZWms7yT37XcGg0c6Y7M62KVPo+1XQ+F+K5bgddkd8G+I9KHf561jIMzBcIodgGRj659954W16D1C92+PF/YWPQoTv2hVK4f60H82ga1YSiz3r9UrFV8d7gLJwtyJT9HNPuXO2VZ7xPhre+n1Wv7No0kH2S/r3nqKK6Bk/kn1ZbAmjLxuw13c95lIir6avlKE7XX4PiQDfcGeAyeXOw/36kLW8wH7kjXWdBspld1AiI4fCOaszNXF+7gcuTxIhECl+mEyrJbMI88EWllq+LbydiOrVLFXXRMiCbvj+VTYjzimgJPp+Vuvg=="; - ApiTokenIndexListenerCache.getInstance().getJtis().put(encryptedTestJti, new Permissions(List.of(), List.of())); + String token = Jwts.builder() + .setIssuer("opensearch-cluster") + .setSubject("test-token") + .setAudience("test-token") + .setIssuedAt(Date.from(Instant.now())) + .setExpiration(Date.from(Instant.now().plus(1, ChronoUnit.DAYS))) + .signWith(SignatureAlgorithm.HS512, signingKey) + .compact(); + + String encryptedToken = encryptionUtil.encrypt(token); + ApiTokenIndexListenerCache.getInstance().getJtis().put(encryptedToken, new Permissions(List.of(), List.of())); SecurityRequest request = mock(SecurityRequest.class); - when(request.header("Authorization")).thenReturn("Bearer " + testJti); + when(request.header("Authorization")).thenReturn("Bearer " + token); when(request.path()).thenReturn("/_plugins/_security/api/apitokens"); AuthCredentials ac = authenticator.extractCredentials(request, threadcontext); @@ -158,9 +185,16 @@ public void testExtractCredentialsFailWhenAccessingRestrictedEndpoint() { @Test public void testAuthenticatorNotEnabled() { - String encryptedTestJti = - "k3JQNRXR57Y4V4W1LNkpEP+QzcDra5+/fFfQrr0Rncr/RhYAnYEDgN9RKcJ2Wmjf63NCQa9+HjeFcn0H2wKhMm7pl9bd3ya9FO+LUD7t9Sih4DOjUt0t7ee4ROC0eRK5glMsKsKQVkuY+YKa6A6dT8bMqmt7kIrer7w8TRENr9J8x41TGb/cDDWDvJLME7QkFzJjMxYDgKNiEevMbOpC8yjIZdK08jPe3Thq+xm+JYruoYeyI5g8QjkJA9ZOs1f6eXTAvPxhseuPqgIKykRE25fuWjl5n9tJ9W+jpl+iET7zzOLXSPEU5UepL/COkVd6xW63Ay72oMOewqveDXzyR8S8LAfgRuKgYZWms7yT37XcGg0c6Y7M62KVPo+1XQ+F+K5bgddkd8G+I9KHf561jIMzBcIodgGRj659954W16D1C92+PF/YWPQoTv2hVK4f60H82ga1YSiz3r9UrFV8d7gLJwtyJT9HNPuXO2VZ7xPhre+n1Wv7No0kH2S/r3nqKK6Bk/kn1ZbAmjLxuw13c95lIir6avlKE7XX4PiQDfcGeAyeXOw/36kLW8wH7kjXWdBspld1AiI4fCOaszNXF+7gcuTxIhECl+mEyrJbMI88EWllq+LbydiOrVLFXXRMiCbvj+VTYjzimgJPp+Vuvg=="; - ApiTokenIndexListenerCache.getInstance().getJtis().put(encryptedTestJti, new Permissions(List.of(), List.of())); + String token = Jwts.builder() + .setIssuer("opensearch-cluster") + .setSubject("test-token") + .setAudience("test-token") + .setIssuedAt(Date.from(Instant.now())) + .setExpiration(Date.from(Instant.now().plus(1, ChronoUnit.DAYS))) + .signWith(SignatureAlgorithm.HS512, signingKey) + .compact(); + String encryptedToken = encryptionUtil.encrypt(token); + ApiTokenIndexListenerCache.getInstance().getJtis().put(encryptedToken, new Permissions(List.of(), List.of())); SecurityRequest request = mock(SecurityRequest.class); @@ -171,7 +205,7 @@ public void testAuthenticatorNotEnabled() { .build(); ThreadContext threadContext = new ThreadContext(settings); - authenticator = new ApiTokenAuthenticator(settings, "opensearch-cluster-name-mismatch"); + authenticator = new ApiTokenAuthenticator(settings, "opensearch-cluster"); authenticator.log = log; AuthCredentials ac = authenticator.extractCredentials(request, threadContext); From 552aedadf7fc88649b54e213fe8351c3a449d5ae Mon Sep 17 00:00:00 2001 From: Derek Ho Date: Mon, 6 Jan 2025 14:48:47 -0500 Subject: [PATCH 14/23] Fix test Signed-off-by: Derek Ho --- .../security/privileges/ActionPrivilegesTest.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/integrationTest/java/org/opensearch/security/privileges/ActionPrivilegesTest.java b/src/integrationTest/java/org/opensearch/security/privileges/ActionPrivilegesTest.java index ecd76b127c..7bdb0980c1 100644 --- a/src/integrationTest/java/org/opensearch/security/privileges/ActionPrivilegesTest.java +++ b/src/integrationTest/java/org/opensearch/security/privileges/ActionPrivilegesTest.java @@ -270,7 +270,7 @@ public void apiToken_explicit_failsWithWildcard() throws Exception { " - '*'", CType.ROLES); ActionPrivileges subject = new ActionPrivileges(roles, FlattenedActionGroups.EMPTY, null, Settings.EMPTY); String token = "blah"; - PrivilegesEvaluationContext context = ctxWithUserName("apitoken_test:" + token); + PrivilegesEvaluationContext context = ctxWithUserName("apitoken:" + token); context.getApiTokenIndexListenerCache().getJtis().put(token, new Permissions(List.of("*"), List.of())); // Explicit fails assertThat( @@ -290,7 +290,7 @@ public void apiToken_succeedsWithExactMatch() throws Exception { " - '*'", CType.ROLES); ActionPrivileges subject = new ActionPrivileges(roles, FlattenedActionGroups.EMPTY, null, Settings.EMPTY); String token = "blah"; - PrivilegesEvaluationContext context = ctxWithUserName("apitoken_test:" + token); + PrivilegesEvaluationContext context = ctxWithUserName("apitoken:" + token); context.getApiTokenIndexListenerCache().getJtis().put(token, new Permissions(List.of("cluster:whatever"), List.of())); // Explicit succeeds assertThat(subject.hasExplicitClusterPrivilege(context, "cluster:whatever"), isAllowed()); @@ -316,7 +316,7 @@ public void apiToken_succeedsWithActionGroupsExapnded() throws Exception { FlattenedActionGroups actionGroups = new FlattenedActionGroups(config); ActionPrivileges subject = new ActionPrivileges(roles, actionGroups, null, Settings.EMPTY); String token = "blah"; - PrivilegesEvaluationContext context = ctxWithUserName("apitoken_test:" + token); + PrivilegesEvaluationContext context = ctxWithUserName("apitoken:" + token); context.getApiTokenIndexListenerCache().getJtis().put(token, new Permissions(List.of("CLUSTER_ALL"), List.of())); // Explicit succeeds assertThat(subject.hasExplicitClusterPrivilege(context, "cluster:whatever"), isAllowed()); @@ -362,7 +362,7 @@ public void positive_full() throws Exception { @Test public void apiTokens_positive_full() throws Exception { String token = "blah"; - PrivilegesEvaluationContext context = ctxWithUserName("apitoken_test:" + token); + PrivilegesEvaluationContext context = ctxWithUserName("apitoken:" + token); context.getApiTokenIndexListenerCache() .getJtis() .put( @@ -430,7 +430,7 @@ public void negative_wrongRole() throws Exception { @Test public void apiToken_negative_noPermissions() throws Exception { String token = "blah"; - PrivilegesEvaluationContext context = ctxWithUserName("apitoken_test:" + token); + PrivilegesEvaluationContext context = ctxWithUserName("apitoken:" + token); context.getApiTokenIndexListenerCache() .getJtis() .put(token, new Permissions(List.of(), List.of(new ApiToken.IndexPermission(List.of(), List.of())))); @@ -471,7 +471,7 @@ public void positive_hasExplicit_full() { @Test public void apiTokens_positive_hasExplicit_full() { String token = "blah"; - PrivilegesEvaluationContext context = ctxWithUserName("apitoken_test:" + token); + PrivilegesEvaluationContext context = ctxWithUserName("apitoken:" + token); context.getApiTokenIndexListenerCache() .getJtis() .put( From aa506e78eb5699d2580e28d4bb0f0060971449e1 Mon Sep 17 00:00:00 2001 From: Derek Ho Date: Mon, 6 Jan 2025 16:06:40 -0500 Subject: [PATCH 15/23] Remove unecessary id to jti map since we are reloading every time and write test Signed-off-by: Derek Ho --- .../apitokens/ApiTokenIndexListenerCache.java | 21 --- .../ApiTokenIndexListenerCacheTest.java | 165 ++++++++++++++++++ 2 files changed, 165 insertions(+), 21 deletions(-) create mode 100644 src/test/java/org/opensearch/security/action/apitokens/ApiTokenIndexListenerCacheTest.java diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexListenerCache.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexListenerCache.java index 501638e9d4..9c2a10802b 100644 --- a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexListenerCache.java +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexListenerCache.java @@ -21,7 +21,6 @@ import org.opensearch.cluster.ClusterStateListener; import org.opensearch.cluster.metadata.IndexMetadata; import org.opensearch.cluster.service.ClusterService; -import org.opensearch.core.rest.RestStatus; import org.opensearch.index.query.QueryBuilders; import org.opensearch.security.support.ConfigConstants; @@ -63,18 +62,7 @@ public void clusterChanged(ClusterChangedEvent event) { } void reloadApiTokensFromIndex() { - if (!initialized.get()) { - log.debug("Cache not yet initialized or client is null, skipping reload"); - return; - } - - if (clusterService.state() != null && clusterService.state().blocks().hasGlobalBlockWithStatus(RestStatus.SERVICE_UNAVAILABLE)) { - log.debug("Cluster not yet ready, skipping API tokens cache reload"); - return; - } - try { - idToJtiMap.clear(); jtis.clear(); client.prepareSearch(getSecurityIndexName()) @@ -88,8 +76,6 @@ void reloadApiTokensFromIndex() { String id = hit.getId(); String jti = (String) source.get("jti"); Permissions permissions = parsePermissions(source); - - idToJtiMap.put(id, jti); jtis.put(jti, permissions); }); @@ -123,11 +109,4 @@ public boolean isValidToken(String jti) { public Map getJtis() { return jtis; } - - // Cleanup method - public void close() { - if (clusterService != null) { - clusterService.removeListener(this); - } - } } diff --git a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenIndexListenerCacheTest.java b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenIndexListenerCacheTest.java new file mode 100644 index 0000000000..0df9f63427 --- /dev/null +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenIndexListenerCacheTest.java @@ -0,0 +1,165 @@ +/* + * 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.action.apitokens; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; + +import org.apache.lucene.search.TotalHits; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.opensearch.action.search.SearchRequestBuilder; +import org.opensearch.action.search.SearchResponse; +import org.opensearch.client.Client; +import org.opensearch.cluster.ClusterChangedEvent; +import org.opensearch.cluster.ClusterState; +import org.opensearch.cluster.metadata.IndexMetadata; +import org.opensearch.cluster.metadata.Metadata; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.action.ActionFuture; +import org.opensearch.common.xcontent.XContentFactory; +import org.opensearch.core.common.bytes.BytesReference; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.search.SearchHit; +import org.opensearch.search.SearchHits; +import org.opensearch.security.support.ConfigConstants; + +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@RunWith(MockitoJUnitRunner.class) +public class ApiTokenIndexListenerCacheTest { + + private ApiTokenIndexListenerCache cache; + + @Mock + private ClusterService clusterService; + + @Mock + private Client client; + + @Mock + private ClusterChangedEvent event; + + @Mock + private ClusterState clusterState; + + @Mock + private IndexMetadata indexMetadata; + @Mock + private SearchResponse searchResponse; + + @Mock + private SearchRequestBuilder searchRequestBuilder; + + @Mock + private ActionFuture actionFuture; + + @Before + public void setUp() { + ApiTokenIndexListenerCache.getInstance().initialize(clusterService, client); + cache = ApiTokenIndexListenerCache.getInstance(); + } + + @Test + public void testSingleton() { + ApiTokenIndexListenerCache instance1 = ApiTokenIndexListenerCache.getInstance(); + ApiTokenIndexListenerCache instance2 = ApiTokenIndexListenerCache.getInstance(); + assertSame("getInstance should always return the same instance", instance1, instance2); + } + + @Test + public void testJtisOperations() { + String jti = "testJti"; + Permissions permissions = new Permissions(List.of("read"), List.of(new ApiToken.IndexPermission(List.of(), List.of()))); + + cache.getJtis().put(jti, permissions); + assertEquals("Should retrieve correct permissions", permissions, cache.getJtis().get(jti)); + + cache.getJtis().remove(jti); + assertNull("Should return null after removal", cache.getJtis().get(jti)); + } + + @Test + public void testClearJtis() { + cache.getJtis().put("testJti", new Permissions(List.of("read"), List.of(new ApiToken.IndexPermission(List.of(), List.of())))); + cache.reloadApiTokensFromIndex(); + + assertTrue("Jtis should be empty after clear", cache.getJtis().isEmpty()); + } + + @Test + public void testClusterChangedInvokesReloadTokens() { + ClusterState clusterState = mock(ClusterState.class); + Metadata metadata = mock(Metadata.class); + when(clusterState.metadata()).thenReturn(metadata); + when(metadata.index(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX)).thenReturn(indexMetadata); + when(event.state()).thenReturn(clusterState); + + ApiTokenIndexListenerCache cacheSpy = spy(cache); + cacheSpy.clusterChanged(event); + + verify(cacheSpy).reloadApiTokensFromIndex(); + } + + @Test + public void testReloadApiTokensFromIndexAndParse() throws IOException { + SearchHit hit = createSearchHitFromApiToken("1", "testJti", Arrays.asList("cluster:monitor"), List.of()); + + SearchHits searchHits = new SearchHits(new SearchHit[] { hit }, new TotalHits(1, TotalHits.Relation.EQUAL_TO), 1.0f); + + // Mock the search response + when(searchResponse.getHits()).thenReturn(searchHits); + when(client.prepareSearch(any())).thenReturn(searchRequestBuilder); + when(searchRequestBuilder.setQuery(any())).thenReturn(searchRequestBuilder); + when(searchRequestBuilder.execute()).thenReturn(actionFuture); + when(actionFuture.actionGet()).thenReturn(searchResponse); + + // Execute the reload + cache.reloadApiTokensFromIndex(); + + // Verify the cache was updated + assertFalse("Jtis should not be empty after reload", cache.getJtis().isEmpty()); + assertEquals("Should have one JTI entry", 1, cache.getJtis().size()); + assertTrue("Should contain testJti", cache.getJtis().containsKey("testJti")); + // Verify extraction works + assertEquals("Should have one cluster action", List.of("cluster:monitor"), cache.getJtis().get("testJti").getClusterPerm()); + assertEquals("Should have no index actions", List.of(), cache.getJtis().get("testJti").getIndexPermission()); + } + + private SearchHit createSearchHitFromApiToken( + String id, + String jti, + List allowedActions, + List prohibitedActions + ) throws IOException { + ApiToken apiToken = new ApiToken("test", jti, allowedActions, prohibitedActions, Long.MAX_VALUE); + XContentBuilder builder = XContentFactory.jsonBuilder(); + apiToken.toXContent(builder, null); + + SearchHit hit = new SearchHit(Integer.parseInt(id), id, null, null, null); + hit.sourceRef(BytesReference.bytes(builder)); + return hit; + } + +} From 82996451305c2ebbfbe14378005170cc31ee829a Mon Sep 17 00:00:00 2001 From: Derek Ho Date: Tue, 7 Jan 2025 16:58:46 -0500 Subject: [PATCH 16/23] Add permission checks around creation Signed-off-by: Derek Ho --- .../security/OpenSearchSecurityPlugin.java | 2 +- .../action/apitokens/ApiTokenAction.java | 159 ++++++++++++++++-- .../action/apitokens/ApiTokenActionTest.java | 2 +- 3 files changed, 150 insertions(+), 13 deletions(-) diff --git a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java index 048fa1fea9..8b70958c9a 100644 --- a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java +++ b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java @@ -646,7 +646,7 @@ public List getRestHandlers( ) ); handlers.add(new CreateOnBehalfOfTokenAction(tokenManager)); - handlers.add(new ApiTokenAction(cs, localClient, tokenManager)); + handlers.add(new ApiTokenAction(cs, localClient, tokenManager, Objects.requireNonNull(threadPool), cr)); handlers.addAll( SecurityRestApiActions.getHandler( settings, diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java index 75bf3ffa01..8760d0f553 100644 --- a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java @@ -14,15 +14,19 @@ import java.io.IOException; import java.time.Instant; import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.opensearch.OpenSearchException; import org.opensearch.client.Client; import org.opensearch.client.node.NodeClient; import org.opensearch.cluster.service.ClusterService; @@ -32,9 +36,20 @@ import org.opensearch.rest.BaseRestHandler; import org.opensearch.rest.BytesRestResponse; import org.opensearch.rest.RestChannel; -import org.opensearch.rest.RestHandler; import org.opensearch.rest.RestRequest; +import org.opensearch.security.configuration.ConfigurationRepository; import org.opensearch.security.identity.SecurityTokenManager; +import org.opensearch.security.securityconf.DynamicConfigFactory; +import org.opensearch.security.securityconf.FlattenedActionGroups; +import org.opensearch.security.securityconf.impl.CType; +import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; +import org.opensearch.security.securityconf.impl.v7.ActionGroupsV7; +import org.opensearch.security.securityconf.impl.v7.RoleMappingsV7; +import org.opensearch.security.securityconf.impl.v7.RoleV7; +import org.opensearch.security.support.ConfigConstants; +import org.opensearch.security.support.WildcardMatcher; +import org.opensearch.security.user.User; +import org.opensearch.threadpool.ThreadPool; import static org.opensearch.rest.RestRequest.Method.DELETE; import static org.opensearch.rest.RestRequest.Method.GET; @@ -53,17 +68,23 @@ public class ApiTokenAction extends BaseRestHandler { private final ApiTokenRepository apiTokenRepository; public Logger log = LogManager.getLogger(this.getClass()); + private final ThreadPool threadPool; + private final ConfigurationRepository configurationRepository; - private static final List ROUTES = addRoutesPrefix( - ImmutableList.of( - new RestHandler.Route(POST, "/apitokens"), - new RestHandler.Route(DELETE, "/apitokens"), - new RestHandler.Route(GET, "/apitokens") - ) + private static final List ROUTES = addRoutesPrefix( + ImmutableList.of(new Route(POST, "/apitokens"), new Route(DELETE, "/apitokens"), new Route(GET, "/apitokens")) ); - public ApiTokenAction(ClusterService clusterService, Client client, SecurityTokenManager securityTokenManager) { + public ApiTokenAction( + ClusterService clusterService, + Client client, + SecurityTokenManager securityTokenManager, + ThreadPool threadpool, + ConfigurationRepository configurationRepository + ) { this.apiTokenRepository = new ApiTokenRepository(client, clusterService, securityTokenManager); + threadPool = threadpool; + this.configurationRepository = configurationRepository; } @Override @@ -72,7 +93,7 @@ public String getName() { } @Override - public List routes() { + public List routes() { return ROUTES; } @@ -123,7 +144,6 @@ private RestChannelConsumer handleGet(RestRequest request, NodeClient client) { private RestChannelConsumer handlePost(RestRequest request, NodeClient client) { return channel -> { final XContentBuilder builder = channel.newBuilder(); - BytesRestResponse response; try { final Map requestBody = request.contentOrSourceParamParser().map(); validateRequestParameters(requestBody); @@ -131,6 +151,8 @@ private RestChannelConsumer handlePost(RestRequest request, NodeClient client) { List clusterPermissions = extractClusterPermissions(requestBody); List indexPermissions = extractIndexPermissions(requestBody); + validateUserPermissions(clusterPermissions, indexPermissions); + String token = apiTokenRepository.createApiToken( (String) requestBody.get(NAME_FIELD), clusterPermissions, @@ -162,6 +184,7 @@ public void onFailure(Exception e) { } }); } catch (final Exception exception) { + log.error(exception.toString()); sendErrorResponse(channel, RestStatus.INTERNAL_SERVER_ERROR, exception.getMessage()); } }; @@ -249,7 +272,6 @@ void validateIndexPermissionsList(List> indexPermsList) { private RestChannelConsumer handleDelete(RestRequest request, NodeClient client) { return channel -> { final XContentBuilder builder = channel.newBuilder(); - BytesRestResponse response; try { final Map requestBody = request.contentOrSourceParamParser().map(); @@ -298,4 +320,119 @@ private void sendErrorResponse(RestChannel channel, RestStatus status, String er } } + /** + * Validates that the user has the required permissions to create an API token (must be a subset of their own permissions) + * */ + @SuppressWarnings("unchecked") + private void validateUserPermissions(List clusterPermissions, List indexPermissions) { + final User user = threadPool.getThreadContext().getTransient(ConfigConstants.OPENDISTRO_SECURITY_USER); + Set backendRoles = new HashSet<>(user.getRoles()); + Set roles = new HashSet<>(user.getSecurityRoles()); + final SecurityDynamicConfiguration rolesMappingConfiguration = load(CType.ROLESMAPPING, true); + // Load all roles the user has access to + rolesMappingConfiguration.getCEntries().forEach((roleName, mapping) -> { + // Check username matches + RoleMappingsV7 roleMapping = (RoleMappingsV7) mapping; + if (roleMapping.getUsers() != null && roleMapping.getUsers().contains(user.getName())) { + roles.add(roleName); + } + + // Check backend roles matches + if (roleMapping.getBackend_roles() != null && !Collections.disjoint(roleMapping.getBackend_roles(), backendRoles)) { + roles.add(roleName); + } + + // Check and_backend_roles matches (all must match) + if (roleMapping.getAnd_backend_roles() != null + && !roleMapping.getAnd_backend_roles().isEmpty() + && backendRoles.containsAll(roleMapping.getAnd_backend_roles())) { + roles.add(roleName); + } + }); + + if (roles.isEmpty()) { + throw new OpenSearchException("User does not have any roles"); + } else if (roles.contains("all_access")) { + // all_access == * + return; + } + + // Verify user has all requested cluster permissions + final SecurityDynamicConfiguration actionGroupsConfiguraiton = (SecurityDynamicConfiguration) load( + CType.ACTIONGROUPS, + true + ); + FlattenedActionGroups flattenedActionGroups = new FlattenedActionGroups(actionGroupsConfiguraiton); + final SecurityDynamicConfiguration rolesConfiguration = load(CType.ROLES, true); + ImmutableSet resolvedClusterPermissions = flattenedActionGroups.resolve(clusterPermissions); + Set clusterPermissionsWithoutActionGroups = resolvedClusterPermissions.stream() + .filter(permission -> !actionGroupsConfiguraiton.getCEntries().containsKey(permission)) + .collect(Collectors.toSet()); + + // Load all roles the user has access to, remove permissions that + for (String role : roles) { + RoleV7 roleV7 = (RoleV7) rolesConfiguration.getCEntry(role); + ImmutableSet expandedRoleClusterPermissions = flattenedActionGroups.resolve(roleV7.getCluster_permissions()); + for (String clusterPermission : expandedRoleClusterPermissions) { + WildcardMatcher matcher = WildcardMatcher.from(clusterPermission); + clusterPermissionsWithoutActionGroups.removeIf(matcher); + } + } + + if (!clusterPermissionsWithoutActionGroups.isEmpty()) { + throw new OpenSearchException("User does not have all requested cluster permissions"); + } + + // Verify user has all requested index permissions + for (ApiToken.IndexPermission requestedPermission : indexPermissions) { + // First, flatten/resolve any action groups into the underlying actions and remove action group names (which may not exact + // match) + Set resolvedActions = new HashSet<>(flattenedActionGroups.resolve(requestedPermission.getAllowedActions())); + resolvedActions.removeIf(permission -> actionGroupsConfiguraiton.getCEntries().containsKey(permission)); + + // For each index pattern in the requested permission + for (String requestedPattern : requestedPermission.getIndexPatterns()) { + + Set actionsForIndexPattern = new HashSet<>(resolvedActions); + + // Check each role the user has + for (String roleName : roles) { + RoleV7 role = (RoleV7) rolesConfiguration.getCEntry(roleName); + if (role == null || role.getIndex_permissions() == null) continue; + + // Check each index permission block in the role + for (RoleV7.Index indexPermission : role.getIndex_permissions()) { + log.error(indexPermission); + log.error(requestedPattern); + log.error(WildcardMatcher.from(indexPermission.getIndex_patterns()).test(requestedPattern)); + log.error(flattenedActionGroups.resolve(indexPermission.getAllowed_actions())); + List rolePatterns = indexPermission.getIndex_patterns(); + List roleIndexPerms = indexPermission.getAllowed_actions(); + + // Check if this role's pattern covers the requested pattern + if (WildcardMatcher.from(rolePatterns).test(requestedPattern)) { + // Get resolved actions for this role's index permissions + Set roleActions = flattenedActionGroups.resolve(roleIndexPerms); + WildcardMatcher matcher = WildcardMatcher.from(roleActions); + + actionsForIndexPattern.removeIf(matcher); + } + } + } + + // After checking all roles, verify if all requested actions were covered + if (!actionsForIndexPattern.isEmpty()) { + throw new OpenSearchException("User does not have sufficient permissions for index pattern: " + requestedPattern); + } + } + } + } + + private SecurityDynamicConfiguration load(final CType config, boolean logComplianceEvent) { + SecurityDynamicConfiguration loaded = configurationRepository.getConfigurationsFromIndex( + Collections.singleton(config), + logComplianceEvent + ).get(config).deepClone(); + return DynamicConfigFactory.addStatics(loaded); + } } diff --git a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java index 483fe7c9d7..2885634e9b 100644 --- a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java @@ -26,7 +26,7 @@ public class ApiTokenActionTest { - private final ApiTokenAction apiTokenAction = new ApiTokenAction(null, null, null); + private final ApiTokenAction apiTokenAction = new ApiTokenAction(null, null, null, null, null); @Test public void testCreateIndexPermission() { From eebe5fb5a15eb32570b4c741266c0b80fa1e1d38 Mon Sep 17 00:00:00 2001 From: Derek Ho Date: Wed, 8 Jan 2025 15:05:02 -0500 Subject: [PATCH 17/23] Add tests Signed-off-by: Derek Ho --- .../security/OpenSearchSecurityPlugin.java | 2 +- .../action/apitokens/ApiTokenAction.java | 54 ++--- .../action/apitokens/ApiTokenActionTest.java | 200 +++++++++++++++++- 3 files changed, 215 insertions(+), 41 deletions(-) diff --git a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java index 8b70958c9a..36546a7fad 100644 --- a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java +++ b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java @@ -646,7 +646,7 @@ public List getRestHandlers( ) ); handlers.add(new CreateOnBehalfOfTokenAction(tokenManager)); - handlers.add(new ApiTokenAction(cs, localClient, tokenManager, Objects.requireNonNull(threadPool), cr)); + handlers.add(new ApiTokenAction(cs, localClient, tokenManager, Objects.requireNonNull(threadPool), cr, evaluator)); handlers.addAll( SecurityRestApiActions.getHandler( settings, diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java index 8760d0f553..b38849f052 100644 --- a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java @@ -31,6 +31,7 @@ import org.opensearch.client.node.NodeClient; import org.opensearch.cluster.service.ClusterService; import org.opensearch.core.action.ActionListener; +import org.opensearch.core.common.transport.TransportAddress; import org.opensearch.core.rest.RestStatus; import org.opensearch.core.xcontent.XContentBuilder; import org.opensearch.rest.BaseRestHandler; @@ -39,12 +40,12 @@ import org.opensearch.rest.RestRequest; import org.opensearch.security.configuration.ConfigurationRepository; import org.opensearch.security.identity.SecurityTokenManager; +import org.opensearch.security.privileges.PrivilegesEvaluator; import org.opensearch.security.securityconf.DynamicConfigFactory; import org.opensearch.security.securityconf.FlattenedActionGroups; import org.opensearch.security.securityconf.impl.CType; import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; import org.opensearch.security.securityconf.impl.v7.ActionGroupsV7; -import org.opensearch.security.securityconf.impl.v7.RoleMappingsV7; import org.opensearch.security.securityconf.impl.v7.RoleV7; import org.opensearch.security.support.ConfigConstants; import org.opensearch.security.support.WildcardMatcher; @@ -70,6 +71,7 @@ public class ApiTokenAction extends BaseRestHandler { public Logger log = LogManager.getLogger(this.getClass()); private final ThreadPool threadPool; private final ConfigurationRepository configurationRepository; + private final PrivilegesEvaluator privilegesEvaluator; private static final List ROUTES = addRoutesPrefix( ImmutableList.of(new Route(POST, "/apitokens"), new Route(DELETE, "/apitokens"), new Route(GET, "/apitokens")) @@ -80,11 +82,13 @@ public ApiTokenAction( Client client, SecurityTokenManager securityTokenManager, ThreadPool threadpool, - ConfigurationRepository configurationRepository + ConfigurationRepository configurationRepository, + PrivilegesEvaluator privilegesEvaluator ) { this.apiTokenRepository = new ApiTokenRepository(client, clusterService, securityTokenManager); - threadPool = threadpool; + this.threadPool = threadpool; this.configurationRepository = configurationRepository; + this.privilegesEvaluator = privilegesEvaluator; } @Override @@ -324,32 +328,12 @@ private void sendErrorResponse(RestChannel channel, RestStatus status, String er * Validates that the user has the required permissions to create an API token (must be a subset of their own permissions) * */ @SuppressWarnings("unchecked") - private void validateUserPermissions(List clusterPermissions, List indexPermissions) { + void validateUserPermissions(List clusterPermissions, List indexPermissions) { final User user = threadPool.getThreadContext().getTransient(ConfigConstants.OPENDISTRO_SECURITY_USER); - Set backendRoles = new HashSet<>(user.getRoles()); - Set roles = new HashSet<>(user.getSecurityRoles()); - final SecurityDynamicConfiguration rolesMappingConfiguration = load(CType.ROLESMAPPING, true); - // Load all roles the user has access to - rolesMappingConfiguration.getCEntries().forEach((roleName, mapping) -> { - // Check username matches - RoleMappingsV7 roleMapping = (RoleMappingsV7) mapping; - if (roleMapping.getUsers() != null && roleMapping.getUsers().contains(user.getName())) { - roles.add(roleName); - } - - // Check backend roles matches - if (roleMapping.getBackend_roles() != null && !Collections.disjoint(roleMapping.getBackend_roles(), backendRoles)) { - roles.add(roleName); - } - - // Check and_backend_roles matches (all must match) - if (roleMapping.getAnd_backend_roles() != null - && !roleMapping.getAnd_backend_roles().isEmpty() - && backendRoles.containsAll(roleMapping.getAnd_backend_roles())) { - roles.add(roleName); - } - }); + final TransportAddress caller = threadPool.getThreadContext().getTransient(ConfigConstants.OPENDISTRO_SECURITY_REMOTE_ADDRESS); + final Set roles = privilegesEvaluator.mapRoles(user, caller); + // Early return conditions if (roles.isEmpty()) { throw new OpenSearchException("User does not have any roles"); } else if (roles.contains("all_access")) { @@ -359,11 +343,10 @@ private void validateUserPermissions(List clusterPermissions, List actionGroupsConfiguraiton = (SecurityDynamicConfiguration) load( - CType.ACTIONGROUPS, - true + CType.ACTIONGROUPS ); FlattenedActionGroups flattenedActionGroups = new FlattenedActionGroups(actionGroupsConfiguraiton); - final SecurityDynamicConfiguration rolesConfiguration = load(CType.ROLES, true); + final SecurityDynamicConfiguration rolesConfiguration = load(CType.ROLES); ImmutableSet resolvedClusterPermissions = flattenedActionGroups.resolve(clusterPermissions); Set clusterPermissionsWithoutActionGroups = resolvedClusterPermissions.stream() .filter(permission -> !actionGroupsConfiguraiton.getCEntries().containsKey(permission)) @@ -402,10 +385,6 @@ private void validateUserPermissions(List clusterPermissions, List rolePatterns = indexPermission.getIndex_patterns(); List roleIndexPerms = indexPermission.getAllowed_actions(); @@ -428,11 +407,8 @@ private void validateUserPermissions(List clusterPermissions, List load(final CType config, boolean logComplianceEvent) { - SecurityDynamicConfiguration loaded = configurationRepository.getConfigurationsFromIndex( - Collections.singleton(config), - logComplianceEvent - ).get(config).deepClone(); + private SecurityDynamicConfiguration load(final CType config) { + SecurityDynamicConfiguration loaded = configurationRepository.getConfiguration(config); return DynamicConfigFactory.addStatics(loaded); } } diff --git a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java index 2885634e9b..a0f591462d 100644 --- a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java @@ -16,17 +16,124 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; +import com.google.common.collect.ImmutableMap; +import com.fasterxml.jackson.core.JsonProcessingException; +import org.junit.Before; import org.junit.Test; +import org.junit.runner.RunWith; + +import org.opensearch.OpenSearchException; +import org.opensearch.common.settings.Settings; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.security.configuration.ConfigurationRepository; +import org.opensearch.security.privileges.PrivilegesEvaluator; +import org.opensearch.security.securityconf.FlattenedActionGroups; +import org.opensearch.security.securityconf.impl.CType; +import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; +import org.opensearch.security.securityconf.impl.v7.ActionGroupsV7; +import org.opensearch.security.securityconf.impl.v7.RoleV7; +import org.opensearch.threadpool.ThreadPool; + +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.empty; import static org.hamcrest.Matchers.is; +import static org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration.fromMap; import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.when; +@RunWith(MockitoJUnitRunner.class) public class ApiTokenActionTest { - private final ApiTokenAction apiTokenAction = new ApiTokenAction(null, null, null, null, null); + @Mock + private ThreadPool threadPool; + + @Mock + private PrivilegesEvaluator privilegesEvaluator; + + @Mock + private ConfigurationRepository configurationRepository; + + private SecurityDynamicConfiguration actionGroupsConfig; + private SecurityDynamicConfiguration rolesConfig; + private FlattenedActionGroups flattenedActionGroups; + private ApiTokenAction apiTokenAction; + + @Before + public void setUp() throws JsonProcessingException { + // Setup basic action groups + + actionGroupsConfig = SecurityDynamicConfiguration.fromMap( + ImmutableMap.of( + "read_group", + Map.of("allowed_actions", List.of("read", "get", "search")), + "write_group", + Map.of("allowed_actions", List.of("write", "create", "index")) + ), + CType.ACTIONGROUPS + ); + + rolesConfig = fromMap( + ImmutableMap.of( + "read_group_logs-123", + ImmutableMap.of( + "index_permissions", + Arrays.asList(ImmutableMap.of("index_patterns", List.of("logs-123"), "allowed_actions", List.of("read_group"))), + "cluster_permissions", + Arrays.asList("*") + ), + "read_group_logs-star", + ImmutableMap.of( + "index_permissions", + Arrays.asList(ImmutableMap.of("index_patterns", List.of("logs-*"), "allowed_actions", List.of("read_group"))), + "cluster_permissions", + Arrays.asList("*") + ), + "write_group_logs-star", + ImmutableMap.of( + "index_permissions", + Arrays.asList(ImmutableMap.of("index_patterns", List.of("logs-*"), "allowed_actions", List.of("write_group"))), + "cluster_permissions", + Arrays.asList("*") + ), + "write_group_logs-123", + ImmutableMap.of( + "index_permissions", + Arrays.asList(ImmutableMap.of("index_patterns", List.of("logs-123"), "allowed_actions", List.of("write_group"))), + "cluster_permissions", + Arrays.asList("*") + ), + "more_permissable_write_group_lo-star", + ImmutableMap.of( + "index_permissions", + Arrays.asList(ImmutableMap.of("index_patterns", List.of("lo*"), "allowed_actions", List.of("write_group"))), + "cluster_permissions", + Arrays.asList("*") + ), + "cluster_monitor", + ImmutableMap.of( + "index_permissions", + Arrays.asList(ImmutableMap.of("index_patterns", List.of("lo*"), "allowed_actions", List.of("write_group"))), + "cluster_permissions", + Arrays.asList("cluster_monitor") + ) + + ), + CType.ROLES + ); + + when(threadPool.getThreadContext()).thenReturn(new ThreadContext(Settings.EMPTY)); + + when(configurationRepository.getConfiguration(CType.ROLES)).thenReturn(rolesConfig); + when(configurationRepository.getConfiguration(CType.ACTIONGROUPS)).thenReturn(actionGroupsConfig); + + apiTokenAction = new ApiTokenAction(null, null, null, threadPool, configurationRepository, privilegesEvaluator); + + } @Test public void testCreateIndexPermission() { @@ -100,4 +207,95 @@ public void testExtractClusterPermissions() { requestBody.put("cluster_permissions", Arrays.asList("perm1", "perm2")); assertThat(apiTokenAction.extractClusterPermissions(requestBody), is(Arrays.asList("perm1", "perm2"))); } + + @Test + public void testExactMatchPermissionsWithActionGroups() { + when(privilegesEvaluator.mapRoles(null, null)).thenReturn(Set.of("read_group_logs-123")); + + apiTokenAction.validateUserPermissions(List.of(), List.of(new ApiToken.IndexPermission( + List.of("logs-123"), + List.of("read_group") + ))); + } + + @Test + public void testCreateWildcardPermissionWhenNoAccessThrowsException() { + when(privilegesEvaluator.mapRoles(null, null)).thenReturn(Set.of("read_group_logs-123")); + + assertThrows(OpenSearchException.class, () -> apiTokenAction.validateUserPermissions(List.of(), List.of(new ApiToken.IndexPermission(List.of("logs-*"), List.of("read"))))); + } + + @Test + public void testCreateMorePermissableWildcardPermissionWhenNoAccessThrowsException() { + when(privilegesEvaluator.mapRoles(null, null)).thenReturn(Set.of("write_group_logs-star")); + + assertThrows(OpenSearchException.class, () -> apiTokenAction.validateUserPermissions(List.of(), List.of(new ApiToken.IndexPermission(List.of("lo-*"), List.of("write"))))); + } + + @Test + public void testMultipleRolesCoveringPermissions() { + when(privilegesEvaluator.mapRoles(null, null)).thenReturn(Set.of("read_group_logs-123", "write_group_logs-123")); + + ApiToken.IndexPermission requestedPerm = new ApiToken.IndexPermission(List.of("logs-123"), List.of("read", "write")); + + apiTokenAction.validateUserPermissions(List.of(), List.of(requestedPerm)); + } + + @Test + public void testInsufficientPermissions() { + when(privilegesEvaluator.mapRoles(null, null)).thenReturn(Set.of("read_group_logs-star")); + + ApiToken.IndexPermission requestedPerm = new ApiToken.IndexPermission(List.of("logs-2023"), List.of("read", "write")); + + assertThrows(OpenSearchException.class, () -> apiTokenAction.validateUserPermissions(List.of(), List.of(requestedPerm))); + } + + @Test + public void testSeparateIndexPatternThrowsException() { + when(privilegesEvaluator.mapRoles(null, null)).thenReturn(Set.of("read_group_logs-123")); + + ApiToken.IndexPermission requestedPerm = new ApiToken.IndexPermission(List.of("logs-123", "metrics-2023"), List.of("read")); + + assertThrows(OpenSearchException.class, () -> apiTokenAction.validateUserPermissions(List.of(), List.of(requestedPerm))); + } + + @Test + public void testActionGroupResolution() { + when(privilegesEvaluator.mapRoles(null, null)).thenReturn(Set.of("read_group_logs-123", "write_group_logs-123")); + + ApiToken.IndexPermission requestedPerm = new ApiToken.IndexPermission( + List.of("logs-123"), + List.of("read", "write", "get", "create") + ); + + apiTokenAction.validateUserPermissions(List.of(), List.of(requestedPerm)); + } + + @Test + public void testEmptyIndexPermissions() { + when(privilegesEvaluator.mapRoles(null, null)).thenReturn(Set.of("read_group_logs-123", "write_group_logs-123")); + + apiTokenAction.validateUserPermissions(List.of("cluster:monitor"), List.of()); + } + + @Test + public void testClusterPermissionsEvaluation() { + when(privilegesEvaluator.mapRoles(null, null)).thenReturn(Set.of("cluster_monitor")); + + apiTokenAction.validateUserPermissions(List.of("cluster_monitor"), List.of()); + } + + @Test + public void testClusterPermissionsMorePermissableRegexThrowsException() { + when(privilegesEvaluator.mapRoles(null, null)).thenReturn(Set.of("cluster_monitor")); + + assertThrows(OpenSearchException.class, () -> apiTokenAction.validateUserPermissions(List.of("*"), List.of())); + } + + @Test + public void testClusterPermissionsFromMultipleRoles() { + when(privilegesEvaluator.mapRoles(null, null)).thenReturn(Set.of("cluster_monitor", "read_group_logs-123")); + + apiTokenAction.validateUserPermissions(List.of("cluster_monitor", "cluster_health"), List.of()); + } } From c079d0912c4f47994ccffec78fc0d13d0a249f5e Mon Sep 17 00:00:00 2001 From: Derek Ho Date: Wed, 8 Jan 2025 16:31:41 -0500 Subject: [PATCH 18/23] Clean up TODOs Signed-off-by: Derek Ho --- .../security/OpenSearchSecurityPlugin.java | 22 ++++++- .../action/apitokens/ApiTokenAction.java | 61 +++++++++++++++---- .../apitokens/ApiTokenIndexHandler.java | 1 - .../action/apitokens/ApiTokenRepository.java | 1 - .../auditlog/impl/AbstractAuditLog.java | 1 - .../security/dlic/rest/api/Endpoint.java | 3 +- .../api/RestApiAdminPrivilegesEvaluator.java | 1 + .../action/apitokens/ApiTokenActionTest.java | 14 ++++- 8 files changed, 85 insertions(+), 19 deletions(-) diff --git a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java index 36546a7fad..e1c25bf507 100644 --- a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java +++ b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java @@ -646,7 +646,21 @@ public List getRestHandlers( ) ); handlers.add(new CreateOnBehalfOfTokenAction(tokenManager)); - handlers.add(new ApiTokenAction(cs, localClient, tokenManager, Objects.requireNonNull(threadPool), cr, evaluator)); + handlers.add( + new ApiTokenAction( + cs, + localClient, + tokenManager, + Objects.requireNonNull(threadPool), + cr, + evaluator, + settings, + adminDns, + auditLog, + configPath, + principalExtractor + ) + ); handlers.addAll( SecurityRestApiActions.getHandler( settings, @@ -2138,7 +2152,11 @@ public Collection getSystemIndexDescriptors(Settings sett ConfigConstants.OPENDISTRO_SECURITY_DEFAULT_CONFIG_INDEX ); final SystemIndexDescriptor systemIndexDescriptor = new SystemIndexDescriptor(indexPattern, "Security index"); - return Collections.singletonList(systemIndexDescriptor); + final SystemIndexDescriptor apiTokenSystemIndexDescriptor = new SystemIndexDescriptor( + ConfigConstants.OPENSEARCH_API_TOKENS_INDEX, + "Security API token index" + ); + return List.of(systemIndexDescriptor, apiTokenSystemIndexDescriptor); } @Override diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java index b38849f052..87092a834b 100644 --- a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java @@ -12,6 +12,7 @@ package org.opensearch.security.action.apitokens; import java.io.IOException; +import java.nio.file.Path; import java.time.Instant; import java.util.Collections; import java.util.HashSet; @@ -30,6 +31,7 @@ import org.opensearch.client.Client; import org.opensearch.client.node.NodeClient; import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.settings.Settings; import org.opensearch.core.action.ActionListener; import org.opensearch.core.common.transport.TransportAddress; import org.opensearch.core.rest.RestStatus; @@ -38,7 +40,13 @@ import org.opensearch.rest.BytesRestResponse; import org.opensearch.rest.RestChannel; import org.opensearch.rest.RestRequest; +import org.opensearch.security.auditlog.AuditLog; +import org.opensearch.security.configuration.AdminDNs; import org.opensearch.security.configuration.ConfigurationRepository; +import org.opensearch.security.dlic.rest.api.Endpoint; +import org.opensearch.security.dlic.rest.api.RestApiAdminPrivilegesEvaluator; +import org.opensearch.security.dlic.rest.api.RestApiPrivilegesEvaluator; +import org.opensearch.security.dlic.rest.api.SecurityApiDependencies; import org.opensearch.security.identity.SecurityTokenManager; import org.opensearch.security.privileges.PrivilegesEvaluator; import org.opensearch.security.securityconf.DynamicConfigFactory; @@ -47,6 +55,7 @@ import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; import org.opensearch.security.securityconf.impl.v7.ActionGroupsV7; import org.opensearch.security.securityconf.impl.v7.RoleV7; +import org.opensearch.security.ssl.transport.PrincipalExtractor; import org.opensearch.security.support.ConfigConstants; import org.opensearch.security.support.WildcardMatcher; import org.opensearch.security.user.User; @@ -63,6 +72,7 @@ import static org.opensearch.security.action.apitokens.ApiToken.INDEX_PERMISSIONS_FIELD; import static org.opensearch.security.action.apitokens.ApiToken.NAME_FIELD; import static org.opensearch.security.dlic.rest.support.Utils.addRoutesPrefix; +import static org.opensearch.security.support.ConfigConstants.SECURITY_RESTAPI_ADMIN_ENABLED; import static org.opensearch.security.util.ParsingUtils.safeMapList; import static org.opensearch.security.util.ParsingUtils.safeStringList; @@ -72,6 +82,7 @@ public class ApiTokenAction extends BaseRestHandler { private final ThreadPool threadPool; private final ConfigurationRepository configurationRepository; private final PrivilegesEvaluator privilegesEvaluator; + private final SecurityApiDependencies securityApiDependencies; private static final List ROUTES = addRoutesPrefix( ImmutableList.of(new Route(POST, "/apitokens"), new Route(DELETE, "/apitokens"), new Route(GET, "/apitokens")) @@ -83,12 +94,31 @@ public ApiTokenAction( SecurityTokenManager securityTokenManager, ThreadPool threadpool, ConfigurationRepository configurationRepository, - PrivilegesEvaluator privilegesEvaluator + PrivilegesEvaluator privilegesEvaluator, + Settings settings, + AdminDNs adminDns, + AuditLog auditLog, + Path configPath, + PrincipalExtractor principalExtractor ) { this.apiTokenRepository = new ApiTokenRepository(client, clusterService, securityTokenManager); this.threadPool = threadpool; this.configurationRepository = configurationRepository; this.privilegesEvaluator = privilegesEvaluator; + this.securityApiDependencies = new SecurityApiDependencies( + adminDns, + configurationRepository, + privilegesEvaluator, + new RestApiPrivilegesEvaluator(settings, adminDns, privilegesEvaluator, principalExtractor, configPath, threadPool), + new RestApiAdminPrivilegesEvaluator( + threadPool.getThreadContext(), + privilegesEvaluator, + adminDns, + settings.getAsBoolean(SECURITY_RESTAPI_ADMIN_ENABLED, false) + ), + auditLog, + settings + ); } @Override @@ -103,17 +133,17 @@ public List routes() { @Override protected RestChannelConsumer prepareRequest(final RestRequest request, final NodeClient client) throws IOException { - // TODO: Authorize this API properly - switch (request.method()) { - case POST: - return handlePost(request, client); - case DELETE: - return handleDelete(request, client); - case GET: - return handleGet(request, client); - default: - throw new IllegalArgumentException(request.method() + " not supported"); - } + authorizeSecurityAccess(); + return doPrepareRequest(request, client); + } + + RestChannelConsumer doPrepareRequest(RestRequest request, NodeClient client) { + return switch (request.method()) { + case POST -> handlePost(request, client); + case DELETE -> handleDelete(request, client); + case GET -> handleGet(request, client); + default -> throw new IllegalArgumentException(request.method() + " not supported"); + }; } private RestChannelConsumer handleGet(RestRequest request, NodeClient client) { @@ -411,4 +441,11 @@ private SecurityDynamicConfiguration load(final CType config) { SecurityDynamicConfiguration loaded = configurationRepository.getConfiguration(config); return DynamicConfigFactory.addStatics(loaded); } + + protected void authorizeSecurityAccess() throws IOException { + // Check if user has security API access + if (!securityApiDependencies.restApiAdminPrivilegesEvaluator().isCurrentUserAdminFor(Endpoint.APITOKENS)) { + throw new SecurityException("User does not have required security API access"); + } + } } diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandler.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandler.java index 488229a319..8ef3f63571 100644 --- a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandler.java +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandler.java @@ -145,7 +145,6 @@ public Boolean apiTokenIndexExists() { } public void createApiTokenIndexIfAbsent() { - // TODO: Decide if this should be done at bootstrap if (!apiTokenIndexExists()) { final var originalUserAndRemoteAddress = Utils.userAndRemoteAddressFrom(client.threadPool().getThreadContext()); try (final ThreadContext.StoredContext ctx = client.threadPool().getThreadContext().stashContext()) { diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenRepository.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenRepository.java index be336f3582..fb71c0b6df 100644 --- a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenRepository.java +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenRepository.java @@ -48,7 +48,6 @@ public String createApiToken( Long expiration ) { apiTokenIndexHandler.createApiTokenIndexIfAbsent(); - // TODO: Add validation on whether user is creating a token with a subset of their permissions ExpiringBearerAuthToken token = securityTokenManager.issueApiToken(name, expiration); ApiToken apiToken = new ApiToken( name, diff --git a/src/main/java/org/opensearch/security/auditlog/impl/AbstractAuditLog.java b/src/main/java/org/opensearch/security/auditlog/impl/AbstractAuditLog.java index 9a16cd8bfd..5b90f46f83 100644 --- a/src/main/java/org/opensearch/security/auditlog/impl/AbstractAuditLog.java +++ b/src/main/java/org/opensearch/security/auditlog/impl/AbstractAuditLog.java @@ -126,7 +126,6 @@ protected AbstractAuditLog( ConfigConstants.SECURITY_CONFIG_INDEX_NAME, ConfigConstants.OPENDISTRO_SECURITY_DEFAULT_CONFIG_INDEX ); - // TODO: support custom api tokens index? this.securityIndicesMatcher = WildcardMatcher.from( List.of( settings.get(ConfigConstants.SECURITY_CONFIG_INDEX_NAME, ConfigConstants.OPENDISTRO_SECURITY_DEFAULT_CONFIG_INDEX), diff --git a/src/main/java/org/opensearch/security/dlic/rest/api/Endpoint.java b/src/main/java/org/opensearch/security/dlic/rest/api/Endpoint.java index ecc9dcbc59..d5555b445c 100644 --- a/src/main/java/org/opensearch/security/dlic/rest/api/Endpoint.java +++ b/src/main/java/org/opensearch/security/dlic/rest/api/Endpoint.java @@ -30,5 +30,6 @@ public enum Endpoint { WHITELIST, ALLOWLIST, NODESDN, - SSL; + SSL, + APITOKENS; } diff --git a/src/main/java/org/opensearch/security/dlic/rest/api/RestApiAdminPrivilegesEvaluator.java b/src/main/java/org/opensearch/security/dlic/rest/api/RestApiAdminPrivilegesEvaluator.java index faa0217db2..5c1c34da5b 100644 --- a/src/main/java/org/opensearch/security/dlic/rest/api/RestApiAdminPrivilegesEvaluator.java +++ b/src/main/java/org/opensearch/security/dlic/rest/api/RestApiAdminPrivilegesEvaluator.java @@ -71,6 +71,7 @@ default String build() { .put(Endpoint.ROLESMAPPING, action -> buildEndpointPermission(Endpoint.ROLESMAPPING)) .put(Endpoint.TENANTS, action -> buildEndpointPermission(Endpoint.TENANTS)) .put(Endpoint.SSL, action -> buildEndpointActionPermission(Endpoint.SSL, action)) + .put(Endpoint.APITOKENS, action -> buildEndpointActionPermission(Endpoint.APITOKENS, action)) .build(); private final ThreadContext threadContext; diff --git a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java index a0f591462d..5ea893171b 100644 --- a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java @@ -131,7 +131,19 @@ public void setUp() throws JsonProcessingException { when(configurationRepository.getConfiguration(CType.ROLES)).thenReturn(rolesConfig); when(configurationRepository.getConfiguration(CType.ACTIONGROUPS)).thenReturn(actionGroupsConfig); - apiTokenAction = new ApiTokenAction(null, null, null, threadPool, configurationRepository, privilegesEvaluator); + apiTokenAction = new ApiTokenAction( + null, + null, + null, + threadPool, + configurationRepository, + privilegesEvaluator, + null, + null, + null, + null, + null + ); } From 5b48fb91fbc25b960834b30ec0311fb7d4b43c0b Mon Sep 17 00:00:00 2001 From: Derek Ho Date: Thu, 9 Jan 2025 14:20:03 -0500 Subject: [PATCH 19/23] Fix logic Signed-off-by: Derek Ho --- .../action/apitokens/ApiTokenAction.java | 7 +- .../apitokens/ApiTokenIndexHandler.java | 119 +++++++++--------- .../api/RestApiAdminPrivilegesEvaluator.java | 2 +- .../action/apitokens/ApiTokenActionTest.java | 2 +- 4 files changed, 69 insertions(+), 61 deletions(-) diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java index 87092a834b..5da43dd47b 100644 --- a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java @@ -133,7 +133,7 @@ public List routes() { @Override protected RestChannelConsumer prepareRequest(final RestRequest request, final NodeClient client) throws IOException { - authorizeSecurityAccess(); + authorizeSecurityAccess(request); return doPrepareRequest(request, client); } @@ -442,9 +442,10 @@ private SecurityDynamicConfiguration load(final CType config) { return DynamicConfigFactory.addStatics(loaded); } - protected void authorizeSecurityAccess() throws IOException { + protected void authorizeSecurityAccess(RestRequest request) throws IOException { // Check if user has security API access - if (!securityApiDependencies.restApiAdminPrivilegesEvaluator().isCurrentUserAdminFor(Endpoint.APITOKENS)) { + if (!(securityApiDependencies.restApiAdminPrivilegesEvaluator().isCurrentUserAdminFor(Endpoint.APITOKENS) + || securityApiDependencies.restApiPrivilegesEvaluator().checkAccessPermissions(request, Endpoint.APITOKENS) == null)) { throw new SecurityException("User does not have required security API access"); } } diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandler.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandler.java index 8ef3f63571..d34366733e 100644 --- a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandler.java +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandler.java @@ -14,6 +14,7 @@ import java.io.IOException; import java.util.HashMap; import java.util.Map; +import java.util.function.Supplier; import com.google.common.collect.ImmutableMap; import org.apache.logging.log4j.LogManager; @@ -57,42 +58,31 @@ public ApiTokenIndexHandler(Client client, ClusterService clusterService) { this.clusterService = clusterService; } - public String indexTokenMetadata(ApiToken token) { - // TODO: move this out of index handler class, potentially create a layer in between baseresthandler and abstractapiaction which can - // abstract this complexity away - final var originalUserAndRemoteAddress = Utils.userAndRemoteAddressFrom(client.threadPool().getThreadContext()); - try (final ThreadContext.StoredContext ctx = client.threadPool().getThreadContext().stashContext()) { - client.threadPool() - .getThreadContext() - .putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, originalUserAndRemoteAddress.getLeft()); + public void indexTokenMetadata(ApiToken token) { + withSecurityContext(() -> { + try { - XContentBuilder builder = XContentFactory.jsonBuilder(); - String jsonString = token.toXContent(builder, ToXContent.EMPTY_PARAMS).toString(); + XContentBuilder builder = XContentFactory.jsonBuilder(); + String jsonString = token.toXContent(builder, ToXContent.EMPTY_PARAMS).toString(); - IndexRequest request = new IndexRequest(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX).source(jsonString, XContentType.JSON); + IndexRequest request = new IndexRequest(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX).source(jsonString, XContentType.JSON); - ActionListener irListener = ActionListener.wrap(idxResponse -> { - LOGGER.info("Created {} entry.", ConfigConstants.OPENSEARCH_API_TOKENS_INDEX); - }, (failResponse) -> { - LOGGER.error(failResponse.getMessage()); - LOGGER.info("Failed to create {} entry.", ConfigConstants.OPENSEARCH_API_TOKENS_INDEX); - }); - - client.index(request, irListener); - return token.getName(); - - } catch (IOException e) { - throw new RuntimeException(e); - } + ActionListener irListener = ActionListener.wrap(idxResponse -> { + LOGGER.info("Created {} entry.", ConfigConstants.OPENSEARCH_API_TOKENS_INDEX); + }, (failResponse) -> { + LOGGER.error(failResponse.getMessage()); + LOGGER.info("Failed to create {} entry.", ConfigConstants.OPENSEARCH_API_TOKENS_INDEX); + }); + client.index(request, irListener); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); } public void deleteToken(String name) throws ApiTokenException { - final var originalUserAndRemoteAddress = Utils.userAndRemoteAddressFrom(client.threadPool().getThreadContext()); - try (final ThreadContext.StoredContext ctx = client.threadPool().getThreadContext().stashContext()) { - client.threadPool() - .getThreadContext() - .putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, originalUserAndRemoteAddress.getLeft()); + withSecurityContext(() -> { DeleteByQueryRequest request = new DeleteByQueryRequest(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX).setQuery( QueryBuilders.matchQuery(NAME_FIELD, name) ).setRefresh(true); @@ -104,44 +94,61 @@ public void deleteToken(String name) throws ApiTokenException { if (deletedDocs == 0) { throw new ApiTokenException("No token found with name " + name); } - LOGGER.info("Deleted " + deletedDocs + " documents"); - } + }); } public Map getTokenMetadatas() { + return withSecurityContext(() -> { + try { + SearchRequest searchRequest = new SearchRequest(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX); + searchRequest.source(new SearchSourceBuilder()); + + SearchResponse response = client.search(searchRequest).actionGet(); + + Map tokens = new HashMap<>(); + for (SearchHit hit : response.getHits().getHits()) { + try ( + XContentParser parser = XContentType.JSON.xContent() + .createParser( + NamedXContentRegistry.EMPTY, + DeprecationHandler.THROW_UNSUPPORTED_OPERATION, + hit.getSourceRef().streamInput() + ) + ) { + + ApiToken token = ApiToken.fromXContent(parser); + tokens.put(token.getName(), token); + } + } + return tokens; + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + } + + public Boolean apiTokenIndexExists() { + return clusterService.state().metadata().hasConcreteIndex(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX); + } + + private T withSecurityContext(Supplier operation) { final var originalUserAndRemoteAddress = Utils.userAndRemoteAddressFrom(client.threadPool().getThreadContext()); try (final ThreadContext.StoredContext ctx = client.threadPool().getThreadContext().stashContext()) { client.threadPool() .getThreadContext() .putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, originalUserAndRemoteAddress.getLeft()); - SearchRequest searchRequest = new SearchRequest(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX); - searchRequest.source(new SearchSourceBuilder()); - - SearchResponse response = client.search(searchRequest).actionGet(); - - Map tokens = new HashMap<>(); - for (SearchHit hit : response.getHits().getHits()) { - try ( - XContentParser parser = XContentType.JSON.xContent() - .createParser( - NamedXContentRegistry.EMPTY, - DeprecationHandler.THROW_UNSUPPORTED_OPERATION, - hit.getSourceRef().streamInput() - ) - ) { - - ApiToken token = ApiToken.fromXContent(parser); - tokens.put(token.getName(), token); - } - } - return tokens; - } catch (IOException e) { - throw new RuntimeException(e); + return operation.get(); } } - public Boolean apiTokenIndexExists() { - return clusterService.state().metadata().hasConcreteIndex(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX); + private void withSecurityContext(Runnable operation) { + final var originalUserAndRemoteAddress = Utils.userAndRemoteAddressFrom(client.threadPool().getThreadContext()); + try (final ThreadContext.StoredContext ctx = client.threadPool().getThreadContext().stashContext()) { + client.threadPool() + .getThreadContext() + .putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, originalUserAndRemoteAddress.getLeft()); + operation.run(); + } } public void createApiTokenIndexIfAbsent() { diff --git a/src/main/java/org/opensearch/security/dlic/rest/api/RestApiAdminPrivilegesEvaluator.java b/src/main/java/org/opensearch/security/dlic/rest/api/RestApiAdminPrivilegesEvaluator.java index 5c1c34da5b..3714ac4c3d 100644 --- a/src/main/java/org/opensearch/security/dlic/rest/api/RestApiAdminPrivilegesEvaluator.java +++ b/src/main/java/org/opensearch/security/dlic/rest/api/RestApiAdminPrivilegesEvaluator.java @@ -71,7 +71,7 @@ default String build() { .put(Endpoint.ROLESMAPPING, action -> buildEndpointPermission(Endpoint.ROLESMAPPING)) .put(Endpoint.TENANTS, action -> buildEndpointPermission(Endpoint.TENANTS)) .put(Endpoint.SSL, action -> buildEndpointActionPermission(Endpoint.SSL, action)) - .put(Endpoint.APITOKENS, action -> buildEndpointActionPermission(Endpoint.APITOKENS, action)) + .put(Endpoint.APITOKENS, action -> buildEndpointPermission(Endpoint.APITOKENS)) .build(); private final ThreadContext threadContext; diff --git a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java index 5ea893171b..a715c405ac 100644 --- a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java @@ -138,7 +138,7 @@ public void setUp() throws JsonProcessingException { threadPool, configurationRepository, privilegesEvaluator, - null, + Settings.EMPTY, null, null, null, From e13c055e063ae3e5ee40386a53155e5c65893b58 Mon Sep 17 00:00:00 2001 From: Derek Ho Date: Thu, 9 Jan 2025 16:17:01 -0500 Subject: [PATCH 20/23] Fix test and clean up code Signed-off-by: Derek Ho --- .../action/apitokens/ApiTokenAction.java | 33 +++- .../apitokens/ApiTokenIndexHandler.java | 146 +++++++----------- .../api/RestApiAdminPrivilegesEvaluator.java | 2 +- 3 files changed, 79 insertions(+), 102 deletions(-) diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java index 5da43dd47b..b612656307 100644 --- a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java @@ -20,6 +20,7 @@ import java.util.Map; import java.util.Set; import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; import java.util.stream.Collectors; import com.google.common.collect.ImmutableList; @@ -32,6 +33,7 @@ import org.opensearch.client.node.NodeClient; import org.opensearch.cluster.service.ClusterService; import org.opensearch.common.settings.Settings; +import org.opensearch.common.util.concurrent.ThreadContext; import org.opensearch.core.action.ActionListener; import org.opensearch.core.common.transport.TransportAddress; import org.opensearch.core.rest.RestStatus; @@ -47,6 +49,7 @@ import org.opensearch.security.dlic.rest.api.RestApiAdminPrivilegesEvaluator; import org.opensearch.security.dlic.rest.api.RestApiPrivilegesEvaluator; import org.opensearch.security.dlic.rest.api.SecurityApiDependencies; +import org.opensearch.security.dlic.rest.support.Utils; import org.opensearch.security.identity.SecurityTokenManager; import org.opensearch.security.privileges.PrivilegesEvaluator; import org.opensearch.security.securityconf.DynamicConfigFactory; @@ -138,12 +141,18 @@ protected RestChannelConsumer prepareRequest(final RestRequest request, final No } RestChannelConsumer doPrepareRequest(RestRequest request, NodeClient client) { - return switch (request.method()) { - case POST -> handlePost(request, client); - case DELETE -> handleDelete(request, client); - case GET -> handleGet(request, client); - default -> throw new IllegalArgumentException(request.method() + " not supported"); - }; + final var originalUserAndRemoteAddress = Utils.userAndRemoteAddressFrom(client.threadPool().getThreadContext()); + try (final ThreadContext.StoredContext ctx = client.threadPool().getThreadContext().stashContext()) { + client.threadPool() + .getThreadContext() + .putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, originalUserAndRemoteAddress.getLeft()); + return switch (request.method()) { + case POST -> handlePost(request, client); + case DELETE -> handleDelete(request, client); + case GET -> handleGet(request, client); + default -> throw new IllegalArgumentException(request.method() + " not supported"); + }; + } } private RestChannelConsumer handleGet(RestRequest request, NodeClient client) { @@ -177,7 +186,6 @@ private RestChannelConsumer handleGet(RestRequest request, NodeClient client) { private RestChannelConsumer handlePost(RestRequest request, NodeClient client) { return channel -> { - final XContentBuilder builder = channel.newBuilder(); try { final Map requestBody = request.contentOrSourceParamParser().map(); validateRequestParameters(requestBody); @@ -305,7 +313,6 @@ void validateIndexPermissionsList(List> indexPermsList) { private RestChannelConsumer handleDelete(RestRequest request, NodeClient client) { return channel -> { - final XContentBuilder builder = channel.newBuilder(); try { final Map requestBody = request.contentOrSourceParamParser().map(); @@ -449,4 +456,14 @@ protected void authorizeSecurityAccess(RestRequest request) throws IOException { throw new SecurityException("User does not have required security API access"); } } + + private T withSecurityContext(NodeClient client, Supplier operation) { + final var originalUserAndRemoteAddress = Utils.userAndRemoteAddressFrom(client.threadPool().getThreadContext()); + try (final ThreadContext.StoredContext ctx = client.threadPool().getThreadContext().stashContext()) { + client.threadPool() + .getThreadContext() + .putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, originalUserAndRemoteAddress.getLeft()); + return operation.get(); + } + } } diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandler.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandler.java index d34366733e..9145ee4bb1 100644 --- a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandler.java +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandler.java @@ -14,7 +14,6 @@ import java.io.IOException; import java.util.HashMap; import java.util.Map; -import java.util.function.Supplier; import com.google.common.collect.ImmutableMap; import org.apache.logging.log4j.LogManager; @@ -27,7 +26,6 @@ import org.opensearch.action.search.SearchResponse; import org.opensearch.client.Client; import org.opensearch.cluster.service.ClusterService; -import org.opensearch.common.util.concurrent.ThreadContext; import org.opensearch.common.xcontent.XContentFactory; import org.opensearch.common.xcontent.XContentType; import org.opensearch.core.action.ActionListener; @@ -42,7 +40,6 @@ import org.opensearch.index.reindex.DeleteByQueryRequest; import org.opensearch.search.SearchHit; import org.opensearch.search.builder.SearchSourceBuilder; -import org.opensearch.security.dlic.rest.support.Utils; import org.opensearch.security.support.ConfigConstants; import static org.opensearch.security.action.apitokens.ApiToken.NAME_FIELD; @@ -59,116 +56,79 @@ public ApiTokenIndexHandler(Client client, ClusterService clusterService) { } public void indexTokenMetadata(ApiToken token) { - withSecurityContext(() -> { - try { - - XContentBuilder builder = XContentFactory.jsonBuilder(); - String jsonString = token.toXContent(builder, ToXContent.EMPTY_PARAMS).toString(); - - IndexRequest request = new IndexRequest(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX).source(jsonString, XContentType.JSON); - - ActionListener irListener = ActionListener.wrap(idxResponse -> { - LOGGER.info("Created {} entry.", ConfigConstants.OPENSEARCH_API_TOKENS_INDEX); - }, (failResponse) -> { - LOGGER.error(failResponse.getMessage()); - LOGGER.info("Failed to create {} entry.", ConfigConstants.OPENSEARCH_API_TOKENS_INDEX); - }); - client.index(request, irListener); - } catch (IOException e) { - throw new RuntimeException(e); - } - }); + try { + + XContentBuilder builder = XContentFactory.jsonBuilder(); + String jsonString = token.toXContent(builder, ToXContent.EMPTY_PARAMS).toString(); + + IndexRequest request = new IndexRequest(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX).source(jsonString, XContentType.JSON); + + ActionListener irListener = ActionListener.wrap(idxResponse -> { + LOGGER.info("Created {} entry.", ConfigConstants.OPENSEARCH_API_TOKENS_INDEX); + }, (failResponse) -> { + LOGGER.error(failResponse.getMessage()); + LOGGER.info("Failed to create {} entry.", ConfigConstants.OPENSEARCH_API_TOKENS_INDEX); + }); + client.index(request, irListener); + } catch (IOException e) { + throw new RuntimeException(e); + } } public void deleteToken(String name) throws ApiTokenException { - withSecurityContext(() -> { - DeleteByQueryRequest request = new DeleteByQueryRequest(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX).setQuery( - QueryBuilders.matchQuery(NAME_FIELD, name) - ).setRefresh(true); + DeleteByQueryRequest request = new DeleteByQueryRequest(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX).setQuery( + QueryBuilders.matchQuery(NAME_FIELD, name) + ).setRefresh(true); - BulkByScrollResponse response = client.execute(DeleteByQueryAction.INSTANCE, request).actionGet(); + BulkByScrollResponse response = client.execute(DeleteByQueryAction.INSTANCE, request).actionGet(); - long deletedDocs = response.getDeleted(); + long deletedDocs = response.getDeleted(); - if (deletedDocs == 0) { - throw new ApiTokenException("No token found with name " + name); - } - }); + if (deletedDocs == 0) { + throw new ApiTokenException("No token found with name " + name); + } } public Map getTokenMetadatas() { - return withSecurityContext(() -> { - try { - SearchRequest searchRequest = new SearchRequest(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX); - searchRequest.source(new SearchSourceBuilder()); - - SearchResponse response = client.search(searchRequest).actionGet(); - - Map tokens = new HashMap<>(); - for (SearchHit hit : response.getHits().getHits()) { - try ( - XContentParser parser = XContentType.JSON.xContent() - .createParser( - NamedXContentRegistry.EMPTY, - DeprecationHandler.THROW_UNSUPPORTED_OPERATION, - hit.getSourceRef().streamInput() - ) - ) { - - ApiToken token = ApiToken.fromXContent(parser); - tokens.put(token.getName(), token); - } + try { + SearchRequest searchRequest = new SearchRequest(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX); + searchRequest.source(new SearchSourceBuilder()); + + SearchResponse response = client.search(searchRequest).actionGet(); + + Map tokens = new HashMap<>(); + for (SearchHit hit : response.getHits().getHits()) { + try ( + XContentParser parser = XContentType.JSON.xContent() + .createParser( + NamedXContentRegistry.EMPTY, + DeprecationHandler.THROW_UNSUPPORTED_OPERATION, + hit.getSourceRef().streamInput() + ) + ) { + + ApiToken token = ApiToken.fromXContent(parser); + tokens.put(token.getName(), token); } - return tokens; - } catch (IOException e) { - throw new RuntimeException(e); } - }); + return tokens; + } catch (IOException e) { + throw new RuntimeException(e); + } } public Boolean apiTokenIndexExists() { return clusterService.state().metadata().hasConcreteIndex(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX); } - private T withSecurityContext(Supplier operation) { - final var originalUserAndRemoteAddress = Utils.userAndRemoteAddressFrom(client.threadPool().getThreadContext()); - try (final ThreadContext.StoredContext ctx = client.threadPool().getThreadContext().stashContext()) { - client.threadPool() - .getThreadContext() - .putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, originalUserAndRemoteAddress.getLeft()); - return operation.get(); - } - } - - private void withSecurityContext(Runnable operation) { - final var originalUserAndRemoteAddress = Utils.userAndRemoteAddressFrom(client.threadPool().getThreadContext()); - try (final ThreadContext.StoredContext ctx = client.threadPool().getThreadContext().stashContext()) { - client.threadPool() - .getThreadContext() - .putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, originalUserAndRemoteAddress.getLeft()); - operation.run(); - } - } - public void createApiTokenIndexIfAbsent() { if (!apiTokenIndexExists()) { - final var originalUserAndRemoteAddress = Utils.userAndRemoteAddressFrom(client.threadPool().getThreadContext()); - try (final ThreadContext.StoredContext ctx = client.threadPool().getThreadContext().stashContext()) { - client.threadPool() - .getThreadContext() - .putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, originalUserAndRemoteAddress.getLeft()); - final Map indexSettings = ImmutableMap.of( - "index.number_of_shards", - 1, - "index.auto_expand_replicas", - "0-all" - ); - final CreateIndexRequest createIndexRequest = new CreateIndexRequest(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX).settings( - indexSettings - ); - client.admin().indices().create(createIndexRequest); - } + final Map indexSettings = ImmutableMap.of("index.number_of_shards", 1, "index.auto_expand_replicas", "0-all"); + final CreateIndexRequest createIndexRequest = new CreateIndexRequest(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX).settings( + indexSettings + ); + client.admin().indices().create(createIndexRequest); } } diff --git a/src/main/java/org/opensearch/security/dlic/rest/api/RestApiAdminPrivilegesEvaluator.java b/src/main/java/org/opensearch/security/dlic/rest/api/RestApiAdminPrivilegesEvaluator.java index 3714ac4c3d..768f9d2f70 100644 --- a/src/main/java/org/opensearch/security/dlic/rest/api/RestApiAdminPrivilegesEvaluator.java +++ b/src/main/java/org/opensearch/security/dlic/rest/api/RestApiAdminPrivilegesEvaluator.java @@ -70,8 +70,8 @@ default String build() { .put(Endpoint.ROLES, action -> buildEndpointPermission(Endpoint.ROLES)) .put(Endpoint.ROLESMAPPING, action -> buildEndpointPermission(Endpoint.ROLESMAPPING)) .put(Endpoint.TENANTS, action -> buildEndpointPermission(Endpoint.TENANTS)) - .put(Endpoint.SSL, action -> buildEndpointActionPermission(Endpoint.SSL, action)) .put(Endpoint.APITOKENS, action -> buildEndpointPermission(Endpoint.APITOKENS)) + .put(Endpoint.SSL, action -> buildEndpointActionPermission(Endpoint.SSL, action)) .build(); private final ThreadContext threadContext; From b6aa196a1c755896ab9800034e5cd82d6ef1e6bf Mon Sep 17 00:00:00 2001 From: Derek Ho Date: Tue, 4 Feb 2025 17:11:17 -0500 Subject: [PATCH 21/23] Remove unecessary file changes Signed-off-by: Derek Ho --- .../opensearch/security/privileges/ActionPrivileges.java | 6 +----- .../action/apitokens/ApiTokenAuthenticatorTest.java | 4 ++-- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java b/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java index 6066a62725..dcb6cded2d 100644 --- a/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java +++ b/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java @@ -1026,11 +1026,7 @@ PrivilegesEvaluatorResponse providesExplicitPrivilege( } } return PrivilegesEvaluatorResponse.insufficient(checkTable) - .reason( - resolvedIndices.getAllIndices().size() == 1 - ? "Insufficient permissions for the referenced index" - : "None of " + resolvedIndices.getAllIndices().size() + " referenced indices has sufficient permissions" - ) + .reason("No explicit privileges have been provided for the referenced indices.") .evaluationExceptions(exceptions); } diff --git a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenAuthenticatorTest.java b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenAuthenticatorTest.java index 9dd2270dcb..b6c5e0b0f1 100644 --- a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenAuthenticatorTest.java +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenAuthenticatorTest.java @@ -120,7 +120,8 @@ public void testExtractCredentialsFailWhenTokenIsExpired() { AuthCredentials ac = authenticator.extractCredentials(request, threadcontext); assertNull("Should return null when JTI is expired", ac); - verify(log).debug(eq("Invalid or expired JWT token."), any(ExpiredJwtException.class)); + verify(log).debug(eq("Invalid or expired api token."), any(ExpiredJwtException.class)); + } @Test @@ -183,7 +184,6 @@ public void testAuthenticatorNotEnabled() { Settings settings = Settings.builder() .put("enabled", "false") .put("signing_key", "U3VwZXJTZWNyZXRLZXlUaGF0SXNFeGFjdGx5NjRCeXRlc0xvbmdBbmRXaWxsV29ya1dpdGhIUzUxMkFsZ29yaXRobSEhCgo=") - .put("encryption_key", "MTIzNDU2Nzg5MDEyMzQ1Ng==") .build(); ThreadContext threadContext = new ThreadContext(settings); From bf317912bcc7ca22df56c2db2d787aedb454a0b3 Mon Sep 17 00:00:00 2001 From: Derek Ho Date: Thu, 6 Feb 2025 14:35:43 -0500 Subject: [PATCH 22/23] Add alias support Signed-off-by: Derek Ho --- .../security/OpenSearchSecurityPlugin.java | 4 +- .../action/apitokens/ApiTokenAction.java | 27 +++++++-- .../security/privileges/IndexPattern.java | 46 ++++++++++---- .../action/apitokens/ApiTokenActionTest.java | 60 +++++++++++++++++-- 4 files changed, 111 insertions(+), 26 deletions(-) diff --git a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java index eee0b4d88b..c68c4fb610 100644 --- a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java +++ b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java @@ -659,7 +659,9 @@ public List getRestHandlers( auditLog, configPath, principalExtractor, - apiTokenRepository + apiTokenRepository, + cs, + indexNameExpressionResolver ) ); handlers.addAll( diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java index 510ef1acd9..bcef2d0b25 100644 --- a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java @@ -29,6 +29,8 @@ import org.opensearch.OpenSearchException; import org.opensearch.client.node.NodeClient; +import org.opensearch.cluster.metadata.IndexNameExpressionResolver; +import org.opensearch.cluster.service.ClusterService; import org.opensearch.common.settings.Settings; import org.opensearch.common.util.concurrent.ThreadContext; import org.opensearch.core.action.ActionListener; @@ -47,6 +49,8 @@ import org.opensearch.security.dlic.rest.api.RestApiPrivilegesEvaluator; import org.opensearch.security.dlic.rest.api.SecurityApiDependencies; import org.opensearch.security.dlic.rest.support.Utils; +import org.opensearch.security.privileges.IndexPattern; +import org.opensearch.security.privileges.PrivilegesEvaluationException; import org.opensearch.security.privileges.PrivilegesEvaluator; import org.opensearch.security.securityconf.DynamicConfigFactory; import org.opensearch.security.securityconf.FlattenedActionGroups; @@ -82,6 +86,8 @@ public class ApiTokenAction extends BaseRestHandler { private final ConfigurationRepository configurationRepository; private final PrivilegesEvaluator privilegesEvaluator; private final SecurityApiDependencies securityApiDependencies; + private final ClusterService clusterService; + private final IndexNameExpressionResolver indexNameExpressionResolver; private static final List ROUTES = addRoutesPrefix( ImmutableList.of(new Route(POST, "/apitokens"), new Route(DELETE, "/apitokens"), new Route(GET, "/apitokens")) @@ -96,7 +102,9 @@ public ApiTokenAction( AuditLog auditLog, Path configPath, PrincipalExtractor principalExtractor, - ApiTokenRepository apiTokenRepository + ApiTokenRepository apiTokenRepository, + ClusterService clusterService, + IndexNameExpressionResolver indexNameExpressionResolver ) { this.apiTokenRepository = apiTokenRepository; this.threadPool = threadpool; @@ -116,6 +124,8 @@ public ApiTokenAction( auditLog, settings ); + this.clusterService = clusterService; + this.indexNameExpressionResolver = indexNameExpressionResolver; } @Override @@ -358,7 +368,8 @@ private void sendErrorResponse(RestChannel channel, RestStatus status, String er * Validates that the user has the required permissions to create an API token (must be a subset of their own permissions) * */ @SuppressWarnings("unchecked") - void validateUserPermissions(List clusterPermissions, List indexPermissions) { + void validateUserPermissions(List clusterPermissions, List indexPermissions) + throws PrivilegesEvaluationException { final User user = threadPool.getThreadContext().getTransient(ConfigConstants.OPENDISTRO_SECURITY_USER); final TransportAddress caller = threadPool.getThreadContext().getTransient(ConfigConstants.OPENDISTRO_SECURITY_REMOTE_ADDRESS); final Set roles = privilegesEvaluator.mapRoles(user, caller); @@ -366,9 +377,6 @@ void validateUserPermissions(List clusterPermissions, List clusterPermissions, List rolePatterns = indexPermission.getIndex_patterns(); List roleIndexPerms = indexPermission.getAllowed_actions(); + IndexPattern indexPattern = IndexPattern.from(rolePatterns); + // Check if this role's pattern covers the requested pattern - if (WildcardMatcher.from(rolePatterns).test(requestedPattern)) { + if (indexPattern.matches( + requestedPattern, + indexNameExpressionResolver, + (String template) -> WildcardMatcher.NONE, + clusterService.state().metadata().getIndicesLookup() + )) { // Get resolved actions for this role's index permissions Set roleActions = flattenedActionGroups.resolve(roleIndexPerms); WildcardMatcher matcher = WildcardMatcher.from(roleActions); diff --git a/src/main/java/org/opensearch/security/privileges/IndexPattern.java b/src/main/java/org/opensearch/security/privileges/IndexPattern.java index d5d419f72b..e5a4f0f36e 100644 --- a/src/main/java/org/opensearch/security/privileges/IndexPattern.java +++ b/src/main/java/org/opensearch/security/privileges/IndexPattern.java @@ -61,29 +61,33 @@ private IndexPattern(WildcardMatcher staticPattern, ImmutableList patter this.hashCode = staticPattern.hashCode() + patternTemplates.hashCode() + dateMathExpressions.hashCode(); } - public boolean matches(String index, PrivilegesEvaluationContext context, Map indexMetadata) - throws PrivilegesEvaluationException { + @FunctionalInterface + public interface MatcherGenerator { + WildcardMatcher apply(String pattern) throws PrivilegesEvaluationException; + } + + public boolean matches( + String index, + IndexNameExpressionResolver indexNameExpressionResolver, + MatcherGenerator matcherGenerator, + Map indexMetadata + ) throws PrivilegesEvaluationException { if (staticPattern != WildcardMatcher.NONE && staticPattern.test(index)) { return true; } if (!patternTemplates.isEmpty()) { for (String patternTemplate : this.patternTemplates) { - try { - WildcardMatcher matcher = context.getRenderedMatcher(patternTemplate); - if (matcher.test(index)) { - return true; - } - } catch (ExpressionEvaluationException e) { - throw new PrivilegesEvaluationException("Error while evaluating dynamic index pattern: " + patternTemplate, e); + WildcardMatcher matcher = matcherGenerator.apply(patternTemplate); + + if (matcher.test(index)) { + return true; } } } if (!dateMathExpressions.isEmpty()) { - IndexNameExpressionResolver indexNameExpressionResolver = context.getIndexNameExpressionResolver(); - // Note: The use of date math expressions in privileges is a bit odd, as it only provides a very limited // solution for the potential user case. A different approach might be nice. @@ -108,7 +112,12 @@ public boolean matches(String index, PrivilegesEvaluationContext context, Map indexMetadata) + throws PrivilegesEvaluationException { + return matches(index, context.getIndexNameExpressionResolver(), (String patternTemplate) -> { + try { + return context.getRenderedMatcher(patternTemplate); + } catch (ExpressionEvaluationException e) { + throw new PrivilegesEvaluationException("Error while evaluating dynamic index pattern: " + patternTemplate, e); + } + }, indexMetadata); + } + @Override public String toString() { if (patternTemplates.size() == 0 && dateMathExpressions.size() == 0) { diff --git a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java index 285120f60e..838aaf6ed9 100644 --- a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java @@ -25,6 +25,12 @@ import org.junit.runner.RunWith; import org.opensearch.OpenSearchException; +import org.opensearch.Version; +import org.opensearch.cluster.ClusterState; +import org.opensearch.cluster.metadata.AliasMetadata; +import org.opensearch.cluster.metadata.IndexMetadata; +import org.opensearch.cluster.metadata.Metadata; +import org.opensearch.cluster.service.ClusterService; import org.opensearch.common.settings.Settings; import org.opensearch.common.util.concurrent.ThreadContext; import org.opensearch.security.configuration.ConfigurationRepository; @@ -57,6 +63,14 @@ public class ApiTokenActionTest { @Mock private ConfigurationRepository configurationRepository; + @Mock + private ClusterService clusterService; + @Mock + private ClusterState clusterState; + + @Mock + private Metadata metadata; + private SecurityDynamicConfiguration actionGroupsConfig; private SecurityDynamicConfiguration rolesConfig; private FlattenedActionGroups flattenedActionGroups; @@ -119,6 +133,13 @@ public void setUp() throws JsonProcessingException { Arrays.asList(ImmutableMap.of("index_patterns", List.of("lo*"), "allowed_actions", List.of("write_group"))), "cluster_permissions", Arrays.asList("cluster_monitor") + ), + "alias_group", + ImmutableMap.of( + "index_permissions", + Arrays.asList(ImmutableMap.of("index_patterns", List.of("logs"), "allowed_actions", List.of("read"))), + "cluster_permissions", + Arrays.asList("cluster_monitor") ) ), @@ -129,6 +150,19 @@ public void setUp() throws JsonProcessingException { when(configurationRepository.getConfiguration(CType.ROLES)).thenReturn(rolesConfig); when(configurationRepository.getConfiguration(CType.ACTIONGROUPS)).thenReturn(actionGroupsConfig); + when(clusterService.state()).thenReturn(clusterState); + + when(clusterState.metadata()).thenReturn( + Metadata.builder() + .put( + IndexMetadata.builder("my-index") + .settings(Settings.builder().put("index.version.created", Version.CURRENT).build()) + .numberOfShards(1) + .numberOfReplicas(0) + .putAlias(AliasMetadata.builder("logs").build()) + ) + .build() + ); apiTokenAction = new ApiTokenAction( @@ -140,6 +174,8 @@ public void setUp() throws JsonProcessingException { null, null, null, + null, + clusterService, null ); @@ -219,7 +255,7 @@ public void testExtractClusterPermissions() { } @Test - public void testExactMatchPermissionsWithActionGroups() { + public void testExactMatchPermissionsWithActionGroups() throws Exception { when(privilegesEvaluator.mapRoles(null, null)).thenReturn(Set.of("read_group_logs-123")); apiTokenAction.validateUserPermissions(List.of(), List.of(new ApiToken.IndexPermission( @@ -243,7 +279,7 @@ public void testCreateMorePermissableWildcardPermissionWhenNoAccessThrowsExcepti } @Test - public void testMultipleRolesCoveringPermissions() { + public void testMultipleRolesCoveringPermissions() throws Exception { when(privilegesEvaluator.mapRoles(null, null)).thenReturn(Set.of("read_group_logs-123", "write_group_logs-123")); ApiToken.IndexPermission requestedPerm = new ApiToken.IndexPermission(List.of("logs-123"), List.of("read", "write")); @@ -270,7 +306,7 @@ public void testSeparateIndexPatternThrowsException() { } @Test - public void testActionGroupResolution() { + public void testActionGroupResolution() throws Exception{ when(privilegesEvaluator.mapRoles(null, null)).thenReturn(Set.of("read_group_logs-123", "write_group_logs-123")); ApiToken.IndexPermission requestedPerm = new ApiToken.IndexPermission( @@ -282,14 +318,14 @@ public void testActionGroupResolution() { } @Test - public void testEmptyIndexPermissions() { + public void testEmptyIndexPermissions() throws Exception { when(privilegesEvaluator.mapRoles(null, null)).thenReturn(Set.of("read_group_logs-123", "write_group_logs-123")); apiTokenAction.validateUserPermissions(List.of("cluster:monitor"), List.of()); } @Test - public void testClusterPermissionsEvaluation() { + public void testClusterPermissionsEvaluation() throws Exception { when(privilegesEvaluator.mapRoles(null, null)).thenReturn(Set.of("cluster_monitor")); apiTokenAction.validateUserPermissions(List.of("cluster_monitor"), List.of()); @@ -303,9 +339,21 @@ public void testClusterPermissionsMorePermissableRegexThrowsException() { } @Test - public void testClusterPermissionsFromMultipleRoles() { + public void testClusterPermissionsFromMultipleRoles() throws Exception{ when(privilegesEvaluator.mapRoles(null, null)).thenReturn(Set.of("cluster_monitor", "read_group_logs-123")); apiTokenAction.validateUserPermissions(List.of("cluster_monitor", "cluster_health"), List.of()); } + + @Test + public void testAliasAllowsAccessOnUnderlyingIndices() throws Exception { + when(privilegesEvaluator.mapRoles(null, null)).thenReturn(Set.of("alias_group")); + + ApiToken.IndexPermission requestedPerm = new ApiToken.IndexPermission( + List.of("my-index"), + List.of("read") + ); + + apiTokenAction.validateUserPermissions(List.of(), List.of(requestedPerm)); + } } From c95d2563864fefebc9f3f366bc4012832d4089ba Mon Sep 17 00:00:00 2001 From: Derek Ho Date: Tue, 11 Feb 2025 12:44:46 -0500 Subject: [PATCH 23/23] remove permission validation Signed-off-by: Derek Ho --- .../action/apitokens/ApiTokenAction.java | 110 ------------------ .../action/apitokens/ApiTokenActionTest.java | 105 ----------------- 2 files changed, 215 deletions(-) diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java index bcef2d0b25..eddafd79ee 100644 --- a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java @@ -15,26 +15,21 @@ import java.nio.file.Path; import java.time.Instant; import java.util.Collections; -import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableSet; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import org.opensearch.OpenSearchException; import org.opensearch.client.node.NodeClient; import org.opensearch.cluster.metadata.IndexNameExpressionResolver; import org.opensearch.cluster.service.ClusterService; import org.opensearch.common.settings.Settings; import org.opensearch.common.util.concurrent.ThreadContext; import org.opensearch.core.action.ActionListener; -import org.opensearch.core.common.transport.TransportAddress; import org.opensearch.core.rest.RestStatus; import org.opensearch.core.xcontent.XContentBuilder; import org.opensearch.rest.BaseRestHandler; @@ -49,19 +44,9 @@ import org.opensearch.security.dlic.rest.api.RestApiPrivilegesEvaluator; import org.opensearch.security.dlic.rest.api.SecurityApiDependencies; import org.opensearch.security.dlic.rest.support.Utils; -import org.opensearch.security.privileges.IndexPattern; -import org.opensearch.security.privileges.PrivilegesEvaluationException; import org.opensearch.security.privileges.PrivilegesEvaluator; -import org.opensearch.security.securityconf.DynamicConfigFactory; -import org.opensearch.security.securityconf.FlattenedActionGroups; -import org.opensearch.security.securityconf.impl.CType; -import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; -import org.opensearch.security.securityconf.impl.v7.ActionGroupsV7; -import org.opensearch.security.securityconf.impl.v7.RoleV7; import org.opensearch.security.ssl.transport.PrincipalExtractor; import org.opensearch.security.support.ConfigConstants; -import org.opensearch.security.support.WildcardMatcher; -import org.opensearch.security.user.User; import org.opensearch.threadpool.ThreadPool; import static org.opensearch.rest.RestRequest.Method.DELETE; @@ -197,8 +182,6 @@ private RestChannelConsumer handlePost(RestRequest request, NodeClient client) { List clusterPermissions = extractClusterPermissions(requestBody); List indexPermissions = extractIndexPermissions(requestBody); - validateUserPermissions(clusterPermissions, indexPermissions); - String token = apiTokenRepository.createApiToken( (String) requestBody.get(NAME_FIELD), clusterPermissions, @@ -364,99 +347,6 @@ private void sendErrorResponse(RestChannel channel, RestStatus status, String er } } - /** - * Validates that the user has the required permissions to create an API token (must be a subset of their own permissions) - * */ - @SuppressWarnings("unchecked") - void validateUserPermissions(List clusterPermissions, List indexPermissions) - throws PrivilegesEvaluationException { - final User user = threadPool.getThreadContext().getTransient(ConfigConstants.OPENDISTRO_SECURITY_USER); - final TransportAddress caller = threadPool.getThreadContext().getTransient(ConfigConstants.OPENDISTRO_SECURITY_REMOTE_ADDRESS); - final Set roles = privilegesEvaluator.mapRoles(user, caller); - - // Early return conditions - if (roles.isEmpty()) { - throw new OpenSearchException("User does not have any roles"); - } - - // Verify user has all requested cluster permissions - final SecurityDynamicConfiguration actionGroupsConfiguraiton = (SecurityDynamicConfiguration) load( - CType.ACTIONGROUPS - ); - FlattenedActionGroups flattenedActionGroups = new FlattenedActionGroups(actionGroupsConfiguraiton); - final SecurityDynamicConfiguration rolesConfiguration = load(CType.ROLES); - ImmutableSet resolvedClusterPermissions = flattenedActionGroups.resolve(clusterPermissions); - Set clusterPermissionsWithoutActionGroups = resolvedClusterPermissions.stream() - .filter(permission -> !actionGroupsConfiguraiton.getCEntries().containsKey(permission)) - .collect(Collectors.toSet()); - - // Load all roles the user has access to, remove permissions that - for (String role : roles) { - RoleV7 roleV7 = (RoleV7) rolesConfiguration.getCEntry(role); - ImmutableSet expandedRoleClusterPermissions = flattenedActionGroups.resolve(roleV7.getCluster_permissions()); - for (String clusterPermission : expandedRoleClusterPermissions) { - WildcardMatcher matcher = WildcardMatcher.from(clusterPermission); - clusterPermissionsWithoutActionGroups.removeIf(matcher); - } - } - - if (!clusterPermissionsWithoutActionGroups.isEmpty()) { - throw new OpenSearchException("User does not have all requested cluster permissions"); - } - - // Verify user has all requested index permissions - for (ApiToken.IndexPermission requestedPermission : indexPermissions) { - // First, flatten/resolve any action groups into the underlying actions and remove action group names (which may not exact - // match) - Set resolvedActions = new HashSet<>(flattenedActionGroups.resolve(requestedPermission.getAllowedActions())); - resolvedActions.removeIf(permission -> actionGroupsConfiguraiton.getCEntries().containsKey(permission)); - - // For each index pattern in the requested permission - for (String requestedPattern : requestedPermission.getIndexPatterns()) { - - Set actionsForIndexPattern = new HashSet<>(resolvedActions); - - // Check each role the user has - for (String roleName : roles) { - RoleV7 role = (RoleV7) rolesConfiguration.getCEntry(roleName); - if (role == null || role.getIndex_permissions() == null) continue; - - // Check each index permission block in the role - for (RoleV7.Index indexPermission : role.getIndex_permissions()) { - List rolePatterns = indexPermission.getIndex_patterns(); - List roleIndexPerms = indexPermission.getAllowed_actions(); - - IndexPattern indexPattern = IndexPattern.from(rolePatterns); - - // Check if this role's pattern covers the requested pattern - if (indexPattern.matches( - requestedPattern, - indexNameExpressionResolver, - (String template) -> WildcardMatcher.NONE, - clusterService.state().metadata().getIndicesLookup() - )) { - // Get resolved actions for this role's index permissions - Set roleActions = flattenedActionGroups.resolve(roleIndexPerms); - WildcardMatcher matcher = WildcardMatcher.from(roleActions); - - actionsForIndexPattern.removeIf(matcher); - } - } - } - - // After checking all roles, verify if all requested actions were covered - if (!actionsForIndexPattern.isEmpty()) { - throw new OpenSearchException("User does not have sufficient permissions for index pattern: " + requestedPattern); - } - } - } - } - - private SecurityDynamicConfiguration load(final CType config) { - SecurityDynamicConfiguration loaded = configurationRepository.getConfiguration(config); - return DynamicConfigFactory.addStatics(loaded); - } - protected void authorizeSecurityAccess(RestRequest request) throws IOException { // Check if user has security API access if (!(securityApiDependencies.restApiAdminPrivilegesEvaluator().isCurrentUserAdminFor(Endpoint.APITOKENS) diff --git a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java index 838aaf6ed9..24450153a0 100644 --- a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java @@ -16,7 +16,6 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Set; import com.google.common.collect.ImmutableMap; import com.fasterxml.jackson.core.JsonProcessingException; @@ -24,7 +23,6 @@ import org.junit.Test; import org.junit.runner.RunWith; -import org.opensearch.OpenSearchException; import org.opensearch.Version; import org.opensearch.cluster.ClusterState; import org.opensearch.cluster.metadata.AliasMetadata; @@ -253,107 +251,4 @@ public void testExtractClusterPermissions() { requestBody.put("cluster_permissions", Arrays.asList("perm1", "perm2")); assertThat(apiTokenAction.extractClusterPermissions(requestBody), is(Arrays.asList("perm1", "perm2"))); } - - @Test - public void testExactMatchPermissionsWithActionGroups() throws Exception { - when(privilegesEvaluator.mapRoles(null, null)).thenReturn(Set.of("read_group_logs-123")); - - apiTokenAction.validateUserPermissions(List.of(), List.of(new ApiToken.IndexPermission( - List.of("logs-123"), - List.of("read_group") - ))); - } - - @Test - public void testCreateWildcardPermissionWhenNoAccessThrowsException() { - when(privilegesEvaluator.mapRoles(null, null)).thenReturn(Set.of("read_group_logs-123")); - - assertThrows(OpenSearchException.class, () -> apiTokenAction.validateUserPermissions(List.of(), List.of(new ApiToken.IndexPermission(List.of("logs-*"), List.of("read"))))); - } - - @Test - public void testCreateMorePermissableWildcardPermissionWhenNoAccessThrowsException() { - when(privilegesEvaluator.mapRoles(null, null)).thenReturn(Set.of("write_group_logs-star")); - - assertThrows(OpenSearchException.class, () -> apiTokenAction.validateUserPermissions(List.of(), List.of(new ApiToken.IndexPermission(List.of("lo-*"), List.of("write"))))); - } - - @Test - public void testMultipleRolesCoveringPermissions() throws Exception { - when(privilegesEvaluator.mapRoles(null, null)).thenReturn(Set.of("read_group_logs-123", "write_group_logs-123")); - - ApiToken.IndexPermission requestedPerm = new ApiToken.IndexPermission(List.of("logs-123"), List.of("read", "write")); - - apiTokenAction.validateUserPermissions(List.of(), List.of(requestedPerm)); - } - - @Test - public void testInsufficientPermissions() { - when(privilegesEvaluator.mapRoles(null, null)).thenReturn(Set.of("read_group_logs-star")); - - ApiToken.IndexPermission requestedPerm = new ApiToken.IndexPermission(List.of("logs-2023"), List.of("read", "write")); - - assertThrows(OpenSearchException.class, () -> apiTokenAction.validateUserPermissions(List.of(), List.of(requestedPerm))); - } - - @Test - public void testSeparateIndexPatternThrowsException() { - when(privilegesEvaluator.mapRoles(null, null)).thenReturn(Set.of("read_group_logs-123")); - - ApiToken.IndexPermission requestedPerm = new ApiToken.IndexPermission(List.of("logs-123", "metrics-2023"), List.of("read")); - - assertThrows(OpenSearchException.class, () -> apiTokenAction.validateUserPermissions(List.of(), List.of(requestedPerm))); - } - - @Test - public void testActionGroupResolution() throws Exception{ - when(privilegesEvaluator.mapRoles(null, null)).thenReturn(Set.of("read_group_logs-123", "write_group_logs-123")); - - ApiToken.IndexPermission requestedPerm = new ApiToken.IndexPermission( - List.of("logs-123"), - List.of("read", "write", "get", "create") - ); - - apiTokenAction.validateUserPermissions(List.of(), List.of(requestedPerm)); - } - - @Test - public void testEmptyIndexPermissions() throws Exception { - when(privilegesEvaluator.mapRoles(null, null)).thenReturn(Set.of("read_group_logs-123", "write_group_logs-123")); - - apiTokenAction.validateUserPermissions(List.of("cluster:monitor"), List.of()); - } - - @Test - public void testClusterPermissionsEvaluation() throws Exception { - when(privilegesEvaluator.mapRoles(null, null)).thenReturn(Set.of("cluster_monitor")); - - apiTokenAction.validateUserPermissions(List.of("cluster_monitor"), List.of()); - } - - @Test - public void testClusterPermissionsMorePermissableRegexThrowsException() { - when(privilegesEvaluator.mapRoles(null, null)).thenReturn(Set.of("cluster_monitor")); - - assertThrows(OpenSearchException.class, () -> apiTokenAction.validateUserPermissions(List.of("*"), List.of())); - } - - @Test - public void testClusterPermissionsFromMultipleRoles() throws Exception{ - when(privilegesEvaluator.mapRoles(null, null)).thenReturn(Set.of("cluster_monitor", "read_group_logs-123")); - - apiTokenAction.validateUserPermissions(List.of("cluster_monitor", "cluster_health"), List.of()); - } - - @Test - public void testAliasAllowsAccessOnUnderlyingIndices() throws Exception { - when(privilegesEvaluator.mapRoles(null, null)).thenReturn(Set.of("alias_group")); - - ApiToken.IndexPermission requestedPerm = new ApiToken.IndexPermission( - List.of("my-index"), - List.of("read") - ); - - apiTokenAction.validateUserPermissions(List.of(), List.of(requestedPerm)); - } }