Operate
Admin access, tenants and users
How the first owner is created, what each role can do, and how registration, invites and policies work.
Create the tenant (Firestore)
There is no REST endpoint to create a tenant. In the Firebase Console open Firestore, create (or reuse) the tenants collection, and add a document whose ID is the tenant ID: a stable, lowercase, kebab-case slug referenced by every user, study and inference record. Repeat per tenant for multi-tenant installs.
| Field | Type | Example | Notes |
|---|---|---|---|
id | string | acme-pacs | Same as the document ID |
name | string | Acme Cardiology Hospital | Display name shown in the viewer |
address | string | 123 Main St, Montreal, QC | Free-form |
onboarding_questionnaires | string | empty | Optional JSON blob |
onboarding_enable_registration | bool | false | Self-serve public registration toggle |
onboarding_enable_consent | bool | false | Consent-flow toggle |
onboarding_consent_link | string | empty | URL shown during the consent step |
created_at, updated_at | int | 1715000000 | Unix epoch seconds |
Create the first owner (superuser endpoint)
Create the admin user in Firebase Console (Authentication, Users, Add user) with a temporary password communicated out of band, and note the UID Firebase generates for later reconciliation. Then bind that email to the tenant as its owner with the privileged endpoint. It is gated by the FirebaseSuperUserGuard middleware and authenticated with the FIREBASE_SUPERUSER_KEY value sent in the X-FB-SUDO-KEY header (OpenAPI security scheme firebaseSudoKey; all other routes use bearerAuth, a session JWT). The role must be exactly OWNER in upper case: the handler compares against the OWNER constant and returns 403 with "Role must be owner." for anything else. The handler creates the api-pacs record bound to that Firebase email and returns the generated initial password, or 409 if the user already exists. The admin then completes a first login, which creates the user record on the api-pacs side.
curl -X POST https://api.<customer-domain>/v1/user/owner/add \
-H "X-FB-SUDO-KEY: $FIREBASE_SUPERUSER_KEY" \
-H "Content-Type: application/json" \
-d '{
"tenantId": "acme-pacs",
"role": "OWNER",
"name": "Alice Admin",
"email": "[email protected]",
"licenseNo": "MD12345",
"specialty": "Cardiology"
}' Roles
| Role | Scope |
|---|---|
OWNER | Full control over the tenant; created at install through POST /v1/user/owner/add; can add or remove ADMIN and USER members. Enforced by RBACOwnerGuard. |
ADMIN | Manages users, ingestion jobs and inference models; cannot transfer or delete the tenant. Enforced together with owners by RBACOwnerOrAdminGuard. |
USER | Clinician or regular user: views studies, inference results and the worklist; cannot change tenant configuration or membership. Manual reprocessing stays admin-only. |
Every request carries a Firebase ID token verified by api-pacs; sessions are tracked in Redis; every record is scoped to tenant_id and cross-tenant reads are blocked at the application layer (a cross-tenant lookup by study UID returns 404; no role or key exempts a caller from tenant scoping on study lookups). Public registrations are always created as USER; the role is server-owned and never client-chosen.
Tenant, user and account endpoints (/v1)
| Endpoint | Purpose |
|---|---|
GET /tenant, GET /tenant/public?tenantId= | Tenant record for the caller; public tenant metadata used by the login and registration pages |
PUT /tenant/onboarding-registration/config/update | Toggle onboardingEnableRegistration for self-serve sign-up |
PUT /tenant/onboarding-consent/config/update | Consent flow configuration |
POST /tenant/onboarding-questionnaire-answers/add, GET /tenant/onboarding-questionnaire-answers?questionnaireType= | General onboarding questionnaires (PRE_SURVEY, POST_SURVEY) |
POST /inference/onboarding-model-questionnaire-answers/add, GET /inference/onboarding-model-questionnaire-answers?modelId= | Answers to the scored per-model bias and fairness quizzes |
POST /user/tutorial/reset | Deletes the user general and model-specific questionnaire answers so onboarding replays (idempotent) |
POST /user/add | Create a tenant user (role, name, email, licenseNo, specialty) |
POST /user/invite, POST /user/invite/resend, GET /user/invites, DELETE /user/invite/{id}/remove | Email invitations; a registration that carries the invite code is created already verified |
GET /user/all, PUT /user/update, DELETE /user/{id}/remove | List, update and remove members |
POST /user/{id}/suspend, POST /user/{id}/reactivate | Account suspension with immediate session revocation, and reactivation |
GET /user/me, GET /user/metadata, PUT /user/metadata/update, PUT /user/password/update | User self-service |
POST /iam/login, POST /iam/verify-email, POST /iam/forgot-password | Session login, verification-email resend, password reset |
GET /user/policies/registration, POST /user/policies/accept, GET /user/policies/status, GET /user/{ID}/policies/status | Versioned Terms and Privacy acceptance: registration metadata, acceptance, own status, and administrator audit status |
GET /user/specialties | Doctor specialties offered at registration |
GET /ecs/logs?index&query&startDate&endDate&export | Search or export audit indices without opening Kibana |
PATCH /ecs/kibana/indices/sync | Superuser-only (X-FB-SUDO-KEY): sync Kibana index patterns |
GET /debug/vars (server root, not under /v1) | Go expvar metrics as JSON, behind the same basic-auth gate as the OpenAPI docs (user sudo, password OPENAPI_DOCS_PASSWORD) |
POST /lead/contact-form, POST /lead/subscribe | The contact form and newsletter endpoints used by pacsai.co |
Public registration and login
-
POST /v1/user/registeracceptsemail, a singlename(1 to 100 characters),licenseNo,specialty,password, the exact current policy versions and a single-useturnstileToken; unknown fields such as a password confirmation are rejected with 400. Input is normalized and strictly validated (field lengths, password strength, body size, unknown fields, trailing JSON). Registration is only open when the tenant hasonboarding_enable_registrationset. - Turnstile is verified with Cloudflare Siteverify before any tenant lookup or account creation; invalid, expired or replayed tokens return HTTP 400
TURNSTILE_INVALID, Cloudflare failures return HTTP 503CLOUDFLARE_API_ERROR. - Attempts are throttled per tenant, per email and per trusted IP (HTTP 429). If the downstream Firestore profile write fails, the newly created Firebase identity is deleted.
- The account is created unverified and a verification email is sent through Mailgun; login enforces email verification (
FIREBASE_AUTH_EMAIL_NOT_VERIFIED) and resend requests have a 60-second cooldown (POST /v1/iam/verify-email). -
POST /v1/iam/logintakestenantId,email,passwordand an optionalturnstileToken. api-pacs calls Firebase Identity ToolkitsignInWithPassword, verifies the ID token with the tenant Admin SDK, discards Firebase tokens and creates only the PACS Redis session. Passwords and tokens are never logged. - This contract replaced the earlier
{tenantId, idToken}login. It is a breaking change: deploy the paired viewer and backend together (or in a maintenance window) and do not keep the old ID-token route, because it bypasses adaptive enforcement. - Failures use hashed tenant, account and IP counters. Account failures require Turnstile but never hard-lock an email; IP and tenant counters first challenge and later return HTTP 429 with
Retry-After. Redis failure returnsLOGIN_PROTECTION_UNAVAILABLE(503); Siteverify transport or configuration failures returnCLOUDFLARE_API_ERROR(503) without poisoning the counters. - Current policy acceptance is enforced on inference, orchestrator, Orthanc and DICOMweb routes, with an optional grace deadline for existing users.
Audit events (Elasticsearch)
| Event | Index | Notable fields |
|---|---|---|
| User login | logins | session_id, role, specialty |
| Admin invite sent | admin_invites | invitee email |
| User management | admin_members | action (CREATE, UPDATE, DELETE), target user, role, licence |
| Remote-PACS query (C-FIND) | find_modality_studies | modality_id, query_id |
| Study retrieved (C-MOVE) | retrieved_studies | study_instance_uid, modality_id |
| Inference run | predict_inference_models | inference_model_id, docker_image, model, study_instance_uid, series_instance_uids, additional_metadata |
| Consent signed | signed_consents | tenant and user |
| Custom series stored | stored_custom_series | study_instance_uid, series_instance_uids, custom_series_instance_uid, custom_sop_instance_uid, model_name, model_version, patient_id |
Every event carries tenant_id, tenant_name, user_id, email, name and a Unix timestamp. Customers run audit queries in Kibana Discover and export CSV or JSON, or script direct _search queries against Elasticsearch; the predict_inference_models and stored_custom_series events are the canonical record of which model touched which study, when and on whose behalf. No immutable archive ships by default; replicate the indices to write-once storage if tamper resistance is required.