Organizations Feature
A multi-tenant container for clinic/practice data, branding, and integrations.
What this enables
Multi-clinic operations: Specialists and administrators can hold staff memberships at multiple clinics from a single platform identity, switching between them instantly.
Brand control: Each organization has its own logo, colors, and dedicated domains (e.g., healthcorp.clinic.restartix.pro for staff, healthcorp.portal.restartix.pro for patients). Custom domains are also supported (e.g., portal.myclinic.com). White-labeling is universal — every clinic gets it; it is not a tier upsell and not a synonym for a tenancy mode.
Data isolation: Clinical records, consents, programs, sessions, and settings are completely separate per organization — accidentally mixing clinics is impossible. The one deliberate exception is the patient's portable profile (patient_profiles): demographics, blood type, allergies, and insurance travel with the patient across clinics, so they are not re-entered at each one. A clinic reads it because the patient is registered there, and a clinic they are not registered at reads nothing. See Patients →.
Integration storage: Securely store credentials for external services the clinic owns (Cat B Connected Accounts) without exposing secrets in code.
Platform model
RestartiX is a curated platform — clinics cannot self-register. Every organization is created and onboarded by the platform team (superadmin). This ensures quality control, proper configuration, and compliance vetting before a clinic goes live. Self-service organization creation may be added in the future, but is explicitly out of scope for now.
How it works
- Superadmin creates organization: A platform operator (superadmin) creates the clinic/practice via
POST /v1/organizations. A database trigger (create_organization_companion_rows) clones the system role templates into the new org and seeds its settings / billing / entitlements / legal-document rows in the same transaction. - Staff are added: An org admin invites staff via
POST /v1/organizations/{id}/staff-invitations(the onboarding path for someone who has never signed in), or attaches an existing platform identity viaPOST /v1/organizations/{id}/members(email + role code). Patients never appear here — they are not memberships and have no role. See Why patients are not memberships. - Set organization context: All data operations work within that organization's boundary — determined by the domain the user visits.
- Switch organizations (optional): If they hold memberships at multiple clinics, they switch by navigating to the other clinic's hostname. The clinic app's
TeamSwitcherdoes a full-page redirect to{slug}.{CLINIC_DOMAIN}.
Technical Reference
Overview
Organizations are the root of multi-tenancy. Every piece of tenant-scoped data belongs to an organization. A principal can hold staff memberships in multiple organizations and switch between them.
Key Principle: An external identity provider handles authentication (login, MFA, sessions — Clerk today; the verifier package is provider-agnostic). Organizations handle tenancy (data isolation, access control).
Key Concepts
The actor model, in one paragraph
There is no users table. Every actor — human, AI agent, integration service account, and the singleton system job — is a row in principals. Human profiles live in humans (PK principal_id, FK to principals(id)). Staff membership in an organization is a row in organization_memberships (principal_id, organization_id, role_id, is_owner, …). Patients are a separate axis entirely: a portable patient_profiles row plus a per-org patients row. See Actor model →.
Organizations as Multi-Tenancy Root
Every request operates within an organization context determined by the domain:
- User visits
{slug}.clinic.restartix.proor a custom domain - Frontend proxy resolves the hostname → organization via
GET /v1/public/organizations/resolve?slug={slug}(or?domain={hostname}) - Organization ID is passed to the API via the
X-Organization-IDheader - The
OrganizationContextmiddleware binds the transaction's RLS session variables through aSECURITY DEFINERwrapper - All queries are automatically filtered by
organization_idvia Row-Level Security (RLS)
The Golden Rule: WHERE organization_id = current_app_org_id() — direct column check, no sub-queries. Only the identity tables (principals, humans) need a membership sub-query, because a principal is not owned by any single org.
Organization Slug
Every organization has a unique slug (URL-safe identifier) used for subdomain routing:
- Clinic app:
{slug}.clinic.restartix.pro(e.g.,healthcorp.clinic.restartix.pro) - Patient Portal:
{slug}.portal.restartix.pro(e.g.,healthcorp.portal.restartix.pro) - Custom domains: Organizations can optionally configure their own domains. Portal-type custom domains are shipped; clinic-type custom domains are refused at the API until the edge dispatcher that routes a custom hostname to the clinic app exists (see custom-domains.md).
- Public resolution:
GET /v1/public/organizations/resolve?slug={slug}(no auth required, per-IP rate limited)
Example slugs: restartix, healthcorp, medcenter-amsterdam
Organization Switching
Staff who hold memberships at multiple organizations switch by navigating to the other organization's hostname. The TeamSwitcher component (apps/clinic/components/team-switcher.tsx) renders from the memberships array on GET /v1/me and calls window.location.assign() on {slug}.{CLINIC_DOMAIN} — a full page navigation, not an API call.
There is no
PUT /v1/me/switch-organizationendpoint. Older docs describe one; it is not inroutes.goand never shipped. Active-org selection is carried entirely by the hostname. There is also no cached "current organization" column onhumans— default-org-on-first-sign-in is derived fromMAX(last_used_at)acrossorganization_memberships(staff) andpatients(patient).
Use cases:
- Specialist working for multiple clinics
- Admin managing multiple organizations
- Support staff with cross-org access
Connected Accounts (per-org external credentials)
Per-org encrypted credentials for services the clinic owns live in organization_integrations, against a platform catalog in integration_services (migration 000017, foundation 1C.5, Cat B in the glossary). Credentials are encrypted at the application layer (AES-256-GCM via internal/core/crypto) into credentials_encrypted BYTEA.
The framework shipped; the catalog is seeded empty. Each F-tier consumer adds its integration_services row plus the connector implementation in the same PR. The OAuth callback handler (state-token CSRF, code→token exchange, refresh worker) is deferred to the first OAuth-using consumer — the service rejects auth_type = 'oauth2' from the create endpoint today.
Database Schema
Tables
organizations
The root table for multi-tenancy. Defined in services/api/migrations/core/000002_tenancy_rbac.up.sql.
| Column | Type | Description |
|---|---|---|
id | UUID PRIMARY KEY DEFAULT gen_random_uuid() | Primary key |
name | TEXT NOT NULL | Organization name (e.g., "RestartiX") |
slug | TEXT NOT NULL UNIQUE | URL-safe identifier |
tagline | TEXT | Short description |
description | TEXT | Full description |
email | TEXT | Published contact email (public-class; not the billing contact) |
phone | TEXT | Published contact phone |
website | TEXT | Website URL |
location | TEXT | Physical location |
logo_url | TEXT | Logo image URL (Bunny CDN) |
icon_url | TEXT | Icon/favicon URL (Bunny CDN) |
language_code | TEXT NOT NULL DEFAULT 'en' | Default UI language (ISO 639-1, e.g. en, ro) |
portal_self_signup_enabled | BOOLEAN NOT NULL DEFAULT FALSE | Per-clinic toggle for portal walk-up signup; surfaced through the public resolve endpoint |
branding | JSONB NOT NULL DEFAULT '{}' | White-label payload (colors, theme mode, …). JSONB because it's read as a whole blob and never queried per-field |
tenancy_mode | TEXT NOT NULL DEFAULT 'shared' CHECK IN (shared,dedicated) | Tenancy posture. shared is the only sellable mode today; dedicated is a schema reservation — see tenant-isolation.md |
activated_at | TIMESTAMPTZ | NULL = draft (not routable from public endpoints); non-NULL = active |
created_at | TIMESTAMPTZ NOT NULL DEFAULT NOW() | Creation timestamp |
updated_at | TIMESTAMPTZ NOT NULL DEFAULT NOW() | Last update timestamp (auto-updated by the set_updated_at trigger) |
Companion rows (organization_settings, organization_billing, organization_entitlements, organization_legal_documents, the cloned role set, the Free-plan subscription) are all created by the create_organization_companion_rows trigger on INSERT. That function was introduced in 000003 and is redefined via CREATE OR REPLACE in 000009 — edit it there.
organization_domains
Stores additional verified hostnames for an org (custom domains beyond {slug}.clinic.restartix.pro). Also in 000002_tenancy_rbac.up.sql.
Bring-your-own domains are provisioned through Cloudflare for SaaS: the platform registers the hostname with Cloudflare, the clinic points a CNAME at the SaaS target, Cloudflare validates ownership and issues the edge certificate, and status flips pending → verified to gate traffic routing.
| Column | Type | Description |
|---|---|---|
id | UUID PRIMARY KEY DEFAULT gen_random_uuid() | Primary key |
organization_id | UUID NOT NULL | FK to organizations (CASCADE) |
domain | TEXT NOT NULL UNIQUE | Hostname (lowercased, trimmed) |
domain_type | domain_type enum | clinic or portal (clinic is currently refused by the service — see above) |
status | domain_status enum | pending / verified / failed |
cloudflare_hostname_id | TEXT | CF-assigned custom-hostname UUID, used for status polling and deregistration |
ssl_status | TEXT | Mirrors the CF certificate sub-state for admin-UI surfacing |
verification_token | TEXT NOT NULL | Predates the CF flow (legacy DNS-TXT ownership check). Retained, but no longer the routing gate |
verified_at | TIMESTAMPTZ | When the hostname first reached verified |
last_check_at | TIMESTAMPTZ | Most recent re-verification attempt |
created_at | TIMESTAMPTZ NOT NULL DEFAULT NOW() | Creation timestamp |
updated_at | TIMESTAMPTZ NOT NULL DEFAULT NOW() | Last update timestamp (auto-updated) |
organization_memberships
Staff membership + per-org role assignment. This is the only per-org membership table. It replaced the earlier principal_organizations naming, and the never-shipped user_organizations shape that older revisions of this doc described. Also in 000002_tenancy_rbac.up.sql.
CREATE TABLE organization_memberships (
principal_id UUID NOT NULL REFERENCES principals(id) ON DELETE CASCADE,
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
role_id UUID NOT NULL REFERENCES roles(id),
is_owner BOOLEAN NOT NULL DEFAULT FALSE,
last_used_at TIMESTAMPTZ,
invited_at TIMESTAMPTZ,
invited_by UUID REFERENCES principals(id) ON DELETE SET NULL,
accepted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (principal_id, organization_id)
);Load-bearing details:
principal_id, not a user id. The column FKs toprincipals(id)so the table works for any non-patient actor type (agents and service accounts as those features ship), not just humans.- A principal has exactly one role per org (composite PK). Different roles in different orgs is the normal case.
is_owneris a flag, not a role. The owner still carries arole_id(typically the systemadminclone) so ordinary RLS works through the standard (principal, org, role) → permissions chain. Override authority comes fromcurrent_app_is_owner()in RLS andSubject.IsOwnershort-circuitingRequirePermission(...)in middleware. A partial unique indexuniq_owner_per_orgenforces at most one owner per org; the org-creation flow guarantees at least one.- The owner row is protected by trigger.
protect_owner_membershipblocks DELETE of an owner row and blocks theis_owner TRUE → FALSEflip unless the transaction setsapp.allow_owner_demote = 'true'(the ownership-transfer flow does). - Non-human principals are single-org, enforced by the
enforce_single_membership_for_non_humanstrigger againstagents.organization_id/service_accounts.organization_id. - Granting
superadminwipes tenant memberships.clear_organization_memberships_on_superadmin_grantfiresAFTER INSERT ON platform_membershipsand deletes the principal'sorganization_membershipsrows, emitting one audit row per deletion.
Use cases:
- Multi-clinic specialists
- Consultants working across organizations
- Support staff with cross-org access
(Patients switching providers is not one of them — that's a second patients row, not a second membership.)
Indexes
CREATE INDEX idx_organizations_slug ON organizations(slug);
CREATE INDEX idx_organizations_draft ON organizations(id) WHERE activated_at IS NULL;
CREATE INDEX idx_org_domains_org ON organization_domains(organization_id);
CREATE INDEX idx_org_domains_domain ON organization_domains(domain);
CREATE INDEX idx_org_domains_verified ON organization_domains(status) WHERE status = 'verified';
CREATE INDEX idx_org_memberships_org ON organization_memberships(organization_id);
CREATE INDEX idx_org_memberships_principal ON organization_memberships(principal_id);
CREATE INDEX idx_org_memberships_role ON organization_memberships(role_id);
CREATE UNIQUE INDEX uniq_owner_per_org ON organization_memberships(organization_id) WHERE is_owner = TRUE;Uniqueness on organization_domains.domain comes from the table-level UNIQUE constraint, not from idx_org_domains_domain (which is a plain lookup index).
Every organization_id column across the schema has an index for fast RLS checks.
Row-Level Security (RLS)
RLS policies enforce organization boundaries at the database level. Superadmins bypass RLS by connecting through the AdminPool (restartix owner role); tenant traffic runs on the AppPool (restartix_app, which does not bypass RLS).
organizations
| Policy | Operation | Rule |
|---|---|---|
organizations_select | SELECT | id = current_app_org_id() |
organizations_insert | INSERT | WITH CHECK (FALSE) — superadmin-only via AdminPool |
organizations_update | UPDATE | id = current_app_org_id() AND current_app_has_permission('organizations','update') (same expression in USING and WITH CHECK) |
organizations_delete | DELETE | USING (FALSE) — superadmin-only via AdminPool |
There is no public SELECT policy. The public hostname-resolve endpoint runs on AdminPool with a single-row equality match on slug or verified domain. An AppPool connection with no session vars intentionally sees zero organization rows — an earlier broad current_app_role() IS NULL policy made every clinic on the platform listable to any unauthenticated caller, and was removed.
organization_domains
| Policy | Operation | Rule |
|---|---|---|
org_domains_select | SELECT | organization_id = current_app_org_id() |
org_domains_insert/update/delete | INSERT/UPDATE/DELETE | organization_id = current_app_org_id() AND current_app_has_permission('organizations','manage_domains') |
Same note as above: no public carve-out. Public domain resolution runs on AdminPool.
organization_memberships
| Policy | Operation | Rule |
|---|---|---|
org_memberships_select | SELECT | organization_id = current_app_org_id() |
org_memberships_insert/update/delete | INSERT/UPDATE/DELETE | organization_id = current_app_org_id() AND current_app_has_permission('organizations','manage_members') |
How RLS Works
Every authenticated request runs inside an explicit pgx.Tx. Middleware calls a SECURITY DEFINER wrapper on that transaction; the wrapper validates the tuple and then binds the session variables itself. Application code never calls set_config('app.*', …) directly — the app.* namespace is not grantable to restartix_app.
-- Authenticated, no org chosen yet (e.g. listing the caller's memberships):
SELECT set_app_principal('0190af3b-1c2e-7c00-8a4f-b2d9c4e5f001');
-- Staff request scoped to an org. Validates (principal, org, role) against
-- organization_memberships ⨝ roles before setting anything:
SELECT set_app_staff_context(
'0190af3b-1c2e-7c00-8a4f-b2d9c4e5f001', -- principal_id (UUID)
'0190af3b-1c2e-7c00-8a4f-b2d9c4e5f100', -- organization_id (UUID)
'specialist' -- role code
);
-- Patient sessions use set_app_patient_context (defined in 000006 —
-- it joins patients / patient_profiles / patient_caregivers).The session variables those wrappers bind are:
| Session var | Reader helper | Notes |
|---|---|---|
app.current_principal_id | current_app_principal_id() | UUID. Never an integer. There is no app.current_user_id. |
app.current_actor_type | current_app_principal_type() | Read from principals.principal_type by the wrapper — middleware can't lie about it |
app.current_org_id | current_app_org_id() | UUID |
app.current_role | current_app_role() | The role code, carried for diagnostics. Not the authorization primitive |
All are set with set_config(..., true) — transaction-scoped, wiped at COMMIT/ROLLBACK by Postgres. Connection lifetime and authorization lifetime never have to match; a pooled connection carries no GUCs into the next request. The wrappers additionally lock-once on principal_id: within a transaction they refuse to re-bind to a different principal, so an SQLi attacker inside a request cannot re-scope it.
Authorization itself is always a permission check, never a role-string compare:
CREATE POLICY organizations_update ON organizations FOR UPDATE USING (
id = current_app_org_id()
AND current_app_has_permission('organizations', 'update')
) WITH CHECK (
id = current_app_org_id()
AND current_app_has_permission('organizations', 'update')
);current_app_has_permission(resource, action) is SECURITY DEFINER + STABLE. It short-circuits TRUE for the org's owner (current_app_is_owner()), then joins organization_memberships → role_permissions → permissions, and AND's the result with principal_is_active(...) so a blocked or soft-deleted principal is denied regardless of granted role.
The full helper set available to policies: current_app_principal_id(), current_app_principal_type(), current_app_org_id(), current_app_role(), current_app_has_permission(), current_app_is_owner(), current_app_has_patient_access(), current_app_has_org_entitlement(), current_app_break_glass_id(), current_app_impersonation_id(), current_app_action_context(), current_app_ip(), current_app_request_id() / _method() / _path(), current_app_user_agent(), current_human_patient_profile_ids(), current_human_is_patient_at(). There is no current_app_user_id(), no is_superadmin() (superadmins bypass RLS via AdminPool), and no current_app_location_ids() — per-location scoping was deliberately not added (000014_locations.up.sql:144).
Result: callers on the AppPool see only data for their current organization, automatically enforced by PostgreSQL.
See architecture/data-model.md → Area 1 (Foundation) for the canonical schema.
API Reference
Endpoints
The organization identity, membership, and domain surface, with gates listed verbatim as enforced by RequirePermission / RequireSuperadmin / RequirePerOrgPermissionOrBreakGlass in services/api/internal/core/server/routes.go:
| Method | Endpoint | Auth | Gate |
|---|---|---|---|
| GET | /v1/public/organizations/resolve?slug=… or ?domain=… | None | — (per-IP rate limit RATELIMIT_PUBLIC_RESOLVE_*, default 30/min) |
| GET | /v1/organizations | Bearer | superadmin |
| POST | /v1/organizations | Bearer | superadmin |
| GET | /v1/organizations/{id} | Bearer | — (RLS scopes to the caller's org) |
| PATCH | /v1/organizations/{id} | Bearer | organizations.update or org_management break-glass |
| POST | /v1/organizations/{id}/branding/{asset} | Bearer | organizations.update or org_management break-glass (asset ∈ logo, icon) |
| GET | /v1/organizations/{id}/members | Bearer | organizations.manage_members |
| POST | /v1/organizations/{id}/members | Bearer | organizations.manage_members or break-glass (upsert: re-roles existing members) |
| DELETE | /v1/organizations/{id}/members/{principalId} | Bearer | organizations.manage_members or break-glass |
| GET | /v1/organizations/{id}/roles | Bearer | organizations.manage_members |
| POST | /v1/organizations/{id}/staff-invitations | Bearer | organizations.manage_members or break-glass |
| GET | /v1/organizations/{id}/staff-invitations | Bearer | organizations.manage_members |
| GET | /v1/organizations/{id}/domains | Bearer | organizations.manage_domains |
| POST | /v1/organizations/{id}/domains | Bearer | organizations.manage_domains + custom_domain tier entitlement |
| POST | /v1/organizations/{id}/domains/{domainId}/verify | Bearer | organizations.manage_domains |
| DELETE | /v1/organizations/{id}/domains/{domainId} | Bearer | organizations.manage_domains |
| GET | /v1/organizations/{id}/settings | Bearer | — (membership-gated by RLS) |
| PATCH | /v1/organizations/{id}/settings | Bearer | organizations.update_settings |
| GET / PATCH | /v1/organizations/{id}/billing | Bearer | organizations.manage_billing |
| GET / PUT / DELETE | /v1/organizations/{id}/designations[/{kind}] | Bearer | read via RLS (view_directory); write organizations.manage_designations |
| POST / GET / DELETE | /v1/organizations/{id}/ownership-transfers | Bearer | RequireOwner() for initiate + cancel; pending is read-only |
| POST | /v1/organizations/{id}/owner/resend-welcome | Bearer | superadmin |
Every route under /v1/organizations/{id} inherits middleware.RequireURLOrgMatchesScope("id") (P47) at the route-group level — the URL {id} must match the caller's resolved org scope, or the request is refused before the handler runs. Superadmins bypass. This is the guard that makes URL-keyed caching safe; without it RLS would hide one mismatched response and the cache would propagate it.
Other route families are also mounted under /v1/organizations/{id} — patients, patient-tiers, patient-subscriptions, subscriptions, locations, consents, access-offers, outbound-webhook-subscriptions, integrations, legal-documents, share-links, patient-invitations, impersonation sessions, stats, edit locks. Those are documented in their own feature docs; routes.go is authoritative for the complete list.
Listing members reuses organizations.manage_members (there is no separate "view members" permission). All gated routes also enforce the same permission code via current_app_has_permission(...) in RLS — the Go middleware is a fast-fail layer, the database is the authoritative one.
The full set of organizations.* permission codes seeded across migrations: update, update_settings, view_directory, manage_members, manage_domains, manage_billing, manage_designations, manage_integrations, manage_webhooks, manage_share_links, manage_privacy_notice, transfer_ownership. See rbac-permissions.md.
Examples
All identifiers are UUIDs (UUIDv7 per P26); the platform has no integer IDs anywhere on the wire.
Resolve organization by slug (public, for domain routing)
GET /v1/public/organizations/resolve?slug=restartix
Response:
{
"data": {
"id": "0190af3b-1c2e-7c00-8a4f-b2d9c4e5f100",
"name": "RestartiX",
"slug": "restartix",
"tagline": "…",
"description": "…",
"email": "contact@restartix.ro",
"phone": "+40…",
"website": "https://restartix.ro",
"location": "Bucharest",
"logo_url": "https://cdn.../logo.png",
"icon_url": "https://cdn.../icon.png",
"language_code": "ro",
"portal_self_signup_enabled": false,
"branding": { }
}
}Every field here is public-class per data-classification.md → organizations. email/phone are the clinic's published contact (the kind that goes on a landing page), not the internal billing contact — that lives on organization_billing and is admin-only.
List organizations (superadmin)
GET /v1/organizations
Authorization: Bearer <token>
Response:
{
"data": [
{ "id": "0190af3b-1c2e-7c00-8a4f-b2d9c4e5f100", "name": "RestartiX", "slug": "restartix" },
{ "id": "0190af3b-1c2e-7c00-8a4f-b2d9c4e5f200", "name": "HealthCorp", "slug": "healthcorp" }
]
}Listing is superadmin-only. The RLS policy on organizations is id = current_app_org_id(), so a tenant request would see at most one row — not a useful "list" semantic. Tenant staff get their own org list from the memberships array on GET /v1/me (org id + name + slug, enough for the sidebar switcher).
Update organization
Requires organizations.update (or an org_management break-glass session). Method is PATCH, not PUT — only fields present in the body are updated.
PATCH /v1/organizations/0190af3b-1c2e-7c00-8a4f-b2d9c4e5f100
Authorization: Bearer <token>
Content-Type: application/json
{
"name": "RestartiX NL",
"email": "info@restartix.nl"
}
Response:
{
"data": {
"id": "0190af3b-1c2e-7c00-8a4f-b2d9c4e5f100",
"name": "RestartiX NL",
"slug": "restartix",
"email": "info@restartix.nl",
"updated_at": "2026-04-26T14:22:00Z"
}
}Add a member
Requires organizations.manage_members. The endpoint is upsert-style: if the principal is already a member, their role is re-assigned. The role value must match a role code that exists in this org — the cloned system templates are admin, specialist, customer_support. There is no patient role: patients are not memberships.
Two additional refusals worth knowing:
- 404
user_not_foundif nohumansrow has that email. This endpoint attaches an existing platform identity; to onboard someone who has never signed in, usePOST /v1/organizations/{id}/staff-invitations. - 400
superadmin_not_assignableif the target holds a platform superadmin grant — superadmins operate at the platform level and are not assigned to individual organizations.
POST /v1/organizations/0190af3b-1c2e-7c00-8a4f-b2d9c4e5f100/members
Authorization: Bearer <token>
Content-Type: application/json
{
"email": "specialist@example.clinic",
"role": "specialist"
}
Response:
{
"data": {
"principal_id": "0190af3b-1c2e-7c00-8a4f-b2d9c4e5f300",
"email": "specialist@example.clinic",
"organization_id": "0190af3b-1c2e-7c00-8a4f-b2d9c4e5f100",
"role_id": "0190af3b-1c2e-7c00-8a4f-b2d9c4e5f400",
"role_code": "specialist"
}
}The audit row uses entity_type = "organization_membership" with action CREATE for new memberships and UPDATE for role changes; a no-op upsert emits nothing. The same mutations publish organization.member_added / organization.member_role_changed / organization.member_removed on the internal event bus (Cat E).
See api.md for the full endpoint reference (members, domains, request/response shapes, error codes).
Related Features
Authentication
- An external identity provider handles authentication (login, signup, MFA, sessions) — Clerk today; the verifier package in
internal/core/authis provider-agnostic and the binding column ishumans.provider_subject_id. - A principal can hold staff memberships in multiple organizations.
- Authorization is per-org permission codes, resolved through
organization_memberships → roles → role_permissions → permissions. The four canonical staff role codes (specialist,customer_support,admin, plus platform-levelsuperadmin) are rows, not an enum, so custom per-org roles need no schema change.
See ../auth/index.md for authentication documentation.
Connected Accounts / Integrations
Per-org connections to external services live in organization_integrations, keyed to a platform catalog row in integration_services (migration 000017). Each connection stores:
integration_service_id— which catalog entry (Google Calendar, Slack, an EHR, …)auth_type—oauth2|api_key|webhook_in_onlyexternal_account_id+title— the third-party-side identity and a human labelcredentials_encrypted BYTEA— AES-256-GCM sealed viainternal/core/cryptoconfig JSONB— plaintext non-secret connector configurationinbound_token/inbound_signing_secret_encrypted— the Cat D inbound-webhook endpoint, provisioned on demand
The catalog ships empty; each F-tier consumer seeds its own row alongside the connector implementation. All routes gate on organizations.manage_integrations.
integration_services(the catalog of Cat B services we connect to) is a different table fromservice_accounts(principals that authenticate into our API, Cat F). The word "service" collides; the concepts don't. See the glossary.
See api-keys.md for encryption details.
Organization-Scoped Data
Every tenant-scoped table carries organization_id UUID NOT NULL REFERENCES organizations(id). As of migration 000039, 66 of the 109 tables declare the column; 43 do not (see database-overview → Multi-tenancy for the five scoping shapes the other 43 use). A meaningful number leave it nullable on purpose — roles for the NULL-org system templates, exercises / session_exercises / program_phases for platform-tier content per P49, consents for platform-level consent rows. A representative slice:
| Table | Description |
|---|---|
organization_memberships | Staff membership + per-org role |
organization_settings | Operational + compliance knobs, feature flags |
organization_billing | Billing contact, tax data, current plan pointer |
organization_entitlements | Per-org entitlement overrides |
organization_domains | Custom hostnames |
organization_designations | DPO / billing-contact assignments |
organization_legal_documents | Per-org ToS + privacy notice editor state |
roles | Per-org role clones (+ NULL-org system templates) |
patients | Per-org link to a portable patient_profiles row |
consents | Per-clinic consent grants |
locations | Clinic sites |
exercises, sessions, programs, protocols | Clinical content |
session_runs, session_exercises, session_pairings | Session execution + telemetry |
patient_tiers, patient_subscriptions, organization_subscriptions | Commerce |
access_offers + access_offer_* | F14 commerce/campaign access grants |
outbound_webhook_subscriptions | Cat C outbound webhooks |
organization_integrations | Cat B connected accounts |
break_glass_sessions | Cross-tenant access sessions (always audited) |
audit_log | Audit events (range-partitioned monthly, P41) |
All are filtered by RLS on organization_id = current_app_org_id().
Tables named in older revisions of this doc —
specialties,specialists,appointments,form_templates,forms,custom_fields,custom_field_values,appointment_templates,segments— do not exist. F1 Specialists, F3 Forms, F4 Scheduling, F5 Appointments, and F6 Documents are scaffold-only. F8 Segments is out of scope. See platform-completion.md for what's planned and data-model.md for the designed-not-built schema.
Organization Lifecycle Workflows
Not built. There is no automations domain in the API and no automation-rule table in any migration. The event names described in the Automations spec (patient.onboarded, appointment.first_booked, service_plan.enrolled) are design, not code.
What does exist today for lifecycle reactions:
- The internal event bus (
internal/core/events, Cat E) with a code-defined registry. Organization events areorganization.created,organization.updated,organization.member_added,organization.member_role_changed,organization.member_removed. - Cat C outbound webhook subscriptions (
outbound_webhook_subscriptions), which let a clinic POST those events to their own URL (Make.com, Zapier, n8n). - The
create_organization_companion_rowstrigger, which seeds every per-org companion row at org creation.
Consent requirements are enforced by the consents domain (RequireCurrentConsents middleware + consent_purpose_versions), not by an automation engine.
See:
- Automations feature — the spec for the unbuilt workflow system
- Patient onboarding
- Forms feature — also unbuilt (F3)
- GDPR compliance
Design Principles
1. organization_id on Every Tenant-Scoped Table
Why: Direct RLS checks, no sub-queries, maximum performance.
Exceptions:
principals/humanshave noorganization_id— a principal is not owned by any single org. Tenant binding for humans isorganization_memberships(staff) andpatients(patient). Non-human actors do get a direct binding, but on their actor-type child table (agents.organization_id,service_accounts.organization_id, both NOT NULL), so the column shape matches the actor's nature.patient_profilesandpatient_caregivershave noorganization_id— they are patient-owned portable data that travels across orgs.patient_profilesRLS keys oncurrent_human_patient_profile_ids()instead, plus a staff branch requiring apatientsrow at the current org. That row is the whole boundary — there is no second per-field gate above it. See Patients →.rolesallowsorganization_id IS NULLfor the system templates that get cloned into each new org.
2. Clean Table Naming
Why: Human-readable, predictable, easy to understand.
- ✓
organizations,principals,humans,organization_memberships - ✗
auth_organizations,system_users,appt_v2
Column names use the long form organization_id everywhere in DDL. The short org_id is reserved for wire forms — JWT claims, log keys, query params, S3 path templates, Postgres session-var names (app.current_org_id). See glossary → Naming conventions.
3. Explicit Foreign Keys with ON DELETE Behavior
Why: Referential integrity, no orphaned records, clear cascade behavior.
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADEThe cascade is a schema-integrity guarantee, not an operational workflow: organizations_delete is USING (FALSE), there is no DELETE /v1/organizations/{id} route, and patient data is soft-deleted, never hard-deleted.
4. Encryption Only Where Classification Requires It
Column-level encryption is reserved for two classes: auth_secret (credentials, API keys, signing secrets) and pii_regulated (national IDs — CNP, SSN, passport). Everything else — names, emails, phones, addresses, allergies, diagnoses — is plaintext plus layered defense (RLS + audit + at-rest disk encryption + encrypted backups + restricted DB access). The rule is mechanically enforced by cmd/check-classification in make check.
Phone numbers are explicitly not encrypted: partial and last-N-digit search is a required clinic feature, and random-nonce AES-GCM makes it impossible. See decisions.md → Why most PII is plaintext.
5. Timestamps Where They Mean Something
Why: Audit trail, debugging, analytics.
Mutable state tables carry created_at (set once on INSERT) and updated_at (bumped by the set_updated_at trigger). Append-only registries carry only created_at — principals, for example, has created_at + deleted_at and no updated_at, because there's nothing to update on an identity row.
6. UUIDv7 Primary Keys Everywhere
Why: No enumeration, no coordination on insert, and — unlike UUIDv4 — time-ordered so B-tree inserts stay sequential.
Every PK on the platform is a UUID. There are no bigserial / integer surrogate keys and no separate public/internal ID pair. See P26.
Organizations additionally expose slug for human-friendly hostnames, but id is the identifier on the wire.
7. Own Organizations, Not Provider Orgs
Why: The identity provider handles auth only. Organizations are a core domain concept with business logic (integrations, settings, branding, billing, entitlements).
Our organizations table is the source of truth. humans.provider_org_id is reserved for the future dedicated tenancy mode, where each tenant gets its own provider-side organization; it is NULL for every shared-mode tenant today.
8. Append-Only Local Audit
Why: Forensic integrity. All mutations write synchronously to the local audit_log, which has an INSERT policy and no UPDATE/DELETE policies, with mutating grants revoked from restartix_app. The table is range-partitioned monthly (P41).
Audit rows are written through one canonical writer (audit_log_insert), so handler-emitted rows, trigger-cascaded rows, and any SECURITY DEFINER caller all share the same per-request envelope (request id, IP, path, method, user agent, action context) bound by set_app_request_envelope.
Architecture Decisions
Why Not Use the Identity Provider's Organizations?
Clerk offers an organizations feature, but we build our own:
- Data ownership — organizations are a core domain entity with complex business logic
- Integration data — encrypted credentials, settings, branding, entitlements
- Compliance — GDPR (day-one requirement) needs full control over data access and audit trail
- Flexibility — extend organizations with designations, tiers, locations, billing
- RLS integration — direct PostgreSQL integration for row-level security
The provider handles authentication; our organizations handle tenancy.
Why Denormalize organization_id?
Many tables carry organization_id even when it could be derived via JOIN:
-- patient_tier_versions could derive its org from patient_tiers.organization_id,
-- but stores it directly so the RLS predicate is a plain column check.
CREATE TABLE patient_tier_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tier_id UUID NOT NULL REFERENCES patient_tiers(id) ON DELETE CASCADE,
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, -- denormalized
version INT NOT NULL,
...
);(A few child tables leave the denormalized column nullable on purpose — session_exercises.organization_id and program_phases.organization_id are NULL for platform-tier content that belongs to no org, per P49. That's a deliberate carve-out for platform-owned rows, not an exemption from the rule.)
Why:
- RLS performance — a direct column check beats a sub-query on every row
- Index usage — PostgreSQL uses the
organization_idindex directly - Simplicity — no nested logic in RLS policies
Trade-off: slight denormalization for a large, per-query performance gain.
Why bytea for Encrypted Credentials?
Encrypted values are stored as binary:
credentials_encrypted BYTEA NOT NULL- Binary format — AEAD output is binary, not text
- No encoding overhead — no base64/hex round-trip
- Integrity — binary can't be silently mangled by text operations
Wire format: [version: 1 byte][nonce: 12 bytes][ciphertext + GCM tag] — see internal/core/crypto/envelope.go. The leading version byte selects the key from the keyring, which is what makes key rotation possible without a bulk re-encrypt.
The public API is crypto.Encrypt / crypto.EncryptString / crypto.Decrypt / crypto.DecryptString. There is no per-column helper and no organization-id associated-data binding.
See api-keys.md for more.
Security Considerations
RLS Enforcement
RLS is enabled on all tenant-scoped tables. The AppPool connects as restartix_app, which does NOT bypass RLS. The AdminPool (restartix owner) is used only by auth middleware, superadmin requests, public resolve, and system queries — it bypasses RLS by design.
Critical: never grant BYPASSRLS to the restartix_app role.
Defense in depth on top of policies: for tables where AppPool has no legitimate write surface (principals, humans, platform_memberships, agents, service_accounts, roles, role_permissions, permissions, audit_log), the broad DML grants are explicitly REVOKEd. A future migration that accidentally adds a write policy still can't write — the grant has to be restored deliberately in the same migration.
Credential Security
- Encrypted at rest — AES-256-GCM application-level encryption into
BYTEA - Encrypted in transit — TLS
verify-fullto Aurora; TLS for every external call - Permission-gated —
organizations.manage_integrationsat the route layer and in RLS - Audited — access and rotation logged to
audit_log - No caching — decrypted secrets are never cached
- Column-level REVOKE where the value should never be listed — e.g.
REVOKE SELECT (api_key_hash) ON service_accounts FROM restartix_app, soSELECT *fails loudly at review time instead of leaking into a list endpoint
Organization Isolation
- RLS at database level — PostgreSQL enforces boundaries
- Transaction-scoped session variables — bound through
SECURITY DEFINERwrappers that validate the (principal, org, role) tuple and lock-once onprincipal_id - URL ≡ scope guard (P47) — every per-org route group mounts
RequireURLOrgMatchesScope, so a URL org and a header org can never disagree - Superadmin override — via AdminPool, and cross-tenant identifiable access goes through the break-glass pattern (per-org scope, time-bound, justified, always-on clinic notification, all actions including reads audited)
- Portable profile exception —
patient_profilesis reachable by any org the patient is registered at, and registering IS the disclosure: extended fields (DOB, allergies, insurance, …) are readable to that org's staff. An org with nopatientsrow reads nothing. The scoping is enforced in every staff-facing query as well as RLS (P8, retired).
Audit Trail
Organization operations logged to audit_log, with entity_type in organization / organization_membership / organization_domain:
- Organization created / updated
- Member added / role changed / removed (including the cascade when a superadmin grant lands)
- Domain added / verified / removed
- Settings, billing, designations, legal documents changed
- Ownership transfers initiated / accepted / declined / cancelled
Operational-metadata bumps are exempt: organization_memberships.last_used_at (P35 activity tracker) and the universal set_updated_at trigger record presence, not a state transition.
Performance Considerations
Index Coverage
Every organization_id column has an index:
CREATE INDEX idx_org_memberships_org ON organization_memberships(organization_id);
CREATE INDEX idx_patients_org ON patients(organization_id);
CREATE INDEX idx_patients_org_active ON patients(organization_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_consents_active_by_org ON consents(organization_id, ...);
-- ... etc.Why: RLS filters by organization_id on every query.
RLS Policy Design
Policies use direct column checks, not sub-queries:
-- Fast (direct check)
CREATE POLICY locations_select ON locations FOR SELECT USING (
organization_id = current_app_org_id()
);
-- Slow (sub-query — avoid)
CREATE POLICY locations_select ON locations FOR SELECT USING (
id IN (
SELECT location_id FROM some_other_table
WHERE organization_id = current_app_org_id()
)
);Exception: the identity tables need a membership sub-query, because a principal is not owned by an org. Both arms are additionally gated on the organizations.view_directory permission — patient sessions also carry current_app_org_id(), so without that gate a patient at a clinic's portal could enumerate every staff member's email and provider subject id.
CREATE POLICY humans_select ON humans FOR SELECT USING (
principal_id = current_app_principal_id()
OR (
current_app_has_permission('organizations', 'view_directory')
AND principal_id IN (
SELECT po.principal_id FROM organization_memberships po
WHERE po.organization_id = current_app_org_id()
)
)
);This is acceptable because organization_memberships is small per org and indexed on organization_id. principals has the equivalent policy pair (principals_select_self + principals_select_via_membership).
Response Caching
The public resolve path is the hottest read on the platform — every Next.js proxy request for every org hits it. It's wrapped with cache.Aside (P45) against Redis with a 5-minute TTL, keyed separately for slug and domain lookups. GetByID uses the same pattern with its own 5-minute TTL and is the canonical P45 reference example in the codebase.
Invalidation is explicit per key on the write paths: an org update invalidates the slug key and every verified-domain key for that org; domain add/remove/status-change invalidates that domain's key.
Session Variable Overhead
Binding the transaction context costs one round-trip per request:
SELECT set_app_staff_context(
'0190af3b-1c2e-7c00-8a4f-b2d9c4e5f001',
'0190af3b-1c2e-7c00-8a4f-b2d9c4e5f100',
'specialist'
);set_config(..., true) makes each GUC transaction-scoped, so it auto-clears at COMMIT/ROLLBACK and a pooled connection never carries context into the next request.
pgbouncer note (P44): runtime traffic goes through pgbouncer in transaction pooling mode on port 6432. No session-mode Postgres features in runtime paths — no advisory locks, no
LISTEN/NOTIFY, no session-scopedSET(useset_config(..., true)), no temp tables. Migrations useDATABASE_DIRECT_URLon 5432.
Migration Guide
Adding a New Table
When creating a new tenant-scoped table (run /new-migration — it encodes the full checklist):
Add organization_id column:
sqlCREATE TABLE new_table ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, -- ... other columns created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() );If the table records occurrences (append-only, time-ordered, multi-year retention) rather than state, range-partition it monthly from day one —
audit_logis the reference shape (P41). Retrofitting a partition later is not an option.Add index:
sqlCREATE INDEX idx_new_table_org ON new_table(organization_id);Seed the permission and grant it to the system role templates:
sqlINSERT INTO permissions (code, resource, action, description) VALUES ('new_table.manage', 'new_table', 'manage', 'Manage new_table rows'); -- Grant to the NULL-org system templates AND to the already-cloned -- is_system roles under every existing org. INSERT INTO role_permissions (role_id, permission_code) SELECT r.id, 'new_table.manage' FROM roles r WHERE r.is_system = TRUE AND r.code = 'admin';Add RLS policies — permission checks, never role-string compares:
sqlALTER TABLE new_table ENABLE ROW LEVEL SECURITY; -- Superadmins bypass RLS via AdminPool (owner role) — no is_superadmin() exists CREATE POLICY new_table_select ON new_table FOR SELECT USING ( organization_id = current_app_org_id() ); CREATE POLICY new_table_modify ON new_table FOR ALL USING ( organization_id = current_app_org_id() AND current_app_has_permission('new_table', 'manage') ) WITH CHECK ( organization_id = current_app_org_id() AND current_app_has_permission('new_table', 'manage') );Add the auto-update trigger:
sqlCREATE TRIGGER set_updated_at BEFORE UPDATE ON new_table FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();Register every new column in data-classification.md in the same PR —
make checkfails the build otherwise.
Production is live. A migration already applied to production cannot be edited in place; it needs a catch-up DDL script per environment (
infra/scripts/000023-skip-note-prod.sqlis the canonical pattern). Migrations not yet applied anywhere may still be edited freely. New migrations start at000040.
Adding a New Integration Service
There is no integration_service enum. The catalog is rows in integration_services. To add a connector:
Insert the catalog row in the same PR as the connector implementation:
sqlINSERT INTO integration_services (slug, name, description, auth_type, oauth_scopes, oauth_client_capability) VALUES ('google_calendar', 'Google Calendar', '…', 'oauth2', ARRAY['https://www.googleapis.com/auth/calendar'], 'google_oauth');auth_type∈oauth2|api_key|webhook_in_only. OAuth connectors must declareoauth_client_capability(a CHECK enforces it); non-OAuth connectors must leave it NULL.Implement the connector behind the Cat B interface.
Add a SOUP row in soup.md for any new direct dependency, and a glossary entry for any new vocabulary — both in the same PR.
Create the connection via API:
bashPOST /v1/organizations/0190af3b-1c2e-7c00-8a4f-b2d9c4e5f100/integrations { "integration_service_id": "0190af3b-1c2e-7c00-8a4f-b2d9c4e5f500", "auth_type": "api_key", "external_account_id": "acct_123", "title": "Production account", "credentials": { "api_key": "sk_live_..." } }auth_type: "oauth2"is rejected from this path until the OAuth callback handler ships.
Testing
RLS Policy Testing
RLS tests run against a real Postgres (testcontainers) via the internal/test/rlstest harness. Scope a transaction through the same SECURITY DEFINER wrappers production uses, then assert what the principal can and can't see:
// Staff context: requires the (principal, org, role) tuple to exist in
// organization_memberships ⨝ roles — seeded by SeedDefaults.
tx := h.AsPrincipal(t, adminPrincipalID, orgAID, "admin")
var count int
err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM organizations`).Scan(&count)
require.NoError(t, err)
require.Equal(t, 1, count) // only org A is visible
// Unauthenticated: no wrapper call, no GUCs — RLS denies everything.
pub := h.AsPublic(t)
// Patient session: validated against patients / patient_profiles / patient_caregivers.
pt := h.AsPatient(t, patientPrincipalID, orgAID)Equivalent by hand in psql, inside an explicit transaction:
BEGIN;
SELECT set_app_staff_context(
'0190af3b-1c2e-7c00-8a4f-b2d9c4e5f001', -- principal_id
'0190af3b-1c2e-7c00-8a4f-b2d9c4e5f100', -- organization_id
'specialist'
);
-- Sees only that org
SELECT * FROM organizations;
-- Fails: the specialist template has no organizations.update grant
UPDATE organizations SET name = 'Hacked'
WHERE id = '0190af3b-1c2e-7c00-8a4f-b2d9c4e5f100';
COMMIT;Note what you cannot do: there is no way to grant yourself permission by changing a role string. app.current_role is diagnostic only; current_app_has_permission reads organization_memberships → role_permissions → permissions. To test the admin path, bind a principal who actually holds an admin membership.
Multi-Organization Testing
-- Given a principal with memberships at orgs A and B (seeded via
-- organization_memberships, one row per org, each with its own role_id).
BEGIN;
SELECT set_app_staff_context('<principal-uuid>', '<org-A-uuid>', 'admin');
SELECT * FROM patients; -- org A patients only
COMMIT;
BEGIN;
SELECT set_app_staff_context('<principal-uuid>', '<org-B-uuid>', 'specialist');
SELECT * FROM patients; -- org B patients only
COMMIT;The wrapper rejects any (principal, org, role) tuple that has no matching organization_memberships ⨝ roles row, so a test can't accidentally assert against a scope that wouldn't exist in production.
Encryption Testing
func TestCredentialEncryption(t *testing.T) {
require.NoError(t, crypto.Init(keyring))
t.Cleanup(crypto.Reset)
plaintext := "sk_live_test_key_123"
blob, err := crypto.EncryptString(plaintext)
require.NoError(t, err)
got, err := crypto.DecryptString(blob)
require.NoError(t, err)
assert.Equal(t, plaintext, got)
}Run the suites with make test (unit, race detector on) and make test-integration (testcontainers Postgres + S3 LocalStack + ratelimit) from services/api/, or make check-all from the repo root.
Troubleshooting
Staff can't see data after switching orgs
Symptom: a staff member navigates to another clinic's hostname but sees empty lists, or gets a 403.
Causes:
- No
organization_membershipsrow for that (principal, org) - The hostname didn't resolve — the proxy's
org-idcookie is stale or missing, soX-Organization-IDpoints at the wrong org RequireURLOrgMatchesScoperefused because the URL{id}disagrees with the resolved scope- The role clone in that org lacks the permission the RLS policy checks
- The principal is blocked or soft-deleted —
principal_is_active()makes every permission check return FALSE
Debug:
-- Which orgs does this principal have staff access to, and with which role?
SELECT om.organization_id, o.slug, r.code AS role_code, om.is_owner, om.last_used_at
FROM organization_memberships om
JOIN organizations o ON o.id = om.organization_id
JOIN roles r ON r.id = om.role_id
WHERE om.principal_id = '<principal-uuid>';
-- Is the principal still allowed to act at all?
SELECT principal_is_active('<principal-uuid>');
-- What does the role actually grant in that org?
SELECT rp.permission_code
FROM organization_memberships om
JOIN role_permissions rp ON rp.role_id = om.role_id
WHERE om.principal_id = '<principal-uuid>'
AND om.organization_id = '<org-uuid>'
ORDER BY 1;
-- Inside a scoped transaction, what does the session think it is?
BEGIN;
SELECT set_app_staff_context('<principal-uuid>', '<org-uuid>', '<role-code>');
SELECT current_app_principal_id(), current_app_principal_type(),
current_app_org_id(), current_app_role(), current_app_is_owner();
COMMIT;Run these on AdminPool (or as the owner role) — on AppPool the diagnostic queries are themselves RLS-filtered.
Stale organization data after an update
Symptom: an org rename or logo change doesn't show up on the portal.
Causes:
- The Redis resolve cache hasn't expired (5-minute TTL) and the write path didn't invalidate
- A Next.js tagged GET (P42) wasn't invalidated — the server action must call
updateTag(...)andrefresh() - A client component seeded
useStatefrom a server prop (P48), so the new server value is silently ignored
Credentials not decrypting
Symptom: decrypt fails with an authentication-tag error.
Causes:
- The keyring's key for that blob's version byte isn't loaded in this environment
- The active key version rotated but the old version was dropped from the keyring
- Data corruption
Debug: check the first byte of the blob — it's the key version — and confirm the keyring exposes a key for it (crypto.Keyring.Key(version)). Keys live in AWS KMS; never in code, config, or environment variables outside local dev.
Domain Mapping
How Domain Resolution Works
Organization context is request-scoped — determined by the domain the user visits, not stored on the principal record. This is the full flow for every page load:
Browser ──► healthcorp.clinic.restartix.pro (subdomain)
OR portal.myclinic.com (custom domain)
│
▼
┌──────────────────────────────────────────────────────────┐
│ Next.js Proxy (proxy.ts) │
│ │
│ extractSlugFromHostname(hostname, CLINIC_DOMAIN) │
│ ├── Match? slug = "healthcorp" │
│ │ └── resolveOrganization(slug, API_URL) │
│ │ GET /v1/public/organizations/resolve │
│ │ ?slug=healthcorp │
│ │ │
│ └── No match? (custom domain) │
│ └── resolveOrganizationByDomain(hostname, ...) │
│ GET /v1/public/organizations/resolve │
│ ?domain=portal.myclinic.com │
│ │
│ Set httpOnly cookies: org-id, org-slug, org-name │
│ Enforce auth for protected routes │
└──────────────────────────┬───────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ API — Public Resolve Endpoint (no auth, IP rate-limited) │
│ │
│ ?slug=healthcorp │
│ ├── Redis cache hit? (org:resolve:slug:healthcorp) │
│ │ └── Return cached org │
│ └── Cache miss? │
│ └── repo.FindBySlugPublic (AdminPool, no RLS) │
│ └── Cache result in Redis (5 min TTL) │
│ │
│ ?domain=portal.myclinic.com │
│ ├── Redis cache hit? (org:resolve:domain:portal.my..) │
│ │ └── Return cached org │
│ └── Cache miss? │
│ └── repo.FindByDomainPublic (AdminPool, no RLS) │
│ JOIN organization_domains (status='verified') │
│ └── Cache result in Redis (5 min TTL) │
│ │
│ Returns the public-class field set (see example above) │
└──────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ Server Component / Route Handler │
│ │
│ createApiClient() reads the org-id cookie │
│ ApiClient sends X-Organization-ID: {org-id} header │
└──────────────────────────┬───────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ API — Authenticated Request │
│ │
│ 1. Authenticate middleware — verify the JWT │
│ 2. SubjectLoader — build principal.Subject once │
│ (memberships, patient orgs, platform grants) │
│ 3. OrganizationContext middleware: │
│ ├── Read X-Organization-ID header │
│ ├── Confirm the principal has access to that org │
│ │ (staff membership OR patients row) │
│ └── BEGIN tx + call set_app_staff_context(...) or │
│ set_app_patient_context(...) │
│ 4. RequireURLOrgMatchesScope on per-org routes (P47) │
│ 5. Handler executes — every query scoped by RLS │
└──────────────────────────────────────────────────────────┘Cache invalidation: when an org is updated (PATCH /v1/organizations/{id}), both the slug cache and all verified-domain caches for that org are invalidated. Domain caches are also invalidated when a domain is added, removed, or its status changes.
Custom Domain Verification Flow
Verification goes through Cloudflare for SaaS, not a DNS TXT record:
Admin adds domain via POST /v1/organizations/{id}/domains
├── { "domain": "portal.myclinic.com", "domain_type": "portal" }
├── Gate: organizations.manage_domains + `custom_domain` tier entitlement
├── Reject if the hostname falls inside the platform's own subdomain space
├── Reject domain_type = "clinic" (edge dispatcher not built)
├── INSERT organization_domains row (status = 'pending')
└── cloudflaresaas.Register(hostname)
├── Success → store cloudflare_hostname_id + ssl_status, map CF status
└── Failure → roll the row back so the domain can be re-added
│
▼
Clinic points a CNAME at the platform's SaaS target
│
▼
Cloudflare validates ownership and issues the edge certificate
│
▼
Admin (or the admin UI) triggers
POST /v1/organizations/{id}/domains/{domainId}/verify
├── cloudflaresaas.GetStatus(cloudflare_hostname_id)
├── Map CF status → pending | verified | failed
├── Set verified_at on the first flip to verified
└── Invalidate the domain resolve cacheverification_token is retained on the row from the legacy DNS-TXT design and is still returned by the list endpoint (which is why every domain route gates on manage_domains — a stable secret is need-to-know), but it no longer gates routing.
Not built: there is no re-verification cron. Nothing polls Cloudflare on a schedule; status only refreshes when someone calls the verify endpoint. An operator who removes their CNAME will keep a
verifiedrow until a manual re-check.
Today vs Planned
The organization feature is the most-shipped surface on the platform. "Today" describes behavior you can rely on right now; "Planned" is design that has not landed.
Today: Subdomain Routing
Each organization is accessed via its slug-based subdomain:
- Clinic app:
{slug}.clinic.restartix.pro - Patient Portal:
{slug}.portal.restartix.pro - Local dev:
{slug}.clinic.localhost:9100/{slug}.portal.localhost:9200
How it works:
- Browser hits
healthcorp.clinic.restartix.pro - The Next.js proxy extracts slug
healthcorpfrom the hostname - Proxy calls
GET /v1/public/organizations/resolve?slug=healthcorp(public, no auth) - API returns the public-class org payload
- Proxy sets
org-id,org-slug,org-nameas httpOnly cookies createApiClient()reads theorg-idcookie and sendsX-Organization-IDOrganizationContextmiddleware binds the RLS transaction context- All downstream queries are scoped to that organization
Organization switching: TeamSwitcher performs a full-page navigation to {slug}.{CLINIC_DOMAIN}. There is no API switching endpoint.
Today: Custom Domain Support (portal only)
Organizations can configure their own portal hostnames in addition to the default subdomains. Clinic-app custom domains are refused by the service until the edge dispatcher ships.
Database: organization_domains (shape documented above).
API endpoints:
| Method | Path | Gate | Description |
|---|---|---|---|
GET | /v1/organizations/{id}/domains | organizations.manage_domains | List domains (response includes verification_token) |
POST | /v1/organizations/{id}/domains | organizations.manage_domains + custom_domain entitlement | Add a custom domain and register it with Cloudflare |
DELETE | /v1/organizations/{id}/domains/{domainId} | organizations.manage_domains | Remove a custom domain |
POST | /v1/organizations/{id}/domains/{domainId}/verify | organizations.manage_domains | Poll Cloudflare and refresh status |
Adding a domain consumes the custom_domain tier entitlement; list/verify/delete stay permission-only so a plan downgrade doesn't strand an admin from cleaning up domains they already added.
Resolve endpoint extension: GET /v1/public/organizations/resolve?domain=portal.myclinic.com looks up verified custom domains. The frontend proxy uses ?slug= for platform subdomains and ?domain= for custom domains.
Caching: Redis cache-aside for both resolve paths, 5-minute TTL, invalidated on org update or domain status change.
Planned (not shipped today)
- OAuth connected accounts —
organization_integrationssupportsauth_type = 'oauth2'at the schema level, but the callback handler (state-token CSRF, code→token exchange, refresh worker) is deferred to the first OAuth-using F-tier consumer. The create endpoint rejectsoauth2today. - Custom per-org roles — the
rolestable already holds non-system rows (organization_idset,is_system = FALSE), but there is no management API and no INSERT/UPDATE/DELETE policy; the DML grants onroles/role_permissionsare revoked fromrestartix_app. Only the cloned system templates (admin,specialist,customer_support) are in use. Theorganizations.view_directorypermission was introduced specifically so this can ship without a no-permission custom role over-granting visibility into staff metadata. - Domain re-verification cron — nothing polls Cloudflare on a schedule today.
- Clinic-app custom domains — needs the Cloudflare Worker dispatcher that routes a custom hostname to the clinic app instead of the portal. Designed, not built; see custom-domains.md.
- Per-domain auth — custom-domain auth currently routes through the primary hostname via token handoff. Per-domain provider configuration is a scaling gap, not a shipped feature.
- Org-branded auth pages — pass org branding to the provider's sign-in/sign-up pages for white-label login screens.
- Dedicated tenancy mode —
organizations.tenancy_mode = 'dedicated'is a schema reservation. Its only structural axis is a per-tenant provider organization; own-S3 and own-CMK ship later as entitlements, once an exit/portability tool and a documented crypto-shred runbook exist. API paths refusededicateduntil then. See tenant-isolation.md. - Automations / lifecycle workflow engine — see Organization Lifecycle Workflows above.
Further Reading
- Database Schema → architecture/data-model.md → Area 1 (Foundation) — canonical schema with indexes and RLS
- Glossary — Capability vs Entitlement vs Feature, Integration Categories A–F, Principal, naming conventions
- Decisions — why principals are the root identity; why patients are not memberships
- RBAC permissions reference — permission catalog, role templates, Owner
- Tenant isolation — shared vs dedicated tenancy modes
- Custom domains — Cloudflare for SaaS flow and the clinic-routing gap
- API Documentation — organization API endpoints
- API Keys Guide — encryption, rotation, and usage
- Authentication — identity provider integration and principal provisioning