From f563456ffc73656769b9fca85bfce8ad75476bc0 Mon Sep 17 00:00:00 2001 From: Stytch Codegen Bot Date: Fri, 16 Jan 2026 21:57:31 +0000 Subject: [PATCH 1/3] WebAuthn URL encoding + RBAC org policy --- stytch/b2b/api/organizations.py | 38 +++++++++++++ stytch/b2b/api/organizations_members.py | 44 +++++++++++++++ stytch/b2b/api/rbac_organizations.py | 64 ++++++++++++++++++---- stytch/b2b/models/organizations.py | 20 +++++++ stytch/b2b/models/organizations_members.py | 22 ++++++++ stytch/b2b/models/rbac_organizations.py | 11 ++-- stytch/consumer/api/users.py | 27 +++++++++ stytch/consumer/api/webauthn.py | 8 +++ stytch/consumer/models/connected_apps.py | 2 + stytch/consumer/models/users.py | 5 ++ stytch/shared/tests/test_policy_cache.py | 2 +- stytch/version.py | 2 +- 12 files changed, 228 insertions(+), 17 deletions(-) diff --git a/stytch/b2b/api/organizations.py b/stytch/b2b/api/organizations.py index fd683158..9fb452cc 100644 --- a/stytch/b2b/api/organizations.py +++ b/stytch/b2b/api/organizations.py @@ -15,6 +15,8 @@ CreateRequestFirstPartyConnectedAppsAllowedType, CreateRequestThirdPartyConnectedAppsAllowedType, CreateResponse, + DeleteExternalIdRequestOptions, + DeleteExternalIdResponse, DeleteRequestOptions, DeleteResponse, EmailImplicitRoleAssignment, @@ -1171,3 +1173,39 @@ async def get_connected_app_async( ) res = await self.async_client.get(url, data, headers) return GetConnectedAppResponse.from_json(res.response.status, res.json) + + def delete_external_id( + self, + organization_id: str, + method_options: Optional[DeleteExternalIdRequestOptions] = None, + ) -> DeleteExternalIdResponse: + headers: Dict[str, str] = {} + if method_options is not None: + headers = method_options.add_headers(headers) + data: Dict[str, Any] = { + "organization_id": organization_id, + } + + url = self.api_base.url_for( + "/v1/b2b/organizations/{organization_id}/external_id", data + ) + res = self.sync_client.delete(url, headers) + return DeleteExternalIdResponse.from_json(res.response.status_code, res.json) + + async def delete_external_id_async( + self, + organization_id: str, + method_options: Optional[DeleteExternalIdRequestOptions] = None, + ) -> DeleteExternalIdResponse: + headers: Dict[str, str] = {} + if method_options is not None: + headers = method_options.add_headers(headers) + data: Dict[str, Any] = { + "organization_id": organization_id, + } + + url = self.api_base.url_for( + "/v1/b2b/organizations/{organization_id}/external_id", data + ) + res = await self.async_client.delete(url, headers) + return DeleteExternalIdResponse.from_json(res.response.status, res.json) diff --git a/stytch/b2b/api/organizations_members.py b/stytch/b2b/api/organizations_members.py index 779cb3a9..acf83cba 100644 --- a/stytch/b2b/api/organizations_members.py +++ b/stytch/b2b/api/organizations_members.py @@ -14,6 +14,8 @@ from stytch.b2b.models.organizations_members import ( CreateRequestOptions, CreateResponse, + DeleteExternalIdRequestOptions, + DeleteExternalIdResponse, DeleteMFAPhoneNumberRequestOptions, DeleteMFAPhoneNumberResponse, DeletePasswordRequestOptions, @@ -1043,6 +1045,48 @@ async def get_connected_apps_async( res = await self.async_client.get(url, data, headers) return GetConnectedAppsResponse.from_json(res.response.status, res.json) + def delete_external_id( + self, + organization_id: str, + member_id: str, + method_options: Optional[DeleteExternalIdRequestOptions] = None, + ) -> DeleteExternalIdResponse: + headers: Dict[str, str] = {} + if method_options is not None: + headers = method_options.add_headers(headers) + data: Dict[str, Any] = { + "organization_id": organization_id, + "member_id": member_id, + } + + url = self.api_base.url_for( + "/v1/b2b/organizations/{organization_id}/members/{member_id}/external_id", + data, + ) + res = self.sync_client.delete(url, headers) + return DeleteExternalIdResponse.from_json(res.response.status_code, res.json) + + async def delete_external_id_async( + self, + organization_id: str, + member_id: str, + method_options: Optional[DeleteExternalIdRequestOptions] = None, + ) -> DeleteExternalIdResponse: + headers: Dict[str, str] = {} + if method_options is not None: + headers = method_options.add_headers(headers) + data: Dict[str, Any] = { + "organization_id": organization_id, + "member_id": member_id, + } + + url = self.api_base.url_for( + "/v1/b2b/organizations/{organization_id}/members/{member_id}/external_id", + data, + ) + res = await self.async_client.delete(url, headers) + return DeleteExternalIdResponse.from_json(res.response.status, res.json) + def create( self, organization_id: str, diff --git a/stytch/b2b/api/rbac_organizations.py b/stytch/b2b/api/rbac_organizations.py index a6d9d983..7e17a013 100644 --- a/stytch/b2b/api/rbac_organizations.py +++ b/stytch/b2b/api/rbac_organizations.py @@ -6,7 +6,7 @@ from __future__ import annotations -from typing import Any, Dict, Optional, Union +from typing import Any, Dict, Union from stytch.b2b.models.rbac import OrgPolicy from stytch.b2b.models.rbac_organizations import ( @@ -84,16 +84,37 @@ async def get_org_policy_async( def set_org_policy( self, organization_id: str, - org_policy: Optional[Union[OrgPolicy, Dict[str, Any]]] = None, + org_policy: Union[OrgPolicy, Dict[str, Any]], ) -> SetOrgPolicyResponse: + """Set the RBAC Policy for a specific Organization within your Stytch Project. An Organization RBAC Policy allows you to define roles that are specific to that organization, providing fine-grained control over permissions at the organization level. + + This endpoint allows you to create, update, or replace the organization-scoped roles for a given organization. Organization policies supplement the project-level RBAC policy with additional roles that are only applicable within the context of that specific organization. + + The organization policy consists of roles, where each role defines: + - A unique `role_id` to identify the role + - A human-readable `description` of the role's purpose + - A set of `permissions` that specify which actions can be performed on which resources + + When you set an organization policy, it will replace any existing organization-specific roles for that organization. The project-level RBAC policy remains unchanged. + + Organization-specific roles are useful for scenarios where different organizations within your project require different permission structures, such as: + - Multi-tenant applications with varying access levels per tenant + - Organizations with custom approval workflows + - Different organizational hierarchies requiring unique role definitions + + 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. + + Fields: + - 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. + - 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. + """ # noqa headers: Dict[str, str] = {} data: Dict[str, Any] = { "organization_id": organization_id, - } - if org_policy is not None: - data["org_policy"] = ( + "org_policy": ( org_policy if isinstance(org_policy, dict) else org_policy.dict() - ) + ), + } url = self.api_base.url_for( "/v1/b2b/rbac/organizations/{organization_id}", data @@ -104,16 +125,37 @@ def set_org_policy( async def set_org_policy_async( self, organization_id: str, - org_policy: Optional[OrgPolicy] = None, + org_policy: OrgPolicy, ) -> SetOrgPolicyResponse: + """Set the RBAC Policy for a specific Organization within your Stytch Project. An Organization RBAC Policy allows you to define roles that are specific to that organization, providing fine-grained control over permissions at the organization level. + + This endpoint allows you to create, update, or replace the organization-scoped roles for a given organization. Organization policies supplement the project-level RBAC policy with additional roles that are only applicable within the context of that specific organization. + + The organization policy consists of roles, where each role defines: + - A unique `role_id` to identify the role + - A human-readable `description` of the role's purpose + - A set of `permissions` that specify which actions can be performed on which resources + + When you set an organization policy, it will replace any existing organization-specific roles for that organization. The project-level RBAC policy remains unchanged. + + Organization-specific roles are useful for scenarios where different organizations within your project require different permission structures, such as: + - Multi-tenant applications with varying access levels per tenant + - Organizations with custom approval workflows + - Different organizational hierarchies requiring unique role definitions + + 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. + + Fields: + - 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. + - 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. + """ # noqa headers: Dict[str, str] = {} data: Dict[str, Any] = { "organization_id": organization_id, - } - if org_policy is not None: - data["org_policy"] = ( + "org_policy": ( org_policy if isinstance(org_policy, dict) else org_policy.dict() - ) + ), + } url = self.api_base.url_for( "/v1/b2b/rbac/organizations/{organization_id}", data diff --git a/stytch/b2b/models/organizations.py b/stytch/b2b/models/organizations.py index 2a29d8e3..c54cd5b3 100644 --- a/stytch/b2b/models/organizations.py +++ b/stytch/b2b/models/organizations.py @@ -101,6 +101,22 @@ class CustomRole(pydantic.BaseModel): permissions: List[CustomRolePermission] +class DeleteExternalIdRequestOptions(pydantic.BaseModel): + """ + Fields: + - authorization: Optional authorization object. + Pass in an active Stytch Member session token or session JWT and the request + will be run using that member's permissions. + """ # noqa + + authorization: Optional[Authorization] = None + + def add_headers(self, headers: Dict[str, str]) -> Dict[str, str]: + if self.authorization is not None: + headers = self.authorization.add_headers(headers) + return headers + + class DeleteRequestOptions(pydantic.BaseModel): """ Fields: @@ -668,6 +684,10 @@ class CreateResponse(ResponseBase): organization: Organization +class DeleteExternalIdResponse(ResponseBase): + organization: Organization + + class DeleteResponse(ResponseBase): """Response type for `Organizations.delete`. Fields: diff --git a/stytch/b2b/models/organizations_members.py b/stytch/b2b/models/organizations_members.py index 08ee9d1f..8f325d34 100644 --- a/stytch/b2b/models/organizations_members.py +++ b/stytch/b2b/models/organizations_members.py @@ -50,6 +50,22 @@ def add_headers(self, headers: Dict[str, str]) -> Dict[str, str]: return headers +class DeleteExternalIdRequestOptions(pydantic.BaseModel): + """ + Fields: + - authorization: Optional authorization object. + Pass in an active Stytch Member session token or session JWT and the request + will be run using that member's permissions. + """ # noqa + + authorization: Optional[Authorization] = None + + def add_headers(self, headers: Dict[str, str]) -> Dict[str, str]: + if self.authorization is not None: + headers = self.authorization.add_headers(headers) + return headers + + class DeleteMFAPhoneNumberRequestOptions(pydantic.BaseModel): """ Fields: @@ -223,6 +239,12 @@ class CreateResponse(ResponseBase): organization: Organization +class DeleteExternalIdResponse(ResponseBase): + member_id: str + member: Member + organization: Organization + + class DeleteMFAPhoneNumberResponse(ResponseBase): """Response type for `Members.delete_mfa_phone_number`. Fields: diff --git a/stytch/b2b/models/rbac_organizations.py b/stytch/b2b/models/rbac_organizations.py index 7e62489a..9c7bc1bf 100644 --- a/stytch/b2b/models/rbac_organizations.py +++ b/stytch/b2b/models/rbac_organizations.py @@ -6,8 +6,6 @@ from __future__ import annotations -from typing import Optional - from stytch.b2b.models.rbac import OrgPolicy from stytch.core.response_base import ResponseBase @@ -18,8 +16,13 @@ class GetOrgPolicyResponse(ResponseBase): - 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. """ # noqa - org_policy: Optional[OrgPolicy] = None + org_policy: OrgPolicy class SetOrgPolicyResponse(ResponseBase): - org_policy: Optional[OrgPolicy] = None + """Response type for `Organizations.set_org_policy`. + Fields: + - 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. + """ # noqa + + org_policy: OrgPolicy diff --git a/stytch/consumer/api/users.py b/stytch/consumer/api/users.py index 549ea533..1030d2e4 100644 --- a/stytch/consumer/api/users.py +++ b/stytch/consumer/api/users.py @@ -15,6 +15,7 @@ DeleteBiometricRegistrationResponse, DeleteCryptoWalletResponse, DeleteEmailResponse, + DeleteExternalIdResponse, DeleteOAuthRegistrationResponse, DeletePasswordResponse, DeletePhoneNumberResponse, @@ -771,6 +772,32 @@ async def delete_oauth_registration_async( res = await self.async_client.delete(url, headers) return DeleteOAuthRegistrationResponse.from_json(res.response.status, res.json) + def delete_external_id( + self, + user_id: str, + ) -> DeleteExternalIdResponse: + headers: Dict[str, str] = {} + data: Dict[str, Any] = { + "user_id": user_id, + } + + url = self.api_base.url_for("/v1/users/{user_id}/external_id", data) + res = self.sync_client.delete(url, headers) + return DeleteExternalIdResponse.from_json(res.response.status_code, res.json) + + async def delete_external_id_async( + self, + user_id: str, + ) -> DeleteExternalIdResponse: + headers: Dict[str, str] = {} + data: Dict[str, Any] = { + "user_id": user_id, + } + + url = self.api_base.url_for("/v1/users/{user_id}/external_id", data) + res = await self.async_client.delete(url, headers) + return DeleteExternalIdResponse.from_json(res.response.status, res.json) + def connected_apps( self, user_id: str, diff --git a/stytch/consumer/api/webauthn.py b/stytch/consumer/api/webauthn.py index cdf825bd..8899fbb1 100644 --- a/stytch/consumer/api/webauthn.py +++ b/stytch/consumer/api/webauthn.py @@ -255,6 +255,7 @@ def authenticate_start( domain: str, user_id: Optional[str] = None, return_passkey_credential_options: Optional[bool] = None, + use_base64_url_encoding: Optional[bool] = None, ) -> AuthenticateStartResponse: """Initiate the authentication of a Passkey or WebAuthn registration. @@ -269,6 +270,7 @@ def authenticate_start( - user_id: The `user_id` of an active user the Passkey or WebAuthn registration should be tied to. You may use an `external_id` here if one is set for the user. - return_passkey_credential_options: If true, the `public_key_credential_creation_options` returned will be optimized for Passkeys with `userVerification` set to `"preferred"`. + - use_base64_url_encoding: (no documentation yet) """ # noqa headers: Dict[str, str] = {} data: Dict[str, Any] = { @@ -280,6 +282,8 @@ def authenticate_start( data["return_passkey_credential_options"] = ( return_passkey_credential_options ) + if use_base64_url_encoding is not None: + data["use_base64_url_encoding"] = use_base64_url_encoding url = self.api_base.url_for("/v1/webauthn/authenticate/start", data) res = self.sync_client.post(url, data, headers) @@ -290,6 +294,7 @@ async def authenticate_start_async( domain: str, user_id: Optional[str] = None, return_passkey_credential_options: Optional[bool] = None, + use_base64_url_encoding: Optional[bool] = None, ) -> AuthenticateStartResponse: """Initiate the authentication of a Passkey or WebAuthn registration. @@ -304,6 +309,7 @@ async def authenticate_start_async( - user_id: The `user_id` of an active user the Passkey or WebAuthn registration should be tied to. You may use an `external_id` here if one is set for the user. - return_passkey_credential_options: If true, the `public_key_credential_creation_options` returned will be optimized for Passkeys with `userVerification` set to `"preferred"`. + - use_base64_url_encoding: (no documentation yet) """ # noqa headers: Dict[str, str] = {} data: Dict[str, Any] = { @@ -315,6 +321,8 @@ async def authenticate_start_async( data["return_passkey_credential_options"] = ( return_passkey_credential_options ) + if use_base64_url_encoding is not None: + data["use_base64_url_encoding"] = use_base64_url_encoding url = self.api_base.url_for("/v1/webauthn/authenticate/start", data) res = await self.async_client.post(url, data, headers) diff --git a/stytch/consumer/models/connected_apps.py b/stytch/consumer/models/connected_apps.py index c3a095a9..36874357 100644 --- a/stytch/consumer/models/connected_apps.py +++ b/stytch/consumer/models/connected_apps.py @@ -25,6 +25,7 @@ class ConnectedApp(pydantic.BaseModel): - access_token_template_content: (no documentation yet) - post_logout_redirect_urls: Array of redirect URI values for use in OIDC Logout flows. - bypass_consent_for_offline_access: Valid for first party clients only. If true, the client does not need to request explicit user consent for the `offline_access` scope. + - creation_method: (no documentation yet) - client_secret_last_four: The last four characters of the client secret. - next_client_secret_last_four: The last four characters of the `next_client_secret`. Null if no `next_client_secret` exists. - access_token_custom_audience: (no documentation yet) @@ -43,6 +44,7 @@ class ConnectedApp(pydantic.BaseModel): access_token_template_content: str post_logout_redirect_urls: List[str] bypass_consent_for_offline_access: bool + creation_method: str client_secret_last_four: Optional[str] = None next_client_secret_last_four: Optional[str] = None access_token_custom_audience: Optional[str] = None diff --git a/stytch/consumer/models/users.py b/stytch/consumer/models/users.py index 2557ac6c..121ea0e6 100644 --- a/stytch/consumer/models/users.py +++ b/stytch/consumer/models/users.py @@ -293,6 +293,11 @@ class DeleteEmailResponse(ResponseBase): user: User +class DeleteExternalIdResponse(ResponseBase): + user_id: str + user: User + + class DeleteOAuthRegistrationResponse(ResponseBase): """Response type for `Users.delete_oauth_registration`. Fields: diff --git a/stytch/shared/tests/test_policy_cache.py b/stytch/shared/tests/test_policy_cache.py index f8304058..064873f6 100644 --- a/stytch/shared/tests/test_policy_cache.py +++ b/stytch/shared/tests/test_policy_cache.py @@ -6,10 +6,10 @@ OrgPolicy, Policy, PolicyResource, + PolicyResponse, PolicyRole, PolicyRolePermission, PolicyScope, - PolicyResponse, ) from stytch.b2b.models.rbac_organizations import GetOrgPolicyResponse from stytch.shared.policy_cache import PolicyCache, _merge_policies diff --git a/stytch/version.py b/stytch/version.py index 22946915..d5793afc 100644 --- a/stytch/version.py +++ b/stytch/version.py @@ -1 +1 @@ -__version__ = "14.0.0" +__version__ = "15.0.0" From 31c3b738ccb766d14c8953c9f573de8bd91bc04b Mon Sep 17 00:00:00 2001 From: Logan Gore Date: Fri, 16 Jan 2026 14:39:41 -0800 Subject: [PATCH 2/3] Remove problematic test --- stytch/shared/tests/test_policy_cache.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/stytch/shared/tests/test_policy_cache.py b/stytch/shared/tests/test_policy_cache.py index 064873f6..be525864 100644 --- a/stytch/shared/tests/test_policy_cache.py +++ b/stytch/shared/tests/test_policy_cache.py @@ -194,16 +194,6 @@ def test_correct_org_policy_with_multiple_cached(self) -> None: self.assertIn("org_456_role", [r.role_id for r in result_456.roles]) self.assertNotIn("org_123_role", [r.role_id for r in result_456.roles]) - def test_none_org_policy_is_cached(self) -> None: - rbac = FakeRBAC(self.project_policy, {}) - cache = PolicyCache(rbac, refresh_interval_seconds=600) # type: ignore[arg-type] - - cache.get_with_org("org-no-policy") - cache.get_with_org("org-no-policy") - cache.get_with_org("org-no-policy") - - self.assertEqual(rbac.organizations.call_count, 1) - def test_cache_respects_refresh_interval(self) -> None: rbac = FakeRBAC(self.project_policy, {"org-123": self.org_policy}) cache = PolicyCache(rbac, refresh_interval_seconds=1) # type: ignore[arg-type] From 8c38e72df109ed5dfb76e056b4a01b79613e38c1 Mon Sep 17 00:00:00 2001 From: Logan Gore Date: Fri, 16 Jan 2026 14:52:11 -0800 Subject: [PATCH 3/3] Version --- stytch/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stytch/version.py b/stytch/version.py index d5793afc..217fb25c 100644 --- a/stytch/version.py +++ b/stytch/version.py @@ -1 +1 @@ -__version__ = "15.0.0" +__version__ = "14.1.0"