Skip to content

Commit acb7b26

Browse files
authored
feat: add support for using FGA Cache via fga_cache_url configuration (#656)
1 parent bedc2ac commit acb7b26

File tree

5 files changed

+240
-6
lines changed

5 files changed

+240
-6
lines changed

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1224,6 +1224,26 @@ relations = descope_client.mgmt.fga.check(
12241224
)
12251225
```
12261226

1227+
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.
1228+
After setting up the proxy server via the Descope provided Docker image, set the `fga_cache_url` parameter to be equal to the proxy URL to enable its use in the SDK, as shown in the example below:
1229+
1230+
```python
1231+
# Initialize client with FGA cache URL
1232+
descope_client = DescopeClient(
1233+
project_id="<Project ID>",
1234+
management_key="<Management Key>",
1235+
fga_cache_url="https://10.0.0.4", # example FGA Cache Proxy URL, running inside the same backend cluster
1236+
)
1237+
```
1238+
1239+
When the `fga_cache_url` is configured, the following FGA methods will automatically use the cache proxy instead of the default Descope API:
1240+
- `save_schema`
1241+
- `create_relations`
1242+
- `delete_relations`
1243+
- `check`
1244+
1245+
Other FGA operations like `load_schema` will continue to use the standard Descope API endpoints.
1246+
12271247
### Manage Project
12281248

12291249
You can change the project name, as well as clone the current project to

descope/auth.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ def __init__(
7474
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
7575
jwt_validation_leeway: int = 5,
7676
auth_management_key: str | None = None,
77+
fga_cache_url: str | None = None,
7778
):
7879
self.lock_public_keys = Lock()
7980
# validate project id
@@ -99,6 +100,7 @@ def __init__(
99100
self.auth_management_key = auth_management_key or os.getenv(
100101
"DESCOPE_AUTH_MANAGEMENT_KEY"
101102
)
103+
self.fga_cache_url = fga_cache_url
102104

103105
public_key = public_key or os.getenv("DESCOPE_PUBLIC_KEY")
104106
with self.lock_public_keys:
@@ -208,6 +210,31 @@ def do_delete(
208210
self._raise_from_response(response)
209211
return response
210212

213+
def do_post_with_custom_base_url(
214+
self,
215+
uri: str,
216+
body: dict | list[dict] | list[str] | None,
217+
custom_base_url: str | None = None,
218+
params=None,
219+
pswd: str | None = None,
220+
) -> requests.Response:
221+
"""
222+
Post request with optional custom base URL.
223+
If base_url is provided, use it instead of self.base_url.
224+
"""
225+
effective_base_url = custom_base_url if custom_base_url else self.base_url
226+
response = requests.post(
227+
f"{effective_base_url}{uri}",
228+
headers=self._get_default_headers(pswd),
229+
json=body,
230+
allow_redirects=False,
231+
verify=self.secure,
232+
params=params,
233+
timeout=self.timeout_seconds,
234+
)
235+
self._raise_from_response(response)
236+
return response
237+
211238
def exchange_token(
212239
self, uri, code: str, audience: str | None | Iterable[str] = None
213240
) -> dict:
@@ -637,13 +664,13 @@ def _validate_token(
637664
audience=audience,
638665
leeway=self.jwt_validation_leeway,
639666
)
640-
except (ImmatureSignatureError):
667+
except ImmatureSignatureError:
641668
raise AuthException(
642669
400,
643670
ERROR_TYPE_INVALID_TOKEN,
644671
"Received Invalid token (nbf in future) during jwt validation. Error can be due to time glitch (between machines), try to set the jwt_validation_leeway parameter (in DescopeClient) to higher value than 5sec which is the default",
645672
)
646-
except (ExpiredSignatureError):
673+
except ExpiredSignatureError:
647674
raise AuthException(
648675
401,
649676
ERROR_TYPE_INVALID_TOKEN,

descope/descope_client.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ def __init__(
3131
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
3232
jwt_validation_leeway: int = 5,
3333
auth_management_key: str | None = None,
34+
fga_cache_url: str | None = None,
3435
):
3536
auth = Auth(
3637
project_id,
@@ -40,6 +41,7 @@ def __init__(
4041
timeout_seconds,
4142
jwt_validation_leeway,
4243
auth_management_key,
44+
fga_cache_url,
4345
)
4446
self._auth = auth
4547
self._mgmt = MGMT(auth)

descope/management/fga.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,10 @@ def save_schema(self, schema: str):
4040
Raise:
4141
AuthException: raised if saving fails
4242
"""
43-
self._auth.do_post(
43+
self._auth.do_post_with_custom_base_url(
4444
MgmtV1.fga_save_schema,
4545
{"dsl": schema},
46+
custom_base_url=self._auth.fga_cache_url,
4647
pswd=self._auth.management_key,
4748
)
4849

@@ -64,11 +65,12 @@ def create_relations(
6465
Raise:
6566
AuthException: raised if create relations fails
6667
"""
67-
self._auth.do_post(
68+
self._auth.do_post_with_custom_base_url(
6869
MgmtV1.fga_create_relations,
6970
{
7071
"tuples": relations,
7172
},
73+
custom_base_url=self._auth.fga_cache_url,
7274
pswd=self._auth.management_key,
7375
)
7476

@@ -83,11 +85,12 @@ def delete_relations(
8385
Raise:
8486
AuthException: raised if delete relations fails
8587
"""
86-
self._auth.do_post(
88+
self._auth.do_post_with_custom_base_url(
8789
MgmtV1.fga_delete_relations,
8890
{
8991
"tuples": relations,
9092
},
93+
custom_base_url=self._auth.fga_cache_url,
9194
pswd=self._auth.management_key,
9295
)
9396

@@ -124,11 +127,12 @@ def check(
124127
Raise:
125128
AuthException: raised if query fails
126129
"""
127-
response = self._auth.do_post(
130+
response = self._auth.do_post_with_custom_base_url(
128131
MgmtV1.fga_check,
129132
{
130133
"tuples": relations,
131134
},
135+
custom_base_url=self._auth.fga_cache_url,
132136
pswd=self._auth.management_key,
133137
)
134138
return list(

tests/management/test_fga.py

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,3 +308,184 @@ def test_save_resources_details_error(self):
308308
client.mgmt.fga.save_resources_details,
309309
details,
310310
)
311+
312+
def test_fga_cache_url_save_schema(self):
313+
# Test FGA cache URL functionality for save_schema
314+
fga_cache_url = "https://my-fga-cache.example.com"
315+
client = DescopeClient(
316+
self.dummy_project_id,
317+
self.public_key_dict,
318+
False,
319+
self.dummy_management_key,
320+
fga_cache_url=fga_cache_url,
321+
)
322+
323+
with patch("requests.post") as mock_post:
324+
mock_post.return_value.ok = True
325+
client.mgmt.fga.save_schema("model AuthZ 1.0")
326+
mock_post.assert_called_with(
327+
f"{fga_cache_url}{MgmtV1.fga_save_schema}",
328+
headers={
329+
**common.default_headers,
330+
"Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}",
331+
"x-descope-project-id": self.dummy_project_id,
332+
},
333+
params=None,
334+
json={"dsl": "model AuthZ 1.0"},
335+
allow_redirects=False,
336+
verify=True,
337+
timeout=DEFAULT_TIMEOUT_SECONDS,
338+
)
339+
340+
def test_fga_cache_url_create_relations(self):
341+
# Test FGA cache URL functionality for create_relations
342+
fga_cache_url = "https://my-fga-cache.example.com"
343+
client = DescopeClient(
344+
self.dummy_project_id,
345+
self.public_key_dict,
346+
False,
347+
self.dummy_management_key,
348+
fga_cache_url=fga_cache_url,
349+
)
350+
351+
relations = [
352+
{
353+
"resource": "r",
354+
"resourceType": "rt",
355+
"relation": "rel",
356+
"target": "u",
357+
"targetType": "ty",
358+
}
359+
]
360+
361+
with patch("requests.post") as mock_post:
362+
mock_post.return_value.ok = True
363+
client.mgmt.fga.create_relations(relations)
364+
mock_post.assert_called_with(
365+
f"{fga_cache_url}{MgmtV1.fga_create_relations}",
366+
headers={
367+
**common.default_headers,
368+
"Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}",
369+
"x-descope-project-id": self.dummy_project_id,
370+
},
371+
params=None,
372+
json={"tuples": relations},
373+
allow_redirects=False,
374+
verify=True,
375+
timeout=DEFAULT_TIMEOUT_SECONDS,
376+
)
377+
378+
def test_fga_cache_url_delete_relations(self):
379+
# Test FGA cache URL functionality for delete_relations
380+
fga_cache_url = "https://my-fga-cache.example.com"
381+
client = DescopeClient(
382+
self.dummy_project_id,
383+
self.public_key_dict,
384+
False,
385+
self.dummy_management_key,
386+
fga_cache_url=fga_cache_url,
387+
)
388+
389+
relations = [
390+
{
391+
"resource": "r",
392+
"resourceType": "rt",
393+
"relation": "rel",
394+
"target": "u",
395+
"targetType": "ty",
396+
}
397+
]
398+
399+
with patch("requests.post") as mock_post:
400+
mock_post.return_value.ok = True
401+
client.mgmt.fga.delete_relations(relations)
402+
mock_post.assert_called_with(
403+
f"{fga_cache_url}{MgmtV1.fga_delete_relations}",
404+
headers={
405+
**common.default_headers,
406+
"Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}",
407+
"x-descope-project-id": self.dummy_project_id,
408+
},
409+
params=None,
410+
json={"tuples": relations},
411+
allow_redirects=False,
412+
verify=True,
413+
timeout=DEFAULT_TIMEOUT_SECONDS,
414+
)
415+
416+
def test_fga_cache_url_check(self):
417+
# Test FGA cache URL functionality for check
418+
fga_cache_url = "https://my-fga-cache.example.com"
419+
client = DescopeClient(
420+
self.dummy_project_id,
421+
self.public_key_dict,
422+
False,
423+
self.dummy_management_key,
424+
fga_cache_url=fga_cache_url,
425+
)
426+
427+
relations = [
428+
{
429+
"resource": "r",
430+
"resourceType": "rt",
431+
"relation": "rel",
432+
"target": "u",
433+
"targetType": "ty",
434+
}
435+
]
436+
437+
with patch("requests.post") as mock_post:
438+
mock_post.return_value.ok = True
439+
mock_post.return_value.json.return_value = {
440+
"tuples": [
441+
{
442+
"allowed": True,
443+
"tuple": relations[0],
444+
}
445+
]
446+
}
447+
result = client.mgmt.fga.check(relations)
448+
mock_post.assert_called_with(
449+
f"{fga_cache_url}{MgmtV1.fga_check}",
450+
headers={
451+
**common.default_headers,
452+
"Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}",
453+
"x-descope-project-id": self.dummy_project_id,
454+
},
455+
params=None,
456+
json={"tuples": relations},
457+
allow_redirects=False,
458+
verify=True,
459+
timeout=DEFAULT_TIMEOUT_SECONDS,
460+
)
461+
self.assertEqual(len(result), 1)
462+
self.assertTrue(result[0]["allowed"])
463+
self.assertEqual(result[0]["relation"], relations[0])
464+
465+
def test_fga_without_cache_url_uses_default_base_url(self):
466+
# Test that FGA methods use default base URL when cache URL is not provided
467+
client = DescopeClient(
468+
self.dummy_project_id,
469+
self.public_key_dict,
470+
False,
471+
self.dummy_management_key,
472+
# No fga_cache_url provided
473+
)
474+
475+
with patch("requests.post") as mock_post:
476+
mock_post.return_value.ok = True
477+
client.mgmt.fga.save_schema("model AuthZ 1.0")
478+
# Should use default base URL
479+
mock_post.assert_called_with(
480+
f"{common.DEFAULT_BASE_URL}{MgmtV1.fga_save_schema}",
481+
headers={
482+
**common.default_headers,
483+
"Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}",
484+
"x-descope-project-id": self.dummy_project_id,
485+
},
486+
params=None,
487+
json={"dsl": "model AuthZ 1.0"},
488+
allow_redirects=False,
489+
verify=True,
490+
timeout=DEFAULT_TIMEOUT_SECONDS,
491+
)

0 commit comments

Comments
 (0)