fix(dedup): gate destructive endpoints behind permission middleware (bookshelf-u5q4) #889

Merged
zombor merged 2 commits from bd-bookshelf-u5q4 into main 2026-07-03 01:37:31 +00:00
Owner

Summary

  • Three destructive dedup endpoints (POST merge, merge-all, delete-non-selected) had no permission-tier gate — a read-only user could trigger irreversible book merges and file deletes.
  • Add a Gates struct to internal/dedup mirroring the pattern in internal/library and internal/books.
  • Wrap the mutating routes: merge/merge-all → permission_move_organize_files; delete-non-selected → permission_delete_book.
  • Wire gates from appwire.Deps.BookMoveOrganizeRequired and BookDeleteRequired in dedup.Wire.
  • Read routes (GET modal, POST scan, GET merge-all status) remain ungated beyond the existing library-access scoping inside the handlers.

Test plan

  • New internal/dedup/routes_test.go with black-box 403 specs for all three mutating routes when gate forbids
  • Negative tests confirming read routes (GET modal, POST scan, GET status) are NOT blocked by the gate
  • All existing dedup tests updated to use passthroughGates() — no behavior change for non-permission tests
  • make test passes; make coverage shows 100% gate

Closes bead bookshelf-u5q4 on merge.

## Summary - Three destructive dedup endpoints (POST merge, merge-all, delete-non-selected) had no permission-tier gate — a read-only user could trigger irreversible book merges and file deletes. - Add a `Gates` struct to `internal/dedup` mirroring the pattern in `internal/library` and `internal/books`. - Wrap the mutating routes: merge/merge-all → `permission_move_organize_files`; delete-non-selected → `permission_delete_book`. - Wire gates from `appwire.Deps.BookMoveOrganizeRequired` and `BookDeleteRequired` in `dedup.Wire`. - Read routes (GET modal, POST scan, GET merge-all status) remain ungated beyond the existing library-access scoping inside the handlers. ## Test plan - [x] New `internal/dedup/routes_test.go` with black-box 403 specs for all three mutating routes when gate forbids - [x] Negative tests confirming read routes (GET modal, POST scan, GET status) are NOT blocked by the gate - [x] All existing dedup tests updated to use `passthroughGates()` — no behavior change for non-permission tests - [x] `make test` passes; `make coverage` shows 100% gate Closes bead bookshelf-u5q4 on merge.
fix(dedup): gate destructive endpoints behind permission middleware (bookshelf-u5q4)
All checks were successful
/ JS Unit Tests (pull_request) Successful in 1m39s
/ E2E API (pull_request) Successful in 2m41s
/ Integration (pull_request) Successful in 3m39s
/ E2E Browser (pull_request) Successful in 3m59s
/ Lint (pull_request) Successful in 5m14s
/ Test (pull_request) Successful in 6m2s
468e50506f
Merge, merge-all, and delete-non-selected were registered with plain
eh.Wrap() — no permission tier beyond library-access scoping. A user
with read-only library access could trigger irreversible book merges
and file deletes.

Add a Gates struct to the dedup package mirroring the pattern in
internal/library and internal/books. Wrap the three mutating routes:
  - POST .../dedup/merge         → MergeRequired (permission_move_organize_files)
  - POST .../dedup/merge-all     → MergeRequired (permission_move_organize_files)
  - POST .../dedup/delete-non-selected → DeleteRequired (permission_delete_book)

Read routes (GET modal, POST scan, GET merge-all status) remain
ungated beyond the existing library-access scoping inside the handlers.

Wire the gates from appwire.Deps.BookMoveOrganizeRequired and
BookDeleteRequired in dedup.Wire.

Add routes_test.go with black-box 403 tests confirming each mutating
route is blocked when the gate forbids, and read routes are not blocked.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Workflow Detail page screenshot (wf-detail-older-execution)

Older completed ContinueAsNew epoch detail — execution ID and state visible, Cancel absent.

wf-detail-older-execution

