fix(nav): per-user library-scope Authors/AllBooks/ByLibrary nav counts (bookshelf-ebbx) #1090
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "bd-bookshelf-ebbx"
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?
What was leaking
Two nav badge counts used a global
WrapInt64cache shared across all users, so every user saw the same system-wide total regardless of which libraries they have access to:SELECT COUNT(*) FROM book WHERE deleted = 0: global count of every book; a user scoped to 2 libraries out of 10 would see the full 10-library total.SELECT COUNT(*) FROM author: counts every author record in the system, not just authors with books in the user's libraries.CountBooksByLibrary (global map of library_id→count) was audited and found safe to cache globally:
applyNavCountsonly looks up entries for libraries already in the user's sidebar, which are themselves user-scoped, so no cross-user count is ever displayed. A justification comment was added to the struct field.Fix
NavCountDeps.CountAllBookssignature fromfunc(context.Context) (int64, error)tofunc(context.Context, []int64) (int64, error)(library-scoped).NavCountDeps.CountAuthorssignature fromfunc(context.Context) (int64, error)tofunc(context.Context, []int64) (int64, error)(library-scoped).launchNavBookCounts: CountAllBooks goroutine now callsresolveNavLibraryIDsbefore querying — the same fail-closed pattern used by CountDistinctSeries/CountCategories.launchNavMetaCounts: CountAuthors goroutine likewise usesresolveNavLibraryIDs.librarystats.MakeCountBooks/librarystats.MakeCountAuthorswrapped withWrapInt64WithLibraries(per-library-set cache entry, preventing cross-user leakage).Test plan
make test— all packages green includinginternal/middlewaresuite with 8 new specs proving:make coverage— 100% coverage maintainedgolangci-lint run ./internal/middleware/... ./internal/app/... ./internal/librarystats/...— 0 issuesCloses bead bookshelf-ebbx on merge.
Security Review — bookshelf-ebbx (PR #1090)
Focus: multi-user scoping correctness for CountAllBooks and CountAuthors nav badges.
Multi-user scoping
CountAllBooks scoping — CORRECT.
launchNavBookCountsnow callsresolveNavLibraryIDsbefore invokingCountAllBooks.resolveNavLibraryIDsextracts the userID from the session (never from the request body/params), callsGetUserLibraryIDs(ctx, userID), and returns the result.MakeCountBooksthen issuesSELECT COUNT(*) FROM book WHERE deleted = 0 AND library_id IN (?, ...)with those IDs as bound parameters. No global count path remains.CountAuthors scoping — CORRECT. Same pattern via
launchNavMetaCounts.MakeCountAuthorsissuesSELECT COUNT(DISTINCT bma.author_id) … WHERE b.library_id IN (?, ...) AND b.deleted = 0with the user's library IDs bound as parameters.Cache key includes library scope — CORRECT.
WrapInt64WithLibrariesuseslibrarySetKey(ids)which mapsnil→"nil", empty slice →"", and any non-empty slice → sorted-joined string (e.g."1,10,2"for{2,1,10}). Each distinct library set gets its own independentNavCacheentry. User A's library set{1,2}is keyed separately from user B's{3,4}. Cross-user cache contamination is impossible.Fail-closed for empty library access — CORRECT.
MakeCountBooksandMakeCountAuthorsboth guard withif len(ids) == 0 { return 0, nil }. An authenticated user with no accessible libraries gets count0, never the global count.Error path suppresses badge — CORRECT.
resolveNavLibraryIDsreturns(nil, false)on error; the caller checks!okandreturns without settingHasAllBooks/HasAuthors, so the badge is suppressed rather than showing a stale or global value.userID source — CORRECT.
resolveNavLibraryIDstakesuserID int64from the middleware's authenticated session, never from request body or query params.CountBooksByLibrary global-map safety — CORRECT. The map is populated with all library_id → count pairs, but
applyNavCountsonly indexes into it using library IDs that are already inlibs []tmpl.SidebarLibrary— a user-scoped slice. A user cannot observe a count for a library outside their sidebar. The justification in the comment is accurate.[MINOR] internal/middleware/nav.go:26-28 — Doc comment "nil userLibraryIDs = unscoped" is inaccurate for the new CountAllBooks/CountAuthors functions
The comment inherited from the CountDistinctSeries pattern says "nil = unscoped (all libraries)", but
MakeCountBooksandMakeCountAuthorstreatnilidentically to an empty slice —len(nil) == 0is true in Go, so both return0immediately. The actual behavior is more restrictive than the comment implies (unauthenticated users see 0, not the global count). This is secure, but the comment could mislead a future maintainer adding a new count function under this interface who follows the doc and tries to implement a nil-means-unscoped path. Fix: update the doc comment to "nil userLibraryIDs = no libraries accessible, returns 0" for these two fields, or remove the "unscoped" language and standardize on "non-nil empty = zero (fail-closed)".REVIEW VERDICT: 0 blocker, 0 major, 1 minor
CODE REVIEW — bookshelf-ebbx (PR #1090)
Phase 0: DEMO Verification
No runnable DEMO block exists in the bead (bug-fix/audit task). Proceeding on diff review.
Phase 1: Spec Compliance
WrapInt64WithLibraries(librarystats.MakeCountBooks(...), ...)— matched ✓WrapInt64WithLibraries(librarystats.MakeCountAuthors(...), ...)— matched ✓Phase 2: Code Quality
Correctness — SQL queries are genuinely scoped
The critical concern (scoped cache over an unscoped query) does not apply here. Both
MakeCountBooksandMakeCountAuthorsininternal/librarystats/store.gocarry genuineWHERE ... library_id IN (...)filters and fail-closed on empty input. The nav badge fix is end-to-end, not just a cache-key fix.Nil vs empty distinction — documentation inconsistency with CountDistinctSeries
CountDistinctSeries(established in PR #932) correctly distinguishes nil from empty:MakeCountBooksandMakeCountAuthorsboth use:The doc comment on
CountAllBooksandCountAuthorsinNavCountDeps(nav.go) saysnil userLibraryIDs = unscoped; non-nil empty = fail-closed— but the implementation returns 0 for nil too, contradicting the stated contract.resolveNavLibraryIDsreturns(nil, true)for unauthenticated users (userID==0), so those users see 0 books/authors while seeing the global series count (CountDistinctSeries handles nil correctly). The inconsistency doesn't create a security regression (0 is more restrictive than global), but the misleading doc comment is a latent trap for the next developer.[MINOR]
internal/librarystats/store.goMakeCountBooksline 9,MakeCountAuthorsline 48 — nil treated as fail-closed, contradictingnil = unscopeddoc comment and diverging from the CountDistinctSeries precedent ininternal/series/store.go. Fix: changeif len(ids) == 0toif ids != nil && len(ids) == 0(matching CountDistinctSeries) and add the nil = global-count unscoped path, OR correct the NavCountDeps doc comment to saynil OR empty = fail-closed.CountBooksByLibrary global justification
Accurate.
applyNavCountskeys into the returned map only for libraries present in the user's sidebar (populated vialistLibrarieswith userID scoping), so no cross-user count is exposed. Comment added in both nav.go and app.go confirms this.app.go wiring
Clean.
WrapInt64WithLibrariesreturns nil when fn is nil, butlibrarystats.MakeCountBooks/MakeCountAuthorsalways return non-nil closures, so no nil func-field trap applies here.Tests
package middleware_test— black-box ✓ExpectperItthroughout ✓JustBeforeEach/BeforeEachseparation maintained ✓wg count unchanged
wg.Add(9)unchanged. NewresolveNavLibraryIDscalls are inside existing goroutines (not new ones).defer wg.Done()fires correctly even whenresolveNavLibraryIDsreturns false.REVIEW VERDICT: 0 blocker, 0 major, 1 minor