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 --builddetached, 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.

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/.


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