feat: keep file bytes in object storage - #10
Merged
Merged
Conversation
Evidence attachments are a large part of what the Cyber Resilience Act expects a manufacturer to keep for ten years (Art. 13(13)), and a mounted volume offers nothing for an obligation that long: no versioning, no object lock, no lifecycle policy, no replication, nothing an operator can point an auditor at. An S3-compatible bucket offers all of them as configuration they already know how to buy, so the bytes move there and the runtime stops serving them. The lifecycle is prepare an upload intent, PUT the bytes straight to a temporary key with a presigned URL, HEAD it as a cheap filter, copy it server-side to its permanent key conditional on the entity tag that was inspected, read the copy back pinned to the tag the copy itself reported, measure size and SHA-256 in one pass, and only then open a short transaction. PostgreSQL owns finality, authorization, idempotency and audit; the store owns bytes; no database connection is held while the store is being talked to. Both durable facts about a file are measured from the permanent object rather than the staged one, because a client writes headers as well as bytes and `Content-Encoding` would otherwise put a checksum in the row that nothing can reconcile with its own bytes. The failure boundary leans one way on purpose: bytes nothing names cost a sweep, while removing bytes a committed row names cannot be undone. A promoted object is removed only where the rollback is certain from the handler's own control flow — every domain refusal is returned as a value, and the one exception is the handler's own. An unexpected driver error keeps the bytes, since such an error does not say whether the commit was made durable before it arrived. An upload intent is infrastructure state rather than a record: tenant-scoped, the runtime's to change, and in no history, because nothing is evidence until a `file` row exists. Its window is the database's to keep — the runtime holds `UPDATE` on `file_id` alone so nothing else can move while the store is being read, and the completion policy judges by `clock_timestamp()` rather than `now()`, which is frozen at transaction start and would admit an upload that expired while waiting for the evidence lock. Two commands come with it. `verify:files` recomputes every checksum against the bucket, and `reclaim:storage` compares the bucket against the rows and reports what nothing claims, removing only when told to. Storage compatibility is a register rather than a claim: CI asks MinIO on every push, and a scheduled workflow asks Cloudflare R2. No deployment is supported yet, so the schema is edited in place rather than migrated.
`minio/minio` and `minio/mc` have been removed from Docker Hub, so CI could no longer start a store at all: `pull access denied ... repository does not exist`. quay.io is where the open-source images remain. Pinned rather than floating, because that line is no longer moving — MinIO's ongoing product is AIStor, which refuses to start without a license file and is not open-source, so it cannot stand in for a test store. A floating tag would buy nothing and would hide the day these images go too. `docs/development.md` carries the explanation, and the workflow points at it.
MinIO's integration run caught this, which is what it is for: promotion read the tag out of `CopyObjectResult` and handed it straight back as `If-Match`, so a provider that escapes the quotes differently made every permanent read fail with 412 and every completion fail with it. AWS writes `"`, Go's `encoding/xml` — so MinIO — writes `"` for the same character, and only the first was decoded. Both are now, and the result is re-quoted rather than passed through, because an entity tag is quoted (RFC 9110) and a provider that omits them would otherwise send a bare token. The five spellings are a table in `objects.test.ts`, so this cannot regress without a real store. The integration suite now asserts the copy's tag against the one a `HEAD` reports before using it as a precondition: a mismatch there says which two strings disagree, where a conditional read only answers 412.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds the files half of evidence: a client uploads bytes straight to an S3-compatible bucket, and PostgreSQL decides whether they ever become an attachment. This is the storage work #8 split out and promised, so ADR 0020's links to ADR 0013 resolve again.
Evidence attachments are a large part of what the CRA expects a manufacturer to keep for ten years (Art. 13(13)), and a mounted volume offers nothing for an obligation that long — no versioning, no object lock, no lifecycle policy, no replication, nothing to point an auditor at. A bucket offers all of them as configuration an operator already knows how to buy.
The routes
POST/evidence/{id}/file-uploads201with a presignedPUTURL, good for fifteen minutesPUT/file-uploads/{id}/completionGET/files/{id}303to a signedGET, good for a minuteEvidence responses gain a
filesfield. A file is at most 25 MiB and evidence carries at most twenty.The lifecycle
sequenceDiagram participant C as Client participant R as Runtime participant S as Bucket participant P as PostgreSQL C->>R: POST …/file-uploads R->>P: insert file_upload (intent, 15 min) R-->>C: 201 + presigned PUT C->>S: PUT uploads/upl_… C->>R: PUT …/completion R->>S: HEAD uploads/upl_… (cheap filter) R->>S: COPY → files/fil_…, if-match the inspected tag R->>S: GET files/fil_…, if-match the copy's own tag Note over R: size + SHA-256, one pass R->>P: insert file, claim the intent R->>S: DELETE uploads/upl_… R-->>C: 200 filePostgreSQL owns finality, authorization, idempotency and audit; the bucket owns bytes. No database connection is held while the store is being talked to —
concurrency.test.tsruns eight completions through a pool of two to prove it.Both durable facts come from the permanent object
A presigned PUT constrains the method and the key, not the headers. A client can store bytes as
Content-Encoding: gzip; the store keeps that as metadata andfetchhands the response back decompressed, while the copy moves the stored bytes and leaves the encoding behind. Hashing the staged object would therefore record a checksum of bytes nobody keeps, and the firstverify:fileswould call a new file altered. The entity tag is no help — it validates content, not metadata.So the row is built from reading
files/{fileId}back, pinned to the tag the copy itself reported, and the size and SHA-256 come from that one stream.Uncertainty keeps bytes
The two mistakes are not equal: bytes nothing names cost a sweep, and removing bytes a committed row names cannot be undone. A promoted object is removed only where the rollback is certain from the handler's own control flow — every domain refusal is returned as a value, and the one exception is the handler's own. An unexpected driver error keeps the bytes, because such an error does not say whether the commit was made durable before it arrived.
Note
An earlier draft classified
SQLSTATEs into "the server refused this statement" and "this says nothing". It was deleted rather than corrected:pgreports PostgreSQL's localized severity and not the non-localized field, and a list of codes that is not exhaustive fails in the direction that destroys evidence. It also only ever decided the unanticipated failures — exactly the ones this asymmetry says to keep.Details worth a reviewer's eye
file_uploadis infrastructure state, in no history, cascading from its evidence. Evidence attested between prepare and completion refuses the completion.UPDATEonfile_idalone, so which evidence an upload is for and when it expires cannot move while the store is being read. The completion policy judges byclock_timestamp()—now()is frozen at transaction start and would admit an upload that expired while waiting for the evidence lock.Path=/apiand noDomain, and startup refuses a bucket whose URL is this host inside that path — judged on{endpoint}/{bucket}together, since a bucket namedapilands a download there as surely as an endpoint path does. Depth, not a boundary: production gives the store a hostname of its own.files/{fileId}would be a standing licence to replace an attested record's bytes.Operating it
bun run verify:filesrecomputes every checksum against the bucket.bun run reclaim:storagecompares the bucket against the rows and reports what nothing claims — removing only with--remove, leaving anything written in the last day, ignoring keys it did not issue, and refusing outright when the database holds no files at all.Storage compatibility is a register rather than a claim: CI asks MinIO on every push, and
.github/workflows/storage-compatibility.ymlasks Cloudflare R2 on a schedule. AWS and B2 are configurations expected to work, not demonstrated ones.Decisions
ADR 0021 records the design. It supersedes ADR 0013's upload shape and mounted-volume adapter — 0013's asymmetry stands, its reading of the failure boundary does not — and builds on ADR 0016 (what
verify:filesis for) and ADR 0012 (attachments are final once attested). ADR 0020 gains the races this adds.Verification
On the committed tree, clean:
Each new guarantee was checked by removing it and watching a test fail: the
clock_timestamp()policy, the claim's row count, the permanent-object measurement, the storage isolation check, thelistprefix guard, the cookie attributes, and the audit payload's five fields.Note
MinIO removed
minio/minioandminio/mcfrom Docker Hub, so CI could not start a store at all —repository does not exist. Both now come from quay.io, pinned: that line has stopped moving, because MinIO's ongoing product is AIStor, which needs a license file and is not open-source.docs/development.mdexplains the choice so nobody "upgrades" it back.Then the MinIO run earned its keep and caught a real provider bug, which is exactly the composition the integration suite exists to check. Promotion read the entity tag out of
CopyObjectResultand handed it straight back asIf-Match; AWS escapes the quotes as"and Go'sencoding/xml— so MinIO — writes", and only the first was decoded. Every permanent read answered 412 and every completion failed with it. Both are decoded now, and the tag is re-quoted rather than passed through, since a bare token is not an entity tag (RFC 9110). Five spellings are a table inobjects.test.ts, and the integration suite compares the copy's tag with the one aHEADreports before using it as a precondition — so a future provider disagreement names the two strings instead of answering 412.Not here
promoteis next revisited.checksum→sha256.