Skip to main content

Authentication and authorisation

Authentication​

There's no password, session table or sign-in page in the application. Who is calling is decided by an Authenticator (apps/api/src/auth.ts), a function from a Request to an Identity ({ email, name? }) or null. authenticatorFor(config.auth) picks one of two.

Cloudflare Access (staging and production)​

Cloudflare Access authenticates staff against the Davies identity provider before any request reaches the Worker, and forwards a signed JWT. cloudflareAccessAuth(teamDomain, audience):

  1. reads the token from the Cf-Access-Jwt-Assertion header, or the CF_Authorization cookie;
  2. verifies it with jose.jwtVerify against the team's JWKS (https://<team>/cdn-cgi/access/certs, cached per issuer), with the expected issuer (https://<team>) and audience (the Access application's AUD tag);
  3. returns the lower-cased email claim and, if present, name.

Any failure returns null, and the API responds 401 unauthenticated. Verifying on every request means a misconfigured route can't bypass Access.

Signing out goes to Access's own logout, /cdn-cgi/access/logout (ACCESS_SIGN_OUT_PATH), which /api/me returns as signIn.signOutUrl.

Session expiry. When the Access session lapses, Access answers API calls with a redirect to the identity provider. The web client fetches with redirect: "manual", so it sees an opaqueredirect (or a 401) and reloads the page once to go back through SSO. A timestamp in sessionStorage (workbench.sessionReloadAt) stops a broken sign-in from reloading more than once every 30 seconds; after that it shows Your sign-in has expired. Reload the page to sign in again.

Development sign-in​

devAuth(defaultEmail) trusts the x-dev-user header, falling back to DEV_USER_EMAIL. It's only constructed when AUTH_MODE=dev, and parseConfig refuses that outside ENVIRONMENT=development or test. /api/me reports signIn.mode: "dev", and the web client then:

  • offers Sign in as (development) in the account menu;
  • stores the chosen email in localStorage (workbench.devUser) and sends it as x-dev-user;
  • never reloads on 401 (there's no SSO to go back through).

Under Access, the client clears any stored dev user and never sends the header; the server would ignore it anyway.

Provisioning​

resolveUser(db, config, identity, now) (services/users.ts) runs on the service connection for every request:

  • Known user: refreshes last_seen_at (at most every five minutes) and fills in the name from SSO if we didn't have one.
  • Unknown user: provisioned if their email is in BOOTSTRAP_ADMIN_EMAILS (as an admin) or their domain is in ALLOWED_EMAIL_DOMAINS (as a member). Otherwise 403 not_provisioned.
  • Deactivated user: 403 deactivated.

The result, { id, email, name, role }, is set on the Hono context as user.

The database boundary​

Everything a handler reads or writes goes through the Database wrapper:

// apps/api/src/context.ts
export function asUser<T>(c: Ctx, fn: (tx: Db) => Promise<T>): Promise<T> {
return depsOf(c).db.asUser(userOf(c).id, fn);
}

asUser(userId, fn) opens a transaction and runs:

set local role workbench_app;
select set_config('app.user_id', $1, true);

then fn(tx). workbench_app is a non-owner NOLOGIN role with only the grants in the migrations, and every table has RLS enabled, so the policies apply to everything inside the transaction. The user ID is bound as a parameter and also checked against a UUID pattern.

asService(fn) runs on the owner connection and bypasses RLS. It's reserved for reviewed paths:

PathWhy it needs the owner connection
resolveUserProvisioning and last_seen_at bookkeeping before a user exists.
activeAdminCountGuarding the last administrator.
activePromptAI calls read the published prompt; prompt_versions is admin-only under RLS.
Classification-cache writes and hit countsNew cache entries are written only by the service, after validation.
BenchmarksAggregates every opted-in engagement's latest summary; only rounded, anonymised aggregates leave the function and groups seen in fewer than five engagements (after excluding the compared one) are suppressed.
Purge (manual and nightly)Deletes identities a lead may not be able to delete directly; authorised first under RLS, then scoped to one engagement.
AI spendMonth-to-date token totals across the platform (ai_usage is admin-only under RLS).
"Ask the data" quotaCounts the caller's own ask calls today, by their session's user ID.
SeedingVersion 1 of reference data and prompts.
Adding an asService path

Say why in a comment, never pass IDs taken from a request body into it without an asUser authorisation check first, and cover it with a test in test/security.test.ts.

Application-level checks​

services/access.ts mirrors the policies so a request fails early with a clear message; the policies remain the backstop.

const { dataset, access } = await datasetAccess(tx, user, datasetId);
requireWrite(access); // "This engagement is closed…" or "Viewers can't change engagement data."
requireLead(access); // "Only the engagement lead can do this."
requirePii(access); // "Names and emails need PII access on this engagement…"

EngagementAccess carries role, pii, isAdmin, canWrite (leads, analysts and admins on an active engagement) and canLead (leads and admins, any status). Platform-admin endpoints call requireAdmin(c) (routes/helpers.ts), and the admin reference and prompt routers apply it as middleware.

Because the lookups themselves run under RLS, an engagement the user can't read simply isn't found (404), rather than being reported as forbidden.

Authorisation matrix​

LevelCan
Member (any signed-in user)Create clients and engagements; see engagements they're staffed on; read published reference data; use Methodology and Benchmarks.
Engagement viewerRead the engagement's reports; export deliverables that contain no names; ask the data.
Engagement analystAlso upload datasets, override classifications, change assumptions, rescore with new reference data, generate AI text, build rollout plans.
Engagement leadAlso manage the team and settings, close, reopen or purge the engagement, and read its audit trail.
PII access (per member, any role)Store, reveal and match names and emails for that engagement.
AdministratorEverything above except PII, on all engagements; users, the cache, the platform audit trail, AI usage, reference data drafts, prompts; delete engagements. An administrator can't grant PII access to themselves either.

The web app computes the same flags (accessFor in pages/engagement/EngagementLayout.tsx) to hide controls a user can't use, but never relies on that for enforcement.

Tests​

  • apps/api/test/auth.test.ts covers JWT validation (with an injected key set, including the cookie, wrong audience, wrong issuer, expired and email-less tokens), dev sign-in refusal outside development, provisioning, deactivation and the last-admin guard.
  • apps/api/test/security.test.ts tries forbidden things against the real policies: non-members reading an engagement, RLS hiding rows even with no WHERE clause, viewers and outsiders writing, identities without PII access (admins included), editing the audit log, writing the shared cache as a user, spoofing the RLS user ID, writing to a closed engagement, and purging an active engagement.
  • reference.test.ts and prompts.test.ts check that drafts are admin-only and that published versions are immutable.