You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@solr.apache.org by ja...@apache.org on 2022/09/14 20:26:14 UTC

[solr] branch branch_9x updated: SOLR-16230: JWT nested roles support (#890)

This is an automated email from the ASF dual-hosted git repository.

janhoy pushed a commit to branch branch_9x
in repository https://gitbox.apache.org/repos/asf/solr.git


The following commit(s) were added to refs/heads/branch_9x by this push:
     new dd3b35fc00f SOLR-16230: JWT nested roles support (#890)
dd3b35fc00f is described below

commit dd3b35fc00faaf1cafb26350208322dd558a2303
Author: Marco Descher <ma...@descher.at>
AuthorDate: Wed Sep 14 22:10:17 2022 +0200

    SOLR-16230: JWT nested roles support (#890)
    
    Co-authored-by: Jan Høydahl <ja...@users.noreply.github.com>
---
 solr/CHANGES.txt                                   |  2 +
 .../apache/solr/security/jwt/JWTAuthPlugin.java    | 37 +++++++++++++++++-
 .../solr/security/jwt/JWTAuthPluginTest.java       | 44 +++++++++++++++++++++-
 .../pages/jwt-authentication-plugin.adoc           |  2 +-
 4 files changed, 82 insertions(+), 3 deletions(-)

diff --git a/solr/CHANGES.txt b/solr/CHANGES.txt
index b50492c26e0..6cde9ec14a9 100644
--- a/solr/CHANGES.txt
+++ b/solr/CHANGES.txt
@@ -58,6 +58,8 @@ Improvements
 
 * SOLR-16362: Logs: Truncate field values in logs if a doc fails to index. (Nazerke Seidan, David Smiley)
 
+* SOLR-16230: JWT nested roles support (Marco Descher, janhoy)
+
 Optimizations
 ---------------------
 * SOLR-16120: Optimise hl.fl expansion. (Christine Poerschke, David Smiley, Mike Drob)
diff --git a/solr/modules/jwt-auth/src/java/org/apache/solr/security/jwt/JWTAuthPlugin.java b/solr/modules/jwt-auth/src/java/org/apache/solr/security/jwt/JWTAuthPlugin.java
index aa13b44e293..6c85d72ba46 100644
--- a/solr/modules/jwt-auth/src/java/org/apache/solr/security/jwt/JWTAuthPlugin.java
+++ b/solr/modules/jwt-auth/src/java/org/apache/solr/security/jwt/JWTAuthPlugin.java
@@ -34,6 +34,7 @@ import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
@@ -599,11 +600,45 @@ public class JWTAuthPlugin extends AuthenticationPlugin
               // Pull roles from separate claim, either as whitespace separated list or as JSON
               // array
               Object rolesObj = jwtClaims.getClaimValue(rolesClaim);
+              if (rolesObj == null && rolesClaim.indexOf('.') > 0) {
+                // support map resolution of nested values
+                String[] nestedKeys = rolesClaim.split("\\.");
+                rolesObj = jwtClaims.getClaimValue(nestedKeys[0]);
+                for (int i = 1; i < nestedKeys.length; i++) {
+                  if (rolesObj instanceof Map) {
+                    String key = nestedKeys[i];
+                    rolesObj = ((Map<?, ?>) rolesObj).get(key);
+                  }
+                }
+              }
+
               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));
+                  ((List<?>) rolesObj)
+                      .forEach(
+                          entry -> {
+                            if (entry instanceof String) {
+                              finalRoles.add((String) entry);
+                            } else {
+                              throw new SolrException(
+                                  SolrException.ErrorCode.BAD_REQUEST,
+                                  String.format(
+                                      Locale.ROOT,
+                                      "Could not parse roles from JWT claim %s; expected array of strings, got array with a value of type %s",
+                                      rolesClaim,
+                                      entry.getClass().getSimpleName()));
+                            }
+                          });
+                } else {
+                  throw new SolrException(
+                      SolrException.ErrorCode.BAD_REQUEST,
+                      String.format(
+                          Locale.ROOT,
+                          "Could not parse roles from JWT claim %s; got %s",
+                          rolesClaim,
+                          rolesObj.getClass().getSimpleName()));
                 }
               }
             }
