Auth & tenancy
The security spine. If any of the rules below feel optional, they aren't.
Authentication
- NextAuth v5 with JWT sessions.
- Session claims include
userId,role, andclientSlug(null for platform-level roles). - Gate:
src/proxy.ts(Next.js 16's renamedmiddleware.ts) runs in the Node runtime, not the Edge runtime, and authenticates every request outside thePUBLIC_PREFIXESallow-list. It does not check roles. Thelayout.tsxguards under each protected segment remain the primary server-side gate. Do not move auth to the Edge runtime.
Roles
There are eight roles, defined once in src/lib/rbac.ts (ROLES). Never
re-list them inline in a guard — derive from the helpers below.
The operative axis is cross-client vs tenant-locked, not "admin vs user":
| Role | Scope | Notes |
|---|---|---|
super_admin | Cross-client | Full platform access. |
admin | Cross-client | Full platform access. |
account_manager | Cross-client (assigned clients) | Reads and manages the clients assigned to them, and is blocked from platform-only surfaces. With no assigned active client the scope is empty. |
client_admin | Tenant-locked | NOT admin-tier despite the name — manages only its own client. |
C-Level | Tenant-locked | |
editor | Tenant-locked | |
viewer | Tenant-locked | |
client_user | Tenant-locked | Back-compat alias from the early rebuild; treated as viewer. |
Cross-client roles may pass clientId / clientIds to scope a view, or omit
both and read the aggregate. Every other role is locked to its own
clientSlug, and any client_id / clientIds param in the request is
ignored — see the contract comment at the top of src/lib/auth-scope.ts.
client_admin is intentionally NOT admin-tierThe name reads like admin, but client_admin is tenant-scoped: it manages its
own client's settings, brands, and non-admin users, and never reaches /admin/*
platform management. isAdminTier() deliberately excludes it. Gating a new
surface on the string admin appearing in the role name is a tenancy bug.
Role gating
Gate on the helpers in src/lib/rbac.ts — never on an inline role list:
| Helper | True for | Use for |
|---|---|---|
isCrossClientRole() | super_admin, admin, account_manager | May scope across tenants. |
isAdminTier() | super_admin, admin, account_manager | /admin/* client management. |
isPlatformAdmin() | super_admin, admin | Platform-only: System Settings, ENV, ClickUp registry, feature flags, sync-health, admin-user CRUD. account_manager is denied. |
can() / permissionsFor() | per-permission | Everything else; src/lib/nav.ts derives the menu from these. |
| Route | Gate |
|---|---|
/portal/* | Any authed, non-admin session (own tenant only) |
/admin/* | isAdminTier(), with isPlatformAdmin() on platform-only pages |
/api/webhooks/* | Signed by platform; no session |
/api/ingest/* | Signed Inngest events; no session |
The first gate is src/proxy.ts (Node runtime), which authenticates everything
outside the PUBLIC_PREFIXES allow-list but does no role checks — so every
/api/admin/* route must call its own require* guard. The per-segment
layout.tsx guards remain the primary server-side gate.
User-facing marketing Route Handlers converge on resolveQueryScope().
It is their fail-closed chokepoint: cross-client roles may pass clientId /
clientIds hints, but every tenant-locked role gets clientIds = [own clientSlug] regardless of what the request asks for. src/proxy.ts only
proves someone is logged in; it never proves which tenant they may see.
Background jobs, MCP, audit scripts, and other service paths use their own
explicit scope contracts and must be reviewed independently.
Tenancy — MarketingFilter
The browser-reporting tenant boundary. Marketing Route Handlers use:
const scope = await resolveQueryScope(session)
// scope: { clientIds: string[] }
const rows = await db.query(buildWhere(scope, userFilters))
- Cross-client sessions (
isCrossClientRole()) may scope viaclientId/clientIds, or get the aggregate when neither is supplied. - Tenant-locked sessions get
clientIds = [own clientSlug]; request params are discarded, fail-closed. - Anonymous sessions are locked to the
defaultdemo tenant. - A new browser reporting query that bypasses the maintained scope/query builders is a P0. Other execution planes must enforce an equivalent explicit client boundary.
Row-level security
RLS is enabled across many Supabase tables, but policy coverage and grants vary by migration. Do not maintain a hand-counted table list here; inspect the current migrations and deployed policies for the exact target.
Server paths commonly use the Supabase service role, which bypasses RLS, and ClickHouse has no equivalent per-tenant policy boundary in this application. Application scope is therefore load-bearing on those paths. RLS remains useful defense in depth for clients that do not bypass it, but it is never permission to omit an application-layer scope check.
Isolation tests
npm run test:isolation (src/lib/marketing/queries.isolation.test.ts) verifies query-builder-level scoping: for representative filter combinations, it asserts buildWhere() always emits a client_id predicate for tenant-locked scopes and never leaks another tenant's rows. This is query-builder coverage, not a per-API-route tenant-A/tenant-B matrix. Do not skip it.
Invite flow
- An authorized administrator creates the user from the client Users section; the request is handled by
POST /api/admin/users. - The existing
usersrow stores a single-use invite token. Current source does not implement a 72-hour token TTL, so do not promise one. - Acceptance sets the password and clears the token on that existing client-scoped row. There is no open self-signup.