Skip to content

feat(rbac): read-only is a permission; routes declare that they read - #561

Merged
huyplb merged 1 commit into
mainfrom
feat/rbac-route-reads
Oct 26, 2026
Merged

huyplb merged 1 commit into
mainfrom
feat/rbac-route-reads

Conversation

@huyplb

@huyplb huyplb commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Closes the rbac-route-reads item from the #554 review.

Before

The Demo role was held read-only by two things:

  • A URL allow-list in platform/authorization/demo-access.ts, which put feature routes in platform code.
  • Checks of the role's name: isDemoRole in sign-out-others, verify/send, forgot-password and the profile menu.

The permission matrix and can() didn't know about any of this. An admin could not make another role read-only, or let the demo save.

Now

  • A route declares reads beside its handler, for example router.post('/sql/execute', reads, …). A POST that carries a SELECT changes nothing. The router records the marker, and the binder passes it to Fastify as route config.
  • app.write (Save and change): an account without it may call a route only when it is a GET or declares reads. Every other write is refused before the handler runs, with the same refusal as before.
  • account.self (Manage own account): needed for sign-out-others, verify/send and forgot-password. The profile menu uses can('account.self'), and the banner uses can('app.write').
  • Both are install permissions that every role from viewer up has. Demo is simply the role without them.
  • A one-time grant gives both permissions to roles customized before they existed, so nothing that could save stops saving. If an admin removes them later, the removal sticks. Roles emptied on purpose stay empty.
  • demo-access.ts is removed. api/demo-account.test.ts still calls every guarded write as a read-only account. It now also checks the declared reads routes against a reviewed list, so marking a new one is a deliberate test change.
  • docs/DEPLOYMENT.md explains the permission.

Tests

  • read-only.test.ts: the rule, plus a role with app.write removed is refused writes.
  • rbac.service.test.ts: the one-time grant gives customized roles both permissions, never gives them to Demo, and an admin's later removal sticks.
  • demo-account.test.ts: every guarded write as demo, with reads routes passing and the rest refused; the reviewed reads list; and sign-out-others, verify/send and forgot-password, now keyed on account.self.
  • e2e: demo-role 3/3, workspaces 8/8, sql-editor-smoke 5/5.
  • Typecheck, eslint . and the security lint are clean. Unit suite: 6563 pass.

🤖 Generated with Claude Code


Note

High Risk
Changes global auth enforcement on every protected write and introduces new permissions with migration backfill; mis-marked reads routes could let read-only accounts reach unintended handlers.

Overview
Replaces Demo-role name checks and a central URL allow-list with install permissions so any role can be made read-only, not only demo.

app.write blocks mutating API calls unless the route is GET or marked with a new reads declaration beside the handler (e.g. POST /sql/execute, schema/compare loads, preferences, workspace select). The session guard uses requestAllowed and route config from the router; demo-access.ts is removed. account.self gates sign-out-others, verify/send, and forgot-password (and the profile menu) instead of isDemoRole.

Adds app.write and account.self to the permission matrix (Demo defaults omit both; viewer+ include both), a one-time RBAC grant for customized roles on upgrade, UI banner/menu updates via can(), docs, and tests that enumerate reviewed reads routes.

Reviewed by Cursor Bugbot for commit 14a9b7f. Bugbot is set up for automated code reviews on this repo. Configure here.

The Demo role was held read-only by a URL allow-list in platform code plus
role-name checks. Now:

- Routes declare `reads` beside their handler (a SELECT goes over POST but
  changes nothing); the binder passes it as route config.
- `app.write`: an account without it may call a route only when it is a GET
  or declares `reads`. `account.self`: profile, password, reset, verify and
  sign-out-others. Both are install permissions every role from viewer up
  has; the Demo role is simply the role without them, and an admin can make
  any role read-only (or let the demo save) from the role matrix.
- A one-time grant gives both to roles customized before they existed, so
  nothing that could save stops saving; an admin's later removal sticks.
- The test that calls every guarded write now keeps the list of `reads`
  routes reviewed instead of an allow-list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cursor

cursor Bot commented Oct 26, 2026

Copy link
Copy Markdown
Contributor

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: 21d57dbd-2e03-48aa-98b8-9175bdc26de9)

@huyplb
huyplb merged commit d9314d6 into main Oct 26, 2026
12 checks passed
@huyplb
huyplb deleted the feat/rbac-route-reads branch October 26, 2026 05:04
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