Auth & RBAC
Technical design of Clerk integration and Role-Based Access Control
Overview
Danvas uses Clerk for authentication and identity management. Clerk authenticates an external identity and nothing more: team membership, role, location access, lifecycle, and employee mapping are owned by the local users table. Clerk Organizations and publicMetadata are never an authorization source — see docs/adr/0018-local-authorization-boundary.md.
The canonical picture of the three records involved — Clerk identity, local
users row, optional Employee link — is drawn once in that ADR, under the
heading "Canonical identity map". Read it before this page; everything here
assumes it.
Clerk Integration
User Provisioning
Access is invitation-only. Signing in to Clerk creates no local account on its own: provisionUser() in @repo/auth claims an outstanding invitation or grants nothing. A verified email that matches a row on the employee roster is not an invitation — the roster records who works here, not who an administrator asked to sign in. An identity with no invitation lands on /account-inactive.
- Webhook Sync: Clerk webhooks dispatch to
handleClerkUserCreated,handleClerkUserProfileUpdated, andhandleClerkUserDeleted, each with its own allowed-field projection. - Metadata: Clerk
publicMetadatais not written or read for authorization. Local role, team, location, lifecycle, and employee-link fields remain in PostgreSQL; provider profile events update only approved identity fields. - Setup Flow:
/onboardingcollects an optional profile photo and phone number, then walks the user through PWA install instructions for iOS/Android and marksusers.onboardingCompletein the local database. Profile setup is separate from team assignment.
Role Provisioning Trust Boundary
No role of any kind can originate from the provider. A signup with no invitation gets no local row at all, so a role claim on the Clerk session or in publicMetadata has nothing to land on. Every role — member included — comes from:
- an admin-authored pending invitation row in the
userstable (created via the admin Users UI), promoted on first sign-in bypromotePendingInvitation, or - a subsequent scoped, audited Server Action —
updateUserAccess, which commits role, accessible locations, and primary location as one transaction, orupdateMemberRolefor a role-only change from the roster row menu.
Profile events never touch role, teamId, locationIds, lifecycle state, onboarding state, or the employee link. An established account whose email now matches a different local row is reported as an identity conflict rather than rebound — see handleClerkUserProfileUpdated in packages/auth/clerk-user-sync.ts.
Middleware
The @repo/auth package provides middleware that handles:
- Session validation.
- Redirecting unauthenticated users to sign-in.
- Ensuring users belong to an active team.
The teamId is not resolved from a Clerk organization. It comes from the
local users row (CANVAS_TEAM_ID names the tenant), and
Clerk Organizations are not used at all. apps/app/src/proxy.ts does read
orgId/orgSlug, but only to label request logs — that value never reaches an
authorization decision.
Account Lifecycle
users.status is canonical and has five values. Runtime auth requires
active; the retained isActive column is a constrained compatibility
projection, not a second state machine.
| Status | What it means | What it forecloses |
|---|---|---|
invited | An admin staged a role and locations; no provider identity exists yet | Password recovery, impersonation, session review |
active | Promoted and able to sign in | — |
deactivated | Sign-in blocked; the access record is intact and reactivation restores it unchanged | Sign-in, impersonation |
provider_deleted | Clerk no longer has a sign-in for this account; the local row and its history remain | Password recovery, impersonation; normal reactivation cannot recover it |
reconciliation_required | Local record and provider disagree and need bounded operator repair | Sign-in, impersonation |
The admin drawer states the consequence, not just the badge — an account in a non-active state says what it forecloses rather than showing a coloured word and a hidden set of controls.
Why a Blocked Sign-in Is Blocked
/account-inactive catches every identity the authenticated layout turns away.
diagnoseBlockedAccount() in @repo/auth/account-state classifies the caller's
own situation from committed local state — never from a provider claim — into
one of unprovisioned, invitation_pending, deactivated, provider_deleted,
reconciliation_required, identity_conflict, or unavailable, and the page
renders wording specific to that state.
Two rules keep it safe. The caller has already authenticated, so describing
their own account is not enumeration; nothing on the page describes any other
account, and an anonymous visitor gets the generic unavailable copy. And an
identity with no local row is never told it was deactivated — there was nothing
to switch off — while unavailable hedges rather than guessing.
Every render also shows a reference code: CF- plus the leading eight hex
characters of the same sha256(clerkId) digest the bootstrap and lifecycle logs
record as providerIdHash. An operator can correlate a quoted code against
those logs without the person sending an email address or a Clerk ID, and the
code carries no account contents and grants nothing.
The User-Owned Half
Clerk owns the sign-in email, password, second factors, and sessions, so the app
does not rebuild those forms. /settings/security renders Clerk's own
<UserProfile/> through @repo/auth/components/account-security; /settings
keeps the display profile Canvas Forge owns and links across to it. The split
follows ownership, so the email field on /settings is read-only and points
somewhere that can actually change it.
We support three primary roles, stored in the users table. Clerk metadata and session claims are not authorization inputs.
| Role | Permissions |
|---|---|
admin | Full access to all team locations, billing, and settings. |
manager | Access to specific locations, reports, and scheduling. |
member | End-user access (staff) to filing reports and viewing schedules. |
Auth Guards
Use getActionContext() for the current source of truth on role + location checks. The raw requireUserAuth() / requireAdmin() shims are still exported from @repo/auth/get-user-auth but are being phased out in favor of the context object, which bundles auth, role, and location scope in one call.
// apps/app/src/lib/action-context.ts
import { getActionContext } from "@/lib/action-context";
export async function adminAction() {
const { auth } = await getActionContext("admin");
// auth.userId, auth.teamId, auth.role, auth.locationIds
// ...
}The four modes behave differently on failure:
| Mode | Failure behavior |
|---|---|
"admin" | requireAdmin() redirects to /403 if the caller isn't an admin. |
"manager" | Throws Error("Insufficient permissions: manager role required"). |
"adminOrManager" | Throws if the caller is neither admin nor manager. |
"member" | requireUserAuth() redirects to /sign-in if the caller is unauthenticated. |
Pick throw-mode helpers ("manager" / "adminOrManager") in API routes and "admin" / "member" in Server Components, where the redirect is the correct response. The matching minRole field on a navigation section is what hides or reveals a sidebar entry. The page-level server guard is the source of truth — the navigation stays in lockstep with it so a manager never sees a link that 403s on click.
Session Data
The get-user-auth.ts helper authenticates through Clerk and then reads the
local users row. Every field below the first comes from that row, not from the
Clerk session:
userId: The Clerk ID — the one provider-owned value here.teamId:users.teamId, the local tenant identifier.role:users.role.locationIds:users.locationIds, the array of authorized locations.