From 5811348cbc1db43c87c73a1e2b89be7bcc2dbd3a Mon Sep 17 00:00:00 2001 From: Ryan Emerson Date: Tue, 7 Apr 2026 15:37:43 +0100 Subject: [PATCH] AuthZen Evaluations API Closes #47825 Signed-off-by: Ryan Emerson --- .../authorization/authzen/AuthZen.java | 46 +- .../authzen/AuthZenResource.java | 64 ++- .../authzen/AuthZenWellKnownProvider.java | 7 +- .../authzen/client/AuthZenClient.java | 167 +++++- .../authzen/AuthZenEvaluationInteropTest.java | 110 ++-- .../tests/authzen/AuthZenEvaluationTest.java | 1 - .../tests/authzen/AuthZenEvaluationsTest.java | 506 ++++++++++++++++++ .../tests/authzen/AuthZenWellKnownTest.java | 1 + .../tests/authzen/authzen-interop-realm.json | 10 +- ...> decisions-authorization-api-1_0-02.json} | 182 +++++-- 10 files changed, 1003 insertions(+), 91 deletions(-) create mode 100644 authzen/tests/base/src/test/java/org/keycloak/tests/authzen/AuthZenEvaluationsTest.java rename authzen/tests/base/src/test/resources/org/keycloak/tests/authzen/{decisions-authorization-api-1_0-01.json => decisions-authorization-api-1_0-02.json} (69%) diff --git a/authzen/services/src/main/java/org/keycloak/authorization/authzen/AuthZen.java b/authzen/services/src/main/java/org/keycloak/authorization/authzen/AuthZen.java index b9631db50b2..43f391262a7 100644 --- a/authzen/services/src/main/java/org/keycloak/authorization/authzen/AuthZen.java +++ b/authzen/services/src/main/java/org/keycloak/authorization/authzen/AuthZen.java @@ -16,18 +16,21 @@ */ package org.keycloak.authorization.authzen; +import java.util.List; import java.util.Map; import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonValue; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; public final class AuthZen { public static final String AUTHZEN_ACCESS_PATH = "access/v1"; public static final String EVALUATION_PATH = AUTHZEN_ACCESS_PATH + "/evaluation"; + public static final String EVALUATIONS_PATH = AUTHZEN_ACCESS_PATH + "/evaluations"; private AuthZen() { } @@ -80,6 +83,43 @@ public final class AuthZen { @JsonProperty(required = true) Action action, Map context) {} - @JsonIgnoreProperties(ignoreUnknown = true) - public record EvaluationResponse(boolean decision) {} + public record EvaluationResponse(boolean decision, Map context) { + public EvaluationResponse(boolean decision) { + this(decision, null); + } + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + public record EvaluationItem( + Subject subject, + Resource resource, + Action action, + Map context) {} + + public enum EvaluationsSemantic { + @JsonProperty("execute_all") + EXECUTE_ALL, + + @JsonProperty("deny_on_first_deny") + DENY_ON_FIRST_DENY, + + @JsonProperty("permit_on_first_permit") + PERMIT_ON_FIRST_PERMIT; + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + public record Options(EvaluationsSemantic evaluationsSemantic) {} + + @JsonInclude(JsonInclude.Include.NON_NULL) + public record EvaluationsRequest( + Subject subject, + Resource resource, + Action action, + Map context, + Options options, + List evaluations) {} + + @JsonInclude(JsonInclude.Include.NON_NULL) + public record EvaluationsResponse(List evaluations) {} } 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 08462a46e3b..1725253b803 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 @@ -16,6 +16,7 @@ */ package org.keycloak.authorization.authzen; +import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.List; @@ -56,7 +57,7 @@ import org.keycloak.util.JsonSerialization; public class AuthZenResource { - private static final Response DECISION_FALSE = Response.ok(new AuthZen.EvaluationResponse(false)).build(); + private static final AuthZen.EvaluationResponse DECISION_FALSE = new AuthZen.EvaluationResponse(false); 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:"; @@ -78,6 +79,64 @@ public class AuthZenResource { if (token == null) { throw new NotAuthorizedException("Bearer"); } + return Response.ok(evaluateSingle(request, token)).build(); + } + + @POST + @Path(AuthZen.EVALUATIONS_PATH) + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public Response evaluations(AuthZen.EvaluationsRequest request) { + AccessToken token = Tokens.getAccessToken(session); + if (token == null) { + throw new NotAuthorizedException("Bearer"); + } + + if (request.evaluations() == null || request.evaluations().isEmpty()) { + // AuthZen 1.0 Section 7.1 + // "If an evaluations array is NOT present or is empty, the Access Evaluations Request behaves in a + // backwards-compatible manner with the (single) Access Evaluation API Request (Section 6.1)." + AuthZen.EvaluationRequest single = new AuthZen.EvaluationRequest( + request.subject(), request.resource(), request.action(), request.context()); + return Response.ok(evaluateSingle(single, token)).build(); + } + + AuthZen.EvaluationsSemantic semantic = AuthZen.EvaluationsSemantic.EXECUTE_ALL; + if (request.options() != null && request.options().evaluationsSemantic() != null) { + semantic = request.options().evaluationsSemantic(); + } + + List results = new ArrayList<>(request.evaluations().size()); + for (AuthZen.EvaluationItem item : request.evaluations()) { + AuthZen.EvaluationRequest merged = mergeDefaults(request, item); + AuthZen.EvaluationResponse itemResponse = evaluateSingle(merged, token); + + if (semantic == AuthZen.EvaluationsSemantic.DENY_ON_FIRST_DENY && !itemResponse.decision()) { + results.add(new AuthZen.EvaluationResponse(false, Map.of("reason", "deny_on_first_deny"))); + break; + } + + results.add(itemResponse); + + if (semantic == AuthZen.EvaluationsSemantic.PERMIT_ON_FIRST_PERMIT && itemResponse.decision()) { + break; + } + } + return Response.ok(new AuthZen.EvaluationsResponse(results)).build(); + } + + private static AuthZen.EvaluationRequest mergeDefaults(AuthZen.EvaluationsRequest defaults, AuthZen.EvaluationItem item) { + AuthZen.Subject subject = item.subject() != null ? item.subject() : defaults.subject(); + AuthZen.Resource resource = item.resource() != null ? item.resource() : defaults.resource(); + AuthZen.Action action = item.action() != null ? item.action() : defaults.action(); + Map context = item.context() != null ? item.context() : defaults.context(); + return new AuthZen.EvaluationRequest(subject, resource, action, context); + } + + private AuthZen.EvaluationResponse evaluateSingle(AuthZen.EvaluationRequest request, AccessToken token) { + if (request.subject() == null || request.resource() == null || request.action() == null) { + return new AuthZen.EvaluationResponse(false); + } RealmModel realm = session.getContext().getRealm(); AuthorizationProvider authorization = session.getProvider(AuthorizationProvider.class); @@ -143,8 +202,7 @@ public class AuthZenResource { .from(List.of(permission), context) .evaluate(resourceServer, null); - boolean decision = !granted.isEmpty(); - return Response.ok(new AuthZen.EvaluationResponse(decision)).build(); + return new AuthZen.EvaluationResponse(!granted.isEmpty()); } private Identity resolveSubjectIdentity(RealmModel realm, AuthZen.EvaluationRequest request) { diff --git a/authzen/services/src/main/java/org/keycloak/authorization/authzen/AuthZenWellKnownProvider.java b/authzen/services/src/main/java/org/keycloak/authorization/authzen/AuthZenWellKnownProvider.java index 056f17fcb15..e923cf23a60 100644 --- a/authzen/services/src/main/java/org/keycloak/authorization/authzen/AuthZenWellKnownProvider.java +++ b/authzen/services/src/main/java/org/keycloak/authorization/authzen/AuthZenWellKnownProvider.java @@ -39,7 +39,8 @@ public class AuthZenWellKnownProvider implements WellKnownProvider { return Map.of( "policy_decision_point", realmUri, - "access_evaluation_endpoint", accessEvaluationEndpoint(realmUri) + "access_evaluation_endpoint", accessEvaluationEndpoint(realmUri), + "access_evaluations_endpoint", accessEvaluationsEndpoint(realmUri) ); } @@ -47,6 +48,10 @@ public class AuthZenWellKnownProvider implements WellKnownProvider { return String.format("%s/%s/%s", realmUri, AuthZenRealmResourceProviderFactory.PROVIDER_ID, AuthZen.EVALUATION_PATH); } + public static String accessEvaluationsEndpoint(String realmUri) { + return String.format("%s/%s/%s", realmUri, AuthZenRealmResourceProviderFactory.PROVIDER_ID, AuthZen.EVALUATIONS_PATH); + } + @Override public void close() { } diff --git a/authzen/tests/authzen-client/src/main/java/org/keycloak/testframework/authzen/client/AuthZenClient.java b/authzen/tests/authzen-client/src/main/java/org/keycloak/testframework/authzen/client/AuthZenClient.java index 124aaf6af84..fd685561b0c 100644 --- a/authzen/tests/authzen-client/src/main/java/org/keycloak/testframework/authzen/client/AuthZenClient.java +++ b/authzen/tests/authzen-client/src/main/java/org/keycloak/testframework/authzen/client/AuthZenClient.java @@ -17,7 +17,9 @@ package org.keycloak.testframework.authzen.client; import java.io.IOException; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; import jakarta.ws.rs.core.HttpHeaders; @@ -68,7 +70,9 @@ public class AuthZenClient { } try (SimpleHttpResponse response = req(postReq)) { int status = response.getStatus(); - AuthZen.EvaluationResponse body = response.asJson(AuthZen.EvaluationResponse.class); + AuthZen.EvaluationResponse body = status == 200 + ? response.asJson(AuthZen.EvaluationResponse.class) + : null; Map responseHeaders = new HashMap<>(); for (Header h : response.getAllHeaders()) { responseHeaders.putIfAbsent(h.getName(), h.getValue()); @@ -77,6 +81,38 @@ public class AuthZenClient { } } + public EvaluationsResult evaluations(AuthZen.EvaluationsRequest request) throws IOException { + return evaluations((Object) request, null); + } + + public EvaluationsResult evaluations(AuthZen.EvaluationsRequest request, Map headers) throws IOException { + return evaluations((Object) request, headers); + } + + public EvaluationsResult evaluations(JsonNode request) throws IOException { + return evaluations((Object) request, null); + } + + private EvaluationsResult evaluations(Object req, Map headers) throws IOException { + String url = realmUrl + "/authzen/access/v1/evaluations"; + + SimpleHttpRequest postReq = simpleHttp.doPost(url).json(req); + if (headers != null) { + headers.forEach(postReq::header); + } + try (SimpleHttpResponse response = req(postReq)) { + int status = response.getStatus(); + AuthZen.EvaluationsResponse body = status == 200 + ? response.asJson(AuthZen.EvaluationsResponse.class) + : null; + Map responseHeaders = new HashMap<>(); + for (Header h : response.getAllHeaders()) { + responseHeaders.putIfAbsent(h.getName(), h.getValue()); + } + return new EvaluationsResult(status, body, responseHeaders); + } + } + public WellKnownResponse fetchWellKnownConfiguration() throws IOException { String url = realmUrl + "/.well-known/authzen-configuration"; try (SimpleHttpResponse rsp = req(simpleHttp.doGet(url))) { @@ -124,10 +160,29 @@ public class AuthZenClient { } } + public record EvaluationsResult(int statusCode, AuthZen.EvaluationsResponse response, Map headers) { + + public List evaluations() { + return response.evaluations(); + } + + public String header(String name) { + return headers != null ? headers.get(name) : null; + } + } + public static EvaluationRequestBuilder evaluationRequest() { return new EvaluationRequestBuilder(); } + public static EvaluationsRequestBuilder evaluationsRequest() { + return new EvaluationsRequestBuilder(); + } + + public static EvaluationItemBuilder evaluationItem() { + return new EvaluationItemBuilder(); + } + public static final class EvaluationRequestBuilder { private AuthZen.SubjectType subjectType; private String subjectId; @@ -189,4 +244,114 @@ public class AuthZenClient { return new AuthZen.EvaluationRequest(subject, resource, action, contextProperties); } } + + public static final class EvaluationItemBuilder { + private AuthZen.SubjectType subjectType; + private String subjectId; + private boolean subjectSet; + private String resourceType; + private String resourceId; + private Map resourceProperties; + private boolean resourceSet; + private AuthZen.Action action; + private Map contextProperties; + + public EvaluationItemBuilder subject(AuthZen.SubjectType type, String id) { + this.subjectType = type; + this.subjectId = id; + this.subjectSet = true; + return this; + } + + public EvaluationItemBuilder resource(String type, String id) { + this.resourceType = type; + this.resourceId = id; + this.resourceSet = true; + return this; + } + + public EvaluationItemBuilder resourceProperty(String key, Object value) { + if (resourceProperties == null) { + resourceProperties = new HashMap<>(); + } + resourceProperties.put(key, value); + return this; + } + + public EvaluationItemBuilder action(String name) { + this.action = new AuthZen.Action(name); + return this; + } + + public EvaluationItemBuilder contextProperty(String key, Object value) { + if (contextProperties == null) { + contextProperties = new HashMap<>(); + } + contextProperties.put(key, value); + return this; + } + + public AuthZen.EvaluationItem build() { + AuthZen.Subject subject = subjectSet ? new AuthZen.Subject(subjectType, subjectId, null) : null; + AuthZen.Resource resource = resourceSet ? new AuthZen.Resource(resourceType, resourceId, resourceProperties) : null; + return new AuthZen.EvaluationItem(subject, resource, action, contextProperties); + } + } + + public static final class EvaluationsRequestBuilder { + private AuthZen.SubjectType subjectType; + private String subjectId; + private boolean subjectSet; + private String resourceType; + private String resourceId; + private boolean resourceSet; + private AuthZen.Action action; + private Map contextProperties; + private AuthZen.EvaluationsSemantic evaluationsSemantic; + private final List evaluations = new ArrayList<>(); + + public EvaluationsRequestBuilder subject(AuthZen.SubjectType type, String id) { + this.subjectType = type; + this.subjectId = id; + this.subjectSet = true; + return this; + } + + public EvaluationsRequestBuilder resource(String type, String id) { + this.resourceType = type; + this.resourceId = id; + this.resourceSet = true; + return this; + } + + public EvaluationsRequestBuilder action(String name) { + this.action = new AuthZen.Action(name); + return this; + } + + public EvaluationsRequestBuilder contextProperty(String key, Object value) { + if (contextProperties == null) { + contextProperties = new HashMap<>(); + } + contextProperties.put(key, value); + return this; + } + + public EvaluationsRequestBuilder evaluationsSemantic(AuthZen.EvaluationsSemantic semantic) { + this.evaluationsSemantic = semantic; + return this; + } + + public EvaluationsRequestBuilder addEvaluation(AuthZen.EvaluationItem evaluation) { + evaluations.add(evaluation); + return this; + } + + public AuthZen.EvaluationsRequest build() { + AuthZen.Subject subject = subjectSet ? new AuthZen.Subject(subjectType, subjectId, null) : null; + AuthZen.Resource resource = resourceSet ? new AuthZen.Resource(resourceType, resourceId, null) : null; + AuthZen.Options options = evaluationsSemantic != null ? new AuthZen.Options(evaluationsSemantic) : null; + return new AuthZen.EvaluationsRequest(subject, resource, action, contextProperties, options, evaluations); + } + } } diff --git a/authzen/tests/base/src/test/java/org/keycloak/tests/authzen/AuthZenEvaluationInteropTest.java b/authzen/tests/base/src/test/java/org/keycloak/tests/authzen/AuthZenEvaluationInteropTest.java index 5cf3f1dc6a4..fe2c7c06514 100644 --- a/authzen/tests/base/src/test/java/org/keycloak/tests/authzen/AuthZenEvaluationInteropTest.java +++ b/authzen/tests/base/src/test/java/org/keycloak/tests/authzen/AuthZenEvaluationInteropTest.java @@ -23,15 +23,16 @@ import java.util.Map; import java.util.stream.Stream; import java.util.stream.StreamSupport; +import org.keycloak.authorization.authzen.AuthZen; import org.keycloak.testframework.annotations.InjectRealm; import org.keycloak.testframework.annotations.KeycloakIntegrationTest; import org.keycloak.testframework.authzen.client.AuthZenClient; import org.keycloak.testframework.authzen.client.AuthZenClient.EvaluationResult; +import org.keycloak.testframework.authzen.client.AuthZenClient.EvaluationsResult; import org.keycloak.testframework.authzen.client.annotations.InjectAuthZenClient; import org.keycloak.testframework.oauth.OAuthClient; import org.keycloak.testframework.oauth.annotations.InjectOAuthClient; import org.keycloak.testframework.realm.ManagedRealm; -import org.keycloak.testsuite.util.oauth.AccessTokenResponse; import org.keycloak.util.JsonSerialization; import com.fasterxml.jackson.databind.JsonNode; @@ -42,10 +43,10 @@ import static org.junit.jupiter.api.Assertions.assertEquals; /** * Runs the AuthZen interop evaluation test suite defined in the OpenID AuthZen interop project. - * Test scenarios are loaded from the decisions-authorization-api-1_0-01.json resource file. - * Each entry in the JSON defines an AuthZen evaluation request and its expected decision. + * Test scenarios are loaded from the decisions-authorization-api-1_0-02.json resource file. + * The JSON file defines AuthZen evaluation and evaluations requests, as well as the expected decision. *

