Skip to content

feat(access): public/anonymous realm, per-realm permission picker, publishable API keys#292

Draft
khuepm wants to merge 14 commits into
mainfrom
claude/role-permission-hierarchy-vny49f
Draft

feat(access): public/anonymous realm, per-realm permission picker, publishable API keys#292
khuepm wants to merge 14 commits into
mainfrom
claude/role-permission-hierarchy-vny49f

Conversation

@khuepm

@khuepm khuepm commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Summary

Adds the missing public (anonymous) realm and the operator-facing surface for it, so "let visitors read collection X" no longer requires hand-editing policies in Studio.

Four pieces, one per commit:

  1. public role + anonymous principal. withAuth previously returned an unconditional 401 for credential-less requests. An unauthenticated request now resolves to the site's public role — through the permission layer, not around it, so row filters and field masks stay in force.
  2. Per-realm grant API. The subscriber grant API was action-fixed to read. Extracted into a shared realm-access primitive with per-realm limits, plus /access/grants returning collections + both realms' limits and grants in one round trip.
  3. Studio picker. Access control → Public & subscribers: a collection × action grid per non-staff realm.
  4. Publishable API keys. lbk_pub_… keys for client embedding, with a per-key browser-origin allowlist.

Realm limits

public subscriber
Who anyone, no credential registered + signed in on your frontend
Exists by default ❌ opt-in per site ✅ on first registration
Grantable actions read only read/create/update/delete
Row scopes published-only published-only, own-rows-only
Studio never never

Deliberate design decisions worth reviewing

  • Anonymous access is doubly gated: the site must have a public role (creating it is enabling the realm, so existing deployments are unchanged until an operator opts in), and the request must be a GET/HEAD on an allow-listed content prefix (/items, /search, /media, /files). Both are load-bearing and independently tested.
  • /graphql is excluded. Operations arrive over POST, so the read-method rule cannot cover it. Opening it needs read-only operation validation alongside the existing cost limiter — a separate change, not silently folded in here.
  • public is read-only. A generic write grant would hand every unauthenticated caller an unmetered write path. A public write surface (contact form, anonymous comment) needs its own throttle/captcha story behind a purpose-built endpoint.
  • A publishable key is not a secret, and the UI says so. It buys per-key quota, rotation, revocation and audit attribution — not confidentiality. The origin allowlist allows requests with no Origin: it exists to stop another website using the key in a browser, and rejecting absent-origin requests would break legitimate native/server callers while adding no security against a caller that can set any Origin.
  • roles.parentId still has no evaluator support — documented explicitly rather than left ambiguous. There is no role inheritance; the column is grouping metadata.

Loại thay đổi

  • feat — Tính năng mới
  • docs — Cập nhật tài liệu
  • security — hardening (guards + check constraints on the anonymous realm)

Phase / Feature liên quan

Permissions & RBAC (docs/en/features/permissions-rbac.md §1.1, §6.1) · ADR-011 user-management realms.


✅ Definition of Done (DoD)

Code Quality

  • Không có TypeScript error — tsc --noEmit clean cho @lumibase/cms, @lumibase/database, @lumibase/sdk, @lumibase/studio
  • Tất cả tests pass — full pnpm test chạy qua pre-commit hook trên mọi commit, 0 failures
  • Không có lint error — Lint stable packages ✅ trên CI
  • Self-review: đã đọc lại diff

Architecture & Docs

  • Cập nhật apps/cms/openapi.yaml — 6 path mới + RealmAccess* schemas + publishable/allowedOrigins trên API-key schemas
  • Cập nhật packages/sdk types — RealmAccessState, RealmAccessDescriptor, RealmAccessGrant, GrantAction, access.grants.*, apiKeys.setAllowedOrigins
  • Cập nhật docs/en/features/permissions-rbac.md — §1.1 non-staff realms, §6.1 publishable keys, model diagram, Studio UI
  • architecture.md — không đổi cấu trúc hệ thống, giữ nguyên
  • Chưa làm: bản dịch docs/vi/features/permissions-rbac.md chưa sync (Detect language drift là report-only và đã pass)

