Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<FLAG>)` 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.
Expand Down
24 changes: 24 additions & 0 deletions docs/specs/README.md
Original file line number Diff line number Diff line change
@@ -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.
64 changes: 64 additions & 0 deletions docs/specs/admin-panel.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading