Public: anyone can read this; the crew can change it.
Roles, part 3a: a member is refused admin settings and releases, with a 403 on the API and a plain line on the page #2403
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2384-roles-part-3a-a-member-is-refused-admin → main 4 commits fork 2 threads · 2 unresolved
16 files
changed +248
−40
docs/ROLES.md +7
| @@ -15,6 +15,13 @@ verify, a suspended one is refused, an agent and a stranger to a private | ||
| 15 | 15 | repository meet their own reasons first. A comment from a guest is still a |
| 16 | 16 | comment; it just starts no run. Agents and organizations are not guests. |
| 17 | 17 | |
| 18 | Part 3a (#2384) enforces "Administer the forge" for admin settings and | |
| 19 | releases: every `/admin/*` page, `/api/v1/admin/*` call, and the `/releases` | |
| 20 | page with its rollback refuse a member or guest with a 403 and "only an admin | |
| 21 | can change the forge's settings or its releases" (`not_admin` in the API). | |
| 22 | `src/http/admin_gate.ts` is the one gate, ahead of every handler; | |
| 23 | `/releases.json` stays public. Token scopes are a later part. | |
| 24 | ||
| 18 | 25 | Nothing switches the refusal off: the test suite runs with it everywhere, and |
| 19 | 26 | a suite whose people make things promotes them first (`promote` in |
| 20 | 27 | `test/helpers/members.ts`). The crew's wake-up sweep, which starts a run on an |
src/core/roles.ts +3
| @@ -48,6 +48,9 @@ export function rolesTable(): string { | ||
| 48 | 48 | /** The words a guest is refused with, on every surface. */ |
| 49 | 49 | export const NOT_A_MEMBER = "the owner has not made this account a member"; |
| 50 | 50 | |
| 51 | /** The words a member (or guest) is refused admin settings and releases with. */ | |
| 52 | export const NOT_AN_ADMIN = "only an admin can change the forge's settings or its releases"; | |
| 53 | ||
| 51 | 54 | /** |
| 52 | 55 | * Whether the table withholds `ability` from this account. Only a person |
| 53 | 56 | * can be a guest: agents and organizations are not accounts the owner |
src/http/admin_gate.ts added +42
| @@ -0,0 +1,42 @@ | ||
| 1 | /** | |
| 2 | * The one gate in front of admin settings and releases: `/admin/*`, | |
| 3 | * `/api/v1/admin/*` and the `/releases` page with its rollback. A signed-in | |
| 4 | * person whose role lacks `administer` (`core/roles.ts`) is refused with a | |
| 5 | * 403 and one plain sentence, as JSON for the API and as a page for a | |
| 6 | * browser, before any handler runs. Anyone else meets the handler's own | |
| 7 | * rules, so an admin is unchanged. For releases the rule itself is | |
| 8 | * `release/gate.ts`'s, which the terminal shares; this gate only supplies the | |
| 9 | * 403 and its wording on the page. `/releases.json` stays public. | |
| 10 | */ | |
| 11 | ||
| 12 | import type { App } from "../app.ts"; | |
| 13 | import { lacks, NOT_AN_ADMIN } from "../core/roles.ts"; | |
| 14 | import { empty } from "../web/ui.ts"; | |
| 15 | import { html } from "../web/html.ts"; | |
| 16 | import { page } from "../web/layout.ts"; | |
| 17 | import type { Auth, Ctx } from "./context.ts"; | |
| 18 | import { json } from "./respond.ts"; | |
| 19 | ||
| 20 | const under = (path: string, root: string): boolean => path === root || path.startsWith(`${root}/`); | |
| 21 | ||
| 22 | /** Whether this path is one the `administer` ability guards. */ | |
| 23 | export function isAdminPath(path: string): boolean { | |
| 24 | return under(path, "/admin") || under(path, "/api/v1/admin") || under(path, "/releases"); | |
| 25 | } | |
| 26 | ||
| 27 | /** The refusal for a person who may not administer, or undefined to carry on. */ | |
| 28 | export function adminGate(app: App, request: Request, url: URL, auth: Auth): Response | undefined { | |
| 29 | if (auth.user === undefined || !isAdminPath(url.pathname)) return undefined; | |
| 30 | if (!lacks(app.db, auth.user, "administer")) return undefined; | |
| 31 | if (url.pathname.startsWith("/api/")) return json({ error: "not_admin", message: NOT_AN_ADMIN }, 403); | |
| 32 | const ctx: Ctx = { app, request, url, params: {}, auth, theme: undefined }; | |
| 33 | return page( | |
| 34 | { | |
| 35 | ctx, | |
| 36 | title: "Not allowed", | |
| 37 | body: html`<div class="page-head"><h1>403</h1></div> | |
| 38 | ${empty({ icon: "lock", title: "Not allowed", note: NOT_AN_ADMIN })}`, | |
| 39 | }, | |
| 40 | 403, | |
| 41 | ); | |
| 42 | } |
src/http/server.ts +5
| @@ -430,6 +430,7 @@ import { targetForShortCode } from "../origins/oauth.ts"; | ||
| 430 | 430 | import { apiRoutes } from "./api.ts"; |
| 431 | 431 | import { inNamespace } from "../core/proposal_token.ts"; |
| 432 | 432 | import { proposalTokenMint, proposalTokenRevoke } from "../web/pages/proposal-tokens.ts"; |
| 433 | import { adminGate } from "./admin_gate.ts"; | |
| 433 | 434 | import { proposalGate } from "./proposal_gate.ts"; |
| 434 | 435 | import { feedRoutes } from "./feed.ts"; |
| 435 | 436 | import type { Database } from "bun:sqlite"; |
| @@ -2302,6 +2303,10 @@ async function answerGet( | ||
| 2302 | 2303 | const proposalRefusal = proposalGate(app, request, url, auth); |
| 2303 | 2304 | if (proposalRefusal !== undefined) return proposalRefusal; |
| 2304 | 2305 | |
| 2306 | // Admin settings and releases are an admin's; a member is told so. | |
| 2307 | const adminRefusal = adminGate(app, request, url, auth); | |
This gate sits only on the HTTP path. Any rule it adds for releases has to live in |
||
| 2308 | if (adminRefusal !== undefined) return adminRefusal; | |
| 2309 | ||
| 2305 | 2310 | // The two things standing between one runaway agent and everybody |
| 2306 | 2311 | // else's latency, applied before a handler runs and after auth is |
| 2307 | 2312 | // known — a limit needs an identity, and a replay needs a credential. |
src/release/gate.ts +6 −2
| @@ -3,11 +3,15 @@ | ||
| 3 | 3 | * both clients (the /releases page and the SSH terminal) run: the same |
| 4 | 4 | * refusals, `beginSwap`/`endSwap`, the health-read swap, then this process |
| 5 | 5 | * drains. Plain data in, plain data out; neither client re-states a rule. |
| 6 | * Releases are an admin's to see and undo: a person whose role lacks | |
| 7 | * `administer` (`core/roles.ts`) is refused here, whatever they may do to | |
| 8 | * FORGE_REPO, so the page and the terminal agree. | |
| 6 | 9 | */ |
| 7 | 10 | |
| 8 | 11 | import { existsSync } from "node:fs"; |
| 9 | 12 | import type { App } from "../app.ts"; |
| 10 | 13 | import { canRead, canWrite, getRepo, type Repo } from "../core/repos.ts"; |
| 14 | import { lacks } from "../core/roles.ts"; | |
| 11 | 15 | import type { User } from "../core/users.ts"; |
| 12 | 16 | import { releaseDir } from "./checkout.ts"; |
| 13 | 17 | import type { Release } from "./record.ts"; |
| @@ -27,7 +31,7 @@ function forgeRepoOf(app: App): Repo | undefined { | ||
| 27 | 31 | |
| 28 | 32 | /** Who may see the release history: the forge repository's readers and a site admin. */ |
| 29 | 33 | export function mayRead(app: App, user: User | undefined): boolean { |
| 30 | if (user === undefined) return false; | |
| 34 | if (user === undefined || lacks(app.db, user, "administer")) return false; | |
| 31 | 35 | if (user.isAdmin) return true; |
| 32 | 36 | const repo = forgeRepoOf(app); |
| 33 | 37 | return repo !== undefined && canRead(app.db, repo, user); |
| @@ -35,7 +39,7 @@ export function mayRead(app: App, user: User | undefined): boolean { | ||
| 35 | 39 | |
| 36 | 40 | export function mayRollBack(app: App, user: User | undefined): boolean { |
| 37 | 41 | // Undoing a deployment is a person's decision, as a revert is (#1824). |
| 38 | if (user === undefined || user.kind === "agent") return false; | |
| 42 | if (user === undefined || user.kind === "agent" || lacks(app.db, user, "administer")) return false; | |
| 39 | 43 | const repo = forgeRepoOf(app); |
| 40 | 44 | return repo !== undefined && canWrite(app.db, repo, user); |
| 41 | 45 | } |
test/accounts.test.ts +5 −5
| @@ -148,7 +148,7 @@ describe("accounts: the owner sees, suspends and removes", () => { | ||
| 148 | 148 | |
| 149 | 149 | test("nobody but the owner sees the list, or can act on it", async () => { |
| 150 | 150 | const { token } = await outsider("mallory"); |
| 151 | expect((await api("/api/v1/admin/accounts", { token })).status).toBe(404); | |
| 151 | expect((await api("/api/v1/admin/accounts", { token })).status).toBe(403); | |
| 152 | 152 | expect( |
| 153 | 153 | ( |
| 154 | 154 | await api("/api/v1/admin/accounts/ada/suspend", { |
| @@ -156,11 +156,11 @@ describe("accounts: the owner sees, suspends and removes", () => { | ||
| 156 | 156 | token, |
| 157 | 157 | }) |
| 158 | 158 | ).status, |
| 159 | ).toBe(404); | |
| 159 | ).toBe(403); | |
| 160 | 160 | expect( |
| 161 | 161 | (await api("/api/v1/admin/accounts/ada", { method: "DELETE", token })) |
| 162 | 162 | .status, |
| 163 | ).toBe(404); | |
| 163 | ).toBe(403); | |
| 164 | 164 | expect((await api("/api/v1/admin/accounts")).status).toBe(404); |
| 165 | 165 | const page = await fetch(`${base}/settings/accounts`, { |
| 166 | 166 | headers: { authorization: `Bearer ${token}` }, |
| @@ -498,7 +498,7 @@ describe("accounts: the owner sees, suspends and removes", () => { | ||
| 498 | 498 | // The plain path is as it was. |
| 499 | 499 | expect((await del({})).status).toBe(409); |
| 500 | 500 | // A non-admin is refused. |
| 501 | expect((await del({ retireOrgs: true, confirm: true }, bossToken)).status).toBe(404); | |
| 501 | expect((await del({ retireOrgs: true, confirm: true }, bossToken)).status).toBe(403); | |
| 502 | 502 | // Without confirm: the list, and nothing changes. |
| 503 | 503 | const preview = await del({ retireOrgs: true }); |
| 504 | 504 | expect(preview.status).toBe(200); |
| @@ -636,7 +636,7 @@ describe("accounts: the owner sees, suspends and removes", () => { | ||
| 636 | 636 | // Nobody but the owner may say so. |
| 637 | 637 | expect( |
| 638 | 638 | (await api("/api/v1/admin/accounts/nadia/verify", { method: "POST", token: other })).status, |
| 639 | ).toBe(404); | |
| 639 | ).toBe(403); | |
| 640 | 640 | |
| 641 | 641 | const listed = (await (await api("/api/v1/admin/accounts", { token: ownerToken })).json()) as { |
| 642 | 642 | items: { username: string; verified: boolean }[]; |
test/admin_refused.test.ts added +127
| @@ -0,0 +1,127 @@ | ||
| 1 | /** | |
| 2 | * Roles, part 3a (#2384): a member is refused admin settings and releases | |
| 3 | * with a 403 and one plain sentence, on the API and on the page; an admin is | |
| 4 | * unchanged. | |
| 5 | */ | |
| 6 | ||
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { createRepo } from "../src/actions/repos.ts"; | |
| 13 | import { mayRead, mayRollBack } from "../src/release/gate.ts"; | |
| 14 | import { NOT_AN_ADMIN, setMember } from "../src/core/roles.ts"; | |
| 15 | import { createSession } from "../src/core/sessions.ts"; | |
| 16 | import { createToken } from "../src/core/tokens.ts"; | |
| 17 | import { createUser, type User } from "../src/core/users.ts"; | |
| 18 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 19 | import { quietLogs } from "./helpers/globals.ts"; | |
| 20 | ||
| 21 | quietLogs(); | |
| 22 | ||
| 23 | describe("a member is refused admin settings and releases", () => { | |
| 24 | let dataDir = ""; | |
| 25 | let app: App; | |
| 26 | let servers: Servers; | |
| 27 | let alice: User; | |
| 28 | let carol: User; // member | |
| 29 | ||
| 30 | const token = (u: User) => createToken(app.db, u.id, "admin", "admin").token; | |
| 31 | const cookie = (u: User) => `forge_session=${createSession(app.db, u.id).token}`; | |
| 32 | const call = (method: string, path: string, u: User, body?: unknown) => | |
| 33 | fetch(`${servers.url}${path}`, { | |
| 34 | method, | |
| 35 | headers: { "content-type": "application/json", authorization: `Bearer ${token(u)}` }, | |
| 36 | ...(body === undefined ? {} : { body: JSON.stringify(body) }), | |
| 37 | }); | |
| 38 | const visit = (method: string, path: string, u: User) => | |
| 39 | fetch(`${servers.url}${path}`, { | |
| 40 | method, | |
| 41 | headers: { cookie: cookie(u), origin: servers.url, accept: "text/html" }, | |
| 42 | ...(method === "POST" ? { body: new URLSearchParams({}) } : {}), | |
| 43 | redirect: "manual", | |
| 44 | }); | |
| 45 | const mk = async (username: string, admin = false) => { | |
| 46 | const r = await createUser(app.db, { | |
| 47 | trusted: true, | |
| 48 | username, | |
| 49 | email: `${username}@forge.local`, | |
| 50 | password: "a-good-passphrase", | |
| 51 | ...(admin ? { adminIfFirst: true } : {}), | |
| 52 | }); | |
| 53 | if (!r.ok) throw new Error(r.error); | |
| 54 | return r.value; | |
| 55 | }; | |
| 56 | ||
| 57 | beforeAll(async () => { | |
| 58 | dataDir = await mkdtemp(join(tmpdir(), "forge-admin-refused-")); | |
| 59 | app = createApp({ dataDir, port: 0 }); | |
| 60 | servers = await serve(app); | |
| 61 | alice = await mk("alice", true); | |
| 62 | carol = await mk("carol"); | |
| 63 | setMember(app.db, carol.id, true, alice.id, new Date()); | |
| 64 | }, 60_000); | |
| 65 | ||
| 66 | afterAll(async () => { | |
| 67 | servers.stop(); | |
| 68 | app.close(); | |
| 69 | await rm(dataDir, { recursive: true, force: true }); | |
| 70 | }); | |
| 71 | ||
| 72 | const apiActs: [string, string, unknown?][] = [ | |
| 73 | ["GET", "/api/v1/admin/capacity"], | |
| 74 | ["PUT", "/api/v1/admin/capacity/anything", { value: "on" }], | |
| 75 | ["GET", "/api/v1/admin/limits"], | |
| 76 | ["GET", "/api/v1/admin/settings"], | |
| 77 | ["GET", "/api/v1/admin/invites"], | |
| 78 | ["POST", "/api/v1/admin/invites", {}], | |
| 79 | ["GET", "/api/v1/admin/accounts"], | |
| 80 | ["GET", "/api/v1/admin/people"], | |
| 81 | ["POST", "/api/v1/admin/accounts/alice/suspend", {}], | |
| 82 | ]; | |
| 83 | ||
| 84 | test("every admin API act: 403 and the sentence for a member, success for an admin", async () => { | |
| 85 | for (const [method, path, body] of apiActs) { | |
| 86 | const refused = await call(method, path, carol, body); | |
| 87 | expect(refused.status, `${method} ${path}`).toBe(403); | |
| 88 | expect(await refused.json()).toMatchObject({ error: "not_admin", message: NOT_AN_ADMIN }); | |
| 89 | } | |
| 90 | for (const path of ["/api/v1/admin/capacity", "/api/v1/admin/limits", "/api/v1/admin/people"]) | |
| 91 | expect((await call("GET", path, alice)).status, path).toBe(200); | |
| 92 | }); | |
| 93 | ||
| 94 | test("admin pages and releases: a plain 403 line for a member, the page for an admin", async () => { | |
| 95 | const pages: [string, string][] = [ | |
| 96 | ["GET", "/admin/limits"], | |
| 97 | ["POST", "/admin/limits"], | |
| 98 | ["POST", "/admin/quotas"], | |
| 99 | ["POST", "/admin/signup"], | |
| 100 | ["GET", "/releases"], | |
| 101 | ["POST", `/releases/${"a".repeat(40)}/rollback`], | |
| 102 | ]; | |
| 103 | for (const [method, path] of pages) { | |
| 104 | const refused = await visit(method, path, carol); | |
| 105 | expect(refused.status, `${method} ${path}`).toBe(403); | |
| 106 | expect(await refused.text()).toContain(NOT_AN_ADMIN.replace("'", "'")); | |
| 107 | } | |
| 108 | expect((await visit("GET", "/admin/limits", alice)).status).toBe(200); | |
| 109 | expect((await visit("GET", "/releases", alice)).status).toBe(200); | |
| 110 | }); | |
| 111 | ||
| 112 | test("a member who writes the forge's repository is still refused /releases and a rollback", async () => { | |
| 113 | const repo = await createRepo(app, alice, { name: "forge", visibility: "private" }); | |
| 114 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 115 | app.db.query("UPDATE repo SET owner_id = ?1 WHERE id = ?2").run(carol.id, repo.value.id); | |
| 116 | app.config.forgeRepo = "carol/forge"; | |
| 117 | expect(mayRollBack(app, carol)).toBe(false); | |
| 118 | expect(mayRead(app, carol)).toBe(false); | |
| 119 | for (const [m, p] of [["GET", "/releases"], ["POST", `/releases/${"a".repeat(40)}/rollback`]] as const) | |
| 120 | expect((await visit(m, p, carol)).status, p).toBe(403); | |
| 121 | }); | |
| 122 | ||
| 123 | test("the public release list stays public, and a signed-out caller meets the old rules", async () => { | |
| 124 | expect((await fetch(`${servers.url}/releases.json`)).status).toBe(200); | |
| 125 | expect((await fetch(`${servers.url}/api/v1/admin/capacity`)).status).not.toBe(403); | |
| 126 | }); | |
| 127 | }); |
test/capacity.test.ts +4 −2
| @@ -171,14 +171,16 @@ describe("capacity: only a forge admin reads or changes it", () => { | ||
| 171 | 171 | expect(bobPost.status).toBe(404); |
| 172 | 172 | |
| 173 | 173 | for (const token of [bobAdmin, agentRw]) { |
| 174 | expect((await api(token, "GET", "/admin/capacity")).status).toBe(404); | |
| 174 | // A person who is not an admin is refused; an agent is told it is not there. | |
| 175 | const refused = token === bobAdmin ? 403 : 404; | |
| 176 | expect((await api(token, "GET", "/admin/capacity")).status).toBe(refused); | |
| 175 | 177 | expect( |
| 176 | 178 | ( |
| 177 | 179 | await api(token, "PUT", "/admin/capacity/checks.concurrency", { |
| 178 | 180 | value: "9", |
| 179 | 181 | }) |
| 180 | 182 | ).status, |
| 181 | ).toBe(404); | |
| 183 | ).toBe(refused); | |
| 182 | 184 | const tool = await mcp(token, { |
| 183 | 185 | key: "checks.concurrency", |
| 184 | 186 | value: "9", |
test/invites.test.ts +4 −4
| @@ -80,9 +80,9 @@ describe("invites: an admin mints, lists and revokes", () => { | ||
| 80 | 80 | const made = mintInvite(app.db, "x"); |
| 81 | 81 | for (const token of [guest, "forge_nope"]) { |
| 82 | 82 | const post = await api("/api/v1/admin/invites", token, { method: "POST", body: "{}" }); |
| 83 | expect([401, 404]).toContain(post.status); | |
| 84 | expect([401, 404]).toContain((await api("/api/v1/admin/invites", token)).status); | |
| 85 | expect([401, 404]).toContain( | |
| 83 | expect([401, 403, 404]).toContain(post.status); | |
| 84 | expect([401, 403, 404]).toContain((await api("/api/v1/admin/invites", token)).status); | |
| 85 | expect([401, 403, 404]).toContain( | |
| 86 | 86 | (await api(`/api/v1/admin/invites/${made.invite.id}`, token, { method: "DELETE" })).status, |
| 87 | 87 | ); |
| 88 | 88 | } |
| @@ -155,7 +155,7 @@ describe("invites: the settings form", () => { | ||
| 155 | 155 | test("a cross-origin post is refused and a non-admin gets the not-found page", async () => { |
| 156 | 156 | const before = listInvites(app.db).length; |
| 157 | 157 | expect((await post(alice, "/admin/invites", { days: "3" }, "https://evil.example")).status).toBe(403); |
| 158 | expect((await post(bob, "/admin/invites", { days: "3" })).status).toBe(404); | |
| 158 | expect((await post(bob, "/admin/invites", { days: "3" })).status).toBe(403); | |
| 159 | 159 | expect(listInvites(app.db).length).toBe(before); |
| 160 | 160 | }); |
| 161 | 161 | }); |
test/limits.test.ts +6 −5
| @@ -80,12 +80,13 @@ describe("limits", () => { | ||
| 80 | 80 | await rm(dataDir, { recursive: true, force: true }); |
| 81 | 81 | }); |
| 82 | 82 | |
| 83 | test("a non-admin, signed in or not, gets a 404 and changes nothing", async () => { | |
| 83 | test("a non-admin, signed in or not, is refused and changes nothing", async () => { | |
| 84 | 84 | const bob = (await login("bob")).cookie; |
| 85 | 85 | for (const cookie of [bob, ""]) { |
| 86 | 86 | const got = await fetch(`${servers.url}/admin/limits`, { headers: { cookie } }); |
| 87 | expect(got.status).toBe(404); | |
| 88 | expect((await post(cookie, { key: "front.pinned_max", value: "50" })).status).toBe(404); | |
| 87 | const refused = cookie === "" ? 404 : 403; // signed out: not there; signed in: not an admin | |
| 88 | expect(got.status).toBe(refused); | |
| 89 | expect((await post(cookie, { key: "front.pinned_max", value: "50" })).status).toBe(refused); | |
| 89 | 90 | } |
| 90 | 91 | expect(limitValue(app.db, "front.pinned_max")).toBe(LIMITS["front.pinned_max"].default); |
| 91 | 92 | expect(limitChanges(app.db)).toEqual([]); |
| @@ -177,8 +178,8 @@ describe("limits", () => { | ||
| 177 | 178 | const listed = await api(adminToken, "GET", "/admin/limits"); |
| 178 | 179 | expect(listed.status).toBe(200); |
| 179 | 180 | expect(((await listed.json()) as { limits: unknown[] }).limits).toHaveLength(LIMIT_KEYS.length); |
| 180 | expect((await api(bobToken, "GET", "/admin/limits")).status).toBe(404); | |
| 181 | expect((await api(bobToken, "PUT", "/admin/limits/share.daily_max", { value: "9" })).status).toBe(404); | |
| 181 | expect((await api(bobToken, "GET", "/admin/limits")).status).toBe(403); | |
| 182 | expect((await api(bobToken, "PUT", "/admin/limits/share.daily_max", { value: "9" })).status).toBe(403); | |
| 182 | 183 | expect((await api(adminToken, "PUT", "/admin/limits/share.daily_max", { value: "0" })).status).toBe(422); |
| 183 | 184 | expect((await api(adminToken, "PUT", "/admin/limits/nothing.here", { value: "3" })).status).toBe(422); |
| 184 | 185 | expect(limitValue(app.db, "share.daily_max")).toBe(LIMITS["share.daily_max"].default); |
test/model-grant-pages.test.ts +2 −2
| @@ -107,7 +107,7 @@ describe("a model grant, from the account's page and the API", () => { | ||
| 107 | 107 | headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, |
| 108 | 108 | body: JSON.stringify({ usdPerMonth: usd }), |
| 109 | 109 | }); |
| 110 | expect((await post(bobTok.token, 5)).status).toBe(404); | |
| 110 | expect((await post(bobTok.token, 5)).status).toBe(403); | |
| 111 | 111 | expect((await post(tok.token, "5")).status).toBe(422); |
| 112 | 112 | expect((await post(tok.token, -1)).status).toBe(422); |
| 113 | 113 | expect((await post(tok.token, 7.5)).status).toBe(200); |
| @@ -174,7 +174,7 @@ describe("a model grant, from the account's page and the API", () => { | ||
| 174 | 174 | headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, |
| 175 | 175 | body: JSON.stringify(body), |
| 176 | 176 | }); |
| 177 | expect((await post(bobTok.token, { dailyUsd: 5, perRunUsd: 1 })).status).toBe(404); | |
| 177 | expect((await post(bobTok.token, { dailyUsd: 5, perRunUsd: 1 })).status).toBe(403); | |
| 178 | 178 | expect((await post(tok.token, { dailyUsd: "5", perRunUsd: 1 })).status).toBe(422); |
| 179 | 179 | expect((await post(tok.token, { dailyUsd: 0, perRunUsd: 1 })).status).toBe(422); |
| 180 | 180 | expect((await post(tok.token, { dailyUsd: 5, perRunUsd: 1 }, "nobody")).status).toBe(404); |
test/quotas.test.ts +2 −2
| @@ -148,8 +148,8 @@ describe("quotas", () => { | ||
| 148 | 148 | |
| 149 | 149 | test("a non-admin is refused", async () => { |
| 150 | 150 | const t = createToken(app.db, bob.id, "t", "admin").token; |
| 151 | expect((await api(t, "GET")).status).toBe(404); | |
| 152 | expect((await api(t, "PATCH", { quotas: { apps: 2 } })).status).toBe(404); | |
| 151 | expect((await api(t, "GET")).status).toBe(403); | |
| 152 | expect((await api(t, "PATCH", { quotas: { apps: 2 } })).status).toBe(403); | |
| 153 | 153 | expect(getQuotas(app.db).apps).toBeNull(); |
| 154 | 154 | }); |
| 155 | 155 |
test/releases-page.test.ts +7 −5
| @@ -106,22 +106,24 @@ describe("the releases page", () => { | ||
| 106 | 106 | expect(body).not.toContain(`/releases/${PRUNED}/rollback`); |
| 107 | 107 | }); |
| 108 | 108 | |
| 109 | test("a non-reader gets a 404 and no live line; a reader who cannot write sees the list with no button", async () => { | |
| 109 | test("a non-admin is refused with a 403 and gets no live line, even as a reader of the repository", async () => { | |
| 110 | 110 | const cookie = await login("bob", "another-good-passphrase"); |
| 111 | 111 | const get = (path: string) => fetch(`${servers.url}${path}`, { headers: { cookie } }); |
| 112 | expect((await get("/releases")).status).toBe(404); | |
| 112 | expect((await get("/releases")).status).toBe(403); | |
| 113 | 113 | const home = await (await get("/")).text(); |
| 114 | 114 | expect(home).not.toContain("live-release"); |
| 115 | 115 | expect(home).not.toContain("aaaaaaa"); |
| 116 | 116 | expect((await post(`/releases/${KEPT}/rollback`, cookie, servers.url)).status).toBe(403); |
| 117 | 117 | |
| 118 | 118 | app.db.query("UPDATE repo SET visibility = 'public' WHERE name = 'forge'").run(); |
| 119 | const body = await (await get("/releases")).text(); | |
| 120 | expect(body).toContain("bbbbbbb"); | |
| 119 | const refused = await get("/releases"); | |
| 120 | expect(refused.status).toBe(403); | |
| 121 | const body = await refused.text(); | |
| 122 | expect(body).not.toContain("bbbbbbb"); | |
| 121 | 123 | expect(body).not.toContain("/rollback"); |
| 122 | 124 | expect((await post(`/releases/${KEPT}/rollback`, cookie, servers.url)).status).toBe(403); |
| 123 | 125 | expect((await post(`/releases/${KEPT}/rollback`, "", servers.url)).status).toBe(403); |
| 124 | expect(await (await get("/")).text()).toContain("live-release"); | |
| 126 | expect(await (await get("/")).text()).not.toContain("live-release"); | |
| 125 | 127 | app.db.query("UPDATE repo SET visibility = 'private' WHERE name = 'forge'").run(); |
| 126 | 128 | }); |
| 127 | 129 |
test/roles.test.ts +1 −1
| @@ -152,7 +152,7 @@ describe("roles on /settings/people", () => { | ||
| 152 | 152 | headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, |
| 153 | 153 | body: JSON.stringify({ role, usdPerMonth: usd }), |
| 154 | 154 | }); |
| 155 | expect((await send(bobTok, "member")).status).toBe(404); | |
| 155 | expect((await send(bobTok, "member")).status).toBe(403); | |
| 156 | 156 | expect((await send(tok, "admin")).status).toBe(422); |
| 157 | 157 | expect((await send(tok, "member", -1)).status).toBe(422); |
| 158 | 158 | expect(roleNow("bob")).toBe("guest"); |
test/signup.test.ts +2 −2
| @@ -103,7 +103,7 @@ describe("signup setting", () => { | ||
| 103 | 103 | test("a non-admin is refused", async () => { |
| 104 | 104 | const t = createToken(app.db, bob.id, "t", "admin").token; |
| 105 | 105 | const res = await api(t, "PATCH", { signup: "open" }); |
| 106 | expect(res.status).toBe(404); | |
| 106 | expect(res.status).toBe(403); | |
| 107 | 107 | expect(getSignup(app.db)).toBe("closed"); |
| 108 | 108 | }); |
| 109 | 109 | |
| @@ -133,7 +133,7 @@ describe("signup setting", () => { | ||
| 133 | 133 | // Before: invite (set through MCP above). |
| 134 | 134 | expect((await post(aliceCookie, "closed", "https://evil.example")).status).toBe(403); |
| 135 | 135 | expect(getSignup(app.db)).toBe("invite"); |
| 136 | expect((await post(bobCookie, "closed", servers.url)).status).toBe(404); | |
| 136 | expect((await post(bobCookie, "closed", servers.url)).status).toBe(403); | |
| 137 | 137 | expect(getSignup(app.db)).toBe("invite"); |
| 138 | 138 | await post(aliceCookie, "nope", servers.url); |
| 139 | 139 | expect(getSignup(app.db)).toBe("invite"); |
test/tui-releases.test.ts +25 −10
| @@ -10,9 +10,10 @@ import { tmpdir } from "node:os"; | ||
| 10 | 10 | import { join } from "node:path"; |
| 11 | 11 | import { createRepo } from "../src/actions/repos.ts"; |
| 12 | 12 | import { createApp, type App } from "../src/app.ts"; |
| 13 | import { setMember } from "../src/core/roles.ts"; | |
| 13 | 14 | import { createUser, type User } from "../src/core/users.ts"; |
| 14 | 15 | import { releaseDir } from "../src/release/checkout.ts"; |
| 15 | import { mayRollBack } from "../src/release/gate.ts"; | |
| 16 | import { mayRead, mayRollBack } from "../src/release/gate.ts"; | |
| 16 | 17 | import { recordRelease } from "../src/release/record.ts"; |
| 17 | 18 | import { createForgeApp, forgeCommands, Session } from "../src/tui/forge.ts"; |
| 18 | 19 | import type { Ctx } from "../src/tui/app/app.ts"; |
| @@ -113,16 +114,11 @@ describe("the releases screen", () => { | ||
| 113 | 114 | expect(text).toContain("healthy since"); |
| 114 | 115 | }); |
| 115 | 116 | |
| 116 | test("a reader sees the releases but gets no rollback", async () => { | |
| 117 | test("a non-admin reader of FORGE_REPO sees nothing and gets no rollback", async () => { | |
| 117 | 118 | app.db.run("UPDATE repo SET visibility='public' WHERE name='forge'"); |
| 118 | expect(offered(carol)).toBe(true); | |
| 119 | const lines = await readReleases(new Session(app, identity(carol))); | |
| 120 | expect(lines.map((l) => l.release.sha.slice(0, 1))).toEqual(["b", "a"]); | |
| 121 | expect(lines.every((l) => !l.rollable)).toBe(true); | |
| 122 | // Enter on the kept release asks nothing. | |
| 123 | const text = await open(carol, "j\r"); | |
| 124 | expect(text).not.toContain("Swap to"); | |
| 125 | expect(text).toContain("bbbbbbb"); | |
| 119 | expect(offered(carol)).toBe(false); | |
| 120 | expect(await readReleases(new Session(app, identity(carol)))).toEqual([]); | |
| 121 | expect(await open(carol, "j\r")).not.toContain("bbbbbbb"); | |
| 126 | 122 | }); |
| 127 | 123 | |
| 128 | 124 | test("an agent is refused a rollback, whatever it can read", async () => { |
| @@ -142,4 +138,23 @@ describe("the releases screen", () => { | ||
| 142 | 138 | const told = await open(alice, "\r\x1b[D\r"); |
| 143 | 139 | expect(told).toContain("FORGE_SELF_SWAP is off"); |
| 144 | 140 | }); |
| 141 | ||
| 142 | test("a member who writes FORGE_REPO is refused releases and rollback (#2384), in the rule, the palette and the screen", async () => { | |
| 143 | setMember(app.db, carol.id, true, alice.id, new Date()); | |
| 144 | app.db.query("UPDATE repo SET owner_id = ?1 WHERE name = 'forge'").run(carol.id); | |
| 145 | app.config.forgeRepo = "carol/forge"; | |
| 146 | try { | |
| 147 | expect(mayRead(app, carol)).toBe(false); | |
| 148 | expect(mayRollBack(app, carol)).toBe(false); | |
| 149 | expect(mayRead(app, alice) && mayRollBack(app, alice)).toBe(true); | |
| 150 | expect(offered(carol)).toBe(false); | |
| 151 | expect(await readReleases(new Session(app, identity(carol)))).toEqual([]); | |
| 152 | const text = await open(carol, "\r"); | |
| 153 | expect(text).not.toContain("bbbbbbb"); | |
| 154 | expect(text).not.toContain("Swap to"); | |
| 155 | expect(offered(alice)).toBe(true); | |
| 156 | } finally { | |
| 157 | app.config.forgeRepo = "alice/forge"; | |
| 158 | } | |
| 159 | }); | |
| 145 | 160 | }); |
new above
offline
High: this is the only place the
administerrefusal is applied, and only HTTP reaches it. The terminal's Releases screen (src/tui/screens/releases.ts) goes throughrelease/gate.tsmayRead/mayRollBack, which never asklacks(..., "administer"). A member who writes FORGE_REPO can still roll back from SSH. Move the check intorelease/gate.tsso both doors share it.