Skip to content

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.PrincipalContext is now principal.Subject, ClerkAuth is now the provider-agnostic middleware.Authenticate(verifier, resolver, loader), humans.clerk_user_id is now humans.provider_subject_id, and human.Repository is slim (cross-domain reads moved to principal.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 in humans (PK principal_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 request

User 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.

RoleWho they areWhat they can do
PatientEnd users receiving careSee their own data, book appointments, fill forms, follow treatment plans
SpecialistHealthcare providersSee their patients, manage appointments, assign treatment plans
AdminClinic administratorsFull access to their clinic's data and configuration
Customer SupportSupport staffAssist patients across clinics
SuperadminPlatform administratorsCross-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

RoleMFA Required?Why
SuperadminMandatoryFull platform access across all clinics — highest privilege level
AdminMandatoryFull access to clinic data, patient records, and configuration
SpecialistMandatoryAccess to patient clinical data, treatment plans, and medical records
Customer SupportMandatoryCross-clinic patient assistance — access to patient data
PatientRecommended, not requiredPatients 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:

sql
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.sqlthat 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 UUID 00000000-0000-0000-0000-000000000001 so trigger-driven writes always have a valid actor.
  • humans — human profile, PK principal_id (FK to principals.id). Linked to Clerk via clerk_user_id. Carries no authorization state on the row itself; tenant roles live on organization_memberships, platform-level grants live on platform_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 their principals.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 with organization_id IS NULL and 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 the principal_is_human(uuid) helper) restricts grants to human principals.

Key columns on humans:

  • principal_id (UUID, PK, FK to principals.id) — UUIDv7. Inserts go through an atomic transaction that creates the principals row first and then the humans row 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 matching organization_memberships row 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 humans row (and the matching principals row) from the database (one optimized query with specialist/patient joins)
  • Sets PrincipalContext in the request context (with ActorType = 'human')
  • Rejects with 401 if token is missing, invalid, or human is blocked

2. OrganizationContext middleware

  • Reads current_organization_id from PrincipalContext
  • Acquires a dedicated database connection from the pool
  • Sets RLS session variables on that connection (including app.current_principal_id and app.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

ConditionResponse
Missing Authorization header401 Unauthorized
Invalid or expired token401 Unauthorized
Human is blocked403 Forbidden
Human has no access to the org403 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

go
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