diff --git a/stytch/b2b/api/organizations.py b/stytch/b2b/api/organizations.py index fd68315..9fb452c 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 779cb3a..acf83cb 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 a6d9d98..520f95d 100644 --- a/stytch/b2b/api/rbac_organizations.py +++ b/stytch/b2b/api/rbac_organizations.py @@ -86,6 +86,28 @@ def set_org_policy( organization_id: str, org_policy: Optional[Union[OrgPolicy, Dict[str, Any]]] = None, ) -> 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, @@ -106,6 +128,28 @@ async def set_org_policy_async( organization_id: str, org_policy: Optional[OrgPolicy] = None, ) -> 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, diff --git a/stytch/b2b/models/organizations.py b/stytch/b2b/models/organizations.py index 2a29d8e..c54cd5b 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 08ee9d1..8f325d3 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 7e62489..41b3061 100644 --- a/stytch/b2b/models/rbac_organizations.py +++ b/stytch/b2b/models/rbac_organizations.py @@ -22,4 +22,9 @@ class GetOrgPolicyResponse(ResponseBase): class SetOrgPolicyResponse(ResponseBase): + """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: Optional[OrgPolicy] = None diff --git a/stytch/consumer/api/connected_apps_clients.py b/stytch/consumer/api/connected_apps_clients.py index d0e11b1..e264fd2 100644 --- a/stytch/consumer/api/connected_apps_clients.py +++ b/stytch/consumer/api/connected_apps_clients.py @@ -9,6 +9,7 @@ from typing import Any, Dict, List, Optional, Union from stytch.consumer.api.connected_apps_clients_secrets import Secrets +from stytch.consumer.models.connected_apps import SearchConnectedAppsQuery from stytch.consumer.models.connected_apps_clients import ( CreateRequestClientType, CreateResponse, @@ -230,12 +231,14 @@ def search( self, cursor: Optional[str] = None, limit: Optional[int] = None, + query: Optional[Union[SearchConnectedAppsQuery, Dict[str, Any]]] = None, ) -> SearchResponse: """Search for Connected Apps. Supports cursor-based pagination. Specific filters coming soon. Fields: - cursor: The `cursor` field allows you to paginate through your results. Each result array is limited to 1000 results. If your query returns more than 1000 results, you will need to paginate the responses using the `cursor`. If you receive a response that includes a non-null `next_cursor` in the `results_metadata` object, repeat the search call with the `next_cursor` value set to the `cursor` field to retrieve the next page of results. Continue to make search calls until the `next_cursor` in the response is null. - limit: The number of search results to return per page. The default limit is 100. A maximum of 1000 results can be returned by a single search request. If the total size of your result set is greater than one page size, you must paginate the response. See the `cursor` field. + - query: (no documentation yet) """ # noqa headers: Dict[str, str] = {} data: Dict[str, Any] = {} @@ -243,6 +246,8 @@ def search( data["cursor"] = cursor if limit is not None: data["limit"] = limit + if query is not None: + data["query"] = query if isinstance(query, dict) else query.dict() url = self.api_base.url_for("/v1/connected_apps/clients/search", data) res = self.sync_client.post(url, data, headers) @@ -252,12 +257,14 @@ async def search_async( self, cursor: Optional[str] = None, limit: Optional[int] = None, + query: Optional[SearchConnectedAppsQuery] = None, ) -> SearchResponse: """Search for Connected Apps. Supports cursor-based pagination. Specific filters coming soon. Fields: - cursor: The `cursor` field allows you to paginate through your results. Each result array is limited to 1000 results. If your query returns more than 1000 results, you will need to paginate the responses using the `cursor`. If you receive a response that includes a non-null `next_cursor` in the `results_metadata` object, repeat the search call with the `next_cursor` value set to the `cursor` field to retrieve the next page of results. Continue to make search calls until the `next_cursor` in the response is null. - limit: The number of search results to return per page. The default limit is 100. A maximum of 1000 results can be returned by a single search request. If the total size of your result set is greater than one page size, you must paginate the response. See the `cursor` field. + - query: (no documentation yet) """ # noqa headers: Dict[str, str] = {} data: Dict[str, Any] = {} @@ -265,6 +272,8 @@ async def search_async( data["cursor"] = cursor if limit is not None: data["limit"] = limit + if query is not None: + data["query"] = query if isinstance(query, dict) else query.dict() url = self.api_base.url_for("/v1/connected_apps/clients/search", data) res = await self.async_client.post(url, data, headers) diff --git a/stytch/consumer/api/users.py b/stytch/consumer/api/users.py index 549ea53..1030d2e 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 cdf825b..8899fbb 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 c3a095a..04cef16 100644 --- a/stytch/consumer/models/connected_apps.py +++ b/stytch/consumer/models/connected_apps.py @@ -6,11 +6,35 @@ from __future__ import annotations +import enum from typing import List, Optional import pydantic +class SearchConnectedAppsOperandClientTypes(str, enum.Enum): + FIRST_PARTY = "first_party" + FIRST_PARTY_PUBLIC = "first_party_public" + THIRD_PARTY = "third_party" + THIRD_PARTY_PUBLIC = "third_party_public" + + +class SearchConnectedAppsOperandCreationMethods(str, enum.Enum): + DCR = "dcr" + CIMD = "cimd" + MANUAL = "manual" + + +class SearchConnectedAppsOperandFilterTypeSearchConnectedAppsOperandFilterType( + str, enum.Enum +): + UNKNOWN_OPERAND = "UNKNOWN_OPERAND" + CLIENT_IDS = "client_ids" + CLIENT_NAME_PREFIX = "client_name_prefix" + CLIENT_TYPES = "client_types" + CREATION_METHODS = "creation_methods" + + class ConnectedApp(pydantic.BaseModel): """ Fields: @@ -30,6 +54,7 @@ class ConnectedApp(pydantic.BaseModel): - access_token_custom_audience: (no documentation yet) - logo_url: The logo URL of the Connected App, if any. - client_id_metadata_url: (no documentation yet) + - creation_method: (no documentation yet) """ # noqa client_id: str @@ -48,6 +73,7 @@ class ConnectedApp(pydantic.BaseModel): access_token_custom_audience: Optional[str] = None logo_url: Optional[str] = None client_id_metadata_url: Optional[str] = None + creation_method: Optional[str] = None class ConnectedAppPublic(pydantic.BaseModel): @@ -149,3 +175,21 @@ class ResultsMetadata(pydantic.BaseModel): total: int next_cursor: Optional[str] = None + + +class SearchConnectedAppsOperand(pydantic.BaseModel): + client_ids: List[str] + client_types: List[SearchConnectedAppsOperandClientTypes] + creation_methods: List[SearchConnectedAppsOperandCreationMethods] + filter: Optional[ + SearchConnectedAppsOperandFilterTypeSearchConnectedAppsOperandFilterType + ] = None + client_name_prefix: Optional[str] = None + + +class SearchConnectedAppsOperandFilterType(pydantic.BaseModel): + pass + + +class SearchConnectedAppsQuery(pydantic.BaseModel): + operands: List[SearchConnectedAppsOperand] diff --git a/stytch/consumer/models/users.py b/stytch/consumer/models/users.py index 2557ac6..121ea0e 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 f830405..064873f 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 2294691..217fb25 100644 --- a/stytch/version.py +++ b/stytch/version.py @@ -1 +1 @@ -__version__ = "14.0.0" +__version__ = "14.1.0"