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

Merged opened this

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

Merged

scout/forge:scout/3469-a-site-admin-grants-lists-and-revokes-a → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/3469-a-site-admin-grants-lists-and-revokes-a was merged into main as 71e5ff2.

Review

Sign in to review this change.
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
828828 `http://<name>.<your forge's host>` from a small, real app (notes in a SQLite
829829 file), and if you named an agent, the sentence became issue #1, assigned.
830830
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`.
831836 - **Production is the default branch.** The only way code reaches it is that
832837 branch moving, and an agent cannot move it: the crew proposes through
833838 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`
298298 and a `{ "destination": "100.89.95.68:8101" }` body; `GET` lists them. Anyone
299299 else who administers the repository sees the list with no buttons. A
300300 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.
303304
304305 A release that turns egress on must not break an app whose writers have
305306 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";
3232 import { badge, card } from "../ui/widgets.ts";
3333 import type { Session } from "../session.ts";
3434 import { PIN_MARK, ago, drawFacts, plural } from "./common.ts";
35 import { egressScreen } from "./egress.ts";
3536 import { forkOf, hostOf, liveOrigin, localRepo, sourceName } from "./where.ts";
3637
3738 /**
@@ -95,6 +96,10 @@ export function aboutScreen(
9596 ),
9697 ]
9798 : []),
99 // A site admin also reaches the repository's private egress grants.
100 ...(f.user?.isAdmin === true && repo !== undefined
101 ? [binding(["e"], "egress")]

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.

102 : []),
98103 binding(["esc"], "close"),
99104 ];
100105 },
@@ -200,6 +205,10 @@ export function aboutScreen(
200205 });
201206 return true;
202207 }
208 if (e.key === "e" && repo !== undefined && f.user?.isAdmin === true) {
209 ctx.push(egressScreen(f, ctx, repo));
210 return true;
211 }
203212 if (e.key === "esc" || e.key === "q" || e.key === "I") {
204213 ctx.pop();
205214 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs