From 2f2481c37f38942b655372c113f1ef286a8e2459 Mon Sep 17 00:00:00 2001 From: Yosi Haran Date: Sun, 9 Aug 2026 17:58:08 +0300 Subject: [PATCH 1/2] feat(fga): support routing FGA calls to an FGA cache instance Adds Config.fgaCacheUrl, also readable from DESCOPE_FGA_CACHE_URL, mirroring Config.FGACacheURL in the Go SDK. When set, the calls an FGA cache serves go to it instead of the Descope base URL: saveSchema, createRelations, deleteRelations and check on the FGA service, and whoCanAccess and whatCanTargetAccess on the authz service. Everything else stays on the base URL. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 16 +++++++++ src/main/java/com/descope/client/Config.java | 10 ++++++ .../com/descope/client/DescopeClient.java | 2 ++ .../com/descope/literals/AppConstants.java | 1 + .../java/com/descope/model/client/Client.java | 2 ++ .../sdk/mgmt/impl/AuthzServiceImpl.java | 4 +-- .../descope/sdk/mgmt/impl/FGAServiceImpl.java | 8 ++--- .../sdk/mgmt/impl/ManagementsBase.java | 12 +++++++ .../com/descope/utils/EnvironmentUtils.java | 5 +++ .../sdk/mgmt/impl/AuthzServiceImplTest.java | 24 +++++++++++++ .../sdk/mgmt/impl/FGAServiceImplTest.java | 36 +++++++++++++++++++ 11 files changed, 114 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index f521f895..774ba4c9 100644 --- a/README.md +++ b/README.md @@ -1596,6 +1596,22 @@ try { ``` +#### Routing FGA calls to an FGA cache + +If you run an FGA cache (authzcache) instance, set `fgaCacheUrl` on the config, or the +`DESCOPE_FGA_CACHE_URL` env var, and the calls it serves are sent there instead of the Descope base URL: +`saveSchema`, `createRelations`, `deleteRelations` and `check` on the FGA service, plus `whoCanAccess` and +`whatCanTargetAccess` on the authz service. Every other call, including `loadSchema`, `dryRunSchema` and the +resource details calls, stays on the base URL. Leave it unset to send everything to the base URL. + +```java +var descopeClient = new DescopeClient(Config.builder() + .projectId("Your-project") + .managementKey("management-key") + .fgaCacheUrl("https://your-authzcache-host") + .build()); +``` + ### Manage Outbound Applications You can fetch, delete, and manage outbound application tokens: diff --git a/src/main/java/com/descope/client/Config.java b/src/main/java/com/descope/client/Config.java index a8f4216e..f96a48a8 100644 --- a/src/main/java/com/descope/client/Config.java +++ b/src/main/java/com/descope/client/Config.java @@ -41,6 +41,9 @@ public class Config { // communicate with descope services. private String descopeBaseUrl; + // FGACacheURL (optional, "") - pass FGA calls through a cache service, if set. + private String fgaCacheUrl; + // CustomDefaultHeaders (optional, nil) - add custom headers to all requests // used to communicate // with descope services. @@ -60,6 +63,13 @@ public String initializeBaseURL() { return this.descopeBaseUrl; } + public String initializeFgaCacheUrl() { + if (StringUtils.isBlank(this.fgaCacheUrl)) { + this.fgaCacheUrl = EnvironmentUtils.getFgaCacheURL(); + } + return this.fgaCacheUrl; + } + public String initializePublicKey() { if (StringUtils.isBlank(this.publicKey)) { this.publicKey = EnvironmentUtils.getPublicKey(); diff --git a/src/main/java/com/descope/client/DescopeClient.java b/src/main/java/com/descope/client/DescopeClient.java index d47c5dbc..a9cf80c0 100644 --- a/src/main/java/com/descope/client/DescopeClient.java +++ b/src/main/java/com/descope/client/DescopeClient.java @@ -50,6 +50,7 @@ public DescopeClient(Config config) throws DescopeException { } config.initializeManagementKey(); config.initializeBaseURL(); + config.initializeFgaCacheUrl(); Client client = getClient(config); this.authenticationServices = AuthenticationServiceBuilder.buildServices(client); @@ -67,6 +68,7 @@ private static Client getClient(Config config) { final String baseUrl = DEFAULT_BASE_URL.replace(REGION_PLACEHOLDER, region.length() > 3 ? region + "." : ""); Client c = Client.builder() .uri(StringUtils.isBlank(config.getDescopeBaseUrl()) ? baseUrl : config.getDescopeBaseUrl()) + .fgaCacheUri(config.getFgaCacheUrl()) .projectId(projectId) .managementKey(config.getManagementKey()) .headers( diff --git a/src/main/java/com/descope/literals/AppConstants.java b/src/main/java/com/descope/literals/AppConstants.java index 5909c6ae..b795e6da 100644 --- a/src/main/java/com/descope/literals/AppConstants.java +++ b/src/main/java/com/descope/literals/AppConstants.java @@ -8,6 +8,7 @@ public class AppConstants { public static final String PUBLIC_KEY_ENV_VAR = "DESCOPE_PUBLIC_KEY"; public static final String MANAGEMENT_KEY_ENV_VAR = "DESCOPE_MANAGEMENT_KEY"; public static final String BASE_URL_ENV_VAR = "DESCOPE_BASE_URL"; + public static final String FGA_CACHE_URL_ENV_VAR = "DESCOPE_FGA_CACHE_URL"; public static final String AUTHORIZATION_HEADER_NAME = "Authorization"; public static final String BEARER_AUTHORIZATION_PREFIX = "Bearer "; public static final String COOKIE = "Cookie"; diff --git a/src/main/java/com/descope/model/client/Client.java b/src/main/java/com/descope/model/client/Client.java index a8723d57..7e1e1af4 100644 --- a/src/main/java/com/descope/model/client/Client.java +++ b/src/main/java/com/descope/model/client/Client.java @@ -16,6 +16,8 @@ @AllArgsConstructor public class Client { private String uri; + // When set, FGA calls that the FGA cache serves go here instead of uri. + private String fgaCacheUri; private String projectId; private String managementKey; private Map headers; 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 9c83b8e6..cc34d2ab 100644 --- a/src/main/java/com/descope/sdk/mgmt/impl/AuthzServiceImpl.java +++ b/src/main/java/com/descope/sdk/mgmt/impl/AuthzServiceImpl.java @@ -211,7 +211,7 @@ public List whoCanAccess(String resource, String relationDefinition, Str if (context != null && !context.isEmpty()) { request.put("context", context); } - WhoCanAccessResponse resp = apiProxy.post(getUri(MANAGEMENT_AUTHZ_RE_WHO), request, WhoCanAccessResponse.class); + WhoCanAccessResponse resp = apiProxy.post(getFgaUri(MANAGEMENT_AUTHZ_RE_WHO), request, WhoCanAccessResponse.class); return resp.getTargets(); } @@ -252,7 +252,7 @@ public List whatCanTargetAccess(String target, Map con if (context != null && !context.isEmpty()) { request.put("context", context); } - RelationsResponse resp = apiProxy.post(getUri(MANAGEMENT_AUTHZ_RE_TARGET_ALL), request, RelationsResponse.class); + RelationsResponse resp = apiProxy.post(getFgaUri(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 2b15352e..b9fc45d9 100644 --- a/src/main/java/com/descope/sdk/mgmt/impl/FGAServiceImpl.java +++ b/src/main/java/com/descope/sdk/mgmt/impl/FGAServiceImpl.java @@ -47,7 +47,7 @@ public void saveSchema(FGASchema schema) throws DescopeException { requestBody.put("dsl", schema.getDsl()); ApiProxy apiProxy = getApiProxy(); - apiProxy.post(getUri(MANAGEMENT_FGA_SAVE_SCHEMA), requestBody, Void.class); + apiProxy.post(getFgaUri(MANAGEMENT_FGA_SAVE_SCHEMA), requestBody, Void.class); } @Override @@ -91,7 +91,7 @@ public void createRelations(List relations) throws DescopeException requestBody.put("tuples", relations); ApiProxy apiProxy = getApiProxy(); - apiProxy.post(getUri(MANAGEMENT_FGA_CREATE_RELATIONS), requestBody, Void.class); + apiProxy.post(getFgaUri(MANAGEMENT_FGA_CREATE_RELATIONS), requestBody, Void.class); } @Override @@ -104,7 +104,7 @@ public void deleteRelations(List relations) throws DescopeException requestBody.put("tuples", relations); ApiProxy apiProxy = getApiProxy(); - apiProxy.post(getUri(MANAGEMENT_FGA_DELETE_RELATIONS), requestBody, Void.class); + apiProxy.post(getFgaUri(MANAGEMENT_FGA_DELETE_RELATIONS), requestBody, Void.class); } @Override @@ -126,7 +126,7 @@ public List check(List relations, Map results = new ArrayList<>(); if (response == null || response.getTuples() == null) { diff --git a/src/main/java/com/descope/sdk/mgmt/impl/ManagementsBase.java b/src/main/java/com/descope/sdk/mgmt/impl/ManagementsBase.java index 92db8f70..94f55011 100644 --- a/src/main/java/com/descope/sdk/mgmt/impl/ManagementsBase.java +++ b/src/main/java/com/descope/sdk/mgmt/impl/ManagementsBase.java @@ -6,6 +6,8 @@ import com.descope.proxy.impl.ApiProxyBuilder; import com.descope.sdk.SdkServicesBase; import com.descope.sdk.mgmt.ManagementService; +import com.descope.utils.UriUtils; +import java.net.URI; import org.apache.commons.lang3.StringUtils; abstract class ManagementsBase extends SdkServicesBase implements ManagementService { @@ -41,4 +43,14 @@ ApiProxy getApiProxyWithBearer(String bearerJwt) { String token = String.format("Bearer %s", bearerJwt); return ApiProxyBuilder.buildProxy(() -> token, client); } + + // FGA calls that the FGA cache serves go to it when one is configured, everything else + // stays on the Descope base URL. + URI getFgaUri(String path) { + String fgaCacheUri = client.getFgaCacheUri(); + if (StringUtils.isBlank(fgaCacheUri)) { + return getUri(path); + } + return UriUtils.getUri(StringUtils.removeEnd(fgaCacheUri, "/"), path); + } } diff --git a/src/main/java/com/descope/utils/EnvironmentUtils.java b/src/main/java/com/descope/utils/EnvironmentUtils.java index 3e6448a6..a9e42b94 100644 --- a/src/main/java/com/descope/utils/EnvironmentUtils.java +++ b/src/main/java/com/descope/utils/EnvironmentUtils.java @@ -1,6 +1,7 @@ package com.descope.utils; import static com.descope.literals.AppConstants.BASE_URL_ENV_VAR; +import static com.descope.literals.AppConstants.FGA_CACHE_URL_ENV_VAR; import static com.descope.literals.AppConstants.MANAGEMENT_KEY_ENV_VAR; import static com.descope.literals.AppConstants.PROJECT_ID_ENV_VAR; import static com.descope.literals.AppConstants.PUBLIC_KEY_ENV_VAR; @@ -20,6 +21,10 @@ public static String getBaseURL() { return dotenv.get(BASE_URL_ENV_VAR); } + public static String getFgaCacheURL() { + return dotenv.get(FGA_CACHE_URL_ENV_VAR); + } + public static String getPublicKey() { return dotenv.get(PUBLIC_KEY_ENV_VAR); } 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 a97a5f72..ec6d346f 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.times; import static org.mockito.Mockito.verify; import com.descope.exception.RateLimitExceededException; @@ -34,6 +35,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import java.io.File; +import java.net.URI; import java.time.Instant; import java.time.Period; import java.util.Arrays; @@ -441,6 +443,28 @@ void testWhatCanTargetAccessForSuccess() { } } + @Test + void testFgaCacheRouting() { + Client client = Client.builder().uri("https://api.descope.com").fgaCacheUri("https://cache.example.com/") + .projectId("someProjectId").managementKey("someManagementKey").build(); + AuthzService cachedAuthzService = ManagementServiceBuilder.buildServices(client).getAuthzService(); + ApiProxy apiProxy = mock(ApiProxy.class); + doReturn(new RelationsResponse(Arrays.asList(new Relation()))).when(apiProxy).post(any(), any(), any()); + try (MockedStatic mockedApiProxyBuilder = mockStatic(ApiProxyBuilder.class)) { + mockedApiProxyBuilder.when( + () -> ApiProxyBuilder.buildProxy(any(), any())).thenReturn(apiProxy); + cachedAuthzService.whatCanTargetAccess("kiki"); + cachedAuthzService.resourceRelations("kuku"); + + ArgumentCaptor uriCaptor = ArgumentCaptor.forClass(URI.class); + verify(apiProxy, times(2)).post(uriCaptor.capture(), any(), any()); + assertEquals("https://cache.example.com/v1/mgmt/authz/re/targetall", + uriCaptor.getAllValues().get(0).toString()); + assertEquals("https://api.descope.com/v1/mgmt/authz/re/resource", + uriCaptor.getAllValues().get(1).toString()); + } + } + @SuppressWarnings("unchecked") @Test void testWhatCanTargetAccessWithContext() { 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 52387c34..2627aa6d 100644 --- a/src/test/java/com/descope/sdk/mgmt/impl/FGAServiceImplTest.java +++ b/src/test/java/com/descope/sdk/mgmt/impl/FGAServiceImplTest.java @@ -8,6 +8,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -35,6 +36,7 @@ import com.descope.sdk.mgmt.FGAService; import com.fasterxml.jackson.core.type.TypeReference; import java.io.IOException; +import java.net.URI; import java.nio.file.Files; import java.nio.file.Paths; import java.util.Arrays; @@ -319,6 +321,40 @@ void testLoadResourcesDetails_Success() throws Exception { } } + @Test + void testFgaCacheRouting() throws Exception { + when(client.getUri()).thenReturn("https://api.descope.com"); + when(client.getFgaCacheUri()).thenReturn("https://cache.example.com/"); + + try (MockedStatic mockedStatic = Mockito.mockStatic(ApiProxyBuilder.class)) { + mockedStatic.when(() -> ApiProxyBuilder.buildProxy(any(), any())).thenReturn(apiProxy); + + fgaService.check(Arrays.asList(new FGARelation("doc1", "doc", "viewer", "user1", "user"))); + fgaService.dryRunSchema(new FGASchema("model AuthZ 1.0\ntype user")); + + ArgumentCaptor uriCaptor = ArgumentCaptor.forClass(URI.class); + verify(apiProxy, times(2)).post(uriCaptor.capture(), any(), any()); + assertEquals("https://cache.example.com/v1/mgmt/fga/check", uriCaptor.getAllValues().get(0).toString()); + assertEquals("https://api.descope.com/v1/mgmt/fga/schema/dryrun", + uriCaptor.getAllValues().get(1).toString()); + } + } + + @Test + void testFgaCacheRoutingUsesBaseUrlWhenNotConfigured() throws Exception { + when(client.getUri()).thenReturn("https://api.descope.com"); + + try (MockedStatic mockedStatic = Mockito.mockStatic(ApiProxyBuilder.class)) { + mockedStatic.when(() -> ApiProxyBuilder.buildProxy(any(), any())).thenReturn(apiProxy); + + fgaService.check(Arrays.asList(new FGARelation("doc1", "doc", "viewer", "user1", "user"))); + + ArgumentCaptor uriCaptor = ArgumentCaptor.forClass(URI.class); + verify(apiProxy).post(uriCaptor.capture(), any(), any()); + assertEquals("https://api.descope.com/v1/mgmt/fga/check", uriCaptor.getValue().toString()); + } + } + @Test void testSaveResourcesDetails_Success() throws Exception { List details = Arrays.asList( From b0c3162e651a44a45637e2e941023e134731625b Mon Sep 17 00:00:00 2001 From: Yosi Haran Date: Sun, 9 Aug 2026 19:03:03 +0300 Subject: [PATCH 2/2] fix(fga): keep the previous all-args constructors on Config and Client Both are published API, so adding a field to the Lombok @AllArgsConstructor would break callers that construct them positionally. Put the new field last and keep a constructor with the previous parameter list. Co-Authored-By: Claude Opus 5 (1M context) --- src/main/java/com/descope/client/Config.java | 12 +++++++++--- src/main/java/com/descope/model/client/Client.java | 10 ++++++++-- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/descope/client/Config.java b/src/main/java/com/descope/client/Config.java index f96a48a8..e6079f15 100644 --- a/src/main/java/com/descope/client/Config.java +++ b/src/main/java/com/descope/client/Config.java @@ -41,14 +41,20 @@ public class Config { // communicate with descope services. private String descopeBaseUrl; - // FGACacheURL (optional, "") - pass FGA calls through a cache service, if set. - private String fgaCacheUrl; - // CustomDefaultHeaders (optional, nil) - add custom headers to all requests // used to communicate // with descope services. private Map customDefaultHeaders; + // FGACacheURL (optional, "") - pass FGA calls through a cache service, if set. + private String fgaCacheUrl; + + // Keeps the pre-fgaCacheUrl all-args constructor available to callers that use it positionally. + public Config(String projectId, String managementKey, String publicKey, String descopeBaseUrl, + Map customDefaultHeaders) { + this(projectId, managementKey, publicKey, descopeBaseUrl, customDefaultHeaders, null); + } + public String initializeProjectId() { if (StringUtils.isBlank(this.projectId)) { this.projectId = EnvironmentUtils.getProjectId(); diff --git a/src/main/java/com/descope/model/client/Client.java b/src/main/java/com/descope/model/client/Client.java index 7e1e1af4..2d36b1be 100644 --- a/src/main/java/com/descope/model/client/Client.java +++ b/src/main/java/com/descope/model/client/Client.java @@ -16,8 +16,6 @@ @AllArgsConstructor public class Client { private String uri; - // When set, FGA calls that the FGA cache serves go here instead of uri. - private String fgaCacheUri; private String projectId; private String managementKey; private Map headers; @@ -25,6 +23,14 @@ public class Client { private Key providedKey; @Builder.Default private AtomicReference> keys = new AtomicReference<>(new HashMap<>()); + // When set, FGA calls that the FGA cache serves go here instead of uri. + private String fgaCacheUri; + + // Keeps the pre-fgaCacheUri all-args constructor available to callers that use it positionally. + public Client(String uri, String projectId, String managementKey, Map headers, + SdkInfo sdkInfo, Key providedKey, AtomicReference> keys) { + this(uri, projectId, managementKey, headers, sdkInfo, providedKey, keys, null); + } public Key getKey(String keyId) { if (providedKey != null) {