Public: anyone can read this; the crew can change it.
A crew run on a from-app issue in a private app gets a read-only grant to that one app for the run, and the brief says the app is private #11821
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11789-a-crew-run-on-a-from-app-issue-in-a-priv → main 2 commits fork 4 threads · 4 unresolved
3 files
changed +163
−5
src/apps/proxy.ts +7 −4
| @@ -730,8 +730,10 @@ const failedTokens = new WeakMap<App, RateLimiter>(); | ||
| 730 | 730 | |
| 731 | 731 | /** |
| 732 | 732 | * A bearer token on a request to a guarded app, read the way the forge's |
| 733 | * own API reads it. It admits when its person may enter the app; a run's | |
| 734 | * token only on the repository it was minted for; a proposal or via token never. A person who can read | |
| 733 | * own API reads it. It admits when its person may enter the app. A run's | |
| 734 | * token admits read-only (a user, GET and HEAD only, never an admin) and only | |
| 735 | * on the repository or fork it was minted for, on any private app, from-app | |
| 736 | * or not; it dies with the run. A proposal or via token never admits. A person who can read | |
| 735 | 737 | * the repository but not enter is told so (`hint`); anyone else — a token |
| 736 | 738 | * that cannot read it, an expired one — gets nothing to learn from. |
| 737 | 739 | */ |
| @@ -783,15 +785,16 @@ function bearerAdmission( | ||
| 783 | 785 | id: user.id, |
| 784 | 786 | name: user.username, |
| 785 | 787 | // A read-only token is never an admin inside the app. |
| 788 | // A run's token is read-only here too: the run reads its app, never acts in it. | |
| 786 | 789 | role: |
| 787 | scope !== "ro" && canWrite(runtime.app.db, repo, user) | |
| 790 | scope !== "ro" && run === undefined && canWrite(runtime.app.db, repo, user) | |
| 788 | 791 | ? "admin" |
| 789 | 792 | : "user", |
| 790 | 793 | }, |
| 791 | 794 | expiresMs: Date.now() + BEARER_GRANT_MS, |
| 792 | 795 | }; |
| 793 | 796 | return stillEntitled(runtime, target, grant) |
| 794 | ? { grant, readOnly: scope === "ro" } | |
| 797 | ? { grant, readOnly: scope === "ro" || run !== undefined } | |
| 795 | 798 | : { hint: NOT_ENTITLED_WORDS }; |
| 796 | 799 | } |
| 797 | 800 |
src/crew/brief.ts +1 −1
| @@ -1005,7 +1005,7 @@ export function ownAppLines(own: OwnApp | undefined): string[] { | ||
| 1005 | 1005 | ]), |
| 1006 | 1006 | ...(own.access === "private" |
| 1007 | 1007 | ? [ |
| 1008 | '- This app is private: signed out, its door answers 404 by design, so a 404 from a plain `curl` is the door, not a fault in the app. Probe it as its owner does, with `-H "authorization: Bearer $FORGE_TOKEN"`, and judge what that answers.', | |
| 1008 | '- This app is private: signed out, its door answers 404 by design, so a 404 from a plain `curl` is the door, not a fault in the app. Probe it as its owner does, with `-H "authorization: Bearer $FORGE_TOKEN"`, and judge what that answers. That token is read-only here (GET and HEAD; any write is refused 403), reaches this app alone, and stops working when your run ends.', | |
| 1009 | 1009 | ] |
| 1010 | 1010 | : []), |
| 1011 | 1011 | ]; |
test/private_app_grant.test.ts added +155
| @@ -0,0 +1,155 @@ | ||
| 1 | /** | |
| 2 | * A crew run's token at a private app's door (#11789): it enters as a plain user, | |
outdated
Header is pasted from another test; describe only this file. |
||
| 3 | * reads with GET and HEAD only, reaches only the app of the repository or fork the | |
| 4 | * run was minted for, and stops working when the run ends. The brief says so. | |
| 5 | */ | |
| 6 | ||
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { newApp, updateAppSettings } from "../src/actions/apps.ts"; | |
| 9 | import { commitToBranch } from "../src/actions/repos.ts"; | |
| 10 | import type { App } from "../src/app.ts"; | |
| 11 | import type { AppRecord } from "../src/core/apps.ts"; | |
| 12 | import type { Repo } from "../src/core/repos.ts"; | |
| 13 | import { mintRunToken } from "../src/core/run_token.ts"; | |
| 14 | import { deleteToken } from "../src/core/tokens.ts"; | |
| 15 | import type { OwnApp } from "../src/crew/app_hosts.ts"; | |
| 16 | import { type User } from "../src/core/users.ts"; | |
| 17 | import { ownAppLines } from "../src/crew/brief.ts"; | |
| 18 | import { | |
| 19 | deploySettled, | |
| 20 | fetchAsHost, | |
| 21 | startAppsForge, | |
| 22 | stopAppsForge, | |
| 23 | type AppsForge, | |
| 24 | } from "./helpers/apps-forge.ts"; | |
| 25 | ||
| 26 | const SERVER_SUITES = [ | |
| 27 | "src/audit.test.ts", | |
| 28 | "src/openapi.test.ts", | |
| 29 | "src/security.test.ts", | |
| 30 | "src/site.test.ts", | |
| 31 | ]; | |
| 32 | ||
| 33 | describe("a run's read-only grant to a private app", () => { | |
| 34 | let forge: AppsForge; | |
| 35 | let app: App; | |
| 36 | let alice: User; | |
| 37 | ||
| 38 | let repo: Repo; | |
| 39 | let record: AppRecord; | |
| 40 | let open: { repo: Repo; record: AppRecord }; | |
| 41 | const host = () => `vault.${forge.runtime.options.domain}`; | |
| 42 | const get = (h: string, token?: string, accept = "application/json") => | |
| 43 | fetchAsHost(forge.servers, h, "/", { | |
| 44 | headers: { | |
| 45 | accept, | |
| 46 | ...(token === undefined ? {} : { authorization: `Bearer ${token}` }), | |
| 47 | }, | |
| 48 | }); | |
| 49 | ||
| 50 | beforeAll(async () => { | |
| 51 | forge = await startAppsForge(); | |
| 52 | ({ app, alice } = forge); | |
| 53 | const vault = await newApp(app, alice, { | |
| 54 | name: "vault", | |
| 55 | description: "Private", | |
| 56 | visibility: "private", | |
| 57 | }); | |
| 58 | if (!vault.ok) throw new Error(String(vault.error)); | |
| 59 | ({ repo, app: record } = vault.value); | |
| 60 | expect(record.access).toBe("private"); | |
| 61 | await deploySettled(app, repo.id, (r) => r.env === "production"); | |
| 62 | const committed = await commitToBranch(app, alice, repo, { | |
| 63 | message: "Echo the request", | |
| 64 | files: [ | |
| 65 | ...SERVER_SUITES.map((path) => ({ path, content: null })), | |
| 66 | { | |
| 67 | path: "src/server.test.ts", | |
| 68 | content: | |
| 69 | 'import { expect, test } from "bun:test";\ntest("passes", () => { expect(1).toBe(1); });\n', | |
| 70 | }, | |
| 71 | { | |
| 72 | path: "src/server.ts", | |
| 73 | content: `Bun.serve({ | |
| 74 | port: Number(process.env.PORT), | |
| 75 | hostname: "127.0.0.1", | |
| 76 | fetch(request) { | |
| 77 | return Response.json({ | |
| 78 | page: "secret page", | |
| 79 | authorization: request.headers.get("authorization"), | |
| 80 | identity: request.headers.get("x-forge-identity"), | |
| 81 | }); | |
| 82 | }, | |
| 83 | }); | |
| 84 | `, | |
| 85 | }, | |
| 86 | ], | |
| 87 | }); | |
| 88 | if (!committed.ok) throw new Error(JSON.stringify(committed.error)); | |
| 89 | const run = await deploySettled( | |
| 90 | app, | |
| 91 | repo.id, | |
| 92 | (r) => r.env === "production" && r.commitOid === committed.value.commitOid, | |
| 93 | ); | |
| 94 | if (run.state !== "ok") throw new Error(run.summary); | |
| 95 | // A public repository whose app is private: its readers may read the code, not enter. | |
| 96 | const second = await newApp(app, alice, { | |
| 97 | name: "lobby", | |
| 98 | description: "Public code", | |
| 99 | visibility: "public", | |
| 100 | }); | |
| 101 | if (!second.ok) throw new Error(String(second.error)); | |
| 102 | open = { repo: second.value.repo, record: second.value.app }; | |
| 103 | const closed = updateAppSettings(app, alice, open.repo, { access: "private" }); | |
| 104 | if (!closed.ok) throw new Error(String(closed.error)); | |
| 105 | }, 120_000); | |
| 106 | ||
Duplicates the mutation loop below; remove. Also check the updateAppSettings result above. |
||
| 107 | afterAll(async () => { | |
| 108 | await forge.runtime.stop(); | |
| 109 | await stopAppsForge(forge); | |
| 110 | }); | |
| 111 | ||
| 112 | const run = (id: string, repoId: string, expires = Date.now() + 3_600_000) => | |
| 113 | mintRunToken(app.db, alice.id, id, { repoId, forkId: repoId }, new Date(expires).toISOString()); | |
| 114 | ||
| 115 | test("a run's token opens the private app's pages, as a user", async () => { | |
| 116 | const res = await get(host(), run("g1", repo.id).token); | |
| 117 | expect(res.status).toBe(200); | |
| 118 | const heard = (await res.json()) as { page: string; identity: string }; | |
| 119 | expect(heard.page).toBe("secret page"); | |
| 120 | expect(heard.identity.split("|")[2]).toBe("user"); | |
| 121 | }); | |
| 122 | ||
| 123 | test("it refuses every mutation", async () => { | |
| 124 | const t = run("g2", repo.id).token; | |
| 125 | for (const method of ["POST", "PUT", "PATCH", "DELETE"]) { | |
| 126 | const res = await fetchAsHost(forge.servers, host(), "/", { | |
| 127 | method, | |
| 128 | headers: { authorization: `Bearer ${t}`, accept: "application/json" }, | |
| 129 | body: method === "DELETE" ? null : "x", | |
| 130 | }); | |
| 131 | expect(res.status).toBe(403); | |
| 132 | } | |
| 133 | }); | |
| 134 | ||
| 135 | test("it is refused for another app", async () => { | |
| 136 | const t = run("g3", open.repo.id).token; | |
| 137 | expect((await get(host(), t)).status).toBe(404); | |
| 138 | }); | |
| 139 | ||
| 140 | test("it is refused after the run ends", async () => { | |
| 141 | const minted = run("g4", repo.id); | |
| 142 | expect((await get(host(), minted.token)).status).toBe(200); | |
| 143 | deleteToken(app.db, alice.id, minted.id); | |
| 144 | expect((await get(host(), minted.token)).status).toBe(404); | |
| 145 | expect((await get(host(), run("g5", repo.id, Date.now() - 1000).token)).status).toBe(404); | |
| 146 | }); | |
| 147 | ||
| 148 | test("the brief says the app is private and the token read-only", () => { | |
| 149 | const own: OwnApp = { production: "http://vault.apps.test/", previews: [], access: "private" }; | |
| 150 | const lines = ownAppLines(own).join("\n"); | |
| 151 | expect(lines).toContain("This app is private"); | |
| 152 | expect(lines).toContain("read-only"); | |
| 153 | expect(lines).toContain("stops working when your run ends"); | |
| 154 | }); | |
| 155 | }); |
new above
offline
Update the function's doc comment to say a run's token is read-only (user role, GET/HEAD only).