Auth & Tenancy: The Gate Chain
The portal is login-protected by default — every route except the auth flow and health endpoints — without paying a server round-trip on in-app navigations. This page walks the chain a request passes through, and where each decision is actually enforced. The one-sentence mental model, straight from ADR-0024: the portal’s gates protect the app shell; the real data-authorization boundary is apps/api (JWKS bearer + Postgres RLS). Portal gates are UX and defense-in-depth, never the last line — because an edge Worker sits outside the database’s RLS enforcement, the only boundary that can actually be trusted with tenant isolation.
Layer 1 — the login gate (ADR-0024)
routes/__root.tsx beforeLoad runs the gate only during SSR:
// apps/portal/src/routes/__root.tsx
beforeLoad: async ({ location }) => {
if (!import.meta.env.SSR) return; // client navs pay nothing
const gateResult = await checkPortalSessionGate({
data: { pathname: location.pathname, search: location.searchStr ?? "" },
});
if (gateResult.redirectTo !== null) {
throw redirect({ href: gateResult.redirectTo, statusCode: 302, reloadDocument: true, /* … */ });
}The server side (apps/portal/src/server/portal-auth-gate-server.ts) is edge-safe by design: it unseals the iron-session cookie and checks only the refresh window — no WorkOS round-trip, no Postgres. A cookie that unseals with an open refresh window is a live session; the short-lived access token inside may be expired and is refreshed later by the data layer. Details worth knowing:
- Public allowlist:
/auth/*(gating the cookie-setting callback would loop),/healthz,/readyz,/statusz,/version, and browser icon probes (/favicon.ico, apple-touch icons — Safari fires these unconditionally and would otherwise 302 into WorkOS for a favicon). - Open-redirect guard:
safeReturnTorejects absolute and protocol-relative (//host) values, so a crafted login link can’t bounce a user off-site. - Build-time prerender (
TSS_PRERENDERING) is never gated. - Redirects carry an
X-OpsDNA-Auth-Gate: <reason>header (missing_cookie/invalid_cookie/expired) plus recovery cookies for the invalid/expired cases.
Mid-session expiry or revocation is layer 2 of ADR-0024: data-layer calls surface RequiresAuth, and the adapters throw an auth-session-recovery redirect back to login.
Layer 2 — the active-grant gate
A valid session is not access. A user whose WorkOS login succeeded but whom OpsDNA has not provisioned holds zero active permission grants; apps/portal/src/server/portal-active-grant-gate-server.ts runs after the login gate and redirects them to the chromeless /access-pending surface before any app chrome streams. It reads the ambient grant from async context when the request edge already resolved one, otherwise resolves via resolveActiveGrantForRequestEnvGated, and emits a portal.active_grant_gate span recording why a render bounced (distinguishing “provisioned user resolved to 0 grants” — a bug — from a genuinely unprovisioned user).
Do not assume the SSR active-grant gate is live. __root.tsx currently carries an incident bypass: invoking the gate via createServerFn during SSR tripped TanStack Start’s server-function registry on the Cloudflare worker after deploy, blanking the shell for provisioned users. The bypass is safe only because apps/api still enforces access on every data route — which is exactly why you must never treat a portal gate as the authorization boundary, and never skip the API-side check because “the portal already gated it.”
Layer 3 — tenant selection (ADR-0033)
One identity can hold grants in multiple tenants (an auditor across firms; GP at X, LP at Y). ADR-0033 separates two walls: selection (which tenant does this request act in?) and enforcement (tenant_id FORCE RLS — the actual boundary, independent of selection). The portal participates only in selection: every data adapter forwards the resolved active tenant as a header.
// apps/portal/src/server/active-tenant-header.ts
export const ACTIVE_TENANT_HEADER = "x-opsdna-active-tenant"
export function activeTenantHeader(): Record<string, string> {
const tenantId = getActiveGrantFromAsyncContext()?.tenantId
return tenantId ? { [ACTIVE_TENANT_HEADER]: tenantId } : {}
}The header is untrusted by design. apps/api’s resolveTenant only honors it when the JWKS-verified subject actually holds an active grant in that tenant (Path B, 2+ grants); with an org_id claim present (Path A) the header is ignored entirely; 0 grants fails closed as no_workspace. Forwarding an unvalidated selection is therefore safe — the grant set is the boundary. When no ambient grant exists (e.g. a client-navigation server-fn call), the adapter sends no header and the data plane falls back to grant resolution.
Layer 4 — in-tenant capability gates (ADR-0026/0027)
Within a tenant, “what can you do here” is Postgres-authoritative (ADR-0026’s identity-bound, transaction-bound P0 core; ADR-0027 is the future platform on top). On the portal side this shows up as route-level capability gates — routes/companies.index.tsx is the pattern-setter, layering an L3 persona/shell gate in beforeLoad and an L2 deny-before-read loader backstop over the L1 API boundary. apps/portal/src/server/per-request-permission-cache.ts is the (currently Phase-1 stub) per-request memo for capability decisions so the route guard, nav model, and loaders see one consistent answer per request; its own header warns it must never survive a request boundary.
Related
- Data Access — how the JWT and header actually travel.
- /api — the enforcement side: JWKS verification,
resolveTenant, RLS. - /entity-model — what
tenant_idscopes. - ADRs in the monorepo:
docs/adrs/0024-portal-login-gate.md,0026,0027,0033.