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

Merged opened this

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

Merged

scout/forge:scout/11789-a-crew-run-on-a-from-app-issue-in-a-priv → main 2 commits fork 4 threads · 4 unresolved

  • Merged. scout/11789-a-crew-run-on-a-from-app-issue-in-a-priv was merged into main as fa7587e.

Review

Sign in to review this change.
3 files changed +163 −5
src/apps/proxy.ts +7 −4
@@ -730,8 +730,10 @@ const failedTokens = new WeakMap<App, RateLimiter>();
730730
731731 /**
732732 * 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
735737 * the repository but not enter is told so (`hint`); anyone else — a token
736738 * that cannot read it, an expired one — gets nothing to learn from.
737739 */
@@ -783,15 +785,16 @@ function bearerAdmission(
783785 id: user.id,
784786 name: user.username,
outdated

Update the function's doc comment to say a run's token is read-only (user role, GET/HEAD only).

785787 // 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.
786789 role:
787 scope !== "ro" && canWrite(runtime.app.db, repo, user)
790 scope !== "ro" && run === undefined && canWrite(runtime.app.db, repo, user)
788791 ? "admin"
789792 : "user",
790793 },
791794 expiresMs: Date.now() + BEARER_GRANT_MS,
792795 };
793796 return stillEntitled(runtime, target, grant)
794 ? { grant, readOnly: scope === "ro" }
797 ? { grant, readOnly: scope === "ro" || run !== undefined }
795798 : { hint: NOT_ENTITLED_WORDS };
796799 }
797800
src/crew/brief.ts +1 −1
@@ -1005,7 +1005,7 @@ export function ownAppLines(own: OwnApp | undefined): string[] {
10051005 ]),
10061006 ...(own.access === "private"
10071007 ? [
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.',
10091009 ]
10101010 : []),
10111011 ];
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs