feat(auth): RBAC permission middleware + nav gating + bookdrop gating (bookshelf-4op.3.1) #458
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "bd-bookshelf-4op.3.1"
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
PermissionRequired(getPerms, check, logger)middleware ininternal/users/: admin short-circuits via JWTisAdmin(zero DB calls), non-admin does a single PK-indexedSELECTonuser_permissions, result cached in request context so repeated checks share one round-trip. Fail-closed: missing row → 403, DB error → 403.GetPermissionspublic helper so theextractUserclosure inapp.goreuses the same context cache when populatingCurrentUser.CanAccessBookdropon every page render.CanAccessBookdrop boolontmpl.CurrentUser/BaseDatafor template use (bookshelf-v1at).base.html: BookDrop wrapped in{{if .CurrentUser.CanAccessBookdrop}}; Field Priority and Metadata Providers moved inside existing{{if .CurrentUser.IsAdmin}}block (bookshelf-v1at).BookdropRequiredmiddleware to all 6 bookdrop routes viabookdrop.RegisterRoutes— GET /bookdrop + 5 POSTs (bookshelf-f8so).BookdropRequiredinappwire.Depsandapp.go.GetUserPermissions :onesqlc query + regenerateinternal/db/sqlc/.Test plan
make test— all unit tests passmake coverage— 100% coverage gate passesuser_permissionsrow: 403 (fail-closed)denyRequest){"error":"Forbidden"}on 403Closes bead bookshelf-4op.3.1 on merge.
Also resolves bookshelf-v1at and bookshelf-f8so (folded into this slice).
Code Review (bot)
PR #458 — RBAC foundation: PermissionRequired middleware + bookdrop gating
Phase 0: DEMO Verification
No DEMO block found in bead comments (
bd comments bookshelf-4op.3.1) or PR description. Per review standard, this is a NOT APPROVED gate. The PR has a test plan (make test / make coverage) but no runnable DEMO command.Phase 1: Spec Compliance
All stated requirements are present:
PermissionRequiredmiddleware with admin short-circuit, per-request DB lookup, context cache, fail-closed — implemented ininternal/users/permissions.goGetPermissionspublic helper for theextractUserclosure — presentCurrentUser.CanAccessBookdropsurfaced ontmpl.CurrentUser— presentbase.html: BookDrop wrapped in{{if .CurrentUser.CanAccessBookdrop}}, Field Priority + Metadata Providers inside{{if .CurrentUser.IsAdmin}}— correct and matches existing route-levelAdminRequiredenforcement ininternal/settings/routes.gointernal/bookdrop/routes.goBookdropRequiredwired throughappwire.Depsandapp.go— presentGetUserPermissions :onesqlc query + regeneratedinternal/db/sqlc/— presentPhase 2: Code Quality
[MINOR] internal/users/permissions.go:20 —
UserPermissionsstruct is defined but never usedThe struct
type UserPermissions struct { AccessBookdrop bool }was likely a design artifact from an earlier iteration. The actual cache and all callers usedbsqlc.GetUserPermissionsRowdirectly. The comment on line 14 ("permsKey is the unexported context key used to cache resolved UserPermissions") is also misleading — it cachesGetUserPermissionsRow, notUserPermissions. Remove the struct and fix the comment to avoid confusion when the next permission is added.[MINOR] internal/bookdrop/routes_test.go — mutation routes not covered by gate test
The test suite covers
GET /bookdropgating (passthrough → 200, forbid → 403) but none of the five mutation routes (POST /bookdrop/accept,POST /bookdrop/reject,POST /bookdrop/refresh,POST /bookdrop/{id}/reject,POST /bookdrop/{id}/accept) have a gating assertion. The implementation is correct (all routes use the samegatevariable), but a test that verifies at least one mutation route underforbidMiddlewarereturns 403 would give stronger regression protection.Positive observations:
noPermsRowtrick in the admin test proves the DB stub is never called.resolvePermissionswraps with%wanderrors.Isunwraps properly.r.Context()), not cross-request — correct.contextKeytyped int prevents collision betweenclaimsKey=1andpermsKey=2.GetUserPermissionsSQL query usesCOALESCE(..., 0)so a partial row (NULL columns) still returns false, not a scan error.forbidRequest(vsdenyRequest) distinction is correct: missing claims → 401 redirect, missing permission → 403.BeforeEachfor setup, separateItblocks for each assertion.REVIEW VERDICT: 0 blocker, 0 major, 2 minor
NOT APPROVED — no DEMO block provided (review standard Phase 0 gate).
Security Review (bot)
PR #458 — RBAC permission middleware + nav visibility + bookdrop gating (bookshelf-4op.3.1)
Fail-Closed Analysis
All failure modes deny access (403/401), never allow:
claims == nil→denyRequest(401) ✓sql.ErrNoRows(no permissions row) →forbidRequest(403) ✓ — explicitly testedforbidRequest(403) ✓ — explicitly testedcheck(perms)returns false →forbidRequest(403) ✓No fail-open path exists. The
if err == nilguard inextractUser(app.go:191) for the nav-visibleCanAccessBookdropflag also fails closed — on error the flag staysfalse(nav link hidden). Correct for a display-only signal that is NOT the gate.UserID / IsAdmin Source
claims.UserIDandclaims.IsAdminare extracted exclusively fromClaimsFromContext(r.Context()), which is populated byAuthMiddlewareafter verifying the HS256 JWT signature against the server secret.isAdminis embedded in the token at login time (issueAccessToken) sourced from a DB lookup (getUserIsAdmin). There is no path that readsIsAdminfrom a request header, body, or query parameter.PermissionRequired: readsclaims.IsAdmin(JWT-bound), callsnext.ServeHTTPdirectly. Cannot be spoofed without forging the JWT signature.Per-Request Cache Isolation
permsKey contextKey = 2(unexported typed constant) — cannot be set from outside theuserspackage.context.WithValueis immutable; each HTTP request innet/httpgets a freshcontext.Background()-rooted context. No cross-request state sharing is possible.check(perms)passes (line 77 of permissions.go). A denied request never caches its zero-value row, so there is no "false allow on second check in same request" path.permsKeyto context. DownstreamGetPermissions(isAdmin=true)returns the fully-set synthetic row without a DB call — correct.Bookdrop Route Coverage
All 6 registered routes are wrapped with
gate := d.BookdropRequired:GET /bookdropPOST /bookdrop/accept(bulk)POST /bookdrop/reject(bulk)POST /bookdrop/refreshPOST /bookdrop/{id}/rejectPOST /bookdrop/{id}/acceptNo bookdrop routes are registered outside
internal/bookdrop/routes.go. Verified:wfengineandappregister no additional bookdrop HTTP routes.Nav Hiding vs Route Authz Alignment
/bookdropnav:{{if .CurrentUser.CanAccessBookdrop}}← matchesBookdropRequired(admin OR perm flag). Defense-in-depth correct./settings/metadata-field-priorityand/settings/metadata-providersnav:{{if .CurrentUser.IsAdmin}}← both routes are gated byadminRequiredininternal/settings/routes.go. Defense-in-depth correct.IsAdmininCurrentUsercomes from JWT claims — same trust anchor as the middleware.Findings
[MINOR] internal/users/permissions.go:17 —
UserPermissionsstruct is defined but never usedThe type
UserPermissions struct { AccessBookdrop bool }is declared but referenced nowhere in production code. The actual API surface usesdbsqlc.GetUserPermissionsRowthroughout. This is dead code that will cause a lint warning and reader confusion about whether a projection type is intended. Remove it, or document why it is reserved for future use.REVIEW VERDICT: 0 blocker, 0 major, 1 minor
b67aa97929746e93554f