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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/12408-an-everyday-session-is-refused-every-adm → main 3 commits fork 1 thread · 1 unresolved

  • Merged. scout/12408-an-everyday-session-is-refused-every-adm was merged into main as 43e1187.

Review

Sign in to review this change.
12 files changed +168 −23
src/actions/admin_words.ts +8
@@ -4,7 +4,15 @@
44 * drift when one door is edited.
55 */
66
7 import { SIGN_IN_AS_ADMIN } from "../core/roles.ts";
8
79 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,
816 /** account_suspend / remove: the owner acting on themselves, an admin, or an agent. */
917 selfSuspend: "an admin cannot suspend or remove themselves, another admin, or an agent",
1018 /** 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 = {
44 method: "post",
55 path: `${REPO}/data-import`,
66 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",
88 auth: true,
9 role: "admin",
9 role: "member",
1010 body: { dump: "string", env: "string?", file: "string?", replace: "boolean?" },
1111 refusals: {
1212 invalid_dump: "dump is not text (422)",
src/http/admin_gate.ts +21 −3
@@ -2,7 +2,7 @@
22 * The one gate in front of every admin-only route: those `route_marks.ts`
33 * marks `admin` — `/admin/*`, `/api/v1/admin/*`, `/releases` with its
44 * 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

Nit: this comment line is well over 100 columns; rewrap it.

66 * refused with a 403 and one plain sentence saying to sign in as admin, as
77 * JSON for the API and as a page for a browser, before any handler runs. Anyone else meets the handler's own
88 * rules, so an admin is unchanged. For releases the rule itself is
@@ -11,6 +11,7 @@
1111 */
1212
1313 import type { App } from "../app.ts";
14 import { ROUTES } from "../contract/openapi.ts";
1415 import { lacks, SIGN_IN_AS_ADMIN } from "../core/roles.ts";
1516 import { forbiddenPage } from "../web/chrome.ts";
1617 import type { Auth, Ctx } from "./context.ts";
@@ -26,6 +27,22 @@ export function isAdminPath(path: string): boolean {
2627 return isAdminRoute(path);
2728 }
2829
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
2946 /**
3047 * The refusal for a person who may not administer, or undefined to carry on.
3148 * `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 {
3552 export function adminGate(app: App, request: Request, url: URL, auth: Auth, pattern?: string): Response | undefined {
3653 if (auth.user === undefined) return undefined;
3754 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;
3957 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);
4159 const ctx: Ctx = { app, request, url, params: {}, auth, theme: undefined };
4260 return forbiddenPage(ctx, SIGN_IN_AS_ADMIN);
4361 }
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", () => {
6969 expect(m.text).toBe(`not_allowed: ${ADMIN_WORDS.selfSuspend}`);
7070 });
7171
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 () => {
7374 const r = await api(person, "GET", "/api/v1/crew/scorecard");
7475 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 });
7677 });
7778
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 () => {
7980 const r = await api(person, "PUT", "/api/v1/front", { pinned: [], hidden: [] });
8081 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 });
8283 const m = await mcp(person, "front_page", { pinned: [], hidden: [] });
8384 expect(m.error).toBe(true);
8485 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", () => {
6868 expect(body).toEqual(await mcp(admin));
6969 });
7070
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 () => {
7272 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");
7575 expect((await get()).status).toBe(401);
7676 });
7777
test/billing.test.ts +1 −1
@@ -113,7 +113,7 @@ describe("billing seam", () => {
113113 expect((await send("PUT", "/api/v1/forges/kid/plan", admin, { plan: "huge" })).status).toBe(422);
114114 expect((await send("PUT", "/api/v1/forges/kid/plan", admin, {})).status).toBe(422);
115115 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);
117117 expect((await send("PUT", "/api/v1/forges/kid/plan", undefined, { plan: "small" })).status).toBeGreaterThanOrEqual(401);
118118 expect((await send("PUT", "/api/v1/forges/kid/plan", adminRo, { plan: "small" })).status).toBeGreaterThanOrEqual(403);
119119 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
145145 expect(ro.headers.get("etag")).toBeNull();
146146 const plain = await asAdmin({}, bobTok);
147147 const bob = await asAdmin(star, bobTok);
148 expect(bob.status).toBe(404);
148 expect(bob.status).toBe(403);
149149 expect(bob.status).toBe(plain.status);
150150 expect(await bob.text()).toBe(await plain.text());
151151 expect(bob.headers.get("etag")).toBeNull();
test/economics.test.ts +2 −2
@@ -88,8 +88,8 @@ describe("economics", () => {
8888 expect((await post(cookie, { position: "90", marginPct: "10" })).status).not.toBe(303);
8989 }
9090 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);
9393 expect(getEconomics(app.db)).toEqual({ position: 50, marginPct: 0 });
9494 });
9595
test/egress-metrics-route.test.ts +3 −3
@@ -88,10 +88,10 @@ afterAll(async () => {
8888 const get = (headers: Record<string, string> = {}) =>
8989 fetch(`${servers.url}/metrics`, { headers });
9090
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 () => {
9292 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);
9595 expect((await get({ authorization: `Bearer ${agent}` })).status).toBe(404);
9696 });
9797
test/features.test.ts +1 −1
@@ -271,7 +271,7 @@ describe("absorbing an app", () => {
271271 const byOwner = await absorbApp(app, cy, "radar");
272272 expect(!byOwner.ok && byOwner.error).toBe("forbidden");
273273 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);
275275 expect((await api("GET", bobToken, "/radar")).status).toBe(404);
276276 expect((await api("GET", cyToken, "")).status).toBe(404);
277277 // 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", () => {
124124 expect((await send("POST", "/api/v1/codes", admin, { plan: "small", note: "x".repeat(81) })).status).toBe(422);
125125 expect((await send("POST", "/api/v1/codes", admin, { plan: "small", note: "a\nb" })).status).toBe(422);
126126 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);
128128 expect((await send("POST", "/api/v1/codes", adminRo, { plan: "small", dryRun: false })).status).toBeGreaterThanOrEqual(403);
129129 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);
131131 });
132132
133133 test("make, redeem: the forge is on that plan, sourced code:<id>, with no payment call", async () => {
@@ -211,7 +211,7 @@ describe("gift codes", () => {
211211 fresh();
212212 const r = await make({ plan: "large", uses: 5 });
213213 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);
215215 expect((await send("DELETE", `/api/v1/codes/${r.made.id}`, admin)).status).toBe(200);
216216 expect((await send("DELETE", `/api/v1/codes/${r.made.id}`, admin)).status).toBe(404);
217217 const refused = await spawn(bob, "after-revoke", r.code);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs