From 02dbe69fa0eaf38b0de8bddcdf4f829bfae02597 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 7 Oct 2026 16:55:12 -0700 Subject: [PATCH 1/5] feat(api): expose credential sharing and SSO administration --- apps/docs/content/docs/cli/credentials.mdx | 89 + apps/docs/content/docs/cli/organizations.mdx | 288 + apps/docs/content/docs/cli/reference.mdx | 377 ++ apps/docs/lib/openapi-documents.ts | 18 + apps/docs/lib/openapi-download.ts | 20 +- apps/docs/lib/openapi.ts | 20 +- apps/docs/openapi-v2-billing.json | 2 + apps/docs/openapi-v2-files-audit.json | 2 + apps/docs/openapi-v2-knowledge.json | 2 + apps/docs/openapi-v2-logs.json | 2 + apps/docs/openapi-v2-resources.json | 5505 ++++++++++++----- apps/docs/openapi-v2-tables.json | 2 + apps/docs/openapi-v2-workflows.json | 2 + .../sso/providers/[providerId]/route.test.ts | 49 +- .../auth/sso/providers/[providerId]/route.ts | 130 +- .../app/api/auth/sso/providers/route.test.ts | 3 +- apps/sim/app/api/auth/sso/providers/route.ts | 139 +- .../app/api/auth/sso/register/route.test.ts | 29 +- apps/sim/app/api/auth/sso/register/route.ts | 911 +-- .../organizations/[id]/sso-policy/route.ts | 7 +- .../[credentialId]/members/[userId]/route.ts | 20 + .../[credentialId]/members/route.ts | 71 + .../domains/[domainId]/route.ts | 16 + .../domains/[domainId]/verify/route.ts | 17 + .../[organizationId]/domains/route.ts | 53 + .../[organizationId]/sso/policy/route.ts | 26 + .../providers/[providerId]/primary/route.ts | 19 + .../sso/providers/[providerId]/route.ts | 27 + .../[organizationId]/sso/providers/route.ts | 51 + apps/sim/lib/api/contracts/auth.ts | 65 +- apps/sim/lib/api/contracts/v2/credentials.ts | 102 + .../api/contracts/v2/list-pagination.test.ts | 9 + .../v2/openapi/credential-members.ts | 117 + .../lib/api/contracts/v2/openapi/resources.ts | 4 + apps/sim/lib/api/contracts/v2/openapi/sso.ts | 406 ++ apps/sim/lib/api/contracts/v2/sso.ts | 375 ++ .../lib/api/mcp/generated/v2-operations.ts | 166 + .../routes/copilot-route-inventory.test.ts | 60 + apps/sim/lib/api/server/routes/sso.ts | 87 + .../server/routes/v2-route-table.generated.ts | 41 + apps/sim/lib/api/server/sso-presenters.ts | 99 + apps/sim/lib/auth/auth.ts | 2 +- .../lib/auth/sso/application/operations.ts | 57 + .../sso/application/provider-registration.ts | 807 +++ .../provider-settings.integration.ts | 608 ++ .../auth/sso/application/provider-settings.ts | 213 + .../sso/application/set-primary-provider.ts | 23 +- .../auth/sso/application/sso-requirement.ts | 27 +- apps/sim/lib/auth/sso/provider-adapter.ts | 62 + apps/sim/lib/auth/sso/provider-repository.ts | 286 + apps/sim/lib/auth/sso/registration-input.ts | 66 + apps/sim/lib/billing/core/subscription.ts | 22 - apps/sim/lib/billing/index.ts | 1 - apps/sim/lib/core/application/forbidden.ts | 4 + .../application/credential-members.ts | 60 +- .../credential-sharing.integration.ts | 226 + .../lib/credentials/application/operations.ts | 9 +- apps/sim/lib/credentials/members.ts | 167 +- .../authorized-configuration-use-case.ts | 5 +- .../application/domain-settings.ts | 59 +- .../organizations/application/operations.ts | 50 + .../application/revoke-sessions.ts | 2 +- .../application/security-operations.ts | 47 - .../application/security-settings.test.ts | 9 +- packages/sim-cli/src/contract/commands.ts | 71 + packages/sim-cli/src/generated/v2-api.ts | 862 +++ packages/sim-cli/src/http/client.test.ts | 6 + scripts/check-explicit-any.baseline.json | 1 - 68 files changed, 10222 insertions(+), 2958 deletions(-) create mode 100644 apps/docs/lib/openapi-documents.ts create mode 100644 apps/sim/app/api/v2/credentials/[credentialId]/members/[userId]/route.ts create mode 100644 apps/sim/app/api/v2/credentials/[credentialId]/members/route.ts create mode 100644 apps/sim/app/api/v2/organizations/[organizationId]/domains/[domainId]/route.ts create mode 100644 apps/sim/app/api/v2/organizations/[organizationId]/domains/[domainId]/verify/route.ts create mode 100644 apps/sim/app/api/v2/organizations/[organizationId]/domains/route.ts create mode 100644 apps/sim/app/api/v2/organizations/[organizationId]/sso/policy/route.ts create mode 100644 apps/sim/app/api/v2/organizations/[organizationId]/sso/providers/[providerId]/primary/route.ts create mode 100644 apps/sim/app/api/v2/organizations/[organizationId]/sso/providers/[providerId]/route.ts create mode 100644 apps/sim/app/api/v2/organizations/[organizationId]/sso/providers/route.ts create mode 100644 apps/sim/lib/api/contracts/v2/openapi/credential-members.ts create mode 100644 apps/sim/lib/api/contracts/v2/openapi/sso.ts create mode 100644 apps/sim/lib/api/contracts/v2/sso.ts create mode 100644 apps/sim/lib/api/server/routes/sso.ts create mode 100644 apps/sim/lib/api/server/sso-presenters.ts create mode 100644 apps/sim/lib/auth/sso/application/provider-registration.ts create mode 100644 apps/sim/lib/auth/sso/application/provider-settings.integration.ts create mode 100644 apps/sim/lib/auth/sso/application/provider-settings.ts create mode 100644 apps/sim/lib/auth/sso/provider-adapter.ts create mode 100644 apps/sim/lib/auth/sso/provider-repository.ts create mode 100644 apps/sim/lib/auth/sso/registration-input.ts create mode 100644 apps/sim/lib/credentials/application/credential-sharing.integration.ts delete mode 100644 apps/sim/lib/organizations/application/security-operations.ts diff --git a/apps/docs/content/docs/cli/credentials.mdx b/apps/docs/content/docs/cli/credentials.mdx index 16763637d19..de465ee06ff 100644 --- a/apps/docs/content/docs/cli/credentials.mdx +++ b/apps/docs/content/docs/cli/credentials.mdx @@ -37,6 +37,95 @@ Disconnect Credential (OAuth login or personal API key required) +## List credential members + +```bash +sim credentials members list [options] +``` + +List Credential Members (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `credentialId` | Yes | Credential whose sharing grants are managed. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | +| `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `email`, `name`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | + + + +## Remove credential member + +```bash +sim credentials members remove [options] +``` + +Remove Credential Member (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `credentialId` | Yes | Credential whose sharing grants are managed. | +| `userId` | Yes | User whose explicit grant will be revoked. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-y, --yes` | Yes | Confirm this operation. | + + + +## Upsert credential member + +```bash +sim credentials members upsert [options] +``` + +Upsert Credential Member (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `credentialId` | Yes | Credential whose sharing grants are managed. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--user ` | Yes | Existing workspace member to grant or change access for. | +| `--role ` | Yes | Credential role to grant; workspace administrators cannot be demoted. Accepted values: `admin`, `member`. | + + + ## List credential providers ```bash diff --git a/apps/docs/content/docs/cli/organizations.mdx b/apps/docs/content/docs/cli/organizations.mdx index 9d19dcc8793..c72b99dc436 100644 --- a/apps/docs/content/docs/cli/organizations.mdx +++ b/apps/docs/content/docs/cli/organizations.mdx @@ -7,6 +7,104 @@ import { CommandTable } from '@/components/ui/command-table' Every command below also accepts the [global options](/cli/commands#global-options). +## Add organization domain + +```bash +sim organizations domains add [options] +``` + +Add Organization Domain (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | +| `--domain ` | Yes | Domain to claim and verify through a DNS TXT record. | + + + +## List organization domains + +```bash +sim organizations domains list [options] +``` + +List Organization Domains (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | +| `--sort-by ` | No | Field used to sort the result. Accepted values: `domain`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | + + + +## Remove organization domain + +```bash +sim organizations domains remove [options] +``` + +Remove Organization Domain (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `domainId` | Yes | Domain claim owned by this organization. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | +| `-y, --yes` | Yes | Confirm this operation. | + + + +## Verify organization domain + +```bash +sim organizations domains verify [options] +``` + +Verify Organization Domain (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `domainId` | Yes | Domain claim owned by this organization. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | + + + ## Cancel organization access request ```bash @@ -386,6 +484,196 @@ Revoke Organization Invitation (OAuth login or personal API key required) +## Delete SSO provider + +```bash +sim organizations sso providers delete [options] +``` + +Delete SSO Provider (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `providerId` | Yes | Identity provider identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | +| `-y, --yes` | Yes | Confirm this operation. | + + + +## Get SSO provider + +```bash +sim organizations sso providers get [options] +``` + +Get SSO Provider (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `providerId` | Yes | Identity provider identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | + + + +## List SSO providers + +```bash +sim organizations sso providers list [options] +``` + +List SSO Providers (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | +| `--sort-by ` | No | Field used to sort the result. Accepted values: `providerId`, `domain`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | + + + +## Save SSO provider + +```bash +sim organizations sso providers save [options] +``` + +Save SSO Provider (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | +| `--provider-type ` | Yes | oidc: Configure an OpenID Connect identity provider. saml: Configure a SAML identity provider. Accepted values: `oidc`, `saml`. | +| `--provider-id ` | Yes | Globally unique provider ID; saving an existing provider replaces its supplied configuration. | +| `--issuer ` | Yes | Identity provider issuer URL. | +| `--domain ` | Yes | Email domain already verified by this organization. | +| `--jit-provisioning-enabled` | No | Allow SSO sign-in to provision organization membership, subject to eligibility and available seats. | +| `--no-jit-provisioning-enabled` | No | Send --jit-provisioning-enabled as false. | +| `--mapping ` | No | Identity-provider claims mapped to user fields. (JSON, or @path / @- to read a file or stdin). | +| `--client-id ` | No | Identity provider client identifier. Available when providerType is oidc. Required when providerType is oidc. | +| `--client-secret ` | No | Write-only client secret; the redacted marker from Get SSO Provider preserves an existing secret. Available when providerType is oidc. Required when providerType is oidc. | +| `--scopes ` | No | OIDC scopes; offline_access is omitted. Available when providerType is oidc. (JSON, or @path / @- to read a file or stdin). | +| `--pkce` | No | Use PKCE for the authorization flow. Available when providerType is oidc. | +| `--no-pkce` | No | Send --pkce as false. | +| `--authorization-endpoint ` | No | Optional authorization endpoint; otherwise resolved through issuer discovery. Available when providerType is oidc. | +| `--token-endpoint ` | No | Optional token endpoint; otherwise resolved through issuer discovery. Available when providerType is oidc. | +| `--user-info-endpoint ` | No | Optional UserInfo endpoint. Available when providerType is oidc. | +| `--skip-user-info-endpoint` | No | Read identity claims from the ID token instead of calling UserInfo. Available when providerType is oidc. | +| `--no-skip-user-info-endpoint` | No | Send --skip-user-info-endpoint as false. | +| `--jwks-endpoint ` | No | Optional signing-key endpoint; otherwise resolved through issuer discovery. Available when providerType is oidc. | +| `--entry-point ` | No | Identity provider SAML sign-in endpoint. Available when providerType is saml. Required when providerType is saml. | +| `--cert ` | No | Identity provider signing certificate. Available when providerType is saml. Required when providerType is saml. | +| `--callback-url ` | No | SAML callback URL; defaults to this provider’s Sim callback. Available when providerType is saml. | +| `--audience ` | No | SAML audience; omission preserves the saved value. Available when providerType is saml. | +| `--want-assertions-signed` | No | Require signed assertions; omission preserves the saved value. Available when providerType is saml. | +| `--no-want-assertions-signed` | No | Send --want-assertions-signed as false. | +| `--signature-algorithm ` | No | Signature algorithm accepted by the SAML configuration validator; omission preserves the saved value. Available when providerType is saml. | +| `--digest-algorithm ` | No | Digest algorithm accepted by the SAML configuration validator; omission preserves the saved value. Available when providerType is saml. | +| `--identifier-format ` | No | SAML NameID format; omission clears the saved value. Available when providerType is saml. | +| `--idp-metadata ` | No | Identity provider metadata XML; omission clears the saved document. Available when providerType is saml. | + + + +## Set primary SSO provider + +```bash +sim organizations sso providers primary [options] +``` + +Set Primary SSO Provider (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `providerId` | Yes | Identity provider identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | + + + +## Get SSO policy + +```bash +sim organizations sso policy get [options] +``` + +Get SSO Policy (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | + + + +## Update SSO policy + +```bash +sim organizations sso policy update [options] +``` + +Update SSO Policy (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | +| `--require-sso ` | Yes | Require organization SSO on future sign-ins; existing sessions remain active. Accepted values: `true`, `false`. | + + + ## Get organization ```bash diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index 336165025e9..9dd4cdd532f 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -466,6 +466,95 @@ sim credentials delete [options] +### sim credentials members list + +List Credential Members (OAuth login or personal API key required) + +```bash +sim credentials members list [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `credentialId` | Yes | Credential whose sharing grants are managed. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | +| `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `email`, `name`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | + + + +### sim credentials members remove + +Remove Credential Member (OAuth login or personal API key required) + +```bash +sim credentials members remove [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `credentialId` | Yes | Credential whose sharing grants are managed. | +| `userId` | Yes | User whose explicit grant will be revoked. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-y, --yes` | Yes | Confirm this operation. | + + + +### sim credentials members upsert + +Upsert Credential Member (OAuth login or personal API key required) + +```bash +sim credentials members upsert [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `credentialId` | Yes | Credential whose sharing grants are managed. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--user ` | Yes | Existing workspace member to grant or change access for. | +| `--role ` | Yes | Credential role to grant; workspace administrators cannot be demoted. Accepted values: `admin`, `member`. | + + + ### sim credentials providers list List Credential Providers @@ -3030,6 +3119,104 @@ sim meta status ## sim organizations +### sim organizations domains add + +Add Organization Domain (OAuth login or personal API key required) + +```bash +sim organizations domains add [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | +| `--domain ` | Yes | Domain to claim and verify through a DNS TXT record. | + + + +### sim organizations domains list + +List Organization Domains (OAuth login or personal API key required) + +```bash +sim organizations domains list [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | +| `--sort-by ` | No | Field used to sort the result. Accepted values: `domain`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | + + + +### sim organizations domains remove + +Remove Organization Domain (OAuth login or personal API key required) + +```bash +sim organizations domains remove [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `domainId` | Yes | Domain claim owned by this organization. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | +| `-y, --yes` | Yes | Confirm this operation. | + + + +### sim organizations domains verify + +Verify Organization Domain (OAuth login or personal API key required) + +```bash +sim organizations domains verify [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `domainId` | Yes | Domain claim owned by this organization. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | + + + ### sim organizations access-requests cancel Cancel Organization Access Request (OAuth login or personal API key required) @@ -3409,6 +3596,196 @@ sim organizations invitations revoke [options] +### sim organizations sso providers delete + +Delete SSO Provider (OAuth login or personal API key required) + +```bash +sim organizations sso providers delete [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `providerId` | Yes | Identity provider identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | +| `-y, --yes` | Yes | Confirm this operation. | + + + +### sim organizations sso providers get + +Get SSO Provider (OAuth login or personal API key required) + +```bash +sim organizations sso providers get [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `providerId` | Yes | Identity provider identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | + + + +### sim organizations sso providers list + +List SSO Providers (OAuth login or personal API key required) + +```bash +sim organizations sso providers list [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | +| `--sort-by ` | No | Field used to sort the result. Accepted values: `providerId`, `domain`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | + + + +### sim organizations sso providers save + +Save SSO Provider (OAuth login or personal API key required) + +```bash +sim organizations sso providers save [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | +| `--provider-type ` | Yes | oidc: Configure an OpenID Connect identity provider. saml: Configure a SAML identity provider. Accepted values: `oidc`, `saml`. | +| `--provider-id ` | Yes | Globally unique provider ID; saving an existing provider replaces its supplied configuration. | +| `--issuer ` | Yes | Identity provider issuer URL. | +| `--domain ` | Yes | Email domain already verified by this organization. | +| `--jit-provisioning-enabled` | No | Allow SSO sign-in to provision organization membership, subject to eligibility and available seats. | +| `--no-jit-provisioning-enabled` | No | Send --jit-provisioning-enabled as false. | +| `--mapping ` | No | Identity-provider claims mapped to user fields. (JSON, or @path / @- to read a file or stdin). | +| `--client-id ` | No | Identity provider client identifier. Available when providerType is oidc. Required when providerType is oidc. | +| `--client-secret ` | No | Write-only client secret; the redacted marker from Get SSO Provider preserves an existing secret. Available when providerType is oidc. Required when providerType is oidc. | +| `--scopes ` | No | OIDC scopes; offline_access is omitted. Available when providerType is oidc. (JSON, or @path / @- to read a file or stdin). | +| `--pkce` | No | Use PKCE for the authorization flow. Available when providerType is oidc. | +| `--no-pkce` | No | Send --pkce as false. | +| `--authorization-endpoint ` | No | Optional authorization endpoint; otherwise resolved through issuer discovery. Available when providerType is oidc. | +| `--token-endpoint ` | No | Optional token endpoint; otherwise resolved through issuer discovery. Available when providerType is oidc. | +| `--user-info-endpoint ` | No | Optional UserInfo endpoint. Available when providerType is oidc. | +| `--skip-user-info-endpoint` | No | Read identity claims from the ID token instead of calling UserInfo. Available when providerType is oidc. | +| `--no-skip-user-info-endpoint` | No | Send --skip-user-info-endpoint as false. | +| `--jwks-endpoint ` | No | Optional signing-key endpoint; otherwise resolved through issuer discovery. Available when providerType is oidc. | +| `--entry-point ` | No | Identity provider SAML sign-in endpoint. Available when providerType is saml. Required when providerType is saml. | +| `--cert ` | No | Identity provider signing certificate. Available when providerType is saml. Required when providerType is saml. | +| `--callback-url ` | No | SAML callback URL; defaults to this provider’s Sim callback. Available when providerType is saml. | +| `--audience ` | No | SAML audience; omission preserves the saved value. Available when providerType is saml. | +| `--want-assertions-signed` | No | Require signed assertions; omission preserves the saved value. Available when providerType is saml. | +| `--no-want-assertions-signed` | No | Send --want-assertions-signed as false. | +| `--signature-algorithm ` | No | Signature algorithm accepted by the SAML configuration validator; omission preserves the saved value. Available when providerType is saml. | +| `--digest-algorithm ` | No | Digest algorithm accepted by the SAML configuration validator; omission preserves the saved value. Available when providerType is saml. | +| `--identifier-format ` | No | SAML NameID format; omission clears the saved value. Available when providerType is saml. | +| `--idp-metadata ` | No | Identity provider metadata XML; omission clears the saved document. Available when providerType is saml. | + + + +### sim organizations sso providers primary + +Set Primary SSO Provider (OAuth login or personal API key required) + +```bash +sim organizations sso providers primary [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `providerId` | Yes | Identity provider identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | + + + +### sim organizations sso policy get + +Get SSO Policy (OAuth login or personal API key required) + +```bash +sim organizations sso policy get [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | + + + +### sim organizations sso policy update + +Update SSO Policy (OAuth login or personal API key required) + +```bash +sim organizations sso policy update [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--organization ` | Yes | Organization identifier. | +| `--require-sso ` | Yes | Require organization SSO on future sign-ins; existing sessions remain active. Accepted values: `true`, `false`. | + + + ### sim organizations get Get Organization (OAuth login or personal API key required) diff --git a/apps/docs/lib/openapi-documents.ts b/apps/docs/lib/openapi-documents.ts new file mode 100644 index 00000000000..3d3e825bced --- /dev/null +++ b/apps/docs/lib/openapi-documents.ts @@ -0,0 +1,18 @@ +import type { OPENAPI_SPEC_FILES } from '@/lib/openapi-specs' +import billingSpec from '@/openapi-v2-billing.json' +import filesAuditSpec from '@/openapi-v2-files-audit.json' +import knowledgeSpec from '@/openapi-v2-knowledge.json' +import logsSpec from '@/openapi-v2-logs.json' +import resourcesSpec from '@/openapi-v2-resources.json' +import tablesSpec from '@/openapi-v2-tables.json' +import workflowsSpec from '@/openapi-v2-workflows.json' + +export const OPENAPI_DOCUMENTS_BY_FILE = { + 'openapi-v2-billing.json': billingSpec, + 'openapi-v2-files-audit.json': filesAuditSpec, + 'openapi-v2-knowledge.json': knowledgeSpec, + 'openapi-v2-logs.json': logsSpec, + 'openapi-v2-resources.json': resourcesSpec, + 'openapi-v2-tables.json': tablesSpec, + 'openapi-v2-workflows.json': workflowsSpec, +} satisfies Record<(typeof OPENAPI_SPEC_FILES)[number], Record> diff --git a/apps/docs/lib/openapi-download.ts b/apps/docs/lib/openapi-download.ts index 4ed635367aa..132a783892c 100644 --- a/apps/docs/lib/openapi-download.ts +++ b/apps/docs/lib/openapi-download.ts @@ -1,26 +1,8 @@ import { isDeepStrictEqual } from 'node:util' +import { OPENAPI_DOCUMENTS_BY_FILE } from '@/lib/openapi-documents' import { OPENAPI_SPEC_FILES } from '@/lib/openapi-specs' -import billingSpec from '@/openapi-v2-billing.json' -import filesAuditSpec from '@/openapi-v2-files-audit.json' -import knowledgeSpec from '@/openapi-v2-knowledge.json' -import logsSpec from '@/openapi-v2-logs.json' -import resourcesSpec from '@/openapi-v2-resources.json' -import tablesSpec from '@/openapi-v2-tables.json' -import workflowsSpec from '@/openapi-v2-workflows.json' type JsonObject = Record -type OpenApiSpecFile = (typeof OPENAPI_SPEC_FILES)[number] - -const OPENAPI_DOCUMENTS_BY_FILE = { - 'openapi-v2-workflows.json': workflowsSpec, - 'openapi-v2-logs.json': logsSpec, - 'openapi-v2-files-audit.json': filesAuditSpec, - 'openapi-v2-tables.json': tablesSpec, - 'openapi-v2-knowledge.json': knowledgeSpec, - 'openapi-v2-billing.json': billingSpec, - 'openapi-v2-resources.json': resourcesSpec, -} satisfies Record - const OPENAPI_DOCUMENTS = OPENAPI_SPEC_FILES.map((file) => ({ document: OPENAPI_DOCUMENTS_BY_FILE[file], namespace: file diff --git a/apps/docs/lib/openapi.ts b/apps/docs/lib/openapi.ts index ef7453bcf36..1593c7f3a52 100644 --- a/apps/docs/lib/openapi.ts +++ b/apps/docs/lib/openapi.ts @@ -2,14 +2,8 @@ import type { MethodInformation } from 'fumadocs-openapi' import type { InlineCodeUsageGenerator } from 'fumadocs-openapi/requests/generators' import { createOpenAPI } from 'fumadocs-openapi/server' import { buildAuthCodeSamples } from '@/lib/openapi-code-samples' +import { OPENAPI_DOCUMENTS_BY_FILE } from '@/lib/openapi-documents' import { OPENAPI_SPEC_FILES } from '@/lib/openapi-specs' -import billingSpec from '@/openapi-v2-billing.json' -import filesAuditSpec from '@/openapi-v2-files-audit.json' -import knowledgeSpec from '@/openapi-v2-knowledge.json' -import logsSpec from '@/openapi-v2-logs.json' -import resourcesSpec from '@/openapi-v2-resources.json' -import tablesSpec from '@/openapi-v2-tables.json' -import workflowsSpec from '@/openapi-v2-workflows.json' export const openapi = createOpenAPI({ input: OPENAPI_SPEC_FILES.map((file) => `./${file}`), @@ -71,17 +65,7 @@ function formatSchema(schema: unknown): string { return JSON.stringify(schema, null, 2) } -const SPEC_BY_FILE = { - 'openapi-v2-billing.json': billingSpec, - 'openapi-v2-files-audit.json': filesAuditSpec, - 'openapi-v2-knowledge.json': knowledgeSpec, - 'openapi-v2-logs.json': logsSpec, - 'openapi-v2-resources.json': resourcesSpec, - 'openapi-v2-tables.json': tablesSpec, - 'openapi-v2-workflows.json': workflowsSpec, -} satisfies Record<(typeof OPENAPI_SPEC_FILES)[number], Record> - -const SPECS = OPENAPI_SPEC_FILES.map((file) => SPEC_BY_FILE[file]) +const SPECS = OPENAPI_SPEC_FILES.map((file) => OPENAPI_DOCUMENTS_BY_FILE[file]) function getSpecs(): Record[] { return SPECS diff --git a/apps/docs/openapi-v2-billing.json b/apps/docs/openapi-v2-billing.json index a531def03f3..3769e23500c 100644 --- a/apps/docs/openapi-v2-billing.json +++ b/apps/docs/openapi-v2-billing.json @@ -475,6 +475,8 @@ "ORGANIZATION_MEMBERSHIP_REQUIRED", "ORGANIZATION_ADMIN_REQUIRED", "ENTERPRISE_PLAN_REQUIRED", + "SSO_DOMAIN_NOT_VERIFIED", + "SSO_PROVIDER_LIMIT_REACHED", "ORGANIZATION_PLAN_REQUIRED", "AUDIT_LOGS_DISABLED", "ACCESS_REQUESTS_DISABLED", diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index bc682bdbbe7..299c02b01e5 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -3836,6 +3836,8 @@ "ORGANIZATION_MEMBERSHIP_REQUIRED", "ORGANIZATION_ADMIN_REQUIRED", "ENTERPRISE_PLAN_REQUIRED", + "SSO_DOMAIN_NOT_VERIFIED", + "SSO_PROVIDER_LIMIT_REACHED", "ORGANIZATION_PLAN_REQUIRED", "AUDIT_LOGS_DISABLED", "ACCESS_REQUESTS_DISABLED", diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index 7a693d01088..f667508cab1 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -4732,6 +4732,8 @@ "ORGANIZATION_MEMBERSHIP_REQUIRED", "ORGANIZATION_ADMIN_REQUIRED", "ENTERPRISE_PLAN_REQUIRED", + "SSO_DOMAIN_NOT_VERIFIED", + "SSO_PROVIDER_LIMIT_REACHED", "ORGANIZATION_PLAN_REQUIRED", "AUDIT_LOGS_DISABLED", "ACCESS_REQUESTS_DISABLED", diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json index 352407da6cc..0016aefb8ee 100644 --- a/apps/docs/openapi-v2-logs.json +++ b/apps/docs/openapi-v2-logs.json @@ -866,6 +866,8 @@ "ORGANIZATION_MEMBERSHIP_REQUIRED", "ORGANIZATION_ADMIN_REQUIRED", "ENTERPRISE_PLAN_REQUIRED", + "SSO_DOMAIN_NOT_VERIFIED", + "SSO_PROVIDER_LIMIT_REACHED", "ORGANIZATION_PLAN_REQUIRED", "AUDIT_LOGS_DISABLED", "ACCESS_REQUESTS_DISABLED", diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index ba518619df0..20b70430ae2 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -6905,31 +6905,78 @@ } } }, - "/api/v2/workspaces/{workspaceId}/permission-config": { + "/api/v2/organizations/{organizationId}/sso/providers": { "get": { - "operationId": "getWorkspacePermissionConfig", - "summary": "Get Workspace Permission Config", - "description": "Get the acting user's governing permission group and configuration for a workspace they can access. This describes permission-group restrictions, not the user's workspace role. Group and config are null when no group governs the caller; entitled indicates whether organization permission governance is active. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "permission_groups.read_user_config", + "operationId": "listSsoProviders", + "summary": "List SSO Providers", + "description": "List identity providers owned by the organization. Requires organization administrator access. OIDC client secrets are redacted and SAML private keys are omitted. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "organization.sso.providers.list", "x-oauth-scope": "api:read", - "tags": ["Workspaces"], + "tags": ["Organizations"], "parameters": [ { - "name": "workspaceId", + "name": "organizationId", "in": "path", "required": true, - "description": "Unique workspace identifier.", + "description": "Organization whose single sign-on settings are managed.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "description": "Unique workspace identifier." + "description": "Organization whose single sign-on settings are managed." + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum identity providers to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum identity providers to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "schema": { + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "type": "string", + "minLength": 1 + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field used to sort the result.", + "schema": { + "default": "providerId", + "description": "Field used to sort the result.", + "type": "string", + "enum": ["providerId", "domain"] + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "default": "asc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc", "desc"] } } ], "responses": { "200": { - "description": "The caller’s effective permission-group configuration.", + "description": "List SSO Providers result.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -6944,7 +6991,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GetWorkspacePermissionConfigResponse" + "$ref": "#/components/schemas/ListSsoProvidersResponse" } } } @@ -6961,6 +7008,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -6971,44 +7021,41 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - } - }, - "/api/v2/workspaces/{workspaceId}/invitations": { + }, "post": { - "operationId": "createWorkspaceInvitations", - "summary": "Create Workspace Invitations", - "description": "Invite people to a workspace or grant access immediately to existing organization members. Requires workspace administrator access and current invitation eligibility; organization administrator invitations also require organization administrator access. Recipients are processed independently: inspect failed even after HTTP 200, and inspect invitation status before retrying a delivery failure. Existing access is preserved. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "invitations.send_batch", + "operationId": "saveSsoProvider", + "summary": "Save SSO Provider", + "description": "Create or update an organization identity provider’s configuration on a verified domain. Requires organization administrator access and SSO entitlement. Existing providers return 200; creation returns 201. Omission behavior is field-specific; OIDC’s redacted secret marker preserves the saved secret. Identity changes with linked accounts conflict. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "organization.sso.providers.save", "x-oauth-scope": "api:write", - "tags": ["Workspaces"], + "tags": ["Organizations"], "parameters": [ { - "name": "workspaceId", + "name": "organizationId", "in": "path", "required": true, - "description": "Unique workspace identifier.", + "description": "Organization whose single sign-on settings are managed.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "description": "Unique workspace identifier." + "description": "Organization whose single sign-on settings are managed." } } ], "requestBody": { "required": true, - "description": "Recipients and the access to grant.", + "description": "Configuration accepted by Save SSO Provider.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateWorkspaceInvitationsBody" + "$ref": "#/components/schemas/SaveSsoProviderBody" } } } }, "responses": { "200": { - "description": "Per-recipient invitation and direct-grant outcomes.", + "description": "Save SSO Provider result.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -7023,7 +7070,28 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateWorkspaceInvitationsResponse" + "$ref": "#/components/schemas/SaveSsoProviderResponse" + } + } + } + }, + "201": { + "description": "Save SSO Provider result.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SaveSsoProviderResponse" } } } @@ -7061,12 +7129,12 @@ } } }, - "/api/v2/organizations/{organizationId}/members/{userId}/usage-limit": { + "/api/v2/organizations/{organizationId}/sso/providers/{providerId}": { "get": { - "operationId": "getOrganizationMemberUsageLimit", - "summary": "Get Organization Member Credit Limit", - "description": "Read a person’s credit cap and credits consumed in the organization billing period. Hosted only. The userId identifies an organization member or external collaborator with workspace access in this organization; it is not a membership record ID. Null means no per-person cap, while organization limits still apply. Requires organization administrator access. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "organization_member_usage_limits.read", + "operationId": "getSsoProvider", + "summary": "Get SSO Provider", + "description": "Get an identity provider owned by the organization. Requires organization administrator access. OIDC client secrets are redacted and SAML private keys are omitted. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "organization.sso.providers.list", "x-oauth-scope": "api:read", "tags": ["Organizations"], "parameters": [ @@ -7074,28 +7142,29 @@ "name": "organizationId", "in": "path", "required": true, - "description": "Organization identifier.", + "description": "Organization whose single sign-on settings are managed.", "schema": { "type": "string", "minLength": 1, - "description": "Organization identifier." + "description": "Organization whose single sign-on settings are managed." } }, { - "name": "userId", + "name": "providerId", "in": "path", "required": true, - "description": "User ID of an organization member or external collaborator with workspace access in this organization. Use List Organization Members or List Workspace Members to find it.", + "description": "Identity provider identifier.", "schema": { "type": "string", "minLength": 1, - "description": "User ID of an organization member or external collaborator with workspace access in this organization. Use List Organization Members or List Workspace Members to find it." + "maxLength": 255, + "description": "Identity provider identifier." } } ], "responses": { "200": { - "description": "Get Organization Member Credit Limit result.", + "description": "Get SSO Provider result.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -7110,7 +7179,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GetOrganizationMemberUsageLimitResponse" + "$ref": "#/components/schemas/GetSsoProviderResponse" } } } @@ -7127,6 +7196,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -7138,11 +7210,11 @@ } } }, - "patch": { - "operationId": "updateOrganizationMemberUsageLimit", - "summary": "Update Organization Member Credit Limit", - "description": "Set or clear a person’s credit cap. Hosted only. The userId must identify an organization member or external collaborator with workspace access in this organization. The cap is a nonnegative whole number of credits, not dollars: 0 prevents further credit-consuming usage; null removes the per-person cap. Organization limits continue to apply. Retrying the same value is safe. Requires organization administrator access. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "organization_member_usage_limits.update", + "delete": { + "operationId": "deleteSsoProvider", + "summary": "Delete SSO Provider", + "description": "Remove an identity provider and clear its primary selection. Requires organization administrator access. Existing accounts, memberships, and sessions remain; sign-in falls back to another verified provider on the domain. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "organization.sso.providers.delete", "x-oauth-scope": "api:write", "tags": ["Organizations"], "parameters": [ @@ -7150,39 +7222,29 @@ "name": "organizationId", "in": "path", "required": true, - "description": "Organization identifier.", + "description": "Organization whose single sign-on settings are managed.", "schema": { "type": "string", "minLength": 1, - "description": "Organization identifier." + "description": "Organization whose single sign-on settings are managed." } }, { - "name": "userId", + "name": "providerId", "in": "path", "required": true, - "description": "User ID of an organization member or external collaborator with workspace access in this organization. Use List Organization Members or List Workspace Members to find it.", + "description": "Identity provider identifier.", "schema": { "type": "string", "minLength": 1, - "description": "User ID of an organization member or external collaborator with workspace access in this organization. Use List Organization Members or List Workspace Members to find it." + "maxLength": 255, + "description": "Identity provider identifier." } } ], - "requestBody": { - "required": true, - "description": "Credit cap in whole credits; null clears the cap.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateOrganizationMemberUsageLimitBody" - } - } - } - }, "responses": { "200": { - "description": "Update Organization Member Credit Limit result.", + "description": "Delete SSO Provider result.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -7197,7 +7259,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateOrganizationMemberUsageLimitResponse" + "$ref": "#/components/schemas/DeleteSsoProviderResponse" } } } @@ -7214,11 +7276,8 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, - "415": { - "$ref": "#/components/responses/UnsupportedMediaType" + "409": { + "$ref": "#/components/responses/Conflict" }, "429": { "$ref": "#/components/responses/RateLimited" @@ -7232,90 +7291,53 @@ } } }, - "/api/v2/organizations/{organizationId}/usage/summary": { - "get": { - "operationId": "getOrganizationUsageSummary", - "summary": "Get Organization Usage Summary", - "description": "Read pooled credits, a usage series, and an exact previous-period comparison when available. Requires organization administrator access and Usage Monitoring (Enterprise on hosted; enabled on self-hosted). Defaults to 30 days. Custom dates include both dates in the selected timezone and cannot exceed 92 days. Billing windows exceeding 366 days are rejected. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "organization_usage.summary.read", - "x-oauth-scope": "api:read", + "/api/v2/organizations/{organizationId}/sso/providers/{providerId}/primary": { + "post": { + "operationId": "setPrimarySsoProvider", + "summary": "Set Primary SSO Provider", + "description": "Make a verified organization provider handle sign-in for its domain. Requires organization administrator access. Other providers remain available for testing and later switching. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "organization.sso.set_primary_provider", + "x-oauth-scope": "api:write", "tags": ["Organizations"], "parameters": [ { "name": "organizationId", "in": "path", "required": true, - "description": "Organization identifier.", + "description": "Organization whose single sign-on settings are managed.", "schema": { "type": "string", "minLength": 1, - "description": "Organization identifier." - } - }, - { - "name": "preset", - "in": "query", - "required": false, - "description": "Reporting window. Custom requires startDate and endDate and is capped at 92 days; other presets reject those bounds. Resolved billing windows are capped at 366 days.", - "schema": { - "default": "30d", - "description": "Reporting window. Custom requires startDate and endDate and is capped at 92 days; other presets reject those bounds. Resolved billing windows are capped at 366 days.", - "type": "string", - "enum": ["current-period", "previous-period", "7d", "30d", "custom"] - } - }, - { - "name": "startDate", - "in": "query", - "required": false, - "description": "First calendar date included, in the selected timezone. Requires preset=custom.", - "schema": { - "description": "First calendar date included, in the selected timezone. Requires preset=custom.", - "type": "string", - "format": "date", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))$" - } - }, - { - "name": "endDate", - "in": "query", - "required": false, - "description": "Last calendar date included, in the selected timezone. Requires preset=custom.", - "schema": { - "description": "Last calendar date included, in the selected timezone. Requires preset=custom.", - "type": "string", - "format": "date", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))$" - } - }, - { - "name": "timezone", - "in": "query", - "required": false, - "description": "IANA timezone for calendar boundaries; defaults to UTC.", - "schema": { - "default": "UTC", - "description": "IANA timezone for calendar boundaries; defaults to UTC.", - "type": "string", - "minLength": 1 + "description": "Organization whose single sign-on settings are managed." } }, { - "name": "workspaceId", - "in": "query", - "required": false, - "description": "Restrict usage to one workspace owned by the organization.", + "name": "providerId", + "in": "path", + "required": true, + "description": "Identity provider identifier.", "schema": { - "description": "Restrict usage to one workspace owned by the organization.", "type": "string", "minLength": 1, - "maxLength": 128 + "maxLength": 255, + "description": "Identity provider identifier." } } ], + "requestBody": { + "required": true, + "description": "Configuration accepted by Set Primary SSO Provider.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetPrimarySsoProviderBody" + } + } + } + }, "responses": { "200": { - "description": "Get Organization Usage Summary result.", + "description": "Set Primary SSO Provider result.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -7330,7 +7352,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GetOrganizationUsageSummaryResponse" + "$ref": "#/components/schemas/SetPrimarySsoProviderResponse" } } } @@ -7347,6 +7369,15 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -7359,12 +7390,12 @@ } } }, - "/api/v2/organizations/{organizationId}/usage/breakdown": { + "/api/v2/organizations/{organizationId}/sso/policy": { "get": { - "operationId": "getOrganizationUsageBreakdown", - "summary": "Get Organization Usage Breakdown", - "description": "Read ranked organization usage by member, workspace, workflow, model, BYOK provider, or source. Requires organization administrator access and Usage Monitoring. Omitted usage is summarized in other. BYOK ranks tokens; other dimensions rank cost. More than 10,000 underlying groups returns 413; narrow the window or workspace. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "organization_usage.breakdown.read", + "operationId": "getSsoPolicy", + "summary": "Get SSO Policy", + "description": "Get the stored organization SSO requirement and whether it is currently enforced. Requires organization membership. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "organization.sso.read_requirement", "x-oauth-scope": "api:read", "tags": ["Organizations"], "parameters": [ @@ -7372,101 +7403,96 @@ "name": "organizationId", "in": "path", "required": true, - "description": "Organization identifier.", + "description": "Organization whose single sign-on settings are managed.", "schema": { "type": "string", "minLength": 1, - "description": "Organization identifier." + "description": "Organization whose single sign-on settings are managed." } - }, - { - "name": "preset", - "in": "query", - "required": false, - "description": "Reporting window. Custom requires startDate and endDate and is capped at 92 days; other presets reject those bounds. Resolved billing windows are capped at 366 days.", - "schema": { - "default": "30d", - "description": "Reporting window. Custom requires startDate and endDate and is capped at 92 days; other presets reject those bounds. Resolved billing windows are capped at 366 days.", - "type": "string", - "enum": ["current-period", "previous-period", "7d", "30d", "custom"] + } + ], + "responses": { + "200": { + "description": "Get SSO Policy result.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetSsoPolicyResponse" + } + } } }, - { - "name": "startDate", - "in": "query", - "required": false, - "description": "First calendar date included, in the selected timezone. Requires preset=custom.", - "schema": { - "description": "First calendar date included, in the selected timezone. Requires preset=custom.", - "type": "string", - "format": "date", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))$" - } + "400": { + "$ref": "#/components/responses/BadRequest" }, - { - "name": "endDate", - "in": "query", - "required": false, - "description": "Last calendar date included, in the selected timezone. Requires preset=custom.", - "schema": { - "description": "Last calendar date included, in the selected timezone. Requires preset=custom.", - "type": "string", - "format": "date", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))$" - } + "401": { + "$ref": "#/components/responses/Unauthorized" }, - { - "name": "timezone", - "in": "query", - "required": false, - "description": "IANA timezone for calendar boundaries; defaults to UTC.", - "schema": { - "default": "UTC", - "description": "IANA timezone for calendar boundaries; defaults to UTC.", - "type": "string", - "minLength": 1 - } + "403": { + "$ref": "#/components/responses/Forbidden" }, - { - "name": "workspaceId", - "in": "query", - "required": false, - "description": "Restrict usage to one workspace owned by the organization.", - "schema": { - "description": "Restrict usage to one workspace owned by the organization.", - "type": "string", - "minLength": 1, - "maxLength": 128 - } + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + }, + "patch": { + "operationId": "updateSsoPolicy", + "summary": "Update SSO Policy", + "description": "Require or stop requiring SSO on future sign-ins. Requires organization administrator access. Enabling requires SSO entitlement and a verified provider; disabling remains available after entitlement is lost. Existing sessions remain active. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "organization.sso.set_requirement", + "x-oauth-scope": "api:write", + "tags": ["Organizations"], + "parameters": [ { - "name": "dimension", - "in": "query", + "name": "organizationId", + "in": "path", "required": true, - "description": "Usage grouping dimension.", + "description": "Organization whose single sign-on settings are managed.", "schema": { "type": "string", - "enum": ["member", "workspace", "workflow", "model", "byok", "source"], - "description": "Usage grouping dimension." - } - }, - { - "name": "limit", - "in": "query", - "required": false, - "description": "Maximum ranked groups to return. Remaining usage is summarized in other. Must be a whole number from 1 to 100. Defaults to 50.", - "schema": { - "default": 50, - "description": "Maximum ranked groups to return. Remaining usage is summarized in other. Must be a whole number from 1 to 100. Defaults to 50.", - "type": "integer", - "minimum": 1, - "maximum": 100 + "minLength": 1, + "description": "Organization whose single sign-on settings are managed." } } ], + "requestBody": { + "required": true, + "description": "Configuration accepted by Update SSO Policy.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateSsoPolicyBody" + } + } + } + }, "responses": { "200": { - "description": "Get Organization Usage Breakdown result.", + "description": "Update SSO Policy result.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -7481,7 +7507,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GetOrganizationUsageBreakdownResponse" + "$ref": "#/components/schemas/UpdateSsoPolicyResponse" } } } @@ -7498,9 +7524,15 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -7513,12 +7545,12 @@ } } }, - "/api/v2/organizations/{organizationId}/usage/events": { + "/api/v2/organizations/{organizationId}/domains": { "get": { - "operationId": "listOrganizationUsageEvents", - "summary": "List Organization Usage Events", - "description": "Page through usage events, including zero-cost reporting. Requires organization administrator access and Usage Monitoring. Defaults to 30 days. Cursors retain the initial reporting window; keep filters and sort unchanged while paging. The sim-chat source covers both chat surfaces. Per-event rounding can produce credits=0 with hasCost=true. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "organization_usage.events.list", + "operationId": "listOrganizationDomains", + "summary": "List Organization Domains", + "description": "List the organization’s domain claims with cursor pagination. Requires organization membership. Pending DNS challenge values are returned only to administrators using their own credentials. Organizations without Enterprise domain entitlement return an empty list. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "organizations.domains.list", "x-oauth-scope": "api:read", "tags": ["Organizations"], "parameters": [ @@ -7526,91 +7558,21 @@ "name": "organizationId", "in": "path", "required": true, - "description": "Organization identifier.", + "description": "Organization whose single sign-on settings are managed.", "schema": { "type": "string", "minLength": 1, - "description": "Organization identifier." - } - }, - { - "name": "preset", - "in": "query", - "required": false, - "description": "Reporting window. Custom requires startDate and endDate and is capped at 92 days; other presets reject those bounds. Resolved billing windows are capped at 366 days.", - "schema": { - "default": "30d", - "description": "Reporting window. Custom requires startDate and endDate and is capped at 92 days; other presets reject those bounds. Resolved billing windows are capped at 366 days.", - "type": "string", - "enum": ["current-period", "previous-period", "7d", "30d", "custom"] - } - }, - { - "name": "startDate", - "in": "query", - "required": false, - "description": "First calendar date included, in the selected timezone. Requires preset=custom.", - "schema": { - "description": "First calendar date included, in the selected timezone. Requires preset=custom.", - "type": "string", - "format": "date", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))$" - } - }, - { - "name": "endDate", - "in": "query", - "required": false, - "description": "Last calendar date included, in the selected timezone. Requires preset=custom.", - "schema": { - "description": "Last calendar date included, in the selected timezone. Requires preset=custom.", - "type": "string", - "format": "date", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))$" - } - }, - { - "name": "timezone", - "in": "query", - "required": false, - "description": "IANA timezone for calendar boundaries; defaults to UTC.", - "schema": { - "default": "UTC", - "description": "IANA timezone for calendar boundaries; defaults to UTC.", - "type": "string", - "minLength": 1 - } - }, - { - "name": "source", - "in": "query", - "required": false, - "description": "Restrict events to one product surface.", - "schema": { - "description": "Restrict events to one product surface.", - "type": "string", - "enum": [ - "workflow", - "wand", - "sim-chat", - "mcp_copilot", - "mothership_block", - "knowledge-base", - "voice-input", - "enrichment", - "voice-output", - "api-tool" - ] + "description": "Organization whose single sign-on settings are managed." } }, { "name": "limit", "in": "query", "required": false, - "description": "Maximum usage events per page. Must be a whole number from 1 to 100. Defaults to 50.", + "description": "Maximum domain claims to return per page. Must be a whole number from 1 to 100. Defaults to 50.", "schema": { "default": 50, - "description": "Maximum usage events per page. Must be a whole number from 1 to 100. Defaults to 50.", + "description": "Maximum domain claims to return per page. Must be a whole number from 1 to 100. Defaults to 50.", "type": "integer", "minimum": 1, "maximum": 100 @@ -7633,10 +7595,10 @@ "required": false, "description": "Field used to sort the result.", "schema": { - "default": "createdAt", + "default": "domain", "description": "Field used to sort the result.", "type": "string", - "enum": ["createdAt"] + "enum": ["domain"] } }, { @@ -7645,7 +7607,7 @@ "required": false, "description": "Sort direction.", "schema": { - "default": "desc", + "default": "asc", "description": "Sort direction.", "type": "string", "enum": ["asc", "desc"] @@ -7654,7 +7616,7 @@ ], "responses": { "200": { - "description": "List Organization Usage Events result.", + "description": "List Organization Domains result.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -7669,7 +7631,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListOrganizationUsageEventsResponse" + "$ref": "#/components/schemas/ListOrganizationDomainsResponse" } } } @@ -7686,6 +7648,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -7696,125 +7661,41 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - } - }, - "/api/v2/workspaces/{workspaceId}/access-requests/discovery": { - "get": { - "operationId": "discoverWorkspaceAccessRequests", - "summary": "Discover Workspace Access Requests", - "description": "Discover the acting user’s access to features, integrations, models, tools, authentication methods, and member credit limits. Returns an empty list while requests are disabled. Requires access to the workspace; external collaborators use their workspace grant. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "access_requests.discover", - "x-oauth-scope": "api:read", - "tags": ["Access Requests"], + }, + "post": { + "operationId": "addOrganizationDomain", + "summary": "Add Organization Domain", + "description": "Claim a domain and receive its DNS TXT challenge. Requires organization administrator access and Enterprise domain entitlement. An existing claim returns 200; a new claim returns 201. A domain verified by another organization conflicts. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "organizations.domains.add", + "x-oauth-scope": "api:write", + "tags": ["Organizations"], "parameters": [ { - "name": "workspaceId", + "name": "organizationId", "in": "path", "required": true, - "description": "Workspace in which the acting user requests access.", + "description": "Organization whose single sign-on settings are managed.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "description": "Workspace in which the acting user requests access." + "description": "Organization whose single sign-on settings are managed." } - }, - { - "name": "search", - "in": "query", - "required": false, - "description": "Case-insensitive substring match against the access item label.", - "schema": { - "description": "Case-insensitive substring match against the access item label.", - "type": "string", - "minLength": 1, - "maxLength": 200 - } - }, - { - "name": "targetKind", - "in": "query", - "required": false, - "description": "Category of access to discover.", - "schema": { - "description": "Category of access to discover.", - "type": "string", - "enum": [ - "feature", - "integration", - "provider", - "model", - "tool", - "knowledge_connector", - "file_share_auth", - "chat_deploy_auth", - "usage_limit" - ] - } - }, - { - "name": "state", - "in": "query", - "required": false, - "description": "Filter by the acting user’s current access. Requestable items can be submitted for review.", - "schema": { - "description": "Filter by the acting user’s current access. Requestable items can be submitted for review.", - "type": "string", - "enum": ["allowed", "requestable", "unavailable"] - } - }, - { - "name": "sortBy", - "in": "query", - "required": false, - "description": "Field used to sort the result.", - "schema": { - "default": "label", - "description": "Field used to sort the result.", - "type": "string", - "enum": ["label"] - } - }, - { - "name": "sortOrder", - "in": "query", - "required": false, - "description": "Sort direction.", - "schema": { - "default": "asc", - "description": "Sort direction.", - "type": "string", - "enum": ["asc", "desc"] - } - }, - { - "name": "limit", - "in": "query", - "required": false, - "description": "Maximum access items to return per page. Must be a whole number from 1 to 100. Defaults to 50.", - "schema": { - "default": 50, - "description": "Maximum access items to return per page. Must be a whole number from 1 to 100. Defaults to 50.", - "type": "integer", - "minimum": 1, - "maximum": 100 - } - }, - { - "name": "cursor", - "in": "query", - "required": false, - "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", - "schema": { - "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", - "type": "string", - "minLength": 1 + } + ], + "requestBody": { + "required": true, + "description": "Configuration accepted by Add Organization Domain.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddOrganizationDomainBody" + } } } - ], + }, "responses": { "200": { - "description": "Discover Workspace Access Requests result.", + "description": "Add Organization Domain result.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -7829,122 +7710,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DiscoverWorkspaceAccessRequestsResponse" + "$ref": "#/components/schemas/AddOrganizationDomainResponse" } } } }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "429": { - "$ref": "#/components/responses/RateLimited" - }, - "500": { - "$ref": "#/components/responses/InternalError" - }, - "503": { - "$ref": "#/components/responses/ServiceUnavailable" - } - } - } - }, - "/api/v2/workspaces/{workspaceId}/access-requests": { - "get": { - "operationId": "listMyWorkspaceAccessRequests", - "summary": "List My Workspace Access Requests", - "description": "List only the acting user’s requests in this workspace, including resolved history and organization-wide member credit-limit requests. History remains available while requests are disabled. Requires access to the workspace; external collaborators use their workspace grant. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "access_requests.list_mine", - "x-oauth-scope": "api:read", - "tags": ["Access Requests"], - "parameters": [ - { - "name": "workspaceId", - "in": "path", - "required": true, - "description": "Workspace in which the acting user requests access.", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace in which the acting user requests access." - } - }, - { - "name": "status", - "in": "query", - "required": false, - "description": "Filter by request status; omit to include all statuses.", - "schema": { - "description": "Filter by request status; omit to include all statuses.", - "type": "string", - "enum": ["pending", "fulfilled", "declined", "cancelled", "closed"] - } - }, - { - "name": "sortBy", - "in": "query", - "required": false, - "description": "Field used to sort the result.", - "schema": { - "default": "createdAt", - "description": "Field used to sort the result.", - "type": "string", - "enum": ["createdAt", "targetLabel"] - } - }, - { - "name": "sortOrder", - "in": "query", - "required": false, - "description": "Sort direction.", - "schema": { - "default": "desc", - "description": "Sort direction.", - "type": "string", - "enum": ["asc", "desc"] - } - }, - { - "name": "limit", - "in": "query", - "required": false, - "description": "Maximum access requests to return per page. Must be a whole number from 1 to 100. Defaults to 50.", - "schema": { - "default": 50, - "description": "Maximum access requests to return per page. Must be a whole number from 1 to 100. Defaults to 50.", - "type": "integer", - "minimum": 1, - "maximum": 100 - } - }, - { - "name": "cursor", - "in": "query", - "required": false, - "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", - "schema": { - "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", - "type": "string", - "minLength": 1 - } - } - ], - "responses": { - "200": { - "description": "List My Workspace Access Requests result.", + "201": { + "description": "Add Organization Domain result.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -7959,7 +7731,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListMyWorkspaceAccessRequestsResponse" + "$ref": "#/components/schemas/AddOrganizationDomainResponse" } } } @@ -7979,6 +7751,12 @@ "409": { "$ref": "#/components/responses/Conflict" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -7989,42 +7767,55 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - }, + } + }, + "/api/v2/organizations/{organizationId}/domains/{domainId}/verify": { "post": { - "operationId": "createWorkspaceAccessRequest", - "summary": "Create Workspace Access Request", - "description": "Request access for the acting user using a target from discovery. Returns an existing matching pending request when applicable; the result may be closed if access is already available. Permission approvals change the governing group for all affected members. Requires access to the workspace; external collaborators use their workspace grant. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "access_requests.create", + "operationId": "verifyOrganizationDomain", + "summary": "Verify Organization Domain", + "description": "Verify domain ownership through the published DNS TXT challenge and grant domain trust to matching organization providers. Requires organization administrator access and Enterprise domain entitlement. An already-verified domain is returned unchanged. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "organizations.domains.verify", "x-oauth-scope": "api:write", - "tags": ["Access Requests"], + "tags": ["Organizations"], "parameters": [ { - "name": "workspaceId", + "name": "organizationId", "in": "path", "required": true, - "description": "Workspace in which the acting user requests access.", + "description": "Organization whose single sign-on settings are managed.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "description": "Workspace in which the acting user requests access." + "description": "Organization whose single sign-on settings are managed." + } + }, + { + "name": "domainId", + "in": "path", + "required": true, + "description": "Domain claim owned by this organization.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Domain claim owned by this organization." } } ], "requestBody": { "required": true, - "description": "Inputs for this operation.", + "description": "Configuration accepted by Verify Organization Domain.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateWorkspaceAccessRequestBody" + "$ref": "#/components/schemas/VerifyOrganizationDomainBody" } } } }, "responses": { "200": { - "description": "Create Workspace Access Request result.", + "description": "Verify Organization Domain result.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -8039,7 +7830,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateWorkspaceAccessRequestResponse" + "$ref": "#/components/schemas/VerifyOrganizationDomainResponse" } } } @@ -8077,43 +7868,42 @@ } } }, - "/api/v2/workspaces/{workspaceId}/access-requests/{requestId}/cancel": { - "post": { - "operationId": "cancelWorkspaceAccessRequest", - "summary": "Cancel Workspace Access Request", - "description": "Cancel the acting user’s pending request in this scope, including an organization-wide member credit-limit request. Already resolved requests are returned unchanged. Cancellation remains available while requests are disabled. Requires access to the workspace; external collaborators use their workspace grant. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "access_requests.cancel", + "/api/v2/organizations/{organizationId}/domains/{domainId}": { + "delete": { + "operationId": "removeOrganizationDomain", + "summary": "Remove Organization Domain", + "description": "Remove a domain claim and revoke verified sign-in authority from matching organization providers. Requires organization administrator access and Enterprise domain entitlement. Existing accounts and memberships remain. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "organizations.domains.remove", "x-oauth-scope": "api:write", - "tags": ["Access Requests"], + "tags": ["Organizations"], "parameters": [ { - "name": "workspaceId", + "name": "organizationId", "in": "path", "required": true, - "description": "Workspace in which the acting user requests access.", + "description": "Organization whose single sign-on settings are managed.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "description": "Workspace in which the acting user requests access." + "description": "Organization whose single sign-on settings are managed." } }, { - "name": "requestId", + "name": "domainId", "in": "path", "required": true, - "description": "Access request identifier.", + "description": "Domain claim owned by this organization.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "description": "Access request identifier." + "maxLength": 255, + "description": "Domain claim owned by this organization." } } ], "responses": { "200": { - "description": "Cancel Workspace Access Request result.", + "description": "Remove Organization Domain result.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -8128,7 +7918,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CancelWorkspaceAccessRequestResponse" + "$ref": "#/components/schemas/RemoveOrganizationDomainResponse" } } } @@ -8160,80 +7950,73 @@ } } }, - "/api/v2/organizations/{organizationId}/access-requests/discovery": { + "/api/v2/credentials/{credentialId}/members": { "get": { - "operationId": "discoverOrganizationAccessRequests", - "summary": "Discover Organization Access Requests", - "description": "Discover the acting user’s access to features, integrations, models, tools, authentication methods, and member credit limits. Returns an empty list while requests are disabled. Requires organization membership. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "access_requests.discover", + "operationId": "listCredentialMembers", + "summary": "List Credential Members", + "description": "List explicit credential grants, including revoked grants, and inherited workspace administrator access. Requires workspace read access. Credentials must be OAuth or service-account connections. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "credentials.members.list", "x-oauth-scope": "api:read", - "tags": ["Access Requests"], + "tags": ["Credentials"], "parameters": [ { - "name": "organizationId", + "name": "credentialId", "in": "path", "required": true, - "description": "Organization that owns the access requests.", + "description": "Credential whose sharing grants are managed.", "schema": { "type": "string", "minLength": 1, - "description": "Organization that owns the access requests." + "maxLength": 255, + "description": "Credential whose sharing grants are managed." } }, { - "name": "search", + "name": "workspaceId", "in": "query", - "required": false, - "description": "Case-insensitive substring match against the access item label.", + "required": true, + "description": "Workspace expected to own the credential.", "schema": { - "description": "Case-insensitive substring match against the access item label.", "type": "string", "minLength": 1, - "maxLength": 200 + "maxLength": 128, + "description": "Workspace expected to own the credential." } }, { - "name": "targetKind", + "name": "limit", "in": "query", "required": false, - "description": "Category of access to discover.", + "description": "Maximum credential members to return per page. Must be a whole number from 1 to 100. Defaults to 50.", "schema": { - "description": "Category of access to discover.", - "type": "string", - "enum": [ - "feature", - "integration", - "provider", - "model", - "tool", - "knowledge_connector", - "file_share_auth", - "chat_deploy_auth", - "usage_limit" - ] + "default": 50, + "description": "Maximum credential members to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 } }, { - "name": "state", + "name": "cursor", "in": "query", "required": false, - "description": "Filter by the acting user’s current access. Requestable items can be submitted for review.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "schema": { - "description": "Filter by the acting user’s current access. Requestable items can be submitted for review.", + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "type": "string", - "enum": ["allowed", "requestable", "unavailable"] + "minLength": 1 } }, { "name": "sortBy", "in": "query", "required": false, - "description": "Field used to sort the result.", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "schema": { - "default": "label", - "description": "Field used to sort the result.", + "default": "email", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "type": "string", - "enum": ["label"] + "enum": ["email", "name"] } }, { @@ -8247,35 +8030,11 @@ "type": "string", "enum": ["asc", "desc"] } - }, - { - "name": "limit", - "in": "query", - "required": false, - "description": "Maximum access items to return per page. Must be a whole number from 1 to 100. Defaults to 50.", - "schema": { - "default": 50, - "description": "Maximum access items to return per page. Must be a whole number from 1 to 100. Defaults to 50.", - "type": "integer", - "minimum": 1, - "maximum": 100 - } - }, - { - "name": "cursor", - "in": "query", - "required": false, - "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", - "schema": { - "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", - "type": "string", - "minLength": 1 - } } ], "responses": { "200": { - "description": "Discover Organization Access Requests result.", + "description": "List Credential Members result.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -8290,7 +8049,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DiscoverOrganizationAccessRequestsResponse" + "$ref": "#/components/schemas/ListCredentialMembersResponse" } } } @@ -8320,91 +8079,54 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - } - }, - "/api/v2/organizations/{organizationId}/access-requests/mine": { - "get": { - "operationId": "listMyOrganizationAccessRequests", - "summary": "List My Organization Access Requests", - "description": "List the acting user’s organization-level requests and member credit-limit requests, including resolved history. For workspace-scoped requests, use List My Workspace Access Requests. History remains available while requests are disabled. Requires organization membership. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "access_requests.list_mine", - "x-oauth-scope": "api:read", - "tags": ["Access Requests"], + }, + "post": { + "operationId": "upsertCredentialMember", + "summary": "Upsert Credential Member", + "description": "Grant or change an existing workspace member’s credential role. Requires credential administrator access. Revoked grants become active again; inherited administrators cannot be demoted. A new grant returns 201; an existing grant returns 200. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "credentials.members.upsert", + "x-oauth-scope": "api:write", + "tags": ["Credentials"], "parameters": [ { - "name": "organizationId", + "name": "credentialId", "in": "path", "required": true, - "description": "Organization that owns the access requests.", + "description": "Credential whose sharing grants are managed.", "schema": { "type": "string", "minLength": 1, - "description": "Organization that owns the access requests." - } - }, - { - "name": "status", - "in": "query", - "required": false, - "description": "Filter by request status; omit to include all statuses.", - "schema": { - "description": "Filter by request status; omit to include all statuses.", - "type": "string", - "enum": ["pending", "fulfilled", "declined", "cancelled", "closed"] - } - }, - { - "name": "sortBy", - "in": "query", - "required": false, - "description": "Field used to sort the result.", - "schema": { - "default": "createdAt", - "description": "Field used to sort the result.", - "type": "string", - "enum": ["createdAt", "targetLabel"] - } - }, - { - "name": "sortOrder", - "in": "query", - "required": false, - "description": "Sort direction.", - "schema": { - "default": "desc", - "description": "Sort direction.", - "type": "string", - "enum": ["asc", "desc"] - } - }, - { - "name": "limit", - "in": "query", - "required": false, - "description": "Maximum access requests to return per page. Must be a whole number from 1 to 100. Defaults to 50.", - "schema": { - "default": 50, - "description": "Maximum access requests to return per page. Must be a whole number from 1 to 100. Defaults to 50.", - "type": "integer", - "minimum": 1, - "maximum": 100 + "maxLength": 255, + "description": "Credential whose sharing grants are managed." } }, { - "name": "cursor", + "name": "workspaceId", "in": "query", - "required": false, - "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "required": true, + "description": "Workspace expected to own the credential.", "schema": { - "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "type": "string", - "minLength": 1 + "minLength": 1, + "maxLength": 128, + "description": "Workspace expected to own the credential." } } ], + "requestBody": { + "required": true, + "description": "Configuration accepted by Upsert Credential Member.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpsertCredentialMemberBody" + } + } + } + }, "responses": { "200": { - "description": "List My Organization Access Requests result.", + "description": "Upsert Credential Member result.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -8419,7 +8141,28 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListMyOrganizationAccessRequestsResponse" + "$ref": "#/components/schemas/UpsertCredentialMemberResponse" + } + } + } + }, + "201": { + "description": "Upsert Credential Member result.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpsertCredentialMemberResponse" } } } @@ -8439,6 +8182,12 @@ "409": { "$ref": "#/components/responses/Conflict" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -8451,41 +8200,55 @@ } } }, - "/api/v2/organizations/{organizationId}/access-requests": { - "post": { - "operationId": "createOrganizationAccessRequest", - "summary": "Create Organization Access Request", - "description": "Request access for the acting user using a target from discovery. Returns an existing matching pending request when applicable; the result may be closed if access is already available. Permission approvals change the governing group for all affected members. Requires organization membership. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "access_requests.create", + "/api/v2/credentials/{credentialId}/members/{userId}": { + "delete": { + "operationId": "removeCredentialMember", + "summary": "Remove Credential Member", + "description": "Revoke an active explicit credential grant. Requires credential administrator access. Inherited workspace administrators cannot be removed; an absent or already-revoked grant returns 404. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "credentials.members.remove", "x-oauth-scope": "api:write", - "tags": ["Access Requests"], + "tags": ["Credentials"], "parameters": [ { - "name": "organizationId", + "name": "credentialId", "in": "path", "required": true, - "description": "Organization that owns the access requests.", + "description": "Credential whose sharing grants are managed.", "schema": { "type": "string", "minLength": 1, - "description": "Organization that owns the access requests." + "maxLength": 255, + "description": "Credential whose sharing grants are managed." } - } - ], - "requestBody": { - "required": true, - "description": "Inputs for this operation.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateOrganizationAccessRequestBody" - } + }, + { + "name": "userId", + "in": "path", + "required": true, + "description": "User whose explicit grant will be revoked.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "User whose explicit grant will be revoked." + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace expected to own the credential.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace expected to own the credential." } } - }, + ], "responses": { "200": { - "description": "Create Organization Access Request result.", + "description": "Remove Credential Member result.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -8500,7 +8263,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateOrganizationAccessRequestResponse" + "$ref": "#/components/schemas/RemoveCredentialMemberResponse" } } } @@ -8520,12 +8283,6 @@ "409": { "$ref": "#/components/responses/Conflict" }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, - "415": { - "$ref": "#/components/responses/UnsupportedMediaType" - }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -8536,101 +8293,33 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - }, + } + }, + "/api/v2/workspaces/{workspaceId}/permission-config": { "get": { - "operationId": "listOrganizationAccessRequests", - "summary": "List Organization Access Requests", - "description": "List requests across the organization for administrator review. Includes requests from organization members and external workspace collaborators; history remains available while requests are disabled. Requires organization administrator access. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "access_requests.list_organization", - "x-oauth-scope": "api:read", - "tags": ["Access Requests"], + "operationId": "getWorkspacePermissionConfig", + "summary": "Get Workspace Permission Config", + "description": "Get the acting user's governing permission group and configuration for a workspace they can access. This describes permission-group restrictions, not the user's workspace role. Group and config are null when no group governs the caller; entitled indicates whether organization permission governance is active. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "permission_groups.read_user_config", + "x-oauth-scope": "api:read", + "tags": ["Workspaces"], "parameters": [ { - "name": "organizationId", + "name": "workspaceId", "in": "path", "required": true, - "description": "Organization that owns the access requests.", - "schema": { - "type": "string", - "minLength": 1, - "description": "Organization that owns the access requests." - } - }, - { - "name": "status", - "in": "query", - "required": false, - "description": "Filter by request status; omit to include all statuses.", - "schema": { - "description": "Filter by request status; omit to include all statuses.", - "type": "string", - "enum": ["pending", "fulfilled", "declined", "cancelled", "closed"] - } - }, - { - "name": "sortBy", - "in": "query", - "required": false, - "description": "Field used to sort the result.", - "schema": { - "default": "createdAt", - "description": "Field used to sort the result.", - "type": "string", - "enum": ["createdAt", "targetLabel"] - } - }, - { - "name": "sortOrder", - "in": "query", - "required": false, - "description": "Sort direction.", - "schema": { - "default": "desc", - "description": "Sort direction.", - "type": "string", - "enum": ["asc", "desc"] - } - }, - { - "name": "limit", - "in": "query", - "required": false, - "description": "Maximum access requests to return per page. Must be a whole number from 1 to 100. Defaults to 50.", - "schema": { - "default": 50, - "description": "Maximum access requests to return per page. Must be a whole number from 1 to 100. Defaults to 50.", - "type": "integer", - "minimum": 1, - "maximum": 100 - } - }, - { - "name": "cursor", - "in": "query", - "required": false, - "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", - "schema": { - "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", - "type": "string", - "minLength": 1 - } - }, - { - "name": "search", - "in": "query", - "required": false, - "description": "Case-insensitive substring match against the target label or requester name or email.", + "description": "Unique workspace identifier.", "schema": { - "description": "Case-insensitive substring match against the target label or requester name or email.", "type": "string", "minLength": 1, - "maxLength": 200 + "maxLength": 128, + "description": "Unique workspace identifier." } } ], "responses": { "200": { - "description": "List Organization Access Requests result.", + "description": "The caller’s effective permission-group configuration.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -8645,7 +8334,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListOrganizationAccessRequestsResponse" + "$ref": "#/components/schemas/GetWorkspacePermissionConfigResponse" } } } @@ -8662,9 +8351,6 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "409": { - "$ref": "#/components/responses/Conflict" - }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -8677,42 +8363,42 @@ } } }, - "/api/v2/organizations/{organizationId}/access-requests/{requestId}/cancel": { + "/api/v2/workspaces/{workspaceId}/invitations": { "post": { - "operationId": "cancelOrganizationAccessRequest", - "summary": "Cancel Organization Access Request", - "description": "Cancel the acting user’s pending request in this scope, including an organization-wide member credit-limit request. Already resolved requests are returned unchanged. Cancellation remains available while requests are disabled. Requires organization membership. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "access_requests.cancel", + "operationId": "createWorkspaceInvitations", + "summary": "Create Workspace Invitations", + "description": "Invite people to a workspace or grant access immediately to existing organization members. Requires workspace administrator access and current invitation eligibility; organization administrator invitations also require organization administrator access. Recipients are processed independently: inspect failed even after HTTP 200, and inspect invitation status before retrying a delivery failure. Existing access is preserved. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "invitations.send_batch", "x-oauth-scope": "api:write", - "tags": ["Access Requests"], + "tags": ["Workspaces"], "parameters": [ { - "name": "organizationId", - "in": "path", - "required": true, - "description": "Organization that owns the access requests.", - "schema": { - "type": "string", - "minLength": 1, - "description": "Organization that owns the access requests." - } - }, - { - "name": "requestId", + "name": "workspaceId", "in": "path", "required": true, - "description": "Access request identifier.", + "description": "Unique workspace identifier.", "schema": { "type": "string", "minLength": 1, "maxLength": 128, - "description": "Access request identifier." + "description": "Unique workspace identifier." } } ], + "requestBody": { + "required": true, + "description": "Recipients and the access to grant.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateWorkspaceInvitationsBody" + } + } + } + }, "responses": { "200": { - "description": "Cancel Organization Access Request result.", + "description": "Per-recipient invitation and direct-grant outcomes.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -8727,7 +8413,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CancelOrganizationAccessRequestResponse" + "$ref": "#/components/schemas/CreateWorkspaceInvitationsResponse" } } } @@ -8747,6 +8433,12 @@ "409": { "$ref": "#/components/responses/Conflict" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -8759,42 +8451,41 @@ } } }, - "/api/v2/organizations/{organizationId}/access-requests/{requestId}/preview": { + "/api/v2/organizations/{organizationId}/members/{userId}/usage-limit": { "get": { - "operationId": "previewOrganizationAccessRequest", - "summary": "Preview Organization Access Request", - "description": "Preview the current permission changes, affected group and audience, or member credit cap. Review canApply, changes, impact, and fingerprint before resolving. Permission changes affect the entire governing group, not only the requester. Requires organization administrator access. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "access_requests.preview", + "operationId": "getOrganizationMemberUsageLimit", + "summary": "Get Organization Member Credit Limit", + "description": "Read a person’s credit cap and credits consumed in the organization billing period. Hosted only. The userId identifies an organization member or external collaborator with workspace access in this organization; it is not a membership record ID. Null means no per-person cap, while organization limits still apply. Requires organization administrator access. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "organization_member_usage_limits.read", "x-oauth-scope": "api:read", - "tags": ["Access Requests"], + "tags": ["Organizations"], "parameters": [ { "name": "organizationId", "in": "path", "required": true, - "description": "Organization that owns the access requests.", + "description": "Organization identifier.", "schema": { "type": "string", "minLength": 1, - "description": "Organization that owns the access requests." + "description": "Organization identifier." } }, { - "name": "requestId", + "name": "userId", "in": "path", "required": true, - "description": "Access request identifier.", + "description": "User ID of an organization member or external collaborator with workspace access in this organization. Use List Organization Members or List Workspace Members to find it.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "description": "Access request identifier." + "description": "User ID of an organization member or external collaborator with workspace access in this organization. Use List Organization Members or List Workspace Members to find it." } } ], "responses": { "200": { - "description": "Preview Organization Access Request result.", + "description": "Get Organization Member Credit Limit result.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -8809,7 +8500,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PreviewOrganizationAccessRequestResponse" + "$ref": "#/components/schemas/GetOrganizationMemberUsageLimitResponse" } } } @@ -8826,9 +8517,6 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "409": { - "$ref": "#/components/responses/Conflict" - }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -8839,55 +8527,52 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - } - }, - "/api/v2/organizations/{organizationId}/access-requests/{requestId}/resolve": { - "post": { - "operationId": "resolveOrganizationAccessRequest", - "summary": "Resolve Organization Access Request", - "description": "Apply a reviewed request or decline it with a reason. Applying requires the preview fingerprint; changed policy or membership returns a conflict. Credit requests also require a higher newLimitCredits. Already resolved requests are returned unchanged. Requires organization administrator access. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "access_requests.resolve", + }, + "patch": { + "operationId": "updateOrganizationMemberUsageLimit", + "summary": "Update Organization Member Credit Limit", + "description": "Set or clear a person’s credit cap. Hosted only. The userId must identify an organization member or external collaborator with workspace access in this organization. The cap is a nonnegative whole number of credits, not dollars: 0 prevents further credit-consuming usage; null removes the per-person cap. Organization limits continue to apply. Retrying the same value is safe. Requires organization administrator access. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "organization_member_usage_limits.update", "x-oauth-scope": "api:write", - "tags": ["Access Requests"], + "tags": ["Organizations"], "parameters": [ { "name": "organizationId", "in": "path", "required": true, - "description": "Organization that owns the access requests.", + "description": "Organization identifier.", "schema": { "type": "string", "minLength": 1, - "description": "Organization that owns the access requests." + "description": "Organization identifier." } }, { - "name": "requestId", + "name": "userId", "in": "path", "required": true, - "description": "Access request identifier.", + "description": "User ID of an organization member or external collaborator with workspace access in this organization. Use List Organization Members or List Workspace Members to find it.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "description": "Access request identifier." + "description": "User ID of an organization member or external collaborator with workspace access in this organization. Use List Organization Members or List Workspace Members to find it." } } ], "requestBody": { "required": true, - "description": "Inputs for this operation.", + "description": "Credit cap in whole credits; null clears the cap.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ResolveOrganizationAccessRequestBody" + "$ref": "#/components/schemas/UpdateOrganizationMemberUsageLimitBody" } } } }, "responses": { "200": { - "description": "Resolve Organization Access Request result.", + "description": "Update Organization Member Credit Limit result.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -8902,7 +8587,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ResolveOrganizationAccessRequestResponse" + "$ref": "#/components/schemas/UpdateOrganizationMemberUsageLimitResponse" } } } @@ -8919,9 +8604,6 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "409": { - "$ref": "#/components/responses/Conflict" - }, "413": { "$ref": "#/components/responses/PayloadTooLarge" }, @@ -8940,64 +8622,121 @@ } } }, - "/api/v2/organizations/{organizationId}/access-requests/settings": { + "/api/v2/organizations/{organizationId}/usage/summary": { "get": { - "operationId": "getOrganizationAccessRequestSettings", - "summary": "Get Organization Access Request Settings", - "description": "Get whether the organization allows new access requests and approvals. This preference does not enable features unavailable in the deployment or subscription. Requires organization administrator access. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "access_requests.get_settings", + "operationId": "getOrganizationUsageSummary", + "summary": "Get Organization Usage Summary", + "description": "Read pooled credits, a usage series, and an exact previous-period comparison when available. Requires organization administrator access and Usage Monitoring (Enterprise on hosted; enabled on self-hosted). Defaults to 30 days. Custom dates include both dates in the selected timezone and cannot exceed 92 days. Billing windows exceeding 366 days are rejected. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "organization_usage.summary.read", "x-oauth-scope": "api:read", - "tags": ["Access Requests"], + "tags": ["Organizations"], "parameters": [ { "name": "organizationId", "in": "path", "required": true, - "description": "Organization that owns the access requests.", + "description": "Organization identifier.", "schema": { "type": "string", "minLength": 1, - "description": "Organization that owns the access requests." - } - } - ], - "responses": { - "200": { - "description": "Get Organization Access Request Settings result.", - "headers": { - "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" - }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" - }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GetOrganizationAccessRequestSettingsResponse" - } - } + "description": "Organization identifier." } }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" + { + "name": "preset", + "in": "query", + "required": false, + "description": "Reporting window. Custom requires startDate and endDate and is capped at 92 days; other presets reject those bounds. Resolved billing windows are capped at 366 days.", + "schema": { + "default": "30d", + "description": "Reporting window. Custom requires startDate and endDate and is capped at 92 days; other presets reject those bounds. Resolved billing windows are capped at 366 days.", + "type": "string", + "enum": ["current-period", "previous-period", "7d", "30d", "custom"] + } + }, + { + "name": "startDate", + "in": "query", + "required": false, + "description": "First calendar date included, in the selected timezone. Requires preset=custom.", + "schema": { + "description": "First calendar date included, in the selected timezone. Requires preset=custom.", + "type": "string", + "format": "date", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))$" + } + }, + { + "name": "endDate", + "in": "query", + "required": false, + "description": "Last calendar date included, in the selected timezone. Requires preset=custom.", + "schema": { + "description": "Last calendar date included, in the selected timezone. Requires preset=custom.", + "type": "string", + "format": "date", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))$" + } + }, + { + "name": "timezone", + "in": "query", + "required": false, + "description": "IANA timezone for calendar boundaries; defaults to UTC.", + "schema": { + "default": "UTC", + "description": "IANA timezone for calendar boundaries; defaults to UTC.", + "type": "string", + "minLength": 1 + } + }, + { + "name": "workspaceId", + "in": "query", + "required": false, + "description": "Restrict usage to one workspace owned by the organization.", + "schema": { + "description": "Restrict usage to one workspace owned by the organization.", + "type": "string", + "minLength": 1, + "maxLength": 128 + } + } + ], + "responses": { + "200": { + "description": "Get Organization Usage Summary result.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetOrganizationUsageSummaryResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" }, "404": { "$ref": "#/components/responses/NotFound" }, - "409": { - "$ref": "#/components/responses/Conflict" - }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -9008,41 +8747,116 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - }, - "patch": { - "operationId": "updateOrganizationAccessRequestSettings", - "summary": "Update Organization Access Request Settings", - "description": "Allow or pause new access requests and approvals. Pausing preserves history, cancellation, and decline, and does not revoke previously granted access. Requires organization administrator access. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "access_requests.update_settings", - "x-oauth-scope": "api:write", - "tags": ["Access Requests"], + } + }, + "/api/v2/organizations/{organizationId}/usage/breakdown": { + "get": { + "operationId": "getOrganizationUsageBreakdown", + "summary": "Get Organization Usage Breakdown", + "description": "Read ranked organization usage by member, workspace, workflow, model, BYOK provider, or source. Requires organization administrator access and Usage Monitoring. Omitted usage is summarized in other. BYOK ranks tokens; other dimensions rank cost. More than 10,000 underlying groups returns 413; narrow the window or workspace. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "organization_usage.breakdown.read", + "x-oauth-scope": "api:read", + "tags": ["Organizations"], "parameters": [ { "name": "organizationId", "in": "path", "required": true, - "description": "Organization that owns the access requests.", + "description": "Organization identifier.", "schema": { "type": "string", "minLength": 1, - "description": "Organization that owns the access requests." + "description": "Organization identifier." } - } - ], - "requestBody": { - "required": true, - "description": "Inputs for this operation.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateOrganizationAccessRequestSettingsBody" - } + }, + { + "name": "preset", + "in": "query", + "required": false, + "description": "Reporting window. Custom requires startDate and endDate and is capped at 92 days; other presets reject those bounds. Resolved billing windows are capped at 366 days.", + "schema": { + "default": "30d", + "description": "Reporting window. Custom requires startDate and endDate and is capped at 92 days; other presets reject those bounds. Resolved billing windows are capped at 366 days.", + "type": "string", + "enum": ["current-period", "previous-period", "7d", "30d", "custom"] + } + }, + { + "name": "startDate", + "in": "query", + "required": false, + "description": "First calendar date included, in the selected timezone. Requires preset=custom.", + "schema": { + "description": "First calendar date included, in the selected timezone. Requires preset=custom.", + "type": "string", + "format": "date", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))$" + } + }, + { + "name": "endDate", + "in": "query", + "required": false, + "description": "Last calendar date included, in the selected timezone. Requires preset=custom.", + "schema": { + "description": "Last calendar date included, in the selected timezone. Requires preset=custom.", + "type": "string", + "format": "date", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))$" + } + }, + { + "name": "timezone", + "in": "query", + "required": false, + "description": "IANA timezone for calendar boundaries; defaults to UTC.", + "schema": { + "default": "UTC", + "description": "IANA timezone for calendar boundaries; defaults to UTC.", + "type": "string", + "minLength": 1 + } + }, + { + "name": "workspaceId", + "in": "query", + "required": false, + "description": "Restrict usage to one workspace owned by the organization.", + "schema": { + "description": "Restrict usage to one workspace owned by the organization.", + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + { + "name": "dimension", + "in": "query", + "required": true, + "description": "Usage grouping dimension.", + "schema": { + "type": "string", + "enum": ["member", "workspace", "workflow", "model", "byok", "source"], + "description": "Usage grouping dimension." + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum ranked groups to return. Remaining usage is summarized in other. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum ranked groups to return. Remaining usage is summarized in other. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 } } - }, + ], "responses": { "200": { - "description": "Update Organization Access Request Settings result.", + "description": "Get Organization Usage Breakdown result.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -9057,7 +8871,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateOrganizationAccessRequestSettingsResponse" + "$ref": "#/components/schemas/GetOrganizationUsageBreakdownResponse" } } } @@ -9074,15 +8888,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "409": { - "$ref": "#/components/responses/Conflict" - }, "413": { "$ref": "#/components/responses/PayloadTooLarge" }, - "415": { - "$ref": "#/components/responses/UnsupportedMediaType" - }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -9094,42 +8902,1624 @@ } } } - } - }, - "components": { - "securitySchemes": { - "apiKey": { - "type": "apiKey", - "in": "header", - "name": "X-API-Key", - "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." - }, - "oauthBearer": { - "type": "http", - "scheme": "bearer", - "bearerFormat": "OAuth 2.0 access token", - "description": "A Sim OAuth access token obtained by a registered client through the authorization-code flow. Each operation declares its required scope: api:read permits reads and searches; api:write also permits changes and execution and implies api:read. Scope requirements follow the application operation, independent of HTTP method or workspace role." - } }, - "headers": { - "X-RateLimit-Limit": { - "description": "Maximum requests allowed in the current window.", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "title": "Rate limit", - "description": "Maximum requests allowed in the current window." - } - }, - "X-RateLimit-Remaining": { - "description": "Requests remaining in the current window.", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "title": "Rate limit remaining", - "description": "Requests remaining in the current window." + "/api/v2/organizations/{organizationId}/usage/events": { + "get": { + "operationId": "listOrganizationUsageEvents", + "summary": "List Organization Usage Events", + "description": "Page through usage events, including zero-cost reporting. Requires organization administrator access and Usage Monitoring. Defaults to 30 days. Cursors retain the initial reporting window; keep filters and sort unchanged while paging. The sim-chat source covers both chat surfaces. Per-event rounding can produce credits=0 with hasCost=true. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "organization_usage.events.list", + "x-oauth-scope": "api:read", + "tags": ["Organizations"], + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "description": "Organization identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Organization identifier." + } + }, + { + "name": "preset", + "in": "query", + "required": false, + "description": "Reporting window. Custom requires startDate and endDate and is capped at 92 days; other presets reject those bounds. Resolved billing windows are capped at 366 days.", + "schema": { + "default": "30d", + "description": "Reporting window. Custom requires startDate and endDate and is capped at 92 days; other presets reject those bounds. Resolved billing windows are capped at 366 days.", + "type": "string", + "enum": ["current-period", "previous-period", "7d", "30d", "custom"] + } + }, + { + "name": "startDate", + "in": "query", + "required": false, + "description": "First calendar date included, in the selected timezone. Requires preset=custom.", + "schema": { + "description": "First calendar date included, in the selected timezone. Requires preset=custom.", + "type": "string", + "format": "date", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))$" + } + }, + { + "name": "endDate", + "in": "query", + "required": false, + "description": "Last calendar date included, in the selected timezone. Requires preset=custom.", + "schema": { + "description": "Last calendar date included, in the selected timezone. Requires preset=custom.", + "type": "string", + "format": "date", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))$" + } + }, + { + "name": "timezone", + "in": "query", + "required": false, + "description": "IANA timezone for calendar boundaries; defaults to UTC.", + "schema": { + "default": "UTC", + "description": "IANA timezone for calendar boundaries; defaults to UTC.", + "type": "string", + "minLength": 1 + } + }, + { + "name": "source", + "in": "query", + "required": false, + "description": "Restrict events to one product surface.", + "schema": { + "description": "Restrict events to one product surface.", + "type": "string", + "enum": [ + "workflow", + "wand", + "sim-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment", + "voice-output", + "api-tool" + ] + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum usage events per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum usage events per page. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "schema": { + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "type": "string", + "minLength": 1 + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field used to sort the result.", + "schema": { + "default": "createdAt", + "description": "Field used to sort the result.", + "type": "string", + "enum": ["createdAt"] + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "default": "desc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc", "desc"] + } + } + ], + "responses": { + "200": { + "description": "List Organization Usage Events result.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListOrganizationUsageEventsResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/workspaces/{workspaceId}/access-requests/discovery": { + "get": { + "operationId": "discoverWorkspaceAccessRequests", + "summary": "Discover Workspace Access Requests", + "description": "Discover the acting user’s access to features, integrations, models, tools, authentication methods, and member credit limits. Returns an empty list while requests are disabled. Requires access to the workspace; external collaborators use their workspace grant. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "access_requests.discover", + "x-oauth-scope": "api:read", + "tags": ["Access Requests"], + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "description": "Workspace in which the acting user requests access.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace in which the acting user requests access." + } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the access item label.", + "schema": { + "description": "Case-insensitive substring match against the access item label.", + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + { + "name": "targetKind", + "in": "query", + "required": false, + "description": "Category of access to discover.", + "schema": { + "description": "Category of access to discover.", + "type": "string", + "enum": [ + "feature", + "integration", + "provider", + "model", + "tool", + "knowledge_connector", + "file_share_auth", + "chat_deploy_auth", + "usage_limit" + ] + } + }, + { + "name": "state", + "in": "query", + "required": false, + "description": "Filter by the acting user’s current access. Requestable items can be submitted for review.", + "schema": { + "description": "Filter by the acting user’s current access. Requestable items can be submitted for review.", + "type": "string", + "enum": ["allowed", "requestable", "unavailable"] + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field used to sort the result.", + "schema": { + "default": "label", + "description": "Field used to sort the result.", + "type": "string", + "enum": ["label"] + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "default": "asc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc", "desc"] + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum access items to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum access items to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "schema": { + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "type": "string", + "minLength": 1 + } + } + ], + "responses": { + "200": { + "description": "Discover Workspace Access Requests result.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DiscoverWorkspaceAccessRequestsResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/workspaces/{workspaceId}/access-requests": { + "get": { + "operationId": "listMyWorkspaceAccessRequests", + "summary": "List My Workspace Access Requests", + "description": "List only the acting user’s requests in this workspace, including resolved history and organization-wide member credit-limit requests. History remains available while requests are disabled. Requires access to the workspace; external collaborators use their workspace grant. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "access_requests.list_mine", + "x-oauth-scope": "api:read", + "tags": ["Access Requests"], + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "description": "Workspace in which the acting user requests access.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace in which the acting user requests access." + } + }, + { + "name": "status", + "in": "query", + "required": false, + "description": "Filter by request status; omit to include all statuses.", + "schema": { + "description": "Filter by request status; omit to include all statuses.", + "type": "string", + "enum": ["pending", "fulfilled", "declined", "cancelled", "closed"] + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field used to sort the result.", + "schema": { + "default": "createdAt", + "description": "Field used to sort the result.", + "type": "string", + "enum": ["createdAt", "targetLabel"] + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "default": "desc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc", "desc"] + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum access requests to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum access requests to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "schema": { + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "type": "string", + "minLength": 1 + } + } + ], + "responses": { + "200": { + "description": "List My Workspace Access Requests result.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListMyWorkspaceAccessRequestsResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + }, + "post": { + "operationId": "createWorkspaceAccessRequest", + "summary": "Create Workspace Access Request", + "description": "Request access for the acting user using a target from discovery. Returns an existing matching pending request when applicable; the result may be closed if access is already available. Permission approvals change the governing group for all affected members. Requires access to the workspace; external collaborators use their workspace grant. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "access_requests.create", + "x-oauth-scope": "api:write", + "tags": ["Access Requests"], + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "description": "Workspace in which the acting user requests access.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace in which the acting user requests access." + } + } + ], + "requestBody": { + "required": true, + "description": "Inputs for this operation.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateWorkspaceAccessRequestBody" + } + } + } + }, + "responses": { + "200": { + "description": "Create Workspace Access Request result.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateWorkspaceAccessRequestResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/workspaces/{workspaceId}/access-requests/{requestId}/cancel": { + "post": { + "operationId": "cancelWorkspaceAccessRequest", + "summary": "Cancel Workspace Access Request", + "description": "Cancel the acting user’s pending request in this scope, including an organization-wide member credit-limit request. Already resolved requests are returned unchanged. Cancellation remains available while requests are disabled. Requires access to the workspace; external collaborators use their workspace grant. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "access_requests.cancel", + "x-oauth-scope": "api:write", + "tags": ["Access Requests"], + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "description": "Workspace in which the acting user requests access.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace in which the acting user requests access." + } + }, + { + "name": "requestId", + "in": "path", + "required": true, + "description": "Access request identifier.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Access request identifier." + } + } + ], + "responses": { + "200": { + "description": "Cancel Workspace Access Request result.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CancelWorkspaceAccessRequestResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/organizations/{organizationId}/access-requests/discovery": { + "get": { + "operationId": "discoverOrganizationAccessRequests", + "summary": "Discover Organization Access Requests", + "description": "Discover the acting user’s access to features, integrations, models, tools, authentication methods, and member credit limits. Returns an empty list while requests are disabled. Requires organization membership. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "access_requests.discover", + "x-oauth-scope": "api:read", + "tags": ["Access Requests"], + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "description": "Organization that owns the access requests.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Organization that owns the access requests." + } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the access item label.", + "schema": { + "description": "Case-insensitive substring match against the access item label.", + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + { + "name": "targetKind", + "in": "query", + "required": false, + "description": "Category of access to discover.", + "schema": { + "description": "Category of access to discover.", + "type": "string", + "enum": [ + "feature", + "integration", + "provider", + "model", + "tool", + "knowledge_connector", + "file_share_auth", + "chat_deploy_auth", + "usage_limit" + ] + } + }, + { + "name": "state", + "in": "query", + "required": false, + "description": "Filter by the acting user’s current access. Requestable items can be submitted for review.", + "schema": { + "description": "Filter by the acting user’s current access. Requestable items can be submitted for review.", + "type": "string", + "enum": ["allowed", "requestable", "unavailable"] + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field used to sort the result.", + "schema": { + "default": "label", + "description": "Field used to sort the result.", + "type": "string", + "enum": ["label"] + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "default": "asc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc", "desc"] + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum access items to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum access items to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "schema": { + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "type": "string", + "minLength": 1 + } + } + ], + "responses": { + "200": { + "description": "Discover Organization Access Requests result.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DiscoverOrganizationAccessRequestsResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/organizations/{organizationId}/access-requests/mine": { + "get": { + "operationId": "listMyOrganizationAccessRequests", + "summary": "List My Organization Access Requests", + "description": "List the acting user’s organization-level requests and member credit-limit requests, including resolved history. For workspace-scoped requests, use List My Workspace Access Requests. History remains available while requests are disabled. Requires organization membership. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "access_requests.list_mine", + "x-oauth-scope": "api:read", + "tags": ["Access Requests"], + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "description": "Organization that owns the access requests.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Organization that owns the access requests." + } + }, + { + "name": "status", + "in": "query", + "required": false, + "description": "Filter by request status; omit to include all statuses.", + "schema": { + "description": "Filter by request status; omit to include all statuses.", + "type": "string", + "enum": ["pending", "fulfilled", "declined", "cancelled", "closed"] + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field used to sort the result.", + "schema": { + "default": "createdAt", + "description": "Field used to sort the result.", + "type": "string", + "enum": ["createdAt", "targetLabel"] + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "default": "desc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc", "desc"] + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum access requests to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum access requests to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "schema": { + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "type": "string", + "minLength": 1 + } + } + ], + "responses": { + "200": { + "description": "List My Organization Access Requests result.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListMyOrganizationAccessRequestsResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/organizations/{organizationId}/access-requests": { + "post": { + "operationId": "createOrganizationAccessRequest", + "summary": "Create Organization Access Request", + "description": "Request access for the acting user using a target from discovery. Returns an existing matching pending request when applicable; the result may be closed if access is already available. Permission approvals change the governing group for all affected members. Requires organization membership. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "access_requests.create", + "x-oauth-scope": "api:write", + "tags": ["Access Requests"], + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "description": "Organization that owns the access requests.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Organization that owns the access requests." + } + } + ], + "requestBody": { + "required": true, + "description": "Inputs for this operation.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateOrganizationAccessRequestBody" + } + } + } + }, + "responses": { + "200": { + "description": "Create Organization Access Request result.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateOrganizationAccessRequestResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + }, + "get": { + "operationId": "listOrganizationAccessRequests", + "summary": "List Organization Access Requests", + "description": "List requests across the organization for administrator review. Includes requests from organization members and external workspace collaborators; history remains available while requests are disabled. Requires organization administrator access. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "access_requests.list_organization", + "x-oauth-scope": "api:read", + "tags": ["Access Requests"], + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "description": "Organization that owns the access requests.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Organization that owns the access requests." + } + }, + { + "name": "status", + "in": "query", + "required": false, + "description": "Filter by request status; omit to include all statuses.", + "schema": { + "description": "Filter by request status; omit to include all statuses.", + "type": "string", + "enum": ["pending", "fulfilled", "declined", "cancelled", "closed"] + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field used to sort the result.", + "schema": { + "default": "createdAt", + "description": "Field used to sort the result.", + "type": "string", + "enum": ["createdAt", "targetLabel"] + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "default": "desc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc", "desc"] + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum access requests to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum access requests to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "schema": { + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "type": "string", + "minLength": 1 + } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the target label or requester name or email.", + "schema": { + "description": "Case-insensitive substring match against the target label or requester name or email.", + "type": "string", + "minLength": 1, + "maxLength": 200 + } + } + ], + "responses": { + "200": { + "description": "List Organization Access Requests result.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListOrganizationAccessRequestsResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/organizations/{organizationId}/access-requests/{requestId}/cancel": { + "post": { + "operationId": "cancelOrganizationAccessRequest", + "summary": "Cancel Organization Access Request", + "description": "Cancel the acting user’s pending request in this scope, including an organization-wide member credit-limit request. Already resolved requests are returned unchanged. Cancellation remains available while requests are disabled. Requires organization membership. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "access_requests.cancel", + "x-oauth-scope": "api:write", + "tags": ["Access Requests"], + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "description": "Organization that owns the access requests.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Organization that owns the access requests." + } + }, + { + "name": "requestId", + "in": "path", + "required": true, + "description": "Access request identifier.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Access request identifier." + } + } + ], + "responses": { + "200": { + "description": "Cancel Organization Access Request result.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CancelOrganizationAccessRequestResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/organizations/{organizationId}/access-requests/{requestId}/preview": { + "get": { + "operationId": "previewOrganizationAccessRequest", + "summary": "Preview Organization Access Request", + "description": "Preview the current permission changes, affected group and audience, or member credit cap. Review canApply, changes, impact, and fingerprint before resolving. Permission changes affect the entire governing group, not only the requester. Requires organization administrator access. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "access_requests.preview", + "x-oauth-scope": "api:read", + "tags": ["Access Requests"], + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "description": "Organization that owns the access requests.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Organization that owns the access requests." + } + }, + { + "name": "requestId", + "in": "path", + "required": true, + "description": "Access request identifier.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Access request identifier." + } + } + ], + "responses": { + "200": { + "description": "Preview Organization Access Request result.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreviewOrganizationAccessRequestResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/organizations/{organizationId}/access-requests/{requestId}/resolve": { + "post": { + "operationId": "resolveOrganizationAccessRequest", + "summary": "Resolve Organization Access Request", + "description": "Apply a reviewed request or decline it with a reason. Applying requires the preview fingerprint; changed policy or membership returns a conflict. Credit requests also require a higher newLimitCredits. Already resolved requests are returned unchanged. Requires organization administrator access. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "access_requests.resolve", + "x-oauth-scope": "api:write", + "tags": ["Access Requests"], + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "description": "Organization that owns the access requests.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Organization that owns the access requests." + } + }, + { + "name": "requestId", + "in": "path", + "required": true, + "description": "Access request identifier.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Access request identifier." + } + } + ], + "requestBody": { + "required": true, + "description": "Inputs for this operation.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResolveOrganizationAccessRequestBody" + } + } + } + }, + "responses": { + "200": { + "description": "Resolve Organization Access Request result.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResolveOrganizationAccessRequestResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/organizations/{organizationId}/access-requests/settings": { + "get": { + "operationId": "getOrganizationAccessRequestSettings", + "summary": "Get Organization Access Request Settings", + "description": "Get whether the organization allows new access requests and approvals. This preference does not enable features unavailable in the deployment or subscription. Requires organization administrator access. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "access_requests.get_settings", + "x-oauth-scope": "api:read", + "tags": ["Access Requests"], + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "description": "Organization that owns the access requests.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Organization that owns the access requests." + } + } + ], + "responses": { + "200": { + "description": "Get Organization Access Request Settings result.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetOrganizationAccessRequestSettingsResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + }, + "patch": { + "operationId": "updateOrganizationAccessRequestSettings", + "summary": "Update Organization Access Request Settings", + "description": "Allow or pause new access requests and approvals. Pausing preserves history, cancellation, and decline, and does not revoke previously granted access. Requires organization administrator access. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "access_requests.update_settings", + "x-oauth-scope": "api:write", + "tags": ["Access Requests"], + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "description": "Organization that owns the access requests.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Organization that owns the access requests." + } + } + ], + "requestBody": { + "required": true, + "description": "Inputs for this operation.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateOrganizationAccessRequestSettingsBody" + } + } + } + }, + "responses": { + "200": { + "description": "Update Organization Access Request Settings result.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateOrganizationAccessRequestSettingsResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." + }, + "oauthBearer": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "OAuth 2.0 access token", + "description": "A Sim OAuth access token obtained by a registered client through the authorization-code flow. Each operation declares its required scope: api:read permits reads and searches; api:write also permits changes and execution and implies api:read. Scope requirements follow the application operation, independent of HTTP method or workspace role." + } + }, + "headers": { + "X-RateLimit-Limit": { + "description": "Maximum requests allowed in the current window.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "title": "Rate limit", + "description": "Maximum requests allowed in the current window." + } + }, + "X-RateLimit-Remaining": { + "description": "Requests remaining in the current window.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "title": "Rate limit remaining", + "description": "Requests remaining in the current window." } }, "X-RateLimit-Reset": { @@ -9381,6 +10771,8 @@ "ORGANIZATION_MEMBERSHIP_REQUIRED", "ORGANIZATION_ADMIN_REQUIRED", "ENTERPRISE_PLAN_REQUIRED", + "SSO_DOMAIN_NOT_VERIFIED", + "SSO_PROVIDER_LIMIT_REACHED", "ORGANIZATION_PLAN_REQUIRED", "AUDIT_LOGS_DISABLED", "ACCESS_REQUESTS_DISABLED", @@ -17041,71 +18433,357 @@ } }, "additionalProperties": false, - "title": "Update Permission Group request", - "description": "Update Permission Group inputs.", + "title": "Update Permission Group request", + "description": "Update Permission Group inputs.", + "examples": [ + { + "description": "Restricted workspace access" + } + ] + }, + "V2PermissionGroupDeletion": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Deleted permission group identifier." + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Whether the group was permanently deleted." + } + }, + "required": ["id", "deleted"], + "additionalProperties": false, + "title": "Permission group deletion", + "description": "Acknowledges permanent group deletion." + }, + "DeletePermissionGroupResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2PermissionGroupDeletion" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Delete Permission Group response", + "description": "Delete Permission Group result.", + "examples": [ + { + "data": { + "id": "group-123", + "deleted": true + } + } + ] + }, + "V2PermissionGroupMember": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Membership assignment identifier." + }, + "userId": { + "type": "string", + "description": "Organization member assigned to the group." + }, + "assignedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "When the member was assigned." + }, + "userName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Member display name." + }, + "userEmail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Member email address." + }, + "userImage": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Member avatar URL." + } + }, + "required": ["id", "userId", "assignedAt", "userName", "userEmail", "userImage"], + "additionalProperties": false, + "title": "Permission group member", + "description": "An explicit permission-group membership assignment." + }, + "ListPermissionGroupMembersResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2PermissionGroupMember" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "List Permission Group Members response", + "description": "List Permission Group Members result.", + "examples": [ + { + "data": [ + { + "id": "assignment-123", + "userId": "user-123", + "assignedAt": "2026-06-01T09:00:00.000Z", + "userName": "Example Member", + "userEmail": "member@example.com", + "userImage": null + } + ], + "nextCursor": null + } + ] + }, + "V2PermissionGroupAssignment": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Membership assignment identifier." + }, + "permissionGroupId": { + "type": "string", + "description": "Group receiving the member." + }, + "organizationId": { + "type": "string", + "description": "Organization that owns the group." + }, + "userId": { + "type": "string", + "description": "User assigned to the group." + }, + "assignedBy": { + "type": "string", + "description": "User who made the assignment." + }, + "assignedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "When the assignment was created." + } + }, + "required": [ + "id", + "permissionGroupId", + "organizationId", + "userId", + "assignedBy", + "assignedAt" + ], + "additionalProperties": false, + "title": "Permission group assignment", + "description": "The newly created membership assignment." + }, + "AddPermissionGroupMemberResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2PermissionGroupAssignment" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Add Permission Group Member response", + "description": "Add Permission Group Member result.", + "examples": [ + { + "data": { + "id": "assignment-123", + "permissionGroupId": "group-123", + "organizationId": "org-123", + "userId": "user-123", + "assignedBy": "admin-123", + "assignedAt": "2026-06-01T09:00:00.000Z" + } + } + ] + }, + "AddPermissionGroupMemberRequest": { + "type": "object", + "properties": { + "userId": { + "type": "string", + "minLength": 1, + "description": "Existing organization member to add." + } + }, + "required": ["userId"], + "additionalProperties": false, + "title": "Add Permission Group Member request", + "description": "Add Permission Group Member inputs.", + "examples": [ + { + "userId": "user-123" + } + ] + }, + "V2PermissionGroupMemberDeletion": { + "type": "object", + "properties": { + "userId": { + "type": "string", + "description": "User whose membership assignment was removed." + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Whether the assignment was removed." + } + }, + "required": ["userId", "deleted"], + "additionalProperties": false, + "title": "Permission group member deletion", + "description": "Acknowledges membership removal." + }, + "RemovePermissionGroupMemberResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2PermissionGroupMemberDeletion" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Remove Permission Group Member response", + "description": "Remove Permission Group Member result.", "examples": [ { - "description": "Restricted workspace access" + "data": { + "userId": "user-123", + "deleted": true + } } ] }, - "V2PermissionGroupDeletion": { + "V2PermissionGroupBulkAdd": { "type": "object", "properties": { - "id": { - "type": "string", - "description": "Deleted permission group identifier." + "added": { + "type": "number", + "description": "Number of members added." }, - "deleted": { - "type": "boolean", - "const": true, - "description": "Whether the group was permanently deleted." + "skipped": { + "type": "number", + "description": "Number of selected organization members already in the group." } }, - "required": ["id", "deleted"], + "required": ["added", "skipped"], "additionalProperties": false, - "title": "Permission group deletion", - "description": "Acknowledges permanent group deletion." + "title": "Permission group bulk addition", + "description": "Counts of added and already assigned organization members." }, - "DeletePermissionGroupResponse": { + "BulkAddPermissionGroupMembersResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2PermissionGroupDeletion" + "$ref": "#/components/schemas/V2PermissionGroupBulkAdd" } }, "required": ["data"], "additionalProperties": false, - "title": "Delete Permission Group response", - "description": "Delete Permission Group result.", + "title": "Bulk Add Permission Group Members response", + "description": "Bulk Add Permission Group Members result.", "examples": [ { "data": { - "id": "group-123", - "deleted": true + "added": 1, + "skipped": 0 } } ] }, - "V2PermissionGroupMember": { + "BulkAddPermissionGroupMembersRequest": { + "type": "object", + "properties": { + "userIds": { + "description": "Organization member identifiers. Existing group members are skipped; users outside the organization are ignored.", + "minItems": 1, + "maxItems": 1000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "addAllOrganizationMembers": { + "description": "Add every current organization member in bounded batches within one transaction. Cannot be combined with userIds.", + "type": "boolean" + } + }, + "additionalProperties": false, + "title": "Bulk Add Permission Group Members request", + "description": "Bulk Add Permission Group Members inputs.", + "examples": [ + { + "userIds": ["user-123"] + } + ] + }, + "V2Organization": { "type": "object", "properties": { "id": { "type": "string", - "description": "Membership assignment identifier." + "description": "Organization identifier." }, - "userId": { + "name": { "type": "string", - "description": "Organization member assigned to the group." + "description": "Organization display name." }, - "assignedAt": { + "slug": { "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "When the member was assigned." + "description": "Organization slug." }, - "userName": { + "logo": { "anyOf": [ { "type": "string" @@ -17114,9 +18792,36 @@ "type": "null" } ], - "description": "Member display name." + "description": "Organization logo URL, or null when unset." }, - "userEmail": { + "role": { + "type": "string", + "enum": ["owner", "admin", "member"], + "description": "The acting user’s role in this organization." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "When the organization was created." + } + }, + "required": ["id", "name", "slug", "logo", "role", "createdAt"], + "additionalProperties": false, + "title": "Organization", + "description": "An organization the acting user belongs to." + }, + "ListOrganizationsResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2Organization" + }, + "description": "Items in the current page." + }, + "nextCursor": { "anyOf": [ { "type": "string" @@ -17125,9 +18830,82 @@ "type": "null" } ], - "description": "Member email address." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "List Organizations response", + "description": "List Organizations result.", + "examples": [ + { + "data": [ + { + "id": "org-123", + "name": "Example Organization", + "slug": "example", + "logo": null, + "role": "admin", + "createdAt": "2026-06-01T09:00:00.000Z" + } + ], + "nextCursor": null + } + ] + }, + "GetOrganizationResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2Organization" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Get Organization response", + "description": "Get Organization result.", + "examples": [ + { + "data": { + "id": "org-123", + "name": "Example Organization", + "slug": "example", + "logo": null, + "role": "admin", + "createdAt": "2026-06-01T09:00:00.000Z" + } + } + ] + }, + "V2OrganizationWorkspace": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Workspace identifier." }, - "userImage": { + "name": { + "type": "string", + "description": "Workspace display name." + } + }, + "required": ["id", "name"], + "additionalProperties": false, + "title": "Organization workspace", + "description": "A workspace owned by the organization." + }, + "ListOrganizationWorkspacesResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2OrganizationWorkspace" + }, + "description": "Items in the current page." + }, + "nextCursor": { "anyOf": [ { "type": "string" @@ -17136,21 +18914,64 @@ "type": "null" } ], - "description": "Member avatar URL." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, - "required": ["id", "userId", "assignedAt", "userName", "userEmail", "userImage"], + "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "Permission group member", - "description": "An explicit permission-group membership assignment." + "title": "List Organization Workspaces response", + "description": "List Organization Workspaces result.", + "examples": [ + { + "data": [ + { + "id": "workspace-123", + "name": "Engineering" + } + ], + "nextCursor": null + } + ] }, - "ListPermissionGroupMembersResponse": { + "V2OrganizationMember": { + "type": "object", + "properties": { + "userId": { + "type": "string", + "description": "User identifier; use this identifier to update or remove the member." + }, + "name": { + "type": "string", + "description": "Member display name." + }, + "email": { + "type": "string", + "description": "Member email address." + }, + "role": { + "type": "string", + "enum": ["owner", "admin", "member"], + "description": "Organization role; separate from workspace permissions." + }, + "joinedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "When the user joined the organization." + } + }, + "required": ["userId", "name", "email", "role", "joinedAt"], + "additionalProperties": false, + "title": "Organization member", + "description": "An organization membership identified by user ID." + }, + "ListOrganizationMembersResponse": { "type": "object", "properties": { "data": { "type": "array", "items": { - "$ref": "#/components/schemas/V2PermissionGroupMember" + "$ref": "#/components/schemas/V2OrganizationMember" }, "description": "Items in the current page." }, @@ -17168,140 +18989,96 @@ }, "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "List Permission Group Members response", - "description": "List Permission Group Members result.", + "title": "List Organization Members response", + "description": "List Organization Members result.", "examples": [ { "data": [ { - "id": "assignment-123", "userId": "user-123", - "assignedAt": "2026-06-01T09:00:00.000Z", - "userName": "Example Member", - "userEmail": "member@example.com", - "userImage": null + "name": "Example Member", + "email": "member@example.com", + "role": "member", + "joinedAt": "2026-06-01T09:00:00.000Z" } ], "nextCursor": null } ] }, - "V2PermissionGroupAssignment": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Membership assignment identifier." - }, - "permissionGroupId": { - "type": "string", - "description": "Group receiving the member." - }, - "organizationId": { - "type": "string", - "description": "Organization that owns the group." - }, - "userId": { - "type": "string", - "description": "User assigned to the group." - }, - "assignedBy": { - "type": "string", - "description": "User who made the assignment." - }, - "assignedAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "When the assignment was created." - } - }, - "required": [ - "id", - "permissionGroupId", - "organizationId", - "userId", - "assignedBy", - "assignedAt" - ], - "additionalProperties": false, - "title": "Permission group assignment", - "description": "The newly created membership assignment." - }, - "AddPermissionGroupMemberResponse": { + "UpdateOrganizationMemberResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2PermissionGroupAssignment" + "$ref": "#/components/schemas/V2OrganizationMember" } }, "required": ["data"], "additionalProperties": false, - "title": "Add Permission Group Member response", - "description": "Add Permission Group Member result.", + "title": "Update Organization Member response", + "description": "Update Organization Member result.", "examples": [ { "data": { - "id": "assignment-123", - "permissionGroupId": "group-123", - "organizationId": "org-123", "userId": "user-123", - "assignedBy": "admin-123", - "assignedAt": "2026-06-01T09:00:00.000Z" + "name": "Example Member", + "email": "member@example.com", + "role": "admin", + "joinedAt": "2026-06-01T09:00:00.000Z" } } ] }, - "AddPermissionGroupMemberRequest": { + "UpdateOrganizationMemberBody": { "type": "object", "properties": { - "userId": { + "role": { "type": "string", - "minLength": 1, - "description": "Existing organization member to add." + "enum": ["member", "admin"], + "description": "New organization role. Ownership transfers use a separate operation." } }, - "required": ["userId"], + "required": ["role"], "additionalProperties": false, - "title": "Add Permission Group Member request", - "description": "Add Permission Group Member inputs.", + "title": "Update Organization Member body", + "description": "Update Organization Member input.", "examples": [ { - "userId": "user-123" + "role": "admin" } ] }, - "V2PermissionGroupMemberDeletion": { + "V2OrganizationMemberDeletion": { "type": "object", "properties": { "userId": { "type": "string", - "description": "User whose membership assignment was removed." + "description": "User removed from the organization." }, "deleted": { "type": "boolean", "const": true, - "description": "Whether the assignment was removed." + "description": "Whether membership and organization workspace access were removed." } }, "required": ["userId", "deleted"], "additionalProperties": false, - "title": "Permission group member deletion", - "description": "Acknowledges membership removal." + "title": "Organization member removal", + "description": "Acknowledges removal of an organization member." }, - "RemovePermissionGroupMemberResponse": { + "RemoveOrganizationMemberResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2PermissionGroupMemberDeletion" + "$ref": "#/components/schemas/V2OrganizationMemberDeletion" } }, "required": ["data"], "additionalProperties": false, - "title": "Remove Permission Group Member response", - "description": "Remove Permission Group Member result.", + "title": "Remove Organization Member response", + "description": "Remove Organization Member result.", "examples": [ { "data": { @@ -17311,121 +19088,241 @@ } ] }, - "V2PermissionGroupBulkAdd": { + "V2OrganizationInvitation": { "type": "object", "properties": { - "added": { - "type": "number", - "description": "Number of members added." + "id": { + "type": "string", + "description": "Invitation identifier." }, - "skipped": { - "type": "number", - "description": "Number of selected organization members already in the group." + "organizationId": { + "type": "string", + "description": "Organization that owns the invitation." + }, + "email": { + "type": "string", + "description": "Email address of the invitee." + }, + "role": { + "type": "string", + "enum": ["member", "admin"], + "description": "Organization role offered to an internal invitee." + }, + "kind": { + "type": "string", + "enum": ["organization", "workspace"], + "description": "Whether the invitation originated from organization or workspace administration." + }, + "membershipIntent": { + "type": "string", + "enum": ["internal", "external"], + "description": "Whether acceptance joins the organization or grants workspace access only." + }, + "status": { + "type": "string", + "enum": ["pending", "accepted", "rejected", "cancelled", "expired"], + "description": "Current invitation status; elapsed pending invitations are reported as expired." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "When the invitation was created." + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "When the invitation expires." } }, - "required": ["added", "skipped"], + "required": [ + "id", + "organizationId", + "email", + "role", + "kind", + "membershipIntent", + "status", + "createdAt", + "expiresAt" + ], "additionalProperties": false, - "title": "Permission group bulk addition", - "description": "Counts of added and already assigned organization members." + "title": "Organization invitation", + "description": "Invitation metadata without its acceptance token." }, - "BulkAddPermissionGroupMembersResponse": { + "ListOrganizationInvitationsResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2OrganizationInvitation" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "List Organization Invitations response", + "description": "List Organization Invitations result.", + "examples": [ + { + "data": [ + { + "id": "invitation-123", + "organizationId": "org-123", + "email": "member@example.com", + "role": "member", + "kind": "organization", + "membershipIntent": "internal", + "status": "pending", + "createdAt": "2026-06-01T09:00:00.000Z", + "expiresAt": "2026-06-08T09:00:00.000Z" + } + ], + "nextCursor": null + } + ] + }, + "CreateOrganizationInvitationResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2PermissionGroupBulkAdd" + "$ref": "#/components/schemas/V2OrganizationInvitation" } }, "required": ["data"], "additionalProperties": false, - "title": "Bulk Add Permission Group Members response", - "description": "Bulk Add Permission Group Members result.", + "title": "Create Organization Invitation response", + "description": "Create Organization Invitation result.", + "examples": [ + { + "data": { + "id": "invitation-123", + "organizationId": "org-123", + "email": "member@example.com", + "role": "member", + "kind": "organization", + "membershipIntent": "internal", + "status": "pending", + "createdAt": "2026-06-01T09:00:00.000Z", + "expiresAt": "2026-06-08T09:00:00.000Z" + } + } + ] + }, + "CreateOrganizationInvitationBody": { + "type": "object", + "properties": { + "email": { + "type": "string", + "minLength": 1, + "maxLength": 254, + "format": "email", + "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", + "description": "Email address of the person to invite." + }, + "role": { + "default": "member", + "description": "Organization role to offer. Defaults to member; grants no workspace-specific permissions.", + "type": "string", + "enum": ["member", "admin"] + } + }, + "required": ["email"], + "additionalProperties": false, + "title": "Create Organization Invitation body", + "description": "Create Organization Invitation input.", "examples": [ { - "data": { - "added": 1, - "skipped": 0 - } + "email": "member@example.com", + "role": "member" } ] }, - "BulkAddPermissionGroupMembersRequest": { + "GetOrganizationInvitationResponse": { "type": "object", "properties": { - "userIds": { - "description": "Organization member identifiers. Existing group members are skipped; users outside the organization are ignored.", - "minItems": 1, - "maxItems": 1000, - "type": "array", - "items": { - "type": "string", - "minLength": 1 - } - }, - "addAllOrganizationMembers": { - "description": "Add every current organization member in bounded batches within one transaction. Cannot be combined with userIds.", - "type": "boolean" + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2OrganizationInvitation" } }, + "required": ["data"], "additionalProperties": false, - "title": "Bulk Add Permission Group Members request", - "description": "Bulk Add Permission Group Members inputs.", + "title": "Get Organization Invitation response", + "description": "Get Organization Invitation result.", "examples": [ { - "userIds": ["user-123"] + "data": { + "id": "invitation-123", + "organizationId": "org-123", + "email": "member@example.com", + "role": "member", + "kind": "organization", + "membershipIntent": "internal", + "status": "pending", + "createdAt": "2026-06-01T09:00:00.000Z", + "expiresAt": "2026-06-08T09:00:00.000Z" + } } ] }, - "V2Organization": { + "V2OrganizationInvitationWorkspace": { "type": "object", "properties": { "id": { "type": "string", - "description": "Organization identifier." + "description": "Workspace identifier." }, "name": { "type": "string", - "description": "Organization display name." + "description": "Workspace display name." }, - "slug": { + "permission": { "type": "string", - "description": "Organization slug." + "enum": ["admin", "write", "read"], + "description": "Workspace permission offered by the invitation." }, - "logo": { + "archivedAt": { "anyOf": [ { - "type": "string" + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" }, { "type": "null" } ], - "description": "Organization logo URL, or null when unset." - }, - "role": { - "type": "string", - "enum": ["owner", "admin", "member"], - "description": "The acting user’s role in this organization." - }, - "createdAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "When the organization was created." + "description": "When the workspace was archived, or null while active." } }, - "required": ["id", "name", "slug", "logo", "role", "createdAt"], + "required": ["id", "name", "permission", "archivedAt"], "additionalProperties": false, - "title": "Organization", - "description": "An organization the acting user belongs to." + "title": "Organization invitation workspace", + "description": "A workspace grant attached to an invitation, separate from organization membership." }, - "ListOrganizationsResponse": { + "ListOrganizationInvitationWorkspacesResponse": { "type": "object", "properties": { "data": { "type": "array", "items": { - "$ref": "#/components/schemas/V2Organization" + "$ref": "#/components/schemas/V2OrganizationInvitationWorkspace" }, "description": "Items in the current page." }, @@ -17443,73 +19340,182 @@ }, "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "List Organizations response", - "description": "List Organizations result.", + "title": "List Organization Invitation Workspaces response", + "description": "Workspace grants retained on an invitation.", "examples": [ { "data": [ { - "id": "org-123", - "name": "Example Organization", - "slug": "example", - "logo": null, - "role": "admin", - "createdAt": "2026-06-01T09:00:00.000Z" + "id": "workspace-123", + "name": "Engineering", + "permission": "write", + "archivedAt": null } ], "nextCursor": null } ] }, - "GetOrganizationResponse": { + "ResendOrganizationInvitationResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2Organization" + "$ref": "#/components/schemas/V2OrganizationInvitation" } }, "required": ["data"], "additionalProperties": false, - "title": "Get Organization response", - "description": "Get Organization result.", + "title": "Resend Organization Invitation response", + "description": "Resend Organization Invitation result.", "examples": [ { "data": { - "id": "org-123", - "name": "Example Organization", - "slug": "example", - "logo": null, - "role": "admin", - "createdAt": "2026-06-01T09:00:00.000Z" + "id": "invitation-123", + "organizationId": "org-123", + "email": "member@example.com", + "role": "member", + "kind": "organization", + "membershipIntent": "internal", + "status": "pending", + "createdAt": "2026-06-01T09:00:00.000Z", + "expiresAt": "2026-06-08T09:00:00.000Z" } } ] }, - "V2OrganizationWorkspace": { + "ResendOrganizationInvitationBody": { + "default": {}, + "title": "Resend Organization Invitation body", + "description": "Resend Organization Invitation input.", + "examples": [{}], + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "V2OrganizationInvitationRevocation": { "type": "object", "properties": { "id": { "type": "string", - "description": "Workspace identifier." + "description": "Revoked invitation identifier." }, - "name": { + "status": { "type": "string", - "description": "Workspace display name." + "const": "cancelled", + "description": "Revocation cancels the invitation and prevents acceptance." } }, - "required": ["id", "name"], + "required": ["id", "status"], "additionalProperties": false, - "title": "Organization workspace", - "description": "A workspace owned by the organization." + "title": "Organization invitation revocation", + "description": "Acknowledges cancellation of a pending invitation." }, - "ListOrganizationWorkspacesResponse": { + "RevokeOrganizationInvitationResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2OrganizationInvitationRevocation" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Revoke Organization Invitation response", + "description": "Revoke Organization Invitation result.", + "examples": [ + { + "data": { + "id": "invitation-123", + "status": "cancelled" + } + } + ] + }, + "ListSsoProvidersResponse": { "type": "object", "properties": { "data": { "type": "array", "items": { - "$ref": "#/components/schemas/V2OrganizationWorkspace" + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Provider record identifier." + }, + "providerId": { + "type": "string", + "minLength": 1, + "description": "Globally unique identity provider identifier." + }, + "providerType": { + "type": "string", + "enum": ["oidc", "saml"], + "description": "Identity provider protocol." + }, + "domain": { + "type": "string", + "description": "Email domain served by the provider." + }, + "domainKey": { + "type": "string", + "description": "Normalized email domain used for sign-in." + }, + "issuer": { + "type": "string", + "description": "Identity provider issuer URL." + }, + "oidcConfig": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "JSON configuration with the client secret redacted; null for SAML." + }, + "samlConfig": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "JSON configuration without private keys; null for OIDC." + }, + "jitProvisioningEnabled": { + "type": "boolean", + "description": "Whether successful SSO sign-in may add organization members, subject to membership and seat policies." + }, + "domainVerified": { + "type": "boolean", + "description": "Whether the provider has a verified domain grant." + }, + "isPrimary": { + "type": "boolean", + "description": "Whether this provider currently handles sign-in for its domain." + } + }, + "required": [ + "id", + "providerId", + "providerType", + "domain", + "domainKey", + "issuer", + "oidcConfig", + "samlConfig", + "jitProvisioningEnabled", + "domainVerified", + "isPrimary" + ], + "additionalProperties": false }, "description": "Items in the current page." }, @@ -17527,245 +19533,591 @@ }, "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "List Organization Workspaces response", - "description": "List Organization Workspaces result.", - "examples": [ - { - "data": [ - { - "id": "workspace-123", - "name": "Engineering" + "title": "List SSO Providers response", + "description": "List SSO Providers result." + }, + "GetSsoProviderResponse": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Provider record identifier." + }, + "providerId": { + "type": "string", + "minLength": 1, + "description": "Globally unique identity provider identifier." + }, + "providerType": { + "type": "string", + "enum": ["oidc", "saml"], + "description": "Identity provider protocol." + }, + "domain": { + "type": "string", + "description": "Email domain served by the provider." + }, + "domainKey": { + "type": "string", + "description": "Normalized email domain used for sign-in." + }, + "issuer": { + "type": "string", + "description": "Identity provider issuer URL." + }, + "oidcConfig": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "JSON configuration with the client secret redacted; null for SAML." + }, + "samlConfig": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "JSON configuration without private keys; null for OIDC." + }, + "jitProvisioningEnabled": { + "type": "boolean", + "description": "Whether successful SSO sign-in may add organization members, subject to membership and seat policies." + }, + "domainVerified": { + "type": "boolean", + "description": "Whether the provider has a verified domain grant." + }, + "isPrimary": { + "type": "boolean", + "description": "Whether this provider currently handles sign-in for its domain." } + }, + "required": [ + "id", + "providerId", + "providerType", + "domain", + "domainKey", + "issuer", + "oidcConfig", + "samlConfig", + "jitProvisioningEnabled", + "domainVerified", + "isPrimary" ], - "nextCursor": null - } - ] - }, - "V2OrganizationMember": { - "type": "object", - "properties": { - "userId": { - "type": "string", - "description": "User identifier; use this identifier to update or remove the member." - }, - "name": { - "type": "string", - "description": "Member display name." - }, - "email": { - "type": "string", - "description": "Member email address." - }, - "role": { - "type": "string", - "enum": ["owner", "admin", "member"], - "description": "Organization role; separate from workspace permissions." - }, - "joinedAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "When the user joined the organization." + "additionalProperties": false, + "description": "Response data." } }, - "required": ["userId", "name", "email", "role", "joinedAt"], + "required": ["data"], "additionalProperties": false, - "title": "Organization member", - "description": "An organization membership identified by user ID." + "title": "Get SSO Provider response", + "description": "Get SSO Provider result." }, - "ListOrganizationMembersResponse": { + "SaveSsoProviderResponse": { "type": "object", "properties": { "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/V2OrganizationMember" - }, - "description": "Items in the current page." - }, - "nextCursor": { - "anyOf": [ - { - "type": "string" + "type": "object", + "properties": { + "providerId": { + "type": "string", + "minLength": 1, + "description": "Saved provider identifier." }, - { - "type": "null" + "providerType": { + "type": "string", + "enum": ["oidc", "saml"], + "description": "Saved identity provider protocol." + }, + "created": { + "type": "boolean", + "description": "Whether a new provider was created." } - ], - "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + }, + "required": ["providerId", "providerType", "created"], + "additionalProperties": false, + "description": "Response data." } }, - "required": ["data", "nextCursor"], + "required": ["data"], "additionalProperties": false, - "title": "List Organization Members response", - "description": "List Organization Members result.", - "examples": [ + "title": "Save SSO Provider response", + "description": "Save SSO Provider result." + }, + "SaveSsoProviderBody": { + "oneOf": [ { - "data": [ - { - "userId": "user-123", - "name": "Example Member", - "email": "member@example.com", - "role": "member", - "joinedAt": "2026-06-01T09:00:00.000Z" + "type": "object", + "properties": { + "providerType": { + "type": "string", + "const": "oidc", + "description": "Configure an OpenID Connect identity provider." + }, + "providerId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Globally unique provider ID; saving an existing provider replaces its supplied configuration." + }, + "issuer": { + "type": "string", + "maxLength": 2048, + "format": "uri", + "description": "Identity provider issuer URL." + }, + "domain": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Email domain already verified by this organization." + }, + "jitProvisioningEnabled": { + "default": true, + "description": "Allow SSO sign-in to provision organization membership, subject to eligibility and available seats.", + "type": "boolean" + }, + "mapping": { + "default": { + "id": "sub", + "email": "email", + "name": "name", + "image": "picture" + }, + "description": "Identity-provider claims mapped to user fields.", + "type": "object", + "properties": { + "id": { + "default": "sub", + "description": "Claim holding the stable identity identifier.", + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "email": { + "default": "email", + "description": "Claim holding the email address.", + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "name": { + "default": "name", + "description": "Claim holding the display name.", + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "image": { + "default": "picture", + "description": "Claim holding the avatar URL.", + "type": "string", + "minLength": 1, + "maxLength": 255 + } + }, + "additionalProperties": false + }, + "clientId": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "description": "Identity provider client identifier." + }, + "clientSecret": { + "type": "string", + "minLength": 1, + "maxLength": 8192, + "description": "Write-only client secret; the redacted marker from Get SSO Provider preserves an existing secret.", + "writeOnly": true + }, + "scopes": { + "default": ["openid", "profile", "email"], + "description": "OIDC scopes; offline_access is omitted.", + "maxItems": 50, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 255 + } + }, + "pkce": { + "default": true, + "description": "Use PKCE for the authorization flow.", + "type": "boolean" + }, + "authorizationEndpoint": { + "description": "Optional authorization endpoint; otherwise resolved through issuer discovery.", + "type": "string", + "maxLength": 2048, + "format": "uri" + }, + "tokenEndpoint": { + "description": "Optional token endpoint; otherwise resolved through issuer discovery.", + "type": "string", + "maxLength": 2048, + "format": "uri" + }, + "userInfoEndpoint": { + "description": "Optional UserInfo endpoint.", + "type": "string", + "maxLength": 2048, + "format": "uri" + }, + "skipUserInfoEndpoint": { + "default": false, + "description": "Read identity claims from the ID token instead of calling UserInfo.", + "type": "boolean" + }, + "jwksEndpoint": { + "description": "Optional signing-key endpoint; otherwise resolved through issuer discovery.", + "type": "string", + "maxLength": 2048, + "format": "uri" } + }, + "required": [ + "providerType", + "providerId", + "issuer", + "domain", + "clientId", + "clientSecret" ], - "nextCursor": null + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "providerType": { + "type": "string", + "const": "saml", + "description": "Configure a SAML identity provider." + }, + "providerId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Globally unique provider ID; saving an existing provider replaces its supplied configuration." + }, + "issuer": { + "type": "string", + "maxLength": 2048, + "format": "uri", + "description": "Identity provider issuer URL." + }, + "domain": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Email domain already verified by this organization." + }, + "jitProvisioningEnabled": { + "default": true, + "description": "Allow SSO sign-in to provision organization membership, subject to eligibility and available seats.", + "type": "boolean" + }, + "mapping": { + "default": { + "id": "sub", + "email": "email", + "name": "name", + "image": "picture" + }, + "description": "Identity-provider claims mapped to user fields.", + "type": "object", + "properties": { + "id": { + "default": "sub", + "description": "Claim holding the stable identity identifier.", + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "email": { + "default": "email", + "description": "Claim holding the email address.", + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "name": { + "default": "name", + "description": "Claim holding the display name.", + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "image": { + "default": "picture", + "description": "Claim holding the avatar URL.", + "type": "string", + "minLength": 1, + "maxLength": 255 + } + }, + "additionalProperties": false + }, + "entryPoint": { + "type": "string", + "maxLength": 2048, + "format": "uri", + "description": "Identity provider SAML sign-in endpoint." + }, + "cert": { + "type": "string", + "minLength": 1, + "maxLength": 65536, + "description": "Identity provider signing certificate." + }, + "callbackUrl": { + "description": "SAML callback URL; defaults to this provider’s Sim callback.", + "type": "string", + "maxLength": 2048, + "format": "uri" + }, + "audience": { + "description": "SAML audience; omission preserves the saved value.", + "type": "string", + "maxLength": 2048 + }, + "wantAssertionsSigned": { + "description": "Require signed assertions; omission preserves the saved value.", + "type": "boolean" + }, + "signatureAlgorithm": { + "description": "Signature algorithm accepted by the SAML configuration validator; omission preserves the saved value.", + "type": "string", + "maxLength": 255 + }, + "digestAlgorithm": { + "description": "Digest algorithm accepted by the SAML configuration validator; omission preserves the saved value.", + "type": "string", + "maxLength": 255 + }, + "identifierFormat": { + "description": "SAML NameID format; omission clears the saved value.", + "type": "string", + "maxLength": 2048 + }, + "idpMetadata": { + "description": "Identity provider metadata XML; omission clears the saved document.", + "type": "string", + "maxLength": 102400 + } + }, + "required": ["providerType", "providerId", "issuer", "domain", "entryPoint", "cert"], + "additionalProperties": false } - ] + ], + "title": "Save SSO Provider body", + "description": "Configuration accepted by Save SSO Provider." }, - "UpdateOrganizationMemberResponse": { + "DeleteSsoProviderResponse": { "type": "object", "properties": { "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2OrganizationMember" + "type": "object", + "properties": { + "providerId": { + "type": "string", + "minLength": 1, + "description": "Removed provider identifier." + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "The provider was removed; existing accounts and memberships remain." + } + }, + "required": ["providerId", "deleted"], + "additionalProperties": false, + "description": "Response data." } }, "required": ["data"], "additionalProperties": false, - "title": "Update Organization Member response", - "description": "Update Organization Member result.", - "examples": [ - { - "data": { - "userId": "user-123", - "name": "Example Member", - "email": "member@example.com", - "role": "admin", - "joinedAt": "2026-06-01T09:00:00.000Z" - } + "title": "Delete SSO Provider response", + "description": "Delete SSO Provider result." + }, + "SetPrimarySsoProviderResponse": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "providerId": { + "type": "string", + "minLength": 1, + "description": "Provider selected for domain sign-in." + }, + "domain": { + "type": "string", + "description": "Verified domain whose primary provider changed." + } + }, + "required": ["providerId", "domain"], + "additionalProperties": false, + "description": "Response data." } - ] + }, + "required": ["data"], + "additionalProperties": false, + "title": "Set Primary SSO Provider response", + "description": "Set Primary SSO Provider result." }, - "UpdateOrganizationMemberBody": { + "SetPrimarySsoProviderBody": { "type": "object", - "properties": { - "role": { - "type": "string", - "enum": ["member", "admin"], - "description": "New organization role. Ownership transfers use a separate operation." - } - }, - "required": ["role"], + "properties": {}, "additionalProperties": false, - "title": "Update Organization Member body", - "description": "Update Organization Member input.", - "examples": [ - { - "role": "admin" - } - ] + "title": "Set Primary SSO Provider body", + "description": "Configuration accepted by Set Primary SSO Provider." }, - "V2OrganizationMemberDeletion": { + "GetSsoPolicyResponse": { "type": "object", "properties": { - "userId": { - "type": "string", - "description": "User removed from the organization." - }, - "deleted": { - "type": "boolean", - "const": true, - "description": "Whether membership and organization workspace access were removed." + "data": { + "type": "object", + "properties": { + "requireSso": { + "type": "boolean", + "description": "Stored single sign-on requirement." + }, + "hasVerifiedProvider": { + "type": "boolean", + "description": "Whether a verified provider can satisfy the requirement." + }, + "isEnforced": { + "type": "boolean", + "description": "Whether sign-in currently enforces the requirement." + } + }, + "required": ["requireSso", "hasVerifiedProvider", "isEnforced"], + "additionalProperties": false, + "description": "Response data." } }, - "required": ["userId", "deleted"], + "required": ["data"], "additionalProperties": false, - "title": "Organization member removal", - "description": "Acknowledges removal of an organization member." + "title": "Get SSO Policy response", + "description": "Get SSO Policy result." }, - "RemoveOrganizationMemberResponse": { + "UpdateSsoPolicyResponse": { "type": "object", "properties": { "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2OrganizationMemberDeletion" + "type": "object", + "properties": { + "requireSso": { + "type": "boolean", + "description": "Stored single sign-on requirement." + }, + "hasVerifiedProvider": { + "type": "boolean", + "description": "Whether a verified provider can satisfy the requirement." + }, + "isEnforced": { + "type": "boolean", + "description": "Whether sign-in currently enforces the requirement." + } + }, + "required": ["requireSso", "hasVerifiedProvider", "isEnforced"], + "additionalProperties": false, + "description": "Response data." } }, "required": ["data"], "additionalProperties": false, - "title": "Remove Organization Member response", - "description": "Remove Organization Member result.", - "examples": [ - { - "data": { - "userId": "user-123", - "deleted": true - } - } - ] + "title": "Update SSO Policy response", + "description": "Update SSO Policy result." }, - "V2OrganizationInvitation": { + "UpdateSsoPolicyBody": { "type": "object", "properties": { - "id": { - "type": "string", - "description": "Invitation identifier." - }, - "organizationId": { - "type": "string", - "description": "Organization that owns the invitation." - }, - "email": { - "type": "string", - "description": "Email address of the invitee." - }, - "role": { - "type": "string", - "enum": ["member", "admin"], - "description": "Organization role offered to an internal invitee." - }, - "kind": { - "type": "string", - "enum": ["organization", "workspace"], - "description": "Whether the invitation originated from organization or workspace administration." - }, - "membershipIntent": { - "type": "string", - "enum": ["internal", "external"], - "description": "Whether acceptance joins the organization or grants workspace access only." - }, - "status": { - "type": "string", - "enum": ["pending", "accepted", "rejected", "cancelled", "expired"], - "description": "Current invitation status; elapsed pending invitations are reported as expired." - }, - "createdAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "When the invitation was created." - }, - "expiresAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "When the invitation expires." + "requireSso": { + "type": "boolean", + "description": "Require organization SSO on future sign-ins; existing sessions remain active." } }, - "required": [ - "id", - "organizationId", - "email", - "role", - "kind", - "membershipIntent", - "status", - "createdAt", - "expiresAt" - ], + "required": ["requireSso"], "additionalProperties": false, - "title": "Organization invitation", - "description": "Invitation metadata without its acceptance token." + "title": "Update SSO Policy body", + "description": "Configuration accepted by Update SSO Policy." }, - "ListOrganizationInvitationsResponse": { + "ListOrganizationDomainsResponse": { "type": "object", "properties": { "data": { "type": "array", "items": { - "$ref": "#/components/schemas/V2OrganizationInvitation" + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Domain claim identifier." + }, + "domain": { + "type": "string", + "description": "Normalized email domain." + }, + "status": { + "type": "string", + "enum": ["pending", "verified"], + "description": "DNS ownership verification state." + }, + "verifiedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "When domain ownership was verified." + }, + "challengeHost": { + "type": "string", + "description": "DNS host where the verification TXT record must be published." + }, + "txtRecordValue": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "TXT record value for a pending domain; only administrators receive it." + } + }, + "required": [ + "id", + "domain", + "status", + "verifiedAt", + "challengeHost", + "txtRecordValue" + ], + "additionalProperties": false }, "description": "Items in the current page." }, @@ -17783,154 +20135,268 @@ }, "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "List Organization Invitations response", - "description": "List Organization Invitations result.", - "examples": [ - { - "data": [ - { - "id": "invitation-123", - "organizationId": "org-123", - "email": "member@example.com", - "role": "member", - "kind": "organization", - "membershipIntent": "internal", - "status": "pending", - "createdAt": "2026-06-01T09:00:00.000Z", - "expiresAt": "2026-06-08T09:00:00.000Z" - } - ], - "nextCursor": null - } - ] + "title": "List Organization Domains response", + "description": "List Organization Domains result." }, - "CreateOrganizationInvitationResponse": { + "AddOrganizationDomainResponse": { "type": "object", "properties": { "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2OrganizationInvitation" + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Domain claim identifier." + }, + "domain": { + "type": "string", + "description": "Normalized email domain." + }, + "status": { + "type": "string", + "enum": ["pending", "verified"], + "description": "DNS ownership verification state." + }, + "verifiedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "When domain ownership was verified." + }, + "challengeHost": { + "type": "string", + "description": "DNS host where the verification TXT record must be published." + }, + "txtRecordValue": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "TXT record value for a pending domain; only administrators receive it." + } + }, + "required": ["id", "domain", "status", "verifiedAt", "challengeHost", "txtRecordValue"], + "additionalProperties": false, + "description": "Response data." } }, "required": ["data"], "additionalProperties": false, - "title": "Create Organization Invitation response", - "description": "Create Organization Invitation result.", - "examples": [ - { - "data": { - "id": "invitation-123", - "organizationId": "org-123", - "email": "member@example.com", - "role": "member", - "kind": "organization", - "membershipIntent": "internal", - "status": "pending", - "createdAt": "2026-06-01T09:00:00.000Z", - "expiresAt": "2026-06-08T09:00:00.000Z" - } - } - ] - }, - "CreateOrganizationInvitationBody": { - "type": "object", - "properties": { - "email": { - "type": "string", - "minLength": 1, - "maxLength": 254, - "format": "email", - "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", - "description": "Email address of the person to invite." - }, - "role": { - "default": "member", - "description": "Organization role to offer. Defaults to member; grants no workspace-specific permissions.", + "title": "Add Organization Domain response", + "description": "Add Organization Domain result." + }, + "AddOrganizationDomainBody": { + "type": "object", + "properties": { + "domain": { "type": "string", - "enum": ["member", "admin"] + "minLength": 1, + "maxLength": 253, + "description": "Domain to claim and verify through a DNS TXT record." } }, - "required": ["email"], + "required": ["domain"], "additionalProperties": false, - "title": "Create Organization Invitation body", - "description": "Create Organization Invitation input.", - "examples": [ - { - "email": "member@example.com", - "role": "member" - } - ] + "title": "Add Organization Domain body", + "description": "Configuration accepted by Add Organization Domain." }, - "GetOrganizationInvitationResponse": { + "VerifyOrganizationDomainResponse": { "type": "object", "properties": { "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2OrganizationInvitation" + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Domain claim identifier." + }, + "domain": { + "type": "string", + "description": "Normalized email domain." + }, + "status": { + "type": "string", + "enum": ["pending", "verified"], + "description": "DNS ownership verification state." + }, + "verifiedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "When domain ownership was verified." + }, + "challengeHost": { + "type": "string", + "description": "DNS host where the verification TXT record must be published." + }, + "txtRecordValue": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "TXT record value for a pending domain; only administrators receive it." + } + }, + "required": ["id", "domain", "status", "verifiedAt", "challengeHost", "txtRecordValue"], + "additionalProperties": false, + "description": "Response data." } }, "required": ["data"], "additionalProperties": false, - "title": "Get Organization Invitation response", - "description": "Get Organization Invitation result.", - "examples": [ - { - "data": { - "id": "invitation-123", - "organizationId": "org-123", - "email": "member@example.com", - "role": "member", - "kind": "organization", - "membershipIntent": "internal", - "status": "pending", - "createdAt": "2026-06-01T09:00:00.000Z", - "expiresAt": "2026-06-08T09:00:00.000Z" - } - } - ] + "title": "Verify Organization Domain response", + "description": "Verify Organization Domain result." }, - "V2OrganizationInvitationWorkspace": { + "VerifyOrganizationDomainBody": { + "type": "object", + "properties": {}, + "additionalProperties": false, + "title": "Verify Organization Domain body", + "description": "Configuration accepted by Verify Organization Domain." + }, + "RemoveOrganizationDomainResponse": { "type": "object", "properties": { - "id": { - "type": "string", - "description": "Workspace identifier." - }, - "name": { - "type": "string", - "description": "Workspace display name." - }, - "permission": { - "type": "string", - "enum": ["admin", "write", "read"], - "description": "Workspace permission offered by the invitation." - }, - "archivedAt": { - "anyOf": [ - { + "data": { + "type": "object", + "properties": { + "id": { "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + "minLength": 1, + "description": "Removed domain claim identifier." }, - { - "type": "null" + "deleted": { + "type": "boolean", + "const": true, + "description": "The claim was removed; providers on this domain lose verified sign-in authority." } - ], - "description": "When the workspace was archived, or null while active." + }, + "required": ["id", "deleted"], + "additionalProperties": false, + "description": "Response data." } }, - "required": ["id", "name", "permission", "archivedAt"], + "required": ["data"], "additionalProperties": false, - "title": "Organization invitation workspace", - "description": "A workspace grant attached to an invitation, separate from organization membership." + "title": "Remove Organization Domain response", + "description": "Remove Organization Domain result." }, - "ListOrganizationInvitationWorkspacesResponse": { + "ListCredentialMembersResponse": { "type": "object", "properties": { "data": { "type": "array", "items": { - "$ref": "#/components/schemas/V2OrganizationInvitationWorkspace" + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Membership identifier; inherited grants have a derived identifier." + }, + "userId": { + "type": "string", + "minLength": 1, + "description": "User holding the credential grant." + }, + "role": { + "type": "string", + "enum": ["admin", "member"], + "description": "Effective credential role." + }, + "status": { + "type": "string", + "enum": ["active", "pending", "revoked"], + "description": "Explicit grant status, or active for inherited administrators." + }, + "joinedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "When the explicit grant started; null for an inherited-only grant." + }, + "userName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Member display name." + }, + "userEmail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Member email." + }, + "userImage": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Member avatar URL." + }, + "roleSource": { + "type": "string", + "enum": ["explicit", "workspace-admin"], + "description": "Whether workspace administrator access supplies the grant." + } + }, + "required": [ + "id", + "userId", + "role", + "status", + "joinedAt", + "userName", + "userEmail", + "userImage", + "roleSource" + ], + "additionalProperties": false }, "description": "Items in the current page." }, @@ -17948,97 +20414,86 @@ }, "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "List Organization Invitation Workspaces response", - "description": "Workspace grants retained on an invitation.", - "examples": [ - { - "data": [ - { - "id": "workspace-123", - "name": "Engineering", - "permission": "write", - "archivedAt": null - } - ], - "nextCursor": null - } - ] + "title": "List Credential Members response", + "description": "List Credential Members result." }, - "ResendOrganizationInvitationResponse": { + "UpsertCredentialMemberResponse": { "type": "object", "properties": { "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2OrganizationInvitation" + "type": "object", + "properties": { + "userId": { + "type": "string", + "minLength": 1, + "description": "User whose explicit grant was saved." + }, + "role": { + "type": "string", + "enum": ["admin", "member"], + "description": "Saved explicit credential role." + }, + "created": { + "type": "boolean", + "description": "Whether a new explicit grant was created." + } + }, + "required": ["userId", "role", "created"], + "additionalProperties": false, + "description": "Response data." } }, "required": ["data"], "additionalProperties": false, - "title": "Resend Organization Invitation response", - "description": "Resend Organization Invitation result.", - "examples": [ - { - "data": { - "id": "invitation-123", - "organizationId": "org-123", - "email": "member@example.com", - "role": "member", - "kind": "organization", - "membershipIntent": "internal", - "status": "pending", - "createdAt": "2026-06-01T09:00:00.000Z", - "expiresAt": "2026-06-08T09:00:00.000Z" - } - } - ] - }, - "ResendOrganizationInvitationBody": { - "default": {}, - "title": "Resend Organization Invitation body", - "description": "Resend Organization Invitation input.", - "examples": [{}], - "type": "object", - "properties": {}, - "additionalProperties": false + "title": "Upsert Credential Member response", + "description": "Upsert Credential Member result." }, - "V2OrganizationInvitationRevocation": { + "UpsertCredentialMemberBody": { "type": "object", "properties": { - "id": { + "userId": { "type": "string", - "description": "Revoked invitation identifier." + "minLength": 1, + "maxLength": 255, + "description": "Existing workspace member to grant or change access for." }, - "status": { + "role": { "type": "string", - "const": "cancelled", - "description": "Revocation cancels the invitation and prevents acceptance." + "enum": ["admin", "member"], + "description": "Credential role to grant; workspace administrators cannot be demoted." } }, - "required": ["id", "status"], + "required": ["userId", "role"], "additionalProperties": false, - "title": "Organization invitation revocation", - "description": "Acknowledges cancellation of a pending invitation." + "title": "Upsert Credential Member body", + "description": "Configuration accepted by Upsert Credential Member." }, - "RevokeOrganizationInvitationResponse": { + "RemoveCredentialMemberResponse": { "type": "object", "properties": { "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2OrganizationInvitationRevocation" + "type": "object", + "properties": { + "userId": { + "type": "string", + "minLength": 1, + "description": "User whose explicit grant was revoked." + }, + "revoked": { + "type": "boolean", + "const": true, + "description": "The grant is revoked; inherited workspace administrator access is preserved." + } + }, + "required": ["userId", "revoked"], + "additionalProperties": false, + "description": "Response data." } }, "required": ["data"], "additionalProperties": false, - "title": "Revoke Organization Invitation response", - "description": "Revoke Organization Invitation result.", - "examples": [ - { - "data": { - "id": "invitation-123", - "status": "cancelled" - } - } - ] + "title": "Remove Credential Member response", + "description": "Remove Credential Member result." }, "V2WorkspacePermissionConfig": { "type": "object", diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index e18c88c511b..73d85efaa41 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -5021,6 +5021,8 @@ "ORGANIZATION_MEMBERSHIP_REQUIRED", "ORGANIZATION_ADMIN_REQUIRED", "ENTERPRISE_PLAN_REQUIRED", + "SSO_DOMAIN_NOT_VERIFIED", + "SSO_PROVIDER_LIMIT_REACHED", "ORGANIZATION_PLAN_REQUIRED", "AUDIT_LOGS_DISABLED", "ACCESS_REQUESTS_DISABLED", diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 67f2a16340e..88ca5508a91 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -5722,6 +5722,8 @@ "ORGANIZATION_MEMBERSHIP_REQUIRED", "ORGANIZATION_ADMIN_REQUIRED", "ENTERPRISE_PLAN_REQUIRED", + "SSO_DOMAIN_NOT_VERIFIED", + "SSO_PROVIDER_LIMIT_REACHED", "ORGANIZATION_PLAN_REQUIRED", "AUDIT_LOGS_DISABLED", "ACCESS_REQUESTS_DISABLED", diff --git a/apps/sim/app/api/auth/sso/providers/[providerId]/route.test.ts b/apps/sim/app/api/auth/sso/providers/[providerId]/route.test.ts index 5b29c395c27..b2d946bc20f 100644 --- a/apps/sim/app/api/auth/sso/providers/[providerId]/route.test.ts +++ b/apps/sim/app/api/auth/sso/providers/[providerId]/route.test.ts @@ -11,7 +11,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' /** Authorization and the primary switch are the use case's; its own tests and the PostgreSQL suite cover them. */ vi.mock('@/lib/auth/sso/application/set-primary-provider', () => ({ - setPrimarySsoProviderOperation: { id: 'organization.sso.set_primary_provider' }, setPrimarySsoProvider: { operation: { id: 'organization.sso.set_primary_provider' }, execute: vi.fn(), @@ -28,27 +27,19 @@ const request = () => createMockRequest('DELETE') describe('DELETE /api/auth/sso/providers/[providerId]', () => { beforeEach(() => { resetDbChainMock() - mockGetSession.mockResolvedValue({ user: { id: 'u1' } }) + mockGetSession.mockResolvedValue({ user: { id: 'u1' }, session: { id: 'session-1' } }) dbChainMockFns.returning.mockResolvedValue([{ id: 'row-1' }]) }) - it('clears the name of a domain that made the deleted provider primary', async () => { - queueTableRows(schemaMock.ssoProvider, [ - { id: 'row-1', organizationId: 'org1', userId: 'u1', domain: 'acme.com' }, - ]) - queueTableRows(schemaMock.member, [{ role: 'owner' }]) - const res = await DELETE(request(), context) - expect(res.status).toBe(200) - expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(1) - expect(dbChainMockFns.update).toHaveBeenCalledWith(schemaMock.ssoDomain) - expect(dbChainMockFns.set).toHaveBeenCalledWith( - expect.objectContaining({ primaryProviderId: null }) - ) - }) - it('leaves domains alone when deleting a personal provider', async () => { queueTableRows(schemaMock.ssoProvider, [ - { id: 'row-1', organizationId: null, userId: 'u1', domain: 'acme.com' }, + { + id: 'row-1', + providerId: 'acme-okta', + organizationId: null, + userId: 'u1', + domain: 'acme.com', + }, ]) const res = await DELETE(request(), context) expect(res.status).toBe(200) @@ -57,7 +48,13 @@ describe('DELETE /api/auth/sso/providers/[providerId]', () => { it("refuses an organization provider to a member who is not the organization's admin", async () => { queueTableRows(schemaMock.ssoProvider, [ - { id: 'row-1', organizationId: 'org1', userId: 'u-other', domain: 'acme.com' }, + { + id: 'row-1', + providerId: 'acme-okta', + organizationId: 'org1', + userId: 'u-other', + domain: 'acme.com', + }, ]) queueTableRows(schemaMock.member, [{ role: 'member' }]) const res = await DELETE(request(), context) @@ -67,7 +64,13 @@ describe('DELETE /api/auth/sso/providers/[providerId]', () => { it('lets only the creator delete a personal provider', async () => { queueTableRows(schemaMock.ssoProvider, [ - { id: 'row-1', organizationId: null, userId: 'u-other', domain: 'acme.com' }, + { + id: 'row-1', + providerId: 'acme-okta', + organizationId: null, + userId: 'u-other', + domain: 'acme.com', + }, ]) const refused = await DELETE(request(), context) expect(refused.status).toBe(403) @@ -75,7 +78,13 @@ describe('DELETE /api/auth/sso/providers/[providerId]', () => { resetDbChainMock() dbChainMockFns.returning.mockResolvedValue([{ id: 'row-1' }]) queueTableRows(schemaMock.ssoProvider, [ - { id: 'row-1', organizationId: null, userId: 'u1', domain: 'acme.com' }, + { + id: 'row-1', + providerId: 'acme-okta', + organizationId: null, + userId: 'u1', + domain: 'acme.com', + }, ]) const allowed = await DELETE(request(), context) expect(allowed.status).toBe(200) diff --git a/apps/sim/app/api/auth/sso/providers/[providerId]/route.ts b/apps/sim/app/api/auth/sso/providers/[providerId]/route.ts index 22bf9c5d96b..d1034b15808 100644 --- a/apps/sim/app/api/auth/sso/providers/[providerId]/route.ts +++ b/apps/sim/app/api/auth/sso/providers/[providerId]/route.ts @@ -1,63 +1,19 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db, ssoProvider } from '@sim/db' -import { forgetPrimaryProvider } from '@sim/db/sso-primary-provider' -import { createLogger } from '@sim/logger' -import { and, eq, isNull } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' import { deleteSsoProviderContract, setPrimarySsoProviderContract } from '@/lib/api/contracts/auth' -import { parseRequest } from '@/lib/api/server' import { defineInternalJsonRoute, internalOrchestrationErrorPolicy, internalRateLimits, internalSessionAuth, } from '@/lib/api/server/routes' -import { getSession } from '@/lib/auth' -import { - setPrimarySsoProvider, - setPrimarySsoProviderOperation, -} from '@/lib/auth/sso/application/set-primary-provider' -import { invalidateSsoPolicyCache } from '@/lib/auth/sso-policy' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { isOrganizationAdminOrOwner } from '@/lib/workspaces/permissions/utils' - -const logger = createLogger('SSOProviderRoute') - -type RouteContext = { params: Promise<{ providerId: string }> } - -/** - * Loads a provider the caller may manage: an organization provider for its - * owners and admins, a personal provider for its creator. Sim owns deleting - * rather than exposing the SSO plugin's own delete, which `/api/auth/[...all]` - * blocks by design: the plugin gates only on the row's creator, while an - * organization's providers belong to the organization. - */ -async function loadManagedProvider(userId: string, providerId: string) { - const [provider] = await db - .select({ - id: ssoProvider.id, - organizationId: ssoProvider.organizationId, - userId: ssoProvider.userId, - domain: ssoProvider.domain, - }) - .from(ssoProvider) - .where(eq(ssoProvider.providerId, providerId)) - .limit(1) - if (!provider) return NextResponse.json({ error: 'Provider not found' }, { status: 404 }) +import { internalSsoErrorPolicy } from '@/lib/api/server/routes/sso' +import { ssoProviderOperations, ssoSettingsOperations } from '@/lib/auth/sso/application/operations' +import { deleteSsoProvider } from '@/lib/auth/sso/application/provider-settings' +import { setPrimarySsoProvider } from '@/lib/auth/sso/application/set-primary-provider' - const allowed = provider.organizationId - ? await isOrganizationAdminOrOwner(userId, provider.organizationId) - : provider.userId === userId - if (!allowed) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - - return provider -} - -/** Makes this provider the one its domain signs in through. */ export const PATCH = defineInternalJsonRoute({ contract: setPrimarySsoProviderContract, auth: internalSessionAuth, - operation: setPrimarySsoProviderOperation, + operation: ssoSettingsOperations.setPrimary, rateLimit: internalRateLimits.user({ bucketName: 'sso-set-primary-provider' }), errorPolicy: internalOrchestrationErrorPolicy, mapInput: ({ params }) => ({ providerId: params.providerId }), @@ -65,69 +21,15 @@ export const PATCH = defineInternalJsonRoute({ present: ({ providerId }) => ({ success: true as const, providerId }), }) -/** - * Removes one identity provider. Accounts and memberships it admitted are - * untouched; only the sign-in path goes. A domain that named it as primary - * forgets the name, so sign-in moves to the domain's next verified provider - * and a later provider reusing the id does not inherit the role. - */ -export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } - - const parsed = await parseRequest(deleteSsoProviderContract, request, context) - if (!parsed.success) return parsed.response - const { providerId } = parsed.data.params - - const provider = await loadManagedProvider(session.user.id, providerId) - if (provider instanceof NextResponse) return provider - const { organizationId } = provider - - /** - * Deleted by primary key under the same ownership the check established, - * so a concurrent re-registration of the providerId cannot be the row - * removed. - */ - const ownerClause = organizationId - ? eq(ssoProvider.organizationId, organizationId) - : and(eq(ssoProvider.userId, session.user.id), isNull(ssoProvider.organizationId)) - const removed = await db.transaction(async (tx) => { - const deleted = await tx - .delete(ssoProvider) - .where(and(eq(ssoProvider.id, provider.id), ownerClause)) - .returning({ id: ssoProvider.id }) - if (deleted.length > 0 && organizationId) { - await forgetPrimaryProvider(tx, organizationId, providerId) - } - return deleted - }) - if (removed.length === 0) { - return NextResponse.json({ error: 'Provider not found' }, { status: 404 }) - } - - /** The organization may have just lost the provider its sign-in requirement depends on. */ - if (organizationId) { - invalidateSsoPolicyCache(organizationId) - recordAudit({ - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.ORGANIZATION_SSO_PROVIDER_DELETED, - resourceType: AuditResourceType.ORGANIZATION, - resourceId: organizationId, - description: 'Deleted organization SSO provider', - metadata: { organizationId, providerId, domain: provider.domain }, - request, - }) - } - - logger.info('Deleted SSO provider', { - providerId, - organizationId, - domain: provider.domain, - userId: session.user.id, - }) - return NextResponse.json({ success: true, providerId }) +export const DELETE = defineInternalJsonRoute({ + contract: deleteSsoProviderContract, + auth: internalSessionAuth, + operation: ssoProviderOperations.delete, + rateLimit: internalRateLimits.none({ + reason: 'Preserves the existing session-only SSO settings delete policy.', + }), + errorPolicy: internalSsoErrorPolicy, + mapInput: ({ params }) => ({ providerId: params.providerId }), + useCase: deleteSsoProvider, + present: ({ providerId }) => ({ success: true as const, providerId }), }) diff --git a/apps/sim/app/api/auth/sso/providers/route.test.ts b/apps/sim/app/api/auth/sso/providers/route.test.ts index a69aae221fb..480c408fbab 100644 --- a/apps/sim/app/api/auth/sso/providers/route.test.ts +++ b/apps/sim/app/api/auth/sso/providers/route.test.ts @@ -24,12 +24,13 @@ const providerRow = { domainVerified: true, domainKey: 'acme.com', isNamedPrimary: false, + isPrimary: true, } describe('GET /api/auth/sso/providers', () => { beforeEach(() => { resetDbChainMock() - mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + mockGetSession.mockResolvedValue({ user: { id: 'user-1' }, session: { id: 'session-1' } }) }) it('lists only the providers the caller registered when no organization is named', async () => { diff --git a/apps/sim/app/api/auth/sso/providers/route.ts b/apps/sim/app/api/auth/sso/providers/route.ts index 9a447da9df0..cce7820a598 100644 --- a/apps/sim/app/api/auth/sso/providers/route.ts +++ b/apps/sim/app/api/auth/sso/providers/route.ts @@ -1,120 +1,23 @@ -import { db, member, ssoDomain, ssoProvider } from '@sim/db' -import { - isNamedPrimary, - ssoProviderDomainKey, - verifiedDomainOfProvider, -} from '@sim/db/sso-primary-provider' -import { createLogger } from '@sim/logger' -import { and, asc, eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' import { listSsoProvidersContract } from '@/lib/api/contracts/auth' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { markSignInProviders } from '@/lib/auth/sso/primary-provider' -import { REDACTED_MARKER } from '@/lib/core/security/redaction' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' - -const logger = createLogger('SSOProvidersRoute') - -/** Secrets shorter than this reveal too large a fraction of themselves in 4 characters. */ -const MIN_LENGTH_FOR_HINT = 16 - -/** - * Last four characters of a stored client secret, so an admin can tell *which* - * secret is saved rather than only that one exists. Four characters of a - * high-entropy secret is not a meaningful disclosure to an owner or admin, who - * can rotate it anyway — but short secrets are left unhinted, where the same four - * characters would be a large share of the value. - */ -function buildClientSecretHint(clientSecret: unknown): string | null { - if (typeof clientSecret !== 'string' || clientSecret.length < MIN_LENGTH_FOR_HINT) return null - return clientSecret.slice(-4) -} - -/** - * Lists the identity providers the caller administers: an organization's when an - * owner or admin names it, otherwise the ones the caller registered. - * - * Signed-in only. Sign-in resolves one address at a time through - * `/api/auth/sso/resolve`; nothing needs every configured domain, and listing - * them would publish which organizations use SSO. - */ -export const GET = withRouteHandler(async (request: NextRequest) => { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const parsed = await parseRequest(listSsoProvidersContract, request, {}) - if (!parsed.success) return parsed.response - const { organizationId } = parsed.data.query - const userId = session.user.id - - let verifiedOrganizationId: string | null = null - if (organizationId) { - const [membership] = await db - .select({ organizationId: member.organizationId, role: member.role }) - .from(member) - .where(and(eq(member.userId, userId), eq(member.organizationId, organizationId))) - .limit(1) - if (!membership) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - if (membership.role !== 'owner' && membership.role !== 'admin') { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - verifiedOrganizationId = membership.organizationId - } - - const whereClause = verifiedOrganizationId - ? eq(ssoProvider.organizationId, verifiedOrganizationId) - : eq(ssoProvider.userId, userId) - - const results = await db - .select({ - id: ssoProvider.id, - providerId: ssoProvider.providerId, - domain: ssoProvider.domain, - issuer: ssoProvider.issuer, - oidcConfig: ssoProvider.oidcConfig, - samlConfig: ssoProvider.samlConfig, - userId: ssoProvider.userId, - organizationId: ssoProvider.organizationId, - jitProvisioningEnabled: ssoProvider.jitProvisioningEnabled, - domainVerified: ssoProvider.domainVerified, - domainKey: ssoProviderDomainKey, - isNamedPrimary, - }) - .from(ssoProvider) - .leftJoin(ssoDomain, verifiedDomainOfProvider) - .where(whereClause) - .orderBy(asc(ssoProvider.providerId)) - - const providers = markSignInProviders(results).map((provider) => { - let oidcConfig = provider.oidcConfig - if (oidcConfig) { - try { - const parsed = JSON.parse(oidcConfig) - const hint = buildClientSecretHint(parsed.clientSecret) - parsed.clientSecret = REDACTED_MARKER - if (hint) parsed.clientSecretHint = hint - oidcConfig = JSON.stringify(parsed) - } catch { - oidcConfig = null - } - } - return { - ...provider, - oidcConfig, - providerType: (provider.samlConfig ? 'saml' : 'oidc') as 'oidc' | 'saml', - } - }) - - logger.info('Fetched SSO providers', { userId, providerCount: providers.length }) - - return NextResponse.json({ providers }) - } catch (error) { - logger.error('Failed to fetch SSO providers', { error }) - return NextResponse.json({ error: 'Failed to fetch SSO providers' }, { status: 500 }) - } +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalSsoErrorPolicy } from '@/lib/api/server/routes/sso' +import { presentSsoProviderSettings } from '@/lib/api/server/sso-presenters' +import { ssoProviderOperations } from '@/lib/auth/sso/application/operations' +import { listSsoProviders } from '@/lib/auth/sso/application/provider-settings' + +export const GET = defineInternalJsonRoute({ + contract: listSsoProvidersContract, + auth: internalSessionAuth, + operation: ssoProviderOperations.list, + rateLimit: internalRateLimits.none({ + reason: 'Preserves the existing session-only SSO settings read policy.', + }), + errorPolicy: internalSsoErrorPolicy, + mapInput: ({ query }) => ({ organizationId: query.organizationId }), + useCase: listSsoProviders, + present: ({ providers }) => ({ providers: providers.map(presentSsoProviderSettings) }), }) diff --git a/apps/sim/app/api/auth/sso/register/route.test.ts b/apps/sim/app/api/auth/sso/register/route.test.ts index 7dc11402814..9c45478173a 100644 --- a/apps/sim/app/api/auth/sso/register/route.test.ts +++ b/apps/sim/app/api/auth/sso/register/route.test.ts @@ -10,19 +10,20 @@ import { setEnvFlags, } from '@sim/testing' import { authMockFns } from '@sim/testing/mocks/auth.mock' +import { + billingSubscriptionMock, + billingSubscriptionMockFns, +} from '@sim/testing/mocks/billing-subscription.mock' import { inputValidationMock, inputValidationMockFns, } from '@sim/testing/mocks/input-validation.mock' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockHasSSOAccess } = vi.hoisted(() => ({ - mockHasSSOAccess: vi.fn(), -})) - /** Queues the caller's org membership row(s) for the admin/owner check. */ function queueMembers(rows: Array>) { queueTableRows(schemaMock.member, rows) + queueTableRows(schemaMock.member, rows) } /** @@ -41,17 +42,7 @@ function queueProviders( queueTableRows(schemaMock.ssoProvider, domainRows) } -vi.mock('@/lib/billing', () => ({ - hasSSOAccess: mockHasSSOAccess, -})) - -vi.mock('@sim/utils/sso-domain', () => ({ - normalizeSSODomain: (input: unknown): string | null => { - if (typeof input !== 'string') return null - const value = input.trim().toLowerCase() - return /^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(value) ? value : null - }, -})) +vi.mock('@/lib/billing/core/subscription', () => billingSubscriptionMock) vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) @@ -90,8 +81,8 @@ describe('POST /api/auth/sso/register', () => { * var, so the suite switch (`ENTERPRISE_ENABLED`) can register SSO too. */ setEnvFlags({ isSsoEnabled: true }) - mockGetSession.mockResolvedValue({ user: { id: 'u1' } }) - mockHasSSOAccess.mockResolvedValue(true) + mockGetSession.mockResolvedValue({ user: { id: 'u1' }, session: { id: 'session-1' } }) + billingSubscriptionMockFns.mockIsOrganizationFeatureEntitled.mockResolvedValue(true) mockValidateUrlWithDNS.mockResolvedValue({ isValid: true, resolvedIP: '1.2.3.4' }) mockSecureFetchWithPinnedIP.mockRejectedValue(new Error('discovery not mocked for this test')) mockRegisterSSOProvider.mockResolvedValue({ id: 'row-1', providerId: 'acme-oidc' }) @@ -116,9 +107,11 @@ describe('POST /api/auth/sso/register', () => { }) it('rejects callers without an Enterprise plan', async () => { - mockHasSSOAccess.mockResolvedValue(false) + queueMembers([{ organizationId: 'org1', role: 'owner' }]) + billingSubscriptionMockFns.mockIsOrganizationFeatureEntitled.mockResolvedValue(false) const res = await POST(request(OIDC_BODY)) expect(res.status).toBe(403) + expect(await res.json()).toEqual({ error: 'SSO requires an Enterprise plan' }) expect(mockRegisterSSOProvider).not.toHaveBeenCalled() }) diff --git a/apps/sim/app/api/auth/sso/register/route.ts b/apps/sim/app/api/auth/sso/register/route.ts index c0f20d11891..8867d3ec5dc 100644 --- a/apps/sim/app/api/auth/sso/register/route.ts +++ b/apps/sim/app/api/auth/sso/register/route.ts @@ -1,882 +1,39 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db, member, ssoDomain, ssoProvider } from '@sim/db' -import { keepDomainSignInProvider, ssoProviderDomainKey } from '@sim/db/sso-primary-provider' -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { normalizeSSODomain } from '@sim/utils/sso-domain' -import { and, eq, sql } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' +import { NextResponse } from 'next/server' import { ssoRegistrationContract } from '@/lib/api/contracts/auth' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { auth, getSession } from '@/lib/auth' -import { invalidateSsoPolicyCache } from '@/lib/auth/sso-policy' -import { hasSSOAccess } from '@/lib/billing' -import { isSsoEnabled } from '@/lib/core/config/env-flags' -import { runWithOutboundOrganization } from '@/lib/core/network/context.server' +import { getValidationErrorMessage } from '@/lib/api/server' import { - secureFetchWithPinnedIP, - validateUrlWithDNS, -} from '@/lib/core/security/input-validation.server' -import { REDACTED_MARKER } from '@/lib/core/security/redaction' -import { getBaseUrl } from '@/lib/core/utils/urls' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' - -const logger = createLogger('SSORegisterRoute') - -type TokenEndpointAuthMethod = 'client_secret_basic' | 'client_secret_post' - -/** - * Prefers client_secret_post over client_secret_basic when an IdP supports both: - * better-auth sends client_secret_basic credentials without URL-encoding per - * RFC 6749 §2.3.1, so a '+' in the client secret is decoded as a space, causing - * invalid_client errors. Matches the same default in register-sso-provider.ts. - */ -function selectTokenEndpointAuthMethod( - supportedMethods: unknown, - existing?: TokenEndpointAuthMethod -): TokenEndpointAuthMethod { - if (existing) return existing - if (!Array.isArray(supportedMethods) || supportedMethods.length === 0) { - return 'client_secret_post' - } - if (supportedMethods.includes('client_secret_post')) return 'client_secret_post' - if (supportedMethods.includes('client_secret_basic')) return 'client_secret_basic' - return 'client_secret_post' -} - -/** - * Proposes a free provider ID by suffixing the domain's first label - * (`azure-ad` + `acme.com` -> `azure-ad-acme`). Callers pass a domain already - * through `normalizeSSODomain`, whose shape guarantees a non-empty first label. - */ -function suggestProviderId(providerId: string, domain: string): string { - return `${providerId}-${domain.split('.')[0]}` -} - -type DiscoveryResult = - | { ok: true; discovery: Record } - | { ok: false; error: string } - -const OIDC_DISCOVERY_TIMEOUT_MS = 10000 - -async function fetchOIDCDiscoveryDocument(discoveryUrl: string): Promise { - const urlValidation = await validateUrlWithDNS( - discoveryUrl, - 'OIDC discovery URL', - 'configuredEndpoint' - ) - if (!urlValidation.isValid) { - return { ok: false, error: urlValidation.error } - } - - try { - const response = await secureFetchWithPinnedIP(discoveryUrl, urlValidation.resolvedIP, { - profile: 'configuredEndpoint', - headers: { Accept: 'application/json' }, - timeout: OIDC_DISCOVERY_TIMEOUT_MS, - }) - if (!response.ok) { - return { ok: false, error: `Discovery request failed with status ${response.status}` } - } - return { ok: true, discovery: (await response.json()) as Record } - } catch (error) { - return { ok: false, error: getErrorMessage(error, 'Unknown error') } - } -} - -export const POST = withRouteHandler(async (request: NextRequest) => { - try { - if (!isSsoEnabled) { - return NextResponse.json({ error: 'SSO is not enabled' }, { status: 400 }) - } - - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } - - const hasAccess = await hasSSOAccess(session.user.id) - if (!hasAccess) { - return NextResponse.json({ error: 'SSO requires an Enterprise plan' }, { status: 403 }) - } - - const parsed = await parseRequest( - ssoRegistrationContract, - request, - {}, - { - validationErrorResponse: (error) => { - logger.warn('Invalid SSO registration request', { errors: error.issues }) - return NextResponse.json( - { error: getValidationErrorMessage(error, 'Validation failed') }, - { status: 400 } - ) - }, - } - ) - if (!parsed.success) return parsed.response - - const body = parsed.data.body - const { providerId, issuer, providerType, mapping, orgId, jitProvisioningEnabled } = body - - /** - * Always org-scoped: an org-less provider has no `sso_domain` proof, so only - * operators create one, via `packages/db/scripts/register-sso-provider.ts`. - */ - const [membership] = await db - .select({ organizationId: member.organizationId, role: member.role }) - .from(member) - .where(and(eq(member.userId, session.user.id), eq(member.organizationId, orgId))) - .limit(1) - if (!membership) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - if (membership.role !== 'owner' && membership.role !== 'admin') { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - - const domain = normalizeSSODomain(body.domain) - if (!domain) { - return NextResponse.json( - { error: 'Enter a valid domain, for example acme.com' }, - { status: 400 } - ) - } - - /** - * Configuring org SSO for a domain requires DNS-proven ownership; without it - * a first-come claim lets any org wire another company's domain to their own - * IdP. Migration 0266 grandfathered existing domains. - */ - const verifiedDomainClause = and( - eq(ssoDomain.organizationId, orgId), - eq(ssoDomain.domain, domain), - eq(ssoDomain.status, 'verified') - ) - - const isOrgDomainVerified = async (): Promise => { - const [verified] = await db - .select({ id: ssoDomain.id }) - .from(ssoDomain) - .where(verifiedDomainClause) - .limit(1) - return Boolean(verified) - } - - const domainNotVerifiedResponse = () => - NextResponse.json( - { - error: `Verify ownership of ${domain} under Verified domains above before configuring SSO for it.`, - code: 'SSO_DOMAIN_NOT_VERIFIED', - }, - { status: 403 } - ) - - // Fail fast before OIDC discovery; re-checked before the write to close the - // window where the proof is removed while discovery is in flight. - if (!(await isOrgDomainVerified())) return domainNotVerifiedResponse() - - /** - * An org-less provider the caller created counts as theirs, so its claim on - * a domain is not reported as another tenant's. - */ - const isOwnedByCaller = (provider: { - userId: string | null - organizationId: string | null - }): boolean => - provider.organizationId === orgId || - (provider.userId === session.user.id && !provider.organizationId) - - const ownerClause = and( - eq(ssoProvider.providerId, providerId), - eq(ssoProvider.organizationId, orgId) - ) - - /** - * Refuses the domain when another tenant has claimed it, or when the caller's - * own personal provider signs it in. The caller's organization may add a - * provider to a domain it already signs in through: the new provider waits, - * reachable by test link, until an admin makes it the domain's primary. - */ - const findDomainRefusal = async (): Promise => { - const claims = await db - .select({ - userId: ssoProvider.userId, - organizationId: ssoProvider.organizationId, - providerId: ssoProvider.providerId, - }) - .from(ssoProvider) - .where(sql`${ssoProviderDomainKey} = ${domain}`) - if (claims.some((provider) => !isOwnedByCaller(provider))) { - logger.warn('Rejected SSO registration for domain owned by another tenant', { - domain, - orgId, - userId: session.user.id, - }) - return NextResponse.json( - { - error: 'This domain is already registered for SSO by another organization.', - code: 'SSO_DOMAIN_ALREADY_REGISTERED', - }, - { status: 409 } - ) - } - const personal = claims.find( - (provider) => - !provider.organizationId && - typeof provider.providerId === 'string' && - provider.providerId !== providerId - ) - if (personal) { - return NextResponse.json( - { - error: `${domain} already signs in through the provider "${personal.providerId}". Edit that provider, or give this one a different verified domain.`, - code: 'SSO_DOMAIN_ALREADY_ROUTED', - }, - { status: 409 } - ) - } - return null - } - - /** - * Better Auth treats `providerId` as globally unique, not per-tenant, and - * resolves providers by that column alone. Catching the cross-tenant - * collision here turns its opaque 422 into a 409 naming a free id. - */ - const findProviderIdConflict = async () => - ( - await db - .select({ userId: ssoProvider.userId, organizationId: ssoProvider.organizationId }) - .from(ssoProvider) - .where(eq(ssoProvider.providerId, providerId)) - ).find((provider) => !isOwnedByCaller(provider)) - - const providerIdConflictResponse = () => + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalSsoErrorPolicy } from '@/lib/api/server/routes/sso' +import { ssoProviderOperations } from '@/lib/auth/sso/application/operations' +import { saveSsoProvider } from '@/lib/auth/sso/application/provider-registration' + +export const POST = defineInternalJsonRoute({ + contract: ssoRegistrationContract, + auth: internalSessionAuth, + operation: ssoProviderOperations.save, + rateLimit: internalRateLimits.none({ + reason: 'Preserves the existing session-only SSO settings write policy.', + }), + errorPolicy: internalSsoErrorPolicy, + parseOptions: { + validationErrorResponse: (error) => NextResponse.json( - { - error: `The provider ID "${providerId}" is already taken by another organization. Provider IDs are global, so pick a unique one — for example "${suggestProviderId(providerId, domain)}". It appears in the redirect URL you register with your identity provider, so choose it before configuring the IdP.`, - code: 'SSO_PROVIDER_ID_TAKEN', - }, - { status: 409 } - ) - - if (await findProviderIdConflict()) { - logger.warn('Rejected SSO registration for providerId owned by another tenant', { - providerId, - orgId, - userId: session.user.id, - }) - return providerIdConflictResponse() - } - - const domainRefusal = await findDomainRefusal() - if (domainRefusal) return domainRefusal - - const headers: Record = {} - request.headers.forEach((value, key) => { - headers[key] = value - }) - - const providerConfig: any = { - providerId, - issuer, - domain, - organizationId: orgId, - } - - if (providerType === 'oidc') { - const { - clientId, - clientSecret: rawClientSecret, - scopes, - pkce, - authorizationEndpoint, - tokenEndpoint, - userInfoEndpoint, - skipUserInfoEndpoint, - jwksEndpoint, - } = body - - let clientSecret = rawClientSecret - if (rawClientSecret === REDACTED_MARKER) { - const [existing] = await db - .select({ oidcConfig: ssoProvider.oidcConfig }) - .from(ssoProvider) - .where(ownerClause) - .limit(1) - if (!existing?.oidcConfig) { - return NextResponse.json( - { error: 'Cannot update: existing provider not found. Re-enter your client secret.' }, - { status: 400 } - ) - } - try { - clientSecret = JSON.parse(existing.oidcConfig).clientSecret - } catch { - return NextResponse.json( - { - error: 'Cannot update: failed to read existing secret. Re-enter your client secret.', - }, - { status: 400 } - ) - } - } - - const oidcConfig: any = { - clientId, - clientSecret, - scopes: Array.isArray(scopes) - ? scopes.filter((s: string) => s !== 'offline_access') - : ['openid', 'profile', 'email'].filter((s: string) => s !== 'offline_access'), - pkce: pkce ?? true, - } - - oidcConfig.authorizationEndpoint = authorizationEndpoint - oidcConfig.tokenEndpoint = tokenEndpoint - oidcConfig.userInfoEndpoint = userInfoEndpoint - oidcConfig.jwksEndpoint = jwksEndpoint - - const userProvidedEndpoints: Record = { - authorizationEndpoint, - tokenEndpoint, - jwksEndpoint, - ...(skipUserInfoEndpoint ? {} : { userInfoEndpoint }), - } - - for (const [name, endpointUrl] of Object.entries(userProvidedEndpoints)) { - if (endpointUrl) { - const endpointValidation = await validateUrlWithDNS( - endpointUrl, - `OIDC ${name}`, - 'configuredEndpoint' - ) - if (!endpointValidation.isValid) { - logger.warn('Explicitly provided OIDC endpoint failed SSRF validation', { - endpoint: name, - url: endpointUrl, - error: endpointValidation.error, - }) - return NextResponse.json( - { - error: `OIDC ${name} failed security validation: ${endpointValidation.error}`, - }, - { status: 400 } - ) - } - } - } - - const needsDiscovery = - !oidcConfig.authorizationEndpoint || !oidcConfig.tokenEndpoint || !oidcConfig.jwksEndpoint - - const discoveryUrl = `${issuer.replace(/\/$/, '')}/.well-known/openid-configuration` - const discoveryResult = await runWithOutboundOrganization(membership.organizationId, () => - fetchOIDCDiscoveryDocument(discoveryUrl) - ) - - if (needsDiscovery) { - logger.info('Fetching OIDC discovery document for missing endpoints', { - discoveryUrl, - hasAuthEndpoint: !!oidcConfig.authorizationEndpoint, - hasTokenEndpoint: !!oidcConfig.tokenEndpoint, - hasJwksEndpoint: !!oidcConfig.jwksEndpoint, - }) - - if (!discoveryResult.ok) { - logger.error('Failed to fetch OIDC discovery document', { discoveryResult }) - return NextResponse.json( - { - error: `Failed to fetch OIDC discovery document: ${discoveryResult.error}. Provide all endpoints explicitly or verify the issuer URL.`, - }, - { status: 400 } - ) - } - - const { discovery } = discoveryResult - - const discoveredEndpoints: Record = { - authorization_endpoint: discovery.authorization_endpoint, - token_endpoint: discovery.token_endpoint, - jwks_uri: discovery.jwks_uri, - ...(skipUserInfoEndpoint ? {} : { userinfo_endpoint: discovery.userinfo_endpoint }), - } - - for (const [key, value] of Object.entries(discoveredEndpoints)) { - if (typeof value === 'string') { - const endpointValidation = await validateUrlWithDNS( - value, - `OIDC ${key}`, - 'contentFetch' - ) - if (!endpointValidation.isValid) { - logger.warn('OIDC discovered endpoint failed SSRF validation', { - endpoint: key, - url: value, - error: endpointValidation.error, - }) - return NextResponse.json( - { - error: `Discovered OIDC ${key} failed security validation: ${endpointValidation.error}`, - }, - { status: 400 } - ) - } - } - } - - oidcConfig.authorizationEndpoint = - oidcConfig.authorizationEndpoint || discovery.authorization_endpoint - oidcConfig.tokenEndpoint = oidcConfig.tokenEndpoint || discovery.token_endpoint - oidcConfig.userInfoEndpoint = oidcConfig.userInfoEndpoint || discovery.userinfo_endpoint - oidcConfig.jwksEndpoint = oidcConfig.jwksEndpoint || discovery.jwks_uri - oidcConfig.tokenEndpointAuthentication = selectTokenEndpointAuthMethod( - discovery.token_endpoint_auth_methods_supported, - oidcConfig.tokenEndpointAuthentication - ) - - logger.info('Merged OIDC endpoints (user-provided + discovery)', { - providerId, - issuer, - authorizationEndpoint: oidcConfig.authorizationEndpoint, - tokenEndpoint: oidcConfig.tokenEndpoint, - userInfoEndpoint: oidcConfig.userInfoEndpoint, - jwksEndpoint: oidcConfig.jwksEndpoint, - tokenEndpointAuthentication: oidcConfig.tokenEndpointAuthentication, - }) - } else { - logger.info('Using explicitly provided OIDC endpoints (all present)', { - providerId, - issuer, - authorizationEndpoint: oidcConfig.authorizationEndpoint, - tokenEndpoint: oidcConfig.tokenEndpoint, - userInfoEndpoint: oidcConfig.userInfoEndpoint, - jwksEndpoint: oidcConfig.jwksEndpoint, - }) - - if (!discoveryResult.ok) { - logger.info('OIDC discovery unavailable; falling back to the default token auth method', { - providerId, - discoveryUrl, - }) - } - oidcConfig.tokenEndpointAuthentication = selectTokenEndpointAuthMethod( - discoveryResult.ok - ? discoveryResult.discovery.token_endpoint_auth_methods_supported - : undefined, - oidcConfig.tokenEndpointAuthentication - ) - } - - if (skipUserInfoEndpoint) { - oidcConfig.userInfoEndpoint = undefined - logger.info('Skipping UserInfo endpoint for provider, claims will come from the ID token', { - providerId, - }) - } - - if ( - !oidcConfig.authorizationEndpoint || - !oidcConfig.tokenEndpoint || - !oidcConfig.jwksEndpoint - ) { - const missing: string[] = [] - if (!oidcConfig.authorizationEndpoint) missing.push('authorizationEndpoint') - if (!oidcConfig.tokenEndpoint) missing.push('tokenEndpoint') - if (!oidcConfig.jwksEndpoint) missing.push('jwksEndpoint') - - logger.error('Missing required OIDC endpoints after discovery merge', { - missing, - authorizationEndpoint: oidcConfig.authorizationEndpoint, - tokenEndpoint: oidcConfig.tokenEndpoint, - jwksEndpoint: oidcConfig.jwksEndpoint, - }) - return NextResponse.json( - { - error: `Missing required OIDC endpoints: ${missing.join(', ')}. Please provide these explicitly or verify the issuer supports OIDC discovery.`, - }, - { status: 400 } - ) - } - - oidcConfig.skipDiscovery = true - // Better Auth reads the attribute mapping from oidcConfig.mapping, not a - // top-level field — nesting it here is what makes a custom mapping apply. - if (mapping) oidcConfig.mapping = mapping - providerConfig.oidcConfig = oidcConfig - } else if (providerType === 'saml') { - const { - entryPoint, - cert, - callbackUrl, - audience, - wantAssertionsSigned, - signatureAlgorithm, - digestAlgorithm, - identifierFormat, - idpMetadata, - } = body - - const computedCallbackUrl = - callbackUrl || `${getBaseUrl()}/api/auth/sso/saml2/callback/${providerId}` - - const escapeXml = (str: string) => - str.replace(/[<>&"']/g, (c) => { - switch (c) { - case '<': - return '<' - case '>': - return '>' - case '&': - return '&' - case '"': - return '"' - case "'": - return ''' - default: - return c - } - }) - - const spMetadataXml = ` - - - - -` - - const samlConfig: any = { - entryPoint, - cert, - callbackUrl: computedCallbackUrl, - spMetadata: { - metadata: spMetadataXml, - }, - } - - if (audience) samlConfig.audience = audience - if (wantAssertionsSigned !== undefined) samlConfig.wantAssertionsSigned = wantAssertionsSigned - if (signatureAlgorithm) samlConfig.signatureAlgorithm = signatureAlgorithm - if (digestAlgorithm) samlConfig.digestAlgorithm = digestAlgorithm - - /** - * Always written, empty when unset: Better Auth merges SAML config with - * `??`, so an omitted key keeps whatever was stored and clearing either - * field would never take effect. Both are falsy-guarded downstream. - * - * Metadata must not be generated here — a document built from cert + - * entryPoint outranks the certificate on re-save, silently defeating - * SAML cert rotation. - */ - samlConfig.idpMetadata = { metadata: idpMetadata ?? '' } - samlConfig.identifierFormat = identifierFormat ?? '' - // Better Auth reads the attribute mapping from samlConfig.mapping. - if (mapping) samlConfig.mapping = mapping - - providerConfig.samlConfig = samlConfig - } - - logger.info('Calling Better Auth registerSSOProvider with config:', { - providerId: providerConfig.providerId, - domain: providerConfig.domain, - hasOidcConfig: !!providerConfig.oidcConfig, - hasSamlConfig: !!providerConfig.samlConfig, - samlConfigKeys: providerConfig.samlConfig ? Object.keys(providerConfig.samlConfig) : [], - fullConfig: JSON.stringify( - { - ...providerConfig, - oidcConfig: providerConfig.oidcConfig - ? { - ...providerConfig.oidcConfig, - clientSecret: REDACTED_MARKER, - } - : undefined, - samlConfig: providerConfig.samlConfig - ? { - ...providerConfig.samlConfig, - cert: REDACTED_MARKER, - } - : undefined, - }, - null, - 2 + { error: getValidationErrorMessage(error, 'Validation failed') }, + { status: 400 } ), - }) - - if (await findProviderIdConflict()) { - logger.warn('Rejected SSO registration: providerId was claimed during registration', { - providerId, - orgId, - userId: session.user.id, - }) - return providerIdConflictResponse() - } - - const domainRefusalBeforeWrite = await findDomainRefusal() - if (domainRefusalBeforeWrite) return domainRefusalBeforeWrite - - // Authoritative verification re-check: the verified row could have been - // removed during OIDC discovery. Re-checking here (not just at handler - // entry) ensures ownership still holds at the moment of the write. - if (!(await isOrgDomainVerified())) { - logger.warn( - 'Rejected SSO registration: domain verification was revoked during registration', - { - domain, - orgId, - userId: session.user.id, - } - ) - return domainNotVerifiedResponse() - } - - // Better Auth's registerSSOProvider is create-only (it throws on an existing - // providerId). If the caller already owns a provider with this id, route the - // edit through updateSSOProvider so re-saving an SSO config works instead of - // failing. The verification gate above already ran against the target domain, - // so an edit that moves SSO to an unverified domain is still blocked. - // Config columns are captured, not just the id: an update whose trust grant is - // refused has to be undone, or the rejected config stays stored and goes live - // the moment the domain is verified again. - const [existingOwnedProvider] = await db - .select({ - id: ssoProvider.id, - issuer: ssoProvider.issuer, - domain: ssoProvider.domain, - domainVerified: ssoProvider.domainVerified, - oidcConfig: ssoProvider.oidcConfig, - samlConfig: ssoProvider.samlConfig, - jitProvisioningEnabled: ssoProvider.jitProvisioningEnabled, - }) - .from(ssoProvider) - .where(ownerClause) - .limit(1) - - /** - * Grants domain trust only while the proof is held under a row lock. - * - * A WHERE-clause EXISTS test is not enough: under READ COMMITTED the subquery - * sees the statement's original snapshot, so a delete committing while the - * UPDATE waits can still grant trust after ownership is gone. The row lock - * orders the two — the delete blocks until this commits, and if it committed - * first the SELECT finds nothing. - * - * A provider joining a domain another provider already signs in does not - * take over by sorting first: unless the domain's named primary still signs - * it in, the provider signing it in until now is named, in the same - * transaction. The lock is `FOR UPDATE` so two providers joining at once - * settle it one after the other. - */ - const grantProviderDomainTrust = (joinsDomain: boolean): Promise => - db.transaction(async (tx) => { - const [proof] = await tx - .select({ id: ssoDomain.id }) - .from(ssoDomain) - .where(verifiedDomainClause) - .limit(1) - .for('update') - if (!proof) return false - - const granted = await tx - .update(ssoProvider) - .set({ domainVerified: true, jitProvisioningEnabled }) - .where(ownerClause) - .returning({ id: ssoProvider.id }) - if (granted.length === 0) return false - - if (joinsDomain) { - await keepDomainSignInProvider(tx, { - domainRecordId: proof.id, - organizationId: orgId, - domain, - joiningProviderId: providerId, - }) - } - return true - }) - - if (existingOwnedProvider) { - const revertProviderUpdate = async (): Promise => { - await db - .update(ssoProvider) - .set({ - issuer: existingOwnedProvider.issuer, - domain: existingOwnedProvider.domain, - oidcConfig: existingOwnedProvider.oidcConfig, - samlConfig: existingOwnedProvider.samlConfig, - domainVerified: false, - jitProvisioningEnabled: existingOwnedProvider.jitProvisioningEnabled, - }) - .where(eq(ssoProvider.id, existingOwnedProvider.id)) - } - - await auth.api.updateSSOProvider({ - body: { - providerId, - issuer, - domain, - ...(providerConfig.oidcConfig ? { oidcConfig: providerConfig.oidcConfig } : {}), - ...(providerConfig.samlConfig ? { samlConfig: providerConfig.samlConfig } : {}), - }, - headers, - }) - - let domainTrustGranted: boolean - try { - /** An owned provider joins the domain when it moves to it or is not yet trusted on it. */ - domainTrustGranted = await grantProviderDomainTrust( - !existingOwnedProvider.domainVerified || - normalizeSSODomain(existingOwnedProvider.domain) !== domain - ) - } catch (error) { - try { - await revertProviderUpdate() - } catch (rollbackError) { - logger.error('Failed to revert SSO provider after domain trust write failed', { - domain, - orgId, - providerId, - userId: session.user.id, - error, - rollbackError, - }) - } - throw error - } - - // Restore the pre-update config and clear the flag together. Clearing alone - // is not enough: re-verifying the domain now regrants trust automatically, - // which would activate the very config this request reported as rejected. - if (!domainTrustGranted) { - await revertProviderUpdate() - logger.warn('Reverted SSO update: domain verification was removed mid-write', { - domain, - orgId, - providerId, - userId: session.user.id, - }) - return domainNotVerifiedResponse() - } - - /** The edit may have changed whether this provider can satisfy the sign-in requirement. */ - invalidateSsoPolicyCache(orgId) - - recordAudit({ - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.ORGANIZATION_SSO_PROVIDER_UPDATED, - resourceType: AuditResourceType.ORGANIZATION, - resourceId: membership.organizationId, - description: 'Updated organization SSO provider', - metadata: { - organizationId: membership.organizationId, - providerId, - providerType, - domain, - jitProvisioningEnabled, - }, - request, - }) - logger.info('SSO provider updated successfully', { providerId, providerType, domain }) - return NextResponse.json({ - success: true, - providerId, - providerType, - message: `${providerType.toUpperCase()} provider updated successfully`, - }) - } - - const registration = await auth.api.registerSSOProvider({ - body: providerConfig, - headers, - }) - - // A refused grant means the proof vanished mid-write, leaving a provider on a - // domain the org no longer proves — roll it back. Deleted by primary key, not - // providerId, which a concurrent delete+recreate could point at another row. - if (!(await grantProviderDomainTrust(true))) { - // registerSSOProvider spreads the created row's `id` at runtime, but the - // typed return omits it — read it defensively and only delete when it's a - // real id, so a future shape change can't turn the rollback into a silent - // no-op that leaves a provider on an unverified domain. - // double-cast-allowed: Better Auth's return type omits the runtime `id` - const createdRowId = (registration as unknown as { id?: unknown }).id - if (typeof createdRowId === 'string' && createdRowId.length > 0) { - await db - .delete(ssoProvider) - .where(and(eq(ssoProvider.id, createdRowId), eq(ssoProvider.organizationId, orgId))) - logger.warn('Rolled back SSO provider: domain verification revoked mid-registration', { - domain, - orgId, - providerId: registration.providerId, - userId: session.user.id, - }) - } else { - logger.error('Could not roll back SSO provider: registration returned no usable id', { - domain, - orgId, - providerId: registration.providerId, - userId: session.user.id, - }) - } - return domainNotVerifiedResponse() - } - - /** A new provider can make an organization able to require single sign-on again. */ - invalidateSsoPolicyCache(orgId) - - recordAudit({ - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.ORGANIZATION_SSO_PROVIDER_CREATED, - resourceType: AuditResourceType.ORGANIZATION, - resourceId: membership.organizationId, - description: 'Created organization SSO provider', - metadata: { - organizationId: membership.organizationId, - providerId: registration.providerId, - providerType, - domain, - jitProvisioningEnabled, - }, - request, - }) - logger.info('SSO provider registered successfully', { - providerId, - providerType, - domain, - }) - - return NextResponse.json({ - success: true, - providerId: registration.providerId, - providerType, - message: `${providerType.toUpperCase()} provider registered successfully`, - }) - } catch (error) { - logger.error('Failed to save SSO provider', { - error, - errorMessage: getErrorMessage(error, 'Unknown error'), - errorStack: error instanceof Error ? error.stack : undefined, - errorDetails: JSON.stringify(error), - }) - - // Surface Better Auth's own APIError (e.g. a 409 when identity fields change - // while linked accounts exist, or a 404) with its status and message instead - // of a generic 500, so the client shows an actionable error. - const apiError = error as { statusCode?: unknown; body?: { message?: unknown } } - if (typeof apiError.statusCode === 'number' && typeof apiError.body?.message === 'string') { - return NextResponse.json({ error: apiError.body.message }, { status: apiError.statusCode }) - } - - return NextResponse.json( - { - error: 'Failed to save the SSO provider', - details: getErrorMessage(error, 'Unknown error'), - }, - { status: 500 } - ) - } + }, + mapInput: ({ body: { orgId, ...configuration } }) => ({ + organizationId: orgId, + ...configuration, + }), + useCase: saveSsoProvider, + present: ({ providerId, providerType, message }) => ({ + success: true as const, + providerId, + providerType, + message, + }), }) diff --git a/apps/sim/app/api/organizations/[id]/sso-policy/route.ts b/apps/sim/app/api/organizations/[id]/sso-policy/route.ts index 8a97929003a..e4b9c9da77c 100644 --- a/apps/sim/app/api/organizations/[id]/sso-policy/route.ts +++ b/apps/sim/app/api/organizations/[id]/sso-policy/route.ts @@ -8,12 +8,11 @@ import { internalRateLimits, internalSessionAuth, } from '@/lib/api/server/routes' +import { ssoSettingsOperations } from '@/lib/auth/sso/application/operations' import { readSsoRequirement, - readSsoRequirementOperation, type SsoRequirement, setSsoRequirement, - setSsoRequirementOperation, } from '@/lib/auth/sso/application/sso-requirement' const present = (requirement: SsoRequirement) => ({ success: true as const, data: requirement }) @@ -22,7 +21,7 @@ const present = (requirement: SsoRequirement) => ({ success: true as const, data export const GET = defineInternalJsonRoute({ contract: getOrganizationSsoPolicyContract, auth: internalSessionAuth, - operation: readSsoRequirementOperation, + operation: ssoSettingsOperations.readRequirement, rateLimit: internalRateLimits.none({ reason: 'Settings read behind organization membership' }), errorPolicy: internalOrchestrationErrorPolicy, mapInput: ({ params }) => ({ organizationId: params.id }), @@ -34,7 +33,7 @@ export const GET = defineInternalJsonRoute({ export const PUT = defineInternalJsonRoute({ contract: updateOrganizationSsoPolicyContract, auth: internalSessionAuth, - operation: setSsoRequirementOperation, + operation: ssoSettingsOperations.setRequirement, rateLimit: internalRateLimits.user({ bucketName: 'sso-set-requirement' }), errorPolicy: internalOrchestrationErrorPolicy, mapInput: ({ params, body }) => ({ organizationId: params.id, requireSso: body.requireSso }), diff --git a/apps/sim/app/api/v2/credentials/[credentialId]/members/[userId]/route.ts b/apps/sim/app/api/v2/credentials/[credentialId]/members/[userId]/route.ts new file mode 100644 index 00000000000..6cc9efea2f0 --- /dev/null +++ b/apps/sim/app/api/v2/credentials/[credentialId]/members/[userId]/route.ts @@ -0,0 +1,20 @@ +import { v2RemoveCredentialMemberContract } from '@/lib/api/contracts/v2/credentials' +import { + createV2ResourceConcealmentPolicy, + defineV2JsonRoute, + v2ApiKeyAuth, + v2RateLimits, +} from '@/lib/api/server/routes' +import { removeCredentialMemberUseCase } from '@/lib/credentials/application/credential-members' +import { credentialOperations } from '@/lib/credentials/application/operations' + +export const DELETE = defineV2JsonRoute({ + contract: v2RemoveCredentialMemberContract, + operation: credentialOperations.removeMember, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: createV2ResourceConcealmentPolicy({ notFoundMessage: 'Credential not found' }), + mapInput: ({ params, query }) => ({ ...params, assertedWorkspaceId: query.workspaceId }), + useCase: removeCredentialMemberUseCase, + present: ({ targetUserId }) => ({ data: { userId: targetUserId, revoked: true as const } }), +}) diff --git a/apps/sim/app/api/v2/credentials/[credentialId]/members/route.ts b/apps/sim/app/api/v2/credentials/[credentialId]/members/route.ts new file mode 100644 index 00000000000..6f4b4f5bb08 --- /dev/null +++ b/apps/sim/app/api/v2/credentials/[credentialId]/members/route.ts @@ -0,0 +1,71 @@ +import { + v2ListCredentialMembersContract, + v2UpsertCredentialMemberContract, +} from '@/lib/api/contracts/v2/credentials' +import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' +import { + createV2ResourceConcealmentPolicy, + defineV2JsonRoute, + v2ApiKeyAuth, + v2RateLimits, +} from '@/lib/api/server/routes' +import { + listCredentialMembersUseCase, + upsertCredentialMemberUseCase, +} from '@/lib/credentials/application/credential-members' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' + +const errorPolicy = createV2ResourceConcealmentPolicy({ notFoundMessage: 'Credential not found' }) + +export const GET = defineV2JsonRoute({ + contract: v2ListCredentialMembersContract, + operation: credentialOperations.listMembers, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy, + mapInput: ({ params, query }) => ({ + credentialId: params.credentialId, + assertedWorkspaceId: query.workspaceId, + ...query, + cursorKeys: readSortedCursor( + query.cursor, + query.sortBy, + query.sortOrder, + cursorScopeKey(cursorRoute(v2ListCredentialMembersContract, params), { + workspaceId: query.workspaceId, + }) + ), + }), + useCase: listCredentialMembersUseCase, + present: ({ members, nextCursorKeys }, { params, query }) => ({ + data: members.map((member) => ({ + ...member, + joinedAt: member.joinedAt?.toISOString() ?? null, + })), + nextCursor: writeSortedCursor( + nextCursorKeys, + query.sortBy, + query.sortOrder, + cursorScopeKey(cursorRoute(v2ListCredentialMembersContract, params), { + workspaceId: query.workspaceId, + }) + ), + }), +}) + +export const POST = defineV2JsonRoute({ + contract: v2UpsertCredentialMemberContract, + operation: credentialOperations.upsertMember, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy, + mapInput: ({ params, query, body }) => ({ + ...body, + credentialId: params.credentialId, + assertedWorkspaceId: query.workspaceId, + }), + useCase: upsertCredentialMemberUseCase, + present: ({ targetUserId, role, created }) => ({ data: { userId: targetUserId, role, created } }), + statusForResult: ({ created }) => (created ? 201 : 200), +}) diff --git a/apps/sim/app/api/v2/organizations/[organizationId]/domains/[domainId]/route.ts b/apps/sim/app/api/v2/organizations/[organizationId]/domains/[domainId]/route.ts new file mode 100644 index 00000000000..bcebcdce22f --- /dev/null +++ b/apps/sim/app/api/v2/organizations/[organizationId]/domains/[domainId]/route.ts @@ -0,0 +1,16 @@ +import { v2RemoveOrganizationDomainContract } from '@/lib/api/contracts/v2/sso' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2SsoErrorPolicy } from '@/lib/api/server/routes/sso' +import { removeOrganizationDomain } from '@/lib/organizations/application/domain-settings' +import { organizationSecurityOperations } from '@/lib/organizations/application/operations' + +export const DELETE = defineV2JsonRoute({ + contract: v2RemoveOrganizationDomainContract, + operation: organizationSecurityOperations.removeDomain, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2SsoErrorPolicy, + mapInput: ({ params }) => params, + useCase: removeOrganizationDomain, + present: (_result, { params }) => ({ data: { id: params.domainId, deleted: true as const } }), +}) diff --git a/apps/sim/app/api/v2/organizations/[organizationId]/domains/[domainId]/verify/route.ts b/apps/sim/app/api/v2/organizations/[organizationId]/domains/[domainId]/verify/route.ts new file mode 100644 index 00000000000..10108e1d2b6 --- /dev/null +++ b/apps/sim/app/api/v2/organizations/[organizationId]/domains/[domainId]/verify/route.ts @@ -0,0 +1,17 @@ +import { v2VerifyOrganizationDomainContract } from '@/lib/api/contracts/v2/sso' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2SsoErrorPolicy } from '@/lib/api/server/routes/sso' +import { toDomainResponse } from '@/lib/auth/sso/domain-verification' +import { verifyOrganizationDomain } from '@/lib/organizations/application/domain-settings' +import { organizationSecurityOperations } from '@/lib/organizations/application/operations' + +export const POST = defineV2JsonRoute({ + contract: v2VerifyOrganizationDomainContract, + operation: organizationSecurityOperations.verifyDomain, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2SsoErrorPolicy, + mapInput: ({ params }) => params, + useCase: verifyOrganizationDomain, + present: ({ domain }) => ({ data: toDomainResponse(domain) }), +}) diff --git a/apps/sim/app/api/v2/organizations/[organizationId]/domains/route.ts b/apps/sim/app/api/v2/organizations/[organizationId]/domains/route.ts new file mode 100644 index 00000000000..6ee3347021b --- /dev/null +++ b/apps/sim/app/api/v2/organizations/[organizationId]/domains/route.ts @@ -0,0 +1,53 @@ +import { + v2AddOrganizationDomainContract, + v2ListOrganizationDomainsContract, +} from '@/lib/api/contracts/v2/sso' +import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2SsoErrorPolicy } from '@/lib/api/server/routes/sso' +import { toDomainResponse } from '@/lib/auth/sso/domain-verification' +import { + addOrganizationDomain, + listOrganizationDomains, +} from '@/lib/organizations/application/domain-settings' +import { organizationSecurityOperations } from '@/lib/organizations/application/operations' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' + +export const GET = defineV2JsonRoute({ + contract: v2ListOrganizationDomainsContract, + operation: organizationSecurityOperations.listDomains, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2SsoErrorPolicy, + mapInput: ({ params, query }) => ({ + ...params, + ...query, + cursorKeys: readSortedCursor( + query.cursor, + query.sortBy, + query.sortOrder, + cursorScopeKey(cursorRoute(v2ListOrganizationDomainsContract, params), {}) + ), + }), + useCase: listOrganizationDomains, + present: ({ domains, nextCursorKeys }, { params, query }) => ({ + data: domains.map((domain) => toDomainResponse(domain)), + nextCursor: writeSortedCursor( + nextCursorKeys, + query.sortBy, + query.sortOrder, + cursorScopeKey(cursorRoute(v2ListOrganizationDomainsContract, params), {}) + ), + }), +}) +export const POST = defineV2JsonRoute({ + contract: v2AddOrganizationDomainContract, + operation: organizationSecurityOperations.addDomain, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2SsoErrorPolicy, + mapInput: ({ params, body }) => ({ ...params, ...body }), + useCase: addOrganizationDomain, + present: ({ domain }) => ({ data: toDomainResponse(domain) }), + statusForResult: ({ created }) => (created ? 201 : 200), +}) diff --git a/apps/sim/app/api/v2/organizations/[organizationId]/sso/policy/route.ts b/apps/sim/app/api/v2/organizations/[organizationId]/sso/policy/route.ts new file mode 100644 index 00000000000..903fdfc2ce1 --- /dev/null +++ b/apps/sim/app/api/v2/organizations/[organizationId]/sso/policy/route.ts @@ -0,0 +1,26 @@ +import { v2GetSsoPolicyContract, v2UpdateSsoPolicyContract } from '@/lib/api/contracts/v2/sso' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2SsoErrorPolicy } from '@/lib/api/server/routes/sso' +import { ssoSettingsOperations } from '@/lib/auth/sso/application/operations' +import { readSsoRequirement, setSsoRequirement } from '@/lib/auth/sso/application/sso-requirement' + +export const GET = defineV2JsonRoute({ + contract: v2GetSsoPolicyContract, + operation: ssoSettingsOperations.readRequirement, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2SsoErrorPolicy, + mapInput: ({ params }) => params, + useCase: readSsoRequirement, + present: (data) => ({ data }), +}) +export const PATCH = defineV2JsonRoute({ + contract: v2UpdateSsoPolicyContract, + operation: ssoSettingsOperations.setRequirement, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2SsoErrorPolicy, + mapInput: ({ params, body }) => ({ ...params, ...body }), + useCase: setSsoRequirement, + present: (data) => ({ data }), +}) diff --git a/apps/sim/app/api/v2/organizations/[organizationId]/sso/providers/[providerId]/primary/route.ts b/apps/sim/app/api/v2/organizations/[organizationId]/sso/providers/[providerId]/primary/route.ts new file mode 100644 index 00000000000..5a484de05ba --- /dev/null +++ b/apps/sim/app/api/v2/organizations/[organizationId]/sso/providers/[providerId]/primary/route.ts @@ -0,0 +1,19 @@ +import { v2SetPrimarySsoProviderContract } from '@/lib/api/contracts/v2/sso' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2SsoErrorPolicy } from '@/lib/api/server/routes/sso' +import { ssoSettingsOperations } from '@/lib/auth/sso/application/operations' +import { setPrimarySsoProvider } from '@/lib/auth/sso/application/set-primary-provider' + +export const POST = defineV2JsonRoute({ + contract: v2SetPrimarySsoProviderContract, + operation: ssoSettingsOperations.setPrimary, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2SsoErrorPolicy, + mapInput: ({ params }) => ({ + providerId: params.providerId, + assertedOrganizationId: params.organizationId, + }), + useCase: setPrimarySsoProvider, + present: ({ providerId, domain }) => ({ data: { providerId, domain } }), +}) diff --git a/apps/sim/app/api/v2/organizations/[organizationId]/sso/providers/[providerId]/route.ts b/apps/sim/app/api/v2/organizations/[organizationId]/sso/providers/[providerId]/route.ts new file mode 100644 index 00000000000..c141cfbc023 --- /dev/null +++ b/apps/sim/app/api/v2/organizations/[organizationId]/sso/providers/[providerId]/route.ts @@ -0,0 +1,27 @@ +import { v2DeleteSsoProviderContract, v2GetSsoProviderContract } from '@/lib/api/contracts/v2/sso' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2SsoErrorPolicy } from '@/lib/api/server/routes/sso' +import { presentSsoProvider } from '@/lib/api/server/sso-presenters' +import { ssoProviderOperations } from '@/lib/auth/sso/application/operations' +import { deleteSsoProvider, getSsoProvider } from '@/lib/auth/sso/application/provider-settings' + +export const GET = defineV2JsonRoute({ + contract: v2GetSsoProviderContract, + operation: ssoProviderOperations.list, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2SsoErrorPolicy, + mapInput: ({ params }) => params, + useCase: getSsoProvider, + present: (provider) => ({ data: presentSsoProvider(provider) }), +}) +export const DELETE = defineV2JsonRoute({ + contract: v2DeleteSsoProviderContract, + operation: ssoProviderOperations.delete, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2SsoErrorPolicy, + mapInput: ({ params }) => params, + useCase: deleteSsoProvider, + present: ({ providerId }) => ({ data: { providerId, deleted: true as const } }), +}) diff --git a/apps/sim/app/api/v2/organizations/[organizationId]/sso/providers/route.ts b/apps/sim/app/api/v2/organizations/[organizationId]/sso/providers/route.ts new file mode 100644 index 00000000000..4086c10114c --- /dev/null +++ b/apps/sim/app/api/v2/organizations/[organizationId]/sso/providers/route.ts @@ -0,0 +1,51 @@ +import { v2ListSsoProvidersContract, v2SaveSsoProviderContract } from '@/lib/api/contracts/v2/sso' +import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2SsoErrorPolicy } from '@/lib/api/server/routes/sso' +import { presentSsoProvider } from '@/lib/api/server/sso-presenters' +import { ssoProviderOperations } from '@/lib/auth/sso/application/operations' +import { saveSsoProvider } from '@/lib/auth/sso/application/provider-registration' +import { listSsoProviders } from '@/lib/auth/sso/application/provider-settings' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' + +export const GET = defineV2JsonRoute({ + contract: v2ListSsoProvidersContract, + operation: ssoProviderOperations.list, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2SsoErrorPolicy, + mapInput: ({ params, query }) => ({ + ...params, + ...query, + cursorKeys: readSortedCursor( + query.cursor, + query.sortBy, + query.sortOrder, + cursorScopeKey(cursorRoute(v2ListSsoProvidersContract, params), {}) + ), + }), + useCase: listSsoProviders, + present: ({ providers, nextCursorKeys }, { params, query }) => ({ + data: providers.map(presentSsoProvider), + nextCursor: writeSortedCursor( + nextCursorKeys, + query.sortBy, + query.sortOrder, + cursorScopeKey(cursorRoute(v2ListSsoProvidersContract, params), {}) + ), + }), +}) +export const POST = defineV2JsonRoute({ + contract: v2SaveSsoProviderContract, + operation: ssoProviderOperations.save, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2SsoErrorPolicy, + parseOptions: { maxBodyBytes: 262144 }, + mapInput: ({ params, body }) => ({ ...params, ...body }), + useCase: saveSsoProvider, + present: ({ providerId, providerType, created }) => ({ + data: { providerId, providerType, created }, + }), + statusForResult: ({ created }) => (created ? 201 : 200), +}) diff --git a/apps/sim/lib/api/contracts/auth.ts b/apps/sim/lib/api/contracts/auth.ts index cdd96d38547..0f65216a72b 100644 --- a/apps/sim/lib/api/contracts/auth.ts +++ b/apps/sim/lib/api/contracts/auth.ts @@ -1,7 +1,7 @@ import { z } from 'zod' -import { organizationIdSchema } from '@/lib/api/contracts/primitives' import type { ContractJsonResponse } from '@/lib/api/contracts/types' import { defineRouteContract } from '@/lib/api/contracts/types' +import { ssoRegistrationInputSchema } from '@/lib/auth/sso/registration-input' export const ssoProvidersQuerySchema = z.object({ organizationId: z.string().min(1).optional(), @@ -14,69 +14,14 @@ export const authProviderStatusResponseSchema = z.object({ registrationDisabled: z.boolean(), }) -const ssoMappingSchema = z - .object({ - id: z.string().default('sub'), - email: z.string().default('email'), - name: z.string().default('name'), - image: z.string().default('picture'), - }) - .default({ - id: 'sub', - email: 'email', - name: 'name', - image: 'picture', - }) - export const ssoRegistrationBodySchema = z.discriminatedUnion('providerType', [ - z.object({ - providerType: z.literal('oidc').default('oidc'), - providerId: z.string().min(1, 'Provider ID is required'), - issuer: z.string().url('Issuer must be a valid URL'), - domain: z.string().min(1, 'Domain is required'), - orgId: organizationIdSchema, - jitProvisioningEnabled: z.boolean().default(true), - mapping: ssoMappingSchema, - clientId: z.string().min(1, 'Client ID is required for OIDC'), - clientSecret: z.string().min(1, 'Client Secret is required for OIDC'), - scopes: z - .union([ - z.string().transform((s) => - s - .split(',') - .map((value) => value.trim()) - .filter((value) => value !== '') - ), - z.array(z.string()), - ]) - .default(['openid', 'profile', 'email']), - pkce: z.boolean().default(true), - authorizationEndpoint: z.string().url().optional(), - tokenEndpoint: z.string().url().optional(), - userInfoEndpoint: z.string().url().optional(), - skipUserInfoEndpoint: z.boolean().default(false), - jwksEndpoint: z.string().url().optional(), + ssoRegistrationInputSchema.options[0].omit({ organizationId: true }).extend({ + orgId: z.string({ error: 'Organization ID is required' }).min(1, 'Organization ID is required'), }), - z.object({ - providerType: z.literal('saml'), - providerId: z.string().min(1, 'Provider ID is required'), - issuer: z.string().url('Issuer must be a valid URL'), - domain: z.string().min(1, 'Domain is required'), - orgId: organizationIdSchema, - jitProvisioningEnabled: z.boolean().default(true), - mapping: ssoMappingSchema, - entryPoint: z.string().url('Entry point must be a valid URL for SAML'), - cert: z.string().min(1, 'Certificate is required for SAML'), - callbackUrl: z.string().url().optional(), - audience: z.string().optional(), - wantAssertionsSigned: z.boolean().optional(), - signatureAlgorithm: z.string().optional(), - digestAlgorithm: z.string().optional(), - identifierFormat: z.string().optional(), - idpMetadata: z.string().optional(), + ssoRegistrationInputSchema.options[1].omit({ organizationId: true }).extend({ + orgId: z.string({ error: 'Organization ID is required' }).min(1, 'Organization ID is required'), }), ]) - export type SsoRegistrationBody = z.input export const ssoRegistrationContract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/v2/credentials.ts b/apps/sim/lib/api/contracts/v2/credentials.ts index b0287708383..1f90952100f 100644 --- a/apps/sim/lib/api/contracts/v2/credentials.ts +++ b/apps/sim/lib/api/contracts/v2/credentials.ts @@ -779,3 +779,105 @@ export const v2DeleteCredentialContract = defineRouteContract({ schema: v2DataResponse(v2CredentialDeleteDataSchema), }, }) + +const v2CredentialMemberParamsSchema = z + .object({ + credentialId: nonEmptyIdSchema + .max(255) + .describe('Credential whose sharing grants are managed.'), + }) + .strict() + +const v2CredentialMemberScopeSchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace expected to own the credential.'), + }) + .strict() + +const v2CredentialMemberSchema = z.object({ + id: nonEmptyIdSchema.describe( + 'Membership identifier; inherited grants have a derived identifier.' + ), + userId: nonEmptyIdSchema.describe('User holding the credential grant.'), + role: workspaceCredentialRoleSchema.describe('Effective credential role.'), + status: z + .enum(['active', 'pending', 'revoked']) + .describe('Explicit grant status, or active for inherited administrators.'), + joinedAt: v2TimestampSchema + .nullable() + .describe('When the explicit grant started; null for an inherited-only grant.'), + userName: z.string().nullable().describe('Member display name.'), + userEmail: z.string().nullable().describe('Member email.'), + userImage: z.string().nullable().describe('Member avatar URL.'), + roleSource: z + .enum(['explicit', 'workspace-admin']) + .describe('Whether workspace administrator access supplies the grant.'), +}) + +const v2ListCredentialMembersQuerySchema = v2CredentialMemberScopeSchema + .extend({ + ...v2PaginationFields({ description: 'Maximum credential members to return per page.' }), + ...v2SortFields(['email', 'name'], { sortBy: 'email', sortOrder: 'asc' }), + }) + .strict() + +export const v2ListCredentialMembersContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/credentials/[credentialId]/members', + params: v2CredentialMemberParamsSchema, + query: v2ListCredentialMembersQuerySchema, + response: { mode: 'json', schema: v2CursorListResponse(v2CredentialMemberSchema) }, +}) + +const v2UpsertCredentialMemberBodySchema = z + .object({ + userId: nonEmptyIdSchema + .max(255) + .describe('Existing workspace member to grant or change access for.'), + role: workspaceCredentialRoleSchema.describe( + 'Credential role to grant; workspace administrators cannot be demoted.' + ), + }) + .strict() + +export const v2UpsertCredentialMemberContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/credentials/[credentialId]/members', + params: v2CredentialMemberParamsSchema, + query: v2CredentialMemberScopeSchema, + body: v2UpsertCredentialMemberBodySchema, + response: { + mode: 'json', + status: [200, 201], + schema: v2DataResponse( + z.object({ + userId: nonEmptyIdSchema.describe('User whose explicit grant was saved.'), + role: workspaceCredentialRoleSchema.describe('Saved explicit credential role.'), + created: z.boolean().describe('Whether a new explicit grant was created.'), + }) + ), + }, +}) + +const v2RemoveCredentialMemberParamsSchema = v2CredentialMemberParamsSchema + .extend({ + userId: nonEmptyIdSchema.max(255).describe('User whose explicit grant will be revoked.'), + }) + .strict() +export const v2RemoveCredentialMemberContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/credentials/[credentialId]/members/[userId]', + params: v2RemoveCredentialMemberParamsSchema, + query: v2CredentialMemberScopeSchema, + response: { + mode: 'json', + schema: v2DataResponse( + z.object({ + userId: nonEmptyIdSchema.describe('User whose explicit grant was revoked.'), + revoked: z + .literal(true) + .describe('The grant is revoked; inherited workspace administrator access is preserved.'), + }) + ), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/list-pagination.test.ts b/apps/sim/lib/api/contracts/v2/list-pagination.test.ts index cce0448bb7b..e594e674224 100644 --- a/apps/sim/lib/api/contracts/v2/list-pagination.test.ts +++ b/apps/sim/lib/api/contracts/v2/list-pagination.test.ts @@ -38,6 +38,9 @@ import { listContractFiles, MAX_SCHEMA_DEPTH } from '@/lib/api/contracts/v2/test /** Lists that accept `limit` + `cursor` and can return a non-null `nextCursor`. */ const PAGED_LISTS = [ + 'GET /api/v2/organizations/[organizationId]/domains', + 'GET /api/v2/credentials/[credentialId]/members', + 'GET /api/v2/organizations/[organizationId]/sso/providers', 'GET /api/v2/organizations/[organizationId]/usage/events', 'GET /api/v2/organizations/[organizationId]/invitations/[invitationId]/workspaces', 'GET /api/v2/organizations/[organizationId]/access-requests', @@ -155,6 +158,9 @@ const FULL_SET_LISTS = [ * therefore fails here until someone decides whether the cursor is bound to it. */ const CURSOR_BINDINGS: Record = { + 'GET /api/v2/credentials/[credentialId]/members': ['workspaceId', 'sortBy', 'sortOrder'], + 'GET /api/v2/organizations/[organizationId]/sso/providers': ['sortBy', 'sortOrder'], + 'GET /api/v2/organizations/[organizationId]/domains': ['sortBy', 'sortOrder'], 'GET /api/v2/organizations/[organizationId]/usage/events': [ 'preset', 'startDate', @@ -358,6 +364,9 @@ const CURSOR_BINDINGS: Record = { * resolves the path before fingerprinting it. */ const CURSOR_BOUND_PATH_PARAMS: Record = { + 'GET /api/v2/credentials/[credentialId]/members': ['credentialId'], + 'GET /api/v2/organizations/[organizationId]/sso/providers': ['organizationId'], + 'GET /api/v2/organizations/[organizationId]/domains': ['organizationId'], 'GET /api/v2/organizations/[organizationId]/invitations/[invitationId]/workspaces': [ 'organizationId', 'invitationId', diff --git a/apps/sim/lib/api/contracts/v2/openapi/credential-members.ts b/apps/sim/lib/api/contracts/v2/openapi/credential-members.ts new file mode 100644 index 00000000000..e83bff53043 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/openapi/credential-members.ts @@ -0,0 +1,117 @@ +import { + v2ListCredentialMembersContract, + v2RemoveCredentialMemberContract, + v2UpsertCredentialMemberContract, +} from '@/lib/api/contracts/v2/credentials' +import { + documentedSchema, + RATE_LIMIT_HEADERS, + RESOURCE_CONFLICT_ERRORS, + WORKSPACE_API_KEY_DENIED, +} from '@/lib/api/contracts/v2/openapi/shared' +import { defineOpenApiRoute } from '@/lib/api/openapi/types' +import { credentialOperations } from '@/lib/credentials/application/operations' +export const credentialMemberOpenApiRoutes = [ + defineOpenApiRoute( + v2ListCredentialMembersContract, + { + applicationOperation: credentialOperations.listMembers, + operationId: 'listCredentialMembers', + summary: 'List Credential Members', + description: `List explicit credential grants, including revoked grants, and inherited workspace administrator access. Requires workspace read access. Credentials must be OAuth or service-account connections. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Credentials'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'List Credential Members result.', headers: RATE_LIMIT_HEADERS }, + }, + { + params: documentedSchema( + v2ListCredentialMembersContract.params, + 'ListCredentialMembersParams', + 'List Credential Members parameters', + 'Resource identifiers.' + ), + query: documentedSchema( + v2ListCredentialMembersContract.query, + 'ListCredentialMembersQuery', + 'Query parameters', + 'Filters and pagination controls.' + ), + response: documentedSchema( + v2ListCredentialMembersContract.response.schema, + 'ListCredentialMembersResponse', + 'List Credential Members response', + 'List Credential Members result.' + ), + } + ), + defineOpenApiRoute( + v2UpsertCredentialMemberContract, + { + applicationOperation: credentialOperations.upsertMember, + operationId: 'upsertCredentialMember', + summary: 'Upsert Credential Member', + description: `Grant or change an existing workspace member’s credential role. Requires credential administrator access. Revoked grants become active again; inherited administrators cannot be demoted. A new grant returns 201; an existing grant returns 200. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Credentials'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'Upsert Credential Member result.', headers: RATE_LIMIT_HEADERS }, + }, + { + params: documentedSchema( + v2UpsertCredentialMemberContract.params, + 'UpsertCredentialMemberParams', + 'Upsert Credential Member parameters', + 'Resource identifiers.' + ), + query: documentedSchema( + v2UpsertCredentialMemberContract.query, + 'UpsertCredentialMemberQuery', + 'Query parameters', + 'Filters and pagination controls.' + ), + body: documentedSchema( + v2UpsertCredentialMemberContract.body, + 'UpsertCredentialMemberBody', + 'Upsert Credential Member body', + 'Configuration accepted by Upsert Credential Member.' + ), + response: documentedSchema( + v2UpsertCredentialMemberContract.response.schema, + 'UpsertCredentialMemberResponse', + 'Upsert Credential Member response', + 'Upsert Credential Member result.' + ), + } + ), + defineOpenApiRoute( + v2RemoveCredentialMemberContract, + { + applicationOperation: credentialOperations.removeMember, + operationId: 'removeCredentialMember', + summary: 'Remove Credential Member', + description: `Revoke an active explicit credential grant. Requires credential administrator access. Inherited workspace administrators cannot be removed; an absent or already-revoked grant returns 404. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Credentials'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'Remove Credential Member result.', headers: RATE_LIMIT_HEADERS }, + }, + { + params: documentedSchema( + v2RemoveCredentialMemberContract.params, + 'RemoveCredentialMemberParams', + 'Remove Credential Member parameters', + 'Resource identifiers.' + ), + query: documentedSchema( + v2RemoveCredentialMemberContract.query, + 'RemoveCredentialMemberQuery', + 'Query parameters', + 'Filters and pagination controls.' + ), + response: documentedSchema( + v2RemoveCredentialMemberContract.response.schema, + 'RemoveCredentialMemberResponse', + 'Remove Credential Member response', + 'Remove Credential Member result.' + ), + } + ), +] as const diff --git a/apps/sim/lib/api/contracts/v2/openapi/resources.ts b/apps/sim/lib/api/contracts/v2/openapi/resources.ts index a3eb1ed67d6..8d1c1beea67 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/resources.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/resources.ts @@ -32,6 +32,7 @@ import { } from '@/lib/api/contracts/v2/mcp-servers' import { v2GetMetaContract } from '@/lib/api/contracts/v2/meta' import { accessRequestOpenApiRoutes } from '@/lib/api/contracts/v2/openapi/access-requests' +import { credentialMemberOpenApiRoutes } from '@/lib/api/contracts/v2/openapi/credential-members' import { organizationUsageOpenApiRoutes } from '@/lib/api/contracts/v2/openapi/organization-usage' import { organizationOpenApiRoutes } from '@/lib/api/contracts/v2/openapi/organizations' import { permissionGroupOpenApiRoutes } from '@/lib/api/contracts/v2/openapi/permission-groups' @@ -51,6 +52,7 @@ import { withErrorExamples, withRequestBodyErrors, } from '@/lib/api/contracts/v2/openapi/shared' +import { ssoOpenApiRoutes } from '@/lib/api/contracts/v2/openapi/sso' import { workspaceInvitationOpenApiRoutes } from '@/lib/api/contracts/v2/openapi/workspace-invitations' import { workspacePermissionOpenApiRoutes } from '@/lib/api/contracts/v2/openapi/workspace-permissions' import { @@ -2165,6 +2167,8 @@ const declaredRoutes = [ ), ...permissionGroupOpenApiRoutes, ...organizationOpenApiRoutes, + ...ssoOpenApiRoutes, + ...credentialMemberOpenApiRoutes, ...workspacePermissionOpenApiRoutes, ...workspaceInvitationOpenApiRoutes, ...organizationUsageOpenApiRoutes, diff --git a/apps/sim/lib/api/contracts/v2/openapi/sso.ts b/apps/sim/lib/api/contracts/v2/openapi/sso.ts new file mode 100644 index 00000000000..f66f4039468 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/openapi/sso.ts @@ -0,0 +1,406 @@ +import { + documentedSchema, + RATE_LIMIT_HEADERS, + RESOURCE_CONFLICT_ERRORS, + WORKSPACE_API_KEY_DENIED, +} from '@/lib/api/contracts/v2/openapi/shared' +import { + v2AddOrganizationDomainContract, + v2DeleteSsoProviderContract, + v2GetSsoPolicyContract, + v2GetSsoProviderContract, + v2ListOrganizationDomainsContract, + v2ListSsoProvidersContract, + v2RemoveOrganizationDomainContract, + v2SaveSsoProviderContract, + v2SetPrimarySsoProviderContract, + v2UpdateSsoPolicyContract, + v2VerifyOrganizationDomainContract, +} from '@/lib/api/contracts/v2/sso' +import { defineOpenApiRoute } from '@/lib/api/openapi/types' +import { ssoProviderOperations, ssoSettingsOperations } from '@/lib/auth/sso/application/operations' +import { organizationSecurityOperations } from '@/lib/organizations/application/operations' +export const ssoOpenApiRoutes = [ + defineOpenApiRoute( + v2ListSsoProvidersContract, + { + applicationOperation: ssoProviderOperations.list, + operationId: 'listSsoProviders', + summary: 'List SSO Providers', + description: `List identity providers owned by the organization. Requires organization administrator access. OIDC client secrets are redacted and SAML private keys are omitted. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Organizations'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'List SSO Providers result.', headers: RATE_LIMIT_HEADERS }, + }, + { + params: documentedSchema( + v2ListSsoProvidersContract.params, + 'ListSsoProvidersParams', + 'List SSO Providers parameters', + 'Resource identifiers.' + ), + query: documentedSchema( + v2ListSsoProvidersContract.query, + 'ListSsoProvidersQuery', + 'Query parameters', + 'Filters and pagination controls.' + ), + response: documentedSchema( + v2ListSsoProvidersContract.response.schema, + 'ListSsoProvidersResponse', + 'List SSO Providers response', + 'List SSO Providers result.' + ), + } + ), + defineOpenApiRoute( + v2GetSsoProviderContract, + { + applicationOperation: ssoProviderOperations.list, + operationId: 'getSsoProvider', + summary: 'Get SSO Provider', + description: `Get an identity provider owned by the organization. Requires organization administrator access. OIDC client secrets are redacted and SAML private keys are omitted. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Organizations'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'Get SSO Provider result.', headers: RATE_LIMIT_HEADERS }, + }, + { + params: documentedSchema( + v2GetSsoProviderContract.params, + 'GetSsoProviderParams', + 'Get SSO Provider parameters', + 'Resource identifiers.' + ), + query: documentedSchema( + v2GetSsoProviderContract.query, + 'GetSsoProviderQuery', + 'Query parameters', + 'Filters and pagination controls.' + ), + response: documentedSchema( + v2GetSsoProviderContract.response.schema, + 'GetSsoProviderResponse', + 'Get SSO Provider response', + 'Get SSO Provider result.' + ), + } + ), + defineOpenApiRoute( + v2SaveSsoProviderContract, + { + applicationOperation: ssoProviderOperations.save, + operationId: 'saveSsoProvider', + summary: 'Save SSO Provider', + description: `Create or update an organization identity provider’s configuration on a verified domain. Requires organization administrator access and SSO entitlement. Existing providers return 200; creation returns 201. Omission behavior is field-specific; OIDC’s redacted secret marker preserves the saved secret. Identity changes with linked accounts conflict. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Organizations'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'Save SSO Provider result.', headers: RATE_LIMIT_HEADERS }, + }, + { + params: documentedSchema( + v2SaveSsoProviderContract.params, + 'SaveSsoProviderParams', + 'Save SSO Provider parameters', + 'Resource identifiers.' + ), + query: documentedSchema( + v2SaveSsoProviderContract.query, + 'SaveSsoProviderQuery', + 'Query parameters', + 'Filters and pagination controls.' + ), + body: documentedSchema( + v2SaveSsoProviderContract.body, + 'SaveSsoProviderBody', + 'Save SSO Provider body', + 'Configuration accepted by Save SSO Provider.' + ), + response: documentedSchema( + v2SaveSsoProviderContract.response.schema, + 'SaveSsoProviderResponse', + 'Save SSO Provider response', + 'Save SSO Provider result.' + ), + } + ), + defineOpenApiRoute( + v2DeleteSsoProviderContract, + { + applicationOperation: ssoProviderOperations.delete, + operationId: 'deleteSsoProvider', + summary: 'Delete SSO Provider', + description: `Remove an identity provider and clear its primary selection. Requires organization administrator access. Existing accounts, memberships, and sessions remain; sign-in falls back to another verified provider on the domain. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Organizations'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'Delete SSO Provider result.', headers: RATE_LIMIT_HEADERS }, + }, + { + params: documentedSchema( + v2DeleteSsoProviderContract.params, + 'DeleteSsoProviderParams', + 'Delete SSO Provider parameters', + 'Resource identifiers.' + ), + query: documentedSchema( + v2DeleteSsoProviderContract.query, + 'DeleteSsoProviderQuery', + 'Query parameters', + 'Filters and pagination controls.' + ), + response: documentedSchema( + v2DeleteSsoProviderContract.response.schema, + 'DeleteSsoProviderResponse', + 'Delete SSO Provider response', + 'Delete SSO Provider result.' + ), + } + ), + defineOpenApiRoute( + v2SetPrimarySsoProviderContract, + { + applicationOperation: ssoSettingsOperations.setPrimary, + operationId: 'setPrimarySsoProvider', + summary: 'Set Primary SSO Provider', + description: `Make a verified organization provider handle sign-in for its domain. Requires organization administrator access. Other providers remain available for testing and later switching. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Organizations'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'Set Primary SSO Provider result.', headers: RATE_LIMIT_HEADERS }, + }, + { + params: documentedSchema( + v2SetPrimarySsoProviderContract.params, + 'SetPrimarySsoProviderParams', + 'Set Primary SSO Provider parameters', + 'Resource identifiers.' + ), + query: documentedSchema( + v2SetPrimarySsoProviderContract.query, + 'SetPrimarySsoProviderQuery', + 'Query parameters', + 'Filters and pagination controls.' + ), + body: documentedSchema( + v2SetPrimarySsoProviderContract.body, + 'SetPrimarySsoProviderBody', + 'Set Primary SSO Provider body', + 'Configuration accepted by Set Primary SSO Provider.' + ), + response: documentedSchema( + v2SetPrimarySsoProviderContract.response.schema, + 'SetPrimarySsoProviderResponse', + 'Set Primary SSO Provider response', + 'Set Primary SSO Provider result.' + ), + } + ), + defineOpenApiRoute( + v2GetSsoPolicyContract, + { + applicationOperation: ssoSettingsOperations.readRequirement, + operationId: 'getSsoPolicy', + summary: 'Get SSO Policy', + description: `Get the stored organization SSO requirement and whether it is currently enforced. Requires organization membership. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Organizations'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'Get SSO Policy result.', headers: RATE_LIMIT_HEADERS }, + }, + { + params: documentedSchema( + v2GetSsoPolicyContract.params, + 'GetSsoPolicyParams', + 'Get SSO Policy parameters', + 'Resource identifiers.' + ), + query: documentedSchema( + v2GetSsoPolicyContract.query, + 'GetSsoPolicyQuery', + 'Query parameters', + 'Filters and pagination controls.' + ), + response: documentedSchema( + v2GetSsoPolicyContract.response.schema, + 'GetSsoPolicyResponse', + 'Get SSO Policy response', + 'Get SSO Policy result.' + ), + } + ), + defineOpenApiRoute( + v2UpdateSsoPolicyContract, + { + applicationOperation: ssoSettingsOperations.setRequirement, + operationId: 'updateSsoPolicy', + summary: 'Update SSO Policy', + description: `Require or stop requiring SSO on future sign-ins. Requires organization administrator access. Enabling requires SSO entitlement and a verified provider; disabling remains available after entitlement is lost. Existing sessions remain active. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Organizations'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'Update SSO Policy result.', headers: RATE_LIMIT_HEADERS }, + }, + { + params: documentedSchema( + v2UpdateSsoPolicyContract.params, + 'UpdateSsoPolicyParams', + 'Update SSO Policy parameters', + 'Resource identifiers.' + ), + query: documentedSchema( + v2UpdateSsoPolicyContract.query, + 'UpdateSsoPolicyQuery', + 'Query parameters', + 'Filters and pagination controls.' + ), + body: documentedSchema( + v2UpdateSsoPolicyContract.body, + 'UpdateSsoPolicyBody', + 'Update SSO Policy body', + 'Configuration accepted by Update SSO Policy.' + ), + response: documentedSchema( + v2UpdateSsoPolicyContract.response.schema, + 'UpdateSsoPolicyResponse', + 'Update SSO Policy response', + 'Update SSO Policy result.' + ), + } + ), + defineOpenApiRoute( + v2ListOrganizationDomainsContract, + { + applicationOperation: organizationSecurityOperations.listDomains, + operationId: 'listOrganizationDomains', + summary: 'List Organization Domains', + description: `List the organization’s domain claims with cursor pagination. Requires organization membership. Pending DNS challenge values are returned only to administrators using their own credentials. Organizations without Enterprise domain entitlement return an empty list. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Organizations'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'List Organization Domains result.', headers: RATE_LIMIT_HEADERS }, + }, + { + params: documentedSchema( + v2ListOrganizationDomainsContract.params, + 'ListOrganizationDomainsParams', + 'List Organization Domains parameters', + 'Resource identifiers.' + ), + query: documentedSchema( + v2ListOrganizationDomainsContract.query, + 'ListOrganizationDomainsQuery', + 'Query parameters', + 'Filters and pagination controls.' + ), + response: documentedSchema( + v2ListOrganizationDomainsContract.response.schema, + 'ListOrganizationDomainsResponse', + 'List Organization Domains response', + 'List Organization Domains result.' + ), + } + ), + defineOpenApiRoute( + v2AddOrganizationDomainContract, + { + applicationOperation: organizationSecurityOperations.addDomain, + operationId: 'addOrganizationDomain', + summary: 'Add Organization Domain', + description: `Claim a domain and receive its DNS TXT challenge. Requires organization administrator access and Enterprise domain entitlement. An existing claim returns 200; a new claim returns 201. A domain verified by another organization conflicts. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Organizations'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'Add Organization Domain result.', headers: RATE_LIMIT_HEADERS }, + }, + { + params: documentedSchema( + v2AddOrganizationDomainContract.params, + 'AddOrganizationDomainParams', + 'Add Organization Domain parameters', + 'Resource identifiers.' + ), + query: documentedSchema( + v2AddOrganizationDomainContract.query, + 'AddOrganizationDomainQuery', + 'Query parameters', + 'Filters and pagination controls.' + ), + body: documentedSchema( + v2AddOrganizationDomainContract.body, + 'AddOrganizationDomainBody', + 'Add Organization Domain body', + 'Configuration accepted by Add Organization Domain.' + ), + response: documentedSchema( + v2AddOrganizationDomainContract.response.schema, + 'AddOrganizationDomainResponse', + 'Add Organization Domain response', + 'Add Organization Domain result.' + ), + } + ), + defineOpenApiRoute( + v2VerifyOrganizationDomainContract, + { + applicationOperation: organizationSecurityOperations.verifyDomain, + operationId: 'verifyOrganizationDomain', + summary: 'Verify Organization Domain', + description: `Verify domain ownership through the published DNS TXT challenge and grant domain trust to matching organization providers. Requires organization administrator access and Enterprise domain entitlement. An already-verified domain is returned unchanged. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Organizations'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'Verify Organization Domain result.', headers: RATE_LIMIT_HEADERS }, + }, + { + params: documentedSchema( + v2VerifyOrganizationDomainContract.params, + 'VerifyOrganizationDomainParams', + 'Verify Organization Domain parameters', + 'Resource identifiers.' + ), + query: documentedSchema( + v2VerifyOrganizationDomainContract.query, + 'VerifyOrganizationDomainQuery', + 'Query parameters', + 'Filters and pagination controls.' + ), + body: documentedSchema( + v2VerifyOrganizationDomainContract.body, + 'VerifyOrganizationDomainBody', + 'Verify Organization Domain body', + 'Configuration accepted by Verify Organization Domain.' + ), + response: documentedSchema( + v2VerifyOrganizationDomainContract.response.schema, + 'VerifyOrganizationDomainResponse', + 'Verify Organization Domain response', + 'Verify Organization Domain result.' + ), + } + ), + defineOpenApiRoute( + v2RemoveOrganizationDomainContract, + { + applicationOperation: organizationSecurityOperations.removeDomain, + operationId: 'removeOrganizationDomain', + summary: 'Remove Organization Domain', + description: `Remove a domain claim and revoke verified sign-in authority from matching organization providers. Requires organization administrator access and Enterprise domain entitlement. Existing accounts and memberships remain. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Organizations'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'Remove Organization Domain result.', headers: RATE_LIMIT_HEADERS }, + }, + { + params: documentedSchema( + v2RemoveOrganizationDomainContract.params, + 'RemoveOrganizationDomainParams', + 'Remove Organization Domain parameters', + 'Resource identifiers.' + ), + query: documentedSchema( + v2RemoveOrganizationDomainContract.query, + 'RemoveOrganizationDomainQuery', + 'Query parameters', + 'Filters and pagination controls.' + ), + response: documentedSchema( + v2RemoveOrganizationDomainContract.response.schema, + 'RemoveOrganizationDomainResponse', + 'Remove Organization Domain response', + 'Remove Organization Domain result.' + ), + } + ), +] as const diff --git a/apps/sim/lib/api/contracts/v2/sso.ts b/apps/sim/lib/api/contracts/v2/sso.ts new file mode 100644 index 00000000000..5f7e2a16aa8 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/sso.ts @@ -0,0 +1,375 @@ +import { z } from 'zod' +import { + noInputSchema, + nonEmptyIdSchema, + organizationIdSchema, +} from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v2CursorListResponse, + v2DataResponse, + v2PaginationFields, + v2SortFields, + v2TimestampSchema, +} from '@/lib/api/contracts/v2/shared' +import { ssoRegistrationInputSchema } from '@/lib/auth/sso/registration-input' +import { addOrganizationDomainBodySchema } from '@/lib/organizations/domain-validation' + +const v2SsoOrganizationParamsSchema = z + .object({ + organizationId: organizationIdSchema.describe( + 'Organization whose single sign-on settings are managed.' + ), + }) + .strict() +const v2SsoProviderParamsSchema = v2SsoOrganizationParamsSchema + .extend({ + providerId: nonEmptyIdSchema.max(255).describe('Identity provider identifier.'), + }) + .strict() + +const v2SsoProviderSchema = z.object({ + id: nonEmptyIdSchema.describe('Provider record identifier.'), + providerId: nonEmptyIdSchema.describe('Globally unique identity provider identifier.'), + providerType: z.enum(['oidc', 'saml']).describe('Identity provider protocol.'), + domain: z.string().describe('Email domain served by the provider.'), + domainKey: z.string().describe('Normalized email domain used for sign-in.'), + issuer: z.string().describe('Identity provider issuer URL.'), + oidcConfig: z + .string() + .nullable() + .describe('JSON configuration with the client secret redacted; null for SAML.'), + samlConfig: z + .string() + .nullable() + .describe('JSON configuration without private keys; null for OIDC.'), + jitProvisioningEnabled: z + .boolean() + .describe( + 'Whether successful SSO sign-in may add organization members, subject to membership and seat policies.' + ), + domainVerified: z.boolean().describe('Whether the provider has a verified domain grant.'), + isPrimary: z + .boolean() + .describe('Whether this provider currently handles sign-in for its domain.'), +}) +export type V2SsoProvider = z.output + +const v2ListSsoProvidersQuerySchema = z + .object({ + ...v2PaginationFields({ description: 'Maximum identity providers to return per page.' }), + ...v2SortFields(['providerId', 'domain'], { sortBy: 'providerId', sortOrder: 'asc' }), + }) + .strict() +export const v2ListSsoProvidersContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/organizations/[organizationId]/sso/providers', + params: v2SsoOrganizationParamsSchema, + query: v2ListSsoProvidersQuerySchema, + response: { mode: 'json', schema: v2CursorListResponse(v2SsoProviderSchema) }, +}) +export const v2GetSsoProviderContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/organizations/[organizationId]/sso/providers/[providerId]', + params: v2SsoProviderParamsSchema, + query: noInputSchema, + response: { mode: 'json', schema: v2DataResponse(v2SsoProviderSchema) }, +}) + +const mapping = z + .object({ + id: z + .string() + .min(1) + .max(255) + .default('sub') + .describe('Claim holding the stable identity identifier.'), + email: z.string().min(1).max(255).default('email').describe('Claim holding the email address.'), + name: z.string().min(1).max(255).default('name').describe('Claim holding the display name.'), + image: z.string().min(1).max(255).default('picture').describe('Claim holding the avatar URL.'), + }) + .strict() + .default({ id: 'sub', email: 'email', name: 'name', image: 'picture' }) + .describe('Identity-provider claims mapped to user fields.') +const shared = { + providerId: nonEmptyIdSchema + .max(255) + .describe( + 'Globally unique provider ID; saving an existing provider replaces its supplied configuration.' + ), + domain: z + .string() + .trim() + .min(1) + .max(255) + .describe('Email domain already verified by this organization.'), + issuer: z.string().url().max(2048).describe('Identity provider issuer URL.'), + mapping, + jitProvisioningEnabled: z + .boolean() + .default(true) + .describe( + 'Allow SSO sign-in to provision organization membership, subject to eligibility and available seats.' + ), +} +const v2SaveSsoProviderBodySchema = z.discriminatedUnion('providerType', [ + ssoRegistrationInputSchema.options[0] + .omit({ organizationId: true }) + .extend({ + ...shared, + providerType: z.literal('oidc').describe('Configure an OpenID Connect identity provider.'), + clientId: z.string().min(1).max(1024).describe('Identity provider client identifier.'), + clientSecret: z + .string() + .min(1) + .max(8192) + .describe( + 'Write-only client secret; the redacted marker from Get SSO Provider preserves an existing secret.' + ) + .meta({ writeOnly: true }), + scopes: z + .array(z.string().trim().min(1).max(255)) + .max(50) + .default(['openid', 'profile', 'email']) + .describe('OIDC scopes; offline_access is omitted.'), + pkce: z.boolean().default(true).describe('Use PKCE for the authorization flow.'), + skipUserInfoEndpoint: z + .boolean() + .default(false) + .describe('Read identity claims from the ID token instead of calling UserInfo.'), + authorizationEndpoint: z + .string() + .url() + .max(2048) + .optional() + .describe('Optional authorization endpoint; otherwise resolved through issuer discovery.'), + tokenEndpoint: z + .string() + .url() + .max(2048) + .optional() + .describe('Optional token endpoint; otherwise resolved through issuer discovery.'), + userInfoEndpoint: z + .string() + .url() + .max(2048) + .optional() + .describe('Optional UserInfo endpoint.'), + jwksEndpoint: z + .string() + .url() + .max(2048) + .optional() + .describe('Optional signing-key endpoint; otherwise resolved through issuer discovery.'), + }) + .strict(), + ssoRegistrationInputSchema.options[1] + .omit({ organizationId: true }) + .extend({ + ...shared, + providerType: z.literal('saml').describe('Configure a SAML identity provider.'), + entryPoint: z.string().url().max(2048).describe('Identity provider SAML sign-in endpoint.'), + cert: z.string().min(1).max(65_536).describe('Identity provider signing certificate.'), + callbackUrl: z + .string() + .url() + .max(2048) + .optional() + .describe('SAML callback URL; defaults to this provider’s Sim callback.'), + audience: z + .string() + .max(2048) + .optional() + .describe('SAML audience; omission preserves the saved value.'), + wantAssertionsSigned: z + .boolean() + .optional() + .describe('Require signed assertions; omission preserves the saved value.'), + signatureAlgorithm: z + .string() + .max(255) + .optional() + .describe( + 'Signature algorithm accepted by the SAML configuration validator; omission preserves the saved value.' + ), + digestAlgorithm: z + .string() + .max(255) + .optional() + .describe( + 'Digest algorithm accepted by the SAML configuration validator; omission preserves the saved value.' + ), + identifierFormat: z + .string() + .max(2048) + .optional() + .describe('SAML NameID format; omission clears the saved value.'), + idpMetadata: z + .string() + .max(102_400) + .optional() + .describe('Identity provider metadata XML; omission clears the saved document.'), + }) + .strict(), +]) +export const v2SaveSsoProviderContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/organizations/[organizationId]/sso/providers', + params: v2SsoOrganizationParamsSchema, + query: noInputSchema, + body: v2SaveSsoProviderBodySchema, + response: { + mode: 'json', + status: [200, 201], + schema: v2DataResponse( + z.object({ + providerId: nonEmptyIdSchema.describe('Saved provider identifier.'), + providerType: z.enum(['oidc', 'saml']).describe('Saved identity provider protocol.'), + created: z.boolean().describe('Whether a new provider was created.'), + }) + ), + }, +}) +export const v2DeleteSsoProviderContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/organizations/[organizationId]/sso/providers/[providerId]', + params: v2SsoProviderParamsSchema, + query: noInputSchema, + response: { + mode: 'json', + schema: v2DataResponse( + z.object({ + providerId: nonEmptyIdSchema.describe('Removed provider identifier.'), + deleted: z + .literal(true) + .describe('The provider was removed; existing accounts and memberships remain.'), + }) + ), + }, +}) +export const v2SetPrimarySsoProviderContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/organizations/[organizationId]/sso/providers/[providerId]/primary', + params: v2SsoProviderParamsSchema, + query: noInputSchema, + body: noInputSchema, + response: { + mode: 'json', + schema: v2DataResponse( + z.object({ + providerId: nonEmptyIdSchema.describe('Provider selected for domain sign-in.'), + domain: z.string().describe('Verified domain whose primary provider changed.'), + }) + ), + }, +}) + +const v2SsoPolicySchema = z.object({ + requireSso: z.boolean().describe('Stored single sign-on requirement.'), + hasVerifiedProvider: z + .boolean() + .describe('Whether a verified provider can satisfy the requirement.'), + isEnforced: z.boolean().describe('Whether sign-in currently enforces the requirement.'), +}) +export const v2GetSsoPolicyContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/organizations/[organizationId]/sso/policy', + params: v2SsoOrganizationParamsSchema, + query: noInputSchema, + response: { mode: 'json', schema: v2DataResponse(v2SsoPolicySchema) }, +}) +const v2UpdateSsoPolicyBodySchema = z + .object({ + requireSso: z + .boolean() + .describe('Require organization SSO on future sign-ins; existing sessions remain active.'), + }) + .strict() +export const v2UpdateSsoPolicyContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/organizations/[organizationId]/sso/policy', + params: v2SsoOrganizationParamsSchema, + query: noInputSchema, + body: v2UpdateSsoPolicyBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2SsoPolicySchema) }, +}) + +const v2OrganizationDomainSchema = z.object({ + id: nonEmptyIdSchema.describe('Domain claim identifier.'), + domain: z.string().describe('Normalized email domain.'), + status: z.enum(['pending', 'verified']).describe('DNS ownership verification state.'), + verifiedAt: v2TimestampSchema.nullable().describe('When domain ownership was verified.'), + challengeHost: z + .string() + .describe('DNS host where the verification TXT record must be published.'), + txtRecordValue: z + .string() + .nullable() + .describe('TXT record value for a pending domain; only administrators receive it.'), +}) +const v2OrganizationDomainParamsSchema = v2SsoOrganizationParamsSchema + .extend({ + domainId: nonEmptyIdSchema.max(255).describe('Domain claim owned by this organization.'), + }) + .strict() +export const v2ListOrganizationDomainsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/organizations/[organizationId]/domains', + params: v2SsoOrganizationParamsSchema, + query: z + .object({ + ...v2PaginationFields({ description: 'Maximum domain claims to return per page.' }), + ...v2SortFields(['domain'], { sortBy: 'domain', sortOrder: 'asc' }), + }) + .strict(), + response: { + mode: 'json', + schema: v2CursorListResponse(v2OrganizationDomainSchema), + }, +}) +const v2AddOrganizationDomainBodySchema = addOrganizationDomainBodySchema + .extend({ + domain: addOrganizationDomainBodySchema.shape.domain.describe( + 'Domain to claim and verify through a DNS TXT record.' + ), + }) + .strict() +export const v2AddOrganizationDomainContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/organizations/[organizationId]/domains', + params: v2SsoOrganizationParamsSchema, + query: noInputSchema, + body: v2AddOrganizationDomainBodySchema, + response: { + mode: 'json', + status: [200, 201], + schema: v2DataResponse(v2OrganizationDomainSchema), + }, +}) +export const v2VerifyOrganizationDomainContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/organizations/[organizationId]/domains/[domainId]/verify', + params: v2OrganizationDomainParamsSchema, + query: noInputSchema, + body: noInputSchema, + response: { mode: 'json', schema: v2DataResponse(v2OrganizationDomainSchema) }, +}) +export const v2RemoveOrganizationDomainContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/organizations/[organizationId]/domains/[domainId]', + params: v2OrganizationDomainParamsSchema, + query: noInputSchema, + response: { + mode: 'json', + schema: v2DataResponse( + z.object({ + id: nonEmptyIdSchema.describe('Removed domain claim identifier.'), + deleted: z + .literal(true) + .describe( + 'The claim was removed; providers on this domain lose verified sign-in authority.' + ), + }) + ), + }, +}) diff --git a/apps/sim/lib/api/mcp/generated/v2-operations.ts b/apps/sim/lib/api/mcp/generated/v2-operations.ts index 9d829fdbfde..ae0cb39aa0f 100644 --- a/apps/sim/lib/api/mcp/generated/v2-operations.ts +++ b/apps/sim/lib/api/mcp/generated/v2-operations.ts @@ -45,9 +45,12 @@ import { v2CreateCredentialConnectionContract, v2CreateServiceAccountCredentialContract, v2DeleteCredentialContract, + v2ListCredentialMembersContract, v2ListCredentialProvidersContract, v2ListCredentialsContract, + v2RemoveCredentialMemberContract, v2UpdateCredentialContract, + v2UpsertCredentialMemberContract, } from '@/lib/api/contracts/v2/credentials' import { v2CreateCustomToolContract, @@ -205,6 +208,19 @@ import { v2RevokeSkillEditorContract, v2UpdateSkillContract, } from '@/lib/api/contracts/v2/skills' +import { + v2AddOrganizationDomainContract, + v2DeleteSsoProviderContract, + v2GetSsoPolicyContract, + v2GetSsoProviderContract, + v2ListOrganizationDomainsContract, + v2ListSsoProvidersContract, + v2RemoveOrganizationDomainContract, + v2SaveSsoProviderContract, + v2SetPrimarySsoProviderContract, + v2UpdateSsoPolicyContract, + v2VerifyOrganizationDomainContract, +} from '@/lib/api/contracts/v2/sso' import { v2AddTableColumnContract, v2AddWorkflowGroupContract, @@ -367,6 +383,17 @@ export const V2_MCP_OPERATIONS = { (route) => route.POST ), }, + addOrganizationDomain: { + contract: v2AddOrganizationDomainContract, + summary: 'Add Organization Domain', + description: + 'Claim a domain and receive its DNS TXT challenge. Requires organization administrator access and Enterprise domain entitlement. An existing claim returns 200; a new claim returns 201. A domain verified by another organization conflicts. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/organizations/[organizationId]/domains/route').then( + (route) => route.POST + ), + }, addPermissionGroupMember: { contract: v2AddPermissionGroupMemberContract, summary: 'Add Permission Group Member', @@ -1014,6 +1041,17 @@ export const V2_MCP_OPERATIONS = { workspaceKeyUnsupported: true, handler: () => import('@/app/api/v2/skills/[skillId]/route').then((route) => route.DELETE), }, + deleteSsoProvider: { + contract: v2DeleteSsoProviderContract, + summary: 'Delete SSO Provider', + description: + 'Remove an identity provider and clear its primary selection. Requires organization administrator access. Existing accounts, memberships, and sessions remain; sign-in falls back to another verified provider on the domain. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/organizations/[organizationId]/sso/providers/[providerId]/route').then( + (route) => route.DELETE + ), + }, deleteTable: { contract: v2DeleteTableContract, summary: 'Delete Table', @@ -1437,6 +1475,28 @@ export const V2_MCP_OPERATIONS = { 'Get one workspace or built-in skill, including its full content. Built-in skills are marked read-only.\n\nOAuth scope: `api:read`.', handler: () => import('@/app/api/v2/skills/[skillId]/route').then((route) => route.GET), }, + getSsoPolicy: { + contract: v2GetSsoPolicyContract, + summary: 'Get SSO Policy', + description: + 'Get the stored organization SSO requirement and whether it is currently enforced. Requires organization membership. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/organizations/[organizationId]/sso/policy/route').then( + (route) => route.GET + ), + }, + getSsoProvider: { + contract: v2GetSsoProviderContract, + summary: 'Get SSO Provider', + description: + 'Get an identity provider owned by the organization. Requires organization administrator access. OIDC client secrets are redacted and SAML private keys are omitted. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/organizations/[organizationId]/sso/providers/[providerId]/route').then( + (route) => route.GET + ), + }, getTable: { contract: v2GetTableContract, summary: 'Get Table', @@ -1662,6 +1722,15 @@ export const V2_MCP_OPERATIONS = { 'List knowledge-base connector types with opaque cursors, defaulting to 25 summaries per page: identifier, name, description, and auth mode. `detail=full` adds accepted source configuration fields. Fields with `multi: true` accept `string[]` instead of `string`. A `canonicalParamId` pairs a picker with manual entry for the same configuration key: send exactly one value, keyed by `canonicalParamId` rather than the field’s `id`.\n\nOAuth scope: `api:read`.', handler: () => import('@/app/api/v2/connector-types/route').then((route) => route.GET), }, + listCredentialMembers: { + contract: v2ListCredentialMembersContract, + summary: 'List Credential Members', + description: + 'List explicit credential grants, including revoked grants, and inherited workspace administrator access. Requires workspace read access. Credentials must be OAuth or service-account connections. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/credentials/[credentialId]/members/route').then((route) => route.GET), + }, listCredentialProviders: { contract: v2ListCredentialProvidersContract, summary: 'List Credential Providers', @@ -1834,6 +1903,17 @@ export const V2_MCP_OPERATIONS = { (route) => route.GET ), }, + listOrganizationDomains: { + contract: v2ListOrganizationDomainsContract, + summary: 'List Organization Domains', + description: + 'List the organization’s domain claims with cursor pagination. Requires organization membership. Pending DNS challenge values are returned only to administrators using their own credentials. Organizations without Enterprise domain entitlement return an empty list. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/organizations/[organizationId]/domains/route').then( + (route) => route.GET + ), + }, listOrganizationInvitations: { contract: v2ListOrganizationInvitationsContract, summary: 'List Organization Invitations', @@ -1956,6 +2036,17 @@ export const V2_MCP_OPERATIONS = { 'List workspace and built-in skills with cursor pagination. Built-in skills are read-only. The list omits skill bodies; use Get Skill to read content.\n\nOAuth scope: `api:read`.', handler: () => import('@/app/api/v2/skills/route').then((route) => route.GET), }, + listSsoProviders: { + contract: v2ListSsoProvidersContract, + summary: 'List SSO Providers', + description: + 'List identity providers owned by the organization. Requires organization administrator access. OIDC client secrets are redacted and SAML private keys are omitted. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/organizations/[organizationId]/sso/providers/route').then( + (route) => route.GET + ), + }, listTableDispatches: { contract: v2ListTableDispatchesContract, summary: 'List Run Dispatches', @@ -2249,6 +2340,28 @@ export const V2_MCP_OPERATIONS = { 'Rename or move a workflow folder and update all descendant paths. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:write`.', handler: () => import('@/app/api/v2/workflows/folders/route').then((route) => route.PATCH), }, + removeCredentialMember: { + contract: v2RemoveCredentialMemberContract, + summary: 'Remove Credential Member', + description: + 'Revoke an active explicit credential grant. Requires credential administrator access. Inherited workspace administrators cannot be removed; an absent or already-revoked grant returns 404. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/credentials/[credentialId]/members/[userId]/route').then( + (route) => route.DELETE + ), + }, + removeOrganizationDomain: { + contract: v2RemoveOrganizationDomainContract, + summary: 'Remove Organization Domain', + description: + 'Remove a domain claim and revoke verified sign-in authority from matching organization providers. Requires organization administrator access and Enterprise domain entitlement. Existing accounts and memberships remain. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/organizations/[organizationId]/domains/[domainId]/route').then( + (route) => route.DELETE + ), + }, removeOrganizationMember: { contract: v2RemoveOrganizationMemberContract, summary: 'Remove Organization Member', @@ -2446,6 +2559,17 @@ export const V2_MCP_OPERATIONS = { (route) => route.POST ), }, + saveSsoProvider: { + contract: v2SaveSsoProviderContract, + summary: 'Save SSO Provider', + description: + 'Create or update an organization identity provider’s configuration on a verified domain. Requires organization administrator access and SSO entitlement. Existing providers return 200; creation returns 201. Omission behavior is field-specific; OIDC’s redacted secret marker preserves the saved secret. Identity changes with linked accounts conflict. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/organizations/[organizationId]/sso/providers/route').then( + (route) => route.POST + ), + }, searchFileContent: { contract: v2SearchFileContentContract, summary: 'Search File Content', @@ -2468,6 +2592,17 @@ export const V2_MCP_OPERATIONS = { handler: () => import('@/app/api/v2/tables/[tableId]/rows/search/route').then((route) => route.POST), }, + setPrimarySsoProvider: { + contract: v2SetPrimarySsoProviderContract, + summary: 'Set Primary SSO Provider', + description: + 'Make a verified organization provider handle sign-in for its domain. Requires organization administrator access. Other providers remain available for testing and later switching. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import( + '@/app/api/v2/organizations/[organizationId]/sso/providers/[providerId]/primary/route' + ).then((route) => route.POST), + }, setSecret: { contract: v2SetSecretContract, summary: 'Set Secret', @@ -2695,6 +2830,17 @@ export const V2_MCP_OPERATIONS = { workspaceKeyUnsupported: true, handler: () => import('@/app/api/v2/skills/[skillId]/route').then((route) => route.PATCH), }, + updateSsoPolicy: { + contract: v2UpdateSsoPolicyContract, + summary: 'Update SSO Policy', + description: + 'Require or stop requiring SSO on future sign-ins. Requires organization administrator access. Enabling requires SSO entitlement and a verified provider; disabling remains available after entitlement is lost. Existing sessions remain active. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/organizations/[organizationId]/sso/policy/route').then( + (route) => route.PATCH + ), + }, updateTable: { contract: v2UpdateTableContract, summary: 'Update Table', @@ -2791,6 +2937,15 @@ export const V2_MCP_OPERATIONS = { (route) => route.PUT ), }, + upsertCredentialMember: { + contract: v2UpsertCredentialMemberContract, + summary: 'Upsert Credential Member', + description: + 'Grant or change an existing workspace member’s credential role. Requires credential administrator access. Revoked grants become active again; inherited administrators cannot be demoted. A new grant returns 201; an existing grant returns 200. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/credentials/[credentialId]/members/route').then((route) => route.POST), + }, upsertFileShare: { contract: v2UpsertFileShareContract, summary: 'Enable or Disable File Share', @@ -2807,6 +2962,17 @@ export const V2_MCP_OPERATIONS = { handler: () => import('@/app/api/v2/tables/[tableId]/rows/upsert/route').then((route) => route.POST), }, + verifyOrganizationDomain: { + contract: v2VerifyOrganizationDomainContract, + summary: 'Verify Organization Domain', + description: + 'Verify domain ownership through the published DNS TXT challenge and grant domain trust to matching organization providers. Requires organization administrator access and Enterprise domain entitlement. An already-verified domain is returned unchanged. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/organizations/[organizationId]/domains/[domainId]/verify/route').then( + (route) => route.POST + ), + }, } as const satisfies Record export type V2McpOperationName = keyof typeof V2_MCP_OPERATIONS diff --git a/apps/sim/lib/api/server/routes/copilot-route-inventory.test.ts b/apps/sim/lib/api/server/routes/copilot-route-inventory.test.ts index 55c235dc1eb..cbdd35762ca 100644 --- a/apps/sim/lib/api/server/routes/copilot-route-inventory.test.ts +++ b/apps/sim/lib/api/server/routes/copilot-route-inventory.test.ts @@ -40,6 +40,24 @@ it('inventories private operation admission without executing route requests', a /** Public organization and version-history operations require a direct caller. */ expect(inventory.filter((route) => !route.audience)).toMatchInlineSnapshot(` [ + { + "audience": null, + "method": "GET", + "operation": "credentials.members.list", + "path": "/api/v2/credentials/[credentialId]/members", + }, + { + "audience": null, + "method": "POST", + "operation": "credentials.members.upsert", + "path": "/api/v2/credentials/[credentialId]/members", + }, + { + "audience": null, + "method": "DELETE", + "operation": "credentials.members.remove", + "path": "/api/v2/credentials/[credentialId]/members/[userId]", + }, { "audience": null, "method": "GET", @@ -268,6 +286,48 @@ it('inventories private operation admission without executing route requests', a "operation": "permission_groups.members.bulk_add", "path": "/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members/bulk", }, + { + "audience": null, + "method": "GET", + "operation": "organization.sso.read_requirement", + "path": "/api/v2/organizations/[organizationId]/sso/policy", + }, + { + "audience": null, + "method": "PATCH", + "operation": "organization.sso.set_requirement", + "path": "/api/v2/organizations/[organizationId]/sso/policy", + }, + { + "audience": null, + "method": "GET", + "operation": "organization.sso.providers.list", + "path": "/api/v2/organizations/[organizationId]/sso/providers", + }, + { + "audience": null, + "method": "POST", + "operation": "organization.sso.providers.save", + "path": "/api/v2/organizations/[organizationId]/sso/providers", + }, + { + "audience": null, + "method": "GET", + "operation": "organization.sso.providers.list", + "path": "/api/v2/organizations/[organizationId]/sso/providers/[providerId]", + }, + { + "audience": null, + "method": "DELETE", + "operation": "organization.sso.providers.delete", + "path": "/api/v2/organizations/[organizationId]/sso/providers/[providerId]", + }, + { + "audience": null, + "method": "POST", + "operation": "organization.sso.set_primary_provider", + "path": "/api/v2/organizations/[organizationId]/sso/providers/[providerId]/primary", + }, { "audience": null, "method": "GET", diff --git a/apps/sim/lib/api/server/routes/sso.ts b/apps/sim/lib/api/server/routes/sso.ts new file mode 100644 index 00000000000..71199453439 --- /dev/null +++ b/apps/sim/lib/api/server/routes/sso.ts @@ -0,0 +1,87 @@ +import { APIError } from 'better-auth/api' +import { + extendInternalErrorPolicy, + internalErrorResponse, + internalOrchestrationErrorPolicy, +} from '@/lib/api/server/routes/internal-json-route' +import { v2OrganizationErrorPolicy } from '@/lib/api/server/routes/organizations' +import type { V2ErrorPolicy } from '@/lib/api/server/routes/v2-json-route' +import { SsoProviderSettingsError } from '@/lib/auth/sso/application/provider-registration' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' +import { OrganizationMembershipNotFoundError } from '@/lib/core/application/organization-authorization' +import { DomainVerificationLookupError } from '@/lib/organizations/application/domain-settings' +import { v2Error } from '@/app/api/v2/lib/response' + +/** Preserves actionable domain verification and identity-provider validation failures. */ +export const v2SsoErrorPolicy: V2ErrorPolicy = { + render(error) { + if (error instanceof APIError) { + if (error.statusCode >= 500) + return v2Error( + 'SERVICE_UNAVAILABLE', + 'Identity provider settings are temporarily unavailable' + ) + if (error.statusCode === 409) + return v2Error( + 'CONFLICT', + error.body?.message ?? 'Identity provider configuration conflicts' + ) + if (error.statusCode === 403) + return v2Error( + 'FORBIDDEN', + error.body?.message ?? 'Identity provider configuration is not permitted', + { + details: { + code: + error.body?.message === 'You have reached the maximum number of SSO providers' || + error.body?.message === 'SSO provider registration is disabled' + ? 'SSO_PROVIDER_LIMIT_REACHED' + : 'ORGANIZATION_ADMIN_REQUIRED', + }, + } + ) + if (error.statusCode === 404) return v2Error('NOT_FOUND', 'Provider not found') + return v2Error( + 'BAD_REQUEST', + error.body?.message ?? 'Invalid identity provider configuration' + ) + } + if (error instanceof DomainVerificationLookupError) { + return error.status === 503 + ? v2Error('SERVICE_UNAVAILABLE', 'DNS verification is temporarily unavailable') + : v2Error('BAD_REQUEST', error.message) + } + if (error instanceof SsoProviderSettingsError) { + return v2Error( + error.status === 409 ? 'CONFLICT' : error.status === 403 ? 'FORBIDDEN' : 'BAD_REQUEST', + error.message, + error.reason ? { details: { code: error.reason } } : undefined + ) + } + return v2OrganizationErrorPolicy.render(error) + }, +} + +/** Retains the settings UI's actionable SSO error codes and provider validation messages. */ +export const internalSsoErrorPolicy = extendInternalErrorPolicy( + internalOrchestrationErrorPolicy, + (error) => { + if (error instanceof OrganizationMembershipNotFoundError) + return internalErrorResponse(403, { error: 'Forbidden' }) + if (error instanceof ForbiddenOperationError) + return internalErrorResponse(403, { + error: error.message, + ...(error.detailCode === 'SSO_DOMAIN_NOT_VERIFIED' ? { code: error.detailCode } : {}), + }) + if (error instanceof SsoProviderSettingsError) + return internalErrorResponse(error.status, { + error: error.message, + ...(error.reason ? { code: error.reason } : {}), + }) + if (error instanceof APIError) + return internalErrorResponse(error.statusCode, { + error: error.body?.message ?? 'Failed to save the SSO provider', + }) + return null + } +) diff --git a/apps/sim/lib/api/server/routes/v2-route-table.generated.ts b/apps/sim/lib/api/server/routes/v2-route-table.generated.ts index cd6ac503cd5..f48e826f28e 100644 --- a/apps/sim/lib/api/server/routes/v2-route-table.generated.ts +++ b/apps/sim/lib/api/server/routes/v2-route-table.generated.ts @@ -54,6 +54,14 @@ export const V2_ROUTES: readonly V2RouteEntry[] = [ pattern: '/api/v2/credentials/{credentialId}', load: () => import('@/app/api/v2/credentials/[credentialId]/route'), }, + { + pattern: '/api/v2/credentials/{credentialId}/members', + load: () => import('@/app/api/v2/credentials/[credentialId]/members/route'), + }, + { + pattern: '/api/v2/credentials/{credentialId}/members/{userId}', + load: () => import('@/app/api/v2/credentials/[credentialId]/members/[userId]/route'), + }, { pattern: '/api/v2/credentials/connections', load: () => import('@/app/api/v2/credentials/connections/route'), @@ -341,6 +349,19 @@ export const V2_ROUTES: readonly V2RouteEntry[] = [ load: () => import('@/app/api/v2/organizations/[organizationId]/access-requests/settings/route'), }, + { + pattern: '/api/v2/organizations/{organizationId}/domains', + load: () => import('@/app/api/v2/organizations/[organizationId]/domains/route'), + }, + { + pattern: '/api/v2/organizations/{organizationId}/domains/{domainId}', + load: () => import('@/app/api/v2/organizations/[organizationId]/domains/[domainId]/route'), + }, + { + pattern: '/api/v2/organizations/{organizationId}/domains/{domainId}/verify', + load: () => + import('@/app/api/v2/organizations/[organizationId]/domains/[domainId]/verify/route'), + }, { pattern: '/api/v2/organizations/{organizationId}/invitations', load: () => import('@/app/api/v2/organizations/[organizationId]/invitations/route'), @@ -405,6 +426,26 @@ export const V2_ROUTES: readonly V2RouteEntry[] = [ '@/app/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members/bulk/route' ), }, + { + pattern: '/api/v2/organizations/{organizationId}/sso/policy', + load: () => import('@/app/api/v2/organizations/[organizationId]/sso/policy/route'), + }, + { + pattern: '/api/v2/organizations/{organizationId}/sso/providers', + load: () => import('@/app/api/v2/organizations/[organizationId]/sso/providers/route'), + }, + { + pattern: '/api/v2/organizations/{organizationId}/sso/providers/{providerId}', + load: () => + import('@/app/api/v2/organizations/[organizationId]/sso/providers/[providerId]/route'), + }, + { + pattern: '/api/v2/organizations/{organizationId}/sso/providers/{providerId}/primary', + load: () => + import( + '@/app/api/v2/organizations/[organizationId]/sso/providers/[providerId]/primary/route' + ), + }, { pattern: '/api/v2/organizations/{organizationId}/usage/breakdown', load: () => import('@/app/api/v2/organizations/[organizationId]/usage/breakdown/route'), diff --git a/apps/sim/lib/api/server/sso-presenters.ts b/apps/sim/lib/api/server/sso-presenters.ts new file mode 100644 index 00000000000..3525d8a5213 --- /dev/null +++ b/apps/sim/lib/api/server/sso-presenters.ts @@ -0,0 +1,99 @@ +import { toStringOrNull } from '@sim/utils/coerce' +import { toRecord } from '@sim/utils/object' +import type { SsoProviderView } from '@/lib/api/contracts/auth' +import type { V2SsoProvider } from '@/lib/api/contracts/v2/sso' +import type { SsoProviderSettings } from '@/lib/auth/sso/application/provider-settings' +import { REDACTED_MARKER } from '@/lib/core/security/redaction' + +const OIDC_FIELDS = [ + 'issuer', + 'clientId', + 'scopes', + 'pkce', + 'authorizationEndpoint', + 'tokenEndpoint', + 'userInfoEndpoint', + 'jwksEndpoint', + 'tokenEndpointAuthentication', + 'mapping', + 'skipDiscovery', + 'discoveryEndpoint', + 'overrideUserInfo', +] as const +const SAML_FIELDS = [ + 'issuer', + 'entryPoint', + 'cert', + 'callbackUrl', + 'spMetadata', + 'idpMetadata', + 'audience', + 'wantAssertionsSigned', + 'signatureAlgorithm', + 'digestAlgorithm', + 'identifierFormat', + 'mapping', +] as const + +function publicConfig( + value: string | null, + fields: readonly string[], + oidc: boolean +): string | null { + if (!value) return null + try { + const record = toRecord(JSON.parse(value)) + const projected: Record = {} + for (const key of fields) { + if (record[key] === undefined) continue + if (key === 'spMetadata' || key === 'idpMetadata') { + const metadata = toRecord(record[key]) + projected[key] = { + metadata: metadata.metadata, + entityID: metadata.entityID, + cert: metadata.cert, + binding: metadata.binding, + singleSignOnService: metadata.singleSignOnService, + isAssertionEncrypted: metadata.isAssertionEncrypted, + } + } else projected[key] = record[key] + } + if (oidc) projected.clientSecret = REDACTED_MARKER + return JSON.stringify(projected) + } catch { + return null + } +} + +/** Projects provider settings without stored OIDC secrets or SAML private keys. */ +export function presentSsoProvider(provider: SsoProviderSettings): V2SsoProvider { + return { + id: provider.id, + providerId: provider.providerId, + providerType: provider.samlConfig ? 'saml' : 'oidc', + domain: provider.domain, + domainKey: provider.domainKey, + issuer: provider.issuer, + domainVerified: provider.domainVerified, + isPrimary: provider.isPrimary, + jitProvisioningEnabled: provider.jitProvisioningEnabled, + oidcConfig: publicConfig(provider.oidcConfig, OIDC_FIELDS, true), + samlConfig: publicConfig(provider.samlConfig, SAML_FIELDS, false), + } +} + +/** Keeps the settings UI's masked secret hint for sufficiently long client secrets. */ +export function presentSsoProviderSettings(provider: SsoProviderSettings): SsoProviderView { + const result = presentSsoProvider(provider) + if (provider.oidcConfig && result.oidcConfig) { + try { + const secret = toStringOrNull(toRecord(JSON.parse(provider.oidcConfig)).clientSecret) + if (secret && secret.length >= 16) + result.oidcConfig = JSON.stringify({ + ...toRecord(JSON.parse(result.oidcConfig)), + clientSecretHint: secret.slice(-4), + }) + } catch {} + } + return { ...result, userId: provider.userId, organizationId: provider.organizationId } +} diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index b6ce68fa447..66e9b27011e 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -1454,7 +1454,7 @@ export const auth = betterAuth({ * Include SSO plugin when enabled. Resolved through `isSsoEnabled` rather * than the raw env var so the `ENTERPRISE_ENABLED` suite switch registers * the plugin too — reading `env.SSO_ENABLED` here would leave the settings - * section visible and `hasSSOAccess` passing while sign-in silently had no + * section visible and SSO entitlement passing while sign-in silently had no * SSO provider behind it. */ ...(isSsoEnabled diff --git a/apps/sim/lib/auth/sso/application/operations.ts b/apps/sim/lib/auth/sso/application/operations.ts index 1d68afdc7fe..74a8d85c011 100644 --- a/apps/sim/lib/auth/sso/application/operations.ts +++ b/apps/sim/lib/auth/sso/application/operations.ts @@ -1,4 +1,5 @@ import { defineOperation } from '@/lib/core/application/operation' +import { defineOrganizationOperation } from '@/lib/core/application/organization-operation' /** * Admits a newly authenticated SSO identity before organization membership @@ -11,3 +12,59 @@ export const ssoJitAdmissionOperation = defineOperation({ principalKinds: ['session'] as const, capability: 'none', }) + +const principals = ['session', 'personal_api_key', 'oauth_access_token'] as const + +export const ssoProviderOperations = { + // permission-group-exempt: organization SSO settings require current owner or administrator membership. + list: defineOrganizationOperation({ + id: 'organization.sso.providers.list', + minimumRole: 'admin', + principalKinds: principals, + oauthScope: 'api:read', + capability: 'none', + }), + // permission-group-exempt: organization SSO settings require current owner or administrator membership and enterprise entitlement. + save: defineOrganizationOperation({ + id: 'organization.sso.providers.save', + minimumRole: 'admin', + principalKinds: principals, + oauthScope: 'api:write', + capability: 'none', + }), + // permission-group-exempt: organization SSO settings require current owner or administrator membership. + delete: defineOrganizationOperation({ + id: 'organization.sso.providers.delete', + minimumRole: 'admin', + principalKinds: principals, + oauthScope: 'api:write', + capability: 'none', + }), +} as const + +export const ssoSettingsOperations = { + // permission-group-exempt: organization SSO settings use current membership, administrator role and entitlement. + readRequirement: defineOrganizationOperation({ + id: 'organization.sso.read_requirement', + oauthScope: 'api:read', + minimumRole: 'member', + principalKinds: ['session', 'personal_api_key', 'oauth_access_token'], + capability: 'none', + }), + // permission-group-exempt: organization SSO settings use current membership, administrator role and entitlement. + setRequirement: defineOrganizationOperation({ + id: 'organization.sso.set_requirement', + oauthScope: 'api:write', + minimumRole: 'admin', + principalKinds: ['session', 'personal_api_key', 'oauth_access_token'], + capability: 'none', + }), + // permission-group-exempt: organization SSO settings use current membership, administrator role and entitlement. + setPrimary: defineOrganizationOperation({ + id: 'organization.sso.set_primary_provider', + oauthScope: 'api:write', + minimumRole: 'admin', + principalKinds: ['session', 'personal_api_key', 'oauth_access_token'], + capability: 'none', + }), +} as const diff --git a/apps/sim/lib/auth/sso/application/provider-registration.ts b/apps/sim/lib/auth/sso/application/provider-registration.ts new file mode 100644 index 00000000000..06165a1af05 --- /dev/null +++ b/apps/sim/lib/auth/sso/application/provider-registration.ts @@ -0,0 +1,807 @@ +import type { Principal } from '@sim/auth/principal' +import { db, ssoDomain, ssoProvider } from '@sim/db' +import { keepDomainSignInProvider, ssoProviderDomainKey } from '@sim/db/sso-primary-provider' +import { createLogger } from '@sim/logger' +import { toStringOrNull } from '@sim/utils/coerce' +import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors' +import { toRecord } from '@sim/utils/object' +import { normalizeSSODomain } from '@sim/utils/sso-domain' +import { and, eq, sql } from 'drizzle-orm' +import { ssoProviderOperations } from '@/lib/auth/sso/application/operations' +import { type SsoProviderConfig, ssoProviderWriter } from '@/lib/auth/sso/provider-adapter' +import type { SsoRegistrationInput } from '@/lib/auth/sso/registration-input' +import { invalidateSsoPolicyCache } from '@/lib/auth/sso-policy' +import { isOrganizationFeatureEntitled } from '@/lib/billing/core/subscription' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' +import { + authorizeOrganizationOperation, + type OrganizationMembershipContext, +} from '@/lib/core/application/organization-authorization' +import { isSsoEnabled } from '@/lib/core/config/env-flags' +import { runWithOutboundOrganization } from '@/lib/core/network/context.server' +import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' +import { REDACTED_MARKER } from '@/lib/core/security/redaction' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { defineOrganizationConfigurationUseCase } from '@/lib/organizations/application/authorized-configuration-use-case' + +const logger = createLogger('SaveSsoProvider') + +export class SsoProviderSettingsError extends OrchestrationError { + constructor( + readonly status: number, + message: string, + readonly reason?: string + ) { + super(status === 409 ? 'conflict' : status === 403 ? 'forbidden' : 'validation', message) + } +} +function failSsoProvider(status: number, message: string, reason?: string): never { + if (status === 403 && reason === 'SSO_DOMAIN_NOT_VERIFIED') { + throw new ForbiddenOperationError('SSO_DOMAIN_NOT_VERIFIED', message) + } + throw new SsoProviderSettingsError(status, message, reason) +} + +type TokenEndpointAuthMethod = 'client_secret_basic' | 'client_secret_post' + +/** + * Prefers client_secret_post over client_secret_basic when an IdP supports both: + * better-auth sends client_secret_basic credentials without URL-encoding per + * RFC 6749 §2.3.1, so a '+' in the client secret is decoded as a space, causing + * invalid_client errors. Matches the same default in register-sso-provider.ts. + */ +function selectTokenEndpointAuthMethod( + supportedMethods: unknown, + existing?: TokenEndpointAuthMethod +): TokenEndpointAuthMethod { + if (existing) return existing + if (!Array.isArray(supportedMethods) || supportedMethods.length === 0) { + return 'client_secret_post' + } + if (supportedMethods.includes('client_secret_post')) return 'client_secret_post' + if (supportedMethods.includes('client_secret_basic')) return 'client_secret_basic' + return 'client_secret_post' +} + +/** + * Proposes a free provider ID by suffixing the domain's first label + * (`azure-ad` + `acme.com` -> `azure-ad-acme`). Callers pass a domain already + * through `normalizeSSODomain`, whose shape guarantees a non-empty first label. + */ +function suggestProviderId(providerId: string, domain: string): string { + return `${providerId}-${domain.split('.')[0]}` +} + +type DiscoveryResult = + | { ok: true; discovery: Record } + | { ok: false; error: string } + +const OIDC_DISCOVERY_TIMEOUT_MS = 10000 + +async function fetchOIDCDiscoveryDocument(discoveryUrl: string): Promise { + const urlValidation = await validateUrlWithDNS( + discoveryUrl, + 'OIDC discovery URL', + 'configuredEndpoint' + ) + if (!urlValidation.isValid) { + return { ok: false, error: urlValidation.error } + } + + try { + const response = await secureFetchWithPinnedIP(discoveryUrl, urlValidation.resolvedIP, { + profile: 'configuredEndpoint', + headers: { Accept: 'application/json' }, + timeout: OIDC_DISCOVERY_TIMEOUT_MS, + }) + if (!response.ok) { + return { ok: false, error: `Discovery request failed with status ${response.status}` } + } + return { ok: true, discovery: (await response.json()) as Record } + } catch (error) { + return { ok: false, error: getErrorMessage(error, 'Unknown error') } + } +} + +export const saveSsoProvider = defineOrganizationConfigurationUseCase({ + operation: ssoProviderOperations.save, + async execute({ + principal, + input, + context, + request, + }: { + principal: Principal + input: SsoRegistrationInput + context: OrganizationMembershipContext + request?: OrchestrationRequestContext + }) { + if (!isSsoEnabled) throw new OrchestrationError('validation', 'SSO is not enabled') + if (!(await isOrganizationFeatureEntitled(context.organizationId, isSsoEnabled))) + throw new ForbiddenOperationError( + 'ENTERPRISE_PLAN_REQUIRED', + 'SSO requires an Enterprise plan' + ) + const body = input + const { + providerId, + issuer, + providerType, + mapping, + organizationId: orgId, + jitProvisioningEnabled, + } = body + + /** + * Always org-scoped: an org-less provider has no `sso_domain` proof, so only + * operators create one, via `packages/db/scripts/register-sso-provider.ts`. + */ + const domain = normalizeSSODomain(body.domain) + if (!domain) { + return failSsoProvider(400, 'Enter a valid domain, for example acme.com') + } + + /** + * Configuring org SSO for a domain requires DNS-proven ownership; without it + * a first-come claim lets any org wire another company's domain to their own + * IdP. Migration 0266 grandfathered existing domains. + */ + const verifiedDomainClause = and( + eq(ssoDomain.organizationId, orgId), + eq(ssoDomain.domain, domain), + eq(ssoDomain.status, 'verified') + ) + + const isOrgDomainVerified = async (): Promise => { + const [verified] = await db + .select({ id: ssoDomain.id }) + .from(ssoDomain) + .where(verifiedDomainClause) + .limit(1) + return Boolean(verified) + } + + const domainNotVerifiedResponse = () => + failSsoProvider( + 403, + `Verify ownership of ${domain} before configuring SSO for it.`, + 'SSO_DOMAIN_NOT_VERIFIED' + ) + + // Fail fast before OIDC discovery; re-checked before the write to close the + // window where the proof is removed while discovery is in flight. + if (!(await isOrgDomainVerified())) return domainNotVerifiedResponse() + + /** + * An org-less provider the caller created counts as theirs, so its claim on + * a domain is not reported as another tenant's. + */ + const isOwnedByCaller = (provider: { + userId: string | null + organizationId: string | null + }): boolean => + provider.organizationId === orgId || + (provider.userId === context.userId && !provider.organizationId) + + const ownerClause = and( + eq(ssoProvider.providerId, providerId), + eq(ssoProvider.organizationId, orgId) + ) + + /** + * Refuses the domain when another tenant has claimed it, or when the caller's + * own personal provider signs it in. The caller's organization may add a + * provider to a domain it already signs in through: the new provider waits, + * reachable by test link, until an admin makes it the domain's primary. + */ + const findDomainRefusal = async (): Promise => { + const claims = await db + .select({ + userId: ssoProvider.userId, + organizationId: ssoProvider.organizationId, + providerId: ssoProvider.providerId, + }) + .from(ssoProvider) + .where(sql`${ssoProviderDomainKey} = ${domain}`) + if (claims.some((provider) => !isOwnedByCaller(provider))) { + logger.warn('Rejected SSO registration for domain owned by another tenant', { + domain, + orgId, + userId: context.userId, + }) + return failSsoProvider( + 409, + 'This domain is already registered for SSO by another organization.', + 'SSO_DOMAIN_ALREADY_REGISTERED' + ) + } + const personal = claims.find( + (provider) => + !provider.organizationId && + typeof provider.providerId === 'string' && + provider.providerId !== providerId + ) + if (personal) { + return failSsoProvider( + 409, + `${domain} already signs in through the provider "${personal.providerId}". Edit that provider, or give this one a different verified domain.`, + 'SSO_DOMAIN_ALREADY_ROUTED' + ) + } + return + } + + /** + * Better Auth treats `providerId` as globally unique, not per-tenant, and + * resolves providers by that column alone. Catching the cross-tenant + * collision here turns its opaque 422 into a 409 naming a free id. + */ + const findProviderIdConflict = async () => + ( + await db + .select({ userId: ssoProvider.userId, organizationId: ssoProvider.organizationId }) + .from(ssoProvider) + .where(eq(ssoProvider.providerId, providerId)) + ).find((provider) => !isOwnedByCaller(provider)) + + const providerIdConflictResponse = () => + failSsoProvider( + 409, + `The provider ID "${providerId}" is already taken by another organization. Provider IDs are global, so pick a unique one — for example "${suggestProviderId(providerId, domain)}". It appears in the redirect URL you register with your identity provider, so choose it before configuring the IdP.`, + 'SSO_PROVIDER_ID_TAKEN' + ) + + if (await findProviderIdConflict()) { + logger.warn('Rejected SSO registration for providerId owned by another tenant', { + providerId, + orgId, + userId: context.userId, + }) + return providerIdConflictResponse() + } + + await findDomainRefusal() + + const writer = await ssoProviderWriter(principal, orgId, request) + + const providerConfig: SsoProviderConfig = { + providerId, + issuer, + domain, + organizationId: orgId, + } + + if (providerType === 'oidc') { + const { + clientId, + clientSecret: rawClientSecret, + scopes, + pkce, + authorizationEndpoint, + tokenEndpoint, + userInfoEndpoint, + skipUserInfoEndpoint, + jwksEndpoint, + } = body + + let clientSecret = rawClientSecret + if (rawClientSecret === REDACTED_MARKER) { + const [existing] = await db + .select({ oidcConfig: ssoProvider.oidcConfig }) + .from(ssoProvider) + .where(ownerClause) + .limit(1) + if (!existing?.oidcConfig) { + return failSsoProvider( + 400, + 'Cannot update: existing provider not found. Re-enter your client secret.' + ) + } + try { + const stored = toRecord(JSON.parse(existing.oidcConfig)) + const secret = toStringOrNull(stored.clientSecret) + if (!secret) return failSsoProvider(400, 'Re-enter your client secret.') + clientSecret = secret + } catch { + return failSsoProvider( + 400, + 'Cannot update: failed to read existing secret. Re-enter your client secret.' + ) + } + } + + const oidcConfig: NonNullable = { + clientId, + clientSecret, + authorizationEndpoint, + tokenEndpoint, + userInfoEndpoint, + jwksEndpoint, + scopes: Array.isArray(scopes) + ? scopes.filter((s: string) => s !== 'offline_access') + : ['openid', 'profile', 'email'].filter((s: string) => s !== 'offline_access'), + pkce: pkce ?? true, + } + + oidcConfig.authorizationEndpoint = authorizationEndpoint + oidcConfig.tokenEndpoint = tokenEndpoint + oidcConfig.userInfoEndpoint = userInfoEndpoint + oidcConfig.jwksEndpoint = jwksEndpoint + + const userProvidedEndpoints: Record = { + authorizationEndpoint, + tokenEndpoint, + jwksEndpoint, + ...(skipUserInfoEndpoint ? {} : { userInfoEndpoint }), + } + + for (const [name, endpointUrl] of Object.entries(userProvidedEndpoints)) { + if (endpointUrl) { + const endpointValidation = await validateUrlWithDNS( + endpointUrl, + `OIDC ${name}`, + 'configuredEndpoint' + ) + if (!endpointValidation.isValid) { + logger.warn('Explicitly provided OIDC endpoint failed SSRF validation', { + endpoint: name, + url: endpointUrl, + error: endpointValidation.error, + }) + return failSsoProvider( + 400, + `OIDC ${name} failed security validation: ${endpointValidation.error}` + ) + } + } + } + + const needsDiscovery = + !oidcConfig.authorizationEndpoint || !oidcConfig.tokenEndpoint || !oidcConfig.jwksEndpoint + + const discoveryUrl = `${issuer.replace(/\/$/, '')}/.well-known/openid-configuration` + const discoveryResult = await runWithOutboundOrganization(context.organizationId, () => + fetchOIDCDiscoveryDocument(discoveryUrl) + ) + + if (needsDiscovery) { + logger.info('Fetching OIDC discovery document for missing endpoints', { + discoveryUrl, + hasAuthEndpoint: !!oidcConfig.authorizationEndpoint, + hasTokenEndpoint: !!oidcConfig.tokenEndpoint, + hasJwksEndpoint: !!oidcConfig.jwksEndpoint, + }) + + if (!discoveryResult.ok) { + logger.error('Failed to fetch OIDC discovery document', { discoveryResult }) + return failSsoProvider( + 400, + `Failed to fetch OIDC discovery document: ${discoveryResult.error}. Provide all endpoints explicitly or verify the issuer URL.` + ) + } + + const { discovery } = discoveryResult + + const discoveredEndpoints: Record = { + authorization_endpoint: discovery.authorization_endpoint, + token_endpoint: discovery.token_endpoint, + jwks_uri: discovery.jwks_uri, + ...(skipUserInfoEndpoint ? {} : { userinfo_endpoint: discovery.userinfo_endpoint }), + } + + for (const [key, value] of Object.entries(discoveredEndpoints)) { + if (typeof value === 'string') { + const endpointValidation = await validateUrlWithDNS( + value, + `OIDC ${key}`, + 'contentFetch' + ) + if (!endpointValidation.isValid) { + logger.warn('OIDC discovered endpoint failed SSRF validation', { + endpoint: key, + url: value, + error: endpointValidation.error, + }) + return failSsoProvider( + 400, + `Discovered OIDC ${key} failed security validation: ${endpointValidation.error}` + ) + } + } + } + + oidcConfig.authorizationEndpoint = + oidcConfig.authorizationEndpoint || + toStringOrNull(discovery.authorization_endpoint) || + undefined + oidcConfig.tokenEndpoint = + oidcConfig.tokenEndpoint || toStringOrNull(discovery.token_endpoint) || undefined + oidcConfig.userInfoEndpoint = + oidcConfig.userInfoEndpoint || toStringOrNull(discovery.userinfo_endpoint) || undefined + oidcConfig.jwksEndpoint = + oidcConfig.jwksEndpoint || toStringOrNull(discovery.jwks_uri) || undefined + oidcConfig.tokenEndpointAuthentication = selectTokenEndpointAuthMethod( + discovery.token_endpoint_auth_methods_supported, + oidcConfig.tokenEndpointAuthentication + ) + + logger.info('Merged OIDC endpoints (user-provided + discovery)', { + providerId, + issuer, + authorizationEndpoint: oidcConfig.authorizationEndpoint, + tokenEndpoint: oidcConfig.tokenEndpoint, + userInfoEndpoint: oidcConfig.userInfoEndpoint, + jwksEndpoint: oidcConfig.jwksEndpoint, + tokenEndpointAuthentication: oidcConfig.tokenEndpointAuthentication, + }) + } else { + logger.info('Using explicitly provided OIDC endpoints (all present)', { + providerId, + issuer, + authorizationEndpoint: oidcConfig.authorizationEndpoint, + tokenEndpoint: oidcConfig.tokenEndpoint, + userInfoEndpoint: oidcConfig.userInfoEndpoint, + jwksEndpoint: oidcConfig.jwksEndpoint, + }) + + if (!discoveryResult.ok) { + logger.info('OIDC discovery unavailable; falling back to the default token auth method', { + providerId, + discoveryUrl, + }) + } + oidcConfig.tokenEndpointAuthentication = selectTokenEndpointAuthMethod( + discoveryResult.ok + ? discoveryResult.discovery.token_endpoint_auth_methods_supported + : undefined, + oidcConfig.tokenEndpointAuthentication + ) + } + + if (skipUserInfoEndpoint) { + oidcConfig.userInfoEndpoint = undefined + logger.info('Skipping UserInfo endpoint for provider, claims will come from the ID token', { + providerId, + }) + } + + if ( + !oidcConfig.authorizationEndpoint || + !oidcConfig.tokenEndpoint || + !oidcConfig.jwksEndpoint + ) { + const missing: string[] = [] + if (!oidcConfig.authorizationEndpoint) missing.push('authorizationEndpoint') + if (!oidcConfig.tokenEndpoint) missing.push('tokenEndpoint') + if (!oidcConfig.jwksEndpoint) missing.push('jwksEndpoint') + + logger.error('Missing required OIDC endpoints after discovery merge', { + missing, + authorizationEndpoint: oidcConfig.authorizationEndpoint, + tokenEndpoint: oidcConfig.tokenEndpoint, + jwksEndpoint: oidcConfig.jwksEndpoint, + }) + return failSsoProvider( + 400, + `Missing required OIDC endpoints: ${missing.join(', ')}. Please provide these explicitly or verify the issuer supports OIDC discovery.` + ) + } + + oidcConfig.skipDiscovery = true + // Better Auth reads the attribute mapping from oidcConfig.mapping, not a + // top-level field — nesting it here is what makes a custom mapping apply. + if (mapping) oidcConfig.mapping = mapping + providerConfig.oidcConfig = oidcConfig + } else if (providerType === 'saml') { + const { + entryPoint, + cert, + callbackUrl, + audience, + wantAssertionsSigned, + signatureAlgorithm, + digestAlgorithm, + identifierFormat, + idpMetadata, + } = body + + const computedCallbackUrl = + callbackUrl || `${getBaseUrl()}/api/auth/sso/saml2/callback/${providerId}` + + const escapeXml = (str: string) => + str.replace(/[<>&"']/g, (c) => { + switch (c) { + case '<': + return '<' + case '>': + return '>' + case '&': + return '&' + case '"': + return '"' + case "'": + return ''' + default: + return c + } + }) + + const spMetadataXml = ` + + + + +` + + const samlConfig: NonNullable = { + entryPoint, + cert, + callbackUrl: computedCallbackUrl, + spMetadata: { + metadata: spMetadataXml, + }, + } + + if (audience) samlConfig.audience = audience + if (wantAssertionsSigned !== undefined) samlConfig.wantAssertionsSigned = wantAssertionsSigned + if (signatureAlgorithm) samlConfig.signatureAlgorithm = signatureAlgorithm + if (digestAlgorithm) samlConfig.digestAlgorithm = digestAlgorithm + + /** + * Always written, empty when unset: Better Auth merges SAML config with + * `??`, so an omitted key keeps whatever was stored and clearing either + * field would never take effect. Both are falsy-guarded downstream. + * + * Metadata must not be generated here — a document built from cert + + * entryPoint outranks the certificate on re-save, silently defeating + * SAML cert rotation. + */ + samlConfig.idpMetadata = { metadata: idpMetadata ?? '' } + samlConfig.identifierFormat = identifierFormat ?? '' + // Better Auth reads the attribute mapping from samlConfig.mapping. + if (mapping) samlConfig.mapping = mapping + + providerConfig.samlConfig = samlConfig + } + + logger.info('Saving SSO provider configuration', { + providerId, + providerType, + domain, + organizationId: orgId, + }) + + if (await findProviderIdConflict()) { + logger.warn('Rejected SSO registration: providerId was claimed during registration', { + providerId, + orgId, + userId: context.userId, + }) + return providerIdConflictResponse() + } + + await findDomainRefusal() + + // Authoritative verification re-check: the verified row could have been + // removed during OIDC discovery. Re-checking here (not just at handler + // entry) ensures ownership still holds at the moment of the write. + if (!(await isOrgDomainVerified())) { + logger.warn( + 'Rejected SSO registration: domain verification was revoked during registration', + { + domain, + orgId, + userId: context.userId, + } + ) + return domainNotVerifiedResponse() + } + + // OIDC discovery may outlive the caller's administrator membership or OAuth grant. + await authorizeOrganizationOperation(principal, ssoProviderOperations.save, { + organizationId: orgId, + }) + + // Better Auth's registerSSOProvider is create-only (it throws on an existing + // providerId). If the caller already owns a provider with this id, route the + // edit through updateSSOProvider so re-saving an SSO config works instead of + // failing. The verification gate above already ran against the target domain, + // so an edit that moves SSO to an unverified domain is still blocked. + // Config columns are captured, not just the id: an update whose trust grant is + // refused has to be undone, or the rejected config stays stored and goes live + // the moment the domain is verified again. + const [existingOwnedProvider] = await db + .select({ + id: ssoProvider.id, + issuer: ssoProvider.issuer, + domain: ssoProvider.domain, + domainVerified: ssoProvider.domainVerified, + oidcConfig: ssoProvider.oidcConfig, + samlConfig: ssoProvider.samlConfig, + jitProvisioningEnabled: ssoProvider.jitProvisioningEnabled, + }) + .from(ssoProvider) + .where(ownerClause) + .limit(1) + + /** + * Grants domain trust only while the proof is held under a row lock. + * + * A WHERE-clause EXISTS test is not enough: under READ COMMITTED the subquery + * sees the statement's original snapshot, so a delete committing while the + * UPDATE waits can still grant trust after ownership is gone. The row lock + * orders the two — the delete blocks until this commits, and if it committed + * first the SELECT finds nothing. + * + * A provider joining a domain another provider already signs in does not + * take over by sorting first: unless the domain's named primary still signs + * it in, the provider signing it in until now is named, in the same + * transaction. The lock is `FOR UPDATE` so two providers joining at once + * settle it one after the other. + */ + const grantProviderDomainTrust = (joinsDomain: boolean, rowId: string): Promise => + db.transaction(async (tx) => { + const [proof] = await tx + .select({ id: ssoDomain.id }) + .from(ssoDomain) + .where(verifiedDomainClause) + .limit(1) + .for('update') + if (!proof) return false + + const granted = await tx + .update(ssoProvider) + .set({ domainVerified: true, jitProvisioningEnabled }) + .where(and(ownerClause, eq(ssoProvider.id, rowId))) + .returning({ id: ssoProvider.id }) + if (granted.length === 0) return false + + if (joinsDomain) { + await keepDomainSignInProvider(tx, { + domainRecordId: proof.id, + organizationId: orgId, + domain, + joiningProviderId: providerId, + }) + } + return true + }) + + if (existingOwnedProvider) { + const revertProviderUpdate = async (): Promise => { + await db + .update(ssoProvider) + .set({ + issuer: existingOwnedProvider.issuer, + domain: existingOwnedProvider.domain, + oidcConfig: existingOwnedProvider.oidcConfig, + samlConfig: existingOwnedProvider.samlConfig, + domainVerified: false, + jitProvisioningEnabled: existingOwnedProvider.jitProvisioningEnabled, + }) + .where(eq(ssoProvider.id, existingOwnedProvider.id)) + } + + await writer.update({ + providerId, + issuer, + domain, + ...(providerConfig.oidcConfig ? { oidcConfig: providerConfig.oidcConfig } : {}), + ...(providerConfig.samlConfig ? { samlConfig: providerConfig.samlConfig } : {}), + }) + + let domainTrustGranted: boolean + try { + domainTrustGranted = await grantProviderDomainTrust( + !existingOwnedProvider.domainVerified || + normalizeSSODomain(existingOwnedProvider.domain) !== domain, + existingOwnedProvider.id + ) + } catch (error) { + try { + await revertProviderUpdate() + } catch (rollbackError) { + logger.error('Failed to revert SSO provider after domain trust write failed', { + domain, + orgId, + providerId, + userId: context.userId, + error, + rollbackError, + }) + } + throw error + } + + // Restore the pre-update config and clear the flag together. Clearing alone + // is not enough: re-verifying the domain now regrants trust automatically, + // which would activate the very config this request reported as rejected. + if (!domainTrustGranted) { + await revertProviderUpdate() + logger.warn('Reverted SSO update: domain verification was removed mid-write', { + domain, + orgId, + providerId, + userId: context.userId, + }) + return domainNotVerifiedResponse() + } + + /** The edit may have changed whether this provider can satisfy the sign-in requirement. */ + invalidateSsoPolicyCache(orgId) + + logger.info('SSO provider updated successfully', { providerId, providerType, domain }) + return { + created: false, + providerId, + providerType, + message: `${providerType.toUpperCase()} provider updated successfully`, + } + } + + const registration = await writer.register(providerConfig).catch((error: unknown) => { + if (getPostgresErrorCode(error) === '23505') + throw new OrchestrationError( + 'conflict', + 'The provider ID was claimed during registration. Reload the providers and retry.' + ) + throw error + }) + + // Better Auth omits the runtime record ID from its type; trust and rollback must bind to that record. + const createdRowId = toStringOrNull(toRecord(registration).id) + if (!createdRowId) throw new Error('SSO registration returned no provider record identifier') + const revertProviderRegistration = () => + db + .delete(ssoProvider) + .where(and(eq(ssoProvider.id, createdRowId), eq(ssoProvider.organizationId, orgId))) + let domainTrustGranted: boolean + try { + domainTrustGranted = await grantProviderDomainTrust(true, createdRowId) + } catch (error) { + try { + await revertProviderRegistration() + } catch (rollbackError) { + logger.error('Failed to remove SSO provider after domain trust write failed', { + domain, + orgId, + providerId, + error, + rollbackError, + }) + } + throw error + } + if (!domainTrustGranted) { + await revertProviderRegistration() + logger.warn('Rolled back SSO provider: domain verification revoked mid-registration', { + domain, + orgId, + providerId: registration.providerId, + userId: context.userId, + }) + return domainNotVerifiedResponse() + } + + /** A new provider can make an organization able to require single sign-on again. */ + invalidateSsoPolicyCache(orgId) + + logger.info('SSO provider registered successfully', { + providerId, + providerType, + domain, + }) + + return { + created: true, + providerId: registration.providerId, + providerType, + message: `${providerType.toUpperCase()} provider registered successfully`, + } + }, +}) diff --git a/apps/sim/lib/auth/sso/application/provider-settings.integration.ts b/apps/sim/lib/auth/sso/application/provider-settings.integration.ts new file mode 100644 index 00000000000..144d25ee2a1 --- /dev/null +++ b/apps/sim/lib/auth/sso/application/provider-settings.integration.ts @@ -0,0 +1,608 @@ +import { envFlagsMock, setEnvFlags } from '@sim/testing/mocks/env-flags.mock' +import { + inputValidationMock, + inputValidationMockFns, +} from '@sim/testing/mocks/input-validation.mock' +import { generateId } from '@sim/utils/id' +import { omit } from '@sim/utils/object' +import { NextRequest } from 'next/server' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/core/config/env-flags', () => envFlagsMock) +vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) + +/** Exercises provider writes, tenant grants, and sign-in routing against real PostgreSQL. */ +describe('Organization SSO administration through API credentials', () => { + const organizationId = generateId() + const userId = generateId() + const outsiderId = generateId() + const suffix = generateId().slice(0, 8) + const domain = `sso-${suffix}.test` + const providerId = `saml-${suffix}` + const apiKeyValue = `sk-sim-${generateId()}` + const principal = { kind: 'personal_api_key' as const, userId, keyId: generateId() } + let runtime: Awaited> + + async function loadRuntime() { + const [{ db }, schema, { and, eq, inArray, sql }, providers, requirements, primary] = + await Promise.all([ + import('@sim/db'), + import('@sim/db/schema'), + import('drizzle-orm'), + import('@/lib/auth/sso/application/provider-settings'), + import('@/lib/auth/sso/application/sso-requirement'), + import('@/lib/auth/sso/application/set-primary-provider'), + ]) + const { saveSsoProvider } = await import('@/lib/auth/sso/application/provider-registration') + const { presentSsoProvider } = await import('@/lib/api/server/sso-presenters') + const domainSettings = await import('@/lib/organizations/application/domain-settings') + const keyCrypto = await import('@/lib/api-key/crypto') + const apiProviders = await import( + '@/app/api/v2/organizations/[organizationId]/sso/providers/route' + ) + return { + db, + schema, + and, + eq, + inArray, + sql, + ...providers, + ...requirements, + ...primary, + saveSsoProvider, + presentSsoProvider, + domainSettings, + keyCrypto, + apiProviders, + } + } + + beforeAll(async () => { + setEnvFlags({ isSsoEnabled: true, isBillingEnabled: false, isOrganizationsEnabled: true }) + runtime = await loadRuntime() + const { db, schema } = runtime + await db.insert(schema.user).values( + [userId, outsiderId].map((id) => ({ + id, + name: id, + email: `${id}@${domain}`, + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + })) + ) + await db.insert(schema.apiKey).values({ + id: principal.keyId, + userId, + name: 'SSO integration', + key: (await runtime.keyCrypto.encryptApiKey(apiKeyValue)).encrypted, + keyHash: runtime.keyCrypto.hashApiKey(apiKeyValue), + type: 'personal', + }) + await db.insert(schema.organization).values({ + id: organizationId, + name: 'SSO test', + slug: organizationId, + createdAt: new Date(), + }) + await db.insert(schema.member).values({ + id: generateId(), + userId, + organizationId, + role: 'owner', + createdAt: new Date(), + }) + await db.insert(schema.ssoDomain).values({ + id: generateId(), + organizationId, + domain, + status: 'verified', + verificationToken: generateId(), + verifiedAt: new Date(), + }) + }, 60_000) + + beforeEach(async () => { + const { db, schema, and, eq } = runtime + await db.delete(schema.ssoProvider).where(eq(schema.ssoProvider.organizationId, organizationId)) + await db + .update(schema.ssoDomain) + .set({ primaryProviderId: null }) + .where(eq(schema.ssoDomain.organizationId, organizationId)) + await db + .delete(schema.ssoDomain) + .where( + and( + eq(schema.ssoDomain.organizationId, organizationId), + eq(schema.ssoDomain.domain, `aaa-pending-${suffix}.test`) + ) + ) + await db + .delete(schema.member) + .where( + and(eq(schema.member.organizationId, organizationId), eq(schema.member.userId, outsiderId)) + ) + await db + .update(schema.organization) + .set({ requireSso: false }) + .where(eq(schema.organization.id, organizationId)) + }) + + afterAll(async () => { + if (!runtime) return + const { db, schema, eq, inArray } = runtime + await db.delete(schema.organization).where(eq(schema.organization.id, organizationId)) + await db.delete(schema.user).where(inArray(schema.user.id, [userId, outsiderId])) + }) + + const config = () => ({ + organizationId, + providerId, + providerType: 'saml' as const, + issuer: `https://idp.${domain}`, + domain, + entryPoint: `https://idp.${domain}/sso`, + cert: 'test signing certificate', + mapping: { id: 'sub', email: 'email', name: 'name', image: 'picture' }, + jitProvisioningEnabled: true, + }) + + const oidcConfig = () => ({ + organizationId, + providerId, + providerType: 'oidc' as const, + issuer: 'https://idp.example.com', + domain, + clientId: 'initial-client', + clientSecret: 'initial-secret', + authorizationEndpoint: 'https://idp.example.com/authorize', + tokenEndpoint: 'https://idp.example.com/token', + jwksEndpoint: 'https://idp.example.com/jwks', + userInfoEndpoint: 'https://idp.example.com/userinfo', + mapping: config().mapping, + scopes: ['openid', 'email'], + pkce: true, + skipUserInfoEndpoint: false, + jitProvisioningEnabled: true, + }) + + it('creates and edits a provider without minting a browser session, then deletes it', async () => { + const { db, schema, eq } = runtime + const created = await runtime.saveSsoProvider.execute({ principal, input: config() }) + expect(created).toMatchObject({ providerId, created: true }) + const edited = await runtime.saveSsoProvider.execute({ + principal, + input: { ...config(), cert: 'rotated signing certificate' }, + }) + expect(edited.created).toBe(false) + const [row] = await db + .select() + .from(schema.ssoProvider) + .where(eq(schema.ssoProvider.providerId, providerId)) + expect(row).toMatchObject({ userId, organizationId, domainVerified: true }) + expect(JSON.parse(row.samlConfig ?? '{}').cert).toBe('rotated signing certificate') + expect( + await db.select().from(schema.session).where(eq(schema.session.userId, userId)) + ).toHaveLength(0) + await runtime.deleteSsoProvider.execute({ principal, input: { organizationId, providerId } }) + expect( + await db + .select() + .from(schema.ssoProvider) + .where(eq(schema.ssoProvider.providerId, providerId)) + ).toHaveLength(0) + }) + + it('refuses unverified domains and conceals organizations from outsiders', async () => { + await expect( + runtime.saveSsoProvider.execute({ + principal, + input: { ...config(), domain: `unverified-${suffix}.test` }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + await expect( + runtime.saveSsoProvider.execute({ + principal: { ...principal, userId: outsiderId }, + input: config(), + }) + ).rejects.toMatchObject({ code: 'not_found' }) + }) + + it('removes a newly registered provider when its domain-trust write fails', async () => { + const { db, schema, sql, eq } = runtime + const constraint = sql.identifier(`sso-trust-failure-${suffix}`) + await db.execute( + sql`ALTER TABLE ${schema.ssoProvider} ADD CONSTRAINT ${constraint} CHECK (NOT domain_verified) NOT VALID` + ) + try { + await expect( + runtime.saveSsoProvider.execute({ principal, input: config() }) + ).rejects.toMatchObject({ cause: { code: '23514' } }) + expect( + await db + .select() + .from(schema.ssoProvider) + .where(eq(schema.ssoProvider.providerId, providerId)) + ).toHaveLength(0) + } finally { + await db.execute(sql`ALTER TABLE ${schema.ssoProvider} DROP CONSTRAINT ${constraint}`) + } + }) + + it('preserves plugin validation and linked-account identity boundaries for API writes', async () => { + await expect( + runtime.saveSsoProvider.execute({ + principal, + input: { ...config(), providerId: 'credential' }, + }) + ).rejects.toMatchObject({ statusCode: 422 }) + await expect( + runtime.saveSsoProvider.execute({ + principal, + input: { ...config(), signatureAlgorithm: 'invalid-algorithm' }, + }) + ).rejects.toMatchObject({ statusCode: 400 }) + await expect( + runtime.saveSsoProvider.execute({ + principal, + input: { ...config(), idpMetadata: 'é'.repeat(60_000) }, + }) + ).rejects.toMatchObject({ statusCode: 400 }) + await runtime.saveSsoProvider.execute({ principal, input: config() }) + const { db, schema, eq } = runtime + const accountId = generateId() + await db.insert(schema.account).values({ + id: accountId, + accountId: 'linked-identity', + providerId, + userId, + createdAt: new Date(), + updatedAt: new Date(), + }) + try { + for (const changes of [ + { issuer: `https://other.${domain}` }, + { entryPoint: `https://other.${domain}/sso` }, + { mapping: { ...config().mapping, id: 'different-subject' } }, + { idpMetadata: '' }, + ]) { + await expect( + runtime.saveSsoProvider.execute({ principal, input: { ...config(), ...changes } }) + ).rejects.toMatchObject({ statusCode: 409 }) + const [row] = await db + .select() + .from(schema.ssoProvider) + .where(eq(schema.ssoProvider.providerId, providerId)) + expect(row.issuer).toBe(config().issuer) + expect(JSON.parse(row.samlConfig ?? '{}')).toMatchObject({ + entryPoint: config().entryPoint, + mapping: { id: 'sub' }, + idpMetadata: { metadata: '' }, + }) + } + await runtime.saveSsoProvider.execute({ + principal, + input: { ...config(), cert: 'rotated linked certificate' }, + }) + const [rotated] = await db + .select() + .from(schema.ssoProvider) + .where(eq(schema.ssoProvider.providerId, providerId)) + expect(JSON.parse(rotated.samlConfig ?? '{}').cert).toBe('rotated linked certificate') + } finally { + await db.delete(schema.account).where(eq(schema.account.id, accountId)) + } + }) + + it('keeps OIDC identity stable for linked accounts while allowing secret rotation and redacted re-saves', async () => { + inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({ + isValid: true, + resolvedIP: '203.0.113.10', + }) + inputValidationMockFns.mockSecureFetchWithPinnedIP.mockRejectedValue( + new Error('Fixture supplies all OIDC endpoints') + ) + const oidc = oidcConfig() + await runtime.saveSsoProvider.execute({ principal, input: oidc }) + const { db, schema, eq } = runtime + const accountId = generateId() + await db.insert(schema.account).values({ + id: accountId, + accountId: 'linked-oidc-identity', + providerId, + userId, + createdAt: new Date(), + updatedAt: new Date(), + }) + try { + for (const changes of [ + { clientId: 'other-client' }, + { jwksEndpoint: 'https://other.example.com/jwks' }, + { tokenEndpoint: 'https://other.example.com/token' }, + { authorizationEndpoint: 'https://other.example.com/authorize' }, + { userInfoEndpoint: 'https://other.example.com/userinfo' }, + { mapping: { ...oidc.mapping, id: 'other-subject' } }, + ]) { + await expect( + runtime.saveSsoProvider.execute({ principal, input: { ...oidc, ...changes } }) + ).rejects.toMatchObject({ statusCode: 409 }) + const [stored] = await db + .select() + .from(schema.ssoProvider) + .where(eq(schema.ssoProvider.providerId, providerId)) + expect(JSON.parse(stored.oidcConfig ?? '{}')).toMatchObject({ + clientId: oidc.clientId, + clientSecret: oidc.clientSecret, + jwksEndpoint: oidc.jwksEndpoint, + tokenEndpoint: oidc.tokenEndpoint, + authorizationEndpoint: oidc.authorizationEndpoint, + userInfoEndpoint: oidc.userInfoEndpoint, + mapping: { id: 'sub' }, + }) + } + await runtime.saveSsoProvider.execute({ + principal, + input: { ...oidc, clientSecret: 'rotated-secret' }, + }) + const provider = await runtime.getSsoProvider.execute({ + principal, + input: { organizationId, providerId }, + }) + const projected = runtime.presentSsoProvider(provider) + expect(JSON.parse(projected.oidcConfig ?? '{}').clientSecret).toBe('[REDACTED]') + await runtime.saveSsoProvider.execute({ + principal, + input: { ...oidc, clientSecret: '[REDACTED]' }, + }) + const [stored] = await db + .select() + .from(schema.ssoProvider) + .where(eq(schema.ssoProvider.providerId, providerId)) + expect(JSON.parse(stored.oidcConfig ?? '{}').clientSecret).toBe('rotated-secret') + } finally { + await db.delete(schema.account).where(eq(schema.account.id, accountId)) + } + }) + + it('refuses a provider write when organization administrator access is revoked during discovery', async () => { + const { db, schema, and, eq } = runtime + const membership = and( + eq(schema.member.organizationId, organizationId), + eq(schema.member.userId, userId) + ) + inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({ + isValid: true, + resolvedIP: '203.0.113.10', + }) + inputValidationMockFns.mockSecureFetchWithPinnedIP.mockImplementation(async () => { + await db.update(schema.member).set({ role: 'member' }).where(membership) + throw new Error('Discovery unavailable after membership changed') + }) + try { + await expect( + runtime.saveSsoProvider.execute({ principal, input: oidcConfig() }) + ).rejects.toMatchObject({ detailCode: 'ORGANIZATION_ADMIN_REQUIRED' }) + expect( + await db + .select() + .from(schema.ssoProvider) + .where(eq(schema.ssoProvider.organizationId, organizationId)) + ).toHaveLength(0) + } finally { + await db.update(schema.member).set({ role: 'owner' }).where(membership) + inputValidationMockFns.mockSecureFetchWithPinnedIP.mockRejectedValue( + new Error('Fixture supplies all OIDC endpoints') + ) + } + }) + + it('enforces the acting user’s provider ceiling and still allows existing configurations to be edited', async () => { + for (let index = 0; index < 10; index++) { + await runtime.saveSsoProvider.execute({ + principal, + input: { ...config(), providerId: `limit-${suffix}-${index}` }, + }) + } + await expect( + runtime.saveSsoProvider.execute({ principal, input: config() }) + ).rejects.toMatchObject({ statusCode: 403 }) + await runtime.saveSsoProvider.execute({ + principal, + input: { ...config(), providerId: `limit-${suffix}-0`, cert: 'rotated at limit' }, + }) + const { db, schema, eq } = runtime + const providers = await db + .select() + .from(schema.ssoProvider) + .where(eq(schema.ssoProvider.organizationId, organizationId)) + expect(providers).toHaveLength(10) + expect( + providers.find((provider) => provider.providerId === `limit-${suffix}-0`)?.samlConfig + ).toContain('rotated at limit') + }) + + it('allows API administration of the primary provider and requirement while rejecting mismatched organization assertions', async () => { + await runtime.saveSsoProvider.execute({ principal, input: config() }) + await runtime.setPrimarySsoProvider.execute({ + principal, + input: { providerId, assertedOrganizationId: organizationId }, + }) + await runtime.setSsoRequirement.execute({ + principal, + input: { organizationId, requireSso: true }, + }) + expect( + await runtime.readSsoRequirement.execute({ principal, input: { organizationId } }) + ).toMatchObject({ requireSso: true, isEnforced: true, hasVerifiedProvider: true }) + await expect( + runtime.setPrimarySsoProvider.execute({ + principal, + input: { providerId, assertedOrganizationId: generateId() }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + }) + + it('keeps primary selection stable across pages and omits nested private keys', async () => { + await runtime.saveSsoProvider.execute({ principal, input: config() }) + await runtime.setPrimarySsoProvider.execute({ + principal, + input: { providerId, assertedOrganizationId: organizationId }, + }) + const otherId = `aaa-${suffix}` + await runtime.saveSsoProvider.execute({ + principal, + input: { ...config(), providerId: otherId }, + }) + const { db, schema, eq } = runtime + await db + .update(schema.ssoProvider) + .set({ + samlConfig: JSON.stringify({ + cert: 'public-certificate', + privateKey: 'root-secret', + decryptionPvk: 'decrypt-secret', + spMetadata: { + metadata: '', + privateKey: 'nested-secret', + privateKeyPass: 'password-secret', + }, + }), + }) + .where(eq(schema.ssoProvider.providerId, otherId)) + const first = await runtime.listSsoProviders.execute({ + principal, + input: { organizationId, limit: 1, sortBy: 'providerId', sortOrder: 'asc' }, + }) + expect(first.providers.map((provider) => [provider.providerId, provider.isPrimary])).toEqual([ + [otherId, false], + ]) + const second = await runtime.listSsoProviders.execute({ + principal, + input: { + organizationId, + limit: 1, + sortBy: 'providerId', + sortOrder: 'asc', + cursorKeys: first.nextCursorKeys ?? undefined, + }, + }) + expect(second.providers.map((provider) => [provider.providerId, provider.isPrimary])).toEqual([ + [providerId, true], + ]) + expect(second.nextCursorKeys).toBeNull() + const presented = runtime.presentSsoProvider(first.providers[0]) + expect(presented.samlConfig).not.toContain('secret') + expect(JSON.parse(presented.samlConfig ?? '{}')).toMatchObject({ + cert: 'public-certificate', + spMetadata: { metadata: '' }, + }) + }) + + it('refuses workspace keys and expired or insufficient OAuth grants before resource lookup', async () => { + const input = { ...config(), organizationId: generateId() } + await expect( + runtime.saveSsoProvider.execute({ + principal: { kind: 'workspace_api_key', keyId: generateId(), workspaceId: generateId() }, + input, + }) + ).rejects.toMatchObject({ detailCode: 'PRINCIPAL_KIND_NOT_PERMITTED' }) + const token = { + kind: 'oauth_access_token' as const, + userId, + tokenId: generateId(), + clientId: 'integration-client', + scopes: ['api:read'], + expiresAt: new Date(Date.now() + 60_000), + } + await expect( + runtime.saveSsoProvider.execute({ principal: token, input }) + ).rejects.toMatchObject({ detailCode: 'INSUFFICIENT_SCOPE' }) + await expect( + runtime.saveSsoProvider.execute({ + principal: { ...token, scopes: ['api:write'], expiresAt: new Date(0) }, + input, + }) + ).rejects.toMatchObject({ code: 'unauthorized' }) + }) + + it('authenticates a real API key and returns the public create and paginated-read envelopes', async () => { + await runtime.saveSsoProvider.execute({ principal, input: config() }) + const publicProviderId = `api-${suffix}` + const url = `http://localhost/api/v2/organizations/${organizationId}/sso/providers` + const context = { params: Promise.resolve({ organizationId }) } + const body = { ...omit(config(), ['organizationId']), providerId: publicProviderId } + const response = await runtime.apiProviders.POST( + new NextRequest(url, { + method: 'POST', + headers: { + 'x-api-key': apiKeyValue, + 'x-forwarded-for': '127.0.0.1', + 'content-type': 'application/json', + }, + body: JSON.stringify(body), + }), + context + ) + expect(response.status).toBe(201) + expect(await response.json()).toEqual({ + data: { providerId: publicProviderId, providerType: 'saml', created: true }, + }) + const page = await runtime.apiProviders.GET( + new NextRequest(`${url}?limit=1`, { + headers: { 'x-api-key': apiKeyValue, 'x-forwarded-for': '127.0.0.1' }, + }), + context + ) + expect(page.status).toBe(200) + const pageBody = await page.json() + expect(pageBody.data).toHaveLength(1) + expect(pageBody.nextCursor).toBeTypeOf('string') + const changedScope = await runtime.apiProviders.GET( + new NextRequest( + `${url}?limit=1&sortOrder=desc&cursor=${encodeURIComponent(pageBody.nextCursor)}`, + { headers: { 'x-api-key': apiKeyValue, 'x-forwarded-for': '127.0.0.1' } } + ), + context + ) + expect(changedScope.status).toBe(400) + expect(await changedScope.json()).toMatchObject({ error: { code: 'BAD_REQUEST' } }) + }) + + it('paginates domain claims and keeps pending DNS challenges restricted to current administrators', async () => { + const { db, schema } = runtime + const pendingDomain = `aaa-pending-${suffix}.test` + await db.insert(schema.ssoDomain).values({ + id: generateId(), + organizationId, + domain: pendingDomain, + status: 'pending', + verificationToken: 'pending-test-challenge', + }) + await db.insert(schema.member).values({ + id: generateId(), + userId: outsiderId, + organizationId, + role: 'member', + createdAt: new Date(), + }) + const input = { organizationId, limit: 1, sortBy: 'domain' as const, sortOrder: 'asc' as const } + const first = await runtime.domainSettings.listOrganizationDomains.execute({ principal, input }) + expect(first.domains).toHaveLength(1) + expect(first.domains[0]).toMatchObject({ + domain: pendingDomain, + verificationToken: 'pending-test-challenge', + }) + const second = await runtime.domainSettings.listOrganizationDomains.execute({ + principal, + input: { ...input, cursorKeys: first.nextCursorKeys ?? undefined }, + }) + expect(second.domains.map((claim) => claim.domain)).toEqual([domain]) + expect(second.nextCursorKeys).toBeNull() + const memberRead = await runtime.domainSettings.listOrganizationDomains.execute({ + principal: { ...principal, userId: outsiderId }, + input, + }) + expect(memberRead.domains[0].verificationToken).toBeNull() + }) +}) diff --git a/apps/sim/lib/auth/sso/application/provider-settings.ts b/apps/sim/lib/auth/sso/application/provider-settings.ts new file mode 100644 index 00000000000..5a661554914 --- /dev/null +++ b/apps/sim/lib/auth/sso/application/provider-settings.ts @@ -0,0 +1,213 @@ +import type { Principal } from '@sim/auth/principal' +import { db, ssoDomain, ssoProvider } from '@sim/db' +import { + forgetPrimaryProvider, + isNamedPrimary, + ssoProviderDomainKey, + verifiedDomainOfProvider, +} from '@sim/db/sso-primary-provider' +import { and, asc, eq, isNull, sql } from 'drizzle-orm' +import { + type CursorKey, + keysetColumns, + keysetPage, + type ListSortOrder, + listOrderBy, + resumeKeyset, + textKey, +} from '@/lib/api/list-query' +import { ssoProviderOperations } from '@/lib/auth/sso/application/operations' +import { markSignInProviders } from '@/lib/auth/sso/primary-provider' +import { invalidateSsoPolicyCache } from '@/lib/auth/sso-policy' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' +import { requireOAuthOperationScope } from '@/lib/core/application/oauth-authorization' +import type { OperationUseCase } from '@/lib/core/application/operation' +import { authorizeOrganizationOperation } from '@/lib/core/application/organization-authorization' +import { PrincipalKindAuthorizationError } from '@/lib/core/application/workspace-authorization' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +interface ProviderScope { + organizationId?: string + providerId?: string +} +interface ProviderListInput extends ProviderScope { + limit?: number + sortBy?: 'providerId' | 'domain' + sortOrder?: ListSortOrder + cursorKeys?: CursorKey[] +} + +function requireProviderPrincipal( + principal: Principal, + operation: typeof ssoProviderOperations.list | typeof ssoProviderOperations.delete +) { + if (!operation.principalKinds.some((kind) => kind === principal.kind)) + throw new PrincipalKindAuthorizationError(principal.kind, operation.id) + requireOAuthOperationScope(principal, operation) +} + +async function loadProviders(principal: Principal, input: ProviderListInput) { + requireProviderPrincipal(principal, ssoProviderOperations.list) + const ownership = input.organizationId + ? eq( + ssoProvider.organizationId, + ( + await authorizeOrganizationOperation(principal, ssoProviderOperations.list, { + organizationId: input.organizationId, + }) + ).organizationId + ) + : principal.kind === 'session' + ? eq(ssoProvider.userId, principal.userId) + : undefined + if (!ownership) throw new OrchestrationError('validation', 'organizationId is required') + + if (input.limit === undefined) { + const rows = await db + .select({ + id: ssoProvider.id, + providerId: ssoProvider.providerId, + domain: ssoProvider.domain, + issuer: ssoProvider.issuer, + oidcConfig: ssoProvider.oidcConfig, + samlConfig: ssoProvider.samlConfig, + userId: ssoProvider.userId, + organizationId: ssoProvider.organizationId, + jitProvisioningEnabled: ssoProvider.jitProvisioningEnabled, + domainVerified: ssoProvider.domainVerified, + domainKey: ssoProviderDomainKey, + isNamedPrimary, + }) + .from(ssoProvider) + .leftJoin(ssoDomain, verifiedDomainOfProvider) + .where(ownership) + .orderBy(asc(ssoProvider.providerId)) + return { providers: markSignInProviders(rows), nextCursorKeys: null } + } + const providers = db + .select({ + id: ssoProvider.id, + providerId: ssoProvider.providerId, + domain: ssoProvider.domain, + issuer: ssoProvider.issuer, + oidcConfig: ssoProvider.oidcConfig, + samlConfig: ssoProvider.samlConfig, + userId: ssoProvider.userId, + organizationId: ssoProvider.organizationId, + jitProvisioningEnabled: ssoProvider.jitProvisioningEnabled, + domainVerified: ssoProvider.domainVerified, + domainKey: ssoProviderDomainKey.as('domain_key'), + isPrimary: + sql`${ssoProvider.domainVerified} and ${ssoProvider.providerId} = first_value(${ssoProvider.providerId}) over ( + partition by ${ssoProviderDomainKey} order by case when ${ssoProvider.domainVerified} and ${isNamedPrimary} then 0 when ${ssoProvider.domainVerified} then 1 else 2 end, ${ssoProvider.providerId} + )`.as('is_primary'), + }) + .from(ssoProvider) + .leftJoin(ssoDomain, verifiedDomainOfProvider) + .where(ownership) + .as('sso_settings') + const sortKeys = [ + input.sortBy === 'domain' + ? textKey(providers.domain, (row: SsoProviderSettings) => row.domain) + : textKey(providers.providerId, (row: SsoProviderSettings) => row.providerId), + textKey(providers.id, (row: SsoProviderSettings) => row.id), + ] + const query = db + .select() + .from(providers) + .where( + and( + input.providerId ? eq(providers.providerId, input.providerId) : undefined, + resumeKeyset(sortKeys, input.cursorKeys, input.sortOrder ?? 'asc') + ) + ) + .orderBy(...listOrderBy(keysetColumns(sortKeys), input.sortOrder ?? 'asc')) + const rows = input.limit === undefined ? await query : await query.limit(input.limit + 1) + const page = keysetPage(sortKeys, rows, input.limit) + return { providers: page.data, nextCursorKeys: page.nextCursorKeys } +} + +export interface SsoProviderSettings { + id: string + providerId: string + domain: string + issuer: string + oidcConfig: string | null + samlConfig: string | null + userId: string + organizationId: string | null + jitProvisioningEnabled: boolean + domainVerified: boolean + domainKey: string + isPrimary: boolean +} + +export const listSsoProviders: OperationUseCase< + typeof ssoProviderOperations.list, + ProviderListInput, + Awaited> +> = { + operation: ssoProviderOperations.list, + execute: ({ principal, input }) => loadProviders(principal, input), +} + +async function executeDeleteProvider( + principal: Principal, + input: { organizationId?: string; providerId: string } +) { + requireProviderPrincipal(principal, ssoProviderOperations.delete) + const [provider] = await db + .select() + .from(ssoProvider) + .where(eq(ssoProvider.providerId, input.providerId)) + .limit(1) + if (!provider || (input.organizationId && provider.organizationId !== input.organizationId)) + throw new OrchestrationError('not_found', 'Provider not found') + if (provider.organizationId) { + await authorizeOrganizationOperation(principal, ssoProviderOperations.delete, { + organizationId: provider.organizationId, + }) + } else if (principal.kind !== 'session' || provider.userId !== principal.userId) { + if (principal.kind === 'session') + throw new ForbiddenOperationError('ORGANIZATION_ADMIN_REQUIRED', 'Forbidden') + throw new OrchestrationError('not_found', 'Provider not found') + } + const ownerClause = provider.organizationId + ? eq(ssoProvider.organizationId, provider.organizationId) + : and(eq(ssoProvider.userId, provider.userId), isNull(ssoProvider.organizationId)) + const removed = await db.transaction(async (tx) => { + const deleted = await tx + .delete(ssoProvider) + .where(and(eq(ssoProvider.id, provider.id), ownerClause)) + .returning({ id: ssoProvider.id }) + if (deleted.length && provider.organizationId) + await forgetPrimaryProvider(tx, provider.organizationId, provider.providerId) + return deleted + }) + if (!removed.length) throw new OrchestrationError('not_found', 'Provider not found') + if (provider.organizationId) invalidateSsoPolicyCache(provider.organizationId) + return { providerId: provider.providerId } +} + +export const deleteSsoProvider: OperationUseCase< + typeof ssoProviderOperations.delete, + { organizationId?: string; providerId: string }, + { providerId: string } +> = { + operation: ssoProviderOperations.delete, + execute: ({ principal, input }) => executeDeleteProvider(principal, input), +} + +export const getSsoProvider: OperationUseCase< + typeof ssoProviderOperations.list, + { organizationId: string; providerId: string }, + SsoProviderSettings +> = { + operation: ssoProviderOperations.list, + async execute({ principal, input }) { + const result = await loadProviders(principal, { ...input, limit: 1 }) + const provider = result.providers[0] + if (!provider) throw new OrchestrationError('not_found', 'Provider not found') + return provider + }, +} diff --git a/apps/sim/lib/auth/sso/application/set-primary-provider.ts b/apps/sim/lib/auth/sso/application/set-primary-provider.ts index 549d73e30b0..5f7e4b4e701 100644 --- a/apps/sim/lib/auth/sso/application/set-primary-provider.ts +++ b/apps/sim/lib/auth/sso/application/set-primary-provider.ts @@ -3,24 +3,19 @@ import { db } from '@sim/db' import { ssoDomain, ssoProvider } from '@sim/db/schema' import { verifiedDomainOfProvider } from '@sim/db/sso-primary-provider' import { and, eq, exists, sql } from 'drizzle-orm' +import { ssoSettingsOperations } from '@/lib/auth/sso/application/operations' import { recordProjectedUseCaseAuditEntries } from '@/lib/core/application/authorized-workspace-use-case' +import { requireOAuthOperationScope } from '@/lib/core/application/oauth-authorization' import type { OperationUseCase } from '@/lib/core/application/operation' import { authorizeOrganizationOperation } from '@/lib/core/application/organization-authorization' -import { defineOrganizationOperation } from '@/lib/core/application/organization-operation' +import { PrincipalKindAuthorizationError } from '@/lib/core/application/workspace-authorization' import { OrchestrationError } from '@/lib/core/orchestration/types' -/** - * permission-group-exempt: SSO providers are managed by organization owners and administrators, the same gate as the rest of SSO settings. - */ -export const setPrimarySsoProviderOperation = defineOrganizationOperation({ - id: 'organization.sso.set_primary_provider', - minimumRole: 'admin', - principalKinds: ['session'], - capability: 'none', -}) +const setPrimarySsoProviderOperation = ssoSettingsOperations.setPrimary export interface SetPrimarySsoProviderInput { providerId: string + assertedOrganizationId?: string } export interface SetPrimarySsoProviderResult { @@ -42,6 +37,9 @@ export const setPrimarySsoProvider: OperationUseCase< > = { operation: setPrimarySsoProviderOperation, async execute({ principal, input, request }) { + if (!setPrimarySsoProviderOperation.principalKinds.some((kind) => kind === principal.kind)) + throw new PrincipalKindAuthorizationError(principal.kind, setPrimarySsoProviderOperation.id) + requireOAuthOperationScope(principal, setPrimarySsoProviderOperation) const [provider] = await db .select({ id: ssoProvider.id, @@ -51,7 +49,10 @@ export const setPrimarySsoProvider: OperationUseCase< .from(ssoProvider) .where(eq(ssoProvider.providerId, input.providerId)) .limit(1) - if (!provider?.organizationId) { + if ( + !provider?.organizationId || + (input.assertedOrganizationId && provider.organizationId !== input.assertedOrganizationId) + ) { throw new OrchestrationError('not_found', 'Provider not found') } const { organizationId } = await authorizeOrganizationOperation( diff --git a/apps/sim/lib/auth/sso/application/sso-requirement.ts b/apps/sim/lib/auth/sso/application/sso-requirement.ts index 19c8666a8f4..277070bc50d 100644 --- a/apps/sim/lib/auth/sso/application/sso-requirement.ts +++ b/apps/sim/lib/auth/sso/application/sso-requirement.ts @@ -2,35 +2,20 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { db } from '@sim/db' import { organization } from '@sim/db/schema' import { eq } from 'drizzle-orm' +import { ssoSettingsOperations } from '@/lib/auth/sso/application/operations' import { hasSignInCapableSsoProvider } from '@/lib/auth/sso/verified-provider' import { invalidateSsoPolicyCache } from '@/lib/auth/sso-policy' import { isOrganizationFeatureEntitled } from '@/lib/billing/core/subscription' import { recordProjectedUseCaseAuditEntries } from '@/lib/core/application/authorized-workspace-use-case' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import type { OperationUseCase } from '@/lib/core/application/operation' import { authorizeOrganizationOperation } from '@/lib/core/application/organization-authorization' -import { defineOrganizationOperation } from '@/lib/core/application/organization-operation' import { isBillingEnabled, isSsoEnabled } from '@/lib/core/config/env-flags' import { OrchestrationError } from '@/lib/core/orchestration/types' -/** - * permission-group-exempt: The sign-in requirement is managed by organization owners and administrators, the same gate as the rest of SSO settings. - */ -export const readSsoRequirementOperation = defineOrganizationOperation({ - id: 'organization.sso.read_requirement', - minimumRole: 'member', - principalKinds: ['session'], - capability: 'none', -}) +const readSsoRequirementOperation = ssoSettingsOperations.readRequirement -/** - * permission-group-exempt: The sign-in requirement is managed by organization owners and administrators, the same gate as the rest of SSO settings. - */ -export const setSsoRequirementOperation = defineOrganizationOperation({ - id: 'organization.sso.set_requirement', - minimumRole: 'admin', - principalKinds: ['session'], - capability: 'none', -}) +const setSsoRequirementOperation = ssoSettingsOperations.setRequirement export interface SsoRequirement { /** The stored setting. */ @@ -106,8 +91,8 @@ export const setSsoRequirement: OperationUseCase< * the entitlement came back, with no administrator action behind it. */ if (input.requireSso && !(await isOrganizationFeatureEntitled(organizationId, isSsoEnabled))) { - throw new OrchestrationError( - 'forbidden', + throw new ForbiddenOperationError( + 'ENTERPRISE_PLAN_REQUIRED', isBillingEnabled ? 'Single Sign-On is available on Enterprise plans only' : 'Single Sign-On is disabled. Set ENTERPRISE_ENABLED or SSO_ENABLED to enable it.' diff --git a/apps/sim/lib/auth/sso/provider-adapter.ts b/apps/sim/lib/auth/sso/provider-adapter.ts new file mode 100644 index 00000000000..90df42c63fc --- /dev/null +++ b/apps/sim/lib/auth/sso/provider-adapter.ts @@ -0,0 +1,62 @@ +import type { sso } from '@better-auth/sso' +import { type Principal, requirePrincipalSubjectUserId } from '@sim/auth/principal' +import type { BetterAuthPlugin } from 'better-auth' +import type { auth } from '@/lib/auth' +import { + OrchestrationError, + type OrchestrationRequestContext, +} from '@/lib/core/orchestration/types' + +export type SsoProviderConfig = NonNullable< + Parameters[0] +>['body'] + +function isSsoPlugin(plugin: BetterAuthPlugin): plugin is ReturnType { + return plugin.id === 'sso' +} + +/** Selects a writer after the application operation has authorized the acting user. */ +export async function ssoProviderWriter( + principal: Principal, + organizationId: string, + request?: OrchestrationRequestContext +) { + const { auth } = await import('@/lib/auth') + if (principal.kind === 'session') { + return { + register: (body: SsoProviderConfig) => + auth.api.registerSSOProvider({ + body, + headers: request?.headers instanceof Headers ? request.headers : undefined, + }), + update: (body: NonNullable[0]>['body']) => + auth.api.updateSSOProvider({ + body, + headers: request?.headers instanceof Headers ? request.headers : undefined, + }), + } + } + if (principal.kind !== 'personal_api_key' && principal.kind !== 'oauth_access_token') + throw new OrchestrationError('forbidden', 'SSO provider writes require a user credential') + const userId = requirePrincipalSubjectUserId(principal) + const plugins: readonly BetterAuthPlugin[] = auth.options.plugins ?? [] + const plugin = plugins.find(isSsoPlugin) + if (!plugin) throw new OrchestrationError('validation', 'SSO is not enabled') + const context = await auth.$context + const { createSsoProviderRepository } = await import('@/lib/auth/sso/provider-repository') + return createSsoProviderRepository(userId, organizationId, plugin, { + reservedProviderIds: [ + ...Object.keys(context.options.socialProviders ?? {}), + ...context.socialProviders.map((provider) => provider.id), + ...context.trustedProviders, + ], + hasScimProvider: async (providerId) => + context.hasPlugin('scim') && + Boolean( + await context.adapter.findOne({ + model: 'scimProvider', + where: [{ field: 'providerId', value: providerId }], + }) + ), + }) +} diff --git a/apps/sim/lib/auth/sso/provider-repository.ts b/apps/sim/lib/auth/sso/provider-repository.ts new file mode 100644 index 00000000000..be243d6a782 --- /dev/null +++ b/apps/sim/lib/auth/sso/provider-repository.ts @@ -0,0 +1,286 @@ +import { + DEFAULT_MAX_SAML_METADATA_SIZE, + DigestAlgorithm, + SignatureAlgorithm, + type sso, +} from '@better-auth/sso' +import { account, db, ssoProvider, user } from '@sim/db' +import { createLogger } from '@sim/logger' +import { generateId } from '@sim/utils/id' +import { filterUndefined, sortObjectKeysDeep, toRecord } from '@sim/utils/object' +import { APIError } from 'better-auth/api' +import { and, count, eq } from 'drizzle-orm' +import type { auth } from '@/lib/auth' +import type { SsoProviderConfig } from '@/lib/auth/sso/provider-adapter' + +const logger = createLogger('SsoProviderRepository') +const BUILT_IN_PROVIDER_IDS = [ + 'credential', + 'email-otp', + 'magic-link', + 'phone-number', + 'anonymous', + 'siwe', +] as const +const OIDC_IDENTITY_FIELDS = [ + 'authorizationEndpoint', + 'clientId', + 'discoveryEndpoint', + 'jwksEndpoint', + 'tokenEndpoint', + 'userInfoEndpoint', +] as const +const SAML_IDENTITY_FIELDS = ['audience', 'callbackUrl', 'entryPoint', 'identifierFormat'] as const +const SAML_IDP_IDENTITY_FIELDS = ['metadata', 'entityID', 'singleSignOnService'] as const +const SAML_SP_IDENTITY_FIELDS = ['metadata', 'entityID'] as const + +interface RepositoryConfiguration { + reservedProviderIds: readonly string[] + hasScimProvider(providerId: string): Promise +} + +function changedFields( + current: Record, + updated: Record, + fields: readonly string[] +) { + return fields.some( + (field) => + JSON.stringify(sortObjectKeysDeep(current[field])) !== + JSON.stringify(sortObjectKeysDeep(updated[field])) + ) +} + +function storedConfiguration(value: string | null, protocol: string): Record { + if (!value) + throw new APIError('BAD_REQUEST', { + message: `Cannot update ${protocol} config for a provider that doesn't have ${protocol} configured`, + }) + try { + return toRecord(JSON.parse(value)) + } catch { + throw new APIError('BAD_REQUEST', { + message: `Cannot update invalid ${protocol} configuration`, + }) + } +} + +function normalizeAlgorithm(value: string, algorithms: Record, signature: boolean) { + const suffix = value.toLowerCase() + return ( + Object.values(algorithms).find( + (uri) => + uri === value || + uri.split('#')[1] === suffix || + (signature && uri.split('#')[1] === `rsa-${suffix}`) + ) ?? value + ) +} + +/** Persists API-authorized users directly, without converting their credential into a browser session. */ +export function createSsoProviderRepository( + userId: string, + organizationId: string, + plugin: ReturnType, + configuration: RepositoryConfiguration +) { + const validateConfig = (body: SsoProviderConfig) => { + const config = body.samlConfig + if (!config) return + const maximum = plugin.options?.saml?.maxMetadataSize ?? DEFAULT_MAX_SAML_METADATA_SIZE + if ( + config.idpMetadata?.metadata && + new TextEncoder().encode(config.idpMetadata.metadata).length > maximum + ) + throw new APIError('BAD_REQUEST', { + message: `IdP metadata exceeds maximum allowed size (${maximum} bytes)`, + }) + const options = plugin.options?.saml?.algorithms + for (const [value, algorithms, allowed, deprecated, signature] of [ + [ + config.signatureAlgorithm, + SignatureAlgorithm, + options?.allowedSignatureAlgorithms, + SignatureAlgorithm.RSA_SHA1, + true, + ], + [ + config.digestAlgorithm, + DigestAlgorithm, + options?.allowedDigestAlgorithms, + DigestAlgorithm.SHA1, + false, + ], + ] as const) { + if (!value) continue + const normalized = normalizeAlgorithm(value, algorithms, signature) + if ( + allowed + ? !allowed.some( + (entry) => normalizeAlgorithm(entry, algorithms, signature) === normalized + ) + : !Object.values(algorithms).some((entry) => entry === normalized) + ) + throw new APIError('BAD_REQUEST', { message: 'SAML algorithm is not permitted' }) + if (!allowed && normalized === deprecated) { + if (options?.onDeprecated === 'reject') + throw new APIError('BAD_REQUEST', { message: 'SAML algorithm is deprecated' }) + if (options?.onDeprecated !== 'allow') + logger.warn('SSO configuration uses a deprecated SAML algorithm') + } + } + } + + return { + async register(input: SsoProviderConfig) { + const body = plugin.endpoints.registerSSOProvider.options.body.parse(input) + if (body.organizationId !== organizationId) + throw new APIError('BAD_REQUEST', { + message: 'Provider organization does not match its authorized scope', + }) + validateConfig(body) + const reserved = new Set([ + ...BUILT_IN_PROVIDER_IDS, + ...configuration.reservedProviderIds, + ...(plugin.options?.defaultSSO?.map((provider) => provider.providerId) ?? []), + ]) + if (reserved.has(body.providerId)) + throw new APIError('UNPROCESSABLE_ENTITY', { + message: 'This providerId is reserved and cannot be used for an SSO provider', + }) + if (await configuration.hasScimProvider(body.providerId)) + throw new APIError('UNPROCESSABLE_ENTITY', { + message: + 'This providerId is already used by a SCIM provider and cannot be used for an SSO provider', + }) + return db.transaction(async (tx) => { + const [subject] = await tx + .select() + .from(user) + .where(eq(user.id, userId)) + .for('update') + .limit(1) + if (!subject) throw new APIError('NOT_FOUND', { message: 'User not found' }) + const configuredLimit = plugin.options?.providersLimit + const limit = + typeof configuredLimit === 'function' + ? await configuredLimit(subject) + : (configuredLimit ?? 10) + const [total] = await tx + .select({ value: count() }) + .from(ssoProvider) + .where(eq(ssoProvider.userId, userId)) + if (!limit || total.value >= limit) + throw new APIError('FORBIDDEN', { + message: !limit + ? 'SSO provider registration is disabled' + : 'You have reached the maximum number of SSO providers', + }) + const id = generateId() + await tx.insert(ssoProvider).values({ + id, + userId, + providerId: body.providerId, + organizationId, + issuer: body.issuer, + domain: body.domain, + domainVerified: false, + oidcConfig: body.oidcConfig + ? JSON.stringify({ + ...body.oidcConfig, + issuer: body.issuer, + overrideUserInfo: + body.overrideUserInfo ?? plugin.options?.defaultOverrideUserInfo ?? false, + }) + : null, + samlConfig: body.samlConfig + ? JSON.stringify({ ...body.samlConfig, issuer: body.issuer }) + : null, + }) + return { id, providerId: body.providerId } + }) + }, + async update(input: NonNullable[0]>['body']) { + const body = plugin.endpoints.updateSSOProvider.options.body.parse(input) + return db.transaction(async (tx) => { + const [existing] = await tx + .select() + .from(ssoProvider) + .where( + and( + eq(ssoProvider.providerId, body.providerId), + eq(ssoProvider.organizationId, organizationId) + ) + ) + .for('update') + .limit(1) + if (!existing) throw new APIError('NOT_FOUND', { message: 'Provider not found' }) + const issuer = body.issuer ?? existing.issuer + const changes: Partial = { + ...(body.issuer === undefined ? {} : { issuer: body.issuer }), + ...(body.domain === undefined ? {} : { domain: body.domain }), + ...(body.domain !== undefined && body.domain !== existing.domain + ? { domainVerified: false } + : {}), + } + let identityChanged = issuer !== existing.issuer + for (const protocol of ['oidc', 'saml'] as const) { + const key = protocol === 'oidc' ? 'oidcConfig' : 'samlConfig' + const config = body[key] + if (!config) continue + validateConfig({ + providerId: body.providerId, + issuer, + domain: body.domain ?? existing.domain, + [key]: config, + }) + const current = storedConfiguration(existing[key], protocol.toUpperCase()) + const updated: Record = { + ...current, + ...filterUndefined(config), + issuer, + } + if (protocol === 'oidc') { + updated.pkce = toRecord(config).pkce ?? current.pkce ?? true + identityChanged ||= changedFields(current, updated, OIDC_IDENTITY_FIELDS) + } else { + identityChanged ||= + changedFields(current, updated, SAML_IDENTITY_FIELDS) || + changedFields( + toRecord(current.idpMetadata), + toRecord(updated.idpMetadata), + SAML_IDP_IDENTITY_FIELDS + ) || + changedFields( + toRecord(current.spMetadata), + toRecord(updated.spMetadata), + SAML_SP_IDENTITY_FIELDS + ) + } + identityChanged ||= changedFields(toRecord(current.mapping), toRecord(updated.mapping), [ + 'id', + ]) + changes[key] = JSON.stringify(updated) + } + if (identityChanged) { + const [linked] = await tx + .select({ id: account.id }) + .from(account) + .where(eq(account.providerId, body.providerId)) + .limit(1) + if (linked) + throw new APIError('CONFLICT', { + message: 'Cannot change SSO provider identity fields while linked accounts exist', + }) + } + await tx + .update(ssoProvider) + .set(changes) + .where( + and(eq(ssoProvider.id, existing.id), eq(ssoProvider.organizationId, organizationId)) + ) + return { providerId: existing.providerId } + }) + }, + } +} diff --git a/apps/sim/lib/auth/sso/registration-input.ts b/apps/sim/lib/auth/sso/registration-input.ts new file mode 100644 index 00000000000..a393735354e --- /dev/null +++ b/apps/sim/lib/auth/sso/registration-input.ts @@ -0,0 +1,66 @@ +import { z } from 'zod' + +const ssoMappingSchema = z + .object({ + id: z.string().default('sub'), + email: z.string().default('email'), + name: z.string().default('name'), + image: z.string().default('picture'), + }) + .default({ + id: 'sub', + email: 'email', + name: 'name', + image: 'picture', + }) + +export const ssoRegistrationInputSchema = z.discriminatedUnion('providerType', [ + z.object({ + providerType: z.literal('oidc').default('oidc'), + providerId: z.string().min(1, 'Provider ID is required'), + issuer: z.string().url('Issuer must be a valid URL'), + domain: z.string().min(1, 'Domain is required'), + organizationId: z.string().min(1).max(255), + jitProvisioningEnabled: z.boolean().default(true), + mapping: ssoMappingSchema, + clientId: z.string().min(1, 'Client ID is required for OIDC'), + clientSecret: z.string().min(1, 'Client Secret is required for OIDC'), + scopes: z + .union([ + z.string().transform((s) => + s + .split(',') + .map((value) => value.trim()) + .filter((value) => value !== '') + ), + z.array(z.string()), + ]) + .default(['openid', 'profile', 'email']), + pkce: z.boolean().default(true), + authorizationEndpoint: z.string().url().optional(), + tokenEndpoint: z.string().url().optional(), + userInfoEndpoint: z.string().url().optional(), + skipUserInfoEndpoint: z.boolean().default(false), + jwksEndpoint: z.string().url().optional(), + }), + z.object({ + providerType: z.literal('saml'), + providerId: z.string().min(1, 'Provider ID is required'), + issuer: z.string().url('Issuer must be a valid URL'), + domain: z.string().min(1, 'Domain is required'), + organizationId: z.string().min(1).max(255), + jitProvisioningEnabled: z.boolean().default(true), + mapping: ssoMappingSchema, + entryPoint: z.string().url('Entry point must be a valid URL for SAML'), + cert: z.string().min(1, 'Certificate is required for SAML'), + callbackUrl: z.string().url().optional(), + audience: z.string().optional(), + wantAssertionsSigned: z.boolean().optional(), + signatureAlgorithm: z.string().optional(), + digestAlgorithm: z.string().optional(), + identifierFormat: z.string().optional(), + idpMetadata: z.string().optional(), + }), +]) + +export type SsoRegistrationInput = z.output diff --git a/apps/sim/lib/billing/core/subscription.ts b/apps/sim/lib/billing/core/subscription.ts index 64c6b0c8f6d..01c925544bc 100644 --- a/apps/sim/lib/billing/core/subscription.ts +++ b/apps/sim/lib/billing/core/subscription.ts @@ -33,7 +33,6 @@ import { isInboxEnabled, isSandboxDeploymentEntitled, isSandboxesEnabled, - isSsoEnabled, } from '@/lib/core/config/env-flags' import { getBaseUrl } from '@/lib/core/utils/urls' import type { DbOrTx } from '@/lib/db/types' @@ -635,27 +634,6 @@ export async function isOrganizationFeatureEntitled( return isOrganizationOnEnterprisePlan(organizationId, options.onError ?? 'return-false', executor) } -/** - * Check if user has access to SSO feature - * Returns true if: - * - SSO_ENABLED env var is set (self-hosted override), OR - * - User is admin/owner of an enterprise organization - * - * In non-production environments, returns true for convenience. - */ -export async function hasSSOAccess(userId: string): Promise { - try { - if (isSsoEnabled && !isHosted) { - return true - } - - return isEnterpriseOrgAdminOrOwner(userId) - } catch (error) { - logger.error('Error checking SSO access', { error, userId }) - return false - } -} - /** * Check whether a workspace is entitled to workspace-scoped enterprise features * — today, copilot BYOK. Entitlement follows the workspace's billing entity: diff --git a/apps/sim/lib/billing/index.ts b/apps/sim/lib/billing/index.ts index 1e43f2a866b..31f6820a72a 100644 --- a/apps/sim/lib/billing/index.ts +++ b/apps/sim/lib/billing/index.ts @@ -10,7 +10,6 @@ export * from '@/lib/billing/core/subscription' export { getHighestPrioritySubscription as getActiveSubscription, hasPaidSubscription, - hasSSOAccess, isEnterpriseOrgAdminOrOwner, isEnterprisePlan as hasEnterprisePlan, isOrganizationGovernanceActive, diff --git a/apps/sim/lib/core/application/forbidden.ts b/apps/sim/lib/core/application/forbidden.ts index f16401d2e88..9de0f6cf29f 100644 --- a/apps/sim/lib/core/application/forbidden.ts +++ b/apps/sim/lib/core/application/forbidden.ts @@ -37,6 +37,10 @@ export const FORBIDDEN_DETAIL_CODES = [ 'ORGANIZATION_ADMIN_REQUIRED', /** The organization has no usable enterprise subscription. */ 'ENTERPRISE_PLAN_REQUIRED', + /** Verify DNS ownership before configuring a provider for this domain. */ + 'SSO_DOMAIN_NOT_VERIFIED', + /** The acting user has reached the configured identity-provider ceiling. */ + 'SSO_PROVIDER_LIMIT_REACHED', /** The organization has no usable organization plan of any tier. */ 'ORGANIZATION_PLAN_REQUIRED', /** Audit logging is switched off for this deployment. */ diff --git a/apps/sim/lib/credentials/application/credential-members.ts b/apps/sim/lib/credentials/application/credential-members.ts index 715cf4dcc3c..e217d98bdea 100644 --- a/apps/sim/lib/credentials/application/credential-members.ts +++ b/apps/sim/lib/credentials/application/credential-members.ts @@ -3,7 +3,10 @@ import { requirePrincipalSubjectUserId, type SessionPrincipal } from '@sim/auth/ import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { requireOrdinaryCredentialType } from '@/lib/credentials/access' -import { defineAuthorizedCredentialUseCase } from '@/lib/credentials/application/authorized-credential-use-case' +import { + defineAuthorizedCredentialUseCase, + requireManageableCredentialType, +} from '@/lib/credentials/application/authorized-credential-use-case' import { defineAuthorizedCredentialUserUseCase } from '@/lib/credentials/application/authorized-user-use-case' import { resolveCredentialApplicationContext } from '@/lib/credentials/application/credential-context' import { @@ -11,9 +14,11 @@ import { credentialUserOperations, } from '@/lib/credentials/application/operations' import { + type CredentialMemberPageInput, leaveCredentialMembership, listCredentialMembers, listCredentialMembershipsForUser, + listCredentialMembersPage, removeCredentialMember, upsertCredentialMember, } from '@/lib/credentials/members' @@ -22,32 +27,33 @@ import { captureServerEvent } from '@/lib/posthog/server' interface CredentialMemberResourceInput { credentialId: string -} - -function resolveSessionCredentialContext( - _principal: SessionPrincipal, - input: CredentialMemberResourceInput -) { - return resolveCredentialApplicationContext(input) + assertedWorkspaceId?: string } export const listCredentialMembersUseCase = defineAuthorizedWorkspaceUseCase({ operation: credentialOperations.listMembers, resolveContext: ({ - principal, input, }: { - principal: SessionPrincipal - input: CredentialMemberResourceInput - }) => resolveSessionCredentialContext(principal, input), + input: CredentialMemberResourceInput & Partial + }) => resolveCredentialApplicationContext(input), authorizationOptions: {}, - async execute({ principal, context }) { + async execute({ principal, input, context }) { + requireManageableCredentialType(principal, context.credential) if (context.credential.type === 'personal_token') { - if (context.credential.createdBy !== principal.userId) + if (context.credential.createdBy !== requirePrincipalSubjectUserId(principal)) throw new OrchestrationError('not_found', 'Credential not found') - return { members: [] } + return { members: [], nextCursorKeys: null } + } + if (input.limit !== undefined) { + return listCredentialMembersPage(context, { + limit: input.limit, + sortBy: input.sortBy ?? 'email', + sortOrder: input.sortOrder ?? 'asc', + cursorKeys: input.cursorKeys, + }) } - return { members: await listCredentialMembers(context.credential) } + return { members: await listCredentialMembers(context.credential), nextCursorKeys: null } }, }) @@ -58,14 +64,10 @@ export interface UpsertCredentialMemberInput extends CredentialMemberResourceInp export const upsertCredentialMemberUseCase = defineAuthorizedCredentialUseCase({ operation: credentialOperations.upsertMember, - resolveContext: ({ - principal, - input, - }: { - principal: SessionPrincipal - input: UpsertCredentialMemberInput - }) => resolveSessionCredentialContext(principal, input), + resolveContext: ({ input }: { input: UpsertCredentialMemberInput }) => + resolveCredentialApplicationContext(input), async execute({ principal, input, context }) { + requireManageableCredentialType(principal, context.credential) const result = await upsertCredentialMember({ credential: context.credential, actorUserId: requirePrincipalSubjectUserId(principal), @@ -106,14 +108,10 @@ export interface RemoveCredentialMemberInput extends CredentialMemberResourceInp export const removeCredentialMemberUseCase = defineAuthorizedCredentialUseCase({ operation: credentialOperations.removeMember, - resolveContext: ({ - principal, - input, - }: { - principal: SessionPrincipal - input: RemoveCredentialMemberInput - }) => resolveSessionCredentialContext(principal, input), - async execute({ input, context }) { + resolveContext: ({ input }: { input: RemoveCredentialMemberInput }) => + resolveCredentialApplicationContext(input), + async execute({ principal, input, context }) { + requireManageableCredentialType(principal, context.credential) await removeCredentialMember({ credential: context.credential, targetUserId: input.userId }) return { success: true as const, targetUserId: input.userId } }, diff --git a/apps/sim/lib/credentials/application/credential-sharing.integration.ts b/apps/sim/lib/credentials/application/credential-sharing.integration.ts new file mode 100644 index 00000000000..3584aa6665b --- /dev/null +++ b/apps/sim/lib/credentials/application/credential-sharing.integration.ts @@ -0,0 +1,226 @@ +import { generateId } from '@sim/utils/id' +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' + +/** Public sharing must obey the same workspace and credential grants as the UI. */ +describe('Credential sharing through user-held API credentials in PostgreSQL', () => { + const actorId = generateId() + const readerId = generateId() + const outsiderId = generateId() + const orgAdminId = generateId() + const workspaceId = generateId() + const organizationId = generateId() + const credentialId = generateId() + const principal = { kind: 'personal_api_key' as const, userId: actorId, keyId: generateId() } + let runtime: Awaited> + + async function loadRuntime() { + const [{ db }, schema, { and, eq, inArray }, useCases] = await Promise.all([ + import('@sim/db'), + import('@sim/db/schema'), + import('drizzle-orm'), + import('@/lib/credentials/application/credential-members'), + ]) + return { db, schema, and, eq, inArray, ...useCases } + } + + beforeAll(async () => { + runtime = await loadRuntime() + const { db, schema } = runtime + await db.insert(schema.user).values( + [actorId, readerId, outsiderId, orgAdminId].map((id) => ({ + id, + name: id, + email: `${id}@sharing.test`, + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + })) + ) + await db.insert(schema.organization).values({ + id: organizationId, + name: 'Sharing test', + slug: organizationId, + createdAt: new Date(), + }) + await db.insert(schema.member).values({ + id: generateId(), + userId: orgAdminId, + organizationId, + role: 'admin', + createdAt: new Date(), + }) + await db.insert(schema.workspace).values({ + id: workspaceId, + name: 'Sharing test', + ownerId: actorId, + organizationId, + billedAccountUserId: actorId, + }) + await db.insert(schema.permissions).values( + [actorId, readerId].map((userId) => ({ + id: generateId(), + userId, + entityType: 'workspace' as const, + entityId: workspaceId, + permissionType: userId === actorId ? ('admin' as const) : ('read' as const), + })) + ) + await db.insert(schema.credential).values({ + id: credentialId, + workspaceId, + type: 'service_account', + displayName: 'Sharing test', + providerId: 'google-service-account', + createdBy: actorId, + }) + }, 30_000) + + beforeEach(async () => { + const { db, schema, eq } = runtime + await db + .delete(schema.credentialMember) + .where(eq(schema.credentialMember.credentialId, credentialId)) + }) + + afterAll(async () => { + if (!runtime) return + const { db, schema, eq, inArray } = runtime + await db.delete(schema.workspace).where(eq(schema.workspace.id, workspaceId)) + await db.delete(schema.organization).where(eq(schema.organization.id, organizationId)) + await db + .delete(schema.user) + .where(inArray(schema.user.id, [actorId, readerId, outsiderId, orgAdminId])) + }) + + it('grants, changes, and revokes explicit membership with the acting API user', async () => { + const input = { credentialId, assertedWorkspaceId: workspaceId, userId: readerId } + await runtime.upsertCredentialMemberUseCase.execute({ + principal, + input: { ...input, role: 'member' }, + }) + const { db, schema, and, eq } = runtime + const [added] = await db + .select() + .from(schema.credentialMember) + .where( + and( + eq(schema.credentialMember.credentialId, credentialId), + eq(schema.credentialMember.userId, readerId) + ) + ) + expect(added).toMatchObject({ status: 'active', role: 'member', invitedBy: actorId }) + const changed = await runtime.upsertCredentialMemberUseCase.execute({ + principal, + input: { ...input, role: 'admin' }, + }) + expect(changed).toMatchObject({ created: false, previousRole: 'member' }) + await runtime.removeCredentialMemberUseCase.execute({ principal, input }) + const [removed] = await db + .select() + .from(schema.credentialMember) + .where(eq(schema.credentialMember.id, added.id)) + expect(removed).toMatchObject({ status: 'revoked', role: 'admin' }) + }) + + it('denies workspace keys before looking up a credential', async () => { + await expect( + runtime.upsertCredentialMemberUseCase.execute({ + principal: { kind: 'workspace_api_key', workspaceId, keyId: generateId() }, + input: { credentialId: generateId(), userId: readerId, role: 'admin' }, + }) + ).rejects.toMatchObject({ detailCode: 'WORKSPACE_KEY_OPERATION_NOT_PERMITTED' }) + }) + + it('conceals another tenant and rejects mismatched workspace assertions', async () => { + await expect( + runtime.upsertCredentialMemberUseCase.execute({ + principal: { ...principal, userId: outsiderId }, + input: { credentialId, userId: readerId, role: 'admin' }, + }) + ).rejects.toMatchObject({ name: 'NoWorkspaceAccessError' }) + await expect( + runtime.upsertCredentialMemberUseCase.execute({ + principal, + input: { credentialId, assertedWorkspaceId: generateId(), userId: readerId, role: 'admin' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + }) + + it('rejects outsiders and protects inherited administrator grants', async () => { + await expect( + runtime.upsertCredentialMemberUseCase.execute({ + principal, + input: { credentialId, userId: outsiderId, role: 'member' }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + await expect( + runtime.upsertCredentialMemberUseCase.execute({ + principal, + input: { credentialId, userId: orgAdminId, role: 'member' }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + }) + + it('paginates explicit and inherited members without losing or repeating a user', async () => { + await runtime.upsertCredentialMemberUseCase.execute({ + principal, + input: { credentialId, assertedWorkspaceId: workspaceId, userId: readerId, role: 'member' }, + }) + const seen: string[] = [] + let cursorKeys: (string | number)[] | undefined + for (let page = 0; page < 10; page++) { + const result = await runtime.listCredentialMembersUseCase.execute({ + principal, + input: { + credentialId, + assertedWorkspaceId: workspaceId, + limit: 1, + sortBy: 'email', + sortOrder: 'asc', + cursorKeys, + }, + }) + expect(result.members).toHaveLength(1) + seen.push(result.members[0].userId) + if (!result.nextCursorKeys) break + cursorKeys = result.nextCursorKeys + } + expect(seen).toHaveLength(new Set(seen).size) + expect(new Set(seen)).toEqual(new Set([actorId, readerId, orgAdminId])) + }) + + it('concurrent first grants converge on one active member instead of a server error', async () => { + const { db, schema, and, eq } = runtime + await db + .delete(schema.credentialMember) + .where( + and( + eq(schema.credentialMember.credentialId, credentialId), + eq(schema.credentialMember.userId, readerId) + ) + ) + const input = { + credentialId, + assertedWorkspaceId: workspaceId, + userId: readerId, + role: 'member' as const, + } + const results = await Promise.all( + Array.from({ length: 4 }, () => + runtime.upsertCredentialMemberUseCase.execute({ principal, input }) + ) + ) + expect(results.filter((result) => result.created)).toHaveLength(1) + const rows = await db + .select() + .from(schema.credentialMember) + .where( + and( + eq(schema.credentialMember.credentialId, credentialId), + eq(schema.credentialMember.userId, readerId) + ) + ) + expect(rows).toHaveLength(1) + expect(rows[0].status).toBe('active') + }) +}) diff --git a/apps/sim/lib/credentials/application/operations.ts b/apps/sim/lib/credentials/application/operations.ts index 86d5111d7fc..3d45db277f4 100644 --- a/apps/sim/lib/credentials/application/operations.ts +++ b/apps/sim/lib/credentials/application/operations.ts @@ -191,29 +191,32 @@ export const credentialOperations = { principalKinds: ['session'], }), listMembers: defineWorkspaceOperation({ + oauthScope: 'api:read', id: 'credentials.members.list', minimumRole: 'read', workspaceApiKey: 'deny', capability: 'integrations.manage', - principalKinds: ['session'], + principalKinds: ['session', 'personal_api_key', 'oauth_access_token'], }), upsertMember: defineCredentialOperation( defineWorkspaceOperation({ + oauthScope: 'api:write', id: 'credentials.members.upsert', minimumRole: 'read', workspaceApiKey: 'deny', capability: 'integrations.manage', - principalKinds: ['session'], + principalKinds: ['session', 'personal_api_key', 'oauth_access_token'], }), 'admin' ), removeMember: defineCredentialOperation( defineWorkspaceOperation({ + oauthScope: 'api:write', id: 'credentials.members.remove', minimumRole: 'read', workspaceApiKey: 'deny', capability: 'integrations.manage', - principalKinds: ['session'], + principalKinds: ['session', 'personal_api_key', 'oauth_access_token'], }), 'admin' ), diff --git a/apps/sim/lib/credentials/members.ts b/apps/sim/lib/credentials/members.ts index abe6ad0fa9a..0f3bafe8372 100644 --- a/apps/sim/lib/credentials/members.ts +++ b/apps/sim/lib/credentials/members.ts @@ -1,9 +1,19 @@ import { db } from '@sim/db' -import { credential, credentialMember, user } from '@sim/db/schema' +import { credential, credentialMember, member, permissions, user } from '@sim/db/schema' import { generateId } from '@sim/utils/id' -import { and, eq, notInArray } from 'drizzle-orm' +import { and, eq, exists, inArray, notInArray, or, sql } from 'drizzle-orm' +import { + type CursorKey, + keysetColumns, + keysetPage, + type ListSortOrder, + listOrderBy, + resumeKeyset, + textKey, +} from '@/lib/api/list-query' import { OrchestrationError } from '@/lib/core/orchestration/types' import { isSharedCredentialType, requireOrdinaryCredentialType } from '@/lib/credentials/access' +import type { CredentialAuthorizationContext } from '@/lib/credentials/application/authorized-credential-use-case' import type { CredentialRow } from '@/lib/credentials/queries' import { getUserEntityPermissions, @@ -73,6 +83,92 @@ export async function listCredentialMembers( return Array.from(byUser.values()) } +export interface CredentialMemberPageInput { + limit: number + sortBy: 'email' | 'name' + sortOrder: ListSortOrder + cursorKeys?: CursorKey[] +} + +/** Reads effective credential membership with a bounded SQL keyset, including inherited admins. */ +export async function listCredentialMembersPage( + context: CredentialAuthorizationContext, + input: CredentialMemberPageInput +) { + const workspaceAdmin = exists( + db + .select({ found: sql`1` }) + .from(permissions) + .where( + and( + eq(permissions.userId, user.id), + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, context.workspaceId), + eq(permissions.permissionType, 'admin') + ) + ) + ) + const organizationAdmin = context.workspaceOrganizationId + ? exists( + db + .select({ found: sql`1` }) + .from(member) + .where( + and( + eq(member.userId, user.id), + eq(member.organizationId, context.workspaceOrganizationId), + inArray(member.role, ['owner', 'admin']) + ) + ) + ) + : sql`false` + const inheritedAdmin = isSharedCredentialType(context.credential.type) + ? sql`(${workspaceAdmin} or ${organizationAdmin})` + : sql`false` + const sortKeys = [ + input.sortBy === 'name' + ? textKey(user.name, (row: CredentialMemberView) => row.userName ?? '') + : textKey(user.email, (row: CredentialMemberView) => row.userEmail ?? ''), + textKey(user.id, (row: CredentialMemberView) => row.userId), + ] + const rows = await db + .select({ + id: sql`coalesce(${credentialMember.id}, 'workspace-admin-' || ${user.id})`, + userId: user.id, + role: sql< + 'admin' | 'member' + >`case when ${inheritedAdmin} then 'admin' else ${credentialMember.role} end`, + status: sql< + 'active' | 'pending' | 'revoked' + >`case when ${inheritedAdmin} then 'active' else ${credentialMember.status} end`, + joinedAt: credentialMember.joinedAt, + userName: user.name, + userEmail: user.email, + userImage: user.image, + roleSource: sql< + 'explicit' | 'workspace-admin' + >`case when ${inheritedAdmin} then 'workspace-admin' else 'explicit' end`, + }) + .from(user) + .leftJoin( + credentialMember, + and( + eq(credentialMember.userId, user.id), + eq(credentialMember.credentialId, context.credential.id) + ) + ) + .where( + and( + or(sql`${credentialMember.id} is not null`, inheritedAdmin), + resumeKeyset(sortKeys, input.cursorKeys, input.sortOrder) + ) + ) + .orderBy(...listOrderBy(keysetColumns(sortKeys), input.sortOrder)) + .limit(input.limit + 1) + const page = keysetPage(sortKeys, rows, input.limit) + return { members: page.data, nextCursorKeys: page.nextCursorKeys } +} + export interface UpsertCredentialMemberParams { credential: CredentialRow actorUserId: string @@ -111,47 +207,46 @@ export async function upsertCredentialMember( ) } - const [existing] = await db - .select({ id: credentialMember.id }) - .from(credentialMember) - .where( - and( - eq(credentialMember.credentialId, params.credential.id), - eq(credentialMember.userId, params.targetUserId) + return db.transaction(async (tx) => { + const [locked] = await tx + .select({ id: credential.id }) + .from(credential) + .where(eq(credential.id, params.credential.id)) + .limit(1) + .for('update') + if (!locked) throw new OrchestrationError('not_found', 'Credential not found') + const [existing] = await tx + .select({ id: credentialMember.id, role: credentialMember.role }) + .from(credentialMember) + .where( + and( + eq(credentialMember.credentialId, locked.id), + eq(credentialMember.userId, params.targetUserId) + ) ) - ) - .limit(1) - const now = new Date() - if (existing) { - const previousRole = await db.transaction(async (tx) => { - const [current] = await tx - .select({ role: credentialMember.role }) - .from(credentialMember) - .where(eq(credentialMember.id, existing.id)) - .limit(1) - .for('update') - if (!current) throw new Error('Credential membership disappeared during update') + .limit(1) + .for('update') + const now = new Date() + if (existing) { await tx .update(credentialMember) .set({ role: params.role, status: 'active', updatedAt: now }) .where(eq(credentialMember.id, existing.id)) - return current.role + return { created: false, previousRole: existing.role } + } + await tx.insert(credentialMember).values({ + id: generateId(), + credentialId: locked.id, + userId: params.targetUserId, + role: params.role, + status: 'active', + joinedAt: now, + invitedBy: params.actorUserId, + createdAt: now, + updatedAt: now, }) - return { created: false, previousRole } - } - - await db.insert(credentialMember).values({ - id: generateId(), - credentialId: params.credential.id, - userId: params.targetUserId, - role: params.role, - status: 'active', - joinedAt: now, - invitedBy: params.actorUserId, - createdAt: now, - updatedAt: now, + return { created: true } }) - return { created: true } } export async function removeCredentialMember(params: { diff --git a/apps/sim/lib/organizations/application/authorized-configuration-use-case.ts b/apps/sim/lib/organizations/application/authorized-configuration-use-case.ts index 7c7bd8c479d..aaf6b6e1708 100644 --- a/apps/sim/lib/organizations/application/authorized-configuration-use-case.ts +++ b/apps/sim/lib/organizations/application/authorized-configuration-use-case.ts @@ -50,7 +50,10 @@ export function defineOrganizationConfigurationUseCase< ) } catch (error) { const classified = asOrchestrationError(error) - if (classified?.code === 'not_found') { + if ( + classified?.code === 'not_found' && + (args.principal.kind === 'session' || args.principal.kind === 'organization_delegated') + ) { throw new OrchestrationError('forbidden', 'Forbidden - Not a member of this organization') } if ( diff --git a/apps/sim/lib/organizations/application/domain-settings.ts b/apps/sim/lib/organizations/application/domain-settings.ts index 02f195a7c9a..375469210de 100644 --- a/apps/sim/lib/organizations/application/domain-settings.ts +++ b/apps/sim/lib/organizations/application/domain-settings.ts @@ -7,6 +7,15 @@ import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { normalizeSSODomain } from '@sim/utils/sso-domain' import { and, asc, eq, sql } from 'drizzle-orm' +import { + type CursorKey, + keysetColumns, + keysetPage, + type ListSortOrder, + listOrderBy, + resumeKeyset, + textKey, +} from '@/lib/api/list-query' import { buildChallengeHost, checkDomainTxtRecord, @@ -14,11 +23,12 @@ import { } from '@/lib/auth/sso/domain-verification' import { invalidateSsoPolicyCache } from '@/lib/auth/sso-policy' import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import { env, isTruthy } from '@/lib/core/config/env' import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags' import { OrchestrationError } from '@/lib/core/orchestration/types' import { defineOrganizationConfigurationUseCase } from '@/lib/organizations/application/authorized-configuration-use-case' -import { organizationSecurityOperations } from '@/lib/organizations/application/security-operations' +import { organizationSecurityOperations } from '@/lib/organizations/application/operations' import { addOrganizationDomainBodySchema, MAX_ORGANIZATION_DOMAINS, @@ -43,15 +53,19 @@ function domainValue( return { ...row, verificationToken: - principal.kind === 'session' && includeToken && row.status === 'pending' + (principal.kind === 'session' || + principal.kind === 'personal_api_key' || + principal.kind === 'oauth_access_token') && + includeToken && + row.status === 'pending' ? row.verificationToken : null, } } async function requireDomainEnterprise(organizationId: string) { if (isBillingEnabled && !(await isOrganizationOnEnterprisePlan(organizationId))) - throw new OrchestrationError( - 'forbidden', + throw new ForbiddenOperationError( + 'ENTERPRISE_PLAN_REQUIRED', 'Domain verification is available on Enterprise plans only' ) } @@ -87,12 +101,44 @@ export const listOrganizationDomains = defineOrganizationConfigurationUseCase({ context, }: { principal: Principal - input: OrganizationInput + input: OrganizationInput & { + limit?: number + sortBy?: 'domain' + sortOrder?: ListSortOrder + cursorKeys?: CursorKey[] + } context: { role: string } }) { const isEnterprise = !isBillingEnabled || (await isOrganizationOnEnterprisePlan(input.organizationId)) - if (!isEnterprise) return { isEnterprise: false, domains: [], truncated: false } + if (!isEnterprise) + return { isEnterprise: false, domains: [], truncated: false, nextCursorKeys: null } + if (input.limit !== undefined) { + const sortKeys = [ + textKey(ssoDomain.domain, (row: DomainRow) => row.domain), + textKey(ssoDomain.id, (row: DomainRow) => row.id), + ] + const rows = await db + .select() + .from(ssoDomain) + .where( + and( + eq(ssoDomain.organizationId, input.organizationId), + resumeKeyset(sortKeys, input.cursorKeys, input.sortOrder ?? 'asc') + ) + ) + .orderBy(...listOrderBy(keysetColumns(sortKeys), input.sortOrder ?? 'asc')) + .limit(input.limit + 1) + const page = keysetPage(sortKeys, rows, input.limit) + return { + isEnterprise: true, + truncated: false, + nextCursorKeys: page.nextCursorKeys, + domains: page.data.map((row) => + domainValue(row, principal, context.role === 'owner' || context.role === 'admin') + ), + } + } const query = db .select() .from(ssoDomain) @@ -107,6 +153,7 @@ export const listOrganizationDomains = defineOrganizationConfigurationUseCase({ return { isEnterprise: true, truncated, + nextCursorKeys: null, domains: (truncated ? rows.slice(0, MAX_ORGANIZATION_DOMAINS) : rows).map((row) => domainValue(row, principal, context.role === 'owner' || context.role === 'admin') ), diff --git a/apps/sim/lib/organizations/application/operations.ts b/apps/sim/lib/organizations/application/operations.ts index a49d6f76d73..5f4fa9511d7 100644 --- a/apps/sim/lib/organizations/application/operations.ts +++ b/apps/sim/lib/organizations/application/operations.ts @@ -126,3 +126,53 @@ export const organizationSettingsOperations = { capability: 'none', }), } as const + +const securityPolicy = { + principalKinds: ['session', 'personal_api_key', 'oauth_access_token', 'organization_delegated'], + delegationAudience: 'sim:settings', + delegatedServices: ['copilot'], + /** permission-group-exempt: organization domain security is governed by membership, admin role and Enterprise entitlement. */ + capability: 'none', +} as const +export const organizationSecurityOperations = { + // permission-group-exempt: organization domain security is governed by membership, admin role and Enterprise entitlement. + listDomains: defineOrganizationOperation({ + oauthScope: 'api:read', + id: 'organizations.domains.list', + minimumRole: 'member', + ...securityPolicy, + capability: 'none', + }), + // permission-group-exempt: organization domain security is governed by membership, admin role and Enterprise entitlement. + addDomain: defineOrganizationOperation({ + oauthScope: 'api:write', + id: 'organizations.domains.add', + minimumRole: 'admin', + ...securityPolicy, + capability: 'none', + }), + // permission-group-exempt: organization domain security is governed by membership, admin role and Enterprise entitlement. + verifyDomain: defineOrganizationOperation({ + oauthScope: 'api:write', + id: 'organizations.domains.verify', + minimumRole: 'admin', + ...securityPolicy, + capability: 'none', + }), + // permission-group-exempt: organization domain security is governed by membership, admin role and Enterprise entitlement. + removeDomain: defineOrganizationOperation({ + oauthScope: 'api:write', + id: 'organizations.domains.remove', + minimumRole: 'admin', + ...securityPolicy, + capability: 'none', + }), + // permission-group-exempt: organization domain security is governed by membership, admin role and Enterprise entitlement. + revokeSessions: defineOrganizationOperation({ + id: 'organizations.sessions.revoke', + minimumRole: 'admin', + principalKinds: ['session'], + /** permission-group-exempt: session revocation requires current administrator session and Enterprise entitlement. */ + capability: 'none', + }), +} as const diff --git a/apps/sim/lib/organizations/application/revoke-sessions.ts b/apps/sim/lib/organizations/application/revoke-sessions.ts index c2a971d4ede..0be2c6c2e57 100644 --- a/apps/sim/lib/organizations/application/revoke-sessions.ts +++ b/apps/sim/lib/organizations/application/revoke-sessions.ts @@ -8,7 +8,7 @@ import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' import { isBillingEnabled } from '@/lib/core/config/env-flags' import { OrchestrationError } from '@/lib/core/orchestration/types' import { defineOrganizationConfigurationUseCase } from '@/lib/organizations/application/authorized-configuration-use-case' -import { organizationSecurityOperations } from '@/lib/organizations/application/security-operations' +import { organizationSecurityOperations } from '@/lib/organizations/application/operations' /** A genuine current session is required to preserve caller and impersonator access. */ export const revokeOrganizationSessions = defineOrganizationConfigurationUseCase({ diff --git a/apps/sim/lib/organizations/application/security-operations.ts b/apps/sim/lib/organizations/application/security-operations.ts deleted file mode 100644 index 0e3d73b99ab..00000000000 --- a/apps/sim/lib/organizations/application/security-operations.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { defineOrganizationOperation } from '@/lib/core/application/organization-operation' - -const policy = { - principalKinds: ['session', 'organization_delegated'], - delegationAudience: 'sim:settings', - delegatedServices: ['copilot'], - /** permission-group-exempt: organization domain security is governed by membership, admin role and Enterprise entitlement. */ - capability: 'none', -} as const -export const organizationSecurityOperations = { - // permission-group-exempt: organization domain security is governed by membership, admin role and Enterprise entitlement. - listDomains: defineOrganizationOperation({ - id: 'organizations.domains.list', - minimumRole: 'member', - ...policy, - capability: 'none', - }), - // permission-group-exempt: organization domain security is governed by membership, admin role and Enterprise entitlement. - addDomain: defineOrganizationOperation({ - id: 'organizations.domains.add', - minimumRole: 'admin', - ...policy, - capability: 'none', - }), - // permission-group-exempt: organization domain security is governed by membership, admin role and Enterprise entitlement. - verifyDomain: defineOrganizationOperation({ - id: 'organizations.domains.verify', - minimumRole: 'admin', - ...policy, - capability: 'none', - }), - // permission-group-exempt: organization domain security is governed by membership, admin role and Enterprise entitlement. - removeDomain: defineOrganizationOperation({ - id: 'organizations.domains.remove', - minimumRole: 'admin', - ...policy, - capability: 'none', - }), - // permission-group-exempt: organization domain security is governed by membership, admin role and Enterprise entitlement. - revokeSessions: defineOrganizationOperation({ - id: 'organizations.sessions.revoke', - minimumRole: 'admin', - principalKinds: ['session'], - /** permission-group-exempt: session revocation requires current administrator session and Enterprise entitlement. */ - capability: 'none', - }), -} as const diff --git a/apps/sim/lib/organizations/application/security-settings.test.ts b/apps/sim/lib/organizations/application/security-settings.test.ts index 36d20d7ebae..ff1634e233a 100644 --- a/apps/sim/lib/organizations/application/security-settings.test.ts +++ b/apps/sim/lib/organizations/application/security-settings.test.ts @@ -45,8 +45,8 @@ import { removeOrganizationDomain, verifyOrganizationDomain, } from '@/lib/organizations/application/domain-settings' +import { organizationSecurityOperations } from '@/lib/organizations/application/operations' import { revokeOrganizationSessions } from '@/lib/organizations/application/revoke-sessions' -import { organizationSecurityOperations } from '@/lib/organizations/application/security-operations' const mocks = { ...hoisted, @@ -161,7 +161,12 @@ describe('organization domain Settings operations', () => { mocks.enterprise.mockResolvedValue(false) await expect( listOrganizationDomains.execute({ principal: delegated, input: { organizationId: 'org' } }) - ).resolves.toEqual({ isEnterprise: false, domains: [], truncated: false }) + ).resolves.toEqual({ + isEnterprise: false, + domains: [], + truncated: false, + nextCursorKeys: null, + }) }) it.each(['remove', 'verify'] as const)( 'invalidates the SSO requirement after a committed domain %s', diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index 62dc0258cdd..e83d2bcff8e 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -344,6 +344,24 @@ export const CLI_CONTRACT: CliContract = { deleteCredential: { confirm: 'This disconnects the credential and removes its stored authentication.', }, + listCredentialMembers: { + columns: [ + { header: 'user', path: 'userId' }, + { header: 'name', path: 'userName' }, + { header: 'email', path: 'userEmail' }, + { header: 'role' }, + { header: 'status' }, + { header: 'source', path: 'roleSource' }, + ], + }, + upsertCredentialMember: { + command: 'credentials members upsert', + flags: { userId: { name: 'user' } }, + }, + removeCredentialMember: { + command: 'credentials members remove', + confirm: 'This revokes the selected user’s explicit credential grant.', + }, deleteSkill: { confirm: 'This deletes the skill.' }, revokeSkillEditor: { confirm: 'This revokes the explicit skill editor grant for the selected email.', @@ -1088,6 +1106,59 @@ export const CLI_CONTRACT: CliContract = { columns: [{ header: 'id' }, { header: 'name' }, { header: 'role' }], }, getOrganization: { command: 'organizations get' }, + listSsoProviders: { + command: 'organizations sso providers list', + pathFlags: ORGANIZATION_FLAG, + columns: [ + { header: 'provider', path: 'providerId' }, + { header: 'domain' }, + { header: 'primary', path: 'isPrimary', format: 'bool' }, + { header: 'verified', path: 'domainVerified', format: 'bool' }, + ], + }, + getSsoProvider: { + command: 'organizations sso providers get', + pathFlags: ORGANIZATION_FLAG, + }, + saveSsoProvider: { + command: 'organizations sso providers save', + pathFlags: ORGANIZATION_FLAG, + }, + deleteSsoProvider: { + command: 'organizations sso providers delete', + pathFlags: ORGANIZATION_FLAG, + confirm: 'This removes the identity provider used for SSO sign-in; linked accounts remain.', + }, + setPrimarySsoProvider: { + command: 'organizations sso providers primary', + pathFlags: ORGANIZATION_FLAG, + }, + getSsoPolicy: { + command: 'organizations sso policy get', + pathFlags: ORGANIZATION_FLAG, + }, + updateSsoPolicy: { + command: 'organizations sso policy update', + pathFlags: ORGANIZATION_FLAG, + }, + listOrganizationDomains: { + command: 'organizations domains list', + pathFlags: ORGANIZATION_FLAG, + columns: [{ header: 'id' }, { header: 'domain' }, { header: 'status' }], + }, + addOrganizationDomain: { + command: 'organizations domains add', + pathFlags: ORGANIZATION_FLAG, + }, + verifyOrganizationDomain: { + command: 'organizations domains verify', + pathFlags: ORGANIZATION_FLAG, + }, + removeOrganizationDomain: { + command: 'organizations domains remove', + pathFlags: ORGANIZATION_FLAG, + confirm: 'This removes the domain claim and revokes verified SSO authority for its providers.', + }, listOrganizationWorkspaces: { command: 'organizations workspaces', pathFlags: ORGANIZATION_FLAG, diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 7c8ebdbde70..6aa23058759 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -169,6 +169,28 @@ export type ActivateWorkflowVersionResponse = { data: ActivateWorkflowVersionResponseRef4 } +/** `POST /api/v2/organizations/[organizationId]/domains` */ +export type AddOrganizationDomainParams = { + organizationId: string +} + +export type AddOrganizationDomainQuery = Record + +export type AddOrganizationDomainBody = { + domain: string +} + +export type AddOrganizationDomainResponse = { + data: { + id: string + domain: string + status: 'pending' | 'verified' + verifiedAt: string | null + challengeHost: string + txtRecordValue: string | null + } +} + /** `POST /api/v2/organizations/[organizationId]/permission-groups/[groupId]/members` */ export type AddPermissionGroupMemberParams = { organizationId: string @@ -4054,6 +4076,21 @@ export type DeleteSkillResponse = { data: DeleteSkillResponseRef0 } +/** `DELETE /api/v2/organizations/[organizationId]/sso/providers/[providerId]` */ +export type DeleteSsoProviderParams = { + organizationId: string + providerId: string +} + +export type DeleteSsoProviderQuery = Record + +export type DeleteSsoProviderResponse = { + data: { + providerId: string + deleted: true + } +} + /** `DELETE /api/v2/tables/[tableId]` */ export type DeleteTableParams = { tableId: string @@ -6409,6 +6446,45 @@ export type GetSkillResponse = { data: GetSkillResponseRef0 } +/** `GET /api/v2/organizations/[organizationId]/sso/policy` */ +export type GetSsoPolicyParams = { + organizationId: string +} + +export type GetSsoPolicyQuery = Record + +export type GetSsoPolicyResponse = { + data: { + requireSso: boolean + hasVerifiedProvider: boolean + isEnforced: boolean + } +} + +/** `GET /api/v2/organizations/[organizationId]/sso/providers/[providerId]` */ +export type GetSsoProviderParams = { + organizationId: string + providerId: string +} + +export type GetSsoProviderQuery = Record + +export type GetSsoProviderResponse = { + data: { + id: string + providerId: string + providerType: 'oidc' | 'saml' + domain: string + domainKey: string + issuer: string + oidcConfig: string | null + samlConfig: string | null + jitProvisioningEnabled: boolean + domainVerified: boolean + isPrimary: boolean + } +} + /** `GET /api/v2/tables/[tableId]` */ export type GetTableParams = { tableId: string @@ -7680,6 +7756,34 @@ export type ListConnectorTypesResponse = { nextCursor: string | null } +/** `GET /api/v2/credentials/[credentialId]/members` */ +export type ListCredentialMembersParams = { + credentialId: string +} + +export type ListCredentialMembersQuery = { + workspaceId: string + limit?: number + cursor?: string + sortBy?: 'email' | 'name' + sortOrder?: 'asc' | 'desc' +} + +export type ListCredentialMembersResponse = { + data: Array<{ + id: string + userId: string + role: 'admin' | 'member' + status: 'active' | 'pending' | 'revoked' + joinedAt: string | null + userName: string | null + userEmail: string | null + userImage: string | null + roleSource: 'explicit' | 'workspace-admin' + }> + nextCursor: string | null +} + /** `GET /api/v2/credentials/providers` */ export type ListCredentialProvidersQuery = { workspaceId: string @@ -8697,6 +8801,30 @@ export type ListOrganizationAccessRequestsResponse = { nextCursor: string | null } +/** `GET /api/v2/organizations/[organizationId]/domains` */ +export type ListOrganizationDomainsParams = { + organizationId: string +} + +export type ListOrganizationDomainsQuery = { + limit?: number + cursor?: string + sortBy?: 'domain' + sortOrder?: 'asc' | 'desc' +} + +export type ListOrganizationDomainsResponse = { + data: Array<{ + id: string + domain: string + status: 'pending' | 'verified' + verifiedAt: string | null + challengeHost: string + txtRecordValue: string | null + }> + nextCursor: string | null +} + /** `GET /api/v2/organizations/[organizationId]/invitations` */ export type ListOrganizationInvitationsParams = { organizationId: string @@ -9246,6 +9374,35 @@ export type ListSkillsResponse = { nextCursor: string | null } +/** `GET /api/v2/organizations/[organizationId]/sso/providers` */ +export type ListSsoProvidersParams = { + organizationId: string +} + +export type ListSsoProvidersQuery = { + limit?: number + cursor?: string + sortBy?: 'providerId' | 'domain' + sortOrder?: 'asc' | 'desc' +} + +export type ListSsoProvidersResponse = { + data: Array<{ + id: string + providerId: string + providerType: 'oidc' | 'saml' + domain: string + domainKey: string + issuer: string + oidcConfig: string | null + samlConfig: string | null + jitProvisioningEnabled: boolean + domainVerified: boolean + isPrimary: boolean + }> + nextCursor: string | null +} + /** `GET /api/v2/tables/[tableId]/dispatches` */ export type ListTableDispatchesParams = { tableId: string @@ -11365,6 +11522,38 @@ export type RelocateWorkflowFolderResponse = { data: RelocateWorkflowFolderResponseRef0 } +/** `DELETE /api/v2/credentials/[credentialId]/members/[userId]` */ +export type RemoveCredentialMemberParams = { + credentialId: string + userId: string +} + +export type RemoveCredentialMemberQuery = { + workspaceId: string +} + +export type RemoveCredentialMemberResponse = { + data: { + userId: string + revoked: true + } +} + +/** `DELETE /api/v2/organizations/[organizationId]/domains/[domainId]` */ +export type RemoveOrganizationDomainParams = { + organizationId: string + domainId: string +} + +export type RemoveOrganizationDomainQuery = Record + +export type RemoveOrganizationDomainResponse = { + data: { + id: string + deleted: true + } +} + /** `DELETE /api/v2/organizations/[organizationId]/members/[userId]` */ export type RemoveOrganizationMemberParams = { organizationId: string @@ -12305,6 +12494,67 @@ export type RunRowEnrichmentResponse = { data: RunRowEnrichmentResponseRef0 } +/** `POST /api/v2/organizations/[organizationId]/sso/providers` */ +export type SaveSsoProviderParams = { + organizationId: string +} + +export type SaveSsoProviderQuery = Record + +export type SaveSsoProviderBody = + | { + providerType: 'oidc' + providerId: string + issuer: string + domain: string + jitProvisioningEnabled?: boolean + mapping?: { + id?: string + email?: string + name?: string + image?: string + } + clientId: string + clientSecret: string + scopes?: Array + pkce?: boolean + authorizationEndpoint?: string + tokenEndpoint?: string + userInfoEndpoint?: string + skipUserInfoEndpoint?: boolean + jwksEndpoint?: string + } + | { + providerType: 'saml' + providerId: string + issuer: string + domain: string + jitProvisioningEnabled?: boolean + mapping?: { + id?: string + email?: string + name?: string + image?: string + } + entryPoint: string + cert: string + callbackUrl?: string + audience?: string + wantAssertionsSigned?: boolean + signatureAlgorithm?: string + digestAlgorithm?: string + identifierFormat?: string + idpMetadata?: string + } + +export type SaveSsoProviderResponse = { + data: { + providerId: string + providerType: 'oidc' | 'saml' + created: boolean + } +} + /** `GET /api/v2/files/search` */ export type SearchFileContentQuery = { workspaceId: string @@ -12529,6 +12779,23 @@ export type SearchTableRowsResponse = { data: SearchTableRowsResponseRef1 } +/** `POST /api/v2/organizations/[organizationId]/sso/providers/[providerId]/primary` */ +export type SetPrimarySsoProviderParams = { + organizationId: string + providerId: string +} + +export type SetPrimarySsoProviderQuery = Record + +export type SetPrimarySsoProviderBody = Record + +export type SetPrimarySsoProviderResponse = { + data: { + providerId: string + domain: string + } +} + /** `PUT /api/v2/secrets/[name]` */ export type SetSecretParams = { name: string @@ -13553,6 +13820,25 @@ export type UpdateSkillResponse = { data: UpdateSkillResponseRef0 } +/** `PATCH /api/v2/organizations/[organizationId]/sso/policy` */ +export type UpdateSsoPolicyParams = { + organizationId: string +} + +export type UpdateSsoPolicyQuery = Record + +export type UpdateSsoPolicyBody = { + requireSso: boolean +} + +export type UpdateSsoPolicyResponse = { + data: { + requireSso: boolean + hasVerifiedProvider: boolean + isEnforced: boolean + } +} + /** `PATCH /api/v2/tables/[tableId]` */ export type UpdateTableParams = { tableId: string @@ -14134,6 +14420,28 @@ export type UploadKnowledgeDocumentResponse = { data: UploadKnowledgeDocumentResponseRef0 } +/** `POST /api/v2/credentials/[credentialId]/members` */ +export type UpsertCredentialMemberParams = { + credentialId: string +} + +export type UpsertCredentialMemberQuery = { + workspaceId: string +} + +export type UpsertCredentialMemberBody = { + userId: string + role: 'admin' | 'member' +} + +export type UpsertCredentialMemberResponse = { + data: { + userId: string + role: 'admin' | 'member' + created: boolean + } +} + /** `PATCH /api/v2/files/[fileId]/share` */ export type UpsertFileShareParams = { fileId: string @@ -14209,6 +14517,27 @@ export type UpsertTableRowResponse = { data: UpsertTableRowResponseRef3 } +/** `POST /api/v2/organizations/[organizationId]/domains/[domainId]/verify` */ +export type VerifyOrganizationDomainParams = { + organizationId: string + domainId: string +} + +export type VerifyOrganizationDomainQuery = Record + +export type VerifyOrganizationDomainBody = Record + +export type VerifyOrganizationDomainResponse = { + data: { + id: string + domain: string + status: 'pending' | 'verified' + verifiedAt: string | null + challengeHost: string + txtRecordValue: string | null + } +} + /** * Every v2 operation, keyed by name. * @@ -14285,6 +14614,22 @@ export const V2_OPERATIONS = { summary: 'Activate Workflow Version', workspaceKeyUnsupported: true, }, + addOrganizationDomain: { + method: 'POST', + path: '/api/v2/organizations/[organizationId]/domains', + pathParams: ['organizationId'] as const, + pathParamDocs: { organizationId: 'Organization whose single sign-on settings are managed.' }, + responseMode: 'json', + summary: 'Add Organization Domain', + workspaceKeyUnsupported: true, + body: { + domain: { + kind: 'string', + required: true, + describe: 'Domain to claim and verify through a DNS TXT record.', + }, + }, + }, addPermissionGroupMember: { method: 'POST', path: '/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members', @@ -16003,6 +16348,18 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true, describe: 'Workspace that owns the skill.' }, }, }, + deleteSsoProvider: { + method: 'DELETE', + path: '/api/v2/organizations/[organizationId]/sso/providers/[providerId]', + pathParams: ['organizationId', 'providerId'] as const, + pathParamDocs: { + organizationId: 'Organization whose single sign-on settings are managed.', + providerId: 'Identity provider identifier.', + }, + responseMode: 'json', + summary: 'Delete SSO Provider', + workspaceKeyUnsupported: true, + }, deleteTable: { method: 'DELETE', path: '/api/v2/tables/[tableId]', @@ -17227,6 +17584,27 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true, describe: 'Workspace that owns the skill.' }, }, }, + getSsoPolicy: { + method: 'GET', + path: '/api/v2/organizations/[organizationId]/sso/policy', + pathParams: ['organizationId'] as const, + pathParamDocs: { organizationId: 'Organization whose single sign-on settings are managed.' }, + responseMode: 'json', + summary: 'Get SSO Policy', + workspaceKeyUnsupported: true, + }, + getSsoProvider: { + method: 'GET', + path: '/api/v2/organizations/[organizationId]/sso/providers/[providerId]', + pathParams: ['organizationId', 'providerId'] as const, + pathParamDocs: { + organizationId: 'Organization whose single sign-on settings are managed.', + providerId: 'Identity provider identifier.', + }, + responseMode: 'json', + summary: 'Get SSO Provider', + workspaceKeyUnsupported: true, + }, getTable: { method: 'GET', path: '/api/v2/tables/[tableId]', @@ -17831,6 +18209,46 @@ export const V2_OPERATIONS = { }, }, }, + listCredentialMembers: { + method: 'GET', + path: '/api/v2/credentials/[credentialId]/members', + pathParams: ['credentialId'] as const, + pathParamDocs: { credentialId: 'Credential whose sharing grants are managed.' }, + responseMode: 'json', + summary: 'List Credential Members', + workspaceKeyUnsupported: true, + query: { + workspaceId: { + kind: 'string', + required: true, + describe: 'Workspace expected to own the credential.', + }, + limit: { + kind: 'integer', + default: 50, + describe: + 'Maximum credential members to return per page. Must be a whole number from 1 to 100. Defaults to 50.', + }, + cursor: { + kind: 'string', + describe: + 'Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.', + }, + sortBy: { + kind: 'enum', + values: ['email', 'name'] as const, + default: 'email', + describe: + 'Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.', + }, + sortOrder: { + kind: 'enum', + values: ['asc', 'desc'] as const, + default: 'asc', + describe: 'Sort direction.', + }, + }, + }, listCredentialProviders: { method: 'GET', path: '/api/v2/credentials/providers', @@ -18733,6 +19151,40 @@ export const V2_OPERATIONS = { }, }, }, + listOrganizationDomains: { + method: 'GET', + path: '/api/v2/organizations/[organizationId]/domains', + pathParams: ['organizationId'] as const, + pathParamDocs: { organizationId: 'Organization whose single sign-on settings are managed.' }, + responseMode: 'json', + summary: 'List Organization Domains', + workspaceKeyUnsupported: true, + query: { + limit: { + kind: 'integer', + default: 50, + describe: + 'Maximum domain claims to return per page. Must be a whole number from 1 to 100. Defaults to 50.', + }, + cursor: { + kind: 'string', + describe: + 'Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.', + }, + sortBy: { + kind: 'enum', + values: ['domain'] as const, + default: 'domain', + describe: 'Field used to sort the result.', + }, + sortOrder: { + kind: 'enum', + values: ['asc', 'desc'] as const, + default: 'asc', + describe: 'Sort direction.', + }, + }, + }, listOrganizationInvitations: { method: 'GET', path: '/api/v2/organizations/[organizationId]/invitations', @@ -19388,6 +19840,40 @@ export const V2_OPERATIONS = { }, }, }, + listSsoProviders: { + method: 'GET', + path: '/api/v2/organizations/[organizationId]/sso/providers', + pathParams: ['organizationId'] as const, + pathParamDocs: { organizationId: 'Organization whose single sign-on settings are managed.' }, + responseMode: 'json', + summary: 'List SSO Providers', + workspaceKeyUnsupported: true, + query: { + limit: { + kind: 'integer', + default: 50, + describe: + 'Maximum identity providers to return per page. Must be a whole number from 1 to 100. Defaults to 50.', + }, + cursor: { + kind: 'string', + describe: + 'Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.', + }, + sortBy: { + kind: 'enum', + values: ['providerId', 'domain'] as const, + default: 'providerId', + describe: 'Field used to sort the result.', + }, + sortOrder: { + kind: 'enum', + values: ['asc', 'desc'] as const, + default: 'asc', + describe: 'Sort direction.', + }, + }, + }, listTableDispatches: { method: 'GET', path: '/api/v2/tables/[tableId]/dispatches', @@ -20436,6 +20922,37 @@ export const V2_OPERATIONS = { }, }, }, + removeCredentialMember: { + method: 'DELETE', + path: '/api/v2/credentials/[credentialId]/members/[userId]', + pathParams: ['credentialId', 'userId'] as const, + pathParamDocs: { + credentialId: 'Credential whose sharing grants are managed.', + userId: 'User whose explicit grant will be revoked.', + }, + responseMode: 'json', + summary: 'Remove Credential Member', + workspaceKeyUnsupported: true, + query: { + workspaceId: { + kind: 'string', + required: true, + describe: 'Workspace expected to own the credential.', + }, + }, + }, + removeOrganizationDomain: { + method: 'DELETE', + path: '/api/v2/organizations/[organizationId]/domains/[domainId]', + pathParams: ['organizationId', 'domainId'] as const, + pathParamDocs: { + organizationId: 'Organization whose single sign-on settings are managed.', + domainId: 'Domain claim owned by this organization.', + }, + responseMode: 'json', + summary: 'Remove Organization Domain', + workspaceKeyUnsupported: true, + }, removeOrganizationMember: { method: 'DELETE', path: '/api/v2/organizations/[organizationId]/members/[userId]', @@ -20862,6 +21379,282 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true, describe: 'Unique workspace identifier.' }, }, }, + saveSsoProvider: { + method: 'POST', + path: '/api/v2/organizations/[organizationId]/sso/providers', + pathParams: ['organizationId'] as const, + pathParamDocs: { organizationId: 'Organization whose single sign-on settings are managed.' }, + responseMode: 'json', + summary: 'Save SSO Provider', + workspaceKeyUnsupported: true, + body: { + providerType: { + kind: 'enum', + required: true, + values: ['oidc', 'saml'] as const, + describe: + 'oidc: Configure an OpenID Connect identity provider. saml: Configure a SAML identity provider.', + }, + providerId: { + kind: 'string', + required: true, + describe: + 'Globally unique provider ID; saving an existing provider replaces its supplied configuration.', + }, + issuer: { kind: 'string', required: true, describe: 'Identity provider issuer URL.' }, + domain: { + kind: 'string', + required: true, + describe: 'Email domain already verified by this organization.', + }, + jitProvisioningEnabled: { + kind: 'boolean', + default: true, + describe: + 'Allow SSO sign-in to provision organization membership, subject to eligibility and available seats.', + }, + mapping: { + kind: 'object', + default: { id: 'sub', email: 'email', name: 'name', image: 'picture' }, + describe: 'Identity-provider claims mapped to user fields.', + }, + clientId: { + kind: 'string', + describe: + 'Identity provider client identifier. Available when providerType is oidc. Required when providerType is oidc.', + }, + clientSecret: { + kind: 'string', + describe: + 'Write-only client secret; the redacted marker from Get SSO Provider preserves an existing secret. Available when providerType is oidc. Required when providerType is oidc.', + }, + scopes: { + kind: 'array', + default: ['openid', 'profile', 'email'], + describe: 'OIDC scopes; offline_access is omitted. Available when providerType is oidc.', + }, + pkce: { + kind: 'boolean', + default: true, + describe: 'Use PKCE for the authorization flow. Available when providerType is oidc.', + }, + authorizationEndpoint: { + kind: 'string', + describe: + 'Optional authorization endpoint; otherwise resolved through issuer discovery. Available when providerType is oidc.', + }, + tokenEndpoint: { + kind: 'string', + describe: + 'Optional token endpoint; otherwise resolved through issuer discovery. Available when providerType is oidc.', + }, + userInfoEndpoint: { + kind: 'string', + describe: 'Optional UserInfo endpoint. Available when providerType is oidc.', + }, + skipUserInfoEndpoint: { + kind: 'boolean', + default: false, + describe: + 'Read identity claims from the ID token instead of calling UserInfo. Available when providerType is oidc.', + }, + jwksEndpoint: { + kind: 'string', + describe: + 'Optional signing-key endpoint; otherwise resolved through issuer discovery. Available when providerType is oidc.', + }, + entryPoint: { + kind: 'string', + describe: + 'Identity provider SAML sign-in endpoint. Available when providerType is saml. Required when providerType is saml.', + }, + cert: { + kind: 'string', + describe: + 'Identity provider signing certificate. Available when providerType is saml. Required when providerType is saml.', + }, + callbackUrl: { + kind: 'string', + describe: + 'SAML callback URL; defaults to this provider’s Sim callback. Available when providerType is saml.', + }, + audience: { + kind: 'string', + describe: + 'SAML audience; omission preserves the saved value. Available when providerType is saml.', + }, + wantAssertionsSigned: { + kind: 'boolean', + describe: + 'Require signed assertions; omission preserves the saved value. Available when providerType is saml.', + }, + signatureAlgorithm: { + kind: 'string', + describe: + 'Signature algorithm accepted by the SAML configuration validator; omission preserves the saved value. Available when providerType is saml.', + }, + digestAlgorithm: { + kind: 'string', + describe: + 'Digest algorithm accepted by the SAML configuration validator; omission preserves the saved value. Available when providerType is saml.', + }, + identifierFormat: { + kind: 'string', + describe: + 'SAML NameID format; omission clears the saved value. Available when providerType is saml.', + }, + idpMetadata: { + kind: 'string', + describe: + 'Identity provider metadata XML; omission clears the saved document. Available when providerType is saml.', + }, + }, + bodyDiscriminator: { + field: 'providerType', + variants: { + oidc: { + providerType: { + kind: 'string', + required: true, + describe: 'Configure an OpenID Connect identity provider.', + }, + providerId: { + kind: 'string', + required: true, + describe: + 'Globally unique provider ID; saving an existing provider replaces its supplied configuration.', + }, + issuer: { kind: 'string', required: true, describe: 'Identity provider issuer URL.' }, + domain: { + kind: 'string', + required: true, + describe: 'Email domain already verified by this organization.', + }, + jitProvisioningEnabled: { + kind: 'boolean', + default: true, + describe: + 'Allow SSO sign-in to provision organization membership, subject to eligibility and available seats.', + }, + mapping: { + kind: 'object', + default: { id: 'sub', email: 'email', name: 'name', image: 'picture' }, + describe: 'Identity-provider claims mapped to user fields.', + }, + clientId: { + kind: 'string', + required: true, + describe: 'Identity provider client identifier.', + }, + clientSecret: { + kind: 'string', + required: true, + describe: + 'Write-only client secret; the redacted marker from Get SSO Provider preserves an existing secret.', + }, + scopes: { + kind: 'array', + default: ['openid', 'profile', 'email'], + describe: 'OIDC scopes; offline_access is omitted.', + }, + pkce: { + kind: 'boolean', + default: true, + describe: 'Use PKCE for the authorization flow.', + }, + authorizationEndpoint: { + kind: 'string', + describe: + 'Optional authorization endpoint; otherwise resolved through issuer discovery.', + }, + tokenEndpoint: { + kind: 'string', + describe: 'Optional token endpoint; otherwise resolved through issuer discovery.', + }, + userInfoEndpoint: { kind: 'string', describe: 'Optional UserInfo endpoint.' }, + skipUserInfoEndpoint: { + kind: 'boolean', + default: false, + describe: 'Read identity claims from the ID token instead of calling UserInfo.', + }, + jwksEndpoint: { + kind: 'string', + describe: 'Optional signing-key endpoint; otherwise resolved through issuer discovery.', + }, + }, + saml: { + providerType: { + kind: 'string', + required: true, + describe: 'Configure a SAML identity provider.', + }, + providerId: { + kind: 'string', + required: true, + describe: + 'Globally unique provider ID; saving an existing provider replaces its supplied configuration.', + }, + issuer: { kind: 'string', required: true, describe: 'Identity provider issuer URL.' }, + domain: { + kind: 'string', + required: true, + describe: 'Email domain already verified by this organization.', + }, + jitProvisioningEnabled: { + kind: 'boolean', + default: true, + describe: + 'Allow SSO sign-in to provision organization membership, subject to eligibility and available seats.', + }, + mapping: { + kind: 'object', + default: { id: 'sub', email: 'email', name: 'name', image: 'picture' }, + describe: 'Identity-provider claims mapped to user fields.', + }, + entryPoint: { + kind: 'string', + required: true, + describe: 'Identity provider SAML sign-in endpoint.', + }, + cert: { + kind: 'string', + required: true, + describe: 'Identity provider signing certificate.', + }, + callbackUrl: { + kind: 'string', + describe: 'SAML callback URL; defaults to this provider’s Sim callback.', + }, + audience: { + kind: 'string', + describe: 'SAML audience; omission preserves the saved value.', + }, + wantAssertionsSigned: { + kind: 'boolean', + describe: 'Require signed assertions; omission preserves the saved value.', + }, + signatureAlgorithm: { + kind: 'string', + describe: + 'Signature algorithm accepted by the SAML configuration validator; omission preserves the saved value.', + }, + digestAlgorithm: { + kind: 'string', + describe: + 'Digest algorithm accepted by the SAML configuration validator; omission preserves the saved value.', + }, + identifierFormat: { + kind: 'string', + describe: 'SAML NameID format; omission clears the saved value.', + }, + idpMetadata: { + kind: 'string', + describe: 'Identity provider metadata XML; omission clears the saved document.', + }, + }, + }, + }, + }, searchFileContent: { method: 'GET', path: '/api/v2/files/search', @@ -20983,6 +21776,18 @@ export const V2_OPERATIONS = { sort: { kind: 'array', describe: 'Ordered table-row sort specification.' }, }, }, + setPrimarySsoProvider: { + method: 'POST', + path: '/api/v2/organizations/[organizationId]/sso/providers/[providerId]/primary', + pathParams: ['organizationId', 'providerId'] as const, + pathParamDocs: { + organizationId: 'Organization whose single sign-on settings are managed.', + providerId: 'Identity provider identifier.', + }, + responseMode: 'json', + summary: 'Set Primary SSO Provider', + workspaceKeyUnsupported: true, + }, setSecret: { method: 'PUT', path: '/api/v2/secrets/[name]', @@ -21618,6 +22423,22 @@ export const V2_OPERATIONS = { content: { kind: 'string', describe: 'Replacement skill body.' }, }, }, + updateSsoPolicy: { + method: 'PATCH', + path: '/api/v2/organizations/[organizationId]/sso/policy', + pathParams: ['organizationId'] as const, + pathParamDocs: { organizationId: 'Organization whose single sign-on settings are managed.' }, + responseMode: 'json', + summary: 'Update SSO Policy', + workspaceKeyUnsupported: true, + body: { + requireSso: { + kind: 'boolean', + required: true, + describe: 'Require organization SSO on future sign-ins; existing sessions remain active.', + }, + }, + }, updateTable: { method: 'PATCH', path: '/api/v2/tables/[tableId]', @@ -21859,6 +22680,35 @@ export const V2_OPERATIONS = { }, }, }, + upsertCredentialMember: { + method: 'POST', + path: '/api/v2/credentials/[credentialId]/members', + pathParams: ['credentialId'] as const, + pathParamDocs: { credentialId: 'Credential whose sharing grants are managed.' }, + responseMode: 'json', + summary: 'Upsert Credential Member', + workspaceKeyUnsupported: true, + query: { + workspaceId: { + kind: 'string', + required: true, + describe: 'Workspace expected to own the credential.', + }, + }, + body: { + userId: { + kind: 'string', + required: true, + describe: 'Existing workspace member to grant or change access for.', + }, + role: { + kind: 'enum', + required: true, + values: ['admin', 'member'] as const, + describe: 'Credential role to grant; workspace administrators cannot be demoted.', + }, + }, + }, upsertFileShare: { method: 'PATCH', path: '/api/v2/files/[fileId]/share', @@ -21911,6 +22761,18 @@ export const V2_OPERATIONS = { conflictTarget: { kind: 'string', describe: 'Unique column used to detect a conflict.' }, }, }, + verifyOrganizationDomain: { + method: 'POST', + path: '/api/v2/organizations/[organizationId]/domains/[domainId]/verify', + pathParams: ['organizationId', 'domainId'] as const, + pathParamDocs: { + organizationId: 'Organization whose single sign-on settings are managed.', + domainId: 'Domain claim owned by this organization.', + }, + responseMode: 'json', + summary: 'Verify Organization Domain', + workspaceKeyUnsupported: true, + }, } as const export type V2OperationName = keyof typeof V2_OPERATIONS diff --git a/packages/sim-cli/src/http/client.test.ts b/packages/sim-cli/src/http/client.test.ts index b08ba9057df..4cf4279cc4f 100644 --- a/packages/sim-cli/src/http/client.test.ts +++ b/packages/sim-cli/src/http/client.test.ts @@ -433,6 +433,7 @@ describe('destructive operations are gated', () => { 'updatePermissionGroup', 'addPermissionGroupMember', 'bulkAddPermissionGroupMembers', + 'addOrganizationDomain', 'forkWorkspace', 'getSelector', 'listSelector', @@ -518,8 +519,11 @@ describe('destructive operations are gated', () => { 'bulkSaveKnowledgeTagDefinitions', 'searchKnowledge', 'setSecret', + 'saveSsoProvider', + 'setPrimarySsoProvider', 'syncKnowledgeConnector', 'updateCredential', + 'updateSsoPolicy', 'updateCustomTool', 'updateFileContent', 'updateKnowledgeBase', @@ -542,7 +546,9 @@ describe('destructive operations are gated', () => { 'updateWorkflowVersion', 'uploadKnowledgeDocument', 'upsertFileShare', + 'upsertCredentialMember', 'upsertTableRow', + 'verifyOrganizationDomain', ]) it('forces every non-GET operation into a destructiveness classification', () => { diff --git a/scripts/check-explicit-any.baseline.json b/scripts/check-explicit-any.baseline.json index 60da56b0fb0..3efe2c8b77f 100644 --- a/scripts/check-explicit-any.baseline.json +++ b/scripts/check-explicit-any.baseline.json @@ -7,7 +7,6 @@ "apps/sim/app/(auth)/verify/use-verification.ts": 1, "apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx": 16, "apps/sim/app/api/auth/oauth/utils.test.ts": 2, - "apps/sim/app/api/auth/sso/register/route.ts": 3, "apps/sim/app/api/chat/[identifier]/route.test.ts": 1, "apps/sim/app/api/chat/[identifier]/route.ts": 5, "apps/sim/app/api/chat/utils.test.ts": 4, From 76ddc23bcef680ca1b5756ba920ea022a272673f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 7 Oct 2026 19:38:56 -0700 Subject: [PATCH 2/5] fix(api): make SSO administration atomic and align CLI actions --- apps/docs/content/docs/cli/credentials.mdx | 3 +- apps/docs/content/docs/cli/organizations.mdx | 8 +- apps/docs/content/docs/cli/reference.mdx | 11 +- apps/docs/openapi-v2-billing.json | 1 + apps/docs/openapi-v2-files-audit.json | 1 + apps/docs/openapi-v2-knowledge.json | 1 + apps/docs/openapi-v2-logs.json | 1 + apps/docs/openapi-v2-resources.json | 72 ++- apps/docs/openapi-v2-tables.json | 1 + apps/docs/openapi-v2-workflows.json | 1 + .../app/api/auth/sso/register/route.test.ts | 229 -------- .../app/api/mothership/execute/route.test.ts | 12 +- apps/sim/app/api/v2/chat/route.test.ts | 17 +- .../domains/[domainId]/verify/route.ts | 1 + .../providers/[providerId]/primary/route.ts | 1 + apps/sim/lib/api/contracts/auth.ts | 5 +- .../v2/openapi/credential-members.ts | 8 +- apps/sim/lib/api/contracts/v2/sso.ts | 6 +- .../routes/copilot-route-inventory.test.ts | 18 +- apps/sim/lib/api/server/routes/sso.ts | 3 +- apps/sim/lib/api/server/sso-presenters.ts | 3 +- apps/sim/lib/auth/auth.ts | 55 +- apps/sim/lib/auth/sim-auth-adapter.ts | 59 +- apps/sim/lib/auth/sso-trust.test.ts | 19 +- .../sso/application/provider-registration.ts | 371 ++++-------- .../provider-settings.integration.ts | 545 +++++++++++++++++- .../auth/sso/application/provider-settings.ts | 14 + .../auth/sso/application/sso-requirement.ts | 2 +- apps/sim/lib/auth/sso/plugin.ts | 26 + .../auth/sso/primary-provider.integration.ts | 34 +- apps/sim/lib/auth/sso/provider-adapter.ts | 74 +-- .../sso/provider-concurrency.integration.ts | 281 +++++++++ apps/sim/lib/auth/sso/provider-lock.ts | 7 + apps/sim/lib/auth/sso/provider-repository.ts | 54 +- apps/sim/lib/core/application/forbidden.ts | 2 + .../credential-sharing.integration.ts | 27 + apps/sim/lib/credentials/members.ts | 28 +- .../slack-search/assistant.integration.ts | 23 +- .../slack-search/assistant.test.ts | 10 +- .../lib/knowledge/mcp/server.protocol.test.ts | 2 + apps/sim/lib/knowledge/mcp/server.test.ts | 2 + .../sim/lib/mothership/inbox/executor.test.ts | 17 +- apps/sim/lib/mothership/tasks/wake.test.ts | 26 +- .../application/domain-settings.ts | 2 +- packages/sim-cli/src/contract/commands.ts | 7 + packages/sim-cli/src/contract/types.ts | 2 + packages/sim-cli/src/generated/v2-api.ts | 45 +- packages/sim-cli/src/runtime/options.ts | 15 +- packages/sim-cli/src/runtime/request.ts | 4 + packages/testing/src/mocks/auth.mock.ts | 3 + .../mothership-headless-lifecycle.mock.ts | 11 + 51 files changed, 1367 insertions(+), 803 deletions(-) create mode 100644 apps/sim/lib/auth/sso/plugin.ts create mode 100644 apps/sim/lib/auth/sso/provider-concurrency.integration.ts create mode 100644 apps/sim/lib/auth/sso/provider-lock.ts create mode 100644 packages/testing/src/mocks/mothership-headless-lifecycle.mock.ts diff --git a/apps/docs/content/docs/cli/credentials.mdx b/apps/docs/content/docs/cli/credentials.mdx index de465ee06ff..2a4393937cf 100644 --- a/apps/docs/content/docs/cli/credentials.mdx +++ b/apps/docs/content/docs/cli/credentials.mdx @@ -61,8 +61,7 @@ List Credential Members (OAuth login or personal API key required) | Option | Required | Description | | --- | --- | --- | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | -| `--cursor ` | No | Continue from nextCursor returned by a previous result. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `email`, `name`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | diff --git a/apps/docs/content/docs/cli/organizations.mdx b/apps/docs/content/docs/cli/organizations.mdx index c72b99dc436..05c47a2d394 100644 --- a/apps/docs/content/docs/cli/organizations.mdx +++ b/apps/docs/content/docs/cli/organizations.mdx @@ -41,8 +41,7 @@ List Organization Domains (OAuth login or personal API key required) | Option | Required | Description | | --- | --- | --- | | `--organization ` | Yes | Organization identifier. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | -| `--cursor ` | No | Continue from nextCursor returned by a previous result. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | | `--sort-by ` | No | Field used to sort the result. Accepted values: `domain`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | @@ -556,8 +555,7 @@ List SSO Providers (OAuth login or personal API key required) | Option | Required | Description | | --- | --- | --- | | `--organization ` | Yes | Organization identifier. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | -| `--cursor ` | No | Continue from nextCursor returned by a previous result. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | | `--sort-by ` | No | Field used to sort the result. Accepted values: `providerId`, `domain`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | @@ -586,7 +584,7 @@ Save SSO Provider (OAuth login or personal API key required) | `--no-jit-provisioning-enabled` | No | Send --jit-provisioning-enabled as false. | | `--mapping ` | No | Identity-provider claims mapped to user fields. (JSON, or @path / @- to read a file or stdin). | | `--client-id ` | No | Identity provider client identifier. Available when providerType is oidc. Required when providerType is oidc. | -| `--client-secret ` | No | Write-only client secret; the redacted marker from Get SSO Provider preserves an existing secret. Available when providerType is oidc. Required when providerType is oidc. | +| `--client-secret ` | No | Write-only OIDC client secret; the redacted marker from providers get preserves an existing secret. Passing it inline exposes it to shell history and process listings. Required when --provider-type is oidc (@path / @- reads a file or stdin verbatim, including trailing newlines; @@value for a literal leading @). | | `--scopes ` | No | OIDC scopes; offline_access is omitted. Available when providerType is oidc. (JSON, or @path / @- to read a file or stdin). | | `--pkce` | No | Use PKCE for the authorization flow. Available when providerType is oidc. | | `--no-pkce` | No | Send --pkce as false. | diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index 9dd4cdd532f..e3da93c12f6 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -490,8 +490,7 @@ sim credentials members list [options] | Option | Required | Description | | --- | --- | --- | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | -| `--cursor ` | No | Continue from nextCursor returned by a previous result. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `email`, `name`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | @@ -3153,8 +3152,7 @@ sim organizations domains list [options] | Option | Required | Description | | --- | --- | --- | | `--organization ` | Yes | Organization identifier. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | -| `--cursor ` | No | Continue from nextCursor returned by a previous result. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | | `--sort-by ` | No | Field used to sort the result. Accepted values: `domain`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | @@ -3668,8 +3666,7 @@ sim organizations sso providers list [options] | Option | Required | Description | | --- | --- | --- | | `--organization ` | Yes | Organization identifier. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | -| `--cursor ` | No | Continue from nextCursor returned by a previous result. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | | `--sort-by ` | No | Field used to sort the result. Accepted values: `providerId`, `domain`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | @@ -3698,7 +3695,7 @@ sim organizations sso providers save [options] | `--no-jit-provisioning-enabled` | No | Send --jit-provisioning-enabled as false. | | `--mapping ` | No | Identity-provider claims mapped to user fields. (JSON, or @path / @- to read a file or stdin). | | `--client-id ` | No | Identity provider client identifier. Available when providerType is oidc. Required when providerType is oidc. | -| `--client-secret ` | No | Write-only client secret; the redacted marker from Get SSO Provider preserves an existing secret. Available when providerType is oidc. Required when providerType is oidc. | +| `--client-secret ` | No | Write-only OIDC client secret; the redacted marker from providers get preserves an existing secret. Passing it inline exposes it to shell history and process listings. Required when --provider-type is oidc (@path / @- reads a file or stdin verbatim, including trailing newlines; @@value for a literal leading @). | | `--scopes ` | No | OIDC scopes; offline_access is omitted. Available when providerType is oidc. (JSON, or @path / @- to read a file or stdin). | | `--pkce` | No | Use PKCE for the authorization flow. Available when providerType is oidc. | | `--no-pkce` | No | Send --pkce as false. | diff --git a/apps/docs/openapi-v2-billing.json b/apps/docs/openapi-v2-billing.json index 3769e23500c..7869df5c9bf 100644 --- a/apps/docs/openapi-v2-billing.json +++ b/apps/docs/openapi-v2-billing.json @@ -475,6 +475,7 @@ "ORGANIZATION_MEMBERSHIP_REQUIRED", "ORGANIZATION_ADMIN_REQUIRED", "ENTERPRISE_PLAN_REQUIRED", + "SSO_DISABLED", "SSO_DOMAIN_NOT_VERIFIED", "SSO_PROVIDER_LIMIT_REACHED", "ORGANIZATION_PLAN_REQUIRED", diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 299c02b01e5..9e96b13c639 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -3836,6 +3836,7 @@ "ORGANIZATION_MEMBERSHIP_REQUIRED", "ORGANIZATION_ADMIN_REQUIRED", "ENTERPRISE_PLAN_REQUIRED", + "SSO_DISABLED", "SSO_DOMAIN_NOT_VERIFIED", "SSO_PROVIDER_LIMIT_REACHED", "ORGANIZATION_PLAN_REQUIRED", diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index f667508cab1..d54d703a883 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -4732,6 +4732,7 @@ "ORGANIZATION_MEMBERSHIP_REQUIRED", "ORGANIZATION_ADMIN_REQUIRED", "ENTERPRISE_PLAN_REQUIRED", + "SSO_DISABLED", "SSO_DOMAIN_NOT_VERIFIED", "SSO_PROVIDER_LIMIT_REACHED", "ORGANIZATION_PLAN_REQUIRED", diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json index 0016aefb8ee..2279e818cfc 100644 --- a/apps/docs/openapi-v2-logs.json +++ b/apps/docs/openapi-v2-logs.json @@ -866,6 +866,7 @@ "ORGANIZATION_MEMBERSHIP_REQUIRED", "ORGANIZATION_ADMIN_REQUIRED", "ENTERPRISE_PLAN_REQUIRED", + "SSO_DISABLED", "SSO_DOMAIN_NOT_VERIFIED", "SSO_PROVIDER_LIMIT_REACHED", "ORGANIZATION_PLAN_REQUIRED", diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index 20b70430ae2..2c85cbdf23a 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -6918,11 +6918,11 @@ "name": "organizationId", "in": "path", "required": true, - "description": "Organization whose single sign-on settings are managed.", + "description": "Organization whose single sign-on settings and verified domains are managed.", "schema": { "type": "string", "minLength": 1, - "description": "Organization whose single sign-on settings are managed." + "description": "Organization whose single sign-on settings and verified domains are managed." } }, { @@ -7034,11 +7034,11 @@ "name": "organizationId", "in": "path", "required": true, - "description": "Organization whose single sign-on settings are managed.", + "description": "Organization whose single sign-on settings and verified domains are managed.", "schema": { "type": "string", "minLength": 1, - "description": "Organization whose single sign-on settings are managed." + "description": "Organization whose single sign-on settings and verified domains are managed." } } ], @@ -7142,11 +7142,11 @@ "name": "organizationId", "in": "path", "required": true, - "description": "Organization whose single sign-on settings are managed.", + "description": "Organization whose single sign-on settings and verified domains are managed.", "schema": { "type": "string", "minLength": 1, - "description": "Organization whose single sign-on settings are managed." + "description": "Organization whose single sign-on settings and verified domains are managed." } }, { @@ -7222,11 +7222,11 @@ "name": "organizationId", "in": "path", "required": true, - "description": "Organization whose single sign-on settings are managed.", + "description": "Organization whose single sign-on settings and verified domains are managed.", "schema": { "type": "string", "minLength": 1, - "description": "Organization whose single sign-on settings are managed." + "description": "Organization whose single sign-on settings and verified domains are managed." } }, { @@ -7304,11 +7304,11 @@ "name": "organizationId", "in": "path", "required": true, - "description": "Organization whose single sign-on settings are managed.", + "description": "Organization whose single sign-on settings and verified domains are managed.", "schema": { "type": "string", "minLength": 1, - "description": "Organization whose single sign-on settings are managed." + "description": "Organization whose single sign-on settings and verified domains are managed." } }, { @@ -7325,7 +7325,7 @@ } ], "requestBody": { - "required": true, + "required": false, "description": "Configuration accepted by Set Primary SSO Provider.", "content": { "application/json": { @@ -7403,11 +7403,11 @@ "name": "organizationId", "in": "path", "required": true, - "description": "Organization whose single sign-on settings are managed.", + "description": "Organization whose single sign-on settings and verified domains are managed.", "schema": { "type": "string", "minLength": 1, - "description": "Organization whose single sign-on settings are managed." + "description": "Organization whose single sign-on settings and verified domains are managed." } } ], @@ -7471,11 +7471,11 @@ "name": "organizationId", "in": "path", "required": true, - "description": "Organization whose single sign-on settings are managed.", + "description": "Organization whose single sign-on settings and verified domains are managed.", "schema": { "type": "string", "minLength": 1, - "description": "Organization whose single sign-on settings are managed." + "description": "Organization whose single sign-on settings and verified domains are managed." } } ], @@ -7558,11 +7558,11 @@ "name": "organizationId", "in": "path", "required": true, - "description": "Organization whose single sign-on settings are managed.", + "description": "Organization whose single sign-on settings and verified domains are managed.", "schema": { "type": "string", "minLength": 1, - "description": "Organization whose single sign-on settings are managed." + "description": "Organization whose single sign-on settings and verified domains are managed." } }, { @@ -7674,11 +7674,11 @@ "name": "organizationId", "in": "path", "required": true, - "description": "Organization whose single sign-on settings are managed.", + "description": "Organization whose single sign-on settings and verified domains are managed.", "schema": { "type": "string", "minLength": 1, - "description": "Organization whose single sign-on settings are managed." + "description": "Organization whose single sign-on settings and verified domains are managed." } } ], @@ -7782,11 +7782,11 @@ "name": "organizationId", "in": "path", "required": true, - "description": "Organization whose single sign-on settings are managed.", + "description": "Organization whose single sign-on settings and verified domains are managed.", "schema": { "type": "string", "minLength": 1, - "description": "Organization whose single sign-on settings are managed." + "description": "Organization whose single sign-on settings and verified domains are managed." } }, { @@ -7803,7 +7803,7 @@ } ], "requestBody": { - "required": true, + "required": false, "description": "Configuration accepted by Verify Organization Domain.", "content": { "application/json": { @@ -7881,11 +7881,11 @@ "name": "organizationId", "in": "path", "required": true, - "description": "Organization whose single sign-on settings are managed.", + "description": "Organization whose single sign-on settings and verified domains are managed.", "schema": { "type": "string", "minLength": 1, - "description": "Organization whose single sign-on settings are managed." + "description": "Organization whose single sign-on settings and verified domains are managed." } }, { @@ -8066,9 +8066,6 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "409": { - "$ref": "#/components/responses/Conflict" - }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -8179,9 +8176,6 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "409": { - "$ref": "#/components/responses/Conflict" - }, "413": { "$ref": "#/components/responses/PayloadTooLarge" }, @@ -8280,9 +8274,6 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "409": { - "$ref": "#/components/responses/Conflict" - }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -10771,6 +10762,7 @@ "ORGANIZATION_MEMBERSHIP_REQUIRED", "ORGANIZATION_ADMIN_REQUIRED", "ENTERPRISE_PLAN_REQUIRED", + "SSO_DISABLED", "SSO_DOMAIN_NOT_VERIFIED", "SSO_PROVIDER_LIMIT_REACHED", "ORGANIZATION_PLAN_REQUIRED", @@ -19981,11 +19973,12 @@ "description": "Set Primary SSO Provider result." }, "SetPrimarySsoProviderBody": { + "default": {}, + "title": "Set Primary SSO Provider body", + "description": "Configuration accepted by Set Primary SSO Provider.", "type": "object", "properties": {}, - "additionalProperties": false, - "title": "Set Primary SSO Provider body", - "description": "Configuration accepted by Set Primary SSO Provider." + "additionalProperties": false }, "GetSsoPolicyResponse": { "type": "object", @@ -20272,11 +20265,12 @@ "description": "Verify Organization Domain result." }, "VerifyOrganizationDomainBody": { + "default": {}, + "title": "Verify Organization Domain body", + "description": "Configuration accepted by Verify Organization Domain.", "type": "object", "properties": {}, - "additionalProperties": false, - "title": "Verify Organization Domain body", - "description": "Configuration accepted by Verify Organization Domain." + "additionalProperties": false }, "RemoveOrganizationDomainResponse": { "type": "object", diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index 73d85efaa41..00a5cdccb61 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -5021,6 +5021,7 @@ "ORGANIZATION_MEMBERSHIP_REQUIRED", "ORGANIZATION_ADMIN_REQUIRED", "ENTERPRISE_PLAN_REQUIRED", + "SSO_DISABLED", "SSO_DOMAIN_NOT_VERIFIED", "SSO_PROVIDER_LIMIT_REACHED", "ORGANIZATION_PLAN_REQUIRED", diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 88ca5508a91..f32a7151820 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -5722,6 +5722,7 @@ "ORGANIZATION_MEMBERSHIP_REQUIRED", "ORGANIZATION_ADMIN_REQUIRED", "ENTERPRISE_PLAN_REQUIRED", + "SSO_DISABLED", "SSO_DOMAIN_NOT_VERIFIED", "SSO_PROVIDER_LIMIT_REACHED", "ORGANIZATION_PLAN_REQUIRED", diff --git a/apps/sim/app/api/auth/sso/register/route.test.ts b/apps/sim/app/api/auth/sso/register/route.test.ts index 9c45478173a..382848e5449 100644 --- a/apps/sim/app/api/auth/sso/register/route.test.ts +++ b/apps/sim/app/api/auth/sso/register/route.test.ts @@ -1,6 +1,5 @@ import { createMockRequest, - dbChainMockFns, queueTableRows, resetDbChainMock, resetEnvFlagsMock, @@ -51,8 +50,6 @@ import { POST } from '@/app/api/auth/sso/register/route' const mockValidateUrlWithDNS = inputValidationMockFns.mockValidateUrlWithDNS const mockSecureFetchWithPinnedIP = inputValidationMockFns.mockSecureFetchWithPinnedIP const mockGetSession = authMockFns.mockGetSession -const mockRegisterSSOProvider = authMockFns.mockRegisterSSOProvider -const mockUpdateSSOProvider = authMockFns.mockUpdateSSOProvider const OIDC_BODY = { providerType: 'oidc' as const, @@ -85,16 +82,6 @@ describe('POST /api/auth/sso/register', () => { billingSubscriptionMockFns.mockIsOrganizationFeatureEntitled.mockResolvedValue(true) mockValidateUrlWithDNS.mockResolvedValue({ isValid: true, resolvedIP: '1.2.3.4' }) mockSecureFetchWithPinnedIP.mockRejectedValue(new Error('discovery not mocked for this test')) - mockRegisterSSOProvider.mockResolvedValue({ id: 'row-1', providerId: 'acme-oidc' }) - mockUpdateSSOProvider.mockResolvedValue({ providerId: 'acme-oidc' }) - // The trust UPDATE reports the row it matched; by default the provider exists. - dbChainMockFns.returning.mockResolvedValue([{ id: 'provider-row' }]) - // Default: the org has already verified the domain, so the ownership gate - // passes and each test exercises the logic beyond it. A successful org-scoped - // registration reads it three times: the fail-fast entry gate, the - // authoritative re-check before the write, and the locking read inside the - // trust transaction. Gate-specific tests reset the queue to assert the - // unverified paths. queueTableRows(schemaMock.ssoDomain, [{ id: 'verified-domain' }]) queueTableRows(schemaMock.ssoDomain, [{ id: 'verified-domain' }]) queueTableRows(schemaMock.ssoDomain, [{ id: 'verified-domain' }]) @@ -112,14 +99,12 @@ describe('POST /api/auth/sso/register', () => { const res = await POST(request(OIDC_BODY)) expect(res.status).toBe(403) expect(await res.json()).toEqual({ error: 'SSO requires an Enterprise plan' }) - expect(mockRegisterSSOProvider).not.toHaveBeenCalled() }) it('rejects callers who are not an admin/owner of the target org', async () => { queueMembers([{ organizationId: 'org1', role: 'member' }]) const res = await POST(request(OIDC_BODY)) expect(res.status).toBe(403) - expect(mockRegisterSSOProvider).not.toHaveBeenCalled() }) it('rejects configuring org SSO for a domain the org has not verified', async () => { @@ -130,7 +115,6 @@ describe('POST /api/auth/sso/register', () => { const json = await res.json() expect(res.status).toBe(403) expect(json.code).toBe('SSO_DOMAIN_NOT_VERIFIED') - expect(mockRegisterSSOProvider).not.toHaveBeenCalled() }) it('re-checks verification before the write and 403s if it was revoked mid-registration', async () => { @@ -142,21 +126,6 @@ describe('POST /api/auth/sso/register', () => { const json = await res.json() expect(res.status).toBe(403) expect(json.code).toBe('SSO_DOMAIN_NOT_VERIFIED') - expect(mockRegisterSSOProvider).not.toHaveBeenCalled() - }) - - it('rolls back the newly-created provider if verification is revoked after the write', async () => { - resetDbChainMock() - queueMembers([{ organizationId: 'org1', role: 'owner' }]) - queueTableRows(schemaMock.ssoDomain, [{ id: 'v' }]) // entry gate: verified - queueTableRows(schemaMock.ssoDomain, [{ id: 'v' }]) // pre-write re-check: verified - queueTableRows(schemaMock.ssoDomain, []) // locking read in the grant: proof gone - const res = await POST(request(OIDC_BODY)) - const json = await res.json() - expect(res.status).toBe(403) - expect(json.code).toBe('SSO_DOMAIN_NOT_VERIFIED') - expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1) // it was created… - expect(dbChainMockFns.delete).toHaveBeenCalled() // …then rolled back }) it('rejects a domain already registered by another organization', async () => { @@ -166,20 +135,6 @@ describe('POST /api/auth/sso/register', () => { const json = await res.json() expect(res.status).toBe(409) expect(json.code).toBe('SSO_DOMAIN_ALREADY_REGISTERED') - expect(mockRegisterSSOProvider).not.toHaveBeenCalled() - }) - - it('matches conflicts across casing variants', async () => { - queueMembers([{ organizationId: 'org-attacker', role: 'owner' }]) - queueProviders([{ domain: 'ACME.com', userId: 'u-victim', organizationId: 'org-victim' }]) - const res = await POST(request({ ...OIDC_BODY, orgId: 'org-attacker' })) - expect(res.status).toBe(409) - expect(mockRegisterSSOProvider).not.toHaveBeenCalled() - /** The conflict lookup compares the normalized domain key, which is case-insensitive. */ - const conflictWhere = dbChainMockFns.where.mock.calls.find(([condition]) => - JSON.stringify(condition ?? '').includes('regexp_replace') - ) - expect(conflictWhere?.[0]?.values).toContain('acme.com') }) /** @@ -194,7 +149,6 @@ describe('POST /api/auth/sso/register', () => { const json = await res.json() expect(res.status).toBe(409) expect(json.code).toBe('SSO_PROVIDER_ID_TAKEN') - expect(mockRegisterSSOProvider).not.toHaveBeenCalled() }) it('suggests a free, domain-scoped providerId when the requested one is taken', async () => { @@ -205,99 +159,6 @@ describe('POST /api/auth/sso/register', () => { expect(json.error).toContain('acme-oidc-acme') }) - it('does not treat the caller’s own provider as a providerId conflict', async () => { - queueMembers([{ organizationId: 'org1', role: 'owner' }]) - queueProviders([], [{ domain: 'acme.com', userId: 'u1', organizationId: 'org1' }]) - const res = await POST(request(OIDC_BODY)) - expect(res.status).toBe(200) - }) - - /** - * Better Auth's `isTrustedProvider` reads this flag, and it is the only thing - * that lets an SSO sign-in link to a pre-existing same-email account once the - * plugin stopped honouring `trustedProviders` for SSO. `registerSSOProvider` - * always persists `false`, so the route must set it after the write. - */ - it('marks the provider domain-verified after registering', async () => { - queueMembers([{ organizationId: 'org1', role: 'owner' }]) - const res = await POST(request(OIDC_BODY)) - expect(res.status).toBe(200) - expect(dbChainMockFns.set).toHaveBeenCalledWith({ - domainVerified: true, - jitProvisioningEnabled: true, - }) - }) - - /** - * The create path rolls the provider back when verification is revoked during the - * write. The update path has no new row to delete, so it restores the pre-update - * config and clears the trust flag together. Clearing alone would leave the - * rejected config stored, and re-verifying the domain regrants trust - * automatically — silently activating a config the caller was told had failed. - */ - it('reverts the config and revokes trust when verification is removed mid-update', async () => { - queueMembers([{ organizationId: 'org1', role: 'owner' }]) - resetDbChainMock() - queueMembers([{ organizationId: 'org1', role: 'owner' }]) - queueTableRows(schemaMock.ssoDomain, [{ id: 'v' }]) // entry gate - queueTableRows(schemaMock.ssoDomain, [{ id: 'v' }]) // pre-write re-check - queueTableRows(schemaMock.ssoDomain, []) // locking read in the grant: proof gone - queueProviders([]) - queueTableRows(schemaMock.ssoProvider, [ - { - id: 'p1', - issuer: 'https://old-issuer.example.com', - domain: 'acme.com', - oidcConfig: '{"stored":"oidc"}', - samlConfig: null, - jitProvisioningEnabled: false, - }, - ]) // provider already owned → update path - - const res = await POST(request(OIDC_BODY)) - expect(res.status).toBe(403) - expect(mockUpdateSSOProvider).toHaveBeenCalledTimes(1) - // The conditional grant UPDATE is still issued — it simply matches no rows once - // the proof is gone — so the signal is the restoring write plus the 403. - expect(dbChainMockFns.set).toHaveBeenCalledWith({ - issuer: 'https://old-issuer.example.com', - domain: 'acme.com', - oidcConfig: '{"stored":"oidc"}', - samlConfig: null, - domainVerified: false, - jitProvisioningEnabled: false, - }) - }) - - it('reverts the config and provisioning mode when the trust write fails', async () => { - queueMembers([{ organizationId: 'org1', role: 'owner' }]) - queueProviders([]) - queueTableRows(schemaMock.ssoProvider, [ - { - id: 'p1', - issuer: 'https://old-issuer.example.com', - domain: 'acme.com', - oidcConfig: '{"stored":"oidc"}', - samlConfig: null, - jitProvisioningEnabled: true, - }, - ]) - dbChainMockFns.returning.mockRejectedValueOnce(new Error('trust write failed')) - - const res = await POST(request({ ...OIDC_BODY, jitProvisioningEnabled: false })) - - expect(res.status).toBe(500) - expect(mockUpdateSSOProvider).toHaveBeenCalledTimes(1) - expect(dbChainMockFns.set).toHaveBeenCalledWith({ - issuer: 'https://old-issuer.example.com', - domain: 'acme.com', - oidcConfig: '{"stored":"oidc"}', - samlConfig: null, - domainVerified: false, - jitProvisioningEnabled: true, - }) - }) - /** * An org-less provider has no `sso_domain` proof behind its domain, and domain * trust is what auto-links an SSO sign-in into an existing same-email account, @@ -308,33 +169,6 @@ describe('POST /api/auth/sso/register', () => { const res = await POST(request(orgLessBody)) expect(res.status).toBe(400) expect((await res.json()).error).toContain('Organization ID is required') - expect(mockRegisterSSOProvider).not.toHaveBeenCalled() - }) - - /** - * Persisting generated IdP metadata made re-saving destructive: the form loaded - * it back, resent it, and it then won over the certificate — so rotating a SAML - * cert through the form silently did nothing. - */ - it('writes empty IdP metadata when the admin supplied none, so a stored one clears', async () => { - queueMembers([{ organizationId: 'org1', role: 'owner' }]) - queueProviders([]) - await POST( - request({ - providerType: 'saml', - providerId: 'acme-saml', - issuer: 'https://idp.acme.com', - domain: 'acme.com', - orgId: 'org1', - entryPoint: 'https://idp.acme.com/sso', - cert: 'ORIGINAL-CERT', - }) - ) - const sent = mockRegisterSSOProvider.mock.calls[0][0].body - // Written as empty rather than omitted: Better Auth merges with `??`, so an - // omitted key would retain a previously stored document on update. - expect(sent.samlConfig.idpMetadata).toEqual({ metadata: '' }) - expect(sent.samlConfig.cert).toBe('ORIGINAL-CERT') }) it("refuses a second provider on a domain the caller's own org-less provider signs in", async () => { @@ -345,7 +179,6 @@ describe('POST /api/auth/sso/register', () => { const res = await POST(request(OIDC_BODY)) expect(res.status).toBe(409) await expect(res.json()).resolves.toMatchObject({ code: 'SSO_DOMAIN_ALREADY_ROUTED' }) - expect(mockRegisterSSOProvider).not.toHaveBeenCalled() }) it("still blocks an org admin from claiming another user's user-scoped domain", async () => { @@ -353,67 +186,5 @@ describe('POST /api/auth/sso/register', () => { queueProviders([{ domain: 'acme.com', userId: 'someone-else', organizationId: null }]) const res = await POST(request(OIDC_BODY)) expect(res.status).toBe(409) - expect(mockRegisterSSOProvider).not.toHaveBeenCalled() - }) - - it('normalizes the domain before persisting it', async () => { - queueMembers([{ organizationId: 'org1', role: 'owner' }]) - const res = await POST(request({ ...OIDC_BODY, domain: 'ACME.com' })) - expect(res.status).toBe(200) - expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1) - const config = mockRegisterSSOProvider.mock.calls[0][0].body - expect(config.domain).toBe('acme.com') - }) - - it('does not SSRF-validate userInfoEndpoint when skipUserInfoEndpoint is requested', async () => { - queueMembers([{ organizationId: 'org1', role: 'owner' }]) - mockValidateUrlWithDNS.mockImplementation(async (_url: string, label: string) => { - if (label === 'OIDC userInfoEndpoint') { - return { isValid: false, error: 'resolves to a private IP address' } - } - return { isValid: true, resolvedIP: '1.2.3.4' } - }) - const res = await POST(request({ ...OIDC_BODY, skipUserInfoEndpoint: true })) - expect(res.status).toBe(200) - const config = mockRegisterSSOProvider.mock.calls[0][0].body - expect(config.oidcConfig.userInfoEndpoint).toBeUndefined() - }) - - it('selects tokenEndpointAuthentication from the discovery document when endpoints are auto-discovered', async () => { - queueMembers([{ organizationId: 'org1', role: 'owner' }]) - mockSecureFetchWithPinnedIP.mockResolvedValue({ - ok: true, - json: async () => ({ - authorization_endpoint: 'https://idp.acme.com/authorize', - token_endpoint: 'https://idp.acme.com/token', - userinfo_endpoint: 'https://idp.acme.com/userinfo', - jwks_uri: 'https://idp.acme.com/jwks', - token_endpoint_auth_methods_supported: ['client_secret_post'], - }), - }) - const discoveredBody = { - ...OIDC_BODY, - authorizationEndpoint: undefined, - tokenEndpoint: undefined, - jwksEndpoint: undefined, - } - const res = await POST(request(discoveredBody)) - expect(res.status).toBe(200) - const config = mockRegisterSSOProvider.mock.calls[0][0].body - expect(config.oidcConfig.tokenEndpointAuthentication).toBe('client_secret_post') - }) - - it('prefers client_secret_post over client_secret_basic when an IdP supports both', async () => { - queueMembers([{ organizationId: 'org1', role: 'owner' }]) - mockSecureFetchWithPinnedIP.mockResolvedValue({ - ok: true, - json: async () => ({ - token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'], - }), - }) - const res = await POST(request(OIDC_BODY)) - expect(res.status).toBe(200) - const config = mockRegisterSSOProvider.mock.calls[0][0].body - expect(config.oidcConfig.tokenEndpointAuthentication).toBe('client_secret_post') }) }) diff --git a/apps/sim/app/api/mothership/execute/route.test.ts b/apps/sim/app/api/mothership/execute/route.test.ts index 5c54096766f..26483dbe509 100644 --- a/apps/sim/app/api/mothership/execute/route.test.ts +++ b/apps/sim/app/api/mothership/execute/route.test.ts @@ -14,6 +14,10 @@ import { mothershipChatPayloadMock, mothershipChatPayloadMockFns, } from '@sim/testing/mocks/mothership-chat-payload.mock' +import { + mothershipHeadlessLifecycleMock, + mothershipHeadlessLifecycleMockFns, +} from '@sim/testing/mocks/mothership-headless-lifecycle.mock' import { permissionsMock, permissionsMockFns } from '@sim/testing/mocks/permissions.mock' import { createMockRequest } from '@sim/testing/mocks/request.mock' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -24,14 +28,12 @@ const { mockComputeWorkspaceEntitlements, mockProcessContextsServer, mockRequestExplicitStreamAbort, - mockRunHeadlessCopilotLifecycle, } = vi.hoisted(() => ({ mockBuildSelectedMcpToolSchemas: vi.fn(), mockBuildTaggedMcpToolSchemas: vi.fn(), mockComputeWorkspaceEntitlements: vi.fn(), mockProcessContextsServer: vi.fn(), mockRequestExplicitStreamAbort: vi.fn(), - mockRunHeadlessCopilotLifecycle: vi.fn(), })) vi.mock('@/lib/auth/internal', () => authInternalMock) @@ -56,9 +58,7 @@ vi.mock('@/lib/mothership/mcp-tools', () => ({ buildTaggedMcpToolSchemas: mockBuildTaggedMcpToolSchemas, })) -vi.mock('@/lib/mothership/request/lifecycle/headless', () => ({ - runHeadlessCopilotLifecycle: mockRunHeadlessCopilotLifecycle, -})) +vi.mock('@/lib/mothership/request/lifecycle/headless', () => mothershipHeadlessLifecycleMock) vi.mock('@/lib/mothership/request/session/explicit-abort', () => ({ requestExplicitStreamAbort: mockRequestExplicitStreamAbort, @@ -85,6 +85,8 @@ authInternalMockFns.mockVerifyInternalDelegationToken.mockResolvedValue({ }) const mockBuildIntegrationToolSchemas = mothershipChatPayloadMockFns.mockBuildIntegrationToolSchemas +const mockRunHeadlessCopilotLifecycle = + mothershipHeadlessLifecycleMockFns.mockRunHeadlessCopilotLifecycle const mockAssertActiveWorkspaceAccess = permissionsMockFns.mockAssertActiveWorkspaceAccess const mockCheckInternalAuth = hybridAuthMockFns.mockCheckInternalAuth diff --git a/apps/sim/app/api/v2/chat/route.test.ts b/apps/sim/app/api/v2/chat/route.test.ts index 66d3084f8f8..4fb03ebb53c 100644 --- a/apps/sim/app/api/v2/chat/route.test.ts +++ b/apps/sim/app/api/v2/chat/route.test.ts @@ -30,6 +30,10 @@ import { mothershipEnvironmentContextMock, mothershipEnvironmentContextMockFns, } from '@sim/testing/mocks/mothership-environment-context.mock' +import { + mothershipHeadlessLifecycleMock, + mothershipHeadlessLifecycleMockFns, +} from '@sim/testing/mocks/mothership-headless-lifecycle.mock' import { MockWorkspaceAccessDeniedError, permissionsMock, @@ -44,11 +48,7 @@ import { sleep } from '@sim/utils/helpers' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - billingAttributionSnapshot, - mockRequestExplicitStreamAbort, - mockRunHeadlessCopilotLifecycle, -} = vi.hoisted(() => ({ +const { billingAttributionSnapshot, mockRequestExplicitStreamAbort } = vi.hoisted(() => ({ billingAttributionSnapshot: { actorUserId: 'user-1', workspaceId: 'workspace-1', @@ -59,7 +59,6 @@ const { payerSubscription: null, }, mockRequestExplicitStreamAbort: vi.fn().mockResolvedValue(undefined), - mockRunHeadlessCopilotLifecycle: vi.fn(), })) vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) @@ -88,9 +87,7 @@ vi.mock('@/lib/mothership/entitlements', () => ({ computeWorkspaceEntitlements: vi.fn().mockResolvedValue([]), })) -vi.mock('@/lib/mothership/request/lifecycle/headless', () => ({ - runHeadlessCopilotLifecycle: mockRunHeadlessCopilotLifecycle, -})) +vi.mock('@/lib/mothership/request/lifecycle/headless', () => mothershipHeadlessLifecycleMock) vi.mock('@/lib/mothership/request/session/explicit-abort', () => ({ requestExplicitStreamAbort: mockRequestExplicitStreamAbort, @@ -104,6 +101,8 @@ vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScop const mockResolvePermissionGroupConfig = permissionGroupScopeMockFns.mockResolvePermissionGroupConfig +const mockRunHeadlessCopilotLifecycle = + mothershipHeadlessLifecycleMockFns.mockRunHeadlessCopilotLifecycle const mockAssertActiveWorkspaceAccess = permissionsMockFns.mockAssertActiveWorkspaceAccess const mockGenerateId = idMockFns.mockGenerateId idMockFns.mockGenerateShortId.mockReturnValue('mock-short-id') diff --git a/apps/sim/app/api/v2/organizations/[organizationId]/domains/[domainId]/verify/route.ts b/apps/sim/app/api/v2/organizations/[organizationId]/domains/[domainId]/verify/route.ts index 10108e1d2b6..f68fdfa41f4 100644 --- a/apps/sim/app/api/v2/organizations/[organizationId]/domains/[domainId]/verify/route.ts +++ b/apps/sim/app/api/v2/organizations/[organizationId]/domains/[domainId]/verify/route.ts @@ -11,6 +11,7 @@ export const POST = defineV2JsonRoute({ auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, errorPolicy: v2SsoErrorPolicy, + parseOptions: { optionalJsonBody: true }, mapInput: ({ params }) => params, useCase: verifyOrganizationDomain, present: ({ domain }) => ({ data: toDomainResponse(domain) }), diff --git a/apps/sim/app/api/v2/organizations/[organizationId]/sso/providers/[providerId]/primary/route.ts b/apps/sim/app/api/v2/organizations/[organizationId]/sso/providers/[providerId]/primary/route.ts index 5a484de05ba..b7ff3893bc0 100644 --- a/apps/sim/app/api/v2/organizations/[organizationId]/sso/providers/[providerId]/primary/route.ts +++ b/apps/sim/app/api/v2/organizations/[organizationId]/sso/providers/[providerId]/primary/route.ts @@ -10,6 +10,7 @@ export const POST = defineV2JsonRoute({ auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, errorPolicy: v2SsoErrorPolicy, + parseOptions: { optionalJsonBody: true }, mapInput: ({ params }) => ({ providerId: params.providerId, assertedOrganizationId: params.organizationId, diff --git a/apps/sim/lib/api/contracts/auth.ts b/apps/sim/lib/api/contracts/auth.ts index 0f65216a72b..c9e39ef33c5 100644 --- a/apps/sim/lib/api/contracts/auth.ts +++ b/apps/sim/lib/api/contracts/auth.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { organizationIdSchema } from '@/lib/api/contracts/primitives' import type { ContractJsonResponse } from '@/lib/api/contracts/types' import { defineRouteContract } from '@/lib/api/contracts/types' import { ssoRegistrationInputSchema } from '@/lib/auth/sso/registration-input' @@ -16,10 +17,10 @@ export const authProviderStatusResponseSchema = z.object({ export const ssoRegistrationBodySchema = z.discriminatedUnion('providerType', [ ssoRegistrationInputSchema.options[0].omit({ organizationId: true }).extend({ - orgId: z.string({ error: 'Organization ID is required' }).min(1, 'Organization ID is required'), + orgId: organizationIdSchema, }), ssoRegistrationInputSchema.options[1].omit({ organizationId: true }).extend({ - orgId: z.string({ error: 'Organization ID is required' }).min(1, 'Organization ID is required'), + orgId: organizationIdSchema, }), ]) export type SsoRegistrationBody = z.input diff --git a/apps/sim/lib/api/contracts/v2/openapi/credential-members.ts b/apps/sim/lib/api/contracts/v2/openapi/credential-members.ts index e83bff53043..c26e03cb08f 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/credential-members.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/credential-members.ts @@ -6,7 +6,7 @@ import { import { documentedSchema, RATE_LIMIT_HEADERS, - RESOURCE_CONFLICT_ERRORS, + RESOURCE_ERRORS, WORKSPACE_API_KEY_DENIED, } from '@/lib/api/contracts/v2/openapi/shared' import { defineOpenApiRoute } from '@/lib/api/openapi/types' @@ -20,7 +20,7 @@ export const credentialMemberOpenApiRoutes = [ summary: 'List Credential Members', description: `List explicit credential grants, including revoked grants, and inherited workspace administrator access. Requires workspace read access. Credentials must be OAuth or service-account connections. ${WORKSPACE_API_KEY_DENIED}`, tags: ['Credentials'], - errors: RESOURCE_CONFLICT_ERRORS, + errors: RESOURCE_ERRORS, success: { description: 'List Credential Members result.', headers: RATE_LIMIT_HEADERS }, }, { @@ -52,7 +52,7 @@ export const credentialMemberOpenApiRoutes = [ summary: 'Upsert Credential Member', description: `Grant or change an existing workspace member’s credential role. Requires credential administrator access. Revoked grants become active again; inherited administrators cannot be demoted. A new grant returns 201; an existing grant returns 200. ${WORKSPACE_API_KEY_DENIED}`, tags: ['Credentials'], - errors: RESOURCE_CONFLICT_ERRORS, + errors: RESOURCE_ERRORS, success: { description: 'Upsert Credential Member result.', headers: RATE_LIMIT_HEADERS }, }, { @@ -90,7 +90,7 @@ export const credentialMemberOpenApiRoutes = [ summary: 'Remove Credential Member', description: `Revoke an active explicit credential grant. Requires credential administrator access. Inherited workspace administrators cannot be removed; an absent or already-revoked grant returns 404. ${WORKSPACE_API_KEY_DENIED}`, tags: ['Credentials'], - errors: RESOURCE_CONFLICT_ERRORS, + errors: RESOURCE_ERRORS, success: { description: 'Remove Credential Member result.', headers: RATE_LIMIT_HEADERS }, }, { diff --git a/apps/sim/lib/api/contracts/v2/sso.ts b/apps/sim/lib/api/contracts/v2/sso.ts index 5f7e2a16aa8..44a0152a388 100644 --- a/apps/sim/lib/api/contracts/v2/sso.ts +++ b/apps/sim/lib/api/contracts/v2/sso.ts @@ -18,7 +18,7 @@ import { addOrganizationDomainBodySchema } from '@/lib/organizations/domain-vali const v2SsoOrganizationParamsSchema = z .object({ organizationId: organizationIdSchema.describe( - 'Organization whose single sign-on settings are managed.' + 'Organization whose single sign-on settings and verified domains are managed.' ), }) .strict() @@ -252,7 +252,7 @@ export const v2SetPrimarySsoProviderContract = defineRouteContract({ path: '/api/v2/organizations/[organizationId]/sso/providers/[providerId]/primary', params: v2SsoProviderParamsSchema, query: noInputSchema, - body: noInputSchema, + body: noInputSchema.optional().default({}), response: { mode: 'json', schema: v2DataResponse( @@ -351,7 +351,7 @@ export const v2VerifyOrganizationDomainContract = defineRouteContract({ path: '/api/v2/organizations/[organizationId]/domains/[domainId]/verify', params: v2OrganizationDomainParamsSchema, query: noInputSchema, - body: noInputSchema, + body: noInputSchema.optional().default({}), response: { mode: 'json', schema: v2DataResponse(v2OrganizationDomainSchema) }, }) export const v2RemoveOrganizationDomainContract = defineRouteContract({ diff --git a/apps/sim/lib/api/server/routes/copilot-route-inventory.test.ts b/apps/sim/lib/api/server/routes/copilot-route-inventory.test.ts index cbdd35762ca..1f3a241a10c 100644 --- a/apps/sim/lib/api/server/routes/copilot-route-inventory.test.ts +++ b/apps/sim/lib/api/server/routes/copilot-route-inventory.test.ts @@ -1,5 +1,11 @@ +import { apiServerRoutesMock } from '@sim/testing/mocks/api-server-routes.mock' +import { mothershipHeadlessLifecycleMock } from '@sim/testing/mocks/mothership-headless-lifecycle.mock' +import { triggersMock } from '@sim/testing/mocks/triggers.mock' import { expect, it, vi } from 'vitest' +vi.mock('@/triggers', () => triggersMock) +vi.mock('@/lib/mothership/request/lifecycle/headless', () => mothershipHeadlessLifecycleMock) + const inventory = vi.hoisted( () => [] as Array<{ method: string; path: string; operation: string; audience: string | null }> ) @@ -19,16 +25,16 @@ const builder = vi.hoisted( return async () => new Response() } ) -vi.mock('@/lib/api/server/routes/v2-json-route', async (importOriginal) => ({ - ...(await importOriginal()), +vi.mock('@/lib/api/server/routes/v2-json-route', () => ({ + ...apiServerRoutesMock, defineV2JsonRoute: builder, })) -vi.mock('@/lib/api/server/routes/v2-binary-route', async (importOriginal) => ({ - ...(await importOriginal()), +vi.mock('@/lib/api/server/routes/v2-binary-route', () => ({ + ...apiServerRoutesMock, defineV2BinaryRoute: builder, })) -vi.mock('@/lib/api/server/routes/v2-body-lifecycle-route', async (importOriginal) => ({ - ...(await importOriginal()), +vi.mock('@/lib/api/server/routes/v2-body-lifecycle-route', () => ({ + ...apiServerRoutesMock, defineV2BodyLifecycleRoute: builder, })) diff --git a/apps/sim/lib/api/server/routes/sso.ts b/apps/sim/lib/api/server/routes/sso.ts index 71199453439..d9f8bb23ffd 100644 --- a/apps/sim/lib/api/server/routes/sso.ts +++ b/apps/sim/lib/api/server/routes/sso.ts @@ -16,11 +16,12 @@ import { v2Error } from '@/app/api/v2/lib/response' export const v2SsoErrorPolicy: V2ErrorPolicy = { render(error) { if (error instanceof APIError) { - if (error.statusCode >= 500) + if (error.statusCode === 503) return v2Error( 'SERVICE_UNAVAILABLE', 'Identity provider settings are temporarily unavailable' ) + if (error.statusCode >= 500) return v2Error('INTERNAL_ERROR', 'Internal server error') if (error.statusCode === 409) return v2Error( 'CONFLICT', diff --git a/apps/sim/lib/api/server/sso-presenters.ts b/apps/sim/lib/api/server/sso-presenters.ts index 3525d8a5213..7e6e0ed2d49 100644 --- a/apps/sim/lib/api/server/sso-presenters.ts +++ b/apps/sim/lib/api/server/sso-presenters.ts @@ -47,7 +47,8 @@ function publicConfig( for (const key of fields) { if (record[key] === undefined) continue if (key === 'spMetadata' || key === 'idpMetadata') { - const metadata = toRecord(record[key]) + const metadata: Record = + typeof record[key] === 'string' ? { metadata: record[key] } : toRecord(record[key]) projected[key] = { metadata: metadata.metadata, entityID: metadata.entityID, diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 66e9b27011e..fac5e58e8a2 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -1,6 +1,5 @@ import { cache } from 'react' import { oauthProvider } from '@better-auth/oauth-provider' -import { sso } from '@better-auth/sso' import { stripe } from '@better-auth/stripe' import { db } from '@sim/db' import * as schema from '@sim/db/schema' @@ -73,6 +72,7 @@ import { getActiveOrganizationId } from '@/lib/auth/session-response' import { createSimAuthAdapter } from '@/lib/auth/sim-auth-adapter' import { admitSsoUser } from '@/lib/auth/sso/application/admit-sso-user' import { resolveSsoCallbackProviderId } from '@/lib/auth/sso/callback-provider' +import { configuredSsoPlugin } from '@/lib/auth/sso/plugin' import { sendPlanWelcomeEmail } from '@/lib/billing' import { assertPersonalCheckoutAllowed, @@ -1457,58 +1457,7 @@ export const auth = betterAuth({ * section visible and SSO entitlement passing while sign-in silently had no * SSO provider behind it. */ - ...(isSsoEnabled - ? [ - sso({ - /** - * MUST stay false. Better Auth's link gate is - * `!isTrustedProvider && !userInfo.emailVerified`, so a true - * `email_verified` claim substitutes for the domain binding - * entirely: an IdP could assert any address — including one from a - * domain it does not own — and auto-link into that user's existing - * account. Since a provider row can be registered by any - * organization owner or admin (or by an operator via the register - * script), trusting the claim makes every account reachable from - * any tenant's IdP. - * - * Turning it on only ever set `emailVerified` on the local row; it - * was never what made linking work. Entra omits the claim, and SAML - * ignores it without an explicit `mapping.emailVerified` that the - * register contract does not accept — so SSO users are created - * unverified either way, and `domainVerification` below is the sole - * linking trust source, which is what `trustProviderByName: false` - * already assumes. - */ - trustEmailVerified: false, - /** - * Marks a provider authoritative for its domain, which is what lets an - * SSO sign-in auto-link to an existing same-email account. Without it - * `isTrustedProvider` is always false and every user who already had a - * Sim account is stranded on "account not linked". - * - * Sim does not use Better Auth's DNS challenge endpoints: ownership is - * proven by the `sso_domain` flow before registration, and the register - * route mirrors that decision onto this flag. - * - * With `trustEmailVerified` off this is the only path to linking, and - * it is domain-scoped: `isTrustedProvider` additionally requires - * `validateEmailDomain(userInfo.email, provider.domain)`, so a - * provider can only ever claim identities inside the domain it proved. - */ - domainVerification: { enabled: true }, - organizationProvisioning: { - /** - * Better Auth writes member rows directly and bypasses Sim's seat, - * billing, session-policy, and audit invariants. Admission is owned - * by the application use case in the callback hook above. - */ - disabled: true, - defaultRole: 'member', - }, - }), - ] - : []), - // Only include the Stripe plugin when billing is enabled + ...(isSsoEnabled ? [configuredSsoPlugin] : []), ...(isBillingEnabled && stripeClient ? [ stripe({ diff --git a/apps/sim/lib/auth/sim-auth-adapter.ts b/apps/sim/lib/auth/sim-auth-adapter.ts index b98fe240bbf..dbec035316b 100644 --- a/apps/sim/lib/auth/sim-auth-adapter.ts +++ b/apps/sim/lib/auth/sim-auth-adapter.ts @@ -2,14 +2,49 @@ import { db } from '@sim/db' import * as schema from '@sim/db/schema' import type { BetterAuthOptions } from 'better-auth' import { drizzleAdapter } from 'better-auth/adapters/drizzle' +import { eq } from 'drizzle-orm' import { runWithAuthDatabase } from '@/lib/auth/database-context' import { type AuthDatabase, guardOAuthProviderWrites, } from '@/lib/auth/oauth-provider-adapter-guard' +import { lockSsoProvider } from '@/lib/auth/sso/provider-lock' import { guardSubscriptionPlanWrites } from '@/lib/auth/stripe-adapter-guard' +import type { DbTransaction } from '@/lib/db/types' type BetterAuthAdapter = ReturnType> +type TransactionAdapter = Omit + +function createGuardedAdapter(options: BetterAuthOptions, database: AuthDatabase) { + const base = drizzleAdapter(database, { + provider: 'pg', + schema, + transaction: false, + })(options) + return guardSubscriptionPlanWrites(guardOAuthProviderWrites(base, database)) +} + +function createTransactionAdapter( + options: BetterAuthOptions, + tx: DbTransaction +): TransactionAdapter { + const guarded = createGuardedAdapter(options, tx) + const { transaction: _transaction, ...surface } = guarded + return { + ...surface, + create: async (input) => { + if (input.model === 'account' && typeof input.data.providerId === 'string') { + const [provider] = await tx + .select({ id: schema.ssoProvider.id }) + .from(schema.ssoProvider) + .where(eq(schema.ssoProvider.providerId, input.data.providerId)) + .limit(1) + if (provider) await lockSsoProvider(tx, input.data.providerId) + } + return guarded.create(input) + }, + } +} /** * Builds every Better Auth adapter surface, including transactional callbacks, @@ -17,22 +52,18 @@ type BetterAuthAdapter = ReturnType> */ export function createSimAuthAdapter( options: BetterAuthOptions, - database: AuthDatabase = db, - inTransaction = false + database: AuthDatabase = db ): BetterAuthAdapter { - const base = drizzleAdapter(database, { - provider: 'pg', - schema, - transaction: false, - })(options) - const guarded = guardSubscriptionPlanWrites(guardOAuthProviderWrites(base, database)) - if (inTransaction) return guarded - - guarded.transaction = (callback) => + const guarded = createGuardedAdapter(options, database) + const transaction: BetterAuthAdapter['transaction'] = (callback) => database.transaction(async (tx) => { - const transactionAdapter = createSimAuthAdapter(options, tx, true) - const { transaction: _transaction, ...surface } = transactionAdapter + const surface = createTransactionAdapter(options, tx) return runWithAuthDatabase(tx, () => callback(surface)) }) - return guarded + return { + ...guarded, + create: (input) => + input.model === 'account' ? transaction((tx) => tx.create(input)) : guarded.create(input), + transaction, + } } diff --git a/apps/sim/lib/auth/sso-trust.test.ts b/apps/sim/lib/auth/sso-trust.test.ts index 39f9d0a02dc..618cc88abbb 100644 --- a/apps/sim/lib/auth/sso-trust.test.ts +++ b/apps/sim/lib/auth/sso-trust.test.ts @@ -5,8 +5,7 @@ * as verified and auto-link into that user's account, bypassing the * domain-verification proof entirely. */ -import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' -import { afterAll, beforeAll, expect, it, vi } from 'vitest' +import { expect, it, vi } from 'vitest' const { ssoOptions } = vi.hoisted(() => ({ ssoOptions: { current: undefined as Record | undefined }, @@ -19,21 +18,7 @@ vi.mock('@better-auth/sso', () => ({ }, })) -setEnvFlags({ isSsoEnabled: true }) - -/** - * Structurally slow — it imports the entire Better Auth module graph — so under - * a fully-parallel local run this import blows the default budget while passing - * in isolation and on CI. The plugin options are captured once at module - * evaluation, so every assertion reads the same object: import once, outside - * any per-test budget, with a real budget of its own instead of letting machine - * load decide the verdict. - */ -beforeAll(async () => { - await import('@/lib/auth/auth') -}, 30_000) - -afterAll(resetEnvFlagsMock) +import '@/lib/auth/sso/plugin' it('never trusts the IdP-supplied email_verified claim for SSO linking', () => { expect(ssoOptions.current).toBeDefined() diff --git a/apps/sim/lib/auth/sso/application/provider-registration.ts b/apps/sim/lib/auth/sso/application/provider-registration.ts index 06165a1af05..4a16e8c2013 100644 --- a/apps/sim/lib/auth/sso/application/provider-registration.ts +++ b/apps/sim/lib/auth/sso/application/provider-registration.ts @@ -4,14 +4,16 @@ import { keepDomainSignInProvider, ssoProviderDomainKey } from '@sim/db/sso-prim import { createLogger } from '@sim/logger' import { toStringOrNull } from '@sim/utils/coerce' import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors' -import { toRecord } from '@sim/utils/object' +import { isRecordLike, toRecord } from '@sim/utils/object' import { normalizeSSODomain } from '@sim/utils/sso-domain' import { and, eq, sql } from 'drizzle-orm' import { ssoProviderOperations } from '@/lib/auth/sso/application/operations' import { type SsoProviderConfig, ssoProviderWriter } from '@/lib/auth/sso/provider-adapter' +import { lockSsoProvider } from '@/lib/auth/sso/provider-lock' import type { SsoRegistrationInput } from '@/lib/auth/sso/registration-input' import { invalidateSsoPolicyCache } from '@/lib/auth/sso-policy' import { isOrganizationFeatureEntitled } from '@/lib/billing/core/subscription' +import { acquireOrganizationMutationLock } from '@/lib/billing/organizations/membership' import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import { authorizeOrganizationOperation, @@ -19,7 +21,6 @@ import { } from '@/lib/core/application/organization-authorization' import { isSsoEnabled } from '@/lib/core/config/env-flags' import { runWithOutboundOrganization } from '@/lib/core/network/context.server' -import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' import { OrchestrationError } from '@/lib/core/orchestration/types' import { secureFetchWithPinnedIP, @@ -27,6 +28,7 @@ import { } from '@/lib/core/security/input-validation.server' import { REDACTED_MARKER } from '@/lib/core/security/redaction' import { getBaseUrl } from '@/lib/core/utils/urls' +import type { DbOrTx } from '@/lib/db/types' import { defineOrganizationConfigurationUseCase } from '@/lib/organizations/application/authorized-configuration-use-case' const logger = createLogger('SaveSsoProvider') @@ -102,7 +104,10 @@ async function fetchOIDCDiscoveryDocument(discoveryUrl: string): Promise } + const discovery: unknown = await response.json() + if (!isRecordLike(discovery)) + return { ok: false, error: 'OIDC discovery document must be a JSON object' } + return { ok: true, discovery } } catch (error) { return { ok: false, error: getErrorMessage(error, 'Unknown error') } } @@ -114,12 +119,10 @@ export const saveSsoProvider = defineOrganizationConfigurationUseCase({ principal, input, context, - request, }: { principal: Principal input: SsoRegistrationInput context: OrganizationMembershipContext - request?: OrchestrationRequestContext }) { if (!isSsoEnabled) throw new OrchestrationError('validation', 'SSO is not enabled') if (!(await isOrganizationFeatureEntitled(context.organizationId, isSsoEnabled))) @@ -199,8 +202,8 @@ export const saveSsoProvider = defineOrganizationConfigurationUseCase({ * provider to a domain it already signs in through: the new provider waits, * reachable by test link, until an admin makes it the domain's primary. */ - const findDomainRefusal = async (): Promise => { - const claims = await db + const findDomainRefusal = async (executor: DbOrTx = db): Promise => { + const claims = await executor .select({ userId: ssoProvider.userId, organizationId: ssoProvider.organizationId, @@ -241,9 +244,9 @@ export const saveSsoProvider = defineOrganizationConfigurationUseCase({ * resolves providers by that column alone. Catching the cross-tenant * collision here turns its opaque 422 into a 409 naming a free id. */ - const findProviderIdConflict = async () => + const findProviderIdConflict = async (executor: DbOrTx = db) => ( - await db + await executor .select({ userId: ssoProvider.userId, organizationId: ssoProvider.organizationId }) .from(ssoProvider) .where(eq(ssoProvider.providerId, providerId)) @@ -267,7 +270,23 @@ export const saveSsoProvider = defineOrganizationConfigurationUseCase({ await findDomainRefusal() - const writer = await ssoProviderWriter(principal, orgId, request) + const [ownedProvider] = await db + .select({ + issuer: ssoProvider.issuer, + oidcConfig: ssoProvider.oidcConfig, + samlConfig: ssoProvider.samlConfig, + }) + .from(ssoProvider) + .where(ownerClause) + .limit(1) + if ( + ownedProvider && + !(providerType === 'oidc' ? ownedProvider.oidcConfig : ownedProvider.samlConfig) + ) + return failSsoProvider( + 409, + 'An existing SSO provider cannot change protocols. Create a separate provider.' + ) const providerConfig: SsoProviderConfig = { providerId, @@ -289,35 +308,32 @@ export const saveSsoProvider = defineOrganizationConfigurationUseCase({ jwksEndpoint, } = body - let clientSecret = rawClientSecret - if (rawClientSecret === REDACTED_MARKER) { - const [existing] = await db - .select({ oidcConfig: ssoProvider.oidcConfig }) - .from(ssoProvider) - .where(ownerClause) - .limit(1) - if (!existing?.oidcConfig) { - return failSsoProvider( - 400, - 'Cannot update: existing provider not found. Re-enter your client secret.' - ) - } + const existing = ownedProvider + let stored: Record = {} + if (existing?.oidcConfig) { try { - const stored = toRecord(JSON.parse(existing.oidcConfig)) - const secret = toStringOrNull(stored.clientSecret) - if (!secret) return failSsoProvider(400, 'Re-enter your client secret.') - clientSecret = secret + stored = toRecord(JSON.parse(existing.oidcConfig)) } catch { - return failSsoProvider( - 400, - 'Cannot update: failed to read existing secret. Re-enter your client secret.' - ) + return failSsoProvider(400, 'Cannot update invalid OIDC configuration.') } } + let clientSecret = rawClientSecret + if (rawClientSecret === REDACTED_MARKER) { + const secret = toStringOrNull(stored.clientSecret) + if (!secret) return failSsoProvider(400, 'Re-enter your client secret.') + clientSecret = secret + } + const existingTokenMethod = + existing?.issuer === issuer ? stored.tokenEndpointAuthentication : undefined const oidcConfig: NonNullable = { clientId, clientSecret, + tokenEndpointAuthentication: + existingTokenMethod === 'client_secret_basic' || + existingTokenMethod === 'client_secret_post' + ? existingTokenMethod + : undefined, authorizationEndpoint, tokenEndpoint, userInfoEndpoint, @@ -576,232 +592,97 @@ export const saveSsoProvider = defineOrganizationConfigurationUseCase({ organizationId: orgId, }) - if (await findProviderIdConflict()) { - logger.warn('Rejected SSO registration: providerId was claimed during registration', { - providerId, - orgId, - userId: context.userId, - }) - return providerIdConflictResponse() - } - - await findDomainRefusal() - - // Authoritative verification re-check: the verified row could have been - // removed during OIDC discovery. Re-checking here (not just at handler - // entry) ensures ownership still holds at the moment of the write. - if (!(await isOrgDomainVerified())) { - logger.warn( - 'Rejected SSO registration: domain verification was revoked during registration', - { - domain, - orgId, - userId: context.userId, - } + const result = await db.transaction(async (tx) => { + await acquireOrganizationMutationLock(tx, orgId) + await lockSsoProvider(tx, providerId) + await authorizeOrganizationOperation( + principal, + ssoProviderOperations.save, + { organizationId: orgId }, + { executor: tx, forUpdate: true } ) - return domainNotVerifiedResponse() - } - - // OIDC discovery may outlive the caller's administrator membership or OAuth grant. - await authorizeOrganizationOperation(principal, ssoProviderOperations.save, { - organizationId: orgId, - }) - - // Better Auth's registerSSOProvider is create-only (it throws on an existing - // providerId). If the caller already owns a provider with this id, route the - // edit through updateSSOProvider so re-saving an SSO config works instead of - // failing. The verification gate above already ran against the target domain, - // so an edit that moves SSO to an unverified domain is still blocked. - // Config columns are captured, not just the id: an update whose trust grant is - // refused has to be undone, or the rejected config stays stored and goes live - // the moment the domain is verified again. - const [existingOwnedProvider] = await db - .select({ - id: ssoProvider.id, - issuer: ssoProvider.issuer, - domain: ssoProvider.domain, - domainVerified: ssoProvider.domainVerified, - oidcConfig: ssoProvider.oidcConfig, - samlConfig: ssoProvider.samlConfig, - jitProvisioningEnabled: ssoProvider.jitProvisioningEnabled, - }) - .from(ssoProvider) - .where(ownerClause) - .limit(1) - - /** - * Grants domain trust only while the proof is held under a row lock. - * - * A WHERE-clause EXISTS test is not enough: under READ COMMITTED the subquery - * sees the statement's original snapshot, so a delete committing while the - * UPDATE waits can still grant trust after ownership is gone. The row lock - * orders the two — the delete blocks until this commits, and if it committed - * first the SELECT finds nothing. - * - * A provider joining a domain another provider already signs in does not - * take over by sorting first: unless the domain's named primary still signs - * it in, the provider signing it in until now is named, in the same - * transaction. The lock is `FOR UPDATE` so two providers joining at once - * settle it one after the other. - */ - const grantProviderDomainTrust = (joinsDomain: boolean, rowId: string): Promise => - db.transaction(async (tx) => { - const [proof] = await tx - .select({ id: ssoDomain.id }) - .from(ssoDomain) - .where(verifiedDomainClause) - .limit(1) - .for('update') - if (!proof) return false - - const granted = await tx - .update(ssoProvider) - .set({ domainVerified: true, jitProvisioningEnabled }) - .where(and(ownerClause, eq(ssoProvider.id, rowId))) - .returning({ id: ssoProvider.id }) - if (granted.length === 0) return false - - if (joinsDomain) { - await keepDomainSignInProvider(tx, { - domainRecordId: proof.id, - organizationId: orgId, - domain, - joiningProviderId: providerId, - }) + if (await findProviderIdConflict(tx)) return providerIdConflictResponse() + await findDomainRefusal(tx) + const [proof] = await tx + .select({ id: ssoDomain.id }) + .from(ssoDomain) + .where(verifiedDomainClause) + .limit(1) + .for('update') + if (!proof) return domainNotVerifiedResponse() + const [existing] = await tx + .select() + .from(ssoProvider) + .where(ownerClause) + .limit(1) + .for('update') + if (existing && !(providerType === 'oidc' ? existing.oidcConfig : existing.samlConfig)) + return failSsoProvider( + 409, + 'An existing SSO provider cannot change protocols. Create a separate provider.' + ) + if (existing?.oidcConfig && providerConfig.oidcConfig && body.providerType === 'oidc') { + const current = toRecord(JSON.parse(existing.oidcConfig)) + if (body.clientSecret === REDACTED_MARKER) { + const secret = toStringOrNull(current.clientSecret) + if (!secret) return failSsoProvider(400, 'Re-enter your client secret.') + providerConfig.oidcConfig.clientSecret = secret } - return true - }) - - if (existingOwnedProvider) { - const revertProviderUpdate = async (): Promise => { - await db - .update(ssoProvider) - .set({ - issuer: existingOwnedProvider.issuer, - domain: existingOwnedProvider.domain, - oidcConfig: existingOwnedProvider.oidcConfig, - samlConfig: existingOwnedProvider.samlConfig, - domainVerified: false, - jitProvisioningEnabled: existingOwnedProvider.jitProvisioningEnabled, - }) - .where(eq(ssoProvider.id, existingOwnedProvider.id)) - } - - await writer.update({ - providerId, - issuer, - domain, - ...(providerConfig.oidcConfig ? { oidcConfig: providerConfig.oidcConfig } : {}), - ...(providerConfig.samlConfig ? { samlConfig: providerConfig.samlConfig } : {}), - }) - - let domainTrustGranted: boolean - try { - domainTrustGranted = await grantProviderDomainTrust( - !existingOwnedProvider.domainVerified || - normalizeSSODomain(existingOwnedProvider.domain) !== domain, - existingOwnedProvider.id + if ( + existing.issuer === issuer && + (current.tokenEndpointAuthentication === 'client_secret_basic' || + current.tokenEndpointAuthentication === 'client_secret_post') ) - } catch (error) { - try { - await revertProviderUpdate() - } catch (rollbackError) { - logger.error('Failed to revert SSO provider after domain trust write failed', { - domain, - orgId, + providerConfig.oidcConfig.tokenEndpointAuthentication = + current.tokenEndpointAuthentication + } + const writer = await ssoProviderWriter(principal, orgId, tx) + let rowId: string + if (existing) { + await writer.update( + { providerId, - userId: context.userId, - error, - rollbackError, - }) - } - throw error + issuer, + domain, + ...(providerConfig.oidcConfig ? { oidcConfig: providerConfig.oidcConfig } : {}), + ...(providerConfig.samlConfig ? { samlConfig: providerConfig.samlConfig } : {}), + }, + { clearUserInfoEndpoint: body.providerType === 'oidc' && body.skipUserInfoEndpoint } + ) + rowId = existing.id + } else { + const registration = await writer.register(providerConfig).catch((error: unknown) => { + if (getPostgresErrorCode(error) === '23505') + throw new OrchestrationError( + 'conflict', + 'The provider ID was claimed during registration. Reload the providers and retry.' + ) + throw error + }) + rowId = registration.id } - - // Restore the pre-update config and clear the flag together. Clearing alone - // is not enough: re-verifying the domain now regrants trust automatically, - // which would activate the very config this request reported as rejected. - if (!domainTrustGranted) { - await revertProviderUpdate() - logger.warn('Reverted SSO update: domain verification was removed mid-write', { + const granted = await tx + .update(ssoProvider) + .set({ domainVerified: true, jitProvisioningEnabled }) + .where(and(ownerClause, eq(ssoProvider.id, rowId))) + .returning({ id: ssoProvider.id }) + if (!granted.length) + throw new OrchestrationError('conflict', 'The provider changed during registration.') + if (!existing || !existing.domainVerified || normalizeSSODomain(existing.domain) !== domain) + await keepDomainSignInProvider(tx, { + domainRecordId: proof.id, + organizationId: orgId, domain, - orgId, - providerId, - userId: context.userId, + joiningProviderId: providerId, }) - return domainNotVerifiedResponse() - } - - /** The edit may have changed whether this provider can satisfy the sign-in requirement. */ - invalidateSsoPolicyCache(orgId) - - logger.info('SSO provider updated successfully', { providerId, providerType, domain }) return { - created: false, + created: !existing, providerId, providerType, - message: `${providerType.toUpperCase()} provider updated successfully`, + message: `${providerType.toUpperCase()} provider ${existing ? 'updated' : 'registered'} successfully`, } - } - - const registration = await writer.register(providerConfig).catch((error: unknown) => { - if (getPostgresErrorCode(error) === '23505') - throw new OrchestrationError( - 'conflict', - 'The provider ID was claimed during registration. Reload the providers and retry.' - ) - throw error }) - - // Better Auth omits the runtime record ID from its type; trust and rollback must bind to that record. - const createdRowId = toStringOrNull(toRecord(registration).id) - if (!createdRowId) throw new Error('SSO registration returned no provider record identifier') - const revertProviderRegistration = () => - db - .delete(ssoProvider) - .where(and(eq(ssoProvider.id, createdRowId), eq(ssoProvider.organizationId, orgId))) - let domainTrustGranted: boolean - try { - domainTrustGranted = await grantProviderDomainTrust(true, createdRowId) - } catch (error) { - try { - await revertProviderRegistration() - } catch (rollbackError) { - logger.error('Failed to remove SSO provider after domain trust write failed', { - domain, - orgId, - providerId, - error, - rollbackError, - }) - } - throw error - } - if (!domainTrustGranted) { - await revertProviderRegistration() - logger.warn('Rolled back SSO provider: domain verification revoked mid-registration', { - domain, - orgId, - providerId: registration.providerId, - userId: context.userId, - }) - return domainNotVerifiedResponse() - } - - /** A new provider can make an organization able to require single sign-on again. */ invalidateSsoPolicyCache(orgId) - - logger.info('SSO provider registered successfully', { - providerId, - providerType, - domain, - }) - - return { - created: true, - providerId: registration.providerId, - providerType, - message: `${providerType.toUpperCase()} provider registered successfully`, - } + return result }, }) diff --git a/apps/sim/lib/auth/sso/application/provider-settings.integration.ts b/apps/sim/lib/auth/sso/application/provider-settings.integration.ts index 144d25ee2a1..4a4dd3bfdfb 100644 --- a/apps/sim/lib/auth/sso/application/provider-settings.integration.ts +++ b/apps/sim/lib/auth/sso/application/provider-settings.integration.ts @@ -1,8 +1,16 @@ +import { execFile } from 'node:child_process' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { createServer } from 'node:http' +import { tmpdir } from 'node:os' +import { resolve } from 'node:path' +import { promisify } from 'node:util' +import { createDeferred } from '@sim/testing/helpers/deferred' import { envFlagsMock, setEnvFlags } from '@sim/testing/mocks/env-flags.mock' import { inputValidationMock, inputValidationMockFns, } from '@sim/testing/mocks/input-validation.mock' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { omit } from '@sim/utils/object' import { NextRequest } from 'next/server' @@ -24,15 +32,12 @@ describe('Organization SSO administration through API credentials', () => { let runtime: Awaited> async function loadRuntime() { - const [{ db }, schema, { and, eq, inArray, sql }, providers, requirements, primary] = - await Promise.all([ - import('@sim/db'), - import('@sim/db/schema'), - import('drizzle-orm'), - import('@/lib/auth/sso/application/provider-settings'), - import('@/lib/auth/sso/application/sso-requirement'), - import('@/lib/auth/sso/application/set-primary-provider'), - ]) + const { db } = await import('@sim/db') + const schema = await import('@sim/db/schema') + const { and, eq, inArray, sql } = await import('drizzle-orm') + const providers = await import('@/lib/auth/sso/application/provider-settings') + const requirements = await import('@/lib/auth/sso/application/sso-requirement') + const primary = await import('@/lib/auth/sso/application/set-primary-provider') const { saveSsoProvider } = await import('@/lib/auth/sso/application/provider-registration') const { presentSsoProvider } = await import('@/lib/api/server/sso-presenters') const domainSettings = await import('@/lib/organizations/application/domain-settings') @@ -169,18 +174,21 @@ describe('Organization SSO administration through API credentials', () => { it('creates and edits a provider without minting a browser session, then deletes it', async () => { const { db, schema, eq } = runtime - const created = await runtime.saveSsoProvider.execute({ principal, input: config() }) + const created = await runtime.saveSsoProvider.execute({ + principal, + input: { ...config(), domain: domain.toUpperCase() }, + }) expect(created).toMatchObject({ providerId, created: true }) const edited = await runtime.saveSsoProvider.execute({ principal, - input: { ...config(), cert: 'rotated signing certificate' }, + input: { ...config(), domain: domain.toUpperCase(), cert: 'rotated signing certificate' }, }) expect(edited.created).toBe(false) const [row] = await db .select() .from(schema.ssoProvider) .where(eq(schema.ssoProvider.providerId, providerId)) - expect(row).toMatchObject({ userId, organizationId, domainVerified: true }) + expect(row).toMatchObject({ userId, organizationId, domain, domainVerified: true }) expect(JSON.parse(row.samlConfig ?? '{}').cert).toBe('rotated signing certificate') expect( await db.select().from(schema.session).where(eq(schema.session.userId, userId)) @@ -365,6 +373,519 @@ describe('Organization SSO administration through API credentials', () => { } }) + it('removes a saved UserInfo endpoint when identity-token claims are requested', async () => { + inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({ + isValid: true, + resolvedIP: '203.0.113.10', + }) + inputValidationMockFns.mockSecureFetchWithPinnedIP.mockRejectedValue( + new Error('Fixture supplies all OIDC endpoints') + ) + const oidc = oidcConfig() + await runtime.saveSsoProvider.execute({ principal, input: oidc }) + inputValidationMockFns.mockValidateUrlWithDNS.mockImplementation(async (url: string) => + url === oidc.userInfoEndpoint + ? { isValid: false, error: 'Unused UserInfo endpoint must not be contacted' } + : { isValid: true, resolvedIP: '203.0.113.10' } + ) + await runtime.saveSsoProvider.execute({ + principal, + input: { ...oidc, skipUserInfoEndpoint: true }, + }) + const { db, schema, eq } = runtime + const [stored] = await db + .select() + .from(schema.ssoProvider) + .where(eq(schema.ssoProvider.providerId, providerId)) + expect(JSON.parse(stored.oidcConfig ?? '{}').userInfoEndpoint).toBeUndefined() + expect(stored.domainVerified).toBe(true) + }) + + it('preserves token authentication when an existing issuer cannot be discovered', async () => { + inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({ + isValid: true, + resolvedIP: '203.0.113.10', + }) + inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue( + new Response( + JSON.stringify({ token_endpoint_auth_methods_supported: ['client_secret_basic'] }) + ) + ) + const oidc = oidcConfig() + await runtime.saveSsoProvider.execute({ principal, input: oidc }) + inputValidationMockFns.mockSecureFetchWithPinnedIP.mockRejectedValue( + new Error('Discovery unavailable') + ) + await runtime.saveSsoProvider.execute({ + principal, + input: { ...oidc, clientSecret: '[REDACTED]' }, + }) + const { db, schema, eq } = runtime + const [stored] = await db + .select() + .from(schema.ssoProvider) + .where(eq(schema.ssoProvider.providerId, providerId)) + expect(JSON.parse(stored.oidcConfig ?? '{}')).toMatchObject({ + clientSecret: oidc.clientSecret, + tokenEndpointAuthentication: 'client_secret_basic', + }) + inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue( + new Response( + JSON.stringify({ + token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'], + }) + ) + ) + const other = { ...oidc, providerId: `${providerId}-post` } + await runtime.saveSsoProvider.execute({ principal, input: other }) + const [preferred] = await db + .select() + .from(schema.ssoProvider) + .where(eq(schema.ssoProvider.providerId, other.providerId)) + expect(JSON.parse(preferred.oidcConfig ?? '{}').tokenEndpointAuthentication).toBe( + 'client_secret_post' + ) + }) + + it('preserves private SAML metadata during a linked certificate rotation and projects legacy metadata', async () => { + const { db, schema, eq } = runtime + await runtime.saveSsoProvider.execute({ principal, input: config() }) + const [original] = await db + .select() + .from(schema.ssoProvider) + .where(eq(schema.ssoProvider.providerId, providerId)) + const stored = JSON.parse(original.samlConfig ?? '{}') + await db + .update(schema.ssoProvider) + .set({ + samlConfig: JSON.stringify({ + ...stored, + spMetadata: { + ...stored.spMetadata, + entityID: 'stable-service', + privateKey: 'fixture-private-key', + encPrivateKey: 'fixture-encryption-key', + }, + idpMetadata: { + ...stored.idpMetadata, + entityID: 'stable-idp', + isAssertionEncrypted: true, + }, + }), + }) + .where(eq(schema.ssoProvider.id, original.id)) + const accountId = generateId() + await db.insert(schema.account).values({ + id: accountId, + accountId: 'linked-saml', + providerId, + userId, + createdAt: new Date(), + updatedAt: new Date(), + }) + try { + await runtime.saveSsoProvider.execute({ + principal, + input: { ...config(), cert: 'rotated private-metadata certificate' }, + }) + const [rotated] = await db + .select() + .from(schema.ssoProvider) + .where(eq(schema.ssoProvider.id, original.id)) + expect(JSON.parse(rotated.samlConfig ?? '{}')).toMatchObject({ + cert: 'rotated private-metadata certificate', + spMetadata: { + entityID: 'stable-service', + privateKey: 'fixture-private-key', + encPrivateKey: 'fixture-encryption-key', + }, + idpMetadata: { entityID: 'stable-idp', isAssertionEncrypted: true }, + }) + const legacyMetadata = '' + await db + .update(schema.ssoProvider) + .set({ samlConfig: JSON.stringify({ ...stored, idpMetadata: legacyMetadata }) }) + .where(eq(schema.ssoProvider.id, original.id)) + const listed = await runtime.listSsoProviders.execute({ + principal, + input: { organizationId, limit: 1 }, + }) + expect( + JSON.parse(runtime.presentSsoProvider(listed.providers[0]).samlConfig ?? '{}') + ).toMatchObject({ + idpMetadata: { metadata: legacyMetadata }, + }) + } finally { + await db.delete(schema.account).where(eq(schema.account.id, accountId)) + } + }) + + it('rejects malformed discovery and protocol changes without changing a saved provider', async () => { + inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({ + isValid: true, + resolvedIP: '203.0.113.10', + }) + for (const document of [null, [], 'invalid']) { + inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue( + new Response(JSON.stringify(document)) + ) + await expect( + runtime.saveSsoProvider.execute({ + principal, + input: { + ...oidcConfig(), + authorizationEndpoint: undefined, + tokenEndpoint: undefined, + jwksEndpoint: undefined, + }, + }) + ).rejects.toMatchObject({ status: 400 }) + } + await runtime.saveSsoProvider.execute({ principal, input: config() }) + inputValidationMockFns.mockSecureFetchWithPinnedIP.mockRejectedValue( + new Error('Explicit endpoints') + ) + await expect( + runtime.saveSsoProvider.execute({ principal, input: oidcConfig() }) + ).rejects.toMatchObject({ status: 409 }) + const { db, schema, eq } = runtime + const [stored] = await db + .select() + .from(schema.ssoProvider) + .where(eq(schema.ssoProvider.providerId, providerId)) + expect(stored.oidcConfig).toBeNull() + expect(JSON.parse(stored.samlConfig ?? '{}').cert).toBe(config().cert) + }) + + it('refuses deletion when administrator access is revoked while the write waits', async () => { + const { db, schema, sql, and, eq } = runtime + await runtime.saveSsoProvider.execute({ principal, input: config() }) + await runtime.setPrimarySsoProvider.execute({ + principal, + input: { assertedOrganizationId: organizationId, providerId }, + }) + const ready = createDeferred() + const release = createDeferred() + const holder = db.transaction(async (tx) => { + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${`organization-mutation:${organizationId}`}, 0))` + ) + await tx + .select() + .from(schema.ssoProvider) + .where(eq(schema.ssoProvider.providerId, providerId)) + .for('update') + await tx + .update(schema.member) + .set({ role: 'member' }) + .where( + and(eq(schema.member.organizationId, organizationId), eq(schema.member.userId, userId)) + ) + const [connection] = await tx.execute<{ pid: number }>(sql`SELECT pg_backend_pid() AS pid`) + ready.resolve(connection.pid) + await release.promise + }) + let pending: Promise[]> | undefined + try { + const pid = await ready.promise + const deletion = runtime.deleteSsoProvider.execute({ + principal, + input: { organizationId, providerId }, + }) + pending = Promise.allSettled([deletion]) + await vi.waitFor( + async () => { + const [waiting] = await db.execute<{ waiting: boolean }>( + sql`SELECT EXISTS (SELECT 1 FROM pg_stat_activity WHERE ${pid} = ANY(pg_blocking_pids(pid)) AND wait_event_type = 'Lock') AS waiting` + ) + expect(waiting.waiting).toBe(true) + }, + { timeout: 2000 } + ) + release.resolve() + await holder + await expect(deletion).rejects.toMatchObject({ detailCode: 'ORGANIZATION_ADMIN_REQUIRED' }) + expect( + await db + .select() + .from(schema.ssoProvider) + .where(eq(schema.ssoProvider.providerId, providerId)) + ).toHaveLength(1) + const [claim] = await db + .select() + .from(schema.ssoDomain) + .where(eq(schema.ssoDomain.organizationId, organizationId)) + expect(claim.primaryProviderId).toBe(providerId) + } finally { + release.resolve() + await holder + await pending + await db + .update(schema.member) + .set({ role: 'owner' }) + .where( + and(eq(schema.member.organizationId, organizationId), eq(schema.member.userId, userId)) + ) + } + }) + + it('caps delegated domain reads even when pagination fields are supplied', async () => { + const { db, schema, inArray } = runtime + const domains = Array.from({ length: 26 }, (_, index) => ({ + id: generateId(), + organizationId, + domain: `delegated-${index}-${suffix}.test`, + status: 'pending', + verificationToken: generateId(), + })) + await db.insert(schema.ssoDomain).values(domains) + try { + const result = await runtime.domainSettings.listOrganizationDomains.execute({ + principal: { + kind: 'organization_delegated', + serviceId: 'copilot', + organizationId, + subjectUserId: userId, + delegationId: generateId(), + audience: 'sim:settings', + issuedAt: new Date(Date.now() - 1000), + expiresAt: new Date(Date.now() + 60000), + resourceScope: { chatId: generateId() }, + }, + input: { organizationId, limit: 100, cursorKeys: ['ignored', 'ignored'] }, + }) + expect(result.domains).toHaveLength(25) + expect(result.truncated).toBe(true) + expect(result.nextCursorKeys).toBeNull() + expect(result.domains.every((row) => row.verificationToken === null)).toBe(true) + } finally { + await db.delete(schema.ssoDomain).where( + inArray( + schema.ssoDomain.id, + domains.map((row) => row.id) + ) + ) + } + }) + + it('keeps a pending edit invisible and rolls it back before a later save', async () => { + const { db, schema, sql, eq } = runtime + await runtime.saveSsoProvider.execute({ principal, input: config() }) + await db + .update(schema.ssoProvider) + .set({ domainVerified: false }) + .where(eq(schema.ssoProvider.providerId, providerId)) + const constraint = sql.identifier(`sso-edit-failure-${suffix}`) + await db.execute( + sql`ALTER TABLE ${schema.ssoProvider} ADD CONSTRAINT ${constraint} CHECK (NOT domain_verified OR saml_config::jsonb ->> 'cert' <> 'rejected-certificate') NOT VALID` + ) + const ready = createDeferred() + const release = createDeferred() + const holder = db.transaction(async (tx) => { + await tx + .select() + .from(schema.ssoDomain) + .where(eq(schema.ssoDomain.organizationId, organizationId)) + .for('update') + const [connection] = await tx.execute<{ pid: number }>(sql`SELECT pg_backend_pid() AS pid`) + ready.resolve(connection.pid) + await release.promise + }) + let attempts: Promise[]> | undefined + try { + const pid = await ready.promise + const pending = runtime.saveSsoProvider.execute({ + principal, + input: { ...config(), cert: 'rejected-certificate' }, + }) + attempts = Promise.allSettled([pending]) + await vi.waitFor( + async () => { + const [waiting] = await db.execute<{ waiting: boolean }>( + sql`SELECT EXISTS (SELECT 1 FROM pg_stat_activity WHERE ${pid} = ANY(pg_blocking_pids(pid)) AND wait_event_type = 'Lock') AS waiting` + ) + expect(waiting.waiting).toBe(true) + }, + { timeout: 2000 } + ) + const [visible] = await db + .select() + .from(schema.ssoProvider) + .where(eq(schema.ssoProvider.providerId, providerId)) + expect(JSON.parse(visible.samlConfig ?? '{}').cert).toBe(config().cert) + const later = runtime.saveSsoProvider.execute({ + principal, + input: { ...config(), cert: 'successful-certificate' }, + }) + attempts = Promise.allSettled([pending, later]) + release.resolve() + await holder + expect((await attempts).map((result) => result.status)).toEqual(['rejected', 'fulfilled']) + const [stored] = await db + .select() + .from(schema.ssoProvider) + .where(eq(schema.ssoProvider.providerId, providerId)) + expect(stored.domainVerified).toBe(true) + expect(JSON.parse(stored.samlConfig ?? '{}').cert).toBe('successful-certificate') + } finally { + release.resolve() + await holder + await attempts + await db.execute(sql`ALTER TABLE ${schema.ssoProvider} DROP CONSTRAINT ${constraint}`) + } + }) + + it('executes primary selection and domain verification through the real CLI and HTTP routes', async () => { + const apiPrimary = await import( + '@/app/api/v2/organizations/[organizationId]/sso/providers/[providerId]/primary/route' + ) + const apiDomainVerification = await import( + '@/app/api/v2/organizations/[organizationId]/domains/[domainId]/verify/route' + ) + await runtime.saveSsoProvider.execute({ principal, input: config() }) + const { db, schema, eq } = runtime + const [claim] = await db + .select() + .from(schema.ssoDomain) + .where(eq(schema.ssoDomain.organizationId, organizationId)) + const directory = await mkdtemp(resolve(tmpdir(), 'sim-sso-cli-')) + const cliPath = resolve(process.cwd(), '../../packages/sim-cli/src/index.ts') + const secret = 'fixture-secret+with-newline\n' + const secretPath = resolve(directory, 'client-secret') + await writeFile(secretPath, secret) + inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({ + isValid: true, + resolvedIP: '203.0.113.10', + }) + inputValidationMockFns.mockSecureFetchWithPinnedIP.mockRejectedValue( + new Error('CLI fixture supplies all endpoints') + ) + + let endpoint = '' + const server = createServer(async (incoming, outgoing) => { + try { + const headers = new Headers({ 'x-forwarded-for': '127.0.0.1' }) + for (const [name, value] of Object.entries(incoming.headers)) + if (value) headers.set(name, Array.isArray(value) ? value.join(', ') : value) + const chunks: Buffer[] = [] + let bytes = 0 + for await (const chunk of incoming) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + bytes += buffer.length + if (bytes > 16_384) throw new Error('Fixture body limit exceeded') + chunks.push(buffer) + } + const request = new NextRequest(`${endpoint}${incoming.url}`, { + method: incoming.method, + headers, + ...(chunks.length ? { body: Buffer.concat(chunks).toString('utf8') } : {}), + }) + const path = new URL(request.url).pathname + const response = path.endsWith('/sso/providers') + ? await runtime.apiProviders.POST(request, { + params: Promise.resolve({ organizationId }), + }) + : path.endsWith('/primary') + ? await apiPrimary.POST(request, { + params: Promise.resolve({ organizationId, providerId }), + }) + : await apiDomainVerification.POST(request, { + params: Promise.resolve({ organizationId, domainId: claim.id }), + }) + outgoing.statusCode = response.status + response.headers.forEach((value, name) => outgoing.setHeader(name, value)) + outgoing.end(await response.text()) + } catch (error) { + outgoing.statusCode = 500 + outgoing.end(JSON.stringify({ fixtureError: getErrorMessage(error) })) + } + }) + try { + await new Promise((ready) => server.listen(0, '127.0.0.1', ready)) + const address = server.address() + if (!address || typeof address === 'string') throw new Error('Fixture did not bind loopback') + endpoint = `http://127.0.0.1:${address.port}` + const commands = [ + ['organizations', 'sso', 'providers', 'primary', providerId], + ['organizations', 'domains', 'verify', claim.id], + [ + 'organizations', + 'sso', + 'providers', + 'save', + '--provider-type', + 'oidc', + '--provider-id', + `cli-${suffix}`, + '--issuer', + 'https://idp.example.com', + '--domain', + domain, + '--client-id', + 'cli-client', + '--client-secret', + `@${secretPath}`, + '--authorization-endpoint', + 'https://idp.example.com/authorize', + '--token-endpoint', + 'https://idp.example.com/token', + '--jwks-endpoint', + 'https://idp.example.com/jwks', + '--skip-user-info-endpoint', + ], + ] + const attempts = await Promise.all( + commands.map((args) => + promisify(execFile)( + 'bun', + [ + '--no-env-file', + cliPath, + '--endpoint', + endpoint, + '--output', + 'json', + ...args, + '--organization', + organizationId, + ], + { + cwd: directory, + env: { ...process.env, SIM_CONFIG_DIR: directory, SIM_API_KEY: apiKeyValue }, + timeout: 10_000, + } + ).then( + ({ stdout, stderr }) => ({ code: 0, stdout, stderr }), + (error: unknown) => ({ code: 1, stdout: '', stderr: getErrorMessage(error) }) + ) + ) + ) + expect(attempts).toMatchObject([{ code: 0 }, { code: 0 }, { code: 0 }]) + const [stored] = await db + .select() + .from(schema.ssoDomain) + .where(eq(schema.ssoDomain.id, claim.id)) + expect(stored.primaryProviderId).toBe(providerId) + const [saved] = await db + .select() + .from(schema.ssoProvider) + .where(eq(schema.ssoProvider.providerId, `cli-${suffix}`)) + expect(JSON.parse(saved.oidcConfig ?? '{}').clientSecret).toBe(secret) + expect(attempts[2].stdout).not.toContain(secret) + + expect(JSON.parse(attempts[1].stdout)).toMatchObject({ + id: claim.id, + status: 'verified', + }) + } finally { + await new Promise((complete, reject) => { + server.close((error) => (error ? reject(error) : complete())) + server.closeAllConnections() + }) + await rm(directory, { recursive: true, force: true }) + } + }) + it('refuses a provider write when organization administrator access is revoked during discovery', async () => { const { db, schema, and, eq } = runtime const membership = and( diff --git a/apps/sim/lib/auth/sso/application/provider-settings.ts b/apps/sim/lib/auth/sso/application/provider-settings.ts index 5a661554914..170060d7ccc 100644 --- a/apps/sim/lib/auth/sso/application/provider-settings.ts +++ b/apps/sim/lib/auth/sso/application/provider-settings.ts @@ -18,7 +18,9 @@ import { } from '@/lib/api/list-query' import { ssoProviderOperations } from '@/lib/auth/sso/application/operations' import { markSignInProviders } from '@/lib/auth/sso/primary-provider' +import { lockSsoProvider } from '@/lib/auth/sso/provider-lock' import { invalidateSsoPolicyCache } from '@/lib/auth/sso-policy' +import { acquireOrganizationMutationLock } from '@/lib/billing/organizations/membership' import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import { requireOAuthOperationScope } from '@/lib/core/application/oauth-authorization' import type { OperationUseCase } from '@/lib/core/application/operation' @@ -176,6 +178,18 @@ async function executeDeleteProvider( ? eq(ssoProvider.organizationId, provider.organizationId) : and(eq(ssoProvider.userId, provider.userId), isNull(ssoProvider.organizationId)) const removed = await db.transaction(async (tx) => { + if (provider.organizationId) { + await acquireOrganizationMutationLock(tx, provider.organizationId) + } + await lockSsoProvider(tx, provider.providerId) + if (provider.organizationId) { + await authorizeOrganizationOperation( + principal, + ssoProviderOperations.delete, + { organizationId: provider.organizationId }, + { executor: tx, forUpdate: true } + ) + } const deleted = await tx .delete(ssoProvider) .where(and(eq(ssoProvider.id, provider.id), ownerClause)) diff --git a/apps/sim/lib/auth/sso/application/sso-requirement.ts b/apps/sim/lib/auth/sso/application/sso-requirement.ts index 277070bc50d..9f75389c7da 100644 --- a/apps/sim/lib/auth/sso/application/sso-requirement.ts +++ b/apps/sim/lib/auth/sso/application/sso-requirement.ts @@ -92,7 +92,7 @@ export const setSsoRequirement: OperationUseCase< */ if (input.requireSso && !(await isOrganizationFeatureEntitled(organizationId, isSsoEnabled))) { throw new ForbiddenOperationError( - 'ENTERPRISE_PLAN_REQUIRED', + isBillingEnabled ? 'ENTERPRISE_PLAN_REQUIRED' : 'SSO_DISABLED', isBillingEnabled ? 'Single Sign-On is available on Enterprise plans only' : 'Single Sign-On is disabled. Set ENTERPRISE_ENABLED or SSO_ENABLED to enable it.' diff --git a/apps/sim/lib/auth/sso/plugin.ts b/apps/sim/lib/auth/sso/plugin.ts new file mode 100644 index 00000000000..6d7f153c6ee --- /dev/null +++ b/apps/sim/lib/auth/sso/plugin.ts @@ -0,0 +1,26 @@ +import { sso } from '@better-auth/sso' + +/** Shares the configured SSO plugin between authentication and authorized provider writes. */ +export const configuredSsoPlugin = sso({ + /** + * Trusting `email_verified` bypasses Better Auth's trusted-domain linking + * gate, allowing a tenant's IdP to claim an existing account in another + * domain. Keep linking dependent on verified domain ownership instead. + */ + trustEmailVerified: false, + /** + * Sim proves ownership through `sso_domain` and grants `domainVerified` + * during the provider save. Better Auth requires that proof and a matching + * email domain before linking an SSO identity to an existing account. + */ + domainVerification: { enabled: true }, + organizationProvisioning: { + /** + * Better Auth writes member rows directly and bypasses Sim's seat, + * billing, session-policy, and audit invariants. Admission is owned + * by the application use case in the callback hook. + */ + disabled: true, + defaultRole: 'member', + }, +}) diff --git a/apps/sim/lib/auth/sso/primary-provider.integration.ts b/apps/sim/lib/auth/sso/primary-provider.integration.ts index 73e0808ae4b..4e0ea2567a3 100644 --- a/apps/sim/lib/auth/sso/primary-provider.integration.ts +++ b/apps/sim/lib/auth/sso/primary-provider.integration.ts @@ -1,19 +1,13 @@ -import { setEnvFlags } from '@sim/testing' +import { authMock, authMockFns } from '@sim/testing/mocks/auth.mock' +import { envFlagsMock, setEnvFlags } from '@sim/testing/mocks/env-flags.mock' import { generateId } from '@sim/utils/id' import { NextRequest } from 'next/server' import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetSession, mockRegisterSSOProvider } = vi.hoisted(() => ({ - mockGetSession: vi.fn(), - mockRegisterSSOProvider: vi.fn(), -})) +const mockGetSession = authMockFns.mockGetSession -/** Better Auth's registration is replaced by the row it writes, untrusted until the route grants it. */ -vi.mock('@/lib/core/config/env-flags', async () => (await import('@sim/testing')).envFlagsMock) -vi.mock('@/lib/auth', () => ({ - getSession: mockGetSession, - auth: { api: { registerSSOProvider: mockRegisterSSOProvider, updateSSOProvider: vi.fn() } }, -})) +vi.mock('@/lib/core/config/env-flags', () => envFlagsMock) +vi.mock('@/lib/auth', () => authMock) vi.mock('@/lib/billing', () => ({ hasSSOAccess: vi.fn(async () => true) })) /** Identity provider endpoints are validated by DNS, which test hosts do not have. */ vi.mock('@/lib/core/security/input-validation.server', () => ({ @@ -102,22 +96,6 @@ describe('Primary SSO provider per organization domain in PostgreSQL', () => { .values({ id: generateId(), userId, organizationId, role: 'owner', createdAt: now }) mockGetSession.mockResolvedValue({ user: { id: userId }, session: { id: generateId() } }) setEnvFlags({ isSsoEnabled: true }) - mockRegisterSSOProvider.mockImplementation( - async ({ body }: { body: { providerId: string; issuer: string; domain: string } }) => { - const rowId = generateId() - await runtime.db.insert(runtime.schema.ssoProvider).values({ - id: rowId, - issuer: body.issuer, - domain: body.domain, - userId, - providerId: body.providerId, - organizationId, - oidcConfig: '{}', - domainVerified: false, - }) - return { id: rowId, providerId: body.providerId } - } - ) }) afterEach(async () => { @@ -503,7 +481,7 @@ describe('Primary SSO provider per organization domain in PostgreSQL', () => { await insertProvider({ name: 'foreign', organization: otherOrganizationId, - providerDomain: domain(), + providerDomain: domain().toUpperCase(), }) expect(await register('okta')).toBe(409) }) diff --git a/apps/sim/lib/auth/sso/provider-adapter.ts b/apps/sim/lib/auth/sso/provider-adapter.ts index 90df42c63fc..538523bf9b3 100644 --- a/apps/sim/lib/auth/sso/provider-adapter.ts +++ b/apps/sim/lib/auth/sso/provider-adapter.ts @@ -1,62 +1,44 @@ -import type { sso } from '@better-auth/sso' import { type Principal, requirePrincipalSubjectUserId } from '@sim/auth/principal' -import type { BetterAuthPlugin } from 'better-auth' +import { db } from '@sim/db' import type { auth } from '@/lib/auth' -import { - OrchestrationError, - type OrchestrationRequestContext, -} from '@/lib/core/orchestration/types' +import { configuredSsoPlugin } from '@/lib/auth/sso/plugin' +import { isSsoEnabled } from '@/lib/core/config/env-flags' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbOrTx } from '@/lib/db/types' +/** Registration input accepted by the configured Better Auth SSO plugin. */ export type SsoProviderConfig = NonNullable< Parameters[0] >['body'] -function isSsoPlugin(plugin: BetterAuthPlugin): plugin is ReturnType { - return plugin.id === 'sso' -} - -/** Selects a writer after the application operation has authorized the acting user. */ +/** Creates a common writer after the application operation has authorized the acting user. */ export async function ssoProviderWriter( principal: Principal, organizationId: string, - request?: OrchestrationRequestContext + executor: DbOrTx = db ) { - const { auth } = await import('@/lib/auth') - if (principal.kind === 'session') { - return { - register: (body: SsoProviderConfig) => - auth.api.registerSSOProvider({ - body, - headers: request?.headers instanceof Headers ? request.headers : undefined, - }), - update: (body: NonNullable[0]>['body']) => - auth.api.updateSSOProvider({ - body, - headers: request?.headers instanceof Headers ? request.headers : undefined, - }), - } - } - if (principal.kind !== 'personal_api_key' && principal.kind !== 'oauth_access_token') + if ( + principal.kind !== 'session' && + principal.kind !== 'personal_api_key' && + principal.kind !== 'oauth_access_token' + ) throw new OrchestrationError('forbidden', 'SSO provider writes require a user credential') + if (!isSsoEnabled) throw new OrchestrationError('validation', 'SSO is not enabled') const userId = requirePrincipalSubjectUserId(principal) - const plugins: readonly BetterAuthPlugin[] = auth.options.plugins ?? [] - const plugin = plugins.find(isSsoPlugin) - if (!plugin) throw new OrchestrationError('validation', 'SSO is not enabled') + const { auth } = await import('@/lib/auth') const context = await auth.$context const { createSsoProviderRepository } = await import('@/lib/auth/sso/provider-repository') - return createSsoProviderRepository(userId, organizationId, plugin, { - reservedProviderIds: [ - ...Object.keys(context.options.socialProviders ?? {}), - ...context.socialProviders.map((provider) => provider.id), - ...context.trustedProviders, - ], - hasScimProvider: async (providerId) => - context.hasPlugin('scim') && - Boolean( - await context.adapter.findOne({ - model: 'scimProvider', - where: [{ field: 'providerId', value: providerId }], - }) - ), - }) + return createSsoProviderRepository( + userId, + organizationId, + configuredSsoPlugin, + { + reservedProviderIds: [ + ...Object.keys(context.options.socialProviders ?? {}), + ...context.socialProviders.map((provider) => provider.id), + ...context.trustedProviders, + ], + }, + executor + ) } diff --git a/apps/sim/lib/auth/sso/provider-concurrency.integration.ts b/apps/sim/lib/auth/sso/provider-concurrency.integration.ts new file mode 100644 index 00000000000..9bccc03acef --- /dev/null +++ b/apps/sim/lib/auth/sso/provider-concurrency.integration.ts @@ -0,0 +1,281 @@ +import { createDeferred } from '@sim/testing/helpers/deferred' +import { generateId } from '@sim/utils/id' +import { beforeAll, describe, expect, it, vi } from 'vitest' + +async function loadRuntime() { + const [ + { db }, + schema, + { eq, sql }, + { configuredSsoPlugin }, + providers, + adapters, + locks, + ssoLocks, + ] = await Promise.all([ + import('@sim/db'), + import('@sim/db/schema'), + import('drizzle-orm'), + import('@/lib/auth/sso/plugin'), + import('@/lib/auth/sso/provider-repository'), + import('@/lib/auth/sim-auth-adapter'), + import('@/lib/db/advisory-locks'), + import('@/lib/auth/sso/provider-lock'), + ]) + return { + db, + schema, + eq, + sql, + configuredSsoPlugin, + ...providers, + ...adapters, + ...locks, + ...ssoLocks, + } +} + +describe('SSO account link concurrency', () => { + let runtime: Awaited> + + beforeAll(async () => { + runtime = await loadRuntime() + }, 60_000) + + it.each([false, true])( + 'allows a non-SSO account link while an SSO mutation lock is held with transactional adapter=%s', + async (transactional) => { + const { db, schema, eq, sql } = runtime + const userId = generateId() + const providerId = `social-${generateId()}` + const held = createDeferred() + const release = createDeferred() + const pending: Promise[] = [] + try { + const now = new Date() + await db.insert(schema.user).values({ + id: userId, + name: 'Account concurrency', + email: `${userId}@example.com`, + emailVerified: true, + createdAt: now, + updatedAt: now, + }) + const gate = db.transaction(async (tx) => { + await runtime.lockSsoProvider(tx, providerId) + const [connection] = await tx.execute<{ pid: number }>( + sql`SELECT pg_backend_pid() AS pid` + ) + held.resolve(connection.pid) + await release.promise + }) + pending.push(gate) + void gate.catch((error: unknown) => held.reject(error)) + const blockerPid = await held.promise + const adapter = runtime.createSimAuthAdapter({}) + const accountInput = { + model: 'account', + forceAllowId: true, + data: { + id: generateId(), + accountId: generateId(), + userId, + providerId, + createdAt: now, + updatedAt: now, + }, + } + let inserted = false + const insertion = ( + transactional + ? adapter.transaction((tx) => tx.create(accountInput)) + : adapter.create(accountInput) + ).then((account) => { + inserted = true + return account + }) + pending.push(insertion) + void insertion.catch(() => undefined) + const completedWithoutSsoLock = await vi.waitFor( + async () => { + const [waiting] = await db.execute<{ pid: number }>(sql` + SELECT pid FROM pg_stat_activity + WHERE datname = current_database() AND wait_event = 'advisory' + AND ${blockerPid}::int = ANY(pg_blocking_pids(pid)) + `) + expect(inserted || Boolean(waiting)).toBe(true) + return inserted + }, + { timeout: 5_000, interval: 25 } + ) + expect(completedWithoutSsoLock).toBe(true) + const linked = await db + .select({ userId: schema.account.userId }) + .from(schema.account) + .where(eq(schema.account.providerId, providerId)) + expect(linked).toEqual([{ userId }]) + } finally { + release.resolve() + await Promise.allSettled(pending) + await db.delete(schema.account).where(eq(schema.account.providerId, providerId)) + await db.delete(schema.user).where(eq(schema.user.id, userId)) + } + }, + 30_000 + ) + + it.each([false, true])( + 'preserves the original identity with a transactional adapter=%s', + async (transactional) => { + const { db, schema, eq, sql } = runtime + const userId = generateId() + const organizationId = generateId() + const providerId = `sso-${generateId()}` + const issuer = 'https://original.example.com' + const fixture = `sso_account_gate_${generateId().replaceAll('-', '')}` + const gateKey = `sso-account-fixture:${providerId}` + const held = createDeferred() + const release = createDeferred() + const pending: Promise[] = [] + try { + const now = new Date() + await db.insert(schema.user).values({ + id: userId, + name: 'SSO account concurrency', + email: `${userId}@example.com`, + emailVerified: true, + createdAt: now, + updatedAt: now, + }) + await db.insert(schema.organization).values({ + id: organizationId, + name: 'SSO account concurrency', + slug: organizationId, + createdAt: now, + }) + const repository = runtime.createSsoProviderRepository( + userId, + organizationId, + runtime.configuredSsoPlugin, + { reservedProviderIds: [] } + ) + await repository.register({ + organizationId, + providerId, + issuer, + domain: 'example.com', + samlConfig: { + entryPoint: 'https://original.example.com/sso', + cert: 'fixture certificate', + callbackUrl: 'https://app.example.com/api/auth/sso/saml2/callback', + spMetadata: { metadata: '' }, + mapping: { id: 'sub', email: 'email', name: 'name' }, + }, + }) + await db.execute(sql`CREATE FUNCTION ${sql.identifier(fixture)}() + RETURNS trigger LANGUAGE plpgsql AS $body$ + BEGIN + PERFORM pg_advisory_xact_lock(hashtextextended(TG_ARGV[0], 0)); + RETURN NEW; + END; + $body$`) + await db.execute( + sql.raw(`CREATE TRIGGER "${fixture}" BEFORE INSERT ON account + FOR EACH ROW WHEN (NEW.provider_id = '${providerId}') + EXECUTE FUNCTION "${fixture}"('${gateKey}')`) + ) + const gate = db.transaction(async (tx) => { + await runtime.acquireAdvisoryXactLock(tx, 'sso_account_fixture', gateKey) + const [connection] = await tx.execute<{ pid: number }>( + sql`SELECT pg_backend_pid() AS pid` + ) + held.resolve(connection.pid) + await release.promise + }) + pending.push(gate) + void gate.catch((error: unknown) => held.reject(error)) + const blockerPid = await held.promise + const adapter = runtime.createSimAuthAdapter({}) + const accountInput = { + model: 'account', + forceAllowId: true, + data: { + id: generateId(), + accountId: generateId(), + userId, + providerId, + createdAt: now, + updatedAt: now, + }, + } + const insertion = transactional + ? adapter.transaction((tx) => tx.create(accountInput)) + : adapter.create(accountInput) + pending.push(insertion) + void insertion.catch(() => undefined) + const accountPid = await vi.waitFor( + async () => { + const [waiting] = await db.execute<{ pid: number }>(sql` + SELECT pid FROM pg_stat_activity + WHERE datname = current_database() AND wait_event = 'advisory' + AND ${blockerPid}::int = ANY(pg_blocking_pids(pid)) + `) + expect(waiting).toBeDefined() + return waiting.pid + }, + { timeout: 5_000, interval: 25 } + ) + let updateSettled = false + const update = repository + .update({ providerId, issuer: 'https://changed.example.com' }) + .then( + (value) => { + updateSettled = true + return { status: 'fulfilled' as const, value } + }, + (reason: unknown) => { + updateSettled = true + return { status: 'rejected' as const, reason } + } + ) + pending.push(update) + const waitedForAccount = await vi.waitFor( + async () => { + const [waiting] = await db.execute<{ pid: number }>(sql` + SELECT pid FROM pg_stat_activity + WHERE datname = current_database() AND wait_event = 'advisory' + AND ${accountPid}::int = ANY(pg_blocking_pids(pid)) + `) + expect(Boolean(waiting) || updateSettled).toBe(true) + return Boolean(waiting) + }, + { timeout: 5_000, interval: 25 } + ) + release.resolve() + await gate + await insertion + expect(await update).toMatchObject({ status: 'rejected', reason: { statusCode: 409 } }) + expect(waitedForAccount).toBe(true) + const [provider] = await db + .select({ issuer: schema.ssoProvider.issuer }) + .from(schema.ssoProvider) + .where(eq(schema.ssoProvider.providerId, providerId)) + expect(provider.issuer).toBe(issuer) + const linked = await db + .select({ userId: schema.account.userId }) + .from(schema.account) + .where(eq(schema.account.providerId, providerId)) + expect(linked).toEqual([{ userId }]) + } finally { + release.resolve() + await Promise.allSettled(pending) + await db.execute(sql`DROP TRIGGER IF EXISTS ${sql.identifier(fixture)} ON account`) + await db.execute(sql`DROP FUNCTION IF EXISTS ${sql.identifier(fixture)}()`) + await db.delete(schema.account).where(eq(schema.account.providerId, providerId)) + await db.delete(schema.organization).where(eq(schema.organization.id, organizationId)) + await db.delete(schema.user).where(eq(schema.user.id, userId)) + } + }, + 30_000 + ) +}) diff --git a/apps/sim/lib/auth/sso/provider-lock.ts b/apps/sim/lib/auth/sso/provider-lock.ts new file mode 100644 index 00000000000..24278fb8843 --- /dev/null +++ b/apps/sim/lib/auth/sso/provider-lock.ts @@ -0,0 +1,7 @@ +import { acquireAdvisoryXactLock } from '@/lib/db/advisory-locks' +import type { DbTransaction } from '@/lib/db/types' + +/** Serializes provider mutations and account links across every tenant sharing a provider ID. */ +export function lockSsoProvider(tx: DbTransaction, providerId: string): Promise { + return acquireAdvisoryXactLock(tx, 'sso_provider_mutation', `sso-provider:${providerId}`) +} diff --git a/apps/sim/lib/auth/sso/provider-repository.ts b/apps/sim/lib/auth/sso/provider-repository.ts index be243d6a782..06e377cb366 100644 --- a/apps/sim/lib/auth/sso/provider-repository.ts +++ b/apps/sim/lib/auth/sso/provider-repository.ts @@ -12,6 +12,8 @@ import { APIError } from 'better-auth/api' import { and, count, eq } from 'drizzle-orm' import type { auth } from '@/lib/auth' import type { SsoProviderConfig } from '@/lib/auth/sso/provider-adapter' +import { lockSsoProvider } from '@/lib/auth/sso/provider-lock' +import type { DbOrTx } from '@/lib/db/types' const logger = createLogger('SsoProviderRepository') const BUILT_IN_PROVIDER_IDS = [ @@ -36,7 +38,11 @@ const SAML_SP_IDENTITY_FIELDS = ['metadata', 'entityID'] as const interface RepositoryConfiguration { reservedProviderIds: readonly string[] - hasScimProvider(providerId: string): Promise +} + +/** Distinguishes an omitted UserInfo endpoint from an intentional removal. */ +interface SsoProviderUpdateOptions { + clearUserInfoEndpoint?: boolean } function changedFields( @@ -77,12 +83,17 @@ function normalizeAlgorithm(value: string, algorithms: Record, s ) } -/** Persists API-authorized users directly, without converting their credential into a browser session. */ +function samlMetadata(value: unknown): Record { + return typeof value === 'string' ? { metadata: value } : toRecord(value) +} + +/** Persists authorized provider changes through the caller's database transaction. */ export function createSsoProviderRepository( userId: string, organizationId: string, plugin: ReturnType, - configuration: RepositoryConfiguration + configuration: RepositoryConfiguration, + executor: DbOrTx = db ) { const validateConfig = (body: SsoProviderConfig) => { const config = body.samlConfig @@ -148,12 +159,8 @@ export function createSsoProviderRepository( throw new APIError('UNPROCESSABLE_ENTITY', { message: 'This providerId is reserved and cannot be used for an SSO provider', }) - if (await configuration.hasScimProvider(body.providerId)) - throw new APIError('UNPROCESSABLE_ENTITY', { - message: - 'This providerId is already used by a SCIM provider and cannot be used for an SSO provider', - }) - return db.transaction(async (tx) => { + return executor.transaction(async (tx) => { + await lockSsoProvider(tx, body.providerId) const [subject] = await tx .select() .from(user) @@ -200,9 +207,13 @@ export function createSsoProviderRepository( return { id, providerId: body.providerId } }) }, - async update(input: NonNullable[0]>['body']) { + async update( + input: NonNullable[0]>['body'], + options: SsoProviderUpdateOptions = {} + ) { const body = plugin.endpoints.updateSSOProvider.options.body.parse(input) - return db.transaction(async (tx) => { + return executor.transaction(async (tx) => { + await lockSsoProvider(tx, body.providerId) const [existing] = await tx .select() .from(ssoProvider) @@ -227,7 +238,7 @@ export function createSsoProviderRepository( for (const protocol of ['oidc', 'saml'] as const) { const key = protocol === 'oidc' ? 'oidcConfig' : 'samlConfig' const config = body[key] - if (!config) continue + if (!config && !(protocol === 'oidc' && options.clearUserInfoEndpoint)) continue validateConfig({ providerId: body.providerId, issuer, @@ -237,23 +248,32 @@ export function createSsoProviderRepository( const current = storedConfiguration(existing[key], protocol.toUpperCase()) const updated: Record = { ...current, - ...filterUndefined(config), + ...filterUndefined(config ?? {}), issuer, } if (protocol === 'oidc') { updated.pkce = toRecord(config).pkce ?? current.pkce ?? true + if (options.clearUserInfoEndpoint) updated.userInfoEndpoint = undefined identityChanged ||= changedFields(current, updated, OIDC_IDENTITY_FIELDS) } else { + for (const metadataKey of ['idpMetadata', 'spMetadata'] as const) { + const incoming = body.samlConfig?.[metadataKey] + if (current[metadataKey] !== undefined || incoming !== undefined) + updated[metadataKey] = { + ...samlMetadata(current[metadataKey]), + ...filterUndefined(incoming ?? {}), + } + } identityChanged ||= changedFields(current, updated, SAML_IDENTITY_FIELDS) || changedFields( - toRecord(current.idpMetadata), - toRecord(updated.idpMetadata), + samlMetadata(current.idpMetadata), + samlMetadata(updated.idpMetadata), SAML_IDP_IDENTITY_FIELDS ) || changedFields( - toRecord(current.spMetadata), - toRecord(updated.spMetadata), + samlMetadata(current.spMetadata), + samlMetadata(updated.spMetadata), SAML_SP_IDENTITY_FIELDS ) } diff --git a/apps/sim/lib/core/application/forbidden.ts b/apps/sim/lib/core/application/forbidden.ts index 9de0f6cf29f..c4f3523596d 100644 --- a/apps/sim/lib/core/application/forbidden.ts +++ b/apps/sim/lib/core/application/forbidden.ts @@ -37,6 +37,8 @@ export const FORBIDDEN_DETAIL_CODES = [ 'ORGANIZATION_ADMIN_REQUIRED', /** The organization has no usable enterprise subscription. */ 'ENTERPRISE_PLAN_REQUIRED', + /** Single sign-on is switched off for this deployment. */ + 'SSO_DISABLED', /** Verify DNS ownership before configuring a provider for this domain. */ 'SSO_DOMAIN_NOT_VERIFIED', /** The acting user has reached the configured identity-provider ceiling. */ diff --git a/apps/sim/lib/credentials/application/credential-sharing.integration.ts b/apps/sim/lib/credentials/application/credential-sharing.integration.ts index 3584aa6665b..a47432367e2 100644 --- a/apps/sim/lib/credentials/application/credential-sharing.integration.ts +++ b/apps/sim/lib/credentials/application/credential-sharing.integration.ts @@ -223,4 +223,31 @@ describe('Credential sharing through user-held API credentials in PostgreSQL', ( expect(rows).toHaveLength(1) expect(rows[0].status).toBe('active') }) + it('reports one revocation when concurrent requests remove the same active grant', async () => { + const input = { credentialId, assertedWorkspaceId: workspaceId, userId: readerId } + await runtime.upsertCredentialMemberUseCase.execute({ + principal, + input: { ...input, role: 'member' }, + }) + const results = await Promise.allSettled( + Array.from({ length: 8 }, () => + runtime.removeCredentialMemberUseCase.execute({ principal, input }) + ) + ) + expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1) + for (const result of results) + if (result.status === 'rejected') expect(result.reason).toMatchObject({ code: 'not_found' }) + const { db, schema, and, eq } = runtime + expect( + await db + .select() + .from(schema.credentialMember) + .where( + and( + eq(schema.credentialMember.credentialId, credentialId), + eq(schema.credentialMember.userId, readerId) + ) + ) + ).toMatchObject([{ status: 'revoked' }]) + }) }) diff --git a/apps/sim/lib/credentials/members.ts b/apps/sim/lib/credentials/members.ts index 0f3bafe8372..114fffdbc2b 100644 --- a/apps/sim/lib/credentials/members.ts +++ b/apps/sim/lib/credentials/members.ts @@ -285,7 +285,27 @@ export async function removeCredentialMember(params: { } const revoked = await db.transaction(async (tx) => { - if (!isSharedCredentialType(params.credential.type) && target.role === 'admin') { + const [lockedCredential] = await tx + .select({ id: credential.id }) + .from(credential) + .where(eq(credential.id, params.credential.id)) + .limit(1) + .for('update') + if (!lockedCredential) throw new OrchestrationError('not_found', 'Credential not found') + const [activeTarget] = await tx + .select({ id: credentialMember.id, role: credentialMember.role }) + .from(credentialMember) + .where( + and( + eq(credentialMember.credentialId, lockedCredential.id), + eq(credentialMember.userId, params.targetUserId), + eq(credentialMember.status, 'active') + ) + ) + .limit(1) + .for('update') + if (!activeTarget) throw new OrchestrationError('not_found', 'Member not found') + if (!isSharedCredentialType(params.credential.type) && activeTarget.role === 'admin') { const activeAdmins = await tx .select({ id: credentialMember.id }) .from(credentialMember) @@ -299,10 +319,12 @@ export async function removeCredentialMember(params: { .for('update') if (activeAdmins.length <= 1) return false } - await tx + const removed = await tx .update(credentialMember) .set({ status: 'revoked', updatedAt: new Date() }) - .where(eq(credentialMember.id, target.id)) + .where(and(eq(credentialMember.id, activeTarget.id), eq(credentialMember.status, 'active'))) + .returning({ id: credentialMember.id }) + if (!removed.length) throw new OrchestrationError('not_found', 'Member not found') return true }) if (!revoked) throw new OrchestrationError('validation', 'Cannot remove the last admin') diff --git a/apps/sim/lib/knowledge/application/slack-search/assistant.integration.ts b/apps/sim/lib/knowledge/application/slack-search/assistant.integration.ts index 0923e5b5bbb..5a3f2966f11 100644 --- a/apps/sim/lib/knowledge/application/slack-search/assistant.integration.ts +++ b/apps/sim/lib/knowledge/application/slack-search/assistant.integration.ts @@ -10,6 +10,10 @@ import { mothershipChatPayloadMockFns, } from '@sim/testing/mocks/mothership-chat-payload.mock' import { mothershipEnvironmentContextMock } from '@sim/testing/mocks/mothership-environment-context.mock' +import { + mothershipHeadlessLifecycleMock, + mothershipHeadlessLifecycleMockFns, +} from '@sim/testing/mocks/mothership-headless-lifecycle.mock' import { organizationAuthorizationMock } from '@sim/testing/mocks/organization-authorization.mock' import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' @@ -18,7 +22,6 @@ const hoisted = vi.hoisted(() => ({ turnId: '', userId: '', organizationId: '', - lifecycle: vi.fn(), /** The turn's own controller, which a Stop aborts through its registered stream. */ controller: new AbortController(), stopped: vi.fn(async () => false), @@ -69,9 +72,7 @@ vi.mock('@/lib/mothership/chat/terminal-state', () => ({ finalizeAssistantTurn: hoisted.finalize, })) vi.mock('@/lib/mothership/environment-context', () => mothershipEnvironmentContextMock) -vi.mock('@/lib/mothership/request/lifecycle/headless', () => ({ - runHeadlessCopilotLifecycle: hoisted.lifecycle, -})) +vi.mock('@/lib/mothership/request/lifecycle/headless', () => mothershipHeadlessLifecycleMock) vi.mock('@/lib/mothership/request/session/abort', () => ({ acquirePendingChatStream: async () => true, cleanupAbortMarker: async () => undefined, @@ -106,6 +107,8 @@ import { eq } from 'drizzle-orm' import { runSlackSearchAssistant } from '@/lib/knowledge/application/slack-search/assistant' import { AbortReason } from '@/lib/mothership/request/session/abort-reason' +const { mockRunHeadlessCopilotLifecycle } = mothershipHeadlessLifecycleMockFns + const principal = { kind: 'slack_installation', credentialId: 'c1', @@ -206,7 +209,7 @@ describe('Slack Assistant run record', () => { } it('records a completed turn as complete', async () => { - hoisted.lifecycle.mockResolvedValueOnce({ + mockRunHeadlessCopilotLifecycle.mockResolvedValueOnce({ success: true, content: 'Answer', contentBlocks: [], @@ -221,7 +224,7 @@ describe('Slack Assistant run record', () => { }) it('records a turn its user stopped as cancelled', async () => { - hoisted.lifecycle.mockImplementationOnce(async () => { + mockRunHeadlessCopilotLifecycle.mockImplementationOnce(async () => { /** A Slack Stop marks the turn stopped, then aborts its registered stream. */ hoisted.stopped.mockResolvedValue(true) hoisted.controller.abort(AbortReason.UserStop) @@ -235,7 +238,7 @@ describe('Slack Assistant run record', () => { }) it('records a failed turn as an error', async () => { - hoisted.lifecycle.mockResolvedValueOnce({ + mockRunHeadlessCopilotLifecycle.mockResolvedValueOnce({ success: false, error: 'worker failed', content: '', @@ -251,7 +254,7 @@ describe('Slack Assistant run record', () => { it('records a failed turn as an error even when its Stop cannot be looked up', async () => { hoisted.stopped.mockRejectedValue(new Error('database unavailable')) - hoisted.lifecycle.mockResolvedValueOnce({ + mockRunHeadlessCopilotLifecycle.mockResolvedValueOnce({ success: false, error: 'worker failed', content: '', @@ -266,7 +269,7 @@ describe('Slack Assistant run record', () => { }) it('records an answered turn as an error when its response is not saved', async () => { - hoisted.lifecycle.mockResolvedValueOnce(answered) + mockRunHeadlessCopilotLifecycle.mockResolvedValueOnce(answered) hoisted.finalize.mockResolvedValue({ appendedAssistant: false }) const { run, outcome } = await slackTurn() @@ -276,7 +279,7 @@ describe('Slack Assistant run record', () => { }) it('records an answered turn as an error when its outcome is not saved', async () => { - hoisted.lifecycle.mockResolvedValueOnce(answered) + mockRunHeadlessCopilotLifecycle.mockResolvedValueOnce(answered) hoisted.outcome.mockRejectedValueOnce(new Error('outcome write failed')) const { run, outcome } = await slackTurn() diff --git a/apps/sim/lib/knowledge/application/slack-search/assistant.test.ts b/apps/sim/lib/knowledge/application/slack-search/assistant.test.ts index 7c131c04567..45f5dbb44ec 100644 --- a/apps/sim/lib/knowledge/application/slack-search/assistant.test.ts +++ b/apps/sim/lib/knowledge/application/slack-search/assistant.test.ts @@ -11,6 +11,10 @@ import { mothershipChatPayloadMockFns, } from '@sim/testing/mocks/mothership-chat-payload.mock' import { mothershipEnvironmentContextMock } from '@sim/testing/mocks/mothership-environment-context.mock' +import { + mothershipHeadlessLifecycleMock, + mothershipHeadlessLifecycleMockFns, +} from '@sim/testing/mocks/mothership-headless-lifecycle.mock' import { organizationAuthorizationMock } from '@sim/testing/mocks/organization-authorization.mock' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -23,7 +27,6 @@ const hoisted = vi.hoisted(() => ({ lock: vi.fn(), owners: vi.fn(), release: vi.fn(), - run: vi.fn(), finalize: vi.fn(), start: vi.fn(), finish: vi.fn(), @@ -86,9 +89,7 @@ vi.mock('@/lib/mothership/chat/persisted-message', () => ({ })) vi.mock('@/lib/mothership/chat/terminal-state', () => ({ finalizeAssistantTurn: hoisted.finalize })) vi.mock('@/lib/mothership/environment-context', () => mothershipEnvironmentContextMock) -vi.mock('@/lib/mothership/request/lifecycle/headless', () => ({ - runHeadlessCopilotLifecycle: hoisted.run, -})) +vi.mock('@/lib/mothership/request/lifecycle/headless', () => mothershipHeadlessLifecycleMock) vi.mock('@/lib/mothership/request/session/abort', () => ({ acquirePendingChatStream: hoisted.lock, cleanupAbortMarker: vi.fn(), @@ -122,6 +123,7 @@ import { SlackSearchIdentityError } from '@/lib/knowledge/application/slack-sear const m = { ...hoisted, + run: mothershipHeadlessLifecycleMockFns.mockRunHeadlessCopilotLifecycle, createRun: mothershipAsyncRunsMockFns.mockCreateRunSegment, updateRun: mothershipAsyncRunsMockFns.mockUpdateRunStatus, payload: mothershipChatPayloadMockFns.mockBuildCopilotRequestPayload, diff --git a/apps/sim/lib/knowledge/mcp/server.protocol.test.ts b/apps/sim/lib/knowledge/mcp/server.protocol.test.ts index 594af0f2070..2b5aaf4561d 100644 --- a/apps/sim/lib/knowledge/mcp/server.protocol.test.ts +++ b/apps/sim/lib/knowledge/mcp/server.protocol.test.ts @@ -1,6 +1,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' import { createPersonalApiKeyPrincipal } from '@sim/testing/factories/principal.factory' +import { apiServerRoutesMock } from '@sim/testing/mocks/api-server-routes.mock' import { urlsMockFns } from '@sim/testing/mocks/urls.mock' import { NextRequest } from 'next/server' import { describe, expect, it, vi } from 'vitest' @@ -12,6 +13,7 @@ const hoisted = vi.hoisted(() => ({ vi.mock('@/lib/core/utils/after-response', () => ({ afterResponse: vi.fn() })) vi.mock('@/lib/knowledge/mcp/activity', () => ({ recordOrganizationSearchMcpActivity: vi.fn() })) vi.mock('@/lib/api/server/routes/v2-json-route', () => ({ + ...apiServerRoutesMock, v2RateLimits: { publicApi: { enforce: vi.fn().mockResolvedValue(null) } }, })) vi.mock('@/lib/sim-search/live/application', () => ({ diff --git a/apps/sim/lib/knowledge/mcp/server.test.ts b/apps/sim/lib/knowledge/mcp/server.test.ts index 4617bf078af..087fd8b2bd1 100644 --- a/apps/sim/lib/knowledge/mcp/server.test.ts +++ b/apps/sim/lib/knowledge/mcp/server.test.ts @@ -1,5 +1,6 @@ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js' import { createPersonalApiKeyPrincipal } from '@sim/testing/factories/principal.factory' +import { apiServerRoutesMock } from '@sim/testing/mocks/api-server-routes.mock' import { getMockLogger } from '@sim/testing/mocks/logger.mock' import { urlsMockFns } from '@sim/testing/mocks/urls.mock' import { NextRequest } from 'next/server' @@ -41,6 +42,7 @@ vi.mock('@modelcontextprotocol/sdk/server/mcp.js', () => ({ }, })) vi.mock('@/lib/api/server/routes/v2-json-route', () => ({ + ...apiServerRoutesMock, v2RateLimits: { publicApi: { enforce: hoisted.rateLimit } }, })) vi.mock('@/lib/sim-search/live/application', () => ({ diff --git a/apps/sim/lib/mothership/inbox/executor.test.ts b/apps/sim/lib/mothership/inbox/executor.test.ts index 34484aca06e..b825db1c975 100644 --- a/apps/sim/lib/mothership/inbox/executor.test.ts +++ b/apps/sim/lib/mothership/inbox/executor.test.ts @@ -21,6 +21,10 @@ import { mothershipChatPayloadMockFns, } from '@sim/testing/mocks/mothership-chat-payload.mock' import { mothershipChatStatusMock } from '@sim/testing/mocks/mothership-chat-status.mock' +import { + mothershipHeadlessLifecycleMock, + mothershipHeadlessLifecycleMockFns, +} from '@sim/testing/mocks/mothership-headless-lifecycle.mock' import { permissionsMock, permissionsMockFns } from '@sim/testing/mocks/permissions.mock' import { storageServiceMock, storageServiceMockFns } from '@sim/testing/mocks/storage-service.mock' import { @@ -37,18 +41,13 @@ import { } from '@sim/testing/mocks/workspaces-utils.mock' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockGetMessage, - mockGetAttachment, - mockRunHeadlessCopilotLifecycle, - mockSendInboxResponse, -} = vi.hoisted(() => ({ +const { mockGetMessage, mockGetAttachment, mockSendInboxResponse } = vi.hoisted(() => ({ mockGetMessage: vi.fn(), mockGetAttachment: vi.fn(), - mockRunHeadlessCopilotLifecycle: vi.fn(), mockSendInboxResponse: vi.fn(), })) const { mockResolveOrCreateChat } = mothershipChatLifecycleMockFns +const { mockRunHeadlessCopilotLifecycle } = mothershipHeadlessLifecycleMockFns const { mockBuildIntegrationToolSchemas } = mothershipChatPayloadMockFns const { mockUploadFile, mockDeleteFile } = storageServiceMockFns const { mockDeleteFileMetadata } = uploadsMetadataMockFns @@ -84,9 +83,7 @@ vi.mock('@/lib/mothership/entitlements', () => ({ computeWorkspaceEntitlements: vi.fn().mockResolvedValue([]), })) -vi.mock('@/lib/mothership/request/lifecycle/headless', () => ({ - runHeadlessCopilotLifecycle: mockRunHeadlessCopilotLifecycle, -})) +vi.mock('@/lib/mothership/request/lifecycle/headless', () => mothershipHeadlessLifecycleMock) vi.mock('@/lib/mothership/request/lifecycle/start', () => ({ requestChatTitle: vi.fn(), diff --git a/apps/sim/lib/mothership/tasks/wake.test.ts b/apps/sim/lib/mothership/tasks/wake.test.ts index 4790fd65403..16267744cb8 100644 --- a/apps/sim/lib/mothership/tasks/wake.test.ts +++ b/apps/sim/lib/mothership/tasks/wake.test.ts @@ -14,20 +14,19 @@ import { mothershipChatStatusMock, mothershipChatStatusMockFns, } from '@sim/testing/mocks/mothership-chat-status.mock' +import { + mothershipHeadlessLifecycleMock, + mothershipHeadlessLifecycleMockFns, +} from '@sim/testing/mocks/mothership-headless-lifecycle.mock' import { permissionsMock, permissionsMockFns } from '@sim/testing/mocks/permissions.mock' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockRunHeadlessCopilotLifecycle, - mockAcquirePendingChatStream, - mockReleasePendingChatStream, - mockAuthorizeTaskWake, -} = vi.hoisted(() => ({ - mockRunHeadlessCopilotLifecycle: vi.fn(), - mockAcquirePendingChatStream: vi.fn(), - mockReleasePendingChatStream: vi.fn(), - mockAuthorizeTaskWake: vi.fn(), -})) +const { mockAcquirePendingChatStream, mockReleasePendingChatStream, mockAuthorizeTaskWake } = + vi.hoisted(() => ({ + mockAcquirePendingChatStream: vi.fn(), + mockReleasePendingChatStream: vi.fn(), + mockAuthorizeTaskWake: vi.fn(), + })) vi.mock('@/lib/mothership/tasks/application/prepare-wake', () => ({ authorizeTaskWake: mockAuthorizeTaskWake, @@ -36,9 +35,7 @@ vi.mock('@/lib/billing/core/billing-attribution', () => billingAttributionMock) vi.mock('@/lib/mothership/chat/messages-store', () => mothershipChatMessagesMock) vi.mock('@/lib/mothership/chat/payload', () => mothershipChatPayloadMock) vi.mock('@/lib/mothership/chat-status', () => mothershipChatStatusMock) -vi.mock('@/lib/mothership/request/lifecycle/headless', () => ({ - runHeadlessCopilotLifecycle: mockRunHeadlessCopilotLifecycle, -})) +vi.mock('@/lib/mothership/request/lifecycle/headless', () => mothershipHeadlessLifecycleMock) vi.mock('@/lib/mothership/request/session/abort', () => ({ acquirePendingChatStream: mockAcquirePendingChatStream, releasePendingChatStream: mockReleasePendingChatStream, @@ -49,6 +46,7 @@ import { ChatPayloadSchema } from '@/lib/mothership/generated/protocol' import { runWakeTurn } from '@/lib/mothership/tasks/wake' const { mockCheckWorkspaceAccess } = permissionsMockFns +const { mockRunHeadlessCopilotLifecycle } = mothershipHeadlessLifecycleMockFns const { mockAppendCopilotChatMessages } = mothershipChatMessagesMockFns const { mockBuildIntegrationToolSchemas } = mothershipChatPayloadMockFns const { mockPublishStatusChanged } = mothershipChatStatusMockFns diff --git a/apps/sim/lib/organizations/application/domain-settings.ts b/apps/sim/lib/organizations/application/domain-settings.ts index 375469210de..284dbfaf872 100644 --- a/apps/sim/lib/organizations/application/domain-settings.ts +++ b/apps/sim/lib/organizations/application/domain-settings.ts @@ -113,7 +113,7 @@ export const listOrganizationDomains = defineOrganizationConfigurationUseCase({ !isBillingEnabled || (await isOrganizationOnEnterprisePlan(input.organizationId)) if (!isEnterprise) return { isEnterprise: false, domains: [], truncated: false, nextCursorKeys: null } - if (input.limit !== undefined) { + if (input.limit !== undefined && principal.kind !== 'organization_delegated') { const sortKeys = [ textKey(ssoDomain.domain, (row: DomainRow) => row.domain), textKey(ssoDomain.id, (row: DomainRow) => row.id), diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index e83d2bcff8e..891a106eee4 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -1123,6 +1123,13 @@ export const CLI_CONTRACT: CliContract = { saveSsoProvider: { command: 'organizations sso providers save', pathFlags: ORGANIZATION_FLAG, + flags: { + clientSecret: { + textSource: true, + describe: + 'Write-only OIDC client secret; the redacted marker from providers get preserves an existing secret. Passing it inline exposes it to shell history and process listings. Required when --provider-type is oidc', + }, + }, }, deleteSsoProvider: { command: 'organizations sso providers delete', diff --git a/packages/sim-cli/src/contract/types.ts b/packages/sim-cli/src/contract/types.ts index ed8f8aa951d..5328656a9b6 100644 --- a/packages/sim-cli/src/contract/types.ts +++ b/packages/sim-cli/src/contract/types.ts @@ -71,6 +71,8 @@ export interface FlagSpec { manifest?: true /** Take a JSON string. Implied for object/array/unknown fields. */ json?: boolean + /** Read a scalar string verbatim from `@path` / `@-`; `@@` escapes a literal leading `@`. */ + textSource?: true /** * Accept a plain whole number and send the route's `{ type: 'rows', max: n }`. * diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 6aa23058759..045a9362708 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -14618,7 +14618,10 @@ export const V2_OPERATIONS = { method: 'POST', path: '/api/v2/organizations/[organizationId]/domains', pathParams: ['organizationId'] as const, - pathParamDocs: { organizationId: 'Organization whose single sign-on settings are managed.' }, + pathParamDocs: { + organizationId: + 'Organization whose single sign-on settings and verified domains are managed.', + }, responseMode: 'json', summary: 'Add Organization Domain', workspaceKeyUnsupported: true, @@ -16353,7 +16356,8 @@ export const V2_OPERATIONS = { path: '/api/v2/organizations/[organizationId]/sso/providers/[providerId]', pathParams: ['organizationId', 'providerId'] as const, pathParamDocs: { - organizationId: 'Organization whose single sign-on settings are managed.', + organizationId: + 'Organization whose single sign-on settings and verified domains are managed.', providerId: 'Identity provider identifier.', }, responseMode: 'json', @@ -17588,7 +17592,10 @@ export const V2_OPERATIONS = { method: 'GET', path: '/api/v2/organizations/[organizationId]/sso/policy', pathParams: ['organizationId'] as const, - pathParamDocs: { organizationId: 'Organization whose single sign-on settings are managed.' }, + pathParamDocs: { + organizationId: + 'Organization whose single sign-on settings and verified domains are managed.', + }, responseMode: 'json', summary: 'Get SSO Policy', workspaceKeyUnsupported: true, @@ -17598,7 +17605,8 @@ export const V2_OPERATIONS = { path: '/api/v2/organizations/[organizationId]/sso/providers/[providerId]', pathParams: ['organizationId', 'providerId'] as const, pathParamDocs: { - organizationId: 'Organization whose single sign-on settings are managed.', + organizationId: + 'Organization whose single sign-on settings and verified domains are managed.', providerId: 'Identity provider identifier.', }, responseMode: 'json', @@ -19155,7 +19163,10 @@ export const V2_OPERATIONS = { method: 'GET', path: '/api/v2/organizations/[organizationId]/domains', pathParams: ['organizationId'] as const, - pathParamDocs: { organizationId: 'Organization whose single sign-on settings are managed.' }, + pathParamDocs: { + organizationId: + 'Organization whose single sign-on settings and verified domains are managed.', + }, responseMode: 'json', summary: 'List Organization Domains', workspaceKeyUnsupported: true, @@ -19844,7 +19855,10 @@ export const V2_OPERATIONS = { method: 'GET', path: '/api/v2/organizations/[organizationId]/sso/providers', pathParams: ['organizationId'] as const, - pathParamDocs: { organizationId: 'Organization whose single sign-on settings are managed.' }, + pathParamDocs: { + organizationId: + 'Organization whose single sign-on settings and verified domains are managed.', + }, responseMode: 'json', summary: 'List SSO Providers', workspaceKeyUnsupported: true, @@ -20946,7 +20960,8 @@ export const V2_OPERATIONS = { path: '/api/v2/organizations/[organizationId]/domains/[domainId]', pathParams: ['organizationId', 'domainId'] as const, pathParamDocs: { - organizationId: 'Organization whose single sign-on settings are managed.', + organizationId: + 'Organization whose single sign-on settings and verified domains are managed.', domainId: 'Domain claim owned by this organization.', }, responseMode: 'json', @@ -21383,7 +21398,10 @@ export const V2_OPERATIONS = { method: 'POST', path: '/api/v2/organizations/[organizationId]/sso/providers', pathParams: ['organizationId'] as const, - pathParamDocs: { organizationId: 'Organization whose single sign-on settings are managed.' }, + pathParamDocs: { + organizationId: + 'Organization whose single sign-on settings and verified domains are managed.', + }, responseMode: 'json', summary: 'Save SSO Provider', workspaceKeyUnsupported: true, @@ -21781,7 +21799,8 @@ export const V2_OPERATIONS = { path: '/api/v2/organizations/[organizationId]/sso/providers/[providerId]/primary', pathParams: ['organizationId', 'providerId'] as const, pathParamDocs: { - organizationId: 'Organization whose single sign-on settings are managed.', + organizationId: + 'Organization whose single sign-on settings and verified domains are managed.', providerId: 'Identity provider identifier.', }, responseMode: 'json', @@ -22427,7 +22446,10 @@ export const V2_OPERATIONS = { method: 'PATCH', path: '/api/v2/organizations/[organizationId]/sso/policy', pathParams: ['organizationId'] as const, - pathParamDocs: { organizationId: 'Organization whose single sign-on settings are managed.' }, + pathParamDocs: { + organizationId: + 'Organization whose single sign-on settings and verified domains are managed.', + }, responseMode: 'json', summary: 'Update SSO Policy', workspaceKeyUnsupported: true, @@ -22766,7 +22788,8 @@ export const V2_OPERATIONS = { path: '/api/v2/organizations/[organizationId]/domains/[domainId]/verify', pathParams: ['organizationId', 'domainId'] as const, pathParamDocs: { - organizationId: 'Organization whose single sign-on settings are managed.', + organizationId: + 'Organization whose single sign-on settings and verified domains are managed.', domainId: 'Domain claim owned by this organization.', }, responseMode: 'json', diff --git a/packages/sim-cli/src/runtime/options.ts b/packages/sim-cli/src/runtime/options.ts index e3b2a8201c2..fa46d20bf55 100644 --- a/packages/sim-cli/src/runtime/options.ts +++ b/packages/sim-cli/src/runtime/options.ts @@ -18,11 +18,13 @@ export const DEFAULT_PAGE_SIZE = 100 const COMPLETE_LIST_OPERATIONS: ReadonlySet = new Set([ 'listBlocks', 'listChatDeployments', + 'listCredentialMembers', 'listCredentials', 'listCustomTools', 'listPermissionGroups', 'listPermissionGroupMembers', 'listOrganizations', + 'listOrganizationDomains', 'listOrganizationMembers', 'listOrganizationWorkspaces', 'listFiles', @@ -33,6 +35,7 @@ const COMPLETE_LIST_OPERATIONS: ReadonlySet = new Set([ 'listSecrets', 'listSkillEditors', 'listSkills', + 'listSsoProviders', 'listTables', 'listTools', 'listWorkflowMcpServers', @@ -202,9 +205,11 @@ function addFieldOption( ? '' : wantsJson ? '' - : descriptor.nullable - ? '' - : '' + : flag.textSource + ? '' + : descriptor.nullable + ? '' + : '' const choices = flag.choices ?? descriptor.values /** * Only a body field reaches the wire as JSON, and only a plain scalar flag is @@ -218,7 +223,9 @@ function addFieldOption( : ' (space-separated, or @path / @- with one value per line; @@value for a literal leading @)' : wantsJson ? ' (JSON, or @path / @- to read a file or stdin)' - : '' + : flag.textSource + ? ' (@path / @- reads a file or stdin verbatim, including trailing newlines; @@value for a literal leading @)' + : '' }${descriptor.required ? ' (required)' : ''}${literalNull ? literalNullHint(documented, name) : ''}` const renamedFrom = flag.renamedFrom ?? [] diff --git a/packages/sim-cli/src/runtime/request.ts b/packages/sim-cli/src/runtime/request.ts index 4d07ea27b8c..230f9bbd2e1 100644 --- a/packages/sim-cli/src/runtime/request.ts +++ b/packages/sim-cli/src/runtime/request.ts @@ -445,6 +445,10 @@ export async function coerce( if (flag.rowCap) return coerceRowCap(raw, flagName) + if (flag.textSource && typeof raw === 'string') { + return (await readArgumentSource(raw, flagName)).text + } + if (takesJson(field, flag)) { if (typeof raw !== 'string') return raw const source = await readArgumentSource(raw, flagName) diff --git a/packages/testing/src/mocks/auth.mock.ts b/packages/testing/src/mocks/auth.mock.ts index 40f4e965646..8713172e718 100644 --- a/packages/testing/src/mocks/auth.mock.ts +++ b/packages/testing/src/mocks/auth.mock.ts @@ -59,6 +59,9 @@ export const authMock = { resetPassword: authMockFns.mockResetPassword, }, $context: Promise.resolve({ + options: { socialProviders: {} }, + socialProviders: [], + trustedProviders: [], internalAdapter: { createSession: authMockFns.mockCreateSession, updateSession: authMockFns.mockUpdateSession, diff --git a/packages/testing/src/mocks/mothership-headless-lifecycle.mock.ts b/packages/testing/src/mocks/mothership-headless-lifecycle.mock.ts new file mode 100644 index 00000000000..92306be28f7 --- /dev/null +++ b/packages/testing/src/mocks/mothership-headless-lifecycle.mock.ts @@ -0,0 +1,11 @@ +import { vi } from 'vitest' + +/** Controllable headless Copilot lifecycle for route, inbox, task, and Slack tests. */ +export const mothershipHeadlessLifecycleMockFns = { + mockRunHeadlessCopilotLifecycle: vi.fn(), +} + +/** Static mock module for `@/lib/mothership/request/lifecycle/headless`. */ +export const mothershipHeadlessLifecycleMock = { + runHeadlessCopilotLifecycle: mothershipHeadlessLifecycleMockFns.mockRunHeadlessCopilotLifecycle, +} From 4fdc37e053e64b88b02db4385a3998c0b7da82a5 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 7 Oct 2026 20:00:17 -0700 Subject: [PATCH 3/5] fix(auth): align SSO admission locks and reject stale provider links --- apps/docs/openapi-v2-resources.json | 2 +- .../v2/openapi/credential-members.ts | 2 +- .../lib/api/mcp/generated/v2-operations.ts | 2 +- apps/sim/lib/auth/sim-auth-adapter.ts | 18 +++- .../application/admit-sso-user.integration.ts | 64 +++++++++++- .../sso/application/admit-sso-user.test.ts | 2 +- .../auth/sso/application/admit-sso-user.ts | 23 +++-- .../provider-settings.integration.ts | 33 ++++++- .../sso/provider-concurrency.integration.ts | 99 +++++++++++++++++++ apps/sim/lib/auth/sso/registration-input.ts | 4 +- .../stripe-sync-convergence.integration.ts | 1 + 11 files changed, 230 insertions(+), 20 deletions(-) diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index 2c85cbdf23a..935ab2d09ea 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -7954,7 +7954,7 @@ "get": { "operationId": "listCredentialMembers", "summary": "List Credential Members", - "description": "List explicit credential grants, including revoked grants, and inherited workspace administrator access. Requires workspace read access. Credentials must be OAuth or service-account connections. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "description": "List explicit credential grants, including revoked grants, and inherited workspace administrator access. Requires workspace read access. Personal API keys and OAuth tokens can access OAuth or service-account credentials; sessions can also access workspace environment credentials. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", "x-sim-operation": "credentials.members.list", "x-oauth-scope": "api:read", "tags": ["Credentials"], diff --git a/apps/sim/lib/api/contracts/v2/openapi/credential-members.ts b/apps/sim/lib/api/contracts/v2/openapi/credential-members.ts index c26e03cb08f..e49543112b8 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/credential-members.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/credential-members.ts @@ -18,7 +18,7 @@ export const credentialMemberOpenApiRoutes = [ applicationOperation: credentialOperations.listMembers, operationId: 'listCredentialMembers', summary: 'List Credential Members', - description: `List explicit credential grants, including revoked grants, and inherited workspace administrator access. Requires workspace read access. Credentials must be OAuth or service-account connections. ${WORKSPACE_API_KEY_DENIED}`, + description: `List explicit credential grants, including revoked grants, and inherited workspace administrator access. Requires workspace read access. Personal API keys and OAuth tokens can access OAuth or service-account credentials; sessions can also access workspace environment credentials. ${WORKSPACE_API_KEY_DENIED}`, tags: ['Credentials'], errors: RESOURCE_ERRORS, success: { description: 'List Credential Members result.', headers: RATE_LIMIT_HEADERS }, diff --git a/apps/sim/lib/api/mcp/generated/v2-operations.ts b/apps/sim/lib/api/mcp/generated/v2-operations.ts index ae0cb39aa0f..8df7aca0c3e 100644 --- a/apps/sim/lib/api/mcp/generated/v2-operations.ts +++ b/apps/sim/lib/api/mcp/generated/v2-operations.ts @@ -1726,7 +1726,7 @@ export const V2_MCP_OPERATIONS = { contract: v2ListCredentialMembersContract, summary: 'List Credential Members', description: - 'List explicit credential grants, including revoked grants, and inherited workspace administrator access. Requires workspace read access. Credentials must be OAuth or service-account connections. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + 'List explicit credential grants, including revoked grants, and inherited workspace administrator access. Requires workspace read access. Personal API keys and OAuth tokens can access OAuth or service-account credentials; sessions can also access workspace environment credentials. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', workspaceKeyUnsupported: true, handler: () => import('@/app/api/v2/credentials/[credentialId]/members/route').then((route) => route.GET), diff --git a/apps/sim/lib/auth/sim-auth-adapter.ts b/apps/sim/lib/auth/sim-auth-adapter.ts index dbec035316b..b2bebd08c7c 100644 --- a/apps/sim/lib/auth/sim-auth-adapter.ts +++ b/apps/sim/lib/auth/sim-auth-adapter.ts @@ -2,7 +2,8 @@ import { db } from '@sim/db' import * as schema from '@sim/db/schema' import type { BetterAuthOptions } from 'better-auth' import { drizzleAdapter } from 'better-auth/adapters/drizzle' -import { eq } from 'drizzle-orm' +import { APIError } from 'better-auth/api' +import { and, eq } from 'drizzle-orm' import { runWithAuthDatabase } from '@/lib/auth/database-context' import { type AuthDatabase, @@ -39,7 +40,20 @@ function createTransactionAdapter( .from(schema.ssoProvider) .where(eq(schema.ssoProvider.providerId, input.data.providerId)) .limit(1) - if (provider) await lockSsoProvider(tx, input.data.providerId) + if (provider) { + await lockSsoProvider(tx, input.data.providerId) + const [current] = await tx + .select({ id: schema.ssoProvider.id }) + .from(schema.ssoProvider) + .where( + and( + eq(schema.ssoProvider.id, provider.id), + eq(schema.ssoProvider.providerId, input.data.providerId) + ) + ) + .limit(1) + if (!current) throw new APIError('NOT_FOUND', { message: 'SSO provider not found' }) + } } return guarded.create(input) }, diff --git a/apps/sim/lib/auth/sso/application/admit-sso-user.integration.ts b/apps/sim/lib/auth/sso/application/admit-sso-user.integration.ts index 876d73f4d32..072ba900cc0 100644 --- a/apps/sim/lib/auth/sso/application/admit-sso-user.integration.ts +++ b/apps/sim/lib/auth/sso/application/admit-sso-user.integration.ts @@ -1,3 +1,4 @@ +import { createDeferred } from '@sim/testing/helpers/deferred' import { auditMock } from '@sim/testing/mocks/audit.mock' import { envFlagsMock } from '@sim/testing/mocks/env-flags.mock' import { generateId } from '@sim/utils/id' @@ -18,13 +19,20 @@ vi.mock('@/lib/billing/organizations/seats', () => ({ reconcileOrganizationSeats vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) async function loadRuntime() { - const [{ db }, schema, { eq, inArray }, { admitSsoUser }] = await Promise.all([ + const [ + { db }, + schema, + { eq, inArray, sql }, + { admitSsoUser }, + { acquireOrganizationMutationLock }, + ] = await Promise.all([ import('@sim/db'), import('@sim/db/schema'), import('drizzle-orm'), import('@/lib/auth/sso/application/admit-sso-user'), + import('@/lib/billing/organizations/membership'), ]) - return { db, schema, eq, inArray, admitSsoUser } + return { db, schema, eq, inArray, sql, admitSsoUser, acquireOrganizationMutationLock } } describe('SSO admission with a hosted SCIM directory in PostgreSQL', () => { @@ -133,6 +141,58 @@ describe('SSO admission with a hosted SCIM directory in PostgreSQL', () => { .where(eq(schema.member.userId, userId)) } + it('allows a provider edit to commit while sign-in admission waits for organization mutation', async () => { + const { db, schema, eq, sql } = runtime + await db + .update(schema.scimConnection) + .set({ settings: { disableJit: false } }) + .where(eq(schema.scimConnection.id, connectionId)) + const held = createDeferred() + const release = createDeferred() + const pending: Promise[] = [] + try { + const mutation = db.transaction(async (tx) => { + await runtime.acquireOrganizationMutationLock(tx, organizationId) + const [connection] = await tx.execute<{ pid: number }>(sql`SELECT pg_backend_pid() AS pid`) + held.resolve(connection.pid) + await release.promise + await tx + .select({ id: schema.ssoProvider.id }) + .from(schema.ssoProvider) + .where(eq(schema.ssoProvider.providerId, providerId)) + .for('update', { noWait: true }) + await tx + .update(schema.ssoProvider) + .set({ jitProvisioningEnabled: false }) + .where(eq(schema.ssoProvider.providerId, providerId)) + }) + pending.push(mutation) + void mutation.catch((error: unknown) => held.reject(error)) + const blockerPid = await held.promise + const admission = admit() + pending.push(admission) + void admission.catch(() => undefined) + await vi.waitFor( + async () => { + const [waiting] = await db.execute<{ pid: number }>(sql` + SELECT pid FROM pg_stat_activity + WHERE datname = current_database() AND wait_event = 'advisory' + AND ${blockerPid}::int = ANY(pg_blocking_pids(pid)) + `) + expect(waiting).toBeDefined() + }, + { timeout: 5_000, interval: 25 } + ) + release.resolve() + await mutation + await expect(admission).resolves.toEqual({ kind: 'provisioning-disabled', organizationId }) + expect(await membership()).toEqual([]) + } finally { + release.resolve() + await Promise.allSettled(pending) + } + }) + it('honors disableJit without a global billing read creating fresh membership', async () => { await expect(admit()).resolves.toEqual({ kind: 'provisioning-disabled', organizationId }) expect(await membership()).toEqual([]) diff --git a/apps/sim/lib/auth/sso/application/admit-sso-user.test.ts b/apps/sim/lib/auth/sso/application/admit-sso-user.test.ts index e7be51a24c5..5c36560532e 100644 --- a/apps/sim/lib/auth/sso/application/admit-sso-user.test.ts +++ b/apps/sim/lib/auth/sso/application/admit-sso-user.test.ts @@ -71,6 +71,7 @@ function queueIdentity({ accountLinked?: boolean email?: string } = {}) { + queueTableRows(schemaMock.ssoProvider, [{ id: 'sso-1', organizationId }]) queueTableRows(schemaMock.ssoProvider, [ { id: 'sso-1', @@ -152,7 +153,6 @@ describe('SSO JIT admission', () => { kind: 'denied', reason: 'provider-not-trusted', }) - expect(mockAcquireOrganizationUserMutationLocks).not.toHaveBeenCalled() expect(mockEnsureUserInOrganizationTx).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/auth/sso/application/admit-sso-user.ts b/apps/sim/lib/auth/sso/application/admit-sso-user.ts index 52f48fe48ee..8e97f7fda49 100644 --- a/apps/sim/lib/auth/sso/application/admit-sso-user.ts +++ b/apps/sim/lib/auth/sso/application/admit-sso-user.ts @@ -74,6 +74,20 @@ async function runAdmissionTransaction( providerId: string ): Promise { return db.transaction(async (tx) => { + const [scope] = await tx + .select({ id: ssoProvider.id, organizationId: ssoProvider.organizationId }) + .from(ssoProvider) + .where(eq(ssoProvider.providerId, providerId)) + .limit(1) + if (!scope) { + return { providerId, result: { kind: 'denied', reason: 'provider-not-found' } } + } + if (scope.organizationId) { + await acquireOrganizationUserMutationLocks(tx, { + userId, + organizationIds: [scope.organizationId], + }) + } const [provider] = await tx .select({ id: ssoProvider.id, @@ -83,11 +97,11 @@ async function runAdmissionTransaction( organizationId: ssoProvider.organizationId, }) .from(ssoProvider) - .where(eq(ssoProvider.providerId, providerId)) + .where(and(eq(ssoProvider.id, scope.id), eq(ssoProvider.providerId, providerId))) .limit(1) .for('share') - if (!provider) { + if (!provider || provider.organizationId !== scope.organizationId) { return { providerId, result: { kind: 'denied', reason: 'provider-not-found' } } } if (!provider.domainVerified) { @@ -127,11 +141,6 @@ async function runAdmissionTransaction( } } - await acquireOrganizationUserMutationLocks(tx, { - userId, - organizationIds: [provider.organizationId], - }) - /** * An organization whose directory is the only way in has said so on its * SCIM connection; a first sign-in must not create a membership the directory diff --git a/apps/sim/lib/auth/sso/application/provider-settings.integration.ts b/apps/sim/lib/auth/sso/application/provider-settings.integration.ts index 4a4dd3bfdfb..b398c4432f6 100644 --- a/apps/sim/lib/auth/sso/application/provider-settings.integration.ts +++ b/apps/sim/lib/auth/sso/application/provider-settings.integration.ts @@ -1,4 +1,5 @@ import { execFile } from 'node:child_process' +import { Resolver } from 'node:dns/promises' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { createServer } from 'node:http' import { tmpdir } from 'node:os' @@ -748,6 +749,16 @@ describe('Organization SSO administration through API credentials', () => { .select() .from(schema.ssoDomain) .where(eq(schema.ssoDomain.organizationId, organizationId)) + const [pendingClaim] = await db + .insert(schema.ssoDomain) + .values({ + id: generateId(), + organizationId, + domain: `aaa-pending-${suffix}.test`, + status: 'pending', + verificationToken: generateId(), + }) + .returning() const directory = await mkdtemp(resolve(tmpdir(), 'sim-sso-cli-')) const cliPath = resolve(process.cwd(), '../../packages/sim-cli/src/index.ts') const secret = 'fixture-secret+with-newline\n' @@ -790,7 +801,7 @@ describe('Organization SSO administration through API credentials', () => { params: Promise.resolve({ organizationId, providerId }), }) : await apiDomainVerification.POST(request, { - params: Promise.resolve({ organizationId, domainId: claim.id }), + params: Promise.resolve({ organizationId, domainId: pendingClaim.id }), }) outgoing.statusCode = response.status response.headers.forEach((value, name) => outgoing.setHeader(name, value)) @@ -800,14 +811,22 @@ describe('Organization SSO administration through API credentials', () => { outgoing.end(JSON.stringify({ fixtureError: getErrorMessage(error) })) } }) + const dns = vi + .spyOn(Resolver.prototype, 'resolveTxt') + .mockImplementation(async (host) => + host === `_sim-challenge.${pendingClaim.domain}` + ? [[`sim-domain-verification=${pendingClaim.verificationToken}`]] + : [] + ) try { + setEnvFlags({ isHosted: true }) await new Promise((ready) => server.listen(0, '127.0.0.1', ready)) const address = server.address() if (!address || typeof address === 'string') throw new Error('Fixture did not bind loopback') endpoint = `http://127.0.0.1:${address.port}` const commands = [ ['organizations', 'sso', 'providers', 'primary', providerId], - ['organizations', 'domains', 'verify', claim.id], + ['organizations', 'domains', 'verify', pendingClaim.id], [ 'organizations', 'sso', @@ -874,10 +893,18 @@ describe('Organization SSO administration through API credentials', () => { expect(attempts[2].stdout).not.toContain(secret) expect(JSON.parse(attempts[1].stdout)).toMatchObject({ - id: claim.id, + id: pendingClaim.id, status: 'verified', }) + const [verified] = await db + .select() + .from(schema.ssoDomain) + .where(eq(schema.ssoDomain.id, pendingClaim.id)) + expect(verified.status).toBe('verified') + expect(verified.verifiedAt).not.toBeNull() } finally { + dns.mockRestore() + setEnvFlags({ isHosted: false }) await new Promise((complete, reject) => { server.close((error) => (error ? reject(error) : complete())) server.closeAllConnections() diff --git a/apps/sim/lib/auth/sso/provider-concurrency.integration.ts b/apps/sim/lib/auth/sso/provider-concurrency.integration.ts index 9bccc03acef..30db175713d 100644 --- a/apps/sim/lib/auth/sso/provider-concurrency.integration.ts +++ b/apps/sim/lib/auth/sso/provider-concurrency.integration.ts @@ -42,6 +42,105 @@ describe('SSO account link concurrency', () => { runtime = await loadRuntime() }, 60_000) + it.each([ + [false, false], + [true, false], + [false, true], + [true, true], + ])( + 'refuses an account link when provider deletion wins with transactional adapter=%s and replacement=%s', + async (transactional, replacement) => { + const { db, schema, eq, sql } = runtime + const userId = generateId() + const providerId = `deleted-${generateId()}` + const held = createDeferred() + const release = createDeferred() + const pending: Promise[] = [] + try { + const now = new Date() + await db.insert(schema.user).values({ + id: userId, + name: 'SSO deletion concurrency', + email: `${userId}@example.com`, + emailVerified: true, + createdAt: now, + updatedAt: now, + }) + await db.insert(schema.ssoProvider).values({ + id: generateId(), + userId, + providerId, + issuer: 'https://original.example.com', + domain: 'example.com', + }) + const deletion = db.transaction(async (tx) => { + await runtime.lockSsoProvider(tx, providerId) + const [connection] = await tx.execute<{ pid: number }>( + sql`SELECT pg_backend_pid() AS pid` + ) + held.resolve(connection.pid) + await release.promise + await tx.delete(schema.ssoProvider).where(eq(schema.ssoProvider.providerId, providerId)) + if (replacement) + await tx.insert(schema.ssoProvider).values({ + id: generateId(), + userId, + providerId, + issuer: 'https://replacement.example.com', + domain: 'example.com', + }) + }) + pending.push(deletion) + void deletion.catch((error: unknown) => held.reject(error)) + const blockerPid = await held.promise + const adapter = runtime.createSimAuthAdapter({}) + const input = { + model: 'account', + forceAllowId: true, + data: { + id: generateId(), + accountId: generateId(), + userId, + providerId, + createdAt: now, + updatedAt: now, + }, + } + const insertion = ( + transactional ? adapter.transaction((tx) => tx.create(input)) : adapter.create(input) + ).then( + (value) => ({ status: 'fulfilled' as const, value }), + (reason: unknown) => ({ status: 'rejected' as const, reason }) + ) + pending.push(insertion) + await vi.waitFor( + async () => { + const [waiting] = await db.execute<{ pid: number }>(sql` + SELECT pid FROM pg_stat_activity + WHERE datname = current_database() AND wait_event = 'advisory' + AND ${blockerPid}::int = ANY(pg_blocking_pids(pid)) + `) + expect(waiting).toBeDefined() + }, + { timeout: 5_000, interval: 25 } + ) + release.resolve() + await deletion + expect(await insertion).toMatchObject({ status: 'rejected', reason: { statusCode: 404 } }) + expect( + await db.select().from(schema.account).where(eq(schema.account.providerId, providerId)) + ).toEqual([]) + } finally { + release.resolve() + await Promise.allSettled(pending) + await db.delete(schema.account).where(eq(schema.account.providerId, providerId)) + await db.delete(schema.ssoProvider).where(eq(schema.ssoProvider.providerId, providerId)) + await db.delete(schema.user).where(eq(schema.user.id, userId)) + } + }, + 30_000 + ) + it.each([false, true])( 'allows a non-SSO account link while an SSO mutation lock is held with transactional adapter=%s', async (transactional) => { diff --git a/apps/sim/lib/auth/sso/registration-input.ts b/apps/sim/lib/auth/sso/registration-input.ts index a393735354e..96479a181b9 100644 --- a/apps/sim/lib/auth/sso/registration-input.ts +++ b/apps/sim/lib/auth/sso/registration-input.ts @@ -17,7 +17,7 @@ const ssoMappingSchema = z export const ssoRegistrationInputSchema = z.discriminatedUnion('providerType', [ z.object({ providerType: z.literal('oidc').default('oidc'), - providerId: z.string().min(1, 'Provider ID is required'), + providerId: z.string().min(1, 'Provider ID is required').max(255), issuer: z.string().url('Issuer must be a valid URL'), domain: z.string().min(1, 'Domain is required'), organizationId: z.string().min(1).max(255), @@ -45,7 +45,7 @@ export const ssoRegistrationInputSchema = z.discriminatedUnion('providerType', [ }), z.object({ providerType: z.literal('saml'), - providerId: z.string().min(1, 'Provider ID is required'), + providerId: z.string().min(1, 'Provider ID is required').max(255), issuer: z.string().url('Issuer must be a valid URL'), domain: z.string().min(1, 'Domain is required'), organizationId: z.string().min(1).max(255), diff --git a/apps/sim/lib/billing/webhooks/stripe-sync-convergence.integration.ts b/apps/sim/lib/billing/webhooks/stripe-sync-convergence.integration.ts index 6e7777e611a..1a757734d4e 100644 --- a/apps/sim/lib/billing/webhooks/stripe-sync-convergence.integration.ts +++ b/apps/sim/lib/billing/webhooks/stripe-sync-convergence.integration.ts @@ -161,6 +161,7 @@ beforeAll(async () => { 'audit_log', 'session', 'account', + 'sso_provider', 'verification', ]) { await connection.unsafe(`CREATE TABLE "${table}" (LIKE public."${table}" INCLUDING ALL)`) From 7a5f87b7d71e928ad307f7d44f5ffb48cbbb63f7 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 7 Oct 2026 20:25:39 -0700 Subject: [PATCH 4/5] fix(auth): serialize domain administration and align SSO validation --- apps/docs/openapi-v2-resources.json | 6 +- .../v2/openapi/credential-members.ts | 12 +- .../lib/api/mcp/generated/v2-operations.ts | 6 +- apps/sim/lib/auth/sso/registration-input.ts | 12 +- .../domain-settings.integration.ts | 280 ++++++++++++++++++ .../application/domain-settings.ts | 153 ++++++---- .../application/security-settings.test.ts | 2 + 7 files changed, 398 insertions(+), 73 deletions(-) create mode 100644 apps/sim/lib/organizations/application/domain-settings.integration.ts diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index 935ab2d09ea..0bd968d73f5 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -7954,7 +7954,7 @@ "get": { "operationId": "listCredentialMembers", "summary": "List Credential Members", - "description": "List explicit credential grants, including revoked grants, and inherited workspace administrator access. Requires workspace read access. Personal API keys and OAuth tokens can access OAuth or service-account credentials; sessions can also access workspace environment credentials. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "description": "List explicit credential grants, including revoked grants, and inherited workspace administrator access. Requires workspace read access and the integrations.manage capability. Personal API keys and OAuth tokens can access OAuth or service-account credentials; sessions can also access workspace environment credentials. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", "x-sim-operation": "credentials.members.list", "x-oauth-scope": "api:read", "tags": ["Credentials"], @@ -8080,7 +8080,7 @@ "post": { "operationId": "upsertCredentialMember", "summary": "Upsert Credential Member", - "description": "Grant or change an existing workspace member’s credential role. Requires credential administrator access. Revoked grants become active again; inherited administrators cannot be demoted. A new grant returns 201; an existing grant returns 200. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "description": "Grant or change an existing workspace member’s credential role. Requires credential administrator access and the integrations.manage capability. Revoked grants become active again; inherited administrators cannot be demoted. A new grant returns 201; an existing grant returns 200. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", "x-sim-operation": "credentials.members.upsert", "x-oauth-scope": "api:write", "tags": ["Credentials"], @@ -8198,7 +8198,7 @@ "delete": { "operationId": "removeCredentialMember", "summary": "Remove Credential Member", - "description": "Revoke an active explicit credential grant. Requires credential administrator access. Inherited workspace administrators cannot be removed; an absent or already-revoked grant returns 404. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "description": "Revoke an active explicit credential grant. Requires credential administrator access and the integrations.manage capability. Inherited workspace administrators cannot be removed; an absent or already-revoked grant returns 404. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", "x-sim-operation": "credentials.members.remove", "x-oauth-scope": "api:write", "tags": ["Credentials"], diff --git a/apps/sim/lib/api/contracts/v2/openapi/credential-members.ts b/apps/sim/lib/api/contracts/v2/openapi/credential-members.ts index e49543112b8..16d3cb12ea6 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/credential-members.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/credential-members.ts @@ -18,7 +18,7 @@ export const credentialMemberOpenApiRoutes = [ applicationOperation: credentialOperations.listMembers, operationId: 'listCredentialMembers', summary: 'List Credential Members', - description: `List explicit credential grants, including revoked grants, and inherited workspace administrator access. Requires workspace read access. Personal API keys and OAuth tokens can access OAuth or service-account credentials; sessions can also access workspace environment credentials. ${WORKSPACE_API_KEY_DENIED}`, + description: `List explicit credential grants, including revoked grants, and inherited workspace administrator access. Requires workspace read access and the integrations.manage capability. Personal API keys and OAuth tokens can access OAuth or service-account credentials; sessions can also access workspace environment credentials. ${WORKSPACE_API_KEY_DENIED}`, tags: ['Credentials'], errors: RESOURCE_ERRORS, success: { description: 'List Credential Members result.', headers: RATE_LIMIT_HEADERS }, @@ -34,7 +34,7 @@ export const credentialMemberOpenApiRoutes = [ v2ListCredentialMembersContract.query, 'ListCredentialMembersQuery', 'Query parameters', - 'Filters and pagination controls.' + 'Workspace scope, sorting, and pagination controls.' ), response: documentedSchema( v2ListCredentialMembersContract.response.schema, @@ -50,7 +50,7 @@ export const credentialMemberOpenApiRoutes = [ applicationOperation: credentialOperations.upsertMember, operationId: 'upsertCredentialMember', summary: 'Upsert Credential Member', - description: `Grant or change an existing workspace member’s credential role. Requires credential administrator access. Revoked grants become active again; inherited administrators cannot be demoted. A new grant returns 201; an existing grant returns 200. ${WORKSPACE_API_KEY_DENIED}`, + description: `Grant or change an existing workspace member’s credential role. Requires credential administrator access and the integrations.manage capability. Revoked grants become active again; inherited administrators cannot be demoted. A new grant returns 201; an existing grant returns 200. ${WORKSPACE_API_KEY_DENIED}`, tags: ['Credentials'], errors: RESOURCE_ERRORS, success: { description: 'Upsert Credential Member result.', headers: RATE_LIMIT_HEADERS }, @@ -66,7 +66,7 @@ export const credentialMemberOpenApiRoutes = [ v2UpsertCredentialMemberContract.query, 'UpsertCredentialMemberQuery', 'Query parameters', - 'Filters and pagination controls.' + 'Workspace containing the credential.' ), body: documentedSchema( v2UpsertCredentialMemberContract.body, @@ -88,7 +88,7 @@ export const credentialMemberOpenApiRoutes = [ applicationOperation: credentialOperations.removeMember, operationId: 'removeCredentialMember', summary: 'Remove Credential Member', - description: `Revoke an active explicit credential grant. Requires credential administrator access. Inherited workspace administrators cannot be removed; an absent or already-revoked grant returns 404. ${WORKSPACE_API_KEY_DENIED}`, + description: `Revoke an active explicit credential grant. Requires credential administrator access and the integrations.manage capability. Inherited workspace administrators cannot be removed; an absent or already-revoked grant returns 404. ${WORKSPACE_API_KEY_DENIED}`, tags: ['Credentials'], errors: RESOURCE_ERRORS, success: { description: 'Remove Credential Member result.', headers: RATE_LIMIT_HEADERS }, @@ -104,7 +104,7 @@ export const credentialMemberOpenApiRoutes = [ v2RemoveCredentialMemberContract.query, 'RemoveCredentialMemberQuery', 'Query parameters', - 'Filters and pagination controls.' + 'Workspace containing the credential.' ), response: documentedSchema( v2RemoveCredentialMemberContract.response.schema, diff --git a/apps/sim/lib/api/mcp/generated/v2-operations.ts b/apps/sim/lib/api/mcp/generated/v2-operations.ts index 8df7aca0c3e..20e50543c2e 100644 --- a/apps/sim/lib/api/mcp/generated/v2-operations.ts +++ b/apps/sim/lib/api/mcp/generated/v2-operations.ts @@ -1726,7 +1726,7 @@ export const V2_MCP_OPERATIONS = { contract: v2ListCredentialMembersContract, summary: 'List Credential Members', description: - 'List explicit credential grants, including revoked grants, and inherited workspace administrator access. Requires workspace read access. Personal API keys and OAuth tokens can access OAuth or service-account credentials; sessions can also access workspace environment credentials. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + 'List explicit credential grants, including revoked grants, and inherited workspace administrator access. Requires workspace read access and the integrations.manage capability. Personal API keys and OAuth tokens can access OAuth or service-account credentials; sessions can also access workspace environment credentials. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', workspaceKeyUnsupported: true, handler: () => import('@/app/api/v2/credentials/[credentialId]/members/route').then((route) => route.GET), @@ -2344,7 +2344,7 @@ export const V2_MCP_OPERATIONS = { contract: v2RemoveCredentialMemberContract, summary: 'Remove Credential Member', description: - 'Revoke an active explicit credential grant. Requires credential administrator access. Inherited workspace administrators cannot be removed; an absent or already-revoked grant returns 404. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + 'Revoke an active explicit credential grant. Requires credential administrator access and the integrations.manage capability. Inherited workspace administrators cannot be removed; an absent or already-revoked grant returns 404. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', workspaceKeyUnsupported: true, handler: () => import('@/app/api/v2/credentials/[credentialId]/members/[userId]/route').then( @@ -2941,7 +2941,7 @@ export const V2_MCP_OPERATIONS = { contract: v2UpsertCredentialMemberContract, summary: 'Upsert Credential Member', description: - 'Grant or change an existing workspace member’s credential role. Requires credential administrator access. Revoked grants become active again; inherited administrators cannot be demoted. A new grant returns 201; an existing grant returns 200. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + 'Grant or change an existing workspace member’s credential role. Requires credential administrator access and the integrations.manage capability. Revoked grants become active again; inherited administrators cannot be demoted. A new grant returns 201; an existing grant returns 200. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', workspaceKeyUnsupported: true, handler: () => import('@/app/api/v2/credentials/[credentialId]/members/route').then((route) => route.POST), diff --git a/apps/sim/lib/auth/sso/registration-input.ts b/apps/sim/lib/auth/sso/registration-input.ts index 96479a181b9..8ffab48cf41 100644 --- a/apps/sim/lib/auth/sso/registration-input.ts +++ b/apps/sim/lib/auth/sso/registration-input.ts @@ -2,10 +2,10 @@ import { z } from 'zod' const ssoMappingSchema = z .object({ - id: z.string().default('sub'), - email: z.string().default('email'), - name: z.string().default('name'), - image: z.string().default('picture'), + id: z.string().min(1).max(255).default('sub'), + email: z.string().min(1).max(255).default('email'), + name: z.string().min(1).max(255).default('name'), + image: z.string().min(1).max(255).default('picture'), }) .default({ id: 'sub', @@ -16,7 +16,7 @@ const ssoMappingSchema = z export const ssoRegistrationInputSchema = z.discriminatedUnion('providerType', [ z.object({ - providerType: z.literal('oidc').default('oidc'), + providerType: z.literal('oidc'), providerId: z.string().min(1, 'Provider ID is required').max(255), issuer: z.string().url('Issuer must be a valid URL'), domain: z.string().min(1, 'Domain is required'), @@ -33,7 +33,7 @@ export const ssoRegistrationInputSchema = z.discriminatedUnion('providerType', [ .map((value) => value.trim()) .filter((value) => value !== '') ), - z.array(z.string()), + z.array(z.string().trim().min(1)), ]) .default(['openid', 'profile', 'email']), pkce: z.boolean().default(true), diff --git a/apps/sim/lib/organizations/application/domain-settings.integration.ts b/apps/sim/lib/organizations/application/domain-settings.integration.ts new file mode 100644 index 00000000000..ad89fe8f464 --- /dev/null +++ b/apps/sim/lib/organizations/application/domain-settings.integration.ts @@ -0,0 +1,280 @@ +import { Resolver } from 'node:dns/promises' +import { createPersonalApiKeyPrincipal } from '@sim/testing/factories/principal.factory' +import { createDeferred } from '@sim/testing/helpers/deferred' +import { envFlagsMock, setEnvFlags } from '@sim/testing/mocks/env-flags.mock' +import { generateId } from '@sim/utils/id' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/core/config/env-flags', () => envFlagsMock) + +async function loadRuntime() { + const [{ db }, schema, { eq, sql }, domains, membership, locks, primary] = await Promise.all([ + import('@sim/db'), + import('@sim/db/schema'), + import('drizzle-orm'), + import('@/lib/organizations/application/domain-settings'), + import('@/lib/billing/organizations/membership'), + import('@/lib/db/advisory-locks'), + import('@sim/db/sso-primary-provider'), + ]) + return { db, schema, eq, sql, ...domains, ...membership, ...locks, ...primary } +} + +describe('Organization domain mutation concurrency in PostgreSQL', () => { + let runtime: Awaited> + let organizationId: string + let userId: string + let domainId: string + let providerId: string + let principal: ReturnType + + beforeAll(async () => { + setEnvFlags({ isHosted: true, isBillingEnabled: false, isOrganizationsEnabled: true }) + runtime = await loadRuntime() + }, 60_000) + + beforeEach(async () => { + const { db, schema } = runtime + organizationId = generateId() + userId = generateId() + domainId = generateId() + providerId = generateId() + principal = createPersonalApiKeyPrincipal({ userId }) + const now = new Date() + await db.insert(schema.user).values({ + id: userId, + name: 'Domain concurrency', + email: `${userId}@example.com`, + emailVerified: true, + createdAt: now, + updatedAt: now, + }) + await db.insert(schema.organization).values({ + id: organizationId, + name: 'Domain concurrency', + slug: organizationId, + createdAt: now, + }) + await db + .insert(schema.member) + .values({ id: generateId(), organizationId, userId, role: 'owner' }) + await db.insert(schema.ssoDomain).values({ + id: domainId, + organizationId, + domain: `${organizationId}.test`, + status: 'verified', + verificationToken: generateId(), + verifiedAt: now, + primaryProviderId: providerId, + }) + await db.insert(schema.ssoProvider).values({ + id: generateId(), + providerId, + organizationId, + userId, + issuer: 'https://idp.example.com', + domain: `${organizationId}.test`, + domainVerified: true, + }) + }) + + afterEach(async () => { + const { db, schema, eq } = runtime + await db.delete(schema.organization).where(eq(schema.organization.id, organizationId)) + await db.delete(schema.user).where(eq(schema.user.id, userId)) + }) + + it('allows provider deletion to commit while domain removal waits for organization mutation', async () => { + const { db, schema, eq, sql } = runtime + const held = createDeferred() + const release = createDeferred() + const pending: Promise[] = [] + try { + const deletion = db.transaction(async (tx) => { + await runtime.acquireOrganizationMutationLock(tx, organizationId) + await tx.delete(schema.ssoProvider).where(eq(schema.ssoProvider.providerId, providerId)) + const [connection] = await tx.execute<{ pid: number }>(sql`SELECT pg_backend_pid() AS pid`) + held.resolve(connection.pid) + await release.promise + await tx + .select({ id: schema.ssoDomain.id }) + .from(schema.ssoDomain) + .where(eq(schema.ssoDomain.id, domainId)) + .for('update', { noWait: true }) + await runtime.forgetPrimaryProvider(tx, organizationId, providerId) + }) + pending.push(deletion) + void deletion.catch((error: unknown) => held.reject(error)) + const blockerPid = await held.promise + const removal = runtime.removeOrganizationDomain.execute({ + principal, + input: { organizationId, domainId }, + }) + pending.push(removal) + void removal.catch(() => undefined) + await vi.waitFor( + async () => { + const [waiting] = await db.execute<{ pid: number }>(sql` + SELECT pid FROM pg_stat_activity + WHERE datname = current_database() + AND ${blockerPid}::int = ANY(pg_blocking_pids(pid)) + `) + expect(waiting).toBeDefined() + }, + { timeout: 5_000, interval: 25 } + ) + release.resolve() + await deletion + await expect(removal).resolves.toEqual({ domain: `${organizationId}.test` }) + expect( + await db.select().from(schema.ssoDomain).where(eq(schema.ssoDomain.id, domainId)) + ).toEqual([]) + expect( + await db + .select() + .from(schema.ssoProvider) + .where(eq(schema.ssoProvider.providerId, providerId)) + ).toEqual([]) + } finally { + release.resolve() + await Promise.allSettled(pending) + } + }) + + it('admits only one concurrent distinct claim at the organization domain cap', async () => { + const { db, schema, eq, sql } = runtime + await db.insert(schema.ssoDomain).values( + Array.from({ length: 23 }, (_, index) => ({ + id: generateId(), + organizationId, + domain: `existing-${index}-${organizationId}.test`, + status: 'pending', + verificationToken: generateId(), + })) + ) + const fixture = `domain_cap_gate_${generateId().replaceAll('-', '')}` + const gateKey = `domain-cap-fixture:${organizationId}` + const held = createDeferred() + const release = createDeferred() + const pending: Promise[] = [] + try { + await db.execute(sql`CREATE FUNCTION ${sql.identifier(fixture)}() + RETURNS trigger LANGUAGE plpgsql AS $body$ + BEGIN + PERFORM pg_advisory_xact_lock(hashtextextended(TG_ARGV[0], 0)); + RETURN NEW; + END; + $body$`) + await db.execute( + sql.raw(`CREATE TRIGGER "${fixture}" BEFORE INSERT ON sso_domain + FOR EACH ROW WHEN (NEW.organization_id = '${organizationId}') + EXECUTE FUNCTION "${fixture}"('${gateKey}')`) + ) + const gate = db.transaction(async (tx) => { + await runtime.acquireAdvisoryXactLock(tx, 'domain_cap_fixture', gateKey) + const [connection] = await tx.execute<{ pid: number }>(sql`SELECT pg_backend_pid() AS pid`) + held.resolve(connection.pid) + await release.promise + }) + pending.push(gate) + void gate.catch((error: unknown) => held.reject(error)) + const blockerPid = await held.promise + const claims = [0, 1].map((index) => + runtime.addOrganizationDomain + .execute({ + principal, + input: { organizationId, domain: `new-${index}-${organizationId}.test` }, + }) + .then( + (value) => ({ status: 'fulfilled' as const, value }), + (reason: unknown) => ({ status: 'rejected' as const, reason }) + ) + ) + pending.push(...claims) + await vi.waitFor( + async () => { + const waiting = await db.execute<{ pid: number }>(sql` + WITH RECURSIVE blocked(pid) AS ( + SELECT pid FROM pg_stat_activity + WHERE datname = current_database() + AND ${blockerPid}::int = ANY(pg_blocking_pids(pid)) + UNION + SELECT activity.pid FROM pg_stat_activity AS activity + JOIN blocked ON blocked.pid = ANY(pg_blocking_pids(activity.pid)) + WHERE activity.datname = current_database() + ) SELECT pid FROM blocked + `) + expect(waiting).toHaveLength(2) + }, + { timeout: 5_000, interval: 25 } + ) + release.resolve() + await gate + const outcomes = await Promise.all(claims) + expect(outcomes.filter((outcome) => outcome.status === 'fulfilled')).toHaveLength(1) + expect(outcomes.filter((outcome) => outcome.status === 'rejected')).toMatchObject([ + { reason: { code: 'validation' } }, + ]) + expect( + await db + .select() + .from(schema.ssoDomain) + .where(eq(schema.ssoDomain.organizationId, organizationId)) + ).toHaveLength(25) + await expect( + runtime.addOrganizationDomain.execute({ + principal, + input: { organizationId, domain: `${organizationId}.test` }, + }) + ).resolves.toMatchObject({ created: false, domain: { id: domainId } }) + } finally { + release.resolve() + await Promise.allSettled(pending) + await db.execute(sql`DROP TRIGGER IF EXISTS ${sql.identifier(fixture)} ON sso_domain`) + await db.execute(sql`DROP FUNCTION IF EXISTS ${sql.identifier(fixture)}()`) + } + }) + + it('refuses verification when administrator membership changes during DNS lookup', async () => { + const { db, schema, eq } = runtime + await db + .update(schema.ssoDomain) + .set({ status: 'pending', verifiedAt: null }) + .where(eq(schema.ssoDomain.id, domainId)) + await db + .update(schema.ssoProvider) + .set({ domainVerified: false }) + .where(eq(schema.ssoProvider.providerId, providerId)) + const dns = vi.spyOn(Resolver.prototype, 'resolveTxt').mockImplementation(async () => { + await db + .update(schema.member) + .set({ role: 'member' }) + .where(eq(schema.member.organizationId, organizationId)) + const [claim] = await db + .select() + .from(schema.ssoDomain) + .where(eq(schema.ssoDomain.id, domainId)) + return [[`sim-domain-verification=${claim.verificationToken}`]] + }) + try { + await expect( + runtime.verifyOrganizationDomain.execute({ + principal, + input: { organizationId, domainId }, + }) + ).rejects.toMatchObject({ detailCode: 'ORGANIZATION_ADMIN_REQUIRED' }) + const [claim] = await db + .select() + .from(schema.ssoDomain) + .where(eq(schema.ssoDomain.id, domainId)) + const [provider] = await db + .select() + .from(schema.ssoProvider) + .where(eq(schema.ssoProvider.providerId, providerId)) + expect(claim.status).toBe('pending') + expect(provider.domainVerified).toBe(false) + } finally { + dns.mockRestore() + } + }) +}) diff --git a/apps/sim/lib/organizations/application/domain-settings.ts b/apps/sim/lib/organizations/application/domain-settings.ts index 284dbfaf872..bc97dd9fb01 100644 --- a/apps/sim/lib/organizations/application/domain-settings.ts +++ b/apps/sim/lib/organizations/application/domain-settings.ts @@ -23,10 +23,14 @@ import { } from '@/lib/auth/sso/domain-verification' import { invalidateSsoPolicyCache } from '@/lib/auth/sso-policy' import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' +import { acquireOrganizationMutationLock } from '@/lib/billing/organizations/membership' import { ForbiddenOperationError } from '@/lib/core/application/forbidden' +import { authorizeOrganizationOperation } from '@/lib/core/application/organization-authorization' +import type { OrganizationOperation } from '@/lib/core/application/organization-operation' import { env, isTruthy } from '@/lib/core/config/env' import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags' import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbTransaction } from '@/lib/db/types' import { defineOrganizationConfigurationUseCase } from '@/lib/organizations/application/authorized-configuration-use-case' import { organizationSecurityOperations } from '@/lib/organizations/application/operations' import { @@ -84,6 +88,21 @@ function providersOnDomain(organizationId: string, domain: string) { sql`${ssoProviderDomainKey} = ${domain}` ) } + +async function lockAndAuthorizeDomainMutation( + tx: DbTransaction, + principal: Principal, + operation: OrganizationOperation, + organizationId: string +) { + await acquireOrganizationMutationLock(tx, organizationId) + return authorizeOrganizationOperation( + principal, + operation, + { organizationId }, + { executor: tx, forUpdate: true } + ) +} export class DomainVerificationLookupError extends OrchestrationError { constructor( readonly status: 422 | 503, @@ -184,31 +203,46 @@ export const addOrganizationDomain = defineOrganizationConfigurationUseCase({ .limit(1) if (verifiedElsewhere && verifiedElsewhere.organizationId !== input.organizationId) domainConflict() - const rows = await db - .select() - .from(ssoDomain) - .where(eq(ssoDomain.organizationId, input.organizationId)) - const existing = rows.find((row) => row.domain === domain) - if (existing) return { domain: domainValue(existing, principal, true), created: false } - if (rows.length >= MAX_ORGANIZATION_DOMAINS) - throw new OrchestrationError( - 'validation', - `An organization can claim at most ${MAX_ORGANIZATION_DOMAINS} domains` - ) try { - const [created] = await db - .insert(ssoDomain) - .values({ - id: generateId(), - organizationId: input.organizationId, - domain, - status: 'pending', - verificationToken: generateVerificationToken(), - createdBy: context.userId, - }) - .returning() - if (!created) throw new Error('Domain insert returned no row') - return { domain: domainValue(created, principal, true), created: true } + return await db.transaction(async (tx) => { + await lockAndAuthorizeDomainMutation( + tx, + principal, + organizationSecurityOperations.addDomain, + input.organizationId + ) + const [existing] = await tx + .select() + .from(ssoDomain) + .where( + and(eq(ssoDomain.organizationId, input.organizationId), eq(ssoDomain.domain, domain)) + ) + .limit(1) + if (existing) return { domain: domainValue(existing, principal, true), created: false } + const rows = await tx + .select({ id: ssoDomain.id }) + .from(ssoDomain) + .where(eq(ssoDomain.organizationId, input.organizationId)) + .limit(MAX_ORGANIZATION_DOMAINS) + if (rows.length >= MAX_ORGANIZATION_DOMAINS) + throw new OrchestrationError( + 'validation', + `An organization can claim at most ${MAX_ORGANIZATION_DOMAINS} domains` + ) + const [created] = await tx + .insert(ssoDomain) + .values({ + id: generateId(), + organizationId: input.organizationId, + domain, + status: 'pending', + verificationToken: generateVerificationToken(), + createdBy: context.userId, + }) + .returning() + if (!created) throw new Error('Domain insert returned no row') + return { domain: domainValue(created, principal, true), created: true } + }) } catch (error) { if (getPostgresErrorCode(error) === '23505') { const [winner] = await db @@ -238,9 +272,15 @@ export const addOrganizationDomain = defineOrganizationConfigurationUseCase({ export const removeOrganizationDomain = defineOrganizationConfigurationUseCase({ operation: organizationSecurityOperations.removeDomain, administratorError: 'Forbidden - Only organization owners and admins can remove domains', - async execute({ input }: { input: DomainInput }) { + async execute({ principal, input }: { principal: Principal; input: DomainInput }) { await requireDomainEnterprise(input.organizationId) const removed = await db.transaction(async (tx) => { + await lockAndAuthorizeDomainMutation( + tx, + principal, + organizationSecurityOperations.removeDomain, + input.organizationId + ) const [deleted] = await tx .delete(ssoDomain) .where( @@ -302,9 +342,15 @@ export const verifyOrganizationDomain = defineOrganizationConfigurationUseCase({ .limit(1) if (verifiedElsewhere && verifiedElsewhere.organizationId !== input.organizationId) domainConflict() - let updated: DomainRow[] + let result: { domain: DomainSettingsValue; verified: boolean } try { - updated = await db.transaction(async (tx) => { + result = await db.transaction(async (tx) => { + await lockAndAuthorizeDomainMutation( + tx, + principal, + organizationSecurityOperations.verifyDomain, + input.organizationId + ) const flipped = await tx .update(ssoDomain) .set({ status: 'verified', verifiedAt: new Date(), updatedAt: new Date() }) @@ -317,40 +363,37 @@ export const verifyOrganizationDomain = defineOrganizationConfigurationUseCase({ ) ) .returning() - if (flipped.length > 0) - await tx - .update(ssoProvider) - .set({ domainVerified: true }) - .where(providersOnDomain(input.organizationId, flipped[0].domain)) - return flipped + const current = + flipped[0] ?? + ( + await tx + .select() + .from(ssoDomain) + .where( + and( + eq(ssoDomain.id, input.domainId), + eq(ssoDomain.organizationId, input.organizationId) + ) + ) + .limit(1) + )[0] + if (current?.status !== 'verified') + throw new OrchestrationError( + 'conflict', + 'The domain changed during verification. Refresh and try again.' + ) + await tx + .update(ssoProvider) + .set({ domainVerified: true }) + .where(providersOnDomain(input.organizationId, current.domain)) + return { domain: domainValue(current, principal, true), verified: flipped.length > 0 } }) } catch (error) { if (getPostgresErrorCode(error) === '23505') domainConflict() throw error } - if (!updated.length) { - const [current] = await db - .select() - .from(ssoDomain) - .where( - and(eq(ssoDomain.id, input.domainId), eq(ssoDomain.organizationId, input.organizationId)) - ) - .limit(1) - if (current?.status === 'verified') { - await db - .update(ssoProvider) - .set({ domainVerified: true }) - .where(providersOnDomain(input.organizationId, current.domain)) - invalidateSsoPolicyCache(input.organizationId) - return { domain: domainValue(current, principal, true), verified: false } - } - throw new OrchestrationError( - 'conflict', - 'The domain changed during verification. Refresh and try again.' - ) - } invalidateSsoPolicyCache(input.organizationId) - return { domain: domainValue(updated[0], principal, true), verified: true } + return result }, projectAudit: ({ input, result }) => result.verified diff --git a/apps/sim/lib/organizations/application/security-settings.test.ts b/apps/sim/lib/organizations/application/security-settings.test.ts index ff1634e233a..e57bd915f4f 100644 --- a/apps/sim/lib/organizations/application/security-settings.test.ts +++ b/apps/sim/lib/organizations/application/security-settings.test.ts @@ -171,6 +171,7 @@ describe('organization domain Settings operations', () => { it.each(['remove', 'verify'] as const)( 'invalidates the SSO requirement after a committed domain %s', async (action) => { + queueTableRows(member, [{ role: 'admin' }]) queueTableRows(member, [{ role: 'admin' }]) if (action === 'verify') { queueTableRows(ssoDomain, [row]) @@ -214,6 +215,7 @@ describe('organization domain Settings operations', () => { async (lookup) => { setEnv({ SSO_SKIP_DOMAIN_VERIFICATION: 'true' }) queueTableRows(member, [{ role: 'admin' }]) + queueTableRows(member, [{ role: 'admin' }]) queueTableRows(ssoDomain, [row]) queueTableRows(ssoDomain, []) mocks.dns.mockResolvedValue(lookup) From 592f969a7d635613df0b30939b3fe62076a89336 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 7 Oct 2026 20:41:55 -0700 Subject: [PATCH 5/5] fix(auth): preserve SSO audit events across application operations --- .../sso/providers/[providerId]/route.test.ts | 8 +++- .../[id]/domains/[domainId]/route.test.ts | 3 ++ .../domains/[domainId]/verify/route.test.ts | 1 + .../organizations/[id]/domains/route.test.ts | 3 ++ .../sso/application/provider-registration.ts | 17 ++++++++ .../provider-settings.integration.ts | 30 +++++++++++++- .../auth/sso/application/provider-settings.ts | 40 ++++++++++++++++--- 7 files changed, 93 insertions(+), 9 deletions(-) diff --git a/apps/sim/app/api/auth/sso/providers/[providerId]/route.test.ts b/apps/sim/app/api/auth/sso/providers/[providerId]/route.test.ts index b2d946bc20f..9e4c66ecd93 100644 --- a/apps/sim/app/api/auth/sso/providers/[providerId]/route.test.ts +++ b/apps/sim/app/api/auth/sso/providers/[providerId]/route.test.ts @@ -28,7 +28,9 @@ describe('DELETE /api/auth/sso/providers/[providerId]', () => { beforeEach(() => { resetDbChainMock() mockGetSession.mockResolvedValue({ user: { id: 'u1' }, session: { id: 'session-1' } }) - dbChainMockFns.returning.mockResolvedValue([{ id: 'row-1' }]) + dbChainMockFns.returning.mockResolvedValue([ + { id: 'row-1', providerId: 'acme-okta', organizationId: null, domain: 'acme.com' }, + ]) }) it('leaves domains alone when deleting a personal provider', async () => { @@ -76,7 +78,9 @@ describe('DELETE /api/auth/sso/providers/[providerId]', () => { expect(refused.status).toBe(403) resetDbChainMock() - dbChainMockFns.returning.mockResolvedValue([{ id: 'row-1' }]) + dbChainMockFns.returning.mockResolvedValue([ + { id: 'row-1', providerId: 'acme-okta', organizationId: null, domain: 'acme.com' }, + ]) queueTableRows(schemaMock.ssoProvider, [ { id: 'row-1', diff --git a/apps/sim/app/api/organizations/[id]/domains/[domainId]/route.test.ts b/apps/sim/app/api/organizations/[id]/domains/[domainId]/route.test.ts index 35b98e71803..91bb9b55bbc 100644 --- a/apps/sim/app/api/organizations/[id]/domains/[domainId]/route.test.ts +++ b/apps/sim/app/api/organizations/[id]/domains/[domainId]/route.test.ts @@ -56,6 +56,7 @@ describe('remove org domain route', () => { * trust in the same transaction, or the authorization outlives the ownership. */ it('revokes SSO domain trust for providers on the removed domain', async () => { + queueTableRows(member, [{ role: 'owner' }]) queueTableRows(member, [{ role: 'owner' }]) dbChainMockFns.returning.mockResolvedValueOnce([{ domain: 'acme.com' }]) const res = await DELETE(createMockRequest('DELETE'), routeContext) @@ -70,6 +71,7 @@ describe('remove org domain route', () => { * the proof was deleted. */ it('matches the provider domain the way it was grandfathered (wildcard-tolerant)', async () => { + queueTableRows(member, [{ role: 'owner' }]) queueTableRows(member, [{ role: 'owner' }]) dbChainMockFns.returning.mockResolvedValueOnce([{ domain: 'acme.com' }]) await DELETE(createMockRequest('DELETE'), routeContext) @@ -80,6 +82,7 @@ describe('remove org domain route', () => { }) it('does not revoke trust when no domain was removed', async () => { + queueTableRows(member, [{ role: 'owner' }]) queueTableRows(member, [{ role: 'owner' }]) dbChainMockFns.returning.mockResolvedValueOnce([]) // delete matched nothing const res = await DELETE(createMockRequest('DELETE'), routeContext) diff --git a/apps/sim/app/api/organizations/[id]/domains/[domainId]/verify/route.test.ts b/apps/sim/app/api/organizations/[id]/domains/[domainId]/verify/route.test.ts index 43ede48b232..acfc01e79e5 100644 --- a/apps/sim/app/api/organizations/[id]/domains/[domainId]/verify/route.test.ts +++ b/apps/sim/app/api/organizations/[id]/domains/[domainId]/verify/route.test.ts @@ -43,6 +43,7 @@ const PENDING_ROW = { /** Queues the membership + pending-row lookups shared by the happy path. */ function queueAdminWithPendingRow() { + queueTableRows(member, [{ role: 'owner' }]) queueTableRows(member, [{ role: 'owner' }]) queueTableRows(ssoDomain, [PENDING_ROW]) // row lookup } diff --git a/apps/sim/app/api/organizations/[id]/domains/route.test.ts b/apps/sim/app/api/organizations/[id]/domains/route.test.ts index aff38e39075..c5bd51a8e72 100644 --- a/apps/sim/app/api/organizations/[id]/domains/route.test.ts +++ b/apps/sim/app/api/organizations/[id]/domains/route.test.ts @@ -107,6 +107,7 @@ describe('org domains route', () => { it('re-adds an existing pending domain idempotently without rotating its token', async () => { queueTableRows(member, [{ role: 'owner' }]) // membership + queueTableRows(member, [{ role: 'owner' }]) queueTableRows(ssoDomain, []) // verified-elsewhere check → none queueTableRows(ssoDomain, [ { @@ -131,8 +132,10 @@ describe('org domains route', () => { it('stays idempotent when a concurrent claim wins the unique index race', async () => { queueTableRows(member, [{ role: 'owner' }]) // membership + queueTableRows(member, [{ role: 'owner' }]) queueTableRows(ssoDomain, []) // verified-elsewhere check → none queueTableRows(ssoDomain, []) // org-domains read → none existing, under the cap + queueTableRows(ssoDomain, []) // insert().returning() loses the race and hits sso_domain_org_domain_unique dbChainMockFns.returning.mockRejectedValueOnce( Object.assign(new Error('duplicate key'), { code: '23505' }) diff --git a/apps/sim/lib/auth/sso/application/provider-registration.ts b/apps/sim/lib/auth/sso/application/provider-registration.ts index 4a16e8c2013..15a6309d349 100644 --- a/apps/sim/lib/auth/sso/application/provider-registration.ts +++ b/apps/sim/lib/auth/sso/application/provider-registration.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' import type { Principal } from '@sim/auth/principal' import { db, ssoDomain, ssoProvider } from '@sim/db' import { keepDomainSignInProvider, ssoProviderDomainKey } from '@sim/db/sso-primary-provider' @@ -685,4 +686,20 @@ export const saveSsoProvider = defineOrganizationConfigurationUseCase({ invalidateSsoPolicyCache(orgId) return result }, + projectAudit: ({ input, context, result }) => ({ + action: result.created + ? AuditAction.ORGANIZATION_SSO_PROVIDER_CREATED + : AuditAction.ORGANIZATION_SSO_PROVIDER_UPDATED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: context.organizationId, + description: result.created + ? 'Created organization SSO provider' + : 'Updated organization SSO provider', + metadata: { + providerId: result.providerId, + providerType: result.providerType, + domain: normalizeSSODomain(input.domain), + jitProvisioningEnabled: input.jitProvisioningEnabled, + }, + }), }) diff --git a/apps/sim/lib/auth/sso/application/provider-settings.integration.ts b/apps/sim/lib/auth/sso/application/provider-settings.integration.ts index b398c4432f6..5c90e64c44a 100644 --- a/apps/sim/lib/auth/sso/application/provider-settings.integration.ts +++ b/apps/sim/lib/auth/sso/application/provider-settings.integration.ts @@ -138,6 +138,7 @@ describe('Organization SSO administration through API credentials', () => { afterAll(async () => { if (!runtime) return const { db, schema, eq, inArray } = runtime + await db.delete(schema.auditLog).where(eq(schema.auditLog.resourceId, organizationId)) await db.delete(schema.organization).where(eq(schema.organization.id, organizationId)) await db.delete(schema.user).where(inArray(schema.user.id, [userId, outsiderId])) }) @@ -175,14 +176,17 @@ describe('Organization SSO administration through API credentials', () => { it('creates and edits a provider without minting a browser session, then deletes it', async () => { const { db, schema, eq } = runtime + const request = { headers: new Headers({ 'user-agent': 'SSO administration audit fixture' }) } const created = await runtime.saveSsoProvider.execute({ principal, input: { ...config(), domain: domain.toUpperCase() }, + request, }) expect(created).toMatchObject({ providerId, created: true }) const edited = await runtime.saveSsoProvider.execute({ principal, input: { ...config(), domain: domain.toUpperCase(), cert: 'rotated signing certificate' }, + request, }) expect(edited.created).toBe(false) const [row] = await db @@ -194,13 +198,37 @@ describe('Organization SSO administration through API credentials', () => { expect( await db.select().from(schema.session).where(eq(schema.session.userId, userId)) ).toHaveLength(0) - await runtime.deleteSsoProvider.execute({ principal, input: { organizationId, providerId } }) + await runtime.deleteSsoProvider.execute({ + principal, + input: { organizationId, providerId }, + request, + }) expect( await db .select() .from(schema.ssoProvider) .where(eq(schema.ssoProvider.providerId, providerId)) ).toHaveLength(0) + const history = () => + db.select().from(schema.auditLog).where(eq(schema.auditLog.resourceId, organizationId)) + await expect + .poll(async () => (await history()).map((entry) => entry.action).sort()) + .toEqual([ + 'organization.sso_provider.created', + 'organization.sso_provider.deleted', + 'organization.sso_provider.updated', + ]) + for (const entry of await history()) { + expect(entry).toMatchObject({ + actorId: userId, + resourceType: 'organization', + resourceId: organizationId, + userAgent: 'SSO administration audit fixture', + metadata: { organizationId, providerId, domain, actor: { kind: principal.kind } }, + }) + expect(JSON.stringify(entry)).not.toContain(config().cert) + expect(JSON.stringify(entry)).not.toContain('rotated signing certificate') + } }) it('refuses unverified domains and conceals organizations from outsiders', async () => { diff --git a/apps/sim/lib/auth/sso/application/provider-settings.ts b/apps/sim/lib/auth/sso/application/provider-settings.ts index 170060d7ccc..248e754b78a 100644 --- a/apps/sim/lib/auth/sso/application/provider-settings.ts +++ b/apps/sim/lib/auth/sso/application/provider-settings.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' import type { Principal } from '@sim/auth/principal' import { db, ssoDomain, ssoProvider } from '@sim/db' import { @@ -21,11 +22,13 @@ import { markSignInProviders } from '@/lib/auth/sso/primary-provider' import { lockSsoProvider } from '@/lib/auth/sso/provider-lock' import { invalidateSsoPolicyCache } from '@/lib/auth/sso-policy' import { acquireOrganizationMutationLock } from '@/lib/billing/organizations/membership' +import { recordProjectedUseCaseAuditEntries } from '@/lib/core/application/authorized-workspace-use-case' import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import { requireOAuthOperationScope } from '@/lib/core/application/oauth-authorization' import type { OperationUseCase } from '@/lib/core/application/operation' import { authorizeOrganizationOperation } from '@/lib/core/application/organization-authorization' import { PrincipalKindAuthorizationError } from '@/lib/core/application/workspace-authorization' +import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' import { OrchestrationError } from '@/lib/core/orchestration/types' interface ProviderScope { @@ -155,7 +158,8 @@ export const listSsoProviders: OperationUseCase< async function executeDeleteProvider( principal: Principal, - input: { organizationId?: string; providerId: string } + input: { organizationId?: string; providerId: string }, + request?: OrchestrationRequestContext ) { requireProviderPrincipal(principal, ssoProviderOperations.delete) const [provider] = await db @@ -193,14 +197,38 @@ async function executeDeleteProvider( const deleted = await tx .delete(ssoProvider) .where(and(eq(ssoProvider.id, provider.id), ownerClause)) - .returning({ id: ssoProvider.id }) + .returning({ + id: ssoProvider.id, + providerId: ssoProvider.providerId, + organizationId: ssoProvider.organizationId, + domain: ssoProvider.domain, + }) if (deleted.length && provider.organizationId) await forgetPrimaryProvider(tx, provider.organizationId, provider.providerId) return deleted }) - if (!removed.length) throw new OrchestrationError('not_found', 'Provider not found') - if (provider.organizationId) invalidateSsoPolicyCache(provider.organizationId) - return { providerId: provider.providerId } + const deleted = removed[0] + if (!deleted) throw new OrchestrationError('not_found', 'Provider not found') + if (deleted.organizationId) { + invalidateSsoPolicyCache(deleted.organizationId) + recordProjectedUseCaseAuditEntries( + ssoProviderOperations.delete, + null, + principal, + request, + [ + { + action: AuditAction.ORGANIZATION_SSO_PROVIDER_DELETED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: deleted.organizationId, + description: 'Deleted organization SSO provider', + metadata: { providerId: deleted.providerId, domain: deleted.domain }, + }, + ], + deleted.organizationId + ) + } + return { providerId: deleted.providerId } } export const deleteSsoProvider: OperationUseCase< @@ -209,7 +237,7 @@ export const deleteSsoProvider: OperationUseCase< { providerId: string } > = { operation: ssoProviderOperations.delete, - execute: ({ principal, input }) => executeDeleteProvider(principal, input), + execute: ({ principal, input, request }) => executeDeleteProvider(principal, input, request), } export const getSsoProvider: OperationUseCase<