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).
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./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.
| Port | Service |
|---|---|
| 9000 | Flask application (Gunicorn) |
| 9003 | PostgreSQL database |