All documentation Download (Markdown) Technical

Contract · contract.pdhc.se

Technical manual


Contract Service — Architecture

Technical architecture of the PDHC Contract Manager, covering container topology, request/data flows, the consent side effects, the data model, and security posture.


1) System overview

1.1 Purpose

The Contract Manager is a FHIR R5 microservice for creating, reading, updating, and deleting healthcare Contract resources. It provides public, rate-limited read access and SSO-authenticated write access. Beyond CRUD, a contract write drives three integration side effects: scope-concept validation against plan.pdhc, PatientConsent emission to ips.pdhc, and PAT auto-provisioning to request.pdhc.

1.2 Position in the PDHC platform

The Contract Manager is one service in the PDHC family, alongside:

Each service runs independently on its own port block and Docker Compose project.

The platform has three peer concepts for governing who may see
whose data. They are not interchangeable. New code consistently
gets this wrong — the symptom is usually a contract being asked to
do something it cannot, and a Patient* row not being created where
one should have been. Pick by what is being expressed:

You want to express… Use… Lives in PDL/legal basis
"Organisation A may submit observations on concepts C[] under provider B's care plans" Contract (term[] with request_scope / return_scope) contract.pdhc Civil agreement between two orgs — not a patient-data ruling
"Patient P consents that caregiver G may read their data (optionally only concepts C[])" PatientConsent ips.pdhc (/api/v1/patients/<guid>/consents) Lag (2022:913) § 5 cohesive-care consent
"Patient P blocks caregiver/clinic S from reading their data" PatientBlock ips.pdhc (/api/v1/patients/<guid>/blocks) PDL Ch 4 § 4 spärr

The shape is the giveaway:

When a contract is in a grant status and a Patient/<guid> reference
appears in signer[], contract.pdhc emits a PatientConsent row on
ips.pdhc as a side effect (granted_via='contract',
contract_guid=<linkback>). The signer reference and the auto-emitted
consent are two distinct artefacts in two distinct services, related by
contract_guid:

If you only need patient consent (no civil agreement, no concept
scope, no provider org party) — author the PatientConsent directly
on ips.pdhc. Inventing a contract just to get a consent row is the
wrong tool.

If you only need a block — go straight to PatientBlock. A contract
cannot revoke another organisation's read rights to a patient's data;
that is structurally a different decision (the patient's, not the
caregiver's).


2) Container topology

2.1 Architecture diagram

┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│   browser    │────▶│  web:9022   │     │  db:9020    │
│              │     │  (nginx)    │     │ (PostgreSQL) │
└─────────────┘     └─────────────┘     └──────┬──────┘
                           │                    │
                           │ API calls          │ SQL
                           ▼                    │
                    ┌─────────────┐             │
                    │  api:9021   │─────────────┘
                    │  (Flask)    │
                    └─────────────┘

All three are one Docker Compose project (name: contract). The pgdata volume is declared external as app_contracts_pgdata so a rebuild never silently forks a second data volume.

2.2 Port map

Service Container Port Host bind Purpose
db 5432 127.0.0.1:9020 PostgreSQL 16
api 9021 127.0.0.1:9021 Flask REST API
web 80 127.0.0.1:9022 nginx serving SPA + docs

Every port is pinned to 127.0.0.1 (ticket #72) so only the reverse proxy — not the LAN — can reach the containers. Binding to 0.0.0.0 would expose the DB/API directly and bypass SSO (CLAUDE.md §3).

2.3 Service-to-service consumers and callees

Inbound (other services call the Contract Manager):

Outbound (the Contract Manager calls, as side effects of a write):

This internal/outbound layer is separate from the public FHIR API and the SSO-authenticated write API.


3) Request and data flows

3.1 Public read flow

Browser → GET localhost:9022 → nginx serves index.html (SPA)
SPA JS → GET localhost:9021/fhir/Contract → Flask → PostgreSQL → JSON Bundle

Read endpoints (/fhir/metadata, /fhir/Contract, /fhir/Contract/{guid}, /fhir/Contract/{guid}/scope) are public and rate-limited per IP (flask-limiter, in-memory store, READ_RATE_LIMIT).

3.2 SSO auth flow

Production runs with AUTH_DISABLED=false. The SPA never handles a password.

  1. GET /api/v1/auth/login — store a CSRF state in the session, redirect to SSO_BASE_URL/login?next=<callback>&state=<state>.
  2. SSO authenticates the user and redirects back to GET /api/v1/auth/callback?token=…&state=….
  3. The callback validates state, then calls sso.pdhc /api/auth/me/service (headers X-SSO-Client-Id / X-SSO-Client-Secret) to validate the token and obtain the access blob.
  4. If blob.must_change_password is set, redirect to SSO_BASE_URL/change-password and mint nothing.
  5. Otherwise derive a role from the blob (§5.2), mint an 8-hour local JWT with the derived role plus reform/legacy identity claims (organization_ids/care_unit_guids, effective_phases/session_phases, is_su_admin, user_type, …), and redirect to PUBLIC_WEB_URL/?sso_token=<jwt>.

GET /api/v1/auth/me echoes the JWT claims; GET /api/v1/auth/logout clears the session.

