Skip to content

Commit 7305671

Browse files
ci-stytchStytch Codegen Botetaylormcgregor-stytch
authored
Add custom org roles endpoints (#301)
* Add custom org roles endpoints * use org policy for authorization checks * ignore type check * reformat --------- Co-authored-by: Stytch Codegen Bot <[email protected]> Co-authored-by: Evelyn Taylor-McGregor <[email protected]>
1 parent 4015468 commit 7305671

File tree

12 files changed

+481
-10
lines changed

12 files changed

+481
-10
lines changed

stytch/b2b/api/rbac.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from typing import Any, Dict
1010

11+
from stytch.b2b.api.rbac_organizations import Organizations
1112
from stytch.b2b.models.rbac import PolicyResponse
1213
from stytch.core.api_base import ApiBase
1314
from stytch.core.http.client import AsyncClient, SyncClient
@@ -20,6 +21,11 @@ def __init__(
2021
self.api_base = api_base
2122
self.sync_client = sync_client
2223
self.async_client = async_client
24+
self.organizations = Organizations(
25+
api_base=self.api_base,
26+
sync_client=self.sync_client,
27+
async_client=self.async_client,
28+
)
2329

2430
def policy(
2531
self,
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# !!!
2+
# WARNING: This file is autogenerated
3+
# Only modify code within MANUAL() sections
4+
# or your changes may be overwritten later!
5+
# !!!
6+
7+
from __future__ import annotations
8+
9+
from typing import Any, Dict, Optional, Union
10+
11+
from stytch.b2b.models.rbac import OrgPolicy
12+
from stytch.b2b.models.rbac_organizations import (
13+
GetOrgPolicyResponse,
14+
SetOrgPolicyResponse,
15+
)
16+
from stytch.core.api_base import ApiBase
17+
from stytch.core.http.client import AsyncClient, SyncClient
18+
19+
20+
class Organizations:
21+
def __init__(
22+
self, api_base: ApiBase, sync_client: SyncClient, async_client: AsyncClient
23+
) -> None:
24+
self.api_base = api_base
25+
self.sync_client = sync_client
26+
self.async_client = async_client
27+
28+
def get_org_policy(
29+
self,
30+
organization_id: str,
31+
) -> GetOrgPolicyResponse:
32+
"""Get the active RBAC Policy for a specific Organization within your Stytch Project. An Organization RBAC Policy contains the roles that have been defined specifically for that organization, allowing for organization-specific permissioning models.
33+
34+
This endpoint returns the organization-scoped roles that supplement the project-level RBAC policy. Organization policies allow you to define custom roles that are specific to individual organizations within your project.
35+
36+
When using the backend SDKs, the RBAC Policy will be cached to allow for local evaluations, eliminating the need for an extra request to Stytch. The policy will be refreshed if an authorization check is requested and the RBAC policy was last updated more than 5 minutes ago.
37+
38+
Organization-specific roles can be created and managed through this API endpoint, providing fine-grained control over permissions at the organization level.
39+
40+
Check out the [RBAC overview](https://stytch.com/docs/b2b/guides/rbac/overview) to learn more about Stytch's RBAC permissioning model and organization-scoped policies.
41+
42+
Fields:
43+
- organization_id: Globally unique UUID that identifies a specific Organization. The `organization_id` is critical to perform operations on an Organization, so be sure to preserve this value. You may also use the organization_slug or organization_external_id here as a convenience.
44+
""" # noqa
45+
headers: Dict[str, str] = {}
46+
data: Dict[str, Any] = {
47+
"organization_id": organization_id,
48+
}
49+
50+
url = self.api_base.url_for(
51+
"/v1/b2b/rbac/organizations/{organization_id}", data
52+
)
53+
res = self.sync_client.get(url, data, headers)
54+
return GetOrgPolicyResponse.from_json(res.response.status_code, res.json)
55+
56+
async def get_org_policy_async(
57+
self,
58+
organization_id: str,
59+
) -> GetOrgPolicyResponse:
60+
"""Get the active RBAC Policy for a specific Organization within your Stytch Project. An Organization RBAC Policy contains the roles that have been defined specifically for that organization, allowing for organization-specific permissioning models.
61+
62+
This endpoint returns the organization-scoped roles that supplement the project-level RBAC policy. Organization policies allow you to define custom roles that are specific to individual organizations within your project.
63+
64+
When using the backend SDKs, the RBAC Policy will be cached to allow for local evaluations, eliminating the need for an extra request to Stytch. The policy will be refreshed if an authorization check is requested and the RBAC policy was last updated more than 5 minutes ago.
65+
66+
Organization-specific roles can be created and managed through this API endpoint, providing fine-grained control over permissions at the organization level.
67+
68+
Check out the [RBAC overview](https://stytch.com/docs/b2b/guides/rbac/overview) to learn more about Stytch's RBAC permissioning model and organization-scoped policies.
69+
70+
Fields:
71+
- organization_id: Globally unique UUID that identifies a specific Organization. The `organization_id` is critical to perform operations on an Organization, so be sure to preserve this value. You may also use the organization_slug or organization_external_id here as a convenience.
72+
""" # noqa
73+
headers: Dict[str, str] = {}
74+
data: Dict[str, Any] = {
75+
"organization_id": organization_id,
76+
}
77+
78+
url = self.api_base.url_for(
79+
"/v1/b2b/rbac/organizations/{organization_id}", data
80+
)
81+
res = await self.async_client.get(url, data, headers)
82+
return GetOrgPolicyResponse.from_json(res.response.status, res.json)
83+
84+
def set_org_policy(
85+
self,
86+
organization_id: str,
87+
org_policy: Optional[Union[OrgPolicy, Dict[str, Any]]] = None,
88+
) -> SetOrgPolicyResponse:
89+
headers: Dict[str, str] = {}
90+
data: Dict[str, Any] = {
91+
"organization_id": organization_id,
92+
}
93+
if org_policy is not None:
94+
data["org_policy"] = (
95+
org_policy if isinstance(org_policy, dict) else org_policy.dict()
96+
)
97+
98+
url = self.api_base.url_for(
99+
"/v1/b2b/rbac/organizations/{organization_id}", data
100+
)
101+
res = self.sync_client.put(url, data, headers)
102+
return SetOrgPolicyResponse.from_json(res.response.status_code, res.json)
103+
104+
async def set_org_policy_async(
105+
self,
106+
organization_id: str,
107+
org_policy: Optional[OrgPolicy] = None,
108+
) -> SetOrgPolicyResponse:
109+
headers: Dict[str, str] = {}
110+
data: Dict[str, Any] = {
111+
"organization_id": organization_id,
112+
}
113+
if org_policy is not None:
114+
data["org_policy"] = (
115+
org_policy if isinstance(org_policy, dict) else org_policy.dict()
116+
)
117+
118+
url = self.api_base.url_for(
119+
"/v1/b2b/rbac/organizations/{organization_id}", data
120+
)
121+
res = await self.async_client.put(url, data, headers)
122+
return SetOrgPolicyResponse.from_json(res.response.status, res.json)

stytch/b2b/api/sessions.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -990,7 +990,9 @@ def authenticate_jwt_local(
990990
raise ValueError("Invalid roles claim. Expected a list of strings.")
991991

992992
rbac_local.perform_authorization_check(
993-
policy=self.policy_cache.get(),
993+
policy=self.policy_cache.get_with_org(
994+
local_resp.member_session.organization_id
995+
),
994996
subject_roles=local_resp.roles_claim,
995997
subject_org_id=local_resp.member_session.organization_id,
996998
authorization_check=authorization_check,
@@ -1019,7 +1021,9 @@ async def authenticate_jwt_local_async(
10191021
raise ValueError("Invalid roles claim. Expected a list of strings.")
10201022

10211023
rbac_local.perform_authorization_check(
1022-
policy=await self.policy_cache.get_async(),
1024+
policy=await self.policy_cache.get_with_org_async(
1025+
local_resp.member_session.organization_id
1026+
),
10231027
subject_roles=local_resp.roles_claim,
10241028
subject_org_id=local_resp.member_session.organization_id,
10251029
authorization_check=authorization_check,

stytch/b2b/models/organizations.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,17 @@ def add_headers(self, headers: Dict[str, str]) -> Dict[str, str]:
9090
return headers
9191

9292

93+
class CustomRolePermission(pydantic.BaseModel):
94+
resource_id: str
95+
actions: List[str]
96+
97+
98+
class CustomRole(pydantic.BaseModel):
99+
role_id: str
100+
description: str
101+
permissions: List[CustomRolePermission]
102+
103+
93104
class DeleteRequestOptions(pydantic.BaseModel):
94105
"""
95106
Fields:
@@ -400,6 +411,7 @@ class Organization(pydantic.BaseModel):
400411
`NOT_ALLOWED` – no third party Connected Apps are permitted.
401412
402413
- allowed_third_party_connected_apps: An array of third party Connected App IDs that are allowed for the Organization. Only used when the Organization's `third_party_connected_apps_allowed_type` is `RESTRICTED`.
414+
- custom_roles: (no documentation yet)
403415
- trusted_metadata: An arbitrary JSON object for storing application-specific data or identity-provider-specific data.
404416
- created_at: The timestamp of the Organization's creation. Values conform to the RFC 3339 standard and are expressed in UTC, e.g. `2021-12-29T12:33:09Z`.
405417
- updated_at: The timestamp of when the Organization was last updated. Values conform to the RFC 3339 standard and are expressed in UTC, e.g. `2021-12-29T12:33:09Z`.
@@ -431,6 +443,7 @@ class Organization(pydantic.BaseModel):
431443
allowed_first_party_connected_apps: List[str]
432444
third_party_connected_apps_allowed_type: str
433445
allowed_third_party_connected_apps: List[str]
446+
custom_roles: List[CustomRole]
434447
trusted_metadata: Optional[Dict[str, Any]] = None
435448
created_at: Optional[datetime.datetime] = None
436449
updated_at: Optional[datetime.datetime] = None

stytch/b2b/models/rbac.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,15 @@ class PolicyRole(pydantic.BaseModel):
122122
permissions: List[PolicyRolePermission]
123123

124124

125+
class OrgPolicy(pydantic.BaseModel):
126+
"""
127+
Fields:
128+
- roles: An array of [Role objects](https://stytch.com/docs/b2b/api/rbac-role-object).
129+
""" # noqa
130+
131+
roles: List[PolicyRole]
132+
133+
125134
class PolicyScopePermission(pydantic.BaseModel):
126135
resource_id: str
127136
actions: List[str]
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# !!!
2+
# WARNING: This file is autogenerated
3+
# Only modify code within MANUAL() sections
4+
# or your changes may be overwritten later!
5+
# !!!
6+
7+
from __future__ import annotations
8+
9+
from typing import Optional
10+
11+
from stytch.b2b.models.rbac import OrgPolicy
12+
from stytch.core.response_base import ResponseBase
13+
14+
15+
class GetOrgPolicyResponse(ResponseBase):
16+
"""Response type for `Organizations.get_org_policy`.
17+
Fields:
18+
- org_policy: The organization-specific RBAC Policy that contains roles defined for this organization. Organization policies supplement the project-level RBAC policy with additional roles that are specific to the organization.
19+
""" # noqa
20+
21+
org_policy: Optional[OrgPolicy] = None
22+
23+
24+
class SetOrgPolicyResponse(ResponseBase):
25+
org_policy: Optional[OrgPolicy] = None

stytch/consumer/api/otp.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
from stytch.consumer.api.otp_email import Email
1212
from stytch.consumer.api.otp_sms import Sms
13-
from stytch.consumer.api.otp_whatsapp import Whatsapp
13+
from stytch.consumer.api.otp_whatsapp import WhatsApp
1414
from stytch.consumer.models.attribute import Attributes
1515
from stytch.consumer.models.magic_links import Options
1616
from stytch.consumer.models.otp import AuthenticateResponse
@@ -30,7 +30,7 @@ def __init__(
3030
sync_client=self.sync_client,
3131
async_client=self.async_client,
3232
)
33-
self.whatsapp = Whatsapp(
33+
self.whatsapp = WhatsApp(
3434
api_base=self.api_base,
3535
sync_client=self.sync_client,
3636
async_client=self.async_client,

stytch/consumer/api/otp_whatsapp.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from stytch.core.http.client import AsyncClient, SyncClient
2020

2121

22-
class Whatsapp:
22+
class WhatsApp:
2323
def __init__(
2424
self, api_base: ApiBase, sync_client: SyncClient, async_client: AsyncClient
2525
) -> None:

stytch/consumer/models/otp_whatsapp.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ class SendRequestLocale(str, enum.Enum):
3434

3535

3636
class LoginOrCreateResponse(ResponseBase):
37-
"""Response type for `Whatsapp.login_or_create`.
37+
"""Response type for `WhatsApp.login_or_create`.
3838
Fields:
3939
- user_id: The unique ID of the affected User.
4040
- phone_id: The unique ID for the phone number.
@@ -47,7 +47,7 @@ class LoginOrCreateResponse(ResponseBase):
4747

4848

4949
class SendResponse(ResponseBase):
50-
"""Response type for `Whatsapp.send`.
50+
"""Response type for `WhatsApp.send`.
5151
Fields:
5252
- user_id: The unique ID of the affected User.
5353
- phone_id: The unique ID for the phone number.

stytch/shared/policy_cache.py

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,45 @@
1-
from typing import Optional
1+
import time
2+
from typing import Dict, Optional
23

34
from stytch.b2b.api.rbac import RBAC
4-
from stytch.b2b.models.rbac import Policy
5+
from stytch.b2b.models.rbac import OrgPolicy, Policy
56
from stytch.shared.lazy_cache import LazyCache
67

78
DEFAULT_REFRESH_INTERVAL = 10 * 60 # 10 minutes
89

910

11+
def _merge_policies(project_policy: Policy, org_policy: Optional[OrgPolicy]) -> Policy:
12+
"""Merge project-level and organization-level policies.
13+
14+
Organization policies supplement the project policy by adding additional roles.
15+
The merged policy combines roles from both, with org roles added to project roles.
16+
Resources and scopes come only from the project policy.
17+
"""
18+
if org_policy is None:
19+
return project_policy
20+
21+
return Policy(
22+
roles=list(project_policy.roles) + list(org_policy.roles),
23+
resources=project_policy.resources,
24+
scopes=project_policy.scopes,
25+
)
26+
27+
28+
class _OrgPolicyCacheEntry:
29+
def __init__(self, org_policy: Optional[OrgPolicy], last_refresh_time: float):
30+
self.org_policy = org_policy
31+
self.last_refresh_time = last_refresh_time
32+
33+
1034
class PolicyCache(LazyCache[Policy]):
1135
def __init__(
1236
self,
1337
rbac: RBAC,
1438
refresh_interval_seconds: int = DEFAULT_REFRESH_INTERVAL,
1539
) -> None:
1640
self.rbac = rbac
41+
self.refresh_interval_seconds = refresh_interval_seconds
42+
self._org_cache: Dict[str, _OrgPolicyCacheEntry] = {}
1743
super().__init__(
1844
reload_func=self.reload_policy,
1945
async_reload_func=self.reload_policy_async,
@@ -29,3 +55,49 @@ async def reload_policy_async(self, _: Optional[Policy]) -> Policy:
2955
resp = await self.rbac.policy_async()
3056
assert resp.policy is not None
3157
return resp.policy
58+
59+
def _org_needs_refresh(self, organization_id: str) -> bool:
60+
if organization_id not in self._org_cache:
61+
return True
62+
entry = self._org_cache[organization_id]
63+
return time.time() - entry.last_refresh_time > self.refresh_interval_seconds
64+
65+
def _get_org_policy(self, organization_id: str) -> Optional[OrgPolicy]:
66+
"""Get the organization policy, fetching and caching if needed."""
67+
if self._org_needs_refresh(organization_id):
68+
resp = self.rbac.organizations.get_org_policy(organization_id)
69+
self._org_cache[organization_id] = _OrgPolicyCacheEntry(
70+
org_policy=resp.org_policy,
71+
last_refresh_time=time.time(),
72+
)
73+
return self._org_cache[organization_id].org_policy
74+
75+
async def _get_org_policy_async(self, organization_id: str) -> Optional[OrgPolicy]:
76+
"""Get the organization policy, fetching and caching if needed (async)."""
77+
if self._org_needs_refresh(organization_id):
78+
resp = await self.rbac.organizations.get_org_policy_async(organization_id)
79+
self._org_cache[organization_id] = _OrgPolicyCacheEntry(
80+
org_policy=resp.org_policy,
81+
last_refresh_time=time.time(),
82+
)
83+
return self._org_cache[organization_id].org_policy
84+
85+
def get_with_org(self, organization_id: str) -> Policy:
86+
"""Get the project policy merged with the organization policy.
87+
88+
This fetches both the project-level RBAC policy and the organization-specific
89+
policy, merging the org roles into the project policy for authorization checks.
90+
"""
91+
project_policy = self.get()
92+
org_policy = self._get_org_policy(organization_id)
93+
return _merge_policies(project_policy, org_policy)
94+
95+
async def get_with_org_async(self, organization_id: str) -> Policy:
96+
"""Get the project policy merged with the organization policy (async).
97+
98+
This fetches both the project-level RBAC policy and the organization-specific
99+
policy, merging the org roles into the project policy for authorization checks.
100+
"""
101+
project_policy = await self.get_async()
102+
org_policy = await self._get_org_policy_async(organization_id)
103+
return _merge_policies(project_policy, org_policy)

0 commit comments

Comments
 (0)