Public: anyone can read this; the crew can change it.

A site admin grants or revokes an app's private destinations on its settings page and through the API #3534

Merged opened this

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

Merged

scout/forge:scout/3433-a-site-admin-grants-or-revokes-an-app-s → main 3 commits fork 5 threads · 5 unresolved

  • Merged. scout/3433-a-site-admin-grants-or-revokes-an-app-s was merged into main as 33916ad.

Review

Sign in to review this change.
8 files changed +660 −5
docs/APPS.md +9 −3
@@ -291,9 +291,15 @@ own pod and service ranges (`FORGE_NEVER_GRANT_CIDRS` adds more) are never
291291 grantable. Until granted, the refusal says to add it and ask a site admin.
292292 A grant needs the exact `host:port` in `egress`: a wildcard entry such as
293293 `*.example.com:80` never opens a granted name. Grants are stored in the
294 `egress_grant` table and read at every app start. There is not yet a page or
295 terminal command to make one (#3469), so for now they are written through
296 `grantEgress`.
294 `egress_grant` table and read at every app start. A site admin grants
295 and revokes on the app's settings page ("Private destinations asked for",
296 which lists the exact `host:port` entries forge.json names and who granted
297 each) or with `POST`/`DELETE /api/v1/repos/:owner/:name/egress-grants`
298 and a `{ "destination": "100.89.95.68:8101" }` body; `GET` lists them. Anyone
299 else who administers the repository sees the list with no buttons. A
300 destination forge.json does not name is refused, and each change is an
301 `app.egress.granted` or `app.egress.revoked` event with who and when. There is
302 not yet a terminal command (#3469).
297303
298304 A release that turns egress on must not break an app whose writers have
299305 not declared what it uses yet, so the operator can start it reported:
src/actions/egress_grants.ts added +203
@@ -0,0 +1,203 @@
1 /**
2 * An app's private destinations (#3433): the ones its forge.json `egress`
3 * names exactly as `host:port`, and a site admin's grant or revoke of each.
4 * Seeing the list takes admin over the repository, like every app setting;
5 * changing it takes a site admin. Every change is an event, so who and when
6 * outlive the grant itself.
7 */
8
9 import type { App } from "../app.ts";
10 import { parseManifest } from "../apps/manifest.ts";
11 import { readFileAt } from "../apps/checkout.ts";
12 import {
13 grantEgress,
14 listEgressGrants,
15 parseDestination,
16 revokeEgress,
17 } from "../core/egress_grants.ts";
18 import { listEvents } from "../core/events.ts";
19 import type { Repo } from "../core/repos.ts";
20 import { getUserById, type User } from "../core/users.ts";
21 import { resolveOid } from "../git/merge.ts";
22 import { repoDir } from "../git/repos.ts";
23 import { err, ok, type Result } from "../kernel/result.ts";
24 import { requireRepoAdmin } from "./access.ts";
25
26 /** One destination as a page or the API shows it. */
27 export interface EgressRow {
28 readonly destination: string;
29 /** Whether forge.json still asks for it; a grant it no longer asks for opens nothing. */
30 readonly asked: boolean;
31 readonly granted: boolean;
32 readonly grantedBy: string | null;
33 readonly grantedAt: string | null;
34 }
35
36 /** A grant or revoke, as it happened. */
37 export interface EgressChange {
38 readonly kind: "granted" | "revoked";
39 readonly destination: string;
40 readonly by: string;
41 readonly at: string;
42 }
43
44 export interface EgressView {
45 readonly rows: EgressRow[];
46 readonly changes: EgressChange[];
47 /** Whether this viewer may grant and revoke (a site admin). */
48 readonly canChange: boolean;
49 }
50
51 export type EgressError =
52 | "not_found"
53 | "forbidden"
54 | "invalid_destination"
55 | "not_asked"
56 | "never_grantable"
57 | "no_such_grant";
58
59 const CHANGES_SHOWN = 8;
60
61 const siteAdmin = (actor: User): boolean =>
62 actor.isAdmin && actor.kind !== "agent";
63
64 /** The exact `host:port` entries forge.json asks for at the default branch's tip. */
65 export async function askedDestinations(
66 app: App,
67 repo: Repo,
68 ): Promise<string[]> {
69 const dir = repoDir(app.reposRoot, repo.ownerName, repo.name);
70 const tip = await resolveOid(dir, `refs/heads/${repo.defaultBranch}`);
71 if (tip === undefined) return [];
72 const [forgeJson, packageJson] = await Promise.all([
73 readFileAt(dir, tip, "forge.json"),
74 readFileAt(dir, tip, "package.json"),
75 ]);
76 const manifest = parseManifest(forgeJson, packageJson);
77 if (!manifest.ok) return [];
78 const out: string[] = [];
79 for (const entry of manifest.value.egress ?? []) {
80 const parsed = entry.startsWith("*.") ? undefined : parseDestination(entry);
81 if (parsed?.ok && !out.includes(parsed.value.text))
82 out.push(parsed.value.text);
83 }
84 return out;
85 }
86
87 function nameOf(app: App, userId: string): string {
88 const user = getUserById(app.db, userId);
89 return user === undefined ? "someone" : user.username;
90 }
91
92 function changesOf(app: App, repo: Repo): EgressChange[] {
93 const read = (kind: "granted" | "revoked"): EgressChange[] =>
94 listEvents(app.db, {
95 repoId: repo.id,
96 kind: `app.egress.${kind}`,
97 limit: CHANGES_SHOWN,
98 }).map((e) => ({
99 kind,
100 destination: String(e.payload["destination"] ?? ""),
101 by: e.actorId === null ? "someone" : nameOf(app, e.actorId),
102 at: e.createdAt,
103 }));
104 return [...read("granted"), ...read("revoked")]
105 .sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0))
106 .slice(0, CHANGES_SHOWN);
107 }
108
109 /** What the app asks for, what is granted, and what was done about it. */
110 export async function egressView(
111 app: App,
112 actor: User | undefined,
113 repo: Repo,
114 ): Promise<Result<EgressView, "not_found">> {
115 const admin = requireRepoAdmin(app, actor, repo);
116 if (!admin.ok) return err("not_found");
117 const asked = await askedDestinations(app, repo);
118 const granted = new Map(
119 listEgressGrants(app.db, repo.id).map((g) => [g.destination, g]),
120 );
121 const row = (destination: string): EgressRow => {
122 const g = granted.get(destination);
123 return {
124 destination,
125 asked: asked.includes(destination),
126 granted: g !== undefined,
127 grantedBy: g === undefined ? null : nameOf(app, g.grantedBy),
128 grantedAt: g?.grantedAt ?? null,
129 };
130 };
131 const rows = [...new Set([...asked, ...granted.keys()])]
132 .sort()
133 .map(row);
134 return ok({
135 rows,
136 changes: changesOf(app, repo),
137 canChange: siteAdmin(admin.value),
138 });
139 }
140
141 /** The site admin who may change grants on `repo`. */
142 function changer(
143 app: App,
144 actor: User | undefined,
145 repo: Repo,
146 ): Result<User, "not_found" | "forbidden"> {
147 const admin = requireRepoAdmin(app, actor, repo);
148 if (!admin.ok) return err("not_found");
149 return siteAdmin(admin.value) ? ok(admin.value) : err("forbidden");
150 }
151
152 /** Grant one destination forge.json names exactly. */
153 export async function grantAppEgress(
154 app: App,
155 actor: User | undefined,
156 repo: Repo,
157 destination: string,
158 ): Promise<Result<EgressRow, EgressError>> {
159 const admin = changer(app, actor, repo);
160 if (!admin.ok) return admin;
161 const parsed = parseDestination(destination.trim());
162 if (!parsed.ok) return err("invalid_destination");
163 const text = parsed.value.text;
164 if (!(await askedDestinations(app, repo)).includes(text))
165 return err("not_asked");
166 const granted = grantEgress(app.db, repo.id, text, admin.value);
167 if (!granted.ok) return err("never_grantable");
168 app.bus.append({
169 kind: "app.egress.granted",
170 repoId: repo.id,
171 actorId: admin.value.id,
172 payload: { destination: text },
173 });
174 return ok({
175 destination: text,
176 asked: true,
177 granted: true,
178 grantedBy: nameOf(app, granted.value.grantedBy),
179 grantedAt: granted.value.grantedAt,
180 });
181 }
182
183 /** Take a grant back, whether or not forge.json still asks for it; the canonical destination. */
184 export function revokeAppEgress(
185 app: App,
186 actor: User | undefined,
187 repo: Repo,
188 destination: string,
189 ): Result<string, EgressError> {
190 const admin = changer(app, actor, repo);
191 if (!admin.ok) return admin;
192 const parsed = parseDestination(destination.trim());
193 if (!parsed.ok) return err("invalid_destination");
194 if (!revokeEgress(app.db, repo.id, parsed.value.text, admin.value))
195 return err("no_such_grant");
196 app.bus.append({
197 kind: "app.egress.revoked",
198 repoId: repo.id,
199 actorId: admin.value.id,
200 payload: { destination: parsed.value.text },
201 });
202 return ok(parsed.value.text);
203 }
src/design/activity.ts +7
@@ -58,6 +58,13 @@ export function eventVerb(event: ForgeEvent): string {
5858 ? `set ${which} in the app's environment`
5959 : `removed ${which} from the app's environment`;
6060 }
61 if (kind === "app.egress.granted" || kind === "app.egress.revoked") {

New wording with no test. Add an assertion that eventVerb gives "granted the app 100.89.95.68:8101" and the revoke wording.

62 const destination = payload["destination"];
63 const which = typeof destination === "string" ? destination : "a destination";
64 return kind === "app.egress.granted"
65 ? `granted the app ${which}`
66 : `revoked the app's grant on ${which}`;
67 }
6168 if (kind.startsWith("app.domain.")) {
6269 const host = payload["host"];
6370 const which = typeof host === "string" ? host : "a domain";
src/http/api.ts +50
@@ -272,6 +272,11 @@ import {
272272 listAppDomains,
273273 removeAppDomain,
274274 } from "../actions/domains.ts";
275 import {
276 egressView,
277 grantAppEgress,
278 revokeAppEgress,
279 } from "../actions/egress_grants.ts";
275280 import {
276281 addPromptSource,
277282 listPromptSources,
@@ -5254,6 +5259,51 @@ export function apiRoutes(router: Router<Handler>): void {
52545259 },
52555260 );
52565261
5262 // A site admin's grant of one private destination forge.json asks for
5263 // (#3433). Reading takes admin over the repository; changing takes a site
5264 // admin. The destination is the exact `host:port` forge.json names.
5265 router.add("GET", "/api/v1/repos/:owner/:name/egress-grants", async (ctx) => {
5266 const denied = requireScope(ctx, "admin");
5267 if (denied) return denied;
5268 const repo = loadRepo(ctx);
5269 if (!repo) return notFound();
5270 const view = await egressView(ctx.app, ctx.auth.user, repo);
5271 if (!view.ok) return respondAccess(view.error, 422);
5272 return json({ items: view.value.rows, changes: view.value.changes, next_cursor: null });
5273 });
5274
5275 router.add("POST", "/api/v1/repos/:owner/:name/egress-grants", async (ctx) => {
5276 const denied = requireScope(ctx, "admin");
5277 if (denied) return denied;
5278 const repo = loadRepo(ctx);
5279 if (!repo) return notFound();
5280 const input = await body(ctx.request);
5281 const granted = await grantAppEgress(
5282 ctx.app,
5283 ctx.auth.user,
5284 repo,
5285 str(input.destination),
5286 );
5287 if (!granted.ok) return respondAccess(granted.error, 422);
5288 return json(granted.value, 201);
5289 });
5290
5291 router.add("DELETE", "/api/v1/repos/:owner/:name/egress-grants", async (ctx) => {
5292 const denied = requireScope(ctx, "admin");
5293 if (denied) return denied;
5294 const repo = loadRepo(ctx);
5295 if (!repo) return notFound();
5296 const input = await body(ctx.request);
5297 const revoked = revokeAppEgress(
5298 ctx.app,
5299 ctx.auth.user,
5300 repo,
5301 str(input.destination),
5302 );
5303 if (!revoked.ok) return respondAccess(revoked.error, 422);
5304 return json({}, 200);
5305 });
5306
52575307 // Where the crew's next tell comes from when nobody is typing
52585308 // (docs/APPS.md, "Prompts from Instagram"). Admin scope and admin over
52595309 // the repository; the token goes in once and never comes back out.
src/http/server.ts +12
@@ -355,6 +355,8 @@ import {
355355 appDomainDelete,
356356 appEnvDelete,
357357 appEnvSet,
358 appEgressGrant,
359 appEgressRevoke,
358360 appSettingsPage,
359361 appSettingsUpdate,
360362 autonomySettingsPage,
@@ -1361,6 +1363,16 @@ export function buildRouter(): Router<Handler> {
13611363 "/:owner/:name/settings/app/domains/:host/delete",
13621364 appDomainDelete,
13631365 );
1366 router.add(
1367 "POST",
1368 "/:owner/:name/settings/app/egress/:destination/grant",
1369 appEgressGrant,
1370 );
1371 router.add(
1372 "POST",
1373 "/:owner/:name/settings/app/egress/:destination/revoke",
1374 appEgressRevoke,
1375 );
13641376 router.add("GET", "/:owner/:name/settings/prompts", promptsSettingsPage);
13651377 router.add("POST", "/:owner/:name/settings/prompts", promptSourceAdd);
13661378 router.add("POST", "/:owner/:name/settings/prompts/:id/run", promptSourceRun);
src/web/pages/search.ts +9
@@ -524,6 +524,15 @@ const DESTRUCTIVE: {
524524 phrase: "",
525525 }),
526526 },
527 {
528 pattern: /^\/([^/]+)\/([^/]+)\/settings\/app\/egress\/([^/]+)\/revoke$/,
529 describe: (m) => ({
530 verb: "Revoke",
531 title: `Revoke the app's grant on ${decodeURIComponent(m[3] ?? "")}?`,
532 detail: `${m[1]}/${m[2]}'s cells can no longer reach that destination, from their next start. A site admin can grant it again while forge.json still asks for it.`,
533 phrase: "",
534 }),
535 },
527536 {
528537 pattern: /^\/([^/]+)\/([^/]+)\/settings\/proposal-tokens\/([^/]+)\/delete$/,
529538 describe: (m) => ({
src/web/pages/settings.ts +152 −2
@@ -23,6 +23,12 @@ import {
2323 removeAppDomain,
2424 type AppDomainView,
2525 } from "../../actions/domains.ts";
26 import {
27 egressView,
28 grantAppEgress,
29 revokeAppEgress,
30 type EgressView,
31 } from "../../actions/egress_grants.ts";
2632 import {
2733 addPromptSource,
2834 listPromptSources,
@@ -103,6 +109,7 @@ import {
103109 selectField,
104110 time,
105111 timeAgo,
112 tlEvent,
106113 } from "../ui.ts";
107114 import { cutWaveFor } from "../../actions/cut_wave.ts";
108115 import { proposalTokensSection } from "./proposal-tokens.ts";
@@ -491,6 +498,7 @@ function appSettingsView(
491498 record: AppRecord | undefined,
492499 values: AppValues,
493500 error?: string,
501 egress?: EgressView,
494502 ): Html {
495503 const base = repoBase(repo);
496504 const runtime = appsRuntime(ctx.app);
@@ -598,6 +606,7 @@ function appSettingsView(
598606 <div class="mt">
599607 ${appDomainsView(base, domains.ok ? domains.value : undefined)}
600608 </div>
609 ${when(egress !== undefined && egress.rows.length > 0, () => html`<div class="mt">${appEgressView(base, egress!)}</div>`)}
601610 <div class="mt">
602611 ${card(
603612 html`<p>
@@ -893,12 +902,23 @@ const appValues = (repo: Repo, record: AppRecord | undefined): AppValues => ({
893902 });
894903
895904 // GET /:owner/:name/settings/app
896 export function appSettingsPage(ctx: Ctx): Response {
905 export async function appSettingsPage(ctx: Ctx): Promise<Response> {
897906 const repo = loadAdminRepo(ctx);
898907 if (!repo) return repoNotFound(ctx);
899908 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
900909 const record = getAppByRepo(ctx.app.db, repo.id);
901 const view = appSettingsView(ctx, repo, record, appValues(repo, record));
910 const egress =
911 record === undefined
912 ? undefined
913 : await egressView(ctx.app, ctx.auth.user, repo);
914 const view = appSettingsView(
915 ctx,
916 repo,
917 record,
918 appValues(repo, record),
919 undefined,
920 egress?.ok ? egress.value : undefined,
921 );
902922 const rendering = performance.now();
903923 const response = settingsPageResponse(ctx, repo, "app", "App", view);
904924 notePhase(ctx.request, "render", performance.now() - rendering);
@@ -1157,6 +1177,136 @@ export async function appDomainDelete(ctx: Ctx): Promise<Response> {
11571177 );
11581178 }
11591179
1180 /**
1181 * The private destinations forge.json asks for (#3433), each with who
1182 * granted it; Grant and Revoke only for a site admin. The last few changes
1183 * follow, so a revoke is still on the page after the grant is gone.
1184 */
1185 function appEgressView(base: string, egress: EgressView): Html {
1186 const at = (destination: string) =>
1187 `${base}/settings/app/egress/${encodeURIComponent(destination)}`;
1188 const rows = egress.rows.map(
1189 (r) =>
1190 html`<li>
1191 <div class="row">
1192 <span class="row-lead">${icon("globe")}</span>
1193 <span class="row-main">
1194 <span class="row-title"
1195 ><span class="mono">${r.destination}</span> ${r.granted
1196 ? badge("granted", { tone: "success" })
1197 : badge("not granted", { tone: "attn" })}
1198 ${r.asked ? null : badge("no longer asked")}</span
1199 >
1200 <span class="row-meta"
1201 >${r.granted && r.grantedAt !== null
1202 ? html`Granted by ${r.grantedBy ?? "someone"}
1203 ${time(r.grantedAt)}`
1204 : "Nothing reaches it until a site admin grants it."}</span
1205 >
1206 </span>
1207 ${when(
1208 egress.canChange,
1209 () =>
1210 html`<span class="row-trail">
1211 ${r.granted
1212 ? confirmAction({
outdated

High (design): a destructive action posted directly. DESIGN.md §7 requires /-/confirm. Use confirmAction({ action: ${at(r.destination)}/revoke, verb: "Revoke", size: "sm" }) and add the matching route to the confirm table in src/web/pages/search.ts (domains is the model, at about line 519).

1213 action: `${at(r.destination)}/revoke`,
1214 verb: "Revoke",
1215 size: "sm",
outdated

A filled primary button on every row, on a page that already has its own primary. DESIGN.md allows one per page. Use the default variant.

1216 })
1217 : html`<form method="post" action="${at(r.destination)}/grant">
1218 ${btn("Grant", { size: "sm" })}
1219 </form>`}
1220 </span>`,
1221 )}
1222 </div>
1223 </li>`,
1224 );
1225 const changes = egress.changes.map(
1226 (c) =>
outdated

Hand-built history lines inside a .list that the design system fills with .rows. Use the named timeline item instead (tlEvent(), DESIGN.md §5, "one dim line with a glyph") inside a .timeline, or proper .rows with the time in .row-meta, so the history matches the rest of the forge.

1227 tlEvent({
1228 icon: "globe",
1229 body: html`<span class="muted small"
1230 >${c.by} ${c.kind} <span class="mono">${c.destination}</span>
1231 ${time(c.at)}</span
1232 >`,
1233 }),
1234 );
1235 return card(
1236 html`<div class="card-body">
1237 <p class="muted small">
1238 Each is an exact address and port forge.json asks for. A grant opens
1239 that one destination for the app's cells and nothing else.${egress.canChange
1240 ? null
1241 : " Only a site admin grants or revokes."}
1242 </p>
1243 </div>
1244 <ul class="list">
1245 ${rows}
1246 </ul>
1247 ${when(
1248 changes.length > 0,
1249 () =>
1250 html`<div class="card-body">
1251 <div class="timeline">${changes}</div>
1252 </div>`,
1253 )}`,
1254 {
1255 head: html`${icon("globe")}
1256 <h2>Private destinations asked for</h2>`,
1257 },
1258 );
1259 }
1260
1261 const EGRESS_ERRORS: Record<string, string> = {
1262 forbidden: "Only a site admin grants or revokes a private destination.",
1263 invalid_destination: "That is not an address and port.",
1264 not_asked: "forge.json does not ask for that destination.",
1265 never_grantable:
1266 "That address can never be granted: it is this machine, link-local or the forge's own cluster.",
1267 no_such_grant: "There was no grant for that destination.",
1268 };
1269
1270 // POST /:owner/:name/settings/app/egress/:destination/grant
1271 export async function appEgressGrant(ctx: Ctx): Promise<Response> {
1272 const gate = await adminForm(ctx);
1273 if (gate instanceof Response) return gate;
1274 const { repo } = gate;
1275 const destination = ctx.params.destination ?? "";
1276 const granted = await grantAppEgress(ctx.app, ctx.auth.user, repo, destination);
1277 if (!granted.ok && granted.error === "not_found") return repoNotFound(ctx);
1278 return flashTo(
1279 ctx,
1280 `${repoBase(repo)}/settings/app`,
1281 granted.ok
1282 ? { message: `${granted.value.destination} is granted.` }
1283 : {
1284 tone: "danger",
1285 message: EGRESS_ERRORS[granted.error] ?? "That could not be granted.",
1286 },
1287 );
1288 }
1289
1290 // POST /:owner/:name/settings/app/egress/:destination/revoke
1291 export async function appEgressRevoke(ctx: Ctx): Promise<Response> {
1292 const gate = await adminForm(ctx);
1293 if (gate instanceof Response) return gate;
1294 const { repo } = gate;
1295 const destination = ctx.params.destination ?? "";
1296 const revoked = revokeAppEgress(ctx.app, ctx.auth.user, repo, destination);
1297 if (!revoked.ok && revoked.error === "not_found") return repoNotFound(ctx);
1298 return flashTo(
outdated

This echoes the raw path parameter (it may be upper-case or padded with spaces). Say the canonical host:port the revoke actually removed: return it from revokeAppEgress as the grant path does.

1299 ctx,
1300 `${repoBase(repo)}/settings/app`,
1301 revoked.ok
1302 ? { message: `${revoked.value} is revoked.` }
1303 : {
1304 tone: "danger",
1305 message: EGRESS_ERRORS[revoked.error] ?? "That could not be revoked.",
1306 },
1307 );
1308 }
1309
11601310 /** What a change to the environment sets in motion — when anything serves. */
11611311 function redeploying(ctx: Ctx, how: string): string {
11621312 return appsRuntime(ctx.app) === undefined
test/app-egress-grants.test.ts added +218
@@ -0,0 +1,218 @@
1 /**
2 * A site admin grants or revokes the private destinations an app's forge.json
3 * asks for (#3433), on its settings page and through the API: a non-admin is
4 * refused, a grant shows with who and when, a revoke removes it and is still
5 * on the page, and a destination forge.json does not name is refused.
6 */
7
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createApp, type App } from "../src/app.ts";
13 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
14 import {
15 egressView,
16 grantAppEgress,
17 revokeAppEgress,
18 } from "../src/actions/egress_grants.ts";
19 import { createApp as insertApp } from "../src/core/apps.ts";
20 import { listEgressGrants } from "../src/core/egress_grants.ts";
21 import { eventVerb } from "../src/design/activity.ts";
22 import { listEvents } from "../src/core/events.ts";
23 import type { Repo } from "../src/core/repos.ts";
24 import { createToken } from "../src/core/tokens.ts";
25 import { createUser, type User } from "../src/core/users.ts";
26 import { serve, type Servers } from "../src/http/server.ts";
27 import { quietLogs } from "./helpers/globals.ts";
28 import { promote } from "./helpers/members.ts";
29
30 quietLogs();
31
32 const ARM = "100.89.95.68:8101";
33 const CAMERA = "100.89.95.68:8102";
34 const FORGE_JSON = JSON.stringify({
35 app: { run: "bun run src/server.ts" },
36 egress: [ARM, CAMERA, "api.example.com", "*.example.org"],
37 });
38
39 let dataDir: string;
40 let app: App;
41 let servers: Servers;
42 let ada: User; // site admin
43 let bob: User; // owns nothing here, is not a site admin
44 let cy: User; // owns the repository but is no site admin
45 let repo: Repo;
46 let adaCookie = "";
47 let cyCookie = "";
48 let adaToken = "";
49 let bobToken = "";
50 let cyToken = "";
51
52 const ROUTE = "/api/v1/repos/cy/arm/egress-grants";
53 const api = (method: string, token: string, destination?: string) =>
54 fetch(`${servers.url}${ROUTE}`, {
55 method,
56 headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
57 ...(destination === undefined ? {} : { body: JSON.stringify({ destination }) }),
58 });
59 const web = (cookie: string, path: string, origin = servers.url) =>
60 fetch(`${servers.url}${path}`, {
61 method: path.endsWith("/grant") || path.endsWith("/revoke") ? "POST" : "GET",
62 headers: { cookie, origin },
63 redirect: "manual",
64 });
65 const signIn = async (username: string): Promise<string> => {
66 const response = await fetch(`${servers.url}/signin`, {
67 method: "POST",
68 body: new URLSearchParams({ username, password: "a-good-passphrase" }),
69 redirect: "manual",
70 });
71 return `${(response.headers.get("set-cookie") ?? "").split(";")[0]!}; forge_mode=dev`;
72 };
73 const words = (html: string) => html.replace(/\s+/g, " ");
74
75 beforeAll(async () => {
76 dataDir = await mkdtemp(join(tmpdir(), "forge-egress-grants-"));
77 app = createApp({ dataDir, port: 0, host: "forge.test" });
78 servers = await serve(app);
79 const make = async (username: string, adminIfFirst = false): Promise<User> => {
80 const made = await createUser(app.db, {
81 origin: "seed",
82 trusted: true,
83 username,
84 email: `${username}@forge.local`,
85 password: "a-good-passphrase",
86 adminIfFirst,
87 });
88 if (!made.ok) throw new Error(made.error);
89 return made.value;
90 };
91 ada = await make("ada", true);
92 bob = await make("bob");
93 cy = await make("cy");
94 promote(app.db, cy);
95 const made = await createRepo(app, cy, { name: "arm", visibility: "public", description: "" });
96 if (!made.ok) throw new Error(String(made.error));
97 repo = made.value;
98 const committed = await commitToBranch(app, cy, repo, {
99 message: "Ask for the arm",
100 files: [{ path: "forge.json", content: FORGE_JSON }],
101 });
102 if (!committed.ok) throw new Error(JSON.stringify(committed.error));
103 const row = insertApp(app.db, { repoId: repo.id, slug: "arm", access: "public" });
104 if (!row.ok) throw new Error(row.error);
105 adaCookie = await signIn("ada");
106 cyCookie = await signIn("cy");
107 adaToken = createToken(app.db, ada.id, "admin", "admin").token;
108 bobToken = createToken(app.db, bob.id, "admin", "admin").token;
109 cyToken = createToken(app.db, cy.id, "admin", "admin").token;
110 }, 60_000);
111
112 afterAll(async () => {
113 servers?.stop();
114 app?.close();
115 if (dataDir) await rm(dataDir, { recursive: true, force: true });
116 });
117
118 describe("private destinations asked for", () => {
119 test("only the exact host:port entries are listed, none granted yet", async () => {
120 const view = await egressView(app, ada, repo);
121 expect(view.ok && view.value.rows.map((r) => [r.destination, r.granted])).toEqual([
122 [ARM, false],
123 [CAMERA, false],
124 ]);
125 });
126
127 test("a non-admin is refused, and the grants stay empty", async () => {
128 // The repository's owner is no site admin: told so, nothing written.
129 const viaAction = await grantAppEgress(app, cy, repo, ARM);
130 expect(!viaAction.ok && viaAction.error).toBe("forbidden");
131 expect((await api("POST", cyToken, ARM)).status).toBe(403);
132 // A stranger to the repository learns nothing of it.
133 expect((await api("POST", bobToken, ARM)).status).toBe(404);
134 expect((await api("GET", bobToken)).status).toBe(404);
135 const viaPage = await web(cyCookie, `/cy/arm/settings/app/egress/${encodeURIComponent(ARM)}/grant`);
136 expect(viaPage.status).toBe(303);
137 expect(listEgressGrants(app.db, repo.id)).toEqual([]);
138 const revoked = revokeAppEgress(app, cy, repo, ARM);
139 expect(!revoked.ok && revoked.error).toBe("forbidden");
140 });
141
142 test("a destination forge.json does not name is refused", async () => {
143 for (const bad of [
144 "100.89.95.68:22", // same host, another port
145 "100.89.95.99:8101",
146 "api.example.com:443", // forge.json names it without a port
147 "100.89.95.0/24",
148 "*.example.org:80",
149 "169.254.169.254:80",
150 "",
151 "nonsense",
152 ]) {
153 const response = await api("POST", adaToken, bad);
154 expect(`${bad}: ${response.status}`).toBe(`${bad}: 422`);
155 }
156 expect(listEgressGrants(app.db, repo.id)).toEqual([]);
157 });
158
159 test("an admin grants and the list and the page show it, with who and when", async () => {
160 const response = await api("POST", adaToken, ` ${ARM.toUpperCase()} `);
161 expect(response.status).toBe(201);
162 expect(await response.json()).toMatchObject({ destination: ARM, granted: true, grantedBy: "ada" });
163 expect(listEgressGrants(app.db, repo.id).map((g) => g.destination)).toEqual([ARM]);
164
165 const listed = (await (await api("GET", adaToken)).json()) as {
166 items: { destination: string; granted: boolean; grantedBy: string | null; grantedAt: string | null }[];
167 };
168 const arm = listed.items.find((i) => i.destination === ARM)!;
169 expect([arm.granted, arm.grantedBy]).toEqual([true, "ada"]);
170 expect(arm.grantedAt).not.toBeNull();
171 expect(listed.items.find((i) => i.destination === CAMERA)!.granted).toBe(false);
172
173 const adaPage = words(await (await web(adaCookie, "/cy/arm/settings/app")).text());
174 expect(adaPage).toContain("Private destinations asked for");
175 expect(adaPage).toContain(`/settings/app/egress/${encodeURIComponent(CAMERA)}/grant`);
176 const revokeTo = `/cy/arm/settings/app/egress/${encodeURIComponent(ARM)}/revoke`;
177 expect(adaPage).toContain(`/-/confirm?to=${encodeURIComponent(revokeTo)}`);
178 const confirm = await web(adaCookie, `/-/confirm?to=${encodeURIComponent(revokeTo)}`);
179 expect(confirm.status).toBe(200);
180 expect(words(await confirm.text())).toContain("Revoke the app&#39;s grant on 100.89.95.68:8101");
181 expect(adaPage).toContain("Granted by ada");
182 expect(adaPage).toContain("ada granted");
183 // Someone else who administers the repository sees who granted, and no buttons.
184 const cyPage = words(await (await web(cyCookie, "/cy/arm/settings/app")).text());
185 expect(cyPage).toContain("Granted by ada");
186 expect(cyPage).not.toContain("/settings/app/egress/");
187 expect(cyPage).not.toContain(">Grant<");
188 });
189
190 test("the page grants too, and refuses a foreign origin", async () => {
191 const path = `/cy/arm/settings/app/egress/${encodeURIComponent(CAMERA)}/grant`;
192 await web(adaCookie, path, "https://evil.example");
193 expect(listEgressGrants(app.db, repo.id).map((g) => g.destination)).toEqual([ARM]);
194 const granted = await web(adaCookie, path);
195 expect(granted.status).toBe(303);
196 expect(listEgressGrants(app.db, repo.id).map((g) => g.destination)).toEqual([ARM, CAMERA]);
197 });
198
199 test("a revoke removes the grant and is recorded with who", async () => {
200 expect((await api("DELETE", adaToken, "100.89.95.68:9999")).status).toBe(422);
201 expect((await api("DELETE", adaToken, ARM)).status).toBe(200);
202 expect(listEgressGrants(app.db, repo.id).map((g) => g.destination)).toEqual([CAMERA]);
203 const view = await egressView(app, ada, repo);
204 expect(view.ok && view.value.rows.find((r) => r.destination === ARM)?.granted).toBe(false);
205 const page = words(await (await web(adaCookie, "/cy/arm/settings/app")).text());
206 expect(page).toContain("ada revoked");
207 const kinds = listEvents(app.db, { repoId: repo.id, limit: 50 })
208 .filter((e) => e.kind.startsWith("app.egress."))
209 .map((e) => [e.kind, e.actorId, e.payload["destination"]]);
210 expect(kinds).toContainEqual(["app.egress.revoked", ada.id, ARM]);
211 expect(kinds).toContainEqual(["app.egress.granted", ada.id, ARM]);
212 const verbs = listEvents(app.db, { repoId: repo.id, limit: 50 })
213 .filter((e) => e.kind.startsWith("app.egress."))
214 .map((e) => eventVerb(e));
215 expect(verbs).toContain(`granted the app ${ARM}`);
216 expect(verbs).toContain(`revoked the app's grant on ${ARM}`);
217 });
218 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs