All documentation Download (Markdown) Layman

Contract · contract.pdhc.se

Layman manual


Contract Service — Operator Manual

This manual covers day-to-day operation of the PDHC Contract Manager: starting, stopping, backing up, configuring authentication, running the consent reconciler, testing, and recovering from common failures.


1) Prerequisites

1.1 Software requirements

The service is all-Docker — the database, API, and web SPA all run as containers under one Docker Compose project. On the host you need:

1.2 Environment variables

The .env file lives at ./app/.env and is read by Docker Compose, which passes the values into the api and db containers. A committed .env.example documents the shape; .env itself is gitignored and filled in by the operator on the server.

The list below reflects the actual variables read by config.py, main.py, and docker-compose.yml.

Required in .env:

Required by the app, but supplied automatically by Compose (override only if you know why):

Database naming (optional, defaulted):

Authentication / SSO (see §4):

Service-to-service integration:

Validation strictness (both default true):

Miscellaneous:

Note: BOOTSTRAP_ADMIN_USERNAME / BOOTSTRAP_ADMIN_PASSWORD exist in docker-compose.yml, but the bootstrap admin is only seeded when AUTH_DISABLED=true (i.e. dev only). See §4.3.


2) Start and stop

2.1 Starting the stack

From the repository root:

./start.sh

start.sh is deliberately conservative — it does no kill -9 on ports (the header says so explicitly; docker compose down handles teardown). In order, it:

  1. Locates a docker-compose binary (prefers /opt/homebrew/bin/docker-compose).
  2. Checks that Docker is running (docker info); on failure it points you at restart_all.sh and exits.
  3. Runs docker compose down to stop the previous containers.
  4. Backs up the database: brings up only the db service, waits, and if pg_isready succeeds runs pg_dumpall -U contracts | gzip into ./db_backups/contracts_<UTC-timestamp>.sql.gz, keeping the 10 most recent dumps.
  5. Runs docker compose up -d --build — detached, rebuilding images. This is what picks up code changes (COPY . . bakes source into the image; a restart without --build would run stale code).
  6. Polls http://localhost:9021/health up to 30 times (2 s apart) and prints the service URLs.

The three containers exposed:

2.2 Stopping and cleanup

Because start.sh runs the stack detached, Ctrl+C does nothing — there is no foreground process to interrupt. Stop the service with Compose:

docker compose down -v deletes the app_contracts_pgdata volume and all contract data. Take a backup first (§3), and never run it on the server without explicit authorization (CLAUDE.md §14).


3) Backup and restore

start.sh already snapshots the DB on every start into ./db_backups/. For an on-demand dump/restore:

3.1 Database backup

With the stack running:

docker exec -t $(docker compose -f app/docker-compose.yml ps -q db) \
  pg_dump -U contracts -d contracts > backup_$(date +%Y%m%d_%H%M%S).sql

3.2 Restore from backup

cat backup_YYYYMMDD_HHMMSS.sql | docker exec -i \
  $(docker compose -f app/docker-compose.yml ps -q db) \
  psql -U contracts -d contracts

For a clean restore, run docker compose down -v first to drop the existing volume, then start the stack and pipe in the backup.


4) Authentication

4.1 Production: SSO (the normal path)

Production always runs with AUTH_DISABLED=false. Users authenticate through sso.pdhc; the Contract Manager never holds their password.

The flow:

  1. Browser hits GET /api/v1/auth/login. The service stores a random CSRF state in the session and redirects to the SSO login page (SSO_BASE_URL/login?next=<callback>&state=<state>).
  2. After the user authenticates at SSO, SSO redirects back to GET /api/v1/auth/callback?token=…&state=….
  3. The service validates the state (CSRF), then validates the token by calling SSO's /api/auth/me/service with the X-SSO-Client-Id / X-SSO-Client-Secret headers. This returns the access blob.
  4. If the blob has must_change_password, the user is bounced to SSO_BASE_URL/change-password and no local token is minted. After clearing it at SSO, a second login lands here with the flag off.
  5. Otherwise the service maps the blob to a local role (§4.2), mints an 8-hour local JWT, and redirects to PUBLIC_WEB_URL/?sso_token=<jwt>. The SPA reads the token from the query string.

GET /api/v1/auth/me returns the current user's claims; GET /api/v1/auth/logout clears the session.

What a user sees. There is no login page. Opening a protected page redirects
straight to sso.pdhc and returns the user signed in, at the page they asked for
— the same behaviour as every other PDHC frontend. The public pages (/,
/contracts, /docs, /api) stay readable without signing in.

A sign-in card appears only when the handshake fails — it carries the
sso_error text and a retry button. If a user reports seeing it, the message on
it is the diagnosis; read that before checking anything else.

Every page carries the platform ribbon PDHC / Contracts in the top left;
PDHC returns to https://www.pdhc.se/services.html.

AUTH_DISABLED=true (dev only)

