Skip to content

Commit fae0133

Browse files
authored
feat(fga): add fgacacheurl parameter for cache proxy support (#642)
1 parent e87d3be commit fae0133

File tree

6 files changed

+312
-77
lines changed

6 files changed

+312
-77
lines changed

README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1388,6 +1388,34 @@ const relations = await descopeClient.management.fga.check([
13881388
]);
13891389
```
13901390

1391+
Response times of repeated FGA `check` calls, especially in high volume scenarios, can be reduced to sub-millisecond scales by re-directing the calls to a Descope FGA Cache Proxy running in the same backend cluster as your application.
1392+
1393+
After setting up the proxy server via the Descope provided Docker image, set the `fgaCacheUrl` parameter to be equal to the proxy URL to enable its use in the SDK, as shown in the example below:
1394+
1395+
> **Note:** Both `fgaCacheUrl` and `managementKey` must be provided for the cache proxy to be used. If only `fgaCacheUrl` is configured without `managementKey`, requests will use the standard Descope API.
1396+
1397+
```typescript
1398+
import DescopeClient from '@descope/node-sdk';
1399+
1400+
// Initialize client with FGA cache URL
1401+
const descopeClient = DescopeClient({
1402+
projectId: '<Project ID>',
1403+
managementKey: '<Management Key>', // Required for cache proxy
1404+
fgaCacheUrl: 'https://10.0.0.4', // example FGA Cache Proxy URL, running inside the same backend cluster
1405+
});
1406+
```
1407+
1408+
When the `fgaCacheUrl` is configured, the following FGA methods will automatically use the cache proxy instead of the default Descope API:
1409+
1410+
- `saveSchema`
1411+
- `createRelations`
1412+
- `deleteRelations`
1413+
- `check`
1414+
1415+
If the cache proxy is unreachable or returns an error, the SDK will automatically fall back to the standard Descope API.
1416+
1417+
Other FGA operations like `loadResourcesDetails` and `saveResourcesDetails` will continue to use the standard Descope API endpoints.
1418+
13911419
### Manage Outbound Applications
13921420

13931421
You can create, update, delete or load outbound applications:

lib/index.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,16 @@ type NodeSdkArgs = Parameters<typeof createSdk>[0] & {
3737
managementKey?: string;
3838
authManagementKey?: string;
3939
publicKey?: string;
40+
fgaCacheUrl?: string;
4041
};
4142

42-
const nodeSdk = ({ authManagementKey, managementKey, publicKey, ...config }: NodeSdkArgs) => {
43+
const nodeSdk = ({
44+
authManagementKey,
45+
managementKey,
46+
publicKey,
47+
fgaCacheUrl,
48+
...config
49+
}: NodeSdkArgs) => {
4350
const nodeHeaders = {
4451
'x-descope-sdk-name': 'nodejs',
4552
'x-descope-sdk-node-version': process?.versions?.node || '',
@@ -128,7 +135,12 @@ const nodeSdk = ({ authManagementKey, managementKey, publicKey, ...config }: Nod
128135
},
129136
};
130137
const mgmtHttpClient = createHttpClient(mgmtSdkConfig);
131-
const management = withManagement(mgmtHttpClient);
138+
const management = withManagement(mgmtHttpClient, {
139+
fgaCacheUrl,
140+
managementKey,
141+
projectId,
142+
headers: nodeHeaders,
143+
});
132144

133145
const sdk = {
134146
...coreSdk,

lib/management/fga.test.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import apiPaths from './paths';
33
import { mockHttpClient, resetMockHttpClient } from './testutils';
44
import { FGAResourceIdentifier, FGAResourceDetails } from './types';
55

6+
jest.mock('../fetch-polyfill', () => jest.fn());
7+
68
const emptySuccessResponse = {
79
code: 200,
810
data: { body: 'body' },
@@ -41,6 +43,13 @@ const mockCheckResponse = {
4143
};
4244

4345
describe('Management FGA', () => {
46+
let fetchMock: jest.Mock;
47+
48+
beforeEach(() => {
49+
// eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
50+
fetchMock = require('../fetch-polyfill') as jest.Mock;
51+
});
52+
4453
afterEach(() => {
4554
jest.clearAllMocks();
4655
resetMockHttpClient();
@@ -167,4 +176,142 @@ describe('Management FGA', () => {
167176
await expect(WithFGA(mockHttpClient).saveResourcesDetails(details)).rejects.toThrow();
168177
});
169178
});
179+
180+
describe('FGA Cache URL support', () => {
181+
const fgaCacheUrl = 'https://my-fga-cache.example.com';
182+
const projectId = 'test-project-id';
183+
const managementKey = 'test-management-key';
184+
const headers = {
185+
'x-descope-sdk-name': 'nodejs',
186+
'x-descope-sdk-node-version': '18.0.0',
187+
'x-descope-sdk-version': '1.0.0',
188+
};
189+
190+
const fgaConfig = {
191+
fgaCacheUrl,
192+
managementKey,
193+
projectId,
194+
headers,
195+
};
196+
197+
it('should use cache URL for saveSchema when configured', async () => {
198+
const schema = { dsl: 'model AuthZ 1.0' };
199+
fetchMock.mockResolvedValue({
200+
ok: true,
201+
json: async () => ({}),
202+
clone: () => ({ json: async () => ({}) }),
203+
status: 200,
204+
});
205+
206+
await WithFGA(mockHttpClient, fgaConfig).saveSchema(schema);
207+
208+
expect(fetchMock).toHaveBeenCalledWith(
209+
`${fgaCacheUrl}${apiPaths.fga.schema}`,
210+
expect.objectContaining({
211+
method: 'POST',
212+
headers: expect.objectContaining({
213+
'Content-Type': 'application/json',
214+
Authorization: `Bearer ${projectId}:${managementKey}`,
215+
'x-descope-project-id': projectId,
216+
}),
217+
body: JSON.stringify(schema),
218+
}),
219+
);
220+
});
221+
222+
it('should use cache URL for createRelations when configured', async () => {
223+
const relations = [relation1];
224+
fetchMock.mockResolvedValue({
225+
ok: true,
226+
json: async () => ({}),
227+
clone: () => ({ json: async () => ({}) }),
228+
status: 200,
229+
});
230+
231+
await WithFGA(mockHttpClient, fgaConfig).createRelations(relations);
232+
233+
expect(fetchMock).toHaveBeenCalledWith(
234+
`${fgaCacheUrl}${apiPaths.fga.relations}`,
235+
expect.objectContaining({
236+
method: 'POST',
237+
headers: expect.objectContaining({
238+
'Content-Type': 'application/json',
239+
Authorization: `Bearer ${projectId}:${managementKey}`,
240+
'x-descope-project-id': projectId,
241+
}),
242+
body: JSON.stringify({ tuples: relations }),
243+
}),
244+
);
245+
});
246+
247+
it('should use cache URL for deleteRelations when configured', async () => {
248+
const relations = [relation1];
249+
fetchMock.mockResolvedValue({
250+
ok: true,
251+
json: async () => ({}),
252+
clone: () => ({ json: async () => ({}) }),
253+
status: 200,
254+
});
255+
256+
await WithFGA(mockHttpClient, fgaConfig).deleteRelations(relations);
257+
258+
expect(fetchMock).toHaveBeenCalledWith(
259+
`${fgaCacheUrl}${apiPaths.fga.deleteRelations}`,
260+
expect.objectContaining({
261+
method: 'POST',
262+
headers: expect.objectContaining({
263+
'Content-Type': 'application/json',
264+
Authorization: `Bearer ${projectId}:${managementKey}`,
265+
'x-descope-project-id': projectId,
266+
}),
267+
body: JSON.stringify({ tuples: relations }),
268+
}),
269+
);
270+
});
271+
272+
it('should use cache URL for check when configured', async () => {
273+
const relations = [relation1, relation2];
274+
fetchMock.mockResolvedValue({
275+
ok: true,
276+
json: async () => ({ tuples: mockCheckResponseRelations }),
277+
clone: () => ({ json: async () => ({ tuples: mockCheckResponseRelations }) }),
278+
status: 200,
279+
headers: new Map(),
280+
});
281+
282+
const result = await WithFGA(mockHttpClient, fgaConfig).check(relations);
283+
284+
expect(fetchMock).toHaveBeenCalledWith(
285+
`${fgaCacheUrl}${apiPaths.fga.check}`,
286+
expect.objectContaining({
287+
method: 'POST',
288+
headers: expect.objectContaining({
289+
'Content-Type': 'application/json',
290+
Authorization: `Bearer ${projectId}:${managementKey}`,
291+
'x-descope-project-id': projectId,
292+
}),
293+
body: JSON.stringify({ tuples: relations }),
294+
}),
295+
);
296+
expect(result.data).toEqual(mockCheckResponseRelations);
297+
});
298+
299+
it('should use default httpClient when cache URL is not configured', async () => {
300+
const schema = { dsl: 'model AuthZ 1.0' };
301+
await WithFGA(mockHttpClient).saveSchema(schema);
302+
303+
expect(mockHttpClient.post).toHaveBeenCalledWith(apiPaths.fga.schema, schema);
304+
expect(fetchMock).not.toHaveBeenCalled();
305+
});
306+
307+
it('should fallback to httpClient when cache URL fetch fails', async () => {
308+
const schema = { dsl: 'model AuthZ 1.0' };
309+
fetchMock.mockRejectedValue(new Error('Network error'));
310+
311+
await WithFGA(mockHttpClient, fgaConfig).saveSchema(schema);
312+
313+
expect(fetchMock).toHaveBeenCalled();
314+
expect(mockHttpClient.post).toHaveBeenCalledWith(apiPaths.fga.schema, schema);
315+
});
316+
});
170317
});

0 commit comments

Comments
 (0)