fix(bulk): honor ?adv= advanced-search ruleset in by-filter bulk ops (bookshelf-t7cmm) #1463
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "bd-bookshelf-t7cmm"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
By-filter bulk operations (Select-all-matching → Move & Organize / enrich / covers / DELETE / attach / lock / shelf-assign) rebuilt their match set via
books.ParseViewQueryFilter, which had no handling for the?adv=advanced-search param thatListHandler(the regular list view) already decodes. So an active advanced search was silently dropped and the by-filter bulk op operated on the user's entire accessible library set instead of the search results actually shown — a high-severity data-safety bug, since by-filter DELETE would hard-delete the whole library rather than the search results.Root cause
internal/books/handler.go:365(list view) already decodesadvviadecodeAdvSearch(wired tomagic.DecodeAdvSearch) and injects the predicate asFilter.MagicWhere/MagicArgs/MagicJoins.internal/books/bulk_move_filter_preview_handler.gobuildPreviewIDsParams→ParseViewQueryFilter(no adv handling).internal/wfengine'sBulkByFilterWorkflowactivity re-parsesViewQueryper epoch viainternal/app/build_enrich_deps.go'slistFilteredIDsPageFromViewQuery(same gap).bulk_lock_by_filter_handler.go,bulk_attach_by_filter_handler.go,bulk_shelf_assign_by_filter_handler.go) share the sameParseViewQueryFiltercall with no adv handling.MagicWheresupport existed on the target params struct, two silent field-copy gaps dropped it anyway:FilteredIDsPageParamshad noMagicWhere/MagicArgs/MagicJoinsfields at all, and the sharedapplyFilterFacetsToBulkLockParamscopy helper didn't copy them either.Fix
books.ApplyAdvSearch(filter, q, decodeAdvSearch, userID, userLibraryIDs)— decodesq'sadvparam (when present) via the injectedAdvSearchDecoderand overlaysMagicWhere/MagicArgs/MagicJoinsonto the filter. MirrorsListHandler's existing?adv=handling exactly.FilteredIDsPageParamsgainedMagicWhere/MagicArgs/MagicJoinsfields, wired intobuildFilteredBookIDsPageQuery(mirroring the pattern already used byresolveLockFilterIDs).books.FilteredIDsPageParamsFromFilterreplaces two near-duplicate private mapping functions (one in the preview handler, one ininternal/app) that had silently diverged — now both the preview path and theBulkByFilterWorkflowactivity path buildFilteredIDsPageParamsfrom the same function, so a future field addition can't silently regress one path only.ApplyAdvSearchthreaded into: the move-filter preview handler,internal/app'slistFilteredIDsPageFromViewQuery(the workflow activity's per-epoch ID page builder, usingmagic.DecodeAdvSearchdirectly), and the lock/attach/shelf-assign by-filter handlers (d.DecodeAdvSearchfromappwire.Deps, already wired for the list view).applyFilterFacetsToBulkLockParams(shared by lock/attach/shelf-assign) now also copiesMagicWhere/MagicArgs/MagicJoins.No workflow command-sequence change —
BulkByFilterWorkflow's activity/sub-workflow/ContinueAsNew shape is untouched; only the SQL predicate the existingListFilteredIDsPageactivity builds is affected. Nogowf.Versiongate needed.Test plan
package books_test) forApplyAdvSearch,FilteredIDsPageParams.MagicWhereSQL injection, and regression coverage on the preview handler + lock/attach/shelf-assign handlers proving an active?adv=ruleset is decoded and injected asMagicWhere, and that adecodeAdvSearcherror surfaces as a 5xx.internal/appand**/wire.gostay excluded from the coverage gate per project convention (pure wiring, verified by e2e); the actual decode/injection logic lives ininternal/booksand is unit-tested there.make test lintandmake coverageall green locally (100% coverage gate).Closes bead bookshelf-t7cmm on merge.
Security Review — PR #1463 (bd-bookshelf-t7cmm)
Scope:
?adv=advanced-search ruleset now threaded through the shared filtered-IDsquery used by by-filter bulk preview +
BulkByFilterWorkflow(delete/move/attach/lock/shelf-assign/covers/enrich), closing the "adv= ignored → operated on whole
library" data-safety gap.
Checked: SQL injection surface, multi-user/authz scoping, prod wiring for silent
adv-drop, cross-op consistency, secrets/PII in logs.
Findings:
No BLOCKER, MAJOR, or MINOR findings.
Verification detail (for the record):
SQL injection —
magic.DecodeAdvSearch/TranslateWithOptions(unmodified bythis diff) build predicates via a fixed switch-statement field catalog with
?placeholders; values only ever go into the returnedargs []any. The newfiltered_ids_store.goinjection point (buildFilteredBookIDsPageQuery)appends
p.MagicWhereas an opaque string into thewheresslice (joinedwith
" AND ") and appendsp.MagicArgspositionally towhereArgs—no string concatenation of user data into SQL text, no new interpolation
hole introduced.
Multi-user/authz (core of the fix) —
MagicWhereis always appended to thewheresslice already containing the mandatoryb.library_id IN (...)(fail-closed to
1=0whenUserLibraryIDsis empty/nil,filter_predicates.go:105-116), and all entries are joined withAND(
filtered_ids_store.go:118) — the adv predicate can only narrow, neverwiden, the accessible set.
userID/userLibraryIDsare sourced fromuserIDFromRequest(r)+getUserLibraryIDs(ctx, userID)(session-derived)in every handler touched (attach/lock/shelf-assign/move-preview), never from
request body/query.
ApplyAdvSearchpasses them straight through todecodeAdvSearch, matching the pre-existingListHandlerpattern.No prod path silently drops adv — grepped every
nil, // decodeAdvSearch-shaped nil argument; all occurrences are in
*_test.gofiles. Prod wiring(
internal/books/wire.go) passesd.DecodeAdvSearch(set ininternal/app/app.go:617tomagic.DecodeAdvSearch, a real function) toevery by-filter handler that gained the parameter. The workflow-driven paths
(delete/move/covers/enrich) go through
listFilteredIDsPageFromViewQueryin
internal/app/build_enrich_deps.go, which now unconditionally callsbooks.ApplyAdvSearch(filter, parsed, magic.DecodeAdvSearch, ...)on everyepoch — not parameterized/nilable at that call site.
Consistency — attach, lock, shelf-assign, move-preview handlers, and the
shared workflow ID-sweep activity (used by delete/move/covers/enrich) all
route through the same
ApplyAdvSearch→FilteredIDsPageParamsFromFilter→
buildFilteredBookIDsPageQuerypath. No mutating by-filter op was foundstill bypassing adv.
No secrets/PII logged in the touched handlers (only counts, IDs, trace_id).
Ownership checks (
checkMagicShelfAccess,resolveMagicShelfScope) areunchanged by this diff.
Also noted:
DecodeAdvSearchreturningnilis a documented no-op(
ApplyAdvSearchreturns the filter unchanged whendecodeAdvSearch == nil),which is correct test-double behavior but relies on prod wiring always
supplying a real decoder — confirmed true today at every call site (see #3).
No follow-up needed since there's no code path in
wire.go/build_enrich_deps.gothat could invoke a handler with a nil decoder in production.
REVIEW VERDICT: 0 blocker, 0 major, 0 minor
Code Review: PR #1463 (bd-bookshelf-t7cmm)
Reviewed the diff only (per policy, did not re-run tests; CI green + mergeable=true confirmed independently).
Summary of what I verified (traced, not assumed):
d.DecodeAdvSearchininternal/books/wire.goand call the newbooks.ApplyAdvSearchbefore buildingFilteredIDsPageParams/ListBooksFilteredParams.BulkByFilterWorkflowviaStartBulkByFilter) is fixed at the single shared composition pointlistFilteredIDsPageFromViewQueryininternal/app/build_enrich_deps.go:237-268, which now callsbooks.ApplyAdvSearch(filter, parsed, magic.DecodeAdvSearch, f.UserID, userLibIDs)before building params via the newbooks.FilteredIDsPageParamsFromFilter. Since every by-filter workflow op shares this one function, DELETE (the bead's specific worst-case concern) is fixed, not just preview.MagicArgsare bound as placeholders (whereArgs = append(whereArgs, p.MagicArgs...)), never string-concatenated;wheresare joined with" AND "uniformly (filtered_ids_store.go:196), so the adv predicate is properly ANDed with the rest, not OR'd in a way that could widen the set.userID/userLibraryIDspassed toApplyAdvSearch/DecodeAdvSearchcome fromuserIDFromRequest/getUserLibraryIDs(session-derived) at every call site checked (lock/attach/shelf-assign/move-preview handlers, and the per-epochgetUserLibraryIDsre-resolution inbuildListFilteredIDsPageFn) — never from the request body.internal/wfengine/bulk_by_filter_workflow.goandmodule_bulk_by_filter.gohave zero diff. The fix is entirely activity-internal (the ID set an existing activity produces), so nogowf.Versiongate is required — confirmed correct.internal/books/filter_parse.goandfiltered_ids_store.goimport onlynet/url/middleware, nomagicor workflow-engine import.AdvSearchDecoderis a plain func type; onlyinternal/appwires the concretemagic.DecodeAdvSearch.nil, // decodeAdvSearchoccurrences are confined to*_test.gofiles — no production wiring passes nil for a real by-filter handler.package books_test), and drive real behavior:filter_parse_test.go'sApplyAdvSearchDescribe asserts pass-through of encoded/userID/userLibraryIDs to a stub decoder and error propagation;filtered_ids_store_test.goassertsMagicWhere/MagicJoins/MagicArgsactually appear in the built SQL string and args slice.[MAJOR] internal/app/build_enrich_deps.go:237-268 — the riskiest composition point (workflow EXECUTE for DELETE/enrich/covers/move/etc.) has zero test coverage proving it honors adv
internal/appis explicitly exempted from the 100%-coverage gate because "correctness is verified by e2e tests" (seescripts/check-coverage.shcomment, and CLAUDE.md's "internal/appis pure wiring: NO TESTS in that package"). But this PR adds no e2e test exercising?adv=through the realBulkByFilterWorkflowexecute path (no diff undere2e/). The lower-layer primitives (ApplyAdvSearch,FilteredIDsPageParamsFromFilter, SQL-shape building) are well unit-tested in isolation, but the exact composition inlistFilteredIDsPageFromViewQuery— parse ViewQuery → ApplyAdvSearch → FilteredIDsPageParamsFromFilter → listPage — is untested end-to-end. This matters because the original bug was precisely a case of individually-correct-looking code with one call site silently dropping adv; the fix's most safety-critical call site (the one that actually deletes/moves/enriches books) is verified only by manual reasoning, not a test. Given the bead's own severity framing ("by-filter DELETE under an active adv search would hard-delete the whole library"), this deserves at least a targeted addition to the existinge2e/browser/journey_bulk_toolbar_test.go(which already drives/bulk/filter/— see main:e2e/browser/journey_bulk_toolbar_test.go:189-253) or awfengineintegration test asserting the workflow's produced ID set excludes books that fail an activeadvruleset. Suggested fix: add oneItstep to the existing bulk-toolbar journey (or aBulkByFilterWorkflowtester-based test) that triggers a by-filter delete/enrich with?adv=active and asserts a book failing the ruleset survives / is not touched.[MINOR] internal/books/bulk_lock_by_filter_handler.go:47-51 (and the identical pattern in bulk_attach_by_filter_handler.go, bulk_shelf_assign_by_filter_handler.go) — MagicShelfID silently clobbers an adv-derived predicate instead of combining them
buildBulkLockFilterParams(called first) setsp.MagicWhere/Args/JoinsfromApplyAdvSearch's result whenreq.ViewQuerycarriesadv=. Immediately after,if req.MagicShelfID != 0 { applyMagicShelfScopeToParams(...) }unconditionally overwritesp.MagicWhere/Args/Joinswith the magic shelf's predicate, silently discarding the adv ruleset. The equivalent workflow path (buildListFilteredIDsPageFnininternal/app/build_enrich_deps.go:389-394) has the same precedence: it branches tolistMagicShelfIDsPagebefore ever parsingViewQuery/adv. Static JS evidence (filter_drawer_controller.js:394,ALLOWED_CONTEXT_KEYSincludes bothmagic_shelf_idand general filter context) suggests the UI can plausibly combine a magic-shelf view with an active advanced search. This isn't a regression introduced by this PR (pre-PR, adv was unconditionally dropped in every case), so it doesn't block this fix, but it's an incomplete edge case worth a follow-up bead — file one so it isn't lost (bd create "Fix: adv= silently dropped when MagicShelfID is also present in by-filter ops" -d "Follow-up to bookshelf-t7cmm: ...").REVIEW VERDICT: 0 blocker, 1 major, 1 minor