feat(access): public/anonymous realm, per-realm permission picker, publishable API keys#292
feat(access): public/anonymous realm, per-realm permission picker, publishable API keys#292khuepm wants to merge 14 commits into
Conversation
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.
CI status
The job runs
Evidence it predates this branch:
Why I'm not fixing it in this PR: the remedy is Two other notes:
Everything else so far
This resolves the one caveat I left in the PR description: Generated by Claude Code |
|
Following up as promised — all checks have now completed. Every job that exercises this PR's code is green.
The audit remains the only red check, and it is unrelated to this branch: no Nothing further from me on this PR unless something changes. I'll keep watching it. Generated by Claude Code |
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.
…ction-create-modes
…ing, git-integration
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:
publicrole + anonymous principal.withAuthpreviously returned an unconditional 401 for credential-less requests. An unauthenticated request now resolves to the site'spublicrole — through the permission layer, not around it, so row filters and field masks stay in force.read. Extracted into a shared realm-access primitive with per-realm limits, plus/access/grantsreturning collections + both realms' limits and grants in one round trip.Access control → Public & subscribers: acollection × actiongrid per non-staff realm.lbk_pub_…keys for client embedding, with a per-key browser-origin allowlist.Realm limits
publicsubscriberreadonlyread/create/update/deleteDeliberate design decisions worth reviewing
publicrole (creating it is enabling the realm, so existing deployments are unchanged until an operator opts in), and the request must be aGET/HEADon an allow-listed content prefix (/items,/search,/media,/files). Both are load-bearing and independently tested./graphqlis 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.publicis 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.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 anyOrigin.roles.parentIdstill 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ớidocs— Cập nhật tài liệusecurity— 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
tsc --noEmitclean cho@lumibase/cms,@lumibase/database,@lumibase/sdk,@lumibase/studiopnpm testchạy qua pre-commit hook trên mọi commit, 0 failuresLint stable packages✅ trên CIArchitecture & Docs
apps/cms/openapi.yaml— 6 path mới +RealmAccess*schemas +publishable/allowedOriginstrên API-key schemaspackages/sdktypes —RealmAccessState,RealmAccessDescriptor,RealmAccessGrant,GrantAction,access.grants.*,apiKeys.setAllowedOriginsdocs/en/features/permissions-rbac.md— §1.1 non-staff realms, §6.1 publishable keys, model diagram, Studio UIarchitecture.md— không đổi cấu trúc hệ thống, giữ nguyêndocs/vi/features/permissions-rbac.mdchưa sync (Detect language driftlà report-only và đã pass)Testing
access-grantsroute (15), anonymous auth path (19), publishable-origin middleware (7), Studio picker (10)0012chưa chạy thậtRuntime Compatibility
c.get('runtime').cache, không import CF binding trong business logicSecurity
siteId; rolepubliclà per-sitesha256; prefix (16 ký tự) không đủ để authenticatejavascript-typescript,actions) ✅Dependency vulnerability audit❌ — không do PR này: advisoryreact-routermới công bố trongapps/docs, PR không đổi dependency nào. Chi tiết + lý do không fix ở đây: commentDatabase / Migrations
packages/database/drizzle/0012_public_role_least_privilege.sql+ journal entryALTER TABLE … ADD CONSTRAINTkhông cóIF NOT EXISTS(Postgres không hỗ trợ cho constraint). Chạy lại sẽ lỗiduplicate_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
type(scope): descriptionRollback Plan
Migration chỉ thêm 2 CHECK constraint, không đổi dữ liệu:
Revert code là đủ để đóng anonymous access —
withAuthquay lại 401 vô điều kiện. Role/policypubliccòn sót lại trở thành vô hại (không có codepath nào resolve tới nó); xoá bằngPOST /access/grants/public/disabletrước khi revert nếu muốn sạch hẳn.Notes for Reviewers
apps/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ốiwithAuth. Prefix matching dùngpath === prefix || path.startsWith(prefix + '/')để/api/v1/items-adminkhông lọt qua/api/v1/items(có test).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 joinrole_policies.withStudioAccessđã chặn anonymous sẵn (!auth?.userId→ 403), nên không cần sửa; đã thêm reject tường minh trongrequireSiteAdminvì nhánh carve-out CF Access ở đó cũng khớp "không userId, không apiKeyId".prefixlà 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).#/components/schemas/ExtensionResourcechưa resolve trongopenapi.yaml— lỗi có sẵn, không thuộc PR này.