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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3433-a-site-admin-grants-or-revokes-an-app-s → main 3 commits fork 5 threads · 5 unresolved
docs/APPS.md +9 −3
| @@ -291,9 +291,15 @@ own pod and service ranges (`FORGE_NEVER_GRANT_CIDRS` adds more) are never | ||
| 291 | 291 | grantable. Until granted, the refusal says to add it and ask a site admin. |
| 292 | 292 | A grant needs the exact `host:port` in `egress`: a wildcard entry such as |
| 293 | 293 | `*.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). | |
| 297 | 303 | |
| 298 | 304 | A release that turns egress on must not break an app whose writers have |
| 299 | 305 | 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 { | ||
| 58 | 58 | ? `set ${which} in the app's environment` |
| 59 | 59 | : `removed ${which} from the app's environment`; |
| 60 | 60 | } |
| 61 | if (kind === "app.egress.granted" || kind === "app.egress.revoked") { | |
| 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 | } | |
| 61 | 68 | if (kind.startsWith("app.domain.")) { |
| 62 | 69 | const host = payload["host"]; |
| 63 | 70 | const which = typeof host === "string" ? host : "a domain"; |
src/http/api.ts +50
| @@ -272,6 +272,11 @@ import { | ||
| 272 | 272 | listAppDomains, |
| 273 | 273 | removeAppDomain, |
| 274 | 274 | } from "../actions/domains.ts"; |
| 275 | import { | |
| 276 | egressView, | |
| 277 | grantAppEgress, | |
| 278 | revokeAppEgress, | |
| 279 | } from "../actions/egress_grants.ts"; | |
| 275 | 280 | import { |
| 276 | 281 | addPromptSource, |
| 277 | 282 | listPromptSources, |
| @@ -5254,6 +5259,51 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 5254 | 5259 | }, |
| 5255 | 5260 | ); |
| 5256 | 5261 | |
| 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 | ||
| 5257 | 5307 | // Where the crew's next tell comes from when nobody is typing |
| 5258 | 5308 | // (docs/APPS.md, "Prompts from Instagram"). Admin scope and admin over |
| 5259 | 5309 | // the repository; the token goes in once and never comes back out. |
src/http/server.ts +12
| @@ -355,6 +355,8 @@ import { | ||
| 355 | 355 | appDomainDelete, |
| 356 | 356 | appEnvDelete, |
| 357 | 357 | appEnvSet, |
| 358 | appEgressGrant, | |
| 359 | appEgressRevoke, | |
| 358 | 360 | appSettingsPage, |
| 359 | 361 | appSettingsUpdate, |
| 360 | 362 | autonomySettingsPage, |
| @@ -1361,6 +1363,16 @@ export function buildRouter(): Router<Handler> { | ||
| 1361 | 1363 | "/:owner/:name/settings/app/domains/:host/delete", |
| 1362 | 1364 | appDomainDelete, |
| 1363 | 1365 | ); |
| 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 | ); | |
| 1364 | 1376 | router.add("GET", "/:owner/:name/settings/prompts", promptsSettingsPage); |
| 1365 | 1377 | router.add("POST", "/:owner/:name/settings/prompts", promptSourceAdd); |
| 1366 | 1378 | router.add("POST", "/:owner/:name/settings/prompts/:id/run", promptSourceRun); |
src/web/pages/search.ts +9
| @@ -524,6 +524,15 @@ const DESTRUCTIVE: { | ||
| 524 | 524 | phrase: "", |
| 525 | 525 | }), |
| 526 | 526 | }, |
| 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 | }, | |
| 527 | 536 | { |
| 528 | 537 | pattern: /^\/([^/]+)\/([^/]+)\/settings\/proposal-tokens\/([^/]+)\/delete$/, |
| 529 | 538 | describe: (m) => ({ |
src/web/pages/settings.ts +152 −2
| @@ -23,6 +23,12 @@ import { | ||
| 23 | 23 | removeAppDomain, |
| 24 | 24 | type AppDomainView, |
| 25 | 25 | } from "../../actions/domains.ts"; |
| 26 | import { | |
| 27 | egressView, | |
| 28 | grantAppEgress, | |
| 29 | revokeAppEgress, | |
| 30 | type EgressView, | |
| 31 | } from "../../actions/egress_grants.ts"; | |
| 26 | 32 | import { |
| 27 | 33 | addPromptSource, |
| 28 | 34 | listPromptSources, |
| @@ -103,6 +109,7 @@ import { | ||
| 103 | 109 | selectField, |
| 104 | 110 | time, |
| 105 | 111 | timeAgo, |
| 112 | tlEvent, | |
| 106 | 113 | } from "../ui.ts"; |
| 107 | 114 | import { cutWaveFor } from "../../actions/cut_wave.ts"; |
| 108 | 115 | import { proposalTokensSection } from "./proposal-tokens.ts"; |
| @@ -491,6 +498,7 @@ function appSettingsView( | ||
| 491 | 498 | record: AppRecord | undefined, |
| 492 | 499 | values: AppValues, |
| 493 | 500 | error?: string, |
| 501 | egress?: EgressView, | |
| 494 | 502 | ): Html { |
| 495 | 503 | const base = repoBase(repo); |
| 496 | 504 | const runtime = appsRuntime(ctx.app); |
| @@ -598,6 +606,7 @@ function appSettingsView( | ||
| 598 | 606 | <div class="mt"> |
| 599 | 607 | ${appDomainsView(base, domains.ok ? domains.value : undefined)} |
| 600 | 608 | </div> |
| 609 | ${when(egress !== undefined && egress.rows.length > 0, () => html`<div class="mt">${appEgressView(base, egress!)}</div>`)} | |
| 601 | 610 | <div class="mt"> |
| 602 | 611 | ${card( |
| 603 | 612 | html`<p> |
| @@ -893,12 +902,23 @@ const appValues = (repo: Repo, record: AppRecord | undefined): AppValues => ({ | ||
| 893 | 902 | }); |
| 894 | 903 | |
| 895 | 904 | // GET /:owner/:name/settings/app |
| 896 | export function appSettingsPage(ctx: Ctx): Response { | |
| 905 | export async function appSettingsPage(ctx: Ctx): Promise<Response> { | |
| 897 | 906 | const repo = loadAdminRepo(ctx); |
| 898 | 907 | if (!repo) return repoNotFound(ctx); |
| 899 | 908 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 900 | 909 | 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 | ); | |
| 902 | 922 | const rendering = performance.now(); |
| 903 | 923 | const response = settingsPageResponse(ctx, repo, "app", "App", view); |
| 904 | 924 | notePhase(ctx.request, "render", performance.now() - rendering); |
| @@ -1157,6 +1177,136 @@ export async function appDomainDelete(ctx: Ctx): Promise<Response> { | ||
| 1157 | 1177 | ); |
| 1158 | 1178 | } |
| 1159 | 1179 | |
| 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 |
||
| 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 |
||
| 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 |
||
| 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 | ||
| 1160 | 1310 | /** What a change to the environment sets in motion — when anything serves. */ |
| 1161 | 1311 | function redeploying(ctx: Ctx, how: string): string { |
| 1162 | 1312 | 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'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 | }); |
New wording with no test. Add an assertion that
eventVerbgives "granted the app 100.89.95.68:8101" and the revoke wording.