From fb2fcd9a942a63426e665496056e2122db285be3 Mon Sep 17 00:00:00 2001 From: Ryan Emerson Date: Thu, 30 Apr 2026 15:17:05 +0100 Subject: [PATCH] AuthZEN allow configurable subject lookup semantics Closes #48288 Signed-off-by: Ryan Emerson --- .../authzen/AuthZenResource.java | 41 +++- .../tests/authzen/AuthZenEvaluationTest.java | 219 ++++++++++++++++++ 2 files changed, 258 insertions(+), 2 deletions(-) diff --git a/authzen/services/src/main/java/org/keycloak/authorization/authzen/AuthZenResource.java b/authzen/services/src/main/java/org/keycloak/authorization/authzen/AuthZenResource.java index 73550bba8bbf..08462a46e3b3 100644 --- a/authzen/services/src/main/java/org/keycloak/authorization/authzen/AuthZenResource.java +++ b/authzen/services/src/main/java/org/keycloak/authorization/authzen/AuthZenResource.java @@ -20,7 +20,9 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.regex.Pattern; +import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.NotAuthorizedException; import jakarta.ws.rs.POST; @@ -47,13 +49,19 @@ import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; +import org.keycloak.models.UserProvider; import org.keycloak.representations.AccessToken; import org.keycloak.representations.idm.authorization.Permission; import org.keycloak.util.JsonSerialization; public class AuthZenResource { - private static final Response DECISION_FALSE = jakarta.ws.rs.core.Response.ok(new AuthZen.EvaluationResponse(false)).build(); + private static final Response DECISION_FALSE = Response.ok(new AuthZen.EvaluationResponse(false)).build(); + private static final Pattern UUID_PATTERN = Pattern.compile("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$"); + + private static final String NAMESPACE_ID = "id:"; + private static final String NAMESPACE_USERNAME = "username:"; + private static final String NAMESPACE_EMAIL = "email:"; private final KeycloakSession session; @@ -143,7 +151,7 @@ private Identity resolveSubjectIdentity(RealmModel realm, AuthZen.EvaluationRequ AuthZen.Subject subject = request.subject(); Identity identity = switch (subject.type()) { case USER -> { - UserModel user = session.users().getUserByUsername(realm, subject.id()); + UserModel user = resolveUserId(realm, subject.id()); yield user != null ? new UserModelIdentity(realm, user) : null; } case CLIENT -> { @@ -157,6 +165,35 @@ private Identity resolveSubjectIdentity(RealmModel realm, AuthZen.EvaluationRequ return identity; } + private UserModel resolveUserId(RealmModel realm, String subjectId) { + UserProvider users = session.users(); + if (subjectId.startsWith(NAMESPACE_ID)) { + String id = extractNamespaceValue(subjectId, NAMESPACE_ID); + return users.getUserById(realm, id); + } else if (subjectId.startsWith(NAMESPACE_USERNAME)) { + String username = extractNamespaceValue(subjectId, NAMESPACE_USERNAME); + return users.getUserByUsername(realm, username); + } else if (subjectId.startsWith(NAMESPACE_EMAIL)) { + if (realm.isDuplicateEmailsAllowed()) { + throw new BadRequestException("email namespace cannot be used when duplicate emails are allowed"); + } + String email = extractNamespaceValue(subjectId, NAMESPACE_EMAIL); + return users.getUserByEmail(realm, email); + } else if (UUID_PATTERN.matcher(subjectId).matches()) { + return users.getUserById(realm, subjectId); + } else { + return users.getUserByUsername(realm, subjectId); + } + } + + private static String extractNamespaceValue(String subjectId, String namespace) { + String value = subjectId.substring(namespace.length()); + if (value.isEmpty()) { + throw new BadRequestException("subject id namespace '" + namespace + "' requires a non-empty value"); + } + return value; + } + private static Identity withSubjectProperties(Identity delegate, Map properties) { Map> extra = new HashMap<>(); convertValues(properties, extra); diff --git a/authzen/tests/base/src/test/java/org/keycloak/tests/authzen/AuthZenEvaluationTest.java b/authzen/tests/base/src/test/java/org/keycloak/tests/authzen/AuthZenEvaluationTest.java index 532603daba6a..bd3470af3999 100644 --- a/authzen/tests/base/src/test/java/org/keycloak/tests/authzen/AuthZenEvaluationTest.java +++ b/authzen/tests/base/src/test/java/org/keycloak/tests/authzen/AuthZenEvaluationTest.java @@ -17,6 +17,7 @@ package org.keycloak.tests.authzen; import java.io.IOException; +import java.util.List; import java.util.Map; import java.util.Set; @@ -25,6 +26,7 @@ import org.keycloak.admin.client.resource.AuthorizationResource; import org.keycloak.http.simple.SimpleHttp; import org.keycloak.http.simple.SimpleHttpResponse; +import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.representations.idm.authorization.PolicyRepresentation; import org.keycloak.representations.idm.authorization.RegexPolicyRepresentation; import org.keycloak.representations.idm.authorization.ResourcePermissionRepresentation; @@ -650,6 +652,223 @@ public void testXRequestIdEchoedOnBadRequestResponse() throws IOException { } } + @Test + public void testUsernameNamespaceResolvesUser() throws IOException { + EvaluationResult result = authzenClient("admin-user", "password") + .evaluate(AuthZenClient.evaluationRequest() + .subject(USER, "username:" + ADMIN_USER) + .action("read") + .resource("endpoint", "/admin") + .build()); + + assertEquals(200, result.statusCode()); + assertTrue(result.decision()); + } + + @Test + public void testIdNamespaceResolvesUser() throws IOException { + String userId = lookupUserId(ADMIN_USER); + + EvaluationResult result = authzenClient("admin-user", "password") + .evaluate(AuthZenClient.evaluationRequest() + .subject(USER, "id:" + userId) + .action("read") + .resource("endpoint", "/admin") + .build()); + + assertEquals(200, result.statusCode()); + assertTrue(result.decision()); + } + + @Test + public void testEmailNamespaceResolvesUser() throws IOException { + EvaluationResult result = authzenClient("admin-user", "password") + .evaluate(AuthZenClient.evaluationRequest() + .subject(USER, "email:admin@localhost") + .action("read") + .resource("endpoint", "/admin") + .build()); + + assertEquals(200, result.statusCode()); + assertTrue(result.decision()); + } + + @Test + public void testEmailNamespaceResolvesCorrectUser() throws IOException { + EvaluationResult result = authzenClient("admin-user", "password") + .evaluate(AuthZenClient.evaluationRequest() + .subject(USER, "email:regular@localhost") + .action("read") + .resource("endpoint", "/admin") + .build()); + + assertEquals(200, result.statusCode()); + assertFalse(result.decision()); + } + + @Test + public void testNoNamespaceUUIDFallsBackToIdLookup() throws IOException { + String userId = lookupUserId(ADMIN_USER); + + EvaluationResult result = authzenClient("admin-user", "password") + .evaluate(AuthZenClient.evaluationRequest() + .subject(USER, userId) + .action("read") + .resource("endpoint", "/admin") + .build()); + + assertEquals(200, result.statusCode()); + assertTrue(result.decision()); + } + + @Test + public void testNoNamespaceNonUUIDFallsBackToUsernameLookup() throws IOException { + EvaluationResult result = authzenClient("admin-user", "password") + .evaluate(AuthZenClient.evaluationRequest() + .subject(USER, ADMIN_USER) + .action("read") + .resource("endpoint", "/admin") + .build()); + + assertEquals(200, result.statusCode()); + assertTrue(result.decision()); + } + + @Test + public void testEmailNamespaceWithDuplicateEmailsReturnsBadRequest() throws IOException { + realm.updateWithCleanup(r -> r.duplicateEmailsAllowed(true)); + + EvaluationResult result = authzenClient("admin-user", "password") + .evaluate(AuthZenClient.evaluationRequest() + .subject(USER, "email:admin@localhost") + .action("read") + .resource("endpoint", "/admin") + .build()); + + assertEquals(400, result.statusCode()); + } + + @Test + public void testUsernameNamespaceUnknownUserReturnsDenied() throws IOException { + EvaluationResult result = authzenClient("admin-user", "password") + .evaluate(AuthZenClient.evaluationRequest() + .subject(USER, "username:nonexistent-user") + .action("read") + .resource("endpoint", "/admin") + .build()); + + assertEquals(200, result.statusCode()); + assertFalse(result.decision()); + } + + @Test + public void testIdNamespaceUnknownUserReturnsDenied() throws IOException { + EvaluationResult result = authzenClient("admin-user", "password") + .evaluate(AuthZenClient.evaluationRequest() + .subject(USER, "id:00000000-0000-0000-0000-000000000000") + .action("read") + .resource("endpoint", "/admin") + .build()); + + assertEquals(200, result.statusCode()); + assertFalse(result.decision()); + } + + @Test + public void testEmailNamespaceUnknownUserReturnsDenied() throws IOException { + EvaluationResult result = authzenClient("admin-user", "password") + .evaluate(AuthZenClient.evaluationRequest() + .subject(USER, "email:nonexistent@localhost") + .action("read") + .resource("endpoint", "/admin") + .build()); + + assertEquals(200, result.statusCode()); + assertFalse(result.decision()); + } + + @Test + public void testNoNamespaceUUIDUnknownUserReturnsDenied() throws IOException { + EvaluationResult result = authzenClient("admin-user", "password") + .evaluate(AuthZenClient.evaluationRequest() + .subject(USER, "f81d4fae-7dec-11d0-a765-00a0c91e6bf6") + .action("read") + .resource("endpoint", "/admin") + .build()); + + assertEquals(200, result.statusCode()); + assertFalse(result.decision()); + } + + @Test + public void testEmptyIdNamespaceReturnsBadRequest() throws IOException { + EvaluationResult result = authzenClient("admin-user", "password") + .evaluate(AuthZenClient.evaluationRequest() + .subject(USER, "id:") + .action("read") + .resource("endpoint", "/admin") + .build()); + + assertEquals(400, result.statusCode()); + } + + @Test + public void testEmptyUsernameNamespaceReturnsBadRequest() throws IOException { + EvaluationResult result = authzenClient("admin-user", "password") + .evaluate(AuthZenClient.evaluationRequest() + .subject(USER, "username:") + .action("read") + .resource("endpoint", "/admin") + .build()); + + assertEquals(400, result.statusCode()); + } + + @Test + public void testEmptyEmailNamespaceReturnsBadRequest() throws IOException { + EvaluationResult result = authzenClient("admin-user", "password") + .evaluate(AuthZenClient.evaluationRequest() + .subject(USER, "email:") + .action("read") + .resource("endpoint", "/admin") + .build()); + + assertEquals(400, result.statusCode()); + } + + @Test + public void testMixedCaseNamespaceFallsBackToUsernameLookup() throws IOException { + EvaluationResult result = authzenClient("admin-user", "password") + .evaluate(AuthZenClient.evaluationRequest() + .subject(USER, "Email:admin@localhost") + .action("read") + .resource("endpoint", "/admin") + .build()); + + assertEquals(200, result.statusCode()); + // "Email:admin@localhost" is treated as a username, which won't exist + assertFalse(result.decision()); + } + + @Test + public void testIdNamespaceWithNonUUIDReturnsDenied() throws IOException { + EvaluationResult result = authzenClient("admin-user", "password") + .evaluate(AuthZenClient.evaluationRequest() + .subject(USER, "id:not-a-uuid") + .action("read") + .resource("endpoint", "/admin") + .build()); + + assertEquals(200, result.statusCode()); + assertFalse(result.decision()); + } + + private String lookupUserId(String username) { + List users = realm.admin().users().search(username, true); + assertEquals(1, users.size(), "Expected exactly one user with username: " + username); + return users.get(0).getId(); + } + private String realmUrl() { return keycloakUrls.getBase() + "/realms/" + realm.getName(); }