- * All required Realm configuration including users, roles, client, and authorization settings is loaded from interop-realm.json. + * All required Realm configuration including users, roles, client, and authorization settings are loaded from interop-realm.json. */ @KeycloakIntegrationTest(config = AuthZenServerConfig.class) public class AuthZenEvaluationInteropTest { @@ -73,44 +74,83 @@ public class AuthZenEvaluationInteropTest { @TestFactory Stream interopEvaluationTests() throws IOException { - List decisions = loadDecisions(); + JsonNode root = loadDecisions(); + AuthZenClient.Authenticated client = authenticatedClient(); - AccessTokenResponse tokenResponse = oauth - .client(PDP_CLIENT_ID, PDP_CLIENT_SECRET) - .doClientCredentialsGrantAccessTokenRequest(); - AuthZenClient.Authenticated client = authZenClient.withAccessToken(tokenResponse.getAccessToken()); - - return decisions.stream().map(decision -> { - String testName = buildTestName(decision); - return DynamicTest.dynamicTest(testName, () -> { - EvaluationResult result = client.evaluate(decision.request()); - assertEquals(200, result.statusCode(), "Expected 200 OK for: " + testName); - assertEquals(decision.expected(), result.decision(), "Expected decision=" + decision.expected() + " for: " + testName); - }); - }); + return StreamSupport.stream(root.path("evaluation").spliterator(), false) + .map(node -> { + JsonNode request = node.get("request"); + boolean expected = node.get("expected").asBoolean(); + String testName = buildEvaluationTestName(request, expected); + return DynamicTest.dynamicTest(testName, () -> { + EvaluationResult result = client.evaluate(request); + assertEquals(200, result.statusCode(), "Expected 200 OK for: " + testName); + assertEquals(expected, result.decision(), "Expected decision=" + expected + " for: " + testName); + }); + }); } - private static String buildTestName(InteropDecision decision) { - JsonNode req = decision.request(); - String subjectId = req.path("subject").path("id").asText(); + @TestFactory + Stream interopEvaluationsTests() throws IOException { + JsonNode root = loadDecisions(); + JsonNode evaluationsNode = root.path("evaluations"); + + if (evaluationsNode.isMissingNode() || !evaluationsNode.isArray() || evaluationsNode.isEmpty()) { + return Stream.empty(); + } + + AuthZenClient.Authenticated client = authenticatedClient(); + return StreamSupport.stream(evaluationsNode.spliterator(), false) + .map(node -> { + JsonNode request = node.get("request"); + JsonNode expectedArray = node.get("expected"); + String testName = buildEvaluationsTestName(request); + return DynamicTest.dynamicTest(testName, () -> { + EvaluationsResult result = client.evaluations(request); + assertEquals(200, result.statusCode(), "Expected 200 OK for: " + testName); + + List actualEvaluations = result.evaluations(); + assertEquals(expectedArray.size(), actualEvaluations.size(), "Evaluations count mismatch for: " + testName); + + for (int i = 0; i < expectedArray.size(); i++) { + boolean expectedDecision = expectedArray.get(i).get("decision").asBoolean(); + assertEquals(expectedDecision, actualEvaluations.get(i).decision(), + "Decision mismatch at index " + i + " for: " + testName); + } + }); + }); + } + + private AuthZenClient.Authenticated authenticatedClient() { + return authZenClient.withAccessToken( + oauth.client(PDP_CLIENT_ID, PDP_CLIENT_SECRET) + .doClientCredentialsGrantAccessTokenRequest() + .getAccessToken() + ); + } + + private static String buildEvaluationTestName(JsonNode json, boolean expected) { + String subjectId = json.path("subject").path("id").asText(); String userName = USER_NAMES.getOrDefault(subjectId, subjectId); - String action = req.path("action").path("name").asText(); - String resourceType = req.path("resource").path("type").asText(); - String resourceId = req.path("resource").path("id").asText(); - String expected = decision.expected() ? "ALLOW" : "DENY"; - return String.format("%s | %s %s:%s => %s", userName, action, resourceType, resourceId, expected); + String action = json.path("action").path("name").asText(); + String resourceType = json.path("resource").path("type").asText(); + String resourceId = json.path("resource").path("id").asText(); + String expectedStr = expected ? "ALLOW" : "DENY"; + return String.format("%s | %s %s:%s => %s", userName, action, resourceType, resourceId, expectedStr); } - private static List loadDecisions() throws IOException { + private static String buildEvaluationsTestName(JsonNode json) { + String subjectId = json.path("subject").path("id").asText(); + String userName = USER_NAMES.getOrDefault(subjectId, subjectId); + String action = json.path("action").path("name").asText(); + int count = json.path("evaluations").size(); + return String.format("batch | %s | %s x%d", userName, action, count); + } + + private static JsonNode loadDecisions() throws IOException { try (InputStream is = AuthZenEvaluationInteropTest.class.getResourceAsStream( - "decisions-authorization-api-1_0-01.json")) { - JsonNode root = JsonSerialization.mapper.readTree(is); - return StreamSupport.stream(root.path("evaluation").spliterator(), false) - .map(node -> new InteropDecision(node.get("request"), node.get("expected").asBoolean())) - .toList(); + "decisions-authorization-api-1_0-02.json")) { + return JsonSerialization.mapper.readTree(is); } } - - public record InteropDecision(JsonNode request, boolean expected) { - } } 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 bd3470af399..80ca6f7cb52 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 @@ -629,7 +629,6 @@ public class AuthZenEvaluationTest { ); assertEquals(401, result.statusCode()); - assertFalse(result.decision()); assertEquals(requestId, result.header(X_REQUEST_ID)); } diff --git a/authzen/tests/base/src/test/java/org/keycloak/tests/authzen/AuthZenEvaluationsTest.java b/authzen/tests/base/src/test/java/org/keycloak/tests/authzen/AuthZenEvaluationsTest.java new file mode 100644 index 00000000000..153e1d0c711 --- /dev/null +++ b/authzen/tests/base/src/test/java/org/keycloak/tests/authzen/AuthZenEvaluationsTest.java @@ -0,0 +1,506 @@ +/* + * Copyright 2025 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed 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.keycloak.tests.authzen; + +import java.io.IOException; +import java.util.Map; +import java.util.Set; + +import jakarta.ws.rs.core.Response; + +import org.keycloak.admin.client.resource.AuthorizationResource; +import org.keycloak.authorization.authzen.AuthZen; +import org.keycloak.http.simple.SimpleHttp; +import org.keycloak.http.simple.SimpleHttpResponse; +import org.keycloak.representations.idm.authorization.PolicyRepresentation; +import org.keycloak.representations.idm.authorization.ResourcePermissionRepresentation; +import org.keycloak.representations.idm.authorization.ResourceRepresentation; +import org.keycloak.representations.idm.authorization.RolePolicyRepresentation; +import org.keycloak.representations.idm.authorization.ScopePermissionRepresentation; +import org.keycloak.representations.idm.authorization.ScopeRepresentation; +import org.keycloak.testframework.annotations.InjectClient; +import org.keycloak.testframework.annotations.InjectKeycloakUrls; +import org.keycloak.testframework.annotations.InjectRealm; +import org.keycloak.testframework.annotations.InjectSimpleHttp; +import org.keycloak.testframework.annotations.KeycloakIntegrationTest; +import org.keycloak.testframework.annotations.TestSetup; +import org.keycloak.testframework.authzen.client.AuthZenClient; +import org.keycloak.testframework.authzen.client.AuthZenClient.EvaluationsResult; +import org.keycloak.testframework.authzen.client.annotations.InjectAuthZenClient; +import org.keycloak.testframework.oauth.OAuthClient; +import org.keycloak.testframework.oauth.annotations.InjectOAuthClient; +import org.keycloak.testframework.realm.ClientBuilder; +import org.keycloak.testframework.realm.ClientConfig; +import org.keycloak.testframework.realm.ManagedClient; +import org.keycloak.testframework.realm.ManagedRealm; +import org.keycloak.testframework.realm.RealmBuilder; +import org.keycloak.testframework.realm.RealmConfig; +import org.keycloak.testframework.realm.UserBuilder; +import org.keycloak.testframework.server.KeycloakUrls; +import org.keycloak.testsuite.util.oauth.AccessTokenResponse; + +import com.fasterxml.jackson.databind.JsonNode; +import org.apache.http.entity.StringEntity; +import org.junit.jupiter.api.Test; + +import static org.keycloak.authorization.authzen.AuthZen.SubjectType.USER; +import static org.keycloak.authorization.authzen.AuthZenRequestIdFilter.X_REQUEST_ID; +import static org.keycloak.authorization.authzen.AuthZenWellKnownProvider.accessEvaluationsEndpoint; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests for the AuthZen Evaluations (batch) endpoint. + */ +@KeycloakIntegrationTest(config = AuthZenServerConfig.class) +public class AuthZenEvaluationsTest { + + private static final String ADMIN_USER = "admin-user"; + private static final String REGULAR_USER = "regular-user"; + + @InjectRealm(config = TestRealmConfig.class) + ManagedRealm realm; + + @InjectClient(ref = "authzen-client", config = AuthzClientConfig.class) + ManagedClient client; + + @InjectOAuthClient + OAuthClient oauth; + + @InjectAuthZenClient + AuthZenClient authZenClient; + + @InjectSimpleHttp + SimpleHttp simpleHttp; + + @InjectKeycloakUrls + KeycloakUrls keycloakUrls; + + @TestSetup + public void setup() { + AuthorizationResource authz = client.admin().authorization(); + String adminRoleId = realm.admin().roles().get("admin").toRepresentation().getId(); + + createScope(authz, "read"); + createScope(authz, "write"); + + String adminPolicyId = createRolePolicy(authz, "Require Admin Role", adminRoleId); + String alwaysGrantId = createAlwaysGrantPolicy(authz); + + createResource(authz, "/admin", "endpoint", "read"); + createResourcePermission(authz, "Admin Resource Permission", "/admin", adminPolicyId); + + createResource(authz, "/users", "endpoint", "read"); + createResourcePermission(authz, "Users Resource Permission", "/users", alwaysGrantId); + + createResource(authz, "/scope-limited", "endpoint", "read", "write"); + createScopePermission(authz, "Scope Limited Read Permission", "/scope-limited", "read", "Always Grant"); + } + + @Test + public void testEvaluationsBatchAllGranted() throws IOException { + EvaluationsResult result = authzenClient("admin-user", "password") + .evaluations(AuthZenClient.evaluationsRequest() + .subject(USER, ADMIN_USER) + .action("read") + .addEvaluation(AuthZenClient.evaluationItem() + .resource("endpoint", "/admin") + .build()) + .addEvaluation(AuthZenClient.evaluationItem() + .resource("endpoint", "/users") + .build()) + .build()); + + assertEquals(200, result.statusCode()); + assertEquals(2, result.evaluations().size()); + assertTrue(result.evaluations().get(0).decision()); + assertTrue(result.evaluations().get(1).decision()); + } + + @Test + public void testEvaluationsBatchMixedDecisions() throws IOException { + EvaluationsResult result = authzenClient("regular-user", "password") + .evaluations(AuthZenClient.evaluationsRequest() + .subject(USER, REGULAR_USER) + .action("read") + .addEvaluation(AuthZenClient.evaluationItem() + .resource("endpoint", "/admin") + .build()) + .addEvaluation(AuthZenClient.evaluationItem() + .resource("endpoint", "/users") + .build()) + .build()); + + assertEquals(200, result.statusCode()); + assertEquals(2, result.evaluations().size()); + assertFalse(result.evaluations().get(0).decision()); + assertTrue(result.evaluations().get(1).decision()); + } + + @Test + public void testEvaluationsItemOverridesDefaults() throws IOException { + EvaluationsResult result = authzenClient("admin-user", "password") + .evaluations(AuthZenClient.evaluationsRequest() + .subject(USER, ADMIN_USER) + .action("read") + .resource("endpoint", "/admin") + .addEvaluation(AuthZenClient.evaluationItem() + .build()) + .addEvaluation(AuthZenClient.evaluationItem() + .action("write") + .resource("endpoint", "/scope-limited") + .build()) + .build()); + + assertEquals(200, result.statusCode()); + assertEquals(2, result.evaluations().size()); + assertTrue(result.evaluations().get(0).decision()); + assertFalse(result.evaluations().get(1).decision()); + } + + @Test + public void testEvaluationsUnauthenticatedReturnsUnauthorized() throws IOException { + EvaluationsResult result = authZenClient.evaluations(AuthZenClient.evaluationsRequest() + .subject(USER, ADMIN_USER) + .action("read") + .addEvaluation(AuthZenClient.evaluationItem() + .resource("endpoint", "/admin") + .build()) + .build()); + + assertEquals(401, result.statusCode()); + } + + @Test + public void testEvaluationsEmptyArrayFallsBackToSingleEvaluation() throws IOException { + String json = """ + {"subject":{"type":"user","id":"%s"},\ + "resource":{"type":"endpoint","id":"/admin"},\ + "action":{"name":"read"},\ + "evaluations":[]}""".formatted(ADMIN_USER); + + JsonNode body = postEvaluations("admin-user", "password", json); + assertTrue(body.get("decision").asBoolean()); + assertNull(body.get("evaluations")); + } + + @Test + public void testEvaluationsAbsentFallsBackToSingleEvaluation() throws IOException { + String json = """ + {"subject":{"type":"user","id":"%s"},\ + "resource":{"type":"endpoint","id":"/admin"},\ + "action":{"name":"read"}}""".formatted(ADMIN_USER); + + JsonNode body = postEvaluations(ADMIN_USER, "password", json); + assertTrue(body.get("decision").asBoolean()); + assertNull(body.get("evaluations")); + } + + private JsonNode postEvaluations(String username, String password, String json) throws IOException { + String url = accessEvaluationsEndpoint(keycloakUrls.getBase() + "/realms/" + realm.getName()); + AccessTokenResponse tokenResponse = oauth + .client(client.getClientId(), client.getSecret()) + .doPasswordGrantRequest(username, password); + + try (SimpleHttpResponse response = simpleHttp.doPost(url) + .auth(tokenResponse.getAccessToken()) + .header("Content-Type", "application/json") + .entity(new StringEntity(json)) + .asResponse()) { + assertEquals(200, response.getStatus()); + return response.asJson(); + } + } + + @Test + public void testExecuteAllSemantic() throws IOException { + EvaluationsResult result = authzenClient("regular-user", "password") + .evaluations(AuthZenClient.evaluationsRequest() + .subject(USER, REGULAR_USER) + .action("read") + .evaluationsSemantic(AuthZen.EvaluationsSemantic.EXECUTE_ALL) + .addEvaluation(AuthZenClient.evaluationItem() + .resource("endpoint", "/admin") + .build()) + .addEvaluation(AuthZenClient.evaluationItem() + .resource("endpoint", "/users") + .build()) + .build()); + + assertEquals(200, result.statusCode()); + assertEquals(2, result.evaluations().size()); + assertFalse(result.evaluations().get(0).decision()); + assertTrue(result.evaluations().get(1).decision()); + } + + @Test + public void testDenyOnFirstDenyStopsOnDenial() throws IOException { + EvaluationsResult result = authzenClient("regular-user", "password") + .evaluations(AuthZenClient.evaluationsRequest() + .subject(USER, REGULAR_USER) + .action("read") + .evaluationsSemantic(AuthZen.EvaluationsSemantic.DENY_ON_FIRST_DENY) + .addEvaluation(AuthZenClient.evaluationItem() + .resource("endpoint", "/admin") + .build()) + .addEvaluation(AuthZenClient.evaluationItem() + .resource("endpoint", "/users") + .build()) + .build()); + + assertEquals(200, result.statusCode()); + assertEquals(1, result.evaluations().size()); + assertFalse(result.evaluations().get(0).decision()); + } + + @Test + public void testDenyOnFirstDenyReturnsAllWhenAllPermitted() throws IOException { + EvaluationsResult result = authzenClient("admin-user", "password") + .evaluations(AuthZenClient.evaluationsRequest() + .subject(USER, ADMIN_USER) + .action("read") + .evaluationsSemantic(AuthZen.EvaluationsSemantic.DENY_ON_FIRST_DENY) + .addEvaluation(AuthZenClient.evaluationItem() + .resource("endpoint", "/admin") + .build()) + .addEvaluation(AuthZenClient.evaluationItem() + .resource("endpoint", "/users") + .build()) + .build()); + + assertEquals(200, result.statusCode()); + assertEquals(2, result.evaluations().size()); + assertTrue(result.evaluations().get(0).decision()); + assertTrue(result.evaluations().get(1).decision()); + } + + @Test + public void testPermitOnFirstPermitStopsOnPermit() throws IOException { + EvaluationsResult result = authzenClient("regular-user", "password") + .evaluations(AuthZenClient.evaluationsRequest() + .subject(USER, REGULAR_USER) + .action("read") + .evaluationsSemantic(AuthZen.EvaluationsSemantic.PERMIT_ON_FIRST_PERMIT) + .addEvaluation(AuthZenClient.evaluationItem() + .resource("endpoint", "/admin") + .build()) + .addEvaluation(AuthZenClient.evaluationItem() + .resource("endpoint", "/users") + .build()) + .build()); + + assertEquals(200, result.statusCode()); + assertEquals(2, result.evaluations().size()); + assertFalse(result.evaluations().get(0).decision()); + assertTrue(result.evaluations().get(1).decision()); + } + + @Test + public void testPermitOnFirstPermitStopsImmediatelyOnFirstPermit() throws IOException { + EvaluationsResult result = authzenClient("admin-user", "password") + .evaluations(AuthZenClient.evaluationsRequest() + .subject(USER, ADMIN_USER) + .action("read") + .evaluationsSemantic(AuthZen.EvaluationsSemantic.PERMIT_ON_FIRST_PERMIT) + .addEvaluation(AuthZenClient.evaluationItem() + .resource("endpoint", "/admin") + .build()) + .addEvaluation(AuthZenClient.evaluationItem() + .resource("endpoint", "/users") + .build()) + .build()); + + assertEquals(200, result.statusCode()); + assertEquals(1, result.evaluations().size()); + assertTrue(result.evaluations().get(0).decision()); + } + + @Test + public void testXRequestIdEchoedInResponse() throws IOException { + String requestId = "test-request-id-12345"; + + EvaluationsResult result = authzenClient("admin-user", "password") + .evaluations(AuthZenClient.evaluationsRequest() + .subject(USER, ADMIN_USER) + .action("read") + .addEvaluation(AuthZenClient.evaluationItem() + .resource("endpoint", "/admin") + .build()) + .build(), + Map.of(X_REQUEST_ID, requestId)); + + assertEquals(200, result.statusCode()); + assertTrue(result.evaluations().get(0).decision()); + assertEquals(requestId, result.header(X_REQUEST_ID)); + } + + @Test + public void testXRequestIdEchoedOnUnauthorizedResponse() throws IOException { + String requestId = "unauth-request-id"; + + EvaluationsResult result = new AuthZenClient(simpleHttp, + keycloakUrls.getBase() + "/realms/" + realm.getName()) + .evaluations(AuthZenClient.evaluationsRequest() + .subject(USER, ADMIN_USER) + .action("read") + .addEvaluation(AuthZenClient.evaluationItem() + .resource("endpoint", "/admin") + .build()) + .build(), + Map.of(X_REQUEST_ID, requestId)); + + assertEquals(401, result.statusCode()); + assertEquals(requestId, result.header(X_REQUEST_ID)); + } + + @Test + public void testXRequestIdEchoedOnBadRequestResponse() throws IOException { + String requestId = "bad-request-id"; + + String url = accessEvaluationsEndpoint(keycloakUrls.getBase() + "/realms/" + realm.getName()); + AccessTokenResponse tokenResponse = oauth + .client(client.getClientId(), client.getSecret()) + .doPasswordGrantRequest("admin-user", "password"); + + try (SimpleHttpResponse response = simpleHttp.doPost(url) + .auth(tokenResponse.getAccessToken()) + .header("Content-Type", "application/json") + .header(X_REQUEST_ID, requestId) + .entity(new StringEntity("{invalid json")) + .asResponse()) { + assertEquals(400, response.getStatus()); + assertEquals(requestId, response.getFirstHeader(X_REQUEST_ID)); + } + } + + @Test + public void testDefaultSemanticIsExecuteAll() throws IOException { + EvaluationsResult result = authzenClient("regular-user", "password") + .evaluations(AuthZenClient.evaluationsRequest() + .subject(USER, REGULAR_USER) + .action("read") + .addEvaluation(AuthZenClient.evaluationItem() + .resource("endpoint", "/admin") + .build()) + .addEvaluation(AuthZenClient.evaluationItem() + .resource("endpoint", "/users") + .build()) + .build()); + + assertEquals(200, result.statusCode()); + assertEquals(2, result.evaluations().size()); + } + + private AuthZenClient.Authenticated authzenClient(String username, String password) { + AccessTokenResponse tokenResponse = oauth + .client(client.getClientId(), client.getSecret()) + .doPasswordGrantRequest(username, password); + return authZenClient.withAccessToken(tokenResponse.getAccessToken()); + } + + private static void createScope(AuthorizationResource authz, String name) { + try (Response response = authz.scopes().create(new ScopeRepresentation(name))) { + assertEquals(Response.Status.CREATED.getStatusCode(), response.getStatus()); + } + } + + private static void createResource(AuthorizationResource authz, String name, String type, String... scopes) { + ResourceRepresentation resource = new ResourceRepresentation(); + resource.setName(name); + resource.setType(type); + resource.addScope(scopes); + try (Response response = authz.resources().create(resource)) { + assertEquals(Response.Status.CREATED.getStatusCode(), response.getStatus()); + } + } + + private static String createRolePolicy(AuthorizationResource authz, String name, String roleId) { + RolePolicyRepresentation policy = new RolePolicyRepresentation(); + policy.setName(name); + policy.addRole(roleId); + try (Response response = authz.policies().role().create(policy)) { + assertEquals(Response.Status.CREATED.getStatusCode(), response.getStatus()); + } + return authz.policies().role().findByName(name).getId(); + } + + private static String createAlwaysGrantPolicy(AuthorizationResource authz) { + PolicyRepresentation policy = new PolicyRepresentation(); + policy.setName("Always Grant"); + policy.setType("always-grant"); + try (Response response = authz.policies().create(policy)) { + assertEquals(Response.Status.CREATED.getStatusCode(), response.getStatus()); + } + return authz.policies().findByName("Always Grant").getId(); + } + + private static void createResourcePermission(AuthorizationResource authz, String name, + String resourceName, String policyId) { + ResourcePermissionRepresentation permission = ResourcePermissionRepresentation.create() + .name(name) + .resources(Set.of(authz.resources().findByName(resourceName).get(0).getId())) + .policies(Set.of(policyId)) + .build(); + try (Response response = authz.permissions().resource().create(permission)) { + assertEquals(Response.Status.CREATED.getStatusCode(), response.getStatus()); + } + } + + private static void createScopePermission(AuthorizationResource authz, String name, + String resourceName, String scopeName, String policyName) { + ScopePermissionRepresentation permission = new ScopePermissionRepresentation(); + permission.setName(name); + permission.setResources(Set.of(authz.resources().findByName(resourceName).get(0).getId())); + permission.setScopes(Set.of(authz.scopes().findByName(scopeName).getId())); + permission.addPolicy(policyName); + try (Response response = authz.permissions().scope().create(permission)) { + assertEquals(Response.Status.CREATED.getStatusCode(), response.getStatus()); + } + } + + public static class TestRealmConfig implements RealmConfig { + @Override + public RealmBuilder configure(RealmBuilder realm) { + return realm.realmRoles("admin") + .users( + UserBuilder.create("admin-user") + .name("Admin", "User") + .email("admin@localhost") + .password("password") + .realmRoles("admin"), + + UserBuilder.create("regular-user") + .name("Regular", "User") + .email("regular@localhost") + .password("password") + ); + } + } + + public static class AuthzClientConfig implements ClientConfig { + @Override + public ClientBuilder configure(ClientBuilder client) { + return client + .secret("secret") + .directAccessGrantsEnabled(true) + .authorizationServicesEnabled(true); + } + } +} diff --git a/authzen/tests/base/src/test/java/org/keycloak/tests/authzen/AuthZenWellKnownTest.java b/authzen/tests/base/src/test/java/org/keycloak/tests/authzen/AuthZenWellKnownTest.java index da0e03c2e47..fbd393e0944 100644 --- a/authzen/tests/base/src/test/java/org/keycloak/tests/authzen/AuthZenWellKnownTest.java +++ b/authzen/tests/base/src/test/java/org/keycloak/tests/authzen/AuthZenWellKnownTest.java @@ -70,6 +70,7 @@ public class AuthZenWellKnownTest { assertNotNull(response); assertEquals(expectedRealmUrl, response.policyDecisionPoint()); assertEquals(expectedRealmUrl + "/authzen/access/v1/evaluation", response.accessEvaluationEndpoint()); + assertEquals(expectedRealmUrl + "/authzen/access/v1/evaluations", response.accessEvaluationsEndpoint()); } private String realmUrl() { diff --git a/authzen/tests/base/src/test/resources/org/keycloak/tests/authzen/authzen-interop-realm.json b/authzen/tests/base/src/test/resources/org/keycloak/tests/authzen/authzen-interop-realm.json index c3a846fddfe..4b6dd071ee5 100644 --- a/authzen/tests/base/src/test/resources/org/keycloak/tests/authzen/authzen-interop-realm.json +++ b/authzen/tests/base/src/test/resources/org/keycloak/tests/authzen/authzen-interop-realm.json @@ -157,7 +157,7 @@ ] }, { - "name": "240d0db-8ff0-41ec-98b2-34a096273b95", + "name": "7240d0db-8ff0-41ec-98b2-34a096273b95", "type": "todo", "scopes": [ { "name": "can_read_todos" }, @@ -254,7 +254,7 @@ "logic": "POSITIVE", "decisionStrategy": "UNANIMOUS", "config": { - "resources": "[\"todo-1\",\"7240d0db-8ff0-41ec-98b2-34a096273b92\",\"7240d0db-8ff0-41ec-98b2-34a096273b91\",\"7240d0db-8ff0-41ec-98b2-34a096273b93\",\"7240d0db-8ff0-41ec-98b2-34a096273b94\",\"240d0db-8ff0-41ec-98b2-34a096273b95\"]", + "resources": "[\"todo-1\",\"7240d0db-8ff0-41ec-98b2-34a096273b92\",\"7240d0db-8ff0-41ec-98b2-34a096273b91\",\"7240d0db-8ff0-41ec-98b2-34a096273b93\",\"7240d0db-8ff0-41ec-98b2-34a096273b94\",\"7240d0db-8ff0-41ec-98b2-34a096273b95\"]", "scopes": "[\"can_read_todos\"]", "applyPolicies": "[\"any-role-policy\"]" } @@ -265,7 +265,7 @@ "logic": "POSITIVE", "decisionStrategy": "UNANIMOUS", "config": { - "resources": "[\"todo-1\",\"7240d0db-8ff0-41ec-98b2-34a096273b92\",\"7240d0db-8ff0-41ec-98b2-34a096273b91\",\"7240d0db-8ff0-41ec-98b2-34a096273b93\",\"7240d0db-8ff0-41ec-98b2-34a096273b94\",\"240d0db-8ff0-41ec-98b2-34a096273b95\"]", + "resources": "[\"todo-1\",\"7240d0db-8ff0-41ec-98b2-34a096273b92\",\"7240d0db-8ff0-41ec-98b2-34a096273b91\",\"7240d0db-8ff0-41ec-98b2-34a096273b93\",\"7240d0db-8ff0-41ec-98b2-34a096273b94\",\"7240d0db-8ff0-41ec-98b2-34a096273b95\"]", "scopes": "[\"can_create_todo\"]", "applyPolicies": "[\"admin-or-editor-policy\"]" } @@ -276,7 +276,7 @@ "logic": "POSITIVE", "decisionStrategy": "UNANIMOUS", "config": { - "resources": "[\"todo-1\",\"7240d0db-8ff0-41ec-98b2-34a096273b92\",\"7240d0db-8ff0-41ec-98b2-34a096273b91\",\"7240d0db-8ff0-41ec-98b2-34a096273b93\",\"7240d0db-8ff0-41ec-98b2-34a096273b94\",\"240d0db-8ff0-41ec-98b2-34a096273b95\"]", + "resources": "[\"todo-1\",\"7240d0db-8ff0-41ec-98b2-34a096273b92\",\"7240d0db-8ff0-41ec-98b2-34a096273b91\",\"7240d0db-8ff0-41ec-98b2-34a096273b93\",\"7240d0db-8ff0-41ec-98b2-34a096273b94\",\"7240d0db-8ff0-41ec-98b2-34a096273b95\"]", "scopes": "[\"can_update_todo\"]", "applyPolicies": "[\"admin-or-editor-owner-policy\"]" } @@ -287,7 +287,7 @@ "logic": "POSITIVE", "decisionStrategy": "UNANIMOUS", "config": { - "resources": "[\"todo-1\",\"7240d0db-8ff0-41ec-98b2-34a096273b92\",\"7240d0db-8ff0-41ec-98b2-34a096273b91\",\"7240d0db-8ff0-41ec-98b2-34a096273b93\",\"7240d0db-8ff0-41ec-98b2-34a096273b94\",\"240d0db-8ff0-41ec-98b2-34a096273b95\"]", + "resources": "[\"todo-1\",\"7240d0db-8ff0-41ec-98b2-34a096273b92\",\"7240d0db-8ff0-41ec-98b2-34a096273b91\",\"7240d0db-8ff0-41ec-98b2-34a096273b93\",\"7240d0db-8ff0-41ec-98b2-34a096273b94\",\"7240d0db-8ff0-41ec-98b2-34a096273b95\"]", "scopes": "[\"can_delete_todo\"]", "applyPolicies": "[\"admin-or-editor-owner-policy\"]" } diff --git a/authzen/tests/base/src/test/resources/org/keycloak/tests/authzen/decisions-authorization-api-1_0-01.json b/authzen/tests/base/src/test/resources/org/keycloak/tests/authzen/decisions-authorization-api-1_0-02.json similarity index 69% rename from authzen/tests/base/src/test/resources/org/keycloak/tests/authzen/decisions-authorization-api-1_0-01.json rename to authzen/tests/base/src/test/resources/org/keycloak/tests/authzen/decisions-authorization-api-1_0-02.json index 1469e60cbdc..fb73e456fe5 100644 --- a/authzen/tests/base/src/test/resources/org/keycloak/tests/authzen/decisions-authorization-api-1_0-01.json +++ b/authzen/tests/base/src/test/resources/org/keycloak/tests/authzen/decisions-authorization-api-1_0-02.json @@ -4,7 +4,7 @@ "request": { "subject": { "type": "user", - "id":"CiRmZDA2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + "id": "CiRmZDA2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" }, "action": { "name": "can_read_user" @@ -20,7 +20,7 @@ "request": { "subject": { "type": "user", - "id":"CiRmZDA2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + "id": "CiRmZDA2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" }, "action": { "name": "can_read_user" @@ -36,7 +36,7 @@ "request": { "subject": { "type": "user", - "id":"CiRmZDA2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + "id": "CiRmZDA2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" }, "action": { "name": "can_read_todos" @@ -52,7 +52,7 @@ "request": { "subject": { "type": "user", - "id":"CiRmZDA2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + "id": "CiRmZDA2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" }, "action": { "name": "can_create_todo" @@ -68,7 +68,7 @@ "request": { "subject": { "type": "user", - "id":"CiRmZDA2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + "id": "CiRmZDA2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" }, "action": { "name": "can_update_todo" @@ -87,7 +87,7 @@ "request": { "subject": { "type": "user", - "id":"CiRmZDA2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + "id": "CiRmZDA2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" }, "action": { "name": "can_update_todo" @@ -106,7 +106,7 @@ "request": { "subject": { "type": "user", - "id":"CiRmZDA2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + "id": "CiRmZDA2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" }, "action": { "name": "can_delete_todo" @@ -125,7 +125,7 @@ "request": { "subject": { "type": "user", - "id":"CiRmZDA2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + "id": "CiRmZDA2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" }, "action": { "name": "can_delete_todo" @@ -145,7 +145,7 @@ "request": { "subject": { "type": "user", - "id":"CiRmZDE2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + "id": "CiRmZDE2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" }, "action": { "name": "can_read_user" @@ -161,7 +161,7 @@ "request": { "subject": { "type": "user", - "id":"CiRmZDE2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + "id": "CiRmZDE2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" }, "action": { "name": "can_read_user" @@ -177,7 +177,7 @@ "request": { "subject": { "type": "user", - "id":"CiRmZDE2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + "id": "CiRmZDE2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" }, "action": { "name": "can_read_todos" @@ -193,7 +193,7 @@ "request": { "subject": { "type": "user", - "id":"CiRmZDE2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + "id": "CiRmZDE2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" }, "action": { "name": "can_create_todo" @@ -209,7 +209,7 @@ "request": { "subject": { "type": "user", - "id":"CiRmZDE2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + "id": "CiRmZDE2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" }, "action": { "name": "can_update_todo" @@ -228,7 +228,7 @@ "request": { "subject": { "type": "user", - "id":"CiRmZDE2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + "id": "CiRmZDE2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" }, "action": { "name": "can_update_todo" @@ -247,7 +247,7 @@ "request": { "subject": { "type": "user", - "id":"CiRmZDE2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + "id": "CiRmZDE2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" }, "action": { "name": "can_delete_todo" @@ -266,7 +266,7 @@ "request": { "subject": { "type": "user", - "id":"CiRmZDE2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + "id": "CiRmZDE2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" }, "action": { "name": "can_delete_todo" @@ -286,7 +286,7 @@ "request": { "subject": { "type": "user", - "id":"CiRmZDI2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + "id": "CiRmZDI2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" }, "action": { "name": "can_read_user" @@ -302,7 +302,7 @@ "request": { "subject": { "type": "user", - "id":"CiRmZDI2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + "id": "CiRmZDI2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" }, "action": { "name": "can_read_user" @@ -318,7 +318,7 @@ "request": { "subject": { "type": "user", - "id":"CiRmZDI2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + "id": "CiRmZDI2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" }, "action": { "name": "can_read_todos" @@ -334,7 +334,7 @@ "request": { "subject": { "type": "user", - "id":"CiRmZDI2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + "id": "CiRmZDI2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" }, "action": { "name": "can_create_todo" @@ -350,7 +350,7 @@ "request": { "subject": { "type": "user", - "id":"CiRmZDI2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + "id": "CiRmZDI2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" }, "action": { "name": "can_update_todo" @@ -369,7 +369,7 @@ "request": { "subject": { "type": "user", - "id":"CiRmZDI2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + "id": "CiRmZDI2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" }, "action": { "name": "can_update_todo" @@ -388,7 +388,7 @@ "request": { "subject": { "type": "user", - "id":"CiRmZDI2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + "id": "CiRmZDI2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" }, "action": { "name": "can_delete_todo" @@ -407,7 +407,7 @@ "request": { "subject": { "type": "user", - "id":"CiRmZDI2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + "id": "CiRmZDI2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" }, "action": { "name": "can_delete_todo" @@ -426,7 +426,7 @@ "request": { "subject": { "type": "user", - "id":"CiRmZDM2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + "id": "CiRmZDM2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" }, "action": { "name": "can_read_user" @@ -442,7 +442,7 @@ "request": { "subject": { "type": "user", - "id":"CiRmZDM2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + "id": "CiRmZDM2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" }, "action": { "name": "can_read_user" @@ -458,7 +458,7 @@ "request": { "subject": { "type": "user", - "id":"CiRmZDM2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + "id": "CiRmZDM2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" }, "action": { "name": "can_read_todos" @@ -474,7 +474,7 @@ "request": { "subject": { "type": "user", - "id":"CiRmZDM2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + "id": "CiRmZDM2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" }, "action": { "name": "can_create_todo" @@ -490,7 +490,7 @@ "request": { "subject": { "type": "user", - "id":"CiRmZDM2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + "id": "CiRmZDM2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" }, "action": { "name": "can_update_todo" @@ -509,7 +509,7 @@ "request": { "subject": { "type": "user", - "id":"CiRmZDM2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + "id": "CiRmZDM2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" }, "action": { "name": "can_update_todo" @@ -528,7 +528,7 @@ "request": { "subject": { "type": "user", - "id":"CiRmZDM2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + "id": "CiRmZDM2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" }, "action": { "name": "can_delete_todo" @@ -547,7 +547,7 @@ "request": { "subject": { "type": "user", - "id":"CiRmZDM2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + "id": "CiRmZDM2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" }, "action": { "name": "can_delete_todo" @@ -566,7 +566,7 @@ "request": { "subject": { "type": "user", - "id":"CiRmZDQ2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + "id": "CiRmZDQ2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" }, "action": { "name": "can_read_user" @@ -582,7 +582,7 @@ "request": { "subject": { "type": "user", - "id":"CiRmZDQ2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + "id": "CiRmZDQ2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" }, "action": { "name": "can_read_user" @@ -598,7 +598,7 @@ "request": { "subject": { "type": "user", - "id":"CiRmZDQ2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + "id": "CiRmZDQ2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" }, "action": { "name": "can_read_todos" @@ -614,7 +614,7 @@ "request": { "subject": { "type": "user", - "id":"CiRmZDQ2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + "id": "CiRmZDQ2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" }, "action": { "name": "can_create_todo" @@ -630,7 +630,7 @@ "request": { "subject": { "type": "user", - "id":"CiRmZDQ2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + "id": "CiRmZDQ2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" }, "action": { "name": "can_update_todo" @@ -649,14 +649,14 @@ "request": { "subject": { "type": "user", - "id":"CiRmZDQ2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + "id": "CiRmZDQ2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" }, "action": { "name": "can_update_todo" }, "resource": { "type": "todo", - "id": "240d0db-8ff0-41ec-98b2-34a096273b95", + "id": "7240d0db-8ff0-41ec-98b2-34a096273b95", "properties": { "ownerID": "jerry@the-smiths.com" } @@ -668,7 +668,7 @@ "request": { "subject": { "type": "user", - "id":"CiRmZDQ2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + "id": "CiRmZDQ2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" }, "action": { "name": "can_delete_todo" @@ -687,14 +687,14 @@ "request": { "subject": { "type": "user", - "id":"CiRmZDQ2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + "id": "CiRmZDQ2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" }, "action": { "name": "can_delete_todo" }, "resource": { "type": "todo", - "id": "240d0db-8ff0-41ec-98b2-34a096273b95", + "id": "7240d0db-8ff0-41ec-98b2-34a096273b95", "properties": { "ownerID": "jerry@the-smiths.com" } @@ -702,5 +702,103 @@ }, "expected": false } + ], + "evaluations": [ + { + "request": { + "subject": { + "type": "user", + "id": "CiRmZDA2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + }, + "action": { + "name": "can_update_todo" + }, + "evaluations": [ + { + "resource": { + "type": "todo", + "id": "7240d0db-8ff0-41ec-98b2-34a096273b92", + "properties": { + "ownerID": "rick@the-citadel.com" + } + } + }, + { + "resource": { + "type": "todo", + "id": "7240d0db-8ff0-41ec-98b2-34a096273b95", + "properties": { + "ownerID": "jerry@the-smiths.com" + } + } + } + ] + }, + "expected": [ { "decision": true }, { "decision": true } ] + }, + { + "request": { + "subject": { + "type": "user", + "id": "CiRmZDE2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + }, + "action": { + "name": "can_update_todo" + }, + "evaluations": [ + { + "resource": { + "type": "todo", + "id": "7240d0db-8ff0-41ec-98b2-34a096273b92", + "properties": { + "ownerID": "rick@the-citadel.com" + } + } + }, + { + "resource": { + "type": "todo", + "id": "7240d0db-8ff0-41ec-98b2-34a096273b91", + "properties": { + "ownerID": "morty@the-citadel.com" + } + } + } + ] + }, + "expected": [ { "decision": false }, { "decision": true } ] + }, + { + "request": { + "subject": { + "type": "user", + "id": "CiRmZDQ2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs" + }, + "action": { + "name": "can_update_todo" + }, + "evaluations": [ + { + "resource": { + "type": "todo", + "id": "7240d0db-8ff0-41ec-98b2-34a096273b92", + "properties": { + "ownerID": "rick@the-citadel.com" + } + } + }, + { + "resource": { + "type": "todo", + "id": "7240d0db-8ff0-41ec-98b2-34a096273b95", + "properties": { + "ownerID": "jerry@the-smiths.com" + } + } + } + ] + }, + "expected": [ { "decision": false }, { "decision": false } ] + } ] }