Skip to content

fix(rbac): RBAC dynamic grants + API ACL enforcement#998

Merged
lane711 merged 8 commits into
mainfrom
lane711/rbac-testing-summary
Jul 7, 2026
Merged

fix(rbac): RBAC dynamic grants + API ACL enforcement#998
lane711 merged 8 commits into
mainfrom
lane711/rbac-testing-summary

Conversation

@lane711

@lane711 lane711 commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Dynamic role dropdown: User edit/create screens now load roles from RBAC DB (RbacService.getRoles()) instead of a hardcoded static list — custom roles created in the RBAC panel immediately appear in the user-edit dropdown.
  • API ACL enforcement (D5 fix): All four public GET routes in api.ts (/:collection, /collections/:collection/content, /content, /:collection/:id) now enforce document ACL for authenticated users — previously only is_published was checked, so any logged-in user could read content regardless of role grants.
  • RBAC dynamic grants consulted at API: Both permission systems are now checked together: code-time baseGrants (registered at document-type boot) AND runtime RBAC grants (set via the RBAC matrix UI). Previously only baseGrants was consulted, so changes made through the RBAC UI had no effect on API responses.
  • baseGrants['public'] collision fix: An authenticated user whose auth_user.role is 'public' no longer inherits the anonymous baseGrants.public bucket — fixing a security gap where a logged-in public-role user bypassed ACL the same way an unauthenticated visitor does.

Changes

File Change
routes/admin-users.ts Replace static ROLES constant with fetchRoleOptions(db) async helper backed by RbacService.getRoles()
routes/api.ts Add typeReadAllowed() helper (baseGrants + RBAC fallback); enforce ACL on all 4 GET routes; disable cache for authenticated requests
services/document-permissions.ts Skip baseGrants['public'] lookup for { type: 'role', id: 'public' } principals
services/rbac.ts Add isGrantedForRole(roleSlugOrName, resource, verb) public method
Tests 6 new isGrantedForRole unit tests; 2 new baseGrants collision security tests; stale role-list tests updated; 3 new E2E specs

Testing

  • Unit tests pass: rbac-documents.test.ts (15 tests), documents.sqlite.test.ts (29 tests), rbac-enforcement.test.ts (14 tests)
  • Type-check clean
  • E2E specs: 97-user-edit-dynamic-roles, 98-public-role-api-acl, 99-rbac-grant-api-access (CI validates)

Security notes

  • Authenticated users no longer see content they haven't been granted — the is_published fast-path is gone for non-anonymous callers.
  • Anonymous (unauthenticated) requests are unchanged — still served from cache, still filtered to is_published = 1.
  • Cache is bypassed for all authenticated requests to prevent a cached anonymous response being served to a filtered principal set.

🤖 Generated with Claude Code

lane711 and others added 8 commits July 6, 2026 16:39
- admin-users.ts: replace static ROLES constant with fetchRoleOptions()
  that queries RbacService.getRoles() so user-edit and user-new forms
  always reflect live RBAC roles; validate submitted role against DB
- api.ts: wire isAllowed ACL check into all four GET content routes
  (/:collection, /:collection/:id, /content, /collections/:c/content);
  authenticated non-anonymous users filtered by DocumentRepository.isAllowed
  against type settings; list routes return 403 when principal has no
  type-level read grant; cache disabled for authenticated users
- document-permissions.ts: fix 'public' key collision — { type: 'role',
  id: 'public' } no longer matches baseGrants['public'], which is reserved
  for anonymous { type: 'public', id: '*' } principals only
- E2E specs 97 and 98 covering dynamic role dropdown and API ACL

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Admin can grant a role read access to a document type at runtime via the
RBAC matrix UI. Previously the API only checked code-time baseGrants, so
runtime grants had no effect — authenticated users with RBAC-only grants
got 403/404 even after the grant was set.

Now all four GET routes in api.ts consult both systems via typeReadAllowed
(baseGrants first, RBAC fallback) and RbacService.isGrantedForRole. Per-doc
deny overrides still apply on the baseGrants path; RBAC-only grants pass all
rows (type-level grant implies row-level access).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… and grant→access E2E

- rbac-documents.test.ts: 6 new tests for isGrantedForRole — slug match, name
  match, wildcard resource, grant cleared, verb mismatch, unknown role
- documents.sqlite.test.ts: 2 new tests for the baseGrantAllows collision fix —
  authed { type:role, id:'public' } must not inherit baseGrants['public']
- rbac-enforcement.test.ts: update stale static role list tests to match the
  dynamic role slugs (role-admin, role-editor) used by admin-users.ts
- tests/e2e/99-rbac-grant-api-access.spec.ts: regression test for the reported
  bug — grant public role read via RBAC matrix → /api/example unblocks; revoke
  → 403 again

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Lock file was stale in this worktree — esbuild version mismatch and
missing better-sqlite3 entry caused npm ci to fail in CI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Branch added no new npm packages. Worktree had a stale/diverged
lock file causing npm ci to fail on esbuild version mismatch and
missing better-sqlite3/emnapi entries.

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

The v3.0.0-beta.24 release commit (801789c) stripped
node_modules/better-sqlite3 from the lock file, breaking npm ci
for all PRs and main since July 3.

Regenerated the lock file fresh (esbuild now resolves to 0.28.1 as
wrangler@4.105.0 requires) and patched back the better-sqlite3@12.11.1
and @emnapi/runtime|core@1.11.1 entries that were previously present.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The v3.0.0-beta.24 release commit stripped better-sqlite3 from the
lock file and macOS-regenerated versions lacked Linux native binaries
(@rollup/rollup-linux-x64-gnu, @esbuild/* Linux variants) needed for CI.

Using lock file from 0235ba3 (last known-good state before the release
stripped entries). All CI-required packages present:
- better-sqlite3@12.11.1
- @rollup/rollup-linux-x64-gnu@4.62.2
- wrangler/node_modules/esbuild@0.28.1 (nested, avoids top-level conflict)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
typeReadAllowed now distinguishes "role not found in RBAC document store"
(legacy names like viewer/author → default allow) from "role found but
has no grants" (public role → deny). Adds hasRbacRole() to RbacService.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@lane711 lane711 force-pushed the lane711/rbac-testing-summary branch from bb9491e to 72163c5 Compare July 6, 2026 23:39
@lane711 lane711 merged commit 9e9df42 into main Jul 7, 2026
1 of 2 checks passed
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