From 7ec7fb748152d272f8ed926b4be94ac8ef141d1b Mon Sep 17 00:00:00 2001 From: Yosi Haran Date: Sun, 9 Aug 2026 17:53:29 +0300 Subject: [PATCH 1/5] feat(fga): support ABAC context in check, whoCanAccess and whatCanTargetAccess Adds context overloads mirroring CheckWithContext, WhoCanAccessWithContext and WhatCanTargetAccessWithContext in the Go SDK. The context map is sent only when non-empty, and its keys become variables for the CEL conditions in the schema. Also surfaces the response side, which was previously discarded: check now returns the relation it was asked about and the info object (conditional, missingContext, conditionalErr, factUsed), and loadSchema now returns the schema version and its conditions. Overloads rather than renames, and FGASchema keeps its single-argument constructor, so existing callers are unaffected. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 26 ++++- .../com/descope/model/fga/FGACheckInfo.java | 5 + .../descope/model/fga/FGACheckResponse.java | 25 ++++ .../com/descope/model/fga/FGACondition.java | 17 +++ .../descope/model/fga/FGAConditionParam.java | 15 +++ .../model/fga/FGALoadSchemaResponse.java | 25 ++++ .../java/com/descope/model/fga/FGASchema.java | 7 ++ .../com/descope/sdk/mgmt/AuthzService.java | 32 ++++++ .../java/com/descope/sdk/mgmt/FGAService.java | 15 +++ .../sdk/mgmt/impl/AuthzServiceImpl.java | 17 +++ .../descope/sdk/mgmt/impl/FGAServiceImpl.java | 48 ++++---- .../sdk/mgmt/impl/AuthzServiceImplTest.java | 56 +++++++++ .../sdk/mgmt/impl/FGAServiceImplTest.java | 107 ++++++++++++++++-- 13 files changed, 362 insertions(+), 33 deletions(-) create mode 100644 src/main/java/com/descope/model/fga/FGACheckResponse.java create mode 100644 src/main/java/com/descope/model/fga/FGACondition.java create mode 100644 src/main/java/com/descope/model/fga/FGAConditionParam.java create mode 100644 src/main/java/com/descope/model/fga/FGALoadSchemaResponse.java diff --git a/README.md b/README.md index 79e3852d..06418d03 100644 --- a/README.md +++ b/README.md @@ -1512,7 +1512,7 @@ try { // Load the current authorization schema try { FGASchema schema = fs.loadSchema(); - // Do something with schema.getDsl() + // Do something with schema.getDsl(), schema.getVersion() and schema.getConditions() } catch (DescopeException de) { // Handle the error } @@ -1533,12 +1533,34 @@ try { try { List results = fs.check(relations); for (FGACheckResult result : results) { - // Do something with result.isAllowed() + // Do something with result.isAllowed(), result.getRelation() and result.getInfo() } } catch (DescopeException de) { // Handle the error } +// Check relations against a schema that uses conditions (ABAC), passing the values +// the conditions are evaluated with +Map context = new HashMap<>(); +context.put("role", "admin"); + +try { + List results = fs.check(relations, context); + for (FGACheckResult result : results) { + FGACheckInfo info = result.getInfo(); + // info.isConditional() - the result was decided by a condition + // info.getMissingContext() - context variables the conditions needed but did not get + // info.getConditionalErr() - the condition could not be evaluated + } +} catch (DescopeException de) { + // Handle the error +} + +// The same context can be passed to the authz queries that evaluate conditions +AuthzService authz = descopeClient.getManagementServices().getAuthzService(); +List targets = authz.whoCanAccess("doc1", "viewer", "doc", context); +List relationsForTarget = authz.whatCanTargetAccess("user123", context); + // Delete relations try { fs.deleteRelations(relations); diff --git a/src/main/java/com/descope/model/fga/FGACheckInfo.java b/src/main/java/com/descope/model/fga/FGACheckInfo.java index 7b18db3c..0477ddba 100644 --- a/src/main/java/com/descope/model/fga/FGACheckInfo.java +++ b/src/main/java/com/descope/model/fga/FGACheckInfo.java @@ -1,5 +1,6 @@ package com.descope.model.fga; +import java.util.List; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -11,4 +12,8 @@ @AllArgsConstructor public class FGACheckInfo { private boolean direct; + private boolean conditional; + private List missingContext; + private String conditionalErr; + private boolean factUsed; } diff --git a/src/main/java/com/descope/model/fga/FGACheckResponse.java b/src/main/java/com/descope/model/fga/FGACheckResponse.java new file mode 100644 index 00000000..643d6781 --- /dev/null +++ b/src/main/java/com/descope/model/fga/FGACheckResponse.java @@ -0,0 +1,25 @@ +package com.descope.model.fga; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class FGACheckResponse { + private List tuples; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class FGACheckResponseTuple { + private boolean allowed; + private FGARelation tuple; + private FGACheckInfo info; + } +} diff --git a/src/main/java/com/descope/model/fga/FGACondition.java b/src/main/java/com/descope/model/fga/FGACondition.java new file mode 100644 index 00000000..f64e0128 --- /dev/null +++ b/src/main/java/com/descope/model/fga/FGACondition.java @@ -0,0 +1,17 @@ +package com.descope.model.fga; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class FGACondition { + private String name; + private List params; + private String expression; +} diff --git a/src/main/java/com/descope/model/fga/FGAConditionParam.java b/src/main/java/com/descope/model/fga/FGAConditionParam.java new file mode 100644 index 00000000..9b175a4a --- /dev/null +++ b/src/main/java/com/descope/model/fga/FGAConditionParam.java @@ -0,0 +1,15 @@ +package com.descope.model.fga; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class FGAConditionParam { + private String name; + private String type; +} diff --git a/src/main/java/com/descope/model/fga/FGALoadSchemaResponse.java b/src/main/java/com/descope/model/fga/FGALoadSchemaResponse.java new file mode 100644 index 00000000..0fdfb32e --- /dev/null +++ b/src/main/java/com/descope/model/fga/FGALoadSchemaResponse.java @@ -0,0 +1,25 @@ +package com.descope.model.fga; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class FGALoadSchemaResponse { + private String dsl; + private String version; + private FGALoadSchemaConditions schema; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class FGALoadSchemaConditions { + private List conditions; + } +} diff --git a/src/main/java/com/descope/model/fga/FGASchema.java b/src/main/java/com/descope/model/fga/FGASchema.java index 3378907b..6880299e 100644 --- a/src/main/java/com/descope/model/fga/FGASchema.java +++ b/src/main/java/com/descope/model/fga/FGASchema.java @@ -1,5 +1,6 @@ package com.descope.model.fga; +import java.util.List; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -11,4 +12,10 @@ @AllArgsConstructor public class FGASchema { private String dsl; + private List conditions; + private String version; + + public FGASchema(String dsl) { + this.dsl = dsl; + } } diff --git a/src/main/java/com/descope/sdk/mgmt/AuthzService.java b/src/main/java/com/descope/sdk/mgmt/AuthzService.java index ef161663..ae0c0c5d 100644 --- a/src/main/java/com/descope/sdk/mgmt/AuthzService.java +++ b/src/main/java/com/descope/sdk/mgmt/AuthzService.java @@ -9,6 +9,7 @@ import com.descope.model.authz.Schema; import java.time.Instant; import java.util.List; +import java.util.Map; /** Provides ReBAC authorization service APIs. */ public interface AuthzService { @@ -126,6 +127,23 @@ void saveRelationDefinition(RelationDefinition relationDefinition, String namesp */ List whoCanAccess(String resource, String relationDefinition, String namespace) throws DescopeException; + /** + * List all the users that have the given relation definition to the given resource, evaluating + * any schema conditions against the given context. + * + *

Context keys become variables available to the CEL conditions defined in the schema. + * Values must be JSON serializable. + * + * @param resource The resource we are checking + * @param relationDefinition The relation definition we are querying + * @param namespace The namespace for the relation definition + * @param context Extra context for condition evaluation, may be null or empty + * @return {@link List} of users who have the given relation definition + * @throws DescopeException If there occurs any exception, a subtype of this exception will be thrown. + */ + List whoCanAccess(String resource, String relationDefinition, String namespace, + Map context) throws DescopeException; + /** * Return the list of all defined relations (not recursive) on the given resource. * @@ -153,6 +171,20 @@ void saveRelationDefinition(RelationDefinition relationDefinition, String namesp */ List whatCanTargetAccess(String target) throws DescopeException; + /** + * Return the list of all relations for the given target including derived relations from the + * schema tree, evaluating any schema conditions against the given context. + * + *

Context keys become variables available to the CEL conditions defined in the schema. + * Values must be JSON serializable. + * + * @param target The target to check relations for + * @param context Extra context for condition evaluation, may be null or empty + * @return {@link List} of {@link Relation} that exist for the given target + * @throws DescopeException If there occurs any exception, a subtype of this exception will be thrown. + */ + List whatCanTargetAccess(String target, Map context) throws DescopeException; + /** * Return the list of all resources for the given target and a given relation definition * including derived resources from the schema tree. diff --git a/src/main/java/com/descope/sdk/mgmt/FGAService.java b/src/main/java/com/descope/sdk/mgmt/FGAService.java index 6bb71525..fc546b43 100644 --- a/src/main/java/com/descope/sdk/mgmt/FGAService.java +++ b/src/main/java/com/descope/sdk/mgmt/FGAService.java @@ -8,6 +8,7 @@ import com.descope.model.fga.FGASchema; import com.descope.model.fga.FGASchemaDryRunResponse; import java.util.List; +import java.util.Map; /** * Provides functions for managing Fine-Grained Authorization (FGA) in a project. @@ -68,6 +69,20 @@ public interface FGAService { */ List check(List relations) throws DescopeException; + /** + * Checks if the given FGA relations are satisfied, evaluating any schema conditions against + * the given context. + * + *

Context keys become variables available to the CEL conditions defined in the schema, + * on top of any attributes the backend already holds. Values must be JSON serializable. + * + * @param relations list of relations to check + * @param context extra context for condition evaluation, may be null or empty + * @return list of check results indicating whether each relation is allowed + * @throws DescopeException if the operation fails + */ + List check(List relations, Map context) throws DescopeException; + /** * Loads detailed information for the given resource identifiers. * diff --git a/src/main/java/com/descope/sdk/mgmt/impl/AuthzServiceImpl.java b/src/main/java/com/descope/sdk/mgmt/impl/AuthzServiceImpl.java index 7359ffcc..9c83b8e6 100644 --- a/src/main/java/com/descope/sdk/mgmt/impl/AuthzServiceImpl.java +++ b/src/main/java/com/descope/sdk/mgmt/impl/AuthzServiceImpl.java @@ -190,6 +190,12 @@ public List hasRelations(List relationQueries) thr @Override public List whoCanAccess(String resource, String relationDefinition, String namespace) throws DescopeException { + return whoCanAccess(resource, relationDefinition, namespace, null); + } + + @Override + public List whoCanAccess(String resource, String relationDefinition, String namespace, + Map context) throws DescopeException { if (StringUtils.isBlank(resource)) { throw ServerCommonException.invalidArgument("resource"); } @@ -202,6 +208,9 @@ public List whoCanAccess(String resource, String relationDefinition, Str ApiProxy apiProxy = getApiProxy(); Map request = mapOf("resource", resource, "relationDefinition", relationDefinition, "namespace", namespace); + if (context != null && !context.isEmpty()) { + request.put("context", context); + } WhoCanAccessResponse resp = apiProxy.post(getUri(MANAGEMENT_AUTHZ_RE_WHO), request, WhoCanAccessResponse.class); return resp.getTargets(); } @@ -230,11 +239,19 @@ public List targetsRelations(List targets) throws DescopeExcep @Override public List whatCanTargetAccess(String target) throws DescopeException { + return whatCanTargetAccess(target, null); + } + + @Override + public List whatCanTargetAccess(String target, Map context) throws DescopeException { if (StringUtils.isBlank(target)) { throw ServerCommonException.invalidArgument("target"); } ApiProxy apiProxy = getApiProxy(); Map request = mapOf("target", target); + if (context != null && !context.isEmpty()) { + request.put("context", context); + } RelationsResponse resp = apiProxy.post(getUri(MANAGEMENT_AUTHZ_RE_TARGET_ALL), request, RelationsResponse.class); return resp.getRelations(); } diff --git a/src/main/java/com/descope/sdk/mgmt/impl/FGAServiceImpl.java b/src/main/java/com/descope/sdk/mgmt/impl/FGAServiceImpl.java index 0a3a82e1..faa0afe4 100644 --- a/src/main/java/com/descope/sdk/mgmt/impl/FGAServiceImpl.java +++ b/src/main/java/com/descope/sdk/mgmt/impl/FGAServiceImpl.java @@ -12,7 +12,11 @@ import com.descope.exception.DescopeException; import com.descope.exception.ServerCommonException; import com.descope.model.client.Client; +import com.descope.model.fga.FGACheckInfo; +import com.descope.model.fga.FGACheckResponse; +import com.descope.model.fga.FGACheckResponse.FGACheckResponseTuple; import com.descope.model.fga.FGACheckResult; +import com.descope.model.fga.FGALoadSchemaResponse; import com.descope.model.fga.FGARelation; import com.descope.model.fga.FGAResourceDetails; import com.descope.model.fga.FGAResourceIdentifier; @@ -62,12 +66,16 @@ public FGASchemaDryRunResponse dryRunSchema(FGASchema schema) throws DescopeExce @Override public FGASchema loadSchema() throws DescopeException { ApiProxy apiProxy = getApiProxy(); - Map response = apiProxy.getArray(getUri(MANAGEMENT_FGA_LOAD_SCHEMA), - new TypeReference>() {}); + FGALoadSchemaResponse response = apiProxy.get(getUri(MANAGEMENT_FGA_LOAD_SCHEMA), FGALoadSchemaResponse.class); FGASchema schema = new FGASchema(); - if (response.containsKey("dsl")) { - schema.setDsl((String) response.get("dsl")); + if (response == null) { + return schema; + } + schema.setDsl(response.getDsl()); + schema.setVersion(response.getVersion()); + if (response.getSchema() != null) { + schema.setConditions(response.getSchema().getConditions()); } return schema; } @@ -100,32 +108,34 @@ public void deleteRelations(List relations) throws DescopeException @Override public List check(List relations) throws DescopeException { + return check(relations, null); + } + + @Override + public List check(List relations, Map context) + throws DescopeException { if (relations == null || relations.isEmpty()) { throw ServerCommonException.invalidArgument("relations list"); } Map requestBody = new HashMap<>(); requestBody.put("tuples", relations); + if (context != null && !context.isEmpty()) { + requestBody.put("context", context); + } ApiProxy apiProxy = getApiProxy(); - Map response = apiProxy.postAndGetArray(getUri(MANAGEMENT_FGA_CHECK), - requestBody, new TypeReference>() {}); + FGACheckResponse response = apiProxy.post(getUri(MANAGEMENT_FGA_CHECK), requestBody, FGACheckResponse.class); - if (response.containsKey("tuples")) { - // Convert the response tuples to FGACheckResult objects - @SuppressWarnings("unchecked") - List> tuples = (List>) response.get("tuples"); - List results = new ArrayList<>(); - for (Map tuple : tuples) { - FGACheckResult result = new FGACheckResult(); - if (tuple.containsKey("allowed")) { - result.setAllowed((Boolean) tuple.get("allowed")); - } - results.add(result); - } + List results = new ArrayList<>(); + if (response == null || response.getTuples() == null) { return results; } - return new ArrayList<>(); + for (FGACheckResponseTuple tuple : response.getTuples()) { + FGACheckInfo info = tuple.getInfo() == null ? new FGACheckInfo() : tuple.getInfo(); + results.add(new FGACheckResult(tuple.isAllowed(), tuple.getTuple(), info)); + } + return results; } @Override diff --git a/src/test/java/com/descope/sdk/mgmt/impl/AuthzServiceImplTest.java b/src/test/java/com/descope/sdk/mgmt/impl/AuthzServiceImplTest.java index 47093d86..a97a5f72 100644 --- a/src/test/java/com/descope/sdk/mgmt/impl/AuthzServiceImplTest.java +++ b/src/test/java/com/descope/sdk/mgmt/impl/AuthzServiceImplTest.java @@ -10,6 +10,7 @@ import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; import com.descope.exception.RateLimitExceededException; import com.descope.exception.ServerCommonException; @@ -36,10 +37,13 @@ import java.time.Instant; import java.time.Period; import java.util.Arrays; +import java.util.HashMap; import java.util.List; +import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junitpioneer.jupiter.RetryingTest; +import org.mockito.ArgumentCaptor; import org.mockito.MockedStatic; public class AuthzServiceImplTest { @@ -340,6 +344,40 @@ void testWhoCanAccessForSuccess() { } } + @SuppressWarnings("unchecked") + @Test + void testWhoCanAccessWithContext() { + ApiProxy apiProxy = mock(ApiProxy.class); + doReturn(new WhoCanAccessResponse(Arrays.asList("kuku"))).when(apiProxy).post(any(), any(), any()); + Map context = new HashMap<>(); + context.put("role", "admin"); + try (MockedStatic mockedApiProxyBuilder = mockStatic(ApiProxyBuilder.class)) { + mockedApiProxyBuilder.when( + () -> ApiProxyBuilder.buildProxy(any(), any())).thenReturn(apiProxy); + authzService.whoCanAccess("kiki", "kuku", "kaka", context); + + ArgumentCaptor> bodyCaptor = ArgumentCaptor.forClass(Map.class); + verify(apiProxy).post(any(), bodyCaptor.capture(), any()); + assertEquals(context, bodyCaptor.getValue().get("context")); + } + } + + @SuppressWarnings("unchecked") + @Test + void testWhoCanAccessWithEmptyContext() { + ApiProxy apiProxy = mock(ApiProxy.class); + doReturn(new WhoCanAccessResponse(Arrays.asList("kuku"))).when(apiProxy).post(any(), any(), any()); + try (MockedStatic mockedApiProxyBuilder = mockStatic(ApiProxyBuilder.class)) { + mockedApiProxyBuilder.when( + () -> ApiProxyBuilder.buildProxy(any(), any())).thenReturn(apiProxy); + authzService.whoCanAccess("kiki", "kuku", "kaka", new HashMap<>()); + + ArgumentCaptor> bodyCaptor = ArgumentCaptor.forClass(Map.class); + verify(apiProxy).post(any(), bodyCaptor.capture(), any()); + assertFalse(bodyCaptor.getValue().containsKey("context")); + } + } + @Test void testResourceRelationsForNoResource() { ServerCommonException thrown = @@ -403,6 +441,24 @@ void testWhatCanTargetAccessForSuccess() { } } + @SuppressWarnings("unchecked") + @Test + void testWhatCanTargetAccessWithContext() { + ApiProxy apiProxy = mock(ApiProxy.class); + doReturn(new RelationsResponse(Arrays.asList(new Relation()))).when(apiProxy).post(any(), any(), any()); + Map context = new HashMap<>(); + context.put("role", "admin"); + try (MockedStatic mockedApiProxyBuilder = mockStatic(ApiProxyBuilder.class)) { + mockedApiProxyBuilder.when( + () -> ApiProxyBuilder.buildProxy(any(), any())).thenReturn(apiProxy); + authzService.whatCanTargetAccess("kiki", context); + + ArgumentCaptor> bodyCaptor = ArgumentCaptor.forClass(Map.class); + verify(apiProxy).post(any(), bodyCaptor.capture(), any()); + assertEquals(context, bodyCaptor.getValue().get("context")); + } + } + @Test void testWhatCanTargetAccessWithRelationForNoTarget() { ServerCommonException thrown = diff --git a/src/test/java/com/descope/sdk/mgmt/impl/FGAServiceImplTest.java b/src/test/java/com/descope/sdk/mgmt/impl/FGAServiceImplTest.java index 50fa5581..cefcfbbe 100644 --- a/src/test/java/com/descope/sdk/mgmt/impl/FGAServiceImplTest.java +++ b/src/test/java/com/descope/sdk/mgmt/impl/FGAServiceImplTest.java @@ -1,6 +1,7 @@ package com.descope.sdk.mgmt.impl; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -13,7 +14,14 @@ import com.descope.exception.RateLimitExceededException; import com.descope.exception.ServerCommonException; import com.descope.model.client.Client; +import com.descope.model.fga.FGACheckInfo; +import com.descope.model.fga.FGACheckResponse; +import com.descope.model.fga.FGACheckResponse.FGACheckResponseTuple; import com.descope.model.fga.FGACheckResult; +import com.descope.model.fga.FGACondition; +import com.descope.model.fga.FGAConditionParam; +import com.descope.model.fga.FGALoadSchemaResponse; +import com.descope.model.fga.FGALoadSchemaResponse.FGALoadSchemaConditions; import com.descope.model.fga.FGARelation; import com.descope.model.fga.FGAResourceDetails; import com.descope.model.fga.FGAResourceIdentifier; @@ -37,6 +45,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junitpioneer.jupiter.RetryingTest; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.MockedStatic; import org.mockito.Mockito; @@ -117,20 +126,25 @@ void testDryRunSchema_EmptyDSL() { assertThrows(ServerCommonException.class, () -> fgaService.dryRunSchema(new FGASchema(""))); } - @SuppressWarnings("unchecked") @Test void testLoadSchema_Success() throws Exception { - Map response = new HashMap<>(); - response.put("dsl", "model AuthZ 1.0\ntype user"); + FGACondition condition = new FGACondition("IsAdmin", + Arrays.asList(new FGAConditionParam("role", "string")), "role == \"admin\""); + FGALoadSchemaResponse response = new FGALoadSchemaResponse("model AuthZ 1.0\ntype user", "v1", + new FGALoadSchemaConditions(Arrays.asList(condition))); try (MockedStatic mockedStatic = Mockito.mockStatic(ApiProxyBuilder.class)) { mockedStatic.when(() -> ApiProxyBuilder.buildProxy(any(), any())).thenReturn(apiProxy); - when(apiProxy.getArray(any(), any(TypeReference.class))).thenReturn(response); + when(apiProxy.get(any(), eq(FGALoadSchemaResponse.class))).thenReturn(response); FGASchema result = fgaService.loadSchema(); assertNotNull(result); assertEquals("model AuthZ 1.0\ntype user", result.getDsl()); + assertEquals("v1", result.getVersion()); + assertEquals(1, result.getConditions().size()); + assertEquals("IsAdmin", result.getConditions().get(0).getName()); + assertEquals("role", result.getConditions().get(0).getParams().get(0).getName()); } } @@ -169,28 +183,97 @@ void testDeleteRelations_Success() throws Exception { } } - @SuppressWarnings("unchecked") @Test void testCheck_Success() throws Exception { List relations = Arrays.asList( new FGARelation("doc1", "document", "owner", "user1", "user") ); - Map checkResultMap = new HashMap<>(); - checkResultMap.put("allowed", true); - - Map response = new HashMap<>(); - response.put("tuples", Arrays.asList(checkResultMap)); + FGACheckResponse response = new FGACheckResponse(Arrays.asList( + new FGACheckResponseTuple(true, relations.get(0), FGACheckInfo.builder().direct(true).build()))); try (MockedStatic mockedStatic = Mockito.mockStatic(ApiProxyBuilder.class)) { mockedStatic.when(() -> ApiProxyBuilder.buildProxy(any(), any())).thenReturn(apiProxy); - when(apiProxy.postAndGetArray(any(), any(), any(TypeReference.class))).thenReturn(response); + when(apiProxy.post(any(), any(), eq(FGACheckResponse.class))).thenReturn(response); List results = fgaService.check(relations); assertNotNull(results); assertEquals(1, results.size()); - assertEquals(true, results.get(0).isAllowed()); + assertTrue(results.get(0).isAllowed()); + assertEquals(relations.get(0), results.get(0).getRelation()); + assertTrue(results.get(0).getInfo().isDirect()); + } + } + + @SuppressWarnings("unchecked") + @Test + void testCheck_WithContext() throws Exception { + List relations = Arrays.asList( + new FGARelation("doc1", "doc", "viewer", "user1", "user") + ); + Map context = new HashMap<>(); + context.put("role", "admin"); + + FGACheckInfo info = FGACheckInfo.builder().conditional(true).conditionalErr("bad type") + .missingContext(Arrays.asList("action")).factUsed(true).build(); + FGACheckResponse response = new FGACheckResponse(Arrays.asList( + new FGACheckResponseTuple(false, relations.get(0), info))); + + try (MockedStatic mockedStatic = Mockito.mockStatic(ApiProxyBuilder.class)) { + mockedStatic.when(() -> ApiProxyBuilder.buildProxy(any(), any())).thenReturn(apiProxy); + when(apiProxy.post(any(), any(), eq(FGACheckResponse.class))).thenReturn(response); + + List results = fgaService.check(relations, context); + + ArgumentCaptor> bodyCaptor = ArgumentCaptor.forClass(Map.class); + verify(apiProxy).post(any(), bodyCaptor.capture(), eq(FGACheckResponse.class)); + assertEquals(context, bodyCaptor.getValue().get("context")); + + FGACheckInfo resultInfo = results.get(0).getInfo(); + assertTrue(resultInfo.isConditional()); + assertTrue(resultInfo.isFactUsed()); + assertEquals("bad type", resultInfo.getConditionalErr()); + assertEquals(Arrays.asList("action"), resultInfo.getMissingContext()); + } + } + + @SuppressWarnings("unchecked") + @Test + void testCheck_EmptyContextIsNotSent() throws Exception { + List relations = Arrays.asList( + new FGARelation("doc1", "doc", "viewer", "user1", "user") + ); + + try (MockedStatic mockedStatic = Mockito.mockStatic(ApiProxyBuilder.class)) { + mockedStatic.when(() -> ApiProxyBuilder.buildProxy(any(), any())).thenReturn(apiProxy); + when(apiProxy.post(any(), any(), eq(FGACheckResponse.class))) + .thenReturn(new FGACheckResponse(Arrays.asList())); + + fgaService.check(relations, new HashMap<>()); + + ArgumentCaptor> bodyCaptor = ArgumentCaptor.forClass(Map.class); + verify(apiProxy).post(any(), bodyCaptor.capture(), eq(FGACheckResponse.class)); + assertFalse(bodyCaptor.getValue().containsKey("context")); + } + } + + @Test + void testCheck_MissingInfoIsNotNull() throws Exception { + List relations = Arrays.asList( + new FGARelation("doc1", "doc", "viewer", "user1", "user") + ); + FGACheckResponse response = new FGACheckResponse(Arrays.asList( + new FGACheckResponseTuple(true, relations.get(0), null))); + + try (MockedStatic mockedStatic = Mockito.mockStatic(ApiProxyBuilder.class)) { + mockedStatic.when(() -> ApiProxyBuilder.buildProxy(any(), any())).thenReturn(apiProxy); + when(apiProxy.post(any(), any(), eq(FGACheckResponse.class))).thenReturn(response); + + List results = fgaService.check(relations); + + assertNotNull(results.get(0).getInfo()); + assertFalse(results.get(0).getInfo().isConditional()); } } From 4a396243e0cff17787457808066eeb609268b2a1 Mon Sep 17 00:00:00 2001 From: Yosi Haran Date: Sun, 9 Aug 2026 18:25:17 +0300 Subject: [PATCH 2/5] fix(fga): never return null conditions from loadSchema The README tells callers to iterate schema.getConditions(), so default it to an empty list when the server omits the schema object or its conditions, matching how check normalizes a missing info. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/descope/sdk/mgmt/impl/FGAServiceImpl.java | 3 ++- .../descope/sdk/mgmt/impl/FGAServiceImplTest.java | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/descope/sdk/mgmt/impl/FGAServiceImpl.java b/src/main/java/com/descope/sdk/mgmt/impl/FGAServiceImpl.java index faa0afe4..573a7fd9 100644 --- a/src/main/java/com/descope/sdk/mgmt/impl/FGAServiceImpl.java +++ b/src/main/java/com/descope/sdk/mgmt/impl/FGAServiceImpl.java @@ -69,12 +69,13 @@ public FGASchema loadSchema() throws DescopeException { FGALoadSchemaResponse response = apiProxy.get(getUri(MANAGEMENT_FGA_LOAD_SCHEMA), FGALoadSchemaResponse.class); FGASchema schema = new FGASchema(); + schema.setConditions(new ArrayList<>()); if (response == null) { return schema; } schema.setDsl(response.getDsl()); schema.setVersion(response.getVersion()); - if (response.getSchema() != null) { + if (response.getSchema() != null && response.getSchema().getConditions() != null) { schema.setConditions(response.getSchema().getConditions()); } return schema; diff --git a/src/test/java/com/descope/sdk/mgmt/impl/FGAServiceImplTest.java b/src/test/java/com/descope/sdk/mgmt/impl/FGAServiceImplTest.java index cefcfbbe..75db2cd0 100644 --- a/src/test/java/com/descope/sdk/mgmt/impl/FGAServiceImplTest.java +++ b/src/test/java/com/descope/sdk/mgmt/impl/FGAServiceImplTest.java @@ -148,6 +148,18 @@ void testLoadSchema_Success() throws Exception { } } + @Test + void testLoadSchema_ConditionsAreNeverNull() throws Exception { + FGALoadSchemaResponse response = new FGALoadSchemaResponse("model AuthZ 1.0\ntype user", "v1", null); + + try (MockedStatic mockedStatic = Mockito.mockStatic(ApiProxyBuilder.class)) { + mockedStatic.when(() -> ApiProxyBuilder.buildProxy(any(), any())).thenReturn(apiProxy); + when(apiProxy.get(any(), eq(FGALoadSchemaResponse.class))).thenReturn(response); + + assertTrue(fgaService.loadSchema().getConditions().isEmpty()); + } + } + @Test void testCreateRelations_Success() throws Exception { List relations = Arrays.asList( From 37e72a7d65415f87a2df446abaa78ff11ef30fb9 Mon Sep 17 00:00:00 2001 From: Yosi Haran Date: Sun, 9 Aug 2026 19:01:55 +0300 Subject: [PATCH 3/5] docs(fga): use the example's own namespace in the whoCanAccess snippet The surrounding example declares type document, so the query has to use it. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 06418d03..f521f895 100644 --- a/README.md +++ b/README.md @@ -1558,7 +1558,7 @@ try { // The same context can be passed to the authz queries that evaluate conditions AuthzService authz = descopeClient.getManagementServices().getAuthzService(); -List targets = authz.whoCanAccess("doc1", "viewer", "doc", context); +List targets = authz.whoCanAccess("doc1", "viewer", "document", context); List relationsForTarget = authz.whatCanTargetAccess("user123", context); // Delete relations From ddec604578dfe4f361316adc294a9227304aa527 Mon Sep 17 00:00:00 2001 From: Yosi Haran Date: Sun, 9 Aug 2026 19:32:46 +0300 Subject: [PATCH 4/5] fix(fga): never return null missingContext from check Same reason as conditions: callers iterate it, and the server omits it when nothing is missing. Co-Authored-By: Claude Opus 5 (1M context) --- src/main/java/com/descope/sdk/mgmt/impl/FGAServiceImpl.java | 3 +++ .../java/com/descope/sdk/mgmt/impl/FGAServiceImplTest.java | 1 + 2 files changed, 4 insertions(+) diff --git a/src/main/java/com/descope/sdk/mgmt/impl/FGAServiceImpl.java b/src/main/java/com/descope/sdk/mgmt/impl/FGAServiceImpl.java index 573a7fd9..2b15352e 100644 --- a/src/main/java/com/descope/sdk/mgmt/impl/FGAServiceImpl.java +++ b/src/main/java/com/descope/sdk/mgmt/impl/FGAServiceImpl.java @@ -134,6 +134,9 @@ public List check(List relations, Map()); + } results.add(new FGACheckResult(tuple.isAllowed(), tuple.getTuple(), info)); } return results; diff --git a/src/test/java/com/descope/sdk/mgmt/impl/FGAServiceImplTest.java b/src/test/java/com/descope/sdk/mgmt/impl/FGAServiceImplTest.java index 75db2cd0..52387c34 100644 --- a/src/test/java/com/descope/sdk/mgmt/impl/FGAServiceImplTest.java +++ b/src/test/java/com/descope/sdk/mgmt/impl/FGAServiceImplTest.java @@ -286,6 +286,7 @@ void testCheck_MissingInfoIsNotNull() throws Exception { assertNotNull(results.get(0).getInfo()); assertFalse(results.get(0).getInfo().isConditional()); + assertTrue(results.get(0).getInfo().getMissingContext().isEmpty()); } } From e821553732e67b3ecbdaf842279e06eb0b9bd8e1 Mon Sep 17 00:00:00 2001 From: Yosi Haran Date: Mon, 10 Aug 2026 13:58:43 +0300 Subject: [PATCH 5/5] docs(fga): drop "any" from the condition-evaluation docs Only the conditions on the deciding path are evaluated, so "any schema conditions" overstates it. Co-Authored-By: Claude Opus 5 (1M context) --- src/main/java/com/descope/sdk/mgmt/AuthzService.java | 4 ++-- src/main/java/com/descope/sdk/mgmt/FGAService.java | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/descope/sdk/mgmt/AuthzService.java b/src/main/java/com/descope/sdk/mgmt/AuthzService.java index ae0c0c5d..119eaf96 100644 --- a/src/main/java/com/descope/sdk/mgmt/AuthzService.java +++ b/src/main/java/com/descope/sdk/mgmt/AuthzService.java @@ -129,7 +129,7 @@ void saveRelationDefinition(RelationDefinition relationDefinition, String namesp /** * List all the users that have the given relation definition to the given resource, evaluating - * any schema conditions against the given context. + * schema conditions against the given context. * *

Context keys become variables available to the CEL conditions defined in the schema. * Values must be JSON serializable. @@ -173,7 +173,7 @@ List whoCanAccess(String resource, String relationDefinition, String nam /** * Return the list of all relations for the given target including derived relations from the - * schema tree, evaluating any schema conditions against the given context. + * schema tree, evaluating schema conditions against the given context. * *

Context keys become variables available to the CEL conditions defined in the schema. * Values must be JSON serializable. diff --git a/src/main/java/com/descope/sdk/mgmt/FGAService.java b/src/main/java/com/descope/sdk/mgmt/FGAService.java index fc546b43..bac6711c 100644 --- a/src/main/java/com/descope/sdk/mgmt/FGAService.java +++ b/src/main/java/com/descope/sdk/mgmt/FGAService.java @@ -70,7 +70,7 @@ public interface FGAService { List check(List relations) throws DescopeException; /** - * Checks if the given FGA relations are satisfied, evaluating any schema conditions against + * Checks if the given FGA relations are satisfied, evaluating schema conditions against * the given context. * *

Context keys become variables available to the CEL conditions defined in the schema,