Skip to content

fix(platform): documents authz gates, corpus scope, and replacement race - #3172

Draft
larryro wants to merge 8 commits into
mainfrom
fix/documents-authz-integrity
Draft

fix(platform): documents authz gates, corpus scope, and replacement race#3172
larryro wants to merge 8 commits into
mainfrom
fix/documents-authz-integrity

Conversation

@larryro

@larryro larryro commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Six documents-domain findings from the backend deep review, verified against main at 5f9dc6e (which already carries #3136 write matrix, #3140 corpus purge, #3160 upload authz). Four were still live, one is closed in effect by #3136 and is now pinned, one is refuted in part.

Per-finding outcome

# Finding Outcome Evidence / rule
1 Review respond door ignores the named reviewer; submitter can self-approve (records.ts) fixed The door checked the write matrix but never who was asked. Submit refuses self-designation (REVIEWER_SELF_NOT_ALLOWED), the picker never offers the caller, respond admits only the designee (REVIEW_NOT_ASSIGNED) and never the submitter (REVIEW_SELF_APPROVAL_FORBIDDEN). Review dialog disables the decision buttons for non-designees and says who decides; docs (en/de/fr) say the same.
2 Project doc rename/move/trash require only read access (service.ts) already-fixed (in effect, by #3136) — pinned updateDocument/setDocumentTrashed run assertDocumentsWriteRole; authorizeRls(documents:write) admits exactly {owner, admin, developer, editor} = EDITOR_ROLES behind checkProjectAccess.canEdit, so any writer who can see a project file can edit it. The existing check "documents write matrix: app door refuses read-only member" already covers rename + trash → 403. This PR unifies the door (requireDocumentWriteAccess in service.ts, reused by records, replacement, update, trash — the private copies in records/replacement are gone) and write-guards.test.ts pins the two matrices together so a drift in either fails a test.
3 readProjectTextValues skips project access (project-text.ts) fixed Queried by (org, project, folder, file) behind org membership only. Now runs the project read gate the listing uses (assertReadable): unknown/foreign project → 404, no read access → 403.
4 Project attach/detach never re-stamps knowledge-corpus scope (service.ts) fixed Only the team-change app route re-stamped. attach/detach hand back the blob ref and the routes call syncRagDocumentScope after commit, like the team change. (The REST v1 PATCH /documents/:id team change had the same gap — see below.)
5 Concurrent replacement finalizes silently drop a bound version (replacement.ts) fixed Both binds decided on the same snapshot; the second swap overwrote the first's blob (neither current nor in history). The bind now loads the document FOR UPDATE (loadDocumentForUpdate via the shared door) and the swap UPDATE carries a file_ref compare-and-swap; the loser re-reads the winner's commit and gets DOCUMENT_RECORD_VERSION_MISMATCH (intent → failed, blob swept). No migration needed — a row lock, not a constraint.
6 document_create unvalidated projectId on org-level runs (agent-write.ts) + multi-bound run writes the org hub (workspace_domain_tools.ts) fixed; refuted in part Refuted: a nonexistent id never saved unreachable on base — the documents.project_id → projects FK rejected it with a raw Postgres error (a 500-class failure, not a clean refusal). Confirmed: a foreign org's project id passed the FK and was saved where no listing or retrieval scope reaches it. Now upsertAgentDocument requires the project to exist in the org (PROJECT_NOT_FOUND, the task writer's rule), and runDocumentCreate on a multi-bound run with no projectId refuses like task_create and names the bound projects. One rule across both halves: a document lands in one existing, authorized project of this org — or, for a truly org-level run only, the hub.

Tests

Unit (vitest server): records.test.ts (new — designee / self-designation predicates), write-guards.test.ts (+ matrix drift guard), workspace_tools_bridge.test.ts (+ multi-bound refusal, + named bound project). Red on base: the same three files on 5f9dc6e9 failed / 79 passed (8 predicate tests: assertReviewResponder is not a function; bridge: expected 'ok' to be 'invalid_args'); the drift guard passes on base, as an already-closed finding should.

Integration (backend:integration, new/changed checks): eligibility never self + self-submit 400; designee-only respond (submitter → 403, designee → 200, respondedBy = designee); write-guards owner-approve → 403; project text read (org-wide 200 / team-restricted 403 PROJECT_FORBIDDEN / unknown 404); corpus scope stamped on attach and cleared on detach; replacement finalize race made deterministic by parking both binds behind a test-held row lock (exactly one bound, loser failed + DOCUMENT_RECORD_VERSION_MISMATCH, chain intact); document_create authority (multi-bound no project → invalid_args naming bound ids, bound project ok, outside bindings refused, truly org-level: hub / named project / bogus → not_found, no row); agent upsert bogus + foreign-org project → PROJECT_NOT_FOUND, zero unreachable rows.

Verification

  • bunx tsc --noEmit (platform): exit 0. oxlint --type-aware (whole platform workspace): exit 0.
  • vitest server project (whole platform): 421 files / 5461 tests pass; 3 app/routes/** test files fail to load identically on base in this worktree (Denied ID …/@fontsource/inter/…woff2 — Vite fs-allow with a symlinked node_modules), unrelated. Touched files: 4 files / 92 tests. lib/i18n/messages.test.ts: 24/24. @tale/ui i18n suite: 9/9. @tale/docs test: 30 files / 194 tests.
  • Branch base: 5f9dc6e (origin/main moved on during the work; no rebase performed).
  • backend:integration on throwaway Postgres + MinIO (SANDBOX_LLM_GATEWAY_ADMIN_PASSWORD set — no environmental gateway fails):
    • branch (this head): 379/379 checks passed, exit 0 (two prior full runs of earlier heads: 378/378 twice)
    • base 5f9dc6e with this PR's harness: 369/379, 10 FAIL, exit 1 — every failure is one of this PR's new/changed documents checks.
  • UI vitest (test:ui, app/features/documents): 166/166 tests pass; 9 test files fail to load identically on base in this worktree (Denied ID …/@fontsource/inter/…woff2 — Vite fs-allow with a symlinked node_modules), unrelated.

Cross-class discoveries (not fixed here unless noted)

  • REST v1 PATCH /documents/:id with a teamId change never re-stamps corpus scope (only the app route did) — same class as finding 4; fixed in this PR as part of the scope re-stamp.
  • The respond door validates feedback and resolution state before authorization, so REVIEW_ALREADY_RESOLVED is observable by a caller without write access (minor existence oracle, pre-existing).
  • listProjectDocuments treats a foreign org's org-wide project as readable and answers an empty list instead of 404 (harmless — the query is org-scoped — but inconsistent with the other project doors).
  • upsertAgentDocument on base surfaced FK violations as raw Postgres errors to the agent (status: 'error' with the SQL message) rather than a coded refusal.

Notes

  • New user-visible strings (documents.record.review.onlyDesignee, …NoName) ship in en/de/fr (de-CH needs no override); docs sentence in en/de/fr platform/knowledge/documents.md.
  • Harness fixtures: checkDocuments adds an editor-role reviewer for the four-eyes flow and removes the membership at its end; checkDocumentWriteGuards adds an editor beside its member.

Two replacement finalizes racing on the same current file both passed
their mint- and acquire-time checks and both swapped: the second
overwrote the first's blob, which ended up neither current nor in
history — a version lost behind two success responses.

The bind now loads the document under FOR UPDATE (loadDocumentForUpdate
through the shared requireDocumentWriteAccess door, which records,
replacement, update and trash all consult) and the swap UPDATE carries a
file_ref compare-and-swap; the loser re-reads the winner's commit, fails
the target match and gets DOCUMENT_RECORD_VERSION_MISMATCH.

The shared door also pins the rename/move/trash finding: the
documents:write role set equals the project canEdit set, so a read-only
member never reaches those doors — write-guards.test.ts locks the two
matrices together so a drift in either shows up as a failing test.
The respond door checked the write matrix but never who was asked: any
writer — the submitter included — could decide a review addressed to
someone else, and a submitter could name themselves as reviewer.

Submit now refuses self-designation (REVIEWER_SELF_NOT_ALLOWED) and the
picker never offers the caller; respond admits only the designee
(REVIEW_NOT_ASSIGNED) and never the submitter
(REVIEW_SELF_APPROVAL_FORBIDDEN), so a review is always a second pair of
eyes. The review dialog disables the decision buttons for everyone but
the designee and says who decides; the docs say the same in en/de/fr.
readProjectTextValues queried by (org, project, folder, file) behind org
membership only, so any member could read another team's automation
settings by guessing the well-known file names. The read now runs the
same project read gate the project listing does (assertReadable): an
unknown or foreign project is not found, no read access is forbidden.
Only the team-change route re-stamped retrieval scope; attach/detach
changed project_id and left the corpus rows alone, so a hub document
moved into a restricted project stayed org-wide retrievable and a
detached one vanished from hub retrieval. attach/detach hand back the
blob ref and the routes re-stamp after commit, exactly like the team
change does.
upsertAgentDocument wrote project_id unverified, so a mistyped or
foreign id filed the document where no listing (the hub is
project_id IS NULL) and no retrieval scope reached it — saved, answered
ok, lost. It now requires the project to exist in the org
(PROJECT_NOT_FOUND, the task writer's rule). runDocumentCreate on a
multi-bound org run that named no project fell through to the org hub —
wider than the run's authority; it now refuses like task_create does and
names the bound projects.
The race check parks both binds behind a row lock it holds and releases
once two backends wait. A serialized bind waits at its locked load of
app.documents; an unserialized one waits earlier, at its file_metadata
INSERT — the FK to documents.id takes a KEY SHARE lock the held FOR
UPDATE conflicts with. Counting either statement as parked keeps
"parked" meaning what it says whichever path a bind takes.
An unserialized bind reads the document freely and blocks later — one at
the swap UPDATE behind the gate, the other behind that bind on the
per-org upload counter both touch first — so a statement-text filter
saw one waiter and reported the race as not parked. Counting blocked
backends (pg_blocking_pids) makes "parked" mean both binds waited,
whichever statement each waits in.
Only the app route re-stamped retrieval scope after a team change; the
REST v1 PATCH /documents/:id took a teamId and left the corpus rows
carrying the old team's scope. Same rule as the app route and the
attach/detach doors: re-stamp after commit. The corpus-scope check
gains the REST leg (team via REST → stamped, cleared → null).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant