Skip to main content

ADR-0194: The PEPPOL machine surface is API-key authenticated and tenant-scoped

Date: 2026-08-23 Status: Accepted Deciders: Adrian (project owner, decision recorded 2026-08-23), Claude Opus 5

Context

ADR-0006 specified PEPPOL Verify as a machine-to-machine REST surface authenticated by an API key. ADR-0165 superseded it for a different reason — it withdrew 0006's false "no LLM involvement" rationale — so 0006's transport and credential decision was carried along into supersession without ever being re-decided.

Meanwhile the router had acquired a second requirement. main.py mounted it as include_router(peppol.router, prefix="/v1/peppol", dependencies=[Depends(get_current_user)]), and POST /verify additionally carried require_permission(Permission.CASE_WRITE). So a partner holding a valid API key received 401, and no ADR recorded that the credential model had changed. Issue #950 named this.

Two further facts were established while implementing it, and both change the decision:

  1. The router serves two audiences. POST /verify and GET /evidence/{verification_id} are machine surfaces. But GET /case-verification/{workflow_id} is officer-dashboard-facing — its own docstring says "No API key required" — and carries a Codex P1 fix that scoped it to the caller's tenant, recording that "nothing but that RLS accident stood between one tenant's id and another tenant's result". A router-level dependency cannot express a split, which is why the JWT had been applied to all three.

  2. The machine read was not authorised, only authenticated (#1200). GET /evidence/{verification_id} resolved the record under get_admin_session() — an RLS bypass — filtering on verification_id alone, justified in a comment as "the key already authorised the requestor". Ids are enumerable: get_next_verification_id renders tr-pv-<year>-<5-digit sequence> from a PostgreSQL SEQUENCE. The JWT requirement #950 asks to remove was the only thing in front of it.

  3. No key could be issued. PeppolApiKey.issue() had no non-test caller repo-wide, which is why (2) was never exploitable and why lifting the JWT alone would have changed nothing for any real partner.

Decision

Restore the machine surface to API-key authentication, per audience, with the authorisation gap closed first.

  • POST /v1/peppol/verify and GET /v1/peppol/evidence/{verification_id} authenticate with X-API-Key alone. The blanket mount dependency is removed.
  • GET /v1/peppol/case-verification/{workflow_id} declares Depends(get_current_user) on the route, keeping its bearer requirement and its tenant scoping.
  • The key's tenant is the authorisation boundary. require_api_key_tenant yields the tenant the key is bound to and refuses a key with none (403 — the credential is valid, what it lacks is authority).
  • get_verification's tenant_id becomes a required keyword and the admin-session branch is deleted. The evidence route runs that scoped lookup first and for both formats, because MinIO does not enforce RLS and the JSON path would otherwise read by id with no tenant anywhere in the call.
  • Keys are issued through POST /api/admin/peppol-api-keys, gated on Permission.CONFIG_WRITE (tenant_admin, super_admin; not officer). The tenant is taken from the authenticated caller's session and the request model has no tenant_id field at all — a field that must be validated is a field a reviewer has to notice is validated. The raw secret is returned once; the row stores only its SHA-256 digest.

Consequences

Positive

  • The surface ADR-0006 specified is reachable for the first time: a key can be issued and used without a Keycloak account.
  • A cross-tenant read of every tenant's verification evidence is closed, and closed structurally — a caller with no tenant cannot call the lookup at all.
  • The published contract now states the split: the machine routes advertise APIKeyHeader, the dashboard route advertises HTTPBearer.
  • The null-tenant state that selected the bypass is unreachable from both directions — refused at read, and impossible to create at issuance.

Negative

  • The evidence surface is now reachable without a Keycloak principal. That is the intent, but it is a genuine widening: previously two credentials were required and now one is. The mitigation is that the one credential is tenant-bound and the read is tenant-scoped, which was not true before.
  • Bearer-path rate limiting remains unimplemented (#1181), so the key's own rate_limit_per_minute is the only throttle on a route that no longer requires a human session.
  • A tenant_admin can now mint a credential that reads their tenant's verification evidence without further review. Issuance and revocation are audited, but there is no four-eyes gate on it.
  • POST /v1/peppol/verify is exempt from test_authz_route_coverage's role/permission requirement. The exemption follows the existing portal-token precedent and is backed by a positive assertion that the route reaches verify_api_key, but it is one more entry on a list that should stay short.

Neutral

  • ADR-0006 stays Superseded by ADR-0165. This ADR does not revive it; it decides the same question again, on current facts, with the audience split and the authorisation fix that 0006 did not contemplate.
  • The exporter's co-enforced-credential merge (#995) is now unused by any live route. It is retained, and its test generalised to a rule over every path.

Alternatives Considered

Alternative 1: Keep both credentials and supersede ADR-0006

  • Record that the surface requires machine key AND human JWT, matching what the code did.
  • Why rejected: the owner's decision was to restore machine-to-machine access. It would also have left #1200 unfound — the defect surfaced only because lifting the JWT forced an examination of what the key alone authorised.

Alternative 2: Lift the JWT without the scoping fix

  • The literal request, done in isolation.
  • Why rejected: it converts a latent authorisation defect into a live cross-tenant read. The ids are enumerable and the read ran under an RLS bypass, so the JWT was the only control present. The scoping fix is a precondition, not a companion.

Alternative 3: Lift the JWT at the router mount for all three routes

  • The simplest edit: delete the mount dependency.
  • Why rejected: it silently reverts a Codex P1 on the officer dashboard route, which is not part of the machine surface and has no API key to fall back on.

Alternative 4: Accept tenant_id in the issuance request, validated

  • Let an admin state the tenant, and check it matches their own.
  • Why rejected: a validated field is a field whose validation someone must keep correct. Taking the tenant from the session removes the class — there is nothing to validate and nothing to forget.
Decision context:
- Latency: unchanged. One fewer dependency on the machine routes (no JWKS
fetch/verify); the added scoped WHERE is on an indexed column.
- Dependency surface: no new packages. `secrets` and `sqlalchemy` only.
- Debuggability: a refused key now names its reason (403, "not bound to a
tenant") rather than presenting as a 404 on every read. Issuance and
revocation write immutable audit rows carrying the key_id, never the secret.
- Reversibility: single-commit revert. The scoping fix should NOT be reverted
with it — it stands on its own and #1200 is independent of the credential
decision.
- Blast radius: three routes, one persistence method (two call sites), one new
admin router. No migration; the table and its digest column already existed.
- Alternative considered: keep both credentials and supersede ADR-0006 —
rejected because the owner's decision was to restore machine access, and
because it would have left #1200 undiscovered.