# provenance — a bill-of-materials and compliance register for RODMENA LIMITED https://provenance.rodmena.co.uk This file is written and maintained BY HAND. It is not generated from the code, so it can be wrong in a way an OpenAPI document cannot — if a route here does not behave as described, the route is right and this file is stale. Report it. WHY IT EXISTS RATHER THAN /openapi.json: the live schema endpoints (/openapi.json, /docs, /redoc) are deliberately 404 in production. That hardening was the right call and it created an obligation it did not meet — a legitimate integrator could not discover the API at all, and the only way to learn what a credential held was to provoke a 403 and read the error. This closes the gap without re-exposing a reflective schema. ## Authentication Two credentials, and they behave differently in ways worth knowing before you debug something. - **API key** — `Authorization: Bearer prov__`. Carries explicit SCOPES and no roles. Mandatory expiry, capped at one year. The secret is stored as a SHA-256 digest and is shown exactly once, at creation; it cannot be recovered, so a lost key is revoked and reissued rather than looked up. - **Session cookie** — set by the OIDC round trip at `/auth/login`. Carries ROLES, which expand to permissions. `GET /api/v1/whoami` tells you which one you presented and exactly what it may do. Start there rather than guessing. ### Scopes no key can ever hold `admin_policy` · `asset_dispose` · `vex_approve` · `chain_acknowledge` Refused on a key by anyone, including an administrator. Machines record and propose; a person approves. A 403 on one of these from a key is the design working, not a permissions bug. `pipeline_run` is the mirror image: held by no human role, because the record that a scheduled control ran is only evidence if the scheduled thing is the only writer. ## Conventions - **Unknown query parameters are refused (400), never ignored.** An ignored parameter is worse than a refused one: a caller paging with a name this API does not know would receive the first page for ever. The error names the accepted set. - **Unknown body fields are refused (400), naming the field.** One misspelled key once cleared a field and returned 200. Bodies are strict everywhere now. - **An absent value is never an erasure.** On `/amend`, `value` is required. Send `"value": null` explicitly to record "not known". - **Collections say how much you were given.** `X-Total-Count`, `X-Limit`, `X-Offset` and RFC 8288 `Link`. Read the count; do not measure the array. `limit` above 1000 is refused with 400, not silently clamped. - **Nothing is ever deleted.** Disposal, retirement and revocation are STATES. Corrections are `/amend`, which records the previous value and a reason. That is why there is no PATCH, PUT or DELETE anywhere. - **A 403 is permanent, a 503 is ours.** A row-level security refusal is 403 and says retrying will not help. A 503 carries `Retry-After` and means we failed. - **Every mutation is written to a hash-chained, append-only audit log**, and reads are logged too. ## Routes ### Identity GET /api/v1/whoami what your credential is and may do GET /auth/login starts the OIDC round trip POST /auth/logout POST only; 405 on GET, by design GET /auth/me the signed-in person (cookie only) POST /auth/backchannel-logout OIDC back-channel, from identity ### Hardware register GET /api/v1/assets ?state ?site ?custodian ?include_retired ?limit ?offset POST /api/v1/assets asset_write GET /api/v1/assets/{id} GET /api/v1/assets/{id}/history POST /api/v1/assets/{id}/events the lifecycle log; asset_write POST /api/v1/assets/{id}/amend correct a recorded fact; asset_write POST /api/v1/assets/sites/rename a site's NAME changed, nothing moved; asset_write POST /api/v1/assets/{id}/dispose asset_dispose — HUMAN ONLY GET /api/v1/assets/held-by/{sub} offboarding: what this custodian holds GET /api/v1/assets/as-of the register as it stood on a date `/amend` takes `{"field", "value", "reason"}`. Amendable: asset_tag, category, data_classification, disk_encrypted, hostname, make, model, notes, serial_number, site. Custodian and lifecycle state are EVENTS, not corrections, and a known site changes by a `moved` event; `site` is amendable only where it was never established. A boolean field takes a JSON boolean or null, never the string "true". Any other field is `400`, and the error lists the amendable set. `/sites/rename` takes `{"from", "to", "reason"}` and renames the site on every asset there, retired ones included, with one `asset.site_renamed` audit row per asset. A site nobody is at is `404`. ### Evidence files (screenshots, PDFs, text) Files attach to a record, are stored encrypted, and are served only through these routes. Nothing is deleted: a file is SUPERSEDED, and the history keeps it. is one of: /api/v1/assets/{id}/attachments asset_write to upload, asset_read to read /api/v1/assets/{id}/ce-requirements/{requirement}/attachments asset_write / asset_read /api/v1/ce/organisation/{requirement}/attachments admin_policy / report_read POST the raw file as the body; ?supersedes= to replace one GET {attachments: [...], current: n} GET /{id} one record GET /{id}/content the bytes, with X-Provenance-Sha256 Upload headers: `Content-Type: application/octet-stream` (anything else is 415) and `Content-Disposition: attachment; filename*=UTF-8''`. The type is read from the bytes: PNG, JPEG, PDF or UTF-8 text, 20 MiB at most. A cookie upload must come from the site origin. Answers: 201 with the record; 200 with `duplicate: true` when the same bytes are already current; 400 for a bad name or an extension that does not match the bytes; 404 for a CE requirement with no answer recorded yet (record the answer first); 409 when the supersede target is already superseded or 50 files are current; 413 over 20 MiB; 415 for a type not allowed or a PDF with active content; 503 when storage is unavailable (retry). `filename` is null when the uploader was erased. Render the content safely: images via `` from the same origin, text as text (never HTML), and PDFs as download links. A `502 integrity_failure` from `/content` means the stored file failed verification, and no bytes are served. `custodian_sub` is recorded AS GIVEN and is not resolved against any register of people. An OIDC subject or a team identifier such as `team:infra` are both valid; a machine no person holds is the case the second exists for. Lifecycle events: `assigned` `returned` `moved` `repaired` `lost` `stolen` `wiped` `retired` `disposed`. The vocabulary is employee-device shaped. A server running a workload is modelled by a DEPLOYMENT bound to the asset, not by a lifecycle state. ### Software inventory GET /api/v1/products ?include_retired POST /api/v1/products release_write GET /api/v1/products/{ref} slug or id POST /api/v1/products/{ref}/amend POST /api/v1/products/{ref}/retire POST /api/v1/products/{ref}/unretire GET /api/v1/products/{slug}/releases POST /api/v1/products/{slug}/releases release_write GET /api/v1/releases/{id}/sbom POST /api/v1/releases/{id}/sbom sbom_publish; CycloneDX 1.2-1.6 POST /api/v1/releases/{id}/scan GET /api/v1/components ?purl ?scope GET /api/v1/components/{id}/releases who ships this GET /api/v1/sbom/estate Counts describe the WORKING SET — products that are not retired, and everything under them. The whole record is reported alongside, never instead. CycloneDX 1.2 through 1.6 are accepted; anything else is refused with 422 naming the supported set. 1.6 is what current cyclonedx-py emits by default and needs no `--sv` flag. The document you publish is stored verbatim and returned byte-exact, so a retrieval is your bytes and not a re-render — the 1.5 on `/api/v1/sbom/estate` is a document COMPOSED across the estate, not a replay of anything you sent. A component carrying no `purl` is not stored: it cannot be deduplicated or matched against any advisory feed. The response says so — `components_in_document` is what you sent, `components_total` is what was kept, and `components_skipped` names each omission and why. Check the skip list; do not assume the counts agree. A release accepts `version`, `git_sha`, `source_kind`, `source_note`, `ci_pipeline` and `built_at`, and nothing else. An unknown field is refused with `400` and `extra_forbidden` naming the field you got wrong — so a plausible-sounding name like `commit` or `vcs_ref` is rejected — but that error does not list the accepted set, so take it from here rather than guessing. `source_kind` says how the recorded `git_sha` relates to the bytes actually running, and it is the field most callers do not know they need. Send a value outside the three and the error DOES enumerate them, which is the one place the API tells you the accepted set: built_from_commit the artefact was built from that commit. deployed_from_working_tree the process reads a DIRECTORY, not a commit — an editable install, an rsync, a live checkout. Requires a non-empty `source_note` saying why, enforced by a database CHECK rather than only by the API, so it holds for every row however it was written. unspecified the default, and the honest answer when nobody was asked. Releases created by the repository sweep are `unspecified`: the register does not claim a build it cannot verify. An unqualified `git_sha` reads as "this release was built from that commit", which is a guarantee the model does not make and cannot check. `source_kind` is how you say something weaker and true instead. ### Vulnerabilities and VEX GET /api/v1/findings ?severity ?deployed_only ?unassessed_only GET /api/v1/findings/{id} POST /api/v1/findings/{id}/vex vex_author — PROPOSES a position POST /api/v1/vex/{id}/approve vex_approve — HUMAN ONLY GET /api/v1/alerts GET /api/v1/licenses GET /api/v1/license-policy PUT /api/v1/license-policy/{spdx_id} license_waive A proposed `not_affected` changes no count anywhere until a human approves it. `affected` means "we have confirmed we ARE vulnerable" — it is not an answer. ### Deployments GET /api/v1/deployments POST /api/v1/deployments release_id + environment_slug, optional asset_id POST /api/v1/deployments/{id}/withdraw GET /api/v1/environments POST /api/v1/environments Binding a deployment to an `asset_id` is how a machine becomes "running this release in production". All three references are resolved and 404 if unknown. ### Compliance and audit GET /api/v1/audit/events ?entity_type ?entity_id ?order=seq_asc|seq_desc GET /api/v1/audit/verify recompute the chain; every break, with its cause POST /api/v1/audit/acknowledge chain_acknowledge — HUMAN ONLY GET /api/v1/evidence/controls GET /api/v1/evidence/pack ?scheme ?ref — JSON GET /api/v1/evidence/pack.html GET /api/v1/evidence/pack.pdf GET /api/v1/evidence/blocking report_read — what stands in the way, without exporting GET /api/v1/dashboard GET /api/v1/pipelines/runs GET /api/v1/pipelines/schedule Fetching `/evidence/pack` in any format records an `evidence.export` in the audit trail, because an export is something an auditor must be able to see. To READ whether anything blocks certification, use `/evidence/blocking` instead. It is the same computation, recorded as an access rather than an export: `{scheme, generated_at, blocking, evidence_gaps}`. An empty `blocking` list is not "ready to submit" while `evidence_gaps.total` is above zero. `/audit/verify` publishes the canonical hashed payload and a content anchor per break, so you can recompute the chain yourself from `/audit/events` without trusting this service. You do not need our word for it. ### Cyber Essentials assessment GET /api/v1/ce/requirements the vocabulary: every requirement, its wording, the states and methods GET /api/v1/assets/{id}/ce-requirements what is recorded for one device, and what is NOT assessed PUT /api/v1/assets/{id}/ce-requirements asset_write — record a device's answers GET /api/v1/ce/organisation the organisation-level requirements (access control and similar) PUT /api/v1/ce/organisation admin_policy — record the organisation's answers Both PUTs take the same body: {"states": [{"requirement": "firewall.present", "state": true, "applicable": true, "method": "observed", "evidence_note": "..."}]} Unknown fields are refused with `400 extra_forbidden`. Requirements you do not send stay NOT ASSESSED — they are listed, never omitted, and never counted as met. Generate your form from `GET /api/v1/ce/requirements` rather than hard-coding the requirement slugs; it also names the NCSC scheme version the vocabulary comes from, so store it beside a saved assessment. `state` is a boolean or null, and absence is a fourth state. Keep them apart: true established: the requirement holds false established: it does NOT hold — a finding null looked at, could not be determined absent never assessed. NOT a "no", and not a zero. Send `applicable: false`, with the reason in `evidence_note`, when the requirement's own condition does not hold for the device — the scheme makes several conditional (malware protection needs ONE mechanism, not all of them). Recording such a requirement as `false` instead overstates the estate's failures and buries the real ones. `method` is `observed` or `declared`, per answer rather than per form: an assessor may accept a declaration for a phone and want an observation for a server. Anything else is refused with `400` on both PUTs, and the error names the two accepted values. ### Administration (admin_policy — human only) GET /api/v1/admin/api-keys POST /api/v1/admin/api-keys POST /api/v1/admin/api-keys/{id}/revoke POST /api/v1/admin/sessions/revoke GET /api/v1/admin/roles GET /api/v1/admin/scopes ### Operations GET /healthz answers HEAD identically GET /.well-known/security.txt RFC 9116 ## Known limits, stated rather than discovered - **No bulk import.** Assets are created one request at a time. For a fleet onboarding this is not a viable path; say what size estate you are importing. - **A release cannot be amended or retired.** Products retire and unretire, assets are disposed, keys are revoked — a release does neither. A release created with the wrong version or a missing `git_sha` is permanent, still counts against the cumulative release limit, and the only remedy is to create another. This is the exception to "corrections are `/amend`" above: get releases right the first time, because there is no correction path. - **Tenant isolation is not exercised.** Row visibility is PostgreSQL RLS, and it has only ever run with one tenant present. - **A findings read with only `finding_read` returns 200 and an empty list.** The route is gated on `finding_read`, but its query joins `component`, `release` and `product`, whose row-level policies require `sbom_read`. A key holding exactly the documented scope passes the gate, matches zero rows at the join, and is told the register is empty — with a 200, every time. Until the gate is corrected to demand both, give any key that reads findings `finding_read` AND `sbom_read`. A session with a normal role is unaffected. Reported as provenance #104; the intended fix is a 403 naming the missing scope, because a finding names a component, a release and a product, so reading findings does disclose SBOM data and the gate should say so. ## Reporting a defect security.txt for anything security-relevant. Otherwise: the exact request, the response, the `request_id` header, and what you expected. Findings are re-run before they are agreed OR disagreed with, and you will be told which.