From 1e449e3d048ad9dca3de4630920a9c46d57eb83f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20H=C3=B8ydahl?= Date: Wed, 13 May 2020 23:29:33 +0200 Subject: [PATCH] SOLR-12131: ExternalRoleRuleBasedAuthorizationPlugin (#341) --- solr/CHANGES.txt | 2 + .../solr/handler/admin/SystemInfoHandler.java | 6 +- ...ernalRoleRuleBasedAuthorizationPlugin.java | 57 +++ .../apache/solr/security/JWTAuthPlugin.java | 31 +- .../apache/solr/security/JWTPrincipal.java | 4 +- .../apache/solr/security/KerberosFilter.java | 2 +- .../RuleBasedAuthorizationPlugin.java | 312 +--------------- .../RuleBasedAuthorizationPluginBase.java | 339 ++++++++++++++++++ ...BaseTestRuleBasedAuthorizationPlugin.java} | 247 +++++++------ .../solr/security/JWTAuthPluginTest.java | 22 +- .../solr/security/PrincipalWithUserRoles.java | 91 +++++ ...ernalRoleRuleBasedAuthorizationPlugin.java | 78 ++++ .../src/jwt-authentication-plugin.adoc | 1 + .../src/rule-based-authorization-plugin.adoc | 63 +++- solr/solr-ref-guide/src/securing-solr.adoc | 1 + 15 files changed, 815 insertions(+), 441 deletions(-) create mode 100644 solr/core/src/java/org/apache/solr/security/ExternalRoleRuleBasedAuthorizationPlugin.java create mode 100644 solr/core/src/java/org/apache/solr/security/RuleBasedAuthorizationPluginBase.java rename solr/core/src/test/org/apache/solr/security/{TestRuleBasedAuthorizationPlugin.java => BaseTestRuleBasedAuthorizationPlugin.java} (79%) create mode 100644 solr/core/src/test/org/apache/solr/security/PrincipalWithUserRoles.java create mode 100644 solr/core/src/test/org/apache/solr/security/TestExternalRoleRuleBasedAuthorizationPlugin.java diff --git a/solr/CHANGES.txt b/solr/CHANGES.txt index 2700408e279..36fbb8062cc 100644 --- a/solr/CHANGES.txt +++ b/solr/CHANGES.txt @@ -84,6 +84,8 @@ New Features * SOLR-14237: A new panel with security info in admin UI's dashboard (Ishan Chattopadhyaya, Moshe Bla) +* SOLR-12131: ExternalRoleRuleBasedAuthorizationPlugin which gets user's roles from request (janhoy) + Improvements --------------------- * SOLR-14316: Remove unchecked type conversion warning in JavaBinCodec's readMapEntry's equals() method diff --git a/solr/core/src/java/org/apache/solr/handler/admin/SystemInfoHandler.java b/solr/core/src/java/org/apache/solr/handler/admin/SystemInfoHandler.java index 8016a25a24f..3aa31ce532d 100644 --- a/solr/core/src/java/org/apache/solr/handler/admin/SystemInfoHandler.java +++ b/solr/core/src/java/org/apache/solr/handler/admin/SystemInfoHandler.java @@ -41,7 +41,7 @@ import org.apache.solr.request.SolrQueryRequest; import org.apache.solr.response.SolrQueryResponse; import org.apache.solr.schema.IndexSchema; import org.apache.solr.security.AuthorizationPlugin; -import org.apache.solr.security.RuleBasedAuthorizationPlugin; +import org.apache.solr.security.RuleBasedAuthorizationPluginBase; import org.apache.solr.util.RTimer; import org.apache.solr.util.RedactionUtils; import org.apache.solr.util.stats.MetricUtils; @@ -341,8 +341,8 @@ public class SystemInfoHandler extends RequestHandlerBase // Mapped roles for this principal AuthorizationPlugin auth = cc==null? null: cc.getAuthorizationPlugin(); if (auth != null) { - RuleBasedAuthorizationPlugin rbap = (RuleBasedAuthorizationPlugin) auth; - Set roles = rbap.getRoles(username); + RuleBasedAuthorizationPluginBase rbap = (RuleBasedAuthorizationPluginBase) auth; + Set roles = rbap.getUserRoles(req.getUserPrincipal()); info.add("roles", roles); } } diff --git a/solr/core/src/java/org/apache/solr/security/ExternalRoleRuleBasedAuthorizationPlugin.java b/solr/core/src/java/org/apache/solr/security/ExternalRoleRuleBasedAuthorizationPlugin.java new file mode 100644 index 00000000000..7575c167d9c --- /dev/null +++ b/solr/core/src/java/org/apache/solr/security/ExternalRoleRuleBasedAuthorizationPlugin.java @@ -0,0 +1,57 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.security; + +import java.lang.invoke.MethodHandles; +import java.security.Principal; +import java.util.Collections; +import java.util.Map; +import java.util.Set; + +import org.apache.solr.common.SolrException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Rule Based Authz plugin implementation which reads user roles from the request. This requires + * a Principal implementing VerifiedUserRoles interface, e.g. JWTAuthenticationPlugin + */ +public class ExternalRoleRuleBasedAuthorizationPlugin extends RuleBasedAuthorizationPluginBase { + private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); + + @Override + public void init(Map initInfo) { + super.init(initInfo); + if (initInfo.containsKey("user-role")) { + throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "Configuration should not contain 'user-role' mappings"); + } + } + + /** + * Pulls roles from the Principal + * @param principal the user Principal which should contain roles + * @return set of roles as strings + */ + @Override + public Set getUserRoles(Principal principal) { + if(principal instanceof VerifiedUserRoles) { + return ((VerifiedUserRoles) principal).getVerifiedRoles(); + } else { + return Collections.emptySet(); + } + } +} diff --git a/solr/core/src/java/org/apache/solr/security/JWTAuthPlugin.java b/solr/core/src/java/org/apache/solr/security/JWTAuthPlugin.java index 79875864668..fb7b9db02c9 100644 --- a/solr/core/src/java/org/apache/solr/security/JWTAuthPlugin.java +++ b/solr/core/src/java/org/apache/solr/security/JWTAuthPlugin.java @@ -74,6 +74,7 @@ public class JWTAuthPlugin extends AuthenticationPlugin implements SpecProvider, private static final String PARAM_REQUIRE_SUBJECT = "requireSub"; private static final String PARAM_REQUIRE_ISSUER = "requireIss"; private static final String PARAM_PRINCIPAL_CLAIM = "principalClaim"; + private static final String PARAM_ROLES_CLAIM = "rolesClaim"; private static final String PARAM_REQUIRE_EXPIRATIONTIME = "requireExp"; private static final String PARAM_ALG_WHITELIST = "algWhitelist"; private static final String PARAM_JWK_CACHE_DURATION = "jwkCacheDur"; @@ -92,7 +93,7 @@ public class JWTAuthPlugin extends AuthenticationPlugin implements SpecProvider, private static final Set PROPS = ImmutableSet.of(PARAM_BLOCK_UNKNOWN, PARAM_REQUIRE_SUBJECT, PARAM_PRINCIPAL_CLAIM, PARAM_REQUIRE_EXPIRATIONTIME, PARAM_ALG_WHITELIST, - PARAM_JWK_CACHE_DURATION, PARAM_CLAIMS_MATCH, PARAM_SCOPE, PARAM_REALM, + PARAM_JWK_CACHE_DURATION, PARAM_CLAIMS_MATCH, PARAM_SCOPE, PARAM_REALM, PARAM_ROLES_CLAIM, PARAM_ADMINUI_SCOPE, PARAM_REDIRECT_URIS, PARAM_REQUIRE_ISSUER, PARAM_ISSUERS, // These keys are supported for now to enable PRIMARY issuer config through top-level keys JWTIssuerConfig.PARAM_JWK_URL, JWTIssuerConfig.PARAM_JWKS_URL, JWTIssuerConfig.PARAM_JWK, JWTIssuerConfig.PARAM_ISSUER, @@ -103,6 +104,7 @@ public class JWTAuthPlugin extends AuthenticationPlugin implements SpecProvider, private boolean requireExpirationTime; private List algWhitelist; private String principalClaim; + private String rolesClaim; private HashMap claimsMatchCompiled; private boolean blockUnknown; private List requiredScopes = new ArrayList<>(); @@ -140,6 +142,8 @@ public class JWTAuthPlugin extends AuthenticationPlugin implements SpecProvider, PARAM_REQUIRE_SUBJECT); } principalClaim = (String) pluginConfig.getOrDefault(PARAM_PRINCIPAL_CLAIM, "sub"); + + rolesClaim = (String) pluginConfig.get(PARAM_ROLES_CLAIM); algWhitelist = (List) pluginConfig.get(PARAM_ALG_WHITELIST); realm = (String) pluginConfig.getOrDefault(PARAM_REALM, DEFAULT_AUTH_REALM); @@ -403,6 +407,8 @@ public class JWTAuthPlugin extends AuthenticationPlugin implements SpecProvider, // Fail if we require scopes but they don't exist return new JWTAuthenticationResponse(AuthCode.CLAIM_MISMATCH, "Claim " + CLAIM_SCOPE + " is required but does not exist in JWT"); } + + // Find scopes for user Set scopes = Collections.emptySet(); Object scopesObj = jwtClaims.getClaimValue(CLAIM_SCOPE); if (scopesObj != null) { @@ -417,10 +423,27 @@ public class JWTAuthPlugin extends AuthenticationPlugin implements SpecProvider, return new JWTAuthenticationResponse(AuthCode.SCOPE_MISSING, "Claim " + CLAIM_SCOPE + " does not contain any of the required scopes: " + requiredScopes); } } - final Set finalScopes = new HashSet<>(scopes); - finalScopes.remove("openid"); // Remove standard scope + } + + // Determine roles of user, either from 'rolesClaim' or from 'scope' as parsed above + final Set finalRoles = new HashSet<>(); + if (rolesClaim == null) { // Pass scopes with principal to signal to any Authorization plugins that user has some verified role claims - return new JWTAuthenticationResponse(AuthCode.AUTHENTICATED, new JWTPrincipalWithUserRoles(principal, jwtCompact, jwtClaims.getClaimsMap(), finalScopes)); + finalRoles.addAll(scopes); + finalRoles.remove("openid"); // Remove standard scope + } else { + // Pull roles from separate claim, either as whitespace separated list or as JSON array + Object rolesObj = jwtClaims.getClaimValue(rolesClaim); + if (rolesObj != null) { + if (rolesObj instanceof String) { + finalRoles.addAll(Arrays.asList(((String) rolesObj).split("\\s+"))); + } else if (rolesObj instanceof List) { + finalRoles.addAll(jwtClaims.getStringListClaimValue(rolesClaim)); + } + } + } + if (finalRoles.size() > 0) { + return new JWTAuthenticationResponse(AuthCode.AUTHENTICATED, new JWTPrincipalWithUserRoles(principal, jwtCompact, jwtClaims.getClaimsMap(), finalRoles)); } else { return new JWTAuthenticationResponse(AuthCode.AUTHENTICATED, new JWTPrincipal(principal, jwtCompact, jwtClaims.getClaimsMap())); } diff --git a/solr/core/src/java/org/apache/solr/security/JWTPrincipal.java b/solr/core/src/java/org/apache/solr/security/JWTPrincipal.java index 737f3fa8e4a..810e49ce83c 100644 --- a/solr/core/src/java/org/apache/solr/security/JWTPrincipal.java +++ b/solr/core/src/java/org/apache/solr/security/JWTPrincipal.java @@ -17,7 +17,6 @@ package org.apache.solr.security; -import java.io.Serializable; import java.security.Principal; import java.util.Map; import java.util.Objects; @@ -27,8 +26,7 @@ import org.apache.http.util.Args; /** * Principal object that carries JWT token and claims for authenticated user. */ -public class JWTPrincipal implements Principal, Serializable { - private static final long serialVersionUID = 4144666467522831388L; +public class JWTPrincipal implements Principal { final String username; String token; Map claims; diff --git a/solr/core/src/java/org/apache/solr/security/KerberosFilter.java b/solr/core/src/java/org/apache/solr/security/KerberosFilter.java index 0937d699e3f..6dd6c7fd668 100644 --- a/solr/core/src/java/org/apache/solr/security/KerberosFilter.java +++ b/solr/core/src/java/org/apache/solr/security/KerberosFilter.java @@ -91,7 +91,7 @@ public class KerberosFilter extends AuthenticationFilter { if (authzPlugin instanceof RuleBasedAuthorizationPlugin) { RuleBasedAuthorizationPlugin ruleBased = (RuleBasedAuthorizationPlugin) authzPlugin; if (request.getHeader(KerberosPlugin.ORIGINAL_USER_PRINCIPAL_HEADER) != null && - ruleBased.doesUserHavePermission(request.getUserPrincipal().getName(), PermissionNameProvider.Name.ALL)) { + ruleBased.doesUserHavePermission(request.getUserPrincipal(), PermissionNameProvider.Name.ALL)) { request = new HttpServletRequestWrapper(request) { @Override public Principal getUserPrincipal() { diff --git a/solr/core/src/java/org/apache/solr/security/RuleBasedAuthorizationPlugin.java b/solr/core/src/java/org/apache/solr/security/RuleBasedAuthorizationPlugin.java index eceb0a1e30a..ef012c0853d 100644 --- a/solr/core/src/java/org/apache/solr/security/RuleBasedAuthorizationPlugin.java +++ b/solr/core/src/java/org/apache/solr/security/RuleBasedAuthorizationPlugin.java @@ -16,329 +16,45 @@ */ package org.apache.solr.security; -import java.io.IOException; import java.lang.invoke.MethodHandles; import java.security.Principal; -import java.util.ArrayList; import java.util.HashMap; -import java.util.HashSet; -import java.util.List; import java.util.Map; import java.util.Set; -import java.util.function.Function; -import org.apache.solr.common.SpecProvider; -import org.apache.solr.common.util.Utils; -import org.apache.solr.common.util.ValidatingJsonMap; -import org.apache.solr.common.util.CommandOperation; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import static java.util.Arrays.asList; -import static java.util.Collections.unmodifiableMap; -import static java.util.function.Function.identity; -import static java.util.stream.Collectors.toMap; -import static org.apache.solr.handler.admin.SecurityConfHandler.getListValue; import static org.apache.solr.handler.admin.SecurityConfHandler.getMapValue; - -public class RuleBasedAuthorizationPlugin implements AuthorizationPlugin, ConfigEditablePlugin, SpecProvider { +/** + * Original implementation of Rule Based Authz plugin which configures user/role + * mapping in the security.json configuration + */ +public class RuleBasedAuthorizationPlugin extends RuleBasedAuthorizationPluginBase { private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); private final Map> usersVsRoles = new HashMap<>(); - private final Map mapping = new HashMap<>(); - private final List permissions = new ArrayList<>(); - - - private static class WildCardSupportMap extends HashMap> { - final Set wildcardPrefixes = new HashSet<>(); - - @Override - public List put(String key, List value) { - if (key != null && key.endsWith("/*")) { - key = key.substring(0, key.length() - 2); - wildcardPrefixes.add(key); - } - return super.put(key, value); - } - - @Override - public List get(Object key) { - List result = super.get(key); - if (key == null || result != null) return result; - if (!wildcardPrefixes.isEmpty()) { - for (String s : wildcardPrefixes) { - if (key.toString().startsWith(s)) { - List l = super.get(s); - if (l != null) { - result = result == null ? new ArrayList<>() : new ArrayList<>(result); - result.addAll(l); - } - } - } - } - return result; - } - } - - @Override - public AuthorizationResponse authorize(AuthorizationContext context) { - List collectionRequests = context.getCollectionRequests(); - if (log.isDebugEnabled()) { - log.debug("Attempting to authorize request to [{}] of type: [{}], associated with collections [{}]", - context.getResource(), context.getRequestType(), collectionRequests); - } - - if (context.getRequestType() == AuthorizationContext.RequestType.ADMIN) { - log.debug("Authorizing an ADMIN request, checking admin permissions"); - MatchStatus flag = checkCollPerm(mapping.get(null), context); - return flag.rsp; - } - - for (AuthorizationContext.CollectionRequest collreq : collectionRequests) { - //check permissions for each collection - log.debug("Authorizing collection-aware request, checking perms applicable to specific collection [{}]", - collreq.collectionName); - MatchStatus flag = checkCollPerm(mapping.get(collreq.collectionName), context); - if (flag != MatchStatus.NO_PERMISSIONS_FOUND) return flag.rsp; - } - - log.debug("Authorizing collection-aware request, checking perms applicable to all (*) collections"); - //check wildcard (all=*) permissions. - MatchStatus flag = checkCollPerm(mapping.get("*"), context); - return flag.rsp; - } - - private MatchStatus checkCollPerm(Map> pathVsPerms, - AuthorizationContext context) { - if (pathVsPerms == null) return MatchStatus.NO_PERMISSIONS_FOUND; - - if (log.isTraceEnabled()) { - log.trace("Following perms are associated with collection"); - for (String pathKey : pathVsPerms.keySet()) { - final List permsAssociatedWithPath = pathVsPerms.get(pathKey); - log.trace("Path: [{}], Perms: [{}]", pathKey, permsAssociatedWithPath); - } - } - - String path = context.getResource(); - MatchStatus flag = checkPathPerm(pathVsPerms.get(path), context); - if (flag != MatchStatus.NO_PERMISSIONS_FOUND) return flag; - return checkPathPerm(pathVsPerms.get(null), context); - } - - private MatchStatus checkPathPerm(List permissions, AuthorizationContext context) { - if (permissions == null || permissions.isEmpty()) { - return MatchStatus.NO_PERMISSIONS_FOUND; - } - Principal principal = context.getUserPrincipal(); - - log.trace("Following perms are associated with this collection and path: [{}]", permissions); - final Permission governingPermission = findFirstGoverningPermission(permissions, context); - if (governingPermission == null) { - if (log.isDebugEnabled()) { - log.debug("No perms configured for the resource {} . So allowed to access", context.getResource()); - } - return MatchStatus.NO_PERMISSIONS_FOUND; - } - if (log.isDebugEnabled()) { - log.debug("Found perm [{}] to govern resource [{}]", governingPermission, context.getResource()); - } - - return determineIfPermissionPermitsPrincipal(principal, governingPermission); - } - - private Permission findFirstGoverningPermission(List permissions, AuthorizationContext context) { - for (int i = 0; i < permissions.size(); i++) { - Permission permission = permissions.get(i); - if (permissionAppliesToRequest(permission, context)) return permission; - } - - return null; - } - - private boolean permissionAppliesToRequest(Permission permission, AuthorizationContext context) { - if (log.isTraceEnabled()) { - log.trace("Testing whether permission [{}] applies to request [{}]", permission, context.getResource()); - } - if (PermissionNameProvider.values.containsKey(permission.name)) { - return predefinedPermissionAppliesToRequest(permission, context); - } else { - return customPermissionAppliesToRequest(permission, context); - } - } - - private boolean predefinedPermissionAppliesToRequest(Permission predefinedPermission, AuthorizationContext context) { - log.trace("Permission [{}] is a predefined perm", predefinedPermission); - if (predefinedPermission.wellknownName == PermissionNameProvider.Name.ALL) { - log.trace("'ALL' perm applies to all requests; perm applies."); - return true; //'ALL' applies to everything! - } else if (! (context.getHandler() instanceof PermissionNameProvider)) { - if (log.isTraceEnabled()) { - log.trace("Request handler [{}] is not a PermissionNameProvider, perm doesnt apply", context.getHandler()); - } - return false; // We're not 'ALL', and the handler isn't associated with any other predefined permissions - } else { - PermissionNameProvider handler = (PermissionNameProvider) context.getHandler(); - PermissionNameProvider.Name permissionName = handler.getPermissionName(context); - - boolean applies = permissionName != null && predefinedPermission.name.equals(permissionName.name); - log.trace("Request handler [{}] is associated with predefined perm [{}]? {}", - handler, predefinedPermission.name, applies); - return applies; - } - } - - private boolean customPermissionAppliesToRequest(Permission customPermission, AuthorizationContext context) { - log.trace("Permission [{}] is a custom permission", customPermission); - if (customPermission.method != null && !customPermission.method.contains(context.getHttpMethod())) { - if (log.isTraceEnabled()) { - log.trace("Custom permission requires method [{}] but request had method [{}]; permission doesn't apply", - customPermission.method, context.getHttpMethod()); - } - //this permissions HTTP method does not match this rule. try other rules - return false; - } - if (customPermission.params != null) { - for (Map.Entry> e : customPermission.params.entrySet()) { - String[] paramVal = context.getParams().getParams(e.getKey()); - if(!e.getValue().apply(paramVal)) { - if (log.isTraceEnabled()) { - log.trace("Request has param [{}] which is incompatible with custom perm [{}]; perm doesnt apply", - e.getKey(), customPermission); - } - return false; - } - } - } - - log.trace("Perm [{}] matches method and params for request; permission applies", customPermission); - return true; - } - - private MatchStatus determineIfPermissionPermitsPrincipal(Principal principal, Permission governingPermission) { - if (governingPermission.role == null) { - log.debug("Governing permission [{}] has no role; permitting access", governingPermission); - return MatchStatus.PERMITTED; - } - if (principal == null) { - log.debug("Governing permission [{}] has role, but request principal cannot be identified; forbidding access", governingPermission); - return MatchStatus.USER_REQUIRED; - } else if (governingPermission.role.contains("*")) { - log.debug("Governing permission [{}] allows all roles; permitting access", governingPermission); - return MatchStatus.PERMITTED; - } - - Set userRoles = usersVsRoles.get(principal.getName()); - for (String role : governingPermission.role) { - if (userRoles != null && userRoles.contains(role)) { - log.debug("Governing permission [{}] allows access to role [{}]; permitting access", governingPermission, role); - return MatchStatus.PERMITTED; - } - } - log.info("This resource is configured to have a permission {}, The principal {} does not have the right role ", governingPermission, principal); - return MatchStatus.FORBIDDEN; - } - - public Set getRoles(String user) { - Set roles = usersVsRoles.get(user); - return roles; - } - - public boolean doesUserHavePermission(String user, PermissionNameProvider.Name permission) { - Set roles = usersVsRoles.get(user); - if (roles != null) { - for (String role: roles) { - if (mapping.get(null) == null) continue; - List permissions = mapping.get(null).get(null); - if (permissions != null) { - for (Permission p: permissions) { - if (permission.equals(p.wellknownName) && p.role.contains(role)) { - return true; - } - } - } - } - } - return false; - } @Override public void init(Map initInfo) { - mapping.put(null, new WildCardSupportMap()); + super.init(initInfo); Map map = getMapValue(initInfo, "user-role"); for (Object o : map.entrySet()) { Map.Entry e = (Map.Entry) o; String roleName = (String) e.getKey(); usersVsRoles.put(roleName, Permission.readValueAsSet(map, roleName)); } - List perms = getListValue(initInfo, "permissions"); - for (Map o : perms) { - Permission p; - try { - p = Permission.load(o); - } catch (Exception exp) { - log.error("Invalid permission ", exp); - continue; - } - permissions.add(p); - add2Mapping(p); - } } - //this is to do optimized lookup of permissions for a given collection/path - private void add2Mapping(Permission permission) { - for (String c : permission.collections) { - WildCardSupportMap m = mapping.get(c); - if (m == null) mapping.put(c, m = new WildCardSupportMap()); - for (String path : permission.path) { - List perms = m.get(path); - if (perms == null) m.put(path, perms = new ArrayList<>()); - perms.add(permission); - } - } - } - - + /** + * Look up user's role from the explicit user-role mapping + * + * @param principal the user Principal from the request + * @return set of roles as strings + */ @Override - public void close() throws IOException { } - - enum MatchStatus { - USER_REQUIRED(AuthorizationResponse.PROMPT), - NO_PERMISSIONS_FOUND(AuthorizationResponse.OK), - PERMITTED(AuthorizationResponse.OK), - FORBIDDEN(AuthorizationResponse.FORBIDDEN); - - final AuthorizationResponse rsp; - - MatchStatus(AuthorizationResponse rsp) { - this.rsp = rsp; - } - } - - - - @Override - public Map edit(Map latestConf, List commands) { - for (CommandOperation op : commands) { - AutorizationEditOperation operation = ops.get(op.name); - if (operation == null) { - op.unknownOperation(); - return null; - } - latestConf = operation.edit(latestConf, op); - if (latestConf == null) return null; - - } - return latestConf; - } - - private static final Map ops = unmodifiableMap(asList(AutorizationEditOperation.values()).stream().collect(toMap(AutorizationEditOperation::getOperationName, identity()))); - - - @Override - public ValidatingJsonMap getSpec() { - return Utils.getSpec("cluster.security.RuleBasedAuthorization").getSpec(); - + public Set getUserRoles(Principal principal) { + return usersVsRoles.get(principal.getName()); } } diff --git a/solr/core/src/java/org/apache/solr/security/RuleBasedAuthorizationPluginBase.java b/solr/core/src/java/org/apache/solr/security/RuleBasedAuthorizationPluginBase.java new file mode 100644 index 00000000000..885fc70ea34 --- /dev/null +++ b/solr/core/src/java/org/apache/solr/security/RuleBasedAuthorizationPluginBase.java @@ -0,0 +1,339 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.security; + +import java.io.IOException; +import java.lang.invoke.MethodHandles; +import java.security.Principal; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; + +import org.apache.solr.common.SpecProvider; +import org.apache.solr.common.util.Utils; +import org.apache.solr.common.util.ValidatingJsonMap; +import org.apache.solr.common.util.CommandOperation; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import static java.util.Arrays.asList; +import static java.util.Collections.unmodifiableMap; +import static java.util.function.Function.identity; +import static java.util.stream.Collectors.toMap; +import static org.apache.solr.handler.admin.SecurityConfHandler.getListValue; + +/** + * Base class for rule based authorization plugins + */ +public abstract class RuleBasedAuthorizationPluginBase implements AuthorizationPlugin, ConfigEditablePlugin, SpecProvider { + private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); + + private final Map mapping = new HashMap<>(); + private final List permissions = new ArrayList<>(); + + + private static class WildCardSupportMap extends HashMap> { + final Set wildcardPrefixes = new HashSet<>(); + + @Override + public List put(String key, List value) { + if (key != null && key.endsWith("/*")) { + key = key.substring(0, key.length() - 2); + wildcardPrefixes.add(key); + } + return super.put(key, value); + } + + @Override + public List get(Object key) { + List result = super.get(key); + if (key == null || result != null) return result; + if (!wildcardPrefixes.isEmpty()) { + for (String s : wildcardPrefixes) { + if (key.toString().startsWith(s)) { + List l = super.get(s); + if (l != null) { + result = result == null ? new ArrayList<>() : new ArrayList<>(result); + result.addAll(l); + } + } + } + } + return result; + } + } + + @Override + public AuthorizationResponse authorize(AuthorizationContext context) { + List collectionRequests = context.getCollectionRequests(); + if (log.isDebugEnabled()) { + log.debug("Attempting to authorize request to [{}] of type: [{}], associated with collections [{}]", + context.getResource(), context.getRequestType(), collectionRequests); + } + + if (context.getRequestType() == AuthorizationContext.RequestType.ADMIN) { + log.debug("Authorizing an ADMIN request, checking admin permissions"); + MatchStatus flag = checkCollPerm(mapping.get(null), context); + return flag.rsp; + } + + for (AuthorizationContext.CollectionRequest collreq : collectionRequests) { + //check permissions for each collection + log.debug("Authorizing collection-aware request, checking perms applicable to specific collection [{}]", + collreq.collectionName); + MatchStatus flag = checkCollPerm(mapping.get(collreq.collectionName), context); + if (flag != MatchStatus.NO_PERMISSIONS_FOUND) return flag.rsp; + } + + log.debug("Authorizing collection-aware request, checking perms applicable to all (*) collections"); + //check wildcard (all=*) permissions. + MatchStatus flag = checkCollPerm(mapping.get("*"), context); + return flag.rsp; + } + + private MatchStatus checkCollPerm(Map> pathVsPerms, + AuthorizationContext context) { + if (pathVsPerms == null) return MatchStatus.NO_PERMISSIONS_FOUND; + + if (log.isTraceEnabled()) { + log.trace("Following perms are associated with collection"); + for (String pathKey : pathVsPerms.keySet()) { + final List permsAssociatedWithPath = pathVsPerms.get(pathKey); + log.trace("Path: [{}], Perms: [{}]", pathKey, permsAssociatedWithPath); + } + } + + String path = context.getResource(); + MatchStatus flag = checkPathPerm(pathVsPerms.get(path), context); + if (flag != MatchStatus.NO_PERMISSIONS_FOUND) return flag; + return checkPathPerm(pathVsPerms.get(null), context); + } + + private MatchStatus checkPathPerm(List permissions, AuthorizationContext context) { + if (permissions == null || permissions.isEmpty()) { + return MatchStatus.NO_PERMISSIONS_FOUND; + } + Principal principal = context.getUserPrincipal(); + + log.trace("Following perms are associated with this collection and path: [{}]", permissions); + final Permission governingPermission = findFirstGoverningPermission(permissions, context); + if (governingPermission == null) { + if (log.isDebugEnabled()) { + log.debug("No perms configured for the resource {} . So allowed to access", context.getResource()); + } + return MatchStatus.NO_PERMISSIONS_FOUND; + } + if (log.isDebugEnabled()) { + log.debug("Found perm [{}] to govern resource [{}]", governingPermission, context.getResource()); + } + + return determineIfPermissionPermitsPrincipal(principal, governingPermission); + } + + private Permission findFirstGoverningPermission(List permissions, AuthorizationContext context) { + for (int i = 0; i < permissions.size(); i++) { + Permission permission = permissions.get(i); + if (permissionAppliesToRequest(permission, context)) return permission; + } + + return null; + } + + private boolean permissionAppliesToRequest(Permission permission, AuthorizationContext context) { + if (log.isTraceEnabled()) { + log.trace("Testing whether permission [{}] applies to request [{}]", permission, context.getResource()); + } + if (PermissionNameProvider.values.containsKey(permission.name)) { + return predefinedPermissionAppliesToRequest(permission, context); + } else { + return customPermissionAppliesToRequest(permission, context); + } + } + + private boolean predefinedPermissionAppliesToRequest(Permission predefinedPermission, AuthorizationContext context) { + log.trace("Permission [{}] is a predefined perm", predefinedPermission); + if (predefinedPermission.wellknownName == PermissionNameProvider.Name.ALL) { + log.trace("'ALL' perm applies to all requests; perm applies."); + return true; //'ALL' applies to everything! + } else if (! (context.getHandler() instanceof PermissionNameProvider)) { + if (log.isTraceEnabled()) { + log.trace("Request handler [{}] is not a PermissionNameProvider, perm doesnt apply", context.getHandler()); + } + return false; // We're not 'ALL', and the handler isn't associated with any other predefined permissions + } else { + PermissionNameProvider handler = (PermissionNameProvider) context.getHandler(); + PermissionNameProvider.Name permissionName = handler.getPermissionName(context); + + boolean applies = permissionName != null && predefinedPermission.name.equals(permissionName.name); + log.trace("Request handler [{}] is associated with predefined perm [{}]? {}", + handler, predefinedPermission.name, applies); + return applies; + } + } + + private boolean customPermissionAppliesToRequest(Permission customPermission, AuthorizationContext context) { + log.trace("Permission [{}] is a custom permission", customPermission); + if (customPermission.method != null && !customPermission.method.contains(context.getHttpMethod())) { + if (log.isTraceEnabled()) { + log.trace("Custom permission requires method [{}] but request had method [{}]; permission doesn't apply", + customPermission.method, context.getHttpMethod()); + } + //this permissions HTTP method does not match this rule. try other rules + return false; + } + if (customPermission.params != null) { + for (Map.Entry> e : customPermission.params.entrySet()) { + String[] paramVal = context.getParams().getParams(e.getKey()); + if(!e.getValue().apply(paramVal)) { + if (log.isTraceEnabled()) { + log.trace("Request has param [{}] which is incompatible with custom perm [{}]; perm doesnt apply", + e.getKey(), customPermission); + } + return false; + } + } + } + + log.trace("Perm [{}] matches method and params for request; permission applies", customPermission); + return true; + } + + private MatchStatus determineIfPermissionPermitsPrincipal(Principal principal, Permission governingPermission) { + if (governingPermission.role == null) { + log.debug("Governing permission [{}] has no role; permitting access", governingPermission); + return MatchStatus.PERMITTED; + } + if (principal == null) { + log.debug("Governing permission [{}] has role, but request principal cannot be identified; forbidding access", governingPermission); + return MatchStatus.USER_REQUIRED; + } else if (governingPermission.role.contains("*")) { + log.debug("Governing permission [{}] allows all roles; permitting access", governingPermission); + return MatchStatus.PERMITTED; + } + + Set userRoles = getUserRoles(principal); + for (String role : governingPermission.role) { + if (userRoles != null && userRoles.contains(role)) { + log.debug("Governing permission [{}] allows access to role [{}]; permitting access", governingPermission, role); + return MatchStatus.PERMITTED; + } + } + log.info("This resource is configured to have a permission {}, The principal {} does not have the right role ", governingPermission, principal); + return MatchStatus.FORBIDDEN; + } + + public boolean doesUserHavePermission(Principal principal, PermissionNameProvider.Name permission) { + Set roles = getUserRoles(principal); + if (roles != null) { + for (String role: roles) { + if (mapping.get(null) == null) continue; + List permissions = mapping.get(null).get(null); + if (permissions != null) { + for (Permission p: permissions) { + if (permission.equals(p.wellknownName) && p.role.contains(role)) { + return true; + } + } + } + } + } + return false; + } + + @Override + public void init(Map initInfo) { + mapping.put(null, new WildCardSupportMap()); + List perms = getListValue(initInfo, "permissions"); + for (Map o : perms) { + Permission p; + try { + p = Permission.load(o); + } catch (Exception exp) { + log.error("Invalid permission ", exp); + continue; + } + permissions.add(p); + add2Mapping(p); + } + } + + //this is to do optimized lookup of permissions for a given collection/path + private void add2Mapping(Permission permission) { + for (String c : permission.collections) { + WildCardSupportMap m = mapping.get(c); + if (m == null) mapping.put(c, m = new WildCardSupportMap()); + for (String path : permission.path) { + List perms = m.get(path); + if (perms == null) m.put(path, perms = new ArrayList<>()); + perms.add(permission); + } + } + } + + /** + * Finds users roles + * @param principal the user Principal to fetch roles for + * @return set of roles as strings or empty set if no roles found + */ + public abstract Set getUserRoles(Principal principal); + + @Override + public void close() throws IOException { } + + enum MatchStatus { + USER_REQUIRED(AuthorizationResponse.PROMPT), + NO_PERMISSIONS_FOUND(AuthorizationResponse.OK), + PERMITTED(AuthorizationResponse.OK), + FORBIDDEN(AuthorizationResponse.FORBIDDEN); + + final AuthorizationResponse rsp; + + MatchStatus(AuthorizationResponse rsp) { + this.rsp = rsp; + } + } + + + + @Override + public Map edit(Map latestConf, List commands) { + for (CommandOperation op : commands) { + AutorizationEditOperation operation = ops.get(op.name); + if (operation == null) { + op.unknownOperation(); + return null; + } + latestConf = operation.edit(latestConf, op); + if (latestConf == null) return null; + + } + return latestConf; + } + + private static final Map ops = unmodifiableMap(asList(AutorizationEditOperation.values()).stream().collect(toMap(AutorizationEditOperation::getOperationName, identity()))); + + + @Override + public ValidatingJsonMap getSpec() { + return Utils.getSpec("cluster.security.RuleBasedAuthorization").getSpec(); + + } +} diff --git a/solr/core/src/test/org/apache/solr/security/TestRuleBasedAuthorizationPlugin.java b/solr/core/src/test/org/apache/solr/security/BaseTestRuleBasedAuthorizationPlugin.java similarity index 79% rename from solr/core/src/test/org/apache/solr/security/TestRuleBasedAuthorizationPlugin.java rename to solr/core/src/test/org/apache/solr/security/BaseTestRuleBasedAuthorizationPlugin.java index 4e1e9aad3e7..43ff9a8e05a 100644 --- a/solr/core/src/test/org/apache/solr/security/TestRuleBasedAuthorizationPlugin.java +++ b/solr/core/src/test/org/apache/solr/security/BaseTestRuleBasedAuthorizationPlugin.java @@ -26,11 +26,13 @@ import java.util.HashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; import org.apache.http.auth.BasicUserPrincipal; import org.apache.solr.SolrTestCaseJ4; import org.apache.solr.common.params.MapSolrParams; import org.apache.solr.common.params.SolrParams; +import org.apache.solr.common.util.CommandOperation; import org.apache.solr.common.util.Utils; import org.apache.solr.handler.DumpRequestHandler; import org.apache.solr.handler.ReplicationHandler; @@ -43,7 +45,6 @@ import org.apache.solr.handler.component.SearchHandler; import org.apache.solr.request.SolrRequestHandler; import org.apache.solr.security.AuthorizationContext.CollectionRequest; import org.apache.solr.security.AuthorizationContext.RequestType; -import org.apache.solr.common.util.CommandOperation; import org.hamcrest.core.IsInstanceOf; import org.hamcrest.core.IsNot; import org.junit.Test; @@ -51,39 +52,54 @@ import org.junit.Test; import static java.util.Collections.emptyMap; import static java.util.Collections.singletonList; import static java.util.Collections.singletonMap; +import static org.apache.solr.common.util.CommandOperation.captureErrors; import static org.apache.solr.common.util.Utils.getObjectByPath; import static org.apache.solr.common.util.Utils.makeMap; -import static org.apache.solr.common.util.CommandOperation.captureErrors; -public class TestRuleBasedAuthorizationPlugin extends SolrTestCaseJ4 { - private static final int STATUS_OK = 200; - private static final int FORBIDDEN = 403; - private static final int PROMPT_FOR_CREDENTIALS = 401; +/** + * Base class for testing RBAC. This will test the {@link RuleBasedAuthorizationPlugin} implementation + * but also serves as a base class for testing other sub classes + */ +@SuppressWarnings("unchecked") +public class BaseTestRuleBasedAuthorizationPlugin extends SolrTestCaseJ4 { + protected Map rules; - String permissions = "{" + - " user-role : {" + - " steve: [dev,user]," + - " tim: [dev,admin]," + - " joe: [user]," + - " noble:[dev,user]" + - " }," + - " permissions : [" + - " {name:'schema-edit'," + - " role:admin}," + - " {name:'collection-admin-read'," + - " role:null}," + - " {name:collection-admin-edit ," + - " role:admin}," + - " {name:mycoll_update," + - " collection:mycoll," + - " path:'/update/*'," + - " role:[dev,admin]" + - " }," + - "{name:read , role:dev }," + - "{name:freeforall, path:'/foo', role:'*'}]}"; + final int STATUS_OK = 200; + final int FORBIDDEN = 403; + final int PROMPT_FOR_CREDENTIALS = 401; + @Override + public void setUp() throws Exception { + super.setUp(); + resetPermissionsAndRoles(); + } + protected void resetPermissionsAndRoles() { + String permissions = "{" + + " user-role : {" + + " steve: [dev,user]," + + " tim: [dev,admin]," + + " joe: [user]," + + " noble:[dev,user]" + + " }," + + " permissions : [" + + " {name:'schema-edit'," + + " role:admin}," + + " {name:'collection-admin-read'," + + " role:null}," + + " {name:collection-admin-edit ," + + " role:admin}," + + " {name:mycoll_update," + + " collection:mycoll," + + " path:'/update/*'," + + " role:[dev,admin]" + + " }," + + "{name:read, role:dev }," + + "{name:freeforall, path:'/foo', role:'*'}]}"; + rules = (Map) Utils.fromJSONString(permissions); + } + @Test public void testBasicPermissions() { checkRules(makeMap("resource", "/update/json/docs", "httpMethod", "POST", @@ -99,7 +115,6 @@ public class TestRuleBasedAuthorizationPlugin extends SolrTestCaseJ4 { "handler", new UpdateRequestHandler()) , STATUS_OK); - checkRules(makeMap("resource", "/update/json/docs", "httpMethod", "POST", "collectionRequests", "mycoll", @@ -117,8 +132,7 @@ public class TestRuleBasedAuthorizationPlugin extends SolrTestCaseJ4 { "userPrincipal", "somebody", "collectionRequests", "mycoll", "httpMethod", "GET", - "handler", new SchemaHandler() - ) + "handler", new SchemaHandler()) , STATUS_OK); checkRules(makeMap("resource", "/schema/fields", @@ -169,7 +183,6 @@ public class TestRuleBasedAuthorizationPlugin extends SolrTestCaseJ4 { "params", new MapSolrParams(singletonMap("action", "RELOAD"))) , PROMPT_FOR_CREDENTIALS); - checkRules(makeMap("resource", "/admin/collections", "userPrincipal", "somebody", "requestType", RequestType.ADMIN, @@ -193,24 +206,22 @@ public class TestRuleBasedAuthorizationPlugin extends SolrTestCaseJ4 { "userPrincipal", "joe") , FORBIDDEN); - - Map rules = (Map) Utils.fromJSONString(permissions); - ((Map)rules.get("user-role")).put("cio","su"); - ((List)rules.get("permissions")).add( makeMap("name", "all", "role", "su")); + setUserRole("cio", "su"); + addPermission("all", "su"); checkRules(makeMap("resource", ReplicationHandler.PATH, "httpMethod", "POST", "userPrincipal", "tim", "handler", new ReplicationHandler(), "collectionRequests", singletonList(new CollectionRequest("mycoll")) ) - , FORBIDDEN, rules); + , FORBIDDEN); checkRules(makeMap("resource", ReplicationHandler.PATH, "httpMethod", "POST", "userPrincipal", "cio", "handler", new ReplicationHandler(), "collectionRequests", singletonList(new CollectionRequest("mycoll")) ) - , STATUS_OK, rules); + , STATUS_OK); checkRules(makeMap("resource", "/admin/collections", "userPrincipal", "tim", @@ -218,14 +229,13 @@ public class TestRuleBasedAuthorizationPlugin extends SolrTestCaseJ4 { "collectionRequests", null, "handler", new CollectionsHandler(), "params", new MapSolrParams(singletonMap("action", "CREATE"))) - , STATUS_OK, rules); + , STATUS_OK); - rules = (Map) Utils.fromJSONString(permissions); - ((List)rules.get("permissions")).add( makeMap("name", "core-admin-edit", "role", "su")); - ((List)rules.get("permissions")).add( makeMap("name", "core-admin-read", "role", "user")); - ((Map)rules.get("user-role")).put("cio","su"); - ((List)rules.get("permissions")).add( makeMap("name", "all", "role", "su")); - permissions = Utils.toJSONString(rules); + resetPermissionsAndRoles(); + addPermission("core-admin-edit", "su"); + addPermission("core-admin-read", "user"); + setUserRole("cio", "su"); + addPermission("all", "su"); checkRules(makeMap("resource", "/admin/cores", "userPrincipal", null, @@ -243,7 +253,7 @@ public class TestRuleBasedAuthorizationPlugin extends SolrTestCaseJ4 { "params", new MapSolrParams(singletonMap("action", "CREATE"))) , FORBIDDEN); - checkRules(makeMap("resource", "/admin/cores", + checkRules(makeMap("resource", "/admin/cores", "userPrincipal", "joe", "requestType", RequestType.ADMIN, "collectionRequests", null, @@ -257,14 +267,10 @@ public class TestRuleBasedAuthorizationPlugin extends SolrTestCaseJ4 { "collectionRequests", null, "handler", new CoreAdminHandler(null), "params", new MapSolrParams(singletonMap("action", "CREATE"))) - ,STATUS_OK ); + ,STATUS_OK); - rules = (Map) Utils.fromJSONString(permissions); - List permissions = (List) rules.get("permissions"); - permissions.remove(permissions.size() -1);//remove the 'all' permission - permissions.add(makeMap("name", "test-params", "role", "admin", "path", "/x", "params", - makeMap("key", Arrays.asList("REGEX:(?i)val1", "VAL2")))); - this.permissions = Utils.toJSONString(rules); + resetPermissionsAndRoles(); + addPermission("test-params", "admin", "/x", makeMap("key", Arrays.asList("REGEX:(?i)val1", "VAL2"))); checkRules(makeMap("resource", "/x", "userPrincipal", null, @@ -289,6 +295,7 @@ public class TestRuleBasedAuthorizationPlugin extends SolrTestCaseJ4 { "handler", new DumpRequestHandler(), "params", new MapSolrParams(singletonMap("key", "Val1"))) , PROMPT_FOR_CREDENTIALS); + checkRules(makeMap("resource", "/x", "userPrincipal", "joe", "requestType", RequestType.UNKNOWN, @@ -304,6 +311,7 @@ public class TestRuleBasedAuthorizationPlugin extends SolrTestCaseJ4 { "handler", new DumpRequestHandler(), "params", new MapSolrParams(singletonMap("key", "Val2"))) , STATUS_OK); + checkRules(makeMap("resource", "/x", "userPrincipal", "joe", "requestType", RequestType.UNKNOWN, @@ -312,20 +320,24 @@ public class TestRuleBasedAuthorizationPlugin extends SolrTestCaseJ4 { "params", new MapSolrParams(singletonMap("key", "VAL2"))) , FORBIDDEN); + Map customRules = (Map) Utils.fromJSONString( + "{permissions:[" + + " {name:update, role:[admin_role,update_role]}," + + " {name:read, role:[admin_role,update_role,read_role]}" + + "]}"); + + clearUserRoles(); + setUserRole("admin", "admin_role"); + setUserRole("update", "update_role"); + setUserRole("solr", "read_role"); + checkRules(makeMap("resource", "/update", "userPrincipal", "solr", "requestType", RequestType.UNKNOWN, "collectionRequests", "go", "handler", new UpdateRequestHandler(), "params", new MapSolrParams(singletonMap("key", "VAL2"))) - , FORBIDDEN, (Map) Utils.fromJSONString( "{user-role:{" + - " admin:[admin_role]," + - " update:[update_role]," + - " solr:[read_role]}," + - " permissions:[" + - " {name:update, role:[admin_role,update_role]}," + - " {name:read, role:[admin_role,update_role,read_role]}" + - "]}")); + , FORBIDDEN, customRules); } /* @@ -337,19 +349,16 @@ public class TestRuleBasedAuthorizationPlugin extends SolrTestCaseJ4 { public void testAllPermissionAllowsActionsWhenUserHasCorrectRole() { SolrRequestHandler handler = new UpdateRequestHandler(); assertThat(handler, new IsInstanceOf(PermissionNameProvider.class)); + setUserRole("dev", "dev"); + setUserRole("admin", "admin"); + addPermission("all", "dev", "admin"); checkRules(makeMap("resource", "/update", "userPrincipal", "dev", "requestType", RequestType.UNKNOWN, "collectionRequests", "go", "handler", handler, "params", new MapSolrParams(singletonMap("key", "VAL2"))) - , STATUS_OK, (Map) Utils.fromJSONString( "{" + - " user-role:{" + - " dev:[dev_role]," + - " admin:[admin_role]}," + - " permissions:[" + - " {name:all, role:[dev_role, admin_role]}" + - "]}")); + , STATUS_OK); handler = new PropertiesRequestHandler(); assertThat(handler, new IsNot<>(new IsInstanceOf(PermissionNameProvider.class))); @@ -359,13 +368,7 @@ public class TestRuleBasedAuthorizationPlugin extends SolrTestCaseJ4 { "collectionRequests", "go", "handler", handler, "params", new MapSolrParams(emptyMap())) - , STATUS_OK, (Map) Utils.fromJSONString( "{" + - " user-role:{" + - " dev:[dev_role]," + - " admin:[admin_role]}," + - " permissions:[" + - " {name:all, role:[dev_role, admin_role]}" + - "]}")); + , STATUS_OK); } @@ -378,19 +381,16 @@ public class TestRuleBasedAuthorizationPlugin extends SolrTestCaseJ4 { public void testAllPermissionAllowsActionsWhenAssociatedRoleIsWildcard() { SolrRequestHandler handler = new UpdateRequestHandler(); assertThat(handler, new IsInstanceOf(PermissionNameProvider.class)); + setUserRole("dev", "dev"); + setUserRole("admin", "admin"); + addPermission("all", "*"); checkRules(makeMap("resource", "/update", "userPrincipal", "dev", "requestType", RequestType.UNKNOWN, "collectionRequests", "go", "handler", new UpdateRequestHandler(), "params", new MapSolrParams(singletonMap("key", "VAL2"))) - , STATUS_OK, (Map) Utils.fromJSONString( "{" + - " user-role:{" + - " dev:[dev_role]," + - " admin:[admin_role]}," + - " permissions:[" + - " {name:all, role:'*'}" + - "]}")); + , STATUS_OK); handler = new PropertiesRequestHandler(); assertThat(handler, new IsNot<>(new IsInstanceOf(PermissionNameProvider.class))); @@ -400,13 +400,7 @@ public class TestRuleBasedAuthorizationPlugin extends SolrTestCaseJ4 { "collectionRequests", "go", "handler", handler, "params", new MapSolrParams(emptyMap())) - , STATUS_OK, (Map) Utils.fromJSONString( "{" + - " user-role:{" + - " dev:[dev_role]," + - " admin:[admin_role]}," + - " permissions:[" + - " {name:all, role:'*'}" + - "]}")); + , STATUS_OK); } /* @@ -418,19 +412,16 @@ public class TestRuleBasedAuthorizationPlugin extends SolrTestCaseJ4 { public void testAllPermissionDeniesActionsWhenUserIsNotCorrectRole() { SolrRequestHandler handler = new UpdateRequestHandler(); assertThat(handler, new IsInstanceOf(PermissionNameProvider.class)); + setUserRole("dev", "dev"); + setUserRole("admin", "admin"); + addPermission("all", "admin"); checkRules(makeMap("resource", "/update", "userPrincipal", "dev", "requestType", RequestType.UNKNOWN, "collectionRequests", "go", "handler", new UpdateRequestHandler(), "params", new MapSolrParams(singletonMap("key", "VAL2"))) - , FORBIDDEN, (Map) Utils.fromJSONString( "{" + - " user-role:{" + - " dev:[dev_role]," + - " admin:[admin_role]}," + - " permissions:[" + - " {name:all, role:'admin_role'}" + - "]}")); + , FORBIDDEN); handler = new PropertiesRequestHandler(); assertThat(handler, new IsNot<>(new IsInstanceOf(PermissionNameProvider.class))); @@ -440,13 +431,29 @@ public class TestRuleBasedAuthorizationPlugin extends SolrTestCaseJ4 { "collectionRequests", "go", "handler", handler, "params", new MapSolrParams(emptyMap())) - , FORBIDDEN, (Map) Utils.fromJSONString( "{" + - " user-role:{" + - " dev:[dev_role]," + - " admin:[admin_role]}," + - " permissions:[" + - " {name:all, role:'admin_role'}" + - "]}")); + , FORBIDDEN); + } + + void addPermission(String permissionName, String role, String path, Map params) { + ((List)rules.get("permissions")).add( makeMap("name", permissionName, "role", role, "path", path, "params", params)); + } + + void removePermission(String name) { + List> oldPerm = ((List) rules.get("permissions")); + List> newPerm = oldPerm.stream().filter(p -> !p.get("name").equals(name)).collect(Collectors.toList()); + rules.put("permissions", newPerm); + } + + protected void addPermission(String permissionName, String... roles) { + ((List)rules.get("permissions")).add( makeMap("name", permissionName, "role", Arrays.asList(roles))); + } + + void clearUserRoles() { + rules.put("user-role", new HashMap()); + } + + protected void setUserRole(String user, String role) { + ((Map)rules.get("user-role")).put(user, role); } public void testEditRules() throws IOException { @@ -498,13 +505,13 @@ public class TestRuleBasedAuthorizationPlugin extends SolrTestCaseJ4 { } } - private void checkRules(Map values, int expected) { - checkRules(values,expected,(Map) Utils.fromJSONString(permissions)); + void checkRules(Map values, int expected) { + checkRules(values, expected, rules); } - private void checkRules(Map values, int expected, Map permissions) { - AuthorizationContext context = new MockAuthorizationContext(values); - try (RuleBasedAuthorizationPlugin plugin = new RuleBasedAuthorizationPlugin()) { + void checkRules(Map values, int expected, Map permissions) { + AuthorizationContext context = getMockContext(values); + try (RuleBasedAuthorizationPluginBase plugin = createPlugin()) { plugin.init(permissions); AuthorizationResponse authResp = plugin.authorize(context); assertEquals(expected, authResp.statusCode); @@ -513,23 +520,31 @@ public class TestRuleBasedAuthorizationPlugin extends SolrTestCaseJ4 { } } - private static class MockAuthorizationContext extends AuthorizationContext { + protected RuleBasedAuthorizationPluginBase createPlugin() { + return new RuleBasedAuthorizationPlugin(); + } + + AuthorizationContext getMockContext(Map values) { + return new MockAuthorizationContext(values) { + @Override + public Principal getUserPrincipal() { + Object userPrincipal = values.get("userPrincipal"); + return userPrincipal == null ? null : new BasicUserPrincipal(String.valueOf(userPrincipal)); + } + }; + } + + protected abstract class MockAuthorizationContext extends AuthorizationContext { private final Map values; - private MockAuthorizationContext(Map values) { + public MockAuthorizationContext(Map values) { this.values = values; } @Override public SolrParams getParams() { SolrParams params = (SolrParams) values.get("params"); - return params == null ? new MapSolrParams(new HashMap()) : params; - } - - @Override - public Principal getUserPrincipal() { - Object userPrincipal = values.get("userPrincipal"); - return userPrincipal == null ? null : new BasicUserPrincipal(String.valueOf(userPrincipal)); + return params == null ? new MapSolrParams(new HashMap<>()) : params; } @Override diff --git a/solr/core/src/test/org/apache/solr/security/JWTAuthPluginTest.java b/solr/core/src/test/org/apache/solr/security/JWTAuthPluginTest.java index 5ed1032c2f3..7b04c95daef 100644 --- a/solr/core/src/test/org/apache/solr/security/JWTAuthPluginTest.java +++ b/solr/core/src/test/org/apache/solr/security/JWTAuthPluginTest.java @@ -113,8 +113,8 @@ public class JWTAuthPluginTest extends SolrTestCaseJ4 { claims.setClaim("claim1", "foo"); // additional claims/attributes about the subject can be added claims.setClaim("claim2", "bar"); // additional claims/attributes about the subject can be added claims.setClaim("claim3", "foo"); // additional claims/attributes about the subject can be added - List groups = Arrays.asList("group-one", "other-group", "group-three"); - claims.setStringListClaim("groups", groups); // multi-valued claims work too and will end up as a JSON array + List roles = Arrays.asList("group-one", "other-group", "group-three"); + claims.setStringListClaim("roles", roles); // multi-valued claims work too and will end up as a JSON array return claims; } @@ -325,6 +325,7 @@ public class JWTAuthPluginTest extends SolrTestCaseJ4 { JWTAuthPlugin.JWTAuthenticationResponse resp = plugin.authenticate(testHeader); assertTrue(resp.getErrorMessage(), resp.isAuthenticated()); + // When 'rolesClaim' is not defined in config, then all scopes are registered as roles Principal principal = resp.getPrincipal(); assertTrue(principal instanceof VerifiedUserRoles); Set roles = ((VerifiedUserRoles)principal).getVerifiedRoles(); @@ -332,6 +333,23 @@ public class JWTAuthPluginTest extends SolrTestCaseJ4 { assertTrue(roles.contains("solr:read")); } + @Test + public void roles() { + testConfig.put("rolesClaim", "roles"); + plugin.init(testConfig); + JWTAuthPlugin.JWTAuthenticationResponse resp = plugin.authenticate(testHeader); + assertTrue(resp.getErrorMessage(), resp.isAuthenticated()); + + // When 'rolesClaim' is defined in config, then roles from that claim are used instead of claims + Principal principal = resp.getPrincipal(); + assertTrue(principal instanceof VerifiedUserRoles); + Set roles = ((VerifiedUserRoles)principal).getVerifiedRoles(); + assertEquals(3, roles.size()); + assertTrue(roles.contains("group-one")); + assertTrue(roles.contains("other-group")); + assertTrue(roles.contains("group-three")); + } + @Test public void wrongScope() { testConfig.put("scope", "wrong"); diff --git a/solr/core/src/test/org/apache/solr/security/PrincipalWithUserRoles.java b/solr/core/src/test/org/apache/solr/security/PrincipalWithUserRoles.java new file mode 100644 index 00000000000..8d27d0b00d5 --- /dev/null +++ b/solr/core/src/test/org/apache/solr/security/PrincipalWithUserRoles.java @@ -0,0 +1,91 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.security; + +import java.security.Principal; +import java.util.Objects; +import java.util.Set; + +/** + * Type of Principal object that can contain also a list of roles the user has. + * One use case can be to keep track of user-role mappings in an Identity Server + * external to Solr and pass the information to Solr in a signed JWT token or in + * another secure manner. The role information can then be used to authorize + * requests without the need to maintain or lookup what roles each user belongs to. + */ +public class PrincipalWithUserRoles implements Principal, VerifiedUserRoles { + private final String username; + + private final Set roles; + + /** + * User principal with user name as well as one or more roles that he/she belong to + * @param username string with user name for user + * @param roles a set of roles that we know this user belongs to, or empty list for no roles + */ + public PrincipalWithUserRoles(final String username, Set roles) { + super(); + Objects.requireNonNull(username, "User name was null"); + Objects.requireNonNull(roles, "User roles was null"); + this.username = username; + this.roles = roles; + } + + /** + * Returns the name of this principal. + * + * @return the name of this principal. + */ + @Override + public String getName() { + return this.username; + } + + /** + * Gets the list of roles + */ + @Override + public Set getVerifiedRoles() { + return roles; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + PrincipalWithUserRoles that = (PrincipalWithUserRoles) o; + + if (!username.equals(that.username)) return false; + return roles.equals(that.roles); + } + + @Override + public int hashCode() { + int result = username.hashCode(); + result = 31 * result + roles.hashCode(); + return result; + } + + @Override + public String toString() { + return "PrincipalWithUserRoles{" + + "username='" + username + '\'' + + ", roles=" + roles + + '}'; + } +} diff --git a/solr/core/src/test/org/apache/solr/security/TestExternalRoleRuleBasedAuthorizationPlugin.java b/solr/core/src/test/org/apache/solr/security/TestExternalRoleRuleBasedAuthorizationPlugin.java new file mode 100644 index 00000000000..c36cc255307 --- /dev/null +++ b/solr/core/src/test/org/apache/solr/security/TestExternalRoleRuleBasedAuthorizationPlugin.java @@ -0,0 +1,78 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.solr.security; + +import java.security.Principal; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; + +import org.apache.http.auth.BasicUserPrincipal; + +/** + * Tests {@link ExternalRoleRuleBasedAuthorizationPlugin} through simulating principals with roles attached + */ +public class TestExternalRoleRuleBasedAuthorizationPlugin extends BaseTestRuleBasedAuthorizationPlugin { + private HashMap principals; + + @Override + public void setUp() throws Exception { + super.setUp(); + + principals = new HashMap<>(); + setUserRoles("steve", "dev", "user"); + setUserRoles("tim", "dev", "admin"); + setUserRoles("joe", "user"); + setUserRoles("noble", "dev", "user"); + } + + protected void setUserRoles(String user, String... roles) { + principals.put(user, new PrincipalWithUserRoles(user, new HashSet<>(Arrays.asList(roles)))); + } + + @Override + protected void setUserRole(String user, String role) { + principals.put(user, new PrincipalWithUserRoles(user, Collections.singleton(role))); + } + + @Override + AuthorizationContext getMockContext(Map values) { + return new MockAuthorizationContext(values) { + @Override + public Principal getUserPrincipal() { + String userPrincipal = (String) values.get("userPrincipal"); + return userPrincipal == null ? null : + principals.get(userPrincipal) != null ? principals.get(userPrincipal) : + new BasicUserPrincipal(userPrincipal); + } + }; + } + + @Override + protected RuleBasedAuthorizationPluginBase createPlugin() { + return new ExternalRoleRuleBasedAuthorizationPlugin(); + } + + @Override + protected void resetPermissionsAndRoles() { + super.resetPermissionsAndRoles(); + rules.remove("user-role"); + } +} diff --git a/solr/solr-ref-guide/src/jwt-authentication-plugin.adoc b/solr/solr-ref-guide/src/jwt-authentication-plugin.adoc index dbe6147c234..c24e02bc773 100644 --- a/solr/solr-ref-guide/src/jwt-authentication-plugin.adoc +++ b/solr/solr-ref-guide/src/jwt-authentication-plugin.adoc @@ -50,6 +50,7 @@ requireExp ; Fails requests that lacks an `exp` (expiry time) claim algWhitelist ; JSON array with algorithms to accept: `HS256`, `HS384`, `HS512`, `RS256`, `RS384`, `RS512`, `ES256`, `ES384`, `ES512`, `PS256`, `PS384`, `PS512`, `none ; Default is to allow all algorithms jwkCacheDur ; Duration of JWK cache in seconds ; `3600` (1 hour) principalClaim ; What claim id to pull principal from ; `sub` +rolesClaim ; What claim id to pull user roles from. The claim must then either contain a space separated list of roles or a JSON array. The roles can then be used to define fine-grained access in an Authorization plugin ; By default the scopes from `scope` claim are passed on as user roles claimsMatch ; JSON object of claims (key) that must match a regular expression (value). Example: `{ "foo" : "A|B" }` will require the `foo` claim to be either "A" or "B". ; adminUiScope ; Define what scope is requested when logging in from Admin UI ; If not defined, the first scope from `scope` parameter is used redirectUris ; Valid location(s) for redirect after external authentication. Takes a string or array of strings. Must be the base URL of Solr, e.g., https://solr1.example.com:8983/solr/ and must match the list of redirect URIs registered with the Identity Provider beforehand. ; Defaults to empty list, i.e., any node is assumed to be a valid redirect target. diff --git a/solr/solr-ref-guide/src/rule-based-authorization-plugin.adoc b/solr/solr-ref-guide/src/rule-based-authorization-plugin.adoc index 99d09874795..6eb63f55ef2 100644 --- a/solr/solr-ref-guide/src/rule-based-authorization-plugin.adoc +++ b/solr/solr-ref-guide/src/rule-based-authorization-plugin.adoc @@ -16,7 +16,7 @@ // specific language governing permissions and limitations // under the License. -Solr's authentication plugins control whether users can access Solr in a binary fashion. A user is either authenticated, or they aren't. For more fine-grained access control, Solr's Rule-Based Authorization Plugin (hereafter, "RBAP") can be used. +Solr's authentication plugins control whether users can access Solr in a binary fashion. A user is either authenticated, or they aren't. For more fine-grained access control, Solr's Rule-Based Authorization Plugins (hereafter, "RBAP") can be used. [CAUTION] ==== @@ -35,7 +35,10 @@ The users that RBAP sees come from whatever authentication plugin has been confi === Roles -Roles help bridge the gap between users and permissions. Users are assigned one or more roles, and permissions are then given to each of these roles in `security.json` +Roles help bridge the gap between users and permissions. The roles can be used with any of the authentication plugins or with a custom authentication plugin if you have created one. You will only need to ensure that logged-in users are mapped to the roles defined by the plugin. There are two implementations of the plugin, which only differs in how the user's roles are obtained: + +* `RuleBasedAuthorizationPlugin`: The role-to-user mappings must be defined explicitly in `security.json` for every possible authenticated user. +* `ExternalRoleRuleBasedAuthorizationPlugin`: The role-to-user mappings are managed externally. This plugin expects the AuthenticationPlugin to provide a Principal that has the roles information as well, implementing the `VerifiedUserRoles` interface. === Permissions @@ -43,7 +46,7 @@ Permissions control which roles (and consequently, which users) have access to p Administrators can use permissions from a list of predefined options or define their own custom permissions, are are free to mix and match both. -== Configuring the Rule-Based Authorization Plugin +== Configuring the Rule-Based Authorization Plugins Like all of Solr's security plugins, configuration for RBAP lives in a file or ZooKeeper node with the name `security.json`. See <> for more information on how to setup `security.json` in your cluster. @@ -54,15 +57,6 @@ Solr offers an <> for making changes to RBAP configuration. RBAP configuration consists of a small number of required configuration properties. Each of these lives under the `authorization` top level property in `security.json` class:: The authorization plugin to use. For RBAP, this value will always be `solr.RuleBasedAuthorizationPlugin` -user-role:: A mapping of individual users to the roles they belong to. The value of this property is a JSON map, where each property name is a user, and each property value is either the name of a single role or a JSON array of multiple roles that the specified user belongs to. For example: -+ -[source,json] ----- -"user-role": { - "user1": "role1", - "user2": ["role1", "role2"] -} ----- permissions:: A JSON array of permission rules used to restrict access to sections of Solr's API. For example: + [source,json] @@ -75,9 +69,21 @@ permissions:: A JSON array of permission rules used to restrict access to sectio + The syntax for individual permissions is more involved and is treated in greater detail <>. -=== Complete Example +User's roles may either come from the request itself, then you will use the `ExternalRoleRuleBasedAuthorizationPlugin` variant of RBAC. If you need to hardcode user-role mappings, then you need to use the `RuleBasedAuthorizationPlugin` and define the user-role mappings in `security.json` like this: -The example below shows how the configuration properties above can be used to achieve a typical (if simple) RBAP use-case. +user-role:: A mapping of individual users to the roles they belong to. The value of this property is a JSON map, where each property name is a user, and each property value is either the name of a single role or a JSON array of multiple roles that the specified user belongs to. For example: ++ +[source,json] +---- +"user-role": { + "user1": "role1", + "user2": ["role1", "role2"] +} +---- + +=== Example for RuleBasedAuthorizationPlugin and BasicAuth + +This example `security.json` shows how the <> can work with the `RuleBasedAuthorizationPlugin` plugin: [source,json] ---- @@ -112,6 +118,35 @@ The example below shows how the configuration properties above can be used to ac Altogether, this example carves out two restricted areas. Only `admin-user` can access Solr's Authentication and Authorization APIs, and only `dev-user` can access their `dev-private` collection. All other APIs are left open, and can be accessed by both users. +=== Example for ExternalRoleRuleBasedAuthorizationPlugin with JWT auth + +This example `security.json` shows how the <>, which pulls user and user roles from JWT claims, can work with the `ExternalRoleRuleBasedAuthorizationPlugin` plugin: + +[source,json] +---- +{ +"authentication":{ + "class": "solr.JWTAuthPlugin", <1> + "jwksUrl": "https://my.key.server/jwk.json", <2> + "rolesClaim": "roles" <3> +}, +"authorization":{ + "class":"solr.ExternalRoleRuleBasedAuthorizationPlugin", <4> + "permissions":[{"name":"security-edit", + "role":"admin"}] <5> +}} +---- + +Let's walk through this example: + +<1> JWT Authentication plugin is enabled +<2> Public keys is pulled over https +<3> We expect each JWT token to contain a "roles" claim, which will be passed on to Authorization +<4> External Role Rule-based authorization plugin is enabled. +<5> The 'admin' role has been defined, and it has permission to edit security settings. + +Only requests from users having a JWT token with role "admin" will be granted the `security-edit` permission. + == Permissions Solr's Rule-Based Authorization plugin supports a flexible and powerful permission syntax. RBAP supports two types of permissions, each with a slightly different syntax. diff --git a/solr/solr-ref-guide/src/securing-solr.adoc b/solr/solr-ref-guide/src/securing-solr.adoc index 1d3baee532f..837256e88f7 100644 --- a/solr/solr-ref-guide/src/securing-solr.adoc +++ b/solr/solr-ref-guide/src/securing-solr.adoc @@ -53,6 +53,7 @@ Authorization makes sure that only users with the necessary roles/permissions ca // tag::list-of-authorization-plugins[] * <> +* <> // end::list-of-authorization-plugins[] === Audit Logging Plugins