Public: anyone can read this; the crew can change it.
A site admin grants, lists and revokes a repository's private egress destinations on the repository page and in the terminal #3543
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3469-a-site-admin-grants-lists-and-revokes-a → main 2 commits fork 2 threads · 2 unresolved
5 files
changed +203
−2
README.md +5
| @@ -828,6 +828,11 @@ a sentence about what it should do. Seconds later it is serving at | ||
| 828 | 828 | `http://<name>.<your forge's host>` from a small, real app (notes in a SQLite |
| 829 | 829 | file), and if you named an agent, the sentence became issue #1, assigned. |
| 830 | 830 | |
| 831 | - **Private egress is a site admin's yes.** An app reaches a private | |
| 832 | destination (a tailnet or LAN `host:port`) only when `forge.json` `egress` | |
| 833 | names it exactly and a site admin has granted it: on the | |
| 834 | app's settings page, or in the terminal (About `I`, then `e`, `g` | |
| 835 | to grant, `x` to revoke). See `docs/APPS.md`. | |
| 831 | 836 | - **Production is the default branch.** The only way code reaches it is that |
| 832 | 837 | branch moving, and an agent cannot move it: the crew proposes through |
| 833 | 838 | pulls, a person merges. |
docs/APPS.md +3 −2
| @@ -298,8 +298,9 @@ each) or with `POST`/`DELETE /api/v1/repos/:owner/:name/egress-grants` | ||
| 298 | 298 | and a `{ "destination": "100.89.95.68:8101" }` body; `GET` lists them. Anyone |
| 299 | 299 | else who administers the repository sees the list with no buttons. A |
| 300 | 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). | |
| 301 | `app.egress.granted` or `app.egress.revoked` event with who and when. In the terminal a site admin | |
| 302 | opens the repository's About (`I`), then `e`: `g` grants and `x` revokes the | |
| 303 | destination under the cursor. | |
| 303 | 304 | |
| 304 | 305 | A release that turns egress on must not break an app whose writers have |
| 305 | 306 | not declared what it uses yet, so the operator can start it reported: |
src/tui/screens/about.ts +9
| @@ -32,6 +32,7 @@ import { truncate, wrap } from "../term/width.ts"; | ||
| 32 | 32 | import { badge, card } from "../ui/widgets.ts"; |
| 33 | 33 | import type { Session } from "../session.ts"; |
| 34 | 34 | import { PIN_MARK, ago, drawFacts, plural } from "./common.ts"; |
| 35 | import { egressScreen } from "./egress.ts"; | |
| 35 | 36 | import { forkOf, hostOf, liveOrigin, localRepo, sourceName } from "./where.ts"; |
| 36 | 37 | |
| 37 | 38 | /** |
| @@ -95,6 +96,10 @@ export function aboutScreen( | ||
| 95 | 96 | ), |
| 96 | 97 | ] |
| 97 | 98 | : []), |
| 99 | // A site admin also reaches the repository's private egress grants. | |
| 100 | ...(f.user?.isAdmin === true && repo !== undefined | |
| 101 | ? [binding(["e"], "egress")] | |
| 102 | : []), | |
| 98 | 103 | binding(["esc"], "close"), |
| 99 | 104 | ]; |
| 100 | 105 | }, |
| @@ -200,6 +205,10 @@ export function aboutScreen( | ||
| 200 | 205 | }); |
| 201 | 206 | return true; |
| 202 | 207 | } |
| 208 | if (e.key === "e" && repo !== undefined && f.user?.isAdmin === true) { | |
| 209 | ctx.push(egressScreen(f, ctx, repo)); | |
| 210 | return true; | |
| 211 | } | |
| 203 | 212 | if (e.key === "esc" || e.key === "q" || e.key === "I") { |
| 204 | 213 | ctx.pop(); |
| 205 | 214 | return true; |
src/tui/screens/egress.ts added +76
| @@ -0,0 +1,76 @@ | ||
| 1 | /** | |
| 2 | * An app's private destinations in the terminal, a site admin's: the list the | |
| 3 | * settings page shows, with `g` to grant the one under the cursor and `x` to | |
| 4 | * revoke it. The actions decide who may; anyone else is offered nothing. | |
| 5 | */ | |
| 6 | ||
| 7 | import { | |
| 8 | egressView, | |
| 9 | grantAppEgress, | |
| 10 | revokeAppEgress, | |
| 11 | type EgressRow, | |
| 12 | } from "../../actions/egress_grants.ts"; | |
| 13 | import type { Repo } from "../../core/repos.ts"; | |
| 14 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 15 | import { binding } from "../app/keys.ts"; | |
| 16 | import { style } from "../term/style.ts"; | |
| 17 | import { truncate } from "../term/width.ts"; | |
| 18 | import { fill, len, split } from "../ui/layout.ts"; | |
| 19 | import type { Session } from "../session.ts"; | |
| 20 | import { ListState, drawList, type ListRow } from "./common.ts"; | |
| 21 | ||
| 22 | export function egressScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | |
| 23 | const state = new ListState(); | |
| 24 | let rows: EgressRow[] = []; | |
| 25 | const reload = async (): Promise<void> => { | |
| 26 | const view = await egressView(f.app, f.user, repo); | |
| 27 | rows = view.ok ? view.value.rows : []; | |
| 28 | state.clamp(rows.length); | |
| 29 | ctx.invalidate(); | |
| 30 | }; | |
| 31 | void reload(); | |
| 32 | const lines = (): ListRow[] => | |
| 33 | rows.map((r) => ({ | |
| 34 | title: r.destination, | |
| 35 | meta: r.granted | |
| 36 | ? `granted by ${r.grantedBy} ${(r.grantedAt ?? "").slice(0, 16).replace("T", " ")} UTC` | |
| 37 | : "asked for, not granted", | |
Say why in person words per error code (not asked, never grantable, no such grant) instead of one generic danger toast. |
||
| 38 | })); | |
| 39 | const change = async ( | |
| 40 | done: Promise<{ ok: boolean }> | { ok: boolean }, | |
| 41 | said: string, | |
| 42 | ): Promise<void> => { | |
| 43 | const result = await done; | |
| 44 | await reload(); | |
| 45 | if (result.ok) ctx.toast(said, "success"); | |
| 46 | else ctx.toast("that could not be changed", "danger"); | |
| 47 | }; | |
| 48 | ||
| 49 | const screen: Screen = { | |
| 50 | title: "Private egress", | |
| 51 | keys: [binding(["g"], "grant"), binding(["x"], "revoke"), binding(["j", "k"], "move cursor")], | |
| 52 | render(area, s) { | |
| 53 | const c = ctx.theme.c; | |
| 54 | const [head, list] = split(area, "v", [len(1), fill(1)]); | |
| 55 | const note = | |
| 56 | rows.length === 0 | |
| 57 | ? `forge.json of ${repo.ownerName}/${repo.name} asks for no private destination.` | |
| 58 | : `${rows.length} for ${repo.ownerName}/${repo.name}`; | |
| 59 | s.text(head!.x, head!.y, truncate(note, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); | |
| 60 | drawList(s, list!, c, state, lines()); | |
| 61 | }, | |
| 62 | key(e) { | |
| 63 | const row = rows[state.index]; | |
| 64 | if (e.key === "g" && row !== undefined) { | |
| 65 | void change(grantAppEgress(f.app, f.user, repo, row.destination), `${row.destination} is granted.`); | |
| 66 | return true; | |
| 67 | } | |
| 68 | if (e.key === "x" && row !== undefined) { | |
| 69 | void change(revokeAppEgress(f.app, f.user, repo, row.destination), `${row.destination} is revoked.`); | |
| 70 | return true; | |
| 71 | } | |
| 72 | return state.key(e, rows.length); | |
| 73 | }, | |
| 74 | }; | |
| 75 | return screen; | |
| 76 | } |
test/egress-admin.test.ts added +110
| @@ -0,0 +1,110 @@ | ||
| 1 | /** | |
| 2 | * A site admin grants and revokes an app's private destinations in the | |
| 3 | * terminal (#3469); the web and API door is `app-egress-grants.test.ts`. | |
| 4 | * Nobody else is offered the screen. | |
| 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 { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 13 | import { listEgressGrants } from "../src/core/egress_grants.ts"; | |
| 14 | import type { Repo } from "../src/core/repos.ts"; | |
| 15 | import { createUser, type User } from "../src/core/users.ts"; | |
| 16 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 17 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 18 | import { quietLogs } from "./helpers/globals.ts"; | |
| 19 | import { settleApp, settleUntil } from "./helpers/tui.ts"; | |
| 20 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 21 | ||
| 22 | quietLogs(); | |
| 23 | ||
| 24 | const ARM = "100.89.95.68:8101"; | |
| 25 | ||
| 26 | describe("egress grants in the terminal", () => { | |
| 27 | let dir: string; | |
| 28 | let app: App; | |
| 29 | let ada: User; | |
| 30 | let bob: User; | |
| 31 | let repo: Repo; | |
| 32 | ||
| 33 | const grants = () => listEgressGrants(app.db, repo.id).map((g) => g.destination); | |
| 34 | ||
| 35 | beforeAll(async () => { | |
| 36 | dir = await mkdtemp(join(tmpdir(), "egress-admin-")); | |
| 37 | app = createApp({ dataDir: dir, port: 0 }); | |
| 38 | const a = await seedAdmin(app, { username: "ada", email: "[email protected]", password: "grants-are-exact" }); | |
| 39 | if (!a.ok) throw new Error("admin"); | |
| 40 | ada = a.value; | |
| 41 | const b = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "grants-are-exact" }); | |
| 42 | if (!b.ok) throw new Error("bob"); | |
| 43 | bob = b.value; | |
| 44 | const made = await createRepo(app, ada, { name: "arm", visibility: "public", description: "" }); | |
| 45 | if (!made.ok) throw new Error(String(made.error)); | |
| 46 | repo = made.value; | |
| 47 | const committed = await commitToBranch(app, ada, repo, { | |
| 48 | message: "Ask for the arm", | |
| 49 | files: [{ path: "forge.json", content: JSON.stringify({ app: { run: "bun run src/server.ts" }, egress: [ARM] }) }], | |
| 50 | }); | |
| 51 | if (!committed.ok) throw new Error(JSON.stringify(committed.error)); | |
| 52 | }, 60_000); | |
| 53 | afterAll(async () => { | |
| 54 | app.close(); | |
| 55 | await rm(dir, { recursive: true, force: true }); | |
| 56 | }); | |
| 57 | ||
| 58 | function boot(user: User) { | |
| 59 | const { app: tui } = createForgeApp( | |
| 60 | app, | |
| 61 | { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user }, | |
| 62 | { terminal: headlessTerminal(120, 40), exitHooks: false }, | |
| 63 | ); | |
| 64 | tui.start(); | |
| 65 | const press = async (...keys: string[]): Promise<void> => { | |
| 66 | for (const key of keys) | |
| 67 | tui.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false }); | |
| 68 | await settleApp(tui); | |
| 69 | }; | |
| 70 | const openAbout = async (): Promise<void> => { | |
| 71 | await settleApp(tui); | |
| 72 | await press("/"); | |
| 73 | for (const ch of "arm") await press(ch); | |
| 74 | await press("enter", "enter"); | |
| 75 | await settleUntil(tui, "ada/arm"); | |
| 76 | await press("I"); | |
| 77 | await settleUntil(tui, "clone (http)"); | |
| 78 | }; | |
| 79 | return { tui, press, openAbout, text: () => tui.screen.toText() }; | |
| 80 | } | |
| 81 | ||
| 82 | test("an admin grants and revokes from About", async () => { | |
| 83 | const h = boot(ada); | |
| 84 | try { | |
| 85 | await h.openAbout(); | |
| 86 | await h.press("e"); | |
| 87 | await settleUntil(h.tui, "asked for, not granted"); | |
| 88 | await h.press("g"); | |
| 89 | await settleUntil(h.tui, "granted by ada"); | |
| 90 | expect(grants()).toEqual([ARM]); | |
| 91 | await h.press("x"); | |
| 92 | await settleUntil(h.tui, "asked for, not granted"); | |
| 93 | expect(grants()).toEqual([]); | |
| 94 | } finally { | |
| 95 | h.tui.stop(); | |
| 96 | } | |
| 97 | }); | |
| 98 | ||
| 99 | test("a non-admin is not offered it, and the key does nothing", async () => { | |
| 100 | const h = boot(bob); | |
| 101 | try { | |
| 102 | await h.openAbout(); | |
| 103 | await h.press("e"); | |
| 104 | expect(h.text()).not.toContain("Private egress"); | |
| 105 | expect(grants()).toEqual([]); | |
| 106 | } finally { | |
| 107 | h.tui.stop(); | |
| 108 | } | |
| 109 | }); | |
| 110 | }); |
new above
offline
Use the same site-admin predicate as the action (isAdmin and kind !== agent) here and in the key handler, so the binding is not offered to someone who then sees nothing.