Testing

  • Unit test cho logic mới — realm limits, row-scope composition, origin verdicts, prefix classification, cache behaviour
  • Integration test cho endpoint — access-grants route (15), anonymous auth path (19), publishable-origin middleware (7), Studio picker (10)
  • Property-based test (fast-check) — chưa thêm; origin/prefix logic phủ bằng table-driven cases
  • Chưa làm: test thủ công trên Docker runtime (không có DB trong môi trường này) — migration 0012 chưa chạy thật

Runtime Compatibility

  • Cloudflare Workers — chỉ dùng Web Crypto + c.get('runtime').cache, không import CF binding trong business logic
  • Node.js / Docker — cùng codepath; cache optional, fallback về đọc trực tiếp khi không có cache
  • Không dùng API riêng của một runtime

Security

  • Không có secret hardcoded
  • Input validation bằng Zod trên mọi payload mới
  • Multi-tenant isolation — mọi query mới scope theo siteId; role public là per-site
  • Token không log/lưu plaintext — chỉ lưu sha256; prefix (16 ký tự) không đủ để authenticate
  • CodeQL (javascript-typescript, actions) ✅
  • ⚠️ Dependency vulnerability audit ❌ — không do PR này: advisory react-router mới công bố trong apps/docs, PR không đổi dependency nào. Chi tiết + lý do không fix ở đây: comment

Database / Migrations

  • Migration packages/database/drizzle/0012_public_role_least_privilege.sql + journal entry
  • Không idempotent: ALTER TABLE … ADD CONSTRAINT không có IF NOT EXISTS (Postgres không hỗ trợ cho constraint). Chạy lại sẽ lỗi duplicate_object. Khớp với style của các migration hiện có, nhưng cần biết trước khi re-run.

Conventional Commits

  • 5 commits theo type(scope): description

Rollback Plan

Migration chỉ thêm 2 CHECK constraint, không đổi dữ liệu:

ALTER TABLE lumibase_roles DROP CONSTRAINT IF EXISTS roles_public_least_privilege;
ALTER TABLE lumibase_policies DROP CONSTRAINT IF EXISTS policies_public_least_privilege;

Revert code là đủ để đóng anonymous access — withAuth quay lại 401 vô điều kiện. Role/policy public còn sót lại trở thành vô hại (không có codepath nào resolve tới nó); xoá bằng POST /access/grants/public/disable trước khi revert nếu muốn sạch hẳn.

Notes for Reviewers

  • Điểm cần review kỹ nhấtapps/cms/src/middleware/auth.ts — thay đổi ở đây nới lỏng bề mặt tấn công. Cụ thể: allowsAnonymous() (method + prefix allowlist) và nhánh anonymous ở cuối withAuth. Prefix matching dùng path === prefix || path.startsWith(prefix + '/') để /api/v1/items-admin không lọt qua /api/v1/items (có test).
  • Hai lớp guard cho public, cố ý không dựa vào một lớp: check constraint ở DB (0012) cho role/policy canonical, và route guard cho policy do operator tự attach — table check không nhìn được qua join role_policies.
  • withStudioAccess đã chặn anonymous sẵn (!auth?.userId → 403), nên không cần sửa; đã thêm reject tường minh trong requireSiteAdmin vì nhánh carve-out CF Access ở đó cũng khớp "không userId, không apiKeyId".
  • prefix là source of truth cho publishable, không phải metadata flag — derive từ token nên không thể lệch với thứ client đang giữ. Rotation phải carry qua, nếu không key publishable sau rotate sẽ âm thầm thành secret và mất luôn origin enforcement (có test).
  • Ref #/components/schemas/ExtensionResource chưa resolve trong openapi.yaml — lỗi có sẵn, không thuộc PR này.

khuepm added 5 commits July 25, 2026 06:20
Introduces the third authorization realm from ADR-011: callers with no
credential at all. Previously `withAuth` returned an unconditional 401 for
them, so there was no way to serve content publicly through the permission
layer.

An unauthenticated request now resolves to the site's `public` role instead
of bypassing authorization, so row filters and field masks stay in force. Two
conditions gate it, both load-bearing:

- the site has explicitly enabled public access (which is what creates the
  role, so existing deployments are unchanged until an operator turns it on)
- the request is a GET/HEAD on an allow-listed content path