GET /api/v1/auth/login redirects to the SPA's error surface saying auth is
disabled, rather than returning JSON. The SPA navigates to that URL by
itself now, and a JSON body there would be displayed raw in the browser
with no way back.

4.2 Role derivation

Roles come from the SSO access blob, not from a local user table:

admin can create/update/delete contracts and manage users; reader is read-only (functionally the same as anonymous, but identified). Write endpoints require the admin role.

4.3 Dev-only: local login + bootstrap admin

The local POST /auth/login (username/password against the users table, bcrypt) and the BOOTSTRAP_ADMIN_* seeding are only active when AUTH_DISABLED=true, which config.py refuses to boot outside FLASK_ENV=development. With SSO enabled, POST /auth/login returns 400 with a pointer to /api/v1/auth/login.

So on a normal (production-shaped) install:


When a contract in a lifecycle status is written, the service emits or revokes matching PatientConsent rows on ips.pdhc (see the architecture doc §3 and §6). That emission is best-effort: if IPS is briefly unreachable, the write still succeeds and the consent silently drops.

The reconciler is the recovery path. It lives on this service as a Flask CLI command:

docker exec $(docker compose -f app/docker-compose.yml ps -q api) \
  flask reconcile-consents

It walks every ContractRecord in a grant or revoke status, re-calls the (idempotent) emitter/revoker, and prints a one-line summary:

reconcile-consents checked=<n> grants_re_emitted=<n> revokes_re_called=<n> \
  grant_attempts=<n> revoke_attempts=<n> errors=<n>

On the macmini this runs hourly via cron (#246). A clean DB reports grants_re_emitted=0 revokes_re_called=0; a second run within the same window does ~zero work.


6) Running tests

The backend suite runs in-process against an in-memory SQLite database — no Docker required — provided a local Python venv is set up under app/backend/:

cd app/backend
python -m pytest tests/ -v

If a test-results convention is used, outputs follow ./results/<ISO-8601-UTC>_results/.

6.1 The sibling smoke — run this before a deploy (#708)

The unit suite cannot tell you whether this service can still talk to the four
it depends on, and in this repo it demonstrably did not: all 24 of the signer
resolution tests passed for months while the code called a route that does not
exist with a header ips does not read. Every one of them patched
requests.get and asserted on the status code it had itself chosen, so the URL
and the headers were inputs the mock discarded.

./deploy/smoke_siblings.sh --remote --patient-guid <a-really-assigned-patient>

Read-only. It changes nothing, on any service, and says so at the end.
Everything that writes is behind --for-real, which additionally needs
--provider-org-guid and --contract-guid and names the PAT it minted.

It runs inside contract-api-1, because that is the only place the real
configuration resolves — IPS_BASE_URL is host.docker.internal:9040, which
means nothing from a laptop — and it builds each request with this service's
own client helpers, so a bug in a helper is a bug in the smoke's request
too. That is deliberate: #800 was a bug inside _ips_headers, and a smoke that
composed its own headers would have passed against it.

How to read the output:

result meaning
404 on an absent-guid probe PASS — the credential was accepted, the row simply is not there
401 / 403 our credential or header name is wrong; the sibling is up and refused us
5xx the sibling errored; check its logs, and whether our request is malformed
UNREACHABLE network or base URL, not credentials

--patient-guid matters more than it looks. Without it every ips check is
satisfied by a 404 — including the 404 that a wrong route produces for every
guid
. Only a patient that really exists distinguishes "the route is wrong"
from "that guid is absent".


7) Common failures

7.1 Stack won't start: POSTGRES_PASSWORD must be set

Compose uses ${POSTGRES_PASSWORD:?…}. Set POSTGRES_PASSWORD in app/.env (§1.2).

7.2 API refuses to boot: AUTH_DISABLED=true requires FLASK_ENV=development

A stale .env shipped AUTH_DISABLED=true to a production-shaped install. Either remove AUTH_DISABLED (defaults to false → SSO) or, for genuine local dev, also set FLASK_ENV=development.

7.3 Port conflicts

If a container fails to bind:

lsof -i :9020-9022

start.sh does not free ports for you (by design). Stop whatever owns the port, or cd app && docker compose down to clear a previous instance of this stack.

7.4 Database not ready

If the API exits with "Database not ready after waiting":

7.5 Credential drift (password authentication failed)

If the db container reports (healthy) but the api container fails auth, the Postgres password hash predates the current .env (the postgres password is only applied on first volume init; the trust rule masks it for local clients). Reset it via the trust side door:

docker exec $(docker compose -f app/docker-compose.yml ps -q db) \
  psql -h 127.0.0.1 -U contracts -d contracts \
  -c "ALTER USER contracts WITH PASSWORD '<POSTGRES_PASSWORD from .env>';"
docker restart $(docker compose -f app/docker-compose.yml ps -q api)

See CLAUDE.md §9 for the full write-up.

7.6 Contract writes rejected