diff --git a/CLAUDE.md b/CLAUDE.md index c0200c5c7c2fa..27b65b25974df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -174,6 +174,72 @@ packages/ Use existing helpers from `twenty-shared` instead of manual type guards: - `isDefined()`, `isNonEmptyString()`, `isNonEmptyArray()` +## Access Control & Permissions + +Twenty has **two independent admin layers** — do not conflate them: + +| Layer | Field / role | Scope | UI | +|---|---|---|---| +| **Instance admin** | `User.canAccessFullAdminPanel = true` (on `core.user`) | Whole Twenty instance — feature flags, system health, AI models, config variables | `/settings/admin-panel` | +| **Workspace admin** | `WorkspaceMember.role = "Admin"` (universalIdentifier `20202020-02c2-43f2-b94d-cab1f2b532eb`) | One workspace — members, settings within it | `/settings/members`, `/settings/general` | + +- Instance admin is enforced by `AdminPanelGuard` (`packages/twenty-server/src/engine/guards/admin-panel-guard.ts`) on every admin GraphQL op. Admin endpoint is `/admin-panel-graphql-api`, separate from `/graphql`. +- `canAccessFullAdminPanel` is **baked into the JWT at sign-in time** (`auth-context-user-select-fields.constants.ts:12`). After flipping it in the DB, the user MUST sign out and back in for the change to take effect. +- There is **no GraphQL mutation** to toggle `canAccessFullAdminPanel`. Only paths are the CLI bootstrap command (in deployment bundles) or a direct DB UPDATE on `core."user"`. +- Admin role universal identifier: `packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/standard-role.constant.ts:2`. + +### Permission flags (`PermissionFlagType`) + +Many features are gated by `SettingsPermissionGuard(PermissionFlagType.)` at the resolver layer. Flags split into two categories (`packages/twenty-shared/src/constants/PermissionFlagType.ts`): + +- **Settings flags** (`WORKFLOWS`, `WORKSPACE_MEMBERS`, `ROLES`, `DATA_MODEL`, `SECURITY`, `BILLING`, `AI_SETTINGS`, …) — bypassed by `role.canUpdateAllSettings = true`. +- **Tool flags** (`AI`, `VIEWS`, `UPLOAD_FILE`, `IMPORT_CSV`, `SEND_EMAIL_TOOL`, …; canonical list in `permissions/constants/tool-permission-flags.ts`) — bypassed by `role.canAccessAllTools = true`. + +The guard picks which bypass-boolean via `isToolPermission(flag)`. Default value for **every** flag on a non-admin role is `false` (`permissions.service.ts:100-128`). + +A role passes the guard if **either**: +- The category-appropriate bypass is true on the role, OR +- The role's `permissionFlags` array contains an explicit entry for that flag. + +Check logic: `permissions.service.ts:checkRolePermissions` (~232-249). Bootstrap bypass: guard returns true while workspace is in `PENDING_CREATION`/`ONGOING_CREATION` state. + +**Workflows are gated this way.** Every workflow resolver (`workflow-builder`, `workflow-version`, `workflow-trigger`, `workflow-version-step`, `workflow-version-edge`, plus `logic-function`) uses `SettingsPermissionGuard(PermissionFlagType.WORKFLOWS)`. A second enforcement layer in `workspace-roles-permissions-cache.service.ts:149-160` also sets `canRead/canUpdate/canSoftDelete/canDestroy = false` on the `workflow`, `workflowRun`, and `workflowVersion` objects when the flag is off — so workflows are hidden from listings too, not just blocked on create. + +**To grant a non-admin user workflow access:** go to `/settings/roles`, edit their role, toggle the **Workflows** permission flag on. No code change needed — the default-off behavior is intentional. + +## SSO (Single Sign-On) + +**This repo = upstream Twenty.** Upstream supports per-workspace OIDC and SAML SSO, gated as an Enterprise feature. The header-trust / proxy-login flow used in deployment bundles (oauth2-proxy + Traefik ForwardAuth) lives in a **separate fork** and is **NOT present in this tree** — `grep` for `proxy-login` / `ProxyAuthMiddleware` / `AUTH_TYPE` returns nothing here. Don't waste a session looking for it in this repo; the bundle-side contract is owned by the bundle/fork repos, not this one. Full spec: [`docs/specs/sso.md`](docs/specs/sso.md). + +### Upstream SSO surface (this repo) + +- **Provider entity:** `WorkspaceSSOIdentityProvider` at `packages/twenty-server/src/engine/core-modules/sso/workspace-sso-identity-provider.entity.ts` — type (`OIDC` | `SAML`), `status` (Active/Inactive/Error), `issuer`, OIDC fields (`clientID`, `clientSecret`), SAML fields (`ssoURL`, `certificate`, `fingerprint`), `workspaceId` FK. +- **HTTP routes** (`auth/controllers/sso-auth.controller.ts`): + - `GET /auth/oidc/login/:identityProviderId` — initiate OIDC (guard: `OIDCAuthGuard`) + - `GET /auth/oidc/callback` — IDP callback + - `GET /auth/saml/login/:identityProviderId` — initiate SAML (guard: `SAMLAuthGuard`) + - `POST /auth/saml/callback/:identityProviderId` — SAML callback + - `GET /auth/saml/metadata/:identityProviderId` — SP metadata XML +- **Strategies:** OIDC uses `openid-client` (`auth/strategies/oidc.auth.strategy.ts`); SAML uses `@node-saml/passport-saml` `MultiSamlStrategy` for per-request provider lookup (`auth/strategies/saml.auth.strategy.ts`) — certificate whitespace is sanitised at load. +- **GraphQL mutations** (`sso/sso.resolver.ts`): `createOIDCIdentityProvider`, `createSAMLIdentityProvider`, `editSSOIdentityProvider`, `deleteSSOIdentityProvider`. Frontend kicks off login via the `getAuthorizationUrlForSSO` mutation (returns `authorizationURL` + provider type). +- **Frontend:** `packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpWithSSO.tsx` + `hooks/useSSO.ts`. One provider → direct redirect; multiple → picker (`SignInUpStep.SSOIdentityProviderSelection`). Active providers come from `get-auth-providers-by-workspace.util.ts`. +- **Login resolution:** controller calls `authService.findWorkspaceForSignInUp()` then `signInUp()` to link user↔workspace, then `generateLoginToken()` and redirects to the workspace subdomain. Optional `ConnectedAccount` write is feature-flagged. +- **Gating** (both must pass): + - Instance: `EnterpriseFeaturesEnabledGuard` (`auth/guards/enterprise-features-enabled.guard.ts`) — `enterprisePlanService.isValid()` based on a signed JWT licence. There is **no plain env var** to disable SSO globally. + - Workspace: `BillingService.hasEntitlement(BillingEntitlementKey.SSO)` per workspace. +- **Login email override.** The synthesised email from IDP claims (e.g. oauth2-proxy `cognito:username`) is what `signInUp` keys on. Workspace membership and any per-user state hangs off this — make sure it's stable across IDP rotations. + +## Specs + +Cross-cutting capability specs live in `docs/specs/`. They describe **what this repo actually does**, with file:line citations, and are kept in sync with code in the same PR. + +- [`docs/specs/sso.md`](docs/specs/sso.md) — Workspace OIDC/SAML SSO: providers, routes, strategies, enterprise + workspace gating, login resolution. +- [`docs/specs/permissions.md`](docs/specs/permissions.md) — Role-based access control: `SettingsPermissionGuard`, `PermissionFlagType`, role capability booleans, the object-permission cache, and the workflow example. +- [`docs/specs/admin-panel.md`](docs/specs/admin-panel.md) — Instance vs workspace admin, `AdminPanelGuard`, JWT-baked `canAccessFullAdminPanel`, promotion paths and the sign-out requirement. +- [`docs/specs/README.md`](docs/specs/README.md) — Index + conventions for adding/maintaining specs. + +When adding a new cross-cutting capability or changing one of the above, update the relevant spec in the same PR. Don't reach for external rule sets — this repo owns its specs. + ## Development Workflow IMPORTANT: Use Context7 for code generation, setup or configuration steps, or library/API documentation. Automatically use the Context7 MCP tools to resolve library IDs and get library docs without waiting for explicit requests. diff --git a/docs/specs/README.md b/docs/specs/README.md new file mode 100644 index 0000000000000..26b438fd7bcb6 --- /dev/null +++ b/docs/specs/README.md @@ -0,0 +1,24 @@ +# Twenty specs + +In-repo specifications for cross-cutting capabilities of this Twenty tree. The goal: a future engineer (human or LLM) can confirm what THIS codebase is supposed to do without leaving the repo. + +These specs describe **observed contract**, not aspirational design. When code changes, update the spec in the same PR. + +## Scope + +These specs cover **upstream Twenty as it lives in this repository**. They do not describe forks (e.g. `Pressingly/twenty`) or deployment bundles. If a behaviour exists only in a downstream fork, it is out of scope here — document it in that fork. + +## Index + +| Spec | What it covers | +|---|---| +| [sso.md](./sso.md) | Workspace-scoped SSO: OIDC + SAML providers, routes, strategies, enterprise gating, login resolution | +| [permissions.md](./permissions.md) | Role-based access control: settings permission flags, `SettingsPermissionGuard`, role capabilities (`canUpdateAllSettings`, etc.), and the dual-layer object-permission cache. Includes the workflow-access example. | +| [admin-panel.md](./admin-panel.md) | The two independent admin layers (instance vs workspace), `AdminPanelGuard`, JWT-baked admin flag, bootstrap reality | + +## Conventions + +- Each spec opens with a one-sentence **Purpose** and a **Surface** list (what's in scope). +- The **Contract** section lists numbered, testable rules. Cite `file/path.ts:line` for every rule that is enforced in code. +- A **Gotchas** section captures non-obvious failure modes that have bitten past sessions. +- Specs evolve. Verify the cited line numbers before quoting in a PR — they drift. diff --git a/docs/specs/admin-panel.md b/docs/specs/admin-panel.md new file mode 100644 index 0000000000000..c1807583132aa --- /dev/null +++ b/docs/specs/admin-panel.md @@ -0,0 +1,64 @@ +# Spec: Admin layers (instance vs workspace) + +## Purpose + +Twenty has **two independent admin concepts**. They are governed by different fields, enforced by different guards, and grant different scopes. Conflating them is the most common source of admin-related bugs. + +## Surface + +- Instance admin guard: `packages/twenty-server/src/engine/guards/admin-panel-guard.ts` +- Admin GraphQL endpoint factory: `packages/twenty-server/src/engine/api/graphql/admin-panel.module-factory.ts` +- Admin resolvers: `packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.resolver.ts` +- User entity field: `core."user"."canAccessFullAdminPanel"` (boolean, default false) +- JWT field whitelist: `packages/twenty-server/src/engine/core-modules/auth/constants/auth-context-user-select-fields.constants.ts` +- Workspace admin role constant: `packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/standard-role.constant.ts` + +## Contract + +### 1. The two layers + +| Layer | Field / mechanism | Scope | UI surface | +|---|---|---|---| +| **Instance admin** | `User.canAccessFullAdminPanel = true` (column on `core."user"`) | The whole Twenty server: feature flags, AI model catalog, config variables, system health, all workspaces | `/settings/admin-panel` (frontend) + `/admin-panel-graphql-api` (backend) | +| **Workspace admin** | `WorkspaceMember.role` references the `Admin` role (universalIdentifier `20202020-02c2-43f2-b94d-cab1f2b532eb`) | A single workspace: its members, its settings, its roles | `/settings/members`, `/settings/general`, `/settings/roles` | + +The two can be set independently. A user can be a workspace Admin without being an instance admin (the common case) or an instance admin without being an Admin of any workspace (rare; recovery scenarios only). + +### 2. Instance admin enforcement + +- **Endpoint isolation.** All instance-admin GraphQL operations live behind `/admin-panel-graphql-api`, a separate Yoga endpoint (`api/graphql/admin-panel.module-factory.ts`). The main `/graphql` endpoint does not expose admin resolvers — sending an admin operation there is a schema error, not an auth failure. +- **Guard.** Every operation on the admin endpoint is decorated with `@UseGuards(AdminPanelGuard)` (`admin-panel.resolver.ts` — appears on every mutation/query). The guard (`admin-panel-guard.ts`) reads `request.user.canAccessFullAdminPanel` and rejects unless `=== true`. +- **JWT baking.** `canAccessFullAdminPanel` is selected into the auth context at sign-in time (`auth-context-user-select-fields.constants.ts:12`). The current session's value is therefore frozen for the lifetime of the access token — changing the DB field does not affect an active session until the user signs in again. + +### 3. Workspace admin enforcement + +Workspace admin is just a role assignment. Members get the Admin role through: + +- The standard role assignment flow at `/settings/members` (existing Admin/Owner promotes a member via the role dropdown). The mutation is `updateRole` on the `WorkspaceMember` object, gated on the actor being Admin or Owner of the same workspace. +- A direct DB UPDATE on `core."workspaceMember"."roleId"` (recovery only — bypasses the audit trail). + +The Admin role grants `canUpdateAllSettings = true` (see [permissions.md §3](./permissions.md)), so workspace admins automatically pass every `SettingsPermissionGuard` in the workspace, including settings whose individual flags are off. + +### 4. Promotion paths + +| From | To | Mechanism | +|---|---|---| +| Regular member → workspace Admin | UI: `/settings/members` → role dropdown → Admin | +| Workspace Admin → instance admin | **Direct DB UPDATE** on `core."user"."canAccessFullAdminPanel"`. There is no GraphQL mutation, no UI control, no CLI shipped in this tree. | +| Self-promotion via signup | First user during initial workspace bootstrap is granted `canAccessFullAdminPanel = true` (`auth/services/sign-in-up.service.ts` — `hasServerAdmin()` returns false → grant). After any instance admin exists this path closes. | + +### 5. Post-promotion requirement (sign-out + sign-in) + +After flipping `canAccessFullAdminPanel` on a DB row, the user **must sign out and sign back in**. Until they do: + +- The sidebar will not show the Admin Panel link (the frontend checks the JWT-cached value). +- Hitting `/admin-panel-graphql-api` directly will fail the `AdminPanelGuard` (the guard reads from the JWT-derived `request.user`, not the DB). + +This is intentional — it keeps the guard cheap (no DB lookup per request) at the cost of a re-auth on permission change. + +## Gotchas + +- **No first-user-auto-admin on multi-workspace deploys.** `signUp` is gated by `assertSignUpEnabled` — when `IS_MULTIWORKSPACE_ENABLED=false` (default) and any workspace already exists, sign-up is closed. The first-user-becomes-admin path only fires during the very first bootstrap. +- **Workspace admins can't see other workspaces.** They have full power within their workspace and zero visibility outside. Cross-workspace operations (instance health, feature flags, AI provider config) are exclusively instance-admin territory. +- **`canImpersonate` is a separate user-level boolean.** Don't conflate with `canAccessFullAdminPanel`. Impersonation is `PermissionFlagType.IMPERSONATE` for role-level granting plus the user-level boolean for the kill switch. +- **The admin endpoint URL is not just a frontend route.** `/admin-panel-graphql-api` is a real second GraphQL endpoint with its own resolver registry. When adding a new admin operation, register it in the admin module factory — registering it in the main GraphQL module silently makes it accessible to non-admins. diff --git a/docs/specs/permissions.md b/docs/specs/permissions.md new file mode 100644 index 0000000000000..3d830e8fee837 --- /dev/null +++ b/docs/specs/permissions.md @@ -0,0 +1,83 @@ +# Spec: Role-based access control + +## Purpose + +Every workspace has a set of roles. A role grants capabilities through (a) coarse boolean flags on the role row (`canUpdateAllSettings`, `canAccessAllTools`, …) and (b) per-feature **permission flags** (the `PermissionFlagType` enum). Resolvers gate operations on these. + +## Surface + +- Permission service: `packages/twenty-server/src/engine/metadata-modules/permissions/permissions.service.ts` +- Settings guard: `packages/twenty-server/src/engine/guards/settings-permission.guard.ts` +- Role entity: `packages/twenty-server/src/engine/metadata-modules/role/role.entity.ts` +- Flag enum: `packages/twenty-shared/src/constants/PermissionFlagType.ts` +- Object permission cache: `packages/twenty-server/src/engine/metadata-modules/role/services/workspace-roles-permissions-cache.service.ts` +- Standard role constants: `packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/standard-role.constant.ts` + +## Contract + +### 1. Role shape + +A role is a row in `core."role"` (`role.entity.ts`) with: + +- Universal identifier (UUID) — the seed `Admin` role has `20202020-02c2-43f2-b94d-cab1f2b532eb` (`standard-role.constant.ts:2`). +- Capability flags as boolean columns: `canUpdateAllSettings`, `canAccessAllTools`, `canReadAllObjectRecords`, `canUpdateAllObjectRecords`, `canSoftDeleteAllObjectRecords`, `canDestroyAllObjectRecords` (`role.entity.ts` lines ~27-46). +- A `permissionFlags` relation: zero or more `{ flag: PermissionFlagType, … }` rows that opt the role into a specific gated feature. + +Roles are workspace-scoped. The Admin role exists in every workspace by default and has `canUpdateAllSettings = true`. + +### 2. `PermissionFlagType` semantics + +Defined in `packages/twenty-shared/src/constants/PermissionFlagType.ts`. The enum is split into **two categories** — and which category a flag belongs to determines which role-level boolean bypasses the check (see §3). + +| Category | Flags | Source of truth | +|---|---|---| +| **Settings permissions** | `API_KEYS_AND_WEBHOOKS`, `WORKSPACE`, `WORKSPACE_MEMBERS`, `ROLES`, `DATA_MODEL`, `SECURITY`, `WORKFLOWS`, `IMPERSONATE`, `SSO_BYPASS`, `APPLICATIONS`, `MARKETPLACE_APPS`, `LAYOUTS`, `BILLING`, `AI_SETTINGS` | Top half of `PermissionFlagType.ts` (under `// Settings permissions`) | +| **Tool permissions** | `AI`, `VIEWS`, `UPLOAD_FILE`, `DOWNLOAD_FILE`, `SEND_EMAIL_TOOL`, `HTTP_REQUEST_TOOL`, `CODE_INTERPRETER_TOOL`, `IMPORT_CSV`, `EXPORT_CSV`, `CONNECTED_ACCOUNTS`, `PROFILE_INFORMATION` | `permissions/constants/tool-permission-flags.ts` (`TOOL_PERMISSION_FLAGS` array) | + +The default value for **every** flag on a newly created (non-admin) role is `false` (`permissions.service.ts:100-128`, `getDefaultUserWorkspacePermissions`). + +### 3. Settings guard + +Resolvers gate gated operations with `@UseGuards(SettingsPermissionGuard(PermissionFlagType.))` (despite the name, this guard handles **both** settings and tool flags). The guard (`settings-permission.guard.ts`) calls `permissionsService.userHasWorkspaceSettingPermission(...)`, which delegates to `checkRolePermissions` (`permissions.service.ts:232-249`). + +`checkRolePermissions` returns true if **either**: + +1. The role-level bypass is true: + - For **settings flags**: `role.canUpdateAllSettings === true` + - For **tool flags**: `role.canAccessAllTools === true` + (The guard picks which boolean via `isToolPermission(flag)`, which tests membership in `TOOL_PERMISSION_FLAGS`.) +2. OR the role's `permissionFlags` relation contains an entry with `flag === `. + +There is also a **workspace-activation bypass**: while the workspace is in `PENDING_CREATION` or `ONGOING_CREATION` status, the guard returns true unconditionally (`settings-permission.guard.ts:35-42`). This lets the bootstrap flow create roles before any role exists to permit creating them. + +### 4. Object permission cache (second enforcement layer) + +For settings flags that also gate visibility of data (workflows, workspace members), `workspace-roles-permissions-cache.service.ts` produces a per-object `{ canRead, canUpdate, canSoftDelete, canDestroy }` map via `hasSettingsGatedObjectPermissions(role, flags, FLAG)`. When the gating flag is false on the role, all four are forced to `false` for the relevant standard objects. + +Currently mapped: +- `WORKFLOWS` → `workflow`, `workflowRun`, `workflowVersion` standard objects (`workspace-roles-permissions-cache.service.ts:144-162`). +- `WORKSPACE_MEMBERS` → `workspaceMember` object (same file, immediately below the workflow branch). Note: `canRead` is forced to `true` on workspace members regardless, so non-permitted users can still see who's in the workspace; only mutations are blocked. + +This means **revoking a workflow flag both blocks mutations AND hides the data** — a single source of truth. + +### 5. Worked example: workflows + +Every workflow resolver carries `@UseGuards(SettingsPermissionGuard(PermissionFlagType.WORKFLOWS))`: + +- `engine/core-modules/workflow/resolvers/workflow-builder.resolver.ts:25` +- `engine/core-modules/workflow/resolvers/workflow-version.resolver.ts:26` +- `engine/core-modules/workflow/resolvers/workflow-trigger.resolver.ts:32` +- `engine/core-modules/workflow/resolvers/workflow-version-step.resolver.ts:36` +- `engine/core-modules/workflow/resolvers/workflow-version-edge.resolver.ts:25` +- `engine/metadata-modules/logic-function/logic-function.resolver.ts` (7 mutations) + +A user can therefore create / read / edit workflows **only** if their workspace role has `canUpdateAllSettings = true` (Admin) **or** an explicit `permissionFlags` row with `flag = WORKFLOWS`. The data-hiding from §4 means non-permitted users see workflows as if they did not exist. + +**To grant access:** `/settings/roles` → edit the role → toggle **Workflows** on. This adds the `permissionFlags` row. No code or DB write is needed. + +## Gotchas + +- **Don't confuse `canUpdateAllSettings` with `canAccessFullAdminPanel`.** The former is on the workspace role row; the latter is on `core.user` and gates the instance Admin Panel — see [admin-panel.md](./admin-panel.md). +- **Default-off is intentional.** Every flag is `false` for any newly created role. There is no "Member with extras" preset; either you have a flag or you don't. +- **Adding a new gated feature?** Add the flag value to `PermissionFlagType.ts`, then to the default map in `permissions.service.ts:getDefaultUserWorkspacePermissions`, then guard the resolvers with `SettingsPermissionGuard(PermissionFlagType.)`. If the feature also has user-visible data records, extend `workspace-roles-permissions-cache.service.ts` to zero out the object permissions when the flag is off. +- **The guard runs on the resolver, not the service.** Internal service-to-service calls bypass the guard. Any code path reachable from a tool/job/cron must enforce the flag itself if untrusted user data drives it. diff --git a/docs/specs/sso.md b/docs/specs/sso.md new file mode 100644 index 0000000000000..04ce07d4983fe --- /dev/null +++ b/docs/specs/sso.md @@ -0,0 +1,90 @@ +# Spec: Workspace SSO (OIDC + SAML) + +## Purpose + +Allow a workspace owner to configure one or more external identity providers (OIDC or SAML) so that workspace members sign in via their corporate IdP instead of Twenty's password flow. + +## Surface + +- Backend module: `packages/twenty-server/src/engine/core-modules/sso/` +- Auth controller: `packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts` +- Guards: `auth/guards/oidc-auth.guard.ts`, `auth/guards/saml-auth.guard.ts`, `auth/guards/enterprise-features-enabled.guard.ts` +- Strategies: `auth/strategies/oidc.auth.strategy.ts`, `auth/strategies/saml.auth.strategy.ts` +- Frontend entry: `packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpWithSSO.tsx`, `hooks/useSSO.ts` + +## Contract + +### 1. Provider entity + +A workspace SSO provider is a row in `core."workspaceSSOIdentityProvider"` (`sso/workspace-sso-identity-provider.entity.ts`): + +- `type` is one of `OIDC` | `SAML` (`IdentityProviderType` enum). +- `status` is `Active` | `Inactive` | `Error` (`SSOIdentityProviderStatus` enum). Only **Active** providers are surfaced to the sign-in UI. +- `workspaceId` is a foreign key — providers are workspace-scoped, never instance-global. +- OIDC providers carry `issuer`, `clientID`, `clientSecret`. +- SAML providers carry `issuer`, `ssoURL`, `certificate`, optional `fingerprint`. +- The row's `id` is the provider UUID; this UUID appears in the public URLs (see below). + +### 2. HTTP routes + +All routes mounted on the public Twenty server. **`EnterpriseFeaturesEnabledGuard` is re-declared on every route** via `@UseGuards` (not on the controller class) — `sso-auth.controller.ts:59-117`. Adding a new SSO route without that decorator silently bypasses the enterprise gate. + +| Method | Path | Additional guard(s) | Purpose | +|---|---|---|---| +| GET | `/auth/oidc/login/:identityProviderId` | `OIDCAuthGuard` | Initiate OIDC flow → 302 to IdP | +| GET | `/auth/oidc/callback` | `OIDCAuthGuard` | IdP redirect target; mints login token | +| GET | `/auth/saml/login/:identityProviderId` | `SAMLAuthGuard` | Initiate SAML AuthnRequest | +| POST | `/auth/saml/callback/:identityProviderId` | `SAMLAuthGuard` | SAML assertion consumer | +| GET | `/auth/saml/metadata/:identityProviderId` | — | SP metadata XML | + +`:identityProviderId` is the row UUID from §1. It is the only way a request selects which provider to use. + +### 3. Strategy loading + +- **OIDC** (`oidc.auth.strategy.ts`): uses the `openid-client` library. The guard resolves the issuer at request time via OpenID Connect Discovery and instantiates the strategy on demand. The strategy validates the `email` and `given_name`/`family_name` claims from the userinfo endpoint. +- **SAML** (`saml.auth.strategy.ts`): uses `@node-saml/passport-saml`'s `MultiSamlStrategy` so per-provider config is looked up at request time from the DB row. Certificate whitespace is stripped before passing to the strategy. The strategy validates that the SAML assertion contains an email attribute. + +### 4. Login resolution + +After the IdP returns a valid identity, `sso-auth.controller.ts` (`authCallback` path): + +1. Reads the provider entity by `identityProviderId`. +2. Resolves a target workspace via `authService.findWorkspaceForSignInUp()` (provider's `workspaceId`). +3. Calls `authService.signInUp()` to find or create the user, attach to the workspace, and assign the workspace's `defaultRoleId` if no membership exists. +4. Mints a short-lived login token via `generateLoginToken()` and redirects to the workspace subdomain. +5. Optionally writes a SSO `ConnectedAccount` for token-claim provenance (feature-flagged). + +### 5. Frontend flow + +`hooks/useSSO.ts` calls the `getAuthorizationUrlForSSO` GraphQL mutation with the workspace context (read from `workspaceInviteHash` or current subdomain). The mutation returns `{ authorizationURL, type }`. Behaviour by provider count: + +- **0 active providers** → SSO entry is hidden (password flow only, if enabled). +- **1 active provider** → direct `window.location = authorizationURL`. +- **>1 active providers** → `SignInUpStep.SSOIdentityProviderSelection` picker. + +Active providers come from `packages/twenty-server/src/engine/core-modules/workspace/utils/get-auth-providers-by-workspace.util.ts`, which filters on `status === Active`. + +### 6. Gating + +Two independent gates; **both** must pass: + +1. **Instance**: `EnterpriseFeaturesEnabledGuard` (`auth/guards/enterprise-features-enabled.guard.ts`) calls `enterprisePlanService.isValid()`, which validates a signed Enterprise licence JWT. There is **no plain env var** that disables SSO globally — it is licence-gated by design. +2. **Workspace**: `BillingService.hasEntitlement(BillingEntitlementKey.SSO)` checked in `sso/services/sso.service.ts` (the `featureLookUpKey` field). A workspace without the entitlement cannot register providers, even on a licensed instance. + +### 7. GraphQL surface + +Resolver: `sso/sso.resolver.ts`. + +- `createOIDCIdentityProvider(input: SetupOIDCSsoInput)` — issuer URL, clientID, clientSecret, name. +- `createSAMLIdentityProvider(input: SetupSAMLSsoInput)` — issuer URL, ssoURL, certificate (+ optional fingerprint), name, provider UUID. +- `editSSOIdentityProvider`, `deleteSSOIdentityProvider`. +- `getAuthorizationUrlForSSO` — used by the frontend to discover the redirect URL for a given workspace. + +All mutations are guarded by `WorkspaceAuthGuard` + `EnterpriseFeaturesEnabledGuard`. + +## Gotchas + +- **Email is the login key.** `signInUp()` keys on the IdP-provided email. If the IdP rotates a user's email, the next login creates a new `core.user` row instead of resolving the existing one. Memberships and per-user state hang off this — plan IdP changes accordingly. +- **No instance-global "disable SSO" toggle.** If you want a deploy that never authenticates via SSO, you must avoid issuing an enterprise licence, not toggle an env var. Removing existing provider rows is per-workspace. +- **`workspaceInviteHash` is the only way to select workspace at sign-in.** When a user belongs to multiple workspaces, the frontend must pass the invite hash (or the request must come from a workspace subdomain) for `findWorkspaceForSignInUp` to disambiguate. +- **Certificate whitespace.** SAML certificates pasted with stray whitespace will not pre-validate; `saml.auth.strategy.ts` strips whitespace internally, but external tools (e.g. metadata validators) may reject the same blob — store the normalised form.