Public: anyone can read this; the crew can change it.
An everyday session is refused every admin action, in words that point to the admin sign-in: one table test #12428
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/12408-an-everyday-session-is-refused-every-adm → main 3 commits fork 1 thread · 1 unresolved
12 files
changed +168
−23
src/actions/admin_words.ts +8
| @@ -4,7 +4,15 @@ | ||
| 4 | 4 | * drift when one door is edited. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import { SIGN_IN_AS_ADMIN } from "../core/roles.ts"; | |
| 8 | ||
| 7 | 9 | export const ADMIN_WORDS = { |
| 10 | /** | |
| 11 | * Every admin door, asked by an everyday account (#12408): admin is a | |
| 12 | * separate account, so the refusal names the admin sign-in. The gate | |
| 13 | * (`http/admin_gate.ts`) and MCP (`notAdminTool`) say this sentence. | |
| 14 | */ | |
| 15 | signIn: SIGN_IN_AS_ADMIN, | |
| 8 | 16 | /** account_suspend / remove: the owner acting on themselves, an admin, or an agent. */ |
| 9 | 17 | selfSuspend: "an admin cannot suspend or remove themselves, another admin, or an agent", |
| 10 | 18 | /** The crew scorecard, asked for by anyone but a forge admin. */ |
src/contract/openapi/routes/postApiV1ReposOwnerNameDataImport.ts +2 −2
| @@ -4,9 +4,9 @@ export const route: Route = { | ||
| 4 | 4 | method: "post", |
| 5 | 5 | path: `${REPO}/data-import`, |
| 6 | 6 | summary: |
| 7 | "Load a SQL dump (CREATE TABLE, CREATE INDEX, INSERT INTO only; up to 16 MB) into the app's SQLite store and report each table's rows and SHA-256 on the dump side and the store side: { file, statements, tables: [{ table, rows, sha256, store, match }], kept? }; nothing changes unless every table matches; needs an admin token", | |
| 7 | "Load a SQL dump (CREATE TABLE, CREATE INDEX, INSERT INTO only; up to 16 MB) into the app's SQLite store and report each table's rows and SHA-256 on the dump side and the store side: { file, statements, tables: [{ table, rows, sha256, store, match }], kept? }; nothing changes unless every table matches; needs an admin-scope token and the right to administer the repository", | |
| 8 | 8 | auth: true, |
| 9 | role: "admin", | |
| 9 | role: "member", | |
| 10 | 10 | body: { dump: "string", env: "string?", file: "string?", replace: "boolean?" }, |
| 11 | 11 | refusals: { |
| 12 | 12 | invalid_dump: "dump is not text (422)", |
src/http/admin_gate.ts +21 −3
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | * The one gate in front of every admin-only route: those `route_marks.ts` |
| 3 | 3 | * marks `admin` — `/admin/*`, `/api/v1/admin/*`, `/releases` with its |
| 4 | 4 | * rollback, and the admin's pages elsewhere (`/settings/billing`, `/boards`, |
| 5 | * …). A signed-in person whose role lacks `administer` (`core/roles.ts`) is | |
| 5 | * …), and every operation the OpenAPI contract publishes as `x-role: admin`. A signed-in person whose role lacks `administer` (`core/roles.ts`) is | |
| 6 | 6 | * refused with a 403 and one plain sentence saying to sign in as admin, as |
| 7 | 7 | * JSON for the API and as a page for a browser, before any handler runs. Anyone else meets the handler's own |
| 8 | 8 | * rules, so an admin is unchanged. For releases the rule itself is |
| @@ -11,6 +11,7 @@ | ||
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | 13 | import type { App } from "../app.ts"; |
| 14 | import { ROUTES } from "../contract/openapi.ts"; | |
| 14 | 15 | import { lacks, SIGN_IN_AS_ADMIN } from "../core/roles.ts"; |
| 15 | 16 | import { forbiddenPage } from "../web/chrome.ts"; |
| 16 | 17 | import type { Auth, Ctx } from "./context.ts"; |
| @@ -26,6 +27,22 @@ export function isAdminPath(path: string): boolean { | ||
| 26 | 27 | return isAdminRoute(path); |
| 27 | 28 | } |
| 28 | 29 | |
| 30 | /** | |
| 31 | * A route pattern with its parameter names dropped, so `/a/:slug` and the | |
| 32 | * contract's `/a/{slug}` read alike. | |
| 33 | */ | |
| 34 | const shape = (method: string, pattern: string): string => | |
| 35 | `${method.toUpperCase()} ${pattern.replace(/:[A-Za-z_]+|\{[^}]+\}/g, ":")}`; | |
| 36 | ||
| 37 | /** | |
| 38 | * The operations the OpenAPI contract publishes as `x-role: admin`, a site | |
| 39 | * admin's only (#12408): the gate refuses them too, so a row the contract | |
| 40 | * calls admin cannot answer an everyday account with a scope error or a 404. | |
| 41 | */ | |
| 42 | let contractAdmin: ReadonlySet<string> | undefined; | |
| 43 | const isContractAdmin = (method: string, pattern: string): boolean => | |
| 44 | (contractAdmin ??= new Set(ROUTES.filter((r) => r.role === "admin").map((r) => shape(r.method, r.path)))).has(shape(method, pattern)); | |
| 45 | ||
| 29 | 46 | /** |
| 30 | 47 | * The refusal for a person who may not administer, or undefined to carry on. |
| 31 | 48 | * `pattern` is the route the router matched, so the mark is the one of the |
| @@ -35,9 +52,10 @@ export function isAdminPath(path: string): boolean { | ||
| 35 | 52 | export function adminGate(app: App, request: Request, url: URL, auth: Auth, pattern?: string): Response | undefined { |
| 36 | 53 | if (auth.user === undefined) return undefined; |
| 37 | 54 | const mark = pattern === undefined ? undefined : markOf(pattern); |
| 38 | if (!(mark === undefined ? isAdminPath(url.pathname) : mark === "admin")) return undefined; | |
| 55 | const contract = pattern !== undefined && isContractAdmin(request.method, pattern); | |
| 56 | if (!contract && !(mark === undefined ? isAdminPath(url.pathname) : mark === "admin")) return undefined; | |
| 39 | 57 | if (!lacks(app.db, auth.user, "administer")) return undefined; |
| 40 | if (url.pathname.startsWith("/api/")) return json({ error: "not_admin", message: SIGN_IN_AS_ADMIN }, 403); | |
| 58 | if (contract || url.pathname.startsWith("/api/")) return json({ error: "not_admin", message: SIGN_IN_AS_ADMIN }, 403); | |
| 41 | 59 | const ctx: Ctx = { app, request, url, params: {}, auth, theme: undefined }; |
| 42 | 60 | return forbiddenPage(ctx, SIGN_IN_AS_ADMIN); |
| 43 | 61 | } |
test/admin-refusal-table.test.ts added +118
| @@ -0,0 +1,118 @@ | ||
| 1 | /** | |
| 2 | * An everyday session is refused every admin action, in the words that | |
| 3 | * point to the admin sign-in (#12408). The table is read, not written: every | |
| 4 | * OpenAPI row whose role is `admin` (`src/contract/openapi.ts`) and every MCP | |
| 5 | * tool marked `adminOnly` (`src/http/mcp/tools.ts`). Each is called by the | |
| 6 | * everyday account linked to the admin (`core/everyday.ts`), the same person | |
| 7 | * signed in day to day, and must answer 403 with `ADMIN_WORDS.signIn`. A new | |
| 8 | * admin operation joins the table without an edit here. | |
| 9 | */ | |
| 10 | ||
| 11 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 12 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 13 | import { tmpdir } from "node:os"; | |
| 14 | import { join } from "node:path"; | |
| 15 | import { ADMIN_WORDS } from "../src/actions/admin_words.ts"; | |
| 16 | import { createApp, type App } from "../src/app.ts"; | |
| 17 | import { ROUTES } from "../src/contract/openapi.ts"; | |
| 18 | import { adminOf, linkEveryday } from "../src/core/everyday.ts"; | |
| 19 | import { lacks } from "../src/core/roles.ts"; | |
| 20 | import { createSession } from "../src/core/sessions.ts"; | |
| 21 | import { createToken } from "../src/core/tokens.ts"; | |
| 22 | import { createUser, type User } from "../src/core/users.ts"; | |
| 23 | import { TOOLS } from "../src/http/mcp/tools.ts"; | |
| 24 | import { forgeHttp, type ForgeHttp } from "../src/http/server.ts"; | |
| 25 | import { quietLogs } from "./helpers/globals.ts"; | |
| 26 | ||
| 27 | quietLogs(); | |
| 28 | ||
| 29 | const fill = (path: string): string => path.replace(/\{[A-Za-z_]+\}/g, "x"); | |
| 30 | ||
| 31 | describe("an everyday session meets the admin sign-in at every admin door", () => { | |
| 32 | let dataDir = ""; | |
| 33 | let app: App; | |
| 34 | let http: ForgeHttp; | |
| 35 | let admin: User; | |
| 36 | let everyday: User; | |
| 37 | let cookie = ""; | |
| 38 | let bearer = ""; | |
| 39 | const cells = process.env["FORGE_CELLS"]; | |
| 40 | ||
| 41 | beforeAll(async () => { | |
| 42 | process.env["FORGE_CELLS"] = "off"; | |
| 43 | dataDir = await mkdtemp(join(tmpdir(), "forge-admin-refusal-")); | |
| 44 | app = createApp({ dataDir, port: 0 }); | |
| 45 | const mk = async (username: string, isAdmin: boolean) => { | |
| 46 | const r = await createUser(app.db, { | |
| 47 | origin: "seed", trusted: true, isAdmin, username, email: `${username}@forge.local`, password: "a-good-passphrase", | |
| 48 | }); | |
| 49 | if (!r.ok) throw new Error(r.error); | |
| 50 | return r.value; | |
| 51 | }; | |
| 52 | admin = await mk("alice", true); | |
| 53 | everyday = await mk("carol", false); | |
| 54 | linkEveryday(app.db, admin, everyday); | |
| 55 | cookie = `forge_session=${createSession(app.db, everyday.id).token}`; | |
| 56 | bearer = `Bearer ${createToken(app.db, everyday.id, "t", "admin").token}`; | |
| 57 | http = forgeHttp(app); | |
| 58 | }, 60_000); | |
| 59 | ||
| 60 | afterAll(async () => { | |
| 61 | app.close(); | |
| 62 | await rm(dataDir, { recursive: true, force: true }); | |
| 63 | if (cells === undefined) delete process.env["FORGE_CELLS"]; | |
| 64 | else process.env["FORGE_CELLS"] = cells; | |
| 65 | }); | |
| 66 | ||
| 67 | test("the everyday account is the admin's own, and may not administer", () => { | |
| 68 | expect(adminOf(app.db, everyday)?.id).toBe(admin.id); | |
| 69 | expect(lacks(app.db, everyday, "administer")).toBe(true); | |
| 70 | expect(ADMIN_WORDS.signIn).toContain("sign in as admin"); | |
| 71 | }); | |
| 72 | ||
| 73 | const call = async (method: string, path: string, auth: Record<string, string>) => { | |
| 74 | const upper = method.toUpperCase(); | |
| 75 | const headers: Record<string, string> = { accept: "application/json", "content-type": "application/json", ...auth }; | |
| 76 | if (upper !== "GET") headers["origin"] = "http://forge.test"; | |
| 77 | const res = await http.handle( | |
| 78 | new Request(`http://forge.test${fill(path)}`, { method: upper, headers, ...(upper === "GET" ? {} : { body: "{}" }) }), | |
| 79 | ); | |
| 80 | const text = res.status === 403 ? await res.text() : (await res.body?.cancel(), ""); | |
| 81 | let body: unknown; | |
| 82 | try { body = JSON.parse(text); } catch { body = text; } | |
| 83 | return { status: res.status, body }; | |
| 84 | }; | |
| 85 | ||
| 86 | for (const [door, auth] of [["session cookie", () => ({ cookie })], ["token", () => ({ authorization: bearer })]] as const) { | |
| 87 | test(`each admin OpenAPI operation answers the everyday ${door} 403 with the admin sign-in`, async () => { | |
| 88 | const rows = ROUTES.filter((r) => r.role === "admin"); | |
| 89 | expect(rows.length).toBeGreaterThan(50); | |
| 90 | const wrong: string[] = []; | |
| 91 | for (const r of rows) { | |
| 92 | const got = await call(r.method, r.path, auth()); | |
| 93 | const words = (got.body as { message?: unknown } | undefined)?.message; | |
| 94 | if (got.status !== 403 || words !== ADMIN_WORDS.signIn) | |
| 95 | wrong.push(`${r.method.toUpperCase()} ${r.path}: ${got.status} ${JSON.stringify(got.body).slice(0, 120)}`); | |
| 96 | } | |
| 97 | expect(wrong).toEqual([]); | |
| 98 | }, 120_000); | |
| 99 | } | |
| 100 | ||
| 101 | test("each admin MCP tool refuses the everyday account with the admin sign-in", async () => { | |
| 102 | const tools = TOOLS.filter((t) => t.adminOnly === true); | |
| 103 | expect(tools.length).toBeGreaterThan(5); | |
| 104 | const wrong: string[] = []; | |
| 105 | for (const t of tools) { | |
| 106 | const res = await http.handle( | |
| 107 | new Request("http://forge.test/mcp", { | |
| 108 | method: "POST", | |
| 109 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: bearer }, | |
| 110 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: t.name, arguments: {} } }), | |
| 111 | }), | |
| 112 | ); | |
| 113 | const got = (await res.json()) as { error?: { message?: string } }; | |
| 114 | if (!got.error?.message?.endsWith(ADMIN_WORDS.signIn)) wrong.push(`${t.name}: ${JSON.stringify(got).slice(0, 160)}`); | |
| 115 | } | |
| 116 | expect(wrong).toEqual([]); | |
| 117 | }); | |
| 118 | }); |
test/admin-words.test.ts +5 −4
| @@ -69,16 +69,17 @@ describe("an admin's refusals, from every door", () => { | ||
| 69 | 69 | expect(m.text).toBe(`not_allowed: ${ADMIN_WORDS.selfSuspend}`); |
| 70 | 70 | }); |
| 71 | 71 | |
| 72 | test("a non-admin reading the scorecard: the API says the module's sentence", async () => { | |
| 72 | // The API's admin rows meet the gate first (#12408): it names the admin sign-in. | |
| 73 | test("a non-admin reading the scorecard: the API says the admin sign-in", async () => { | |
| 73 | 74 | const r = await api(person, "GET", "/api/v1/crew/scorecard"); |
| 74 | 75 | expect(r.status).toBe(403); |
| 75 | expect(r.body).toMatchObject({ error: "forbidden", message: ADMIN_WORDS.scorecard }); | |
| 76 | expect(r.body).toMatchObject({ error: "not_admin", message: ADMIN_WORDS.signIn }); | |
| 76 | 77 | }); |
| 77 | 78 | |
| 78 | test("a non-admin setting the front page: the API and MCP say the module's sentence", async () => { | |
| 79 | test("a non-admin setting the front page: the API says the admin sign-in, MCP the module's sentence", async () => { | |
| 79 | 80 | const r = await api(person, "PUT", "/api/v1/front", { pinned: [], hidden: [] }); |
| 80 | 81 | expect(r.status).toBe(403); |
| 81 | expect(r.body).toMatchObject({ error: "forbidden", message: ADMIN_WORDS.frontPage }); | |
| 82 | expect(r.body).toMatchObject({ error: "not_admin", message: ADMIN_WORDS.signIn }); | |
| 82 | 83 | const m = await mcp(person, "front_page", { pinned: [], hidden: [] }); |
| 83 | 84 | expect(m.error).toBe(true); |
| 84 | 85 | expect(m.text).toBe(`forbidden: ${ADMIN_WORDS.frontPage}`); |
test/api-cloud-accounts.test.ts +3 −3
| @@ -68,10 +68,10 @@ describe("GET /api/v1/cloud/accounts", () => { | ||
| 68 | 68 | expect(body).toEqual(await mcp(admin)); |
| 69 | 69 | }); |
| 70 | 70 | |
| 71 | test("a member gets 404, and no token 401", async () => { | |
| 71 | test("a member gets 403 naming the admin sign-in, and no token 401", async () => { | |
| 72 | 72 | const res = await get(bob); |
| 73 | expect(res.status).toBe(404); | |
| 74 | expect(((await res.json()) as any).error).toBe("not_found"); | |
| 73 | expect(res.status).toBe(403); | |
| 74 | expect(((await res.json()) as any).error).toBe("not_admin"); | |
| 75 | 75 | expect((await get()).status).toBe(401); |
| 76 | 76 | }); |
| 77 | 77 |
test/billing.test.ts +1 −1
| @@ -113,7 +113,7 @@ describe("billing seam", () => { | ||
| 113 | 113 | expect((await send("PUT", "/api/v1/forges/kid/plan", admin, { plan: "huge" })).status).toBe(422); |
| 114 | 114 | expect((await send("PUT", "/api/v1/forges/kid/plan", admin, {})).status).toBe(422); |
| 115 | 115 | expect((await send("PUT", "/api/v1/forges/nope/plan", admin, { plan: "small" })).status).toBe(404); |
| 116 | expect((await send("PUT", "/api/v1/forges/kid/plan", bob, { plan: "small" })).status).toBe(404); | |
| 116 | expect((await send("PUT", "/api/v1/forges/kid/plan", bob, { plan: "small" })).status).toBe(403); | |
| 117 | 117 | expect((await send("PUT", "/api/v1/forges/kid/plan", undefined, { plan: "small" })).status).toBeGreaterThanOrEqual(401); |
| 118 | 118 | expect((await send("PUT", "/api/v1/forges/kid/plan", adminRo, { plan: "small" })).status).toBeGreaterThanOrEqual(403); |
| 119 | 119 | expect((await send("PUT", "/api/v1/forges/kid/plan", admin, { plan: "small" }, false)).status).toBe(403); |
test/cloud-accounts-etag.test.ts +1 −1
| @@ -145,7 +145,7 @@ test("no session, a read-only token and a non-admin answer as before, with no ta | ||
| 145 | 145 | expect(ro.headers.get("etag")).toBeNull(); |
| 146 | 146 | const plain = await asAdmin({}, bobTok); |
| 147 | 147 | const bob = await asAdmin(star, bobTok); |
| 148 | expect(bob.status).toBe(404); | |
| 148 | expect(bob.status).toBe(403); | |
| 149 | 149 | expect(bob.status).toBe(plain.status); |
| 150 | 150 | expect(await bob.text()).toBe(await plain.text()); |
| 151 | 151 | expect(bob.headers.get("etag")).toBeNull(); |
test/economics.test.ts +2 −2
| @@ -88,8 +88,8 @@ describe("economics", () => { | ||
| 88 | 88 | expect((await post(cookie, { position: "90", marginPct: "10" })).status).not.toBe(303); |
| 89 | 89 | } |
| 90 | 90 | const bobToken = createToken(app.db, bob.id, "t", "admin").token; |
| 91 | expect((await api(bobToken, "GET")).status).toBe(404); | |
| 92 | expect((await api(bobToken, "PUT", { position: 90 })).status).toBe(404); | |
| 91 | expect((await api(bobToken, "GET")).status).toBe(403); | |
| 92 | expect((await api(bobToken, "PUT", { position: 90 })).status).toBe(403); | |
| 93 | 93 | expect(getEconomics(app.db)).toEqual({ position: 50, marginPct: 0 }); |
| 94 | 94 | }); |
| 95 | 95 |
test/egress-metrics-route.test.ts +3 −3
| @@ -88,10 +88,10 @@ afterAll(async () => { | ||
| 88 | 88 | const get = (headers: Record<string, string> = {}) => |
| 89 | 89 | fetch(`${servers.url}/metrics`, { headers }); |
| 90 | 90 | |
| 91 | test("anonymous, a member and an agent are told it is not there", async () => { | |
| 91 | test("anonymous is told it is not there; a member is told to sign in as admin; an agent is told it is not there", async () => { | |
| 92 | 92 | expect((await get()).status).toBe(404); |
| 93 | expect((await get({ cookie: memberCookie })).status).toBe(404); | |
| 94 | expect((await get({ authorization: `Bearer ${member}` })).status).toBe(404); | |
| 93 | expect((await get({ cookie: memberCookie })).status).toBe(403); | |
| 94 | expect((await get({ authorization: `Bearer ${member}` })).status).toBe(403); | |
| 95 | 95 | expect((await get({ authorization: `Bearer ${agent}` })).status).toBe(404); |
| 96 | 96 | }); |
| 97 | 97 |
test/features.test.ts +1 −1
| @@ -271,7 +271,7 @@ describe("absorbing an app", () => { | ||
| 271 | 271 | const byOwner = await absorbApp(app, cy, "radar"); |
| 272 | 272 | expect(!byOwner.ok && byOwner.error).toBe("forbidden"); |
| 273 | 273 | expect((await api("PUT", cyToken, "/radar")).status).toBe(403); |
| 274 | expect((await api("PUT", bobToken, "/radar")).status).toBe(404); | |
| 274 | expect((await api("PUT", bobToken, "/radar")).status).toBe(403); | |
| 275 | 275 | expect((await api("GET", bobToken, "/radar")).status).toBe(404); |
| 276 | 276 | expect((await api("GET", cyToken, "")).status).toBe(404); |
| 277 | 277 | // A read-scope token of the admin may look, not absorb. |
test/gift-codes.test.ts +3 −3
| @@ -124,10 +124,10 @@ describe("gift codes", () => { | ||
| 124 | 124 | expect((await send("POST", "/api/v1/codes", admin, { plan: "small", note: "x".repeat(81) })).status).toBe(422); |
| 125 | 125 | expect((await send("POST", "/api/v1/codes", admin, { plan: "small", note: "a\nb" })).status).toBe(422); |
| 126 | 126 | expect((await send("POST", "/api/v1/codes", admin, { plan: "small", expires: "2001-01-01" })).status).toBe(422); |
| 127 | expect((await send("POST", "/api/v1/codes", bob, { plan: "small", dryRun: false })).status).toBe(404); | |
| 127 | expect((await send("POST", "/api/v1/codes", bob, { plan: "small", dryRun: false })).status).toBe(403); | |
| 128 | 128 | expect((await send("POST", "/api/v1/codes", adminRo, { plan: "small", dryRun: false })).status).toBeGreaterThanOrEqual(403); |
| 129 | 129 | expect((await send("POST", "/api/v1/codes", admin, { plan: "small", dryRun: false }, false)).status).toBe(403); |
| 130 | expect((await send("GET", "/api/v1/codes", bob)).status).toBe(404); | |
| 130 | expect((await send("GET", "/api/v1/codes", bob)).status).toBe(403); | |
| 131 | 131 | }); |
| 132 | 132 | |
| 133 | 133 | test("make, redeem: the forge is on that plan, sourced code:<id>, with no payment call", async () => { |
| @@ -211,7 +211,7 @@ describe("gift codes", () => { | ||
| 211 | 211 | fresh(); |
| 212 | 212 | const r = await make({ plan: "large", uses: 5 }); |
| 213 | 213 | expect((await spawn(bob, "keeps-plan", r.code)).status).toBe(201); |
| 214 | expect((await send("DELETE", `/api/v1/codes/${r.made.id}`, bob)).status).toBe(404); | |
| 214 | expect((await send("DELETE", `/api/v1/codes/${r.made.id}`, bob)).status).toBe(403); | |
| 215 | 215 | expect((await send("DELETE", `/api/v1/codes/${r.made.id}`, admin)).status).toBe(200); |
| 216 | 216 | expect((await send("DELETE", `/api/v1/codes/${r.made.id}`, admin)).status).toBe(404); |
| 217 | 217 | const refused = await spawn(bob, "after-revoke", r.code); |
new above
offline
Nit: this comment line is well over 100 columns; rewrap it.