From d756e8cfd4b3c8af5e294e0f8bac9f5beafb6747 Mon Sep 17 00:00:00 2001 From: GatisB Date: Tue, 1 Sep 2026 23:03:21 +0300 Subject: [PATCH 1/2] release: wait for the branch build, then retag without rewrapping the manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes measured on the first release run. A release published right after a merge races the branch build, so the job now waits for ci to publish the image (up to 20 minutes) before tagging. And the retag is a plain pull/tag/push: imagetools create wraps a single manifest into a new manifest list, which gives the version tag a different, unsigned digest — re-pushing the same manifest keeps the digest, so the cosign signature keeps matching. Signed-off-by: GatisB --- .github/workflows/release.yml | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5cc5e93..bd012f4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,8 +6,10 @@ # Retagging keeps the digest, so the existing cosign signature remains valid for the new tag: # verification resolves the tag to the same signed digest. # -# If this job fails with "manifest unknown", the released commit was never built by ci — publish -# releases only for commits that reached main or develop through a push. +# A release published right after a merge races the branch build: ci takes minutes to build, +# scan and push the image, so this job first waits for the image to appear (up to 20 minutes). +# If it never appears, the released commit was never built by ci — publish releases only for +# commits that reached main or develop through a push. name: release on: @@ -30,8 +32,25 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Wait for ci to publish the image for this commit + run: | + for i in $(seq 1 40); do + if docker buildx imagetools inspect "${IMAGE}:${{ github.sha }}" >/dev/null 2>&1; then + echo "image for ${{ github.sha }} is published" + exit 0 + fi + echo "not published yet — the branch build is still running (attempt $i/40)" + sleep 30 + done + echo "ci never published an image for ${{ github.sha }}" >&2 + exit 1 + + # Plain pull → tag → push, deliberately: `buildx imagetools create` wraps a single + # manifest into a NEW manifest list, so the version tag would get a different digest + # than the one ci signed and cosign verification at the tag would find no signature. + # Re-pushing the same manifest keeps the digest, so the signature keeps matching. - name: Point the version tag at the image ci built for this commit run: | - docker buildx imagetools create \ - --tag "${IMAGE}:${{ github.event.release.tag_name }}" \ - "${IMAGE}:${{ github.sha }}" + docker pull "${IMAGE}:${{ github.sha }}" + docker tag "${IMAGE}:${{ github.sha }}" "${IMAGE}:${{ github.event.release.tag_name }}" + docker push "${IMAGE}:${{ github.event.release.tag_name }}" From d34ceaa6a85950637bd87d95b0b99cc196a283da Mon Sep 17 00:00:00 2001 From: GatisB Date: Fri, 4 Sep 2026 12:19:17 +0300 Subject: [PATCH 2/2] archived: swap the bytes and record the preservation class in one write POST /api/v1/documents/{id}/archived replaced the signed head and then recorded preservation_class through a second, owner-scoped step. Any other party on the chain's access list, a co-signer adding the archive timestamp to the document they signed, had the bytes replaced and then received 404 err:document:notFound; every retry stamped the container again and the row never showed the document as preserved. The class now rides in the same replace_container_blob call (ReplaceContainerArchived), so a refused fact leaves the document untouched and a swapped document is always recorded as archive-timestamped. SetPreservationClass is removed from the service, the store interface, the Postgres adapter and the in-memory double. New route test: a co-signer on the access list adds the archive timestamp and both parties read preservationClass = preservation. Requires the platform database with the widened procedure; response shape unchanged (CHANGELOG v0.1.1). Signed-off-by: GatisB --- CHANGELOG.md | 19 ++++++++++++ README.md | 6 ++-- documents/service.go | 35 ++++++++++++++------- routes/archived_test.go | 69 +++++++++++++++++++++++++++++++++++++++++ routes/documents.go | 21 ++++++------- store/memory.go | 29 +++++++++-------- store/postgres.go | 15 +++------ store/store.go | 8 +++-- 8 files changed, 150 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7922d4f..5d1162a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,25 @@ Notable changes to this service, newest first, per release. This file is written for whoever runs the service or integrates against it. +## v0.1.1 + +### Fixed — an archive timestamp added by a co-signer is recorded, and answers 200 + +`POST /api/v1/documents/{id}/archived` now records the document's upgrade to long-term +preservation (`preservationClass = "preservation"`) in the same database write as the replaced +bytes. Before, the fact was written in a second step that only the uploader could pass: any other +party on the document's access list — a co-signer adding an archive timestamp to the document they signed — had the bytes +replaced and then received `404 err:document:notFound`, so the screen asked them to try again and +every retry stamped the container once more, while the row never showed the document as preserved. +Now every party the access list lets read the document gets the same answer, and a refused fact +leaves the bytes untouched. The response shape is unchanged. + +**Deployment note:** the change rides on the platform database — `document.replace_container_blob` +accepts the class and `document.set_preservation_class` is dropped. Apply that migration before or +together with this version: an older service against the new database fails its archive-timestamp route +loudly (the dropped procedure), while this version against an older database swaps the bytes without +recording the fact. + ## v0.1.0 Initial code. diff --git a/README.md b/README.md index 8202beb..49f9fb3 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ Everything below `/api/v1` is behind DPoP service-token authentication (audience | `GET /api/v1/documents/{id}/data-objects/{name}` | `read` | **Extract one inner file** out of a container on demand (the absorbed originals' only home). Streams the bytes; records the GDPR access event like a content download. Returns only an inner original — never a signature or the assembled container — but the **result freeze** applies the same as a content read: while set, an undeclared caller refuses with `err:document:resultFrozen` (409) unless it declares a purpose (`?conduit=review\|render\|signing`); `review` is the user reviewing/re-staging an original. | | `POST /api/v1/documents/{id}/add-signature` | `write` | Add a parallel (co-sign) signature to a stored container; keep-latest-replace in place; roll the chain's retention forward. | | `POST /api/v1/documents/{id}/signed` | `write` | Store a finished, opaque signed document (multipart `signed`) — e.g. a PDF signed in place — verbatim against its chain. Not assembled or reference-checked; integrity is the embedded signature. The **form is checked** before storing, because the row is recorded as `kind=pdf` and everything downstream branches on it: bytes that are not PDF content are refused with `422 err:document:signedFormMismatch` (nothing stored, chain unchanged) and a high-severity `document.integrity_failure` security event. Form only — signature validity stays the signing service's answer. One live signed document per chain: when the target is itself a signed PDF (the current head, or an uploaded already-signed PDF acting as its own chain root) the result **keep-latest supersedes** it in place rather than adding a second; a fresh row is created only for the first signature on a plain source. | -| `POST /api/v1/documents/{id}/archived` | `write` | Replace a signed head's bytes IN PLACE with its archive-timestamped form (multipart `archived`) — the same document id, refreshed (B-LT → B-LTA). Works for both signed forms (container and signed PDF); CAS-guarded, so a concurrent co-sign wins (`409`); `422` for a plain source. | +| `POST /api/v1/documents/{id}/archived` | `write` | Replace a signed head's bytes IN PLACE with its archive-timestamped form (multipart `archived`) — the same document id, refreshed (B-LT → B-LTA). Works for both signed forms (container and signed PDF); CAS-guarded, so a concurrent co-sign wins (`409`); `422` for a plain source. The upgrade is recorded on the row (`preservationClass = preservation`) in the same write as the bytes, by whichever party on the chain's access list performed it. | | `GET /api/v1/history` | `read` | The caller's TERMINAL chains (every row expired/deleted — storage destroyed, record remaining): one row per chain as its terminal head, owner-scoped by the uploader subject, keyset-paginated. Records are erased by the history sweep after the keep window (`document_history_retention`, default 90 days). | | `DELETE /api/v1/history/{chainRoot}` | `write` | Erase one owned history record early (hard delete of the terminal chain's metadata). `409` while the chain is live or under legal hold. | | `DELETE /api/v1/documents/{id}` | `write` | Remove the caller's standing access; reference-counted — the chain's bytes + data keys are destroyed only when the last participant leaves. Refused under legal hold. | @@ -219,9 +219,9 @@ Access is exclusively through the schema's `SECURITY DEFINER` procedures, called | `document.get` · `document.list` | ACL-authorized read of one row / a keyset page | | `document.list_chains` · `document.get_chain` · `document.list_history` | The chain projection — a keyset page of live chains, ONE chain by any id in it, and the terminal-chain history. All three read the same derivation of a chain's facts, so they cannot disagree about whether a document is signed | | `document.get_container_by_parent` · `document.get_latest_signed_pdf_by_chain` | Re-resolve a chain's current container / signed PDF (race recovery) | -| `document.replace_container_blob` | Keep-latest in-place byte swap under an optimistic CAS | +| `document.replace_container_blob` | Keep-latest in-place byte swap under an optimistic CAS; on an archive-timestamp refresh the preservation class lands in the same write | | `document.grant_acl` · `document.remove_access` | Grant / reference-counted revoke of chain access | -| `document.extend_retention` · `document.set_preservation_class` · `document.set_status` | Roll retention forward / set class / set status | +| `document.extend_retention` · `document.set_status` | Roll retention forward / set status | | `document.sweep_retention` | Flip expired non-hold rows to `expired` and return the byte refs to purge | A document row records `owner`, `kind` (`source` / `container` / `pdf`), the chain `parent_id`, the canonical `content_hash`, `mime`, `size`, `status`, `preservation_class`, `retention_until`, `legal_hold`, and the internal `storage_ref` / `encryption_key_ref` (both nulled once bytes are purged). The two byte-location refs are never exposed on the API projection. diff --git a/documents/service.go b/documents/service.go index 5aa48a6..f215498 100644 --- a/documents/service.go +++ b/documents/service.go @@ -390,6 +390,23 @@ func (s *Service) Rebundle(ctx context.Context, owner, id string, entries []Bund // one container. The new blob is rolled back if the swap fails. Returns the // updated row. func (s *Service) ReplaceContainer(ctx context.Context, containerID, expectedHash string, newBytes []byte) (*store.Document, error) { + return s.replaceContainer(ctx, containerID, expectedHash, newBytes, "") +} + +// ReplaceContainerArchived is ReplaceContainer for the archive-timestamped form +// of a signed head (B-LT → B-LTA): the same swap, and in the same database write +// the row is upgraded to long-term preservation. The bytes and the fact commit +// together — a refused fact leaves the document untouched, and a swapped +// document is always recorded as archive-timestamped. Authority is the caller's: +// whoever may read the head may have its archive timestamp added. +func (s *Service) ReplaceContainerArchived(ctx context.Context, containerID, expectedHash string, archived []byte) (*store.Document, error) { + return s.replaceContainer(ctx, containerID, expectedHash, archived, "preservation") +} + +// replaceContainer is the shared body: encrypt + store, CAS-swap the row (with +// an optional preservation class recorded in the same write), destroy the prior +// blob. +func (s *Service) replaceContainer(ctx context.Context, containerID, expectedHash string, newBytes []byte, preservationClass string) (*store.Document, error) { hash := CanonicalHash(newBytes) plainKey, wrapped, err := s.kms.GenerateDataKey() @@ -407,12 +424,13 @@ func (s *Service) ReplaceContainer(ctx context.Context, containerID, expectedHas } doc, old, err := s.store.ReplaceContainerBlob(ctx, store.ReplaceInput{ - ID: containerID, - ExpectedHash: expectedHash, - StorageRef: objKey, - ContentHash: hash, - Size: int64(len(newBytes)), - EncryptionKeyRef: base64.StdEncoding.EncodeToString(wrapped), + ID: containerID, + ExpectedHash: expectedHash, + StorageRef: objKey, + ContentHash: hash, + Size: int64(len(newBytes)), + EncryptionKeyRef: base64.StdEncoding.EncodeToString(wrapped), + PreservationClass: preservationClass, }) if err != nil { // Roll back the orphan new blob (a CAS rejection leaves nothing behind). @@ -532,11 +550,6 @@ func (s *Service) ExtendRetention(ctx context.Context, id, owner string) error { return s.store.ExtendRetention(ctx, id, owner, time.Now().Add(s.ttl)) } -// SetPreservationClass sets/upgrades the B4 class on an owned document. -func (s *Service) SetPreservationClass(ctx context.Context, id, owner, class string) error { - return s.store.SetPreservationClass(ctx, id, owner, class) -} - // SetResultFreeze sets/clears the chain-level download freeze (resolved to the // chain root from any of its rows). While frozen, content reads of the chain's // non-source rows refuse — the signed result is locked during a signing diff --git a/routes/archived_test.go b/routes/archived_test.go index 650a085..1f08842 100644 --- a/routes/archived_test.go +++ b/routes/archived_test.go @@ -117,3 +117,72 @@ func TestStoreArchivedACLScoped(t *testing.T) { qt.Assert(t, qt.Equals(resp.StatusCode(), fasthttp.StatusNotFound)) fasthttp.ReleaseResponse(resp) } + +// The archive act belongs to every party on the chain's access list, not only the +// uploader: a co-signer who archives the shared container gets the same answer as +// the owner, and the upgrade is recorded on the row for both to read. The swap and +// the recorded fact are one write — a co-signer's click must never replace the +// bytes and then report a failure. +func TestStoreArchivedByCoSignerRecordsPreservation(t *testing.T) { + app := testApp(t) + app.Start(t) + defer app.Stop() + tc := app.TestClient() + + srcID := ingestDoc(t, app, "owner-1", "contract.pdf", samplePDF()).ID + body, ct := buildMultipart(t, map[string]string{"mime": "application/pdf"}, + []fileEntry{{"signed", "contract.pdf", fakeSignedPDF}}) + resp, err := tc.Post("/api/v1/documents/"+srcID+"/signed", body, + tc.WithHeader("Content-Type", ct), + tc.WithHeader("X-Test-Scopes", scopeWrite), + tc.WithHeader("X-Test-Sub", "owner-1")) + qt.Assert(t, qt.IsNil(err)) + qt.Assert(t, qt.Equals(resp.StatusCode(), fasthttp.StatusCreated)) + var signed response.SignedDocument + decode(t, resp, &signed) + fasthttp.ReleaseResponse(resp) + + // The workflow service grants the co-signer's serial on the chain at send. + gr, err := tc.Post("/api/v1/documents/"+srcID+"/acl", []byte(`{"serial":"`+invitedSerial+`"}`), + tc.WithHeader("Content-Type", "application/json"), + tc.WithHeader("X-Test-Scopes", scopeGrant), + tc.WithHeader("X-Test-Sub", "svc:envelope")) + qt.Assert(t, qt.IsNil(err)) + qt.Assert(t, qt.Equals(gr.StatusCode(), fasthttp.StatusNoContent)) + fasthttp.ReleaseResponse(gr) + + // The co-signer (a different sub, on the list by serial) archives the head. + body, ct = buildMultipart(t, nil, []fileEntry{{"archived", "contract.pdf", fakeArchived}}) + resp, err = tc.Post("/api/v1/documents/"+signed.SignedDocumentID+"/archived", body, + tc.WithHeader("Content-Type", ct), + tc.WithHeader("X-Test-Scopes", scopeWrite), + tc.WithHeader("X-Test-Sub", "cosigner-x"), + tc.WithHeader("X-Test-Serial", invitedSerial)) + qt.Assert(t, qt.IsNil(err)) + qt.Assert(t, qt.Equals(resp.StatusCode(), fasthttp.StatusOK)) + var out response.Archived + decode(t, resp, &out) + fasthttp.ReleaseResponse(resp) + qt.Check(t, qt.Equals(out.ID, signed.SignedDocumentID)) + qt.Check(t, qt.Equals(out.Size, int64(len(fakeArchived)))) + + // The fact is on the row: the owner reads it … + resp, err = tc.Get("/api/v1/documents/"+out.ID, + tc.WithHeader("X-Test-Scopes", scopeRead), tc.WithHeader("X-Test-Sub", "owner-1")) + qt.Assert(t, qt.IsNil(err)) + var asOwner response.Document + decode(t, resp, &asOwner) + fasthttp.ReleaseResponse(resp) + qt.Check(t, qt.Equals(asOwner.PreservationClass, "preservation")) + + // … and so does the co-signer who applied it. + resp, err = tc.Get("/api/v1/documents/"+out.ID, + tc.WithHeader("X-Test-Scopes", scopeRead), + tc.WithHeader("X-Test-Sub", "cosigner-x"), + tc.WithHeader("X-Test-Serial", invitedSerial)) + qt.Assert(t, qt.IsNil(err)) + var asCoSigner response.Document + decode(t, resp, &asCoSigner) + fasthttp.ReleaseResponse(resp) + qt.Check(t, qt.Equals(asCoSigner.PreservationClass, "preservation")) +} diff --git a/routes/documents.go b/routes/documents.go index 26d671a..bfdbc85 100644 --- a/routes/documents.go +++ b/routes/documents.go @@ -1315,23 +1315,22 @@ func (r *router) storeArchived(ctx *azugo.Context) { return } - doc, err := r.Documents().ReplaceContainer(ctx, head.ID, head.ContentHash, data) + // The swap and the fact are one write. An archive timestamp upgrades the + // document to long-term preservation (B-LTA); that class is recorded in the + // same transaction as the new bytes, so a refused fact leaves the document + // untouched and a swapped document is always recorded as archive-timestamped. + // Without the fact the only trace is the swapped bytes — indistinguishable + // from an ordinary co-sign replace — and the activity trail could not show + // the timestamp after a reload. Whoever the access list lets read the head may + // add the archive timestamp: a co-signer holds the same document as the + // uploader. + doc, err := r.Documents().ReplaceContainerArchived(ctx, head.ID, head.ContentHash, data) if err != nil { r.writeStoreErr(ctx, err) return } - // Record the archive as a durable fact: an archive timestamp upgrades the - // container to long-term preservation (B-LTA). Without this the only trace is - // the swapped bytes — indistinguishable from an ordinary co-sign replace — so - // the owner-facing activity trail cannot show "archived" after a reload. - if err := r.Documents().SetPreservationClass(ctx, doc.ID, reqCaller(ctx).Sub, "preservation"); err != nil { - r.writeStoreErr(ctx, err) - - return - } - ctx.JSON(&response.Archived{ ID: doc.ID, ContentHash: doc.ContentHash, diff --git a/store/memory.go b/store/memory.go index f915f17..6ce9847 100644 --- a/store/memory.go +++ b/store/memory.go @@ -2,6 +2,7 @@ package store import ( "context" + "fmt" "sort" "strings" "sync" @@ -159,6 +160,14 @@ func (m *Memory) ReplaceContainerBlob(_ context.Context, in ReplaceInput) (*Docu m.mu.Lock() defer m.mu.Unlock() + // Mirrors the procedure's input check: a preservation class, when given, + // is one of the closed set — refused before anything is touched. + switch in.PreservationClass { + case "", "none", "b_lt", "preservation": + default: + return nil, nil, fmt.Errorf("document: invalid preservation class %q", in.PreservationClass) + } + // Only a signed head form (container or signed PDF) may be replaced in // place — a merged co-signature or an archive-timestamped refresh. A plain // source is never replaced. @@ -180,6 +189,11 @@ func (m *Memory) ReplaceContainerBlob(_ context.Context, in ReplaceInput) (*Docu // The platform applied this signature in place — lets a root-headed chain // (a bundle, or an uploaded file co-signed here) read as signed-here. d.SignedAt = &now + // The fact rides with the bytes: an archive-timestamped refresh records its + // class in the same write as the swap. + if in.PreservationClass != "" { + d.PreservationClass = in.PreservationClass + } d.UpdatedAt = now return clone(d), old, nil @@ -831,21 +845,6 @@ func (m *Memory) ChainRetention(_ context.Context, id string) (time.Time, int, e return until, live, nil } -// SetPreservationClass sets an owned document's preservation class. -func (m *Memory) SetPreservationClass(_ context.Context, id, caller, class string) error { - m.mu.Lock() - defer m.mu.Unlock() - - d, ok := m.rows[id] - if !ok || d.Owner != caller { - return ErrNotFound - } - d.PreservationClass = class - d.UpdatedAt = time.Now().UTC() - - return nil -} - // ExtendRetention rolls retention_until forward (never shortens). func (m *Memory) ExtendRetention(_ context.Context, id, caller string, until time.Time) error { m.mu.Lock() diff --git a/store/postgres.go b/store/postgres.go index a0d2203..2f2d26b 100644 --- a/store/postgres.go +++ b/store/postgres.go @@ -145,14 +145,17 @@ func (p *Postgres) Insert(ctx context.Context, in InsertInput) (string, error) { // ReplaceContainerBlob hard-replaces a container's bytes in place (keep-latest) // via document.replace_container_blob, under the optimistic CAS. func (p *Postgres) ReplaceContainerBlob(ctx context.Context, in ReplaceInput) (*Document, *PurgedRef, error) { - data, err := p.call(ctx, "document.replace_container_blob", map[string]any{ + body := map[string]any{ "id": in.ID, "expected_hash": in.ExpectedHash, "storage_ref": in.StorageRef, "content_hash": in.ContentHash, "size": in.Size, "encryption_key_ref": in.EncryptionKeyRef, - }) + } + putOpt(body, "preservation_class", in.PreservationClass) + + data, err := p.call(ctx, "document.replace_container_blob", body) if err != nil { return nil, nil, err } @@ -455,14 +458,6 @@ func (p *Postgres) SetStatus(ctx context.Context, id, caller, status string) err return err } -// SetPreservationClass sets the B4 class via document.set_preservation_class. -func (p *Postgres) SetPreservationClass(ctx context.Context, id, caller, class string) error { - _, err := p.call(ctx, "document.set_preservation_class", - map[string]any{"id": id, "caller": caller, "preservation_class": class}) - - return err -} - // SetResultFreeze sets/clears the chain download freeze via document.set_result_freeze. func (p *Postgres) SetResultFreeze(ctx context.Context, id string, frozen bool) error { _, err := p.call(ctx, "document.set_result_freeze", map[string]any{"id": id, "frozen": frozen}) diff --git a/store/store.go b/store/store.go index b09112f..4e42d7b 100644 --- a/store/store.go +++ b/store/store.go @@ -202,6 +202,11 @@ type ReplaceInput struct { ContentHash string Size int64 EncryptionKeyRef string + // PreservationClass, when set, is recorded in the same write as the swap: an + // archive-timestamped refresh upgrades the row to "preservation" as one fact + // with its bytes, never as a second step that can fail on its own. Empty + // leaves the row's class unchanged. + PreservationClass string } // Bundleable reports whether a document may be absorbed into a bundle: an unsigned @@ -259,6 +264,7 @@ type Store interface { // ReplaceContainerBlob hard-replaces a container's bytes in place (keep-latest) // under an optimistic CAS on its current content hash, returning the updated // row + the PRIOR blob refs to destroy. ErrChainAdvanced when the hash moved. + // A PreservationClass in the input is recorded in the same write. ReplaceContainerBlob(ctx context.Context, in ReplaceInput) (*Document, *PurgedRef, error) // Bundle inserts ONE unsigned container row (a fresh chain root) built from // the owner's loose sources and absorbs those source rows + their ACL @@ -311,8 +317,6 @@ type Store interface { GetChain(ctx context.Context, caller Caller, id string) (*Chain, error) // SetStatus atomically changes an owned document's status. SetStatus(ctx context.Context, id, caller, status string) error - // SetPreservationClass sets/upgrades an owned document's preservation class. - SetPreservationClass(ctx context.Context, id, caller, class string) error // SetResultFreeze sets/clears the chain-level download freeze (resolved to // the chain root from any of its rows). The caller's authority is the // service boundary's job (the grant scope) — the store just records it.