Office auth

architecture/auth.md

Model

One identity system covers everyone. A person signs in through Supabase Auth — Google OAuth is the primary, passwordless path for everyone (and the only one for staff); clients can also use an email magic link or an optional passkey. What they can do is decided by their role, not by how they signed in.

  • accounts is the single identity table (1:1 with auth.users via a nullable user_id — a row can exist before the person ever signs in, e.g. a lead we're working). A client is an account with no role. Staff hold one or more office roles via account_roles.
  • Office roles (roles table, domain = 'office', ranked):
    • staff (rank 10) — read-only across the office (for now).
    • manager (rank 20) — write everything except /inventory, /settings, and /data.
    • administrator (rank 30) — full access. Roles are many-to-many, so an account can also hold orthogonal roles in other domains (e.g. an editor for editorial) without affecting the office ladder.
  • Gates (src/lib/office/auth.ts):
    • requireStaff() — any office role; gates read pages / the dashboard shell.
    • requireRole(min) — minimum rank.
    • requireWrite(section)administrator for inventory/settings/data, manager otherwise. Server actions that mutate call this.
    • getOfficeUser() is the non-redirecting variant (returns OfficeUser | null). An OfficeUser carries { id, accountId, email, roles, rank, isDevBypass }; accountId (the accounts.id) is what attribution uses (audit_log.actor_account_id).
  • No env allowlist. Office access requires an accounts row that holds an office role in account_roles — there's no ADMIN_EMAILS and no auto-bootstrap. Administrators manage roles at /office/team (see "Provisioning"). Magic links are a client convenience: the sign-in action declines any email holding an office role (staff sign in with Google), without leaking which.
  • Sign-in flow: /auth/sign-in is the single door (office + portal). Google OAuth or a client magic link → /auth/callback exchanges the PKCE code (or verifies the magic-link OTP) for a cookie session → destinationForSession() routes by audience: an office role → /office; anyone else → /portal, auto-creating a client account on first sign-in (open signup — provision_oauth_account). The office gate (getOfficeAccess) then requires an office role and a live rolling staff session.
  • REST API (src/lib/office/api/auth.ts): native/portal clients present a Bearer JWT; authenticateApiRequest(req, { minRole }) validates it and resolves the same office identity. Read endpoints take the default (staff+); the pickup/return write endpoints require minRole: "manager".
  • Why OAuth / no passwords: Supabase Auth gives real auth.users identities for the audit trail (audit_log.actor_account_id) and avoids credential handling. Google (Workspace-friendly, centrally 2FA-able) is the staff path; there are no passwords. Staff passkeys (and the old enrol wall) were dropped — passkeys are now a client-only convenience, optional.

Data-access security (important)

Auth identifies who and what role; it is not how data is protected. Every rental table is RLS-locked (deny-all for anon/authenticated); the browser/session client can read nothing. All office data access uses the service-role client (supabaseAdmin()), server-side only, which bypasses RLS. So:

The real access control is requireStaff() / requireWrite(section) running before any service-role query. New tables are auto-RLS-locked by the rls_auto_enable event trigger, so they're service-role-only by default too (this includes accounts, client_profiles, roles, account_roles, and account_verifications).

A leaked anon key exposes nothing in these tables, and there are no per-row RLS policies to maintain.

Env vars

ADMIN_DEV_BYPASS=1                         # dev only; non-prod; opens /office w/o login
# ADMIN_DEV_EMAIL=you@studio.chat          # cosmetic email for the dev-bypass admin
# NEXT_PUBLIC_SITE_URL=https://studio.chat  # for the OAuth / magic-link callback in prod

There is no ADMIN_EMAILS — office access is entirely DB-driven (a role in account_roles).

NEXT_PUBLIC_SUPABASE_URL / NEXT_PUBLIC_SUPABASE_ANON_KEY (already set) back the auth client; SUPABASE_SERVICE_ROLE_KEY backs all data access.

Dev bypass

devBypassEnabled() (src/lib/office/auth.ts) decides whether to skip real auth and return a synthetic administrator (email = ADMIN_DEV_EMAIL or dev@studio.chat, id/accountId = null) so you can open /office without receiving an email. It keys off VERCEL_ENV, not NODE_ENV. Resolution order:

  1. VERCEL_ENV=production (real production) → always off. No override.
  2. ADMIN_DEV_BYPASS=0 → off (force real auth, e.g. to test the Google sign-in flow on a preview / locally).
  3. ADMIN_DEV_BYPASS=1 → on (local dev, or any non-prod host).
  4. Otherwise → on for Vercel preview deploys only (VERCEL_ENV=preview), since per-deploy preview URLs aren't in Supabase's redirect allowlist and magic-link can't complete there.

The sidebar shows an amber dev_bypass_ marker. It's impossible to enable on real production because the VERCEL_ENV=production check short-circuits before any ADMIN_DEV_BYPASS override. Audit rows created under the bypass have actor_user_id = null (the change is still recorded, just unattributed).

Why a default-on preview bypass is safe. A bypassed session is a full admin, so what protects data is not the bypass — it's the database the deploy talks to. Production (VERCEL_ENV=production) has the bypass hard off. Every non-production deploy is VERCEL_ENV=preview and is wired, via the Preview-scoped SUPABASE_* env vars, to the dev Supabase project — never prod. So a bypassed admin on a preview can only touch the dev database (test data); the real PII in prod is unreachable from any preview. Invariant to preserve: the Preview environment's NEXT_PUBLIC_SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY must stay pointed at dev. Aiming them at prod (e.g. "to debug with real data") is what would turn the bypass into a real exposure — that, not the bypass, is the thing never to do. (This is the resolution of audit finding A1, which assumed the shared-DB topology this project doesn't use.)

No passwords

There are no passwords. Everyone uses Google; clients can also use a magic link or a passkey. The default local path is the dev bypass (above), but local Google sign-in does work against the local stack: [auth.external.google] in supabase/config.toml is enabled as config-as-code, reading env(GOOGLE_OAUTH_CLIENT_ID) / env(GOOGLE_OAUTH_SECRET) (set them in .env, gitignored — not the old SUPABASE_AUTH_EXTERNAL_* names, which supabase secrets set rejects for the reserved SUPABASE_ prefix). Add http://127.0.0.1:54321/auth/v1/callback to the OAuth client's redirect URIs and restart the stack. CI/branch previews don't carry those secrets, so ci.yml passes dummy fallbacks (${{ secrets.GOOGLE_OAUTH_CLIENT_ID || 'ci-dummy…' }}) to keep supabase start from failing.

Provisioning office access

Administrators manage who can sign into the office at /office/team: change a role or remove it (→ a plain client) inline, and add or promote someone by email on the /office/team/add sub-page (find-or-create the accounts row, grant a role). Staff = any account with an office role — no email-domain restriction (contractors included). Every change is audited (entity_type = account_roles). Protections:

  • Last administrator can't be removed (lockout guard).

  • Super administrator — the earliest-granted (real) administrator — can't be demoted or removed by anyone; the row is locked in the UI and the actions reject it. "Whoever is first gets it."

  • Service accounts — the system account (administrator@studio.chat, all-zeros SYSTEM_ACCOUNT_ID; trigger / dev-bypass / automation attribution) and the agent account (agent@studio.chat, AGENT_ACCOUNT_ID; AI-agent / automation actions, source = 'agent', migration 0010) — are shown but immutable: setOfficeRole and the team actions reject them (IMMUTABLE_ACCOUNT_IDS), and they're excluded from the admin count and super-admin selection (they can't sign in).

  • Members whose email is outside the studio.chat Workspace are flagged "2SV not enforced" in the roster — a reminder that org-enforced 2-Step Verification only covers Workspace accounts (see src/lib/office/workspace.ts).

  • No staff are seeded. supabase/seed.sql carries the real catalog (items/assets) but no staff account — staff sign in with Google and get roles via /office/team. Locally, open the office with the dev bypass, or bootstrap an admin by SQL:

    insert into accounts (email, display_name, source) values
      ('you@studio.chat', 'You', 'office')
      on conflict (lower(email)) do nothing;
    
    insert into account_roles (account_id, role_id)
    select a.id, r.id from accounts a, roles r
    where lower(a.email) = 'you@studio.chat' and r.key = 'administrator';
    

    Then sign in at /auth/sign-in with Google; resolveOfficeUser() links your auth.users row to the account on first sign-in.

Caveat — promoting an active client. The office gate checks role + live session, not auth method. If you promote a client who's currently signed in (via magic link / passkey), that session keeps office access until it idles (15 min) or hits the 12-hour cap; new magic links are refused once they hold a role. To cut access immediately, revoke their sessions.

Sign-out

The sidebar "sign out" posts a server action that calls supabase.auth.signOut() (revoking the rolling staff session) and redirects to /auth/sign-in.

Design history: the original passkeys-as-staff-door model (and why it was retired for Google-only staff auth) is preserved in the archive.