The role is structurally least-privilege: `adminAccess`/`appAccess` are
pinned off by check constraints in migration 0012, and the route guards
refuse the same edits with a readable 4xx rather than a constraint violation.
Policies an operator attaches to the public role by hand are screened at the
attach point, since a table check cannot see across the role_policies join.

Anonymous role lookups are cached per site (negative results included) —
anonymous traffic is the highest-volume path, and every visitor on a site
shares one compiled permission bundle under the `role:{id}` principal key.

GraphQL is deliberately excluded: its operations arrive over POST, so the
read-method rule cannot cover it, and opening it needs read-only operation
validation alongside the existing cost limiter.
The subscriber grant API was action-fixed to `read`, so "let visitors do X"
could only be expressed by hand-editing policies in Studio. Extracts the grant
logic into a shared realm-access primitive and gives each non-staff realm its
own limits:

- subscriber (authenticated) may hold read/create/update/delete, and can
  express an "own rows only" scope — the shape behind "a subscriber may edit
  their own comments"
- public (anonymous) stays read-only, and rejects the own-rows scope since
  $CURRENT_USER has nothing to resolve to and the rule would silently match
  nothing

Adds `/api/v1/access/grants`, which returns the site's grantable collections
plus every realm's limits and current grants in one round trip, so a picker UI
does not have to reason across the raw role/policy editor. Grant/revoke,
enable/disable and an audit entry per mutation live on the same router.

`publishedOnly` now defaults on for reads and off for writes: a status filter
is rarely what an operator means for a write, and `create` has no existing row
to filter. Both scopes may be combined, composing to an `_and`.

The generic policy-permission editor is screened too — a grant added there
lands in the same compiled bundle, so it would otherwise be a way around the
read-only limit on the public role.

`POST /users/subscriber-access` keeps its original contract when `action` is
omitted.
The Roles/Policies pages expose the full permission model, but nothing
answered the narrower question an operator actually asks — "what can a visitor
reach?". Adds an Access Control tab rendering a collection x action grid per
non-staff realm, backed by the new /access/grants endpoints.

Every limit is read from the server payload rather than hard-coded: which
actions a realm may hold, whether a row scope is expressible, whether the
realm can be toggled. A server-side tightening therefore cannot be silently
contradicted by a stale client assumption.

A togglable realm that is off renders read-only instead of an editable grid,
so the first checkbox click cannot quietly switch anonymous access on. When
public access is enabled the section states plainly that the grants are
reachable with no credential — including that a token in the frontend does not
change that.

Refusals surface the server's own explanation rather than a generic failure,
since that text is what tells the operator why a grant was rejected.
Adds a key kind meant to be shipped inside a browser bundle or mobile app
(`lbk_pub_…`), distinct from a secret `lbk_…` key held server-side.

The framing is deliberate and stated in the UI: a publishable key is NOT a
secret. Anything shipped to a client is extractable, so the only correct
posture is to scope it as if it were already public. What it buys over serving
the same data fully anonymously is per-key rate-limit quota (the limiter
already keys on `k:{apiKeyId}`), revocation and rotation without redeploying,
audit attribution, and per-key scope. What it does not buy is confidentiality.

The prefix is the source of truth rather than a metadata flag — it is derived
from the token, so it cannot drift from what the client actually holds, and it
lets a secret scanner alert on leaked `lbk_` keys while staying quiet about
publishable ones. Rotation carries it across, since otherwise a rotated key
would silently come back as a secret one and lose its origin enforcement.

Origin allowlist (`metadata.allowedOrigins`, editable live without rotating):
enforcement is scoped to publishable keys, because a secret key is used
server-to-server where no Origin exists. A non-matching Origin/Referer is a
403 and audited; an absent origin is allowed. That asymmetry is intentional —
the control stops another website from using the key in a browser, which is
the real failure mode, and rejecting absent-origin requests would break
legitimate native/server callers while adding no security against a caller
that can set any Origin it likes.

A policy carrying adminAccess/appAccess cannot be attached to a publishable
key: that would be an unauthenticated admin bypass with extra steps.
Adds the /access/grants surface, PATCH /api-keys/:id/allowed-origins, and the
publishable/allowedOrigins fields on the API-key schemas to the OpenAPI spec.

