Skip to main content

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, and clientSlug (null for platform-level roles).
  • Gate: src/proxy.ts (Next.js 16's renamed middleware.ts) runs in the Node runtime, not the Edge runtime, and authenticates every request outside the PUBLIC_PREFIXES allow-list. It does not check roles. The layout.tsx guards 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":

RoleScopeNotes
super_adminCross-clientFull platform access.
adminCross-clientFull platform access.
account_managerCross-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_adminTenant-lockedNOT admin-tier despite the name — manages only its own client.
C-LevelTenant-locked
editorTenant-locked
viewerTenant-locked
client_userTenant-lockedBack-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.

warning
client_admin is intentionally NOT admin-tier

The 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:

HelperTrue forUse for
isCrossClientRole()super_admin, admin, account_managerMay scope across tenants.
isAdminTier()super_admin, admin, account_manager/admin/* client management.
isPlatformAdmin()super_admin, adminPlatform-only: System Settings, ENV, ClickUp registry, feature flags, sync-health, admin-user CRUD. account_manager is denied.
can() / permissionsFor()per-permissionEverything else; src/lib/nav.ts derives the menu from these.
RouteGate
/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 via clientId / 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 default demo 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 does not replace application scoping

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 users row 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.