All documentation Download (Markdown) Technical

onboard · onboard.pdhc.se

Technical manual


onboard.pdhc — technical manual

Guided, two-sided provider onboarding. Two people on a telephone fill in one shared agreement from two separate windows; the run ends with a FHIR Contract in contract.pdhc and a Provider Access Token in request.pdhc.

Ports 9120 (app) / 9121 (db). Containerised: onboard_pdhc_app / onboard_pdhc_db, compose project onboard_pdhc.


1. The problem it replaces

A CLI runbook executed by one person on behalf of two. Everything that went wrong with earlier provider onboardings went wrong in the gap between what the provider believed they had agreed and what was actually written down: a legal name PDHC guessed, a delivery URL typed from memory, a concept count nobody read back.

Hence the single rule the design turns on:

Nobody fills in the other side’s facts.

A field is owned by the admin, by the provider, or shared. An owned field can only be set by its owner and must be accepted by the other side; a shared field needs both acceptances.


2. The room model

One onboarding call is one room.

Table Holds
onboarding_rooms the call: status, invite/room-code hashes, liveness, contract + PAT guids
room_fields one agreed value each: key, value, owner, both acceptance timestamps, locked
room_events append-only audit
provider_sessions a provider’s authenticated session inside one room
room_notes free text the admin keeps during the call

Field ownership

app/catalogue.py is the only authority on who may fill a field in. Fourteen declared fields plus one dynamic family (scope.concept.*), each carrying Swedish and English labels, plain-language help, technical help, what it maps to downstream, and a validator.

State machine

DRAFT → NEGOTIATING → AGREED → CONTRACT_WRITTEN → SECRET_ISSUED → VERIFIED → LIVE

Forward only. ABANDONED is reachable from anywhere except LIVE — a live provider is a fact about production, not a draft to throw away; revoke the credential instead.

reopen() is deliberately not a transition. It returns a LIVE room to negotiation while keeping the existing contract_guid, because a PAT is bound to that guid: replacing the contract would dangle the token and break grant exchange.

agree() locks every field.


3. Two windows, one write path

Everything goes through app/services/rooms.py.

Admin window — /api/admin, SSO, SU admin only

Stricter than contract.pdhc’s “any professional”, and deliberately so: the call ends by minting a PAT in request.pdhc, whose routes grant admin only on is_su_admin. A non-SU professional could negotiate the whole contract and fail at the last step with the provider on the telephone.

Provider window — /r/<token>, no PDHC account

An external company cannot be required to hold an account before it has a contract — that is the thing being negotiated. Three independent defences:

  1. A wrong or expired invite gets a flat 404. Distinguishing them tells a prober which guesses are live.
  2. A spoken room code, hashed, five attempts. The link goes by email; the code is read aloud. An intercepted mailbox is not enough.
  3. A liveness gate. The room opens only while an admin is hosting, so a leaked link is not a standing door.

Invite tokens and room codes are stored hashed and compared in constant time.

State is polled, not pushed. Adequate for two people on a call, and it keeps a long-lived connection off the public surface.

Browser surfaces (#702)

Two HTML surfaces render the API; they are clients for it, not a second implementation, and every write goes through the same rooms service so the two cannot drift apart on what is allowed.

Path Who Auth
/admin, /admin/<guid> PDHC administrator sso session (/auth/login → sso.pdhc → /auth/callback)
/r/<token> the provider invite token + spoken room code; no PDHC account
/api/admin/*, /api/r/* integrators, scripts bearer / the same token + code

/r/<token> is the human page. That URL is what gets emailed to a provider, so it belongs to the page a person opens; the JSON provider API moved to /api/r/ when the window was built.

The loader answers browsers and API clients differently. A browser is redirected to log in; anything under /api/ gets JSON. Redirecting an API client would hand it a login page with a 200, which it would read as success.

The admin has no route to mint the credential. The provider’s reveal is what mints it. An admin button would mint first and leave the provider’s reveal returning 409 — with PDHC having held a credential it is meant never to see.


4. Outbound calls

Target Call Auth
plan.pdhc list / fetch PlanDefinitions none (read)
sso.pdhc organisation list, link or create acting admin’s bearer
contract.pdhc POST /fhir/Contract + X-Skip-Auto-Provision: 1 CONTRACT_SERVICE_KEY
request.pdhc PAT mint / rotate, signing secret, validate, sandbox dispatch acting admin’s bearer as X-API-Key

Two of those deserve explanation.

X-Skip-Auto-Provision: 1 — contract.pdhc otherwise auto-provisions a PAT skeleton on contract creation, and onboard mints the real one itself. Two tokens for one contract is how a dangling PAT is made.

request.pdhc takes no service key. Its PAT routes are @requires_auth @requires_role('admin'); requires_auth does not accept X-Service-Key at all, and requires_role caps a blob whose user_type is service at read_write. No machine key can reach those routes under any header — by design, which is the right posture for minting a credential. The acting administrator’s own bearer is forwarded instead, which also puts the real person in request.pdhc’s audit log.


5. Scope is derived, never typed

The contract is always based on an existing care plan: a hand-assembled concept list can disagree with the plan it claims to implement.

Concepts flatten to a set keyed by guid. A concept appearing as a goal’s measurement and in several scheduled transactions is one concept to send. Counting occurrences instead is what produced a 34-vs-76 disagreement on a real onboarding. The count is shown and both sides accept it.

Two plan identifiers are stored separately:

They are genuinely different values in production. A service handed the wrong one reports that the contract has no care plan at all.

Each concept becomes scope.concept.<guid>.obligation, defaulting to obligatory. excluded leaves the contract entirely — it is not “optional”.


6. Secrets

Threat model, public window

Risk Mitigation
Guessed invite token 256-bit, hashed at rest, flat 404 on miss
Intercepted email the room code is spoken, never in the same channel
Leaked link reused later liveness gate — shut unless an admin is hosting
Code brute force five attempts, then refused
Secret in logs raw value never stored; audit carries the guid
Reading another room every route resolves the room from the token; no room id is accepted from the client

7. Configuration

APP_PORT=9120
DB_PORT=9121
AUTH_MODE=sso                 # 'off' is local dev only
SSO_BASE_URL / SSO_CLIENT_ID / SSO_CLIENT_SECRET
CONTRACT_BASE_URL / CONTRACT_SERVICE_KEY
REQUEST_BASE_URL              # no key — see §4
PLAN_BASE_URL
INVITE_TTL_HOURS
ROOM_CODE_TTL_MINUTES

The app binds 127.0.0.1 only; external traffic arrives via the reverse proxy.


8. Deployment

Release-symlink layout under /usr/local/www/onboard.pdhc/, current pointing at a timestamped release, shared/ for logs.

Migrations run in the container entrypoint (flask db upgrade) before gunicorn starts, and fail loudly rather than serving a stale schema. Deploy with docker-compose up -d --build — COPY . . bakes source into the image, so a restart without --build runs the old code.

Rollback is ln -snf releases/<previous> current plus a rebuild.


9. Known gaps