diff --git a/solr/modules/jwt-auth/src/test/org/apache/solr/security/jwt/JWTAuthPluginTest.java b/solr/modules/jwt-auth/src/test/org/apache/solr/security/jwt/JWTAuthPluginTest.java
index 1abbdfa4e11..ebbfcd2a114 100644
--- a/solr/modules/jwt-auth/src/test/org/apache/solr/security/jwt/JWTAuthPluginTest.java
+++ b/solr/modules/jwt-auth/src/test/org/apache/solr/security/jwt/JWTAuthPluginTest.java
@@ -142,6 +142,17 @@ public class JWTAuthPluginTest extends SolrTestCaseJ4 {
     List<String> 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
+
+    // Keycloak Style resource_access roles
+    HashMap<String, Object> solrMap = new HashMap<>();
+    solrMap.put("roles", Arrays.asList("user", "admin"));
+    HashMap<String, Object> resourceAccess = new HashMap<>();
+    resourceAccess.put("solr", solrMap);
+    claims.setClaim("resource_access", resourceAccess);
+
+    // Special claim with dots in key, should still be addressable non-nested
+    claims.setClaim("roles.with.dot", Arrays.asList("user", "admin"));
+
     return claims;
   }
 
@@ -375,7 +386,7 @@ public class JWTAuthPluginTest extends SolrTestCaseJ4 {
     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
+    // When 'rolesClaim' is defined in config, then roles from that claim are used instead of scopes
     Principal principal = resp.getPrincipal();
     assertTrue(principal instanceof VerifiedUserRoles);
     Set<String> roles = ((VerifiedUserRoles) principal).getVerifiedRoles();
@@ -385,6 +396,37 @@ public class JWTAuthPluginTest extends SolrTestCaseJ4 {
     assertTrue(roles.contains("group-three"));
   }
 
+  @Test
+  public void rolesWithDotInKey() {
+    // Special case where a claim key contains dots without being nested
+    testConfig.put("rolesClaim", "roles.with.dot");
+    plugin.init(testConfig);
+    JWTAuthPlugin.JWTAuthenticationResponse resp = plugin.authenticate(testHeader);
+    assertTrue(resp.getErrorMessage(), resp.isAuthenticated());
+    Principal principal = resp.getPrincipal();
+    assertTrue(principal instanceof VerifiedUserRoles);
+    Set<String> roles = ((VerifiedUserRoles) principal).getVerifiedRoles();
+    assertEquals(2, roles.size());
+    assertTrue(roles.contains("user"));
+    assertTrue(roles.contains("admin"));
+  }
+
+  @Test
+  public void nestedRoles() {
+    testConfig.put("rolesClaim", "resource_access.solr.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<String> roles = ((VerifiedUserRoles) principal).getVerifiedRoles();
+    assertEquals(2, roles.size());
+    assertTrue(roles.contains("user"));
+    assertTrue(roles.contains("admin"));
+  }
+
   @Test
   public void wrongScope() {
     testConfig.put("scope", "wrong");
diff --git a/solr/solr-ref-guide/modules/deployment-guide/pages/jwt-authentication-plugin.adoc b/solr/solr-ref-guide/modules/deployment-guide/pages/jwt-authentication-plugin.adoc
index 4bef9c62e55..8bd7962ea1d 100644
--- a/solr/solr-ref-guide/modules/deployment-guide/pages/jwt-authentication-plugin.adoc
+++ b/solr/solr-ref-guide/modules/deployment-guide/pages/jwt-authentication-plugin.adoc
@@ -59,7 +59,7 @@ requireExp           ; Fails requests that lacks an `exp` (expiry time) claim
 algAllowlist         ; 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
+rolesClaim           ; What claim id to pull user roles from. Both top-level claim and nested claim is supported. Use `someClaim.child` syntax to address a claim `child` nested within the `someClaim` object. 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.