Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 24 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 }}"
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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.
Expand Down
35 changes: 24 additions & 11 deletions documents/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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).
Expand Down Expand Up @@ -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
Expand Down
69 changes: 69 additions & 0 deletions routes/archived_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
}
21 changes: 10 additions & 11 deletions routes/documents.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
29 changes: 14 additions & 15 deletions store/memory.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package store

import (
"context"
"fmt"
"sort"
"strings"
"sync"
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down
15 changes: 5 additions & 10 deletions store/postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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})
Expand Down
Loading
Loading