khuepm commented Jul 25, 2026

Copy link
Copy Markdown
Owner Author

CI status

Dependency vulnerability audit is red, and it is not caused by this PR. I'm not fixing it here — reasoning below.

The job runs pnpm audit --prod --audit-level high and trips on a newly-published advisory:

  • GHSA-qwww-vcr4-c8h2 — React Router RSC-mode CSRF bypass, high
  • Path: apps/docs > react-router-dom@7.18.1 > react-router@7.18.1
  • Vulnerable >=7.12.0 <8.3.0, patched >=8.3.0

Evidence it predates this branch:

  • git diff --name-only origin/main...HEAD matches no package.json and no pnpm-lock.yaml — this PR changes zero dependencies.
  • apps/docs/package.json pins "react-router-dom": "^7.18.1" identically on main and on this branch.
  • main's last CI run (aa91f12, 2026-07-24) was green only because the advisory had not been published yet. Since the lockfile is byte-identical, re-running that job on main right now fails the same way.

Why I'm not fixing it in this PR: the remedy is react-router-dom 7.18.1 → ≥8.3.0, a major version bump of the routing library across apps/docs. That is unrelated to this PR's scope (permissions), carries real breakage risk for the docs app, and deserves its own PR where the router migration can actually be reviewed and tested. Folding it in here would mean a permissions PR that also silently rewrites docs routing.

Two other notes:

  • apps/docs is a docs site and the advisory is specific to RSC mode. Whether it is even reachable there is worth checking as part of that separate bump — I have not verified it and am not asserting it is harmless.
  • The audit also reports 1 low and 1 moderate, below the job's high threshold.

Everything else so far

Check Result
Lint stable packages ✅ success
E2E golden path ✅ success
Detect language drift ✅ success
Analyze (actions) ✅ success
Dependency vulnerability audit ❌ failure (pre-existing, above)
build · Version/typecheck/tests/build · CodeQL (rust, js-ts) still running

This resolves the one caveat I left in the PR description: pnpm lint passes — I had flagged that I hadn't run it locally, and CI has now confirmed it. I'll report back on the remaining jobs.


Generated by Claude Code

khuepm commented Jul 25, 2026

Copy link
Copy Markdown
Owner Author

Following up as promised — all checks have now completed. Every job that exercises this PR's code is green.

Check Result
Version, typecheck, tests, and build
build
Lint stable packages
E2E golden path
CodeQL · Analyze (javascript-typescript) · Analyze (actions) · Analyze (rust)
Detect language drift (report only)
Sync and translate skipped
Dependency vulnerability audit ❌ pre-existing — see above

The audit remains the only red check, and it is unrelated to this branch: no package.json or pnpm-lock.yaml change here, and the remedy is a major react-router bump in apps/docs that belongs in its own PR. I'm leaving it for you to decide — say the word if you'd rather I take that bump on separately.

Nothing further from me on this PR unless something changes. I'll keep watching it.


Generated by Claude Code

khuepm added 9 commits July 25, 2026 07:47
The vi file is the source for this doc (`sourceLang: vi`) and en is the
machine-translated side — the earlier commit edited the translated side only,
leaving the pair out of sync.

Ports the same content into the vi source: the realm-layered model diagram,
the no-role-inheritance note on `roles.parentId`, §1.1 on the public and
subscriber realms, §6.1 on publishable keys, and the Studio picker page.

Also closes a pre-existing gap: §12 (compliance) existed only on the en side.
Leaving it out would have meant declaring the pair in sync while the source was
still missing a section.

Provenance stamped with `scripts/docs-i18n/stamp-pair.mjs`, so
`scripts/docs-i18n/sync.mjs` now classifies the pair as up-to-date rather than
queuing a re-translation that would overwrite the hand-written en text.
The sync tooling only ever compared hashes between the en/vi pair. Hash
equality proves the two sides describe the same source revision; it cannot
tell you either side is true. Two translations can agree perfectly and be
wrong together — both confidently documenting an endpoint deleted a release
ago — and the tooling would call that pair "in sync".