**Workflow Detail page screenshot** (wf-detail-older-execution) Older completed ContinueAsNew epoch detail — execution ID and state visible, Cancel absent. ![wf-detail-older-execution](/attachments/ea47b1d1-672a-4e06-a12e-4d0c0750e31e)

Recompute Match Score — kebab open screenshot (recompute-match-score-kebab-open)

recompute-match-score-kebab-open

**Recompute Match Score — kebab open screenshot** (recompute-match-score-kebab-open) ![recompute-match-score-kebab-open](/attachments/241dd620-87c8-4fb8-ae16-ad49ac9bf9bc)
Author
Owner

Security Review — PR #889 (bookshelf-u5q4)

[BLOCKER] internal/dedup/routes.go:52-53 — Merge and MergeAll hard-delete source books but are gated only on permission_move_organize_files, not permission_delete_book
The Merge service function (dedup/service.go:401-402) runs deleteBooksRaw which issues DELETE FROM book WHERE id IN (...) as step 5 of every merge. BulkDedupMergeAllWorkflow calls the same Merge activity. Both POST /libraries/{id}/dedup/merge and POST /libraries/{id}/dedup/merge-all are gated exclusively by g.MergeRequired (permission_move_organize_files). A user who holds permission_move_organize_files but NOT permission_delete_book can permanently destroy book records via these endpoints, bypassing the delete permission gate entirely. The delete-non-selected route is correctly gated by g.DeleteRequired (permission_delete_book); the merge paths need the same guard (either require both permissions, or gate merge routes through a combined checker that asserts both flags). The test in routes_test.go uses forbidGates() which forbids ALL gates at once — it proves the middleware is invoked but does not catch that the wrong single permission is used for a route that deletes books.

REVIEW VERDICT: 1 blocker, 0 major, 0 minor

**Security Review — PR #889 (bookshelf-u5q4)** [BLOCKER] internal/dedup/routes.go:52-53 — Merge and MergeAll hard-delete source books but are gated only on permission_move_organize_files, not permission_delete_book The Merge service function (dedup/service.go:401-402) runs deleteBooksRaw which issues DELETE FROM book WHERE id IN (...) as step 5 of every merge. BulkDedupMergeAllWorkflow calls the same Merge activity. Both POST /libraries/{id}/dedup/merge and POST /libraries/{id}/dedup/merge-all are gated exclusively by g.MergeRequired (permission_move_organize_files). A user who holds permission_move_organize_files but NOT permission_delete_book can permanently destroy book records via these endpoints, bypassing the delete permission gate entirely. The delete-non-selected route is correctly gated by g.DeleteRequired (permission_delete_book); the merge paths need the same guard (either require both permissions, or gate merge routes through a combined checker that asserts both flags). The test in routes_test.go uses forbidGates() which forbids ALL gates at once — it proves the middleware is invoked but does not catch that the wrong single permission is used for a route that deletes books. REVIEW VERDICT: 1 blocker, 0 major, 0 minor
Author
Owner

Code Review — bookshelf-u5q4

Phase 0: DEMO

No DEMO block in the bead (permission-gate enforcement is tested via unit assertions rather than a runnable CLI demo). Acceptable for a middleware-gating fix.


Phase 1 & 2 Findings

[MINOR] internal/dedup/routes_test.go:72,80,87 — negative assertions on ungated routes
Expect(rec.Code).NotTo(Equal(http.StatusForbidden)) would pass even if the handler returned 500, since nopModal/nopScan/nopMergeAllStatus all explicitly write 200. A positive Expect(rec.Code).To(Equal(http.StatusOK)) is more precise and is consistent with the one-assertion-per-It idiom the rest of the test suite uses. Low risk because the nop stubs are trivially correct, but the assertion does not pin the behaviour it is advertising.


What was checked

