From 76e8072d3e0790ad00f4ac04e8bd24ea1045900e Mon Sep 17 00:00:00 2001 From: Tal Weiss Date: Wed, 3 Jun 2026 11:03:12 +0200 Subject: [PATCH] docs: split role-gating into two topologies (Bearer/API edge gate vs cookie-session SSR {from}) D1 of add-ssr-role-guard-helper. Documents that the deploy-spec requireRole edge gate authenticates via Bearer JWT only (doesn't see cookie sessions), is per-function, and returns a JSON 403; cookie-session SSR (Astro/Next) uses the in-function auth.requireRole/role({from}) guard (@run402/functions 3.5.0). Astro SSR catch-all as the worked example. Co-Authored-By: Claude Opus 4.8 --- SKILL.md | 2 +- cli/llms-cli.txt | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/SKILL.md b/SKILL.md index 791adc05..51e6d63d 100644 --- a/SKILL.md +++ b/SKILL.md @@ -411,7 +411,7 @@ Rules and footnotes: - **Deploy-time validation.** Missing table or column at activation fails with `DEPLOY_INVALID_ROLE_GATE` (422) *before* flipping the live release. - **Cache TTL.** Default 60s, max 600s. A demoted user keeps the cached role until expiry — for instant revocation, set `cacheTtl: 0` (fresh lookup per request). - **Gate applies to both** routed (`/your/route`) and direct (`POST /functions/v1/:name` with API key) invocation. Direct invocation still requires the API key at the edge; the gate runs after API-key auth, against the user JWT. -- **Reading the role (`@run402/functions` 3.4.0+).** `await auth.requireRole("operator")` returns `{ user, role }`; it throws a distinct `RoleGateNotConfiguredError` (500) when no `requireRole` gate is declared (vs `InsufficientRoleError` 403 for a real mismatch). For multi-role gates, `await auth.role()` returns the resolved role (or `null`, never throws) so you branch instead of re-asserting. +- **Reading the role — TWO topologies (`@run402/functions` 3.4.0+; `{ from }` since 3.5.0).** (1) **Bearer/API → edge gate:** with a `requireRole` gate declared, `await auth.requireRole("operator")` returns `{ user, role }`, throwing `RoleGateNotConfiguredError` (500) if no gate vs `InsufficientRoleError` (403) for a real mismatch; for multi-role gates read `await auth.role()` (role or `null`, never throws). The edge gate authenticates via `Authorization: Bearer` ONLY — it does NOT see the browser cookie session, and is per-function. (2) **Cookie-session SSR (Astro/Next) → in-function `{ from }`:** the edge gate doesn't apply (no Bearer; can't scope a catch-all to `/admin/*`; JSON 403 not a redirect), so pass `{ from: { table, idColumn, roleColumn } }` — the helper resolves the cookie user + reads their role from your tenant table directly (RLS-bypass), no gate. On `.astro` pages use `await auth.role({ from })` + `Astro.redirect("/admin/login", 303)` (a throw in frontmatter renders a 500). - **Scaffold + first-operator bootstrap.** `run402 auth scaffold-roles --roles operator` emits the `app_roles` migration, the `requireRole` snippet, and a service-role `INSERT` for the FIRST operator — the table starts empty, so the first grant bypasses RLS with the service key. The gate keys on the tenant user id (JWT `sub`), not a wallet. ### Astro SSR runtime + ISR cache (v1.52+) diff --git a/cli/llms-cli.txt b/cli/llms-cli.txt index c3595c2b..a845a4ff 100644 --- a/cli/llms-cli.txt +++ b/cli/llms-cli.txt @@ -639,7 +639,20 @@ Validation rules: - Empty `allowed`. Rejected with `INVALID_SPEC` — set the role values explicitly. - Deploy-time validation. Missing table or column at activation fails with `DEPLOY_INVALID_ROLE_GATE` (HTTP 422) *before* flipping the live release. `run402 deploy apply` surfaces the structured envelope on stderr. -Reading the role in-function (`@run402/functions` 3.4.0+). `await auth.requireRole("operator")` returns `{ user, role }` or throws — and throws a *distinct* `RoleGateNotConfiguredError` (server-class 500) when the function declares no `requireRole` gate, vs `InsufficientRoleError` (403) for a real role mismatch. When a gate allows more than one role, read `await auth.role()` (returns the resolved role or `null`, never throws) and branch instead of re-asserting — `requireRole(x)` requires `x` to be one of `allowed`. +Reading the role in-function — TWO topologies (`@run402/functions` 3.4.0+; `{ from }` since 3.5.0). Role gating has two non-interchangeable forms; pick by how the caller authenticates. + +1. Bearer/API callers → the deploy-spec `requireRole` EDGE gate (above). With a gate declared, `await auth.requireRole("operator")` returns `{ user, role }`, throwing a *distinct* `RoleGateNotConfiguredError` (server-class 500) if no gate is declared vs `InsufficientRoleError` (403) for a real mismatch. For multi-role gates, read `await auth.role()` (resolved role or `null`, never throws) and branch — `requireRole(x)` requires `x` in `allowed`. IMPORTANT: the edge gate authenticates via `Authorization: Bearer` JWT ONLY — it does NOT see the browser cookie session, and it is per-function. + +2. Cookie-session SSR (Astro/Next consoles, dashboards) → the in-function `{ from }` guard. The edge gate does not apply here (no Bearer → it 401s every cookie browser; per-function → one catch-all function can't gate just `/admin/*`; it returns a JSON 403, not a redirect). Pass `{ from }` and the helper is self-contained: it resolves the cookie user (`auth.user()`) and reads their role from your tenant table directly (RLS-bypass), no gate required: + +```ts +const { user } = await auth.requireRole("operator", { from: { table: "staff", idColumn: "user_id", roleColumn: "role" } }); +// or, for an .astro page (a throw in frontmatter renders a 500, not a redirect) use the non-throwing read: +const role = await auth.role({ from: { table: "staff", idColumn: "user_id", roleColumn: "role" } }); +if (role !== "operator") return Astro.redirect("/admin/login", 303); +``` + +Worked example — an Astro SSR hybrid where ONE ssr function serves `/admin/*` AND the public catch-all/404 fallback: use the in-function `{ from }` guard. An edge gate there would also gate public 404s + the public `/admin/login`, and would 401 every cookie browser. `{ from }` scopes the check to your admin pages, in-app, where the cookie identity is available. Scaffolding + first-operator bootstrap. `run402 auth scaffold-roles --roles operator` emits the conventional `app_roles(user_id uuid, role text)` migration, the matching `requireRole` snippet, and a service-role `INSERT` to grant the FIRST role — the table starts empty, so the first grant must bypass RLS with the service key. The gate keys on the tenant user id (`internal.users.id` / JWT `sub`), NOT a wallet. The conventional table is a default — `requireRole` accepts any `(table, idColumn, roleColumn)`.