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..e6079f15 100644 --- a/src/main/java/com/descope/client/Config.java +++ b/src/main/java/com/descope/client/Config.java @@ -46,6 +46,15 @@ public class Config { // 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(); @@ -60,6 +69,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..2d36b1be 100644 --- a/src/main/java/com/descope/model/client/Client.java +++ b/src/main/java/com/descope/model/client/Client.java @@ -23,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) { 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(