Technical manual
This document describes the current SSO service after the access-model
reform (rollup #396: roles registry S2, research-project registry S4,
affiliation binding S3, session-phase intersection S5, blob assembly S6,
guided sign-on / activation gate S9, legacy-field kill-switch M0 #409).
graph TB
subgraph External
Browser[Browser / Mobile]
DS1[Downstream Service A]
DS2[Downstream Service B]
Partner[External Partner caller]
end
subgraph SSO Service
App[Flask App :9000]
DB[(PostgreSQL :9003)]
Logs[Audit Logs]
end
subgraph Infrastructure
RP[nginx Reverse Proxy]
DNS[sso.pdhc.se]
end
Browser -->|HTTPS| DNS
DNS --> RP
RP -->|:9000| App
App --> DB
App --> Logs
DS1 -->|/api/auth/me/service| App
DS2 -->|/api/auth/me/service| App
Partner -->|/api/internal/partner/<guid>/validate| App
Browser -->|Bearer JWT| DS1
Browser -->|Bearer JWT| DS2
All tables use integer primary keys internally and UUID4 GUIDs for external
references (Rule 18). The live schema has 17 tables. The access-model
reform added roles, research_projects, affiliations, and
organisation_audit; the pre-reform tables all remain (the reform dual-runs
user_organisations alongside affiliations until the M0 #409 cleanup).
| # | Table | Model | Purpose |
|---|---|---|---|
| 1 | users |
User | Login identity (patient or professional), SU flag, activation status, forced-reset + session-flush controls |
| 2 | patients |
Patient | Patient profile, personnummer, org, registry status |
| 3 | professionals |
Professional | Professional profile, name, legacy professional_role |
| 4 | organisations |
Organisation | Internal orgs and external partners (single table, is_external); PDL caregiver hierarchy |
| 5 | organisation_audit |
OrganisationAudit | Append-only org / partner lifecycle audit |
| 6 | user_organisations |
UserOrganisation | Legacy person↔org M2M (backfilled into affiliations, kept until #409) |
| 7 | groups |
Group | Organisational grouping; category is a free-form label only |
| 8 | memberships |
Membership | person↔group membership with status + admin flag |
| 9 | group_proposals |
GroupProposal | Professional-suggested new groups awaiting SU decision |
| 10 | leader_requests |
LeaderRequest | Requests for group-admin role awaiting SU decision |
| 11 | access_requests |
AccessRequest | New-professional onboarding requests |
| 12 | invites |
Invite | Group invite tokens |
| 13 | revoked_tokens |
RevokedToken | Per-jti token revocation (logout) |
| 14 | user_phases |
UserPhase | Direct SU phase grants (source of effective_phases) |
| 15 | roles |
Role | Reform S2 — role registry (7 seed roles, each with a zone + permitted phases) |
| 16 | research_projects |
ResearchProject | Reform S4 — research-project (ResDB) registry |
| 17 | affiliations |
Affiliation | Reform S3 — person↔CareUnit↔Role binding; the heart of the reform |
erDiagram
User ||--o| Patient : "has"
User ||--o| Professional : "has"
User ||--o{ UserPhase : "direct phase grants"
User }o--o{ Organisation : "UserOrganisation (legacy)"
User ||--o{ Affiliation : "person_guid"
Organisation ||--o{ Affiliation : "care_unit_guid"
Role ||--o{ Affiliation : "role_guid"
Organisation ||--o{ Organisation : "parent_caregiver_guid"
Organisation ||--o{ OrganisationAudit : "has"
User }o--o{ Group : "Membership"
Group ||--o{ Membership : "has"
Group ||--o{ Invite : "has"
User ||--o{ GroupProposal : "requests"
User ||--o{ LeaderRequest : "requests"
AccessRequest ||--o| User : "creates on approve"
User {
int id PK
uuid guid UK
string email UK
string password_hash
enum user_type "patient|professional"
bool is_su_admin
enum status "active|pending|suspended (#411 activation gate)"
bool force_change_on_next_login "#43"
datetime token_revocation_epoch "#44 nullable"
datetime created_at
}
Affiliation {
int id PK
uuid guid UK
uuid person_guid FK "= User.guid"
uuid care_unit_guid FK "Organisation that is a CareUnit"
uuid role_guid FK "Role"
json research_project_guids "researcher only"
bool is_admin
enum status "active|suspended"
}
Role {
int id PK
uuid guid UK
string code UK "doctor|nurse|other_care|researcher|..."
string display_name
enum zone "care|analysis"
json permitted_phases "subset of phase names"
}
ResearchProject {
int id PK
uuid guid UK
string name UK
string description
string ethics_ref "Etikprovningsmyndigheten dnr"
}
Organisation {
int id PK
uuid guid UK
string name UK
bool is_external "internal org vs external partner (#96)"
uuid parent_caregiver_guid FK "NULL=caregiver, set=care unit (#187)"
enum auth_kind "oauth_client|api_key|none (external)"
string client_id "external"
json allowed_scopes "external"
json allowed_services "external"
json allowed_org_guids "external"
enum status "active|suspended|revoked (external)"
}
OrganisationAudit {
int id PK
uuid organisation_guid FK
uuid actor_user_guid FK
enum event "created|edited|rotated|suspended|reactivated|revoked"
datetime at
json before_json
json after_json
}
UserPhase {
int id PK
uuid guid UK
uuid user_guid FK "#46 direct phase grant"
enum phase "planning|request|provider|analysis"
uuid granted_by_guid
datetime granted_at
}
Patient {
int id PK
uuid guid UK
int user_id FK
string personnummer "12 digits"
uuid organisation_guid FK
bool in_registry
json registries
}
Professional {
int id PK
uuid guid UK
int user_id FK
enum professional_role "doctor|nurse|other (LEGACY; superseded by roles+affiliations)"
string first_name
string last_name
}
UserOrganisation {
int id PK
uuid user_guid FK
uuid organisation_guid FK
}
Group {
int id PK
uuid guid UK
string name
string category "free-form label (#60) — does NOT confer phase access"
datetime created_at
}
Membership {
int id PK
uuid guid UK
uuid user_guid FK
uuid group_guid FK
enum status "pending|approved|rejected"
bool is_admin
uuid decided_by_guid
}
GroupProposal {
int id PK
uuid guid UK
string proposed_name
string category
uuid requested_by_guid FK
enum status "pending|approved|rejected"
}
LeaderRequest {
int id PK
uuid guid UK
uuid user_guid FK
uuid group_guid FK
enum status "pending|approved|rejected"
}
AccessRequest {
int id PK
uuid guid UK
string email
enum professional_role
uuid organisation_guid FK
json requested_phases
uuid chosen_leader_guid FK
enum status "pending|endorsed|approved|rejected"
}
Invite {
int id PK
uuid guid UK
uuid group_guid FK
string token UK
datetime expires_at
}
RevokedToken {
int id PK
string token_guid UK
datetime expires_at
}
person_guid = SSO user guid) tocare_unit_guid → an organisations row) in one Rolerole_guid → a roles row). A person can hold several affiliations —(person, unit, role).roles registry replaces the old 3-value Professional.professional_rolecare | analysis)permitted_phases list:| code | display | zone | permitted_phases |
|---|---|---|---|
doctor |
Doctor | care | request, provider, analysis |
nurse |
Nurse | care | request, provider, analysis |
other_care |
Other care professional | care | request, provider |
researcher |
Researcher | analysis | analysis |
quality_registry_reporter |
Quality registry reporter | analysis | analysis |
local_quality_assured |
Local quality-assured | analysis | analysis |
other_analysis |
Other analysis phase | analysis | analysis |
The role list is SU-edit-only (S8 #410). The planning (Plan) phase is
orthogonal — it touches no patient data, is on no role's
permitted_phases, and passes the session intersection whenever granted.
- ResearchProject (ResDB) rows are referenced from an affiliation's
research_project_guids (which projects a researcher works on). The
analysis read is later intersected with the patient's consented projects in
ips (D1).
organisations is self-referential via parent_caregiver_guid:
NULL → this row is a caregiver (vårdgivare / legal data holder) — aNULL → this row is a vårdenhet / clinic (a CareUnit) whoseOrganisation.care_organisation_guid resolves a CareUnit up to its caregiver
(or returns its own guid when it is already a caregiver). This is what powers
the caregiver roll-up in the access blob (#188, PDL Ch 4 §§2,4).
| Table | FHIR Resource Type |
|---|---|
| Patient | Patient |
| Professional | Practitioner |
| Organisation | Organization |
| Group | Group |
External partners deliberately share the Organisation table so that
Contract.signer.party.reference = Organization/<guid> works uniformly across
internal payers and external providers (#96).
sequenceDiagram
participant C as Client
participant S as SSO Service
participant DB as Database
C->>S: POST /api/auth/login {email, password}
S->>DB: Query user by email
DB-->>S: User record
S->>S: bcrypt.checkpw(password, hash)
S->>S: issue_token(user.guid, secret) — carries sid claim (#191)
S-->>C: {token, user_guid}
C->>S: GET /api/auth/me (Bearer token)
S->>S: decode_token + check revoked + iat vs revocation epoch
S->>DB: build_access_blob(user, session, session_id=sid)
S-->>C: Access blob
Used by downstream services to authenticate users through the central SSO.
sequenceDiagram
participant U as User Browser
participant DS as Downstream Service
participant SSO as SSO Service
U->>DS: Access protected resource
DS->>U: H1: Redirect to SSO /login?next=callback&state=xyz
U->>SSO: H2: Login form (or auto-redirect if session exists)
SSO->>SSO: Authenticate user, issue JWT
SSO->>U: H3: Redirect to callback?token=JWT&state=xyz
U->>DS: H4: Arrive at callback with token
DS->>SSO: GET /api/auth/me/service (Bearer + client creds)
SSO-->>DS: Access blob
DS->>U: Render protected resource
Security controls:
next URL validated against the ALLOWED_CALLBACK_URLS allowlist.state parameter passed through for CSRF protection./api/auth/me/service requireX-SSO-Client-Id and X-SSO-Client-Secret headers. These are matchedSERVICE_CREDENTIALS, a {client_id: secret} map assembled atSSO_CLIENT_ID_<NAME> / SSO_CLIENT_SECRET_<NAME> env pairsconfig.py). A missing or mismatched pair returns 403.sequenceDiagram
participant SU as SU Admin
participant SSO as SSO Service
participant DS as Downstream Service
participant U as User Browser
SU->>SSO: POST /api/admin/users/<guid>/reset-password
SSO->>SSO: user.force_change_on_next_login = True
SSO-->>SU: 200 OK (+ temp password)
U->>DS: GET /protected (existing Bearer)
DS->>SSO: GET /api/auth/me/service
SSO-->>DS: blob { must_change_password: true, ... }
DS->>U: 302/403 → SSO /change-password
U->>SSO: POST /api/auth/change-password (new pw)
SSO->>SSO: user.force_change_on_next_login = False
SSO-->>U: 200 OK
Note over U,DS: Next /me/service call returns must_change_password=false → unblocked
sequenceDiagram
participant SU as SU Admin
participant SSO as SSO Service
participant DS as Downstream Service
participant U as User Browser
SU->>SSO: POST /api/admin/users/<guid>/flush-sessions
SSO->>SSO: user.token_revocation_epoch = now()
SSO-->>SU: 200 OK
U->>DS: GET /protected (existing Bearer, iat < epoch)
DS->>SSO: GET /api/auth/me/service
SSO->>SSO: token_iat_dt < user.token_revocation_epoch → 401
SSO-->>DS: 401 Unauthorized
DS->>U: Redirect to SSO /login
Cheaper than inserting N revoked-token rows when the set of active jtis is
unknown (e.g. after a credential compromise across multiple devices).
/api/auth/me/service is the endpoint every downstream service calls to
validate a token, on every request (Rule 11 forbids caching the blob). Its
credential check used service_creds.get(client_id) != client_secret, whose
run time depends on how many leading bytes match — a timing oracle on the
client secret, on the one route guaranteed to be callable at high volume.
It now uses hmac.compare_digest, matching the pattern already in
auth_middleware. Both sides are encoded to bytes first, so a non-ASCII header
returns 403 rather than raising.
The access blob is the core data structure returned by /api/auth/me and
/api/auth/me/service (build_access_blob in services/auth_service.py).
Downstream services use it to make authorization decisions.
| Field | Meaning |
|---|---|
user_guid |
Stable person identity |
email |
Login email |
user_type |
patient | professional |
is_su_admin |
Super-user flag (bypasses phase/org gates) |
must_change_password |
Forced-reset pending (#43) |
session_id |
The sid claim from the JWT carrying the request (#191). Stable across every /me / /me/service call for the same token. Consumers forward it as X-Operator-Session-Id so downstream audit logs can correlate all reads under one operator session (Lag 2022:913 chain-of-custody). null for pre-#191 tokens. |
{
"user_guid": "a1b2c3d4-...",
"email": "patient@example.com",
"user_type": "patient",
"is_su_admin": false,
"must_change_password": false,
"session_id": "sess-...",
"patient_guid": "e5f6g7h8-...",
"organisation_guid": "i9j0k1l2-...",
"in_registry": true,
"registries": ["INCA"],
"fhir_resource_type": "Patient"
}
{
"user_guid": "m3n4o5p6-...",
"email": "doctor@hospital.se",
"user_type": "professional",
"is_su_admin": false,
"must_change_password": false,
"session_id": "sess-...",
"professional_guid": "q7r8s9t0-...",
"fhir_resource_type": "Practitioner",
"activation_pending": false,
"affiliations": [
{
"affiliation_guid": "aff-...",
"care_unit_guid": "unit-...",
"care_unit_name": "Oncology Clinic",
"care_organisation_guid": "caregiver-...",
"care_organisation_name": "Region Example",
"role": "doctor",
"role_guid": "role-...",
"is_admin": false,
"research_project_guids": []
}
],
"active_affiliation_guid": "aff-...",
"session_phases": ["analysis", "request"],
"professional_role": "doctor",
"organization_ids": ["unit-..."],
"organisation_warning": false,
"organization_caregivers": { "unit-...": "caregiver-..." },
"groups": [
{
"group_guid": "u1v2w3x4-...",
"group_name": "Oncology Planning",
"category": "planning",
"status": "approved",
"is_admin": false
}
],
"effective_phases": ["analysis", "planning", "request"]
}
affiliations[] — the person↔CareUnit↔Role bindings (only status =
active are listed). Each entry resolves the CareUnit up to itsactive_affiliation_guid — the affiliation the current session isnull (the session selects one via the S9 acting-as flow).session_phases — this is the field services must gate on now. Itsession_phases = granted_phases ∩ ( active_role.permitted_phases ∪ {planning} )
computed by resolve_session_phases (S5, Option C). granted_phases are the
raw UserPhase grants; the active role narrows them. When there is no active
role, only orthogonal (planning) phases pass.
- activation_pending — false for an activated professional. See the
activation gate below.
An unactivated professional (User.status != "active") receives a
NO-ACCESS blob regardless of any granted phases: identity fields are
present, but every scope/phase field is empty and activation_pending: true:
{
"activation_pending": true,
"affiliations": [],
"active_affiliation_guid": null,
"session_phases": [],
"organization_ids": [],
"organisation_warning": true,
"organization_caregivers": {},
"groups": [],
"effective_phases": []
}
Granting phases is not enough — an SU must explicitly ACTIVATE the
account (POST /api/admin/users/<guid>/activate), which is refused with a
409 + missing[] list until the profile is complete (≥1 active affiliation, at
least one granted phase the held role can actually use, and every researcher
affiliation carrying ≥1 research project). This replaces the after-the-fact
organisation_warning flag with an up-front gate, so every consumer's existing
phase/org check denies an unactivated user with no consumer-side change.
For backwards compatibility the blob still emits the pre-reform fields
alongside the reform ones. These are gated by the
SSO_EMIT_LEGACY_BLOB_FIELDS environment switch (default true). When it
flips to false, the following keys disappear from professional blobs:
organization_ids, organisation_warning, organization_caregivers,
professional_role, groups, effective_phases
(LEGACY_BLOB_FIELDS in auth_service.py.) The switch can be flipped back to
re-emit instantly. Notes:
effective_phases is the raw UserPhase grant set (not role-narrowed) —session_phases, which is the reform equivalent.professional_role is the single legacy role string; the reform equivalentrole.organization_ids / organization_caregivers come from the legacyuser_organisations M2M and the caregiver roll-up (#188); the reformaffiliations[].care_unit_guid /affiliations[].care_organisation_guid.groups remains available while the switch is on but was always orthogonalGroup.category is a free-form label and confers no phase (#57,POST /api/internal/partner/<guid>/validate (service-key protected) validates
an external partner's credential and returns a distinct blob shape:
{
"access_blob": {
"user_type": "partner",
"partner_guid": "org-...",
"organisation_guid": "org-...",
"display_name": "Acme Integrations",
"allowed_scopes": ["fhir.observation.read"],
"allowed_services": ["cdr.pdhc"],
"allowed_org_guids": ["..."],
"is_su_admin": false,
"organization_ids": ["..."]
}
}
Downstream services use the access blob to authorize actions. Post-reform, the
professional phase check is against session_phases:
flowchart TD
Start[Incoming Request] --> CheckToken{Valid JWT?}
CheckToken -->|No| Deny[401 Unauthorized]
CheckToken -->|Yes| GetBlob[GET /api/auth/me]
GetBlob --> CheckType{user_type?}
CheckType -->|patient| PatientFlow
CheckType -->|professional| ProfFlow
subgraph PatientFlow[Patient Authorization]
P1{Owns resource?} -->|No| Deny2[403 Forbidden]
P1 -->|Yes| P2{Action requires registry?}
P2 -->|No| Allow1[Allow]
P2 -->|Yes| P3{in_registry?}
P3 -->|Yes| Allow2[Allow]
P3 -->|No| Deny3[403 Not in registry]
end
subgraph ProfFlow[Professional Authorization]
A0{activation_pending?} -->|Yes| DenyA[403 Not activated]
A0 -->|No| R1{is_su_admin?}
R1 -->|Yes| AllowAll[Allow all]
R1 -->|No| R2{Action phase?}
R2 --> R3{phase in session_phases?}
R3 -->|No| Deny4[403 No phase access]
R3 -->|Yes| R4{Org / affiliation scope OK?}
R4 -->|No| Deny5[403 Wrong org]
R4 -->|Yes| Allow3[Allow]
end
| Blueprint | Prefix | Purpose |
|---|---|---|
auth |
/api/auth |
login, me, me/service, logout, change-password |
patient |
/api/patient |
Patient self-service |
groups |
/api/groups |
Membership / admin / invite flows |
admin |
/api/admin |
SU administration (see below) |
registry |
/api/registry |
Roles, research-projects, care-organisations, care-units |
public |
/api/public |
Unauthenticated lookups + access-request + partner lookup |
internal |
— | Service-key internal endpoints (push-config, partner validate) |
partners |
— | External-partner subsystem (/api/admin/partners/*, etc.) |
frontend |
— | Server-rendered pages + service-key admin |
fhir |
— | Capability statement |
/api/admin)GET /users, POST /promote-su, DELETE /users/<guid>,POST /users/<guid>/reset-password, POST /users/<guid>/flush-sessions.GET|POST /users/<guid>/phases,DELETE /users/<guid>/phases/<phase>.GET|POST /users/<guid>/affiliations,DELETE /users/<guid>/affiliations/<affiliation_guid>.GET /users/<guid>/completeness (server-computedPOST /users/<guid>/activate (409 + missing[] whenPOST /users/<guid>/deactivate (back to pending).GET|POST /users/<guid>/organisations,DELETE /users/<guid>/organisations/<org_guid>.GET|POST /organisations, PUT|DELETE
/organisations/<guid>, GET /organisations/<guid>/dependents,GET /organisations/<guid>/push-config.DELETE /groups/<guid>, POST /assign-group-admin,GET|POST /group-proposals, GET|POST /leader-requests,GET|POST /access-requests.GET /export-users, POST /import-users,GET|PUT /oath-overview./admin, not /api/admin)require_auth accepts only an Authorization: Bearer header. A browser
following a link sends a session cookie and no bearer token, so an
/api/admin/* route cannot serve a plain <a href> — it answers
{"error": "authentication_required"} in a new tab.
The admin page therefore uses cookie-authenticated routes in frontend.py for
anything a human clicks:
| browser route | API twin |
|---|---|
GET /admin/export-users |
GET /api/admin/export-users |
POST /admin/import-users |
POST /api/admin/import-users |
GET /admin/users-csv-template |
— (panel only) |
POST /admin/create-organisation, POST /admin/set-org-parent |
POST|PUT /api/admin/organisations |
Export's twin was missing until #784, which is why the Export CSV button
returned 401 for every click while the API worked for every script. The API
routes are deliberately not loosened to accept a cookie, and a test pins that
they still refuse one — so "fixing" a future instance of this by weakening the
API will fail the suite instead of passing quietly.
The three CSV surfaces (export, import, template) are generated from one
USER_CSV_COLUMNS contract, so a column cannot appear in the template that the
importer will not accept.
/api/registry) — SU-edit, read-openGET /roles (open to any authenticated professional),POST /roles, PUT /roles/<guid>, DELETE /roles/<guid> (SU only; deleteGET /research-projects (open), POST|PUT|DELETE /research-projects[/<guid>]GET /care-organisations, GET /care-units — dropdown feeds for the guidedExternal partners are organisations rows with is_external = true
(routes/partners.py). The URL surface keeps "partner" naming because that is
the SU-facing concept:
GET /api/public/partner/<guid> — sparse public projectionPOST /api/internal/partner/<guid>/validate (service-key) —client_secret_hash / api_key_hash; returns the partner access blob.GET|POST /api/admin/partners,GET|PATCH /api/admin/partners/<guid>, and lifecycle actionsPOST .../rotate, .../suspend, .../reactivate, .../revoke, plusGET .../audit and GET /api/admin/partners/_meta/catalogue (the closedSCOPE_CATALOGUE and KNOWN_SERVICES sets). Secrets are shown once oncountry_code, org_number, and auth_kind are immutableorganisation_audit row.frontend_bp exposes GET /api/admin/service-keys,
GET /api/admin/service-keys/<service_name>/users, and
POST /api/admin/service-keys/<service_name>/generate for managing the
SSO_CLIENT_ID/SSO_CLIENT_SECRET credentials that downstream services present
on /me/service.
Each request passes through these layers in order:
Origin header against ALLOWED_ORIGINS.jti revocation check,iat < user.token_revocation_epoch check (#44), and user loading intog.current_user (with g.token_payload carrying the sid).Additional hardening: ProxyFix (trusts one hop of X-Forwarded-*), secure
session cookies (Secure/HttpOnly/SameSite=Lax), security headers
(X-Frame-Options, X-Content-Type-Options, HSTS, etc.), and a 16 MB upload
cap.
require_organisation was removed — do not re-add it (#709, 2026-09-29)A require_organisation decorator used to sit in auth_middleware.py: "a
professional must hold at least one organisation membership; SU admins
bypass." It was written before 2026-03-24, deployed uncommitted, and swept
into git by the #365 reconciliation. It was never applied to a route.
The #704 triage flagged it as a rule somebody had intended but was not
enforcing — and the conclusion is the opposite of that. The only routes it
would fit are the four @require_professional ones in routes/groups.py, and
every one of them is how a professional obtains a membership: list-groups,
request-membership, request-admin, join-by-invite. Gating those on already
having one locks a new professional out of the only path to getting one,
permanently.
tests/test_groups.py::TestRequestMembership now pins this: its "regular"
professional holds no UserOrganisation row and must succeed. So the decorator
was not an unenforced rule, it was a rule that would have been wrong to
enforce, and the test exists to stop it coming back.
revoked_tokens grows by one row per logout and per session flush and nothing
ever removed them. A row is only useful until the token it names would have
expired on its own; after that it is answering a question no one can ask.
app/scripts/prune_tokens.py deletes rows whose underlying token has expired,
and jwt_service exposes the predicate so the script and the revocation check
agree on what "expired" means rather than each deciding.
Installed in miserver's crontab, daily at 04:07 UTC:
7 4 * * * docker exec sso_app python scripts/prune_tokens.py 2>&1 | \
while read l; do echo "$(date -u) $l"; done >> ~/logs/prune_tokens.log
Two things about that line are deliberate. There is no % anywhere: in a
crontab a bare % means a newline, so a date +%F inside a cron line silently
never runs. And the timestamp comes from the while read wrapper rather than
from the cron line itself, for the same reason.
requirements.txt is pinned to the versions this service is actually
running, frozen from the live container rather than resolved afresh. Before
that, a docker-compose up -d --build was also an unannounced dependency
upgrade: the same command on the same day held ips's stack still and moved
sso's, bcrypt 4 to 5 among them. The pins only cover direct dependencies, which
the file's own header states.
| Port | Service |
|---|---|
| 9000 | Flask application (Gunicorn) |
| 9003 | PostgreSQL database |