The token is not cached — every request that needs identity re-reads the JWT the callback minted; an SSO-side change takes effect on the next login. The legacy local POST /auth/login is inert unless AUTH_DISABLED=true (dev-only, see §5.1).

3.3 Admin write flow (create / update)

POST /fhir/Contract and PUT /fhir/Contract/{guid} both require the admin role and run the same pipeline:

  1. Shape validationensure_contract_shape() enforces the FHIR R5 shape (resourceType, status, party/topic/signer/term structure). Failure → 400 validation.
  2. Scope-concept validation_validate_scope_concepts() extracts every concept GUID from term[].asset[].typeReference[] and verifies each exists in plan.pdhc. A missing concept → 422 scope_concept_missing; plan.pdhc unreachable under STRICT_SCOPE_CONCEPTS=true503 scope_validation_unavailable. With the flag false, validation is skipped.
  3. Signer resolution_verify_signers() resolves each signer[] reference against the relevant catalogue. Unresolved references → 400 signer_unresolved (gated by STRICT_SIGNER_VALIDATION).
  4. Persist — INSERT (create, 409 on duplicate id) or overwrite fhir_contract (update, 404 if absent).
  5. PAT auto-provision_auto_provision_pat(): if the status is executed/executable/offered/renewed, POST each provider org to request.pdhc's /api/v1/internal/auto-provision-pat (this is the Medituner-style onboarding trigger). Requires REQUEST_BASE_URL + INTERNAL_SERVICE_KEY.
  6. Consent lifecycle_emit_consents_for_lifecycle(): see §3.5.

Steps 5–6 are best-effort — failures are logged, never propagated to the write response.

3.4 Delete flow

DELETE /fhir/Contract/{guid} (admin) removes the row and returns 204. As a best-effort side effect it calls revoke_patient_consents() for the deleted contract, revoking any consents on ips.pdhc that linked back to it.

After a create/update commits, the status decides the verb:

The reconciler (consent_reconciler.py, CLI flask reconcile-consents) is the recovery path for emissions dropped while IPS was briefly down. It walks every contract in a lifecycle status and re-runs the idempotent emitter/revoker. It lives on this service and runs hourly on the macmini (#246, #243).


4) Data model

The schema is deliberately small — two tables, created via Base.metadata.create_all on boot.

4.1 Users table (users)

Only populated in AUTH_DISABLED=true (dev) installs; in production identity comes from SSO.

Column Type Constraints
guid VARCHAR(36) Primary key, UUID v4
username VARCHAR(128) Unique, not null
password_hash VARCHAR(255) Not null (bcrypt)
role VARCHAR(16) Not null ("admin" or "reader")
is_active BOOLEAN Not null, default true
created_at TIMESTAMPTZ Not null, auto-set to UTC now

4.2 Contract records table (contract_records)

Column Type Constraints
guid VARCHAR(36) Primary key, UUID v4
fhir_contract JSON Not null, stores the full FHIR R5 Contract resource
created_at TIMESTAMPTZ Not null, auto-set to UTC now
updated_at TIMESTAMPTZ Not null, auto-updated on modification

The entire FHIR Contract lives in the fhir_contract JSON — including scope, parties, signers, and four PDHC-defined extensions kept inside Contract.extension[] (so the JSON stays portable across FHIR servers; they ride along without a platform-specific column):

Extension URL Type Purpose
https://contract.pdhc.se/StructureDefinition/legally-ok bool Operator has signed off on legal terms
https://contract.pdhc.se/StructureDefinition/pub-exists bool A personuppgiftsbiträdesavtal (data-processor agreement) exists
https://contract.pdhc.se/StructureDefinition/legal-provider bool Provider is a legally registered entity
https://contract.pdhc.se/StructureDefinition/provider-data-status code ok / deficient / unclear — provider-data verification verdict

The API accepts any of the 15 FHIR R5 contract-status codes, but the UI constrains Contract.status to four: negotiable (Under consideration), executed (Active — only this state qualifies the contract as a basis for fulfilling requests), terminated (Expired), and revoked (Revoked). Other codes are still accepted via the API for compatibility with externally authored Contracts.

4.3 Concept scope shape (term[])

Scope is expressed as FHIR Contract.term[] entries, validated by fhir._validate_terms and read back by get_contract_scope:

Each concept is a typeReference[].reference URL of the form https://…/api/v1/concepts/<uuid>; the GUID is parsed out for the scope endpoints. A contract with no term[] has undefined scope (backward-compatible = all permitted).

4.4 GUID rules


5) Security posture

5.1 Authentication

5.2 Authorization

5.3 Rate limiting

5.4 CORS

5.5 Database security

5.6 Health

GET /health probes the DB and returns the canonical PDHC shape — {status, database, service, version} — with HTTP 200 when the DB is reachable and 503 (degraded / unavailable) when it is not.

Port Allocation

All ports bind to 127.0.0.1 (loopback only); external traffic arrives
via the reverse proxy. (Detailed in §2.2 above.)

Port Service
9021 Flask REST API (Gunicorn)
9020 PostgreSQL database
9022 nginx serving SPA + docs