Skip to content

Commit cb224b2

Browse files
committed
Merge branch 'main' into separate-clients
# Conflicts: # descope/auth.py # descope/descope_client.py # descope/management/fga.py # descope/management/user.py
2 parents 4ea64e8 + 2142cec commit cb224b2

File tree

13 files changed

+614
-120
lines changed

13 files changed

+614
-120
lines changed

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ repos:
3434
- id: flake8
3535
additional_dependencies: [Flake8-pyproject]
3636
- repo: https://github.com/python-poetry/poetry
37-
rev: 2.1.4
37+
rev: 2.2.0
3838
hooks:
3939
- id: poetry-export
4040
files: pyproject.toml

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: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from typing import Iterable, Optional
1010

1111
import jwt
12+
import requests
1213
from email_validator import EmailNotValidError, validate_email
1314
from jwt import ExpiredSignatureError, ImmatureSignatureError
1415

@@ -66,7 +67,6 @@ def __init__(
6667
self.project_id = project_id
6768
self.jwt_validation_leeway = jwt_validation_leeway
6869

69-
# Internal HTTP client for all network traffic (must be injected)
7070
self._http = http_client
7171

7272
public_key = public_key or os.getenv("DESCOPE_PUBLIC_KEY")
@@ -447,13 +447,13 @@ def _validate_token(
447447
audience=audience,
448448
leeway=self.jwt_validation_leeway,
449449
)
450-
except (ImmatureSignatureError):
450+
except ImmatureSignatureError:
451451
raise AuthException(
452452
400,
453453
ERROR_TYPE_INVALID_TOKEN,
454454
"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",
455455
)
456-
except (ExpiredSignatureError):
456+
except ExpiredSignatureError:
457457
raise AuthException(
458458
401,
459459
ERROR_TYPE_INVALID_TOKEN,

descope/descope_client.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,10 @@ def __init__(
3030
public_key: Optional[dict] = None,
3131
skip_verify: bool = False,
3232
management_key: Optional[str] = None,
33-
auth_management_key: Optional[str] = None,
3433
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
3534
jwt_validation_leeway: int = 5,
35+
auth_management_key: Optional[str] = None,
36+
fga_cache_url: str | None = None,
3637
):
3738
# Auth Initialization
3839
auth_http_client = HTTPClient(
@@ -68,6 +69,7 @@ def __init__(
6869
)
6970
self._mgmt = MGMT(
7071
http_client=mgmt_http_client,
72+
fga_cache_url=fga_cache_url,
7173
)
7274

7375
@property

descope/http_client.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,10 @@ def post(
9595
body: Optional[Union[dict, list[dict], list[str]]] = None,
9696
params=None,
9797
pswd: Optional[str] = None,
98+
base_url: Optional[str] = None,
9899
) -> requests.Response:
99100
response = requests.post(
100-
f"{self.base_url}{uri}",
101+
f"{base_url or self.base_url}{uri}",
101102
headers=self._get_default_headers(pswd),
102103
json=body,
103104
allow_redirects=False,

descope/management/common.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ class MgmtV1:
6868
user_create_batch_path = "/v1/mgmt/user/create/batch"
6969
user_update_path = "/v1/mgmt/user/update"
7070
user_patch_path = "/v1/mgmt/user/patch"
71+
user_patch_batch_path = "/v1/mgmt/user/patch/batch"
7172
user_delete_path = "/v1/mgmt/user/delete"
7273
user_logout_path = "/v1/mgmt/user/logout"
7374
user_delete_all_test_users_path = "/v1/mgmt/user/test/delete/all"

descope/management/fga.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@
55

66

77
class FGA(HTTPBase):
8+
9+
def __init__(self, http_client, fga_cache_url: str | None = None):
10+
super().__init__(http_client)
11+
self._fga_cache_url = fga_cache_url
12+
813
def save_schema(self, schema: str):
914
"""
1015
Create or update an FGA schema.
@@ -43,6 +48,7 @@ def save_schema(self, schema: str):
4348
self._http.post(
4449
MgmtV1.fga_save_schema,
4550
body={"dsl": schema},
51+
base_url=self._fga_cache_url,
4652
)
4753

4854
def create_relations(
@@ -66,6 +72,7 @@ def create_relations(
6672
self._http.post(
6773
MgmtV1.fga_create_relations,
6874
body={"tuples": relations},
75+
base_url=self._fga_cache_url,
6976
)
7077

7178
def delete_relations(
@@ -82,6 +89,7 @@ def delete_relations(
8289
self._http.post(
8390
MgmtV1.fga_delete_relations,
8491
body={"tuples": relations},
92+
base_url=self._fga_cache_url,
8593
)
8694

8795
def check(
@@ -120,6 +128,7 @@ def check(
120128
response = self._http.post(
121129
MgmtV1.fga_check,
122130
body={"tuples": relations},
131+
base_url=self._fga_cache_url,
123132
)
124133
return list(
125134
map(

descope/management/user.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,7 @@ def patch(
417417
verified_email: Optional[bool] = None,
418418
verified_phone: Optional[bool] = None,
419419
sso_app_ids: Optional[List[str]] = None,
420+
status: Optional[str] = None,
420421
test: bool = False,
421422
) -> dict:
422423
"""
@@ -437,6 +438,7 @@ def patch(
437438
picture (str): Optional url for user picture
438439
custom_attributes (dict): Optional, set the different custom attributes values of the keys that were previously configured in Descope console app
439440
sso_app_ids (List[str]): Optional, list of SSO applications IDs to be associated with the user.
441+
status (str): Optional status field. Can be one of: "enabled", "disabled", "invited".
440442
test (bool, optional): Set to True to update a test user. Defaults to False.
441443
442444
Return value (dict):
@@ -447,6 +449,12 @@ def patch(
447449
Raise:
448450
AuthException: raised if patch operation fails
449451
"""
452+
if status is not None and status not in ["enabled", "disabled", "invited"]:
453+
raise AuthException(
454+
400,
455+
ERROR_TYPE_INVALID_ARGUMENT,
456+
f"Invalid status value: {status}. Must be one of: enabled, disabled, invited",
457+
)
450458
response = self._http.patch(
451459
MgmtV1.user_patch_path,
452460
body=User._compose_patch_body(
@@ -464,11 +472,53 @@ def patch(
464472
verified_email,
465473
verified_phone,
466474
sso_app_ids,
475+
status,
467476
test,
468477
),
469478
)
470479
return response.json()
471480

481+
def patch_batch(
482+
self,
483+
users: List[UserObj],
484+
test: bool = False,
485+
) -> dict:
486+
"""
487+
Patch users in batch. Only the provided fields will be updated for each user.
488+
489+
Args:
490+
users (List[UserObj]): A list of UserObj instances representing users to be patched.
491+
Each UserObj should have a login_id and the fields to be updated.
492+
test (bool, optional): Set to True to patch test users. Defaults to False.
493+
494+
Return value (dict):
495+
Return dict in the format
496+
{"patchedUsers": [...], "failedUsers": [...]}
497+
"patchedUsers" contains successfully patched users,
498+
"failedUsers" contains users that failed to be patched with error details.
499+
500+
Raise:
501+
AuthException: raised if patch batch operation fails
502+
"""
503+
# Validate status fields for all users
504+
for user in users:
505+
if user.status is not None and user.status not in [
506+
"enabled",
507+
"disabled",
508+
"invited",
509+
]:
510+
raise AuthException(
511+
400,
512+
ERROR_TYPE_INVALID_ARGUMENT,
513+
f"Invalid status value: {user.status} for user {user.login_id}. Must be one of: enabled, disabled, invited",
514+
)
515+
516+
response = self._http.patch(
517+
MgmtV1.user_patch_batch_path,
518+
body=User._compose_patch_batch_body(users, test),
519+
)
520+
return response.json()
521+
472522
def delete(
473523
self,
474524
login_id: str,
@@ -1924,6 +1974,7 @@ def _compose_patch_body(
19241974
verified_email: Optional[bool],
19251975
verified_phone: Optional[bool],
19261976
sso_app_ids: Optional[List[str]],
1977+
status: Optional[str],
19271978
test: bool = False,
19281979
) -> dict:
19291980
res: dict[str, Any] = {
@@ -1955,6 +2006,37 @@ def _compose_patch_body(
19552006
res["verifiedPhone"] = verified_phone
19562007
if sso_app_ids is not None:
19572008
res["ssoAppIds"] = sso_app_ids
2009+
if status is not None:
2010+
res["status"] = status
19582011
if test:
19592012
res["test"] = test
19602013
return res
2014+
2015+
@staticmethod
2016+
def _compose_patch_batch_body(
2017+
users: List[UserObj],
2018+
test: bool = False,
2019+
) -> dict:
2020+
users_body = []
2021+
for user in users:
2022+
user_body = User._compose_patch_body(
2023+
login_id=user.login_id,
2024+
email=user.email,
2025+
phone=user.phone,
2026+
display_name=user.display_name,
2027+
given_name=user.given_name,
2028+
middle_name=user.middle_name,
2029+
family_name=user.family_name,
2030+
role_names=user.role_names,
2031+
user_tenants=user.user_tenants,
2032+
picture=user.picture,
2033+
custom_attributes=user.custom_attributes,
2034+
verified_email=user.verified_email,
2035+
verified_phone=user.verified_phone,
2036+
sso_app_ids=user.sso_app_ids,
2037+
status=user.status,
2038+
test=test,
2039+
)
2040+
users_body.append(user_body)
2041+
2042+
return {"users": users_body}

descope/mgmt.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
class MGMT:
2626
_http: HTTPClient
2727

28-
def __init__(self, http_client: HTTPClient):
28+
def __init__(self, http_client: HTTPClient, fga_cache_url: str | None = None):
2929
"""Create a management API facade.
3030
3131
Args:
@@ -35,7 +35,7 @@ def __init__(self, http_client: HTTPClient):
3535
self._access_key = AccessKey(http_client)
3636
self._audit = Audit(http_client)
3737
self._authz = Authz(http_client)
38-
self._fga = FGA(http_client)
38+
self._fga = FGA(http_client, fga_cache_url=fga_cache_url)
3939
self._flow = Flow(http_client)
4040
self._group = Group(http_client)
4141
self._jwt = JWT(http_client)

0 commit comments

Comments
 (0)