# Admin Dashboard Authorization

DB-backed RBAC for `admin-dashboard`: role / permission / role-permission tables, with no auth vendor holding any of it. Single tenant (CFX), single role per user, roles are bags of permissions.

This document covers the admin RBAC services in `@cfxlabsinc/dashboard-auth-services/admin` and the `admin-dashboard`-side `lib/auth.ts` surface. Read this before adding pages, server actions, or new permissions inside admin-dashboard. Customer-dashboard authorization is covered in [DASHBOARD.md](./DASHBOARD.md).

---

## Why this exists

`customer-dashboard` used to ride on PropelAuth, where roles and permissions were encoded in the session JWT. As permission counts grew, the JWT outgrew its size budget. The answer was to load permissions from the DB at request time and stop trusting the token's permission claims.

`admin-dashboard` shipped that DB-backed pattern first. It needed an authorization story regardless (the SHAPE-§13 change-approval work FKs to a `dashboard_user` row), and `customer-dashboard` and `bank-dashboard` have since moved to the same lookup over their own tables ([Customer-dashboard inheritance](#customer-dashboard-inheritance)). The `lib/auth.ts` surface (`getAuthPermissions`, `hasAuthPermissions`, `mustHaveAuthPermissions`, `<ShowIfPermission />`) is identical across the dashboards. PropelAuth is no longer used by any of them: all three sign in through Cognito ([COGNITO_AUTH_MIGRATION.md](proposals/COGNITO_AUTH_MIGRATION.md)).

The operator's Cognito session (`cfx-admin` pool, federated to Google Workspace) proves identity (`sub` + `email`) only. Roles and permissions are resolved server-side, cached per request via `React.cache`.

---

## Data model

Five tables in a new `admin` pgSchema. Schema files live in [`packages/db/src/drizzle/schema/admin/`](../packages/db/src/drizzle/schema/admin).

```mermaid
erDiagram
  dashboard_user ||--o| dashboard_role : "role_id"
  dashboard_role ||--o{ dashboard_role_permission : ""
  dashboard_permission ||--o{ dashboard_role_permission : ""
  dashboard_user ||--o{ dashboard_user_activity : "user_id"
  dashboard_user ||--o| dashboard_user : "role_assigned_by_user_id"
```

| Table                             | Purpose                                                                                                                                                                                                                        |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `admin.dashboard_user`            | One row per operator, keyed on `cognito_sub` (unique); holds `role_id` (single role), status, last seen. The Cloudflare Access key `cf_access_sub` was dropped in `20261010050000_drop_admin_dashboard_user_cf_access_sub.sql` |
| `admin.dashboard_permission`      | Catalog of permission strings; seeded, rarely touched                                                                                                                                                                          |
| `admin.dashboard_role`            | Named role grouping (`owner`, `admin`, `member`, `viewer`). `is_system` blocks UI deletion                                                                                                                                     |
| `admin.dashboard_role_permission` | Many-to-many join between role and permission (composite PK, cascade on delete)                                                                                                                                                |
| `admin.dashboard_user_activity`   | Append-only audit feed for user / role / permission / change-approval events                                                                                                                                                   |
| `admin.dashboard_user_login`      | One `SIGNED_IN` row per operator sign-in (browser callback, password, `cfx login`), with IP and user agent. See [Login history](#login-history)                                                                                |
| `admin.mcp_tool_call`             | Append-only audit log of admin MCP tool calls (user, tool, outcome, arguments, rows, duration); triggers reject UPDATE, DELETE and TRUNCATE. Not an activity type, so reads stay out of History                                |

Services name rows by prefixed id: `adusr_` (user), `role_`, `prm_`, `adact_` (activity). The integer primary keys never leave a service.

The role grant on a user is denormalized onto `dashboard_user` (`role_id`, `role_assigned_by_user_id`, `role_assigned_at`): single-role-per-user, no join table. Reassignment is an UPDATE, not an INSERT-and-deactivate.

`role_id` is nullable so a freshly upserted user exists before an `owner` grants them a role. Gates fail closed: a null-role user's permission set is `[]` and `mustHaveAuthPermissions` redirects to `/forbidden`.

---

## Identity flow

```
/auth/login  →  Google Workspace  →  /auth/callback  →  __Host-cfx_session cookie + admin.dashboard_session row
                                                                    │
every request  →  proxy.ts (does __Host-cfx_session verify?)  →  getAuthUser()  →  resolveOperator() (load / refresh / verify)
                                                                    │
                       AdminDashboardUserService.upsert({ cognitoSub, email })  →  admin.dashboard_user
                                                                    │
                       no passkey on record, and one is required here  →  /passkey
```

1. Operators sign in through the `cfx-admin` Cognito pool on `auth.admin.cfx.to` / `auth.admin.dev.cfx.to`. `/auth/login` skips the hosted UI and sends the browser straight to Google Workspace (`identity_provider=GoogleWorkspace`) with PKCE and a signed state cookie; it also expires any `__Host-cfx_session` cookie the browser sent, which is how a dead id cookie leaves the browser. `/auth/callback` exchanges the code and starts the session; a non-`cfx.to` address is refused and its refresh token revoked. A failed sign-in lands on `/auth/signin-failed`, a plain HTML page outside the app shell with a "Try again" link. `/signout` is a page, like customer's and bank's, so a link to it is safe to prefetch: the shared `LogoutClient` runs its `endAppSession` server action on mount, which revokes the refresh token, ends the stored session as `SIGNED_OUT`, clears the cookie and records `user.loggedOut`, then sends the browser through the pool's hosted `/logout`. A session with no identity provider has none; the page then settles on "You're logged out" with a Log in link to the sign-in start (`signInStartPath()`) rather than redirecting, since `/auth/login` would go straight back through Workspace. The pool's pre sign-up Lambda links an operator's Workspace identity to a native Cognito user on first sign-in, so the `sub` is the native user's; operators who signed in before it existed were relinked once on 2026-10-07 by a since-removed script, which moved their `cognito_sub` and signed them out once. The native user holds the operator's passkey, which is for step-up MFA and never signs in: sign-in 2FA is still Workspace policy ([cognito README](../packages/cognito/README.md#cfx-admin-passkeys-and-the-operator-relink)). The signed-in shell and its session check are the `src/app/(private)` route group's layout; `/signout`, `/login` and `/auth/*` sit outside it, so they run signed out. `/passkey` sits outside it too and resolves the operator itself.
2. [`packages/admin-dashboard/src/proxy.ts`](../packages/admin-dashboard/src/proxy.ts) runs on every request except `/auth/*` and `/signout`, and decides from the cookie and the path alone. It verifies the `__Host-cfx_session` JWT's signature, audience and expiry with CPU only ([Session store](#session-store)), and never reads the store, Cognito or the database. A request without a cookie that verifies goes to sign in (table below), and a cookie that doesn't verify is expired on that response. One that verifies is forwarded with the `x-cfx-current-path` header, whatever row it names. It also expires any of the four pre-store token cookies (`__Host-cfx_session_{id,access,refresh,method}`) the browser sent.
3. Inside the app, `getAuthUser` ([`lib/auth.ts`](../packages/admin-dashboard/src/lib/auth.ts)) calls `resolveOperator` ([`lib/operatorSession.ts`](../packages/admin-dashboard/src/lib/operator-session.ts)). That loads the session's tokens from `admin.dashboard_session` ([Session store](#session-store)) and refreshes them near expiry, resealing the row, through the shared `resolveStoredSession` ([`packages/ui/src/lib/cognito-session-store.ts`](../packages/ui/src/lib/cognito-session-store.ts)). It then verifies them. No token reaches the browser, and a refresh sets no cookie, so it runs in a server component. The shared library treats `GoogleWorkspace` as every operator's SSO provider, so its SSO rules hold the session to Workspace; the session lasts the SSO cap of 12h (refresh token 1 day). An email not ending `@cfx.to` ends the row as `REVOKED` and is treated as signed out. No header carries the identity.

   | Condition                                                                       | Response                                                                                                                      |
   | ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
   | No session cookie, GET/HEAD                                                     | proxy: 302 to `/auth/login?return_to_path=…`                                                                                  |
   | No session cookie, any other verb                                               | proxy: 401 (server actions, APIs)                                                                                             |
   | Cookie that doesn't verify (forged, another dashboard's, expired, malformed)    | proxy: the same 302 or 401, expiring the cookie                                                                               |
   | Cookie verifies but no live row, a page or GET route                            | `getAuthUser`: redirect to `/auth/login?return_to_path=…` (from `x-cfx-current-path`), which expires the dead cookie          |
   | Cookie verifies but no live row, a server action                                | `getAuthUser` throws `SignInRequiredError`; the action fails before its body runs, and Sentry drops it                        |
   | Tokens fail verification, the refresh ends the session, or a non-`cfx.to` email | `getAuthUser`: the row is ended as `REVOKED`, then as no live row                                                             |
   | Signed in, but the operator owes a passkey                                      | `getAuthUser`: redirect to `/passkey`, for a page, a server action and a route handler alike ([below](#the-operator-passkey)) |
   | Pool not configured                                                             | `getAuthUser` throws; `/auth/login` answers 500                                                                               |
   | Cognito, the JWKS or the session store unreachable                              | `getAuthUser` throws, like any other dependency failure; the error boundary renders it, and the cookie and the row stay       |
   | Off-production or preview                                                       | no session needed; see [Local dev impersonation](#local-dev-impersonation)                                                    |

   A failed write after a refresh fails the request, like any other write. The `serverAction` wrapper ([`lib/serverAction.ts`](../packages/admin-dashboard/src/lib/server-action.ts)) calls `getAuthUser` before every action body, with or without `permissions`, because the proxy checks only the cookie's signature and expiry: revocation, the idle timeout and the tokens are the store's.

4. `getAuthUser` then calls `AdminDashboardUserService.upsert({ cognitoSub, email })`, through `getOperatorUser` in [`lib/operatorUser.ts`](../packages/admin-dashboard/src/lib/operator-user.ts). The upsert merges the `email` claim and the `data.lastSeenAt` jsonb field; `status` is never in a SET clause, so a `DISABLED` row is never flipped back to `ACTIVE`.
5. An operator whose row has no `data.passkeyEnrolledAt` is sent to `/passkey` wherever a passkey is required ([The operator passkey](#the-operator-passkey)).
6. `React.cache` wraps `getAuthUser` so the session read and the upsert run at most once per render regardless of how many components call it.

The sign-in code is `src/lib/{cognito-setup,cognito,cognito-routes,operator,operator-session,operator-user,operator-passkey,passkey-requirement,session-store}.ts`, `src/app/auth/{login,callback,signin-failed}/route.ts`, `src/app/passkey/` and `src/app/signout/{page.tsx,_actions.ts}`. The pool client's config is read in `cognitoSource` (`src/lib/cognito-setup.ts`): `COGNITO_ADMIN_USER_POOL_ID`, `COGNITO_ADMIN_USER_ISSUER` and `COGNITO_ADMIN_USER_AUTH_DOMAIN` come from the `cognito` namespace (`/cfx/stack/<stage>/cognito/`), and `COGNITO_ADMIN_DASHBOARD_CLIENT_ID` and `COGNITO_ADMIN_DASHBOARD_CLIENT_SECRET` from the `admin-dashboard` namespace. The secret is not a stack output; read it with `aws cognito-idp describe-user-pool-client`.

`COGNITO_ADMIN_CLI_CLIENT_ID` is the public `cfx-cli` client that `/api/cli/whoami` accepts tokens from, the cognito stack's `CFX_CLI_CLIENT_ID` output. The cognito stack writes it to the `cognito` namespace (`/cfx/stack/<stage>/cognito/`), and `/api/cli/whoami` reads it with `get`, so it is required: there is no `not_configured` path. The CLI sends its token in `x-cfx-admin-token`, never `Authorization`: CloudFront signs each request to the server function with origin access control, and that SigV4 signature replaces any `Authorization` the viewer sent. Sentry and the WAF logs both drop the header.

The upsert runs in one transaction:

1. A row with that `cognito_sub` has its email and `data` refreshed.
2. Otherwise, an operator's first Cognito sign-in links the one row whose email matches case-insensitively and has no `cognito_sub`, so they keep their role. Two or more matches are ambiguous and none is linked.
3. Otherwise a row is inserted, with `ON CONFLICT (cognito_sub) DO UPDATE` covering a concurrent first sign-in. `xmax = 0` in the `RETURNING` clause marks the INSERT branch, the only one that runs the [bootstrap](#bootstrap) grant.

**Per-request write is intentional.** One write per page render is cheap at this scale (tens of operators on dedicated infra). Revisit if the user count crosses ~1k or if activity-log fanout makes the per-request budget tight.

### The operator passkey

Every operator must hold a passkey on their native Cognito user, and one who holds none creates it at their next sign-in. The dashboard never signs in with it: operators keep signing in with Google Workspace. The admin client carries `ALLOW_USER_AUTH` only because `StartWebAuthnRegistration` refuses a token from a client without it ([cognito README](../packages/cognito/README.md#cfx-admin-passkeys-and-the-operator-relink)). It is there for step-up on sensitive actions ([below](#step-up)).

- **The fact** is `admin.dashboard_user.data.passkeyEnrolledAt`, written only by `AdminDashboardUserService.recordPasskeyEnrollment`. Cognito is the source of truth. As a session starts, `beginOperatorSession` lists the login's passkeys with the new access token (`reconcileOperatorPasskey` in [`lib/operatorPasskey.ts`](../packages/admin-dashboard/src/lib/operator-passkey.ts)) and stores the answer: a list with none clears the fact. A list Cognito refuses is reported and keeps the stored fact, so the session still starts; a failed write fails the sign-in before any session is stored. The authorize request asks for `aws.cognito.signin.user.admin`, which is what lets a Workspace session's access token list and register its own passkeys.
- **The gate** is `getEnrolledOperatorUser` in [`lib/operatorUser.ts`](../packages/admin-dashboard/src/lib/operator-user.ts), which `getAuthUser` and `getAuthProfile` both resolve the operator through: an operator who owes a passkey is redirected to `/passkey`. The `(private)` layout (`getAuthProfile`), the `serverAction` wrapper and every route handler's permission check (`getAuthUser`) all go through it, so none of them serves such an operator. `/auth/*`, `/signout` and `/login` never call it, and the root layout reads the ungated row (`getOperatorUser`) for the color scheme alone.
- **`/passkey`** sits outside the `(private)` route group, like `/login`, so it renders without the app shell; the page opens by resolving the operator and sends one who owes nothing to `/`. Its two server actions resolve the operator without the gate (`getOperatorUser`), open the registration with the session's access token, and record the fact and a `user.passkeyAdded` activity. An operator whose login already holds a passkey is recorded and let through without a prompt. The screen sits in the `StandalonePage` frame `/signout` uses, says the passkey is for confirming sensitive actions while sign-in stays with Google Workspace, names the operator, and offers "Create passkey" and "Log out", with no skip.
- **Where it is required**: `passkeyRequired()` in [`admin-auth/src/passkeyRequired.ts`](../packages/admin-auth/src/passkeyRequired.ts) decides it. The dashboard calls it through [`lib/passkey-requirement.ts`](../packages/admin-dashboard/src/lib/passkey-requirement.ts) with its own flag, stage and origin, and admin-api calls it too. It is true on prod whatever else is set. Elsewhere it is true unless `ADMIN_PASSKEY_REQUIRED` is exactly `false`, or `ADMIN_DASHBOARD_BASE_URL` is on `localhost` / `127.0.0.1`, which is a built server on a developer's machine against the dev pool. `sst.config.ts` sets it to `true` on every stage, previews included; `e2e/testEnv.ts` sets `false` for the e2e server. The pool's relying-party ID is `cfx.to` / `dev.cfx.to`, so a preview host (`pr-<N>-admin.preview.dev.cfx.to`, on the dev pool) can register a passkey and localhost cannot. Local dev's synthetic operator has no Cognito login and never owes one.

#### Step-up

A sensitive action calls `requireRecentStepUp()` ([`lib/step-up.ts`](../packages/admin-dashboard/src/lib/step-up.ts)) before it reads or writes anything sensitive. It passes when this session's operator confirmed their passkey within `STEP_UP_MAX_AGE` (five minutes). The `/config` save and revert are the actions that call it today; revert calls it before its decrypting read.

- **Who asserts.** The server starts a Cognito `USER_AUTH` sign-in with `PREFERRED_CHALLENGE=WEB_AUTHN` for the session's verified email, which is the admin pool's native username; a session with no verified email is refused. The pre sign-up Lambda links Workspace to that native user, so a Workspace session's `sub` is the native user's. The username never comes from the browser. The browser signs the request options with `getPasskeyAssertion`, and the server answers the challenge. Cognito verifies the signature, and the server checks that the ID token it returns verifies and names the same `sub`.
- **The tokens.** That sign-in returns tokens. The refresh token is revoked first, then the tokens are verified. Nothing stores, returns or logs them; they open no session.
- **Where the proof lives.** Valkey namespace `admin-step-up`, keyed by the SHA-256 of the session id: `record:` holds `{ sub, assertedAt }` for five minutes, and `challenge:` holds Cognito's open challenge `Session` for three. A challenge is deleted as it is answered, so a second answer finds nothing, and one opened by another `sub` is refused. Another session of the same operator has another key, so it is not fresh.
- **Fail closed.** No session, a store error, a Cognito refusal, a failed revocation, a closed prompt, an expired record or a mismatched `sub` all refuse the write. No stage, flag or `passkeyRequired()` value skips it. Local dev's synthetic operator and a localhost base URL are refused with a message naming why: no passkey asserts off the pool's relying party. A preview host can assert.
- **The browser.** An action refused at step-up returns `{ ok: false, stepUp: true }`. `runWithStepUp` (`packages/ui/src/lib/step-up.ts`) opens the prompt through the `beginStepUp` and `confirmStepUp` server actions, then retries the action once. A confirmation records `user.stepUpConfirmed`; a refusal is logged with its reason and Cognito's error code only.

A session that began before the app asked for `aws.cognito.signin.user.admin` cannot manage passkeys: `/passkey` tells that operator to sign out and sign in again. There is no account page in admin-dashboard, so a passkey cannot yet be listed or removed there.

### Session store

All three dashboards keep the sign-in session server-side the same way, through [`packages/ui/src/lib/cognito-session-store.ts`](../packages/ui/src/lib/cognito-session-store.ts) (`openSession`, `beginStoredSession`, `refreshStoredSession`, `endStoredSession`). `cognito-session.ts` still does the verification and refresh; the store only changes where its four values live.

No proxy reads the store. Each proxy verifies the cookie's JWT with CPU alone and expires one that doesn't verify: admin on every response, customer and bank on a page load (`GET`/`HEAD` outside `/auth/*`) before forwarding it, so a sign-in that writes a new cookie is never raced by the clear. `resolveStoredSession` opens, refreshes and verifies a session from the request's cookies. It answers signed in, signed out (no cookie that verifies, or no live row) or ended (a row it just ended as `REVOKED`), and throws when the store, Cognito or the JWKS doesn't answer. A cookie that doesn't verify never reaches the store. Each dashboard calls it once per request through its own auth helper. Customer and bank call it from `getSession` (`src/lib/session.ts`, behind `getSessionOrLogin`), and admin from `getAuthUser`. A server component cannot set a cookie, so a cookie that verifies but names a dead row stays in the browser until a route or action clears it. On admin that is `/auth/login` or `/signout`'s action. On customer and bank it is the next sign-in, which replaces it, or `/signout`'s action. Until then each request spends one store read that finds no row.

- The browser holds one httpOnly cookie, `__Host-cfx_session`: a compact JWS, HS256, header `{ alg, kid }`, claims `sid` (a random 32-byte id, base64url), `aud`, `iat` and `exp`. About 260 bytes with its name (measured 259 to 265, by dashboard). Before the store, the ID, access and refresh tokens and the signed sign-in method were four cookies of about 4.8 KB, enough to push requests past CloudFront's header limit ([runbook](runbooks/dashboard-431-request-headers.md)).
- Those four values sit in the dashboard's `encrypted_tokens` column, a compact JWE (`dir` / `A256GCM`) under a key HKDF-derived from the `sid`. A read of the table or the cache alone cannot open a row.
- A row is keyed by `id_hash`, the SHA-256 of the `sid`. Neither the id nor the JWT is ever stored.
- `DashboardSessionService` ([`dashboard-auth-services`](../packages/dashboard-auth-services/src/DashboardSessionService.ts)) is the store, built in each dashboard's `src/lib/session-store.ts` with that dashboard's cookie signer. Its contract speaks session ids and plain values: `create` mints the id, signs the cookie, seals the values and writes the row, and the hash and encrypted tokens never leave it. It must be given a writer database: a replica read can miss the row sign-in just wrote and sign the new session out.

| Dashboard | Table                            | Database                                                                          |
| --------- | -------------------------------- | --------------------------------------------------------------------------------- |
| admin     | `admin.dashboard_session`        | `Db.instance()`                                                                   |
| customer  | `b2b.customer_dashboard_session` | `Db.instance({ queryTimeout: 25 })`, the writer, capped by `DB_QUERY_TIMEOUT_MAX` |
| bank      | `bank.bank_dashboard_session`    | `Db.instance()`, `bank_dashboard_rw` on the writer                                |

Rows are cached in the Valkey namespace `dashboard-session` for up to 5 minutes (never past the session's expiry). A refresh or an end deletes the entry, which every instance sees on its next read, and a miss is never cached. See [CACHING.md § 7](CACHING.md#7-what-is-cached--and-what-is-deliberately-not).

| Status       | Set when                                                                                                                                                                                                                                               |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `ACTIVE`     | sign-in; the only status whose row holds encrypted tokens                                                                                                                                                                                              |
| `SIGNED_OUT` | sign-out                                                                                                                                                                                                                                               |
| `EXPIRED`    | the row is read, or swept, past `expires_at` or idle 7 days                                                                                                                                                                                            |
| `REVOKED`    | the request's auth helper ends it: tokens fail verification, the refresh ends the session, or (admin) a non-`cfx.to` email; or (customer, bank) the login's password changes in another session, or a staff session's admin user is no longer admitted |
| `REPLACED`   | a new sign-in arrives from a browser still holding a live id                                                                                                                                                                                           |

- Ending a row clears `encrypted_tokens` and stamps `ended_at`.
- A session unused for 7 days ends, within its absolute lifetime (`SESSION_IDLE_TIMEOUT_MS`). Last activity is `refreshed_at`, or `created_at` before the first refresh: the auth helper refreshes the hour-long Cognito tokens on the first request after they expire, so a session in use advances `refreshed_at` at least hourly and no per-request write is needed.
- `find` ends a row it reads past `expires_at` or idle. An hourly Temporal schedule, the `expireDashboardSessions` workflow, ends the rows nobody reads again through [`DashboardSessionExpiryService.expireStale`](../packages/dashboard-auth-services/src/DashboardSessionExpiryService.ts), which needs no cookie secret and evicts the same cache keys, in batches it repeats until one comes back short. Each table's `<table>_active_expires_at_idx` (`expires_at WHERE status = 'ACTIVE'`) serves it.
- A password change (customer and bank; the admin dashboard has no passwords) ends every other `ACTIVE` session of that Cognito sub in that dashboard as `REVOKED`, keeping the one that changed it. Cognito `GlobalSignOut` is not used: it would revoke the current session's refresh token too, and an ended row's tokens are gone with its `encrypted_tokens` anyway. A failure to end them is reported, and the password change still answers success.
- Rows are never deleted: `b2b.prevent_hard_delete` triggers reject `DELETE` and `TRUNCATE`. Each table is therefore a sign-in history, recording the Cognito sub, email, sign-in method, IP, user agent, country, region and city at sign-in ([`session-client.ts`](../packages/ui/src/lib/session-client.ts)). Customer and bank are not proxied through Cloudflare, so CloudFront's own viewer headers are the ones used there. Admin is reached only through the WARP tunnel ([WARP.md § 6](WARP.md#6-open-the-admin-dashboard)), so CloudFront's viewer is the connector's NAT EIP and the operator's address and location come from Cloudflare's `cf-*` headers. Those headers count only when CloudFront's viewer address is in Cloudflare's published ranges or is one of the egress VPC's NAT EIPs, which admin's web ACL makes the only way in.
- The lifetime is unchanged: 30 days, or 12 hours for a federated sign-in other than Google (which covers the operators' `GoogleWorkspace`). The cookie's max age, its JWT's `exp` and the row's `expires_at` all carry it.
- `authenticated_at` records when credentials were last presented. Sign-in sets it; nothing reads it yet.
- The four pre-store token cookies are never read, only expired, so deploying the store signs every user of all three dashboards out once.

#### Staff sessions on the customer and bank dashboards

CFX staff sign in to the customer and bank dashboards through the `cfx-admin` pool, with each dashboard's staff app client (`customer-dashboard-staff`, `bank-dashboard-staff`), never through the dashboard's own pool. They have no row in `customer_dashboard_user` or `bank_dashboard_user`.

- **Routing.** `DashboardSsoSignInService.signInProviderForEmail` answers `GoogleWorkspace` (`STAFF_IDENTITY_PROVIDER`) for a `cfx.to` address, before any tenant SSO lookup. The sign-in actions treat it like a tenant IdP: no password, passkey, reset or sign-up reaches the dashboard pool, and `/auth/login?identity_provider=GoogleWorkspace` starts OAuth on the staff client (`startDashboardOAuth`). With the `COGNITO_ADMIN_*` / `COGNITO_STAFF_*` keys unset (a local run), a `cfx.to` address is refused as `staff-unavailable`.
- **Callback.** Both clients share `/auth/callback`. `finishDashboardOAuth` exchanges the code with the client whose signer wrote the pending `cfx_oauth` cookie. Each signer is keyed from its own client secret, so no query or claim can select the other client. A staff callback verifies the tokens against the admin issuer and staff client. It then admits them through `DashboardStaffAdminService.admit`, which requires the same `sub`, a `cfx.to` email and `canShadowRead` (ACTIVE plus `user-shadow:r`, or `owner`).
- **Issuing pool.** A staff session's row carries `admin_user_id`, the admin user (`adusr_…`). Members' rows have it null. `resolveStoredSession` picks the verifier, refresh client and method signer by that column, never by the token's claims. It re-admits a staff session on every request, so a DISABLED admin user, or one who lost `user-shadow:r`, ends at the next request. Sign-out revokes the token with the issuing client and visits that pool's hosted logout. `DashboardSessionService.endStaffSessions` ends every live session of one admin user, but nothing calls it yet.
- **In the app.** `getAuthPrincipal()` in each dashboard's `lib/auth.ts` returns `{ kind: "member"; user }` or `{ kind: "staff"; staff }` and is the only reader of the staff session. A staff session has no membership row anywhere; how it enters a workspace is below.

#### Staff inside a customer or bank workspace

- **Picking a workspace.** `/workspaces` serves members and staff alike, and so do the workspace dropdown and the customer Ctrl+K palette (`WorkspaceSwitcherMenuSection`, `WorkspaceSwitcherCommandGroup` and `WorkspacesPage` in `packages/ui`). Each lists the last 10 workspaces the caller entered ([Login history](#login-history)), and typing searches on the server: a member's own memberships (`searchOwn({ q })`), or for staff every live ACTIVE customer with its main organization (`CustomerDashboardWorkspaceAdminService.search({ staff, q })`) or every bank (`BankDashboardWorkspaceAdminService.search`). `searchAuthWorkspaces` in each dashboard's `auth.ts` routes the search by principal. A row opens `/switch?workspaceId=…`, which sets `cfx_workspace` as it does for a member. Customer `/workspaces` sits outside `(private)`, whose gate needs a customer that staff who have entered none yet lack. Staff sign in to the workspace they entered last, and land on `/workspaces` only with none. `/staff` redirects to `/workspaces`.
- **Every request re-reads it.** The cookie is only a key: the gate re-reads the workspace through the admin service's `get`/`search` each request, so a forged, deleted or (customer) non-ACTIVE or organization-less workspace grants nothing and sends staff back to `/workspaces`.
- **Keys.** `permissionsOf(caller)` on `CustomerDashboardMemberPermissionService` / `BankDashboardMemberPermissionService` reads the same `callerCte` the services authorize with; `getAuthCaller()` supplies the caller (the services' `…Caller | …StaffCaller` union). A member gets their role's keys. Staff get the workspace owner role's keys: `:r` only with `user-shadow:r`, all of them with `user-shadow:w`. Staff act at the owner role's rank, so they can manage and assign up to owner, never the `is_internal` role. Every gate reads these keys: `mustHaveAuthPermissions`, `serverAction({permissions})`, bank `workspaceAction`, `<ShowIfPermission>`, `hasAuthPermissions`.
- **Internal roles.** An `is_internal` role grants nothing to anyone, member sessions included: no keys, no rank, no internal visibility (`memberCallerCte` in `packages/dashboard-auth-services/src/{customer,bank}/internal.ts`).
- **Admin and owner view.** Same keys in both. Admin view (default) shows internal-only UI (`isInternal` true for staff); owner view hides it. The overlay pill toggles `cfx_view_as`, keyed to the workspace, and names the staff member. `realIsInternal` stays true in both views.
- **Writes.** Staff may take every write the owner can: team invite, role, status, remove and revoke; payment and ACH-debit confirmation; terms acceptance; bank documents. A staff write records no member id. `invitedByUser*` and `roleAssignedByUser*` are null; `transaction_metadata.createdByAdminUserId` is set instead of `createdByDashboardUserId`; terms record `acceptedByAdminUserId`; bank documents record a null `updatedByUserId`. Every activity row is `{adminUserId, visibility: "INTERNAL"}`, in either view.
- **Self-only pages.** `getAuthMemberSelf()` sends staff to `/forbidden?reason=staff` from pages about the signed-in member: own account, own notification preferences, own avatar.
- **No account page.** `/settings/account`, `/settings/notifications` (customer) and sign-out-everywhere are self-only; their links are hidden. Staff manage their passkey in admin-dashboard. Color scheme and time zone are not saved for staff.
- **Hidden email subscription.** In admin view a staff member subscribes themself to a customer's emails (`internalEmailUserIds`, admin user ids) on Settings → Email notifications. Only subscribers still holding `user-shadow:r` are listed and emailed.
- **Cutover for `cfx.to`.** Every `cfx.to` sign-in goes through the staff client, and an existing dashboard-pool `cfx.to` session ends at its next refresh. The internal-role preview code is deleted. `cfxStaffRuleRole` on membership create stays until Phase 5, which deletes the internal roles and the `cfx.to` dashboard-pool users.
- **Before deploying.** The prod `cfx-cognito` apply ([#4850](https://github.com/cfxlabsinc/cfx-platform/pull/4850)) must have written the five staff keys (`COGNITO_ADMIN_USER_POOL_ID`, `COGNITO_ADMIN_USER_ISSUER`, `COGNITO_ADMIN_USER_AUTH_DOMAIN`, `COGNITO_STAFF_CLIENT_ID`, `COGNITO_STAFF_CLIENT_SECRET`); without them every `cfx.to` sign-in is `staff-unavailable`.

#### Session cookie key

The cookie is an HMAC (HS256) JWT, signed by `DashboardSessionService.create` at sign-in and verified by the proxy and the server with the same key. Code: [`sessionCookie.ts`](../packages/dashboard-auth-services/src/sessionCookie.ts) (`@cfxlabsinc/dashboard-auth-services/session-cookie`), which imports only `jose` and `node:crypto` so the proxy can load it, wired per dashboard in `src/lib/sessionCookie.ts`, which builds the keys at import.

- **Secret.** `SESSION_COOKIE_SECRET`, generated per dashboard per stage by `random.RandomBytes("SessionCookieSecret", { length: 32 })` in its `sst.config.ts`, and set as base64 in the server Lambda's `environment` (65 bytes of the 4 KB cap). Every stage, each `pr-<N>` preview included, gets its own value, held in that stage's SST state. Nothing is in SSM.
- **Stable across deploys.** The resource has a fixed name, a constant `length` and no `keepers`, so a redeploy plans no change to it: the random provider generates a value only on create. It is `protect`ed in prod, where a replacement or delete fails the deploy instead of rotating the key. Replacing it signs everyone on that stage out.
- **Key.** HKDF-SHA256 over the secret with info `cfx_session_cookie` (`deriveSessionCookieKey`), the one derivation the proxy and the server share. The secret must be at least 32 bytes. `kid` is a second HKDF output (info `cfx_session_cookie_kid`, 6 bytes), so it names the key without exposing it. `aud` (`cfx-admin-dashboard`, `cfx-customer-dashboard`, `cfx-bank-dashboard`) stops one dashboard's cookie from verifying in another.
- **Verifying.** `verifySessionCookie` picks the key by `kid` and pins `algorithms: ["HS256"]`, the dashboard's `aud` and `exp`. `alg: none`, ES256, RS256, a forged MAC, another dashboard's cookie, an expired cookie and garbage all read as no session, without touching the store.
- **Boot.** On Lambda, a missing secret, one under 32 bytes or the local test value fails the import of `sessionCookie.ts`, which the proxy loads, so the stage fails at boot.
- **Rotation.** `SESSION_COOKIE_SECRET_PREVIOUS` is verify-only: the old cookies keep working until they expire while new ones are signed under the current secret. No config sets it; to rotate gently, add the old value to the dashboard's `environment` by hand for one session lifetime.
- **Local dev and e2e.** Off Lambda, an unset secret falls back to `LOCAL_SESSION_COOKIE_SECRET`, a fixed, non-secret test value. A deployed stage never falls back to it and refuses it if set.

---

## Authorization surface

[`packages/admin-dashboard/src/lib/auth.ts`](../packages/admin-dashboard/src/lib/auth.ts) exports four functions and one type. The shape mirrors `packages/customer-dashboard/src/lib/auth.ts` verbatim.

```ts
export const getAuthUser = cache(async (): Promise<AuthUserTokenInfo> => {
  // lib/operatorUser.ts. Signed out: a page redirects to /auth/login, a server
  // action throws. DISABLED: /forbidden. Owing a passkey: /passkey.
  const user = await getEnrolledOperatorUser();
  return { id: user.id, email: user.email, ... };
});

export const getAuthPermissions = cache(async (): Promise<AuthPermission[]> => {
  const user = await getAuthUser();
  const result = await createAdminDashboardRoleService().listPermissionsForUser({ userId: user.id });
  return result.ok ? (result.value.permissionKeys as AuthPermission[]) : [];
});

export const hasAuthPermissions = async (input: {
  allOf?: AuthPermission[];
  anyOf?: AuthPermission[];
}): Promise<boolean> => { ... };

export const mustHaveAuthPermissions = async (input: {
  allOf?: AuthPermission[];
  anyOf?: AuthPermission[];
}): Promise<void> => {
  if (!(await hasAuthPermissions(input))) redirect("/forbidden");
};
```

`AuthPermission` is a hardcoded TypeScript union. `AdminDashboardPermissionService` is the source of truth for the DB catalog; the union must remain a subset of it. Drift logs a Sentry breadcrumb on boot. Codegen is a v2 task.

Real call sites:

```tsx
// packages/admin-dashboard/src/app/(private)/products/page.tsx
await mustHaveAuthPermissions({ anyOf: ["products:r", "products:w"] });
```

```tsx
// packages/admin-dashboard/src/app/(private)/admin/users/page.tsx
await mustHaveAuthPermissions({ anyOf: ["admin/users:r", "admin/users:w"] });
```

```tsx
// packages/admin-dashboard/src/app/(private)/admin/roles/page.tsx
<ShowIfPermission allOf="admin/users:w">
  <Button size="sm" asChild>
    <PageLink href="/admin/roles/new">{"New role"}</PageLink>
  </Button>
</ShowIfPermission>
```

`<ShowIfPermission />` is an `async` server component — it imports `lib/auth.ts` which starts with `import "server-only"`. **Never import it from a `"use client"` module**: Turbopack walks the client boundary and fails the build with `'server-only' cannot be imported from a Client Component module`. Gate at the nearest server-component ancestor and pass plain props into the client leaf instead.

---

## Three gating mechanisms

| Surface       | Mechanism                                          | Example                                                                  |
| ------------- | -------------------------------------------------- | ------------------------------------------------------------------------ |
| Page (RSC)    | `await mustHaveAuthPermissions(...)` at top        | `await mustHaveAuthPermissions({ anyOf: ["products:r", "products:w"] })` |
| UI element    | `<ShowIfPermission />` wrapper                     | `<ShowIfPermission allOf="admin/users:w"><Button>Edit</Button></...>`    |
| Server action | `permissions` option on the `serverAction` wrapper | `serverAction(fn, { permissions: { allOf: "admin/users:w" } })`          |

The page gate is the last line of defence; `<ShowIfPermission />` is UI-only and never the sole check. Every privileged page, server action, and route handler must repeat the gate independently. `serverAction(...)`'s `permissions` option wraps the body with `mustHaveAuthPermissions(...)` and redirects to `/forbidden` on miss; see [`packages/admin-dashboard/src/lib/server-action.ts`](../packages/admin-dashboard/src/lib/server-action.ts).

---

## Permission key format

`<resource>[/<sub-resource>]:<action>`, the same convention as customer-dashboard.

- `r` (read): restricted view
- `w` (write): create / update / manage

Categories follow the admin-dashboard sidebar groupings: `Entities`, `Payments`, `Compliance`, `Configure`, `Investigate`, `Admin`, `Auth`. The full v1 catalog is seeded by the initial admin-rbac migration; the live list is visible at `/admin/permissions` (gated by `admin/users:r`).

Examples: `customers:w`, `payments/ach:r`, `compliance/aml:w`, `admin/users:w`.

---

## Roles

Four system roles seeded by the initial migration. `is_system = true` blocks UI deletion and renaming; permission sets remain UI-mutable — **except `owner`** (see below).

| Role     | Purpose                                                                                                                   |
| -------- | ------------------------------------------------------------------------------------------------------------------------- |
| `owner`  | Top of the tree. Full surface including `admin/users:w` and `admin/approvals-config:w`.                                   |
| `admin`  | Full surface except the two meta-writes above. Can edit config, run ops, but can't grant roles or change approval policy. |
| `member` | Day-to-day operator: entity/payments/compliance writes, no Configure writes, no Admin/\*.                                 |
| `viewer` | Read-only across entity/payments/compliance/configure/simulator/audit-log/approvals. No Admin/\* reads, no Auth-meta.     |

**System-role permission sets are intentionally mutable post-seed** (for `admin` / `member` / `viewer`). The seed runs once per environment (`ON CONFLICT DO NOTHING` on `name`); subsequent deploys never re-write `admin.dashboard_role_permission`. UI edits to a system role's permission set are the canonical value going forward. Reseeding on deploy would silently revert UI-authored grants; the audit trail in `admin.dashboard_user_activity` is the durable record.

**`owner` is the exception.** `getAuthPermissions` short-circuits to the full `ADMIN_PERMISSIONS` list by role name (`lib/auth.ts`), so owner's DB-side permission set is never authoritative — editing it would be a silent no-op. The matrix UI disables owner's column and `AdminDashboardRoleService.update` rejects owner permission-set changes with `ROLE_IS_SYSTEM`; the DB rows are kept only for display in the role-detail view. System-role _names_ are likewise immutable: renaming `owner` would break the short-circuit, and renaming any system role would break the seed's `ON CONFLICT (name)`.

`ai-connector:r` gates the whole admin MCP endpoint (`/mcp`), because tool data leaves CFX systems. Owners hold it through the rule above and the seeded `admin` role holds it by a migration grant; no other role does, and it is granted per role in the matrix.

Custom (non-system) roles are added via `/admin/roles/new`. Don't seed roles in migrations unless they're system-level.

---

## Services

Four services in [`packages/dashboard-auth-services/src/admin/`](../packages/dashboard-auth-services/src/admin), exported as `@cfxlabsinc/dashboard-auth-services/admin`. All return `Result<T, ServiceError<...>>` per [SERVICE.md](./SERVICE.md), except `get`, which returns the object or `null`.

### `AdminDashboardUserService`

Identity + status management. The upsert is the hot path; everything else feeds the `/admin/users` UI.

```ts
upsert({ cognitoSub, email, firstName?, lastName?, hostedDomain? }):
  Promise<Result<AdminDashboardUser & { created: boolean }, ServiceError<"ADMIN_USER_DISABLED">>>

get({ id }): Promise<AdminDashboardUser | null>

search(input?: { ids?, statuses?, roleIds?, cognitoSubs?, query?, orderBy?, page?, pageSize? }):
  Promise<Result<{ items: AdminDashboardUser[]; hasNext: boolean; total: number }>>

update({ id, data: { status }, record }):
  Promise<Result<AdminDashboardUser & { activity }, ServiceError<"ADMIN_USER_NOT_FOUND">>>

recordPasskeyEnrollment({ id, enrolled }):
  Promise<Result<{ changed: boolean }, ServiceError<"ADMIN_USER_NOT_FOUND">>>
```

`get` is `search({ ids: [id], pageSize: 1 })`; look a user up by Cognito subject with `search({ cognitoSubs: [sub] })`. `AdminDashboardUser.cognitoSub` is `string | null` (null on a pre-cutover row not yet linked). Each user carries `statusDescription`, `roleName` and `roleAssignedByUserName` beside the values they describe. `record` is required: when the status changes, `update` hands a `user.statusChanged` descriptor to it inside the write's transaction, and the dashboard's recorder writes the row with the acting admin as `user_id`. See [Audit trail](#audit-trail). `recordPasskeyEnrollment` stamps `passkeyEnrolledAt` the first time Cognito lists a passkey, keeps an earlier stamp, and clears it once the login holds none ([The operator passkey](#the-operator-passkey)).

### `AdminDashboardRoleService`

Role catalog, role↔permission mapping, and per-user role assignment live in one service: assignment writes touch `admin.dashboard_user`, so collocating with role logic keeps the audit trail consistent. Every `AdminDashboardRole` carries its `permissions`.

```ts
create({ data: { name, description?, permissionKeys } }):
  Promise<Result<AdminDashboardRole, ServiceError<"ROLE_DUPLICATE_NAME" | "ROLE_UNKNOWN_PERMISSION">>>

get({ id }): Promise<AdminDashboardRole | null>

search(input?: { ids?, names?, userIds?, page?, pageSize? }):
  Promise<Result<{ items: AdminDashboardRole[]; hasNext: boolean; total: number }>>

update({ id, data: { name?, description?, permissionKeys? } }):
  Promise<Result<AdminDashboardRole, ServiceError<"ROLE_NOT_FOUND" | "ROLE_IS_SYSTEM" | "ROLE_DUPLICATE_NAME" | "ROLE_UNKNOWN_PERMISSION">>>

delete({ id }): Promise<Result<void, ServiceError<"ROLE_NOT_FOUND" | "ROLE_IS_SYSTEM" | "ROLE_IN_USE">>>

assignToUser({ userId, roleId, assignedByUserId, record }):
  Promise<Result<AdminDashboardUser & { activity }, ServiceError<"ADMIN_USER_NOT_FOUND" | "ADMIN_ACTOR_NOT_FOUND" | "ROLE_NOT_FOUND" | "LAST_OWNER">>>
unassignFromUser({ userId, assignedByUserId, record }):
  Promise<Result<AdminDashboardUser & { activity }, ServiceError<"ADMIN_USER_NOT_FOUND" | "ADMIN_ACTOR_NOT_FOUND" | "ROLE_NOT_FOUND" | "LAST_OWNER">>>

listPermissionsForUser({ userId }): Promise<Result<{ roleName: string | null; permissionKeys: string[] }>>
```

`update`'s `permissionKeys` is a full replacement: the service clears the join rows for that role and re-inserts inside one transaction. Cheaper than diffing at this scale and trivially correct.

`delete` is a hard `DELETE`: `admin.dashboard_role` has no `deleted_at`, and a role still held by a user is refused (`ROLE_IN_USE`), so nothing references the deleted row.

`assignToUser` / `unassignFromUser` are idempotent. When the role actually changes, the service hands `record` a `user.roleChanged` descriptor with `{ fromRoleId, toRoleId }` (`role_…` ids) inside its transaction. No-op assignments record nothing and don't touch `role_assigned_by_user_id` / `role_assigned_at`.

Both methods take the acting user as `assignedByUserId` and resolve it inside the transaction. A missing actor row fails with `ADMIN_ACTOR_NOT_FOUND`; a missing target user fails with `ADMIN_USER_NOT_FOUND`. The two are distinguished so the UI can render an internal-error toast for the actor case (the dashboard's own user row should always exist) versus a user-facing "user not found" for the target.

### Last-owner guard

`AdminDashboardRoleService.applyRoleAssignment` (the shared body of `assignToUser` / `unassignFromUser`) refuses to drop the final `owner` user. If the operation would leave zero `ACTIVE` users with the `owner` role, the transaction returns `LAST_OWNER` and rolls back. The check runs inside the same transaction that updates `role_id`, so concurrent assignments can't race past it.

The action layer in `admin-dashboard` runs a UX pre-check before opening the role-change confirmation dialog and surfaces the same condition with a friendlier message; the service-layer guard is the authoritative one. UI gating is not a substitute.

### `AdminDashboardPermissionService`

Read-mostly catalog. Authoring permission keys via UI is v2; for v1 the catalog is migration-managed.

```ts
search(input?: { ids?, keys?, categories?, page?, pageSize? }):
  Promise<Result<{ items: AdminDashboardPermission[]; hasNext: boolean; total: number }>>
```

No `get(key)` — caller invokes `search({ keys: [key] })` and reads `.items[0]`. Returns the standard `{ items, hasNext, total }` shape per [`rules/service-pattern.md`](../rules/service-pattern.md). `pageSize` defaults to 100 since the catalog is bounded; `AdminDashboardRoleService.search` defaults to 50 for the same reason. Callers that need every row pass `pageSize: 1000` (or higher) and assert `hasNext === false`.

---

## Bootstrap

A fresh environment has no `owner` users. Without one, no operator can assign roles via the UI, and every login lands on `/forbidden`. The bootstrap path solves the chicken-and-egg.

`AdminDashboardUserService` accepts an `ownerEmails: string[]` constructor argument plumbed in from the `ADMIN_DASHBOARD_OWNER_EMAILS` env var. On the **INSERT branch** of `upsert` (detected via `xmax = 0`), if the new user's lowercased email is in the allowlist and the `owner` role exists in the DB, the same transaction sets `role_id`; `getAuthUser` then sees `created` with a role and records a `user.roleAssigned` activity with `data.roleId` set to the owner role's `role_…` id and `data.source = "bootstrap-allowlist"`. `role_assigned_by_user_id` stays `NULL` (no actor).

- The UPDATE branch never touches role columns; the allowlist never re-grants or revokes.
- If the seed hasn't run yet (the `owner` role doesn't exist), the grant is skipped silently; the next environment where the seed has run will pick it up.
- Dev defaults to `dev@cfx.to` when `NODE_ENV !== "production"`, matching the synthetic operator's email (`syntheticOperator` in `lib/operator.ts`). A fresh `nx serve admin-dashboard` lands straight into an `owner`-roled user.

Production allowlist is Pulumi-managed under the `ownerEmails` config key on each stack:

```sh
pulumi config set ownerEmails "alice@cfx.to,bob@cfx.to" --stack=prod
```

(See [`packages/admin-dashboard/pulumi/index.ts`](../packages/admin-dashboard/pulumi/index.ts) for the wiring.) The Pulumi config's git history plus `admin.dashboard_user_activity` are the durable audit trail.

### Local dev impersonation

`getAuthUser` skips the session and uses a synthetic operator (`syntheticOperator` in [`lib/operator.ts`](../packages/admin-dashboard/src/lib/operator.ts)), and `proxy.ts` lets requests without a session cookie through, under `next dev` only (`NODE_ENV !== "production"`). Every built server needs a real session: dev, prod, PR previews and the e2e suite's `next start`. The defaults are `DEV_FAKE_OPERATOR_SUB=dev_local_user` and `DEV_FAKE_OPERATOR_EMAIL=dev@cfx.to`. Override them to impersonate a specific user:

```sh
DEV_FAKE_OPERATOR_SUB=<cognito-sub> \
DEV_FAKE_OPERATOR_EMAIL=alice@cfx.to \
nx serve admin-dashboard
```

The first request upserts the synthetic row in `admin.dashboard_user`; status / role are managed via the UI as usual.

### E2E password sign-in (dev and previews only)

`/login` signs the e2e operator in with an email and password instead of Google Workspace. It exists for one user, `e2e-operator@cfx.to`, a native user that `packages/cognito/pulumi` creates in the dev `cfx-admin` pool only. Its password is `E2E_ADMIN_PASSWORD` at `/cfx/secrets/ci/` in the dev account. Only dev's `admin-dashboard` app client allows `ALLOW_USER_PASSWORD_AUTH` and the `COGNITO` provider. Prod's allows refresh and `GoogleWorkspace` alone.

One gate decides everything: `passwordSignInEnabled()` in [`lib/passwordSignIn.ts`](../packages/admin-dashboard/src/lib/password-sign-in.ts) is true only when `ADMIN_PASSWORD_SIGN_IN=true` and the stage is not `prod`. `sst.config.ts` sets the flag on `dev` and `pr-<N>` stages, and throws if prod would get it. `e2e/testEnv.ts` sets it for the e2e server. While the gate is off:

- `proxy.ts` answers `/login` with a 404 before any session check.
- The page calls `notFound()`, and its action returns before contacting Cognito.
- `findOperatorSsoProvider` holds a password sign-in to `GoogleWorkspace`, so the shared session code refuses it at sign-in and at every refresh.

While it is on, `findOperatorSsoProvider` returns `null` for a `{ kind: "password" }` sign-in only, so `admits()` lets it through. Everything else is unchanged: the address must still be `@cfx.to`, and a refusal revokes the refresh token. A wrong password, an unknown user, a challenge and a non-operator address all get the same refusal.

PR previews (`pr-<N>`) sign in here. Their host has no callback on the `cfx-admin` client, so Google Workspace cannot complete there, and a signed-out page on a preview stage goes to `/login` instead of `/auth/login` (`signInStartPath()` in the same file). Dev keeps Workspace as its sign-in, with `/login` beside it.

The e2e suite signs in here too. `e2e/global-setup.ts` builds and starts the app against the dev `cfx-admin` pool, signs the e2e operator in once through `/login`, and saves the cookies as Playwright `storageState` for every spec. The operator's `admin.dashboard_user` row is seeded as `owner` by sub (`db/seed/*_e2e_tenants.sql`, a snapshot of the dev rows the real provisioning and invite flows created), so its permissions need no allowlist. CI's `admin-dashboard / e2e` job reads the credentials from Secrets Manager. Locally, with Docker running:

```sh
bun tools/secrets/run.ts --app ci --config dev -- \
  bun tools/secrets/run.ts --app admin-dashboard --config dev -- \
  bun nx run admin-dashboard:e2e-functional
```

`--only-secrets` keeps the rest of the dev config, real third-party endpoints included, out of the server under test.

### Customer and bank sign-in against dev

`nx run customer-dashboard:e2e-dev` and `nx run bank-dashboard:e2e-dev` sign the dev pools' e2e users in at `https://app.dev.cfx.to` and `https://bank.dev.cfx.to` with password and TOTP, then run one signed-in smoke spec each. Each target reads its user's keys from `/cfx/secrets/ci/` in the dev account itself; `E2E_BASE_URL` points it at another stage or a local server. The sign-in helpers live in [`tools/e2e/`](../tools/e2e/) for the dashboards' future suites. Neither target runs in PR CI.

`/login` runs signed out, so it sits outside the `src/app/(private)` route group, whose layout calls `getAuthProfile()` and renders the shell; the root layout holds only the document. [`src/rootLayout.test.ts`](../packages/admin-dashboard/src/rootLayout.test.ts) fails any page outside `(private)` but `/login`, `/signout` and `/passkey`, and holds `/passkey` to resolving the operator first.

---

## How to add a permission

1. Add the row to the admin-rbac seed (or a new migration that inserts into `admin.dashboard_permission` with `key`, `description`, `category`).
2. Add the key to `ADMIN_PERMISSIONS` in [`packages/ui/src/lib/dashboard-permissions.ts`](../packages/ui/src/lib/dashboard-permissions.ts); the `AuthPermission` union in `packages/admin-dashboard/src/lib/auth.ts` is derived from it.
3. `nx run db:start` locally to apply — it re-seeds on every boot, picking up the new permission.

The catalog rows are colocated with the catalog migration; there is no separate seed file for them. A migration that also grants the new key to the roles holding another key needs a `_local.sql` twin in `packages/db/seed/` re-running that grant, because a database built from scratch seeds its role grants after the migrations ran (see `seed/20261002162348_aiprise_templates_permissions_local.sql`). Production migrations carry the seed forward. A boot-time check logs a Sentry breadcrumb when the TS union and the DB catalog drift.

## How to add a role

UI only: `/admin/roles/new` (gated by `admin/users:w`). Custom roles are non-system (`is_system = false`) so they can be deleted from the UI. Don't seed roles via migration unless they're system-level: there are four already, be deliberate before adding another.

## How to change a user's role

UI only: `/admin/users/[id]/role` (gated by `admin/users:w`). For bootstrap, add the user's email to the `ownerEmails` allowlist and have them sign in once; the grant fires on first sight.

**Don't `UPDATE admin.dashboard_user SET role_id = ...` directly.** The activity row in `admin.dashboard_user_activity` is the durable record of who held what when; direct DB edits skip it.

---

## Audit trail

Every role assignment, status flip and role catalog edit — a role's name, description or permission set — emits to `admin.dashboard_user_activity`. The `type` column is a TS union. What is written today:

- `user.statusChanged`, `user.roleChanged`, and `user.roleAssigned` for the bootstrap owner grant
- `user.loggedOut`, from `/signout`'s action, on the operator's own `adusr_…` id; only when a live session named them, and a lost write never stops the sign-out
- `user.passkeyAdded`, when an operator creates a passkey at `/passkey`; the operator is both the actor and the `objectId`
- `role.created`, `role.updated`, `role.deleted`
- `rolePermission.assigned` / `rolePermission.unassigned`, one per key a role gains or loses
- the config surfaces' own types: `tokenConfig.*`, `chainConfig.*`, `backofficeWallet.*`, `maintenance.*`, `emailTemplate.*`
- `verificationTemplate.created`, `verificationTemplate.updated`, `verificationTemplate.deleted`, one per AiPrise template row written, from `/aiprise/templates` and from `/customers/[id]/edit` alike; `objectId` is the row's `vtmpl_…` id. The customer page also writes `customer.verificationTemplates.updated` on the `cust_…` id, summarising the types that changed
- `internalSlackChannel.created`, `internalSlackChannel.updated`, `internalSlackChannel.deleted`, one per internal Slack feed row written from `/internal-slack-channels`; `objectId` is the row's `islackch_…` id
- `utilaVault.created`, `utilaVault.updated`, `utilaVault.deleted`, one per Utila vault row written from `/utila-vaults`; `objectId` is the row's `uvault_…` id
- operator writes to the customer and bank dashboards' RBAC: `dashboardRoleSet.{created,updated,deleted,assigned}`, `dashboardRole.{created,updated,deleted}`, `dashboardMember.{roleChanged,statusChanged,removed}`, `dashboardInvite.{created,revoked}`. They land here because those dashboards' own activity tables require a dashboard-user actor; `objectId` is the role set, role, member's user or invite id

Every member of the union has a writer; a type with none is removed rather than reserved. The permission catalog is seeded by migration, so there is no `permission.*` type.

The full list lives in [`packages/db/src/drizzle/schema/admin/dashboard_user_activity.ts`](../packages/db/src/drizzle/schema/admin/dashboard_user_activity.ts) as the `type` column's `$type<>` union. `visibility = "INTERNAL"` is the default for admin-dashboard activity; the `ALL` value is reserved for the (future) customer-visible feed.

`user_id` is the actor (FK to `admin.dashboard_user`, NOT NULL) and the only place the actor is stored; `search` returns it as `actor`. `objectId` names the target by its prefixed id: `adusr_…` for `user.*`, `role_…` for `role.*`, and so on. Role references inside `data` (`fromRoleId`, `toRoleId`, `roleId`) are `role_…` ids, so the feed renders without re-joining. Rows written before the recorder API put the target in `userId` for `user.statusChanged` and carried the actor in `data.performedByUserId`.

No domain service writes this table. A write returns an `activity` descriptor (`{ type, objectId, data }`) and takes a required `record(activity, tx)` hook, which it calls inside its own transaction; the dashboard's recorder calls `AdminDashboardUserActivityService.create({ events, tx })` on the writer. An operation with no DB row of its own — `EmailTemplateAdminService.sendTest` — opens a transaction for the audit row immediately after the send, since an SES send cannot join one. `create` writes every event in one multi-row INSERT and returns their `adact_…` ids in input order. `search({ ids?, objectIds?, types?, userIds?, visibilities?, page?, pageSize? })` returns `{ items, hasNext, total }`, each item carrying its `actor`, `typeDescription` and `visibilityDescription`.

Bootstrap grants record the new user as their own actor (`user_id` is NOT NULL, so the row self-references); the "no actor" semantic lives in `data.source = "bootstrap-allowlist"` and the `NULL` on `dashboard_user.role_assigned_by_user_id`.

### Login history

Each dashboard records sign-ins in its own schema, beside its activity table, through `*DashboardUserLoginService.record` in `dashboard-auth-services`. Every write is awaited: a row that fails to write fails the sign-in or the switch. IP and user agent come from `sessionClientFromHeaders`, as the session row's do.

- **`admin.dashboard_user_login`** (`adlgn_…`): `SIGNED_IN` at the browser callback and password sign-in, and at `cfx login`'s whoami check (`?sign_in=1`; a plain `cfx whoami` records nothing). An operator's first sign-in has no row yet, so its login is recorded by the first request's upsert instead.
- **`b2b.customer_dashboard_user_login`** (`cdlgn_…`) and **`bank.bank_dashboard_user_login`** (`bdlgn_…`): `SIGNED_IN` at sign-in and `ENTERED_WORKSPACE` at `/switch`, actor a member (`user_id`) XOR CFX staff (`admin_user_id`), always on a workspace. A sign-in that lands in no workspace sets `cfx_sign_in_pending`, and the first workspace entered records the `SIGNED_IN` instead. `search({ actor, page, pageSize })` pages the workspaces an actor entered, newest first, that they may still enter. `record` takes `signInPending` and picks the type; `getStaffLanding` applies the staff fallback. Staff sign in to the one they entered last (on bank, from the apex; a bank host lands on its own bank).

---

## Customer and bank dashboard access

Admin-dashboard is where CFX operators author the customer and bank dashboards' RBAC; workspace owners cannot author roles in their own dashboard. The services are `*DashboardRoleSetAdminService`, `*DashboardRoleAdminService`, `*DashboardMembershipAdminService` and `*DashboardInviteAdminService` in `dashboard-auth-services` ([SHARED_DASHBOARD_AUTH.md § 2.4](proposals/SHARED_DASHBOARD_AUTH.md#operator-authoring)).

| Key                   | Gates                                                                                                                      |
| --------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `dashboard-roles:r`   | `/role-sets` and each set's matrix, roles, workspaces and history                                                          |
| `dashboard-roles:w`   | authoring role sets, roles and grants; switching a workspace's role set                                                    |
| `dashboard-members:r` | `/dashboard-users`, `/customers/[id]/team`, `/banks/[id]/team`                                                             |
| `dashboard-members:w` | inviting, re-roling, disabling, re-enabling and removing members; revoking invites                                         |
| `dashboard-sso:r`     | the Single sign-on section of `/customers/[id]/team` and `/banks/[id]/team`, and its status on the customer and bank pages |
| `dashboard-sso:w`     | setting up, verifying (DNS check), editing and removing a workspace's SSO connection                                       |

`20260926224812_admin_dashboard_access_permissions.sql` grants all four to `owner` and `admin`, the two `:r` keys to `member`, and none to `viewer`. `20260929190000_admin_dashboard_sso_permissions.sql` grants `dashboard-sso:*` the same way. Those dashboards' permission keys stay migration-managed; the UI edits grants only.

An operator disables or re-enables a member from the members table on `/customers/[id]/team` or `/banks/[id]/team` (`setDashboardMemberStatus`). A DISABLED membership keeps its role and grants nothing; the last ACTIVE holder of the top role cannot be disabled (`LAST_OWNER`). The change is recorded here as `dashboardMember.statusChanged` and writes no row in the workspace's own activity feed, as for an operator's role change.

## Customer-dashboard inheritance

Done: customer-dashboard and bank-dashboard read permissions from their own membership tables behind the same `lib/auth.ts` names ([SHARED_DASHBOARD_AUTH.md](proposals/SHARED_DASHBOARD_AUTH.md)).

---

## Business email only

The rule lives in `@cfxlabsinc/email` ([packages/email](../packages/email/README.md)): personal providers, privacy relays and disposable domains are refused, subdomains included. Existing logins and members are untouched.

- **Sign-up**: the Cognito pre-sign-up Lambda ([pre-sign-up.mjs](../packages/cognito/pulumi/handlers/pre-sign-up.mjs)) is the authority, on the `platform` pool only (`CHECK_BUSINESS_EMAIL`). It checks email/password sign-ups and Google sign-ups that would create a new login. An existing confirmed login that adds Google is linked as before. Tenant SSO, the Workspace IdP and `AdminCreateUser` are not checked. The sign-up form in `packages/ui` shows a hint only.
- **Address insights**: at native sign-up the Lambda also calls SES `GetEmailAddressInsights` and refuses on `IsDisposable` HIGH. It fails open: an error, `AccessDenied` or a 2 s timeout allows the sign-up and logs a `console.warn`. It needs `ses:GetEmailAddressInsights` in `cfx-workload-boundary` ([workloads-nonprod](../packages/aft/account-customizations/workloads-nonprod/terraform/iam.tf), [workloads-prod](../packages/aft/account-customizations/workloads-prod/terraform/iam.tf)); until AFT applies it, every call is `AccessDenied` and fails open.
- **Invites**: the four `create` methods in `dashboard-auth-services` refuse with `WORKSPACE_INVITE_PERSONAL_EMAIL` ("You can't invite personal email addresses."). There are no exceptions, including admin-issued and onboarding owner invites.
- **Applications**: the company domain must equal the applicant's email domain, both normalized (IDN-safe) in [basics.ts](../packages/onboarding-services/src/basics.ts). A mailbox on a subdomain applies as that subdomain.
- **Known gap**: a user can change `email` to a personal address after sign-up, since the pre-sign-up trigger does not fire on an attribute update. Out of scope for ENG-3589.

---

## Out of scope

Deliberately omitted from v1 (and v2 unless the case becomes load-bearing):

- **Role hierarchy / inheritance.** Multiple roles per user is the answer when a user needs combined permissions.
- **Subscriptions / role-mapping versioning.** Single tenant, one universe.
- **Per-resource ABAC** (e.g. "only this team can approve this rule"). SHAPE-§13 covers approval-rule gating via its own config table.
- **Time-bounded role assignments.** Cut from v1; revisit if break-glass becomes a real need.

---

## Testing

The services are exercised by integration tests against a real Postgres via `getTestDb()` from `@cfxlabsinc/db/test`, a fresh `postgres:18` testcontainer per suite (requires Docker). Run with:

```sh
nx run dashboard-auth-services:test
```

Test files: [`AdminDashboardUserService.test.ts`](../packages/dashboard-auth-services/test/AdminDashboardUserService.test.ts), [`AdminDashboardRoleService.test.ts`](../packages/dashboard-auth-services/test/AdminDashboardRoleService.test.ts), [`AdminDashboardPermissionService.test.ts`](../packages/dashboard-auth-services/test/AdminDashboardPermissionService.test.ts).
