app: fix round 1 on document ownership — scope the write, refuse owner 0
Four findings from review of "documents belong to people": ApplyUpdate's updated_at bump was the one write in store.go that skipped the owned() seam (bare WHERE id = ?, no owner predicate) -- safe today only because a *Document can only arrive from an already-scoped Get, and exactly the line the editing plan's next route would have copied. Routed through owned(doc.OwnerID) instead, with a regression test proving a mismatched owner no longer bumps it. owned() now refuses owner <= 0 itself (matches nothing) rather than trusting every caller to reject it first -- people.id starts at 1, so 0 is never a real person, and it is exactly the value 0002_owners.sql backfills onto pre-ownership documents. That backfill's DEFAULT 0 -- permanently orphaning any document that predates ownership -- is now a documented decision in the migration itself, not an accidental side effect, and pinned by a test asserting such a document is visible to nobody, owner 0 included. owned()'s doc comment now says why it deliberately omits deleted_at: folding it in would make the function do two unrelated jobs under one name and foreclose a future caller that wants every row regardless of deletion state.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011F5CivVm4CmL9ByfSSAaXA
4 files changed,
+238
−11
internal/docs/migrations/0002_owners.sql+12 −0internal/docs/scope_test.go+129 −6internal/docs/store.go+46 −5internal/docs/store_test.go+51 −0
diff --git a/internal/docs/migrations/0002_owners.sql b/internal/docs/migrations/0002_owners.sql| index 4f47586..b8c60af 100644 |
| --- a/internal/docs/migrations/0002_owners.sql |
| +++ b/internal/docs/migrations/0002_owners.sql |
| @@ -4,6 +4,18 @@ CREATE TABLE IF NOT EXISTS people ( |
| created_at DATETIME NOT NULL |
| ); |
| +-- DEFAULT 0 is a deliberate decision, not a placeholder: SQLite's |
| +-- INTEGER PRIMARY KEY on people.id starts at 1, so owner_id = 0 can |
| +-- never match a real person. Any document that existed before this |
| +-- migration ran is backfilled to owner_id = 0 and becomes permanently |
| +-- invisible through Store (store.go's owned() explicitly matches |
| +-- nothing for owner <= 0, so no future caller can accidentally expose |
| +-- these rows either) -- there is no live deployment yet (this plan is |
| +-- what takes Docs live for the first time), so today that set is |
| +-- empty in practice, but the behaviour is real and permanent for |
| +-- anyone who runs this migration against a database that already has |
| +-- rows: those documents are orphaned on purpose, not lost by oversight. |
| +-- Pinned by TestOrphanedDocumentsAreVisibleToNobody in scope_test.go. |
| ALTER TABLE documents ADD COLUMN owner_id INTEGER NOT NULL DEFAULT 0; |
| CREATE INDEX IF NOT EXISTS idx_documents_owner_id ON documents (owner_id); |
diff --git a/internal/docs/scope_test.go b/internal/docs/scope_test.go| index 9387b7c..cb5377a 100644 |
| --- a/internal/docs/scope_test.go |
| +++ b/internal/docs/scope_test.go |
| @@ -3,6 +3,7 @@ package docs |
| import ( |
| "context" |
| "testing" |
| + "time" |
| ) |
| // TestOwnedScopesByOwner tests store.go's owned function directly — |
| @@ -53,15 +54,137 @@ func TestOwnedScopesByOwner(t *testing.T) { |
| t.Errorf("owned(bob) returned %d row(s), want 0 — bob does not own alice's document", len(bobRows)) |
| } |
| - // An owner nobody has ever used (no Person row, no document) is |
| - // scoped to nothing, not an error — the same shape a stranger |
| - // probing gets, never a distinct failure mode of its own. |
| + // Owner 0 is scoped to nothing — not an unused id that merely |
| + // never happens to match a row (999999 would prove that, but is a |
| + // value that never existed), but the one real value |
| + // 0002_owners.sql's migration actually writes onto every |
| + // pre-ownership document, and the one owned() refuses |
| + // structurally regardless of what the table holds (fix round 1, |
| + // Finding 2). TestOwnedRejectsOwnerZero and |
| + // TestOrphanedDocumentsAreVisibleToNobody below cover the same |
| + // refusal against a row that genuinely has owner_id = 0; this |
| + // assertion holds even with no such row present at all. |
| var noRows []*Document |
| - if err := owned(store.db.WithContext(ctx), 999999).Find(&noRows).Error; err != nil { |
| - t.Fatalf("owned(unknown owner): %v", err) |
| + if err := owned(store.db.WithContext(ctx), 0).Find(&noRows).Error; err != nil { |
| + t.Fatalf("owned(0): %v", err) |
| } |
| if len(noRows) != 0 { |
| - t.Errorf("owned(unknown owner) returned %d row(s), want 0", len(noRows)) |
| + t.Errorf("owned(0) returned %d row(s), want 0", len(noRows)) |
| + } |
| +} |
| + |
| +// TestOwnedRejectsOwnerZero is fix round 1's own regression test |
| +// (Finding 2): a reviewer found owned(g, 0), Store.List(ctx, 0) and |
| +// Store.Get(ctx, 0, ...) all happily returned a row whose owner_id is |
| +// 0 — exactly what 0002_owners.sql's DEFAULT 0 backfill produces for |
| +// a document that predates ownership (see that migration's own |
| +// comment). This inserts such a row directly, bypassing Store.Create |
| +// (which only ever receives a real Person id from its caller), to |
| +// reproduce that exact shape, then confirms owned(g, 0) still matches |
| +// nothing even though a matching row genuinely exists. Negative |
| +// owners are checked too — the same "not a real person" reasoning, |
| +// not a special case for the literal value 0. |
| +func TestOwnedRejectsOwnerZero(t *testing.T) { |
| + app, err := NewForTest(t) |
| + if err != nil { |
| + t.Fatalf("NewForTest: %v", err) |
| + } |
| + ctx := context.Background() |
| + store := app.Store() |
| + |
| + now := time.Now().UTC() |
| + orphan := &Document{ |
| + Sqid: "orphanzero1", |
| + Title: "Pre-ownership document", |
| + OwnerID: 0, |
| + GUID: "orphan-guid-zero", |
| + ClientID: 1, |
| + CreatedAt: now, |
| + UpdatedAt: now, |
| + } |
| + if err := store.db.WithContext(ctx).Create(orphan).Error; err != nil { |
| + t.Fatalf("insert orphaned document: %v", err) |
| + } |
| + |
| + var zeroRows []*Document |
| + if err := owned(store.db.WithContext(ctx), 0).Find(&zeroRows).Error; err != nil { |
| + t.Fatalf("owned(0): %v", err) |
| + } |
| + if len(zeroRows) != 0 { |
| + t.Errorf("owned(0) returned %d row(s), want 0 — owner 0 is never a real person, even when a row with owner_id = 0 exists", len(zeroRows)) |
| + } |
| + |
| + var negRows []*Document |
| + if err := owned(store.db.WithContext(ctx), -1).Find(&negRows).Error; err != nil { |
| + t.Fatalf("owned(-1): %v", err) |
| + } |
| + if len(negRows) != 0 { |
| + t.Errorf("owned(-1) returned %d row(s), want 0", len(negRows)) |
| + } |
| +} |
| + |
| +// TestOrphanedDocumentsAreVisibleToNobody is fix round 1's own |
| +// regression test (Finding 3): 0002_owners.sql's DEFAULT 0 backfill |
| +// is a deliberate decision — a document that existed before ownership |
| +// did becomes permanently invisible — but that property was |
| +// previously asserted nowhere. This pins it through the same Store |
| +// API a real request uses (List and Get), not just owned() directly: |
| +// an orphaned document must not appear in anyone's list, and must not |
| +// be fetchable by anyone via its sqid, exactly as if it did not |
| +// exist — spec §4.2's own contract, applied to the migration's own |
| +// backfill rather than only to a document someone else owns. |
| +func TestOrphanedDocumentsAreVisibleToNobody(t *testing.T) { |
| + app, err := NewForTest(t) |
| + if err != nil { |
| + t.Fatalf("NewForTest: %v", err) |
| + } |
| + ctx := context.Background() |
| + store := app.Store() |
| + |
| + now := time.Now().UTC() |
| + orphan := &Document{ |
| + Sqid: "orphanzero2", |
| + Title: "Another pre-ownership document", |
| + OwnerID: 0, |
| + GUID: "orphan-guid-visible", |
| + ClientID: 2, |
| + CreatedAt: now, |
| + UpdatedAt: now, |
| + } |
| + if err := store.db.WithContext(ctx).Create(orphan).Error; err != nil { |
| + t.Fatalf("insert orphaned document: %v", err) |
| + } |
| + |
| + alice, err := store.EnsurePerson(ctx, "alice-orphan-check@example.com") |
| + if err != nil { |
| + t.Fatalf("EnsurePerson(alice): %v", err) |
| + } |
| + bob, err := store.EnsurePerson(ctx, "bob-orphan-check@example.com") |
| + if err != nil { |
| + t.Fatalf("EnsurePerson(bob): %v", err) |
| + } |
| + |
| + for _, owner := range []int64{alice, bob} { |
| + list, err := store.List(ctx, owner) |
| + if err != nil { |
| + t.Fatalf("List(%d): %v", owner, err) |
| + } |
| + for _, d := range list { |
| + if d.Sqid == orphan.Sqid { |
| + t.Errorf("owner %d's list contains the orphaned document %q", owner, orphan.Sqid) |
| + } |
| + } |
| + |
| + if _, err := store.Get(ctx, owner, orphan.Sqid); err == nil { |
| + t.Errorf("owner %d fetched the orphaned document %q", owner, orphan.Sqid) |
| + } |
| + } |
| + |
| + // And owner 0 itself — the value the row actually carries — must |
| + // not see it either; owned() refuses it outright rather than |
| + // treating it as "the orphan's own owner." |
| + if _, err := store.Get(ctx, 0, orphan.Sqid); err == nil { |
| + t.Error("owner 0 fetched the orphaned document — owned() must refuse owner 0 unconditionally") |
| } |
| } |
diff --git a/internal/docs/store.go b/internal/docs/store.go| index 66cc8ef..999969e 100644 |
| --- a/internal/docs/store.go |
| +++ b/internal/docs/store.go |
| @@ -86,11 +86,38 @@ const maxSqidAttempts = 5 |
| // pins its current, ownership-only contract so a future edit that |
| // widens it does so on purpose. |
| // |
| -// It does not filter deleted_at: that is not an ownership concern, and |
| -// callers that care (List, Get) add it themselves, deliberately |
| -// visible at the call site rather than folded into a function named |
| -// for something else. |
| +// owner <= 0 matches nothing, deliberately, rather than trusting every |
| +// caller to reject it first. people.id starts at 1 (SQLite's INTEGER |
| +// PRIMARY KEY), so 0 is never a real person — it is exactly the value |
| +// 0002_owners.sql's migration backfilled onto every document that |
| +// existed before ownership did (see that file's own comment, and |
| +// TestOrphanedDocumentsAreVisibleToNobody in scope_test.go, which |
| +// pins this). Fix round 1 on this task: a reviewer found owned(g, 0), |
| +// Store.List(ctx, 0) and Store.Get(ctx, 0, ...) all happily returned |
| +// those orphaned rows — nothing upstream exploited that yet, but nothing |
| +// prevented a careless future caller from doing so either, and the |
| +// seam is the one place that can make it structurally impossible |
| +// rather than merely unexercised today. Matching nothing (a `1 = 0` |
| +// clause) rather than returning an error keeps every call site's |
| +// chaining (.Where/.Order/.Find) unchanged, and gives owner 0 the same |
| +// outcome as any other owner with no visible rows — no distinct error |
| +// shape for "invalid owner" to leak through a 500 or otherwise |
| +// distinguish itself from "you just have nothing here" (spec §4.2's |
| +// own reasoning against a distinguishable failure mode, applied here |
| +// to the seam itself rather than only to the 404-vs-403 handler |
| +// boundary). |
| +// |
| +// It does not filter deleted_at: that is not an ownership concern. |
| +// Folding it in here would make `owned` do two unrelated jobs under |
| +// one name, and would silently change behaviour for any future caller |
| +// that wants every one of an owner's rows regardless of deletion state |
| +// (an admin or export path, say) without ever needing to touch this |
| +// function. List and Get add `deleted_at IS NULL` themselves, |
| +// deliberately visible at the call site. |
| func owned(g *gorm.DB, owner int64) *gorm.DB { |
| + if owner <= 0 { |
| + return g.Where("1 = 0") |
| + } |
| return g.Where("owner_id = ?", owner) |
| } |
| @@ -337,7 +364,21 @@ func (s *Store) ApplyUpdate(ctx context.Context, doc *Document, update []byte) e |
| // to the same `now`), so List ordering by either column looked |
| // identical and the divergence stayed invisible until something |
| // — this method — actually moved UpdatedAt away from CreatedAt. |
| - if err := s.db.WithContext(ctx). |
| + // |
| + // Routed through owned(doc.OwnerID) — fix round 1 on this task: this |
| + // used to be a bare `Where("id = ?", doc.ID)`, the one write in this |
| + // file that skipped the seam. It was safe only by accident, and only |
| + // today: the sole way to obtain a *Document at all is Store.Get, |
| + // which is already owner-scoped, so doc.OwnerID here always matches |
| + // the caller who legitimately holds doc. But nothing enforced that |
| + // invariant at this call site, and the editing plan is exactly what |
| + // adds a route that calls ApplyUpdate — whoever writes it would have |
| + // copied this line as the established pattern for "how this file |
| + // writes to documents," carrying the gap into a place where it can |
| + // actually be reached. SKILL.md §3, verbatim: "Scope the write, not |
| + // just the read that loaded the row — the SQL must carry |
| + // `WHERE user_id = ? AND id = ?`." |
| + if err := owned(s.db.WithContext(ctx), doc.OwnerID). |
| Model(&Document{}). |
| Where("id = ?", doc.ID). |
| Update("updated_at", time.Now().UTC()).Error; err != nil { |
diff --git a/internal/docs/store_test.go b/internal/docs/store_test.go| index d349346..b7c0c16 100644 |
| --- a/internal/docs/store_test.go |
| +++ b/internal/docs/store_test.go |
| @@ -185,3 +185,54 @@ func TestUnownedDocumentOverHTTPIs404Not403(t *testing.T) { |
| t.Errorf("status %d, want 404 — a document you cannot see does not exist (spec §4.2)", rec.Code) |
| } |
| } |
| + |
| +// TestApplyUpdateDoesNotBumpAnotherOwnersDocument is fix round 1's own |
| +// regression test (Finding 1): ApplyUpdate's updated_at bump used to |
| +// run `UPDATE documents SET updated_at WHERE id = ?` with no owner |
| +// predicate at all — the one write in store.go that skipped the |
| +// owned() seam. It was safe only because the sole way to obtain a |
| +// *Document is Store.Get, which is already owner-scoped, so in |
| +// practice doc.OwnerID always matched. Nothing enforced that |
| +// invariant at the call site, so this simulates the case where it |
| +// doesn't hold — a *Document whose OwnerID has been tampered with to |
| +// someone else's id — and asserts the write is refused: the real |
| +// row's UpdatedAt must not move. SKILL.md §3, verbatim: "Scope the |
| +// write, not just the read that loaded the row." |
| +func TestApplyUpdateDoesNotBumpAnotherOwnersDocument(t *testing.T) { |
| + app, err := docs.NewForTest(t) |
| + if err != nil { |
| + t.Fatalf("NewForTest: %v", err) |
| + } |
| + ctx := context.Background() |
| + owner := app.TestPerson(t, "owner@example.com") |
| + |
| + doc, err := app.Store().Create(ctx, owner, "Real owner's document") |
| + if err != nil { |
| + t.Fatalf("Create: %v", err) |
| + } |
| + before, err := app.Store().Get(ctx, owner, doc.Sqid) |
| + if err != nil { |
| + t.Fatalf("Get (before): %v", err) |
| + } |
| + |
| + // A real gap so a spurious bump would be visible against the |
| + // clock, not hidden by two timestamps landing in the same tick. |
| + time.Sleep(5 * time.Millisecond) |
| + |
| + tampered := *doc |
| + tampered.OwnerID = owner + 1_000_000 // guaranteed to belong to nobody |
| + |
| + update := docs.ReadFixture(t, "hello") |
| + if err := app.Store().ApplyUpdate(ctx, &tampered, update); err != nil { |
| + t.Fatalf("ApplyUpdate: %v", err) |
| + } |
| + |
| + after, err := app.Store().Get(ctx, owner, doc.Sqid) |
| + if err != nil { |
| + t.Fatalf("Get (after): %v", err) |
| + } |
| + if !after.UpdatedAt.Equal(before.UpdatedAt) { |
| + t.Errorf("UpdatedAt moved from %v to %v even though ApplyUpdate was called with a mismatched owner (%d instead of the document's real owner %d)", |
| + before.UpdatedAt, after.UpdatedAt, tampered.OwnerID, owner) |
| + } |
| +} |