From b1cd46634313aaa138534747f8be50d01ca936da Mon Sep 17 00:00:00 2001 From: Sylvester Damgaard Date: Sat, 10 Oct 2026 08:39:37 +0200 Subject: [PATCH 1/3] Feature flags, fine-grained authorization and Pipes leases - feature_flags()/has_feature() and CboxUser.feature_flags / has_feature() from the feature_flags claim; FEATURE_FLAGS_SCOPE. - Management clients regenerated from the wave-10 specs (feature flag evaluation, FGA, pipes, radar, SMS settings, HRIS directories). The generator folds an action that is also a namespace (fga.check.batch -> fga.check_batch). fga_tuple() for the batch notation. - client.pipes.lease_token() / PipesClient with typed refusals carrying connect_url and retry_after; pipe_connect_url() and CboxIdClient.pipe_connect_url(). --- CHANGELOG.md | 32 + README.md | 99 + openapi/account.yaml | 67 + openapi/environment.yaml | 14489 +++++++++++----- scripts/generate_management.py | 9 + src/cbox_id/__init__.py | 28 + src/cbox_id/claims.py | 28 + src/cbox_id/client.py | 56 +- src/cbox_id/errors.py | 75 + src/cbox_id/management/__init__.py | 2 + src/cbox_id/management/fga.py | 58 + src/cbox_id/management/generated/account.py | 60 + .../management/generated/environment.py | 2482 ++- src/cbox_id/models.py | 11 + src/cbox_id/pipes.py | 207 + tests/fixtures/management_surface.txt | 57 + tests/test_feature_flags.py | 70 + tests/test_fga.py | 213 + tests/test_pipes.py | 171 + 19 files changed, 13228 insertions(+), 4986 deletions(-) create mode 100644 src/cbox_id/management/fga.py create mode 100644 src/cbox_id/pipes.py create mode 100644 tests/test_feature_flags.py create mode 100644 tests/test_fga.py create mode 100644 tests/test_pipes.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 77eb86f..e5792e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,38 @@ by their tags and commit history. ## [Unreleased] +Needs laravel-id 1.24 / the Cbox ID release with feature flags, fine-grained +authorization, Pipes and SMS as a second factor. Against an older instance the new claim +reads as absent and the new endpoints answer 404. + +### Added + +- Feature flags: `feature_flags(source)` and `has_feature(source, key)` read the + `feature_flags` claim from a signed-in user or a verified claim mapping; + `CboxUser.feature_flags` (`None` when the scope was not requested, `[]` when nothing is + on) and `CboxUser.has_feature(key)`. `FEATURE_FLAGS_SCOPE` is the scope to request. +- Management API regenerated from the current specs (Cbox ID wave 10): + `env.feature_flags.*` (including `evaluate({"user_id", "organization_id"})`), `env.fga.*` + (`check`, `check_batch`, tuples write/delete/list, resources and subjects lists, schema + get/update/validate, with `consistency_token` on every read), `env.pipes.*` (pipe setup, + grants, connections), `env.radar.*`, `env.signin.sms.*`, `env.users.mfa.sms.remove()`, the + HRIS directory routes (`env.directories.*`), and on the account plane + `me.mfa.sms.remove()` and `me.pipes.disconnect()`. `fga_tuple()` writes a tuple in the + notation the batch check takes. +- Pipes: `client.pipes.lease_token(provider, user_id=..., purpose=...)` leases a fresh + token for a person's connected account with a cached client-credentials `vault.lease` + token; `PipesClient(issuer, access_token)` does the same with a token you hold. Refusals + are typed: `PipeNotConnectedError` and `PipeReauthorizationRequiredError` carry + `connect_url` (and `connect_url_with(client_id=, return_to=)`), + `PipeTemporarilyUnavailableError` carries `retry_after`, `PipeLeaseDeniedError` is the + 403, all under `PipeLeaseError`. `client.pipe_connect_url(provider, return_to)` and + `pipe_connect_url()` build the hosted connect page. + +### Changed + +- The generator folds an action that is also another action's namespace into its parent + (`fga.check.batch` becomes `fga.check_batch()` next to `fga.check()`). + ## [0.10.0] - 2026-10-08 ### Added diff --git a/README.md b/README.md index c7a6040..4c246b5 100644 --- a/README.md +++ b/README.md @@ -251,6 +251,30 @@ if org and org.role is OrganizationRole.OWNER: Matching is exact — `invoices:*` does not grant `invoices:delete`. An `org_role` this SDK version does not recognise reads as `None`, never as a tier it would have to guess. +### Feature flags + +Request the `feature_flags` scope (and give the app that scope on its Scopes tab) and the +tokens and UserInfo carry the keys of every flag that is on for the person, in the +organization they signed in to: + +```python +from cbox_id import FEATURE_FLAGS_SCOPE, CboxIdConfig, has_feature + +config = CboxIdConfig(..., scopes=["openid", "profile", "email", FEATURE_FLAGS_SCOPE]) + +user.feature_flags # ["acme-beta", "new-dashboard"], or None +if user.has_feature("new-dashboard"): + show_new_dashboard() +has_feature(access_token_payload, "billing.v2") # on a verified claim set too +``` + +`None` means the claim is absent — the scope was not requested — and `[]` means nothing is +on. Either way `has_feature()` is `False`: a missing scope turns every feature off, never +on. A token carries the flags as they were when it was issued; the next refresh picks up a +change. To ask without a token in hand (a job, a webhook handler), use +`env.feature_flags.evaluate({"user_id": ..., "organization_id": ...})` from the +[Management API](#management-api). + ### Support sessions A staff member can act as one of your users for a limited time (at most an hour, no refresh @@ -286,6 +310,37 @@ everywhere" needs. Machine tokens and introspection are confidential-client call require a `client_secret`; revocation also works for a public (PKCE) client, which names itself in the request body instead. +## Pipes: a person's own GitHub, Google, Slack… token + +When a person has connected their account at a provider, lease a fresh access token for it. +`client.pipes` gets its own client-credentials token with the `vault.lease` scope (and +reuses it until it nearly expires); Cbox ID refreshes the provider token first when it is +about to expire: + +```python +from cbox_id import ( + PipeNotConnectedError, + PipeReauthorizationRequiredError, + PipeTemporarilyUnavailableError, +) + +try: + token = client.pipes.lease_token("github", user_id=user_id, purpose="list-repos") + # call https://api.github.com/user/repos with token.access_token, then drop it +except (PipeNotConnectedError, PipeReauthorizationRequiredError) as e: + return redirect(e.connect_url_with(client_id=CLIENT_ID, return_to=request.url)) +except PipeTemporarilyUnavailableError as e: + retry_later(e.retry_after) +# PipeLeaseDeniedError: the app is not granted this pipe — a configuration problem. +``` + +With a token issued for the person (they signed in to your app), use +`PipesClient(issuer, user.access_token).lease_token("github", purpose=...)` and leave +`user_id` out. To send somebody to connect before any lease, +`client.pipe_connect_url("github", return_to)` is the hosted connect page, preselected to +your app; they come back with `?provider=github&status=connected` (or `cancelled`, +`failed`). + ## Declare roles & permissions Your app declares its authorization **roles** and **permissions** in code and publishes @@ -505,6 +560,50 @@ for org in env.organizations.list_all({"limit": 100}): print(org["id"], org["name"]) ``` +### Fine-grained authorization + +`env.fga` writes relationship tuples and asks checks. Every write answers with a +`consistency_token`; pass it to a check that must see the write: + +```python +from cbox_id.management import fga_tuple + +written = env.fga.tuples.write( + { + "tuples": [ + { + "resource_type": "document", + "resource_id": "leave", + "relation": "viewer", + "subject": {"type": "user", "id": "alice"}, + } + ] + } +) + +answer = env.fga.check( + { + "resource_type": "document", + "resource_id": "leave", + "relation": "viewer", + "subject_type": "user", + "subject_id": "alice", + "consistency_token": written.data["consistency_token"], + } +) +answer.data["allowed"] + +# Up to 100 at once, in the tuple notation, answered in order: +env.fga.check_batch( + {"checks": ["document:leave#viewer@user:alice", "document:readme#editor@user:alice"]} +) +``` + +`env.fga.tuples.delete()`, `env.fga.resources.list()` (which documents can alice view), +`env.fga.subjects.list()` (who can view this one), and `env.fga.schema.get()` / +`.update()` / `.validate()` complete it. `fga_tuple()` writes the notation from the same +mapping `tuples.write` takes. + ### Audit Logs Your app records what its users did, per organization (your customer), and Cbox ID keeps diff --git a/openapi/account.yaml b/openapi/account.yaml index 6dfff99..961834e 100644 --- a/openapi/account.yaml +++ b/openapi/account.yaml @@ -52,6 +52,7 @@ tags: description: "Where a held operation's approval stands." - name: Organizations description: 'The organizations you are a member of — and leaving one.' + - name: 'Connected services' paths: '/me/action-approvals/{id}': get: @@ -171,6 +172,34 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' + /me/mfa/sms: + delete: + tags: + - 'Sign-in methods' + summary: 'Remove your phone number for text-message sign-in codes.' + description: 'Requires scope `account:sign_in:write` on an access token you delegated; it acts on your own account only. No management key is accepted. Danger: critical.' + operationId: account_mfa_sms_remove + x-action: account.mfa.sms.remove + x-scope: 'account:sign_in:write' + x-danger: critical + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CboxApproval' + responses: + 204: + description: Done. + 202: + $ref: '#/components/responses/ApprovalRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 409: + $ref: '#/components/responses/Conflict' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' '/me/organizations/{organization_id}/api-keys': post: tags: @@ -363,6 +392,44 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' + '/me/pipes/{provider}': + delete: + tags: + - 'Connected services' + summary: 'Disconnect one of your connected services, revoking the access you gave it.' + description: 'Requires scope `account:pipes:write` on an access token you delegated; it acts on your own account only. No management key is accepted. Danger: destructive.' + operationId: account_pipes_disconnect + x-action: account.pipes.disconnect + x-scope: 'account:pipes:write' + x-danger: destructive + parameters: + - + name: provider + in: path + required: true + schema: + type: string + description: "The provider's key, for example `github`." + maxLength: 64 + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CboxApproval' + responses: + 204: + description: Done. + 202: + $ref: '#/components/responses/ApprovalRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' /me/profile: patch: tags: diff --git a/openapi/environment.yaml b/openapi/environment.yaml index cdbc403..237aad4 100644 --- a/openapi/environment.yaml +++ b/openapi/environment.yaml @@ -95,13 +95,17 @@ tags: - name: 'Directory Sync' - name: Domains - name: Events + - name: 'Feature flags' + - name: 'Fine-grained authorization' - name: 'Publishable keys' - name: Hooks - name: 'Secret keys' - name: 'Legacy login' - name: 'Log streams' - name: 'Admin Portal' + - name: Pipes - name: 'Outbound provisioning' + - name: Radar - name: 'SAML apps' - name: Sign-in - name: 'Enterprise SSO' @@ -4138,6 +4142,145 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' + /directories/hris: + post: + tags: + - 'Directory Sync' + summary: "Connect an HR system (Workday, BambooHR, Rippling, HiBob or Personio) to sync an organization's people from, verifying the credentials first. The first sync is queued." + description: 'Requires scope `directory_sync:write`. Danger: critical.' + operationId: directories_hris_connect + x-action: directories.hris.connect + x-scope: 'directory_sync:write' + x-danger: critical + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + ManagementAccessToken: [] + - + WorkspaceAccessToken: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + organization_id: + type: string + description: 'The organization whose people it provisions.' + maxLength: 64 + provider: + type: string + description: 'workday, bamboohr, rippling, hibob or personio.' + enum: + - workday + - bamboohr + - rippling + - hibob + - personio + name: + type: string + description: "What administrators call it. Defaults to the HR system's name." + maxLength: 120 + credentials: + type: object + description: "The HR system's credentials, in the keys its setup guide names. Write-only: sealed, never returned." + properties: + report_url: + type: string + description: 'Workday: Report web-service URL.' + maxLength: 20000 + username: + type: string + description: 'Workday: Integration system user.' + maxLength: 20000 + password: + type: string + description: 'Workday: Integration system user password (secret).' + maxLength: 20000 + client_id: + type: string + description: 'Workday: API client ID; Personio: Client ID.' + maxLength: 20000 + client_secret: + type: string + description: 'Workday: API client secret (secret); Personio: Client secret (secret).' + maxLength: 20000 + refresh_token: + type: string + description: 'Workday: Refresh token (secret).' + maxLength: 20000 + subdomain: + type: string + description: 'BambooHR: Company subdomain.' + maxLength: 20000 + api_key: + type: string + description: 'BambooHR: API key (secret).' + maxLength: 20000 + api_token: + type: string + description: 'Rippling: API token (secret).' + maxLength: 20000 + service_user_id: + type: string + description: 'HiBob: Service user ID.' + maxLength: 20000 + service_user_token: + type: string + description: 'HiBob: Service user token (secret).' + maxLength: 20000 + custom_attributes: + type: array + description: "The HR system's own field names to copy onto each person, verbatim." + maxItems: 50 + items: + type: string + maxLength: 190 + sync_interval_minutes: + type: + - integer + - 'null' + description: 'Minutes between scheduled pulls, 15 to 1440. Omitted or null: the platform default (hourly).' + minimum: 15 + maximum: 1440 + required: + - organization_id + - provider + - credentials + responses: + 201: + description: OK + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/Directory' + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' '/directories/{id}': delete: tags: @@ -4332,6 +4475,123 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' + '/directories/{id}/credentials': + put: + tags: + - 'Directory Sync' + summary: "Replace a pull directory's provider credentials, verifying the new ones first. Write-only; never returned." + description: 'Requires scope `directory_sync:write`. Danger: critical.' + operationId: directories_credentials_replace + x-action: directories.credentials.replace + x-scope: 'directory_sync:write' + x-danger: critical + parameters: + - + name: id + in: path + required: true + schema: + type: string + description: "The directory's id." + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + ManagementAccessToken: [] + - + WorkspaceAccessToken: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + organization_id: + type: + - string + - 'null' + description: "Only if it is this organization's; anything else is a 404." + maxLength: 64 + credentials: + type: object + description: "The new credentials, in the keys the provider's setup names (Google: service_account_json, admin_email; Entra: tenant_id, client_id, client_secret). Write-only." + properties: + report_url: + type: string + maxLength: 20000 + username: + type: string + maxLength: 20000 + password: + type: string + maxLength: 20000 + client_id: + type: string + maxLength: 20000 + client_secret: + type: string + maxLength: 20000 + refresh_token: + type: string + maxLength: 20000 + subdomain: + type: string + maxLength: 20000 + api_key: + type: string + maxLength: 20000 + api_token: + type: string + maxLength: 20000 + service_user_id: + type: string + maxLength: 20000 + service_user_token: + type: string + maxLength: 20000 + service_account_json: + type: string + maxLength: 20000 + admin_email: + type: string + maxLength: 20000 + tenant_id: + type: string + maxLength: 20000 + required: + - credentials + responses: + 200: + description: OK + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/Directory' + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' '/directories/{id}/group-roles': post: tags: @@ -4639,50 +4899,51 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - /domains: + '/directories/{id}/sync': post: tags: - - Domains - summary: 'Start serving this environment on a custom domain: returns the DNS TXT record that proves you control it.' - description: 'Requires scope `domains:write`. Danger: write.' - operationId: domains_add - x-action: domains.add - x-scope: 'domains:write' + - 'Directory Sync' + summary: 'Pull a Google Workspace, Microsoft Entra or HR-system directory now, on a worker. `full` asks an HR system for everybody rather than what changed.' + description: 'Requires scope `directory_sync:write`. Danger: write.' + operationId: directories_sync + x-action: directories.sync + x-scope: 'directory_sync:write' x-danger: write parameters: + - + name: id + in: path + required: true + schema: + type: string + description: "The directory's id." - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] + - + ManagementAccessToken: [] - WorkspaceAccessToken: [] requestBody: - required: true + required: false content: application/json: schema: type: object properties: - domain: - type: string - description: 'The domain, for example login.example.com.' - maxLength: 253 - required: - - domain + organization_id: + type: + - string + - 'null' + description: "Only if it is this organization's; anything else is a 404." + maxLength: 64 + full: + type: boolean + description: 'Ask an HR system that syncs incrementally for everybody, not only what changed. Ignored by other providers.' responses: - 200: - description: OK - content: - application/json: - schema: - type: object - required: - - data - properties: - data: - $ref: '#/components/schemas/CustomDomain' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -4699,13 +4960,212 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - get: + '/directories/{id}/sync-settings': + patch: tags: - - Domains - summary: "Read this environment's custom domain, and the DNS TXT record that proves a pending one." - description: 'Requires scope `domains:read`. Danger: read.' - operationId: domains_get - x-action: domains.get + - 'Directory Sync' + summary: 'Set how often a pull directory syncs, and for an HR system which of its fields pass through onto people.' + description: 'Requires scope `directory_sync:write`. Danger: write.' + operationId: directories_sync_settings_update + x-action: directories.sync_settings.update + x-scope: 'directory_sync:write' + x-danger: write + parameters: + - + name: id + in: path + required: true + schema: + type: string + description: "The directory's id." + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + ManagementAccessToken: [] + - + WorkspaceAccessToken: [] + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + organization_id: + type: + - string + - 'null' + description: "Only if it is this organization's; anything else is a 404." + maxLength: 64 + sync_interval_minutes: + type: + - integer + - 'null' + description: 'Minutes between scheduled pulls, 15 to 1440. null returns to the platform default (hourly).' + minimum: 15 + maximum: 1440 + custom_attributes: + type: array + description: "HR systems only: the HR system's own field names to copy onto each person, verbatim." + maxItems: 50 + items: + type: string + maxLength: 190 + field_map: + type: object + description: 'Workday only: the report column holding each field, where it is not the default name.' + properties: + id: + type: string + description: 'The report column holding id. Default: Employee_ID.' + maxLength: 190 + email: + type: string + description: 'The report column holding email. Default: Work_Email.' + maxLength: 190 + first_name: + type: string + description: 'The report column holding first_name. Default: Legal_First_Name.' + maxLength: 190 + last_name: + type: string + description: 'The report column holding last_name. Default: Legal_Last_Name.' + maxLength: 190 + display_name: + type: string + description: 'The report column holding display_name. Default: Preferred_Name.' + maxLength: 190 + active: + type: string + description: 'The report column holding active. Default: Active_Status.' + maxLength: 190 + on_leave: + type: string + description: 'The report column holding on_leave. Default: On_Leave.' + maxLength: 190 + hire_date: + type: string + description: 'The report column holding hire_date. Default: Hire_Date.' + maxLength: 190 + termination_date: + type: string + description: 'The report column holding termination_date. Default: Termination_Date.' + maxLength: 190 + department_id: + type: string + description: 'The report column holding department_id. Default: Supervisory_Organization_ID.' + maxLength: 190 + department: + type: string + description: 'The report column holding department. Default: Supervisory_Organization.' + maxLength: 190 + manager_id: + type: string + description: 'The report column holding manager_id. Default: Manager_Employee_ID.' + maxLength: 190 + title: + type: string + description: 'The report column holding title. Default: Business_Title.' + maxLength: 190 + responses: + 200: + description: OK + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/Directory' + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' + /domains: + post: + tags: + - Domains + summary: 'Start serving this environment on a custom domain: returns the DNS TXT record that proves you control it.' + description: 'Requires scope `domains:write`. Danger: write.' + operationId: domains_add + x-action: domains.add + x-scope: 'domains:write' + x-danger: write + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + WorkspaceAccessToken: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + domain: + type: string + description: 'The domain, for example login.example.com.' + maxLength: 253 + required: + - domain + responses: + 200: + description: OK + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/CustomDomain' + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' + get: + tags: + - Domains + summary: "Read this environment's custom domain, and the DNS TXT record that proves a pending one." + description: 'Requires scope `domains:read`. Danger: read.' + operationId: domains_get + x-action: domains.get x-scope: 'domains:read' x-danger: read parameters: @@ -4910,15 +5370,15 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - /frontend-keys: + /feature-flags: post: tags: - - 'Publishable keys' - summary: 'Create a publishable frontend key for a browser app, usable only from the origins listed. The key is public by design.' - description: 'Requires scope `frontend_keys:write`. Danger: write.' - operationId: frontend_keys_create - x-action: frontend_keys.create - x-scope: 'frontend_keys:write' + - 'Feature flags' + summary: 'Define a feature flag: a key apps ask about, its default, and who it is on for — named users, named organizations, a rollout percentage.' + description: 'Requires scope `feature_flags:write`. Danger: write.' + operationId: feature_flags_create + x-action: feature_flags.create + x-scope: 'feature_flags:write' x-danger: write parameters: - $ref: '#/components/parameters/IdempotencyKey' @@ -4936,28 +5396,62 @@ paths: schema: type: object properties: - name: - type: string - description: 'What the key is for, so a person recognises it later.' - maxLength: 120 - mode: + key: type: string - description: "`test` or `live`; it is in the key's prefix." - enum: - - test - - live - origins: + description: 'What code asks for and the `feature_flags` claim carries: lowercase letters and digits, with `-`, `_` or `.` between them. Fixed once created.' + maxLength: 64 + description: + type: + - string + - 'null' + maxLength: 500 + enabled: + type: boolean + description: 'The kill switch: off means off for everyone, whatever the rules say. Defaults to true.' + default_value: + type: boolean + description: 'The answer when no rule matches. Defaults to false.' + users: type: array - description: 'The exact origins (scheme, host, port) allowed to present the key, for example https://app.example.com. At least one.' - minItems: 1 - maxItems: 50 + description: 'Rules for named users — they outrank every other rule. The COMPLETE list when sent.' + maxItems: 1000 items: - type: string - maxLength: 255 + type: object + properties: + id: + type: string + description: "The user's id." + maxLength: 128 + enabled: + type: boolean + description: 'On (true, the default) or off for them, whatever the organization, rollout or default say.' + required: + - id + organizations: + type: array + description: 'Rules for named organizations — they outrank the rollout and the default. The COMPLETE list when sent.' + maxItems: 1000 + items: + type: object + properties: + id: + type: string + description: "The organization's id." + maxLength: 128 + enabled: + type: boolean + description: 'On (true, the default) or off for them, whatever the organization, rollout or default say.' + required: + - id + rollout_percentage: + type: + - integer + - 'null' + description: 'On for this percentage of everyone else, by a stable hash of the user id (or the organization id without one). null for no rollout.' + minimum: 0 + maximum: 100 required: - - name - - mode - - origins + - key responses: 201: description: OK @@ -4969,7 +5463,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/FrontendKey' + $ref: '#/components/schemas/FeatureFlag' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -4988,12 +5482,12 @@ paths: $ref: '#/components/responses/TooManyRequests' get: tags: - - 'Publishable keys' - summary: 'List the publishable frontend keys browser apps present to the Frontend API, in full, with their allowed origins.' - description: 'Requires scope `frontend_keys:read`. Danger: read.' - operationId: frontend_keys_list - x-action: frontend_keys.list - x-scope: 'frontend_keys:read' + - 'Feature flags' + summary: "List this environment's feature flags with their default, kill switch and targeting rules." + description: 'Requires scope `feature_flags:read`. Danger: read.' + operationId: feature_flags_list + x-action: feature_flags.list + x-scope: 'feature_flags:read' x-danger: read parameters: - @@ -5031,7 +5525,7 @@ paths: data: type: array items: - $ref: '#/components/schemas/FrontendKey' + $ref: '#/components/schemas/FeatureFlag' meta: $ref: '#/components/schemas/PageMeta' 202: @@ -5048,25 +5542,33 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - '/frontend-keys/{id}': - delete: + /feature-flags/evaluate: + get: tags: - - 'Publishable keys' - summary: 'Revoke a publishable frontend key. Pages still holding it stop working immediately.' - description: 'Requires scope `frontend_keys:write`. Danger: destructive.' - operationId: frontend_keys_revoke - x-action: frontend_keys.revoke - x-scope: 'frontend_keys:write' - x-danger: destructive + - 'Feature flags' + summary: 'Evaluate every feature flag for a user in an organization: whether each is on, and the rule that decided. For app backends without a token in hand.' + description: 'Requires scope `feature_flags:read`. Danger: read.' + operationId: feature_flags_evaluate + x-action: feature_flags.evaluate + x-scope: 'feature_flags:read' + x-danger: read parameters: - - name: id - in: path - required: true + name: user_id + in: query + required: false schema: type: string - description: "The key's id." - - $ref: '#/components/parameters/IdempotencyKey' + description: 'The user to evaluate for. Left out, only organization rules, an organization-bucketed rollout and defaults apply.' + maxLength: 128 + - + name: organization_id + in: query + required: false + schema: + type: string + description: 'The organization the question is asked in.' + maxLength: 64 - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: @@ -5075,9 +5577,61 @@ paths: - WorkspaceAccessToken: [] responses: - 204: - description: Done. - 202: + 200: + description: OK + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/FeatureFlagEvaluation' + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' + '/feature-flags/{id}': + delete: + tags: + - 'Feature flags' + summary: 'Delete a feature flag and its rules. Its key evaluates to off from now on; tokens already minted keep it until they expire.' + description: 'Requires scope `feature_flags:write`. Danger: destructive.' + operationId: feature_flags_delete + x-action: feature_flags.delete + x-scope: 'feature_flags:write' + x-danger: destructive + parameters: + - + name: id + in: path + required: true + schema: + type: string + description: "The flag's id." + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + WorkspaceAccessToken: [] + responses: + 204: + description: Done. + 202: $ref: '#/components/responses/ApprovalRequired' 400: $ref: '#/components/responses/EnvironmentRequired' @@ -5093,16 +5647,15 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - '/frontend-keys/{id}/origins': - put: + get: tags: - - 'Publishable keys' - summary: 'Replace the exact list of origins allowed to present a publishable frontend key. Live on the next request.' - description: 'Requires scope `frontend_keys:write`. Danger: write.' - operationId: frontend_keys_set_origins - x-action: frontend_keys.set_origins - x-scope: 'frontend_keys:write' - x-danger: write + - 'Feature flags' + summary: 'Read one feature flag: its default, whether it is switched on, and every user, organization and rollout rule.' + description: 'Requires scope `feature_flags:read`. Danger: read.' + operationId: feature_flags_get + x-action: feature_flags.get + x-scope: 'feature_flags:read' + x-danger: read parameters: - name: id @@ -5110,8 +5663,7 @@ paths: required: true schema: type: string - description: "The key's id." - - $ref: '#/components/parameters/IdempotencyKey' + description: "The flag's id." - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: @@ -5119,23 +5671,6 @@ paths: EnvironmentApiKey: [] - WorkspaceAccessToken: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - origins: - type: array - description: 'The exact origins (scheme, host, port) allowed to present the key, for example https://app.example.com. At least one.' - minItems: 1 - maxItems: 50 - items: - type: string - maxLength: 255 - required: - - origins responses: 200: description: OK @@ -5147,7 +5682,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/FrontendKey' + $ref: '#/components/schemas/FeatureFlag' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -5158,69 +5693,95 @@ paths: $ref: '#/components/responses/Forbidden' 404: $ref: '#/components/responses/NotFound' - 409: - $ref: '#/components/responses/Conflict' 422: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - /hooks: - post: + patch: tags: - - Hooks - summary: 'Register a hook at a hook point (token minting, login, registration, password change). Returns its signing secret once.' - description: 'Requires scope `hooks:write`. Danger: critical.' - operationId: hooks_create - x-action: hooks.create - x-scope: 'hooks:write' - x-danger: critical + - 'Feature flags' + summary: 'Change a feature flag: switch it on or off for everyone, change its default, or replace its user rules, organization rules or rollout percentage.' + description: 'Requires scope `feature_flags:write`. Danger: write.' + operationId: feature_flags_update + x-action: feature_flags.update + x-scope: 'feature_flags:write' + x-danger: write parameters: + - + name: id + in: path + required: true + schema: + type: string + description: "The flag's id." - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] - - - ManagementAccessToken: [] - WorkspaceAccessToken: [] requestBody: - required: true + required: false content: application/json: schema: type: object properties: - hook_point: - type: string - description: 'When it is called.' - enum: - - token_minting - - post_login - - pre_registration - - post_registration - - pre_password_change - - post_password_change - url: - type: string - description: 'A public HTTPS URL, called in the middle of the operation.' - format: uri - maxLength: 500 - organization_id: + description: type: - string - 'null' - description: "The organization it belongs to; it carries that organization's traffic only. Send this or environment_wide." - maxLength: 64 - environment_wide: + description: 'Null or empty clears it.' + maxLength: 500 + enabled: type: boolean - description: "True to make it the environment's own, carrying EVERY organization's traffic. Send this or organization_id." - required: - - hook_point - - url + description: 'The kill switch: off means off for everyone.' + default_value: + type: boolean + description: 'The answer when no rule matches.' + users: + type: array + description: 'Rules for named users — they outrank every other rule. The COMPLETE list when sent.' + maxItems: 1000 + items: + type: object + properties: + id: + type: string + description: "The user's id." + maxLength: 128 + enabled: + type: boolean + description: 'On (true, the default) or off for them, whatever the organization, rollout or default say.' + required: + - id + organizations: + type: array + description: 'Rules for named organizations — they outrank the rollout and the default. The COMPLETE list when sent.' + maxItems: 1000 + items: + type: object + properties: + id: + type: string + description: "The organization's id." + maxLength: 128 + enabled: + type: boolean + description: 'On (true, the default) or off for them, whatever the organization, rollout or default say.' + required: + - id + rollout_percentage: + type: + - integer + - 'null' + description: 'On for this percentage of everyone else, by a stable hash of the user id (or the organization id without one). null for no rollout.' + minimum: 0 + maximum: 100 responses: - 201: + 200: description: OK content: application/json: @@ -5230,7 +5791,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/InlineHook' + $ref: '#/components/schemas/FeatureFlag' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -5247,37 +5808,80 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' + /fga/check: get: tags: - - Hooks - summary: 'List the hooks — endpoints called during sign-in and token issuance — with their hook point, owner and whether each is active.' - description: 'Requires scope `hooks:read`. Danger: read.' - operationId: hooks_list - x-action: hooks.list - x-scope: 'hooks:read' + - 'Fine-grained authorization' + summary: 'Check whether a subject has a relation on a resource — directly, through computed relations, parents or nested groups. Optionally at least as fresh as a consistency_token.' + description: 'Requires scope `fga:read`. Danger: read.' + operationId: fga_check + x-action: fga.check + x-scope: 'fga:read' x-danger: read parameters: - - name: limit + name: resource_type in: query - required: false + required: true schema: - type: integer - description: 'Items per page, 1–100. Default 50.' + type: string + description: "The resource's type: `document`." + maxLength: 64 - - name: after + name: resource_id + in: query + required: true + schema: + type: string + description: "The resource's id: `readme`." + maxLength: 128 + - + name: relation + in: query + required: true + schema: + type: string + description: 'The relation to check: `viewer`.' + maxLength: 64 + - + name: subject_type + in: query + required: true + schema: + type: string + description: "The subject's type: `user`, or `group` for a userset." + maxLength: 64 + - + name: subject_id + in: query + required: true + schema: + type: string + description: "The subject's id: `alice`." + maxLength: 128 + - + name: subject_relation in: query required: false schema: type: string - description: 'The `next_cursor` of the previous page.' + description: 'For a userset subject — `member` with `group`/`eng` asks about every member of eng.' + maxLength: 64 + - + name: consistency_token + in: query + required: false + schema: + type: + - string + - 'null' + description: 'A `consistency_token` a write returned: the answer is then at least as fresh as that write.' + maxLength: 64 - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] - - - ManagementAccessToken: [] - WorkspaceAccessToken: [] responses: @@ -5289,14 +5893,9 @@ paths: type: object required: - data - - meta properties: data: - type: array - items: - $ref: '#/components/schemas/InlineHook' - meta: - $ref: '#/components/schemas/PageMeta' + $ref: '#/components/schemas/FgaCheck' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -5311,37 +5910,58 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - '/hooks/{id}': - delete: + /fga/check/batch: + get: tags: - - Hooks - summary: 'Remove a hook. It is no longer called at its hook point.' - description: 'Requires scope `hooks:write`. Danger: destructive.' - operationId: hooks_delete - x-action: hooks.delete - x-scope: 'hooks:write' - x-danger: destructive + - 'Fine-grained authorization' + summary: 'Run 1–100 checks in one round trip, each written resource#relation@subject (document:readme#viewer@user:alice), all at the same revision, answered in order.' + description: 'Requires scope `fga:read`. Danger: read.' + operationId: fga_check_batch + x-action: fga.check.batch + x-scope: 'fga:read' + x-danger: read parameters: - - name: id - in: path + name: checks + in: query required: true schema: - type: string - description: 'The hook id.' - - $ref: '#/components/parameters/IdempotencyKey' + type: array + description: '1–100 checks, each `resource_type:resource_id#relation@subject_type:subject_id[#subject_relation]`.' + minItems: 1 + maxItems: 100 + items: + type: string + maxLength: 400 + - + name: consistency_token + in: query + required: false + schema: + type: + - string + - 'null' + description: 'A `consistency_token` a write returned: the answer is then at least as fresh as that write.' + maxLength: 64 - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] - - - ManagementAccessToken: [] - WorkspaceAccessToken: [] responses: - 204: - description: Done. + 200: + description: OK + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/FgaCheckBatch' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -5352,36 +5972,91 @@ paths: $ref: '#/components/responses/Forbidden' 404: $ref: '#/components/responses/NotFound' - 409: - $ref: '#/components/responses/Conflict' 422: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' + /fga/resources: get: tags: - - Hooks - summary: 'Get one hook: its URL, hook point, owner and whether it is active. Never its signing secret.' - description: 'Requires scope `hooks:read`. Danger: read.' - operationId: hooks_get - x-action: hooks.get - x-scope: 'hooks:read' + - 'Fine-grained authorization' + summary: 'List the resources of one type a subject has a relation on (e.g. every document alice can view), through all inheritance; sorted ids, paged with after.' + description: 'Requires scope `fga:read`. Danger: read.' + operationId: fga_resources_list + x-action: fga.resources.list + x-scope: 'fga:read' x-danger: read parameters: - - name: id - in: path + name: resource_type + in: query required: true schema: type: string - description: 'The hook id.' + description: 'Which type of resource to list: `document`.' + maxLength: 64 + - + name: relation + in: query + required: true + schema: + type: string + description: 'The relation the subject must have: `viewer`.' + maxLength: 64 + - + name: subject_type + in: query + required: true + schema: + type: string + description: "The subject's type: `user`." + maxLength: 64 + - + name: subject_id + in: query + required: true + schema: + type: string + description: "The subject's id: `alice`." + maxLength: 128 + - + name: subject_relation + in: query + required: false + schema: + type: string + description: 'For a userset subject: `member` with `group`/`eng`.' + maxLength: 64 + - + name: consistency_token + in: query + required: false + schema: + type: + - string + - 'null' + description: 'A `consistency_token` a write returned: the answer is then at least as fresh as that write.' + maxLength: 64 + - + name: limit + in: query + required: false + schema: + type: integer + description: 'Ids per page, 1–1000. Default 100.' + - + name: after + in: query + required: false + schema: + type: string + description: 'The `next_cursor` of the previous page.' + maxLength: 128 - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] - - - ManagementAccessToken: [] - WorkspaceAccessToken: [] responses: @@ -5393,9 +6068,14 @@ paths: type: object required: - data + - meta properties: data: - $ref: '#/components/schemas/InlineHook' + type: array + items: + $ref: '#/components/schemas/FgaObject' + meta: + $ref: '#/components/schemas/PageMeta' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -5410,45 +6090,24 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - patch: + /fga/schema: + get: tags: - - Hooks - summary: 'Pause (active: false) or activate (active: true) a hook.' - description: 'Requires scope `hooks:write`. Danger: critical.' - operationId: hooks_update - x-action: hooks.update - x-scope: 'hooks:write' - x-danger: critical + - 'Fine-grained authorization' + summary: "Read this environment's fine-grained authorization schema: the source text, its parsed types and relations, its version, and the current consistency token." + description: 'Requires scope `fga:read`. Danger: read.' + operationId: fga_schema_get + x-action: fga.schema.get + x-scope: 'fga:read' + x-danger: read parameters: - - - name: id - in: path - required: true - schema: - type: string - description: 'The hook id.' - - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] - - - ManagementAccessToken: [] - WorkspaceAccessToken: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - active: - type: boolean - description: 'True to call it at its hook point; false to pause it.' - required: - - active responses: 200: description: OK @@ -5460,7 +6119,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/InlineHook' + $ref: '#/components/schemas/FgaSchema' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -5471,21 +6130,18 @@ paths: $ref: '#/components/responses/Forbidden' 404: $ref: '#/components/responses/NotFound' - 409: - $ref: '#/components/responses/Conflict' 422: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - /keys: - post: + put: tags: - - 'Secret keys' - summary: 'Mint a management key for this environment, at most as wide as the caller. The value is returned once, as `token`.' - description: 'Requires scope `keys:write`. Danger: critical.' - operationId: keys_create - x-action: keys.create - x-scope: 'keys:write' + - 'Fine-grained authorization' + summary: "Replace this environment's fine-grained authorization schema (types, relations and how each is decided). Changes the answer to every check at once; refused if invalid or if existing tuples would no longer fit." + description: 'Requires scope `fga:schema`. Danger: critical.' + operationId: fga_schema_update + x-action: fga.schema.update + x-scope: 'fga:schema' x-danger: critical parameters: - $ref: '#/components/parameters/IdempotencyKey' @@ -5503,54 +6159,14 @@ paths: schema: type: object properties: - name: + schema: type: string - maxLength: 120 - scopes: - type: array - description: 'The scopes the key carries. From a key: a subset of its own.' - minItems: 1 - maxItems: 64 - items: - type: string - maxLength: 64 - expires_at: - type: - - string - - 'null' - description: 'When it stops working. From a key: required to be no later than the parent; defaults to the latest allowed.' - format: date-time - description: - type: - - string - - 'null' - description: 'What it is for — shown beside it in the console.' - maxLength: 500 - require_approval: - type: object - description: "Which of this key's actions need its owner's approval on their device first. From a key: never weaker than its own." - properties: - min_danger: - type: - - string - - 'null' - description: "Every action at or above this danger waits for a person's approval." - enum: - - write - - destructive - - critical - actions: - type: array - description: 'These actions wait for approval whatever their danger.' - maxItems: 100 - items: - type: string - maxLength: 100 + description: 'The schema, in the schema language: `type document` then ` relation viewer: [user, group#member] or editor or viewer from parent`.' + maxLength: 65536 required: - - name - - scopes + - schema responses: - 201: + 200: description: OK content: application/json: @@ -5560,7 +6176,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/ManagementKey' + $ref: '#/components/schemas/FgaSchema' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -5577,30 +6193,25 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' + /fga/schema/validate: get: tags: - - 'Secret keys' - summary: "List this environment's management keys (names, scopes, parents, expiry, last use — never their values)." - description: 'Requires scope `keys:read`. Danger: read.' - operationId: keys_list - x-action: keys.list - x-scope: 'keys:read' + - 'Fine-grained authorization' + summary: 'Check a fine-grained authorization schema without saving it: every error by line, or the parsed types and canonical text.' + description: 'Requires scope `fga:read`. Danger: read.' + operationId: fga_schema_validate + x-action: fga.schema.validate + x-scope: 'fga:read' x-danger: read parameters: - - name: limit - in: query - required: false - schema: - type: integer - description: 'Items per page, 1–100. Default 50.' - - - name: after + name: schema in: query - required: false + required: true schema: type: string - description: 'The `next_cursor` of the previous page.' + description: 'The schema to check, in the schema language.' + maxLength: 65536 - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: @@ -5617,14 +6228,9 @@ paths: type: object required: - data - - meta properties: data: - type: array - items: - $ref: '#/components/schemas/ManagementKey' - meta: - $ref: '#/components/schemas/PageMeta' + $ref: '#/components/schemas/FgaSchemaValidation' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -5639,25 +6245,74 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - '/keys/{id}': - delete: + /fga/subjects: + get: tags: - - 'Secret keys' - summary: 'Revoke a management key and every key it minted, immediately.' - description: 'Requires scope `keys:write`. Danger: destructive.' - operationId: keys_revoke - x-action: keys.revoke - x-scope: 'keys:write' - x-danger: destructive + - 'Fine-grained authorization' + summary: 'List the subjects of one type that have a relation on a resource (e.g. every user who can view the readme), groups and inheritance expanded; sorted ids, paged with after.' + description: 'Requires scope `fga:read`. Danger: read.' + operationId: fga_subjects_list + x-action: fga.subjects.list + x-scope: 'fga:read' + x-danger: read parameters: - - name: id - in: path + name: resource_type + in: query required: true schema: type: string - description: 'The key to revoke.' - - $ref: '#/components/parameters/IdempotencyKey' + description: "The resource's type: `document`." + maxLength: 64 + - + name: resource_id + in: query + required: true + schema: + type: string + description: "The resource's id: `readme`." + maxLength: 128 + - + name: relation + in: query + required: true + schema: + type: string + description: 'The relation: `viewer`.' + maxLength: 64 + - + name: subject_type + in: query + required: true + schema: + type: string + description: 'Which type of subject to list: `user`.' + maxLength: 64 + - + name: consistency_token + in: query + required: false + schema: + type: + - string + - 'null' + description: 'A `consistency_token` a write returned: the answer is then at least as fresh as that write.' + maxLength: 64 + - + name: limit + in: query + required: false + schema: + type: integer + description: 'Ids per page, 1–1000. Default 100.' + - + name: after + in: query + required: false + schema: + type: string + description: 'The `next_cursor` of the previous page.' + maxLength: 128 - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: @@ -5666,8 +6321,22 @@ paths: - WorkspaceAccessToken: [] responses: - 204: - description: Done. + 200: + description: OK + content: + application/json: + schema: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + $ref: '#/components/schemas/FgaObject' + meta: + $ref: '#/components/schemas/PageMeta' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -5678,31 +6347,84 @@ paths: $ref: '#/components/responses/Forbidden' 404: $ref: '#/components/responses/NotFound' - 409: - $ref: '#/components/responses/Conflict' 422: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - '/keys/{id}/rotate': - post: + /fga/tuples: + get: tags: - - 'Secret keys' - summary: 'Rotate a management key: mint a successor with the same scopes and retire the old one after a grace period.' - description: 'Requires scope `keys:write`. Danger: critical.' - operationId: keys_rotate - x-action: keys.rotate - x-scope: 'keys:write' - x-danger: critical + - 'Fine-grained authorization' + summary: 'List stored relationship tuples oldest first, filtered by resource type and id, relation and subject; pass next_cursor as after to page.' + description: 'Requires scope `fga:read`. Danger: read.' + operationId: fga_tuples_list + x-action: fga.tuples.list + x-scope: 'fga:read' + x-danger: read parameters: - - name: id - in: path - required: true + name: resource_type + in: query + required: false schema: type: string - description: 'The key to rotate.' - - $ref: '#/components/parameters/IdempotencyKey' + description: 'Only tuples on this resource type.' + maxLength: 64 + - + name: resource_id + in: query + required: false + schema: + type: string + description: 'Only tuples on this resource.' + maxLength: 128 + - + name: relation + in: query + required: false + schema: + type: string + description: 'Only tuples of this relation.' + maxLength: 64 + - + name: subject_type + in: query + required: false + schema: + type: string + description: 'Only tuples naming this subject type.' + maxLength: 64 + - + name: subject_id + in: query + required: false + schema: + type: string + description: 'Only tuples naming this subject.' + maxLength: 128 + - + name: subject_relation + in: query + required: false + schema: + type: string + description: 'Only tuples naming a userset with this relation.' + maxLength: 64 + - + name: limit + in: query + required: false + schema: + type: integer + description: 'Tuples per page, 1–100. Default 50.' + - + name: after + in: query + required: false + schema: + type: string + description: 'The `next_cursor` of the previous page.' + maxLength: 64 - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: @@ -5710,20 +6432,8 @@ paths: EnvironmentApiKey: [] - WorkspaceAccessToken: [] - requestBody: - required: false - content: - application/json: - schema: - type: object - properties: - grace_hours: - type: integer - description: 'How long the old key keeps working. Default 24.' - minimum: 0 - maximum: 168 responses: - 201: + 200: description: OK content: application/json: @@ -5731,9 +6441,14 @@ paths: type: object required: - data + - meta properties: data: - $ref: '#/components/schemas/ManagementKey' + type: array + items: + $ref: '#/components/schemas/FgaTuple' + meta: + $ref: '#/components/schemas/PageMeta' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -5744,23 +6459,21 @@ paths: $ref: '#/components/responses/Forbidden' 404: $ref: '#/components/responses/NotFound' - 409: - $ref: '#/components/responses/Conflict' 422: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - /legacy-login: - get: + post: tags: - - 'Legacy login' - summary: 'Read the legacy login endpoint an app declared for migration, and whether it is approved to receive sign-ins.' - description: 'Requires scope `signin:read`. Danger: read.' - operationId: legacy_login_get - x-action: legacy_login.get - x-scope: 'signin:read' - x-danger: read + - 'Fine-grained authorization' + summary: 'Write 1–100 relationship tuples (resource#relation@subject) in one atomic batch, each checked against the schema. Grants access at once; returns a consistency_token for checks that must see it.' + description: 'Requires scope `fga:write`. Danger: write.' + operationId: fga_tuples_write + x-action: fga.tuples.write + x-scope: 'fga:write' + x-danger: write parameters: + - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: @@ -5768,6 +6481,61 @@ paths: EnvironmentApiKey: [] - WorkspaceAccessToken: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + tuples: + type: array + description: '1–100 tuples to write, all together or none.' + minItems: 1 + maxItems: 100 + items: + type: object + properties: + resource_type: + type: string + description: "The resource's type, as the schema names it: `document`." + maxLength: 64 + resource_id: + type: string + description: 'Your id for the resource: `readme`.' + maxLength: 128 + relation: + type: string + description: 'The relation: `viewer`.' + maxLength: 64 + subject: + type: object + description: 'Who: one subject (`user:alice`), or a userset (`group:eng#member`).' + properties: + type: + type: string + description: "The subject's type: `user`, or `group` for a userset." + maxLength: 64 + id: + type: string + description: 'Your id for the subject: `alice`, `eng`.' + maxLength: 128 + relation: + type: + - string + - 'null' + description: 'For a userset — everybody with this relation on the subject: `member` of `group:eng`. Omit for one subject.' + maxLength: 64 + required: + - type + - id + required: + - resource_type + - resource_id + - relation + - subject + required: + - tuples responses: 200: description: OK @@ -5779,7 +6547,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/LegacyLogin' + $ref: '#/components/schemas/FgaTupleWrite' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -5790,20 +6558,22 @@ paths: $ref: '#/components/responses/Forbidden' 404: $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' 422: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - /legacy-login/approve: + /fga/tuples/delete: post: tags: - - 'Legacy login' - summary: 'Approve the declared legacy login endpoint: sign-ins for people not yet migrated, with their passwords, go to that URL.' - description: 'Requires scope `signin:write`. Danger: critical.' - operationId: legacy_login_approve - x-action: legacy_login.approve - x-scope: 'signin:write' - x-danger: critical + - 'Fine-grained authorization' + summary: 'Delete 1–100 relationship tuples in one atomic batch. Revokes the access they granted, and everything inherited through them, at once.' + description: 'Requires scope `fga:write`. Danger: destructive.' + operationId: fga_tuples_delete + x-action: fga.tuples.delete + x-scope: 'fga:write' + x-danger: destructive parameters: - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' @@ -5813,6 +6583,61 @@ paths: EnvironmentApiKey: [] - WorkspaceAccessToken: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + tuples: + type: array + description: '1–100 tuples to delete, all together or none.' + minItems: 1 + maxItems: 100 + items: + type: object + properties: + resource_type: + type: string + description: "The resource's type, as the schema names it: `document`." + maxLength: 64 + resource_id: + type: string + description: 'Your id for the resource: `readme`.' + maxLength: 128 + relation: + type: string + description: 'The relation: `viewer`.' + maxLength: 64 + subject: + type: object + description: 'Who: one subject (`user:alice`), or a userset (`group:eng#member`).' + properties: + type: + type: string + description: "The subject's type: `user`, or `group` for a userset." + maxLength: 64 + id: + type: string + description: 'Your id for the subject: `alice`, `eng`.' + maxLength: 128 + relation: + type: + - string + - 'null' + description: 'For a userset — everybody with this relation on the subject: `member` of `group:eng`. Omit for one subject.' + maxLength: 64 + required: + - type + - id + required: + - resource_type + - resource_id + - relation + - subject + required: + - tuples responses: 200: description: OK @@ -5824,7 +6649,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/LegacyLogin' + $ref: '#/components/schemas/FgaTupleWrite' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -5841,15 +6666,15 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - /legacy-login/probe: + /frontend-keys: post: tags: - - 'Legacy login' - summary: 'Ask the declared legacy login endpoint whether it knows an address — your own — to test it before approving. Sends no password.' - description: 'Requires scope `signin:write`. Danger: write.' - operationId: legacy_login_probe - x-action: legacy_login.probe - x-scope: 'signin:write' + - 'Publishable keys' + summary: 'Create a publishable frontend key for a browser app, usable only from the origins listed. The key is public by design.' + description: 'Requires scope `frontend_keys:write`. Danger: write.' + operationId: frontend_keys_create + x-action: frontend_keys.create + x-scope: 'frontend_keys:write' x-danger: write parameters: - $ref: '#/components/parameters/IdempotencyKey' @@ -5867,15 +6692,30 @@ paths: schema: type: object properties: - email: + name: type: string - description: 'An address that exists in your old system — your own.' - format: email - maxLength: 254 + description: 'What the key is for, so a person recognises it later.' + maxLength: 120 + mode: + type: string + description: "`test` or `live`; it is in the key's prefix." + enum: + - test + - live + origins: + type: array + description: 'The exact origins (scheme, host, port) allowed to present the key, for example https://app.example.com. At least one.' + minItems: 1 + maxItems: 50 + items: + type: string + maxLength: 255 required: - - email + - name + - mode + - origins responses: - 200: + 201: description: OK content: application/json: @@ -5885,7 +6725,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/LegacyLoginProbe' + $ref: '#/components/schemas/FrontendKey' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -5902,18 +6742,30 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - /legacy-login/revoke: - post: + get: tags: - - 'Legacy login' - summary: 'Withdraw the legacy login approval. People not yet migrated can no longer sign in; the declaration stays on file.' - description: 'Requires scope `signin:write`. Danger: critical.' - operationId: legacy_login_revoke - x-action: legacy_login.revoke - x-scope: 'signin:write' - x-danger: critical + - 'Publishable keys' + summary: 'List the publishable frontend keys browser apps present to the Frontend API, in full, with their allowed origins.' + description: 'Requires scope `frontend_keys:read`. Danger: read.' + operationId: frontend_keys_list + x-action: frontend_keys.list + x-scope: 'frontend_keys:read' + x-danger: read parameters: - - $ref: '#/components/parameters/IdempotencyKey' + - + name: limit + in: query + required: false + schema: + type: integer + description: 'Items per page, 1–100. Default 50.' + - + name: after + in: query + required: false + schema: + type: string + description: 'The `next_cursor` of the previous page.' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: @@ -5930,9 +6782,14 @@ paths: type: object required: - data + - meta properties: data: - $ref: '#/components/schemas/LegacyLogin' + type: array + items: + $ref: '#/components/schemas/FrontendKey' + meta: + $ref: '#/components/schemas/PageMeta' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -5943,197 +6800,39 @@ paths: $ref: '#/components/responses/Forbidden' 404: $ref: '#/components/responses/NotFound' - 409: - $ref: '#/components/responses/Conflict' 422: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - /log-streams: - post: + '/frontend-keys/{id}': + delete: tags: - - 'Log streams' - summary: 'Stream the audit trail to a SIEM (Splunk, Elastic, Graylog, CEF, JSON), Datadog, or an S3 or GCS bucket. A generated HMAC key is returned once.' - description: 'Requires scope `log_streams:write`. Danger: critical.' - operationId: log_streams_create - x-action: log_streams.create - x-scope: 'log_streams:write' - x-danger: critical + - 'Publishable keys' + summary: 'Revoke a publishable frontend key. Pages still holding it stop working immediately.' + description: 'Requires scope `frontend_keys:write`. Danger: destructive.' + operationId: frontend_keys_revoke + x-action: frontend_keys.revoke + x-scope: 'frontend_keys:write' + x-danger: destructive parameters: + - + name: id + in: path + required: true + schema: + type: string + description: "The key's id." - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] - - - ManagementAccessToken: [] - WorkspaceAccessToken: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - name: - type: string - maxLength: 190 - destination: - type: string - enum: - - splunk_hec - - elastic_ecs - - graylog_gelf - - cef_http - - generic_json - - datadog - - s3 - - gcs - endpoint_url: - type: - - string - - 'null' - description: "An HTTP collector: the public URL entries are POSTed to (required). Datadog, S3 and GCS: leave out for the destination's own endpoint, or an https URL of an S3-compatible store (MinIO, R2)." - format: uri - maxLength: 2048 - auth: - type: string - description: "An HTTP collector: how the endpoint is authenticated. Left out, the destination's default. The cloud destinations authenticate their own way." - enum: - - none - - bearer - - splunk - - hmac - secret: - type: - - string - - 'null' - description: 'The credential, never echoed: the bearer or Splunk token (HTTP collectors; left out with `hmac`, a key is generated and returned once), the Datadog API key, the S3 secret access key (none with `role_arn`), or the GCS service-account JSON key.' - maxLength: 8192 - options: - type: object - description: "Datadog, S3 and GCS only: the destination's settings. An HTTP collector takes none." - properties: - site: - type: - - string - - 'null' - description: 'Datadog: the site your account lives on — the domain you sign in at, e.g. `datadoghq.eu` for EU1. Default `datadoghq.com` (US1). An API key only works on its own site.' - enum: - - datadoghq.com - - us3.datadoghq.com - - us5.datadoghq.com - - datadoghq.eu - - ap1.datadoghq.com - - ap2.datadoghq.com - - ddog-gov.com - service: - type: - - string - - 'null' - description: "Datadog: the `service` attribute on every entry. Default: this platform's name." - maxLength: 100 - source: - type: - - string - - 'null' - description: 'Datadog: the `ddsource` attribute. Default `cbox`.' - maxLength: 100 - tags: - type: - - array - - 'null' - description: 'Datadog: `key:value` tags (`ddtags`) on every entry, e.g. `env:prod`.' - maxItems: 100 - items: - type: string - maxLength: 200 - hostname: - type: - - string - - 'null' - description: "Datadog: the `hostname` attribute. Default: this platform's host." - maxLength: 255 - bucket: - type: - - string - - 'null' - description: 'S3 and GCS: the bucket objects are written to.' - maxLength: 222 - region: - type: - - string - - 'null' - description: "S3: the bucket's AWS Region, e.g. `eu-west-1` (`auto` for Cloudflare R2)." - maxLength: 32 - prefix: - type: - - string - - 'null' - description: 'S3 and GCS: the object key prefix, e.g. `cbox/audit`. Objects are written to `{prefix}/{yyyy}/{mm}/{dd}/{hh}/{batch}.ndjson.gz`.' - maxLength: 512 - access_key_id: - type: - - string - - 'null' - description: "S3 with an access key: the IAM access key ID. Its secret access key is the stream's `secret`." - maxLength: 128 - role_arn: - type: - - string - - 'null' - description: "S3 with an assumed role: the IAM role the platform assumes. No secret is stored; the role's trust policy must require the stream's `external_id`." - maxLength: 2048 - sse: - type: - - string - - 'null' - description: "S3: server-side encryption requested on every object. Left out, the bucket's default." - enum: - - AES256 - - 'aws:kms' - kms_key_id: - type: - - string - - 'null' - description: 'S3 with `aws:kms`: the KMS key ID, alias or ARN.' - maxLength: 2048 - path_style: - type: - - boolean - - 'null' - description: 'S3: path-style addressing. Default: virtual-hosted on AWS, path-style on a custom endpoint (MinIO, R2).' - gzip: - type: - - boolean - - 'null' - description: 'S3 and GCS: gzip each object (`.ndjson.gz`). Default true.' - organization_id: - type: - - string - - 'null' - description: "The organization it belongs to; it carries that organization's traffic only. Send this or environment_wide." - maxLength: 64 - environment_wide: - type: boolean - description: "True to make it the environment's own, carrying EVERY organization's traffic. Send this or organization_id." - required: - - name - - destination responses: - 201: - description: OK - content: - application/json: - schema: - type: object - required: - - data - properties: - data: - $ref: '#/components/schemas/LogStream' + 204: + description: Done. 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -6150,39 +6849,49 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - get: + '/frontend-keys/{id}/origins': + put: tags: - - 'Log streams' - summary: "List the SIEM destinations this environment's audit trail is streamed to, and whether each is enabled. Never a secret." - description: 'Requires scope `log_streams:read`. Danger: read.' - operationId: log_streams_list - x-action: log_streams.list - x-scope: 'log_streams:read' - x-danger: read + - 'Publishable keys' + summary: 'Replace the exact list of origins allowed to present a publishable frontend key. Live on the next request.' + description: 'Requires scope `frontend_keys:write`. Danger: write.' + operationId: frontend_keys_set_origins + x-action: frontend_keys.set_origins + x-scope: 'frontend_keys:write' + x-danger: write parameters: - - name: limit - in: query - required: false - schema: - type: integer - description: 'Items per page, 1–100. Default 50.' - - - name: after - in: query - required: false + name: id + in: path + required: true schema: type: string - description: 'The `next_cursor` of the previous page.' + description: "The key's id." + - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] - - - ManagementAccessToken: [] - WorkspaceAccessToken: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + origins: + type: array + description: 'The exact origins (scheme, host, port) allowed to present the key, for example https://app.example.com. At least one.' + minItems: 1 + maxItems: 50 + items: + type: string + maxLength: 255 + required: + - origins responses: 200: description: OK @@ -6192,14 +6901,9 @@ paths: type: object required: - data - - meta properties: data: - type: array - items: - $ref: '#/components/schemas/LogStream' - meta: - $ref: '#/components/schemas/PageMeta' + $ref: '#/components/schemas/FrontendKey' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -6210,19 +6914,168 @@ paths: $ref: '#/components/responses/Forbidden' 404: $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' 422: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - '/log-streams/{id}': - delete: - tags: - - 'Log streams' - summary: 'Delete an audit log stream. Nothing more is delivered to its endpoint.' - description: 'Requires scope `log_streams:write`. Danger: destructive.' - operationId: log_streams_delete - x-action: log_streams.delete - x-scope: 'log_streams:write' + /hooks: + post: + tags: + - Hooks + summary: 'Register a hook at a hook point (token minting, login, registration, password change). Returns its signing secret once.' + description: 'Requires scope `hooks:write`. Danger: critical.' + operationId: hooks_create + x-action: hooks.create + x-scope: 'hooks:write' + x-danger: critical + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + ManagementAccessToken: [] + - + WorkspaceAccessToken: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + hook_point: + type: string + description: 'When it is called.' + enum: + - token_minting + - post_login + - pre_registration + - post_registration + - pre_password_change + - post_password_change + url: + type: string + description: 'A public HTTPS URL, called in the middle of the operation.' + format: uri + maxLength: 500 + organization_id: + type: + - string + - 'null' + description: "The organization it belongs to; it carries that organization's traffic only. Send this or environment_wide." + maxLength: 64 + environment_wide: + type: boolean + description: "True to make it the environment's own, carrying EVERY organization's traffic. Send this or organization_id." + required: + - hook_point + - url + responses: + 201: + description: OK + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/InlineHook' + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' + get: + tags: + - Hooks + summary: 'List the hooks — endpoints called during sign-in and token issuance — with their hook point, owner and whether each is active.' + description: 'Requires scope `hooks:read`. Danger: read.' + operationId: hooks_list + x-action: hooks.list + x-scope: 'hooks:read' + x-danger: read + parameters: + - + name: limit + in: query + required: false + schema: + type: integer + description: 'Items per page, 1–100. Default 50.' + - + name: after + in: query + required: false + schema: + type: string + description: 'The `next_cursor` of the previous page.' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + ManagementAccessToken: [] + - + WorkspaceAccessToken: [] + responses: + 200: + description: OK + content: + application/json: + schema: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + $ref: '#/components/schemas/InlineHook' + meta: + $ref: '#/components/schemas/PageMeta' + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' + '/hooks/{id}': + delete: + tags: + - Hooks + summary: 'Remove a hook. It is no longer called at its hook point.' + description: 'Requires scope `hooks:write`. Danger: destructive.' + operationId: hooks_delete + x-action: hooks.delete + x-scope: 'hooks:write' x-danger: destructive parameters: - @@ -6231,7 +7084,7 @@ paths: required: true schema: type: string - description: 'The log stream id.' + description: 'The hook id.' - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' @@ -6263,12 +7116,12 @@ paths: $ref: '#/components/responses/TooManyRequests' get: tags: - - 'Log streams' - summary: 'Get one audit log stream: destination, endpoint, auth scheme, owner and health. Never its secret.' - description: 'Requires scope `log_streams:read`. Danger: read.' - operationId: log_streams_get - x-action: log_streams.get - x-scope: 'log_streams:read' + - Hooks + summary: 'Get one hook: its URL, hook point, owner and whether it is active. Never its signing secret.' + description: 'Requires scope `hooks:read`. Danger: read.' + operationId: hooks_get + x-action: hooks.get + x-scope: 'hooks:read' x-danger: read parameters: - @@ -6277,7 +7130,7 @@ paths: required: true schema: type: string - description: 'The log stream id.' + description: 'The hook id.' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: @@ -6298,7 +7151,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/LogStream' + $ref: '#/components/schemas/InlineHook' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -6315,12 +7168,12 @@ paths: $ref: '#/components/responses/TooManyRequests' patch: tags: - - 'Log streams' - summary: "Change an audit log stream's name, destination, endpoint, options or credential (re-validated; resets its circuit breaker), or disable (enabled: false) / resume (enabled: true) it." - description: 'Requires scope `log_streams:write`. Danger: critical.' - operationId: log_streams_update - x-action: log_streams.update - x-scope: 'log_streams:write' + - Hooks + summary: 'Pause (active: false) or activate (active: true) a hook.' + description: 'Requires scope `hooks:write`. Danger: critical.' + operationId: hooks_update + x-action: hooks.update + x-scope: 'hooks:write' x-danger: critical parameters: - @@ -6329,7 +7182,7 @@ paths: required: true schema: type: string - description: 'The log stream id.' + description: 'The hook id.' - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' @@ -6341,7 +7194,66 @@ paths: - WorkspaceAccessToken: [] requestBody: - required: false + required: true + content: + application/json: + schema: + type: object + properties: + active: + type: boolean + description: 'True to call it at its hook point; false to pause it.' + required: + - active + responses: + 200: + description: OK + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/InlineHook' + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' + /keys: + post: + tags: + - 'Secret keys' + summary: 'Mint a management key for this environment, at most as wide as the caller. The value is returned once, as `token`.' + description: 'Requires scope `keys:write`. Danger: critical.' + operationId: keys_create + x-action: keys.create + x-scope: 'keys:write' + x-danger: critical + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + WorkspaceAccessToken: [] + requestBody: + required: true content: application/json: schema: @@ -6349,143 +7261,52 @@ paths: properties: name: type: string - maxLength: 190 - destination: - type: string - description: "Changing it starts the stream's options and credential afresh." - enum: - - splunk_hec - - elastic_ecs - - graylog_gelf - - cef_http - - generic_json - - datadog - - s3 - - gcs - endpoint_url: + maxLength: 120 + scopes: + type: array + description: 'The scopes the key carries. From a key: a subset of its own.' + minItems: 1 + maxItems: 64 + items: + type: string + maxLength: 64 + expires_at: type: - string - 'null' - description: "An HTTP collector's URL; for Datadog, S3 and GCS empty means the destination's own endpoint." - format: uri - maxLength: 2048 - auth: - type: string - description: 'An HTTP collector only.' - enum: - - none - - bearer - - splunk - - hmac - secret: + description: 'When it stops working. From a key: required to be no later than the parent; defaults to the latest allowed.' + format: date-time + description: type: - string - 'null' - description: 'A new credential (token, API key, secret access key or service-account JSON key). Left out, the current one is kept. Never echoed.' - maxLength: 8192 - options: + description: 'What it is for — shown beside it in the console.' + maxLength: 500 + require_approval: type: object - description: "Datadog, S3 and GCS only: the destination's settings. An HTTP collector takes none." + description: "Which of this key's actions need its owner's approval on their device first. From a key: never weaker than its own." properties: - site: + min_danger: type: - string - 'null' - description: 'Datadog: the site your account lives on — the domain you sign in at, e.g. `datadoghq.eu` for EU1. Default `datadoghq.com` (US1). An API key only works on its own site.' + description: "Every action at or above this danger waits for a person's approval." enum: - - datadoghq.com - - us3.datadoghq.com - - us5.datadoghq.com - - datadoghq.eu - - ap1.datadoghq.com - - ap2.datadoghq.com - - ddog-gov.com - service: - type: - - string - - 'null' - description: "Datadog: the `service` attribute on every entry. Default: this platform's name." - maxLength: 100 - source: - type: - - string - - 'null' - description: 'Datadog: the `ddsource` attribute. Default `cbox`.' - maxLength: 100 - tags: - type: - - array - - 'null' - description: 'Datadog: `key:value` tags (`ddtags`) on every entry, e.g. `env:prod`.' + - write + - destructive + - critical + actions: + type: array + description: 'These actions wait for approval whatever their danger.' maxItems: 100 items: type: string - maxLength: 200 - hostname: - type: - - string - - 'null' - description: "Datadog: the `hostname` attribute. Default: this platform's host." - maxLength: 255 - bucket: - type: - - string - - 'null' - description: 'S3 and GCS: the bucket objects are written to.' - maxLength: 222 - region: - type: - - string - - 'null' - description: "S3: the bucket's AWS Region, e.g. `eu-west-1` (`auto` for Cloudflare R2)." - maxLength: 32 - prefix: - type: - - string - - 'null' - description: 'S3 and GCS: the object key prefix, e.g. `cbox/audit`. Objects are written to `{prefix}/{yyyy}/{mm}/{dd}/{hh}/{batch}.ndjson.gz`.' - maxLength: 512 - access_key_id: - type: - - string - - 'null' - description: "S3 with an access key: the IAM access key ID. Its secret access key is the stream's `secret`." - maxLength: 128 - role_arn: - type: - - string - - 'null' - description: "S3 with an assumed role: the IAM role the platform assumes. No secret is stored; the role's trust policy must require the stream's `external_id`." - maxLength: 2048 - sse: - type: - - string - - 'null' - description: "S3: server-side encryption requested on every object. Left out, the bucket's default." - enum: - - AES256 - - 'aws:kms' - kms_key_id: - type: - - string - - 'null' - description: 'S3 with `aws:kms`: the KMS key ID, alias or ARN.' - maxLength: 2048 - path_style: - type: - - boolean - - 'null' - description: 'S3: path-style addressing. Default: virtual-hosted on AWS, path-style on a custom endpoint (MinIO, R2).' - gzip: - type: - - boolean - - 'null' - description: 'S3 and GCS: gzip each object (`.ndjson.gz`). Default true.' - enabled: - type: boolean - description: 'True to deliver; false to stop, keeping what is pending.' + maxLength: 100 + required: + - name + - scopes responses: - 200: + 201: description: OK content: application/json: @@ -6495,7 +7316,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/LogStream' + $ref: '#/components/schemas/ManagementKey' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -6512,32 +7333,35 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - '/log-streams/{id}/test': - post: + get: tags: - - 'Log streams' - summary: 'Send one test event to a log stream now and report whether the destination accepted it, and if not, why.' - description: 'Requires scope `log_streams:write`. Danger: write.' - operationId: log_streams_test - x-action: log_streams.test - x-scope: 'log_streams:write' - x-danger: write + - 'Secret keys' + summary: "List this environment's management keys (names, scopes, parents, expiry, last use — never their values)." + description: 'Requires scope `keys:read`. Danger: read.' + operationId: keys_list + x-action: keys.list + x-scope: 'keys:read' + x-danger: read parameters: - - name: id - in: path - required: true + name: limit + in: query + required: false + schema: + type: integer + description: 'Items per page, 1–100. Default 50.' + - + name: after + in: query + required: false schema: type: string - description: "The log stream's id." - - $ref: '#/components/parameters/IdempotencyKey' + description: 'The `next_cursor` of the previous page.' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] - - - ManagementAccessToken: [] - WorkspaceAccessToken: [] responses: @@ -6549,9 +7373,14 @@ paths: type: object required: - data + - meta properties: data: - $ref: '#/components/schemas/LogStreamTest' + type: array + items: + $ref: '#/components/schemas/ManagementKey' + meta: + $ref: '#/components/schemas/PageMeta' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -6562,147 +7391,73 @@ paths: $ref: '#/components/responses/Forbidden' 404: $ref: '#/components/responses/NotFound' - 409: - $ref: '#/components/responses/Conflict' 422: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - /oauth/api-keys/verify: - servers: - - - url: 'https://{host}' - description: "The environment's own host" - variables: - host: - default: acme.cboxid.com - post: + '/keys/{id}': + delete: tags: - - 'API keys' - summary: 'Verify an API key presented to your API' - description: | - Your API received an API key (`{prefix}_…`) and asks whether it is good and - what it may do. Authenticate as **your app**, exactly as at the token endpoint — - `client_secret_basic`, `client_secret_post` or `private_key_jwt`; a public client is - refused. Send the key as `key`. - - A live key bound to your app answers with its holder, organization, tier and - permissions (`permissions` is re-capped at every call to what the holder still has). - **Everything else** — unknown, malformed, revoked, expired, another app's key, a - holder who left — is exactly `{"active": false}`: the endpoint never says which. - `401` is only ever about your own client credentials. The response is not cacheable. + - 'Secret keys' + summary: 'Revoke a management key and every key it minted, immediately.' + description: 'Requires scope `keys:write`. Danger: destructive.' + operationId: keys_revoke + x-action: keys.revoke + x-scope: 'keys:write' + x-danger: destructive + parameters: + - + name: id + in: path + required: true + schema: + type: string + description: 'The key to revoke.' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' security: - - AppClientCredentials: [] - requestBody: - required: true - content: - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/ApiKeyVerificationRequest' - application/json: - schema: - $ref: '#/components/schemas/ApiKeyVerificationRequest' + EnvironmentApiKey: [] + - + WorkspaceAccessToken: [] responses: - 200: - description: 'The verdict.' - content: - application/json: - schema: - $ref: '#/components/schemas/ApiKeyVerification' - 401: - description: "Your app's client credentials were missing or wrong (RFC 6749 §5.2)." - headers: - WWW-Authenticate: - description: 'Basic realm="api-keys"' - schema: - type: string - content: - application/json: - schema: - $ref: '#/components/schemas/OAuthClientError' - 429: - description: 'Too many verifications from this address — `cbox-id.customer_api_keys.verify_per_minute`, default 600 a minute.' - headers: - Retry-After: - description: 'Seconds to wait before retrying.' - schema: - type: integer - /organizations: - get: - operationId: organizations_list - x-action: organizations.list - tags: - - Organizations - summary: 'List organizations' - description: | - Requires scope `organizations:read`. A page at a time, archived ones included. Narrow - with `q` (a fragment of the name or slug) or `status`. - parameters: - - $ref: '#/components/parameters/Limit' - - $ref: '#/components/parameters/After' - - - name: q - in: query - required: false - schema: - type: string - maxLength: 190 - - - name: status - in: query - required: false - schema: - type: string - enum: - - active - - suspended - - deleted - - $ref: '#/components/parameters/CboxApproval' - - $ref: '#/components/parameters/CboxEnvironment' - security: - - - EnvironmentApiKey: [] - - - WorkspaceAccessToken: [] - responses: - 400: - $ref: '#/components/responses/EnvironmentRequired' - 404: - $ref: '#/components/responses/NotFound' - 422: - $ref: '#/components/responses/UnprocessableEntity' - 200: - description: OK - content: - application/json: - schema: - type: object - properties: - data: - type: array - items: - $ref: '#/components/schemas/Organization' - meta: - $ref: '#/components/schemas/PageMeta' + 204: + description: Done. 202: $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' 401: $ref: '#/components/responses/Unauthorized' 403: $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' + 422: + $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - x-scope: 'organizations:read' - x-danger: read + '/keys/{id}/rotate': post: - operationId: organizations_create - x-action: organizations.create tags: - - Organizations - summary: 'Create an organization, optionally with its owner' - description: 'Requires scope `organizations:write`.' + - 'Secret keys' + summary: 'Rotate a management key: mint a successor with the same scopes and retire the old one after a grace period.' + description: 'Requires scope `keys:write`. Danger: critical.' + operationId: keys_rotate + x-action: keys.rotate + x-scope: 'keys:write' + x-danger: critical parameters: + - + name: id + in: path + required: true + schema: + type: string + description: 'The key to rotate.' - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' @@ -6712,66 +7467,29 @@ paths: - WorkspaceAccessToken: [] requestBody: - required: true + required: false content: application/json: schema: type: object - required: - - name properties: - name: - type: string - maxLength: 190 - slug: - type: - - string - - 'null' - maxLength: 190 - description: | - URL-safe; unique within the environment. Optional — left out, it is derived - from the name and made unique (`acme`, `acme-2`, …). **Send one if you - retry creates**: with your own slug a retried request is a `422 - slug_taken` you can recognise, not a second organization. - parent_id: - type: - - string - - 'null' - description: 'An organization in this environment to place it under (`422 parent_not_found` otherwise).' - owner_user_id: - type: - - string - - 'null' - description: | - An existing user of this environment who becomes the organization's - **owner**, in the same transaction as the create (`422 user_not_found` - otherwise). Without it the organization starts with no owner; make one - later with `transfer-ownership`. - type: - type: string - enum: - - customer - - reseller - default: customer - description: '`reseller` manages customer organizations beneath it in the tree; `customer` is a leaf tenant.' - metadata: - type: - - object - - 'null' - additionalProperties: - type: string - maxLength: 500 - description: 'Free-form text values you keep on the organization.' + grace_hours: + type: integer + description: 'How long the old key keeps working. Default 24.' + minimum: 0 + maximum: 168 responses: 201: - description: Created + description: OK content: application/json: schema: type: object + required: + - data properties: data: - $ref: '#/components/schemas/Organization' + $ref: '#/components/schemas/ManagementKey' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -6782,22 +7500,23 @@ paths: $ref: '#/components/responses/Forbidden' 404: $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' 422: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - x-scope: 'organizations:write' - x-danger: write - '/organizations/{id}': + /legacy-login: get: - operationId: organizations_get - x-action: organizations.get tags: - - Organizations - summary: 'Get an organization' - description: 'Requires scope `organizations:read`.' + - 'Legacy login' + summary: 'Read the legacy login endpoint an app declared for migration, and whether it is approved to receive sign-ins.' + description: 'Requires scope `signin:read`. Danger: read.' + operationId: legacy_login_get + x-action: legacy_login.get + x-scope: 'signin:read' + x-danger: read parameters: - - $ref: '#/components/parameters/Id' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: @@ -6812,9 +7531,11 @@ paths: application/json: schema: type: object + required: + - data properties: data: - $ref: '#/components/schemas/Organization' + $ref: '#/components/schemas/LegacyLogin' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -6825,60 +7546,32 @@ paths: $ref: '#/components/responses/Forbidden' 404: $ref: '#/components/responses/NotFound' + 422: + $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - x-scope: 'organizations:read' - x-danger: read - patch: - operationId: organizations_update - x-action: organizations.update + /legacy-login/approve: + post: tags: - - Organizations - summary: 'Rename an organization, change its slug, or replace its metadata' - description: | - Requires scope `organizations:write`. A field left out is left alone; a request - that changes nothing answers with the organization as it is and records nothing. - A change is announced as the `organization.updated` webhook and recorded on the - organization's trail as `organization.renamed`, with the values before and after. + - 'Legacy login' + summary: 'Approve the declared legacy login endpoint: sign-ins for people not yet migrated, with their passwords, go to that URL.' + description: 'Requires scope `signin:write`. Danger: critical.' + operationId: legacy_login_approve + x-action: legacy_login.approve + x-scope: 'signin:write' + x-danger: critical parameters: - - $ref: '#/components/parameters/Id' - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] - - - ManagementAccessToken: [] - WorkspaceAccessToken: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - name: - type: string - minLength: 1 - maxLength: 190 - slug: - type: string - minLength: 1 - maxLength: 190 - description: 'URL-safe; unique within the environment.' - metadata: - type: - - object - - 'null' - additionalProperties: - type: string - maxLength: 500 - description: 'Replaces the metadata as a whole; `null` or `{}` clears it.' responses: 200: - description: 'The organization after the change.' + description: OK content: application/json: schema: @@ -6887,7 +7580,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/Organization' + $ref: '#/components/schemas/LegacyLogin' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -6898,28 +7591,23 @@ paths: $ref: '#/components/responses/Forbidden' 404: $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' 422: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - x-scope: 'organizations:write' - x-danger: write - delete: - operationId: organizations_delete - x-action: organizations.delete + /legacy-login/probe: + post: tags: - - Organizations - summary: 'Archive an organization' - description: | - Requires scope `organizations:write`. **Archives** rather than erases: the status - becomes `deleted`, every member loses access from their next request, the rows stay - for the audit trail, and `organization.deleted` is announced. Idempotent — archiving - an archived organization answers with it unchanged. - - Refused with `409 owns_products` for an organization that owns identity-provider - projects on this platform (a platform customer, not a tenant of your app). + - 'Legacy login' + summary: 'Ask the declared legacy login endpoint whether it knows an address — your own — to test it before approving. Sends no password.' + description: 'Requires scope `signin:write`. Danger: write.' + operationId: legacy_login_probe + x-action: legacy_login.probe + x-scope: 'signin:write' + x-danger: write parameters: - - $ref: '#/components/parameters/Id' - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' @@ -6928,9 +7616,23 @@ paths: EnvironmentApiKey: [] - WorkspaceAccessToken: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + email: + type: string + description: 'An address that exists in your old system — your own.' + format: email + maxLength: 254 + required: + - email responses: 200: - description: 'The archived organization.' + description: OK content: application/json: schema: @@ -6939,7 +7641,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/Organization' + $ref: '#/components/schemas/LegacyLoginProbe' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -6952,29 +7654,21 @@ paths: $ref: '#/components/responses/NotFound' 409: $ref: '#/components/responses/Conflict' + 422: + $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - x-scope: 'organizations:write' - x-danger: destructive - '/organizations/{id}/reactivate': + /legacy-login/revoke: post: tags: - - Organizations - summary: "Lift an organization's suspension so its members can sign in again." - description: 'Requires scope `organizations:write`. Danger: write.' - operationId: organizations_reactivate - x-action: organizations.reactivate - x-scope: 'organizations:write' - x-danger: write + - 'Legacy login' + summary: 'Withdraw the legacy login approval. People not yet migrated can no longer sign in; the declaration stays on file.' + description: 'Requires scope `signin:write`. Danger: critical.' + operationId: legacy_login_revoke + x-action: legacy_login.revoke + x-scope: 'signin:write' + x-danger: critical parameters: - - - name: id - in: path - required: true - schema: - type: string - description: 'The organization id.' - maxLength: 64 - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' @@ -6994,7 +7688,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/Organization' + $ref: '#/components/schemas/LegacyLogin' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -7011,35 +7705,181 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - '/organizations/{id}/suspend': + /log-streams: post: tags: - - Organizations - summary: 'Suspend an organization: its members cannot sign in to it until it is reactivated.' - description: 'Requires scope `organizations:write`. Danger: write.' - operationId: organizations_suspend - x-action: organizations.suspend - x-scope: 'organizations:write' - x-danger: write + - 'Log streams' + summary: 'Stream the audit trail to a SIEM (Splunk, Elastic, Graylog, CEF, JSON), Datadog, or an S3 or GCS bucket. A generated HMAC key is returned once.' + description: 'Requires scope `log_streams:write`. Danger: critical.' + operationId: log_streams_create + x-action: log_streams.create + x-scope: 'log_streams:write' + x-danger: critical parameters: - - - name: id - in: path - required: true - schema: - type: string - description: 'The organization id.' - maxLength: 64 - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] + - + ManagementAccessToken: [] - WorkspaceAccessToken: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + maxLength: 190 + destination: + type: string + enum: + - splunk_hec + - elastic_ecs + - graylog_gelf + - cef_http + - generic_json + - datadog + - s3 + - gcs + endpoint_url: + type: + - string + - 'null' + description: "An HTTP collector: the public URL entries are POSTed to (required). Datadog, S3 and GCS: leave out for the destination's own endpoint, or an https URL of an S3-compatible store (MinIO, R2)." + format: uri + maxLength: 2048 + auth: + type: string + description: "An HTTP collector: how the endpoint is authenticated. Left out, the destination's default. The cloud destinations authenticate their own way." + enum: + - none + - bearer + - splunk + - hmac + secret: + type: + - string + - 'null' + description: 'The credential, never echoed: the bearer or Splunk token (HTTP collectors; left out with `hmac`, a key is generated and returned once), the Datadog API key, the S3 secret access key (none with `role_arn`), or the GCS service-account JSON key.' + maxLength: 8192 + options: + type: object + description: "Datadog, S3 and GCS only: the destination's settings. An HTTP collector takes none." + properties: + site: + type: + - string + - 'null' + description: 'Datadog: the site your account lives on — the domain you sign in at, e.g. `datadoghq.eu` for EU1. Default `datadoghq.com` (US1). An API key only works on its own site.' + enum: + - datadoghq.com + - us3.datadoghq.com + - us5.datadoghq.com + - datadoghq.eu + - ap1.datadoghq.com + - ap2.datadoghq.com + - ddog-gov.com + service: + type: + - string + - 'null' + description: "Datadog: the `service` attribute on every entry. Default: this platform's name." + maxLength: 100 + source: + type: + - string + - 'null' + description: 'Datadog: the `ddsource` attribute. Default `cbox`.' + maxLength: 100 + tags: + type: + - array + - 'null' + description: 'Datadog: `key:value` tags (`ddtags`) on every entry, e.g. `env:prod`.' + maxItems: 100 + items: + type: string + maxLength: 200 + hostname: + type: + - string + - 'null' + description: "Datadog: the `hostname` attribute. Default: this platform's host." + maxLength: 255 + bucket: + type: + - string + - 'null' + description: 'S3 and GCS: the bucket objects are written to.' + maxLength: 222 + region: + type: + - string + - 'null' + description: "S3: the bucket's AWS Region, e.g. `eu-west-1` (`auto` for Cloudflare R2)." + maxLength: 32 + prefix: + type: + - string + - 'null' + description: 'S3 and GCS: the object key prefix, e.g. `cbox/audit`. Objects are written to `{prefix}/{yyyy}/{mm}/{dd}/{hh}/{batch}.ndjson.gz`.' + maxLength: 512 + access_key_id: + type: + - string + - 'null' + description: "S3 with an access key: the IAM access key ID. Its secret access key is the stream's `secret`." + maxLength: 128 + role_arn: + type: + - string + - 'null' + description: "S3 with an assumed role: the IAM role the platform assumes. No secret is stored; the role's trust policy must require the stream's `external_id`." + maxLength: 2048 + sse: + type: + - string + - 'null' + description: "S3: server-side encryption requested on every object. Left out, the bucket's default." + enum: + - AES256 + - 'aws:kms' + kms_key_id: + type: + - string + - 'null' + description: 'S3 with `aws:kms`: the KMS key ID, alias or ARN.' + maxLength: 2048 + path_style: + type: + - boolean + - 'null' + description: 'S3: path-style addressing. Default: virtual-hosted on AWS, path-style on a custom endpoint (MinIO, R2).' + gzip: + type: + - boolean + - 'null' + description: 'S3 and GCS: gzip each object (`.ndjson.gz`). Default true.' + organization_id: + type: + - string + - 'null' + description: "The organization it belongs to; it carries that organization's traffic only. Send this or environment_wide." + maxLength: 64 + environment_wide: + type: boolean + description: "True to make it the environment's own, carrying EVERY organization's traffic. Send this or organization_id." + required: + - name + - destination responses: - 200: + 201: description: OK content: application/json: @@ -7049,7 +7889,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/Organization' + $ref: '#/components/schemas/LogStream' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -7066,99 +7906,37 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - '/organizations/{id}/transfer-ownership': - post: - operationId: organizations_transfer_ownership - x-action: organizations.transfer_ownership + get: tags: - - Organizations - summary: "Make a member the organization's owner" - description: | - Requires scope `organizations:write`. The new owner must be an **active** member. - With one current owner this is a hand-over: the previous owner stays on as `admin`. - An organization with no owner (created without `owner_user_id`) gets its first; one - with several (from before ownership was transfer-only) ends with exactly one, the - others stepping down to `admin`. - - Refusals: `422 not_a_member`, `409 not_active`, `409 already_owner`. + - 'Log streams' + summary: "List the SIEM destinations this environment's audit trail is streamed to, and whether each is enabled. Never a secret." + description: 'Requires scope `log_streams:read`. Danger: read.' + operationId: log_streams_list + x-action: log_streams.list + x-scope: 'log_streams:read' + x-danger: read parameters: - - $ref: '#/components/parameters/Id' - - $ref: '#/components/parameters/IdempotencyKey' - - $ref: '#/components/parameters/CboxApproval' - - $ref: '#/components/parameters/CboxEnvironment' - security: - - - EnvironmentApiKey: [] - - ManagementAccessToken: [] - - - WorkspaceAccessToken: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - required: - - user_id - properties: - user_id: - type: string - responses: - 200: - description: "The new owner's membership." - content: - application/json: - schema: - type: object - required: - - data - properties: - data: - $ref: '#/components/schemas/Member' - 202: - $ref: '#/components/responses/ApprovalRequired' - 400: - $ref: '#/components/responses/EnvironmentRequired' - 401: - $ref: '#/components/responses/Unauthorized' - 403: - $ref: '#/components/responses/Forbidden' - 404: - $ref: '#/components/responses/NotFound' - 409: - $ref: '#/components/responses/Conflict' - 422: - $ref: '#/components/responses/UnprocessableEntity' - 429: - $ref: '#/components/responses/TooManyRequests' - x-scope: 'organizations:write' - x-danger: critical - '/organizations/{organization_id}/api-keys': - get: - operationId: api_keys_list - x-action: api_keys.list - tags: - - 'API keys' - summary: "List the API keys an organization's members created for your apps" - description: | - Requires scope `api_keys:read`. Revoked and expired keys are included, marked by - `status`. Never the key itself. Narrow to one app with `?client_id=`. - parameters: - - $ref: '#/components/parameters/OrganizationId' - - $ref: '#/components/parameters/Limit' - - $ref: '#/components/parameters/After' + name: limit + in: query + required: false + schema: + type: integer + description: 'Items per page, 1–100. Default 50.' - - name: client_id + name: after in: query required: false schema: type: string + description: 'The `next_cursor` of the previous page.' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] + - + ManagementAccessToken: [] - WorkspaceAccessToken: [] responses: @@ -7175,7 +7953,7 @@ paths: data: type: array items: - $ref: '#/components/schemas/ApiKey' + $ref: '#/components/schemas/LogStream' meta: $ref: '#/components/schemas/PageMeta' 202: @@ -7188,43 +7966,41 @@ paths: $ref: '#/components/responses/Forbidden' 404: $ref: '#/components/responses/NotFound' + 422: + $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - x-scope: 'api_keys:read' - x-danger: read - '/organizations/{organization_id}/domains': - get: - operationId: organizations_domains_list - x-action: organizations.domains.list + '/log-streams/{id}': + delete: tags: - - Organizations - summary: 'List the email domains an organization claims' - description: | - Requires scope `organizations:read`. Verified or not, each with the DNS TXT record - that proves it (`record_name`, `record_value`). A handful at most; not paged. + - 'Log streams' + summary: 'Delete an audit log stream. Nothing more is delivered to its endpoint.' + description: 'Requires scope `log_streams:write`. Danger: destructive.' + operationId: log_streams_delete + x-action: log_streams.delete + x-scope: 'log_streams:write' + x-danger: destructive parameters: - - $ref: '#/components/parameters/OrganizationId' + - + name: id + in: path + required: true + schema: + type: string + description: 'The log stream id.' + - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] + - + ManagementAccessToken: [] - WorkspaceAccessToken: [] responses: - 200: - description: OK - content: - application/json: - schema: - type: object - required: - - data - properties: - data: - type: array - items: - $ref: '#/components/schemas/OrganizationDomain' + 204: + description: Done. 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -7235,51 +8011,40 @@ paths: $ref: '#/components/responses/Forbidden' 404: $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' + 422: + $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - x-scope: 'organizations:read' - x-danger: read - post: + get: tags: - - Organizations - summary: 'Claim an email domain (acme.com) for an organization. Returns the DNS TXT record to publish before verifying it.' - description: 'Requires scope `organizations:write`. Danger: write.' - operationId: organizations_domains_add - x-action: organizations.domains.add - x-scope: 'organizations:write' - x-danger: write + - 'Log streams' + summary: 'Get one audit log stream: destination, endpoint, auth scheme, owner and health. Never its secret.' + description: 'Requires scope `log_streams:read`. Danger: read.' + operationId: log_streams_get + x-action: log_streams.get + x-scope: 'log_streams:read' + x-danger: read parameters: - - name: organization_id + name: id in: path required: true schema: type: string - description: 'The organization id.' - maxLength: 64 - - $ref: '#/components/parameters/IdempotencyKey' + description: 'The log stream id.' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] + - + ManagementAccessToken: [] - WorkspaceAccessToken: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - domain: - type: string - description: 'A domain name — acme.com, not an address or a URL.' - maxLength: 190 - required: - - domain responses: - 201: + 200: description: OK content: application/json: @@ -7289,7 +8054,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/OrganizationDomain' + $ref: '#/components/schemas/LogStream' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -7300,39 +8065,27 @@ paths: $ref: '#/components/responses/Forbidden' 404: $ref: '#/components/responses/NotFound' - 409: - $ref: '#/components/responses/Conflict' 422: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - '/organizations/{organization_id}/domains/{domain_id}': - delete: + patch: tags: - - Organizations - summary: 'Remove a claimed email domain from an organization. Capture on it ends.' - description: 'Requires scope `organizations:write`. Danger: destructive.' - operationId: organizations_domains_remove - x-action: organizations.domains.remove - x-scope: 'organizations:write' - x-danger: destructive + - 'Log streams' + summary: "Change an audit log stream's name, destination, endpoint, options or credential (re-validated; resets its circuit breaker), or disable (enabled: false) / resume (enabled: true) it." + description: 'Requires scope `log_streams:write`. Danger: critical.' + operationId: log_streams_update + x-action: log_streams.update + x-scope: 'log_streams:write' + x-danger: critical parameters: - - name: organization_id - in: path - required: true - schema: - type: string - description: 'The organization id.' - maxLength: 64 - - - name: domain_id + name: id in: path required: true schema: type: string - description: "The claimed domain's id." - maxLength: 64 + description: 'The log stream id.' - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' @@ -7340,73 +8093,153 @@ paths: - EnvironmentApiKey: [] - - WorkspaceAccessToken: [] - responses: - 204: - description: Done. - 202: - $ref: '#/components/responses/ApprovalRequired' - 400: - $ref: '#/components/responses/EnvironmentRequired' - 401: - $ref: '#/components/responses/Unauthorized' - 403: - $ref: '#/components/responses/Forbidden' - 404: - $ref: '#/components/responses/NotFound' - 409: - $ref: '#/components/responses/Conflict' - 422: - $ref: '#/components/responses/UnprocessableEntity' - 429: - $ref: '#/components/responses/TooManyRequests' - '/organizations/{organization_id}/domains/{domain_id}/capture': - put: - tags: - - Organizations - summary: "Turn capture on or off for a verified domain: with it on, everyone with an address there signs in through the organization's SSO." - description: 'Requires scope `organizations:write`. Danger: critical.' - operationId: organizations_domains_capture - x-action: organizations.domains.capture - x-scope: 'organizations:write' - x-danger: critical - parameters: - - - name: organization_id - in: path - required: true - schema: - type: string - description: 'The organization id.' - maxLength: 64 - - - name: domain_id - in: path - required: true - schema: - type: string - description: "The claimed domain's id." - maxLength: 64 - - $ref: '#/components/parameters/IdempotencyKey' - - $ref: '#/components/parameters/CboxApproval' - - $ref: '#/components/parameters/CboxEnvironment' - security: - - - EnvironmentApiKey: [] + ManagementAccessToken: [] - WorkspaceAccessToken: [] requestBody: - required: true + required: false content: application/json: schema: type: object properties: + name: + type: string + maxLength: 190 + destination: + type: string + description: "Changing it starts the stream's options and credential afresh." + enum: + - splunk_hec + - elastic_ecs + - graylog_gelf + - cef_http + - generic_json + - datadog + - s3 + - gcs + endpoint_url: + type: + - string + - 'null' + description: "An HTTP collector's URL; for Datadog, S3 and GCS empty means the destination's own endpoint." + format: uri + maxLength: 2048 + auth: + type: string + description: 'An HTTP collector only.' + enum: + - none + - bearer + - splunk + - hmac + secret: + type: + - string + - 'null' + description: 'A new credential (token, API key, secret access key or service-account JSON key). Left out, the current one is kept. Never echoed.' + maxLength: 8192 + options: + type: object + description: "Datadog, S3 and GCS only: the destination's settings. An HTTP collector takes none." + properties: + site: + type: + - string + - 'null' + description: 'Datadog: the site your account lives on — the domain you sign in at, e.g. `datadoghq.eu` for EU1. Default `datadoghq.com` (US1). An API key only works on its own site.' + enum: + - datadoghq.com + - us3.datadoghq.com + - us5.datadoghq.com + - datadoghq.eu + - ap1.datadoghq.com + - ap2.datadoghq.com + - ddog-gov.com + service: + type: + - string + - 'null' + description: "Datadog: the `service` attribute on every entry. Default: this platform's name." + maxLength: 100 + source: + type: + - string + - 'null' + description: 'Datadog: the `ddsource` attribute. Default `cbox`.' + maxLength: 100 + tags: + type: + - array + - 'null' + description: 'Datadog: `key:value` tags (`ddtags`) on every entry, e.g. `env:prod`.' + maxItems: 100 + items: + type: string + maxLength: 200 + hostname: + type: + - string + - 'null' + description: "Datadog: the `hostname` attribute. Default: this platform's host." + maxLength: 255 + bucket: + type: + - string + - 'null' + description: 'S3 and GCS: the bucket objects are written to.' + maxLength: 222 + region: + type: + - string + - 'null' + description: "S3: the bucket's AWS Region, e.g. `eu-west-1` (`auto` for Cloudflare R2)." + maxLength: 32 + prefix: + type: + - string + - 'null' + description: 'S3 and GCS: the object key prefix, e.g. `cbox/audit`. Objects are written to `{prefix}/{yyyy}/{mm}/{dd}/{hh}/{batch}.ndjson.gz`.' + maxLength: 512 + access_key_id: + type: + - string + - 'null' + description: "S3 with an access key: the IAM access key ID. Its secret access key is the stream's `secret`." + maxLength: 128 + role_arn: + type: + - string + - 'null' + description: "S3 with an assumed role: the IAM role the platform assumes. No secret is stored; the role's trust policy must require the stream's `external_id`." + maxLength: 2048 + sse: + type: + - string + - 'null' + description: "S3: server-side encryption requested on every object. Left out, the bucket's default." + enum: + - AES256 + - 'aws:kms' + kms_key_id: + type: + - string + - 'null' + description: 'S3 with `aws:kms`: the KMS key ID, alias or ARN.' + maxLength: 2048 + path_style: + type: + - boolean + - 'null' + description: 'S3: path-style addressing. Default: virtual-hosted on AWS, path-style on a custom endpoint (MinIO, R2).' + gzip: + type: + - boolean + - 'null' + description: 'S3 and GCS: gzip each object (`.ndjson.gz`). Default true.' enabled: type: boolean - description: 'Whether the domain captures sign-ins.' - required: - - enabled + description: 'True to deliver; false to stop, keeping what is pending.' responses: 200: description: OK @@ -7418,7 +8251,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/OrganizationDomain' + $ref: '#/components/schemas/LogStream' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -7435,39 +8268,32 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - '/organizations/{organization_id}/domains/{domain_id}/verify': + '/log-streams/{id}/test': post: tags: - - Organizations - summary: "Check a claimed domain's DNS TXT record and mark the domain verified if it is published." - description: 'Requires scope `organizations:write`. Danger: write.' - operationId: organizations_domains_verify - x-action: organizations.domains.verify - x-scope: 'organizations:write' + - 'Log streams' + summary: 'Send one test event to a log stream now and report whether the destination accepted it, and if not, why.' + description: 'Requires scope `log_streams:write`. Danger: write.' + operationId: log_streams_test + x-action: log_streams.test + x-scope: 'log_streams:write' x-danger: write parameters: - - name: organization_id - in: path - required: true - schema: - type: string - description: 'The organization id.' - maxLength: 64 - - - name: domain_id + name: id in: path required: true schema: type: string - description: "The claimed domain's id." - maxLength: 64 + description: "The log stream's id." - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] + - + ManagementAccessToken: [] - WorkspaceAccessToken: [] responses: @@ -7481,7 +8307,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/OrganizationDomain' + $ref: '#/components/schemas/LogStreamTest' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -7498,92 +8324,147 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - '/organizations/{organization_id}/invitations': - get: - operationId: invitations_list - x-action: invitations.list + /oauth/api-keys/verify: + servers: + - + url: 'https://{host}' + description: "The environment's own host" + variables: + host: + default: acme.cboxid.com + post: tags: - - Invitations - summary: "List an organization's pending invitations" - description: 'Requires scope `invitations:read`. Pending and unexpired only.' - parameters: - - $ref: '#/components/parameters/OrganizationId' - - $ref: '#/components/parameters/Limit' - - $ref: '#/components/parameters/After' - - $ref: '#/components/parameters/CboxApproval' - - $ref: '#/components/parameters/CboxEnvironment' + - 'API keys' + summary: 'Verify an API key presented to your API' + description: | + Your API received an API key (`{prefix}_…`) and asks whether it is good and + what it may do. Authenticate as **your app**, exactly as at the token endpoint — + `client_secret_basic`, `client_secret_post` or `private_key_jwt`; a public client is + refused. Send the key as `key`. + + A live key bound to your app answers with its holder, organization, tier and + permissions (`permissions` is re-capped at every call to what the holder still has). + **Everything else** — unknown, malformed, revoked, expired, another app's key, a + holder who left — is exactly `{"active": false}`: the endpoint never says which. + `401` is only ever about your own client credentials. The response is not cacheable. security: - - EnvironmentApiKey: [] - - - WorkspaceAccessToken: [] + AppClientCredentials: [] + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/ApiKeyVerificationRequest' + application/json: + schema: + $ref: '#/components/schemas/ApiKeyVerificationRequest' responses: 200: - description: OK + description: 'The verdict.' content: application/json: schema: - type: object - required: - - data - - meta - properties: - data: - type: array - items: - $ref: '#/components/schemas/Invitation' - meta: - $ref: '#/components/schemas/PageMeta' - 202: - $ref: '#/components/responses/ApprovalRequired' - 400: - $ref: '#/components/responses/EnvironmentRequired' + $ref: '#/components/schemas/ApiKeyVerification' 401: - $ref: '#/components/responses/Unauthorized' - 403: - $ref: '#/components/responses/Forbidden' - 404: - $ref: '#/components/responses/NotFound' + description: "Your app's client credentials were missing or wrong (RFC 6749 §5.2)." + headers: + WWW-Authenticate: + description: 'Basic realm="api-keys"' + schema: + type: string + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthClientError' + 429: + description: 'Too many verifications from this address — `cbox-id.customer_api_keys.verify_per_minute`, default 600 a minute.' + headers: + Retry-After: + description: 'Seconds to wait before retrying.' + schema: + type: integer + /organizations: + get: + operationId: organizations_list + x-action: organizations.list + tags: + - Organizations + summary: 'List organizations' + description: | + Requires scope `organizations:read`. A page at a time, archived ones included. Narrow + with `q` (a fragment of the name or slug) or `status`. + parameters: + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/After' + - + name: q + in: query + required: false + schema: + type: string + maxLength: 190 + - + name: status + in: query + required: false + schema: + type: string + enum: + - active + - suspended + - deleted + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + WorkspaceAccessToken: [] + responses: + 400: + $ref: '#/components/responses/EnvironmentRequired' + 404: + $ref: '#/components/responses/NotFound' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 200: + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/Organization' + meta: + $ref: '#/components/schemas/PageMeta' + 202: + $ref: '#/components/responses/ApprovalRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' 429: $ref: '#/components/responses/TooManyRequests' - x-scope: 'invitations:read' + x-scope: 'organizations:read' x-danger: read post: - operationId: invitations_send - x-action: invitations.send + operationId: organizations_create + x-action: organizations.create tags: - - Invitations - summary: 'Invite someone to the organization' - description: | - Requires scope `invitations:write`. Mails a link; the person joins when they open it - and confirm. Inviting an address again replaces its earlier pending invitation. - - - `role` — `admin` or `member` (default `member`). Never `owner`. - - `roles` — access roles granted when the invitation is accepted: role ids, or your - app's manifest **keys** when `client_id` names the app that declared them. Only - roles the organization's own administrators could grant are accepted: a **staff - role** is refused (`422 role_not_assignable`) — grant it after they join, with - `PUT …/members/{user_id}/roles/{role_id}`. - - `client_id` + `return_to` — after accepting, the person is sent to `return_to`, - which must be on one of that app's registered redirect-URI origins (checked again - when the invitation is accepted). - - `inviter_name` — who the mail says it is from. Defaults to the app's name, or the - environment's. - - Refusals: `409 already_member`, `409 access_role_conflict` (segregation of duties), - `422 unknown_role`, `422 role_not_assignable`, `422 unknown_app`, - `422 return_without_app`, `422 return_to_malformed`, `422 return_to_not_registered`, - `503 mail_failed` (nothing was created — retry). + - Organizations + summary: 'Create an organization, optionally with its owner' + description: 'Requires scope `organizations:write`.' parameters: - - $ref: '#/components/parameters/OrganizationId' - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] - - - ManagementAccessToken: [] - WorkspaceAccessToken: [] requestBody: @@ -7593,46 +8474,60 @@ paths: schema: type: object required: - - email + - name properties: - email: + name: type: string - format: email maxLength: 190 - role: - $ref: '#/components/schemas/AssignableMemberRole' - roles: - type: array - maxItems: 50 - items: - type: string - client_id: + slug: type: - string - 'null' - return_to: + maxLength: 190 + description: | + URL-safe; unique within the environment. Optional — left out, it is derived + from the name and made unique (`acme`, `acme-2`, …). **Send one if you + retry creates**: with your own slug a retried request is a `422 + slug_taken` you can recognise, not a second organization. + parent_id: type: - string - 'null' - format: uri - maxLength: 2048 - inviter_name: + description: 'An organization in this environment to place it under (`422 parent_not_found` otherwise).' + owner_user_id: type: - string - 'null' - maxLength: 120 + description: | + An existing user of this environment who becomes the organization's + **owner**, in the same transaction as the create (`422 user_not_found` + otherwise). Without it the organization starts with no owner; make one + later with `transfer-ownership`. + type: + type: string + enum: + - customer + - reseller + default: customer + description: '`reseller` manages customer organizations beneath it in the tree; `customer` is a leaf tenant.' + metadata: + type: + - object + - 'null' + additionalProperties: + type: string + maxLength: 500 + description: 'Free-form text values you keep on the organization.' responses: 201: - description: Sent. + description: Created content: application/json: schema: type: object - required: - - data properties: data: - $ref: '#/components/schemas/Invitation' + $ref: '#/components/schemas/Organization' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -7643,42 +8538,39 @@ paths: $ref: '#/components/responses/Forbidden' 404: $ref: '#/components/responses/NotFound' - 409: - $ref: '#/components/responses/Conflict' 422: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - 503: - $ref: '#/components/responses/MailFailed' - x-scope: 'invitations:write' + x-scope: 'organizations:write' x-danger: write - '/organizations/{organization_id}/invitations/{invitation_id}': - delete: - operationId: invitations_revoke - x-action: invitations.revoke + '/organizations/{id}': + get: + operationId: organizations_get + x-action: organizations.get tags: - - Invitations - summary: 'Withdraw a pending invitation' - description: | - Requires scope `invitations:write`. The link stops working, and the roles parked - for it go with it. `409 not_pending` for one already accepted, withdrawn or expired. + - Organizations + summary: 'Get an organization' + description: 'Requires scope `organizations:read`.' parameters: - - $ref: '#/components/parameters/OrganizationId' - - $ref: '#/components/parameters/InvitationId' - - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/Id' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] - - - ManagementAccessToken: [] - WorkspaceAccessToken: [] responses: - 204: - description: Withdrawn. + 200: + description: OK + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/Organization' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -7689,30 +8581,23 @@ paths: $ref: '#/components/responses/Forbidden' 404: $ref: '#/components/responses/NotFound' - 409: - $ref: '#/components/responses/Conflict' 429: $ref: '#/components/responses/TooManyRequests' - x-scope: 'invitations:write' - x-danger: destructive - '/organizations/{organization_id}/invitations/{invitation_id}/resend': - post: - operationId: invitations_resend - x-action: invitations.resend + x-scope: 'organizations:read' + x-danger: read + patch: + operationId: organizations_update + x-action: organizations.update tags: - - Invitations - summary: 'Mail a pending invitation again' + - Organizations + summary: 'Rename an organization, change its slug, or replace its metadata' description: | - Requires scope `invitations:write`. On a **fresh link**: only a hash of the old one is - kept, so it cannot be re-sent, and the reason people ask is usually that it expired. - The response is the NEW invitation — its `id` replaces the old one, which stops - working. Its roles and app context move with it. - - One re-send per address per minute (`429 too_soon`); `409 not_pending` for one that - was accepted, withdrawn or has expired; `503 mail_failed` keeps the invitation. + Requires scope `organizations:write`. A field left out is left alone; a request + that changes nothing answers with the organization as it is and records nothing. + A change is announced as the `organization.updated` webhook and recorded on the + organization's trail as `organization.renamed`, with the values before and after. parameters: - - $ref: '#/components/parameters/OrganizationId' - - $ref: '#/components/parameters/InvitationId' + - $ref: '#/components/parameters/Id' - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' @@ -7723,48 +8608,75 @@ paths: ManagementAccessToken: [] - WorkspaceAccessToken: [] - responses: - 200: - description: 'Sent again.' - content: - application/json: - schema: - type: object - required: - - data - properties: - data: - $ref: '#/components/schemas/Invitation' - 202: - $ref: '#/components/responses/ApprovalRequired' - 400: - $ref: '#/components/responses/EnvironmentRequired' - 401: - $ref: '#/components/responses/Unauthorized' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + minLength: 1 + maxLength: 190 + slug: + type: string + minLength: 1 + maxLength: 190 + description: 'URL-safe; unique within the environment.' + metadata: + type: + - object + - 'null' + additionalProperties: + type: string + maxLength: 500 + description: 'Replaces the metadata as a whole; `null` or `{}` clears it.' + responses: + 200: + description: 'The organization after the change.' + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/Organization' + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' 403: $ref: '#/components/responses/Forbidden' 404: $ref: '#/components/responses/NotFound' - 409: - $ref: '#/components/responses/Conflict' + 422: + $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - 503: - $ref: '#/components/responses/MailFailed' - x-scope: 'invitations:write' + x-scope: 'organizations:write' x-danger: write - '/organizations/{organization_id}/members': - get: - operationId: members_list - x-action: members.list + delete: + operationId: organizations_delete + x-action: organizations.delete tags: - - Members - summary: "List an organization's members" - description: 'Requires scope `members:read`. Every membership, whatever its status, oldest first.' + - Organizations + summary: 'Archive an organization' + description: | + Requires scope `organizations:write`. **Archives** rather than erases: the status + becomes `deleted`, every member loses access from their next request, the rows stay + for the audit trail, and `organization.deleted` is announced. Idempotent — archiving + an archived organization answers with it unchanged. + + Refused with `409 owns_products` for an organization that owns identity-provider + projects on this platform (a platform customer, not a tenant of your app). parameters: - - $ref: '#/components/parameters/OrganizationId' - - $ref: '#/components/parameters/Limit' - - $ref: '#/components/parameters/After' + - $ref: '#/components/parameters/Id' + - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: @@ -7774,21 +8686,16 @@ paths: WorkspaceAccessToken: [] responses: 200: - description: OK + description: 'The archived organization.' content: application/json: schema: type: object required: - data - - meta properties: data: - type: array - items: - $ref: '#/components/schemas/Member' - meta: - $ref: '#/components/schemas/PageMeta' + $ref: '#/components/schemas/Organization' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -7799,34 +8706,31 @@ paths: $ref: '#/components/responses/Forbidden' 404: $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' 429: $ref: '#/components/responses/TooManyRequests' - x-scope: 'members:read' - x-danger: read + x-scope: 'organizations:write' + x-danger: destructive + '/organizations/{id}/reactivate': post: - operationId: members_add - x-action: members.add tags: - - Members - summary: 'Add an existing user as a member' - description: | - Requires scope `members:write`. Straight in — no invitation, no mail. Name the user by - `user_id` or by `email`; they must exist in this environment (`422 user_not_found`). - A suspended or archived organization takes no new members (`409 organization_inactive`). - - `roles` grants access roles at the same time — ids, or your app's manifest keys with - `client_id` — with the environment's authority (staff roles included). One the - organization cannot hold is `422 role_not_assignable`; a segregation-of-duties - conflict is `409 role_conflict`, and nothing is added. - - `role` is `admin` or `member` (default `member`). `owner` is never assignable: - ownership moves with `transfer-ownership`. - - **Idempotent for the same role**: repeating the request answers `200` with the - existing membership. A member on a *different* role is `409 already_member` — change - the role with `PATCH`. + - Organizations + summary: "Lift an organization's suspension so its members can sign in again." + description: 'Requires scope `organizations:write`. Danger: write.' + operationId: organizations_reactivate + x-action: organizations.reactivate + x-scope: 'organizations:write' + x-danger: write parameters: - - $ref: '#/components/parameters/OrganizationId' + - + name: id + in: path + required: true + schema: + type: string + description: 'The organization id.' + maxLength: 64 - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' @@ -7835,34 +8739,9 @@ paths: EnvironmentApiKey: [] - WorkspaceAccessToken: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - user_id: - type: string - email: - type: string - format: email - description: 'Instead of `user_id`.' - role: - $ref: '#/components/schemas/AssignableMemberRole' - roles: - type: array - maxItems: 50 - items: - type: string - client_id: - type: - - string - - 'null' - description: 'The app whose manifest keys `roles` are.' responses: 200: - description: 'Already a member with that role — nothing changed.' + description: OK content: application/json: schema: @@ -7871,9 +8750,53 @@ paths: - data properties: data: - $ref: '#/components/schemas/Member' - 201: - description: Added. + $ref: '#/components/schemas/Organization' + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' + '/organizations/{id}/suspend': + post: + tags: + - Organizations + summary: 'Suspend an organization: its members cannot sign in to it until it is reactivated.' + description: 'Requires scope `organizations:write`. Danger: write.' + operationId: organizations_suspend + x-action: organizations.suspend + x-scope: 'organizations:write' + x-danger: write + parameters: + - + name: id + in: path + required: true + schema: + type: string + description: 'The organization id.' + maxLength: 64 + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + WorkspaceAccessToken: [] + responses: + 200: + description: OK content: application/json: schema: @@ -7882,7 +8805,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/Member' + $ref: '#/components/schemas/Organization' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -7899,21 +8822,23 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - x-scope: 'members:write' - x-danger: write - '/organizations/{organization_id}/members/{user_id}': - patch: - operationId: members_update - x-action: members.update + '/organizations/{id}/transfer-ownership': + post: + operationId: organizations_transfer_ownership + x-action: organizations.transfer_ownership tags: - - Members - summary: "Change a member's role" + - Organizations + summary: "Make a member the organization's owner" description: | - Requires scope `members:write`. `role` is `admin` or `member`. Demoting the only - owner is refused with `409 last_owner` — transfer ownership first. + Requires scope `organizations:write`. The new owner must be an **active** member. + With one current owner this is a hand-over: the previous owner stays on as `admin`. + An organization with no owner (created without `owner_user_id`) gets its first; one + with several (from before ownership was transfer-only) ends with exactly one, the + others stepping down to `admin`. + + Refusals: `422 not_a_member`, `409 not_active`, `409 already_owner`. parameters: - - $ref: '#/components/parameters/OrganizationId' - - $ref: '#/components/parameters/UserId' + - $ref: '#/components/parameters/Id' - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' @@ -7931,13 +8856,13 @@ paths: schema: type: object required: - - role + - user_id properties: - role: - $ref: '#/components/schemas/AssignableMemberRole' + user_id: + type: string responses: 200: - description: 'The membership after the change.' + description: "The new owner's membership." content: application/json: schema: @@ -7963,33 +8888,52 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - x-scope: 'members:write' - x-danger: write - delete: - operationId: members_remove - x-action: members.remove + x-scope: 'organizations:write' + x-danger: critical + '/organizations/{organization_id}/api-keys': + get: + operationId: api_keys_list + x-action: api_keys.list tags: - - Members - summary: 'Remove a member' + - 'API keys' + summary: "List the API keys an organization's members created for your apps" description: | - Requires scope `members:write`. The member's roles in the organization go with the - membership. Removing the only owner is refused with `409 last_owner`. + Requires scope `api_keys:read`. Revoked and expired keys are included, marked by + `status`. Never the key itself. Narrow to one app with `?client_id=`. parameters: - $ref: '#/components/parameters/OrganizationId' - - $ref: '#/components/parameters/UserId' - - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/After' + - + name: client_id + in: query + required: false + schema: + type: string - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] - - - ManagementAccessToken: [] - WorkspaceAccessToken: [] responses: - 204: - description: Removed. + 200: + description: OK + content: + application/json: + schema: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + $ref: '#/components/schemas/ApiKey' + meta: + $ref: '#/components/schemas/PageMeta' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -8000,26 +8944,22 @@ paths: $ref: '#/components/responses/Forbidden' 404: $ref: '#/components/responses/NotFound' - 409: - $ref: '#/components/responses/Conflict' 429: $ref: '#/components/responses/TooManyRequests' - x-scope: 'members:write' - x-danger: destructive - '/organizations/{organization_id}/members/{user_id}/roles': + x-scope: 'api_keys:read' + x-danger: read + '/organizations/{organization_id}/domains': get: - operationId: members_roles_list - x-action: members.roles.list + operationId: organizations_domains_list + x-action: organizations.domains.list tags: - - Roles - summary: 'List the roles a member holds in the organization' + - Organizations + summary: 'List the email domains an organization claims' description: | - Requires scope `roles:read`. Roles granted AT this organization. Roles a person holds - everywhere in the environment (staff) are listed under - `/users/{id}/environment-roles`. + Requires scope `organizations:read`. Verified or not, each with the DNS TXT record + that proves it (`record_name`, `record_value`). A handful at most; not paged. parameters: - $ref: '#/components/parameters/OrganizationId' - - $ref: '#/components/parameters/UserId' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: @@ -8040,7 +8980,7 @@ paths: data: type: array items: - $ref: '#/components/schemas/RoleAssignment' + $ref: '#/components/schemas/OrganizationDomain' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -8053,45 +8993,50 @@ paths: $ref: '#/components/responses/NotFound' 429: $ref: '#/components/responses/TooManyRequests' - x-scope: 'roles:read' + x-scope: 'organizations:read' x-danger: read - '/organizations/{organization_id}/members/{user_id}/roles/{role_id}': - put: - operationId: members_roles_grant - x-action: members.roles.grant + post: tags: - - Roles - summary: 'Grant a member a role in the organization' - description: | - Requires scope `roles:write`. Idempotent. - - **This API grants with the environment's authority.** It is your backend, so it may - grant any role the organization can hold — including a **staff role** - (`tenant_assignable: false`), such as your support lead's "Support" role at one - customer. An organization's own administrators still cannot: their console, their - invitations and their directory mappings refuse staff roles, and nothing here - changes that. - - Refusals: `422 role_not_assignable` (another organization's role, or one from an app - this organization cannot use), `409 role_conflict` (segregation of duties). + - Organizations + summary: 'Claim an email domain (acme.com) for an organization. Returns the DNS TXT record to publish before verifying it.' + description: 'Requires scope `organizations:write`. Danger: write.' + operationId: organizations_domains_add + x-action: organizations.domains.add + x-scope: 'organizations:write' + x-danger: write parameters: - - $ref: '#/components/parameters/OrganizationId' - - $ref: '#/components/parameters/UserId' - - $ref: '#/components/parameters/RoleId' - - $ref: '#/components/parameters/RoleClientId' + - + name: organization_id + in: path + required: true + schema: + type: string + description: 'The organization id.' + maxLength: 64 - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] - - - ManagementAccessToken: [] - WorkspaceAccessToken: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + domain: + type: string + description: 'A domain name — acme.com, not an address or a URL.' + maxLength: 190 + required: + - domain responses: - 200: - description: 'Granted (or already held).' + 201: + description: OK content: application/json: schema: @@ -8100,7 +9045,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/RoleAssignment' + $ref: '#/components/schemas/OrganizationDomain' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -8117,33 +9062,44 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - x-scope: 'roles:write' - x-danger: write + '/organizations/{organization_id}/domains/{domain_id}': delete: - operationId: members_roles_revoke - x-action: members.roles.revoke tags: - - Roles - summary: 'Take a role back from a member' - description: 'Requires scope `roles:write`. Idempotent — a role the member does not hold is a 204 too.' + - Organizations + summary: 'Remove a claimed email domain from an organization. Capture on it ends.' + description: 'Requires scope `organizations:write`. Danger: destructive.' + operationId: organizations_domains_remove + x-action: organizations.domains.remove + x-scope: 'organizations:write' + x-danger: destructive parameters: - - $ref: '#/components/parameters/OrganizationId' - - $ref: '#/components/parameters/UserId' - - $ref: '#/components/parameters/RoleId' - - $ref: '#/components/parameters/RoleClientId' + - + name: organization_id + in: path + required: true + schema: + type: string + description: 'The organization id.' + maxLength: 64 + - + name: domain_id + in: path + required: true + schema: + type: string + description: "The claimed domain's id." + maxLength: 64 - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] - - - ManagementAccessToken: [] - WorkspaceAccessToken: [] responses: 204: - description: 'Not held any more.' + description: Done. 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -8154,27 +9110,40 @@ paths: $ref: '#/components/responses/Forbidden' 404: $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' + 422: + $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - x-scope: 'roles:write' - x-danger: destructive - '/organizations/{organization_id}/portal-links': - get: - operationId: organizations_portal_links_list - x-action: organizations.portal_links.list - x-scope: 'portal_links:read' - x-danger: read + '/organizations/{organization_id}/domains/{domain_id}/capture': + put: tags: - - 'Admin Portal' - summary: "List an organization's Admin Portal links" - description: | - Requires scope `portal_links:read`. Danger: read. The links minted in the last 30 - days, newest first — every link that can still be opened is among them, since a link - waits a week at most. Each says what it opens, who minted it, whom it was mailed to - and where it stands (`status`). Never the link itself: it was shown once, when it was - minted, and only its hash is kept. Not paged. + - Organizations + summary: "Turn capture on or off for a verified domain: with it on, everyone with an address there signs in through the organization's SSO." + description: 'Requires scope `organizations:write`. Danger: critical.' + operationId: organizations_domains_capture + x-action: organizations.domains.capture + x-scope: 'organizations:write' + x-danger: critical parameters: - - $ref: '#/components/parameters/OrganizationId' + - + name: organization_id + in: path + required: true + schema: + type: string + description: 'The organization id.' + maxLength: 64 + - + name: domain_id + in: path + required: true + schema: + type: string + description: "The claimed domain's id." + maxLength: 64 + - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: @@ -8182,6 +9151,18 @@ paths: EnvironmentApiKey: [] - WorkspaceAccessToken: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + enabled: + type: boolean + description: 'Whether the domain captures sign-ins.' + required: + - enabled responses: 200: description: OK @@ -8193,9 +9174,7 @@ paths: - data properties: data: - type: array - items: - $ref: '#/components/schemas/PortalLinkRecord' + $ref: '#/components/schemas/OrganizationDomain' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -8206,17 +9185,22 @@ paths: $ref: '#/components/responses/Forbidden' 404: $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' + 422: + $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' + '/organizations/{organization_id}/domains/{domain_id}/verify': post: tags: - - 'Admin Portal' - summary: "Create a one-time Admin Portal link an organization's IT administrator uses to set up SSO, directory sync, domain verification, log streams or SAML certificate renewal, or to read its audit logs, without an account. The URL is shown once." - description: 'Requires scope `portal_links:write`. Danger: critical.' - operationId: organizations_portal_links_create - x-action: organizations.portal_links.create - x-scope: 'portal_links:write' - x-danger: critical + - Organizations + summary: "Check a claimed domain's DNS TXT record and mark the domain verified if it is published." + description: 'Requires scope `organizations:write`. Danger: write.' + operationId: organizations_domains_verify + x-action: organizations.domains.verify + x-scope: 'organizations:write' + x-danger: write parameters: - name: organization_id @@ -8224,7 +9208,15 @@ paths: required: true schema: type: string - description: 'The organization the link sets up.' + description: 'The organization id.' + maxLength: 64 + - + name: domain_id + in: path + required: true + schema: + type: string + description: "The claimed domain's id." maxLength: 64 - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' @@ -8232,60 +9224,10 @@ paths: security: - EnvironmentApiKey: [] - - - ManagementAccessToken: [] - WorkspaceAccessToken: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - intents: - type: array - description: "What the link may set up: sso (connection and email domains), dsync (directory sync over SCIM), domain_verification, log_streams, certificate_renewal (a SAML connection's signing certificate), audit_logs (the organization's audit events, read-only, with CSV export)." - minItems: 1 - items: - type: string - enum: - - sso - - dsync - - domain_verification - - log_streams - - certificate_renewal - - audit_logs - expires_in_minutes: - type: - - integer - - 'null' - description: "How long the link may wait to be opened, in minutes — 5 to 10080 (a week). Left out, the deployment's default (30)." - minimum: 5 - maximum: 10080 - email: - type: - - string - - 'null' - description: "Mail the link to this address — the customer's IT contact. Left out, nothing is sent." - format: email - maxLength: 254 - locale: - type: - - string - - 'null' - description: "The language of that mail. Left out, the environment's default language." - enum: - - en - - da - - de - - sv - - nb - - fr - required: - - intents responses: - 201: + 200: description: OK content: application/json: @@ -8295,7 +9237,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/PortalLink' + $ref: '#/components/schemas/OrganizationDomain' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -8312,46 +9254,42 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - '/organizations/{organization_id}/portal-links/{id}': - delete: + '/organizations/{organization_id}/invitations': + get: + operationId: invitations_list + x-action: invitations.list tags: - - 'Admin Portal' - summary: 'Withdraw an Admin Portal link: it can no longer be opened, and a setup session it already opened ends on its next request. What was already set up through it stays.' - description: 'Requires scope `portal_links:write`. Danger: destructive.' - operationId: organizations_portal_links_revoke - x-action: organizations.portal_links.revoke - x-scope: 'portal_links:write' - x-danger: destructive + - Invitations + summary: "List an organization's pending invitations" + description: 'Requires scope `invitations:read`. Pending and unexpired only.' parameters: - - - name: organization_id - in: path - required: true - schema: - type: string - description: 'The organization id.' - maxLength: 64 - - - name: id - in: path - required: true - schema: - type: string - description: "The portal link's id." - maxLength: 64 - - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/OrganizationId' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/After' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] - - - ManagementAccessToken: [] - WorkspaceAccessToken: [] responses: - 204: - description: Done. + 200: + description: OK + content: + application/json: + schema: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + $ref: '#/components/schemas/Invitation' + meta: + $ref: '#/components/schemas/PageMeta' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -8362,23 +9300,38 @@ paths: $ref: '#/components/responses/Forbidden' 404: $ref: '#/components/responses/NotFound' - 409: - $ref: '#/components/responses/Conflict' - 422: - $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - /permissions: + x-scope: 'invitations:read' + x-danger: read post: + operationId: invitations_send + x-action: invitations.send tags: - - Roles - summary: "Author a manual `feature:action` permission, shared with the environment (organization_id null) or one organization's own." - description: 'Requires scope `role_definitions:write`. Danger: write.' - operationId: permissions_create - x-action: permissions.create - x-scope: 'role_definitions:write' - x-danger: write + - Invitations + summary: 'Invite someone to the organization' + description: | + Requires scope `invitations:write`. Mails a link; the person joins when they open it + and confirm. Inviting an address again replaces its earlier pending invitation. + + - `role` — `admin` or `member` (default `member`). Never `owner`. + - `roles` — access roles granted when the invitation is accepted: role ids, or your + app's manifest **keys** when `client_id` names the app that declared them. Only + roles the organization's own administrators could grant are accepted: a **staff + role** is refused (`422 role_not_assignable`) — grant it after they join, with + `PUT …/members/{user_id}/roles/{role_id}`. + - `client_id` + `return_to` — after accepting, the person is sent to `return_to`, + which must be on one of that app's registered redirect-URI origins (checked again + when the invitation is accepted). + - `inviter_name` — who the mail says it is from. Defaults to the app's name, or the + environment's. + + Refusals: `409 already_member`, `409 access_role_conflict` (segregation of duties), + `422 unknown_role`, `422 role_not_assignable`, `422 unknown_app`, + `422 return_without_app`, `422 return_to_malformed`, `422 return_to_not_registered`, + `503 mail_failed` (nothing was created — retry). parameters: + - $ref: '#/components/parameters/OrganizationId' - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' @@ -8395,30 +9348,38 @@ paths: application/json: schema: type: object + required: + - email properties: - name: + email: type: string - description: 'The key: a feature and an action joined by a colon, like invoices:create.' - maxLength: 120 - description: + format: email + maxLength: 190 + role: + $ref: '#/components/schemas/AssignableMemberRole' + roles: + type: array + maxItems: 50 + items: + type: string + client_id: type: - string - 'null' - maxLength: 500 - organization_id: + return_to: type: - string - 'null' - description: "The organization it belongs to; null for the environment's shared tier." - maxLength: 64 - tenant_assignable: - type: boolean - description: "Shared tier only: whether organizations may compose it into their own roles. Default false; always true on an organization's own." - required: - - name + format: uri + maxLength: 2048 + inviter_name: + type: + - string + - 'null' + maxLength: 120 responses: 201: - description: OK + description: Sent. content: application/json: schema: @@ -8427,7 +9388,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/Permission' + $ref: '#/components/schemas/Invitation' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -8444,78 +9405,36 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - get: + 503: + $ref: '#/components/responses/MailFailed' + x-scope: 'invitations:write' + x-danger: write + '/organizations/{organization_id}/invitations/{invitation_id}': + delete: + operationId: invitations_revoke + x-action: invitations.revoke tags: - - Roles - summary: "List the permission catalogue roles are composed from, a page at a time. Narrow by `client_id` (one app's), `organization_id` (what it can see) or a `q` fragment." - description: 'Requires scope `roles:read`. Danger: read.' - operationId: permissions_list - x-action: permissions.list - x-scope: 'roles:read' - x-danger: read + - Invitations + summary: 'Withdraw a pending invitation' + description: | + Requires scope `invitations:write`. The link stops working, and the roles parked + for it go with it. `409 not_pending` for one already accepted, withdrawn or expired. parameters: - - - name: limit - in: query - required: false - schema: - type: integer - description: 'Items per page, 1–100. Default 50.' - - - name: after - in: query - required: false - schema: - type: string - description: 'The `next_cursor` of the previous page.' - - - name: organization_id - in: query - required: false - schema: - type: string - description: 'Also the permissions this organization authored for itself.' - maxLength: 64 - - - name: client_id - in: query - required: false - schema: - type: string - description: 'Only the permissions this app declared.' - maxLength: 255 - - - name: q - in: query - required: false - schema: - type: string - description: 'Only permissions whose key or description contains this.' - maxLength: 120 + - $ref: '#/components/parameters/OrganizationId' + - $ref: '#/components/parameters/InvitationId' + - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] + - + ManagementAccessToken: [] - WorkspaceAccessToken: [] responses: - 200: - description: OK - content: - application/json: - schema: - type: object - required: - - data - - meta - properties: - data: - type: array - items: - $ref: '#/components/schemas/Permission' - meta: - $ref: '#/components/schemas/PageMeta' + 204: + description: Withdrawn. 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -8526,29 +9445,30 @@ paths: $ref: '#/components/responses/Forbidden' 404: $ref: '#/components/responses/NotFound' - 422: - $ref: '#/components/responses/UnprocessableEntity' + 409: + $ref: '#/components/responses/Conflict' 429: $ref: '#/components/responses/TooManyRequests' - '/permissions/{id}': - delete: - tags: - - Roles - summary: 'Delete a manual permission: every role carrying it loses it first.' - description: 'Requires scope `role_definitions:write`. Danger: destructive.' - operationId: permissions_delete - x-action: permissions.delete - x-scope: 'role_definitions:write' + x-scope: 'invitations:write' x-danger: destructive + '/organizations/{organization_id}/invitations/{invitation_id}/resend': + post: + operationId: invitations_resend + x-action: invitations.resend + tags: + - Invitations + summary: 'Mail a pending invitation again' + description: | + Requires scope `invitations:write`. On a **fresh link**: only a hash of the old one is + kept, so it cannot be re-sent, and the reason people ask is usually that it expired. + The response is the NEW invitation — its `id` replaces the old one, which stops + working. Its roles and app context move with it. + + One re-send per address per minute (`429 too_soon`); `409 not_pending` for one that + was accepted, withdrawn or has expired; `503 mail_failed` keeps the invitation. parameters: - - - name: id - in: path - required: true - schema: - type: string - description: 'The permission id.' - maxLength: 64 + - $ref: '#/components/parameters/OrganizationId' + - $ref: '#/components/parameters/InvitationId' - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' @@ -8560,8 +9480,17 @@ paths: - WorkspaceAccessToken: [] responses: - 204: - description: Done. + 200: + description: 'Sent again.' + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/Invitation' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -8574,53 +9503,31 @@ paths: $ref: '#/components/responses/NotFound' 409: $ref: '#/components/responses/Conflict' - 422: - $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - patch: - tags: - - Roles - summary: "Change a manual permission's description, and on the shared tier whether organizations may use it. The key itself never changes." - description: 'Requires scope `role_definitions:write`. Danger: write.' - operationId: permissions_update - x-action: permissions.update - x-scope: 'role_definitions:write' + 503: + $ref: '#/components/responses/MailFailed' + x-scope: 'invitations:write' x-danger: write + '/organizations/{organization_id}/members': + get: + operationId: members_list + x-action: members.list + tags: + - Members + summary: "List an organization's members" + description: 'Requires scope `members:read`. Every membership, whatever its status, oldest first.' parameters: - - - name: id - in: path - required: true - schema: - type: string - description: 'The permission id.' - maxLength: 64 - - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/OrganizationId' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/After' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] - - - ManagementAccessToken: [] - WorkspaceAccessToken: [] - requestBody: - required: false - content: - application/json: - schema: - type: object - properties: - description: - type: - - string - - 'null' - maxLength: 500 - tenant_assignable: - type: boolean - description: 'Shared tier only: whether organizations may compose it into their own roles.' responses: 200: description: OK @@ -8630,9 +9537,14 @@ paths: type: object required: - data + - meta properties: data: - $ref: '#/components/schemas/Permission' + type: array + items: + $ref: '#/components/schemas/Member' + meta: + $ref: '#/components/schemas/PageMeta' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -8643,31 +9555,40 @@ paths: $ref: '#/components/responses/Forbidden' 404: $ref: '#/components/responses/NotFound' - 409: - $ref: '#/components/responses/Conflict' - 422: - $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - /provisioning-targets: + x-scope: 'members:read' + x-danger: read post: + operationId: members_add + x-action: members.add tags: - - 'Outbound provisioning' - summary: "Register a downstream app this platform pushes an organization's people to over SCIM, or every organization's with environment_wide." - description: 'Requires scope `provisioning:write`. Danger: critical.' - operationId: provisioning_targets_create - x-action: provisioning.targets.create - x-scope: 'provisioning:write' - x-danger: critical + - Members + summary: 'Add an existing user as a member' + description: | + Requires scope `members:write`. Straight in — no invitation, no mail. Name the user by + `user_id` or by `email`; they must exist in this environment (`422 user_not_found`). + A suspended or archived organization takes no new members (`409 organization_inactive`). + + `roles` grants access roles at the same time — ids, or your app's manifest keys with + `client_id` — with the environment's authority (staff roles included). One the + organization cannot hold is `422 role_not_assignable`; a segregation-of-duties + conflict is `409 role_conflict`, and nothing is added. + + `role` is `admin` or `member` (default `member`). `owner` is never assignable: + ownership moves with `transfer-ownership`. + + **Idempotent for the same role**: repeating the request answers `200` with the + existing membership. A member on a *different* role is `409 already_member` — change + the role with `PATCH`. parameters: + - $ref: '#/components/parameters/OrganizationId' - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] - - - ManagementAccessToken: [] - WorkspaceAccessToken: [] requestBody: @@ -8677,61 +9598,38 @@ paths: schema: type: object properties: - organization_id: - type: - - string - - 'null' - description: 'The organization the target belongs to. Send this or environment_wide.' - maxLength: 64 - environment_wide: - type: boolean - description: "True to make the target the environment's own rather than one organization's. Send this or organization_id." - name: - type: string - description: 'What administrators call it: "Slack", "Salesforce".' - maxLength: 190 - base_url: - type: string - description: "The target's SCIM base URL. Must be a public address." - format: uri - maxLength: 500 - auth_scheme: + user_id: type: string - description: 'bearer (a long-lived token the app issued you) or oauth2_client_credentials (a token fetched before each batch).' - enum: - - bearer - - oauth2_client_credentials - secret: + email: type: string - description: 'The bearer token, or the OAuth client secret. Write-only.' - maxLength: 4096 - token_url: - type: - - string - - 'null' - description: 'oauth2_client_credentials: the token endpoint.' - format: uri - maxLength: 2048 + format: email + description: 'Instead of `user_id`.' + role: + $ref: '#/components/schemas/AssignableMemberRole' + roles: + type: array + maxItems: 50 + items: + type: string client_id: type: - string - 'null' - description: 'oauth2_client_credentials: the client id.' - maxLength: 400 - scope: - type: - - string - - 'null' - description: 'oauth2_client_credentials: the scope to ask for, if the app wants one.' - maxLength: 400 - required: - - name - - base_url - - auth_scheme - - secret + description: 'The app whose manifest keys `roles` are.' responses: + 200: + description: 'Already a member with that role — nothing changed.' + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/Member' 201: - description: OK + description: Added. content: application/json: schema: @@ -8740,7 +9638,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/ProvisioningTarget' + $ref: '#/components/schemas/Member' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -8757,64 +9655,54 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - get: + x-scope: 'members:write' + x-danger: write + '/organizations/{organization_id}/members/{user_id}': + patch: + operationId: members_update + x-action: members.update tags: - - 'Outbound provisioning' - summary: 'List the downstream SCIM targets people are provisioned to, optionally for one organization, with their failures. Never their credentials.' - description: 'Requires scope `provisioning:read`. Danger: read.' - operationId: provisioning_targets_list - x-action: provisioning.targets.list - x-scope: 'provisioning:read' - x-danger: read + - Members + summary: "Change a member's role" + description: | + Requires scope `members:write`. `role` is `admin` or `member`. Demoting the only + owner is refused with `409 last_owner` — transfer ownership first. parameters: - - - name: organization_id - in: query - required: false - schema: - type: - - string - - 'null' - description: "Only this organization's." - maxLength: 64 - - - name: limit - in: query - required: false - schema: - type: integer - description: 'Items per page, 1–100. Default 50.' - - - name: after - in: query - required: false - schema: - type: string - description: 'The `next_cursor` of the previous page.' - - $ref: '#/components/parameters/CboxApproval' - - $ref: '#/components/parameters/CboxEnvironment' - security: + - $ref: '#/components/parameters/OrganizationId' + - $ref: '#/components/parameters/UserId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: - EnvironmentApiKey: [] + - + ManagementAccessToken: [] - WorkspaceAccessToken: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - role + properties: + role: + $ref: '#/components/schemas/AssignableMemberRole' responses: 200: - description: OK + description: 'The membership after the change.' content: application/json: schema: type: object required: - data - - meta properties: data: - type: array - items: - $ref: '#/components/schemas/ProvisioningTarget' - meta: - $ref: '#/components/schemas/PageMeta' + $ref: '#/components/schemas/Member' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -8825,28 +9713,26 @@ paths: $ref: '#/components/responses/Forbidden' 404: $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' 422: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - '/provisioning-targets/{id}': + x-scope: 'members:write' + x-danger: write delete: + operationId: members_remove + x-action: members.remove tags: - - 'Outbound provisioning' - summary: 'Delete a downstream SCIM target. Nothing more is pushed to it.' - description: 'Requires scope `provisioning:write`. Danger: destructive.' - operationId: provisioning_targets_delete - x-action: provisioning.targets.delete - x-scope: 'provisioning:write' - x-danger: destructive + - Members + summary: 'Remove a member' + description: | + Requires scope `members:write`. The member's roles in the organization go with the + membership. Removing the only owner is refused with `409 last_owner`. parameters: - - - name: id - in: path - required: true - schema: - type: string - description: "The target's id." + - $ref: '#/components/parameters/OrganizationId' + - $ref: '#/components/parameters/UserId' - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' @@ -8857,22 +9743,9 @@ paths: ManagementAccessToken: [] - WorkspaceAccessToken: [] - requestBody: - required: false - content: - application/json: - schema: - type: object - properties: - organization_id: - type: - - string - - 'null' - description: "Only if it is this organization's; anything else is a 404." - maxLength: 64 responses: 204: - description: Done. + description: Removed. 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -8885,37 +9758,24 @@ paths: $ref: '#/components/responses/NotFound' 409: $ref: '#/components/responses/Conflict' - 422: - $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' + x-scope: 'members:write' + x-danger: destructive + '/organizations/{organization_id}/members/{user_id}/roles': get: + operationId: members_roles_list + x-action: members.roles.list tags: - - 'Outbound provisioning' - summary: 'Read one downstream SCIM target: its URL, auth scheme, status and last error. Never its credential.' - description: 'Requires scope `provisioning:read`. Danger: read.' - operationId: provisioning_targets_get - x-action: provisioning.targets.get - x-scope: 'provisioning:read' - x-danger: read + - Roles + summary: 'List the roles a member holds in the organization' + description: | + Requires scope `roles:read`. Roles granted AT this organization. Roles a person holds + everywhere in the environment (staff) are listed under + `/users/{id}/environment-roles`. parameters: - - - name: id - in: path - required: true - schema: - type: string - description: "The target's id." - - - name: organization_id - in: query - required: false - schema: - type: - - string - - 'null' - description: "Only if it is this organization's; anything else is a 404." - maxLength: 64 + - $ref: '#/components/parameters/OrganizationId' + - $ref: '#/components/parameters/UserId' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: @@ -8934,7 +9794,9 @@ paths: - data properties: data: - $ref: '#/components/schemas/ProvisioningTarget' + type: array + items: + $ref: '#/components/schemas/RoleAssignment' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -8945,28 +9807,34 @@ paths: $ref: '#/components/responses/Forbidden' 404: $ref: '#/components/responses/NotFound' - 422: - $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - '/provisioning-targets/{id}/status': - post: + x-scope: 'roles:read' + x-danger: read + '/organizations/{organization_id}/members/{user_id}/roles/{role_id}': + put: + operationId: members_roles_grant + x-action: members.roles.grant tags: - - 'Outbound provisioning' - summary: 'Pause or resume pushing people to a downstream SCIM target.' - description: 'Requires scope `provisioning:write`. Danger: write.' - operationId: provisioning_targets_status_set - x-action: provisioning.targets.status.set - x-scope: 'provisioning:write' - x-danger: write + - Roles + summary: 'Grant a member a role in the organization' + description: | + Requires scope `roles:write`. Idempotent. + + **This API grants with the environment's authority.** It is your backend, so it may + grant any role the organization can hold — including a **staff role** + (`tenant_assignable: false`), such as your support lead's "Support" role at one + customer. An organization's own administrators still cannot: their console, their + invitations and their directory mappings refuse staff roles, and nothing here + changes that. + + Refusals: `422 role_not_assignable` (another organization's role, or one from an app + this organization cannot use), `409 role_conflict` (segregation of duties). parameters: - - - name: id - in: path - required: true - schema: - type: string - description: "The target's id." + - $ref: '#/components/parameters/OrganizationId' + - $ref: '#/components/parameters/UserId' + - $ref: '#/components/parameters/RoleId' + - $ref: '#/components/parameters/RoleClientId' - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' @@ -8977,27 +9845,9 @@ paths: ManagementAccessToken: [] - WorkspaceAccessToken: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - active: - type: boolean - description: 'true to resume pushing, false to pause.' - organization_id: - type: - - string - - 'null' - description: "Only if it is this organization's; anything else is a 404." - maxLength: 64 - required: - - active responses: 200: - description: OK + description: 'Granted (or already held).' content: application/json: schema: @@ -9006,7 +9856,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/ProvisioningTarget' + $ref: '#/components/schemas/RoleAssignment' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -9023,51 +9873,33 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - /roles: - get: - operationId: roles_list - x-action: roles.list + x-scope: 'roles:write' + x-danger: write + delete: + operationId: members_roles_revoke + x-action: members.roles.revoke tags: - Roles - summary: 'List roles' - description: | - Requires scope `roles:read`. Every role that can still be granted, ordered by name, - with the permissions each carries. Not paginated. `?client_id=` narrows to one - app's roles; `?organization_id=` to what can be granted in that organization. + summary: 'Take a role back from a member' + description: 'Requires scope `roles:write`. Idempotent — a role the member does not hold is a 204 too.' parameters: - - - name: client_id - in: query - required: false - schema: - type: string - - - name: organization_id - in: query - required: false - schema: - type: string + - $ref: '#/components/parameters/OrganizationId' + - $ref: '#/components/parameters/UserId' + - $ref: '#/components/parameters/RoleId' + - $ref: '#/components/parameters/RoleClientId' + - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] + - + ManagementAccessToken: [] - WorkspaceAccessToken: [] responses: - 200: - description: OK - content: - application/json: - schema: - type: object - required: - - data - properties: - data: - type: array - items: - $ref: '#/components/schemas/Role' + 204: + description: 'Not held any more.' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -9080,66 +9912,34 @@ paths: $ref: '#/components/responses/NotFound' 429: $ref: '#/components/responses/TooManyRequests' - x-scope: 'roles:read' + x-scope: 'roles:write' + x-danger: destructive + '/organizations/{organization_id}/portal-links': + get: + operationId: organizations_portal_links_list + x-action: organizations.portal_links.list + x-scope: 'portal_links:read' x-danger: read - post: tags: - - Roles - summary: 'Define a role for one organization, or (organization_id null) for the whole environment, optionally scoped to one app, with its opening permissions.' - description: 'Requires scope `role_definitions:write`. Danger: write.' - operationId: roles_create - x-action: roles.create - x-scope: 'role_definitions:write' - x-danger: write + - 'Admin Portal' + summary: "List an organization's Admin Portal links" + description: | + Requires scope `portal_links:read`. Danger: read. The links minted in the last 30 + days, newest first — every link that can still be opened is among them, since a link + waits a week at most. Each says what it opens, who minted it, whom it was mailed to + and where it stands (`status`). Never the link itself: it was shown once, when it was + minted, and only its hash is kept. Not paged. parameters: - - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/OrganizationId' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] - - - ManagementAccessToken: [] - WorkspaceAccessToken: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - name: - type: string - maxLength: 120 - description: - type: - - string - - 'null' - maxLength: 500 - organization_id: - type: - - string - - 'null' - description: 'The organization it belongs to; null defines it for every organization in the environment.' - maxLength: 64 - client_id: - type: - - string - - 'null' - description: 'The app whose tokens it is stamped into; null for every app.' - maxLength: 190 - permissions: - type: array - description: 'Permission ids to grant it from the start.' - maxItems: 200 - items: - type: string - maxLength: 64 - required: - - name responses: - 201: + 200: description: OK content: application/json: @@ -9149,7 +9949,9 @@ paths: - data properties: data: - $ref: '#/components/schemas/Role' + type: array + items: + $ref: '#/components/schemas/PortalLinkRecord' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -9160,30 +9962,25 @@ paths: $ref: '#/components/responses/Forbidden' 404: $ref: '#/components/responses/NotFound' - 409: - $ref: '#/components/responses/Conflict' - 422: - $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - '/roles/{id}': - delete: + post: tags: - - Roles - summary: "Delete a role: everyone who holds it loses it. An app-declared role is removed from the app's manifest instead." - description: 'Requires scope `role_definitions:write`. Danger: destructive.' - operationId: roles_delete - x-action: roles.delete - x-scope: 'role_definitions:write' - x-danger: destructive + - 'Admin Portal' + summary: "Create a one-time Admin Portal link an organization's IT administrator uses to set up SSO, directory sync, domain verification, log streams or SAML certificate renewal, or to read its audit logs, without an account. The URL is shown once." + description: 'Requires scope `portal_links:write`. Danger: critical.' + operationId: organizations_portal_links_create + x-action: organizations.portal_links.create + x-scope: 'portal_links:write' + x-danger: critical parameters: - - name: id + name: organization_id in: path required: true schema: type: string - description: 'The role id.' + description: 'The organization the link sets up.' maxLength: 64 - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' @@ -9195,9 +9992,66 @@ paths: ManagementAccessToken: [] - WorkspaceAccessToken: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + intents: + type: array + description: "What the link may set up: sso (connection and email domains), dsync (directory sync over SCIM), domain_verification, log_streams, certificate_renewal (a SAML connection's signing certificate), audit_logs (the organization's audit events, read-only, with CSV export)." + minItems: 1 + items: + type: string + enum: + - sso + - dsync + - domain_verification + - log_streams + - certificate_renewal + - audit_logs + expires_in_minutes: + type: + - integer + - 'null' + description: "How long the link may wait to be opened, in minutes — 5 to 10080 (a week). Left out, the deployment's default (30)." + minimum: 5 + maximum: 10080 + email: + type: + - string + - 'null' + description: "Mail the link to this address — the customer's IT contact. Left out, nothing is sent." + format: email + maxLength: 254 + locale: + type: + - string + - 'null' + description: "The language of that mail. Left out, the environment's default language." + enum: + - en + - da + - de + - sv + - nb + - fr + required: + - intents responses: - 204: - description: Done. + 201: + description: OK + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/PortalLink' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -9214,51 +10068,46 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - get: + '/organizations/{organization_id}/portal-links/{id}': + delete: tags: - - Roles - summary: 'Get one role and the permissions it carries. Name it by id, or by manifest `key` with `client_id`.' - description: 'Requires scope `roles:read`. Danger: read.' - operationId: roles_get - x-action: roles.get - x-scope: 'roles:read' - x-danger: read + - 'Admin Portal' + summary: 'Withdraw an Admin Portal link: it can no longer be opened, and a setup session it already opened ends on its next request. What was already set up through it stays.' + description: 'Requires scope `portal_links:write`. Danger: destructive.' + operationId: organizations_portal_links_revoke + x-action: organizations.portal_links.revoke + x-scope: 'portal_links:write' + x-danger: destructive parameters: - - name: id + name: organization_id in: path required: true schema: type: string - description: 'The role id, or its manifest `key` with `client_id`.' - maxLength: 190 + description: 'The organization id.' + maxLength: 64 - - name: client_id - in: query - required: false + name: id + in: path + required: true schema: type: string - description: 'The app whose manifest `key` `id` is.' - maxLength: 255 + description: "The portal link's id." + maxLength: 64 + - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] + - + ManagementAccessToken: [] - WorkspaceAccessToken: [] responses: - 200: - description: OK - content: - application/json: - schema: - type: object - required: - - data - properties: - data: - $ref: '#/components/schemas/Role' + 204: + description: Done. 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -9269,28 +10118,23 @@ paths: $ref: '#/components/responses/Forbidden' 404: $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' 422: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - patch: + /permissions: + post: tags: - Roles - summary: "Rename a role or change its description. An app-declared role is changed in the app's manifest instead." + summary: "Author a manual `feature:action` permission, shared with the environment (organization_id null) or one organization's own." description: 'Requires scope `role_definitions:write`. Danger: write.' - operationId: roles_update - x-action: roles.update + operationId: permissions_create + x-action: permissions.create x-scope: 'role_definitions:write' x-danger: write parameters: - - - name: id - in: path - required: true - schema: - type: string - description: 'The role id.' - maxLength: 64 - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' @@ -9302,7 +10146,7 @@ paths: - WorkspaceAccessToken: [] requestBody: - required: false + required: true content: application/json: schema: @@ -9310,15 +10154,26 @@ paths: properties: name: type: string - minLength: 1 + description: 'The key: a feature and an action joined by a colon, like invoices:create.' maxLength: 120 description: type: - string - 'null' maxLength: 500 + organization_id: + type: + - string + - 'null' + description: "The organization it belongs to; null for the environment's shared tier." + maxLength: 64 + tenant_assignable: + type: boolean + description: "Shared tier only: whether organizations may compose it into their own roles. Default false; always true on an organization's own." + required: + - name responses: - 200: + 201: description: OK content: application/json: @@ -9328,7 +10183,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/Role' + $ref: '#/components/schemas/Permission' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -9345,41 +10200,59 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - '/roles/{id}/permissions/{permission_id}': - put: + get: tags: - Roles - summary: "Add a permission to a role: everyone holding the role gains it. The permission must be unscoped or declared by the role's own app." - description: 'Requires scope `role_definitions:write`. Danger: write.' - operationId: roles_permissions_grant - x-action: roles.permissions.grant - x-scope: 'role_definitions:write' - x-danger: write + summary: "List the permission catalogue roles are composed from, a page at a time. Narrow by `client_id` (one app's), `organization_id` (what it can see) or a `q` fragment." + description: 'Requires scope `roles:read`. Danger: read.' + operationId: permissions_list + x-action: permissions.list + x-scope: 'roles:read' + x-danger: read parameters: - - name: id - in: path - required: true + name: limit + in: query + required: false schema: - type: string - description: 'The role id.' - maxLength: 64 + type: integer + description: 'Items per page, 1–100. Default 50.' - - name: permission_id - in: path - required: true + name: after + in: query + required: false schema: type: string - description: 'The permission id.' + description: 'The `next_cursor` of the previous page.' + - + name: organization_id + in: query + required: false + schema: + type: string + description: 'Also the permissions this organization authored for itself.' maxLength: 64 - - $ref: '#/components/parameters/IdempotencyKey' + - + name: client_id + in: query + required: false + schema: + type: string + description: 'Only the permissions this app declared.' + maxLength: 255 + - + name: q + in: query + required: false + schema: + type: string + description: 'Only permissions whose key or description contains this.' + maxLength: 120 - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] - - - ManagementAccessToken: [] - WorkspaceAccessToken: [] responses: @@ -9391,9 +10264,14 @@ paths: type: object required: - data + - meta properties: data: - $ref: '#/components/schemas/Role' + type: array + items: + $ref: '#/components/schemas/Permission' + meta: + $ref: '#/components/schemas/PageMeta' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -9404,19 +10282,18 @@ paths: $ref: '#/components/responses/Forbidden' 404: $ref: '#/components/responses/NotFound' - 409: - $ref: '#/components/responses/Conflict' 422: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' + '/permissions/{id}': delete: tags: - Roles - summary: 'Remove a permission from a role: everyone holding the role loses it.' + summary: 'Delete a manual permission: every role carrying it loses it first.' description: 'Requires scope `role_definitions:write`. Danger: destructive.' - operationId: roles_permissions_revoke - x-action: roles.permissions.revoke + operationId: permissions_delete + x-action: permissions.delete x-scope: 'role_definitions:write' x-danger: destructive parameters: @@ -9426,10 +10303,49 @@ paths: required: true schema: type: string - description: 'The role id.' + description: 'The permission id.' maxLength: 64 + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: - - name: permission_id + EnvironmentApiKey: [] + - + ManagementAccessToken: [] + - + WorkspaceAccessToken: [] + responses: + 204: + description: Done. + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' + patch: + tags: + - Roles + summary: "Change a manual permission's description, and on the shared tier whether organizations may use it. The key itself never changes." + description: 'Requires scope `role_definitions:write`. Danger: write.' + operationId: permissions_update + x-action: permissions.update + x-scope: 'role_definitions:write' + x-danger: write + parameters: + - + name: id in: path required: true schema: @@ -9446,6 +10362,21 @@ paths: ManagementAccessToken: [] - WorkspaceAccessToken: [] + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + description: + type: + - string + - 'null' + maxLength: 500 + tenant_assignable: + type: boolean + description: 'Shared tier only: whether organizations may compose it into their own roles.' responses: 200: description: OK @@ -9457,7 +10388,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/Role' + $ref: '#/components/schemas/Permission' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -9474,16 +10405,16 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - /saml-apps: + /pipes: post: tags: - - 'SAML apps' - summary: 'Register a SAML app that signs people in with their account here. Decides where assertions — and their attributes — are sent.' - description: 'Requires scope `saml_apps:write`. Danger: critical.' - operationId: saml_apps_create - x-action: saml_apps.create - x-scope: 'saml_apps:write' - x-danger: critical + - Pipes + summary: "Configure a pipe: the environment's OAuth app at a third-party provider (GitHub, Google, Microsoft 365, Slack, Salesforce, HubSpot, Linear, Notion), so people can connect their accounts. The client secret is sealed and never returned." + description: 'Requires scope `pipes:write`. Danger: write.' + operationId: pipes_create + x-action: pipes.create + x-scope: 'pipes:write' + x-danger: write parameters: - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' @@ -9500,63 +10431,48 @@ paths: schema: type: object properties: - entity_id: - type: string - description: "The application's SAML EntityID, unique in this environment." - maxLength: 500 - acs_url: - type: string - description: 'Where assertions are posted: https (http only on localhost), absolute, no fragment.' - format: uri - maxLength: 1000 - name_id_format: + provider: type: string - description: 'The NameID format URN. Default emailAddress.' + description: "The provider's key." enum: - - 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress' - - 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent' - - 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient' - - 'urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified' - name_id_attribute: + - github + - google + - microsoft + - slack + - salesforce + - hubspot + - linear + - notion + client_id: type: string - description: "The person's field the NameID is read from. Default `email`." - maxLength: 120 - attribute_mappings: + description: 'The OAuth client id of your app at the provider.' + maxLength: 500 + client_secret: + type: string + description: 'Its client secret. Write-only: sealed, never returned.' + maxLength: 4000 + scopes: type: array - description: 'The attributes the assertion carries. Sent, it is the COMPLETE list afterwards.' - maxItems: 100 + description: "The OAuth scopes to ask people for. Left out: the provider's defaults." items: - type: object - properties: - attribute: - type: string - description: 'The attribute name the assertion emits: `email`, `displayName`.' - maxLength: 190 - field: - type: string - description: "The person's field it is read from: `email`, `name`." - maxLength: 190 - required: - - attribute - - field - want_authn_requests_signed: - type: boolean - description: "Verify the application's signature on every AuthnRequest. Needs a certificate." - certificate: - type: - - string - - 'null' - description: "The application's signing certificate (PEM). Write-only; left out or null keeps the one on file." - maxLength: 20000 - organization_id: - type: - - string - - 'null' - description: 'The organization that owns the application: only its active members are signed in to it, and the assertion names it. Null makes it environment-wide — every person in the environment may sign in to it.' - maxLength: 64 + type: string + maxLength: 190 + parameters: + type: object + description: "Per-installation values some providers need. Left out: the provider's defaults." + properties: + tenant: + type: string + description: "Microsoft 365 only: `common` (default), `organizations`, or one directory's tenant id or domain." + maxLength: 253 + domain: + type: string + description: 'Salesforce only: `login.salesforce.com` (default), `test.salesforce.com`, or your My Domain host.' + maxLength: 253 required: - - entity_id - - acs_url + - provider + - client_id + - client_secret responses: 201: description: OK @@ -9568,7 +10484,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/SamlApp' + $ref: '#/components/schemas/Pipe' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -9587,12 +10503,12 @@ paths: $ref: '#/components/responses/TooManyRequests' get: tags: - - 'SAML apps' - summary: 'List the SAML apps that trust this environment as their identity provider.' - description: 'Requires scope `saml_apps:read`. Danger: read.' - operationId: saml_apps_list - x-action: saml_apps.list - x-scope: 'saml_apps:read' + - Pipes + summary: 'List the pipes — the third-party providers people can connect their accounts to — with their scopes and the apps granted their tokens. Never a client secret.' + description: 'Requires scope `pipes:read`. Danger: read.' + operationId: pipes_list + x-action: pipes.list + x-scope: 'pipes:read' x-danger: read parameters: - @@ -9630,7 +10546,7 @@ paths: data: type: array items: - $ref: '#/components/schemas/SamlApp' + $ref: '#/components/schemas/Pipe' meta: $ref: '#/components/schemas/PageMeta' 202: @@ -9647,16 +10563,16 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - '/saml-apps/{id}': + '/pipes/{id}': delete: tags: - - 'SAML apps' - summary: 'Remove a SAML app. People can no longer sign in to it with their account here.' - description: 'Requires scope `saml_apps:write`. Danger: critical.' - operationId: saml_apps_delete - x-action: saml_apps.delete - x-scope: 'saml_apps:write' - x-danger: critical + - Pipes + summary: 'Remove a pipe and every connection through it. Their tokens are revoked here, not at the provider — disconnect people first if that matters.' + description: 'Requires scope `pipes:write`. Danger: destructive.' + operationId: pipes_delete + x-action: pipes.delete + x-scope: 'pipes:write' + x-danger: destructive parameters: - name: id @@ -9664,7 +10580,7 @@ paths: required: true schema: type: string - description: "The SAML app's id." + description: "The pipe's id." - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' @@ -9694,12 +10610,12 @@ paths: $ref: '#/components/responses/TooManyRequests' get: tags: - - 'SAML apps' - summary: 'Read one SAML app: its entity id, ACS URL, NameID and attribute mappings. Never its certificate.' - description: 'Requires scope `saml_apps:read`. Danger: read.' - operationId: saml_apps_get - x-action: saml_apps.get - x-scope: 'saml_apps:read' + - Pipes + summary: 'Show one pipe: its provider, OAuth client id, scopes, the redirect URI to register at the provider, and the apps granted its tokens.' + description: 'Requires scope `pipes:read`. Danger: read.' + operationId: pipes_get + x-action: pipes.get + x-scope: 'pipes:read' x-danger: read parameters: - @@ -9708,7 +10624,7 @@ paths: required: true schema: type: string - description: "The SAML app's id." + description: "The pipe's id." - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: @@ -9727,7 +10643,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/SamlApp' + $ref: '#/components/schemas/Pipe' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -9744,13 +10660,13 @@ paths: $ref: '#/components/responses/TooManyRequests' patch: tags: - - 'SAML apps' - summary: "Change a SAML app's entity id, ACS URL, NameID, attribute mappings, signing certificate or owning organization." - description: 'Requires scope `saml_apps:write`. Danger: critical.' - operationId: saml_apps_update - x-action: saml_apps.update - x-scope: 'saml_apps:write' - x-danger: critical + - Pipes + summary: 'Change a pipe — client id, client secret (sealed), scopes, parameters, or whether it is enabled. Only what is sent changes.' + description: 'Requires scope `pipes:write`. Danger: write.' + operationId: pipes_update + x-action: pipes.update + x-scope: 'pipes:write' + x-danger: write parameters: - name: id @@ -9758,7 +10674,7 @@ paths: required: true schema: type: string - description: "The SAML app's id." + description: "The pipe's id." - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' @@ -9774,60 +10690,35 @@ paths: schema: type: object properties: - entity_id: + client_id: type: string - description: "The application's SAML EntityID, unique in this environment." + description: 'A new OAuth client id. Left out, unchanged.' maxLength: 500 - acs_url: - type: string - description: 'Where assertions are posted: https (http only on localhost), absolute, no fragment.' - format: uri - maxLength: 1000 - name_id_format: - type: string - description: 'The NameID format URN. Default emailAddress.' - enum: - - 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress' - - 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent' - - 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient' - - 'urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified' - name_id_attribute: + client_secret: type: string - description: "The person's field the NameID is read from. Default `email`." - maxLength: 120 - attribute_mappings: + description: 'A new client secret, sealed. Left out, unchanged.' + maxLength: 4000 + scopes: type: array - description: 'The attributes the assertion carries. Sent, it is the COMPLETE list afterwards.' - maxItems: 100 + description: "The OAuth scopes to ask people for. Left out: the provider's defaults." items: - type: object - properties: - attribute: - type: string - description: 'The attribute name the assertion emits: `email`, `displayName`.' - maxLength: 190 - field: - type: string - description: "The person's field it is read from: `email`, `name`." - maxLength: 190 - required: - - attribute - - field - want_authn_requests_signed: + type: string + maxLength: 190 + parameters: + type: object + description: "Per-installation values some providers need. Left out: the provider's defaults." + properties: + tenant: + type: string + description: "Microsoft 365 only: `common` (default), `organizations`, or one directory's tenant id or domain." + maxLength: 253 + domain: + type: string + description: 'Salesforce only: `login.salesforce.com` (default), `test.salesforce.com`, or your My Domain host.' + maxLength: 253 + enabled: type: boolean - description: "Verify the application's signature on every AuthnRequest. Needs a certificate." - certificate: - type: - - string - - 'null' - description: "The application's signing certificate (PEM). Write-only; left out or null keeps the one on file." - maxLength: 20000 - organization_id: - type: - - string - - 'null' - description: 'The organization that owns the application: only its active members are signed in to it, and the assertion names it. Null makes it environment-wide — every person in the environment may sign in to it.' - maxLength: 64 + description: 'False refuses new connections and every lease. Left out, unchanged.' responses: 200: description: OK @@ -9839,7 +10730,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/SamlApp' + $ref: '#/components/schemas/Pipe' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -9856,132 +10747,53 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - /sign-in/policy: + '/pipes/{id}/connections': get: tags: - - Sign-in - summary: "Read the authentication policy (password, MFA, SSO, lockout): the environment baseline, or one organization's effective rules and override." - description: 'Requires scope `signin:read`. Danger: read.' - operationId: signin_policy_get - x-action: signin.policy.get - x-scope: 'signin:read' + - Pipes + summary: 'List the accounts people connected through a pipe: who, which account, granted scopes, status (active / needs_reauth) and when the token expires. Never a token.' + description: 'Requires scope `pipes:read`. Danger: read.' + operationId: pipes_connections_list + x-action: pipes.connections.list + x-scope: 'pipes:read' x-danger: read parameters: - - name: organization_id + name: id + in: path + required: true + schema: + type: string + description: "The pipe's id." + - + name: user_id in: query required: false schema: type: string - description: "Read one organization's rules. Left out, the environment baseline." - maxLength: 64 - - $ref: '#/components/parameters/CboxApproval' - - $ref: '#/components/parameters/CboxEnvironment' - security: + description: "Only this person's connection." + maxLength: 128 - - EnvironmentApiKey: [] + name: limit + in: query + required: false + schema: + type: integer + description: 'Items per page, 1–100. Default 50.' - - WorkspaceAccessToken: [] - responses: - 200: - description: OK - content: - application/json: - schema: - type: object - required: - - data - properties: - data: - $ref: '#/components/schemas/SignInPolicy' - 202: - $ref: '#/components/responses/ApprovalRequired' - 400: - $ref: '#/components/responses/EnvironmentRequired' - 401: - $ref: '#/components/responses/Unauthorized' - 403: - $ref: '#/components/responses/Forbidden' - 404: - $ref: '#/components/responses/NotFound' - 422: - $ref: '#/components/responses/UnprocessableEntity' - 429: - $ref: '#/components/responses/TooManyRequests' - patch: - tags: - - Sign-in - summary: "Change the authentication policy of the environment baseline, or tighten one organization's override. Requiring SSO signs out password sessions." - description: 'Requires scope `signin:write`. Danger: critical.' - operationId: signin_policy_update - x-action: signin.policy.update - x-scope: 'signin:write' - x-danger: critical - parameters: - - $ref: '#/components/parameters/IdempotencyKey' + name: after + in: query + required: false + schema: + type: string + description: 'The `next_cursor` of the previous page.' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] - - - ManagementAccessToken: [] - WorkspaceAccessToken: [] - requestBody: - required: false - content: - application/json: - schema: - type: object - properties: - organization_id: - type: - - string - - 'null' - description: "Write this organization's override. Left out, the environment baseline." - maxLength: 64 - min_length: - type: integer - description: 'Minimum password length.' - minimum: 8 - maximum: 128 - require_breach_check: - type: boolean - description: 'Refuse passwords that appear in known breaches.' - max_age_days: - type: - - integer - - 'null' - description: 'Days before a password must be changed; null for no limit.' - minimum: 1 - maximum: 3650 - reuse_history: - type: integer - description: 'How many previous passwords may not be reused.' - minimum: 0 - maximum: 24 - mfa: - type: string - description: 'Whether a second factor is off, optional or required.' - enum: - - 'off' - - optional - - required - sso: - type: string - description: 'Whether SSO is off, preferred, or required — `required` refuses every other way in and signs out password sessions.' - enum: - - 'off' - - preferred - - required - lockout_threshold: - type: - - integer - - 'null' - description: 'Failed attempts before an account is locked; null for the deployment default (10 inside 15 minutes unless CBOX_ID_LOCKOUT_THRESHOLD says otherwise).' - minimum: 3 - maximum: 100 responses: 200: description: OK @@ -9991,9 +10803,14 @@ paths: type: object required: - data + - meta properties: data: - $ref: '#/components/schemas/SignInPolicy' + type: array + items: + $ref: '#/components/schemas/PipeConnection' + meta: + $ref: '#/components/schemas/PageMeta' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -10004,39 +10821,41 @@ paths: $ref: '#/components/responses/Forbidden' 404: $ref: '#/components/responses/NotFound' - 409: - $ref: '#/components/responses/Conflict' 422: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - '/sign-in/policy/organizations/{organization_id}': + '/pipes/{id}/connections/{connection_id}': delete: tags: - - Sign-in - summary: "Drop one organization's authentication policy override, so it inherits the environment baseline again." - description: 'Requires scope `signin:write`. Danger: critical.' - operationId: signin_policy_inherit - x-action: signin.policy.inherit - x-scope: 'signin:write' - x-danger: critical + - Pipes + summary: "Disconnect a person's connected account: revoke it at the provider where supported, revoke its tokens here, and forget it." + description: 'Requires scope `pipes:write`. Danger: destructive.' + operationId: pipes_connections_delete + x-action: pipes.connections.delete + x-scope: 'pipes:write' + x-danger: destructive parameters: - - name: organization_id + name: id in: path required: true schema: type: string - description: 'The organization whose override to drop.' - maxLength: 64 + description: "The pipe's id." + - + name: connection_id + in: path + required: true + schema: + type: string + description: "The connection's id." - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] - - - ManagementAccessToken: [] - WorkspaceAccessToken: [] responses: @@ -10058,17 +10877,24 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - /sign-in/self-service-signup: - put: + '/pipes/{id}/grants': + post: tags: - - Sign-in - summary: 'Switch self-service sign-up on or off: whether people can create an account and their own organization in this environment.' - description: 'Requires scope `signin:write`. Danger: critical.' - operationId: signin_self_service_signup_set - x-action: signin.self_service_signup.set - x-scope: 'signin:write' + - Pipes + summary: 'Grant an app (by OAuth client id) the right to lease fresh access tokens for the accounts people connected through this pipe.' + description: 'Requires scope `pipes:write`. Danger: critical.' + operationId: pipes_grants_create + x-action: pipes.grants.create + x-scope: 'pipes:write' x-danger: critical parameters: + - + name: id + in: path + required: true + schema: + type: string + description: "The pipe's id." - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' @@ -10084,11 +10910,12 @@ paths: schema: type: object properties: - enabled: - type: boolean - description: 'On: anyone may create an account and their own organization. Off: people join by invitation.' + client_id: + type: string + description: 'The OAuth client id of an app in this environment.' + maxLength: 190 required: - - enabled + - client_id responses: 200: description: OK @@ -10100,7 +10927,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/SelfServiceSignup' + $ref: '#/components/schemas/Pipe' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -10117,39 +10944,33 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - /sign-in/social-providers: - get: + '/pipes/{id}/grants/{client_id}': + delete: tags: - - Sign-in - summary: 'List the social login providers (Google, GitHub, Apple…) enabled in this environment, optionally for one organization.' - description: 'Requires scope `signin:read`. Danger: read.' - operationId: signin_social_list - x-action: signin.social.list - x-scope: 'signin:read' - x-danger: read + - Pipes + summary: "Withdraw an app's right to lease the tokens people connected through this pipe." + description: 'Requires scope `pipes:write`. Danger: destructive.' + operationId: pipes_grants_delete + x-action: pipes.grants.delete + x-scope: 'pipes:write' + x-danger: destructive parameters: - - name: organization_id - in: query - required: false + name: id + in: path + required: true schema: type: string - description: "Only this organization's providers." - maxLength: 64 - - - name: limit - in: query - required: false - schema: - type: integer - description: 'Items per page, 1–100. Default 50.' + description: "The pipe's id." - - name: after - in: query - required: false + name: client_id + in: path + required: true schema: type: string - description: 'The `next_cursor` of the previous page.' + description: 'The OAuth client id whose grant is withdrawn.' + maxLength: 190 + - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: @@ -10158,22 +10979,8 @@ paths: - WorkspaceAccessToken: [] responses: - 200: - description: OK - content: - application/json: - schema: - type: object - required: - - data - - meta - properties: - data: - type: array - items: - $ref: '#/components/schemas/SocialProvider' - meta: - $ref: '#/components/schemas/PageMeta' + 204: + description: Done. 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -10184,18 +10991,21 @@ paths: $ref: '#/components/responses/Forbidden' 404: $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' 422: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' + /provisioning-targets: post: tags: - - Sign-in - summary: 'Enable a social login provider (Google, GitHub, Apple…) for one organization with its client credentials. The secret is never returned.' - description: 'Requires scope `signin:write`. Danger: critical.' - operationId: signin_social_set - x-action: signin.social.set - x-scope: 'signin:write' + - 'Outbound provisioning' + summary: "Register a downstream app this platform pushes an organization's people to over SCIM, or every organization's with environment_wide." + description: 'Requires scope `provisioning:write`. Danger: critical.' + operationId: provisioning_targets_create + x-action: provisioning.targets.create + x-scope: 'provisioning:write' x-danger: critical parameters: - $ref: '#/components/parameters/IdempotencyKey' @@ -10216,73 +11026,57 @@ paths: type: object properties: organization_id: - type: string - description: 'The organization whose sign-in page offers it.' + type: + - string + - 'null' + description: 'The organization the target belongs to. Send this or environment_wide.' maxLength: 64 - provider: + environment_wide: + type: boolean + description: "True to make the target the environment's own rather than one organization's. Send this or organization_id." + name: type: string - description: 'The catalogue key: google, microsoft, okta, auth0, keycloak, gitlab, slack, github, discord, apple, facebook, linkedin, bitbucket, xero, intuit.' - maxLength: 100 - client_id: + description: 'What administrators call it: "Slack", "Salesforce".' + maxLength: 190 + base_url: type: string - description: 'The client id from your own account with the provider. For Apple, the Services ID.' + description: "The target's SCIM base URL. Must be a public address." + format: uri + maxLength: 500 + auth_scheme: + type: string + description: 'bearer (a long-lived token the app issued you) or oauth2_client_credentials (a token fetched before each batch).' + enum: + - bearer + - oauth2_client_credentials + secret: + type: string + description: 'The bearer token, or the OAuth client secret. Write-only.' + maxLength: 4096 + token_url: + type: + - string + - 'null' + description: 'oauth2_client_credentials: the token endpoint.' + format: uri + maxLength: 2048 + client_id: + type: + - string + - 'null' + description: 'oauth2_client_credentials: the client id.' maxLength: 400 - client_secret: + scope: type: - string - 'null' - description: 'The client secret. Write-only. Not used by Apple, which signs its own.' - maxLength: 5000 - parameters: - type: object - description: "What the chosen provider needs besides its client credentials — Microsoft's directory, Okta's domain, Apple's team id, key id and private key." - properties: - directory: - type: - - string - - 'null' - description: 'Directory (tenant) ID (Microsoft Entra ID).' - maxLength: 5000 - domain: - type: - - string - - 'null' - description: 'Okta domain (Okta).' - maxLength: 5000 - host: - type: - - string - - 'null' - description: 'Keycloak host (Keycloak).' - maxLength: 5000 - key_id: - type: - - string - - 'null' - description: 'Key ID (Apple).' - maxLength: 5000 - private_key: - type: - - string - - 'null' - description: 'Private key (.p8) (Apple).' - maxLength: 5000 - realm: - type: - - string - - 'null' - description: 'Realm (Keycloak).' - maxLength: 5000 - team_id: - type: - - string - - 'null' - description: 'Team ID (Apple).' - maxLength: 5000 + description: 'oauth2_client_credentials: the scope to ask for, if the app wants one.' + maxLength: 400 required: - - organization_id - - provider - - client_id + - name + - base_url + - auth_scheme + - secret responses: 201: description: OK @@ -10294,7 +11088,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/SocialProvider' + $ref: '#/components/schemas/ProvisioningTarget' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -10311,16 +11105,88 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - '/sign-in/social-providers/{id}': + get: + tags: + - 'Outbound provisioning' + summary: 'List the downstream SCIM targets people are provisioned to, optionally for one organization, with their failures. Never their credentials.' + description: 'Requires scope `provisioning:read`. Danger: read.' + operationId: provisioning_targets_list + x-action: provisioning.targets.list + x-scope: 'provisioning:read' + x-danger: read + parameters: + - + name: organization_id + in: query + required: false + schema: + type: + - string + - 'null' + description: "Only this organization's." + maxLength: 64 + - + name: limit + in: query + required: false + schema: + type: integer + description: 'Items per page, 1–100. Default 50.' + - + name: after + in: query + required: false + schema: + type: string + description: 'The `next_cursor` of the previous page.' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + WorkspaceAccessToken: [] + responses: + 200: + description: OK + content: + application/json: + schema: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + $ref: '#/components/schemas/ProvisioningTarget' + meta: + $ref: '#/components/schemas/PageMeta' + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' + '/provisioning-targets/{id}': delete: tags: - - Sign-in - summary: 'Stop offering a social login provider. People who used it keep their accounts.' - description: 'Requires scope `signin:write`. Danger: critical.' - operationId: signin_social_delete - x-action: signin.social.delete - x-scope: 'signin:write' - x-danger: critical + - 'Outbound provisioning' + summary: 'Delete a downstream SCIM target. Nothing more is pushed to it.' + description: 'Requires scope `provisioning:write`. Danger: destructive.' + operationId: provisioning_targets_delete + x-action: provisioning.targets.delete + x-scope: 'provisioning:write' + x-danger: destructive parameters: - name: id @@ -10328,7 +11194,7 @@ paths: required: true schema: type: string - description: "The provider's id." + description: "The target's id." - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' @@ -10350,7 +11216,7 @@ paths: type: - string - 'null' - description: "Only remove it if it is this organization's." + description: "Only if it is this organization's; anything else is a 404." maxLength: 64 responses: 204: @@ -10371,66 +11237,114 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - /sod-policies: - post: + get: tags: - - Governance - summary: 'Define a role-conflict rule: two or more roles no one person may hold together. Enforced on every grant from now on.' - description: 'Requires scope `governance:write`. Danger: write.' - operationId: sod_policies_create - x-action: sod_policies.create - x-scope: 'governance:write' - x-danger: write + - 'Outbound provisioning' + summary: 'Read one downstream SCIM target: its URL, auth scheme, status and last error. Never its credential.' + description: 'Requires scope `provisioning:read`. Danger: read.' + operationId: provisioning_targets_get + x-action: provisioning.targets.get + x-scope: 'provisioning:read' + x-danger: read parameters: - - $ref: '#/components/parameters/IdempotencyKey' + - + name: id + in: path + required: true + schema: + type: string + description: "The target's id." + - + name: organization_id + in: query + required: false + schema: + type: + - string + - 'null' + description: "Only if it is this organization's; anything else is a 404." + maxLength: 64 - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] - - - ManagementAccessToken: [] - WorkspaceAccessToken: [] - requestBody: - required: true - content: - application/json: + responses: + 200: + description: OK + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/ProvisioningTarget' + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' + '/provisioning-targets/{id}/status': + post: + tags: + - 'Outbound provisioning' + summary: 'Pause or resume pushing people to a downstream SCIM target.' + description: 'Requires scope `provisioning:write`. Danger: write.' + operationId: provisioning_targets_status_set + x-action: provisioning.targets.status.set + x-scope: 'provisioning:write' + x-danger: write + parameters: + - + name: id + in: path + required: true + schema: + type: string + description: "The target's id." + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + ManagementAccessToken: [] + - + WorkspaceAccessToken: [] + requestBody: + required: true + content: + application/json: schema: type: object properties: + active: + type: boolean + description: 'true to resume pushing, false to pause.' organization_id: type: - string - 'null' - description: 'The organization the rule belongs to. Send this or environment_wide.' + description: "Only if it is this organization's; anything else is a 404." maxLength: 64 - environment_wide: - type: boolean - description: "True to make the rule the environment's own rather than one organization's. Send this or organization_id." - name: - type: string - description: 'What the rule is for: "Payments maker/checker".' - maxLength: 190 - description: - type: - - string - - 'null' - description: 'Why the roles conflict.' - maxLength: 500 - role_ids: - type: array - description: 'The roles no one person may hold together; at least two.' - minItems: 2 - maxItems: 50 - items: - type: string - maxLength: 64 required: - - name - - role_ids + - active responses: - 201: + 200: description: OK content: application/json: @@ -10440,7 +11354,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/SodPolicy' + $ref: '#/components/schemas/ProvisioningTarget' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -10457,40 +11371,118 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' + /radar/decisions: get: tags: - - Governance - summary: 'List role-conflict (segregation of duties) rules: sets of roles no one person may hold together. Optionally those binding one organization.' - description: 'Requires scope `governance:read`. Danger: read.' - operationId: sod_policies_list - x-action: sod_policies.list - x-scope: 'governance:read' + - Radar + summary: "List this environment's Radar decisions newest first — verdict, deciding rule, every rule that fired, reasons and facts — filtered by verdict, flow, rule, country, email, IP, device or time; pass `next_cursor` as `after` to page. Email and IP are matched by keyed pseudonym and never returned." + description: 'Requires scope `radar:read`. Danger: read.' + operationId: radar_decisions_list + x-action: radar.decisions.list + x-scope: 'radar:read' x-danger: read parameters: - - name: organization_id + name: verdict in: query required: false schema: - type: - - string - - 'null' - description: 'Only the rules binding this organization: its own and the environment-wide ones.' + type: string + description: '`allow`, `challenge` or `block`.' + enum: + - allow + - challenge + - block + - + name: flow + in: query + required: false + schema: + type: string + description: '`sign_in` or `sign_up`.' + enum: + - sign_in + - sign_up + - + name: rule + in: query + required: false + schema: + type: string + description: 'A rule key — `builtin:credential_stuffing`, `rule:`, `deny_list:ip` — that decided or fired.' + maxLength: 64 + - + name: country + in: query + required: false + schema: + type: string + description: 'Two-letter country code.' + maxLength: 2 + - + name: email + in: query + required: false + schema: + type: string + description: 'Attempts with this address (matched by pseudonym).' + maxLength: 255 + - + name: ip + in: query + required: false + schema: + type: string + description: 'Attempts from this IP (matched by pseudonym).' + maxLength: 64 + - + name: device + in: query + required: false + schema: + type: string + description: 'Attempts from this device id.' maxLength: 64 + - + name: enforced + in: query + required: false + schema: + type: boolean + description: 'Only decisions that were (true) or were not (false) acted on.' + - + name: from + in: query + required: false + schema: + type: string + description: 'On or after this time (ISO 8601).' + format: date-time + - + name: to + in: query + required: false + schema: + type: string + description: 'On or before this time (ISO 8601).' + format: date-time - name: limit in: query required: false schema: type: integer - description: 'Items per page, 1–100. Default 50.' + description: 'Decisions per page, 1–100. Default 50.' + minimum: 1 + maximum: 100 - name: after in: query required: false schema: type: string - description: 'The `next_cursor` of the previous page.' + description: 'The `next_cursor` of the previous page: the decisions older than it.' + maxLength: 26 - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: @@ -10512,7 +11504,7 @@ paths: data: type: array items: - $ref: '#/components/schemas/SodPolicy' + $ref: '#/components/schemas/RadarDecision' meta: $ref: '#/components/schemas/PageMeta' 202: @@ -10529,74 +11521,15 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - '/sod-policies/{id}': - delete: - tags: - - Governance - summary: 'Remove a role-conflict rule. Grants it refused are allowed from then on.' - description: 'Requires scope `governance:write`. Danger: critical.' - operationId: sod_policies_delete - x-action: sod_policies.delete - x-scope: 'governance:write' - x-danger: critical - parameters: - - - name: id - in: path - required: true - schema: - type: string - description: "The rule's id." - - $ref: '#/components/parameters/IdempotencyKey' - - $ref: '#/components/parameters/CboxApproval' - - $ref: '#/components/parameters/CboxEnvironment' - security: - - - EnvironmentApiKey: [] - - - ManagementAccessToken: [] - - - WorkspaceAccessToken: [] - requestBody: - required: false - content: - application/json: - schema: - type: object - properties: - organization_id: - type: - - string - - 'null' - description: "Only if it is this organization's; anything else is a 404." - maxLength: 64 - responses: - 204: - description: Done. - 202: - $ref: '#/components/responses/ApprovalRequired' - 400: - $ref: '#/components/responses/EnvironmentRequired' - 401: - $ref: '#/components/responses/Unauthorized' - 403: - $ref: '#/components/responses/Forbidden' - 404: - $ref: '#/components/responses/NotFound' - 409: - $ref: '#/components/responses/Conflict' - 422: - $ref: '#/components/responses/UnprocessableEntity' - 429: - $ref: '#/components/responses/TooManyRequests' + '/radar/decisions/{id}': get: tags: - - Governance - summary: 'Read one role-conflict rule: the roles no one person may hold together, and whether it is enforced.' - description: 'Requires scope `governance:read`. Danger: read.' - operationId: sod_policies_get - x-action: sod_policies.get - x-scope: 'governance:read' + - Radar + summary: 'Read one Radar decision: the verdict, whether it was enforced, the deciding rule, every rule that fired, the reasons, the risk score and the facts it was decided on.' + description: 'Requires scope `radar:read`. Danger: read.' + operationId: radar_decisions_get + x-action: radar.decisions.get + x-scope: 'radar:read' x-danger: read parameters: - @@ -10605,17 +11538,8 @@ paths: required: true schema: type: string - description: "The rule's id." - - - name: organization_id - in: query - required: false - schema: - type: - - string - - 'null' - description: 'Only if it binds this organization (its own, or environment-wide); anything else is a 404.' - maxLength: 64 + description: "The decision's id." + maxLength: 26 - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: @@ -10634,7 +11558,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/SodPolicy' + $ref: '#/components/schemas/RadarDecision' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -10649,32 +11573,23 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - '/sod-policies/{id}/status': + /radar/lists: post: tags: - - Governance - summary: 'Enforce a role-conflict rule, or stop enforcing it — switched off, grants that break it are allowed again.' - description: 'Requires scope `governance:write`. Danger: critical.' - operationId: sod_policies_status_set - x-action: sod_policies.status.set - x-scope: 'governance:write' - x-danger: critical + - Radar + summary: "Add an entry to this environment's Radar allow or deny list: an IP or CIDR range, an email address, a mail domain (and its subdomains), or a device id from the decisions explorer. Deny blocks before every rule; allow skips every rule." + description: 'Requires scope `radar:write`. Danger: write.' + operationId: radar_lists_add + x-action: radar.lists.add + x-scope: 'radar:write' + x-danger: write parameters: - - - name: id - in: path - required: true - schema: - type: string - description: "The rule's id." - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] - - - ManagementAccessToken: [] - WorkspaceAccessToken: [] requestBody: @@ -10684,155 +11599,40 @@ paths: schema: type: object properties: - active: - type: boolean - description: 'true to enforce the rule, false to stop enforcing it.' - organization_id: - type: - - string - - 'null' - description: "Only if it is this organization's; anything else is a 404." - maxLength: 64 - required: - - active - responses: - 200: - description: OK - content: - application/json: - schema: - type: object - required: - - data - properties: - data: - $ref: '#/components/schemas/SodPolicy' - 202: - $ref: '#/components/responses/ApprovalRequired' - 400: - $ref: '#/components/responses/EnvironmentRequired' - 401: - $ref: '#/components/responses/Unauthorized' - 403: - $ref: '#/components/responses/Forbidden' - 404: - $ref: '#/components/responses/NotFound' - 409: - $ref: '#/components/responses/Conflict' - 422: - $ref: '#/components/responses/UnprocessableEntity' - 429: - $ref: '#/components/responses/TooManyRequests' - /sso/connections: - post: - tags: - - 'Enterprise SSO' - summary: "Connect an organization's SAML or OIDC identity provider as a draft. Activate it to start signing people in through it." - description: 'Requires scope `sso:write`. Danger: write.' - operationId: sso_connections_create - x-action: sso.connections.create - x-scope: 'sso:write' - x-danger: write - parameters: - - $ref: '#/components/parameters/IdempotencyKey' - - $ref: '#/components/parameters/CboxApproval' - - $ref: '#/components/parameters/CboxEnvironment' - security: - - - EnvironmentApiKey: [] - - - ManagementAccessToken: [] - - - WorkspaceAccessToken: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - organization_id: - type: - - string - - 'null' - description: 'The organization the connection belongs to. Send this or environment_wide.' - maxLength: 64 - environment_wide: - type: boolean - description: "True to make the connection the environment's own rather than one organization's. Send this or organization_id." - name: + list: type: string - description: 'What administrators call it: "Okta", "Entra ID".' - maxLength: 120 - type: + description: '`allow` or `deny`.' + enum: + - allow + - deny + kind: type: string - description: 'saml or oidc. The config fields of that type are required, unless pending_idp.' + description: '`ip` (an address or CIDR range), `email`, `email_domain` or `device`.' enum: - - saml - - oidc - pending_idp: - type: boolean - description: "True to create the draft before the identity provider's details are known: the answer's service_provider is what to paste into it. Complete it with update before activating." - idp_entity_id: - type: - - string - - 'null' - description: "SAML: the identity provider's entity id." - maxLength: 500 - idp_sso_url: - type: - - string - - 'null' - description: "SAML: the identity provider's single sign-on URL." - format: uri - maxLength: 500 - idp_x509cert: - type: - - string - - 'null' - description: "SAML: the identity provider's signing certificate (PEM). Write-only." - maxLength: 20000 - sp_entity_id: - type: - - string - - 'null' - description: "SAML: the entity id this environment presents as the service provider. Left out on create, this connection's own." - maxLength: 500 - sp_acs_url: - type: - - string - - 'null' - description: "SAML: where the identity provider posts its assertions. Left out on create, this connection's own ACS URL." - format: uri - maxLength: 500 - issuer: - type: - - string - - 'null' - description: "OIDC: the provider's issuer URL; its endpoints are discovered from it." - format: uri - maxLength: 500 - client_id: - type: - - string - - 'null' - description: 'OIDC: the client id registered at the provider.' - maxLength: 500 - client_secret: + - ip + - email + - email_domain + - device + value: + type: string + description: 'The IP, range, address, domain or device id.' + maxLength: 255 + note: type: - string - 'null' - description: 'OIDC: the client secret. Write-only.' - maxLength: 500 - signing_key: + description: 'Why it is listed.' + maxLength: 255 + expires_at: type: - string - 'null' - description: "OIDC, optional: the provider's ID-token signing key (an RS256 public key, PEM). Leave it out for any provider whose discovery document publishes a jwks_uri — the keys are read from there and follow its rotations. Needed only when it does not. Write-only." - maxLength: 20000 + description: 'When the entry stops applying (ISO 8601). Never, when omitted.' + format: date-time required: - - name - - type + - list + - kind + - value responses: 201: description: OK @@ -10844,7 +11644,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/SsoConnection' + $ref: '#/components/schemas/RadarListEntry' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -10863,24 +11663,36 @@ paths: $ref: '#/components/responses/TooManyRequests' get: tags: - - 'Enterprise SSO' - summary: 'List the SAML and OIDC connections organizations sign in through, optionally for one organization. Never a certificate or secret.' - description: 'Requires scope `sso:read`. Danger: read.' - operationId: sso_connections_list - x-action: sso.connections.list - x-scope: 'sso:read' + - Radar + summary: "List this environment's Radar allow and deny entries — IPs and CIDR ranges, addresses, mail domains, devices — optionally one list or one kind." + description: 'Requires scope `radar:read`. Danger: read.' + operationId: radar_lists_list + x-action: radar.lists.list + x-scope: 'radar:read' x-danger: read parameters: - - name: organization_id + name: list in: query required: false schema: - type: - - string - - 'null' - description: "Only this organization's." - maxLength: 64 + type: string + description: '`allow` or `deny`; both when omitted.' + enum: + - allow + - deny + - + name: kind + in: query + required: false + schema: + type: string + description: 'Only entries of this kind.' + enum: + - ip + - email + - email_domain + - device - name: limit in: query @@ -10916,7 +11728,7 @@ paths: data: type: array items: - $ref: '#/components/schemas/SsoConnection' + $ref: '#/components/schemas/RadarListEntry' meta: $ref: '#/components/schemas/PageMeta' 202: @@ -10933,16 +11745,16 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - '/sso/connections/{id}': + '/radar/lists/{id}': delete: tags: - - 'Enterprise SSO' - summary: 'Delete an SSO connection and its settings. Where SSO is required, its people cannot sign in until another is activated.' - description: 'Requires scope `sso:write`. Danger: critical.' - operationId: sso_connections_delete - x-action: sso.connections.delete - x-scope: 'sso:write' - x-danger: critical + - Radar + summary: "Remove an entry from this environment's Radar allow or deny list." + description: 'Requires scope `radar:write`. Danger: destructive.' + operationId: radar_lists_remove + x-action: radar.lists.remove + x-scope: 'radar:write' + x-danger: destructive parameters: - name: id @@ -10950,30 +11762,16 @@ paths: required: true schema: type: string - description: "The connection's id." + description: "The entry's id." + maxLength: 26 - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] - - - ManagementAccessToken: [] - WorkspaceAccessToken: [] - requestBody: - required: false - content: - application/json: - schema: - type: object - properties: - organization_id: - type: - - string - - 'null' - description: "Only if it is this organization's; anything else is a 404." - maxLength: 64 responses: 204: description: Done. @@ -10993,33 +11791,18 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - get: + /radar/mode: + put: tags: - - 'Enterprise SSO' - summary: 'Read one SSO connection: its type, status, entity ids, URLs, issuer and client id. Never a certificate or secret.' - description: 'Requires scope `sso:read`. Danger: read.' - operationId: sso_connections_get - x-action: sso.connections.get - x-scope: 'sso:read' - x-danger: read + - Radar + summary: "Switch this environment's Radar between `monitor` (verdicts recorded, never acted on) and `enforce` (blocks refuse sign-ins and sign-ups, challenges demand a second factor). Changes the environment's security posture." + description: 'Requires scope `radar:manage`. Danger: critical.' + operationId: radar_mode_set + x-action: radar.mode.set + x-scope: 'radar:manage' + x-danger: critical parameters: - - - name: id - in: path - required: true - schema: - type: string - description: "The connection's id." - - - name: organization_id - in: query - required: false - schema: - type: - - string - - 'null' - description: "Only if it is this organization's; anything else is a 404." - maxLength: 64 + - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: @@ -11027,6 +11810,21 @@ paths: EnvironmentApiKey: [] - WorkspaceAccessToken: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + mode: + type: string + description: '`monitor` or `enforce`.' + enum: + - monitor + - enforce + required: + - mode responses: 200: description: OK @@ -11038,7 +11836,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/SsoConnection' + $ref: '#/components/schemas/RadarSettings' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -11049,113 +11847,143 @@ paths: $ref: '#/components/responses/Forbidden' 404: $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' 422: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - patch: + /radar/rules: + post: tags: - - 'Enterprise SSO' - summary: "Change an SSO connection's name or identity-provider settings. Secrets left out keep the ones on file." - description: 'Requires scope `sso:write`. Danger: critical.' - operationId: sso_connections_update - x-action: sso.connections.update - x-scope: 'sso:write' - x-danger: critical + - Radar + summary: 'Add a Radar rule: when EVERY condition (field, operator, value) holds, allow, challenge or block. Rules run in order after the allow/deny lists and before the built-in rules; the first match decides. Example: country not_in [DK, SE] → challenge.' + description: 'Requires scope `radar:write`. Danger: write.' + operationId: radar_rules_create + x-action: radar.rules.create + x-scope: 'radar:write' + x-danger: write parameters: - - - name: id - in: path - required: true - schema: - type: string - description: "The connection's id." - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] - - - ManagementAccessToken: [] - WorkspaceAccessToken: [] requestBody: - required: false + required: true content: application/json: schema: type: object properties: - organization_id: - type: - - string - - 'null' - description: "Only if it is this organization's; anything else is a 404." - maxLength: 64 name: type: string - description: 'A new name.' + description: 'What the rule is for, as the console lists it.' maxLength: 120 - idp_entity_id: - type: - - string - - 'null' - description: "SAML: the identity provider's entity id." - maxLength: 500 - idp_sso_url: - type: - - string - - 'null' - description: "SAML: the identity provider's single sign-on URL." - format: uri - maxLength: 500 - idp_x509cert: - type: - - string - - 'null' - description: "SAML: the identity provider's signing certificate (PEM). Write-only." - maxLength: 20000 - sp_entity_id: - type: - - string - - 'null' - description: "SAML: the entity id this environment presents as the service provider. Left out on create, this connection's own." - maxLength: 500 - sp_acs_url: - type: - - string - - 'null' - description: "SAML: where the identity provider posts its assertions. Left out on create, this connection's own ACS URL." - format: uri - maxLength: 500 - issuer: - type: - - string - - 'null' - description: "OIDC: the provider's issuer URL; its endpoints are discovered from it." - format: uri - maxLength: 500 - client_id: - type: - - string - - 'null' - description: 'OIDC: the client id registered at the provider.' - maxLength: 500 - client_secret: + description: type: - string - 'null' - description: 'OIDC: the client secret. Write-only.' + description: 'Why the rule exists, for whoever reads it next.' maxLength: 500 - signing_key: - type: - - string - - 'null' - description: "OIDC, optional: the provider's ID-token signing key (an RS256 public key, PEM). Leave it out for any provider whose discovery document publishes a jwks_uri — the keys are read from there and follow its rotations. Needed only when it does not. Write-only." - maxLength: 20000 + action: + type: string + description: 'What happens when every condition holds: `allow` (skip every rule after this one), `challenge` (a second factor on sign-in; a CAPTCHA or an emailed code on sign-up) or `block`.' + enum: + - allow + - challenge + - block + applies_to: + type: string + description: '`all` (the default), `sign_in` or `sign_up`.' + enum: + - all + - sign_in + - sign_up + conditions: + type: array + description: 'Up to 10 conditions; the rule matches when EVERY one holds. A fact that is unknown (no IP intelligence, no address on a passkey sign-in) matches no condition.' + minItems: 1 + maxItems: 10 + items: + type: object + properties: + field: + type: string + description: 'The fact to test.' + enum: + - ip + - country + - asn + - as_organization + - is_hosting + - is_vpn + - is_proxy + - is_tor + - email + - email_domain + - disposable_email + - user_agent + - method + - new_device + - impossible_travel + - travel_kmh + - risk_score + - ip_attempts_1m + - ip_attempts_1h + - ip_distinct_emails_10m + - ip_failures_1h + - email_attempts_1h + - email_failures_1h + - device_attempts_1h + operator: + type: string + description: 'How to compare it. Which operators a field takes depends on its type.' + enum: + - eq + - neq + - in + - not_in + - gt + - gte + - lt + - lte + - contains + - not_contains + - starts_with + - ends_with + - in_cidr + - not_in_cidr + value: + type: string + description: 'The one value to compare with, as text — `DK`, `20`, `true`. For every operator except the list ones.' + maxLength: 255 + values: + type: array + description: 'The values to compare with, for `in`, `not_in`, `in_cidr` and `not_in_cidr`.' + maxItems: 100 + items: + type: string + maxLength: 255 + required: + - field + - operator + enabled: + type: boolean + description: 'Whether the rule is evaluated. Default true.' + position: + type: integer + description: 'Where in the order to put it: 1 is evaluated first. Default: last.' + minimum: 1 + required: + - name + - action + - conditions responses: - 200: + 201: description: OK content: application/json: @@ -11165,7 +11993,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/SsoConnection' + $ref: '#/components/schemas/RadarRule' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -11182,47 +12010,38 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - '/sso/connections/{id}/activate': - post: + get: tags: - - 'Enterprise SSO' - summary: 'Activate an SSO connection: people whose email domain routes to it start signing in through it.' - description: 'Requires scope `sso:write`. Danger: critical.' - operationId: sso_connections_activate - x-action: sso.connections.activate - x-scope: 'sso:write' - x-danger: critical + - Radar + summary: "List this environment's own Radar rules in evaluation order (the first whose conditions all hold decides), with their conditions and a readable summary of each." + description: 'Requires scope `radar:read`. Danger: read.' + operationId: radar_rules_list + x-action: radar.rules.list + x-scope: 'radar:read' + x-danger: read parameters: - - name: id - in: path - required: true + name: limit + in: query + required: false + schema: + type: integer + description: 'Rules per page, 1–100. Default 100 — an environment holds at most 100, so one page is all of them.' + - + name: after + in: query + required: false schema: type: string - description: "The connection's id." - - $ref: '#/components/parameters/IdempotencyKey' + description: 'The `next_cursor` of the previous page.' + maxLength: 26 - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] - - - ManagementAccessToken: [] - WorkspaceAccessToken: [] - requestBody: - required: false - content: - application/json: - schema: - type: object - properties: - organization_id: - type: - - string - - 'null' - description: "Only if it is this organization's; anything else is a 404." - maxLength: 64 responses: 200: description: OK @@ -11232,9 +12051,66 @@ paths: type: object required: - data + - meta properties: data: - $ref: '#/components/schemas/SsoConnection' + type: array + items: + $ref: '#/components/schemas/RadarRule' + meta: + $ref: '#/components/schemas/PageMeta' + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' + /radar/rules/order: + put: + tags: + - Radar + summary: 'Set the order Radar rules are evaluated in: `rule_ids` lists every rule of the environment exactly once, first evaluated first.' + description: 'Requires scope `radar:write`. Danger: write.' + operationId: radar_rules_reorder + x-action: radar.rules.reorder + x-scope: 'radar:write' + x-danger: write + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + WorkspaceAccessToken: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + rule_ids: + type: array + description: 'Every rule id, in the new order.' + maxItems: 100 + items: + type: string + maxLength: 26 + required: + - rule_ids + responses: + 204: + description: Done. 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -11251,16 +12127,16 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - '/sso/connections/{id}/certificates': - get: + '/radar/rules/{id}': + delete: tags: - - 'Enterprise SSO' - summary: "List a SAML connection's signing certificates — primary and staged — with each one's fingerprint and expiry." - description: 'Requires scope `sso:read`. Danger: read.' - operationId: sso_connections_certificates_list - x-action: sso.connections.certificates.list - x-scope: 'sso:read' - x-danger: read + - Radar + summary: 'Delete a Radar rule. Attempts it decided are decided by the next matching rule, or the built-in rules, from then on.' + description: 'Requires scope `radar:write`. Danger: destructive.' + operationId: radar_rules_delete + x-action: radar.rules.delete + x-scope: 'radar:write' + x-danger: destructive parameters: - name: id @@ -11268,24 +12144,58 @@ paths: required: true schema: type: string - description: "The connection's id." + description: "The rule's id." + maxLength: 26 + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: - - name: organization_id - in: query - required: false + EnvironmentApiKey: [] + - + WorkspaceAccessToken: [] + responses: + 204: + description: Done. + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' + get: + tags: + - Radar + summary: 'Read one Radar rule: its action, the flows it applies to, its position and its conditions.' + description: 'Requires scope `radar:read`. Danger: read.' + operationId: radar_rules_get + x-action: radar.rules.get + x-scope: 'radar:read' + x-danger: read + parameters: + - + name: id + in: path + required: true schema: - type: - - string - - 'null' - description: "Only if it is this organization's; anything else is a 404." - maxLength: 64 + type: string + description: "The rule's id." + maxLength: 26 - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] - - - ManagementAccessToken: [] - WorkspaceAccessToken: [] responses: @@ -11299,7 +12209,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/SsoCertificates' + $ref: '#/components/schemas/RadarRule' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -11314,15 +12224,15 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - post: + patch: tags: - - 'Enterprise SSO' - summary: "Stage a SAML connection's new IdP signing certificate (PEM or IdP metadata) beside the current one, so a renewal has no outage. Activate it once the IdP signs with it." - description: 'Requires scope `sso:write`. Danger: critical.' - operationId: sso_connections_certificates_stage - x-action: sso.connections.certificates.stage - x-scope: 'sso:write' - x-danger: critical + - Radar + summary: 'Change a Radar rule: its name, description, action, flows, enabled state, position, or (replaced whole) its conditions.' + description: 'Requires scope `radar:write`. Danger: write.' + operationId: radar_rules_update + x-action: radar.rules.update + x-scope: 'radar:write' + x-danger: write parameters: - name: id @@ -11330,15 +12240,14 @@ paths: required: true schema: type: string - description: "The connection's id." + description: "The rule's id." + maxLength: 26 - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] - - - ManagementAccessToken: [] - WorkspaceAccessToken: [] requestBody: @@ -11348,24 +12257,105 @@ paths: schema: type: object properties: - organization_id: - type: - - string - - 'null' - description: "Only if it is this organization's; anything else is a 404." - maxLength: 64 - certificate: - type: - - string - - 'null' - description: 'The new signing certificate, PEM. Send this or metadata.' - maxLength: 20000 - metadata: + name: + type: string + description: 'What the rule is for, as the console lists it.' + maxLength: 120 + description: type: - string - 'null' - description: "The identity provider's metadata XML, or the https URL it is published at. Send this or certificate." - maxLength: 500000 + description: 'Why the rule exists, for whoever reads it next.' + maxLength: 500 + action: + type: string + description: 'What happens when every condition holds: `allow` (skip every rule after this one), `challenge` (a second factor on sign-in; a CAPTCHA or an emailed code on sign-up) or `block`.' + enum: + - allow + - challenge + - block + applies_to: + type: string + description: '`all` (the default), `sign_in` or `sign_up`.' + enum: + - all + - sign_in + - sign_up + conditions: + type: array + description: 'Up to 10 conditions; the rule matches when EVERY one holds. A fact that is unknown (no IP intelligence, no address on a passkey sign-in) matches no condition.' + minItems: 1 + maxItems: 10 + items: + type: object + properties: + field: + type: string + description: 'The fact to test.' + enum: + - ip + - country + - asn + - as_organization + - is_hosting + - is_vpn + - is_proxy + - is_tor + - email + - email_domain + - disposable_email + - user_agent + - method + - new_device + - impossible_travel + - travel_kmh + - risk_score + - ip_attempts_1m + - ip_attempts_1h + - ip_distinct_emails_10m + - ip_failures_1h + - email_attempts_1h + - email_failures_1h + - device_attempts_1h + operator: + type: string + description: 'How to compare it. Which operators a field takes depends on its type.' + enum: + - eq + - neq + - in + - not_in + - gt + - gte + - lt + - lte + - contains + - not_contains + - starts_with + - ends_with + - in_cidr + - not_in_cidr + value: + type: string + description: 'The one value to compare with, as text — `DK`, `20`, `true`. For every operator except the list ones.' + maxLength: 255 + values: + type: array + description: 'The values to compare with, for `in`, `not_in`, `in_cidr` and `not_in_cidr`.' + maxItems: 100 + items: + type: string + maxLength: 255 + required: + - field + - operator + enabled: + type: boolean + description: 'Whether the rule is evaluated. Default true.' + position: + type: integer + description: 'Where in the order to put it: 1 is evaluated first. Default: last.' + minimum: 1 responses: 200: description: OK @@ -11377,7 +12367,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/SsoCertificates' + $ref: '#/components/schemas/RadarRule' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -11394,53 +12384,24 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - '/sso/connections/{id}/certificates/activate': - post: + /radar/settings: + get: tags: - - 'Enterprise SSO' - summary: "Make a staged SAML signing certificate the connection's primary. The certificate it replaces stops being trusted." - description: 'Requires scope `sso:write`. Danger: critical.' - operationId: sso_connections_certificates_activate - x-action: sso.connections.certificates.activate - x-scope: 'sso:write' - x-danger: critical + - Radar + summary: "Read this environment's Radar settings: the mode (monitor or enforce, and whether it is inherited from the deployment), the IP intelligence source, and every built-in rule with its action and threshold." + description: 'Requires scope `radar:read`. Danger: read.' + operationId: radar_settings_get + x-action: radar.settings.get + x-scope: 'radar:read' + x-danger: read parameters: - - - name: id - in: path - required: true - schema: - type: string - description: "The connection's id." - - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] - - - ManagementAccessToken: [] - WorkspaceAccessToken: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - organization_id: - type: - - string - - 'null' - description: "Only if it is this organization's; anything else is a 404." - maxLength: 64 - fingerprint_sha256: - type: string - description: "The staged certificate's SHA-256 fingerprint, as the certificate list gives it." - maxLength: 200 - required: - - fingerprint_sha256 responses: 200: description: OK @@ -11452,7 +12413,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/SsoCertificates' + $ref: '#/components/schemas/RadarSettings' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -11463,53 +12424,41 @@ paths: $ref: '#/components/responses/Forbidden' 404: $ref: '#/components/responses/NotFound' - 409: - $ref: '#/components/responses/Conflict' 422: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - '/sso/connections/{id}/disable': - post: + patch: tags: - - 'Enterprise SSO' - summary: 'Disable an SSO connection without deleting it. Where SSO is required, its people cannot sign in until it is re-activated.' - description: 'Requires scope `sso:write`. Danger: critical.' - operationId: sso_connections_disable - x-action: sso.connections.disable - x-scope: 'sso:write' - x-danger: critical + - Radar + summary: "Tune this environment's built-in Radar rules: `builtin_rules` maps a rule key (credential_stuffing, bot_velocity, account_attack, impossible_travel, new_device, anonymous_network, hosting_network, disposable_email, risk_score_reject, risk_score_elevated) to any of enabled, action and threshold." + description: 'Requires scope `radar:write`. Danger: write.' + operationId: radar_settings_update + x-action: radar.settings.update + x-scope: 'radar:write' + x-danger: write parameters: - - - name: id - in: path - required: true - schema: - type: string - description: "The connection's id." - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] - - - ManagementAccessToken: [] - WorkspaceAccessToken: [] requestBody: - required: false + required: true content: application/json: schema: type: object properties: - organization_id: - type: - - string - - 'null' - description: "Only if it is this organization's; anything else is a 404." - maxLength: 64 + builtin_rules: + type: object + description: 'Rule key => `{"enabled": bool, "action": "allow"|"challenge"|"block", "threshold": int}`, each part optional. Keys: credential_stuffing, bot_velocity, account_attack, impossible_travel, new_device, anonymous_network, hosting_network, disposable_email, risk_score_reject, risk_score_elevated.' + properties: {} + required: + - builtin_rules responses: 200: description: OK @@ -11521,7 +12470,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/SsoConnection' + $ref: '#/components/schemas/RadarSettings' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -11538,47 +12487,37 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - '/sso/connections/{id}/require-sso': - post: + /roles: + get: + operationId: roles_list + x-action: roles.list tags: - - 'Enterprise SSO' - summary: 'Require single sign-on for the organization an SSO connection belongs to. Ends every password session there.' - description: 'Requires scope `sso:write`. Danger: critical.' - operationId: sso_connections_require_sso - x-action: sso.connections.require_sso - x-scope: 'sso:write' - x-danger: critical + - Roles + summary: 'List roles' + description: | + Requires scope `roles:read`. Every role that can still be granted, ordered by name, + with the permissions each carries. Not paginated. `?client_id=` narrows to one + app's roles; `?organization_id=` to what can be granted in that organization. parameters: - - name: id - in: path - required: true + name: client_id + in: query + required: false + schema: + type: string + - + name: organization_id + in: query + required: false schema: type: string - description: "The connection's id." - - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] - - - ManagementAccessToken: [] - WorkspaceAccessToken: [] - requestBody: - required: false - content: - application/json: - schema: - type: object - properties: - organization_id: - type: - - string - - 'null' - description: "Only if it is this organization's; anything else is a 404." - maxLength: 64 responses: 200: description: OK @@ -11590,7 +12529,9 @@ paths: - data properties: data: - $ref: '#/components/schemas/SignInPolicy' + type: array + items: + $ref: '#/components/schemas/Role' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -11601,21 +12542,18 @@ paths: $ref: '#/components/responses/Forbidden' 404: $ref: '#/components/responses/NotFound' - 409: - $ref: '#/components/responses/Conflict' - 422: - $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - /sso/domains: + x-scope: 'roles:read' + x-danger: read post: tags: - - 'Enterprise SSO' - summary: "Claim an email domain for an organization's single sign-on, and get the DNS TXT record to publish to prove it." - description: 'Requires scope `sso:write`. Danger: write.' - operationId: sso_domains_create - x-action: sso.domains.create - x-scope: 'sso:write' + - Roles + summary: 'Define a role for one organization, or (organization_id null) for the whole environment, optionally scoped to one app, with its opening permissions.' + description: 'Requires scope `role_definitions:write`. Danger: write.' + operationId: roles_create + x-action: roles.create + x-scope: 'role_definitions:write' x-danger: write parameters: - $ref: '#/components/parameters/IdempotencyKey' @@ -11635,17 +12573,35 @@ paths: schema: type: object properties: - organization_id: + name: type: string - description: 'The organization claiming it.' + maxLength: 120 + description: + type: + - string + - 'null' + maxLength: 500 + organization_id: + type: + - string + - 'null' + description: 'The organization it belongs to; null defines it for every organization in the environment.' maxLength: 64 - domain: - type: string - description: 'The email domain, e.g. acme.com.' - maxLength: 253 + client_id: + type: + - string + - 'null' + description: 'The app whose tokens it is stamped into; null for every app.' + maxLength: 190 + permissions: + type: array + description: 'Permission ids to grant it from the start.' + maxItems: 200 + items: + type: string + maxLength: 64 required: - - organization_id - - domain + - name responses: 201: description: OK @@ -11657,7 +12613,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/SsoDomain' + $ref: '#/components/schemas/Role' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -11674,64 +12630,38 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - get: + '/roles/{id}': + delete: tags: - - 'Enterprise SSO' - summary: 'List the email domains organizations claimed for single sign-on, whether each is verified and captured, and the DNS record that proves an unverified one.' - description: 'Requires scope `sso:read`. Danger: read.' - operationId: sso_domains_list - x-action: sso.domains.list - x-scope: 'sso:read' - x-danger: read + - Roles + summary: "Delete a role: everyone who holds it loses it. An app-declared role is removed from the app's manifest instead." + description: 'Requires scope `role_definitions:write`. Danger: destructive.' + operationId: roles_delete + x-action: roles.delete + x-scope: 'role_definitions:write' + x-danger: destructive parameters: - - name: organization_id - in: query - required: false - schema: - type: - - string - - 'null' - description: "Only this organization's." - maxLength: 64 - - - name: limit - in: query - required: false - schema: - type: integer - description: 'Items per page, 1–100. Default 50.' - - - name: after - in: query - required: false + name: id + in: path + required: true schema: type: string - description: 'The `next_cursor` of the previous page.' + description: 'The role id.' + maxLength: 64 + - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] + - + ManagementAccessToken: [] - WorkspaceAccessToken: [] responses: - 200: - description: OK - content: - application/json: - schema: - type: object - required: - - data - - meta - properties: - data: - type: array - items: - $ref: '#/components/schemas/SsoDomain' - meta: - $ref: '#/components/schemas/PageMeta' + 204: + description: Done. 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -11742,20 +12672,21 @@ paths: $ref: '#/components/responses/Forbidden' 404: $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' 422: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - '/sso/domains/{id}': - delete: + get: tags: - - 'Enterprise SSO' - summary: "Remove a claimed email domain. Its people stop being routed to the organization's SSO connection." - description: 'Requires scope `sso:write`. Danger: destructive.' - operationId: sso_domains_delete - x-action: sso.domains.delete - x-scope: 'sso:write' - x-danger: destructive + - Roles + summary: 'Get one role and the permissions it carries. Name it by id, or by manifest `key` with `client_id`.' + description: 'Requires scope `roles:read`. Danger: read.' + operationId: roles_get + x-action: roles.get + x-scope: 'roles:read' + x-danger: read parameters: - name: id @@ -11763,33 +12694,35 @@ paths: required: true schema: type: string - description: "The domain's id." - - $ref: '#/components/parameters/IdempotencyKey' + description: 'The role id, or its manifest `key` with `client_id`.' + maxLength: 190 + - + name: client_id + in: query + required: false + schema: + type: string + description: 'The app whose manifest `key` `id` is.' + maxLength: 255 - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] - - - ManagementAccessToken: [] - WorkspaceAccessToken: [] - requestBody: - required: false - content: - application/json: - schema: - type: object - properties: - organization_id: - type: - - string - - 'null' - description: "Only if it is this organization's; anything else is a 404." - maxLength: 64 responses: - 204: - description: Done. + 200: + description: OK + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/Role' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -11800,22 +12733,19 @@ paths: $ref: '#/components/responses/Forbidden' 404: $ref: '#/components/responses/NotFound' - 409: - $ref: '#/components/responses/Conflict' 422: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - '/sso/domains/{id}/capture': - post: + patch: tags: - - 'Enterprise SSO' - summary: "Turn capture on or off for a verified domain: captured, everyone with an address at it must sign in through the organization's SSO." - description: 'Requires scope `sso:write`. Danger: critical.' - operationId: sso_domains_capture - x-action: sso.domains.capture - x-scope: 'sso:write' - x-danger: critical + - Roles + summary: "Rename a role or change its description. An app-declared role is changed in the app's manifest instead." + description: 'Requires scope `role_definitions:write`. Danger: write.' + operationId: roles_update + x-action: roles.update + x-scope: 'role_definitions:write' + x-danger: write parameters: - name: id @@ -11823,7 +12753,8 @@ paths: required: true schema: type: string - description: "The domain's id." + description: 'The role id.' + maxLength: 64 - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' @@ -11835,23 +12766,21 @@ paths: - WorkspaceAccessToken: [] requestBody: - required: true + required: false content: application/json: schema: type: object properties: - capture: - type: boolean - description: 'true to capture the domain, false to release it.' - organization_id: + name: + type: string + minLength: 1 + maxLength: 120 + description: type: - string - 'null' - description: "Only if it is this organization's; anything else is a 404." - maxLength: 64 - required: - - capture + maxLength: 500 responses: 200: description: OK @@ -11863,7 +12792,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/SsoDomain' + $ref: '#/components/schemas/Role' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -11880,15 +12809,15 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - '/sso/domains/{id}/verify': - post: + '/roles/{id}/permissions/{permission_id}': + put: tags: - - 'Enterprise SSO' - summary: "Check a claimed domain's DNS TXT record now. Answers the domain, with verified true once the record is found." - description: 'Requires scope `sso:write`. Danger: write.' - operationId: sso_domains_verify - x-action: sso.domains.verify - x-scope: 'sso:write' + - Roles + summary: "Add a permission to a role: everyone holding the role gains it. The permission must be unscoped or declared by the role's own app." + description: 'Requires scope `role_definitions:write`. Danger: write.' + operationId: roles_permissions_grant + x-action: roles.permissions.grant + x-scope: 'role_definitions:write' x-danger: write parameters: - @@ -11897,7 +12826,16 @@ paths: required: true schema: type: string - description: "The domain's id." + description: 'The role id.' + maxLength: 64 + - + name: permission_id + in: path + required: true + schema: + type: string + description: 'The permission id.' + maxLength: 64 - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' @@ -11908,19 +12846,6 @@ paths: ManagementAccessToken: [] - WorkspaceAccessToken: [] - requestBody: - required: false - content: - application/json: - schema: - type: object - properties: - organization_id: - type: - - string - - 'null' - description: "Only if it is this organization's; anything else is a 404." - maxLength: 64 responses: 200: description: OK @@ -11932,7 +12857,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/SsoDomain' + $ref: '#/components/schemas/Role' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -11949,17 +12874,32 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - /sso/saml-metadata: - post: + delete: tags: - - 'Enterprise SSO' - summary: "Parse an identity provider's SAML metadata (XML or a metadata URL) into the entity id, SSO URL and certificate a SAML connection needs. Stores nothing." - description: 'Requires scope `sso:write`. Danger: write.' - operationId: sso_saml_metadata_import - x-action: sso.saml_metadata.import - x-scope: 'sso:write' - x-danger: write + - Roles + summary: 'Remove a permission from a role: everyone holding the role loses it.' + description: 'Requires scope `role_definitions:write`. Danger: destructive.' + operationId: roles_permissions_revoke + x-action: roles.permissions.revoke + x-scope: 'role_definitions:write' + x-danger: destructive parameters: + - + name: id + in: path + required: true + schema: + type: string + description: 'The role id.' + maxLength: 64 + - + name: permission_id + in: path + required: true + schema: + type: string + description: 'The permission id.' + maxLength: 64 - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' @@ -11970,19 +12910,6 @@ paths: ManagementAccessToken: [] - WorkspaceAccessToken: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - metadata: - type: string - description: 'The IdP metadata XML, or an https URL it is published at.' - maxLength: 500000 - required: - - metadata responses: 200: description: OK @@ -11994,7 +12921,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/SamlMetadata' + $ref: '#/components/schemas/Role' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -12011,38 +12938,17 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - /support-sessions: + /saml-apps: post: - operationId: support_sessions_start - x-action: support_sessions.start tags: - - 'Support sessions' - summary: 'Start a support session' - description: | - Requires scope `support:write`. A member of your staff (`actor_user_id`) signs in to - one of your apps (`client_id`) AS one of a customer's users (`user_id` in - `organization_id`), for a stated `reason`, for at most 60 minutes. - - The actor must hold your app's own `support:impersonate` permission through an - **environment-wide** grant (see `PUT /users/{id}/environment-roles/{role_id}`); - otherwise `403 not_permitted`. The app must be a first-party app the environment - owns that uses the authorization-code grant (`422 client_not_eligible`). The target - must be an active member (`422 target_not_member`) of an active organization - (`409 organization_inactive`). - - Send `redirect_uri` (registered for the app) and a PKCE S256 `code_challenge` to get - the first authorization `code` in the response. Your app redeems it at `/oauth/token` - with the verifier; every token carries `act: {"sub": actor_user_id}` and there is - never a refresh token. The session is audited on the customer's trail and the - environment's, and announced as the `support_session.started` webhook. - - `scopes` in the response are exactly what every token of the session carries: the - ones asked for (or, with none, the app's registration), never `offline_access`, and - audienced the way the token endpoint audiences them — once any of them belongs to a - registered API, the token is for that API and a scope no API registered (such as - `apps.manifest`) is left out. Scopes of two registered APIs cannot be audienced to - one token and are refused before the session starts (`422 invalid_target`). - parameters: + - 'SAML apps' + summary: 'Register a SAML app that signs people in with their account here. Decides where assertions — and their attributes — are sent.' + description: 'Requires scope `saml_apps:write`. Danger: critical.' + operationId: saml_apps_create + x-action: saml_apps.create + x-scope: 'saml_apps:write' + x-danger: critical + parameters: - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' @@ -12057,49 +12963,67 @@ paths: application/json: schema: type: object - required: - - user_id - - organization_id - - client_id - - actor_user_id - - reason properties: - user_id: - type: string - description: "The customer's user to act as." - organization_id: + entity_id: type: string - client_id: + description: "The application's SAML EntityID, unique in this environment." + maxLength: 500 + acs_url: type: string - description: "The app's public client id." - actor_user_id: + description: 'Where assertions are posted: https (http only on localhost), absolute, no fragment.' + format: uri + maxLength: 1000 + name_id_format: type: string - description: 'Your staff member.' - reason: + description: 'The NameID format URN. Default emailAddress.' + enum: + - 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress' + - 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent' + - 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient' + - 'urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified' + name_id_attribute: type: string - maxLength: 500 - description: 'Shown to the customer.' - ttl_minutes: - type: integer - minimum: 1 - maximum: 60 - scopes: + description: "The person's field the NameID is read from. Default `email`." + maxLength: 120 + attribute_mappings: type: array + description: 'The attributes the assertion carries. Sent, it is the COMPLETE list afterwards.' + maxItems: 100 items: - type: string - description: "Narrowed to the app's registration; omitted means all of it." - redirect_uri: - type: string - code_challenge: - type: string - pattern: '^[A-Za-z0-9_-]{43}$' - nonce: + type: object + properties: + attribute: + type: string + description: 'The attribute name the assertion emits: `email`, `displayName`.' + maxLength: 190 + field: + type: string + description: "The person's field it is read from: `email`, `name`." + maxLength: 190 + required: + - attribute + - field + want_authn_requests_signed: + type: boolean + description: "Verify the application's signature on every AuthnRequest. Needs a certificate." + certificate: + type: + - string + - 'null' + description: "The application's signing certificate (PEM). Write-only; left out or null keeps the one on file." + maxLength: 20000 + organization_id: type: - string - 'null' + description: 'The organization that owns the application: only its active members are signed in to it, and the assertion names it. Null makes it environment-wide — every person in the environment may sign in to it.' + maxLength: 64 + required: + - entity_id + - acs_url responses: 201: - description: Started. + description: OK content: application/json: schema: @@ -12108,7 +13032,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/SupportSession' + $ref: '#/components/schemas/SamlApp' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -12125,28 +13049,30 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - x-scope: 'support:write' - x-danger: critical - '/support-sessions/{id}': - delete: + get: tags: - - 'Support sessions' - summary: 'End a support session now: every token it issued is revoked.' - description: 'Requires scope `support:write`. Danger: destructive.' - operationId: support_sessions_end - x-action: support_sessions.end - x-scope: 'support:write' - x-danger: destructive + - 'SAML apps' + summary: 'List the SAML apps that trust this environment as their identity provider.' + description: 'Requires scope `saml_apps:read`. Danger: read.' + operationId: saml_apps_list + x-action: saml_apps.list + x-scope: 'saml_apps:read' + x-danger: read parameters: - - name: id - in: path - required: true + name: limit + in: query + required: false + schema: + type: integer + description: 'Items per page, 1–100. Default 50.' + - + name: after + in: query + required: false schema: type: string - description: 'The support session id.' - maxLength: 64 - - $ref: '#/components/parameters/IdempotencyKey' + description: 'The `next_cursor` of the previous page.' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: @@ -12155,8 +13081,22 @@ paths: - WorkspaceAccessToken: [] responses: - 204: - description: Done. + 200: + description: OK + content: + application/json: + schema: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + $ref: '#/components/schemas/SamlApp' + meta: + $ref: '#/components/schemas/PageMeta' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -12167,74 +13107,39 @@ paths: $ref: '#/components/responses/Forbidden' 404: $ref: '#/components/responses/NotFound' - 409: - $ref: '#/components/responses/Conflict' 422: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - /token-vault/secrets: - post: + '/saml-apps/{id}': + delete: tags: - - 'Token vault' - summary: "Store a downstream credential (a third-party API key) in an organization's token vault, or the environment's own. The value is sealed and never returned." - description: 'Requires scope `token_vault:write`. Danger: write.' - operationId: token_vault_secrets_create - x-action: token_vault.secrets.create - x-scope: 'token_vault:write' - x-danger: write + - 'SAML apps' + summary: 'Remove a SAML app. People can no longer sign in to it with their account here.' + description: 'Requires scope `saml_apps:write`. Danger: critical.' + operationId: saml_apps_delete + x-action: saml_apps.delete + x-scope: 'saml_apps:write' + x-danger: critical parameters: + - + name: id + in: path + required: true + schema: + type: string + description: "The SAML app's id." - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] - - - ManagementAccessToken: [] - WorkspaceAccessToken: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - organization_id: - type: - - string - - 'null' - description: "The organization whose vault this is. Left out: the environment's own secrets, a separate collection." - maxLength: 64 - name: - type: string - description: 'What it is: "Stripe live key".' - maxLength: 190 - provider: - type: string - description: 'Whose credential it is: "stripe", "openai".' - maxLength: 190 - secret: - type: string - description: 'The credential. Write-only: sealed, never returned.' - maxLength: 20000 - required: - - name - - provider - - secret responses: - 201: - description: OK - content: - application/json: - schema: - type: object - required: - - data - properties: - data: - $ref: '#/components/schemas/TokenVaultSecret' + 204: + description: Done. 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -12253,38 +13158,21 @@ paths: $ref: '#/components/responses/TooManyRequests' get: tags: - - 'Token vault' - summary: "List the downstream credentials stored in an organization's token vault (or the environment's own): names, providers, status. Never a value." - description: 'Requires scope `token_vault:read`. Danger: read.' - operationId: token_vault_secrets_list - x-action: token_vault.secrets.list - x-scope: 'token_vault:read' + - 'SAML apps' + summary: 'Read one SAML app: its entity id, ACS URL, NameID and attribute mappings. Never its certificate.' + description: 'Requires scope `saml_apps:read`. Danger: read.' + operationId: saml_apps_get + x-action: saml_apps.get + x-scope: 'saml_apps:read' x-danger: read parameters: - - name: organization_id - in: query - required: false - schema: - type: - - string - - 'null' - description: "The organization whose vault this is. Left out: the environment's own secrets, a separate collection." - maxLength: 64 - - - name: limit - in: query - required: false - schema: - type: integer - description: 'Items per page, 1–100. Default 50.' - - - name: after - in: query - required: false + name: id + in: path + required: true schema: type: string - description: 'The `next_cursor` of the previous page.' + description: "The SAML app's id." - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: @@ -12301,14 +13189,9 @@ paths: type: object required: - data - - meta properties: data: - type: array - items: - $ref: '#/components/schemas/TokenVaultSecret' - meta: - $ref: '#/components/schemas/PageMeta' + $ref: '#/components/schemas/SamlApp' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -12323,16 +13206,15 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - '/token-vault/secrets/{id}': - get: + patch: tags: - - 'Token vault' - summary: 'Read one stored credential and the client ids granted to lease it. Never its value.' - description: 'Requires scope `token_vault:read`. Danger: read.' - operationId: token_vault_secrets_get - x-action: token_vault.secrets.get - x-scope: 'token_vault:read' - x-danger: read + - 'SAML apps' + summary: "Change a SAML app's entity id, ACS URL, NameID, attribute mappings, signing certificate or owning organization." + description: 'Requires scope `saml_apps:write`. Danger: critical.' + operationId: saml_apps_update + x-action: saml_apps.update + x-scope: 'saml_apps:write' + x-danger: critical parameters: - name: id @@ -12340,16 +13222,122 @@ paths: required: true schema: type: string - description: "The secret's id." + description: "The SAML app's id." + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + WorkspaceAccessToken: [] + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + entity_id: + type: string + description: "The application's SAML EntityID, unique in this environment." + maxLength: 500 + acs_url: + type: string + description: 'Where assertions are posted: https (http only on localhost), absolute, no fragment.' + format: uri + maxLength: 1000 + name_id_format: + type: string + description: 'The NameID format URN. Default emailAddress.' + enum: + - 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress' + - 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent' + - 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient' + - 'urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified' + name_id_attribute: + type: string + description: "The person's field the NameID is read from. Default `email`." + maxLength: 120 + attribute_mappings: + type: array + description: 'The attributes the assertion carries. Sent, it is the COMPLETE list afterwards.' + maxItems: 100 + items: + type: object + properties: + attribute: + type: string + description: 'The attribute name the assertion emits: `email`, `displayName`.' + maxLength: 190 + field: + type: string + description: "The person's field it is read from: `email`, `name`." + maxLength: 190 + required: + - attribute + - field + want_authn_requests_signed: + type: boolean + description: "Verify the application's signature on every AuthnRequest. Needs a certificate." + certificate: + type: + - string + - 'null' + description: "The application's signing certificate (PEM). Write-only; left out or null keeps the one on file." + maxLength: 20000 + organization_id: + type: + - string + - 'null' + description: 'The organization that owns the application: only its active members are signed in to it, and the assertion names it. Null makes it environment-wide — every person in the environment may sign in to it.' + maxLength: 64 + responses: + 200: + description: OK + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/SamlApp' + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' + /sign-in/policy: + get: + tags: + - Sign-in + summary: "Read the authentication policy (password, MFA, SSO, lockout): the environment baseline, or one organization's effective rules and override." + description: 'Requires scope `signin:read`. Danger: read.' + operationId: signin_policy_get + x-action: signin.policy.get + x-scope: 'signin:read' + x-danger: read + parameters: - name: organization_id in: query required: false schema: - type: - - string - - 'null' - description: "The organization whose vault this is. Left out: the environment's own secrets, a separate collection." + type: string + description: "Read one organization's rules. Left out, the environment baseline." maxLength: 64 - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' @@ -12369,7 +13357,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/TokenVaultSecret' + $ref: '#/components/schemas/SignInPolicy' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -12384,24 +13372,16 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - '/token-vault/secrets/{id}/grants': - post: + patch: tags: - - 'Token vault' - summary: 'Grant an app (by OAuth client id) the right to lease a stored credential.' - description: 'Requires scope `token_vault:write`. Danger: critical.' - operationId: token_vault_grants_create - x-action: token_vault.grants.create - x-scope: 'token_vault:write' + - Sign-in + summary: "Change the authentication policy of the environment baseline, or tighten one organization's override. Requiring SSO signs out password sessions." + description: 'Requires scope `signin:write`. Danger: critical.' + operationId: signin_policy_update + x-action: signin.policy.update + x-scope: 'signin:write' x-danger: critical parameters: - - - name: id - in: path - required: true - schema: - type: string - description: "The secret's id." - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' @@ -12413,7 +13393,7 @@ paths: - WorkspaceAccessToken: [] requestBody: - required: true + required: false content: application/json: schema: @@ -12423,14 +13403,49 @@ paths: type: - string - 'null' - description: "The organization whose vault this is. Left out: the environment's own secrets, a separate collection." + description: "Write this organization's override. Left out, the environment baseline." maxLength: 64 - client_id: + min_length: + type: integer + description: 'Minimum password length.' + minimum: 8 + maximum: 128 + require_breach_check: + type: boolean + description: 'Refuse passwords that appear in known breaches.' + max_age_days: + type: + - integer + - 'null' + description: 'Days before a password must be changed; null for no limit.' + minimum: 1 + maximum: 3650 + reuse_history: + type: integer + description: 'How many previous passwords may not be reused.' + minimum: 0 + maximum: 24 + mfa: type: string - description: 'The OAuth client id of the app that may lease it.' - maxLength: 190 - required: - - client_id + description: 'Whether a second factor is off, optional or required.' + enum: + - 'off' + - optional + - required + sso: + type: string + description: 'Whether SSO is off, preferred, or required — `required` refuses every other way in and signs out password sessions.' + enum: + - 'off' + - preferred + - required + lockout_threshold: + type: + - integer + - 'null' + description: 'Failed attempts before an account is locked; null for the deployment default (10 inside 15 minutes unless CBOX_ID_LOCKOUT_THRESHOLD says otherwise).' + minimum: 3 + maximum: 100 responses: 200: description: OK @@ -12442,7 +13457,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/TokenVaultSecret' + $ref: '#/components/schemas/SignInPolicy' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -12459,32 +13474,25 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - '/token-vault/secrets/{id}/grants/{client_id}': + '/sign-in/policy/organizations/{organization_id}': delete: tags: - - 'Token vault' - summary: "Withdraw an app's right to lease a stored credential." - description: 'Requires scope `token_vault:write`. Danger: destructive.' - operationId: token_vault_grants_delete - x-action: token_vault.grants.delete - x-scope: 'token_vault:write' - x-danger: destructive + - Sign-in + summary: "Drop one organization's authentication policy override, so it inherits the environment baseline again." + description: 'Requires scope `signin:write`. Danger: critical.' + operationId: signin_policy_inherit + x-action: signin.policy.inherit + x-scope: 'signin:write' + x-danger: critical parameters: - - name: id - in: path - required: true - schema: - type: string - description: "The secret's id." - - - name: client_id + name: organization_id in: path required: true schema: type: string - description: 'The OAuth client id whose grant is withdrawn.' - maxLength: 190 + description: 'The organization whose override to drop.' + maxLength: 64 - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' @@ -12495,19 +13503,6 @@ paths: ManagementAccessToken: [] - WorkspaceAccessToken: [] - requestBody: - required: false - content: - application/json: - schema: - type: object - properties: - organization_id: - type: - - string - - 'null' - description: "The organization whose vault this is. Left out: the environment's own secrets, a separate collection." - maxLength: 64 responses: 204: description: Done. @@ -12527,47 +13522,37 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - '/token-vault/secrets/{id}/revoke': - post: - tags: - - 'Token vault' - summary: 'Revoke a stored credential permanently. No app can lease it again.' - description: 'Requires scope `token_vault:write`. Danger: destructive.' - operationId: token_vault_secrets_revoke - x-action: token_vault.secrets.revoke - x-scope: 'token_vault:write' - x-danger: destructive + /sign-in/self-service-signup: + put: + tags: + - Sign-in + summary: 'Switch self-service sign-up on or off: whether people can create an account and their own organization in this environment.' + description: 'Requires scope `signin:write`. Danger: critical.' + operationId: signin_self_service_signup_set + x-action: signin.self_service_signup.set + x-scope: 'signin:write' + x-danger: critical parameters: - - - name: id - in: path - required: true - schema: - type: string - description: "The secret's id." - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] - - - ManagementAccessToken: [] - WorkspaceAccessToken: [] requestBody: - required: false + required: true content: application/json: schema: type: object properties: - organization_id: - type: - - string - - 'null' - description: "The organization whose vault this is. Left out: the environment's own secrets, a separate collection." - maxLength: 64 + enabled: + type: boolean + description: 'On: anyone may create an account and their own organization. Off: people join by invitation.' + required: + - enabled responses: 200: description: OK @@ -12579,7 +13564,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/TokenVaultSecret' + $ref: '#/components/schemas/SelfServiceSignup' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -12596,53 +13581,320 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - '/token-vault/secrets/{id}/rotate': - post: + /sign-in/sms: + get: tags: - - 'Token vault' - summary: "Replace a stored credential's value. Every later lease hands out the new one. The value is never returned." - description: 'Requires scope `token_vault:write`. Danger: critical.' - operationId: token_vault_secrets_rotate - x-action: token_vault.secrets.rotate - x-scope: 'token_vault:write' - x-danger: critical + - Sign-in + summary: 'Read the SMS second-factor policy: whether text-message codes are accepted, for which countries, and the administrator rule.' + description: 'Requires scope `signin:read`. Danger: read.' + operationId: signin_sms_get + x-action: signin.sms.get + x-scope: 'signin:read' + x-danger: read parameters: + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: - - name: id - in: path - required: true - schema: - type: string - description: "The secret's id." + EnvironmentApiKey: [] + - + WorkspaceAccessToken: [] + responses: + 200: + description: OK + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/SmsFactorPolicy' + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' + patch: + tags: + - Sign-in + summary: "Change the SMS second-factor policy: accept text-message codes or not, the countries they may go to, and whether SMS may be an administrator's only factor." + description: 'Requires scope `signin:write`. Danger: critical.' + operationId: signin_sms_update + x-action: signin.sms.update + x-scope: 'signin:write' + x-danger: critical + parameters: - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] - - - ManagementAccessToken: [] - WorkspaceAccessToken: [] requestBody: - required: true + required: false content: application/json: schema: type: object properties: - organization_id: - type: - - string - - 'null' - description: "The organization whose vault this is. Left out: the environment's own secrets, a separate collection." - maxLength: 64 - secret: - type: string - description: 'The new value. Write-only: sealed, never returned.' - maxLength: 20000 - required: - - secret + enabled: + type: boolean + description: 'Accept text messages as a second factor in this environment. Off by default: SMS is the weakest factor offered.' + allowed_countries: + type: array + description: 'ISO 3166-1 alpha-2 countries whose numbers may enrol and be texted. Required to be non-empty while SMS is on: which countries you text is your toll-fraud exposure.' + maxItems: 250 + items: + type: string + enum: + - AC + - AD + - AE + - AF + - AG + - AI + - AL + - AM + - AO + - AR + - AS + - AT + - AU + - AW + - AZ + - BA + - BB + - BD + - BE + - BF + - BG + - BH + - BI + - BJ + - BM + - BN + - BO + - BR + - BS + - BT + - BW + - BY + - BZ + - CA + - CD + - CF + - CG + - CH + - CI + - CK + - CL + - CM + - CN + - CO + - CR + - CU + - CV + - CW + - CY + - CZ + - DE + - DJ + - DK + - DM + - DO + - DZ + - EC + - EE + - EG + - ER + - ES + - ET + - FI + - FJ + - FK + - FM + - FO + - FR + - GA + - GB + - GD + - GE + - GF + - GH + - GI + - GL + - GM + - GN + - GP + - GQ + - GR + - GT + - GU + - GW + - GY + - HK + - HN + - HR + - HT + - HU + - ID + - IE + - IL + - IN + - IO + - IQ + - IR + - IS + - IT + - JM + - JO + - JP + - KE + - KG + - KH + - KI + - KM + - KN + - KP + - KR + - KW + - KY + - KZ + - LA + - LB + - LC + - LI + - LK + - LR + - LS + - LT + - LU + - LV + - LY + - MA + - MC + - MD + - ME + - MG + - MH + - MK + - ML + - MM + - MN + - MO + - MP + - MQ + - MR + - MS + - MT + - MU + - MV + - MW + - MX + - MY + - MZ + - NA + - NC + - NE + - NF + - NG + - NI + - NL + - 'NO' + - NP + - NR + - NU + - NZ + - OM + - PA + - PE + - PF + - PG + - PH + - PK + - PL + - PM + - PR + - PS + - PT + - PW + - PY + - QA + - RE + - RO + - RS + - RU + - RW + - SA + - SB + - SC + - SD + - SE + - SG + - SH + - SI + - SK + - SL + - SM + - SN + - SO + - SR + - SS + - ST + - SV + - SX + - SY + - SZ + - TC + - TD + - TG + - TH + - TJ + - TK + - TL + - TM + - TN + - TO + - TR + - TT + - TV + - TW + - TZ + - UA + - UG + - US + - UY + - UZ + - VC + - VE + - VG + - VI + - VN + - VU + - WF + - WS + - XK + - YE + - ZA + - ZM + - ZW + maxLength: 2 + privileged_need_stronger_factor: + type: boolean + description: 'Administrators may add SMS only beside an authenticator app or a passkey, and are asked to enrol one if SMS is all they hold. On by default.' responses: 200: description: OK @@ -12654,7 +13906,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/TokenVaultSecret' + $ref: '#/components/schemas/SmsFactorPolicy' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -12671,46 +13923,39 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - /users: + /sign-in/social-providers: get: - operationId: users_list - x-action: users.list tags: - - Users - summary: 'List users' - description: | - Requires scope `users:read`. A page at a time, in id order. Narrow it the ways you - look for somebody: `email` (exactly that address, case-insensitive), `q` (a fragment - of the address or the name) and `status`. Filters combine. + - Sign-in + summary: 'List the social login providers (Google, GitHub, Apple…) enabled in this environment, optionally for one organization.' + description: 'Requires scope `signin:read`. Danger: read.' + operationId: signin_social_list + x-action: signin.social.list + x-scope: 'signin:read' + x-danger: read parameters: - - $ref: '#/components/parameters/Limit' - - $ref: '#/components/parameters/After' - - name: email + name: organization_id in: query required: false schema: type: string - format: email - maxLength: 190 + description: "Only this organization's providers." + maxLength: 64 - - name: q + name: limit in: query required: false - description: 'A fragment of the address or the name.' schema: - type: string - maxLength: 190 + type: integer + description: 'Items per page, 1–100. Default 50.' - - name: status + name: after in: query required: false schema: type: string - enum: - - active - - disabled - - locked + description: 'The `next_cursor` of the previous page.' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: @@ -12719,42 +13964,45 @@ paths: - WorkspaceAccessToken: [] responses: - 400: - $ref: '#/components/responses/EnvironmentRequired' - 404: - $ref: '#/components/responses/NotFound' - 422: - $ref: '#/components/responses/UnprocessableEntity' 200: description: OK content: application/json: schema: type: object + required: + - data + - meta properties: data: type: array items: - $ref: '#/components/schemas/User' + $ref: '#/components/schemas/SocialProvider' meta: $ref: '#/components/schemas/PageMeta' 202: $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' 401: $ref: '#/components/responses/Unauthorized' 403: $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 422: + $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - x-scope: 'users:read' - x-danger: read post: - operationId: users_create - x-action: users.create tags: - - Users - summary: 'Create a user' - description: 'Requires scope `users:write`. Password is optional — omit it to create a passwordless identity the user completes via an invite/magic-link.' + - Sign-in + summary: 'Enable a social login provider (Google, GitHub, Apple…) for one organization with its client credentials. The secret is never returned.' + description: 'Requires scope `signin:write`. Danger: critical.' + operationId: signin_social_set + x-action: signin.social.set + x-scope: 'signin:write' + x-danger: critical parameters: - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' @@ -12762,6 +14010,8 @@ paths: security: - EnvironmentApiKey: [] + - + ManagementAccessToken: [] - WorkspaceAccessToken: [] requestBody: @@ -12770,82 +14020,87 @@ paths: application/json: schema: type: object - required: - - email properties: - email: + organization_id: type: string - format: email - maxLength: 190 - name: - type: - - string - - 'null' - maxLength: 190 - password: + description: 'The organization whose sign-in page offers it.' + maxLength: 64 + provider: + type: string + description: 'The catalogue key: google, microsoft, okta, auth0, keycloak, gitlab, slack, github, discord, apple, facebook, linkedin, bitbucket, xero, intuit.' + maxLength: 100 + client_id: + type: string + description: 'The client id from your own account with the provider. For Apple, the Services ID.' + maxLength: 400 + client_secret: type: - string - 'null' - minLength: 8 - maxLength: 255 - description: "Checked against this environment's password policy (`422 password_policy`)." - send_sign_in_link: - type: boolean - default: false - description: 'Mail the new user a one-click sign-in link.' - responses: - 201: - description: Created - content: - application/json: - schema: - type: object - properties: - data: - $ref: '#/components/schemas/User' - 202: - $ref: '#/components/responses/ApprovalRequired' - 400: - $ref: '#/components/responses/EnvironmentRequired' - 401: - $ref: '#/components/responses/Unauthorized' - 403: - $ref: '#/components/responses/Forbidden' - 404: - $ref: '#/components/responses/NotFound' - 422: - $ref: '#/components/responses/UnprocessableEntity' - 429: - $ref: '#/components/responses/TooManyRequests' - x-scope: 'users:write' - x-danger: write - '/users/{id}': - get: - operationId: users_get - x-action: users.get - tags: - - Users - summary: 'Get a user' - description: 'Requires scope `users:read`.' - parameters: - - $ref: '#/components/parameters/Id' - - $ref: '#/components/parameters/CboxApproval' - - $ref: '#/components/parameters/CboxEnvironment' - security: - - - EnvironmentApiKey: [] - - - WorkspaceAccessToken: [] + description: 'The client secret. Write-only. Not used by Apple, which signs its own.' + maxLength: 5000 + parameters: + type: object + description: "What the chosen provider needs besides its client credentials — Microsoft's directory, Okta's domain, Apple's team id, key id and private key." + properties: + directory: + type: + - string + - 'null' + description: 'Directory (tenant) ID (Microsoft Entra ID).' + maxLength: 5000 + domain: + type: + - string + - 'null' + description: 'Okta domain (Okta).' + maxLength: 5000 + host: + type: + - string + - 'null' + description: 'Keycloak host (Keycloak).' + maxLength: 5000 + key_id: + type: + - string + - 'null' + description: 'Key ID (Apple).' + maxLength: 5000 + private_key: + type: + - string + - 'null' + description: 'Private key (.p8) (Apple).' + maxLength: 5000 + realm: + type: + - string + - 'null' + description: 'Realm (Keycloak).' + maxLength: 5000 + team_id: + type: + - string + - 'null' + description: 'Team ID (Apple).' + maxLength: 5000 + required: + - organization_id + - provider + - client_id responses: - 200: + 201: description: OK content: application/json: schema: type: object + required: + - data properties: data: - $ref: '#/components/schemas/User' + $ref: '#/components/schemas/SocialProvider' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -12856,41 +14111,56 @@ paths: $ref: '#/components/responses/Forbidden' 404: $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' + 422: + $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - x-scope: 'users:read' - x-danger: read + '/sign-in/social-providers/{id}': delete: - operationId: users_deactivate - x-action: users.deactivate tags: - - Users - summary: 'Deactivate a user' - description: | - Requires scope `users:write`. A **soft disable** — the identity can no longer - authenticate, but is never hard-deleted — and every OAuth grant they hold is - revoked, which reactivating them does not bring back. Returns the user in its new - `disabled` state. Idempotent. The console's "Deactivate" is this action. + - Sign-in + summary: 'Stop offering a social login provider. People who used it keep their accounts.' + description: 'Requires scope `signin:write`. Danger: critical.' + operationId: signin_social_delete + x-action: signin.social.delete + x-scope: 'signin:write' + x-danger: critical parameters: - - $ref: '#/components/parameters/Id' + - + name: id + in: path + required: true + schema: + type: string + description: "The provider's id." - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] + - + ManagementAccessToken: [] - WorkspaceAccessToken: [] + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + organization_id: + type: + - string + - 'null' + description: "Only remove it if it is this organization's." + maxLength: 64 responses: - 200: - description: OK - content: - application/json: - schema: - type: object - properties: - data: - $ref: '#/components/schemas/User' + 204: + description: Done. 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -12901,55 +14171,72 @@ paths: $ref: '#/components/responses/Forbidden' 404: $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' + 422: + $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - x-scope: 'users:write' - x-danger: destructive - patch: + /sod-policies: + post: tags: - - Users - summary: "Change a user's name and/or email address. A changed address must be verified again." - description: 'Requires scope `users:write`. Danger: critical.' - operationId: users_update - x-action: users.update - x-scope: 'users:write' - x-danger: critical + - Governance + summary: 'Define a role-conflict rule: two or more roles no one person may hold together. Enforced on every grant from now on.' + description: 'Requires scope `governance:write`. Danger: write.' + operationId: sod_policies_create + x-action: sod_policies.create + x-scope: 'governance:write' + x-danger: write parameters: - - - name: id - in: path - required: true - schema: - type: string - description: 'The user id.' - maxLength: 64 - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] + - + ManagementAccessToken: [] - WorkspaceAccessToken: [] requestBody: - required: false + required: true content: application/json: schema: type: object properties: - name: + organization_id: type: - string - 'null' - maxLength: 190 - email: + description: 'The organization the rule belongs to. Send this or environment_wide.' + maxLength: 64 + environment_wide: + type: boolean + description: "True to make the rule the environment's own rather than one organization's. Send this or organization_id." + name: type: string - description: 'The new address. Its verification is cleared.' - format: email + description: 'What the rule is for: "Payments maker/checker".' maxLength: 190 + description: + type: + - string + - 'null' + description: 'Why the roles conflict.' + maxLength: 500 + role_ids: + type: array + description: 'The roles no one person may hold together; at least two.' + minItems: 2 + maxItems: 50 + items: + type: string + maxLength: 64 + required: + - name + - role_ids responses: - 200: + 201: description: OK content: application/json: @@ -12959,7 +14246,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/User' + $ref: '#/components/schemas/SodPolicy' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -12976,16 +14263,40 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - '/users/{id}/environment-roles': get: - operationId: users_environment_roles_list - x-action: users.environment_roles.list tags: - - Roles - summary: 'List the roles a user holds everywhere (staff)' - description: 'Requires scope `roles:read`.' + - Governance + summary: 'List role-conflict (segregation of duties) rules: sets of roles no one person may hold together. Optionally those binding one organization.' + description: 'Requires scope `governance:read`. Danger: read.' + operationId: sod_policies_list + x-action: sod_policies.list + x-scope: 'governance:read' + x-danger: read parameters: - - $ref: '#/components/parameters/Id' + - + name: organization_id + in: query + required: false + schema: + type: + - string + - 'null' + description: 'Only the rules binding this organization: its own and the environment-wide ones.' + maxLength: 64 + - + name: limit + in: query + required: false + schema: + type: integer + description: 'Items per page, 1–100. Default 50.' + - + name: after + in: query + required: false + schema: + type: string + description: 'The `next_cursor` of the previous page.' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: @@ -13002,11 +14313,14 @@ paths: type: object required: - data + - meta properties: data: type: array items: - $ref: '#/components/schemas/RoleAssignment' + $ref: '#/components/schemas/SodPolicy' + meta: + $ref: '#/components/schemas/PageMeta' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -13017,92 +14331,54 @@ paths: $ref: '#/components/responses/Forbidden' 404: $ref: '#/components/responses/NotFound' + 422: + $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - x-scope: 'roles:read' - x-danger: read - '/users/{id}/environment-roles/{role_id}': - get: - operationId: users_environment_roles_get - x-action: users.environment_roles.get + '/sod-policies/{id}': + delete: tags: - - Roles - summary: 'Whether a user holds a role everywhere' - description: 'Requires scope `roles:read`. `200` with the grant, or `404` when it is not held.' - parameters: - - $ref: '#/components/parameters/Id' - - $ref: '#/components/parameters/RoleId' - - $ref: '#/components/parameters/RoleClientId' - - $ref: '#/components/parameters/CboxApproval' - - $ref: '#/components/parameters/CboxEnvironment' - security: - - - EnvironmentApiKey: [] - - - WorkspaceAccessToken: [] - responses: - 200: - description: Held. - content: - application/json: - schema: - type: object - required: - - data - properties: - data: - $ref: '#/components/schemas/RoleAssignment' - 202: - $ref: '#/components/responses/ApprovalRequired' - 400: - $ref: '#/components/responses/EnvironmentRequired' - 401: - $ref: '#/components/responses/Unauthorized' - 403: - $ref: '#/components/responses/Forbidden' - 404: - $ref: '#/components/responses/NotFound' - 429: - $ref: '#/components/responses/TooManyRequests' - x-scope: 'roles:read' - x-danger: read - put: - operationId: users_environment_roles_grant - x-action: users.environment_roles.grant - tags: - - Roles - summary: 'Grant a role everywhere in the environment (staff)' - description: | - Requires scope `roles:write`. Idempotent. For your own people who act across every - customer. Any role that belongs to no organization may be granted this way: an - app-agnostic one reaches every app's tokens, one your app declared reaches only - your app's. An organization's own role is refused with `422 role_not_assignable`; a - grant that would form a segregation-of-duties conflict in any organization the - person belongs to with `409 role_conflict`. + - Governance + summary: 'Remove a role-conflict rule. Grants it refused are allowed from then on.' + description: 'Requires scope `governance:write`. Danger: critical.' + operationId: sod_policies_delete + x-action: sod_policies.delete + x-scope: 'governance:write' + x-danger: critical parameters: - - $ref: '#/components/parameters/Id' - - $ref: '#/components/parameters/RoleId' - - $ref: '#/components/parameters/RoleClientId' + - + name: id + in: path + required: true + schema: + type: string + description: "The rule's id." - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] + - + ManagementAccessToken: [] - WorkspaceAccessToken: [] + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + organization_id: + type: + - string + - 'null' + description: "Only if it is this organization's; anything else is a 404." + maxLength: 64 responses: - 200: - description: 'Granted (or already held).' - content: - application/json: - schema: - type: object - required: - - data - properties: - data: - $ref: '#/components/schemas/RoleAssignment' + 204: + description: Done. 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -13119,54 +14395,15 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - x-scope: 'roles:write' - x-danger: critical - delete: - operationId: users_environment_roles_revoke - x-action: users.environment_roles.revoke - tags: - - Roles - summary: 'Take back an environment-wide grant' - description: 'Requires scope `roles:write`. Idempotent.' - parameters: - - $ref: '#/components/parameters/Id' - - $ref: '#/components/parameters/RoleId' - - $ref: '#/components/parameters/RoleClientId' - - $ref: '#/components/parameters/IdempotencyKey' - - $ref: '#/components/parameters/CboxApproval' - - $ref: '#/components/parameters/CboxEnvironment' - security: - - - EnvironmentApiKey: [] - - - WorkspaceAccessToken: [] - responses: - 204: - description: 'Not held any more.' - 202: - $ref: '#/components/responses/ApprovalRequired' - 400: - $ref: '#/components/responses/EnvironmentRequired' - 401: - $ref: '#/components/responses/Unauthorized' - 403: - $ref: '#/components/responses/Forbidden' - 404: - $ref: '#/components/responses/NotFound' - 429: - $ref: '#/components/responses/TooManyRequests' - x-scope: 'roles:write' - x-danger: destructive - '/users/{id}/erase': - post: + get: tags: - - Users - summary: 'Erase a person (GDPR Art. 17): revoke their sessions and tokens, delete their credentials, memberships and personal data, and pseudonymise their account. Cannot be undone.' - description: 'Requires scope `users:erase`. Danger: critical.' - operationId: users_erase - x-action: users.erase - x-scope: 'users:erase' - x-danger: critical + - Governance + summary: 'Read one role-conflict rule: the roles no one person may hold together, and whether it is enforced.' + description: 'Requires scope `governance:read`. Danger: read.' + operationId: sod_policies_get + x-action: sod_policies.get + x-scope: 'governance:read' + x-danger: read parameters: - name: id @@ -13174,8 +14411,17 @@ paths: required: true schema: type: string - description: "The user's id." - - $ref: '#/components/parameters/IdempotencyKey' + description: "The rule's id." + - + name: organization_id + in: query + required: false + schema: + type: + - string + - 'null' + description: 'Only if it binds this organization (its own, or environment-wide); anything else is a 404.' + maxLength: 64 - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: @@ -13194,7 +14440,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/ErasureReceipt' + $ref: '#/components/schemas/SodPolicy' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -13205,21 +14451,19 @@ paths: $ref: '#/components/responses/Forbidden' 404: $ref: '#/components/responses/NotFound' - 409: - $ref: '#/components/responses/Conflict' 422: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - '/users/{id}/mfa': - delete: + '/sod-policies/{id}/status': + post: tags: - - Users - summary: "Reset a user's two-factor authentication: their authenticator and recovery codes are removed and they must enrol again." - description: 'Requires scope `users:write`. Danger: critical.' - operationId: users_mfa_reset - x-action: users.mfa.reset - x-scope: 'users:write' + - Governance + summary: 'Enforce a role-conflict rule, or stop enforcing it — switched off, grants that break it are allowed again.' + description: 'Requires scope `governance:write`. Danger: critical.' + operationId: sod_policies_status_set + x-action: sod_policies.status.set + x-scope: 'governance:write' x-danger: critical parameters: - @@ -13228,19 +14472,47 @@ paths: required: true schema: type: string - description: 'The user id.' - maxLength: 64 + description: "The rule's id." - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] + - + ManagementAccessToken: [] - WorkspaceAccessToken: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + active: + type: boolean + description: 'true to enforce the rule, false to stop enforcing it.' + organization_id: + type: + - string + - 'null' + description: "Only if it is this organization's; anything else is a 404." + maxLength: 64 + required: + - active responses: - 204: - description: Done. + 200: + description: OK + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/SodPolicy' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -13257,31 +14529,25 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - '/users/{id}/password': + /sso/connections: post: tags: - - Users - summary: "Set a user's password, temporary by default, and mail it to them unless told not to. Signs them out according to `revoke`." - description: 'Requires scope `users:write`. Danger: critical.' - operationId: users_password_set - x-action: users.password.set - x-scope: 'users:write' - x-danger: critical + - 'Enterprise SSO' + summary: "Connect an organization's SAML or OIDC identity provider as a draft. Activate it to start signing people in through it." + description: 'Requires scope `sso:write`. Danger: write.' + operationId: sso_connections_create + x-action: sso.connections.create + x-scope: 'sso:write' + x-danger: write parameters: - - - name: id - in: path - required: true - schema: - type: string - description: 'The user id.' - maxLength: 64 - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] + - + ManagementAccessToken: [] - WorkspaceAccessToken: [] requestBody: @@ -13291,37 +14557,90 @@ paths: schema: type: object properties: - password: - type: string - description: "The new password. Checked against this environment's password policy." - maxLength: 200 - reason: + organization_id: + type: + - string + - 'null' + description: 'The organization the connection belongs to. Send this or environment_wide.' + maxLength: 64 + environment_wide: + type: boolean + description: "True to make the connection the environment's own rather than one organization's. Send this or organization_id." + name: type: string - description: 'Why — recorded on the audit trail.' - maxLength: 200 - temporary: - type: boolean - description: 'The user must choose a new password at their next sign-in. Default true.' - expires_in_hours: - type: integer - description: 'A temporary password stops working after this many hours. 0 or absent: until changed.' - minimum: 0 - maximum: 8760 - revoke: + description: 'What administrators call it: "Okta", "Entra ID".' + maxLength: 120 + type: type: string - description: 'How much existing access to end. Default `sessions_and_tokens`.' + description: 'saml or oidc. The config fields of that type are required, unless pending_idp.' enum: - - sessions_and_tokens - - sessions_only - - nothing - send_email: + - saml + - oidc + pending_idp: type: boolean - description: 'Mail the password to the user. Default true.' + description: "True to create the draft before the identity provider's details are known: the answer's service_provider is what to paste into it. Complete it with update before activating." + idp_entity_id: + type: + - string + - 'null' + description: "SAML: the identity provider's entity id." + maxLength: 500 + idp_sso_url: + type: + - string + - 'null' + description: "SAML: the identity provider's single sign-on URL." + format: uri + maxLength: 500 + idp_x509cert: + type: + - string + - 'null' + description: "SAML: the identity provider's signing certificate (PEM). Write-only." + maxLength: 20000 + sp_entity_id: + type: + - string + - 'null' + description: "SAML: the entity id this environment presents as the service provider. Left out on create, this connection's own." + maxLength: 500 + sp_acs_url: + type: + - string + - 'null' + description: "SAML: where the identity provider posts its assertions. Left out on create, this connection's own ACS URL." + format: uri + maxLength: 500 + issuer: + type: + - string + - 'null' + description: "OIDC: the provider's issuer URL; its endpoints are discovered from it." + format: uri + maxLength: 500 + client_id: + type: + - string + - 'null' + description: 'OIDC: the client id registered at the provider.' + maxLength: 500 + client_secret: + type: + - string + - 'null' + description: 'OIDC: the client secret. Write-only.' + maxLength: 500 + signing_key: + type: + - string + - 'null' + description: "OIDC, optional: the provider's ID-token signing key (an RS256 public key, PEM). Leave it out for any provider whose discovery document publishes a jwks_uri — the keys are read from there and follow its rotations. Needed only when it does not. Write-only." + maxLength: 20000 required: - - password - - reason + - name + - type responses: - 200: + 201: description: OK content: application/json: @@ -13331,7 +14650,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/User' + $ref: '#/components/schemas/SsoConnection' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -13348,26 +14667,40 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - '/users/{id}/password-reset': - post: + get: tags: - - Users - summary: 'Mail a user a password-reset link at their own address.' - description: 'Requires scope `users:write`. Danger: write.' - operationId: users_password_reset_send - x-action: users.password_reset.send - x-scope: 'users:write' - x-danger: write + - 'Enterprise SSO' + summary: 'List the SAML and OIDC connections organizations sign in through, optionally for one organization. Never a certificate or secret.' + description: 'Requires scope `sso:read`. Danger: read.' + operationId: sso_connections_list + x-action: sso.connections.list + x-scope: 'sso:read' + x-danger: read parameters: - - name: id - in: path - required: true + name: organization_id + in: query + required: false schema: - type: string - description: 'The user id.' + type: + - string + - 'null' + description: "Only this organization's." maxLength: 64 - - $ref: '#/components/parameters/IdempotencyKey' + - + name: limit + in: query + required: false + schema: + type: integer + description: 'Items per page, 1–100. Default 50.' + - + name: after + in: query + required: false + schema: + type: string + description: 'The `next_cursor` of the previous page.' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: @@ -13376,8 +14709,22 @@ paths: - WorkspaceAccessToken: [] responses: - 204: - description: Done. + 200: + description: OK + content: + application/json: + schema: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + $ref: '#/components/schemas/SsoConnection' + meta: + $ref: '#/components/schemas/PageMeta' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -13388,22 +14735,20 @@ paths: $ref: '#/components/responses/Forbidden' 404: $ref: '#/components/responses/NotFound' - 409: - $ref: '#/components/responses/Conflict' 422: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - '/users/{id}/reactivate': - post: + '/sso/connections/{id}': + delete: tags: - - Users - summary: 'Reactivate a deactivated user so they can sign in again. Grants revoked on deactivation stay revoked.' - description: 'Requires scope `users:write`. Danger: write.' - operationId: users_reactivate - x-action: users.reactivate - x-scope: 'users:write' - x-danger: write + - 'Enterprise SSO' + summary: 'Delete an SSO connection and its settings. Where SSO is required, its people cannot sign in until another is activated.' + description: 'Requires scope `sso:write`. Danger: critical.' + operationId: sso_connections_delete + x-action: sso.connections.delete + x-scope: 'sso:write' + x-danger: critical parameters: - name: id @@ -13411,28 +14756,33 @@ paths: required: true schema: type: string - description: 'The user id.' - maxLength: 64 + description: "The connection's id." - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] + - + ManagementAccessToken: [] - WorkspaceAccessToken: [] + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + organization_id: + type: + - string + - 'null' + description: "Only if it is this organization's; anything else is a 404." + maxLength: 64 responses: - 200: - description: OK - content: - application/json: - schema: - type: object - required: - - data - properties: - data: - $ref: '#/components/schemas/User' + 204: + description: Done. 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -13449,19 +14799,33 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - '/users/{id}/sessions': get: - operationId: users_sessions_list - x-action: users.sessions.list tags: - - Users - summary: "List a user's live sign-in sessions" - description: | - Requires scope `users:read`. The fifty most recently active, newest first — enough to - recognise a device and end one (`DELETE /users/{id}/sessions/{session_id}`), not a log. - `impersonation` marks a session somebody else opened as this person. + - 'Enterprise SSO' + summary: 'Read one SSO connection: its type, status, entity ids, URLs, issuer and client id. Never a certificate or secret.' + description: 'Requires scope `sso:read`. Danger: read.' + operationId: sso_connections_get + x-action: sso.connections.get + x-scope: 'sso:read' + x-danger: read parameters: - - $ref: '#/components/parameters/Id' + - + name: id + in: path + required: true + schema: + type: string + description: "The connection's id." + - + name: organization_id + in: query + required: false + schema: + type: + - string + - 'null' + description: "Only if it is this organization's; anything else is a 404." + maxLength: 64 - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: @@ -13480,9 +14844,7 @@ paths: - data properties: data: - type: array - items: - $ref: '#/components/schemas/UserSession' + $ref: '#/components/schemas/SsoConnection' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -13493,39 +14855,123 @@ paths: $ref: '#/components/responses/Forbidden' 404: $ref: '#/components/responses/NotFound' + 422: + $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - x-scope: 'users:read' - x-danger: read - delete: + patch: tags: - - Users - summary: 'Sign a user out everywhere: end every session and revoke every OAuth grant they hold.' - description: 'Requires scope `users:write`. Danger: destructive.' - operationId: users_sessions_revoke_all - x-action: users.sessions.revoke_all - x-scope: 'users:write' - x-danger: destructive - parameters: - - + - 'Enterprise SSO' + summary: "Change an SSO connection's name or identity-provider settings. Secrets left out keep the ones on file." + description: 'Requires scope `sso:write`. Danger: critical.' + operationId: sso_connections_update + x-action: sso.connections.update + x-scope: 'sso:write' + x-danger: critical + parameters: + - name: id in: path required: true schema: type: string - description: 'The user id.' - maxLength: 64 + description: "The connection's id." - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] + - + ManagementAccessToken: [] - WorkspaceAccessToken: [] + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + organization_id: + type: + - string + - 'null' + description: "Only if it is this organization's; anything else is a 404." + maxLength: 64 + name: + type: string + description: 'A new name.' + maxLength: 120 + idp_entity_id: + type: + - string + - 'null' + description: "SAML: the identity provider's entity id." + maxLength: 500 + idp_sso_url: + type: + - string + - 'null' + description: "SAML: the identity provider's single sign-on URL." + format: uri + maxLength: 500 + idp_x509cert: + type: + - string + - 'null' + description: "SAML: the identity provider's signing certificate (PEM). Write-only." + maxLength: 20000 + sp_entity_id: + type: + - string + - 'null' + description: "SAML: the entity id this environment presents as the service provider. Left out on create, this connection's own." + maxLength: 500 + sp_acs_url: + type: + - string + - 'null' + description: "SAML: where the identity provider posts its assertions. Left out on create, this connection's own ACS URL." + format: uri + maxLength: 500 + issuer: + type: + - string + - 'null' + description: "OIDC: the provider's issuer URL; its endpoints are discovered from it." + format: uri + maxLength: 500 + client_id: + type: + - string + - 'null' + description: 'OIDC: the client id registered at the provider.' + maxLength: 500 + client_secret: + type: + - string + - 'null' + description: 'OIDC: the client secret. Write-only.' + maxLength: 500 + signing_key: + type: + - string + - 'null' + description: "OIDC, optional: the provider's ID-token signing key (an RS256 public key, PEM). Leave it out for any provider whose discovery document publishes a jwks_uri — the keys are read from there and follow its rotations. Needed only when it does not. Write-only." + maxLength: 20000 responses: - 204: - description: Done. + 200: + description: OK + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/SsoConnection' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -13542,16 +14988,16 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - '/users/{id}/sessions/{session_id}': - delete: + '/sso/connections/{id}/activate': + post: tags: - - Users - summary: "End one of a user's sign-in sessions." - description: 'Requires scope `users:write`. Danger: destructive.' - operationId: users_sessions_revoke - x-action: users.sessions.revoke - x-scope: 'users:write' - x-danger: destructive + - 'Enterprise SSO' + summary: 'Activate an SSO connection: people whose email domain routes to it start signing in through it.' + description: 'Requires scope `sso:write`. Danger: critical.' + operationId: sso_connections_activate + x-action: sso.connections.activate + x-scope: 'sso:write' + x-danger: critical parameters: - name: id @@ -13559,27 +15005,42 @@ paths: required: true schema: type: string - description: 'The user id.' - maxLength: 64 - - - name: session_id - in: path - required: true - schema: - type: string - description: 'The session id, from `GET /users/{id}/sessions`.' - maxLength: 64 + description: "The connection's id." - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] + - + ManagementAccessToken: [] - WorkspaceAccessToken: [] + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + organization_id: + type: + - string + - 'null' + description: "Only if it is this organization's; anything else is a 404." + maxLength: 64 responses: - 204: - description: Done. + 200: + description: OK + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/SsoConnection' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -13596,16 +15057,16 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - '/users/{id}/verification': - post: + '/sso/connections/{id}/certificates': + get: tags: - - Users - summary: 'Mail a user the link that verifies their email address. Does nothing for an address already verified.' - description: 'Requires scope `users:write`. Danger: write.' - operationId: users_verification_send - x-action: users.verification.send - x-scope: 'users:write' - x-danger: write + - 'Enterprise SSO' + summary: "List a SAML connection's signing certificates — primary and staged — with each one's fingerprint and expiry." + description: 'Requires scope `sso:read`. Danger: read.' + operationId: sso_connections_certificates_list + x-action: sso.connections.certificates.list + x-scope: 'sso:read' + x-danger: read parameters: - name: id @@ -13613,19 +15074,38 @@ paths: required: true schema: type: string - description: 'The user id.' + description: "The connection's id." + - + name: organization_id + in: query + required: false + schema: + type: + - string + - 'null' + description: "Only if it is this organization's; anything else is a 404." maxLength: 64 - - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] + - + ManagementAccessToken: [] - WorkspaceAccessToken: [] responses: - 204: - description: Done. + 200: + description: OK + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/SsoCertificates' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -13636,21 +15116,18 @@ paths: $ref: '#/components/responses/Forbidden' 404: $ref: '#/components/responses/NotFound' - 409: - $ref: '#/components/responses/Conflict' 422: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - '/users/{id}/verify': post: tags: - - Users - summary: "Mark a user's email address verified without the emailed link. It can then be used to recover the account." - description: 'Requires scope `users:write`. Danger: critical.' - operationId: users_verify - x-action: users.verify - x-scope: 'users:write' + - 'Enterprise SSO' + summary: "Stage a SAML connection's new IdP signing certificate (PEM or IdP metadata) beside the current one, so a renewal has no outage. Activate it once the IdP signs with it." + description: 'Requires scope `sso:write`. Danger: critical.' + operationId: sso_connections_certificates_stage + x-action: sso.connections.certificates.stage + x-scope: 'sso:write' x-danger: critical parameters: - @@ -13659,16 +15136,42 @@ paths: required: true schema: type: string - description: 'The user id.' - maxLength: 64 + description: "The connection's id." - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] + - + ManagementAccessToken: [] - WorkspaceAccessToken: [] + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + organization_id: + type: + - string + - 'null' + description: "Only if it is this organization's; anything else is a 404." + maxLength: 64 + certificate: + type: + - string + - 'null' + description: 'The new signing certificate, PEM. Send this or metadata.' + maxLength: 20000 + metadata: + type: + - string + - 'null' + description: "The identity provider's metadata XML, or the https URL it is published at. Send this or certificate." + maxLength: 500000 responses: 200: description: OK @@ -13680,7 +15183,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/User' + $ref: '#/components/schemas/SsoCertificates' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -13697,302 +15200,229 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - /vault/secrets: + '/sso/connections/{id}/certificates/activate': post: tags: - - 'Token Vault' - summary: 'Store a downstream credential, sealed at rest' - description: | - The secret is sealed with the environment's crypto key and is never returned by - any read endpoint — only a `lease` reveals it, and only to a client holding a - live grant. - - Ownership is derived from the calling token's `org` claim and CANNOT be supplied - in the body: the owner is the vault's tenancy boundary, and a caller-chosen owner - would be no boundary at all. + - 'Enterprise SSO' + summary: "Make a staged SAML signing certificate the connection's primary. The certificate it replaces stops being trusted." + description: 'Requires scope `sso:write`. Danger: critical.' + operationId: sso_connections_certificates_activate + x-action: sso.connections.certificates.activate + x-scope: 'sso:write' + x-danger: critical + parameters: + - + name: id + in: path + required: true + schema: + type: string + description: "The connection's id." + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' security: - - VaultManagerToken: [] + EnvironmentApiKey: [] + - + ManagementAccessToken: [] + - + WorkspaceAccessToken: [] requestBody: required: true content: application/json: schema: type: object - required: - - name - - provider - - secret properties: - name: - type: string - maxLength: 200 - description: 'Unique per owner — two organizations may each hold a `github-token`.' - provider: - type: string - maxLength: 100 - secret: - type: string - description: 'The value to seal. Never returned by a read.' - expires_at: + organization_id: type: - string - 'null' - format: date-time + description: "Only if it is this organization's; anything else is a 404." + maxLength: 64 + fingerprint_sha256: + type: string + description: "The staged certificate's SHA-256 fingerprint, as the certificate list gives it." + maxLength: 200 + required: + - fingerprint_sha256 responses: - 201: - description: Stored. + 200: + description: OK content: application/json: schema: - $ref: '#/components/schemas/VaultSecret' + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/SsoCertificates' + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' 401: - $ref: '#/components/responses/BearerUnauthorized' + $ref: '#/components/responses/Unauthorized' 403: - $ref: '#/components/responses/BearerForbidden' + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' 422: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - '/vault/secrets/{id}': - delete: - tags: - - 'Token Vault' - summary: 'Revoke a secret permanently' - description: 'No future lease can open it. Existing leases are not recalled — they are short-lived by design.' - security: - - - VaultManagerToken: [] - parameters: - - $ref: '#/components/parameters/Id' - responses: - 204: - description: Revoked. - 401: - $ref: '#/components/responses/BearerUnauthorized' - 403: - $ref: '#/components/responses/BearerForbidden' - 404: - description: "No such secret in the caller's organization." - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - 429: - $ref: '#/components/responses/TooManyRequests' - '/vault/secrets/{id}/grants': + '/sso/connections/{id}/disable': post: tags: - - 'Token Vault' - summary: 'Authorize an agent client to lease this secret' + - 'Enterprise SSO' + summary: 'Disable an SSO connection without deleting it. Where SSO is required, its people cannot sign in until it is re-activated.' + description: 'Requires scope `sso:write`. Danger: critical.' + operationId: sso_connections_disable + x-action: sso.connections.disable + x-scope: 'sso:write' + x-danger: critical + parameters: + - + name: id + in: path + required: true + schema: + type: string + description: "The connection's id." + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' security: - - VaultManagerToken: [] - parameters: - - $ref: '#/components/parameters/Id' + EnvironmentApiKey: [] + - + ManagementAccessToken: [] + - + WorkspaceAccessToken: [] requestBody: - required: true + required: false content: application/json: schema: type: object - required: - - client_id properties: - client_id: - type: string - maxLength: 200 - max_ttl_seconds: + organization_id: type: - - integer + - string - 'null' - minimum: 1 + description: "Only if it is this organization's; anything else is a 404." + maxLength: 64 responses: - 201: - description: Granted. + 200: + description: OK content: application/json: schema: type: object + required: + - data properties: - secret_id: - type: string - client_id: - type: string - max_ttl_seconds: - type: - - integer - - 'null' + data: + $ref: '#/components/schemas/SsoConnection' + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' 401: - $ref: '#/components/responses/BearerUnauthorized' + $ref: '#/components/responses/Unauthorized' 403: - $ref: '#/components/responses/BearerForbidden' + $ref: '#/components/responses/Forbidden' 404: - description: "No such secret in the caller's organization." - content: - application/json: - schema: - $ref: '#/components/schemas/Error' + $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' 422: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - '/vault/secrets/{id}/grants/{clientId}': - delete: + '/sso/connections/{id}/require-sso': + post: tags: - - 'Token Vault' - summary: "Revoke an agent's authorization" - description: 'Idempotent — revoking an absent grant succeeds.' - security: - - - VaultManagerToken: [] + - 'Enterprise SSO' + summary: 'Require single sign-on for the organization an SSO connection belongs to. Ends every password session there.' + description: 'Requires scope `sso:write`. Danger: critical.' + operationId: sso_connections_require_sso + x-action: sso.connections.require_sso + x-scope: 'sso:write' + x-danger: critical parameters: - - $ref: '#/components/parameters/Id' - - name: clientId + name: id in: path required: true schema: type: string - responses: - 204: - description: 'Revoked (or already absent).' - 401: - $ref: '#/components/responses/BearerUnauthorized' - 403: - $ref: '#/components/responses/BearerForbidden' - 429: - $ref: '#/components/responses/TooManyRequests' - '/vault/secrets/{id}/lease': - post: - tags: - - 'Token Vault' - summary: 'Redeem a grant for the plaintext secret' - description: | - The only endpoint that reveals a secret. Every refusal — unknown secret, revoked, - expired, or no live grant — is the SAME 403 with no detail, so a caller cannot - use it to enumerate what exists. + description: "The connection's id." + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' security: - - AgentToken: [] - parameters: - - $ref: '#/components/parameters/Id' + EnvironmentApiKey: [] + - + ManagementAccessToken: [] + - + WorkspaceAccessToken: [] requestBody: - required: true + required: false content: application/json: schema: type: object - required: - - purpose properties: - purpose: - type: string - maxLength: 200 - description: 'Recorded on the audit trail.' + organization_id: + type: + - string + - 'null' + description: "Only if it is this organization's; anything else is a 404." + maxLength: 64 responses: 200: - description: Leased. + description: OK content: application/json: schema: type: object required: - - secret_id - - provider - - secret - - expires_at + - data properties: - secret_id: - type: string - provider: - type: string - description: 'The downstream provider the secret belongs to.' - secret: - type: string - expires_at: - type: string - format: date-time - 401: - $ref: '#/components/responses/BearerUnauthorized' - 403: - description: | - Lease denied. Deliberately uniform — unknown secret, revoked, expired and - ungranted are ONE response, with a constant message, so it cannot be used to - enumerate. A token missing the `vault.lease` scope is an RFC 6750 challenge - instead, and is distinguishable only because it never reaches the vault. - content: - application/json: - schema: - oneOf: - - $ref: '#/components/schemas/Error' - - $ref: '#/components/schemas/BearerChallenge' - example: - error: lease_denied - message: 'The lease was denied.' - 422: - $ref: '#/components/responses/UnprocessableEntity' - 429: - $ref: '#/components/responses/TooManyRequests' - '/vault/secrets/{id}/rotate': - post: - tags: - - 'Token Vault' - summary: 'Replace the sealed value, keeping the id and its grants' - security: - - - VaultManagerToken: [] - parameters: - - $ref: '#/components/parameters/Id' - requestBody: - required: true - content: - application/json: - schema: - type: object - required: - - secret - properties: - secret: - type: string - responses: - 200: - description: Rotated. - content: - application/json: - schema: - $ref: '#/components/schemas/VaultSecret' + data: + $ref: '#/components/schemas/SignInPolicy' + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' 401: - $ref: '#/components/responses/BearerUnauthorized' + $ref: '#/components/responses/Unauthorized' 403: - $ref: '#/components/responses/BearerForbidden' + $ref: '#/components/responses/Forbidden' 404: - description: | - No such secret **in the caller's organization**. A secret owned by another - organization is indistinguishable from one that does not exist — the lookup - is filtered, not fetched-then-compared, so nothing leaks about what exists - outside the caller's own scope. The message is a constant for the same reason. - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - example: - error: not_found - message: 'No such secret.' + $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' 422: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - /webhooks: + /sso/domains: post: tags: - - Webhooks - summary: 'Register a webhook endpoint for one organization or the whole environment. Returns its signing secret once.' - description: 'Requires scope `webhooks:write`. Danger: critical.' - operationId: webhooks_create - x-action: webhooks.create - x-scope: 'webhooks:write' - x-danger: critical + - 'Enterprise SSO' + summary: "Claim an email domain for an organization's single sign-on, and get the DNS TXT record to publish to prove it." + description: 'Requires scope `sso:write`. Danger: write.' + operationId: sso_domains_create + x-action: sso.domains.create + x-scope: 'sso:write' + x-danger: write parameters: - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' @@ -14011,77 +15441,17 @@ paths: schema: type: object properties: - url: - type: string - description: 'A public HTTPS URL. Signed deliveries are POSTed here.' - format: uri - maxLength: 500 - event_types: - type: array - description: 'The events it receives.' - minItems: 1 - items: - type: string - enum: - - user.created - - user.updated - - user.deactivated - - user.login - - user.reactivated - - identity.linked - - user.erased - - organization.created - - organization.suspended - - organization.reactivated - - organization.updated - - organization.deleted - - membership.created - - membership.updated - - membership.deleted - - invitation.created - - invitation.accepted - - invitation.revoked - - role.assigned - - role.unassigned - - role.assigned_everywhere - - role.unassigned_everywhere - - api_key.created - - api_key.revoked - - support_session.started - - directory.user.provisioned - - directory.user.deprovisioned - - directory.user.deactivated - - directory.group.membership_changed - - domain.added - - domain.removed - - domain.verified - - connection.activated - - connection.certificate_expiring - - entitlement.set - - entitlement.updated - - entitlement.revoked - - vault.grant.created - - vault.grant.revoked - - vault.secret.revoked - - governance.access.revoked - signature_scheme: - type: string - description: 'How deliveries are signed: `cbox` (X-Cbox-Signature, the default) or `standard_webhooks` (webhook-id / webhook-timestamp / webhook-signature, verifiable with any Standard Webhooks library; the secret is a `whsec_` secret).' - enum: - - cbox - - standard_webhooks organization_id: - type: - - string - - 'null' - description: "The organization it belongs to; it carries that organization's traffic only. Send this or environment_wide." + type: string + description: 'The organization claiming it.' maxLength: 64 - environment_wide: - type: boolean - description: "True to make it the environment's own, carrying EVERY organization's traffic. Send this or organization_id." + domain: + type: string + description: 'The email domain, e.g. acme.com.' + maxLength: 253 required: - - url - - event_types + - organization_id + - domain responses: 201: description: OK @@ -14093,7 +15463,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/Webhook' + $ref: '#/components/schemas/SsoDomain' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -14112,14 +15482,24 @@ paths: $ref: '#/components/responses/TooManyRequests' get: tags: - - Webhooks - summary: 'List the webhook endpoints registered in this environment: URL, owner, subscribed events and whether each is active.' - description: 'Requires scope `webhooks:read`. Danger: read.' - operationId: webhooks_list - x-action: webhooks.list - x-scope: 'webhooks:read' + - 'Enterprise SSO' + summary: 'List the email domains organizations claimed for single sign-on, whether each is verified and captured, and the DNS record that proves an unverified one.' + description: 'Requires scope `sso:read`. Danger: read.' + operationId: sso_domains_list + x-action: sso.domains.list + x-scope: 'sso:read' x-danger: read parameters: + - + name: organization_id + in: query + required: false + schema: + type: + - string + - 'null' + description: "Only this organization's." + maxLength: 64 - name: limit in: query @@ -14139,8 +15519,6 @@ paths: security: - EnvironmentApiKey: [] - - - ManagementAccessToken: [] - WorkspaceAccessToken: [] responses: @@ -14157,7 +15535,7 @@ paths: data: type: array items: - $ref: '#/components/schemas/Webhook' + $ref: '#/components/schemas/SsoDomain' meta: $ref: '#/components/schemas/PageMeta' 202: @@ -14174,15 +15552,15 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - '/webhooks/{id}': + '/sso/domains/{id}': delete: tags: - - Webhooks - summary: 'Delete a webhook endpoint. It stops receiving events at once.' - description: 'Requires scope `webhooks:write`. Danger: destructive.' - operationId: webhooks_delete - x-action: webhooks.delete - x-scope: 'webhooks:write' + - 'Enterprise SSO' + summary: "Remove a claimed email domain. Its people stop being routed to the organization's SSO connection." + description: 'Requires scope `sso:write`. Danger: destructive.' + operationId: sso_domains_delete + x-action: sso.domains.delete + x-scope: 'sso:write' x-danger: destructive parameters: - @@ -14191,7 +15569,7 @@ paths: required: true schema: type: string - description: 'The webhook endpoint id.' + description: "The domain's id." - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' @@ -14202,6 +15580,19 @@ paths: ManagementAccessToken: [] - WorkspaceAccessToken: [] + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + organization_id: + type: + - string + - 'null' + description: "Only if it is this organization's; anything else is a 404." + maxLength: 64 responses: 204: description: Done. @@ -14221,15 +15612,16 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - get: + '/sso/domains/{id}/capture': + post: tags: - - Webhooks - summary: 'Get one webhook endpoint: URL, owner, subscribed events, health. Never its signing secret.' - description: 'Requires scope `webhooks:read`. Danger: read.' - operationId: webhooks_get - x-action: webhooks.get - x-scope: 'webhooks:read' - x-danger: read + - 'Enterprise SSO' + summary: "Turn capture on or off for a verified domain: captured, everyone with an address at it must sign in through the organization's SSO." + description: 'Requires scope `sso:write`. Danger: critical.' + operationId: sso_domains_capture + x-action: sso.domains.capture + x-scope: 'sso:write' + x-danger: critical parameters: - name: id @@ -14237,7 +15629,8 @@ paths: required: true schema: type: string - description: 'The webhook endpoint id.' + description: "The domain's id." + - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: @@ -14247,18 +15640,36 @@ paths: ManagementAccessToken: [] - WorkspaceAccessToken: [] - responses: - 200: - description: OK - content: - application/json: - schema: - type: object - required: + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + capture: + type: boolean + description: 'true to capture the domain, false to release it.' + organization_id: + type: + - string + - 'null' + description: "Only if it is this organization's; anything else is a 404." + maxLength: 64 + required: + - capture + responses: + 200: + description: OK + content: + application/json: + schema: + type: object + required: - data properties: data: - $ref: '#/components/schemas/Webhook' + $ref: '#/components/schemas/SsoDomain' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -14269,18 +15680,21 @@ paths: $ref: '#/components/responses/Forbidden' 404: $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' 422: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - patch: + '/sso/domains/{id}/verify': + post: tags: - - Webhooks - summary: "Change a webhook endpoint's URL and/or the events it subscribes to." - description: 'Requires scope `webhooks:write`. Danger: write.' - operationId: webhooks_update - x-action: webhooks.update - x-scope: 'webhooks:write' + - 'Enterprise SSO' + summary: "Check a claimed domain's DNS TXT record now. Answers the domain, with verified true once the record is found." + description: 'Requires scope `sso:write`. Danger: write.' + operationId: sso_domains_verify + x-action: sso.domains.verify + x-scope: 'sso:write' x-danger: write parameters: - @@ -14289,7 +15703,7 @@ paths: required: true schema: type: string - description: 'The webhook endpoint id.' + description: "The domain's id." - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' @@ -14307,17 +15721,12 @@ paths: schema: type: object properties: - url: - type: string - description: 'A public HTTPS URL. Left out, unchanged.' - format: uri - maxLength: 500 - event_types: - type: array - description: 'The COMPLETE set of events afterwards. Left out, unchanged.' - minItems: 1 - items: - type: string + organization_id: + type: + - string + - 'null' + description: "Only if it is this organization's; anything else is a 404." + maxLength: 64 responses: 200: description: OK @@ -14329,7 +15738,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/Webhook' + $ref: '#/components/schemas/SsoDomain' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -14346,24 +15755,17 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - '/webhooks/{id}/pause': + /sso/saml-metadata: post: tags: - - Webhooks - summary: 'Pause a webhook endpoint: it stops receiving events until resumed.' - description: 'Requires scope `webhooks:write`. Danger: write.' - operationId: webhooks_pause - x-action: webhooks.pause - x-scope: 'webhooks:write' + - 'Enterprise SSO' + summary: "Parse an identity provider's SAML metadata (XML or a metadata URL) into the entity id, SSO URL and certificate a SAML connection needs. Stores nothing." + description: 'Requires scope `sso:write`. Danger: write.' + operationId: sso_saml_metadata_import + x-action: sso.saml_metadata.import + x-scope: 'sso:write' x-danger: write parameters: - - - name: id - in: path - required: true - schema: - type: string - description: 'The webhook endpoint id.' - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' @@ -14374,6 +15776,19 @@ paths: ManagementAccessToken: [] - WorkspaceAccessToken: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + metadata: + type: string + description: 'The IdP metadata XML, or an https URL it is published at.' + maxLength: 500000 + required: + - metadata responses: 200: description: OK @@ -14385,7 +15800,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/Webhook' + $ref: '#/components/schemas/SamlMetadata' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -14402,37 +15817,95 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - '/webhooks/{id}/resume': + /support-sessions: post: + operationId: support_sessions_start + x-action: support_sessions.start tags: - - Webhooks - summary: 'Resume a paused webhook endpoint: it receives events again.' - description: 'Requires scope `webhooks:write`. Danger: write.' - operationId: webhooks_resume - x-action: webhooks.resume - x-scope: 'webhooks:write' - x-danger: write + - 'Support sessions' + summary: 'Start a support session' + description: | + Requires scope `support:write`. A member of your staff (`actor_user_id`) signs in to + one of your apps (`client_id`) AS one of a customer's users (`user_id` in + `organization_id`), for a stated `reason`, for at most 60 minutes. + + The actor must hold your app's own `support:impersonate` permission through an + **environment-wide** grant (see `PUT /users/{id}/environment-roles/{role_id}`); + otherwise `403 not_permitted`. The app must be a first-party app the environment + owns that uses the authorization-code grant (`422 client_not_eligible`). The target + must be an active member (`422 target_not_member`) of an active organization + (`409 organization_inactive`). + + Send `redirect_uri` (registered for the app) and a PKCE S256 `code_challenge` to get + the first authorization `code` in the response. Your app redeems it at `/oauth/token` + with the verifier; every token carries `act: {"sub": actor_user_id}` and there is + never a refresh token. The session is audited on the customer's trail and the + environment's, and announced as the `support_session.started` webhook. + + `scopes` in the response are exactly what every token of the session carries: the + ones asked for (or, with none, the app's registration), never `offline_access`, and + audienced the way the token endpoint audiences them — once any of them belongs to a + registered API, the token is for that API and a scope no API registered (such as + `apps.manifest`) is left out. Scopes of two registered APIs cannot be audienced to + one token and are refused before the session starts (`422 invalid_target`). parameters: - - - name: id - in: path - required: true - schema: - type: string - description: 'The webhook endpoint id.' - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] - - - ManagementAccessToken: [] - WorkspaceAccessToken: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - user_id + - organization_id + - client_id + - actor_user_id + - reason + properties: + user_id: + type: string + description: "The customer's user to act as." + organization_id: + type: string + client_id: + type: string + description: "The app's public client id." + actor_user_id: + type: string + description: 'Your staff member.' + reason: + type: string + maxLength: 500 + description: 'Shown to the customer.' + ttl_minutes: + type: integer + minimum: 1 + maximum: 60 + scopes: + type: array + items: + type: string + description: "Narrowed to the app's registration; omitted means all of it." + redirect_uri: + type: string + code_challenge: + type: string + pattern: '^[A-Za-z0-9_-]{43}$' + nonce: + type: + - string + - 'null' responses: - 200: - description: OK + 201: + description: Started. content: application/json: schema: @@ -14441,7 +15914,7 @@ paths: - data properties: data: - $ref: '#/components/schemas/Webhook' + $ref: '#/components/schemas/SupportSession' 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -14458,16 +15931,18 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - '/webhooks/{id}/rotate': - post: - tags: - - Webhooks - summary: 'Issue a new signing secret for a webhook endpoint, returned once. The old secret stops verifying immediately.' - description: 'Requires scope `webhooks:write`. Danger: critical.' - operationId: webhooks_secret_rotate - x-action: webhooks.secret.rotate - x-scope: 'webhooks:write' + x-scope: 'support:write' x-danger: critical + '/support-sessions/{id}': + delete: + tags: + - 'Support sessions' + summary: 'End a support session now: every token it issued is revoked.' + description: 'Requires scope `support:write`. Danger: destructive.' + operationId: support_sessions_end + x-action: support_sessions.end + x-scope: 'support:write' + x-danger: destructive parameters: - name: id @@ -14475,29 +15950,19 @@ paths: required: true schema: type: string - description: 'The webhook endpoint id.' + description: 'The support session id.' + maxLength: 64 - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/CboxApproval' - $ref: '#/components/parameters/CboxEnvironment' security: - EnvironmentApiKey: [] - - - ManagementAccessToken: [] - WorkspaceAccessToken: [] responses: - 200: - description: OK - content: - application/json: - schema: - type: object - required: - - data - properties: - data: - $ref: '#/components/schemas/Webhook' + 204: + description: Done. 202: $ref: '#/components/responses/ApprovalRequired' 400: @@ -14514,19 +15979,2566 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' - '/webhooks/{id}/signature-scheme': + /token-vault/secrets: post: tags: - - Webhooks - summary: "Change how a webhook endpoint's deliveries are signed (cbox or standard_webhooks). No new secret is issued: a hex secret is used as whsec_ + base64 of itself. Update the receiver first." - description: 'Requires scope `webhooks:write`. Danger: destructive.' - operationId: webhooks_signature_scheme_change - x-action: webhooks.signature_scheme.change - x-scope: 'webhooks:write' - x-danger: destructive + - 'Token vault' + summary: "Store a downstream credential (a third-party API key) in an organization's token vault, or the environment's own. The value is sealed and never returned." + description: 'Requires scope `token_vault:write`. Danger: write.' + operationId: token_vault_secrets_create + x-action: token_vault.secrets.create + x-scope: 'token_vault:write' + x-danger: write parameters: - - - name: id + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + ManagementAccessToken: [] + - + WorkspaceAccessToken: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + organization_id: + type: + - string + - 'null' + description: "The organization whose vault this is. Left out: the environment's own secrets, a separate collection." + maxLength: 64 + name: + type: string + description: 'What it is: "Stripe live key".' + maxLength: 190 + provider: + type: string + description: 'Whose credential it is: "stripe", "openai".' + maxLength: 190 + secret: + type: string + description: 'The credential. Write-only: sealed, never returned.' + maxLength: 20000 + required: + - name + - provider + - secret + responses: + 201: + description: OK + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/TokenVaultSecret' + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' + get: + tags: + - 'Token vault' + summary: "List the downstream credentials stored in an organization's token vault (or the environment's own): names, providers, status. Never a value." + description: 'Requires scope `token_vault:read`. Danger: read.' + operationId: token_vault_secrets_list + x-action: token_vault.secrets.list + x-scope: 'token_vault:read' + x-danger: read + parameters: + - + name: organization_id + in: query + required: false + schema: + type: + - string + - 'null' + description: "The organization whose vault this is. Left out: the environment's own secrets, a separate collection." + maxLength: 64 + - + name: limit + in: query + required: false + schema: + type: integer + description: 'Items per page, 1–100. Default 50.' + - + name: after + in: query + required: false + schema: + type: string + description: 'The `next_cursor` of the previous page.' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + WorkspaceAccessToken: [] + responses: + 200: + description: OK + content: + application/json: + schema: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + $ref: '#/components/schemas/TokenVaultSecret' + meta: + $ref: '#/components/schemas/PageMeta' + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' + '/token-vault/secrets/{id}': + get: + tags: + - 'Token vault' + summary: 'Read one stored credential and the client ids granted to lease it. Never its value.' + description: 'Requires scope `token_vault:read`. Danger: read.' + operationId: token_vault_secrets_get + x-action: token_vault.secrets.get + x-scope: 'token_vault:read' + x-danger: read + parameters: + - + name: id + in: path + required: true + schema: + type: string + description: "The secret's id." + - + name: organization_id + in: query + required: false + schema: + type: + - string + - 'null' + description: "The organization whose vault this is. Left out: the environment's own secrets, a separate collection." + maxLength: 64 + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + WorkspaceAccessToken: [] + responses: + 200: + description: OK + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/TokenVaultSecret' + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' + '/token-vault/secrets/{id}/grants': + post: + tags: + - 'Token vault' + summary: 'Grant an app (by OAuth client id) the right to lease a stored credential.' + description: 'Requires scope `token_vault:write`. Danger: critical.' + operationId: token_vault_grants_create + x-action: token_vault.grants.create + x-scope: 'token_vault:write' + x-danger: critical + parameters: + - + name: id + in: path + required: true + schema: + type: string + description: "The secret's id." + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + ManagementAccessToken: [] + - + WorkspaceAccessToken: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + organization_id: + type: + - string + - 'null' + description: "The organization whose vault this is. Left out: the environment's own secrets, a separate collection." + maxLength: 64 + client_id: + type: string + description: 'The OAuth client id of the app that may lease it.' + maxLength: 190 + required: + - client_id + responses: + 200: + description: OK + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/TokenVaultSecret' + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' + '/token-vault/secrets/{id}/grants/{client_id}': + delete: + tags: + - 'Token vault' + summary: "Withdraw an app's right to lease a stored credential." + description: 'Requires scope `token_vault:write`. Danger: destructive.' + operationId: token_vault_grants_delete + x-action: token_vault.grants.delete + x-scope: 'token_vault:write' + x-danger: destructive + parameters: + - + name: id + in: path + required: true + schema: + type: string + description: "The secret's id." + - + name: client_id + in: path + required: true + schema: + type: string + description: 'The OAuth client id whose grant is withdrawn.' + maxLength: 190 + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + ManagementAccessToken: [] + - + WorkspaceAccessToken: [] + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + organization_id: + type: + - string + - 'null' + description: "The organization whose vault this is. Left out: the environment's own secrets, a separate collection." + maxLength: 64 + responses: + 204: + description: Done. + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' + '/token-vault/secrets/{id}/revoke': + post: + tags: + - 'Token vault' + summary: 'Revoke a stored credential permanently. No app can lease it again.' + description: 'Requires scope `token_vault:write`. Danger: destructive.' + operationId: token_vault_secrets_revoke + x-action: token_vault.secrets.revoke + x-scope: 'token_vault:write' + x-danger: destructive + parameters: + - + name: id + in: path + required: true + schema: + type: string + description: "The secret's id." + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + ManagementAccessToken: [] + - + WorkspaceAccessToken: [] + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + organization_id: + type: + - string + - 'null' + description: "The organization whose vault this is. Left out: the environment's own secrets, a separate collection." + maxLength: 64 + responses: + 200: + description: OK + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/TokenVaultSecret' + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' + '/token-vault/secrets/{id}/rotate': + post: + tags: + - 'Token vault' + summary: "Replace a stored credential's value. Every later lease hands out the new one. The value is never returned." + description: 'Requires scope `token_vault:write`. Danger: critical.' + operationId: token_vault_secrets_rotate + x-action: token_vault.secrets.rotate + x-scope: 'token_vault:write' + x-danger: critical + parameters: + - + name: id + in: path + required: true + schema: + type: string + description: "The secret's id." + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + ManagementAccessToken: [] + - + WorkspaceAccessToken: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + organization_id: + type: + - string + - 'null' + description: "The organization whose vault this is. Left out: the environment's own secrets, a separate collection." + maxLength: 64 + secret: + type: string + description: 'The new value. Write-only: sealed, never returned.' + maxLength: 20000 + required: + - secret + responses: + 200: + description: OK + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/TokenVaultSecret' + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' + /users: + get: + operationId: users_list + x-action: users.list + tags: + - Users + summary: 'List users' + description: | + Requires scope `users:read`. A page at a time, in id order. Narrow it the ways you + look for somebody: `email` (exactly that address, case-insensitive), `q` (a fragment + of the address or the name) and `status`. Filters combine. + parameters: + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/After' + - + name: email + in: query + required: false + schema: + type: string + format: email + maxLength: 190 + - + name: q + in: query + required: false + description: 'A fragment of the address or the name.' + schema: + type: string + maxLength: 190 + - + name: status + in: query + required: false + schema: + type: string + enum: + - active + - disabled + - locked + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + WorkspaceAccessToken: [] + responses: + 400: + $ref: '#/components/responses/EnvironmentRequired' + 404: + $ref: '#/components/responses/NotFound' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 200: + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/User' + meta: + $ref: '#/components/schemas/PageMeta' + 202: + $ref: '#/components/responses/ApprovalRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 429: + $ref: '#/components/responses/TooManyRequests' + x-scope: 'users:read' + x-danger: read + post: + operationId: users_create + x-action: users.create + tags: + - Users + summary: 'Create a user' + description: 'Requires scope `users:write`. Password is optional — omit it to create a passwordless identity the user completes via an invite/magic-link.' + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + WorkspaceAccessToken: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - email + properties: + email: + type: string + format: email + maxLength: 190 + name: + type: + - string + - 'null' + maxLength: 190 + password: + type: + - string + - 'null' + minLength: 8 + maxLength: 255 + description: "Checked against this environment's password policy (`422 password_policy`)." + send_sign_in_link: + type: boolean + default: false + description: 'Mail the new user a one-click sign-in link.' + responses: + 201: + description: Created + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/User' + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' + x-scope: 'users:write' + x-danger: write + '/users/{id}': + get: + operationId: users_get + x-action: users.get + tags: + - Users + summary: 'Get a user' + description: 'Requires scope `users:read`.' + parameters: + - $ref: '#/components/parameters/Id' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + WorkspaceAccessToken: [] + responses: + 200: + description: OK + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/User' + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 429: + $ref: '#/components/responses/TooManyRequests' + x-scope: 'users:read' + x-danger: read + delete: + operationId: users_deactivate + x-action: users.deactivate + tags: + - Users + summary: 'Deactivate a user' + description: | + Requires scope `users:write`. A **soft disable** — the identity can no longer + authenticate, but is never hard-deleted — and every OAuth grant they hold is + revoked, which reactivating them does not bring back. Returns the user in its new + `disabled` state. Idempotent. The console's "Deactivate" is this action. + parameters: + - $ref: '#/components/parameters/Id' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + WorkspaceAccessToken: [] + responses: + 200: + description: OK + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/User' + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 429: + $ref: '#/components/responses/TooManyRequests' + x-scope: 'users:write' + x-danger: destructive + patch: + tags: + - Users + summary: "Change a user's name and/or email address. A changed address must be verified again." + description: 'Requires scope `users:write`. Danger: critical.' + operationId: users_update + x-action: users.update + x-scope: 'users:write' + x-danger: critical + parameters: + - + name: id + in: path + required: true + schema: + type: string + description: 'The user id.' + maxLength: 64 + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + WorkspaceAccessToken: [] + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + name: + type: + - string + - 'null' + maxLength: 190 + email: + type: string + description: 'The new address. Its verification is cleared.' + format: email + maxLength: 190 + responses: + 200: + description: OK + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/User' + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' + '/users/{id}/environment-roles': + get: + operationId: users_environment_roles_list + x-action: users.environment_roles.list + tags: + - Roles + summary: 'List the roles a user holds everywhere (staff)' + description: 'Requires scope `roles:read`.' + parameters: + - $ref: '#/components/parameters/Id' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + WorkspaceAccessToken: [] + responses: + 200: + description: OK + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + type: array + items: + $ref: '#/components/schemas/RoleAssignment' + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 429: + $ref: '#/components/responses/TooManyRequests' + x-scope: 'roles:read' + x-danger: read + '/users/{id}/environment-roles/{role_id}': + get: + operationId: users_environment_roles_get + x-action: users.environment_roles.get + tags: + - Roles + summary: 'Whether a user holds a role everywhere' + description: 'Requires scope `roles:read`. `200` with the grant, or `404` when it is not held.' + parameters: + - $ref: '#/components/parameters/Id' + - $ref: '#/components/parameters/RoleId' + - $ref: '#/components/parameters/RoleClientId' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + WorkspaceAccessToken: [] + responses: + 200: + description: Held. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/RoleAssignment' + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 429: + $ref: '#/components/responses/TooManyRequests' + x-scope: 'roles:read' + x-danger: read + put: + operationId: users_environment_roles_grant + x-action: users.environment_roles.grant + tags: + - Roles + summary: 'Grant a role everywhere in the environment (staff)' + description: | + Requires scope `roles:write`. Idempotent. For your own people who act across every + customer. Any role that belongs to no organization may be granted this way: an + app-agnostic one reaches every app's tokens, one your app declared reaches only + your app's. An organization's own role is refused with `422 role_not_assignable`; a + grant that would form a segregation-of-duties conflict in any organization the + person belongs to with `409 role_conflict`. + parameters: + - $ref: '#/components/parameters/Id' + - $ref: '#/components/parameters/RoleId' + - $ref: '#/components/parameters/RoleClientId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + WorkspaceAccessToken: [] + responses: + 200: + description: 'Granted (or already held).' + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/RoleAssignment' + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' + x-scope: 'roles:write' + x-danger: critical + delete: + operationId: users_environment_roles_revoke + x-action: users.environment_roles.revoke + tags: + - Roles + summary: 'Take back an environment-wide grant' + description: 'Requires scope `roles:write`. Idempotent.' + parameters: + - $ref: '#/components/parameters/Id' + - $ref: '#/components/parameters/RoleId' + - $ref: '#/components/parameters/RoleClientId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + WorkspaceAccessToken: [] + responses: + 204: + description: 'Not held any more.' + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 429: + $ref: '#/components/responses/TooManyRequests' + x-scope: 'roles:write' + x-danger: destructive + '/users/{id}/erase': + post: + tags: + - Users + summary: 'Erase a person (GDPR Art. 17): revoke their sessions and tokens, delete their credentials, memberships and personal data, and pseudonymise their account. Cannot be undone.' + description: 'Requires scope `users:erase`. Danger: critical.' + operationId: users_erase + x-action: users.erase + x-scope: 'users:erase' + x-danger: critical + parameters: + - + name: id + in: path + required: true + schema: + type: string + description: "The user's id." + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + WorkspaceAccessToken: [] + responses: + 200: + description: OK + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/ErasureReceipt' + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' + '/users/{id}/mfa': + delete: + tags: + - Users + summary: "Reset a user's two-factor authentication: their authenticator and recovery codes are removed and they must enrol again." + description: 'Requires scope `users:write`. Danger: critical.' + operationId: users_mfa_reset + x-action: users.mfa.reset + x-scope: 'users:write' + x-danger: critical + parameters: + - + name: id + in: path + required: true + schema: + type: string + description: 'The user id.' + maxLength: 64 + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + WorkspaceAccessToken: [] + responses: + 204: + description: Done. + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' + '/users/{id}/mfa/sms': + delete: + tags: + - Users + summary: "Remove a user's phone number for text-message sign-in codes. Their other factors and recovery codes stay." + description: 'Requires scope `users:write`. Danger: critical.' + operationId: users_mfa_sms_remove + x-action: users.mfa.sms.remove + x-scope: 'users:write' + x-danger: critical + parameters: + - + name: id + in: path + required: true + schema: + type: string + description: 'The user id.' + maxLength: 64 + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + WorkspaceAccessToken: [] + responses: + 204: + description: Done. + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' + '/users/{id}/password': + post: + tags: + - Users + summary: "Set a user's password, temporary by default, and mail it to them unless told not to. Signs them out according to `revoke`." + description: 'Requires scope `users:write`. Danger: critical.' + operationId: users_password_set + x-action: users.password.set + x-scope: 'users:write' + x-danger: critical + parameters: + - + name: id + in: path + required: true + schema: + type: string + description: 'The user id.' + maxLength: 64 + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + WorkspaceAccessToken: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + password: + type: string + description: "The new password. Checked against this environment's password policy." + maxLength: 200 + reason: + type: string + description: 'Why — recorded on the audit trail.' + maxLength: 200 + temporary: + type: boolean + description: 'The user must choose a new password at their next sign-in. Default true.' + expires_in_hours: + type: integer + description: 'A temporary password stops working after this many hours. 0 or absent: until changed.' + minimum: 0 + maximum: 8760 + revoke: + type: string + description: 'How much existing access to end. Default `sessions_and_tokens`.' + enum: + - sessions_and_tokens + - sessions_only + - nothing + send_email: + type: boolean + description: 'Mail the password to the user. Default true.' + required: + - password + - reason + responses: + 200: + description: OK + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/User' + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' + '/users/{id}/password-reset': + post: + tags: + - Users + summary: 'Mail a user a password-reset link at their own address.' + description: 'Requires scope `users:write`. Danger: write.' + operationId: users_password_reset_send + x-action: users.password_reset.send + x-scope: 'users:write' + x-danger: write + parameters: + - + name: id + in: path + required: true + schema: + type: string + description: 'The user id.' + maxLength: 64 + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + WorkspaceAccessToken: [] + responses: + 204: + description: Done. + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' + '/users/{id}/reactivate': + post: + tags: + - Users + summary: 'Reactivate a deactivated user so they can sign in again. Grants revoked on deactivation stay revoked.' + description: 'Requires scope `users:write`. Danger: write.' + operationId: users_reactivate + x-action: users.reactivate + x-scope: 'users:write' + x-danger: write + parameters: + - + name: id + in: path + required: true + schema: + type: string + description: 'The user id.' + maxLength: 64 + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + WorkspaceAccessToken: [] + responses: + 200: + description: OK + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/User' + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' + '/users/{id}/sessions': + get: + operationId: users_sessions_list + x-action: users.sessions.list + tags: + - Users + summary: "List a user's live sign-in sessions" + description: | + Requires scope `users:read`. The fifty most recently active, newest first — enough to + recognise a device and end one (`DELETE /users/{id}/sessions/{session_id}`), not a log. + `impersonation` marks a session somebody else opened as this person. + parameters: + - $ref: '#/components/parameters/Id' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + WorkspaceAccessToken: [] + responses: + 200: + description: OK + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + type: array + items: + $ref: '#/components/schemas/UserSession' + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 429: + $ref: '#/components/responses/TooManyRequests' + x-scope: 'users:read' + x-danger: read + delete: + tags: + - Users + summary: 'Sign a user out everywhere: end every session and revoke every OAuth grant they hold.' + description: 'Requires scope `users:write`. Danger: destructive.' + operationId: users_sessions_revoke_all + x-action: users.sessions.revoke_all + x-scope: 'users:write' + x-danger: destructive + parameters: + - + name: id + in: path + required: true + schema: + type: string + description: 'The user id.' + maxLength: 64 + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + WorkspaceAccessToken: [] + responses: + 204: + description: Done. + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' + '/users/{id}/sessions/{session_id}': + delete: + tags: + - Users + summary: "End one of a user's sign-in sessions." + description: 'Requires scope `users:write`. Danger: destructive.' + operationId: users_sessions_revoke + x-action: users.sessions.revoke + x-scope: 'users:write' + x-danger: destructive + parameters: + - + name: id + in: path + required: true + schema: + type: string + description: 'The user id.' + maxLength: 64 + - + name: session_id + in: path + required: true + schema: + type: string + description: 'The session id, from `GET /users/{id}/sessions`.' + maxLength: 64 + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + WorkspaceAccessToken: [] + responses: + 204: + description: Done. + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' + '/users/{id}/verification': + post: + tags: + - Users + summary: 'Mail a user the link that verifies their email address. Does nothing for an address already verified.' + description: 'Requires scope `users:write`. Danger: write.' + operationId: users_verification_send + x-action: users.verification.send + x-scope: 'users:write' + x-danger: write + parameters: + - + name: id + in: path + required: true + schema: + type: string + description: 'The user id.' + maxLength: 64 + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + WorkspaceAccessToken: [] + responses: + 204: + description: Done. + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' + '/users/{id}/verify': + post: + tags: + - Users + summary: "Mark a user's email address verified without the emailed link. It can then be used to recover the account." + description: 'Requires scope `users:write`. Danger: critical.' + operationId: users_verify + x-action: users.verify + x-scope: 'users:write' + x-danger: critical + parameters: + - + name: id + in: path + required: true + schema: + type: string + description: 'The user id.' + maxLength: 64 + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + WorkspaceAccessToken: [] + responses: + 200: + description: OK + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/User' + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' + '/vault/pipes/{provider}/token': + post: + tags: + - 'Token Vault' + summary: "Lease a fresh access token for a person's connected account (Pipes)" + description: | + Returns a working access token for one person's connected account at a + third-party provider (GitHub, Google, Microsoft 365, Slack, Salesforce, HubSpot, + Linear, Notion), refreshing it first when it is about to expire. The app must be + granted the provider's pipe (Developers › Pipes); an app an organization owns may + only lease for that organization's members. + + Whose token: a token issued to the app FOR a person leases that person's + connection, and `user_id` may only repeat it; a client-credentials token names the + person in `user_id`. The response is `Cache-Control: no-store`. Drop the token + when you are done and lease again next time. + security: + - + AgentToken: [] + parameters: + - + name: provider + in: path + required: true + description: "The provider's key." + schema: + type: string + enum: + - github + - google + - microsoft + - slack + - salesforce + - hubspot + - linear + - notion + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - purpose + properties: + purpose: + type: string + maxLength: 200 + description: 'Recorded on the audit trail.' + user_id: + type: string + maxLength: 128 + description: "The person, for a token not issued for one. With a person's token, may only name that person." + responses: + 200: + description: 'A fresh access token.' + headers: + Cache-Control: + schema: + type: string + example: no-store + content: + application/json: + schema: + type: object + required: + - access_token + - token_type + - provider + - user_id + - connection_id + - scopes + - lease_expires_at + - metadata + properties: + access_token: + type: string + description: "The provider's access token. Use it as a bearer token against the provider's API." + token_type: + type: string + enum: + - Bearer + provider: + type: string + user_id: + type: string + connection_id: + type: string + scopes: + type: array + items: + type: string + description: 'What the person consented to.' + expires_at: + type: + - string + - 'null' + format: date-time + description: 'When the PROVIDER stops accepting it. Null for tokens that do not expire.' + lease_expires_at: + type: string + format: date-time + description: 'When to drop it and lease again.' + metadata: + type: object + additionalProperties: + type: string + description: "What the app needs to call the API at all — Salesforce's `instance_url`, Slack's `team.id`, Notion's `workspace_id`. Never a credential." + 401: + $ref: '#/components/responses/BearerUnauthorized' + 403: + description: | + Lease denied — the app is not granted this pipe, there is no such (enabled) + pipe, the person is not a member of the app's organization, or `user_id` names + someone other than the token's person. One response, with a constant message. + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/Error' + - $ref: '#/components/schemas/BearerChallenge' + example: + error: lease_denied + message: 'The lease was denied.' + 404: + description: 'The app is granted the pipe, and the person has not connected the provider. Send them to `connect_url`.' + content: + application/json: + schema: + $ref: '#/components/schemas/PipeConnectRequired' + example: + error: not_connected + message: 'This person has not connected the provider.' + connect_url: 'https://id.example.com/account/connected-services/github/connect' + 409: + description: 'The person must connect the provider again — it refused the refresh token, or the token expired with none. Send them to `connect_url`.' + content: + application/json: + schema: + $ref: '#/components/schemas/PipeConnectRequired' + example: + error: reauthorization_required + message: 'This person must connect the provider again.' + connect_url: 'https://id.example.com/account/connected-services/github/connect' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' + 503: + description: 'A needed refresh could not complete right now. Retry after the `Retry-After` header.' + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /vault/secrets: + post: + tags: + - 'Token Vault' + summary: 'Store a downstream credential, sealed at rest' + description: | + The secret is sealed with the environment's crypto key and is never returned by + any read endpoint — only a `lease` reveals it, and only to a client holding a + live grant. + + Ownership is derived from the calling token's `org` claim and CANNOT be supplied + in the body: the owner is the vault's tenancy boundary, and a caller-chosen owner + would be no boundary at all. + security: + - + VaultManagerToken: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - name + - provider + - secret + properties: + name: + type: string + maxLength: 200 + description: 'Unique per owner — two organizations may each hold a `github-token`.' + provider: + type: string + maxLength: 100 + secret: + type: string + description: 'The value to seal. Never returned by a read.' + expires_at: + type: + - string + - 'null' + format: date-time + responses: + 201: + description: Stored. + content: + application/json: + schema: + $ref: '#/components/schemas/VaultSecret' + 401: + $ref: '#/components/responses/BearerUnauthorized' + 403: + $ref: '#/components/responses/BearerForbidden' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' + '/vault/secrets/{id}': + delete: + tags: + - 'Token Vault' + summary: 'Revoke a secret permanently' + description: 'No future lease can open it. Existing leases are not recalled — they are short-lived by design.' + security: + - + VaultManagerToken: [] + parameters: + - $ref: '#/components/parameters/Id' + responses: + 204: + description: Revoked. + 401: + $ref: '#/components/responses/BearerUnauthorized' + 403: + $ref: '#/components/responses/BearerForbidden' + 404: + description: "No such secret in the caller's organization." + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + 429: + $ref: '#/components/responses/TooManyRequests' + '/vault/secrets/{id}/grants': + post: + tags: + - 'Token Vault' + summary: 'Authorize an agent client to lease this secret' + security: + - + VaultManagerToken: [] + parameters: + - $ref: '#/components/parameters/Id' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - client_id + properties: + client_id: + type: string + maxLength: 200 + max_ttl_seconds: + type: + - integer + - 'null' + minimum: 1 + responses: + 201: + description: Granted. + content: + application/json: + schema: + type: object + properties: + secret_id: + type: string + client_id: + type: string + max_ttl_seconds: + type: + - integer + - 'null' + 401: + $ref: '#/components/responses/BearerUnauthorized' + 403: + $ref: '#/components/responses/BearerForbidden' + 404: + description: "No such secret in the caller's organization." + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' + '/vault/secrets/{id}/grants/{clientId}': + delete: + tags: + - 'Token Vault' + summary: "Revoke an agent's authorization" + description: 'Idempotent — revoking an absent grant succeeds.' + security: + - + VaultManagerToken: [] + parameters: + - $ref: '#/components/parameters/Id' + - + name: clientId + in: path + required: true + schema: + type: string + responses: + 204: + description: 'Revoked (or already absent).' + 401: + $ref: '#/components/responses/BearerUnauthorized' + 403: + $ref: '#/components/responses/BearerForbidden' + 429: + $ref: '#/components/responses/TooManyRequests' + '/vault/secrets/{id}/lease': + post: + tags: + - 'Token Vault' + summary: 'Redeem a grant for the plaintext secret' + description: | + The only endpoint that reveals a secret. Every refusal — unknown secret, revoked, + expired, or no live grant — is the SAME 403 with no detail, so a caller cannot + use it to enumerate what exists. + security: + - + AgentToken: [] + parameters: + - $ref: '#/components/parameters/Id' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - purpose + properties: + purpose: + type: string + maxLength: 200 + description: 'Recorded on the audit trail.' + responses: + 200: + description: Leased. + content: + application/json: + schema: + type: object + required: + - secret_id + - provider + - secret + - expires_at + properties: + secret_id: + type: string + provider: + type: string + description: 'The downstream provider the secret belongs to.' + secret: + type: string + expires_at: + type: string + format: date-time + 401: + $ref: '#/components/responses/BearerUnauthorized' + 403: + description: | + Lease denied. Deliberately uniform — unknown secret, revoked, expired and + ungranted are ONE response, with a constant message, so it cannot be used to + enumerate. A token missing the `vault.lease` scope is an RFC 6750 challenge + instead, and is distinguishable only because it never reaches the vault. + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/Error' + - $ref: '#/components/schemas/BearerChallenge' + example: + error: lease_denied + message: 'The lease was denied.' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' + '/vault/secrets/{id}/rotate': + post: + tags: + - 'Token Vault' + summary: 'Replace the sealed value, keeping the id and its grants' + security: + - + VaultManagerToken: [] + parameters: + - $ref: '#/components/parameters/Id' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - secret + properties: + secret: + type: string + responses: + 200: + description: Rotated. + content: + application/json: + schema: + $ref: '#/components/schemas/VaultSecret' + 401: + $ref: '#/components/responses/BearerUnauthorized' + 403: + $ref: '#/components/responses/BearerForbidden' + 404: + description: | + No such secret **in the caller's organization**. A secret owned by another + organization is indistinguishable from one that does not exist — the lookup + is filtered, not fetched-then-compared, so nothing leaks about what exists + outside the caller's own scope. The message is a constant for the same reason. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + error: not_found + message: 'No such secret.' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' + /webhooks: + post: + tags: + - Webhooks + summary: 'Register a webhook endpoint for one organization or the whole environment. Returns its signing secret once.' + description: 'Requires scope `webhooks:write`. Danger: critical.' + operationId: webhooks_create + x-action: webhooks.create + x-scope: 'webhooks:write' + x-danger: critical + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + ManagementAccessToken: [] + - + WorkspaceAccessToken: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + url: + type: string + description: 'A public HTTPS URL. Signed deliveries are POSTed here.' + format: uri + maxLength: 500 + event_types: + type: array + description: 'The events it receives.' + minItems: 1 + items: + type: string + enum: + - user.created + - user.updated + - user.deactivated + - user.login + - user.reactivated + - identity.linked + - user.erased + - organization.created + - organization.suspended + - organization.reactivated + - organization.updated + - organization.deleted + - membership.created + - membership.updated + - membership.deleted + - invitation.created + - invitation.accepted + - invitation.revoked + - role.assigned + - role.unassigned + - role.assigned_everywhere + - role.unassigned_everywhere + - api_key.created + - api_key.revoked + - support_session.started + - directory.user.provisioned + - directory.user.deprovisioned + - directory.user.deactivated + - directory.group.membership_changed + - domain.added + - domain.removed + - domain.verified + - connection.activated + - connection.certificate_expiring + - entitlement.set + - entitlement.updated + - entitlement.revoked + - vault.grant.created + - vault.grant.revoked + - vault.secret.revoked + - pipe.connection.connected + - pipe.connection.needs_reauth + - pipe.connection.disconnected + - governance.access.revoked + - feature_flag.created + - feature_flag.updated + - feature_flag.deleted + signature_scheme: + type: string + description: 'How deliveries are signed: `cbox` (X-Cbox-Signature, the default) or `standard_webhooks` (webhook-id / webhook-timestamp / webhook-signature, verifiable with any Standard Webhooks library; the secret is a `whsec_` secret).' + enum: + - cbox + - standard_webhooks + organization_id: + type: + - string + - 'null' + description: "The organization it belongs to; it carries that organization's traffic only. Send this or environment_wide." + maxLength: 64 + environment_wide: + type: boolean + description: "True to make it the environment's own, carrying EVERY organization's traffic. Send this or organization_id." + required: + - url + - event_types + responses: + 201: + description: OK + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/Webhook' + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' + get: + tags: + - Webhooks + summary: 'List the webhook endpoints registered in this environment: URL, owner, subscribed events and whether each is active.' + description: 'Requires scope `webhooks:read`. Danger: read.' + operationId: webhooks_list + x-action: webhooks.list + x-scope: 'webhooks:read' + x-danger: read + parameters: + - + name: limit + in: query + required: false + schema: + type: integer + description: 'Items per page, 1–100. Default 50.' + - + name: after + in: query + required: false + schema: + type: string + description: 'The `next_cursor` of the previous page.' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + ManagementAccessToken: [] + - + WorkspaceAccessToken: [] + responses: + 200: + description: OK + content: + application/json: + schema: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + $ref: '#/components/schemas/Webhook' + meta: + $ref: '#/components/schemas/PageMeta' + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' + '/webhooks/{id}': + delete: + tags: + - Webhooks + summary: 'Delete a webhook endpoint. It stops receiving events at once.' + description: 'Requires scope `webhooks:write`. Danger: destructive.' + operationId: webhooks_delete + x-action: webhooks.delete + x-scope: 'webhooks:write' + x-danger: destructive + parameters: + - + name: id + in: path + required: true + schema: + type: string + description: 'The webhook endpoint id.' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + ManagementAccessToken: [] + - + WorkspaceAccessToken: [] + responses: + 204: + description: Done. + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' + get: + tags: + - Webhooks + summary: 'Get one webhook endpoint: URL, owner, subscribed events, health. Never its signing secret.' + description: 'Requires scope `webhooks:read`. Danger: read.' + operationId: webhooks_get + x-action: webhooks.get + x-scope: 'webhooks:read' + x-danger: read + parameters: + - + name: id + in: path + required: true + schema: + type: string + description: 'The webhook endpoint id.' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + ManagementAccessToken: [] + - + WorkspaceAccessToken: [] + responses: + 200: + description: OK + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/Webhook' + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' + patch: + tags: + - Webhooks + summary: "Change a webhook endpoint's URL and/or the events it subscribes to." + description: 'Requires scope `webhooks:write`. Danger: write.' + operationId: webhooks_update + x-action: webhooks.update + x-scope: 'webhooks:write' + x-danger: write + parameters: + - + name: id + in: path + required: true + schema: + type: string + description: 'The webhook endpoint id.' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + ManagementAccessToken: [] + - + WorkspaceAccessToken: [] + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + url: + type: string + description: 'A public HTTPS URL. Left out, unchanged.' + format: uri + maxLength: 500 + event_types: + type: array + description: 'The COMPLETE set of events afterwards. Left out, unchanged.' + minItems: 1 + items: + type: string + responses: + 200: + description: OK + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/Webhook' + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' + '/webhooks/{id}/pause': + post: + tags: + - Webhooks + summary: 'Pause a webhook endpoint: it stops receiving events until resumed.' + description: 'Requires scope `webhooks:write`. Danger: write.' + operationId: webhooks_pause + x-action: webhooks.pause + x-scope: 'webhooks:write' + x-danger: write + parameters: + - + name: id + in: path + required: true + schema: + type: string + description: 'The webhook endpoint id.' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + ManagementAccessToken: [] + - + WorkspaceAccessToken: [] + responses: + 200: + description: OK + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/Webhook' + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' + '/webhooks/{id}/resume': + post: + tags: + - Webhooks + summary: 'Resume a paused webhook endpoint: it receives events again.' + description: 'Requires scope `webhooks:write`. Danger: write.' + operationId: webhooks_resume + x-action: webhooks.resume + x-scope: 'webhooks:write' + x-danger: write + parameters: + - + name: id + in: path + required: true + schema: + type: string + description: 'The webhook endpoint id.' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + ManagementAccessToken: [] + - + WorkspaceAccessToken: [] + responses: + 200: + description: OK + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/Webhook' + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' + '/webhooks/{id}/rotate': + post: + tags: + - Webhooks + summary: 'Issue a new signing secret for a webhook endpoint, returned once. The old secret stops verifying immediately.' + description: 'Requires scope `webhooks:write`. Danger: critical.' + operationId: webhooks_secret_rotate + x-action: webhooks.secret.rotate + x-scope: 'webhooks:write' + x-danger: critical + parameters: + - + name: id + in: path + required: true + schema: + type: string + description: 'The webhook endpoint id.' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + ManagementAccessToken: [] + - + WorkspaceAccessToken: [] + responses: + 200: + description: OK + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/Webhook' + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' + '/webhooks/{id}/signature-scheme': + post: + tags: + - Webhooks + summary: "Change how a webhook endpoint's deliveries are signed (cbox or standard_webhooks). No new secret is issued: a hex secret is used as whsec_ + base64 of itself. Update the receiver first." + description: 'Requires scope `webhooks:write`. Danger: destructive.' + operationId: webhooks_signature_scheme_change + x-action: webhooks.signature_scheme.change + x-scope: 'webhooks:write' + x-danger: destructive + parameters: + - + name: id in: path required: true schema: @@ -14887,6 +18899,32 @@ components: type: array items: type: string + SmsFactorPolicy: + type: object + required: + - enabled + - allowed_countries + - privileged_need_stronger_factor + - deployment_countries + properties: + enabled: + type: boolean + description: 'Text-message codes are accepted as a second factor. Off by default.' + allowed_countries: + type: array + items: + type: string + pattern: '^[A-Z]{2}$' + description: 'ISO 3166-1 alpha-2 countries whose numbers may enrol and be texted. Re-checked at every send.' + privileged_need_stronger_factor: + type: boolean + description: 'Administrators may add SMS only beside an authenticator app or a passkey.' + deployment_countries: + type: array + items: + type: string + pattern: '^[A-Z]{2}$' + description: "The deployment's ceiling (CBOX_ID_SMS_ALLOWED_COUNTRIES). Empty: no deployment restriction. A listed country outside it is never texted." SelfServiceSignup: type: object required: @@ -16825,389 +20863,938 @@ components: SamlMetadata: type: object required: - - idp_entity_id - - idp_sso_url - - idp_x509cert - description: "An identity provider's settings read from its SAML metadata. Nothing is stored." + - idp_entity_id + - idp_sso_url + - idp_x509cert + description: "An identity provider's settings read from its SAML metadata. Nothing is stored." + properties: + idp_entity_id: + type: string + idp_sso_url: + type: string + idp_x509cert: + type: string + description: "The IdP's public signing certificate, from its own metadata." + PortalLink: + type: object + required: + - id + - organization_id + - intents + - url + - expires_at + - emailed_to + - email_suppressed + properties: + id: + type: string + organization_id: + type: string + intents: + type: array + description: 'What the link may set up.' + items: + type: string + enum: + - sso + - dsync + - domain_verification + - log_streams + - certificate_renewal + - audit_logs + emailed_to: + type: + - string + - 'null' + description: 'The address the link was mailed to, or null when it was not sent — none was asked for, or the mail was suppressed (see email_suppressed).' + email_suppressed: + type: boolean + description: | + True when an `email` was asked for and NOT sent because this is a sandbox + environment, which sends no mail. `emailed_to` is then null, and the `url` in this + answer is the only way to the link — share it yourself. + url: + type: + - string + - 'null' + description: | + The one-time setup link — the whole credential. Shown once; `null` on an + idempotent replay of this answer. + expires_at: + type: + - string + - 'null' + format: date-time + PortalLinkRecord: + type: object + description: 'An Admin Portal link as a list shows it. Never its URL: that was shown once, when it was minted, and only its hash is kept.' + required: + - id + - organization_id + - intents + - status + - created_at + - created_by + - emailed_to + - expires_at + - consumed_at + - completed_at + - revoked_at + properties: + id: + type: string + organization_id: + type: string + intents: + type: array + description: 'What the link may set up.' + items: + type: string + enum: + - sso + - dsync + - domain_verification + - log_streams + - certificate_renewal + - audit_logs + status: + type: string + enum: + - pending + - in_use + - completed + - expired + - revoked + description: | + `pending`: not opened yet, and it still can be. `in_use`: opened, and the setup + session it started may still be running. `completed`: its setup was finished. + `expired`: never opened in time, or its setup session ran out. `revoked`: withdrawn. + created_at: + type: + - string + - 'null' + format: date-time + created_by: + type: string + description: "Who minted it: the console person's id, or the management key's." + emailed_to: + type: + - string + - 'null' + description: 'The address it was mailed to, or null when it was not sent.' + expires_at: + type: + - string + - 'null' + format: date-time + description: 'How long it could wait to be opened.' + consumed_at: + type: + - string + - 'null' + format: date-time + description: 'When it was opened. It is single-use.' + completed_at: + type: + - string + - 'null' + format: date-time + description: 'When its setup was finished.' + revoked_at: + type: + - string + - 'null' + format: date-time + description: 'When it was withdrawn.' + AuditLogEvent: + type: object + description: | + One audit event your app sent. `hash` covers `id`, `organization_id`, `sequence`, + `action`, `occurred_at`, `actor`, `targets`, `context` and `metadata`, chained to + the organization's previous event by `prev_hash` — see the Audit Logs guide for how + to verify it yourself. + required: + - id + - organization_id + - sequence + - action + - occurred_at + - actor + - targets + - context + - metadata + - schema_version + - received_at + - prev_hash + - hash + properties: + id: + type: string + organization_id: + type: string + sequence: + type: integer + description: "The event's place in its organization's chain, from 1." + action: + type: string + description: 'e.g. `invoice.voided`.' + occurred_at: + type: string + format: date-time + description: 'When it happened, as the sender said, to the millisecond.' + actor: + type: object + required: + - id + - type + - name + - metadata + properties: + id: + type: string + type: + type: string + name: + type: + - string + - 'null' + metadata: + type: + - object + - 'null' + targets: + type: array + items: + type: object + required: + - id + - type + - name + - metadata + properties: + id: + type: string + type: + type: string + name: + type: + - string + - 'null' + metadata: + type: + - object + - 'null' + context: + type: object + required: + - location + - user_agent + properties: + location: + type: + - string + - 'null' + user_agent: + type: + - string + - 'null' + metadata: + type: + - object + - 'null' + schema_version: + type: + - integer + - 'null' + description: "The version of its action's schema it was checked against; null when the action has none." + received_at: + type: + - string + - 'null' + format: date-time + description: 'When it arrived. Retention counts from here.' + prev_hash: + type: string + description: "The previous event's hash; 64 zeros for an organization's first." + hash: + type: string + AuditLogEventBatch: + type: object + required: + - events properties: - idp_entity_id: - type: string - idp_sso_url: - type: string - idp_x509cert: - type: string - description: "The IdP's public signing certificate, from its own metadata." - PortalLink: + events: + type: array + description: 'The recorded events, in the order they were sent.' + items: + $ref: '#/components/schemas/AuditLogEvent' + AuditLogSchema: type: object required: - id - - organization_id - - intents - - url - - expires_at - - emailed_to + - action + - version + - targets + - actor_metadata + - metadata + - created_at + - updated_at properties: id: type: string - organization_id: + action: type: string - intents: - type: array - description: 'What the link may set up.' + version: + type: integer + description: 'Starts at 1; every replacement is the next.' + targets: + type: + - array + - 'null' + description: "The target types this action's events may name; null allows any." items: - type: string - enum: - - sso - - dsync - - domain_verification - - log_streams - - certificate_renewal - - audit_logs - emailed_to: + type: object + required: + - type + - metadata + properties: + type: + type: string + metadata: + type: + - object + - 'null' + actor_metadata: type: - - string + - object - 'null' - description: 'The address the link was mailed to, or null when it was not sent.' - url: + description: 'A metadata schema (a subset of JSON Schema).' + metadata: + type: + - object + - 'null' + description: 'A metadata schema (a subset of JSON Schema).' + created_at: type: - string - 'null' - description: | - The one-time setup link — the whole credential. Shown once; `null` on an - idempotent replay of this answer. - expires_at: + format: date-time + updated_at: type: - string - 'null' format: date-time - PortalLinkRecord: + AuditLogExport: type: object - description: 'An Admin Portal link as a list shows it. Never its URL: that was shown once, when it was minted, and only its hash is kept.' required: - id - organization_id - - intents - - status + - state + - filters + - row_count + - url - created_at - - created_by - - emailed_to - - expires_at - - consumed_at - completed_at - - revoked_at + - expires_at properties: id: type: string organization_id: - type: string - intents: - type: array - description: 'What the link may set up.' - items: - type: string - enum: - - sso - - dsync - - domain_verification - - log_streams - - certificate_renewal - - audit_logs - status: + type: + - string + - 'null' + description: 'null for an export across the whole environment.' + state: type: string enum: - pending - - in_use - - completed + - ready + - failed - expired - - revoked + filters: + type: object + description: 'The filters it was asked with.' + row_count: + type: + - integer + - 'null' + url: + type: + - string + - 'null' description: | - `pending`: not opened yet, and it still can be. `in_use`: opened, and the setup - session it started may still be running. `completed`: its setup was finished. - `expired`: never opened in time, or its setup session ran out. `revoked`: withdrawn. + Where the CSV downloads from once `ready` — signed, and valid for a few minutes; + read the export again for a fresh one. null otherwise, and on an idempotent replay. created_at: type: - string - 'null' format: date-time - created_by: - type: string - description: "Who minted it: the console person's id, or the management key's." - emailed_to: + completed_at: type: - string - 'null' - description: 'The address it was mailed to, or null when it was not sent.' + format: date-time expires_at: type: - string - 'null' format: date-time - description: 'How long it could wait to be opened.' - consumed_at: + description: 'When the file is deleted.' + FgaSubject: + type: object + required: + - type + - id + - relation + properties: + type: + type: string + description: "The subject's type: `user`, or `group` for a userset." + id: + type: string + relation: type: - string - 'null' - format: date-time - description: 'When it was opened. It is single-use.' - completed_at: + description: 'For a userset — everybody with this relation on the subject (`member` of `group:eng`). null for one subject.' + FgaTuple: + type: object + required: + - resource_type + - resource_id + - relation + - subject + - tuple + properties: + resource_type: + type: string + resource_id: + type: string + relation: + type: string + subject: + $ref: '#/components/schemas/FgaSubject' + tuple: + type: string + description: 'The same tuple in the notation: `document:readme#viewer@group:eng#member`.' + FgaSchema: + type: object + required: + - defined + - schema + - version + - types + - updated_at + - consistency_token + properties: + defined: + type: boolean + description: 'false until a schema is first saved.' + schema: + type: + - string + - 'null' + description: 'The source as it was written, comments and all.' + version: + type: integer + description: 'How many times the schema has been replaced.' + types: + type: array + description: 'The parsed types, each with its relations and how each is decided.' + items: + $ref: '#/components/schemas/FgaType' + updated_at: type: - string - 'null' format: date-time - description: 'When its setup was finished.' - revoked_at: + consistency_token: + type: string + description: 'The revision the model is at.' + FgaType: + type: object + required: + - name + - relations + properties: + name: + type: string + relations: + type: array + items: + type: object + required: + - name + - rewrite + - directly_related + properties: + name: + type: string + rewrite: + type: object + description: | + How the relation is decided, as a tree of one-key objects: `direct` (a list of + `{type, relation?}`), `computed` (a relation name), `from` (`{tupleset, relation}`), + `union` and `intersection` (lists of rewrites), `exclusion` (`{base, subtract}`). + directly_related: + type: array + description: 'The subjects a tuple may name on this relation; empty for a relation that is only computed.' + items: + type: object + required: + - type + properties: + type: + type: string + relation: + type: string + FgaSchemaValidation: + type: object + required: + - valid + - errors + - types + - canonical + properties: + valid: + type: boolean + errors: + type: array + items: + type: object + required: + - line + - message + properties: + line: + type: integer + description: '0 when the problem is about the whole schema.' + message: + type: string + types: + type: array + items: + $ref: '#/components/schemas/FgaType' + canonical: + type: + - string + - 'null' + description: 'The schema as the platform prints it, when valid.' + FgaTupleWrite: + type: object + required: + - written + - deleted + - consistency_token + properties: + written: + type: integer + description: 'Tuples that were new. Re-writing an existing one is not counted.' + deleted: + type: integer + description: 'Tuples that were there to delete.' + consistency_token: + type: string + description: 'Pass to a check that must see this write.' + FgaCheck: + type: object + required: + - allowed + - resource_type + - resource_id + - relation + - subject + - consistency_token + properties: + allowed: + type: boolean + resource_type: + type: string + resource_id: + type: string + relation: + type: string + subject: + $ref: '#/components/schemas/FgaSubject' + consistency_token: + type: string + description: 'The revision the answer was decided at.' + FgaCheckBatch: + type: object + required: + - results + - consistency_token + properties: + results: + type: array + description: 'One answer per check, in the order asked.' + items: + $ref: '#/components/schemas/FgaCheck' + consistency_token: type: - string - 'null' - format: date-time - description: 'When it was withdrawn.' - AuditLogEvent: + FgaObject: type: object - description: | - One audit event your app sent. `hash` covers `id`, `organization_id`, `sequence`, - `action`, `occurred_at`, `actor`, `targets`, `context` and `metadata`, chained to - the organization's previous event by `prev_hash` — see the Audit Logs guide for how - to verify it yourself. required: + - type - id - - organization_id - - sequence - - action - - occurred_at - - actor - - targets - - context - - metadata - - schema_version - - received_at - - prev_hash - - hash properties: - id: + type: type: string - organization_id: + id: type: string - sequence: + AuditLogSettings: + type: object + required: + - retention_days + - strict_schemas + properties: + retention_days: type: integer - description: "The event's place in its organization's chain, from 1." - action: + description: 'Events are kept this many days after they arrive.' + strict_schemas: + type: boolean + description: 'Whether an action with no schema is refused.' + RadarSettings: + type: object + required: + - mode + - mode_inherited + - deployment_mode + - ip_intelligence + - builtin_rules + properties: + mode: type: string - description: 'e.g. `invoice.voided`.' - occurred_at: + enum: + - monitor + - enforce + description: 'monitor records every verdict and acts on none; enforce blocks and challenges.' + mode_inherited: + type: boolean + description: "True while the environment follows the deployment's default (RISK_MODE) rather than a mode it chose." + deployment_mode: type: string - format: date-time - description: 'When it happened, as the sender said, to the millisecond.' - actor: - type: object - required: - - id - - type - - name - - metadata - properties: - id: - type: string - type: - type: string - name: - type: - - string - - 'null' - metadata: - type: - - object - - 'null' - targets: + enum: + - monitor + - enforce + ip_intelligence: + type: string + enum: + - none + - maxmind + - ipinfo + description: 'The configured IP intelligence source. With none, country, network and travel facts are unknown and the rules on them never fire.' + builtin_rules: type: array items: type: object required: - - id - - type + - key - name - - metadata + - description + - applies_to + - enabled + - action + - threshold + - threshold_unit + - threshold_min + - threshold_max properties: - id: - type: string - type: + key: type: string + enum: + - credential_stuffing + - bot_velocity + - account_attack + - impossible_travel + - new_device + - anonymous_network + - hosting_network + - disposable_email + - risk_score_reject + - risk_score_elevated name: + type: string + description: + type: string + applies_to: + type: string + enum: + - all + - sign_in + - sign_up + enabled: + type: boolean + action: + type: string + enum: + - allow + - challenge + - block + threshold: + type: + - integer + - 'null' + description: 'null for a rule that counts nothing.' + threshold_unit: type: - string - 'null' - metadata: + threshold_min: type: - - object + - integer - 'null' - context: - type: object - required: - - location - - user_agent - properties: - location: - type: - - string - - 'null' - user_agent: - type: - - string - - 'null' - metadata: - type: - - object - - 'null' - schema_version: - type: - - integer - - 'null' - description: "The version of its action's schema it was checked against; null when the action has none." - received_at: - type: - - string - - 'null' - format: date-time - description: 'When it arrived. Retention counts from here.' - prev_hash: - type: string - description: "The previous event's hash; 64 zeros for an organization's first." - hash: - type: string - AuditLogEventBatch: - type: object - required: - - events - properties: - events: - type: array - description: 'The recorded events, in the order they were sent.' - items: - $ref: '#/components/schemas/AuditLogEvent' - AuditLogSchema: + threshold_max: + type: + - integer + - 'null' + RadarRule: type: object required: - id + - name + - description + - position + - enabled + - applies_to - action - - version - - targets - - actor_metadata - - metadata + - conditions + - summary - created_at - updated_at properties: id: type: string - action: + name: type: string - version: - type: integer - description: 'Starts at 1; every replacement is the next.' - targets: + description: type: - - array + - string - 'null' - description: "The target types this action's events may name; null allows any." + position: + type: integer + description: '1 is evaluated first; the first rule whose conditions all hold decides.' + enabled: + type: boolean + applies_to: + type: string + enum: + - all + - sign_in + - sign_up + action: + type: string + enum: + - allow + - challenge + - block + conditions: + type: array items: type: object required: - - type - - metadata + - field + - operator + - value properties: - type: + field: type: string - metadata: - type: - - object - - 'null' - actor_metadata: - type: - - object - - 'null' - description: 'A metadata schema (a subset of JSON Schema).' - metadata: - type: - - object - - 'null' - description: 'A metadata schema (a subset of JSON Schema).' + operator: + type: string + enum: + - eq + - neq + - in + - not_in + - gt + - gte + - lt + - lte + - contains + - not_contains + - starts_with + - ends_with + - in_cidr + - not_in_cidr + value: + description: 'Normalised: a string, a number, true/false, or a list for the list operators.' + oneOf: + - type: string + - type: number + - type: boolean + - + type: array + items: + oneOf: + - type: string + - type: number + summary: + type: string + description: 'The conditions as a person reads them.' created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + RadarListEntry: + type: object + required: + - id + - list + - kind + - value + - note + - expires_at + - active + - created_at + properties: + id: + type: string + list: + type: string + enum: + - allow + - deny + kind: + type: string + enum: + - ip + - email + - email_domain + - device + value: + type: string + description: 'Normalised: an IP or CIDR range, a lower-case address or domain, or a device id.' + note: type: - string - 'null' - format: date-time - updated_at: + expires_at: type: - string - 'null' format: date-time - AuditLogExport: + active: + type: boolean + created_at: + type: string + format: date-time + RadarDecision: type: object required: - id - - organization_id - - state - - filters - - row_count - - url - - created_at - - completed_at - - expires_at + - assessed_at + - flow + - method + - verdict + - enforced + - mode + - rule + - rule_name + - triggered + - reasons + - risk_score + - risk_outcome + - country + - asn + - email_domain + - device + - facts properties: id: type: string - organization_id: + assessed_at: + type: string + format: date-time + flow: + type: string + enum: + - sign_in + - sign_up + method: type: - string - 'null' - description: 'null for an export across the whole environment.' - state: - type: string enum: - - pending - - ready - - failed - - expired - filters: - type: object - description: 'The filters it was asked with.' - row_count: + - password + - magic_link + - passkey + - sign_up + - null + verdict: + type: string + enum: + - allow + - challenge + - block + enforced: + type: boolean + description: 'Whether the verdict was acted on; false under monitor.' + mode: + type: string + enum: + - monitor + - enforce + rule: type: - - integer + - string - 'null' - url: + description: 'What decided it: deny_list:, allow_list:, rule:, builtin:; null when nothing matched.' + rule_name: type: - string - 'null' - description: | - Where the CSV downloads from once `ready` — signed, and valid for a few minutes; - read the export again for a fresh one. null otherwise, and on an idempotent replay. - created_at: + triggered: + type: array + description: 'Every rule that fired, the deciding one included.' + items: + type: object + required: + - rule + - name + properties: + rule: + type: string + name: + type: string + reasons: + type: array + items: + type: string + risk_score: + type: number + risk_outcome: + type: string + enum: + - allow + - flag + - challenge + - step_up + - reject + country: type: - string - 'null' - format: date-time - completed_at: + asn: + type: + - integer + - 'null' + email_domain: type: - string - 'null' - format: date-time - expires_at: + device: type: - string - 'null' - format: date-time - description: 'When the file is deleted.' - AuditLogSettings: - type: object - required: - - retention_days - - strict_schemas - properties: - retention_days: - type: integer - description: 'Events are kept this many days after they arrive.' - strict_schemas: - type: boolean - description: 'Whether an action with no schema is refused.' + description: 'The device id (a pseudonym of the device cookie), for a returning device.' + facts: + type: object + description: 'The facts the rules were evaluated on — never the IP, the address or the user agent.' AuditLogVerification: type: object required: @@ -17279,9 +21866,17 @@ components: - scim - google_workspace - microsoft_entra + - workday + - bamboohr + - rippling + - hibob + - personio pull: type: boolean description: 'true when this platform fetches from the provider; false for a SCIM endpoint the provider posts to.' + hris: + type: boolean + description: 'true for an HR system (Workday, BambooHR, Rippling, HiBob, Personio): employment dates decide access and departments become groups.' active: type: boolean status: @@ -17303,6 +21898,74 @@ components: type: - string - 'null' + description: 'Why the last pull failed, or for a partial one how many records could not be synced and the first reason. Never names a person.' + last_sync_started_at: + type: + - string + - 'null' + format: date-time + description: 'When the last pull started. null for a SCIM directory.' + last_sync_status: + type: + - string + - 'null' + enum: + - running + - succeeded + - partial + - failed + - null + description: 'How the last pull went. partial: the provider answered but some records could not be reconciled, or a mass deprovisioning was refused.' + last_sync_stats: + type: + - object + - 'null' + description: "The last pull's counts, and up to 50 records it could not reconcile — by the provider's own id, never by name or email." + properties: + mode: + type: string + enum: + - full + - incremental + provisioned: + type: integer + deprovisioned: + type: integer + groups: + type: integer + skipped: + type: integer + description: 'Leavers and people who have not started, who never had an account.' + failed: + type: integer + failures: + type: array + items: + type: object + properties: + external_id: + type: + - string + - 'null' + reason: + type: string + sync_interval_minutes: + type: + - integer + - 'null' + description: 'Minutes between scheduled pulls. null for a SCIM directory.' + next_sync_at: + type: + - string + - 'null' + format: date-time + custom_attributes: + type: + - array + - 'null' + items: + type: string + description: "HR systems only: the HR system's own field names copied onto each person." created_at: type: - string @@ -17426,6 +22089,115 @@ components: - string - 'null' format: date-time + FeatureFlag: + type: object + required: + - id + - key + - enabled + - default_value + - users + - organizations + description: 'A switch apps ask about per user and organization. First match wins: switched off, then a user rule, then an organization rule, then the rollout, then the default.' + properties: + id: + type: string + key: + type: string + description: 'What code asks for and the `feature_flags` claim carries. Fixed once created.' + description: + type: + - string + - 'null' + enabled: + type: boolean + description: 'The kill switch: false is off for everyone.' + default_value: + type: boolean + description: 'The answer when no rule matches.' + rollout_percentage: + type: + - integer + - 'null' + minimum: 0 + maximum: 100 + description: 'On for this share of everyone else, by a stable hash of the user id (the organization id without one).' + users: + type: array + description: 'Rules for named users; they outrank every other rule.' + items: + $ref: '#/components/schemas/FeatureFlagRule' + organizations: + type: array + description: 'Rules for named organizations; they outrank the rollout and the default.' + items: + $ref: '#/components/schemas/FeatureFlagRule' + created_at: + type: + - string + - 'null' + format: date-time + updated_at: + type: + - string + - 'null' + format: date-time + FeatureFlagRule: + type: object + required: + - id + - enabled + properties: + id: + type: string + description: "The user's or organization's id." + enabled: + type: boolean + description: 'On or off for them.' + FeatureFlagEvaluation: + type: object + required: + - user_id + - organization_id + - feature_flags + - evaluations + description: "Every flag's answer for the user and organization asked about." + properties: + user_id: + type: + - string + - 'null' + organization_id: + type: + - string + - 'null' + feature_flags: + type: array + description: "The keys of the flags that are on, sorted — exactly what the token's `feature_flags` claim would carry." + items: + type: string + evaluations: + type: array + items: + type: object + required: + - key + - enabled + - reason + properties: + key: + type: string + enabled: + type: boolean + reason: + type: string + enum: + - disabled + - user_target + - organization_target + - rollout + - default + description: 'The rule that decided.' AccessReview: type: object required: @@ -17591,6 +22363,155 @@ components: items: type: string description: 'The OAuth client ids that may lease it. Not on lists.' + Pipe: + type: object + required: + - id + - provider + - name + - client_id + - scopes + - enabled + - redirect_uri + - grants + - connections + description: "A pipe: this environment's OAuth app at a third-party provider, through which people connect their own accounts. The client secret is write-only." + properties: + id: + type: string + provider: + type: string + enum: + - github + - google + - microsoft + - slack + - salesforce + - hubspot + - linear + - notion + name: + type: string + client_id: + type: string + description: "Your app's OAuth client id at the provider." + scopes: + type: array + items: + type: string + description: 'What people are asked for when they connect.' + parameters: + type: object + additionalProperties: + type: string + description: "Per-installation values: Microsoft's `tenant`, Salesforce's `domain`." + enabled: + type: boolean + redirect_uri: + type: string + format: uri + description: 'The callback URL to register at the provider.' + grants: + type: array + items: + type: string + description: 'The OAuth client ids of the apps that may lease its tokens.' + connections: + type: integer + description: 'How many people have connected an account through it.' + created_at: + type: + - string + - 'null' + format: date-time + updated_at: + type: + - string + - 'null' + format: date-time + PipeConnection: + type: object + required: + - id + - pipe_id + - provider + - user_id + - status + - scopes + description: "One person's connected account at a pipe's provider. Never a token." + properties: + id: + type: string + pipe_id: + type: string + provider: + type: string + user_id: + type: string + status: + type: string + enum: + - active + - needs_reauth + account: + type: + - string + - 'null' + description: "The account's name at the provider, when it says (a GitHub login, an email, a workspace)." + scopes: + type: array + items: + type: string + metadata: + type: object + additionalProperties: + type: string + expires_at: + type: + - string + - 'null' + format: date-time + description: 'When the current access token expires. Null when it does not.' + connected_at: + type: + - string + - 'null' + format: date-time + last_refreshed_at: + type: + - string + - 'null' + format: date-time + refresh_failures: + type: integer + description: 'Consecutive transient refresh failures.' + last_error: + type: + - string + - 'null' + reauth_reason: + type: + - string + - 'null' + description: "Why it needs reconnecting: the provider's error code, or `no_refresh_token`." + PipeConnectRequired: + type: object + required: + - error + - message + - connect_url + properties: + error: + type: string + enum: + - not_connected + - reauthorization_required + message: + type: string + connect_url: + type: string + format: uri + description: 'Where to send the person to connect. Add `?client_id=…&return_to=…` to bring them back to your app.' AgentRequest: type: object description: "A pending request from an agent to act as one of this environment's people (OIDC CIBA)." diff --git a/scripts/generate_management.py b/scripts/generate_management.py index 5eb6234..987ce7e 100644 --- a/scripts/generate_management.py +++ b/scripts/generate_management.py @@ -690,6 +690,15 @@ def operations(self) -> list[Operation]: for o in actions: o.name = o.name[1:] + # An action can be both a method and the namespace of another: `fga.check` and + # `fga.check.batch`. A member cannot be both, so the deeper one folds its last two + # segments together — `fga.check_batch()` next to `fga.check()`. + leaves = {".".join(o.name) for o in ops} + + for o in ops: + while len(o.name) > 2 and ".".join(o.name[:-1]) in leaves: + o.name = [*o.name[:-2], f"{o.name[-2]}_{o.name[-1]}"] + for o in ops: o.name = [identifier(segment) for segment in o.name] diff --git a/src/cbox_id/__init__.py b/src/cbox_id/__init__.py index f6f0052..9650405 100644 --- a/src/cbox_id/__init__.py +++ b/src/cbox_id/__init__.py @@ -4,8 +4,11 @@ from .authz import AuthzManifest, Permission, Role from .claims import ( + FEATURE_FLAGS_SCOPE, ClaimSource, actor, + feature_flags, + has_feature, has_permission, has_role, is_support_session, @@ -21,6 +24,11 @@ FrontendApiError, InvalidStateError, ManifestPublishError, + PipeLeaseDeniedError, + PipeLeaseError, + PipeNotConnectedError, + PipeReauthorizationRequiredError, + PipeTemporarilyUnavailableError, ) from .frontend import FrontendClient, FrontendConfig, FrontendSession from .legacy import LegacyUser, handle_legacy_login @@ -34,10 +42,30 @@ OrganizationRole, RefreshedTokens, ) +from .pipes import ( + PipeProvider, + PipesClient, + PipeToken, + pipe_connect_url, + with_connect_return, +) from .pkce import challenge, create_verifier, random_token from .webhook import verify_standard_webhook, verify_webhook __all__ = [ + "FEATURE_FLAGS_SCOPE", + "PipeLeaseDeniedError", + "PipeLeaseError", + "PipeNotConnectedError", + "PipeProvider", + "PipeReauthorizationRequiredError", + "PipeTemporarilyUnavailableError", + "PipeToken", + "PipesClient", + "feature_flags", + "has_feature", + "pipe_connect_url", + "with_connect_return", "FrontendClient", "FrontendConfig", "FrontendSession", diff --git a/src/cbox_id/claims.py b/src/cbox_id/claims.py index 95007e4..7276288 100644 --- a/src/cbox_id/claims.py +++ b/src/cbox_id/claims.py @@ -96,6 +96,34 @@ def has_permission(source: ClaimSource, permission: str) -> bool: return permission in permissions(source) +#: The scope that puts the ``feature_flags`` claim on the ID token, the access token and +#: UserInfo. Request it at sign-in and give the app the scope on its Scopes tab. +FEATURE_FLAGS_SCOPE = "feature_flags" + + +def feature_flags(source: ClaimSource) -> list[str] | None: + """The keys of the feature flags on for this person — the ``feature_flags`` claim. + + In the organization the session is bound to, sorted as the server sent them. + + ``None`` IS NOT EMPTY. ``None`` means the claim is absent: the ``feature_flags`` scope + was not requested, or the instance predates it. ``[]`` means it was asked and nothing + is on. A token carries the flags as they were when it was issued; the next refresh, or + UserInfo, picks up a change. + """ + claim = _claims_of(source).get("feature_flags") + return _string_list(claim) if isinstance(claim, list) else None + + +def has_feature(source: ClaimSource, key: str) -> bool: + """Whether the feature flag ``key`` is on for this session. Exact match on the key. + + ``False`` when the claim is absent — an app that forgot to request the scope sees every + feature off, never every feature on. Use :func:`feature_flags` to tell the two apart. + """ + return key in (feature_flags(source) or []) + + def _parse_actor(claim: object, depth: int) -> Actor | None: # Only an absent or null claim means "nobody else". `False`, `""`, `[]` and `{}` are # all present, and all mean an issuer tried to say something about an actor. diff --git a/src/cbox_id/client.py b/src/cbox_id/client.py index fd8e8f3..774ff56 100644 --- a/src/cbox_id/client.py +++ b/src/cbox_id/client.py @@ -29,6 +29,7 @@ CboxUser, RefreshedTokens, ) +from .pipes import PipesClient, pipe_connect_url from .pkce import challenge, create_verifier, random_token from .webhook import verify_webhook @@ -73,6 +74,8 @@ def __init__(self, config: CboxIdConfig, http_client: httpx.Client | None = None self._discovery_cache: tuple[dict[str, Any], float] | None = None self._jwks_cache: tuple[dict[str, Any], float] | None = None self._jwks_refetched_at: float = 0.0 + self._pipes: PipesClient | None = None + self._lease_token_cache: tuple[str, float] | None = None # -- login --------------------------------------------------------------- @@ -294,6 +297,7 @@ def authenticate( # From the signed id_token alone: a `sid` is what a back-channel logout names # to end a session, and UserInfo's unsigned body must not be able to pick it. session_id=sid if isinstance(sid, str) and sid != "" else None, + feature_flags=_claims.feature_flags(claims), ) # -- hosted profile & logout --------------------------------------------- @@ -338,10 +342,56 @@ def logout_url( params["id_token_hint"] = id_token_hint return f"{endpoint}?{urlencode(params)}" + def pipe_connect_url(self, provider: str, return_to: str | None = None) -> str: + """The hosted page where the signed-in person connects their account at ``provider``. + + Pipes: ``/account/connected-services/{provider}/connect``, preselected to this app. + They come back to ``return_to`` with ``?provider=…&status=connected|cancelled|failed``, + honoured only on an origin the app registered. + """ + return pipe_connect_url( + self._config.issuer, provider, client_id=self._config.client_id, return_to=return_to + ) + # -- back-channel -------------------------------------------------------- + @property + def pipes(self) -> PipesClient: + """Lease Pipes tokens as this app, with a client-credentials ``vault.lease`` token. + + The token is obtained on first use and reused until shortly before it expires:: + + token = client.pipes.lease_token("github", user_id=uid, purpose="list-repos") + + For a token issued FOR a person, build a :class:`PipesClient` with it instead. + """ + if self._pipes is None: + self._pipes = PipesClient( + self._config.issuer, + self._lease_token, + client_id=self._config.client_id, + http_client=self._http, + ) + return self._pipes + + def _lease_token(self) -> str: + cached = self._lease_token_cache + if cached is not None and cached[1] > time.monotonic(): + return cached[0] + token, expires_in = self._client_credentials(["vault.lease"], None) + # Thirty seconds of slack, so a lease is never sent with a token about to lapse. + # No `expires_in` means no caching: there is nothing to know when to stop by. + if expires_in is not None and expires_in > 60: + self._lease_token_cache = (token, time.monotonic() + expires_in - 30) + return token + def machine_token(self, *, scopes: list[str] | None = None, resource: str | None = None) -> str: """A machine (client-credentials) token for calling APIs as your app.""" + return self._client_credentials(scopes, resource)[0] + + def _client_credentials( + self, scopes: list[str] | None, resource: str | None + ) -> tuple[str, int | None]: data: dict[str, str] = { "grant_type": "client_credentials", "client_id": self._config.client_id, @@ -355,10 +405,12 @@ def machine_token(self, *, scopes: list[str] | None = None, resource: str | None response = self._http.post(self._endpoint("token_endpoint"), data=data) if response.status_code >= 400: raise AuthenticationError.from_response("Machine token request failed", response) - token = response.json().get("access_token") + body = response.json() + token = body.get("access_token") if not isinstance(token, str): raise AuthenticationError("The token response had no access_token.") - return token + expires_in = body.get("expires_in") + return token, int(expires_in) if isinstance(expires_in, int | float) else None def refresh(self, refresh_token: str) -> RefreshedTokens: """Exchange a refresh token for a fresh access token (OAuth 2.0 refresh_token). diff --git a/src/cbox_id/errors.py b/src/cbox_id/errors.py index b901829..69a3466 100644 --- a/src/cbox_id/errors.py +++ b/src/cbox_id/errors.py @@ -111,3 +111,78 @@ def __init__(self, message: str, *, code: str, status: int | None = None) -> Non super().__init__(message) self.code = code self.status = status + + +class PipeLeaseError(CboxIdError): + """A Pipes token lease was refused. + + Catch the subclasses for the cases you can act on: + + - :class:`PipeNotConnectedError` (404) and :class:`PipeReauthorizationRequiredError` + (409) — send the person to :attr:`connect_url`. + - :class:`PipeTemporarilyUnavailableError` (503) — retry after :attr:`retry_after`. + - :class:`PipeLeaseDeniedError` (403) — a configuration problem: the app is not granted + the pipe, the pipe is disabled, or the person is outside the app's organization. + + Anything else (401, 422, 429) is raised as this base class with ``status`` and ``error``. + """ + + def __init__( + self, + message: str, + *, + error: str | None, + status: int, + connect_url: str | None = None, + retry_after: int | None = None, + ) -> None: + super().__init__(message) + #: The server's error code, e.g. ``not_connected``. + self.error = error + self.status = status + #: Where to send the person to (re)connect — set on 404 and 409. + self.connect_url = connect_url + #: Seconds to wait, off ``Retry-After``, when the server sent one. + self.retry_after = retry_after + + def connect_url_with( + self, *, client_id: str | None = None, return_to: str | None = None + ) -> str | None: + """:attr:`connect_url` with your ``client_id`` and ``return_to`` on it, or ``None``.""" + from .pipes import with_connect_return + + if self.connect_url is None: + return None + return with_connect_return(self.connect_url, client_id=client_id, return_to=return_to) + + +class PipeNotConnectedError(PipeLeaseError): + """The person has not connected this provider (404 ``not_connected``). + + Send them to :attr:`connect_url`. + """ + + connect_url: str + + +class PipeReauthorizationRequiredError(PipeLeaseError): + """The provider stopped accepting the connection (409 ``reauthorization_required``). + + Revoked at the provider, or the refresh token expired. Send the person to + :attr:`connect_url` to connect again. + """ + + connect_url: str + + +class PipeTemporarilyUnavailableError(PipeLeaseError): + """The provider could not refresh the token just now (503). Retry after ``retry_after``.""" + + +class PipeLeaseDeniedError(PipeLeaseError): + """The lease was denied (403 ``lease_denied``). + + The app is not granted the pipe, the pipe is disabled or missing, the person is not in + the app's organization, or ``user_id`` names someone other than the token's person. One + answer for every reason, by design. + """ diff --git a/src/cbox_id/management/__init__.py b/src/cbox_id/management/__init__.py index 1ba0f5f..5600aec 100644 --- a/src/cbox_id/management/__init__.py +++ b/src/cbox_id/management/__init__.py @@ -40,6 +40,7 @@ CboxIdApiError, ManagementNetworkError, ) +from .fga import fga_tuple from .generated import account as account_api from .generated import environment as environment_api from .generated import platform as platform_api @@ -106,6 +107,7 @@ "canonical_json", "environment_api", "export_audit_logs", + "fga_tuple", "generate_dpop_key", "platform_api", "retry_after_seconds", diff --git a/src/cbox_id/management/fga.py b/src/cbox_id/management/fga.py new file mode 100644 index 0000000..f4f9d7a --- /dev/null +++ b/src/cbox_id/management/fga.py @@ -0,0 +1,58 @@ +"""Fine-grained authorization helpers for ``env.fga``. + +The batch check (``env.fga.check_batch({"checks": [...]})``) takes each check in the tuple +notation — ``document:readme#viewer@user:alice``, or ``folder:policies#viewer@group:eng#member`` +for a userset. :func:`fga_tuple` writes that notation from the same mapping +``env.fga.tuples.write`` takes, so code that writes tuples and checks them speaks one shape. +""" + +from __future__ import annotations + +import re +from collections.abc import Mapping +from typing import Any + +_NAME_FORBIDDEN = re.compile(r"[\s#@:]") +_ID_FORBIDDEN = re.compile(r"[\s#@]") + + +def _name(value: object, what: str) -> str: + if not isinstance(value, str) or value == "" or _NAME_FORBIDDEN.search(value): + raise ValueError( + f"An FGA {what} cannot be empty or contain whitespace, '#', '@' or ':': {value!r}" + ) + return value + + +def _id(value: object, what: str) -> str: + if not isinstance(value, str) or value == "" or _ID_FORBIDDEN.search(value): + raise ValueError( + f"An FGA {what} cannot be empty or contain whitespace, '#' or '@': {value!r}" + ) + return value + + +def fga_tuple(tuple_: Mapping[str, Any]) -> str: + """Write a tuple in the notation the batch check and the console use. + + ``{"resource_type": "document", "resource_id": "readme", "relation": "viewer", + "subject": {"type": "user", "id": "alice"}}`` → ``document:readme#viewer@user:alice``. + + Raises :class:`ValueError` for a part that is empty or contains ``#``, ``@``, ``:`` (in + a type or relation) or whitespace — the notation has no escaping, so such a part would + be read back as a different tuple. Ids may contain ``:``. + """ + subject = tuple_.get("subject") + if not isinstance(subject, Mapping): + raise ValueError("An FGA tuple needs a subject mapping with a type and an id.") + + relation = subject.get("relation") + userset = f"#{_name(relation, 'subject relation')}" if relation else "" + + return ( + f"{_name(tuple_.get('resource_type'), 'resource type')}:" + f"{_id(tuple_.get('resource_id'), 'resource id')}#" + f"{_name(tuple_.get('relation'), 'relation')}@" + f"{_name(subject.get('type'), 'subject type')}:{_id(subject.get('id'), 'subject id')}" + f"{userset}" + ) diff --git a/src/cbox_id/management/generated/account.py b/src/cbox_id/management/generated/account.py index e54ce49..083daa2 100644 --- a/src/cbox_id/management/generated/account.py +++ b/src/cbox_id/management/generated/account.py @@ -90,8 +90,10 @@ class ProfileUpdateBody(TypedDict): "account.api_keys.revoke": OperationSpec(action="account.api_keys.revoke", operation_id="account_api_keys_revoke", method="DELETE", path="/me/organizations/{organization_id}/api-keys/{key_id}", path_params=("organization_id", "key_id"), scope="account:api_keys:write", danger="destructive", approval=True, body=False, pagination=None), "account.applications.revoke": OperationSpec(action="account.applications.revoke", operation_id="account_applications_revoke", method="DELETE", path="/me/applications/{client_id}", path_params=("client_id",), scope="account:applications:write", danger="destructive", approval=True, body=False, pagination=None), "account.devices.remove": OperationSpec(action="account.devices.remove", operation_id="account_devices_remove", method="DELETE", path="/me/devices/{device_id}", path_params=("device_id",), scope="account:devices:write", danger="destructive", approval=True, body=False, pagination=None), + "account.mfa.sms.remove": OperationSpec(action="account.mfa.sms.remove", operation_id="account_mfa_sms_remove", method="DELETE", path="/me/mfa/sms", path_params=(), scope="account:sign_in:write", danger="critical", approval=True, body=False, pagination=None), "account.organizations.leave": OperationSpec(action="account.organizations.leave", operation_id="account_organizations_leave", method="POST", path="/me/organizations/{organization_id}/leave", path_params=("organization_id",), scope="account:organizations:write", danger="destructive", approval=True, body=False, pagination=None), "account.passkeys.remove": OperationSpec(action="account.passkeys.remove", operation_id="account_passkeys_remove", method="DELETE", path="/me/passkeys/{passkey_id}", path_params=("passkey_id",), scope="account:sign_in:write", danger="critical", approval=True, body=False, pagination=None), + "account.pipes.disconnect": OperationSpec(action="account.pipes.disconnect", operation_id="account_pipes_disconnect", method="DELETE", path="/me/pipes/{provider}", path_params=("provider",), scope="account:pipes:write", danger="destructive", approval=True, body=False, pagination=None), "account.profile.update": OperationSpec(action="account.profile.update", operation_id="account_profile_update", method="PATCH", path="/me/profile", path_params=(), scope="account:profile:write", danger="write", approval=True, body=True, pagination=None), "account.sessions.revoke": OperationSpec(action="account.sessions.revoke", operation_id="account_sessions_revoke", method="DELETE", path="/me/sessions/{session_id}", path_params=("session_id",), scope="account:sessions:write", danger="destructive", approval=True, body=False, pagination=None), "account.sessions.revoke_others": OperationSpec(action="account.sessions.revoke_others", operation_id="account_sessions_revoke_others", method="POST", path="/me/sessions/revoke-others", path_params=(), scope="account:sessions:write", danger="critical", approval=True, body=False, pagination=None), @@ -207,6 +209,38 @@ def remove(self, device_id: str, *, approval: ApprovalMode = "wait", idempotency return self._core.call(ACCOUNT_OPERATIONS["account.devices.remove"], (device_id,), None, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) +class MfaSmsMethods: + """``mfa.sms.*``""" + + def __init__(self, core: ManagementTransport) -> None: + self._core = core + + @overload + def remove(self, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[None]: ... + + @overload + def remove(self, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[None] | PendingApprovalResult[None]: ... + + def remove(self, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[None] | PendingApprovalResult[None]: + """Remove your phone number for text-message sign-in codes. + + Requires scope `account:sign_in:write` on an access token you delegated; it acts on your own account only. No management key is accepted. Danger: critical. + + ``DELETE /me/mfa/sms`` · action ``account.mfa.sms.remove`` + Scope ``account:sign_in:write`` · danger: critical. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ACCOUNT_OPERATIONS["account.mfa.sms.remove"], (), None, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) + + +class MfaMethods: + """``mfa.*``""" + + def __init__(self, core: ManagementTransport) -> None: + self._core = core + self.sms = MfaSmsMethods(core) + + class OrganizationsMethods: """``organizations.*``""" @@ -255,6 +289,30 @@ def remove(self, passkey_id: str, *, approval: ApprovalMode = "wait", idempotenc return self._core.call(ACCOUNT_OPERATIONS["account.passkeys.remove"], (passkey_id,), None, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) +class PipesMethods: + """``pipes.*``""" + + def __init__(self, core: ManagementTransport) -> None: + self._core = core + + @overload + def disconnect(self, provider: str, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[None]: ... + + @overload + def disconnect(self, provider: str, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[None] | PendingApprovalResult[None]: ... + + def disconnect(self, provider: str, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[None] | PendingApprovalResult[None]: + """Disconnect one of your connected services, revoking the access you gave it. + + Requires scope `account:pipes:write` on an access token you delegated; it acts on your own account only. No management key is accepted. Danger: destructive. + + ``DELETE /me/pipes/{provider}`` · action ``account.pipes.disconnect`` + Scope ``account:pipes:write`` · danger: destructive. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ACCOUNT_OPERATIONS["account.pipes.disconnect"], (provider,), None, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) + + class ProfileMethods: """``profile.*``""" @@ -366,8 +424,10 @@ def _bind(self, core: ManagementTransport) -> None: self.api_keys = ApiKeysMethods(core) self.applications = ApplicationsMethods(core) self.devices = DevicesMethods(core) + self.mfa = MfaMethods(core) self.organizations = OrganizationsMethods(core) self.passkeys = PasskeysMethods(core) + self.pipes = PipesMethods(core) self.profile = ProfileMethods(core) self.sessions = SessionsMethods(core) self.social = SocialMethods(core) diff --git a/src/cbox_id/management/generated/environment.py b/src/cbox_id/management/generated/environment.py index e9ad72e..2b4e523 100644 --- a/src/cbox_id/management/generated/environment.py +++ b/src/cbox_id/management/generated/environment.py @@ -72,6 +72,20 @@ class ManifestSync(TypedDict): orphaned_permission_keys: list[str] +class SmsFactorPolicy(TypedDict): + #: Text-message codes are accepted as a second factor. Off by default. + enabled: bool + + #: ISO 3166-1 alpha-2 countries whose numbers may enrol and be texted. Re-checked at every send. + allowed_countries: list[str] + + #: Administrators may add SMS only beside an authenticator app or a passkey. + privileged_need_stronger_factor: bool + + #: The deployment's ceiling (CBOX_ID_SMS_ALLOWED_COUNTRIES). Empty: no deployment restriction. A listed country outside it is never texted. + deployment_countries: list[str] + + class SelfServiceSignup(TypedDict): enabled: bool @@ -919,9 +933,14 @@ class PortalLink(TypedDict): #: What the link may set up. intents: list[Literal["sso", "dsync", "domain_verification", "log_streams", "certificate_renewal", "audit_logs"]] - #: The address the link was mailed to, or null when it was not sent. + #: The address the link was mailed to, or null when it was not sent — none was asked for, or the mail was suppressed (see email_suppressed). emailed_to: str | None + #: True when an `email` was asked for and NOT sent because this is a sandbox + #: environment, which sends no mail. `emailed_to` is then null, and the `url` in this + #: answer is the only way to the link — share it yourself. + email_suppressed: bool + #: The one-time setup link — the whole credential. Shown once; `null` on an #: idempotent replay of this answer. url: str | None @@ -1065,6 +1084,113 @@ class AuditLogExport(TypedDict): expires_at: str | None +class FgaSubject(TypedDict): + #: The subject's type: `user`, or `group` for a userset. + type: str + id: str + + #: For a userset — everybody with this relation on the subject (`member` of `group:eng`). null for one subject. + relation: str | None + + +class FgaTuple(TypedDict): + resource_type: str + resource_id: str + relation: str + subject: FgaSubject + + #: The same tuple in the notation: `document:readme#viewer@group:eng#member`. + tuple: str + + +class FgaSchema(TypedDict): + #: false until a schema is first saved. + defined: bool + + #: The source as it was written, comments and all. + schema: str | None + + #: How many times the schema has been replaced. + version: int + + #: The parsed types, each with its relations and how each is decided. + types: list[FgaType] + updated_at: str | None + + #: The revision the model is at. + consistency_token: str + + +class FgaTypeRelationsItemDirectlyRelatedItem(TypedDict): + type: str + relation: NotRequired[str] + + +class FgaTypeRelationsItem(TypedDict): + name: str + + #: How the relation is decided, as a tree of one-key objects: `direct` (a list of + #: `{type, relation?}`), `computed` (a relation name), `from` (`{tupleset, relation}`), + #: `union` and `intersection` (lists of rewrites), `exclusion` (`{base, subtract}`). + rewrite: dict[str, Any] + + #: The subjects a tuple may name on this relation; empty for a relation that is only computed. + directly_related: list[FgaTypeRelationsItemDirectlyRelatedItem] + + +class FgaType(TypedDict): + name: str + relations: list[FgaTypeRelationsItem] + + +class FgaSchemaValidationErrorsItem(TypedDict): + #: 0 when the problem is about the whole schema. + line: int + message: str + + +class FgaSchemaValidation(TypedDict): + valid: bool + errors: list[FgaSchemaValidationErrorsItem] + types: list[FgaType] + + #: The schema as the platform prints it, when valid. + canonical: str | None + + +class FgaTupleWrite(TypedDict): + #: Tuples that were new. Re-writing an existing one is not counted. + written: int + + #: Tuples that were there to delete. + deleted: int + + #: Pass to a check that must see this write. + consistency_token: str + + +class FgaCheck(TypedDict): + allowed: bool + resource_type: str + resource_id: str + relation: str + subject: FgaSubject + + #: The revision the answer was decided at. + consistency_token: str + + +class FgaCheckBatch(TypedDict): + #: One answer per check, in the order asked. + results: list[FgaCheck] + consistency_token: str | None + + +class FgaObject(TypedDict): + type: str + id: str + + class AuditLogSettings(TypedDict): #: Events are kept this many days after they arrive. retention_days: int @@ -1073,6 +1199,109 @@ class AuditLogSettings(TypedDict): strict_schemas: bool +class RadarSettingsBuiltinRulesItem(TypedDict): + key: Literal["credential_stuffing", "bot_velocity", "account_attack", "impossible_travel", "new_device", "anonymous_network", "hosting_network", "disposable_email", "risk_score_reject", "risk_score_elevated"] + name: str + description: str + applies_to: Literal["all", "sign_in", "sign_up"] + enabled: bool + action: Literal["allow", "challenge", "block"] + + #: null for a rule that counts nothing. + threshold: int | None + threshold_unit: str | None + threshold_min: int | None + threshold_max: int | None + + +class RadarSettings(TypedDict): + #: monitor records every verdict and acts on none; enforce blocks and challenges. + mode: Literal["monitor", "enforce"] + + #: True while the environment follows the deployment's default (RISK_MODE) rather than a mode it chose. + mode_inherited: bool + deployment_mode: Literal["monitor", "enforce"] + + #: The configured IP intelligence source. With none, country, network and travel facts are unknown and the rules on them never fire. + ip_intelligence: Literal["none", "maxmind", "ipinfo"] + builtin_rules: list[RadarSettingsBuiltinRulesItem] + + +class RadarRuleConditionsItem(TypedDict): + field: str + operator: Literal["eq", "neq", "in", "not_in", "gt", "gte", "lt", "lte", "contains", "not_contains", "starts_with", "ends_with", "in_cidr", "not_in_cidr"] + + #: Normalised: a string, a number, true/false, or a list for the list operators. + value: str | float | bool | list[str | float] + + +class RadarRule(TypedDict): + id: str + name: str + description: str | None + + #: 1 is evaluated first; the first rule whose conditions all hold decides. + position: int + enabled: bool + applies_to: Literal["all", "sign_in", "sign_up"] + action: Literal["allow", "challenge", "block"] + conditions: list[RadarRuleConditionsItem] + + #: The conditions as a person reads them. + summary: str + created_at: str + updated_at: str + + +class RadarListEntry(TypedDict): + id: str + list: Literal["allow", "deny"] + kind: Literal["ip", "email", "email_domain", "device"] + + #: Normalised: an IP or CIDR range, a lower-case address or domain, or a device id. + value: str + note: str | None + expires_at: str | None + active: bool + created_at: str + + +class RadarDecisionTriggeredItem(TypedDict): + rule: str + name: str + + +class RadarDecision(TypedDict): + id: str + assessed_at: str + flow: Literal["sign_in", "sign_up"] + method: Literal["password", "magic_link", "passkey", "sign_up"] | None + verdict: Literal["allow", "challenge", "block"] + + #: Whether the verdict was acted on; false under monitor. + enforced: bool + mode: Literal["monitor", "enforce"] + + #: What decided it: deny_list:, allow_list:, rule:, builtin:; null when nothing matched. + rule: str | None + rule_name: str | None + + #: Every rule that fired, the deciding one included. + triggered: list[RadarDecisionTriggeredItem] + reasons: list[str] + risk_score: float + risk_outcome: Literal["allow", "flag", "challenge", "step_up", "reject"] + country: str | None + asn: int | None + email_domain: str | None + + #: The device id (a pseudonym of the device cookie), for a returning device. + device: str | None + + #: The facts the rules were evaluated on — never the IP, the address or the user agent. + facts: dict[str, Any] + + class AuditLogVerification(TypedDict): organization_id: str @@ -1089,23 +1318,63 @@ class AuditLogVerification(TypedDict): reason: Literal["missing", "link", "hash", "truncated"] | None +class DirectoryLastSyncStatsFailuresItem(TypedDict): + external_id: NotRequired[str | None] + reason: NotRequired[str] + + +class DirectoryLastSyncStats(TypedDict): + """The last pull's counts, and up to 50 records it could not reconcile — by the provider's own id, never by name or email.""" + + mode: NotRequired[Literal["full", "incremental"]] + provisioned: NotRequired[int] + deprovisioned: NotRequired[int] + groups: NotRequired[int] + + #: Leavers and people who have not started, who never had an account. + skipped: NotRequired[int] + failed: NotRequired[int] + failures: NotRequired[list[DirectoryLastSyncStatsFailuresItem]] + + class Directory(TypedDict): """A directory syncing an organization's people in. Its token and credentials are never returned after they are minted.""" id: str organization_id: str name: str - provider: Literal["scim", "google_workspace", "microsoft_entra"] + provider: Literal["scim", "google_workspace", "microsoft_entra", "workday", "bamboohr", "rippling", "hibob", "personio"] #: true when this platform fetches from the provider; false for a SCIM endpoint the provider posts to. pull: bool + + #: true for an HR system (Workday, BambooHR, Rippling, HiBob, Personio): employment dates decide access and departments become groups. + hris: NotRequired[bool] active: bool status: Literal["active", "paused"] #: Where the identity provider sends SCIM requests. null for a pull directory. scim_base_url: NotRequired[str | None] last_synced_at: NotRequired[str | None] + + #: Why the last pull failed, or for a partial one how many records could not be synced and the first reason. Never names a person. last_sync_error: NotRequired[str | None] + + #: When the last pull started. null for a SCIM directory. + last_sync_started_at: NotRequired[str | None] + + #: How the last pull went. partial: the provider answered but some records could not be reconciled, or a mass deprovisioning was refused. + last_sync_status: NotRequired[Literal["running", "succeeded", "partial", "failed"] | None] + + #: The last pull's counts, and up to 50 records it could not reconcile — by the provider's own id, never by name or email. + last_sync_stats: NotRequired[DirectoryLastSyncStats | None] + + #: Minutes between scheduled pulls. null for a SCIM directory. + sync_interval_minutes: NotRequired[int | None] + next_sync_at: NotRequired[str | None] + + #: HR systems only: the HR system's own field names copied onto each person. + custom_attributes: NotRequired[list[str] | None] created_at: NotRequired[str | None] #: The SCIM bearer token, on the create and rotate answers only — shown once, never @@ -1158,6 +1427,60 @@ class SodPolicy(TypedDict): created_at: NotRequired[str | None] +class FeatureFlag(TypedDict): + """A switch apps ask about per user and organization. First match wins: switched off, then a user rule, then an organization rule, then the rollout, then the default.""" + + id: str + + #: What code asks for and the `feature_flags` claim carries. Fixed once created. + key: str + description: NotRequired[str | None] + + #: The kill switch: false is off for everyone. + enabled: bool + + #: The answer when no rule matches. + default_value: bool + + #: On for this share of everyone else, by a stable hash of the user id (the organization id without one). + rollout_percentage: NotRequired[int | None] + + #: Rules for named users; they outrank every other rule. + users: list[FeatureFlagRule] + + #: Rules for named organizations; they outrank the rollout and the default. + organizations: list[FeatureFlagRule] + created_at: NotRequired[str | None] + updated_at: NotRequired[str | None] + + +class FeatureFlagRule(TypedDict): + #: The user's or organization's id. + id: str + + #: On or off for them. + enabled: bool + + +class FeatureFlagEvaluationEvaluationsItem(TypedDict): + key: str + enabled: bool + + #: The rule that decided. + reason: Literal["disabled", "user_target", "organization_target", "rollout", "default"] + + +class FeatureFlagEvaluation(TypedDict): + """Every flag's answer for the user and organization asked about.""" + + user_id: str | None + organization_id: str | None + + #: The keys of the flags that are on, sorted — exactly what the token's `feature_flags` claim would carry. + feature_flags: list[str] + evaluations: list[FeatureFlagEvaluationEvaluationsItem] + + class AccessReview(TypedDict): id: str @@ -1219,6 +1542,70 @@ class TokenVaultSecret(TypedDict): grants: NotRequired[list[str]] +class Pipe(TypedDict): + """A pipe: this environment's OAuth app at a third-party provider, through which people connect their own accounts. The client secret is write-only.""" + + id: str + provider: Literal["github", "google", "microsoft", "slack", "salesforce", "hubspot", "linear", "notion"] + name: str + + #: Your app's OAuth client id at the provider. + client_id: str + + #: What people are asked for when they connect. + scopes: list[str] + + #: Per-installation values: Microsoft's `tenant`, Salesforce's `domain`. + parameters: NotRequired[dict[str, str]] + enabled: bool + + #: The callback URL to register at the provider. + redirect_uri: str + + #: The OAuth client ids of the apps that may lease its tokens. + grants: list[str] + + #: How many people have connected an account through it. + connections: int + created_at: NotRequired[str | None] + updated_at: NotRequired[str | None] + + +class PipeConnection(TypedDict): + """One person's connected account at a pipe's provider. Never a token.""" + + id: str + pipe_id: str + provider: str + user_id: str + status: Literal["active", "needs_reauth"] + + #: The account's name at the provider, when it says (a GitHub login, an email, a workspace). + account: NotRequired[str | None] + scopes: list[str] + metadata: NotRequired[dict[str, str]] + + #: When the current access token expires. Null when it does not. + expires_at: NotRequired[str | None] + connected_at: NotRequired[str | None] + last_refreshed_at: NotRequired[str | None] + + #: Consecutive transient refresh failures. + refresh_failures: NotRequired[int] + last_error: NotRequired[str | None] + + #: Why it needs reconnecting: the provider's error code, or `no_refresh_token`. + reauth_reason: NotRequired[str | None] + + +class PipeConnectRequired(TypedDict): + error: Literal["not_connected", "reauthorization_required"] + message: str + + #: Where to send the person to connect. Add `?client_id=…&return_to=…` to bring them back to your app. + connect_url: str + + class AgentRequestSubject(TypedDict): """Whom the agent asks to act as.""" @@ -1815,6 +2202,33 @@ class DirectoriesCreateBody(TypedDict): name: str +class DirectoriesCredentialsReplaceBodyCredentials(TypedDict): + """The new credentials, in the keys the provider's setup names (Google: service_account_json, admin_email; Entra: tenant_id, client_id, client_secret). Write-only.""" + + report_url: NotRequired[str] + username: NotRequired[str] + password: NotRequired[str] + client_id: NotRequired[str] + client_secret: NotRequired[str] + refresh_token: NotRequired[str] + subdomain: NotRequired[str] + api_key: NotRequired[str] + api_token: NotRequired[str] + service_user_id: NotRequired[str] + service_user_token: NotRequired[str] + service_account_json: NotRequired[str] + admin_email: NotRequired[str] + tenant_id: NotRequired[str] + + +class DirectoriesCredentialsReplaceBody(TypedDict): + #: Only if it is this organization's; anything else is a 404. + organization_id: NotRequired[str | None] + + #: The new credentials, in the keys the provider's setup names (Google: service_account_json, admin_email; Entra: tenant_id, client_id, client_secret). Write-only. + credentials: DirectoriesCredentialsReplaceBodyCredentials + + class DirectoriesDeleteBody(TypedDict): #: Only if it is this organization's; anything else is a 404. organization_id: NotRequired[str | None] @@ -1853,6 +2267,64 @@ class DirectoriesGroupsMapBody(TypedDict): mapped: bool +class DirectoriesHrisConnectBodyCredentials(TypedDict): + """The HR system's credentials, in the keys its setup guide names. Write-only: sealed, never returned.""" + + + #: Workday: Report web-service URL. + report_url: NotRequired[str] + + #: Workday: Integration system user. + username: NotRequired[str] + + #: Workday: Integration system user password (secret). + password: NotRequired[str] + + #: Workday: API client ID; Personio: Client ID. + client_id: NotRequired[str] + + #: Workday: API client secret (secret); Personio: Client secret (secret). + client_secret: NotRequired[str] + + #: Workday: Refresh token (secret). + refresh_token: NotRequired[str] + + #: BambooHR: Company subdomain. + subdomain: NotRequired[str] + + #: BambooHR: API key (secret). + api_key: NotRequired[str] + + #: Rippling: API token (secret). + api_token: NotRequired[str] + + #: HiBob: Service user ID. + service_user_id: NotRequired[str] + + #: HiBob: Service user token (secret). + service_user_token: NotRequired[str] + + +class DirectoriesHrisConnectBody(TypedDict): + #: The organization whose people it provisions. + organization_id: str + + #: workday, bamboohr, rippling, hibob or personio. + provider: Literal["workday", "bamboohr", "rippling", "hibob", "personio"] + + #: What administrators call it. Defaults to the HR system's name. + name: NotRequired[str] + + #: The HR system's credentials, in the keys its setup guide names. Write-only: sealed, never returned. + credentials: DirectoriesHrisConnectBodyCredentials + + #: The HR system's own field names to copy onto each person, verbatim. + custom_attributes: NotRequired[list[str]] + + #: Minutes between scheduled pulls, 15 to 1440. Omitted or null: the platform default (hourly). + sync_interval_minutes: NotRequired[int | None] + + class DirectoriesListQuery(TypedDict): #: Only this organization's. organization_id: NotRequired[str | None] @@ -1875,6 +2347,72 @@ class DirectoriesStatusSetBody(TypedDict): organization_id: NotRequired[str | None] +class DirectoriesSyncBody(TypedDict): + #: Only if it is this organization's; anything else is a 404. + organization_id: NotRequired[str | None] + + #: Ask an HR system that syncs incrementally for everybody, not only what changed. Ignored by other providers. + full: NotRequired[bool] + + +class DirectoriesSyncSettingsUpdateBodyFieldMap(TypedDict): + """Workday only: the report column holding each field, where it is not the default name.""" + + + #: The report column holding id. Default: Employee_ID. + id: NotRequired[str] + + #: The report column holding email. Default: Work_Email. + email: NotRequired[str] + + #: The report column holding first_name. Default: Legal_First_Name. + first_name: NotRequired[str] + + #: The report column holding last_name. Default: Legal_Last_Name. + last_name: NotRequired[str] + + #: The report column holding display_name. Default: Preferred_Name. + display_name: NotRequired[str] + + #: The report column holding active. Default: Active_Status. + active: NotRequired[str] + + #: The report column holding on_leave. Default: On_Leave. + on_leave: NotRequired[str] + + #: The report column holding hire_date. Default: Hire_Date. + hire_date: NotRequired[str] + + #: The report column holding termination_date. Default: Termination_Date. + termination_date: NotRequired[str] + + #: The report column holding department_id. Default: Supervisory_Organization_ID. + department_id: NotRequired[str] + + #: The report column holding department. Default: Supervisory_Organization. + department: NotRequired[str] + + #: The report column holding manager_id. Default: Manager_Employee_ID. + manager_id: NotRequired[str] + + #: The report column holding title. Default: Business_Title. + title: NotRequired[str] + + +class DirectoriesSyncSettingsUpdateBody(TypedDict): + #: Only if it is this organization's; anything else is a 404. + organization_id: NotRequired[str | None] + + #: Minutes between scheduled pulls, 15 to 1440. null returns to the platform default (hourly). + sync_interval_minutes: NotRequired[int | None] + + #: HR systems only: the HR system's own field names to copy onto each person, verbatim. + custom_attributes: NotRequired[list[str]] + + #: Workday only: the report column holding each field, where it is not the default name. + field_map: NotRequired[DirectoriesSyncSettingsUpdateBodyFieldMap] + + class DirectoriesTokenRotateBody(TypedDict): #: Only if it is this organization's; anything else is a 404. organization_id: NotRequired[str | None] @@ -1910,23 +2448,306 @@ class EventsListQuery(TypedDict): EventsListData: TypeAlias = 'list[DomainEvent]' -class FrontendKeysCreateBody(TypedDict): - #: What the key is for, so a person recognises it later. - name: str - - #: `test` or `live`; it is in the key's prefix. - mode: Literal["test", "live"] +class FeatureFlagsCreateBodyUsersItem(TypedDict): + #: The user's id. + id: str - #: The exact origins (scheme, host, port) allowed to present the key, for example https://app.example.com. At least one. - origins: list[str] + #: On (true, the default) or off for them, whatever the organization, rollout or default say. + enabled: NotRequired[bool] -class FrontendKeysListQuery(TypedDict): - #: Items per page, 1–100. Default 50. - limit: NotRequired[int] +class FeatureFlagsCreateBodyOrganizationsItem(TypedDict): + #: The organization's id. + id: str - #: The `next_cursor` of the previous page. - after: NotRequired[str] + #: On (true, the default) or off for them, whatever the organization, rollout or default say. + enabled: NotRequired[bool] + + +class FeatureFlagsCreateBody(TypedDict): + #: What code asks for and the `feature_flags` claim carries: lowercase letters and digits, with `-`, `_` or `.` between them. Fixed once created. + key: str + description: NotRequired[str | None] + + #: The kill switch: off means off for everyone, whatever the rules say. Defaults to true. + enabled: NotRequired[bool] + + #: The answer when no rule matches. Defaults to false. + default_value: NotRequired[bool] + + #: Rules for named users — they outrank every other rule. The COMPLETE list when sent. + users: NotRequired[list[FeatureFlagsCreateBodyUsersItem]] + + #: Rules for named organizations — they outrank the rollout and the default. The COMPLETE list when sent. + organizations: NotRequired[list[FeatureFlagsCreateBodyOrganizationsItem]] + + #: On for this percentage of everyone else, by a stable hash of the user id (or the organization id without one). null for no rollout. + rollout_percentage: NotRequired[int | None] + + +class FeatureFlagsEvaluateQuery(TypedDict): + #: The user to evaluate for. Left out, only organization rules, an organization-bucketed rollout and defaults apply. + user_id: NotRequired[str] + + #: The organization the question is asked in. + organization_id: NotRequired[str] + + +class FeatureFlagsListQuery(TypedDict): + #: Items per page, 1–100. Default 50. + limit: NotRequired[int] + + #: The `next_cursor` of the previous page. + after: NotRequired[str] + + +FeatureFlagsListData: TypeAlias = 'list[FeatureFlag]' + + +class FeatureFlagsUpdateBodyUsersItem(TypedDict): + #: The user's id. + id: str + + #: On (true, the default) or off for them, whatever the organization, rollout or default say. + enabled: NotRequired[bool] + + +class FeatureFlagsUpdateBodyOrganizationsItem(TypedDict): + #: The organization's id. + id: str + + #: On (true, the default) or off for them, whatever the organization, rollout or default say. + enabled: NotRequired[bool] + + +class FeatureFlagsUpdateBody(TypedDict): + #: Null or empty clears it. + description: NotRequired[str | None] + + #: The kill switch: off means off for everyone. + enabled: NotRequired[bool] + + #: The answer when no rule matches. + default_value: NotRequired[bool] + + #: Rules for named users — they outrank every other rule. The COMPLETE list when sent. + users: NotRequired[list[FeatureFlagsUpdateBodyUsersItem]] + + #: Rules for named organizations — they outrank the rollout and the default. The COMPLETE list when sent. + organizations: NotRequired[list[FeatureFlagsUpdateBodyOrganizationsItem]] + + #: On for this percentage of everyone else, by a stable hash of the user id (or the organization id without one). null for no rollout. + rollout_percentage: NotRequired[int | None] + + +class FgaCheckQuery(TypedDict): + #: The resource's type: `document`. + resource_type: str + + #: The resource's id: `readme`. + resource_id: str + + #: The relation to check: `viewer`. + relation: str + + #: The subject's type: `user`, or `group` for a userset. + subject_type: str + + #: The subject's id: `alice`. + subject_id: str + + #: For a userset subject — `member` with `group`/`eng` asks about every member of eng. + subject_relation: NotRequired[str] + + #: A `consistency_token` a write returned: the answer is then at least as fresh as that write. + consistency_token: NotRequired[str | None] + + +class FgaCheckBatchQuery(TypedDict): + #: 1–100 checks, each `resource_type:resource_id#relation@subject_type:subject_id[#subject_relation]`. + checks: list[str] + + #: A `consistency_token` a write returned: the answer is then at least as fresh as that write. + consistency_token: NotRequired[str | None] + + +class FgaResourcesListQuery(TypedDict): + #: Which type of resource to list: `document`. + resource_type: str + + #: The relation the subject must have: `viewer`. + relation: str + + #: The subject's type: `user`. + subject_type: str + + #: The subject's id: `alice`. + subject_id: str + + #: For a userset subject: `member` with `group`/`eng`. + subject_relation: NotRequired[str] + + #: A `consistency_token` a write returned: the answer is then at least as fresh as that write. + consistency_token: NotRequired[str | None] + + #: Ids per page, 1–1000. Default 100. + limit: NotRequired[int] + + #: The `next_cursor` of the previous page. + after: NotRequired[str] + + +FgaResourcesListData: TypeAlias = 'list[FgaObject]' + + +class FgaSchemaUpdateBody(TypedDict): + #: The schema, in the schema language: `type document` then ` relation viewer: [user, group#member] or editor or viewer from parent`. + schema: str + + +class FgaSchemaValidateQuery(TypedDict): + #: The schema to check, in the schema language. + schema: str + + +class FgaSubjectsListQuery(TypedDict): + #: The resource's type: `document`. + resource_type: str + + #: The resource's id: `readme`. + resource_id: str + + #: The relation: `viewer`. + relation: str + + #: Which type of subject to list: `user`. + subject_type: str + + #: A `consistency_token` a write returned: the answer is then at least as fresh as that write. + consistency_token: NotRequired[str | None] + + #: Ids per page, 1–1000. Default 100. + limit: NotRequired[int] + + #: The `next_cursor` of the previous page. + after: NotRequired[str] + + +FgaSubjectsListData: TypeAlias = 'list[FgaObject]' + + +class FgaTuplesDeleteBodyTuplesItemSubject(TypedDict): + """Who: one subject (`user:alice`), or a userset (`group:eng#member`).""" + + + #: The subject's type: `user`, or `group` for a userset. + type: str + + #: Your id for the subject: `alice`, `eng`. + id: str + + #: For a userset — everybody with this relation on the subject: `member` of `group:eng`. Omit for one subject. + relation: NotRequired[str | None] + + +class FgaTuplesDeleteBodyTuplesItem(TypedDict): + #: The resource's type, as the schema names it: `document`. + resource_type: str + + #: Your id for the resource: `readme`. + resource_id: str + + #: The relation: `viewer`. + relation: str + + #: Who: one subject (`user:alice`), or a userset (`group:eng#member`). + subject: FgaTuplesDeleteBodyTuplesItemSubject + + +class FgaTuplesDeleteBody(TypedDict): + #: 1–100 tuples to delete, all together or none. + tuples: list[FgaTuplesDeleteBodyTuplesItem] + + +class FgaTuplesListQuery(TypedDict): + #: Only tuples on this resource type. + resource_type: NotRequired[str] + + #: Only tuples on this resource. + resource_id: NotRequired[str] + + #: Only tuples of this relation. + relation: NotRequired[str] + + #: Only tuples naming this subject type. + subject_type: NotRequired[str] + + #: Only tuples naming this subject. + subject_id: NotRequired[str] + + #: Only tuples naming a userset with this relation. + subject_relation: NotRequired[str] + + #: Tuples per page, 1–100. Default 50. + limit: NotRequired[int] + + #: The `next_cursor` of the previous page. + after: NotRequired[str] + + +FgaTuplesListData: TypeAlias = 'list[FgaTuple]' + + +class FgaTuplesWriteBodyTuplesItemSubject(TypedDict): + """Who: one subject (`user:alice`), or a userset (`group:eng#member`).""" + + + #: The subject's type: `user`, or `group` for a userset. + type: str + + #: Your id for the subject: `alice`, `eng`. + id: str + + #: For a userset — everybody with this relation on the subject: `member` of `group:eng`. Omit for one subject. + relation: NotRequired[str | None] + + +class FgaTuplesWriteBodyTuplesItem(TypedDict): + #: The resource's type, as the schema names it: `document`. + resource_type: str + + #: Your id for the resource: `readme`. + resource_id: str + + #: The relation: `viewer`. + relation: str + + #: Who: one subject (`user:alice`), or a userset (`group:eng#member`). + subject: FgaTuplesWriteBodyTuplesItemSubject + + +class FgaTuplesWriteBody(TypedDict): + #: 1–100 tuples to write, all together or none. + tuples: list[FgaTuplesWriteBodyTuplesItem] + + +class FrontendKeysCreateBody(TypedDict): + #: What the key is for, so a person recognises it later. + name: str + + #: `test` or `live`; it is in the key's prefix. + mode: Literal["test", "live"] + + #: The exact origins (scheme, host, port) allowed to present the key, for example https://app.example.com. At least one. + origins: list[str] + + +class FrontendKeysListQuery(TypedDict): + #: Items per page, 1–100. Default 50. + limit: NotRequired[int] + + #: The `next_cursor` of the previous page. + after: NotRequired[str] FrontendKeysListData: TypeAlias = 'list[FrontendKey]' @@ -2343,6 +3164,92 @@ class PermissionsUpdateBody(TypedDict): tenant_assignable: NotRequired[bool] +class PipesConnectionsListQuery(TypedDict): + #: Only this person's connection. + user_id: NotRequired[str] + + #: Items per page, 1–100. Default 50. + limit: NotRequired[int] + + #: The `next_cursor` of the previous page. + after: NotRequired[str] + + +PipesConnectionsListData: TypeAlias = 'list[PipeConnection]' + + +class PipesCreateBodyParameters(TypedDict): + """Per-installation values some providers need. Left out: the provider's defaults.""" + + + #: Microsoft 365 only: `common` (default), `organizations`, or one directory's tenant id or domain. + tenant: NotRequired[str] + + #: Salesforce only: `login.salesforce.com` (default), `test.salesforce.com`, or your My Domain host. + domain: NotRequired[str] + + +class PipesCreateBody(TypedDict): + #: The provider's key. + provider: Literal["github", "google", "microsoft", "slack", "salesforce", "hubspot", "linear", "notion"] + + #: The OAuth client id of your app at the provider. + client_id: str + + #: Its client secret. Write-only: sealed, never returned. + client_secret: str + + #: The OAuth scopes to ask people for. Left out: the provider's defaults. + scopes: NotRequired[list[str]] + + #: Per-installation values some providers need. Left out: the provider's defaults. + parameters: NotRequired[PipesCreateBodyParameters] + + +class PipesGrantsCreateBody(TypedDict): + #: The OAuth client id of an app in this environment. + client_id: str + + +class PipesListQuery(TypedDict): + #: Items per page, 1–100. Default 50. + limit: NotRequired[int] + + #: The `next_cursor` of the previous page. + after: NotRequired[str] + + +PipesListData: TypeAlias = 'list[Pipe]' + + +class PipesUpdateBodyParameters(TypedDict): + """Per-installation values some providers need. Left out: the provider's defaults.""" + + + #: Microsoft 365 only: `common` (default), `organizations`, or one directory's tenant id or domain. + tenant: NotRequired[str] + + #: Salesforce only: `login.salesforce.com` (default), `test.salesforce.com`, or your My Domain host. + domain: NotRequired[str] + + +class PipesUpdateBody(TypedDict): + #: A new OAuth client id. Left out, unchanged. + client_id: NotRequired[str] + + #: A new client secret, sealed. Left out, unchanged. + client_secret: NotRequired[str] + + #: The OAuth scopes to ask people for. Left out: the provider's defaults. + scopes: NotRequired[list[str]] + + #: Per-installation values some providers need. Left out: the provider's defaults. + parameters: NotRequired[PipesUpdateBodyParameters] + + #: False refuses new connections and every lease. Left out, unchanged. + enabled: NotRequired[bool] + + class ProvisioningTargetsCreateBody(TypedDict): #: The organization the target belongs to. Send this or environment_wide. organization_id: NotRequired[str | None] @@ -2404,78 +3311,218 @@ class ProvisioningTargetsStatusSetBody(TypedDict): organization_id: NotRequired[str | None] -class RolesCreateBody(TypedDict): - name: str - description: NotRequired[str | None] +RadarDecisionsListQuery = TypedDict("RadarDecisionsListQuery", {"verdict": 'NotRequired[Literal["allow", "challenge", "block"]]', "flow": 'NotRequired[Literal["sign_in", "sign_up"]]', "rule": 'NotRequired[str]', "country": 'NotRequired[str]', "email": 'NotRequired[str]', "ip": 'NotRequired[str]', "device": 'NotRequired[str]', "enforced": 'NotRequired[bool]', "from": 'NotRequired[str]', "to": 'NotRequired[str]', "limit": 'NotRequired[int]', "after": 'NotRequired[str]'}) - #: The organization it belongs to; null defines it for every organization in the environment. - organization_id: NotRequired[str | None] - #: The app whose tokens it is stamped into; null for every app. - client_id: NotRequired[str | None] +RadarDecisionsListData: TypeAlias = 'list[RadarDecision]' - #: Permission ids to grant it from the start. - permissions: NotRequired[list[str]] +class RadarListsAddBody(TypedDict): + #: `allow` or `deny`. + list: Literal["allow", "deny"] -class RolesGetQuery(TypedDict): - #: The app whose manifest `key` `id` is. - client_id: NotRequired[str] + #: `ip` (an address or CIDR range), `email`, `email_domain` or `device`. + kind: Literal["ip", "email", "email_domain", "device"] + #: The IP, range, address, domain or device id. + value: str -class RolesListQuery(TypedDict): - client_id: NotRequired[str] - organization_id: NotRequired[str] + #: Why it is listed. + note: NotRequired[str | None] + #: When the entry stops applying (ISO 8601). Never, when omitted. + expires_at: NotRequired[str | None] -RolesListData: TypeAlias = 'list[Role]' +class RadarListsListQuery(TypedDict): + #: `allow` or `deny`; both when omitted. + list: NotRequired[Literal["allow", "deny"]] -class RolesUpdateBody(TypedDict): - name: NotRequired[str] - description: NotRequired[str | None] + #: Only entries of this kind. + kind: NotRequired[Literal["ip", "email", "email_domain", "device"]] + #: Items per page, 1–100. Default 50. + limit: NotRequired[int] -class SamlAppsCreateBodyAttributeMappingsItem(TypedDict): - #: The attribute name the assertion emits: `email`, `displayName`. - attribute: str + #: The `next_cursor` of the previous page. + after: NotRequired[str] - #: The person's field it is read from: `email`, `name`. - field: str +RadarListsListData: TypeAlias = 'list[RadarListEntry]' -class SamlAppsCreateBody(TypedDict): - #: The application's SAML EntityID, unique in this environment. - entity_id: str - #: Where assertions are posted: https (http only on localhost), absolute, no fragment. - acs_url: str +class RadarModeSetBody(TypedDict): + #: `monitor` or `enforce`. + mode: Literal["monitor", "enforce"] - #: The NameID format URN. Default emailAddress. - name_id_format: NotRequired[Literal["urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent", "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified"]] - #: The person's field the NameID is read from. Default `email`. - name_id_attribute: NotRequired[str] +class RadarRulesCreateBodyConditionsItem(TypedDict): + #: The fact to test. + field: Literal["ip", "country", "asn", "as_organization", "is_hosting", "is_vpn", "is_proxy", "is_tor", "email", "email_domain", "disposable_email", "user_agent", "method", "new_device", "impossible_travel", "travel_kmh", "risk_score", "ip_attempts_1m", "ip_attempts_1h", "ip_distinct_emails_10m", "ip_failures_1h", "email_attempts_1h", "email_failures_1h", "device_attempts_1h"] - #: The attributes the assertion carries. Sent, it is the COMPLETE list afterwards. - attribute_mappings: NotRequired[list[SamlAppsCreateBodyAttributeMappingsItem]] + #: How to compare it. Which operators a field takes depends on its type. + operator: Literal["eq", "neq", "in", "not_in", "gt", "gte", "lt", "lte", "contains", "not_contains", "starts_with", "ends_with", "in_cidr", "not_in_cidr"] - #: Verify the application's signature on every AuthnRequest. Needs a certificate. - want_authn_requests_signed: NotRequired[bool] + #: The one value to compare with, as text — `DK`, `20`, `true`. For every operator except the list ones. + value: NotRequired[str] - #: The application's signing certificate (PEM). Write-only; left out or null keeps the one on file. - certificate: NotRequired[str | None] + #: The values to compare with, for `in`, `not_in`, `in_cidr` and `not_in_cidr`. + values: NotRequired[list[str]] - #: The organization that owns the application: only its active members are signed in to it, and the assertion names it. Null makes it environment-wide — every person in the environment may sign in to it. - organization_id: NotRequired[str | None] +class RadarRulesCreateBody(TypedDict): + #: What the rule is for, as the console lists it. + name: str -class SamlAppsListQuery(TypedDict): - #: Items per page, 1–100. Default 50. - limit: NotRequired[int] + #: Why the rule exists, for whoever reads it next. + description: NotRequired[str | None] - #: The `next_cursor` of the previous page. - after: NotRequired[str] + #: What happens when every condition holds: `allow` (skip every rule after this one), `challenge` (a second factor on sign-in; a CAPTCHA or an emailed code on sign-up) or `block`. + action: Literal["allow", "challenge", "block"] + + #: `all` (the default), `sign_in` or `sign_up`. + applies_to: NotRequired[Literal["all", "sign_in", "sign_up"]] + + #: Up to 10 conditions; the rule matches when EVERY one holds. A fact that is unknown (no IP intelligence, no address on a passkey sign-in) matches no condition. + conditions: list[RadarRulesCreateBodyConditionsItem] + + #: Whether the rule is evaluated. Default true. + enabled: NotRequired[bool] + + #: Where in the order to put it: 1 is evaluated first. Default: last. + position: NotRequired[int] + + +class RadarRulesListQuery(TypedDict): + #: Rules per page, 1–100. Default 100 — an environment holds at most 100, so one page is all of them. + limit: NotRequired[int] + + #: The `next_cursor` of the previous page. + after: NotRequired[str] + + +RadarRulesListData: TypeAlias = 'list[RadarRule]' + + +class RadarRulesReorderBody(TypedDict): + #: Every rule id, in the new order. + rule_ids: list[str] + + +class RadarRulesUpdateBodyConditionsItem(TypedDict): + #: The fact to test. + field: Literal["ip", "country", "asn", "as_organization", "is_hosting", "is_vpn", "is_proxy", "is_tor", "email", "email_domain", "disposable_email", "user_agent", "method", "new_device", "impossible_travel", "travel_kmh", "risk_score", "ip_attempts_1m", "ip_attempts_1h", "ip_distinct_emails_10m", "ip_failures_1h", "email_attempts_1h", "email_failures_1h", "device_attempts_1h"] + + #: How to compare it. Which operators a field takes depends on its type. + operator: Literal["eq", "neq", "in", "not_in", "gt", "gte", "lt", "lte", "contains", "not_contains", "starts_with", "ends_with", "in_cidr", "not_in_cidr"] + + #: The one value to compare with, as text — `DK`, `20`, `true`. For every operator except the list ones. + value: NotRequired[str] + + #: The values to compare with, for `in`, `not_in`, `in_cidr` and `not_in_cidr`. + values: NotRequired[list[str]] + + +class RadarRulesUpdateBody(TypedDict): + #: What the rule is for, as the console lists it. + name: NotRequired[str] + + #: Why the rule exists, for whoever reads it next. + description: NotRequired[str | None] + + #: What happens when every condition holds: `allow` (skip every rule after this one), `challenge` (a second factor on sign-in; a CAPTCHA or an emailed code on sign-up) or `block`. + action: NotRequired[Literal["allow", "challenge", "block"]] + + #: `all` (the default), `sign_in` or `sign_up`. + applies_to: NotRequired[Literal["all", "sign_in", "sign_up"]] + + #: Up to 10 conditions; the rule matches when EVERY one holds. A fact that is unknown (no IP intelligence, no address on a passkey sign-in) matches no condition. + conditions: NotRequired[list[RadarRulesUpdateBodyConditionsItem]] + + #: Whether the rule is evaluated. Default true. + enabled: NotRequired[bool] + + #: Where in the order to put it: 1 is evaluated first. Default: last. + position: NotRequired[int] + + +class RadarSettingsUpdateBody(TypedDict): + #: Rule key => `{"enabled": bool, "action": "allow"|"challenge"|"block", "threshold": int}`, each part optional. Keys: credential_stuffing, bot_velocity, account_attack, impossible_travel, new_device, anonymous_network, hosting_network, disposable_email, risk_score_reject, risk_score_elevated. + builtin_rules: dict[str, Any] + + +class RolesCreateBody(TypedDict): + name: str + description: NotRequired[str | None] + + #: The organization it belongs to; null defines it for every organization in the environment. + organization_id: NotRequired[str | None] + + #: The app whose tokens it is stamped into; null for every app. + client_id: NotRequired[str | None] + + #: Permission ids to grant it from the start. + permissions: NotRequired[list[str]] + + +class RolesGetQuery(TypedDict): + #: The app whose manifest `key` `id` is. + client_id: NotRequired[str] + + +class RolesListQuery(TypedDict): + client_id: NotRequired[str] + organization_id: NotRequired[str] + + +RolesListData: TypeAlias = 'list[Role]' + + +class RolesUpdateBody(TypedDict): + name: NotRequired[str] + description: NotRequired[str | None] + + +class SamlAppsCreateBodyAttributeMappingsItem(TypedDict): + #: The attribute name the assertion emits: `email`, `displayName`. + attribute: str + + #: The person's field it is read from: `email`, `name`. + field: str + + +class SamlAppsCreateBody(TypedDict): + #: The application's SAML EntityID, unique in this environment. + entity_id: str + + #: Where assertions are posted: https (http only on localhost), absolute, no fragment. + acs_url: str + + #: The NameID format URN. Default emailAddress. + name_id_format: NotRequired[Literal["urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent", "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified"]] + + #: The person's field the NameID is read from. Default `email`. + name_id_attribute: NotRequired[str] + + #: The attributes the assertion carries. Sent, it is the COMPLETE list afterwards. + attribute_mappings: NotRequired[list[SamlAppsCreateBodyAttributeMappingsItem]] + + #: Verify the application's signature on every AuthnRequest. Needs a certificate. + want_authn_requests_signed: NotRequired[bool] + + #: The application's signing certificate (PEM). Write-only; left out or null keeps the one on file. + certificate: NotRequired[str | None] + + #: The organization that owns the application: only its active members are signed in to it, and the assertion names it. Null makes it environment-wide — every person in the environment may sign in to it. + organization_id: NotRequired[str | None] + + +class SamlAppsListQuery(TypedDict): + #: Items per page, 1–100. Default 50. + limit: NotRequired[int] + + #: The `next_cursor` of the previous page. + after: NotRequired[str] SamlAppsListData: TypeAlias = 'list[SamlApp]' @@ -2551,6 +3598,17 @@ class SigninSelfServiceSignupSetBody(TypedDict): enabled: bool +class SigninSmsUpdateBody(TypedDict): + #: Accept text messages as a second factor in this environment. Off by default: SMS is the weakest factor offered. + enabled: NotRequired[bool] + + #: ISO 3166-1 alpha-2 countries whose numbers may enrol and be texted. Required to be non-empty while SMS is on: which countries you text is your toll-fraud exposure. + allowed_countries: NotRequired[list[Literal["AC", "AD", "AE", "AF", "AG", "AI", "AL", "AM", "AO", "AR", "AS", "AT", "AU", "AW", "AZ", "BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BM", "BN", "BO", "BR", "BS", "BT", "BW", "BY", "BZ", "CA", "CD", "CF", "CG", "CH", "CI", "CK", "CL", "CM", "CN", "CO", "CR", "CU", "CV", "CW", "CY", "CZ", "DE", "DJ", "DK", "DM", "DO", "DZ", "EC", "EE", "EG", "ER", "ES", "ET", "FI", "FJ", "FK", "FM", "FO", "FR", "GA", "GB", "GD", "GE", "GF", "GH", "GI", "GL", "GM", "GN", "GP", "GQ", "GR", "GT", "GU", "GW", "GY", "HK", "HN", "HR", "HT", "HU", "ID", "IE", "IL", "IN", "IO", "IQ", "IR", "IS", "IT", "JM", "JO", "JP", "KE", "KG", "KH", "KI", "KM", "KN", "KP", "KR", "KW", "KY", "KZ", "LA", "LB", "LC", "LI", "LK", "LR", "LS", "LT", "LU", "LV", "LY", "MA", "MC", "MD", "ME", "MG", "MH", "MK", "ML", "MM", "MN", "MO", "MP", "MQ", "MR", "MS", "MT", "MU", "MV", "MW", "MX", "MY", "MZ", "NA", "NC", "NE", "NF", "NG", "NI", "NL", "NO", "NP", "NR", "NU", "NZ", "OM", "PA", "PE", "PF", "PG", "PH", "PK", "PL", "PM", "PR", "PS", "PT", "PW", "PY", "QA", "RE", "RO", "RS", "RU", "RW", "SA", "SB", "SC", "SD", "SE", "SG", "SH", "SI", "SK", "SL", "SM", "SN", "SO", "SR", "SS", "ST", "SV", "SX", "SY", "SZ", "TC", "TD", "TG", "TH", "TJ", "TK", "TL", "TM", "TN", "TO", "TR", "TT", "TV", "TW", "TZ", "UA", "UG", "US", "UY", "UZ", "VC", "VE", "VG", "VI", "VN", "VU", "WF", "WS", "XK", "YE", "ZA", "ZM", "ZW"]]] + + #: Administrators may add SMS only beside an authenticator app or a passkey, and are asked to enrol one if SMS is all they hold. On by default. + privileged_need_stronger_factor: NotRequired[bool] + + class SigninSocialDeleteBody(TypedDict): #: Only remove it if it is this organization's. organization_id: NotRequired[str | None] @@ -3009,7 +4067,7 @@ class WebhooksCreateBody(TypedDict): url: str #: The events it receives. - event_types: list[Literal["user.created", "user.updated", "user.deactivated", "user.login", "user.reactivated", "identity.linked", "user.erased", "organization.created", "organization.suspended", "organization.reactivated", "organization.updated", "organization.deleted", "membership.created", "membership.updated", "membership.deleted", "invitation.created", "invitation.accepted", "invitation.revoked", "role.assigned", "role.unassigned", "role.assigned_everywhere", "role.unassigned_everywhere", "api_key.created", "api_key.revoked", "support_session.started", "directory.user.provisioned", "directory.user.deprovisioned", "directory.user.deactivated", "directory.group.membership_changed", "domain.added", "domain.removed", "domain.verified", "connection.activated", "connection.certificate_expiring", "entitlement.set", "entitlement.updated", "entitlement.revoked", "vault.grant.created", "vault.grant.revoked", "vault.secret.revoked", "governance.access.revoked"]] + event_types: list[Literal["user.created", "user.updated", "user.deactivated", "user.login", "user.reactivated", "identity.linked", "user.erased", "organization.created", "organization.suspended", "organization.reactivated", "organization.updated", "organization.deleted", "membership.created", "membership.updated", "membership.deleted", "invitation.created", "invitation.accepted", "invitation.revoked", "role.assigned", "role.unassigned", "role.assigned_everywhere", "role.unassigned_everywhere", "api_key.created", "api_key.revoked", "support_session.started", "directory.user.provisioned", "directory.user.deprovisioned", "directory.user.deactivated", "directory.group.membership_changed", "domain.added", "domain.removed", "domain.verified", "connection.activated", "connection.certificate_expiring", "entitlement.set", "entitlement.updated", "entitlement.revoked", "vault.grant.created", "vault.grant.revoked", "vault.secret.revoked", "pipe.connection.connected", "pipe.connection.needs_reauth", "pipe.connection.disconnected", "governance.access.revoked", "feature_flag.created", "feature_flag.updated", "feature_flag.deleted"]] #: How deliveries are signed: `cbox` (X-Cbox-Signature, the default) or `standard_webhooks` (webhook-id / webhook-timestamp / webhook-signature, verifiable with any Standard Webhooks library; the secret is a `whsec_` secret). signature_scheme: NotRequired[Literal["cbox", "standard_webhooks"]] @@ -3104,12 +4162,16 @@ class WebhooksUpdateBody(TypedDict): "branding.whitelabel.set": OperationSpec(action="branding.whitelabel.set", operation_id="branding_whitelabel_set", method="PUT", path="/branding/whitelabel", path_params=(), scope="branding:write", danger="write", approval=True, body=True, pagination=None), "directories.connect": OperationSpec(action="directories.connect", operation_id="directories_connect", method="POST", path="/directories/connect", path_params=(), scope="directory_sync:write", danger="critical", approval=True, body=True, pagination=None), "directories.create": OperationSpec(action="directories.create", operation_id="directories_create", method="POST", path="/directories", path_params=(), scope="directory_sync:write", danger="critical", approval=True, body=True, pagination=None), + "directories.credentials.replace": OperationSpec(action="directories.credentials.replace", operation_id="directories_credentials_replace", method="PUT", path="/directories/{id}/credentials", path_params=("id",), scope="directory_sync:write", danger="critical", approval=True, body=True, pagination=None), "directories.delete": OperationSpec(action="directories.delete", operation_id="directories_delete", method="DELETE", path="/directories/{id}", path_params=("id",), scope="directory_sync:write", danger="destructive", approval=True, body=True, pagination=None), "directories.get": OperationSpec(action="directories.get", operation_id="directories_get", method="GET", path="/directories/{id}", path_params=("id",), scope="directory_sync:read", danger="read", approval=True, body=False, pagination=None), "directories.groups.list": OperationSpec(action="directories.groups.list", operation_id="directories_groups_list", method="GET", path="/directories/{id}/groups", path_params=("id",), scope="directory_sync:read", danger="read", approval=True, body=False, pagination="cursor"), "directories.groups.map": OperationSpec(action="directories.groups.map", operation_id="directories_groups_map", method="POST", path="/directories/{id}/group-roles", path_params=("id",), scope="directory_sync:write", danger="write", approval=True, body=True, pagination=None), + "directories.hris.connect": OperationSpec(action="directories.hris.connect", operation_id="directories_hris_connect", method="POST", path="/directories/hris", path_params=(), scope="directory_sync:write", danger="critical", approval=True, body=True, pagination=None), "directories.list": OperationSpec(action="directories.list", operation_id="directories_list", method="GET", path="/directories", path_params=(), scope="directory_sync:read", danger="read", approval=True, body=False, pagination="cursor"), "directories.status.set": OperationSpec(action="directories.status.set", operation_id="directories_status_set", method="POST", path="/directories/{id}/status", path_params=("id",), scope="directory_sync:write", danger="write", approval=True, body=True, pagination=None), + "directories.sync": OperationSpec(action="directories.sync", operation_id="directories_sync", method="POST", path="/directories/{id}/sync", path_params=("id",), scope="directory_sync:write", danger="write", approval=True, body=True, pagination=None), + "directories.sync_settings.update": OperationSpec(action="directories.sync_settings.update", operation_id="directories_sync_settings_update", method="PATCH", path="/directories/{id}/sync-settings", path_params=("id",), scope="directory_sync:write", danger="write", approval=True, body=True, pagination=None), "directories.token.rotate": OperationSpec(action="directories.token.rotate", operation_id="directories_token_rotate", method="POST", path="/directories/{id}/rotate", path_params=("id",), scope="directory_sync:write", danger="critical", approval=True, body=True, pagination=None), "directories.update": OperationSpec(action="directories.update", operation_id="directories_update", method="PATCH", path="/directories/{id}", path_params=("id",), scope="directory_sync:write", danger="write", approval=True, body=True, pagination=None), "domains.add": OperationSpec(action="domains.add", operation_id="domains_add", method="POST", path="/domains", path_params=(), scope="domains:write", danger="write", approval=True, body=True, pagination=None), @@ -3117,6 +4179,22 @@ class WebhooksUpdateBody(TypedDict): "domains.remove": OperationSpec(action="domains.remove", operation_id="domains_remove", method="DELETE", path="/domains", path_params=(), scope="domains:write", danger="destructive", approval=True, body=False, pagination=None), "domains.verify": OperationSpec(action="domains.verify", operation_id="domains_verify", method="POST", path="/domains/verify", path_params=(), scope="domains:write", danger="write", approval=True, body=False, pagination=None), "events.list": OperationSpec(action="events.list", operation_id="events_list", method="GET", path="/events", path_params=(), scope="events:read", danger="read", approval=True, body=False, pagination="cursor"), + "feature_flags.create": OperationSpec(action="feature_flags.create", operation_id="feature_flags_create", method="POST", path="/feature-flags", path_params=(), scope="feature_flags:write", danger="write", approval=True, body=True, pagination=None), + "feature_flags.delete": OperationSpec(action="feature_flags.delete", operation_id="feature_flags_delete", method="DELETE", path="/feature-flags/{id}", path_params=("id",), scope="feature_flags:write", danger="destructive", approval=True, body=False, pagination=None), + "feature_flags.evaluate": OperationSpec(action="feature_flags.evaluate", operation_id="feature_flags_evaluate", method="GET", path="/feature-flags/evaluate", path_params=(), scope="feature_flags:read", danger="read", approval=True, body=False, pagination=None), + "feature_flags.get": OperationSpec(action="feature_flags.get", operation_id="feature_flags_get", method="GET", path="/feature-flags/{id}", path_params=("id",), scope="feature_flags:read", danger="read", approval=True, body=False, pagination=None), + "feature_flags.list": OperationSpec(action="feature_flags.list", operation_id="feature_flags_list", method="GET", path="/feature-flags", path_params=(), scope="feature_flags:read", danger="read", approval=True, body=False, pagination="cursor"), + "feature_flags.update": OperationSpec(action="feature_flags.update", operation_id="feature_flags_update", method="PATCH", path="/feature-flags/{id}", path_params=("id",), scope="feature_flags:write", danger="write", approval=True, body=True, pagination=None), + "fga.check": OperationSpec(action="fga.check", operation_id="fga_check", method="GET", path="/fga/check", path_params=(), scope="fga:read", danger="read", approval=True, body=False, pagination=None), + "fga.check.batch": OperationSpec(action="fga.check.batch", operation_id="fga_check_batch", method="GET", path="/fga/check/batch", path_params=(), scope="fga:read", danger="read", approval=True, body=False, pagination=None), + "fga.resources.list": OperationSpec(action="fga.resources.list", operation_id="fga_resources_list", method="GET", path="/fga/resources", path_params=(), scope="fga:read", danger="read", approval=True, body=False, pagination="cursor"), + "fga.schema.get": OperationSpec(action="fga.schema.get", operation_id="fga_schema_get", method="GET", path="/fga/schema", path_params=(), scope="fga:read", danger="read", approval=True, body=False, pagination=None), + "fga.schema.update": OperationSpec(action="fga.schema.update", operation_id="fga_schema_update", method="PUT", path="/fga/schema", path_params=(), scope="fga:schema", danger="critical", approval=True, body=True, pagination=None), + "fga.schema.validate": OperationSpec(action="fga.schema.validate", operation_id="fga_schema_validate", method="GET", path="/fga/schema/validate", path_params=(), scope="fga:read", danger="read", approval=True, body=False, pagination=None), + "fga.subjects.list": OperationSpec(action="fga.subjects.list", operation_id="fga_subjects_list", method="GET", path="/fga/subjects", path_params=(), scope="fga:read", danger="read", approval=True, body=False, pagination="cursor"), + "fga.tuples.delete": OperationSpec(action="fga.tuples.delete", operation_id="fga_tuples_delete", method="POST", path="/fga/tuples/delete", path_params=(), scope="fga:write", danger="destructive", approval=True, body=True, pagination=None), + "fga.tuples.list": OperationSpec(action="fga.tuples.list", operation_id="fga_tuples_list", method="GET", path="/fga/tuples", path_params=(), scope="fga:read", danger="read", approval=True, body=False, pagination="cursor"), + "fga.tuples.write": OperationSpec(action="fga.tuples.write", operation_id="fga_tuples_write", method="POST", path="/fga/tuples", path_params=(), scope="fga:write", danger="write", approval=True, body=True, pagination=None), "frontend_keys.create": OperationSpec(action="frontend_keys.create", operation_id="frontend_keys_create", method="POST", path="/frontend-keys", path_params=(), scope="frontend_keys:write", danger="write", approval=True, body=True, pagination=None), "frontend_keys.list": OperationSpec(action="frontend_keys.list", operation_id="frontend_keys_list", method="GET", path="/frontend-keys", path_params=(), scope="frontend_keys:read", danger="read", approval=True, body=False, pagination="cursor"), "frontend_keys.revoke": OperationSpec(action="frontend_keys.revoke", operation_id="frontend_keys_revoke", method="DELETE", path="/frontend-keys/{id}", path_params=("id",), scope="frontend_keys:write", danger="destructive", approval=True, body=False, pagination=None), @@ -3171,11 +4249,34 @@ class WebhooksUpdateBody(TypedDict): "permissions.delete": OperationSpec(action="permissions.delete", operation_id="permissions_delete", method="DELETE", path="/permissions/{id}", path_params=("id",), scope="role_definitions:write", danger="destructive", approval=True, body=False, pagination=None), "permissions.list": OperationSpec(action="permissions.list", operation_id="permissions_list", method="GET", path="/permissions", path_params=(), scope="roles:read", danger="read", approval=True, body=False, pagination="cursor"), "permissions.update": OperationSpec(action="permissions.update", operation_id="permissions_update", method="PATCH", path="/permissions/{id}", path_params=("id",), scope="role_definitions:write", danger="write", approval=True, body=True, pagination=None), + "pipes.connections.delete": OperationSpec(action="pipes.connections.delete", operation_id="pipes_connections_delete", method="DELETE", path="/pipes/{id}/connections/{connection_id}", path_params=("id", "connection_id"), scope="pipes:write", danger="destructive", approval=True, body=False, pagination=None), + "pipes.connections.list": OperationSpec(action="pipes.connections.list", operation_id="pipes_connections_list", method="GET", path="/pipes/{id}/connections", path_params=("id",), scope="pipes:read", danger="read", approval=True, body=False, pagination="cursor"), + "pipes.create": OperationSpec(action="pipes.create", operation_id="pipes_create", method="POST", path="/pipes", path_params=(), scope="pipes:write", danger="write", approval=True, body=True, pagination=None), + "pipes.delete": OperationSpec(action="pipes.delete", operation_id="pipes_delete", method="DELETE", path="/pipes/{id}", path_params=("id",), scope="pipes:write", danger="destructive", approval=True, body=False, pagination=None), + "pipes.get": OperationSpec(action="pipes.get", operation_id="pipes_get", method="GET", path="/pipes/{id}", path_params=("id",), scope="pipes:read", danger="read", approval=True, body=False, pagination=None), + "pipes.grants.create": OperationSpec(action="pipes.grants.create", operation_id="pipes_grants_create", method="POST", path="/pipes/{id}/grants", path_params=("id",), scope="pipes:write", danger="critical", approval=True, body=True, pagination=None), + "pipes.grants.delete": OperationSpec(action="pipes.grants.delete", operation_id="pipes_grants_delete", method="DELETE", path="/pipes/{id}/grants/{client_id}", path_params=("id", "client_id"), scope="pipes:write", danger="destructive", approval=True, body=False, pagination=None), + "pipes.list": OperationSpec(action="pipes.list", operation_id="pipes_list", method="GET", path="/pipes", path_params=(), scope="pipes:read", danger="read", approval=True, body=False, pagination="cursor"), + "pipes.update": OperationSpec(action="pipes.update", operation_id="pipes_update", method="PATCH", path="/pipes/{id}", path_params=("id",), scope="pipes:write", danger="write", approval=True, body=True, pagination=None), "provisioning.targets.create": OperationSpec(action="provisioning.targets.create", operation_id="provisioning_targets_create", method="POST", path="/provisioning-targets", path_params=(), scope="provisioning:write", danger="critical", approval=True, body=True, pagination=None), "provisioning.targets.delete": OperationSpec(action="provisioning.targets.delete", operation_id="provisioning_targets_delete", method="DELETE", path="/provisioning-targets/{id}", path_params=("id",), scope="provisioning:write", danger="destructive", approval=True, body=True, pagination=None), "provisioning.targets.get": OperationSpec(action="provisioning.targets.get", operation_id="provisioning_targets_get", method="GET", path="/provisioning-targets/{id}", path_params=("id",), scope="provisioning:read", danger="read", approval=True, body=False, pagination=None), "provisioning.targets.list": OperationSpec(action="provisioning.targets.list", operation_id="provisioning_targets_list", method="GET", path="/provisioning-targets", path_params=(), scope="provisioning:read", danger="read", approval=True, body=False, pagination="cursor"), "provisioning.targets.status.set": OperationSpec(action="provisioning.targets.status.set", operation_id="provisioning_targets_status_set", method="POST", path="/provisioning-targets/{id}/status", path_params=("id",), scope="provisioning:write", danger="write", approval=True, body=True, pagination=None), + "radar.decisions.get": OperationSpec(action="radar.decisions.get", operation_id="radar_decisions_get", method="GET", path="/radar/decisions/{id}", path_params=("id",), scope="radar:read", danger="read", approval=True, body=False, pagination=None), + "radar.decisions.list": OperationSpec(action="radar.decisions.list", operation_id="radar_decisions_list", method="GET", path="/radar/decisions", path_params=(), scope="radar:read", danger="read", approval=True, body=False, pagination="cursor"), + "radar.lists.add": OperationSpec(action="radar.lists.add", operation_id="radar_lists_add", method="POST", path="/radar/lists", path_params=(), scope="radar:write", danger="write", approval=True, body=True, pagination=None), + "radar.lists.list": OperationSpec(action="radar.lists.list", operation_id="radar_lists_list", method="GET", path="/radar/lists", path_params=(), scope="radar:read", danger="read", approval=True, body=False, pagination="cursor"), + "radar.lists.remove": OperationSpec(action="radar.lists.remove", operation_id="radar_lists_remove", method="DELETE", path="/radar/lists/{id}", path_params=("id",), scope="radar:write", danger="destructive", approval=True, body=False, pagination=None), + "radar.mode.set": OperationSpec(action="radar.mode.set", operation_id="radar_mode_set", method="PUT", path="/radar/mode", path_params=(), scope="radar:manage", danger="critical", approval=True, body=True, pagination=None), + "radar.rules.create": OperationSpec(action="radar.rules.create", operation_id="radar_rules_create", method="POST", path="/radar/rules", path_params=(), scope="radar:write", danger="write", approval=True, body=True, pagination=None), + "radar.rules.delete": OperationSpec(action="radar.rules.delete", operation_id="radar_rules_delete", method="DELETE", path="/radar/rules/{id}", path_params=("id",), scope="radar:write", danger="destructive", approval=True, body=False, pagination=None), + "radar.rules.get": OperationSpec(action="radar.rules.get", operation_id="radar_rules_get", method="GET", path="/radar/rules/{id}", path_params=("id",), scope="radar:read", danger="read", approval=True, body=False, pagination=None), + "radar.rules.list": OperationSpec(action="radar.rules.list", operation_id="radar_rules_list", method="GET", path="/radar/rules", path_params=(), scope="radar:read", danger="read", approval=True, body=False, pagination="cursor"), + "radar.rules.reorder": OperationSpec(action="radar.rules.reorder", operation_id="radar_rules_reorder", method="PUT", path="/radar/rules/order", path_params=(), scope="radar:write", danger="write", approval=True, body=True, pagination=None), + "radar.rules.update": OperationSpec(action="radar.rules.update", operation_id="radar_rules_update", method="PATCH", path="/radar/rules/{id}", path_params=("id",), scope="radar:write", danger="write", approval=True, body=True, pagination=None), + "radar.settings.get": OperationSpec(action="radar.settings.get", operation_id="radar_settings_get", method="GET", path="/radar/settings", path_params=(), scope="radar:read", danger="read", approval=True, body=False, pagination=None), + "radar.settings.update": OperationSpec(action="radar.settings.update", operation_id="radar_settings_update", method="PATCH", path="/radar/settings", path_params=(), scope="radar:write", danger="write", approval=True, body=True, pagination=None), "roles.create": OperationSpec(action="roles.create", operation_id="roles_create", method="POST", path="/roles", path_params=(), scope="role_definitions:write", danger="write", approval=True, body=True, pagination=None), "roles.delete": OperationSpec(action="roles.delete", operation_id="roles_delete", method="DELETE", path="/roles/{id}", path_params=("id",), scope="role_definitions:write", danger="destructive", approval=True, body=False, pagination=None), "roles.get": OperationSpec(action="roles.get", operation_id="roles_get", method="GET", path="/roles/{id}", path_params=("id",), scope="roles:read", danger="read", approval=True, body=False, pagination=None), @@ -3192,6 +4293,8 @@ class WebhooksUpdateBody(TypedDict): "signin.policy.inherit": OperationSpec(action="signin.policy.inherit", operation_id="signin_policy_inherit", method="DELETE", path="/sign-in/policy/organizations/{organization_id}", path_params=("organization_id",), scope="signin:write", danger="critical", approval=True, body=False, pagination=None), "signin.policy.update": OperationSpec(action="signin.policy.update", operation_id="signin_policy_update", method="PATCH", path="/sign-in/policy", path_params=(), scope="signin:write", danger="critical", approval=True, body=True, pagination=None), "signin.self_service_signup.set": OperationSpec(action="signin.self_service_signup.set", operation_id="signin_self_service_signup_set", method="PUT", path="/sign-in/self-service-signup", path_params=(), scope="signin:write", danger="critical", approval=True, body=True, pagination=None), + "signin.sms.get": OperationSpec(action="signin.sms.get", operation_id="signin_sms_get", method="GET", path="/sign-in/sms", path_params=(), scope="signin:read", danger="read", approval=True, body=False, pagination=None), + "signin.sms.update": OperationSpec(action="signin.sms.update", operation_id="signin_sms_update", method="PATCH", path="/sign-in/sms", path_params=(), scope="signin:write", danger="critical", approval=True, body=True, pagination=None), "signin.social.delete": OperationSpec(action="signin.social.delete", operation_id="signin_social_delete", method="DELETE", path="/sign-in/social-providers/{id}", path_params=("id",), scope="signin:write", danger="critical", approval=True, body=True, pagination=None), "signin.social.list": OperationSpec(action="signin.social.list", operation_id="signin_social_list", method="GET", path="/sign-in/social-providers", path_params=(), scope="signin:read", danger="read", approval=True, body=False, pagination="cursor"), "signin.social.set": OperationSpec(action="signin.social.set", operation_id="signin_social_set", method="POST", path="/sign-in/social-providers", path_params=(), scope="signin:write", danger="critical", approval=True, body=True, pagination=None), @@ -3236,6 +4339,7 @@ class WebhooksUpdateBody(TypedDict): "users.get": OperationSpec(action="users.get", operation_id="users_get", method="GET", path="/users/{id}", path_params=("id",), scope="users:read", danger="read", approval=True, body=False, pagination=None), "users.list": OperationSpec(action="users.list", operation_id="users_list", method="GET", path="/users", path_params=(), scope="users:read", danger="read", approval=True, body=False, pagination="cursor"), "users.mfa.reset": OperationSpec(action="users.mfa.reset", operation_id="users_mfa_reset", method="DELETE", path="/users/{id}/mfa", path_params=("id",), scope="users:write", danger="critical", approval=True, body=False, pagination=None), + "users.mfa.sms.remove": OperationSpec(action="users.mfa.sms.remove", operation_id="users_mfa_sms_remove", method="DELETE", path="/users/{id}/mfa/sms", path_params=("id",), scope="users:write", danger="critical", approval=True, body=False, pagination=None), "users.password.set": OperationSpec(action="users.password.set", operation_id="users_password_set", method="POST", path="/users/{id}/password", path_params=("id",), scope="users:write", danger="critical", approval=True, body=True, pagination=None), "users.password_reset.send": OperationSpec(action="users.password_reset.send", operation_id="users_password_reset_send", method="POST", path="/users/{id}/password-reset", path_params=("id",), scope="users:write", danger="write", approval=True, body=False, pagination=None), "users.reactivate": OperationSpec(action="users.reactivate", operation_id="users_reactivate", method="POST", path="/users/{id}/reactivate", path_params=("id",), scope="users:write", danger="write", approval=True, body=False, pagination=None), @@ -4366,6 +5470,30 @@ def __init__(self, core: ManagementTransport) -> None: self.whitelabel = BrandingWhitelabelMethods(core) +class DirectoriesCredentialsMethods: + """``directories.credentials.*``""" + + def __init__(self, core: ManagementTransport) -> None: + self._core = core + + @overload + def replace(self, id: str, body: DirectoriesCredentialsReplaceBody, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Directory]: ... + + @overload + def replace(self, id: str, body: DirectoriesCredentialsReplaceBody, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Directory] | PendingApprovalResult[Directory]: ... + + def replace(self, id: str, body: DirectoriesCredentialsReplaceBody, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Directory] | PendingApprovalResult[Directory]: + """Replace a pull directory's provider credentials, verifying the new ones first. Write-only; never returned. + + Requires scope `directory_sync:write`. Danger: critical. + + ``PUT /directories/{id}/credentials`` · action ``directories.credentials.replace`` + Scope ``directory_sync:write`` · danger: critical. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ENVIRONMENT_OPERATIONS["directories.credentials.replace"], (id,), body, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) + + class DirectoriesGroupsMethods: """``directories.groups.*``""" @@ -4411,6 +5539,30 @@ def map(self, id: str, body: DirectoriesGroupsMapBody, *, approval: ApprovalMode return self._core.call(ENVIRONMENT_OPERATIONS["directories.groups.map"], (id,), body, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) +class DirectoriesHrisMethods: + """``directories.hris.*``""" + + def __init__(self, core: ManagementTransport) -> None: + self._core = core + + @overload + def connect(self, body: DirectoriesHrisConnectBody, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Directory]: ... + + @overload + def connect(self, body: DirectoriesHrisConnectBody, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Directory] | PendingApprovalResult[Directory]: ... + + def connect(self, body: DirectoriesHrisConnectBody, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Directory] | PendingApprovalResult[Directory]: + """Connect an HR system (Workday, BambooHR, Rippling, HiBob or Personio) to sync an organization's people from, verifying the credentials first. The first sync is queued. + + Requires scope `directory_sync:write`. Danger: critical. + + ``POST /directories/hris`` · action ``directories.hris.connect`` + Scope ``directory_sync:write`` · danger: critical. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ENVIRONMENT_OPERATIONS["directories.hris.connect"], (), body, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) + + class DirectoriesStatusMethods: """``directories.status.*``""" @@ -4435,6 +5587,30 @@ def set(self, id: str, body: DirectoriesStatusSetBody, *, approval: ApprovalMode return self._core.call(ENVIRONMENT_OPERATIONS["directories.status.set"], (id,), body, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) +class DirectoriesSyncSettingsMethods: + """``directories.sync_settings.*``""" + + def __init__(self, core: ManagementTransport) -> None: + self._core = core + + @overload + def update(self, id: str, body: DirectoriesSyncSettingsUpdateBody | None = None, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Directory]: ... + + @overload + def update(self, id: str, body: DirectoriesSyncSettingsUpdateBody | None = None, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Directory] | PendingApprovalResult[Directory]: ... + + def update(self, id: str, body: DirectoriesSyncSettingsUpdateBody | None = None, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Directory] | PendingApprovalResult[Directory]: + """Set how often a pull directory syncs, and for an HR system which of its fields pass through onto people. + + Requires scope `directory_sync:write`. Danger: write. + + ``PATCH /directories/{id}/sync-settings`` · action ``directories.sync_settings.update`` + Scope ``directory_sync:write`` · danger: write. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ENVIRONMENT_OPERATIONS["directories.sync_settings.update"], (id,), body, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) + + class DirectoriesTokenMethods: """``directories.token.*``""" @@ -4464,8 +5640,11 @@ class DirectoriesMethods: def __init__(self, core: ManagementTransport) -> None: self._core = core + self.credentials = DirectoriesCredentialsMethods(core) self.groups = DirectoriesGroupsMethods(core) + self.hris = DirectoriesHrisMethods(core) self.status = DirectoriesStatusMethods(core) + self.sync_settings = DirectoriesSyncSettingsMethods(core) self.token = DirectoriesTokenMethods(core) @overload @@ -4474,208 +5653,559 @@ def connect(self, body: DirectoriesConnectBody, *, approval: Literal["wait"] = " @overload def connect(self, body: DirectoriesConnectBody, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Directory] | PendingApprovalResult[Directory]: ... - def connect(self, body: DirectoriesConnectBody, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Directory] | PendingApprovalResult[Directory]: - """Connect a Google Workspace or Microsoft Entra directory to sync an organization's people from, verifying the credentials first. Runs the first sync. + def connect(self, body: DirectoriesConnectBody, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Directory] | PendingApprovalResult[Directory]: + """Connect a Google Workspace or Microsoft Entra directory to sync an organization's people from, verifying the credentials first. Runs the first sync. + + Requires scope `directory_sync:write`. Danger: critical. + + ``POST /directories/connect`` · action ``directories.connect`` + Scope ``directory_sync:write`` · danger: critical. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ENVIRONMENT_OPERATIONS["directories.connect"], (), body, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) + + @overload + def create(self, body: DirectoriesCreateBody, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Directory]: ... + + @overload + def create(self, body: DirectoriesCreateBody, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Directory] | PendingApprovalResult[Directory]: ... + + def create(self, body: DirectoriesCreateBody, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Directory] | PendingApprovalResult[Directory]: + """Register a SCIM directory for an organization. Answers the SCIM base URL and a bearer token, shown once. + + Requires scope `directory_sync:write`. Danger: critical. + + ``POST /directories`` · action ``directories.create`` + Scope ``directory_sync:write`` · danger: critical. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ENVIRONMENT_OPERATIONS["directories.create"], (), body, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) + + @overload + def delete(self, id: str, body: DirectoriesDeleteBody | None = None, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[None]: ... + + @overload + def delete(self, id: str, body: DirectoriesDeleteBody | None = None, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[None] | PendingApprovalResult[None]: ... + + def delete(self, id: str, body: DirectoriesDeleteBody | None = None, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[None] | PendingApprovalResult[None]: + """Delete an inbound directory. Its token stops working; the people it provisioned keep their accounts. + + Requires scope `directory_sync:write`. Danger: destructive. + + ``DELETE /directories/{id}`` · action ``directories.delete`` + Scope ``directory_sync:write`` · danger: destructive. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ENVIRONMENT_OPERATIONS["directories.delete"], (id,), body, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) + + @overload + def get(self, id: str, query: DirectoriesGetQuery | None = None, *, approval: Literal["wait"] = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Directory]: ... + + @overload + def get(self, id: str, query: DirectoriesGetQuery | None = None, *, approval: Literal["return"], approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Directory] | PendingApprovalResult[Directory]: ... + + def get(self, id: str, query: DirectoriesGetQuery | None = None, *, approval: ApprovalMode = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Directory] | PendingApprovalResult[Directory]: + """Read one inbound directory: its provider, status, SCIM base URL and last sync error. Never its token or credentials. + + Requires scope `directory_sync:read`. Danger: read. + + ``GET /directories/{id}`` · action ``directories.get`` + Scope ``directory_sync:read`` · danger: read. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ENVIRONMENT_OPERATIONS["directories.get"], (id,), query, approval=approval, approval_id=approval_id, headers=headers) + + @overload + def list(self, query: DirectoriesListQuery | None = None, *, approval: Literal["wait"] = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[DirectoriesListData]: ... + + @overload + def list(self, query: DirectoriesListQuery | None = None, *, approval: Literal["return"], approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[DirectoriesListData] | PendingApprovalResult[DirectoriesListData]: ... + + def list(self, query: DirectoriesListQuery | None = None, *, approval: ApprovalMode = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[DirectoriesListData] | PendingApprovalResult[DirectoriesListData]: + """List the directories (SCIM, Google Workspace, Microsoft Entra) syncing people in, optionally for one organization, with their last sync error. + + Requires scope `directory_sync:read`. Danger: read. + + ``GET /directories`` · action ``directories.list`` + Scope ``directory_sync:read`` · danger: read. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ENVIRONMENT_OPERATIONS["directories.list"], (), query, approval=approval, approval_id=approval_id, headers=headers) + + def list_all(self, query: DirectoriesListQuery | None = None, *, headers: Mapping[str, str] | None = None) -> Iterator[Directory]: + """Every item of ``directories.list``, fetching pages as the iteration reaches them.""" + return self._core.paginate(ENVIRONMENT_OPERATIONS["directories.list"], (), query, headers=headers) + + @overload + def sync(self, id: str, body: DirectoriesSyncBody | None = None, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[None]: ... + + @overload + def sync(self, id: str, body: DirectoriesSyncBody | None = None, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[None] | PendingApprovalResult[None]: ... + + def sync(self, id: str, body: DirectoriesSyncBody | None = None, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[None] | PendingApprovalResult[None]: + """Pull a Google Workspace, Microsoft Entra or HR-system directory now, on a worker. `full` asks an HR system for everybody rather than what changed. + + Requires scope `directory_sync:write`. Danger: write. + + ``POST /directories/{id}/sync`` · action ``directories.sync`` + Scope ``directory_sync:write`` · danger: write. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ENVIRONMENT_OPERATIONS["directories.sync"], (id,), body, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) + + @overload + def update(self, id: str, body: DirectoriesUpdateBody, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Directory]: ... + + @overload + def update(self, id: str, body: DirectoriesUpdateBody, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Directory] | PendingApprovalResult[Directory]: ... + + def update(self, id: str, body: DirectoriesUpdateBody, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Directory] | PendingApprovalResult[Directory]: + """Rename an inbound directory. + + Requires scope `directory_sync:write`. Danger: write. + + ``PATCH /directories/{id}`` · action ``directories.update`` + Scope ``directory_sync:write`` · danger: write. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ENVIRONMENT_OPERATIONS["directories.update"], (id,), body, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) + + +class DomainsMethods: + """``domains.*``""" + + def __init__(self, core: ManagementTransport) -> None: + self._core = core + + @overload + def add(self, body: DomainsAddBody, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[CustomDomain]: ... + + @overload + def add(self, body: DomainsAddBody, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[CustomDomain] | PendingApprovalResult[CustomDomain]: ... + + def add(self, body: DomainsAddBody, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[CustomDomain] | PendingApprovalResult[CustomDomain]: + """Start serving this environment on a custom domain: returns the DNS TXT record that proves you control it. + + Requires scope `domains:write`. Danger: write. + + ``POST /domains`` · action ``domains.add`` + Scope ``domains:write`` · danger: write. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ENVIRONMENT_OPERATIONS["domains.add"], (), body, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) + + @overload + def get(self, *, approval: Literal["wait"] = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[CustomDomain]: ... + + @overload + def get(self, *, approval: Literal["return"], approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[CustomDomain] | PendingApprovalResult[CustomDomain]: ... + + def get(self, *, approval: ApprovalMode = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[CustomDomain] | PendingApprovalResult[CustomDomain]: + """Read this environment's custom domain, and the DNS TXT record that proves a pending one. + + Requires scope `domains:read`. Danger: read. + + ``GET /domains`` · action ``domains.get`` + Scope ``domains:read`` · danger: read. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ENVIRONMENT_OPERATIONS["domains.get"], (), None, approval=approval, approval_id=approval_id, headers=headers) + + @overload + def remove(self, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[None]: ... + + @overload + def remove(self, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[None] | PendingApprovalResult[None]: ... + + def remove(self, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[None] | PendingApprovalResult[None]: + """Stop serving this environment on its custom domain. Anything using that domain stops reaching the environment. + + Requires scope `domains:write`. Danger: destructive. + + ``DELETE /domains`` · action ``domains.remove`` + Scope ``domains:write`` · danger: destructive. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ENVIRONMENT_OPERATIONS["domains.remove"], (), None, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) + + @overload + def verify(self, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[CustomDomain]: ... + + @overload + def verify(self, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[CustomDomain] | PendingApprovalResult[CustomDomain]: ... + + def verify(self, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[CustomDomain] | PendingApprovalResult[CustomDomain]: + """Look for the pending domain's DNS TXT record and, once it is visible, serve this environment on that domain. + + Requires scope `domains:write`. Danger: write. + + ``POST /domains/verify`` · action ``domains.verify`` + Scope ``domains:write`` · danger: write. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ENVIRONMENT_OPERATIONS["domains.verify"], (), None, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) + + +class EventsMethods: + """``events.*``""" + + def __init__(self, core: ManagementTransport) -> None: + self._core = core + + @overload + def list(self, query: EventsListQuery | None = None, *, approval: Literal["wait"] = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[EventsListData]: ... + + @overload + def list(self, query: EventsListQuery | None = None, *, approval: Literal["return"], approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[EventsListData] | PendingApprovalResult[EventsListData]: ... + + def list(self, query: EventsListQuery | None = None, *, approval: ApprovalMode = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[EventsListData] | PendingApprovalResult[EventsListData]: + """Read this environment's domain events (the facts webhooks deliver) oldest first; pass the last id as `after` to poll for new ones. + + Requires scope `events:read`. Danger: read. + + ``GET /events`` · action ``events.list`` + Scope ``events:read`` · danger: read. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ENVIRONMENT_OPERATIONS["events.list"], (), query, approval=approval, approval_id=approval_id, headers=headers) + + def list_all(self, query: EventsListQuery | None = None, *, headers: Mapping[str, str] | None = None) -> Iterator[DomainEvent]: + """Every item of ``events.list``, fetching pages as the iteration reaches them.""" + return self._core.paginate(ENVIRONMENT_OPERATIONS["events.list"], (), query, headers=headers) + + +class FeatureFlagsMethods: + """``feature_flags.*``""" + + def __init__(self, core: ManagementTransport) -> None: + self._core = core + + @overload + def create(self, body: FeatureFlagsCreateBody, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[FeatureFlag]: ... + + @overload + def create(self, body: FeatureFlagsCreateBody, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[FeatureFlag] | PendingApprovalResult[FeatureFlag]: ... + + def create(self, body: FeatureFlagsCreateBody, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[FeatureFlag] | PendingApprovalResult[FeatureFlag]: + """Define a feature flag: a key apps ask about, its default, and who it is on for — named users, named organizations, a rollout percentage. + + Requires scope `feature_flags:write`. Danger: write. + + ``POST /feature-flags`` · action ``feature_flags.create`` + Scope ``feature_flags:write`` · danger: write. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ENVIRONMENT_OPERATIONS["feature_flags.create"], (), body, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) + + @overload + def delete(self, id: str, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[None]: ... + + @overload + def delete(self, id: str, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[None] | PendingApprovalResult[None]: ... + + def delete(self, id: str, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[None] | PendingApprovalResult[None]: + """Delete a feature flag and its rules. Its key evaluates to off from now on; tokens already minted keep it until they expire. + + Requires scope `feature_flags:write`. Danger: destructive. + + ``DELETE /feature-flags/{id}`` · action ``feature_flags.delete`` + Scope ``feature_flags:write`` · danger: destructive. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ENVIRONMENT_OPERATIONS["feature_flags.delete"], (id,), None, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) + + @overload + def evaluate(self, query: FeatureFlagsEvaluateQuery | None = None, *, approval: Literal["wait"] = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[FeatureFlagEvaluation]: ... + + @overload + def evaluate(self, query: FeatureFlagsEvaluateQuery | None = None, *, approval: Literal["return"], approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[FeatureFlagEvaluation] | PendingApprovalResult[FeatureFlagEvaluation]: ... + + def evaluate(self, query: FeatureFlagsEvaluateQuery | None = None, *, approval: ApprovalMode = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[FeatureFlagEvaluation] | PendingApprovalResult[FeatureFlagEvaluation]: + """Evaluate every feature flag for a user in an organization: whether each is on, and the rule that decided. For app backends without a token in hand. + + Requires scope `feature_flags:read`. Danger: read. + + ``GET /feature-flags/evaluate`` · action ``feature_flags.evaluate`` + Scope ``feature_flags:read`` · danger: read. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ENVIRONMENT_OPERATIONS["feature_flags.evaluate"], (), query, approval=approval, approval_id=approval_id, headers=headers) + + @overload + def get(self, id: str, *, approval: Literal["wait"] = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[FeatureFlag]: ... + + @overload + def get(self, id: str, *, approval: Literal["return"], approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[FeatureFlag] | PendingApprovalResult[FeatureFlag]: ... + + def get(self, id: str, *, approval: ApprovalMode = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[FeatureFlag] | PendingApprovalResult[FeatureFlag]: + """Read one feature flag: its default, whether it is switched on, and every user, organization and rollout rule. + + Requires scope `feature_flags:read`. Danger: read. + + ``GET /feature-flags/{id}`` · action ``feature_flags.get`` + Scope ``feature_flags:read`` · danger: read. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ENVIRONMENT_OPERATIONS["feature_flags.get"], (id,), None, approval=approval, approval_id=approval_id, headers=headers) + + @overload + def list(self, query: FeatureFlagsListQuery | None = None, *, approval: Literal["wait"] = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[FeatureFlagsListData]: ... + + @overload + def list(self, query: FeatureFlagsListQuery | None = None, *, approval: Literal["return"], approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[FeatureFlagsListData] | PendingApprovalResult[FeatureFlagsListData]: ... + + def list(self, query: FeatureFlagsListQuery | None = None, *, approval: ApprovalMode = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[FeatureFlagsListData] | PendingApprovalResult[FeatureFlagsListData]: + """List this environment's feature flags with their default, kill switch and targeting rules. - Requires scope `directory_sync:write`. Danger: critical. + Requires scope `feature_flags:read`. Danger: read. - ``POST /directories/connect`` · action ``directories.connect`` - Scope ``directory_sync:write`` · danger: critical. + ``GET /feature-flags`` · action ``feature_flags.list`` + Scope ``feature_flags:read`` · danger: read. May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. """ - return self._core.call(ENVIRONMENT_OPERATIONS["directories.connect"], (), body, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) + return self._core.call(ENVIRONMENT_OPERATIONS["feature_flags.list"], (), query, approval=approval, approval_id=approval_id, headers=headers) + + def list_all(self, query: FeatureFlagsListQuery | None = None, *, headers: Mapping[str, str] | None = None) -> Iterator[FeatureFlag]: + """Every item of ``feature_flags.list``, fetching pages as the iteration reaches them.""" + return self._core.paginate(ENVIRONMENT_OPERATIONS["feature_flags.list"], (), query, headers=headers) @overload - def create(self, body: DirectoriesCreateBody, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Directory]: ... + def update(self, id: str, body: FeatureFlagsUpdateBody | None = None, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[FeatureFlag]: ... @overload - def create(self, body: DirectoriesCreateBody, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Directory] | PendingApprovalResult[Directory]: ... + def update(self, id: str, body: FeatureFlagsUpdateBody | None = None, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[FeatureFlag] | PendingApprovalResult[FeatureFlag]: ... - def create(self, body: DirectoriesCreateBody, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Directory] | PendingApprovalResult[Directory]: - """Register a SCIM directory for an organization. Answers the SCIM base URL and a bearer token, shown once. + def update(self, id: str, body: FeatureFlagsUpdateBody | None = None, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[FeatureFlag] | PendingApprovalResult[FeatureFlag]: + """Change a feature flag: switch it on or off for everyone, change its default, or replace its user rules, organization rules or rollout percentage. - Requires scope `directory_sync:write`. Danger: critical. + Requires scope `feature_flags:write`. Danger: write. - ``POST /directories`` · action ``directories.create`` - Scope ``directory_sync:write`` · danger: critical. + ``PATCH /feature-flags/{id}`` · action ``feature_flags.update`` + Scope ``feature_flags:write`` · danger: write. May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. """ - return self._core.call(ENVIRONMENT_OPERATIONS["directories.create"], (), body, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) + return self._core.call(ENVIRONMENT_OPERATIONS["feature_flags.update"], (id,), body, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) + + +class FgaResourcesMethods: + """``fga.resources.*``""" + + def __init__(self, core: ManagementTransport) -> None: + self._core = core @overload - def delete(self, id: str, body: DirectoriesDeleteBody | None = None, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[None]: ... + def list(self, query: FgaResourcesListQuery, *, approval: Literal["wait"] = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[FgaResourcesListData]: ... @overload - def delete(self, id: str, body: DirectoriesDeleteBody | None = None, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[None] | PendingApprovalResult[None]: ... + def list(self, query: FgaResourcesListQuery, *, approval: Literal["return"], approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[FgaResourcesListData] | PendingApprovalResult[FgaResourcesListData]: ... - def delete(self, id: str, body: DirectoriesDeleteBody | None = None, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[None] | PendingApprovalResult[None]: - """Delete an inbound directory. Its token stops working; the people it provisioned keep their accounts. + def list(self, query: FgaResourcesListQuery, *, approval: ApprovalMode = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[FgaResourcesListData] | PendingApprovalResult[FgaResourcesListData]: + """List the resources of one type a subject has a relation on (e.g. every document alice can view), through all inheritance; sorted ids, paged with after. - Requires scope `directory_sync:write`. Danger: destructive. + Requires scope `fga:read`. Danger: read. - ``DELETE /directories/{id}`` · action ``directories.delete`` - Scope ``directory_sync:write`` · danger: destructive. + ``GET /fga/resources`` · action ``fga.resources.list`` + Scope ``fga:read`` · danger: read. May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. """ - return self._core.call(ENVIRONMENT_OPERATIONS["directories.delete"], (id,), body, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) + return self._core.call(ENVIRONMENT_OPERATIONS["fga.resources.list"], (), query, approval=approval, approval_id=approval_id, headers=headers) + + def list_all(self, query: FgaResourcesListQuery | None = None, *, headers: Mapping[str, str] | None = None) -> Iterator[FgaObject]: + """Every item of ``fga.resources.list``, fetching pages as the iteration reaches them.""" + return self._core.paginate(ENVIRONMENT_OPERATIONS["fga.resources.list"], (), query, headers=headers) + + +class FgaSchemaMethods: + """``fga.schema.*``""" + + def __init__(self, core: ManagementTransport) -> None: + self._core = core @overload - def get(self, id: str, query: DirectoriesGetQuery | None = None, *, approval: Literal["wait"] = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Directory]: ... + def get(self, *, approval: Literal["wait"] = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[FgaSchema]: ... @overload - def get(self, id: str, query: DirectoriesGetQuery | None = None, *, approval: Literal["return"], approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Directory] | PendingApprovalResult[Directory]: ... + def get(self, *, approval: Literal["return"], approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[FgaSchema] | PendingApprovalResult[FgaSchema]: ... - def get(self, id: str, query: DirectoriesGetQuery | None = None, *, approval: ApprovalMode = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Directory] | PendingApprovalResult[Directory]: - """Read one inbound directory: its provider, status, SCIM base URL and last sync error. Never its token or credentials. + def get(self, *, approval: ApprovalMode = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[FgaSchema] | PendingApprovalResult[FgaSchema]: + """Read this environment's fine-grained authorization schema: the source text, its parsed types and relations, its version, and the current consistency token. - Requires scope `directory_sync:read`. Danger: read. + Requires scope `fga:read`. Danger: read. - ``GET /directories/{id}`` · action ``directories.get`` - Scope ``directory_sync:read`` · danger: read. + ``GET /fga/schema`` · action ``fga.schema.get`` + Scope ``fga:read`` · danger: read. May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. """ - return self._core.call(ENVIRONMENT_OPERATIONS["directories.get"], (id,), query, approval=approval, approval_id=approval_id, headers=headers) + return self._core.call(ENVIRONMENT_OPERATIONS["fga.schema.get"], (), None, approval=approval, approval_id=approval_id, headers=headers) @overload - def list(self, query: DirectoriesListQuery | None = None, *, approval: Literal["wait"] = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[DirectoriesListData]: ... + def update(self, body: FgaSchemaUpdateBody, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[FgaSchema]: ... @overload - def list(self, query: DirectoriesListQuery | None = None, *, approval: Literal["return"], approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[DirectoriesListData] | PendingApprovalResult[DirectoriesListData]: ... + def update(self, body: FgaSchemaUpdateBody, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[FgaSchema] | PendingApprovalResult[FgaSchema]: ... - def list(self, query: DirectoriesListQuery | None = None, *, approval: ApprovalMode = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[DirectoriesListData] | PendingApprovalResult[DirectoriesListData]: - """List the directories (SCIM, Google Workspace, Microsoft Entra) syncing people in, optionally for one organization, with their last sync error. + def update(self, body: FgaSchemaUpdateBody, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[FgaSchema] | PendingApprovalResult[FgaSchema]: + """Replace this environment's fine-grained authorization schema (types, relations and how each is decided). Changes the answer to every check at once; refused if invalid or if existing tuples would no longer fit. - Requires scope `directory_sync:read`. Danger: read. + Requires scope `fga:schema`. Danger: critical. - ``GET /directories`` · action ``directories.list`` - Scope ``directory_sync:read`` · danger: read. + ``PUT /fga/schema`` · action ``fga.schema.update`` + Scope ``fga:schema`` · danger: critical. May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. """ - return self._core.call(ENVIRONMENT_OPERATIONS["directories.list"], (), query, approval=approval, approval_id=approval_id, headers=headers) - - def list_all(self, query: DirectoriesListQuery | None = None, *, headers: Mapping[str, str] | None = None) -> Iterator[Directory]: - """Every item of ``directories.list``, fetching pages as the iteration reaches them.""" - return self._core.paginate(ENVIRONMENT_OPERATIONS["directories.list"], (), query, headers=headers) + return self._core.call(ENVIRONMENT_OPERATIONS["fga.schema.update"], (), body, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) @overload - def update(self, id: str, body: DirectoriesUpdateBody, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Directory]: ... + def validate(self, query: FgaSchemaValidateQuery, *, approval: Literal["wait"] = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[FgaSchemaValidation]: ... @overload - def update(self, id: str, body: DirectoriesUpdateBody, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Directory] | PendingApprovalResult[Directory]: ... + def validate(self, query: FgaSchemaValidateQuery, *, approval: Literal["return"], approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[FgaSchemaValidation] | PendingApprovalResult[FgaSchemaValidation]: ... - def update(self, id: str, body: DirectoriesUpdateBody, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Directory] | PendingApprovalResult[Directory]: - """Rename an inbound directory. + def validate(self, query: FgaSchemaValidateQuery, *, approval: ApprovalMode = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[FgaSchemaValidation] | PendingApprovalResult[FgaSchemaValidation]: + """Check a fine-grained authorization schema without saving it: every error by line, or the parsed types and canonical text. - Requires scope `directory_sync:write`. Danger: write. + Requires scope `fga:read`. Danger: read. - ``PATCH /directories/{id}`` · action ``directories.update`` - Scope ``directory_sync:write`` · danger: write. + ``GET /fga/schema/validate`` · action ``fga.schema.validate`` + Scope ``fga:read`` · danger: read. May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. """ - return self._core.call(ENVIRONMENT_OPERATIONS["directories.update"], (id,), body, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) + return self._core.call(ENVIRONMENT_OPERATIONS["fga.schema.validate"], (), query, approval=approval, approval_id=approval_id, headers=headers) -class DomainsMethods: - """``domains.*``""" +class FgaSubjectsMethods: + """``fga.subjects.*``""" def __init__(self, core: ManagementTransport) -> None: self._core = core @overload - def add(self, body: DomainsAddBody, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[CustomDomain]: ... + def list(self, query: FgaSubjectsListQuery, *, approval: Literal["wait"] = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[FgaSubjectsListData]: ... @overload - def add(self, body: DomainsAddBody, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[CustomDomain] | PendingApprovalResult[CustomDomain]: ... + def list(self, query: FgaSubjectsListQuery, *, approval: Literal["return"], approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[FgaSubjectsListData] | PendingApprovalResult[FgaSubjectsListData]: ... - def add(self, body: DomainsAddBody, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[CustomDomain] | PendingApprovalResult[CustomDomain]: - """Start serving this environment on a custom domain: returns the DNS TXT record that proves you control it. + def list(self, query: FgaSubjectsListQuery, *, approval: ApprovalMode = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[FgaSubjectsListData] | PendingApprovalResult[FgaSubjectsListData]: + """List the subjects of one type that have a relation on a resource (e.g. every user who can view the readme), groups and inheritance expanded; sorted ids, paged with after. - Requires scope `domains:write`. Danger: write. + Requires scope `fga:read`. Danger: read. - ``POST /domains`` · action ``domains.add`` - Scope ``domains:write`` · danger: write. + ``GET /fga/subjects`` · action ``fga.subjects.list`` + Scope ``fga:read`` · danger: read. May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. """ - return self._core.call(ENVIRONMENT_OPERATIONS["domains.add"], (), body, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) + return self._core.call(ENVIRONMENT_OPERATIONS["fga.subjects.list"], (), query, approval=approval, approval_id=approval_id, headers=headers) + + def list_all(self, query: FgaSubjectsListQuery | None = None, *, headers: Mapping[str, str] | None = None) -> Iterator[FgaObject]: + """Every item of ``fga.subjects.list``, fetching pages as the iteration reaches them.""" + return self._core.paginate(ENVIRONMENT_OPERATIONS["fga.subjects.list"], (), query, headers=headers) + + +class FgaTuplesMethods: + """``fga.tuples.*``""" + + def __init__(self, core: ManagementTransport) -> None: + self._core = core @overload - def get(self, *, approval: Literal["wait"] = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[CustomDomain]: ... + def delete(self, body: FgaTuplesDeleteBody, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[FgaTupleWrite]: ... @overload - def get(self, *, approval: Literal["return"], approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[CustomDomain] | PendingApprovalResult[CustomDomain]: ... + def delete(self, body: FgaTuplesDeleteBody, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[FgaTupleWrite] | PendingApprovalResult[FgaTupleWrite]: ... - def get(self, *, approval: ApprovalMode = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[CustomDomain] | PendingApprovalResult[CustomDomain]: - """Read this environment's custom domain, and the DNS TXT record that proves a pending one. + def delete(self, body: FgaTuplesDeleteBody, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[FgaTupleWrite] | PendingApprovalResult[FgaTupleWrite]: + """Delete 1–100 relationship tuples in one atomic batch. Revokes the access they granted, and everything inherited through them, at once. - Requires scope `domains:read`. Danger: read. + Requires scope `fga:write`. Danger: destructive. - ``GET /domains`` · action ``domains.get`` - Scope ``domains:read`` · danger: read. + ``POST /fga/tuples/delete`` · action ``fga.tuples.delete`` + Scope ``fga:write`` · danger: destructive. May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. """ - return self._core.call(ENVIRONMENT_OPERATIONS["domains.get"], (), None, approval=approval, approval_id=approval_id, headers=headers) + return self._core.call(ENVIRONMENT_OPERATIONS["fga.tuples.delete"], (), body, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) @overload - def remove(self, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[None]: ... + def list(self, query: FgaTuplesListQuery | None = None, *, approval: Literal["wait"] = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[FgaTuplesListData]: ... @overload - def remove(self, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[None] | PendingApprovalResult[None]: ... + def list(self, query: FgaTuplesListQuery | None = None, *, approval: Literal["return"], approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[FgaTuplesListData] | PendingApprovalResult[FgaTuplesListData]: ... - def remove(self, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[None] | PendingApprovalResult[None]: - """Stop serving this environment on its custom domain. Anything using that domain stops reaching the environment. + def list(self, query: FgaTuplesListQuery | None = None, *, approval: ApprovalMode = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[FgaTuplesListData] | PendingApprovalResult[FgaTuplesListData]: + """List stored relationship tuples oldest first, filtered by resource type and id, relation and subject; pass next_cursor as after to page. - Requires scope `domains:write`. Danger: destructive. + Requires scope `fga:read`. Danger: read. - ``DELETE /domains`` · action ``domains.remove`` - Scope ``domains:write`` · danger: destructive. + ``GET /fga/tuples`` · action ``fga.tuples.list`` + Scope ``fga:read`` · danger: read. May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. """ - return self._core.call(ENVIRONMENT_OPERATIONS["domains.remove"], (), None, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) + return self._core.call(ENVIRONMENT_OPERATIONS["fga.tuples.list"], (), query, approval=approval, approval_id=approval_id, headers=headers) + + def list_all(self, query: FgaTuplesListQuery | None = None, *, headers: Mapping[str, str] | None = None) -> Iterator[FgaTuple]: + """Every item of ``fga.tuples.list``, fetching pages as the iteration reaches them.""" + return self._core.paginate(ENVIRONMENT_OPERATIONS["fga.tuples.list"], (), query, headers=headers) @overload - def verify(self, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[CustomDomain]: ... + def write(self, body: FgaTuplesWriteBody, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[FgaTupleWrite]: ... @overload - def verify(self, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[CustomDomain] | PendingApprovalResult[CustomDomain]: ... + def write(self, body: FgaTuplesWriteBody, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[FgaTupleWrite] | PendingApprovalResult[FgaTupleWrite]: ... - def verify(self, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[CustomDomain] | PendingApprovalResult[CustomDomain]: - """Look for the pending domain's DNS TXT record and, once it is visible, serve this environment on that domain. + def write(self, body: FgaTuplesWriteBody, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[FgaTupleWrite] | PendingApprovalResult[FgaTupleWrite]: + """Write 1–100 relationship tuples (resource#relation@subject) in one atomic batch, each checked against the schema. Grants access at once; returns a consistency_token for checks that must see it. - Requires scope `domains:write`. Danger: write. + Requires scope `fga:write`. Danger: write. - ``POST /domains/verify`` · action ``domains.verify`` - Scope ``domains:write`` · danger: write. + ``POST /fga/tuples`` · action ``fga.tuples.write`` + Scope ``fga:write`` · danger: write. May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. """ - return self._core.call(ENVIRONMENT_OPERATIONS["domains.verify"], (), None, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) + return self._core.call(ENVIRONMENT_OPERATIONS["fga.tuples.write"], (), body, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) -class EventsMethods: - """``events.*``""" +class FgaMethods: + """``fga.*``""" def __init__(self, core: ManagementTransport) -> None: self._core = core + self.resources = FgaResourcesMethods(core) + self.schema = FgaSchemaMethods(core) + self.subjects = FgaSubjectsMethods(core) + self.tuples = FgaTuplesMethods(core) @overload - def list(self, query: EventsListQuery | None = None, *, approval: Literal["wait"] = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[EventsListData]: ... + def check(self, query: FgaCheckQuery, *, approval: Literal["wait"] = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[FgaCheck]: ... @overload - def list(self, query: EventsListQuery | None = None, *, approval: Literal["return"], approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[EventsListData] | PendingApprovalResult[EventsListData]: ... + def check(self, query: FgaCheckQuery, *, approval: Literal["return"], approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[FgaCheck] | PendingApprovalResult[FgaCheck]: ... - def list(self, query: EventsListQuery | None = None, *, approval: ApprovalMode = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[EventsListData] | PendingApprovalResult[EventsListData]: - """Read this environment's domain events (the facts webhooks deliver) oldest first; pass the last id as `after` to poll for new ones. + def check(self, query: FgaCheckQuery, *, approval: ApprovalMode = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[FgaCheck] | PendingApprovalResult[FgaCheck]: + """Check whether a subject has a relation on a resource — directly, through computed relations, parents or nested groups. Optionally at least as fresh as a consistency_token. - Requires scope `events:read`. Danger: read. + Requires scope `fga:read`. Danger: read. - ``GET /events`` · action ``events.list`` - Scope ``events:read`` · danger: read. + ``GET /fga/check`` · action ``fga.check`` + Scope ``fga:read`` · danger: read. May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. """ - return self._core.call(ENVIRONMENT_OPERATIONS["events.list"], (), query, approval=approval, approval_id=approval_id, headers=headers) + return self._core.call(ENVIRONMENT_OPERATIONS["fga.check"], (), query, approval=approval, approval_id=approval_id, headers=headers) - def list_all(self, query: EventsListQuery | None = None, *, headers: Mapping[str, str] | None = None) -> Iterator[DomainEvent]: - """Every item of ``events.list``, fetching pages as the iteration reaches them.""" - return self._core.paginate(ENVIRONMENT_OPERATIONS["events.list"], (), query, headers=headers) + @overload + def check_batch(self, query: FgaCheckBatchQuery, *, approval: Literal["wait"] = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[FgaCheckBatch]: ... + + @overload + def check_batch(self, query: FgaCheckBatchQuery, *, approval: Literal["return"], approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[FgaCheckBatch] | PendingApprovalResult[FgaCheckBatch]: ... + + def check_batch(self, query: FgaCheckBatchQuery, *, approval: ApprovalMode = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[FgaCheckBatch] | PendingApprovalResult[FgaCheckBatch]: + """Run 1–100 checks in one round trip, each written resource#relation@subject (document:readme#viewer@user:alice), all at the same revision, answered in order. + + Requires scope `fga:read`. Danger: read. + + ``GET /fga/check/batch`` · action ``fga.check.batch`` + Scope ``fga:read`` · danger: read. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ENVIRONMENT_OPERATIONS["fga.check.batch"], (), query, approval=approval, approval_id=approval_id, headers=headers) class FrontendKeysMethods: @@ -5698,40 +7228,207 @@ def update(self, id: str, body: OrganizationsUpdateBody | None = None, *, approv def update(self, id: str, body: OrganizationsUpdateBody | None = None, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Organization] | PendingApprovalResult[Organization]: """Rename an organization, change its slug, or replace its metadata - Requires scope `organizations:write`. A field left out is left alone; a request - that changes nothing answers with the organization as it is and records nothing. - A change is announced as the `organization.updated` webhook and recorded on the - organization's trail as `organization.renamed`, with the values before and after. + Requires scope `organizations:write`. A field left out is left alone; a request + that changes nothing answers with the organization as it is and records nothing. + A change is announced as the `organization.updated` webhook and recorded on the + organization's trail as `organization.renamed`, with the values before and after. + + ``PATCH /organizations/{id}`` · action ``organizations.update`` + Scope ``organizations:write`` · danger: write. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ENVIRONMENT_OPERATIONS["organizations.update"], (id,), body, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) + + +class PermissionsMethods: + """``permissions.*``""" + + def __init__(self, core: ManagementTransport) -> None: + self._core = core + + @overload + def create(self, body: PermissionsCreateBody, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Permission]: ... + + @overload + def create(self, body: PermissionsCreateBody, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Permission] | PendingApprovalResult[Permission]: ... + + def create(self, body: PermissionsCreateBody, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Permission] | PendingApprovalResult[Permission]: + """Author a manual `feature:action` permission, shared with the environment (organization_id null) or one organization's own. + + Requires scope `role_definitions:write`. Danger: write. + + ``POST /permissions`` · action ``permissions.create`` + Scope ``role_definitions:write`` · danger: write. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ENVIRONMENT_OPERATIONS["permissions.create"], (), body, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) + + @overload + def delete(self, id: str, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[None]: ... + + @overload + def delete(self, id: str, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[None] | PendingApprovalResult[None]: ... + + def delete(self, id: str, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[None] | PendingApprovalResult[None]: + """Delete a manual permission: every role carrying it loses it first. + + Requires scope `role_definitions:write`. Danger: destructive. + + ``DELETE /permissions/{id}`` · action ``permissions.delete`` + Scope ``role_definitions:write`` · danger: destructive. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ENVIRONMENT_OPERATIONS["permissions.delete"], (id,), None, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) + + @overload + def list(self, query: PermissionsListQuery | None = None, *, approval: Literal["wait"] = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[PermissionsListData]: ... + + @overload + def list(self, query: PermissionsListQuery | None = None, *, approval: Literal["return"], approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[PermissionsListData] | PendingApprovalResult[PermissionsListData]: ... + + def list(self, query: PermissionsListQuery | None = None, *, approval: ApprovalMode = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[PermissionsListData] | PendingApprovalResult[PermissionsListData]: + """List the permission catalogue roles are composed from, a page at a time. Narrow by `client_id` (one app's), `organization_id` (what it can see) or a `q` fragment. + + Requires scope `roles:read`. Danger: read. + + ``GET /permissions`` · action ``permissions.list`` + Scope ``roles:read`` · danger: read. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ENVIRONMENT_OPERATIONS["permissions.list"], (), query, approval=approval, approval_id=approval_id, headers=headers) + + def list_all(self, query: PermissionsListQuery | None = None, *, headers: Mapping[str, str] | None = None) -> Iterator[Permission]: + """Every item of ``permissions.list``, fetching pages as the iteration reaches them.""" + return self._core.paginate(ENVIRONMENT_OPERATIONS["permissions.list"], (), query, headers=headers) + + @overload + def update(self, id: str, body: PermissionsUpdateBody | None = None, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Permission]: ... + + @overload + def update(self, id: str, body: PermissionsUpdateBody | None = None, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Permission] | PendingApprovalResult[Permission]: ... + + def update(self, id: str, body: PermissionsUpdateBody | None = None, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Permission] | PendingApprovalResult[Permission]: + """Change a manual permission's description, and on the shared tier whether organizations may use it. The key itself never changes. + + Requires scope `role_definitions:write`. Danger: write. + + ``PATCH /permissions/{id}`` · action ``permissions.update`` + Scope ``role_definitions:write`` · danger: write. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ENVIRONMENT_OPERATIONS["permissions.update"], (id,), body, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) + + +class PipesConnectionsMethods: + """``pipes.connections.*``""" + + def __init__(self, core: ManagementTransport) -> None: + self._core = core + + @overload + def delete(self, id: str, connection_id: str, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[None]: ... + + @overload + def delete(self, id: str, connection_id: str, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[None] | PendingApprovalResult[None]: ... + + def delete(self, id: str, connection_id: str, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[None] | PendingApprovalResult[None]: + """Disconnect a person's connected account: revoke it at the provider where supported, revoke its tokens here, and forget it. + + Requires scope `pipes:write`. Danger: destructive. + + ``DELETE /pipes/{id}/connections/{connection_id}`` · action ``pipes.connections.delete`` + Scope ``pipes:write`` · danger: destructive. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ENVIRONMENT_OPERATIONS["pipes.connections.delete"], (id, connection_id), None, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) + + @overload + def list(self, id: str, query: PipesConnectionsListQuery | None = None, *, approval: Literal["wait"] = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[PipesConnectionsListData]: ... + + @overload + def list(self, id: str, query: PipesConnectionsListQuery | None = None, *, approval: Literal["return"], approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[PipesConnectionsListData] | PendingApprovalResult[PipesConnectionsListData]: ... + + def list(self, id: str, query: PipesConnectionsListQuery | None = None, *, approval: ApprovalMode = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[PipesConnectionsListData] | PendingApprovalResult[PipesConnectionsListData]: + """List the accounts people connected through a pipe: who, which account, granted scopes, status (active / needs_reauth) and when the token expires. Never a token. + + Requires scope `pipes:read`. Danger: read. + + ``GET /pipes/{id}/connections`` · action ``pipes.connections.list`` + Scope ``pipes:read`` · danger: read. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ENVIRONMENT_OPERATIONS["pipes.connections.list"], (id,), query, approval=approval, approval_id=approval_id, headers=headers) + + def list_all(self, id: str, query: PipesConnectionsListQuery | None = None, *, headers: Mapping[str, str] | None = None) -> Iterator[PipeConnection]: + """Every item of ``pipes.connections.list``, fetching pages as the iteration reaches them.""" + return self._core.paginate(ENVIRONMENT_OPERATIONS["pipes.connections.list"], (id,), query, headers=headers) + + +class PipesGrantsMethods: + """``pipes.grants.*``""" + + def __init__(self, core: ManagementTransport) -> None: + self._core = core + + @overload + def create(self, id: str, body: PipesGrantsCreateBody, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Pipe]: ... + + @overload + def create(self, id: str, body: PipesGrantsCreateBody, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Pipe] | PendingApprovalResult[Pipe]: ... + + def create(self, id: str, body: PipesGrantsCreateBody, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Pipe] | PendingApprovalResult[Pipe]: + """Grant an app (by OAuth client id) the right to lease fresh access tokens for the accounts people connected through this pipe. + + Requires scope `pipes:write`. Danger: critical. + + ``POST /pipes/{id}/grants`` · action ``pipes.grants.create`` + Scope ``pipes:write`` · danger: critical. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ENVIRONMENT_OPERATIONS["pipes.grants.create"], (id,), body, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) + + @overload + def delete(self, id: str, client_id: str, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[None]: ... + + @overload + def delete(self, id: str, client_id: str, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[None] | PendingApprovalResult[None]: ... + + def delete(self, id: str, client_id: str, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[None] | PendingApprovalResult[None]: + """Withdraw an app's right to lease the tokens people connected through this pipe. - ``PATCH /organizations/{id}`` · action ``organizations.update`` - Scope ``organizations:write`` · danger: write. + Requires scope `pipes:write`. Danger: destructive. + + ``DELETE /pipes/{id}/grants/{client_id}`` · action ``pipes.grants.delete`` + Scope ``pipes:write`` · danger: destructive. May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. """ - return self._core.call(ENVIRONMENT_OPERATIONS["organizations.update"], (id,), body, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) + return self._core.call(ENVIRONMENT_OPERATIONS["pipes.grants.delete"], (id, client_id), None, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) -class PermissionsMethods: - """``permissions.*``""" +class PipesMethods: + """``pipes.*``""" def __init__(self, core: ManagementTransport) -> None: self._core = core + self.connections = PipesConnectionsMethods(core) + self.grants = PipesGrantsMethods(core) @overload - def create(self, body: PermissionsCreateBody, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Permission]: ... + def create(self, body: PipesCreateBody, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Pipe]: ... @overload - def create(self, body: PermissionsCreateBody, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Permission] | PendingApprovalResult[Permission]: ... + def create(self, body: PipesCreateBody, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Pipe] | PendingApprovalResult[Pipe]: ... - def create(self, body: PermissionsCreateBody, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Permission] | PendingApprovalResult[Permission]: - """Author a manual `feature:action` permission, shared with the environment (organization_id null) or one organization's own. + def create(self, body: PipesCreateBody, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Pipe] | PendingApprovalResult[Pipe]: + """Configure a pipe: the environment's OAuth app at a third-party provider (GitHub, Google, Microsoft 365, Slack, Salesforce, HubSpot, Linear, Notion), so people can connect their accounts. The client secret is sealed and never returned. - Requires scope `role_definitions:write`. Danger: write. + Requires scope `pipes:write`. Danger: write. - ``POST /permissions`` · action ``permissions.create`` - Scope ``role_definitions:write`` · danger: write. + ``POST /pipes`` · action ``pipes.create`` + Scope ``pipes:write`` · danger: write. May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. """ - return self._core.call(ENVIRONMENT_OPERATIONS["permissions.create"], (), body, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) + return self._core.call(ENVIRONMENT_OPERATIONS["pipes.create"], (), body, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) @overload def delete(self, id: str, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[None]: ... @@ -5740,53 +7437,70 @@ def delete(self, id: str, *, approval: Literal["wait"] = "wait", idempotency_key def delete(self, id: str, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[None] | PendingApprovalResult[None]: ... def delete(self, id: str, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[None] | PendingApprovalResult[None]: - """Delete a manual permission: every role carrying it loses it first. + """Remove a pipe and every connection through it. Their tokens are revoked here, not at the provider — disconnect people first if that matters. - Requires scope `role_definitions:write`. Danger: destructive. + Requires scope `pipes:write`. Danger: destructive. - ``DELETE /permissions/{id}`` · action ``permissions.delete`` - Scope ``role_definitions:write`` · danger: destructive. + ``DELETE /pipes/{id}`` · action ``pipes.delete`` + Scope ``pipes:write`` · danger: destructive. May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. """ - return self._core.call(ENVIRONMENT_OPERATIONS["permissions.delete"], (id,), None, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) + return self._core.call(ENVIRONMENT_OPERATIONS["pipes.delete"], (id,), None, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) @overload - def list(self, query: PermissionsListQuery | None = None, *, approval: Literal["wait"] = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[PermissionsListData]: ... + def get(self, id: str, *, approval: Literal["wait"] = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Pipe]: ... @overload - def list(self, query: PermissionsListQuery | None = None, *, approval: Literal["return"], approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[PermissionsListData] | PendingApprovalResult[PermissionsListData]: ... + def get(self, id: str, *, approval: Literal["return"], approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Pipe] | PendingApprovalResult[Pipe]: ... - def list(self, query: PermissionsListQuery | None = None, *, approval: ApprovalMode = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[PermissionsListData] | PendingApprovalResult[PermissionsListData]: - """List the permission catalogue roles are composed from, a page at a time. Narrow by `client_id` (one app's), `organization_id` (what it can see) or a `q` fragment. + def get(self, id: str, *, approval: ApprovalMode = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Pipe] | PendingApprovalResult[Pipe]: + """Show one pipe: its provider, OAuth client id, scopes, the redirect URI to register at the provider, and the apps granted its tokens. - Requires scope `roles:read`. Danger: read. + Requires scope `pipes:read`. Danger: read. - ``GET /permissions`` · action ``permissions.list`` - Scope ``roles:read`` · danger: read. + ``GET /pipes/{id}`` · action ``pipes.get`` + Scope ``pipes:read`` · danger: read. May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. """ - return self._core.call(ENVIRONMENT_OPERATIONS["permissions.list"], (), query, approval=approval, approval_id=approval_id, headers=headers) + return self._core.call(ENVIRONMENT_OPERATIONS["pipes.get"], (id,), None, approval=approval, approval_id=approval_id, headers=headers) - def list_all(self, query: PermissionsListQuery | None = None, *, headers: Mapping[str, str] | None = None) -> Iterator[Permission]: - """Every item of ``permissions.list``, fetching pages as the iteration reaches them.""" - return self._core.paginate(ENVIRONMENT_OPERATIONS["permissions.list"], (), query, headers=headers) + @overload + def list(self, query: PipesListQuery | None = None, *, approval: Literal["wait"] = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[PipesListData]: ... @overload - def update(self, id: str, body: PermissionsUpdateBody | None = None, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Permission]: ... + def list(self, query: PipesListQuery | None = None, *, approval: Literal["return"], approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[PipesListData] | PendingApprovalResult[PipesListData]: ... + + def list(self, query: PipesListQuery | None = None, *, approval: ApprovalMode = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[PipesListData] | PendingApprovalResult[PipesListData]: + """List the pipes — the third-party providers people can connect their accounts to — with their scopes and the apps granted their tokens. Never a client secret. + + Requires scope `pipes:read`. Danger: read. + + ``GET /pipes`` · action ``pipes.list`` + Scope ``pipes:read`` · danger: read. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ENVIRONMENT_OPERATIONS["pipes.list"], (), query, approval=approval, approval_id=approval_id, headers=headers) + + def list_all(self, query: PipesListQuery | None = None, *, headers: Mapping[str, str] | None = None) -> Iterator[Pipe]: + """Every item of ``pipes.list``, fetching pages as the iteration reaches them.""" + return self._core.paginate(ENVIRONMENT_OPERATIONS["pipes.list"], (), query, headers=headers) @overload - def update(self, id: str, body: PermissionsUpdateBody | None = None, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Permission] | PendingApprovalResult[Permission]: ... + def update(self, id: str, body: PipesUpdateBody | None = None, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Pipe]: ... - def update(self, id: str, body: PermissionsUpdateBody | None = None, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Permission] | PendingApprovalResult[Permission]: - """Change a manual permission's description, and on the shared tier whether organizations may use it. The key itself never changes. + @overload + def update(self, id: str, body: PipesUpdateBody | None = None, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Pipe] | PendingApprovalResult[Pipe]: ... - Requires scope `role_definitions:write`. Danger: write. + def update(self, id: str, body: PipesUpdateBody | None = None, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Pipe] | PendingApprovalResult[Pipe]: + """Change a pipe — client id, client secret (sealed), scopes, parameters, or whether it is enabled. Only what is sent changes. - ``PATCH /permissions/{id}`` · action ``permissions.update`` - Scope ``role_definitions:write`` · danger: write. + Requires scope `pipes:write`. Danger: write. + + ``PATCH /pipes/{id}`` · action ``pipes.update`` + Scope ``pipes:write`` · danger: write. May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. """ - return self._core.call(ENVIRONMENT_OPERATIONS["permissions.update"], (id,), body, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) + return self._core.call(ENVIRONMENT_OPERATIONS["pipes.update"], (id,), body, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) class ProvisioningTargetsStatusMethods: @@ -5901,6 +7615,303 @@ def __init__(self, core: ManagementTransport) -> None: self.targets = ProvisioningTargetsMethods(core) +class RadarDecisionsMethods: + """``radar.decisions.*``""" + + def __init__(self, core: ManagementTransport) -> None: + self._core = core + + @overload + def get(self, id: str, *, approval: Literal["wait"] = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[RadarDecision]: ... + + @overload + def get(self, id: str, *, approval: Literal["return"], approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[RadarDecision] | PendingApprovalResult[RadarDecision]: ... + + def get(self, id: str, *, approval: ApprovalMode = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[RadarDecision] | PendingApprovalResult[RadarDecision]: + """Read one Radar decision: the verdict, whether it was enforced, the deciding rule, every rule that fired, the reasons, the risk score and the facts it was decided on. + + Requires scope `radar:read`. Danger: read. + + ``GET /radar/decisions/{id}`` · action ``radar.decisions.get`` + Scope ``radar:read`` · danger: read. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ENVIRONMENT_OPERATIONS["radar.decisions.get"], (id,), None, approval=approval, approval_id=approval_id, headers=headers) + + @overload + def list(self, query: RadarDecisionsListQuery | None = None, *, approval: Literal["wait"] = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[RadarDecisionsListData]: ... + + @overload + def list(self, query: RadarDecisionsListQuery | None = None, *, approval: Literal["return"], approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[RadarDecisionsListData] | PendingApprovalResult[RadarDecisionsListData]: ... + + def list(self, query: RadarDecisionsListQuery | None = None, *, approval: ApprovalMode = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[RadarDecisionsListData] | PendingApprovalResult[RadarDecisionsListData]: + """List this environment's Radar decisions newest first — verdict, deciding rule, every rule that fired, reasons and facts — filtered by verdict, flow, rule, country, email, IP, device or time; pass `next_cursor` as `after` to page. Email and IP are matched by keyed pseudonym and never returned. + + Requires scope `radar:read`. Danger: read. + + ``GET /radar/decisions`` · action ``radar.decisions.list`` + Scope ``radar:read`` · danger: read. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ENVIRONMENT_OPERATIONS["radar.decisions.list"], (), query, approval=approval, approval_id=approval_id, headers=headers) + + def list_all(self, query: RadarDecisionsListQuery | None = None, *, headers: Mapping[str, str] | None = None) -> Iterator[RadarDecision]: + """Every item of ``radar.decisions.list``, fetching pages as the iteration reaches them.""" + return self._core.paginate(ENVIRONMENT_OPERATIONS["radar.decisions.list"], (), query, headers=headers) + + +class RadarListsMethods: + """``radar.lists.*``""" + + def __init__(self, core: ManagementTransport) -> None: + self._core = core + + @overload + def add(self, body: RadarListsAddBody, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[RadarListEntry]: ... + + @overload + def add(self, body: RadarListsAddBody, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[RadarListEntry] | PendingApprovalResult[RadarListEntry]: ... + + def add(self, body: RadarListsAddBody, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[RadarListEntry] | PendingApprovalResult[RadarListEntry]: + """Add an entry to this environment's Radar allow or deny list: an IP or CIDR range, an email address, a mail domain (and its subdomains), or a device id from the decisions explorer. Deny blocks before every rule; allow skips every rule. + + Requires scope `radar:write`. Danger: write. + + ``POST /radar/lists`` · action ``radar.lists.add`` + Scope ``radar:write`` · danger: write. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ENVIRONMENT_OPERATIONS["radar.lists.add"], (), body, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) + + @overload + def list(self, query: RadarListsListQuery | None = None, *, approval: Literal["wait"] = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[RadarListsListData]: ... + + @overload + def list(self, query: RadarListsListQuery | None = None, *, approval: Literal["return"], approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[RadarListsListData] | PendingApprovalResult[RadarListsListData]: ... + + def list(self, query: RadarListsListQuery | None = None, *, approval: ApprovalMode = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[RadarListsListData] | PendingApprovalResult[RadarListsListData]: + """List this environment's Radar allow and deny entries — IPs and CIDR ranges, addresses, mail domains, devices — optionally one list or one kind. + + Requires scope `radar:read`. Danger: read. + + ``GET /radar/lists`` · action ``radar.lists.list`` + Scope ``radar:read`` · danger: read. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ENVIRONMENT_OPERATIONS["radar.lists.list"], (), query, approval=approval, approval_id=approval_id, headers=headers) + + def list_all(self, query: RadarListsListQuery | None = None, *, headers: Mapping[str, str] | None = None) -> Iterator[RadarListEntry]: + """Every item of ``radar.lists.list``, fetching pages as the iteration reaches them.""" + return self._core.paginate(ENVIRONMENT_OPERATIONS["radar.lists.list"], (), query, headers=headers) + + @overload + def remove(self, id: str, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[None]: ... + + @overload + def remove(self, id: str, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[None] | PendingApprovalResult[None]: ... + + def remove(self, id: str, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[None] | PendingApprovalResult[None]: + """Remove an entry from this environment's Radar allow or deny list. + + Requires scope `radar:write`. Danger: destructive. + + ``DELETE /radar/lists/{id}`` · action ``radar.lists.remove`` + Scope ``radar:write`` · danger: destructive. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ENVIRONMENT_OPERATIONS["radar.lists.remove"], (id,), None, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) + + +class RadarModeMethods: + """``radar.mode.*``""" + + def __init__(self, core: ManagementTransport) -> None: + self._core = core + + @overload + def set(self, body: RadarModeSetBody, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[RadarSettings]: ... + + @overload + def set(self, body: RadarModeSetBody, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[RadarSettings] | PendingApprovalResult[RadarSettings]: ... + + def set(self, body: RadarModeSetBody, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[RadarSettings] | PendingApprovalResult[RadarSettings]: + """Switch this environment's Radar between `monitor` (verdicts recorded, never acted on) and `enforce` (blocks refuse sign-ins and sign-ups, challenges demand a second factor). Changes the environment's security posture. + + Requires scope `radar:manage`. Danger: critical. + + ``PUT /radar/mode`` · action ``radar.mode.set`` + Scope ``radar:manage`` · danger: critical. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ENVIRONMENT_OPERATIONS["radar.mode.set"], (), body, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) + + +class RadarRulesMethods: + """``radar.rules.*``""" + + def __init__(self, core: ManagementTransport) -> None: + self._core = core + + @overload + def create(self, body: RadarRulesCreateBody, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[RadarRule]: ... + + @overload + def create(self, body: RadarRulesCreateBody, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[RadarRule] | PendingApprovalResult[RadarRule]: ... + + def create(self, body: RadarRulesCreateBody, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[RadarRule] | PendingApprovalResult[RadarRule]: + """Add a Radar rule: when EVERY condition (field, operator, value) holds, allow, challenge or block. Rules run in order after the allow/deny lists and before the built-in rules; the first match decides. Example: country not_in [DK, SE] → challenge. + + Requires scope `radar:write`. Danger: write. + + ``POST /radar/rules`` · action ``radar.rules.create`` + Scope ``radar:write`` · danger: write. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ENVIRONMENT_OPERATIONS["radar.rules.create"], (), body, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) + + @overload + def delete(self, id: str, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[None]: ... + + @overload + def delete(self, id: str, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[None] | PendingApprovalResult[None]: ... + + def delete(self, id: str, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[None] | PendingApprovalResult[None]: + """Delete a Radar rule. Attempts it decided are decided by the next matching rule, or the built-in rules, from then on. + + Requires scope `radar:write`. Danger: destructive. + + ``DELETE /radar/rules/{id}`` · action ``radar.rules.delete`` + Scope ``radar:write`` · danger: destructive. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ENVIRONMENT_OPERATIONS["radar.rules.delete"], (id,), None, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) + + @overload + def get(self, id: str, *, approval: Literal["wait"] = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[RadarRule]: ... + + @overload + def get(self, id: str, *, approval: Literal["return"], approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[RadarRule] | PendingApprovalResult[RadarRule]: ... + + def get(self, id: str, *, approval: ApprovalMode = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[RadarRule] | PendingApprovalResult[RadarRule]: + """Read one Radar rule: its action, the flows it applies to, its position and its conditions. + + Requires scope `radar:read`. Danger: read. + + ``GET /radar/rules/{id}`` · action ``radar.rules.get`` + Scope ``radar:read`` · danger: read. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ENVIRONMENT_OPERATIONS["radar.rules.get"], (id,), None, approval=approval, approval_id=approval_id, headers=headers) + + @overload + def list(self, query: RadarRulesListQuery | None = None, *, approval: Literal["wait"] = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[RadarRulesListData]: ... + + @overload + def list(self, query: RadarRulesListQuery | None = None, *, approval: Literal["return"], approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[RadarRulesListData] | PendingApprovalResult[RadarRulesListData]: ... + + def list(self, query: RadarRulesListQuery | None = None, *, approval: ApprovalMode = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[RadarRulesListData] | PendingApprovalResult[RadarRulesListData]: + """List this environment's own Radar rules in evaluation order (the first whose conditions all hold decides), with their conditions and a readable summary of each. + + Requires scope `radar:read`. Danger: read. + + ``GET /radar/rules`` · action ``radar.rules.list`` + Scope ``radar:read`` · danger: read. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ENVIRONMENT_OPERATIONS["radar.rules.list"], (), query, approval=approval, approval_id=approval_id, headers=headers) + + def list_all(self, query: RadarRulesListQuery | None = None, *, headers: Mapping[str, str] | None = None) -> Iterator[RadarRule]: + """Every item of ``radar.rules.list``, fetching pages as the iteration reaches them.""" + return self._core.paginate(ENVIRONMENT_OPERATIONS["radar.rules.list"], (), query, headers=headers) + + @overload + def reorder(self, body: RadarRulesReorderBody, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[None]: ... + + @overload + def reorder(self, body: RadarRulesReorderBody, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[None] | PendingApprovalResult[None]: ... + + def reorder(self, body: RadarRulesReorderBody, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[None] | PendingApprovalResult[None]: + """Set the order Radar rules are evaluated in: `rule_ids` lists every rule of the environment exactly once, first evaluated first. + + Requires scope `radar:write`. Danger: write. + + ``PUT /radar/rules/order`` · action ``radar.rules.reorder`` + Scope ``radar:write`` · danger: write. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ENVIRONMENT_OPERATIONS["radar.rules.reorder"], (), body, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) + + @overload + def update(self, id: str, body: RadarRulesUpdateBody | None = None, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[RadarRule]: ... + + @overload + def update(self, id: str, body: RadarRulesUpdateBody | None = None, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[RadarRule] | PendingApprovalResult[RadarRule]: ... + + def update(self, id: str, body: RadarRulesUpdateBody | None = None, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[RadarRule] | PendingApprovalResult[RadarRule]: + """Change a Radar rule: its name, description, action, flows, enabled state, position, or (replaced whole) its conditions. + + Requires scope `radar:write`. Danger: write. + + ``PATCH /radar/rules/{id}`` · action ``radar.rules.update`` + Scope ``radar:write`` · danger: write. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ENVIRONMENT_OPERATIONS["radar.rules.update"], (id,), body, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) + + +class RadarSettingsMethods: + """``radar.settings.*``""" + + def __init__(self, core: ManagementTransport) -> None: + self._core = core + + @overload + def get(self, *, approval: Literal["wait"] = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[RadarSettings]: ... + + @overload + def get(self, *, approval: Literal["return"], approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[RadarSettings] | PendingApprovalResult[RadarSettings]: ... + + def get(self, *, approval: ApprovalMode = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[RadarSettings] | PendingApprovalResult[RadarSettings]: + """Read this environment's Radar settings: the mode (monitor or enforce, and whether it is inherited from the deployment), the IP intelligence source, and every built-in rule with its action and threshold. + + Requires scope `radar:read`. Danger: read. + + ``GET /radar/settings`` · action ``radar.settings.get`` + Scope ``radar:read`` · danger: read. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ENVIRONMENT_OPERATIONS["radar.settings.get"], (), None, approval=approval, approval_id=approval_id, headers=headers) + + @overload + def update(self, body: RadarSettingsUpdateBody, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[RadarSettings]: ... + + @overload + def update(self, body: RadarSettingsUpdateBody, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[RadarSettings] | PendingApprovalResult[RadarSettings]: ... + + def update(self, body: RadarSettingsUpdateBody, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[RadarSettings] | PendingApprovalResult[RadarSettings]: + """Tune this environment's built-in Radar rules: `builtin_rules` maps a rule key (credential_stuffing, bot_velocity, account_attack, impossible_travel, new_device, anonymous_network, hosting_network, disposable_email, risk_score_reject, risk_score_elevated) to any of enabled, action and threshold. + + Requires scope `radar:write`. Danger: write. + + ``PATCH /radar/settings`` · action ``radar.settings.update`` + Scope ``radar:write`` · danger: write. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ENVIRONMENT_OPERATIONS["radar.settings.update"], (), body, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) + + +class RadarMethods: + """``radar.*``""" + + def __init__(self, core: ManagementTransport) -> None: + self._core = core + self.decisions = RadarDecisionsMethods(core) + self.lists = RadarListsMethods(core) + self.mode = RadarModeMethods(core) + self.rules = RadarRulesMethods(core) + self.settings = RadarSettingsMethods(core) + + class RolesPermissionsMethods: """``roles.permissions.*``""" @@ -6215,6 +8226,47 @@ def set(self, body: SigninSelfServiceSignupSetBody, *, approval: ApprovalMode = return self._core.call(ENVIRONMENT_OPERATIONS["signin.self_service_signup.set"], (), body, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) +class SigninSmsMethods: + """``signin.sms.*``""" + + def __init__(self, core: ManagementTransport) -> None: + self._core = core + + @overload + def get(self, *, approval: Literal["wait"] = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[SmsFactorPolicy]: ... + + @overload + def get(self, *, approval: Literal["return"], approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[SmsFactorPolicy] | PendingApprovalResult[SmsFactorPolicy]: ... + + def get(self, *, approval: ApprovalMode = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[SmsFactorPolicy] | PendingApprovalResult[SmsFactorPolicy]: + """Read the SMS second-factor policy: whether text-message codes are accepted, for which countries, and the administrator rule. + + Requires scope `signin:read`. Danger: read. + + ``GET /sign-in/sms`` · action ``signin.sms.get`` + Scope ``signin:read`` · danger: read. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ENVIRONMENT_OPERATIONS["signin.sms.get"], (), None, approval=approval, approval_id=approval_id, headers=headers) + + @overload + def update(self, body: SigninSmsUpdateBody | None = None, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[SmsFactorPolicy]: ... + + @overload + def update(self, body: SigninSmsUpdateBody | None = None, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[SmsFactorPolicy] | PendingApprovalResult[SmsFactorPolicy]: ... + + def update(self, body: SigninSmsUpdateBody | None = None, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[SmsFactorPolicy] | PendingApprovalResult[SmsFactorPolicy]: + """Change the SMS second-factor policy: accept text-message codes or not, the countries they may go to, and whether SMS may be an administrator's only factor. + + Requires scope `signin:write`. Danger: critical. + + ``PATCH /sign-in/sms`` · action ``signin.sms.update`` + Scope ``signin:write`` · danger: critical. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ENVIRONMENT_OPERATIONS["signin.sms.update"], (), body, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) + + class SigninSocialMethods: """``signin.social.*``""" @@ -6284,6 +8336,7 @@ def __init__(self, core: ManagementTransport) -> None: self._core = core self.policy = SigninPolicyMethods(core) self.self_service_signup = SigninSelfServiceSignupMethods(core) + self.sms = SigninSmsMethods(core) self.social = SigninSocialMethods(core) @@ -7016,11 +9069,36 @@ def revoke(self, id: str, role_id: str, query: UsersEnvironmentRolesRevokeQuery return self._core.call(ENVIRONMENT_OPERATIONS["users.environment_roles.revoke"], (id, role_id), query, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) +class UsersMfaSmsMethods: + """``users.mfa.sms.*``""" + + def __init__(self, core: ManagementTransport) -> None: + self._core = core + + @overload + def remove(self, id: str, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[None]: ... + + @overload + def remove(self, id: str, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[None] | PendingApprovalResult[None]: ... + + def remove(self, id: str, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[None] | PendingApprovalResult[None]: + """Remove a user's phone number for text-message sign-in codes. Their other factors and recovery codes stay. + + Requires scope `users:write`. Danger: critical. + + ``DELETE /users/{id}/mfa/sms`` · action ``users.mfa.sms.remove`` + Scope ``users:write`` · danger: critical. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ENVIRONMENT_OPERATIONS["users.mfa.sms.remove"], (id,), None, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) + + class UsersMfaMethods: """``users.mfa.*``""" def __init__(self, core: ManagementTransport) -> None: self._core = core + self.sms = UsersMfaSmsMethods(core) @overload def reset(self, id: str, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[None]: ... @@ -7542,6 +9620,8 @@ def _bind(self, core: ManagementTransport) -> None: self.directories = DirectoriesMethods(core) self.domains = DomainsMethods(core) self.events = EventsMethods(core) + self.feature_flags = FeatureFlagsMethods(core) + self.fga = FgaMethods(core) self.frontend_keys = FrontendKeysMethods(core) self.hooks = HooksMethods(core) self.invitations = InvitationsMethods(core) @@ -7551,7 +9631,9 @@ def _bind(self, core: ManagementTransport) -> None: self.members = MembersMethods(core) self.organizations = OrganizationsMethods(core) self.permissions = PermissionsMethods(core) + self.pipes = PipesMethods(core) self.provisioning = ProvisioningMethods(core) + self.radar = RadarMethods(core) self.roles = RolesMethods(core) self.saml_apps = SamlAppsMethods(core) self.signin = SigninMethods(core) diff --git a/src/cbox_id/models.py b/src/cbox_id/models.py index a1eee0a..8559e64 100644 --- a/src/cbox_id/models.py +++ b/src/cbox_id/models.py @@ -145,6 +145,10 @@ class CboxUser: #: your local session on it to honour an OIDC back-channel logout, whose logout token #: names the ``sid`` to end. Read from the signed id_token only, never from UserInfo. session_id: str | None = None + #: The keys of the feature flags on for this person in this organization (the + #: ``feature_flags`` claim), or ``None`` when the claim is absent — the + #: ``feature_flags`` scope was not requested. See :meth:`has_feature`. + feature_flags: list[str] | None = None def claim(self, key: str) -> Any: """Return an arbitrary claim, or ``None``.""" @@ -167,6 +171,13 @@ def has_permission(self, permission: str) -> bool: """Whether the session holds ``permission`` (``feature:action``). Exact match.""" return permission in self.permissions + def has_feature(self, key: str) -> bool: + """Whether the feature flag ``key`` is on for this session. Exact match. + + ``False`` when the claim is absent: a missing scope turns every feature off. + """ + return key in (self.feature_flags or []) + @dataclass(frozen=True) class RefreshedTokens: diff --git a/src/cbox_id/pipes.py b/src/cbox_id/pipes.py new file mode 100644 index 0000000..9afdfa6 --- /dev/null +++ b/src/cbox_id/pipes.py @@ -0,0 +1,207 @@ +"""Pipes: lease a fresh access token for a person's connected account at a provider. + +A person connects their own GitHub, Google, Slack… account on Cbox ID's hosted connect +page; your backend then leases a working access token whenever it calls that provider as +them. Cbox ID refreshes the token first when it is about to expire, so you never handle a +refresh token. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Any, Literal +from urllib.parse import parse_qsl, quote, urlencode, urlsplit, urlunsplit + +import httpx + +from .errors import ( + PipeLeaseDeniedError, + PipeLeaseError, + PipeNotConnectedError, + PipeReauthorizationRequiredError, + PipeTemporarilyUnavailableError, +) + +#: A provider a pipe can connect to. +PipeProvider = Literal[ + "github", "google", "microsoft", "slack", "salesforce", "hubspot", "linear", "notion" +] + + +@dataclass(frozen=True) +class PipeToken: + """A fresh access token for one person's connected account. Use it, drop it, lease again.""" + + #: The provider's access token. Send it as a bearer token to the provider's API. + access_token: str + provider: str + user_id: str + connection_id: str + #: When to drop it and lease again (ISO 8601). + lease_expires_at: str + #: What the person consented to. + scopes: list[str] = field(default_factory=list) + #: When the PROVIDER stops accepting it; ``None`` for tokens that do not expire. + expires_at: str | None = None + #: What some providers need to be called at all — Salesforce's ``instance_url``, + #: Slack's ``team.id``. Never a credential. + metadata: dict[str, str] = field(default_factory=dict) + token_type: str = "Bearer" + + +def with_connect_return( + connect_url: str, *, client_id: str | None = None, return_to: str | None = None +) -> str: + """Add ``client_id`` and ``return_to`` to a connect URL. + + The one a lease error carries, or one built with :func:`pipe_connect_url`. + """ + parts = urlsplit(connect_url) + query = dict(parse_qsl(parts.query)) + if client_id: + query["client_id"] = client_id + if return_to: + query["return_to"] = return_to + return urlunsplit(parts._replace(query=urlencode(query))) + + +def pipe_connect_url( + issuer: str, + provider: str, + *, + client_id: str | None = None, + return_to: str | None = None, +) -> str: + """The hosted page where the signed-in person connects their account at ``provider``. + + ``{issuer}/account/connected-services/{provider}/connect?client_id=…&return_to=…``. They + come back to ``return_to`` with ``?provider=…&status=connected|cancelled|failed``, + honoured only on an origin the app registered. + """ + base = f"{issuer.rstrip('/')}/account/connected-services/{quote(provider, safe='')}/connect" + return with_connect_return(base, client_id=client_id, return_to=return_to) + + +class PipesClient: + """Leases Pipes tokens with an access token that carries the ``vault.lease`` scope. + + ``access_token`` is a string, or a callable returning one (called before every lease, + so it can refresh). A client-credentials token names the person in ``user_id``; a token + issued to your app FOR a person leases that person's connection, and ``user_id`` is + left out. :attr:`CboxIdClient.pipes ` builds one that + obtains its own client-credentials token. + """ + + def __init__( + self, + issuer: str, + access_token: str | Callable[[], str], + *, + client_id: str | None = None, + http_client: httpx.Client | None = None, + timeout: float = 10.0, + ) -> None: + self._issuer = issuer.rstrip("/") + self._access_token = access_token + self._client_id = client_id + self._http = http_client or httpx.Client(timeout=timeout) + + def lease_token( + self, provider: PipeProvider | str, *, purpose: str, user_id: str | None = None + ) -> PipeToken: + """Lease a fresh access token for a person's connected account at ``provider``. + + ``purpose`` is recorded on the audit trail. + + :: + + try: + token = client.pipes.lease_token("github", user_id=uid, purpose="list-repos") + except (PipeNotConnectedError, PipeReauthorizationRequiredError) as e: + return redirect(e.connect_url_with(client_id=CLIENT_ID, return_to=here)) + + :raises PipeNotConnectedError: 404 — send the person to ``connect_url``. + :raises PipeReauthorizationRequiredError: 409 — send them to ``connect_url`` again. + :raises PipeTemporarilyUnavailableError: 503 — retry after ``retry_after`` seconds. + :raises PipeLeaseDeniedError: 403 — the app is not granted this pipe. + :raises PipeLeaseError: anything else. + """ + token = self._access_token() if callable(self._access_token) else self._access_token + payload: dict[str, str] = {"purpose": purpose} + if user_id is not None: + payload["user_id"] = user_id + + response = self._http.post( + f"{self._issuer}/api/v1/vault/pipes/{quote(provider, safe='')}/token", + json=payload, + headers={"Authorization": f"Bearer {token}", "Accept": "application/json"}, + ) + + try: + parsed = response.json() + except ValueError: + parsed = None + body: dict[str, Any] = parsed if isinstance(parsed, dict) else {} + + if response.status_code >= 400: + raise _lease_error(response, body) + + access_token = body.get("access_token") + if not isinstance(access_token, str): + raise PipeLeaseError( + "The pipe lease answered without an access token.", + error=None, + status=response.status_code, + ) + + scopes = body.get("scopes") + metadata = body.get("metadata") + expires_at = body.get("expires_at") + return PipeToken( + access_token=access_token, + provider=str(body.get("provider", provider)), + user_id=str(body.get("user_id", "")), + connection_id=str(body.get("connection_id", "")), + lease_expires_at=str(body.get("lease_expires_at", "")), + scopes=[s for s in scopes if isinstance(s, str)] if isinstance(scopes, list) else [], + expires_at=expires_at if isinstance(expires_at, str) else None, + metadata={k: v for k, v in metadata.items() if isinstance(v, str)} + if isinstance(metadata, dict) + else {}, + ) + + def connect_url(self, provider: PipeProvider | str, *, return_to: str | None = None) -> str: + """The hosted connect page for ``provider``, preselected to this client's app.""" + return pipe_connect_url( + self._issuer, provider, client_id=self._client_id, return_to=return_to + ) + + +def _lease_error(response: httpx.Response, body: dict[str, Any]) -> PipeLeaseError: + status = response.status_code + error = body.get("error") if isinstance(body.get("error"), str) else None + raw_message = body.get("message") + message = ( + raw_message + if isinstance(raw_message, str) and raw_message + else f"The pipe lease failed with status {status}." + ) + raw_url = body.get("connect_url") + connect_url = raw_url if isinstance(raw_url, str) and raw_url else None + header = response.headers.get("retry-after", "").strip() + retry_after = int(header) if header.isdigit() else None + + kind: type[PipeLeaseError] = PipeLeaseError + if status == 404 and connect_url is not None: + kind = PipeNotConnectedError + elif status == 409 and connect_url is not None: + kind = PipeReauthorizationRequiredError + elif status == 503: + kind = PipeTemporarilyUnavailableError + elif status == 403 and error != "insufficient_scope": + kind = PipeLeaseDeniedError + + return kind( + message, error=error, status=status, connect_url=connect_url, retry_after=retry_after + ) diff --git a/tests/fixtures/management_surface.txt b/tests/fixtures/management_surface.txt index 8b5d91d..5683819 100644 --- a/tests/fixtures/management_surface.txt +++ b/tests/fixtures/management_surface.txt @@ -65,11 +65,15 @@ environment.directories.delete(id, body=, *, approval=, idempotency_key=, approv environment.directories.get(id, query=, *, approval=, approval_id=, headers=) -> ApiResponse[Directory] | PendingApprovalResult[Directory] environment.directories.list(query=, *, approval=, approval_id=, headers=) -> ApiResponse[DirectoriesListData] | PendingApprovalResult[DirectoriesListData] environment.directories.list_all(query=, *, headers=) -> Iterator[Directory] +environment.directories.sync(id, body=, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[None] | PendingApprovalResult[None] environment.directories.update(id, body, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[Directory] | PendingApprovalResult[Directory] +environment.directories.credentials.replace(id, body, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[Directory] | PendingApprovalResult[Directory] environment.directories.groups.list(id, query=, *, approval=, approval_id=, headers=) -> ApiResponse[DirectoriesGroupsListData] | PendingApprovalResult[DirectoriesGroupsListData] environment.directories.groups.list_all(id, query=, *, headers=) -> Iterator[DirectoryGroup] environment.directories.groups.map(id, body, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[DirectoryGroup] | PendingApprovalResult[DirectoryGroup] +environment.directories.hris.connect(body, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[Directory] | PendingApprovalResult[Directory] environment.directories.status.set(id, body, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[Directory] | PendingApprovalResult[Directory] +environment.directories.sync_settings.update(id, body=, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[Directory] | PendingApprovalResult[Directory] environment.directories.token.rotate(id, body=, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[Directory] | PendingApprovalResult[Directory] environment.domains.add(body, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[CustomDomain] | PendingApprovalResult[CustomDomain] environment.domains.get(*, approval=, approval_id=, headers=) -> ApiResponse[CustomDomain] | PendingApprovalResult[CustomDomain] @@ -77,6 +81,26 @@ environment.domains.remove(*, approval=, idempotency_key=, approval_id=, headers environment.domains.verify(*, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[CustomDomain] | PendingApprovalResult[CustomDomain] environment.events.list(query=, *, approval=, approval_id=, headers=) -> ApiResponse[EventsListData] | PendingApprovalResult[EventsListData] environment.events.list_all(query=, *, headers=) -> Iterator[DomainEvent] +environment.feature_flags.create(body, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[FeatureFlag] | PendingApprovalResult[FeatureFlag] +environment.feature_flags.delete(id, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[None] | PendingApprovalResult[None] +environment.feature_flags.evaluate(query=, *, approval=, approval_id=, headers=) -> ApiResponse[FeatureFlagEvaluation] | PendingApprovalResult[FeatureFlagEvaluation] +environment.feature_flags.get(id, *, approval=, approval_id=, headers=) -> ApiResponse[FeatureFlag] | PendingApprovalResult[FeatureFlag] +environment.feature_flags.list(query=, *, approval=, approval_id=, headers=) -> ApiResponse[FeatureFlagsListData] | PendingApprovalResult[FeatureFlagsListData] +environment.feature_flags.list_all(query=, *, headers=) -> Iterator[FeatureFlag] +environment.feature_flags.update(id, body=, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[FeatureFlag] | PendingApprovalResult[FeatureFlag] +environment.fga.check(query, *, approval=, approval_id=, headers=) -> ApiResponse[FgaCheck] | PendingApprovalResult[FgaCheck] +environment.fga.check_batch(query, *, approval=, approval_id=, headers=) -> ApiResponse[FgaCheckBatch] | PendingApprovalResult[FgaCheckBatch] +environment.fga.resources.list(query, *, approval=, approval_id=, headers=) -> ApiResponse[FgaResourcesListData] | PendingApprovalResult[FgaResourcesListData] +environment.fga.resources.list_all(query=, *, headers=) -> Iterator[FgaObject] +environment.fga.schema.get(*, approval=, approval_id=, headers=) -> ApiResponse[FgaSchema] | PendingApprovalResult[FgaSchema] +environment.fga.schema.update(body, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[FgaSchema] | PendingApprovalResult[FgaSchema] +environment.fga.schema.validate(query, *, approval=, approval_id=, headers=) -> ApiResponse[FgaSchemaValidation] | PendingApprovalResult[FgaSchemaValidation] +environment.fga.subjects.list(query, *, approval=, approval_id=, headers=) -> ApiResponse[FgaSubjectsListData] | PendingApprovalResult[FgaSubjectsListData] +environment.fga.subjects.list_all(query=, *, headers=) -> Iterator[FgaObject] +environment.fga.tuples.delete(body, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[FgaTupleWrite] | PendingApprovalResult[FgaTupleWrite] +environment.fga.tuples.list(query=, *, approval=, approval_id=, headers=) -> ApiResponse[FgaTuplesListData] | PendingApprovalResult[FgaTuplesListData] +environment.fga.tuples.list_all(query=, *, headers=) -> Iterator[FgaTuple] +environment.fga.tuples.write(body, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[FgaTupleWrite] | PendingApprovalResult[FgaTupleWrite] environment.frontend_keys.create(body, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[FrontendKey] | PendingApprovalResult[FrontendKey] environment.frontend_keys.list(query=, *, approval=, approval_id=, headers=) -> ApiResponse[FrontendKeysListData] | PendingApprovalResult[FrontendKeysListData] environment.frontend_keys.list_all(query=, *, headers=) -> Iterator[FrontendKey] @@ -139,12 +163,40 @@ environment.permissions.delete(id, *, approval=, idempotency_key=, approval_id=, environment.permissions.list(query=, *, approval=, approval_id=, headers=) -> ApiResponse[PermissionsListData] | PendingApprovalResult[PermissionsListData] environment.permissions.list_all(query=, *, headers=) -> Iterator[Permission] environment.permissions.update(id, body=, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[Permission] | PendingApprovalResult[Permission] +environment.pipes.create(body, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[Pipe] | PendingApprovalResult[Pipe] +environment.pipes.delete(id, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[None] | PendingApprovalResult[None] +environment.pipes.get(id, *, approval=, approval_id=, headers=) -> ApiResponse[Pipe] | PendingApprovalResult[Pipe] +environment.pipes.list(query=, *, approval=, approval_id=, headers=) -> ApiResponse[PipesListData] | PendingApprovalResult[PipesListData] +environment.pipes.list_all(query=, *, headers=) -> Iterator[Pipe] +environment.pipes.update(id, body=, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[Pipe] | PendingApprovalResult[Pipe] +environment.pipes.connections.delete(id, connection_id, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[None] | PendingApprovalResult[None] +environment.pipes.connections.list(id, query=, *, approval=, approval_id=, headers=) -> ApiResponse[PipesConnectionsListData] | PendingApprovalResult[PipesConnectionsListData] +environment.pipes.connections.list_all(id, query=, *, headers=) -> Iterator[PipeConnection] +environment.pipes.grants.create(id, body, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[Pipe] | PendingApprovalResult[Pipe] +environment.pipes.grants.delete(id, client_id, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[None] | PendingApprovalResult[None] environment.provisioning.targets.create(body, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[ProvisioningTarget] | PendingApprovalResult[ProvisioningTarget] environment.provisioning.targets.delete(id, body=, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[None] | PendingApprovalResult[None] environment.provisioning.targets.get(id, query=, *, approval=, approval_id=, headers=) -> ApiResponse[ProvisioningTarget] | PendingApprovalResult[ProvisioningTarget] environment.provisioning.targets.list(query=, *, approval=, approval_id=, headers=) -> ApiResponse[ProvisioningTargetsListData] | PendingApprovalResult[ProvisioningTargetsListData] environment.provisioning.targets.list_all(query=, *, headers=) -> Iterator[ProvisioningTarget] environment.provisioning.targets.status.set(id, body, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[ProvisioningTarget] | PendingApprovalResult[ProvisioningTarget] +environment.radar.decisions.get(id, *, approval=, approval_id=, headers=) -> ApiResponse[RadarDecision] | PendingApprovalResult[RadarDecision] +environment.radar.decisions.list(query=, *, approval=, approval_id=, headers=) -> ApiResponse[RadarDecisionsListData] | PendingApprovalResult[RadarDecisionsListData] +environment.radar.decisions.list_all(query=, *, headers=) -> Iterator[RadarDecision] +environment.radar.lists.add(body, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[RadarListEntry] | PendingApprovalResult[RadarListEntry] +environment.radar.lists.list(query=, *, approval=, approval_id=, headers=) -> ApiResponse[RadarListsListData] | PendingApprovalResult[RadarListsListData] +environment.radar.lists.list_all(query=, *, headers=) -> Iterator[RadarListEntry] +environment.radar.lists.remove(id, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[None] | PendingApprovalResult[None] +environment.radar.mode.set(body, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[RadarSettings] | PendingApprovalResult[RadarSettings] +environment.radar.rules.create(body, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[RadarRule] | PendingApprovalResult[RadarRule] +environment.radar.rules.delete(id, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[None] | PendingApprovalResult[None] +environment.radar.rules.get(id, *, approval=, approval_id=, headers=) -> ApiResponse[RadarRule] | PendingApprovalResult[RadarRule] +environment.radar.rules.list(query=, *, approval=, approval_id=, headers=) -> ApiResponse[RadarRulesListData] | PendingApprovalResult[RadarRulesListData] +environment.radar.rules.list_all(query=, *, headers=) -> Iterator[RadarRule] +environment.radar.rules.reorder(body, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[None] | PendingApprovalResult[None] +environment.radar.rules.update(id, body=, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[RadarRule] | PendingApprovalResult[RadarRule] +environment.radar.settings.get(*, approval=, approval_id=, headers=) -> ApiResponse[RadarSettings] | PendingApprovalResult[RadarSettings] +environment.radar.settings.update(body, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[RadarSettings] | PendingApprovalResult[RadarSettings] environment.roles.create(body, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[Role] | PendingApprovalResult[Role] environment.roles.delete(id, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[None] | PendingApprovalResult[None] environment.roles.get(id, query=, *, approval=, approval_id=, headers=) -> ApiResponse[Role] | PendingApprovalResult[Role] @@ -162,6 +214,8 @@ environment.signin.policy.get(query=, *, approval=, approval_id=, headers=) -> A environment.signin.policy.inherit(organization_id, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[None] | PendingApprovalResult[None] environment.signin.policy.update(body=, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[SignInPolicy] | PendingApprovalResult[SignInPolicy] environment.signin.self_service_signup.set(body, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[SelfServiceSignup] | PendingApprovalResult[SelfServiceSignup] +environment.signin.sms.get(*, approval=, approval_id=, headers=) -> ApiResponse[SmsFactorPolicy] | PendingApprovalResult[SmsFactorPolicy] +environment.signin.sms.update(body=, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[SmsFactorPolicy] | PendingApprovalResult[SmsFactorPolicy] environment.signin.social.delete(id, body=, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[None] | PendingApprovalResult[None] environment.signin.social.list(query=, *, approval=, approval_id=, headers=) -> ApiResponse[SigninSocialListData] | PendingApprovalResult[SigninSocialListData] environment.signin.social.list_all(query=, *, headers=) -> Iterator[SocialProvider] @@ -215,6 +269,7 @@ environment.users.environment_roles.grant(id, role_id, query=, *, approval=, ide environment.users.environment_roles.list(id, *, approval=, approval_id=, headers=) -> ApiResponse[UsersEnvironmentRolesListData] | PendingApprovalResult[UsersEnvironmentRolesListData] environment.users.environment_roles.revoke(id, role_id, query=, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[None] | PendingApprovalResult[None] environment.users.mfa.reset(id, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[None] | PendingApprovalResult[None] +environment.users.mfa.sms.remove(id, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[None] | PendingApprovalResult[None] environment.users.password.set(id, body, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[User] | PendingApprovalResult[User] environment.users.password_reset.send(id, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[None] | PendingApprovalResult[None] environment.users.sessions.list(id, *, approval=, approval_id=, headers=) -> ApiResponse[UsersSessionsListData] | PendingApprovalResult[UsersSessionsListData] @@ -277,8 +332,10 @@ account.api_keys.create(organization_id, body, *, approval=, idempotency_key=, a account.api_keys.revoke(organization_id, key_id, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[None] | PendingApprovalResult[None] account.applications.revoke(client_id, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[None] | PendingApprovalResult[None] account.devices.remove(device_id, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[None] | PendingApprovalResult[None] +account.mfa.sms.remove(*, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[None] | PendingApprovalResult[None] account.organizations.leave(organization_id, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[None] | PendingApprovalResult[None] account.passkeys.remove(passkey_id, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[None] | PendingApprovalResult[None] +account.pipes.disconnect(provider, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[None] | PendingApprovalResult[None] account.profile.update(body, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[Profile] | PendingApprovalResult[Profile] account.sessions.revoke(session_id, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[None] | PendingApprovalResult[None] account.sessions.revoke_others(*, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[SessionsRevoked] | PendingApprovalResult[SessionsRevoked] diff --git a/tests/test_feature_flags.py b/tests/test_feature_flags.py new file mode 100644 index 0000000..16960c7 --- /dev/null +++ b/tests/test_feature_flags.py @@ -0,0 +1,70 @@ +"""The feature_flags claim on a signed-in user and on a raw claim set.""" + +from __future__ import annotations + +from urllib.parse import parse_qs, urlparse + +import pytest + +from cbox_id import ( + FEATURE_FLAGS_SCOPE, + CboxIdClient, + CboxIdConfig, + feature_flags, + has_feature, +) + +from .conftest import CLIENT_ID, ISSUER, NONCE, FakeInstance + +STORED = {"expected_state": "state-1", "code_verifier": "verifier-1", "nonce": NONCE} + + +def test_the_user_carries_the_flags_and_answers_has_feature(fake: FakeInstance) -> None: + fake.userinfo_response = {"sub": "user-1", "feature_flags": ["acme-beta", "new-dashboard"]} + + user = fake.client.authenticate(code="auth-code", state="state-1", **STORED) + + assert user.feature_flags == ["acme-beta", "new-dashboard"] + assert user.has_feature("new-dashboard") + assert not user.has_feature("old-reports") + assert has_feature(user, "acme-beta") + + +def test_an_absent_claim_is_none_not_empty(fake: FakeInstance) -> None: + user = fake.client.authenticate(code="auth-code", state="state-1", **STORED) + + assert user.feature_flags is None + assert not user.has_feature("anything") + + +def test_the_scope_is_requested_like_any_other(fake: FakeInstance) -> None: + config = CboxIdConfig( + issuer=ISSUER, + client_id=CLIENT_ID, + redirect_uri="https://app.test/auth/callback", + scopes=["openid", FEATURE_FLAGS_SCOPE], + ) + request = CboxIdClient(config, http_client=fake.http).create_authorization_request() + + assert FEATURE_FLAGS_SCOPE == "feature_flags" + assert parse_qs(urlparse(request.url).query)["scope"] == ["openid feature_flags"] + + +def test_not_requested_and_nothing_on_are_different() -> None: + assert feature_flags({}) is None + assert feature_flags({"feature_flags": []}) == [] + assert not has_feature({"feature_flags": []}, "x") + + +def test_anything_that_is_not_a_key_is_dropped_and_matching_is_exact() -> None: + claims = {"feature_flags": ["billing.v2", 7, "", None]} + + assert feature_flags(claims) == ["billing.v2"] + assert has_feature(claims, "billing.v2") + assert not has_feature(claims, "billing") + + +@pytest.mark.parametrize("claim", ["new-dashboard", {"new-dashboard": True}, 1]) +def test_a_malformed_claim_reads_as_absent_never_as_on(claim: object) -> None: + assert feature_flags({"feature_flags": claim}) is None + assert not has_feature({"feature_flags": claim}, "new-dashboard") diff --git a/tests/test_fga.py b/tests/test_fga.py new file mode 100644 index 0000000..3a8d836 --- /dev/null +++ b/tests/test_fga.py @@ -0,0 +1,213 @@ +"""Feature flag evaluation and fine-grained authorization through the environment client.""" + +from __future__ import annotations + +from urllib.parse import parse_qs, urlparse + +import pytest + +from cbox_id.management import fga_tuple + +from .management_helpers import API, FakeApi, env_client, json_response + +CHECK = { + "allowed": True, + "resource_type": "document", + "resource_id": "leave", + "relation": "viewer", + "subject": {"type": "user", "id": "alice", "relation": None}, + "consistency_token": "7.9f3c1a7be2d0", +} + + +def test_evaluates_every_flag_for_a_user_in_an_organization() -> None: + data = { + "user_id": "usr_1", + "organization_id": "org_1", + "feature_flags": ["acme-beta"], + "evaluations": [ + {"key": "acme-beta", "enabled": True, "reason": "organization_target"}, + {"key": "old-reports", "enabled": False, "reason": "disabled"}, + ], + } + fake = FakeApi(json_response({"data": data})) + + result = env_client(fake).feature_flags.evaluate( + {"user_id": "usr_1", "organization_id": "org_1"} + ) + + assert fake.calls[0].url == f"{API}/feature-flags/evaluate?user_id=usr_1&organization_id=org_1" + assert result.data["feature_flags"] == ["acme-beta"] + assert result.data["evaluations"][1]["reason"] == "disabled" + + +def test_writes_then_checks_at_least_as_fresh_as_the_write() -> None: + fake = FakeApi( + json_response({"data": {"written": 1, "deleted": 0, "consistency_token": "7.9f"}}), + json_response({"data": CHECK}), + ) + env = env_client(fake) + + written = env.fga.tuples.write( + { + "tuples": [ + { + "resource_type": "group", + "resource_id": "eng", + "relation": "member", + "subject": {"type": "user", "id": "alice"}, + } + ] + } + ) + answer = env.fga.check( + { + "resource_type": "document", + "resource_id": "leave", + "relation": "viewer", + "subject_type": "user", + "subject_id": "alice", + "consistency_token": written.data["consistency_token"], + } + ) + + assert fake.calls[0].method == "POST" + assert fake.calls[0].url == f"{API}/fga/tuples" + assert fake.calls[0].headers.get("idempotency-key") + assert parse_qs(urlparse(fake.calls[1].url).query)["consistency_token"] == ["7.9f"] + assert answer.data["allowed"] is True + + +def test_sends_a_batch_as_checks_in_the_tuple_notation() -> None: + fake = FakeApi( + json_response( + {"data": {"results": [CHECK, {**CHECK, "allowed": False}], "consistency_token": "7"}} + ) + ) + + result = env_client(fake).fga.check_batch( + { + "checks": [ + fga_tuple( + { + "resource_type": "document", + "resource_id": "leave", + "relation": "viewer", + "subject": {"type": "user", "id": "alice"}, + } + ), + "document:readme#editor@user:alice", + ] + } + ) + + url = urlparse(fake.calls[0].url) + assert url.path == "/api/v1/fga/check/batch" + assert parse_qs(url.query)["checks[]"] == [ + "document:leave#viewer@user:alice", + "document:readme#editor@user:alice", + ] + assert [r["allowed"] for r in result.data["results"]] == [True, False] + + +def test_deletes_lists_and_reads_and_replaces_the_schema() -> None: + schema = { + "defined": True, + "schema": "type user", + "version": 2, + "types": [], + "updated_at": None, + "consistency_token": "8.b", + } + page = {"has_more": False, "next_cursor": None} + fake = FakeApi( + json_response({"data": {"written": 0, "deleted": 1, "consistency_token": "8.a"}}), + json_response({"data": [{"type": "document", "id": "leave"}], "meta": page}), + json_response({"data": [{"type": "user", "id": "alice"}], "meta": page}), + json_response({"data": schema}), + json_response({"data": schema}), + ) + env = env_client(fake) + tuple_ = { + "resource_type": "group", + "resource_id": "eng", + "relation": "member", + "subject": {"type": "user", "id": "alice"}, + } + + assert env.fga.tuples.delete({"tuples": [tuple_]}).data["deleted"] == 1 + resources = env.fga.resources.list( + { + "resource_type": "document", + "relation": "viewer", + "subject_type": "user", + "subject_id": "alice", + } + ) + subjects = env.fga.subjects.list( + { + "resource_type": "document", + "resource_id": "leave", + "relation": "viewer", + "subject_type": "user", + "consistency_token": "8.a", + } + ) + env.fga.schema.get() + env.fga.schema.update({"schema": "type user"}) + + assert [f"{c.method} {urlparse(c.url).path}" for c in fake.calls] == [ + "POST /api/v1/fga/tuples/delete", + "GET /api/v1/fga/resources", + "GET /api/v1/fga/subjects", + "GET /api/v1/fga/schema", + "PUT /api/v1/fga/schema", + ] + assert parse_qs(urlparse(fake.calls[2].url).query)["consistency_token"] == ["8.a"] + assert fake.calls[4].body == {"schema": "type user"} + assert resources.data == [{"type": "document", "id": "leave"}] + assert subjects.data[0]["id"] == "alice" + + +def test_fga_tuple_writes_one_subject_and_a_userset() -> None: + assert ( + fga_tuple( + { + "resource_type": "folder", + "resource_id": "policies", + "relation": "viewer", + "subject": {"type": "group", "id": "eng", "relation": "member"}, + } + ) + == "folder:policies#viewer@group:eng#member" + ) + # Ids are the app's own and may carry a colon. + assert ( + fga_tuple( + { + "resource_type": "doc", + "resource_id": "a:b", + "relation": "viewer", + "subject": {"type": "user", "id": "u:1"}, + } + ) + == "doc:a:b#viewer@user:u:1" + ) + + +@pytest.mark.parametrize( + ("resource_type", "resource_id", "subject_id"), + [("doc", "a#b", "x"), ("doc", "a", "x@y"), ("doc:x", "a", "x"), ("doc", "", "x")], +) +def test_fga_tuple_refuses_what_the_notation_cannot_carry( + resource_type: str, resource_id: str, subject_id: str +) -> None: + with pytest.raises(ValueError): + fga_tuple( + { + "resource_type": resource_type, + "resource_id": resource_id, + "relation": "viewer", + "subject": {"type": "user", "id": subject_id}, + } + ) diff --git a/tests/test_pipes.py b/tests/test_pipes.py new file mode 100644 index 0000000..3151a01 --- /dev/null +++ b/tests/test_pipes.py @@ -0,0 +1,171 @@ +"""Pipes: leasing a person's connected-account token, and the typed refusals.""" + +from __future__ import annotations + +import json +from typing import Any + +import httpx +import pytest + +from cbox_id import ( + PipeLeaseDeniedError, + PipeLeaseError, + PipeNotConnectedError, + PipeReauthorizationRequiredError, + PipesClient, + PipeTemporarilyUnavailableError, + pipe_connect_url, +) + +from .conftest import CLIENT_ID, ISSUER, FakeInstance + +CONNECT = f"{ISSUER}/account/connected-services/github/connect" +LEASE = { + "access_token": "gho_abc", + "token_type": "Bearer", + "provider": "github", + "user_id": "usr_1", + "connection_id": "con_1", + "scopes": ["read:user", "repo"], + "expires_at": None, + "lease_expires_at": "2026-10-09T14:05:00+00:00", + "metadata": {}, +} + + +class Recorder: + def __init__(self, status: int, body: Any, headers: dict[str, str] | None = None) -> None: + self.requests: list[httpx.Request] = [] + self.status = status + self.body = body + self.headers = headers or {} + + def __call__(self, request: httpx.Request) -> httpx.Response: + self.requests.append(request) + if isinstance(self.body, str): + return httpx.Response(self.status, text=self.body, headers=self.headers) + return httpx.Response(self.status, json=self.body, headers=self.headers) + + +def pipes(recorder: Recorder, token: str = "app-token") -> PipesClient: + http = httpx.Client(transport=httpx.MockTransport(recorder)) + return PipesClient(ISSUER, token, client_id="cid_1", http_client=http) + + +def test_leases_a_token_for_a_named_person() -> None: + recorder = Recorder(200, LEASE) + + token = pipes(recorder).lease_token("github", user_id="usr_1", purpose="list-repos") + + request = recorder.requests[0] + assert str(request.url) == f"{ISSUER}/api/v1/vault/pipes/github/token" + assert request.method == "POST" + assert request.headers["authorization"] == "Bearer app-token" + assert json.loads(request.content) == {"purpose": "list-repos", "user_id": "usr_1"} + assert token.access_token == "gho_abc" + assert token.scopes == ["read:user", "repo"] + assert token.expires_at is None + assert token.lease_expires_at == "2026-10-09T14:05:00+00:00" + + +def test_leaves_user_id_out_for_a_token_issued_for_the_person() -> None: + recorder = Recorder(200, {**LEASE, "metadata": {"instance_url": "https://acme.sf.com"}}) + + token = pipes(recorder, "user-token").lease_token("salesforce", purpose="sync") + + assert json.loads(recorder.requests[0].content) == {"purpose": "sync"} + assert token.metadata == {"instance_url": "https://acme.sf.com"} + + +def test_not_connected_carries_the_connect_url() -> None: + body = {"error": "not_connected", "message": "Not connected.", "connect_url": CONNECT} + + with pytest.raises(PipeNotConnectedError) as raised: + pipes(Recorder(404, body)).lease_token("github", user_id="u", purpose="p") + + error = raised.value + assert isinstance(error, PipeLeaseError) + assert error.status == 404 + assert error.error == "not_connected" + assert error.connect_url == CONNECT + assert error.connect_url_with(client_id="cid_1", return_to="https://app.test/s") == ( + f"{CONNECT}?client_id=cid_1&return_to=https%3A%2F%2Fapp.test%2Fs" + ) + + +def test_a_provider_that_needs_reconnecting_is_typed() -> None: + body = {"error": "reauthorization_required", "message": "Again.", "connect_url": CONNECT} + + with pytest.raises(PipeReauthorizationRequiredError) as raised: + pipes(Recorder(409, body)).lease_token("github", purpose="p") + + assert raised.value.connect_url == CONNECT + + +def test_unavailable_carries_retry_after() -> None: + recorder = Recorder(503, {"error": "temporarily_unavailable"}, {"Retry-After": "30"}) + + with pytest.raises(PipeTemporarilyUnavailableError) as raised: + pipes(recorder).lease_token("google", purpose="p") + + assert raised.value.retry_after == 30 + + +def test_a_denied_lease_is_typed_and_a_missing_scope_is_not() -> None: + with pytest.raises(PipeLeaseDeniedError) as denied: + pipes(Recorder(403, {"error": "lease_denied"})).lease_token("github", purpose="p") + assert denied.value.connect_url_with(client_id="c") is None + + with pytest.raises(PipeLeaseError) as scope: + pipes(Recorder(403, {"error": "insufficient_scope"})).lease_token("github", purpose="p") + assert not isinstance(scope.value, PipeLeaseDeniedError) + assert scope.value.error == "insufficient_scope" + + +def test_survives_a_body_that_is_not_json() -> None: + with pytest.raises(PipeLeaseError) as raised: + pipes(Recorder(502, "bad gateway")).lease_token("github", purpose="p") + + assert raised.value.status == 502 + assert "html" not in str(raised.value) + + +def test_the_client_leases_with_its_own_cached_vault_lease_token(fake: FakeInstance) -> None: + seen: list[str] = [] + grants: list[str] = [] + original = fake.http._transport + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path.startswith("/api/v1/vault/pipes/"): + seen.append(request.headers["authorization"]) + return httpx.Response(200, json=LEASE) + if request.url.path == "/oauth/token": + grants.append(request.content.decode()) + return httpx.Response(200, json={"access_token": "lease-token", "expires_in": 3600}) + return original.handle_request(request) + + fake.http._transport = httpx.MockTransport(handler) + + fake.client.pipes.lease_token("github", user_id="u", purpose="p") + fake.client.pipes.lease_token("github", user_id="u", purpose="p") + + assert seen == ["Bearer lease-token", "Bearer lease-token"] + assert len(grants) == 1 + assert "scope=vault.lease" in grants[0] + + +def test_connect_urls(fake: FakeInstance) -> None: + assert pipe_connect_url( + f"{ISSUER}/", "github", client_id="cid_1", return_to="https://a.test/x" + ) == (f"{CONNECT}?client_id=cid_1&return_to=https%3A%2F%2Fa.test%2Fx") + assert pipe_connect_url(ISSUER, "notion") == ( + f"{ISSUER}/account/connected-services/notion/connect" + ) + assert fake.client.pipe_connect_url("slack", "https://app.test/i") == ( + f"{ISSUER}/account/connected-services/slack/connect" + f"?client_id={CLIENT_ID}&return_to=https%3A%2F%2Fapp.test%2Fi" + ) + assert fake.client.pipes.connect_url("slack") == ( + f"{ISSUER}/account/connected-services/slack/connect?client_id={CLIENT_ID}" + ) From 981cb7a51865273534a207e91a92755a52a5bd7b Mon Sep 17 00:00:00 2001 From: Sylvester Damgaard Date: Sat, 10 Oct 2026 08:54:48 +0200 Subject: [PATCH 2/3] Regenerate from wave-10 6b373319: fga.check_batch, documented 202 bodies The batch check is its own action now, so the generator's namespace folding is gone. An action whose own answer is 202 documents it as oneOf its body and the approval body; the generator reads that branch, so directories.sync returns its Directory. --- CHANGELOG.md | 4 +- openapi/environment.yaml | 49 ++++++++++++++++++- scripts/generate_management.py | 25 ++++++---- .../management/generated/environment.py | 12 ++--- tests/fixtures/management_surface.txt | 2 +- tests/test_fga.py | 11 +++++ 6 files changed, 83 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5792e3..e527457 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,8 +34,8 @@ reads as absent and the new endpoints answer 404. ### Changed -- The generator folds an action that is also another action's namespace into its parent - (`fga.check.batch` becomes `fga.check_batch()` next to `fga.check()`). +- The generator reads an action's own `202 Accepted` body (documented as `oneOf` it and the + approval body), so `env.directories.sync()` now returns the directory. ## [0.10.0] - 2026-10-08 diff --git a/openapi/environment.yaml b/openapi/environment.yaml index 237aad4..faed975 100644 --- a/openapi/environment.yaml +++ b/openapi/environment.yaml @@ -4945,7 +4945,52 @@ paths: description: 'Ask an HR system that syncs incrementally for everybody, not only what changed. Ignored by other providers.' responses: 202: - $ref: '#/components/responses/ApprovalRequired' + description: "Accepted — or held for a person's approval (`error: approval_required`): poll `approval.poll_url`, then repeat the request with `Cbox-Approval`." + content: + application/json: + schema: + oneOf: + - + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/Directory' + - + type: object + required: + - error + - message + - approval + properties: + error: + type: string + enum: + - approval_required + message: + type: string + approval: + type: object + required: + - id + - status + - binding_code + - expires_at + - poll_url + properties: + id: + type: string + status: + type: string + binding_code: + type: string + expires_at: + type: string + format: date-time + poll_url: + type: string + format: uri 400: $ref: '#/components/responses/EnvironmentRequired' 401: @@ -5917,7 +5962,7 @@ paths: summary: 'Run 1–100 checks in one round trip, each written resource#relation@subject (document:readme#viewer@user:alice), all at the same revision, answered in order.' description: 'Requires scope `fga:read`. Danger: read.' operationId: fga_check_batch - x-action: fga.check.batch + x-action: fga.check_batch x-scope: 'fga:read' x-danger: read parameters: diff --git a/scripts/generate_management.py b/scripts/generate_management.py index 987ce7e..50e10e6 100644 --- a/scripts/generate_management.py +++ b/scripts/generate_management.py @@ -650,6 +650,22 @@ def operations(self) -> list[Operation]: response_schema = schema if isinstance(schema, dict) else None break + # An action whose own answer is `202 Accepted` documents it as `oneOf` its body + # and the approval body. The branch that is not the approval is the result. + if "202" in responses and not {"200", "201", "204"} & responses.keys(): + accepted = self.deref(responses["202"], "responses") + accepted_json = obj(accepted.get("content")).get("application/json") + accepted_schema = ( + obj(accepted_json.get("schema")) if isinstance(accepted_json, dict) else {} + ) + own = [ + branch + for branch in arr(accepted_schema.get("oneOf")) + if isinstance(branch, dict) + and "approval_required" not in json.dumps(branch) + ] + response_schema = own[0] if own else None + query_names = {p.get("name") for p in query} name = action.split(".") if action is not None else self.derived_name(method, path) @@ -690,15 +706,6 @@ def operations(self) -> list[Operation]: for o in actions: o.name = o.name[1:] - # An action can be both a method and the namespace of another: `fga.check` and - # `fga.check.batch`. A member cannot be both, so the deeper one folds its last two - # segments together — `fga.check_batch()` next to `fga.check()`. - leaves = {".".join(o.name) for o in ops} - - for o in ops: - while len(o.name) > 2 and ".".join(o.name[:-1]) in leaves: - o.name = [*o.name[:-2], f"{o.name[-2]}_{o.name[-1]}"] - for o in ops: o.name = [identifier(segment) for segment in o.name] diff --git a/src/cbox_id/management/generated/environment.py b/src/cbox_id/management/generated/environment.py index 2b4e523..28c9ce1 100644 --- a/src/cbox_id/management/generated/environment.py +++ b/src/cbox_id/management/generated/environment.py @@ -4186,7 +4186,7 @@ class WebhooksUpdateBody(TypedDict): "feature_flags.list": OperationSpec(action="feature_flags.list", operation_id="feature_flags_list", method="GET", path="/feature-flags", path_params=(), scope="feature_flags:read", danger="read", approval=True, body=False, pagination="cursor"), "feature_flags.update": OperationSpec(action="feature_flags.update", operation_id="feature_flags_update", method="PATCH", path="/feature-flags/{id}", path_params=("id",), scope="feature_flags:write", danger="write", approval=True, body=True, pagination=None), "fga.check": OperationSpec(action="fga.check", operation_id="fga_check", method="GET", path="/fga/check", path_params=(), scope="fga:read", danger="read", approval=True, body=False, pagination=None), - "fga.check.batch": OperationSpec(action="fga.check.batch", operation_id="fga_check_batch", method="GET", path="/fga/check/batch", path_params=(), scope="fga:read", danger="read", approval=True, body=False, pagination=None), + "fga.check_batch": OperationSpec(action="fga.check_batch", operation_id="fga_check_batch", method="GET", path="/fga/check/batch", path_params=(), scope="fga:read", danger="read", approval=True, body=False, pagination=None), "fga.resources.list": OperationSpec(action="fga.resources.list", operation_id="fga_resources_list", method="GET", path="/fga/resources", path_params=(), scope="fga:read", danger="read", approval=True, body=False, pagination="cursor"), "fga.schema.get": OperationSpec(action="fga.schema.get", operation_id="fga_schema_get", method="GET", path="/fga/schema", path_params=(), scope="fga:read", danger="read", approval=True, body=False, pagination=None), "fga.schema.update": OperationSpec(action="fga.schema.update", operation_id="fga_schema_update", method="PUT", path="/fga/schema", path_params=(), scope="fga:schema", danger="critical", approval=True, body=True, pagination=None), @@ -5737,12 +5737,12 @@ def list_all(self, query: DirectoriesListQuery | None = None, *, headers: Mappin return self._core.paginate(ENVIRONMENT_OPERATIONS["directories.list"], (), query, headers=headers) @overload - def sync(self, id: str, body: DirectoriesSyncBody | None = None, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[None]: ... + def sync(self, id: str, body: DirectoriesSyncBody | None = None, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Directory]: ... @overload - def sync(self, id: str, body: DirectoriesSyncBody | None = None, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[None] | PendingApprovalResult[None]: ... + def sync(self, id: str, body: DirectoriesSyncBody | None = None, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Directory] | PendingApprovalResult[Directory]: ... - def sync(self, id: str, body: DirectoriesSyncBody | None = None, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[None] | PendingApprovalResult[None]: + def sync(self, id: str, body: DirectoriesSyncBody | None = None, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Directory] | PendingApprovalResult[Directory]: """Pull a Google Workspace, Microsoft Entra or HR-system directory now, on a worker. `full` asks an HR system for everybody rather than what changed. Requires scope `directory_sync:write`. Danger: write. @@ -6201,11 +6201,11 @@ def check_batch(self, query: FgaCheckBatchQuery, *, approval: ApprovalMode = "wa Requires scope `fga:read`. Danger: read. - ``GET /fga/check/batch`` · action ``fga.check.batch`` + ``GET /fga/check/batch`` · action ``fga.check_batch`` Scope ``fga:read`` · danger: read. May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. """ - return self._core.call(ENVIRONMENT_OPERATIONS["fga.check.batch"], (), query, approval=approval, approval_id=approval_id, headers=headers) + return self._core.call(ENVIRONMENT_OPERATIONS["fga.check_batch"], (), query, approval=approval, approval_id=approval_id, headers=headers) class FrontendKeysMethods: diff --git a/tests/fixtures/management_surface.txt b/tests/fixtures/management_surface.txt index 5683819..96d0f02 100644 --- a/tests/fixtures/management_surface.txt +++ b/tests/fixtures/management_surface.txt @@ -65,7 +65,7 @@ environment.directories.delete(id, body=, *, approval=, idempotency_key=, approv environment.directories.get(id, query=, *, approval=, approval_id=, headers=) -> ApiResponse[Directory] | PendingApprovalResult[Directory] environment.directories.list(query=, *, approval=, approval_id=, headers=) -> ApiResponse[DirectoriesListData] | PendingApprovalResult[DirectoriesListData] environment.directories.list_all(query=, *, headers=) -> Iterator[Directory] -environment.directories.sync(id, body=, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[None] | PendingApprovalResult[None] +environment.directories.sync(id, body=, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[Directory] | PendingApprovalResult[Directory] environment.directories.update(id, body, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[Directory] | PendingApprovalResult[Directory] environment.directories.credentials.replace(id, body, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[Directory] | PendingApprovalResult[Directory] environment.directories.groups.list(id, query=, *, approval=, approval_id=, headers=) -> ApiResponse[DirectoriesGroupsListData] | PendingApprovalResult[DirectoriesGroupsListData] diff --git a/tests/test_fga.py b/tests/test_fga.py index 3a8d836..8b2cb7d 100644 --- a/tests/test_fga.py +++ b/tests/test_fga.py @@ -211,3 +211,14 @@ def test_fga_tuple_refuses_what_the_notation_cannot_carry( "subject": {"type": "user", "id": subject_id}, } ) + + +def test_an_action_that_answers_202_returns_its_own_body() -> None: + directory = {"id": "dir_1", "name": "Workday"} + fake = FakeApi(json_response({"data": directory}, 202)) + + result = env_client(fake).directories.sync("dir_1", {"full": True}) + + assert len(fake.calls) == 1 + assert result.status == 202 + assert result.data["id"] == "dir_1" From 4259abb2344a81b34aab9e418c85be7bf93f857e Mon Sep 17 00:00:00 2001 From: Sylvester Damgaard Date: Sat, 10 Oct 2026 22:53:22 +0200 Subject: [PATCH 3/3] Release prep 0.11.0: regenerate from cbox-id main, frontend config per organization - Vendored environment spec from cbox-id main (aa419d1b): environment sign-in methods (signin.social update/enable/disable/inherit/offered), branding appearance with data-URI logo and favicon. - FrontendClient.config(organization=...) and FrontendConfig.methods. - 0.11.0 in pyproject.toml and the changelog. --- CHANGELOG.md | 11 +- README.md | 3 +- openapi/environment.yaml | 607 +++++++++++++++++- pyproject.toml | 2 +- src/cbox_id/frontend.py | 33 +- .../management/generated/environment.py | 280 +++++++- tests/fixtures/management_surface.txt | 5 + tests/test_frontend.py | 19 + 8 files changed, 924 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e527457..26e4e5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ by their tags and commit history. ## [Unreleased] +## [0.11.0] - 2026-10-10 + Needs laravel-id 1.24 / the Cbox ID release with feature flags, fine-grained authorization, Pipes and SMS as a second factor. Against an older instance the new claim reads as absent and the new endpoints answer 404. @@ -19,10 +21,17 @@ reads as absent and the new endpoints answer 404. `env.feature_flags.*` (including `evaluate({"user_id", "organization_id"})`), `env.fga.*` (`check`, `check_batch`, tuples write/delete/list, resources and subjects lists, schema get/update/validate, with `consistency_token` on every read), `env.pipes.*` (pipe setup, - grants, connections), `env.radar.*`, `env.signin.sms.*`, `env.users.mfa.sms.remove()`, the + grants, connections), `env.radar.*`, `env.signin.sms.*`, the environment sign-in methods + (`env.signin.social.*` update/enable/disable/inherit/offered, `environment_wide` / + `organization_id` on social set, the auth policy's method fields), + `env.branding.appearance.set()` taking the logo and favicon as base64 data URIs, `env.users.mfa.sms.remove()`, the HRIS directory routes (`env.directories.*`), and on the account plane `me.mfa.sms.remove()` and `me.pipes.disconnect()`. `fga_tuple()` writes a tuple in the notation the batch check takes. +- `FrontendClient.config(organization=...)`: the sign-in document for one organization (by + id or slug), whose `social` list is exactly what that organization's hosted page offers. + Each organization is cached on its own. `FrontendConfig.methods` says whether passkeys and + magic links are switched on. - Pipes: `client.pipes.lease_token(provider, user_id=..., purpose=...)` leases a fresh token for a person's connected account with a cached client-credentials `vault.lease` token; `PipesClient(issuer, access_token)` does the same with a token you hold. Refusals diff --git a/README.md b/README.md index 4c246b5..6961691 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,8 @@ from cbox_id import FrontendClient frontend = FrontendClient("https://id.acme.com", "pk_live_…") -config = frontend.config() # endpoints, social buttons, the customer's theme +config = frontend.config() # endpoints, social buttons, which methods are on, the theme +acme = frontend.config(organization="acme") # the buttons acme's hosted page shows session = frontend.session(token) # session.user is None when nobody is signed in ``` diff --git a/openapi/environment.yaml b/openapi/environment.yaml index faed975..24a0866 100644 --- a/openapi/environment.yaml +++ b/openapi/environment.yaml @@ -3476,7 +3476,7 @@ paths: put: tags: - Branding - summary: 'Set the hosted sign-in theme (preset, colours, corners, type, logo) for the environment default or one organization.' + summary: 'Set the hosted sign-in theme (preset, colours, corners, type, uploaded logo and favicon) for the environment default or one organization.' description: 'Requires scope `branding:write`. Danger: write.' operationId: branding_appearance_set x-action: branding.appearance.set @@ -3520,7 +3520,7 @@ paths: maxLength: 32 font: type: string - description: 'Type family: system, geometric, serif.' + description: 'Type family: system, inter, geometric, rounded, serif.' maxLength: 32 light: type: object @@ -3558,9 +3558,14 @@ paths: type: - string - 'null' - description: 'An https URL for the logo; null removes it, left out keeps it.' - format: uri - maxLength: 2048 + description: 'The logo as a base64 data: URI (data:image/png;base64,…): PNG, JPEG or WebP, at most 1024 KB and 4096px on the longest side. SVG and remote URLs are refused. Null removes it; left out keeps it.' + maxLength: 1398204 + favicon: + type: + - string + - 'null' + description: 'The favicon as a base64 data: URI (data:image/png;base64,…): PNG, ICO or WebP, at most 256 KB and 1024px on the longest side. SVG and remote URLs are refused. Null removes it; left out keeps it.' + maxLength: 349628 required: - theme responses: @@ -3615,6 +3620,8 @@ paths: security: - EnvironmentApiKey: [] + - + ManagementAccessToken: [] - WorkspaceAccessToken: [] responses: @@ -13420,7 +13427,7 @@ paths: patch: tags: - Sign-in - summary: "Change the authentication policy of the environment baseline, or tighten one organization's override. Requiring SSO signs out password sessions." + summary: "Change the authentication policy of the environment baseline — including whether passkeys and magic links are offered and how long sessions last — or tighten one organization's override. Requiring SSO signs out password sessions." description: 'Requires scope `signin:write`. Danger: critical.' operationId: signin_policy_update x-action: signin.policy.update @@ -13491,6 +13498,29 @@ paths: description: 'Failed attempts before an account is locked; null for the deployment default (10 inside 15 minutes unless CBOX_ID_LOCKOUT_THRESHOLD says otherwise).' minimum: 3 maximum: 100 + passkeys: + type: boolean + description: 'Environment only. Whether people may sign in with a passkey, and add one. Off at the deployment (CBOX_ID_PASSKEYS_ENABLED=false) stays off.' + magic_link: + type: boolean + description: 'Environment only. Whether a one-time sign-in link may be emailed. Off at the deployment (CBOX_ID_MAGIC_LINK_ENABLED=false) stays off.' + session_idle_minutes: + type: + - integer + - 'null' + description: "Environment only. Minutes of inactivity before a session ends; null for the deployment's. No more than the deployment allows (CBOX_ID_SESSION_IDLE_MINUTES)." + minimum: 1 + maximum: 525600 + session_absolute_minutes: + type: + - integer + - 'null' + description: "Environment only. Minutes a session lasts at most, however active; null for the deployment's. No more than the deployment allows (CBOX_ID_SESSION_TTL_MINUTES)." + minimum: 5 + maximum: 525600 + bot_challenge: + type: boolean + description: 'Environment only. Whether a sign-up Radar flags is asked to prove it is a person (Cloudflare Turnstile). Has no effect where the deployment has no Turnstile keys.' responses: 200: description: OK @@ -13972,7 +14002,7 @@ paths: get: tags: - Sign-in - summary: 'List the social login providers (Google, GitHub, Apple…) enabled in this environment, optionally for one organization.' + summary: "List the social login providers (Google, GitHub, Apple…) set up in this environment — the environment's own and organizations' — optionally for one organization." description: 'Requires scope `signin:read`. Danger: read.' operationId: signin_social_list x-action: signin.social.list @@ -13985,8 +14015,18 @@ paths: required: false schema: type: string - description: "Only this organization's providers." + description: "Only this organization's own providers (not the ones it inherits — see signin.social.offered)." maxLength: 64 + - + name: level + in: query + required: false + schema: + type: string + description: "Only the environment's providers, which every organization inherits, or only organizations' own." + enum: + - environment + - organization - name: limit in: query @@ -14042,7 +14082,7 @@ paths: post: tags: - Sign-in - summary: 'Enable a social login provider (Google, GitHub, Apple…) for one organization with its client credentials. The secret is never returned.' + summary: 'Enable a social login provider (Google, GitHub, Apple…) for the whole environment or for one organization, with its client credentials. The secret is never returned.' description: 'Requires scope `signin:write`. Danger: critical.' operationId: signin_social_set x-action: signin.social.set @@ -14067,9 +14107,14 @@ paths: type: object properties: organization_id: - type: string - description: 'The organization whose sign-in page offers it.' + type: + - string + - 'null' + description: 'The organization the provider belongs to. Send this or environment_wide.' maxLength: 64 + environment_wide: + type: boolean + description: "True to make the provider the environment's own rather than one organization's. Send this or organization_id." provider: type: string description: 'The catalogue key: google, microsoft, okta, auth0, keycloak, gitlab, slack, github, discord, apple, facebook, linkedin, bitbucket, xero, intuit.' @@ -14130,8 +14175,20 @@ paths: - 'null' description: 'Team ID (Apple).' maxLength: 5000 + scopes: + type: array + description: 'Extra scopes to request beyond the ones sign-in needs (for example `read:org` on GitHub). Replaces any extra scopes already set; an empty list removes them.' + maxItems: 20 + items: + type: string + maxLength: 200 + reserved_id: + type: + - string + - 'null' + description: 'A ULID to create the provider under, so its redirect URI (which contains the id) can be registered with the provider first. Left out, one is minted.' + maxLength: 26 required: - - organization_id - provider - client_id responses: @@ -14162,6 +14219,132 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' + '/sign-in/social-providers/inherited/{provider}': + put: + tags: + - Sign-in + summary: "Turn one of the environment's social login providers off (or back on) for one organization's sign-in page." + description: 'Requires scope `signin:write`. Danger: write.' + operationId: signin_social_inherit + x-action: signin.social.inherit + x-scope: 'signin:write' + x-danger: write + parameters: + - + name: provider + in: path + required: true + schema: + type: string + description: 'The catalogue key: google, github…' + maxLength: 100 + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + ManagementAccessToken: [] + - + WorkspaceAccessToken: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + organization_id: + type: string + description: 'The organization whose sign-in page this is about.' + maxLength: 64 + offered: + type: boolean + description: "False to stop offering the environment's provider on this organization's page; true to offer it again." + required: + - organization_id + - offered + responses: + 200: + description: OK + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/OfferedSocialProviders' + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' + /sign-in/social-providers/offered: + get: + tags: + - Sign-in + summary: "The social login buttons one organization's sign-in page shows (or the plain sign-in page's), after inheritance from the environment, with where each comes from." + description: 'Requires scope `signin:read`. Danger: read.' + operationId: signin_social_offered + x-action: signin.social.offered + x-scope: 'signin:read' + x-danger: read + parameters: + - + name: organization_id + in: query + required: false + schema: + type: string + description: 'The organization whose sign-in page to resolve. Left out, the plain sign-in page, before anybody has said who they are.' + maxLength: 64 + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + WorkspaceAccessToken: [] + responses: + 200: + description: OK + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/OfferedSocialProviders' + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' '/sign-in/social-providers/{id}': delete: tags: @@ -14222,6 +14405,275 @@ paths: $ref: '#/components/responses/UnprocessableEntity' 429: $ref: '#/components/responses/TooManyRequests' + patch: + tags: + - Sign-in + summary: "Change a social login provider's client credentials, provider values or extra scopes. Secrets left out keep the ones on file." + description: 'Requires scope `signin:write`. Danger: critical.' + operationId: signin_social_update + x-action: signin.social.update + x-scope: 'signin:write' + x-danger: critical + parameters: + - + name: id + in: path + required: true + schema: + type: string + description: "The provider's id." + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + ManagementAccessToken: [] + - + WorkspaceAccessToken: [] + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + organization_id: + type: + - string + - 'null' + description: "Only change it if it is this organization's; anything else is a 404." + maxLength: 64 + client_id: + type: string + description: 'A new client id. For Apple, the Services ID.' + maxLength: 400 + client_secret: + type: + - string + - 'null' + description: 'A new client secret. Write-only; left out or blank keeps the one on file.' + maxLength: 5000 + parameters: + type: object + description: "What the chosen provider needs besides its client credentials — Microsoft's directory, Okta's domain, Apple's team id, key id and private key." + properties: + directory: + type: + - string + - 'null' + description: 'Directory (tenant) ID (Microsoft Entra ID).' + maxLength: 5000 + domain: + type: + - string + - 'null' + description: 'Okta domain (Okta).' + maxLength: 5000 + host: + type: + - string + - 'null' + description: 'Keycloak host (Keycloak).' + maxLength: 5000 + key_id: + type: + - string + - 'null' + description: 'Key ID (Apple).' + maxLength: 5000 + private_key: + type: + - string + - 'null' + description: 'Private key (.p8) (Apple).' + maxLength: 5000 + realm: + type: + - string + - 'null' + description: 'Realm (Keycloak).' + maxLength: 5000 + team_id: + type: + - string + - 'null' + description: 'Team ID (Apple).' + maxLength: 5000 + scopes: + type: array + description: 'Extra scopes to request beyond the ones sign-in needs (for example `read:org` on GitHub). Replaces any extra scopes already set; an empty list removes them.' + maxItems: 20 + items: + type: string + maxLength: 200 + responses: + 200: + description: OK + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/SocialProvider' + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' + '/sign-in/social-providers/{id}/disable': + post: + tags: + - Sign-in + summary: 'Turn a social login provider off without removing it. People who used it keep their accounts.' + description: 'Requires scope `signin:write`. Danger: critical.' + operationId: signin_social_disable + x-action: signin.social.disable + x-scope: 'signin:write' + x-danger: critical + parameters: + - + name: id + in: path + required: true + schema: + type: string + description: "The provider's id." + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + ManagementAccessToken: [] + - + WorkspaceAccessToken: [] + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + organization_id: + type: + - string + - 'null' + description: "Only if it is this organization's; anything else is a 404." + maxLength: 64 + responses: + 200: + description: OK + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/SocialProvider' + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' + '/sign-in/social-providers/{id}/enable': + post: + tags: + - Sign-in + summary: 'Turn a social login provider that was turned off back on, with the credentials it already has.' + description: 'Requires scope `signin:write`. Danger: write.' + operationId: signin_social_enable + x-action: signin.social.enable + x-scope: 'signin:write' + x-danger: write + parameters: + - + name: id + in: path + required: true + schema: + type: string + description: "The provider's id." + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CboxApproval' + - $ref: '#/components/parameters/CboxEnvironment' + security: + - + EnvironmentApiKey: [] + - + ManagementAccessToken: [] + - + WorkspaceAccessToken: [] + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + organization_id: + type: + - string + - 'null' + description: "Only if it is this organization's; anything else is a 404." + maxLength: 64 + responses: + 200: + description: OK + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/SocialProvider' + 202: + $ref: '#/components/responses/ApprovalRequired' + 400: + $ref: '#/components/responses/EnvironmentRequired' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 409: + $ref: '#/components/responses/Conflict' + 422: + $ref: '#/components/responses/UnprocessableEntity' + 429: + $ref: '#/components/responses/TooManyRequests' /sod-policies: post: tags: @@ -20232,6 +20684,11 @@ components: - mfa - sso - lockout_threshold + - passkeys + - magic_link + - session_idle_minutes + - session_absolute_minutes + - bot_challenge properties: min_length: type: integer @@ -20265,6 +20722,45 @@ components: - integer - 'null' description: 'Failed attempts before a lockout; null for none.' + passkeys: + type: boolean + description: "Environment-wide. Whether passkeys may be used to sign in and be added. An organization's override never changes it." + magic_link: + type: boolean + description: 'Environment-wide. Whether a one-time sign-in link may be emailed.' + session_idle_minutes: + type: + - integer + - 'null' + description: "Environment-wide. Minutes of inactivity before a session ends; null for the deployment's." + session_absolute_minutes: + type: + - integer + - 'null' + description: "Environment-wide. Minutes a session lasts at most; null for the deployment's." + bot_challenge: + type: boolean + description: 'Environment-wide. Whether a sign-up Radar flags is challenged with Turnstile, where the deployment has keys.' + SignInMethodsInForce: + type: object + required: + - passkeys + - magic_link + - session_idle_minutes + - session_absolute_minutes + - bot_challenge + properties: + passkeys: + type: boolean + magic_link: + type: boolean + session_idle_minutes: + type: integer + description: '0 for no idle timeout.' + session_absolute_minutes: + type: integer + bot_challenge: + type: boolean SignInPolicy: type: object required: @@ -20273,6 +20769,8 @@ components: - baseline - override - inheriting + - in_force + - deployment properties: organization_id: type: @@ -20292,17 +20790,26 @@ components: inheriting: type: boolean description: 'True for an organization with no override of its own.' + in_force: + $ref: '#/components/schemas/SignInMethodsInForce' + description: "The environment-wide methods and session lengths actually in force, after the deployment's ceiling." + deployment: + $ref: '#/components/schemas/SignInMethodsInForce' + description: "The deployment's ceiling: which methods it offers at all, the longest sessions it allows, and whether it has Turnstile keys." SocialProvider: type: object required: - id - organization_id + - level - provider - name - protocol - status + - enabled + - scopes - callback_uri - description: 'Its client secret and private key are write-only and never returned.' + description: "Its client secret and private key are write-only and never returned. An environment provider (`level: environment`) is offered on every organization's sign-in page unless the organization has its own for the same provider or turned it off." properties: id: type: string @@ -20310,6 +20817,20 @@ components: type: - string - 'null' + description: "null for the environment's own provider." + level: + type: string + enum: + - environment + - organization + enabled: + type: boolean + description: 'False when it is turned off; its credentials are kept.' + scopes: + type: array + items: + type: string + description: 'Extra scopes requested on top of the ones sign-in needs.' provider: type: - string @@ -20332,6 +20853,51 @@ components: - string - 'null' format: date-time + OfferedSocialProviders: + type: object + required: + - organization_id + - providers + - not_inherited + description: 'The social login buttons one sign-in page shows, after inheritance.' + properties: + organization_id: + type: + - string + - 'null' + description: 'null for the plain sign-in page, before anybody has said who they are.' + providers: + type: array + items: + type: object + required: + - id + - provider + - name + - source + - callback_uri + properties: + id: + type: string + provider: + type: + - string + - 'null' + name: + type: string + source: + type: string + enum: + - environment + - organization + description: "`environment` when inherited; `organization` when the organization's own stands in its place." + callback_uri: + type: string + not_inherited: + type: array + items: + type: string + description: "The environment's providers this organization turned off for its page." FrontendKey: type: object required: @@ -20538,6 +21104,8 @@ components: - customized - theme - logo + - favicon + - remote_logo_ignored properties: organization_id: type: @@ -20564,6 +21132,15 @@ components: type: - string - 'null' + description: 'The uploaded logo, served by this application at /brand-assets/…; null when none is uploaded at this level. Never a remote URL.' + favicon: + type: + - string + - 'null' + description: 'The uploaded favicon, served the same way; null when none.' + remote_logo_ignored: + type: boolean + description: 'A remote logo URL saved before logos became uploads is still stored at this level. It is never fetched and no longer shown on any hosted page; upload a logo (or send `logo: null`) to clear it.' WhitelabelBranding: type: object required: @@ -20602,12 +21179,12 @@ components: type: - string - 'null' - description: 'Uploaded from the console.' + description: "Uploaded with `PUT /branding/appearance` (the console's Branding page)." favicon_url: type: - string - 'null' - description: 'Uploaded from the console.' + description: "Uploaded with `PUT /branding/appearance` (the console's Branding page)." CustomDomain: type: object required: diff --git a/pyproject.toml b/pyproject.toml index 79cdb61..89a06e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "cbox-id-client" -version = "0.10.0" +version = "0.11.0" description = "Turnkey Cbox ID client for Python — OpenID Connect login (PKCE + id_token verification via JWKS), hosted profile-management redirect, machine tokens, UserInfo, RFC 7662 introspection, and webhook signature verification." readme = "README.md" requires-python = ">=3.10" diff --git a/src/cbox_id/frontend.py b/src/cbox_id/frontend.py index 6c593ef..89131df 100644 --- a/src/cbox_id/frontend.py +++ b/src/cbox_id/frontend.py @@ -18,6 +18,7 @@ from dataclasses import dataclass, field from typing import Any +from urllib.parse import urlencode import httpx @@ -33,8 +34,14 @@ class FrontendConfig: mode: str | None issuer: str endpoints: dict[str, str] + #: The social buttons to draw, exactly as the hosted sign-in page would: the + #: environment's own providers, or — with ``config(organization=…)`` — that + #: organization's. social: list[dict[str, str]] = field(default_factory=list) appearance: dict[str, Any] | None = None + #: Which other sign-in methods are switched on (``passkeys``, ``magic_link``), so a box + #: does not draw a button whose endpoint answers 403. ``None`` from older instances. + methods: dict[str, bool] | None = None @dataclass(frozen=True, slots=True) @@ -79,32 +86,46 @@ def __init__( # request sets up and tears down a connection pool for every call, and left a # caller with a configured client — proxy, retries, custom TLS — no way to pass it. self._http_client = http_client - self._config: FrontendConfig | None = None + self._configs: dict[str, FrontendConfig] = {} @property def is_live(self) -> bool: """Whether this key drives real sign-ins — handy for a "test mode" badge.""" return self._key.startswith("pk_live_") - def config(self) -> FrontendConfig: + def config(self, organization: str | None = None) -> FrontendConfig: """The public sign-in configuration. Cached on the instance. The document is small and changes when somebody edits it in the console, so a long-lived process picks changes up when it next builds a client rather than mid-request — the right trade for something that decides layout. + + ``organization`` (an id or slug, the way a hosted page is addressed) asks for that + organization's sign-in box: ``social`` is then exactly what its hosted page offers. + Each organization is cached on its own; one the environment does not have is + answered as no hint at all. """ - if self._config is None: - body = self._get("/frontend/v1/config") + key = organization or "" + + if key not in self._configs: + path = "/frontend/v1/config" + if key: + path += f"?{urlencode({'organization': key})}" + body = self._get(path) + methods = body.get("methods") - self._config = FrontendConfig( + self._configs[key] = FrontendConfig( mode=body.get("mode"), issuer=body.get("issuer", self._issuer), endpoints=body.get("endpoints", {}), social=body.get("social", []), appearance=body.get("appearance"), + methods={k: v for k, v in methods.items() if isinstance(v, bool)} + if isinstance(methods, dict) + else None, ) - return self._config + return self._configs[key] def session(self, access_token: str | None = None) -> FrontendSession: """Who is signed in, given a token you already hold. diff --git a/src/cbox_id/management/generated/environment.py b/src/cbox_id/management/generated/environment.py index 28c9ce1..a62414b 100644 --- a/src/cbox_id/management/generated/environment.py +++ b/src/cbox_id/management/generated/environment.py @@ -625,6 +625,31 @@ class SignInRules(TypedDict): #: Failed attempts before a lockout; null for none. lockout_threshold: int | None + #: Environment-wide. Whether passkeys may be used to sign in and be added. An organization's override never changes it. + passkeys: bool + + #: Environment-wide. Whether a one-time sign-in link may be emailed. + magic_link: bool + + #: Environment-wide. Minutes of inactivity before a session ends; null for the deployment's. + session_idle_minutes: int | None + + #: Environment-wide. Minutes a session lasts at most; null for the deployment's. + session_absolute_minutes: int | None + + #: Environment-wide. Whether a sign-up Radar flags is challenged with Turnstile, where the deployment has keys. + bot_challenge: bool + + +class SignInMethodsInForce(TypedDict): + passkeys: bool + magic_link: bool + + #: 0 for no idle timeout. + session_idle_minutes: int + session_absolute_minutes: int + bot_challenge: bool + class SignInPolicy(TypedDict): #: null for the environment baseline. @@ -640,12 +665,27 @@ class SignInPolicy(TypedDict): #: True for an organization with no override of its own. inheriting: bool + #: The environment-wide methods and session lengths actually in force, after the deployment's ceiling. + in_force: SignInMethodsInForce + + #: The deployment's ceiling: which methods it offers at all, the longest sessions it allows, and whether it has Turnstile keys. + deployment: SignInMethodsInForce + class SocialProvider(TypedDict): - """Its client secret and private key are write-only and never returned.""" + """Its client secret and private key are write-only and never returned. An environment provider (`level: environment`) is offered on every organization's sign-in page unless the organization has its own for the same provider or turned it off.""" id: str + + #: null for the environment's own provider. organization_id: str | None + level: Literal["environment", "organization"] + + #: False when it is turned off; its credentials are kept. + enabled: bool + + #: Extra scopes requested on top of the ones sign-in needs. + scopes: list[str] #: The catalogue key: google, github, apple… provider: str | None @@ -658,6 +698,28 @@ class SocialProvider(TypedDict): created_at: NotRequired[str | None] +class OfferedSocialProvidersProvidersItem(TypedDict): + id: str + provider: str | None + name: str + + #: `environment` when inherited; `organization` when the organization's own stands in its place. + source: Literal["environment", "organization"] + callback_uri: str + + +class OfferedSocialProviders(TypedDict): + """The social login buttons one sign-in page shows, after inheritance.""" + + + #: null for the plain sign-in page, before anybody has said who they are. + organization_id: str | None + providers: list[OfferedSocialProvidersProvidersItem] + + #: The environment's providers this organization turned off for its page. + not_inherited: list[str] + + class FrontendKey(TypedDict): """A publishable key. Returned in full: it is public by design; the origins are the control.""" @@ -765,8 +827,16 @@ class Appearance(TypedDict): #: Whether this level has a theme of its own. customized: bool theme: AppearanceTheme + + #: The uploaded logo, served by this application at /brand-assets/…; null when none is uploaded at this level. Never a remote URL. logo: str | None + #: The uploaded favicon, served the same way; null when none. + favicon: str | None + + #: A remote logo URL saved before logos became uploads is still stored at this level. It is never fetched and no longer shown on any hosted page; upload a logo (or send `logo: null`) to clear it. + remote_logo_ignored: bool + class WhitelabelBranding(TypedDict): #: null for the environment default. @@ -776,10 +846,10 @@ class WhitelabelBranding(TypedDict): email_from_name: str | None email_template: str | None - #: Uploaded from the console. + #: Uploaded with `PUT /branding/appearance` (the console's Branding page). logo_url: str | None - #: Uploaded from the console. + #: Uploaded with `PUT /branding/appearance` (the console's Branding page). favicon_url: str | None @@ -2110,7 +2180,7 @@ class BrandingAppearanceSetBodyTheme(TypedDict): #: Corner radius: 0rem, 0.25rem, 0.375rem, 0.5rem, 0.75rem, 1rem. radius: NotRequired[str] - #: Type family: system, geometric, serif. + #: Type family: system, inter, geometric, rounded, serif. font: NotRequired[str] #: Colours as #rrggbb. One left out takes the preset's. @@ -2127,9 +2197,12 @@ class BrandingAppearanceSetBody(TypedDict): #: The theme. Both modes must be readable: text needs 4.5:1 against the background, muted text 3:1. theme: BrandingAppearanceSetBodyTheme - #: An https URL for the logo; null removes it, left out keeps it. + #: The logo as a base64 data: URI (data:image/png;base64,…): PNG, JPEG or WebP, at most 1024 KB and 4096px on the longest side. SVG and remote URLs are refused. Null removes it; left out keeps it. logo: NotRequired[str | None] + #: The favicon as a base64 data: URI (data:image/png;base64,…): PNG, ICO or WebP, at most 256 KB and 1024px on the longest side. SVG and remote URLs are refused. Null removes it; left out keeps it. + favicon: NotRequired[str | None] + class BrandingWhitelabelGetQuery(TypedDict): #: Read this organization's branding. Left out, the environment default. @@ -3592,6 +3665,21 @@ class SigninPolicyUpdateBody(TypedDict): #: Failed attempts before an account is locked; null for the deployment default (10 inside 15 minutes unless CBOX_ID_LOCKOUT_THRESHOLD says otherwise). lockout_threshold: NotRequired[int | None] + #: Environment only. Whether people may sign in with a passkey, and add one. Off at the deployment (CBOX_ID_PASSKEYS_ENABLED=false) stays off. + passkeys: NotRequired[bool] + + #: Environment only. Whether a one-time sign-in link may be emailed. Off at the deployment (CBOX_ID_MAGIC_LINK_ENABLED=false) stays off. + magic_link: NotRequired[bool] + + #: Environment only. Minutes of inactivity before a session ends; null for the deployment's. No more than the deployment allows (CBOX_ID_SESSION_IDLE_MINUTES). + session_idle_minutes: NotRequired[int | None] + + #: Environment only. Minutes a session lasts at most, however active; null for the deployment's. No more than the deployment allows (CBOX_ID_SESSION_TTL_MINUTES). + session_absolute_minutes: NotRequired[int | None] + + #: Environment only. Whether a sign-up Radar flags is asked to prove it is a person (Cloudflare Turnstile). Has no effect where the deployment has no Turnstile keys. + bot_challenge: NotRequired[bool] + class SigninSelfServiceSignupSetBody(TypedDict): #: On: anyone may create an account and their own organization. Off: people join by invitation. @@ -3614,10 +3702,31 @@ class SigninSocialDeleteBody(TypedDict): organization_id: NotRequired[str | None] +class SigninSocialDisableBody(TypedDict): + #: Only if it is this organization's; anything else is a 404. + organization_id: NotRequired[str | None] + + +class SigninSocialEnableBody(TypedDict): + #: Only if it is this organization's; anything else is a 404. + organization_id: NotRequired[str | None] + + +class SigninSocialInheritBody(TypedDict): + #: The organization whose sign-in page this is about. + organization_id: str + + #: False to stop offering the environment's provider on this organization's page; true to offer it again. + offered: bool + + class SigninSocialListQuery(TypedDict): - #: Only this organization's providers. + #: Only this organization's own providers (not the ones it inherits — see signin.social.offered). organization_id: NotRequired[str] + #: Only the environment's providers, which every organization inherits, or only organizations' own. + level: NotRequired[Literal["environment", "organization"]] + #: Items per page, 1–100. Default 50. limit: NotRequired[int] @@ -3628,6 +3737,11 @@ class SigninSocialListQuery(TypedDict): SigninSocialListData: TypeAlias = 'list[SocialProvider]' +class SigninSocialOfferedQuery(TypedDict): + #: The organization whose sign-in page to resolve. Left out, the plain sign-in page, before anybody has said who they are. + organization_id: NotRequired[str] + + class SigninSocialSetBodyParameters(TypedDict): """What the chosen provider needs besides its client credentials — Microsoft's directory, Okta's domain, Apple's team id, key id and private key.""" @@ -3655,8 +3769,11 @@ class SigninSocialSetBodyParameters(TypedDict): class SigninSocialSetBody(TypedDict): - #: The organization whose sign-in page offers it. - organization_id: str + #: The organization the provider belongs to. Send this or environment_wide. + organization_id: NotRequired[str | None] + + #: True to make the provider the environment's own rather than one organization's. Send this or organization_id. + environment_wide: NotRequired[bool] #: The catalogue key: google, microsoft, okta, auth0, keycloak, gitlab, slack, github, discord, apple, facebook, linkedin, bitbucket, xero, intuit. provider: str @@ -3670,6 +3787,55 @@ class SigninSocialSetBody(TypedDict): #: What the chosen provider needs besides its client credentials — Microsoft's directory, Okta's domain, Apple's team id, key id and private key. parameters: NotRequired[SigninSocialSetBodyParameters] + #: Extra scopes to request beyond the ones sign-in needs (for example `read:org` on GitHub). Replaces any extra scopes already set; an empty list removes them. + scopes: NotRequired[list[str]] + + #: A ULID to create the provider under, so its redirect URI (which contains the id) can be registered with the provider first. Left out, one is minted. + reserved_id: NotRequired[str | None] + + +class SigninSocialUpdateBodyParameters(TypedDict): + """What the chosen provider needs besides its client credentials — Microsoft's directory, Okta's domain, Apple's team id, key id and private key.""" + + + #: Directory (tenant) ID (Microsoft Entra ID). + directory: NotRequired[str | None] + + #: Okta domain (Okta). + domain: NotRequired[str | None] + + #: Keycloak host (Keycloak). + host: NotRequired[str | None] + + #: Key ID (Apple). + key_id: NotRequired[str | None] + + #: Private key (.p8) (Apple). + private_key: NotRequired[str | None] + + #: Realm (Keycloak). + realm: NotRequired[str | None] + + #: Team ID (Apple). + team_id: NotRequired[str | None] + + +class SigninSocialUpdateBody(TypedDict): + #: Only change it if it is this organization's; anything else is a 404. + organization_id: NotRequired[str | None] + + #: A new client id. For Apple, the Services ID. + client_id: NotRequired[str] + + #: A new client secret. Write-only; left out or blank keeps the one on file. + client_secret: NotRequired[str | None] + + #: What the chosen provider needs besides its client credentials — Microsoft's directory, Okta's domain, Apple's team id, key id and private key. + parameters: NotRequired[SigninSocialUpdateBodyParameters] + + #: Extra scopes to request beyond the ones sign-in needs (for example `read:org` on GitHub). Replaces any extra scopes already set; an empty list removes them. + scopes: NotRequired[list[str]] + class SodPoliciesCreateBody(TypedDict): #: The organization the rule belongs to. Send this or environment_wide. @@ -4296,8 +4462,13 @@ class WebhooksUpdateBody(TypedDict): "signin.sms.get": OperationSpec(action="signin.sms.get", operation_id="signin_sms_get", method="GET", path="/sign-in/sms", path_params=(), scope="signin:read", danger="read", approval=True, body=False, pagination=None), "signin.sms.update": OperationSpec(action="signin.sms.update", operation_id="signin_sms_update", method="PATCH", path="/sign-in/sms", path_params=(), scope="signin:write", danger="critical", approval=True, body=True, pagination=None), "signin.social.delete": OperationSpec(action="signin.social.delete", operation_id="signin_social_delete", method="DELETE", path="/sign-in/social-providers/{id}", path_params=("id",), scope="signin:write", danger="critical", approval=True, body=True, pagination=None), + "signin.social.disable": OperationSpec(action="signin.social.disable", operation_id="signin_social_disable", method="POST", path="/sign-in/social-providers/{id}/disable", path_params=("id",), scope="signin:write", danger="critical", approval=True, body=True, pagination=None), + "signin.social.enable": OperationSpec(action="signin.social.enable", operation_id="signin_social_enable", method="POST", path="/sign-in/social-providers/{id}/enable", path_params=("id",), scope="signin:write", danger="write", approval=True, body=True, pagination=None), + "signin.social.inherit": OperationSpec(action="signin.social.inherit", operation_id="signin_social_inherit", method="PUT", path="/sign-in/social-providers/inherited/{provider}", path_params=("provider",), scope="signin:write", danger="write", approval=True, body=True, pagination=None), "signin.social.list": OperationSpec(action="signin.social.list", operation_id="signin_social_list", method="GET", path="/sign-in/social-providers", path_params=(), scope="signin:read", danger="read", approval=True, body=False, pagination="cursor"), + "signin.social.offered": OperationSpec(action="signin.social.offered", operation_id="signin_social_offered", method="GET", path="/sign-in/social-providers/offered", path_params=(), scope="signin:read", danger="read", approval=True, body=False, pagination=None), "signin.social.set": OperationSpec(action="signin.social.set", operation_id="signin_social_set", method="POST", path="/sign-in/social-providers", path_params=(), scope="signin:write", danger="critical", approval=True, body=True, pagination=None), + "signin.social.update": OperationSpec(action="signin.social.update", operation_id="signin_social_update", method="PATCH", path="/sign-in/social-providers/{id}", path_params=("id",), scope="signin:write", danger="critical", approval=True, body=True, pagination=None), "sod_policies.create": OperationSpec(action="sod_policies.create", operation_id="sod_policies_create", method="POST", path="/sod-policies", path_params=(), scope="governance:write", danger="write", approval=True, body=True, pagination=None), "sod_policies.delete": OperationSpec(action="sod_policies.delete", operation_id="sod_policies_delete", method="DELETE", path="/sod-policies/{id}", path_params=("id",), scope="governance:write", danger="critical", approval=True, body=True, pagination=None), "sod_policies.get": OperationSpec(action="sod_policies.get", operation_id="sod_policies_get", method="GET", path="/sod-policies/{id}", path_params=("id",), scope="governance:read", danger="read", approval=True, body=False, pagination=None), @@ -5409,7 +5580,7 @@ def set(self, body: BrandingAppearanceSetBody, *, approval: Literal["wait"] = "w def set(self, body: BrandingAppearanceSetBody, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Appearance] | PendingApprovalResult[Appearance]: ... def set(self, body: BrandingAppearanceSetBody, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[Appearance] | PendingApprovalResult[Appearance]: - """Set the hosted sign-in theme (preset, colours, corners, type, logo) for the environment default or one organization. + """Set the hosted sign-in theme (preset, colours, corners, type, uploaded logo and favicon) for the environment default or one organization. Requires scope `branding:write`. Danger: write. @@ -8191,7 +8362,7 @@ def update(self, body: SigninPolicyUpdateBody | None = None, *, approval: Litera def update(self, body: SigninPolicyUpdateBody | None = None, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[SignInPolicy] | PendingApprovalResult[SignInPolicy]: ... def update(self, body: SigninPolicyUpdateBody | None = None, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[SignInPolicy] | PendingApprovalResult[SignInPolicy]: - """Change the authentication policy of the environment baseline, or tighten one organization's override. Requiring SSO signs out password sessions. + """Change the authentication policy of the environment baseline — including whether passkeys and magic links are offered and how long sessions last — or tighten one organization's override. Requiring SSO signs out password sessions. Requires scope `signin:write`. Danger: critical. @@ -8290,6 +8461,57 @@ def delete(self, id: str, body: SigninSocialDeleteBody | None = None, *, approva """ return self._core.call(ENVIRONMENT_OPERATIONS["signin.social.delete"], (id,), body, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) + @overload + def disable(self, id: str, body: SigninSocialDisableBody | None = None, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[SocialProvider]: ... + + @overload + def disable(self, id: str, body: SigninSocialDisableBody | None = None, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[SocialProvider] | PendingApprovalResult[SocialProvider]: ... + + def disable(self, id: str, body: SigninSocialDisableBody | None = None, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[SocialProvider] | PendingApprovalResult[SocialProvider]: + """Turn a social login provider off without removing it. People who used it keep their accounts. + + Requires scope `signin:write`. Danger: critical. + + ``POST /sign-in/social-providers/{id}/disable`` · action ``signin.social.disable`` + Scope ``signin:write`` · danger: critical. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ENVIRONMENT_OPERATIONS["signin.social.disable"], (id,), body, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) + + @overload + def enable(self, id: str, body: SigninSocialEnableBody | None = None, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[SocialProvider]: ... + + @overload + def enable(self, id: str, body: SigninSocialEnableBody | None = None, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[SocialProvider] | PendingApprovalResult[SocialProvider]: ... + + def enable(self, id: str, body: SigninSocialEnableBody | None = None, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[SocialProvider] | PendingApprovalResult[SocialProvider]: + """Turn a social login provider that was turned off back on, with the credentials it already has. + + Requires scope `signin:write`. Danger: write. + + ``POST /sign-in/social-providers/{id}/enable`` · action ``signin.social.enable`` + Scope ``signin:write`` · danger: write. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ENVIRONMENT_OPERATIONS["signin.social.enable"], (id,), body, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) + + @overload + def inherit(self, provider: str, body: SigninSocialInheritBody, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[OfferedSocialProviders]: ... + + @overload + def inherit(self, provider: str, body: SigninSocialInheritBody, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[OfferedSocialProviders] | PendingApprovalResult[OfferedSocialProviders]: ... + + def inherit(self, provider: str, body: SigninSocialInheritBody, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[OfferedSocialProviders] | PendingApprovalResult[OfferedSocialProviders]: + """Turn one of the environment's social login providers off (or back on) for one organization's sign-in page. + + Requires scope `signin:write`. Danger: write. + + ``PUT /sign-in/social-providers/inherited/{provider}`` · action ``signin.social.inherit`` + Scope ``signin:write`` · danger: write. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ENVIRONMENT_OPERATIONS["signin.social.inherit"], (provider,), body, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) + @overload def list(self, query: SigninSocialListQuery | None = None, *, approval: Literal["wait"] = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[SigninSocialListData]: ... @@ -8297,7 +8519,7 @@ def list(self, query: SigninSocialListQuery | None = None, *, approval: Literal[ def list(self, query: SigninSocialListQuery | None = None, *, approval: Literal["return"], approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[SigninSocialListData] | PendingApprovalResult[SigninSocialListData]: ... def list(self, query: SigninSocialListQuery | None = None, *, approval: ApprovalMode = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[SigninSocialListData] | PendingApprovalResult[SigninSocialListData]: - """List the social login providers (Google, GitHub, Apple…) enabled in this environment, optionally for one organization. + """List the social login providers (Google, GitHub, Apple…) set up in this environment — the environment's own and organizations' — optionally for one organization. Requires scope `signin:read`. Danger: read. @@ -8311,6 +8533,23 @@ def list_all(self, query: SigninSocialListQuery | None = None, *, headers: Mappi """Every item of ``signin.social.list``, fetching pages as the iteration reaches them.""" return self._core.paginate(ENVIRONMENT_OPERATIONS["signin.social.list"], (), query, headers=headers) + @overload + def offered(self, query: SigninSocialOfferedQuery | None = None, *, approval: Literal["wait"] = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[OfferedSocialProviders]: ... + + @overload + def offered(self, query: SigninSocialOfferedQuery | None = None, *, approval: Literal["return"], approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[OfferedSocialProviders] | PendingApprovalResult[OfferedSocialProviders]: ... + + def offered(self, query: SigninSocialOfferedQuery | None = None, *, approval: ApprovalMode = "wait", approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[OfferedSocialProviders] | PendingApprovalResult[OfferedSocialProviders]: + """The social login buttons one organization's sign-in page shows (or the plain sign-in page's), after inheritance from the environment, with where each comes from. + + Requires scope `signin:read`. Danger: read. + + ``GET /sign-in/social-providers/offered`` · action ``signin.social.offered`` + Scope ``signin:read`` · danger: read. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ENVIRONMENT_OPERATIONS["signin.social.offered"], (), query, approval=approval, approval_id=approval_id, headers=headers) + @overload def set(self, body: SigninSocialSetBody, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[SocialProvider]: ... @@ -8318,7 +8557,7 @@ def set(self, body: SigninSocialSetBody, *, approval: Literal["wait"] = "wait", def set(self, body: SigninSocialSetBody, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[SocialProvider] | PendingApprovalResult[SocialProvider]: ... def set(self, body: SigninSocialSetBody, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[SocialProvider] | PendingApprovalResult[SocialProvider]: - """Enable a social login provider (Google, GitHub, Apple…) for one organization with its client credentials. The secret is never returned. + """Enable a social login provider (Google, GitHub, Apple…) for the whole environment or for one organization, with its client credentials. The secret is never returned. Requires scope `signin:write`. Danger: critical. @@ -8328,6 +8567,23 @@ def set(self, body: SigninSocialSetBody, *, approval: ApprovalMode = "wait", ide """ return self._core.call(ENVIRONMENT_OPERATIONS["signin.social.set"], (), body, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) + @overload + def update(self, id: str, body: SigninSocialUpdateBody | None = None, *, approval: Literal["wait"] = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[SocialProvider]: ... + + @overload + def update(self, id: str, body: SigninSocialUpdateBody | None = None, *, approval: Literal["return"], idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[SocialProvider] | PendingApprovalResult[SocialProvider]: ... + + def update(self, id: str, body: SigninSocialUpdateBody | None = None, *, approval: ApprovalMode = "wait", idempotency_key: str | None = None, approval_id: str | None = None, headers: Mapping[str, str] | None = None) -> ApiResponse[SocialProvider] | PendingApprovalResult[SocialProvider]: + """Change a social login provider's client credentials, provider values or extra scopes. Secrets left out keep the ones on file. + + Requires scope `signin:write`. Danger: critical. + + ``PATCH /sign-in/social-providers/{id}`` · action ``signin.social.update`` + Scope ``signin:write`` · danger: critical. + May be held for approval (``202 approval_required``): waited on unless ``approval="return"``. + """ + return self._core.call(ENVIRONMENT_OPERATIONS["signin.social.update"], (id,), body, approval=approval, idempotency_key=idempotency_key, approval_id=approval_id, headers=headers) + class SigninMethods: """``signin.*``""" diff --git a/tests/fixtures/management_surface.txt b/tests/fixtures/management_surface.txt index 96d0f02..ccf2022 100644 --- a/tests/fixtures/management_surface.txt +++ b/tests/fixtures/management_surface.txt @@ -217,9 +217,14 @@ environment.signin.self_service_signup.set(body, *, approval=, idempotency_key=, environment.signin.sms.get(*, approval=, approval_id=, headers=) -> ApiResponse[SmsFactorPolicy] | PendingApprovalResult[SmsFactorPolicy] environment.signin.sms.update(body=, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[SmsFactorPolicy] | PendingApprovalResult[SmsFactorPolicy] environment.signin.social.delete(id, body=, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[None] | PendingApprovalResult[None] +environment.signin.social.disable(id, body=, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[SocialProvider] | PendingApprovalResult[SocialProvider] +environment.signin.social.enable(id, body=, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[SocialProvider] | PendingApprovalResult[SocialProvider] +environment.signin.social.inherit(provider, body, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[OfferedSocialProviders] | PendingApprovalResult[OfferedSocialProviders] environment.signin.social.list(query=, *, approval=, approval_id=, headers=) -> ApiResponse[SigninSocialListData] | PendingApprovalResult[SigninSocialListData] environment.signin.social.list_all(query=, *, headers=) -> Iterator[SocialProvider] +environment.signin.social.offered(query=, *, approval=, approval_id=, headers=) -> ApiResponse[OfferedSocialProviders] | PendingApprovalResult[OfferedSocialProviders] environment.signin.social.set(body, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[SocialProvider] | PendingApprovalResult[SocialProvider] +environment.signin.social.update(id, body=, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[SocialProvider] | PendingApprovalResult[SocialProvider] environment.sod_policies.create(body, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[SodPolicy] | PendingApprovalResult[SodPolicy] environment.sod_policies.delete(id, body=, *, approval=, idempotency_key=, approval_id=, headers=) -> ApiResponse[None] | PendingApprovalResult[None] environment.sod_policies.get(id, query=, *, approval=, approval_id=, headers=) -> ApiResponse[SodPolicy] | PendingApprovalResult[SodPolicy] diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 8ccd6c5..78f5031 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -68,6 +68,25 @@ def test_fetches_the_document_once_per_client() -> None: assert len(seen) == 1 +def test_asks_for_one_organizations_buttons_and_caches_each_on_its_own() -> None: + seen: list[httpx.Request] = [] + body = {**CONFIG, "methods": {"passkeys": True, "magic_link": False}} + client = FrontendClient( + "https://id.acme.test", "pk_live_abc", transport=transport_for(body=body, seen=seen) + ) + + plain = client.config() + client.config(organization="acme corp") + client.config(organization="acme corp") + client.config() + + assert [str(r.url) for r in seen] == [ + "https://id.acme.test/frontend/v1/config", + "https://id.acme.test/frontend/v1/config?organization=acme+corp", + ] + assert plain.methods == {"passkeys": True, "magic_link": False} + + def test_signed_out_is_a_state_not_an_error() -> None: """Code that renders an avatar everywhere should not treat a rejection as a state.""" seen: list[httpx.Request] = []