Adds `verify-code-refs.mjs`, which extracts a doc's claims about the repo and
checks each one: file references must exist, env vars must be read somewhere in
the source, API routes must have a matching mount. Precision over recall — a
claim is only asserted when unambiguously about this repo, so a reader's
Next.js example or an IDE config is skipped rather than guessed at. Skipped
claims are counted so the blind spot stays visible.

Across the tree: 1547 claims checked, 19 stale (a documented
`POST /files/upload-url` that no longer exists, five missing source files,
three env vars nothing reads).

`stamp-pair.mjs --verified` now gates the marker on that check and refuses when
anything is stale — or when a doc makes no testable claim at all, since
"nothing to check" is not a pass and would otherwise let a trivial green light
stand in for verification. Refusals write nothing, so a rejected pair is never
left half-stamped. `codeVerified` is pinned to the body hash it was verified
at, so a later edit invalidates it rather than inheriting the assurance.

Two markers are kept deliberately separate: `syncStatus`/`sourceHash` answer
"same revision?", `codeVerified` answers "claims checked?". A full match needs
both.

Also fixes version drift between the two locales: the target side bumped on
every re-stamp even when nothing changed, so the pair's numbers diverged and
stopped being comparable. Both sides now version on real change only, making
repeat stamps idempotent.

Corrects `TASKS.md`, which still directed the remaining 93 files into PR #205 —
merged on 2026-07-06 with only batch 1 done. That target is closed, so the doc
would have sent the next agent to a dead branch.
Clears all 19 findings the new verifier reported, and adds the mechanism that
was missing to do so honestly.

Genuinely wrong, now corrected:

- `POST /files/upload-url` never existed; the real endpoint is
  `/files/presigned-url` (files.ts:175). Documented in both locales.
- The code-style error-handling section told readers to throw `AppError` from
  `packages/shared` and pointed at `apps/cms/src/middleware/error.ts`. Neither
  exists — no `AppError` anywhere in the tree. Rewritten to the actual
  convention: return the `{ errors: [...] }` envelope with a status, with
  `app.onError` in index.ts as the last-resort handler.
- Studio's client is `lib/api.ts` exporting `getApiClient()`, not
  `lib/api-client.ts` exporting `apiClient`.
- extension-sdk has a single `src/index.ts`, not `types.ts` + `helpers.ts`.
- npm publishing has no allowlist script: the release workflow derives the list
  from each package's own `private` flag.
- `LUMIBASE_ADMIN_PATH` was listed as a config var, but the admin path is chosen
  in the Setup Wizard and stored in the DB — deliberately not in a build
  artifact. Replaced with a note saying so, rather than dropped, since an
  operator reading that table needs to know it is not env-configurable.

Not wrong, and would have been damaged by "fixing" it: plan and design docs
legitimately name things that do not exist yet ("Create a helper at ...", an env
var the feature will read once built). Rather than have the verifier guess at
English, docs now declare those explicitly:

    <!-- verify-code-refs: planned apps/cms/src/x.ts LUMIBASE_FOO -->

They are counted as `planned` and reported separately — never silently passing.
Making the intent explicit in the doc also tells the reader the thing is
unbuilt, which is more useful than a reference that merely resolves.

`LUMIBASE_REALTIME_ENABLED` is one of these: the table implied it worked, so it
is now marked "Not implemented — setting it has no effect".

CI runs the verifier report-only (`|| true`) because the check is landing against
a tree that had findings; drop that once the backlog is clear so a newly-stale
reference cannot land. The JSON report is uploaded as an artifact and gitignored
so a local run cannot commit it.

Tree-wide: 1537 claims checked, 6 planned, 0 findings.
…dmap

Two of the three docs that existed only in Vietnamese. EN is the default
published locale, so a missing EN file means an English reader gets nothing.

Both stamped --verified. The DoD section in the roadmap references
docs/en/data-model.md rather than the vi path the source used, since each
locale should point into its own tree.
marketplace-ui, raw-data-editing and translations-i18n had Vietnamese text
sitting in docs/en/. EN is the default published locale, so an English reader
was being served Vietnamese — the most user-visible class of drift in the
backlog, which is why this group goes first.

All three stamped --verified (8, 2 and 6 code references checked).
…lates

Two more docs whose docs/en/ side held Vietnamese.
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