Correctness of wrapping (vs library/routes.go)

  • POST .../dedup/mergeg.MergeRequired(eh.Wrap(merge))
  • POST .../dedup/merge-allg.MergeRequired(eh.Wrap(mergeAll))
  • POST .../dedup/delete-non-selectedg.DeleteRequired(eh.Wrap(deleteNonSelected))
  • Read routes (GET .../dedup, POST .../dedup/scan, GET .../dedup/merge-all/{instanceID}/status) intentionally ungated — matches bead intent and the scan-is-read-only invariant. ✓

Permission correctness

  • merge/merge-all → d.BookMoveOrganizeRequired (PermissionMoveOrganizeFiles) — correct; mirrors how books.Gates.MoveOrganize gates /books/bulk/move. ✓
  • delete-non-selected → d.BookDeleteRequired (PermissionDeleteBook) — correct; same permission used by books.Gates.Delete and books.Gates.Detach. ✓

Gates struct convention

  • dedup.Gates is structurally identical to books.Gates and library.Gates: a value type carrying named func(http.Handler) http.Handler fields, passed by value to RegisterRoutes, constructed inline in wire.go. This is the established pattern. No inconsistency. ✓

Nil-safety

  • If either Gates field is nil at registration time g.MergeRequired(...) panics. This is the same contract that books.Gates and library.Gates accept. app.go sets both BookMoveOrganizeRequired and BookDeleteRequired unconditionally at startup; wire.go reads (does not reassign) the Deps fields, so the by-value-Deps trap (bookshelf-deps-nil #633) does not apply here. Risk consistent with the rest of the codebase. ✓

passthroughGates() accuracy

  • Sets both fields to func(next) { return next }. Correctly models "user has all permissions" for handler-behaviour tests, which test handler logic, not permission logic. Permission enforcement is tested separately in routes_test.go with forbidGates(). The helper is accurate and not a bypass. ✓

Test quality

  • package dedup_test (black-box) ✓
  • forbidGates() tests 403 for all three gated routes, one Expect per It
  • Negative read-route tests (NotTo(Equal(http.StatusForbidden))) function correctly but are weaker than positive assertions (see MINOR above)
  • Ordered + BeforeAll is valid Ginkgo for shared mux setup; the specs are independently safe ✓
  • audit_test.go updated to pass passthroughGates() through doMergeWithAudit

Wire.go

  • Gates{MergeRequired: d.BookMoveOrganizeRequired, DeleteRequired: d.BookDeleteRequired} inserted as the second-to-last arg, immediately before d.EH — matches the RegisterRoutes signature. ✓

REVIEW VERDICT: 0 blocker, 0 major, 1 minor

## Code Review — bookshelf-u5q4 ### Phase 0: DEMO No DEMO block in the bead (permission-gate enforcement is tested via unit assertions rather than a runnable CLI demo). Acceptable for a middleware-gating fix. --- ### Phase 1 & 2 Findings [MINOR] internal/dedup/routes_test.go:72,80,87 — negative assertions on ungated routes `Expect(rec.Code).NotTo(Equal(http.StatusForbidden))` would pass even if the handler returned 500, since nopModal/nopScan/nopMergeAllStatus all explicitly write 200. A positive `Expect(rec.Code).To(Equal(http.StatusOK))` is more precise and is consistent with the one-assertion-per-It idiom the rest of the test suite uses. Low risk because the nop stubs are trivially correct, but the assertion does not pin the behaviour it is advertising. --- ### What was checked **Correctness of wrapping (vs library/routes.go)** - `POST .../dedup/merge` → `g.MergeRequired(eh.Wrap(merge))` ✓ - `POST .../dedup/merge-all` → `g.MergeRequired(eh.Wrap(mergeAll))` ✓ - `POST .../dedup/delete-non-selected` → `g.DeleteRequired(eh.Wrap(deleteNonSelected))` ✓ - Read routes (`GET .../dedup`, `POST .../dedup/scan`, `GET .../dedup/merge-all/{instanceID}/status`) intentionally ungated — matches bead intent and the scan-is-read-only invariant. ✓ **Permission correctness** - merge/merge-all → `d.BookMoveOrganizeRequired` (PermissionMoveOrganizeFiles) — correct; mirrors how `books.Gates.MoveOrganize` gates `/books/bulk/move`. ✓ - delete-non-selected → `d.BookDeleteRequired` (PermissionDeleteBook) — correct; same permission used by `books.Gates.Delete` and `books.Gates.Detach`. ✓ **Gates struct convention** - `dedup.Gates` is structurally identical to `books.Gates` and `library.Gates`: a value type carrying named `func(http.Handler) http.Handler` fields, passed by value to `RegisterRoutes`, constructed inline in `wire.go`. This is the established pattern. No inconsistency. ✓ **Nil-safety** - If either Gates field is nil at registration time `g.MergeRequired(...)` panics. This is the same contract that `books.Gates` and `library.Gates` accept. `app.go` sets both `BookMoveOrganizeRequired` and `BookDeleteRequired` unconditionally at startup; `wire.go` reads (does not reassign) the Deps fields, so the by-value-Deps trap (bookshelf-deps-nil #633) does not apply here. Risk consistent with the rest of the codebase. ✓ **passthroughGates() accuracy** - Sets both fields to `func(next) { return next }`. Correctly models "user has all permissions" for handler-behaviour tests, which test handler logic, not permission logic. Permission enforcement is tested separately in `routes_test.go` with `forbidGates()`. The helper is accurate and not a bypass. ✓ **Test quality** - `package dedup_test` (black-box) ✓ - `forbidGates()` tests 403 for all three gated routes, one `Expect` per `It` ✓ - Negative read-route tests (`NotTo(Equal(http.StatusForbidden))`) function correctly but are weaker than positive assertions (see MINOR above) - `Ordered` + `BeforeAll` is valid Ginkgo for shared mux setup; the specs are independently safe ✓ - `audit_test.go` updated to pass `passthroughGates()` through `doMergeWithAudit` ✓ **Wire.go** - `Gates{MergeRequired: d.BookMoveOrganizeRequired, DeleteRequired: d.BookDeleteRequired}` inserted as the second-to-last arg, immediately before `d.EH` — matches the `RegisterRoutes` signature. ✓ --- REVIEW VERDICT: 0 blocker, 0 major, 1 minor
zombor force-pushed bd-bookshelf-u5q4 from 468e50506f
All checks were successful
/ JS Unit Tests (pull_request) Successful in 1m39s
/ E2E API (pull_request) Successful in 2m41s
/ Integration (pull_request) Successful in 3m39s
/ E2E Browser (pull_request) Successful in 3m59s
/ Lint (pull_request) Successful in 5m14s
/ Test (pull_request) Successful in 6m2s
to 2f1a2a3d42
All checks were successful
/ JS Unit Tests (pull_request) Successful in 1m30s
/ Lint (pull_request) Successful in 2m13s
/ E2E API (pull_request) Successful in 2m16s
/ Integration (pull_request) Successful in 3m13s
/ E2E Browser (pull_request) Successful in 3m30s
/ Test (pull_request) Successful in 4m1s
2026-07-03 01:30:29 +00:00
Compare

Recompute Match Score — kebab open screenshot (recompute-match-score-kebab-open)

recompute-match-score-kebab-open

**Recompute Match Score — kebab open screenshot** (recompute-match-score-kebab-open) ![recompute-match-score-kebab-open](/attachments/2f85ab82-35d5-4202-8ade-773ea7738efe)

Workflow Detail page screenshot (wf-detail-older-execution)

Older completed ContinueAsNew epoch detail — execution ID and state visible, Cancel absent.

wf-detail-older-execution

**Workflow Detail page screenshot** (wf-detail-older-execution) Older completed ContinueAsNew epoch detail — execution ID and state visible, Cancel absent. ![wf-detail-older-execution](/attachments/9e182e39-86fb-427d-8a92-5cb3365727a4)
zombor merged commit 7007141316 into main 2026-07-03 01:37:31 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
zombor/pergamum!889
No description provided.