Authentication & Principals
Stale post Layer-1.24 Go cleanup — pending holistic rewrite. The Go-side auth chain was refactored after this page was written:
auth.PrincipalContextis nowprincipal.Subject,ClerkAuthis now the provider-agnosticmiddleware.Authenticate(verifier, resolver, loader),humans.clerk_user_idis nowhumans.provider_subject_id, andhuman.Repositoryis slim (cross-domain reads moved toprincipal.SubjectLoader). The conceptual model below — principals as root identity, JIT provisioning, blocked-user gate — is still accurate; the type/middleware/column names are not. Verify against the code before quoting. Authoritative pointer: decisions.md → Auth chain shape.
How people log in, who they are, and what they're allowed to do. Every actor in the system — human, AI agent, integration service account, scheduled system job — is a row in
principals. Humans additionally have a profile row inhumans(PKprincipal_id). The auth flow described here authenticates humans; non-human principals are provisioned by other paths and use this same identity model.
What this enables
- Patients, specialists, and admins each have their own account with the right level of access
- Users can belong to multiple clinics and switch between them without logging out
- Accounts can be blocked without deleting them — access revoked instantly
- Login, MFA, and password reset are handled by a dedicated, HIPAA-compliant service (Clerk)
- The platform never stores raw passwords or handles session tokens directly
How it works
When someone opens the app, they log in through Clerk — a dedicated authentication service that handles the security-sensitive parts (passwords, MFA, session tokens). Once logged in, Clerk gives the app a verified token that says who this person is.
The app then looks up that person in its own database to find out which clinic they belong to, what their role is, and what they're allowed to do. From that point on, every database query is automatically filtered to their clinic's data only.
1. User logs in → Clerk
2. Clerk verifies identity → returns a session token
3. User sends token with every request
4. Platform verifies token with Clerk → confirms who they are
5. Platform looks up role and clinic membership
6. Database is scoped to that clinic for this entire requestUser roles
Roles are per-organization, not global. The same person can be admin at clinic A, customer_support at clinic B, and patient at clinic C — each membership row in organization_memberships carries its own role_id. The 5 canonical role codes below are seeded as system role templates and cloned into every new clinic on creation.
| Role | Who they are | What they can do |
|---|---|---|
| Patient | End users receiving care | See their own data, book appointments, fill forms, follow treatment plans |
| Specialist | Healthcare providers | See their patients, manage appointments, assign treatment plans |
| Admin | Clinic administrators | Full access to their clinic's data and configuration |
| Customer Support | Support staff | Assist patients across clinics |
| Superadmin | Platform administrators | Cross-tenant operator. Not a tenant role — granted via the platform_roles table, not via a clinic membership. |
For the full per-resource permission matrix (and the catalog of permission codes that drive each role), see RBAC Permissions →.
Multi-Factor Authentication (MFA)
MFA adds a second verification step beyond the password — typically a code from an authenticator app or a passkey. Clerk handles all MFA mechanics; the platform controls who is required to use it.
MFA policy by role
| Role | MFA Required? | Why |
|---|---|---|
| Superadmin | Mandatory | Full platform access across all clinics — highest privilege level |
| Admin | Mandatory | Full access to clinic data, patient records, and configuration |
| Specialist | Mandatory | Access to patient clinical data, treatment plans, and medical records |
| Customer Support | Mandatory | Cross-clinic patient assistance — access to patient data |
| Patient | Recommended, not required | Patients access only their own data. Mandatory MFA would create friction for elderly or less tech-savvy patients, which hurts treatment adherence |
Why staff MFA is mandatory
Every staff role (admin, specialist, customer support, superadmin) has access to patient health data, which is protected under GDPR (and HIPAA for future US clinics). EU and US health data regulations both require strong access controls, and MFA is the baseline expected measure for any system handling patient records. Medical device regulations (EU MDR) also require controlled access to clinical functions.
Why patient MFA is optional
Patients access only their own data — a compromised patient account affects only that one person. Making MFA mandatory for patients would:
- Create a barrier for elderly patients who struggle with authenticator apps
- Reduce treatment plan adherence (patients who can't log in don't do their exercises)
- Add unnecessary friction to the onboarding flow at physical clinics
Instead, patients are encouraged to enable MFA (shown during onboarding and in settings), but it is not enforced. Clinics can choose to make patient MFA mandatory via a clinic-level setting if their security policy requires it.
How it's enforced
- Staff accounts: MFA is required on first login. Users cannot access the Clinic app or Console without completing MFA setup.
- Patient accounts: MFA is offered during onboarding. Patients can skip it but see a periodic reminder in their settings.
- Supported MFA methods: authenticator app (e.g., Google Authenticator, Authy), SMS code, or passkey.
Break-glass access
Emergency access (break-glass) sessions require MFA regardless of role. See the audit trail documentation for break-glass procedures.
Technical Reference
Everything below is intended for developers building on or maintaining the platform.
Architecture
Authentication is split between two systems:
- Clerk — handles signup, login, MFA, password reset, social auth, session management
- Platform DB — stores app-specific data: role, clinic memberships, specialist/patient profile links
The link between them is humans.clerk_user_id — the Clerk external ID stored in the platform's database. Each Clerk identity maps to exactly one principals row (principal_type = 'human') plus its sibling humans profile row.
Organization context & RLS
Multi-clinic isolation is enforced via PostgreSQL session variables set at the start of every request:
set_config('app.current_principal_id', '01928e9a-...', true) -- UUID (FK to principals.id)
set_config('app.current_actor_type', 'human', true) -- 'human' | 'agent' | 'service_account' | 'system'
set_config('app.current_org_id', '01928f12-...', true) -- UUID
set_config('app.current_role', 'specialist', true) -- role code (used only by the public-resolve carve-out)These are transaction-scoped — they apply only to the current request and are cleaned up automatically. All RLS policies on every table reference these variables. IDs are UUIDv7 strings; helpers current_app_principal_id() / current_app_principal_type() / current_app_org_id() return UUID / TEXT / UUID.
Database schema
The auth-related tables live in services/api/migrations/core/000002_tenancy_rbac.up.sql — that file is the source of truth. A short summary of what's there:
principals— root identity row for every actor: humans, AI agents, integration service accounts, scheduled system jobs. Columns:id,principal_type('human' | 'agent' | 'service_account' | 'system'),organization_id(NULL for humans and the singleton system actor; required for non-human tenant-bound actors),parent_principal_id,created_at,deleted_at. Singleton system principal seeded with UUID00000000-0000-0000-0000-000000000001so trigger-driven writes always have a valid actor.humans— human profile, PKprincipal_id(FK toprincipals.id). Linked to Clerk viaclerk_user_id. Carries no authorization state on the row itself; tenant roles live onorganization_memberships, platform-level grants live onplatform_roles. Future sibling tables (agents,service_accounts) follow the same PK pattern.organization_memberships—(principal_id, organization_id, role_id)membership rows; a principal can hold a different role in each org they belong to. A trigger restricts non-human principals to a single membership matching theirprincipals.organization_id.permissions— deploy-time catalog of fine-grained capabilities (organizations.update,appointments.create, etc.).roles— named bundles of permissions, scoped per-org. System role templates live withorganization_id IS NULLand are cloned into every new org on creation.role_permissions— M:M grants of permissions to roles.platform_roles— platform-level grants (e.g.superadmin). Tiny table (<20 rows ever); RLS-enabled with no policies so the restricted app pool can't see it at all. A CHECK constraint (via theprincipal_is_human(uuid)helper) restricts grants to human principals.
Key columns on humans:
principal_id(UUID, PK, FK toprincipals.id) — UUIDv7. Inserts go through an atomic transaction that creates theprincipalsrow first and then thehumansrow using the same id.clerk_user_id(TEXT, UNIQUE, nullable) — link to Clerk's external ID. Nullable so invited humans can exist before signing up.email(TEXT, UNIQUE) /username(TEXT, UNIQUE)current_organization_id(UUID) — the human's last-active clinic context. Cleared automatically by trigger when the matchingorganization_membershipsrow is removed.blocked(BOOLEAN) — when true, all requests are rejected with 403.
Middleware stack
1. ClerkAuth middleware
- Extracts
Authorization: Bearer <token>header - Verifies the token with Clerk
- Loads the internal
humansrow (and the matchingprincipalsrow) from the database (one optimized query with specialist/patient joins) - Sets
PrincipalContextin the request context (withActorType = 'human') - Rejects with 401 if token is missing, invalid, or human is blocked
2. OrganizationContext middleware
- Reads
current_organization_idfrom PrincipalContext - Acquires a dedicated database connection from the pool
- Sets RLS session variables on that connection (including
app.current_principal_idandapp.current_actor_type) - Stores the connection in the request context (all repositories use it for this request)
- Connection is released when the request completes via
defer
3. Repository execution
- All repositories extract the connection via
database.TxFromContext(ctx) - All queries run with RLS session variables already set
- Tenant isolation is guaranteed by PostgreSQL — not by application code
Fail modes
| Condition | Response |
|---|---|
Missing Authorization header | 401 Unauthorized |
| Invalid or expired token | 401 Unauthorized |
| Human is blocked | 403 Forbidden |
| Human has no access to the org | 403 Forbidden |
Human provisioning
The platform does not use Clerk webhooks. Humans are auto-provisioned on first login: the ClerkAuth middleware verifies the JWT, looks up the human by clerk_user_id, and if no row exists, calls Clerk's Backend API once to fetch the email and inserts both rows in a single transaction — first principals (principal_type = 'human'), then humans with the matching principal_id. Subsequent requests hit the cached row.
If the request that triggers provisioning carries an X-Organization-ID header (set by the patient portal proxy from the slug subdomain), the new principal is also auto-linked to that org with role=patient — so signing up at <slug>.portal.localhost lands directly on a populated dashboard. Clinic and Console never carry that header for unprovisioned users (clinic sign-up is disabled; console is superadmin-only), so the auto-link only fires for portal signups.
Connection pool consideration
Each request holds a database connection for its entire duration (required for RLS session variables). DB_POOL_MAX must be >= the expected peak concurrent requests. Monitor pool wait time — if it rises above 0 for more than a minute, the pool is undersized.
Usage in handlers
func (h *Handler) MyEndpoint(w http.ResponseWriter, r *http.Request) {
principal := auth.PrincipalFromContext(r.Context())
// principal.PrincipalID uuid.UUID — FK to principals.id
// principal.ActorType string — 'human' | 'agent' | 'service_account' | 'system'
// principal.IsSuperadmin bool — derived from platform_roles (humans only)
// principal.CurrentOrganizationID uuid.UUID — the org this request is scoped to
// principal.CurrentRoleCode string — the role held in that org
// principal.HasPermission("appointments.create") — canonical permission check
}Implementation files
internal/core/
├── middleware/
│ ├── auth.go # ClerkAuth middleware
│ └── organization.go # OrganizationContext middleware + RLS
├── domain/human/
│ ├── model.go # Human, HumanRole types
│ ├── repository.go # DB queries (atomic principals + humans inserts)
│ ├── service.go # Business logic
│ ├── handler.go # HTTP handlers (/v1/me, webhook)
│ └── errors.go # Domain errors
└── integration/clerk/
└── client.go # Clerk API client