Public: anyone can read this; the crew can change it.
A site admin grants an app's private destinations on its settings page, and only the repository's admins hear about it #3686
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3621-a-site-admin-grants-an-app-s-private-des → main 10 commits fork 1 thread · 1 unresolved
2 files
changed +22
−0
src/core/event_audience.ts +2
| @@ -139,6 +139,8 @@ export function classifiedAudience(kind: string): EventAudience | undefined { | ||
| 139 | 139 | kind.startsWith("webhook.") || |
| 140 | 140 | kind.startsWith("app.env_") || |
| 141 | 141 | kind.startsWith("app.domain.") || |
| 142 | // A private address and port, and who may reach it (#3543). | |
| 143 | kind.startsWith("app.egress.") || | |
Right place and right audience. Follow-up: cover the HTTP outbox and tty feed with a test for this kind. |
||
| 142 | 144 | kind.startsWith("prompt.") || |
| 143 | 145 | kind.startsWith("secret.") || |
| 144 | 146 | // The forge's mail ceiling is its owner's to hear (`actions/mail.ts`). |
test/event-audience.test.ts +20
| @@ -28,6 +28,8 @@ import { serve, type Servers } from "../src/http/server.ts"; | ||
| 28 | 28 | |
| 29 | 29 | const HOOK = "https://hooks.example.test/services/T000/B000/s3cr3t-in-the-path"; |
| 30 | 30 | |
| 31 | const EGRESS = "100.89.95.68:8101"; | |
| 32 | ||
| 31 | 33 | describe("who an event is for", () => { |
| 32 | 34 | let dataDir: string; |
| 33 | 35 | let app: App; |
| @@ -88,6 +90,12 @@ describe("who an event is for", () => { | ||
| 88 | 90 | "sk_live_x", |
| 89 | 91 | ); |
| 90 | 92 | if (!set.ok) throw new Error(String(set.error)); |
| 93 | app.bus.append({ | |
| 94 | kind: "app.egress.granted", | |
| 95 | repoId: repo.id, | |
| 96 | actorId: alice.id, | |
| 97 | payload: { destination: EGRESS }, | |
| 98 | }); | |
| 91 | 99 | expect(pinRepo(app, bob, repo).ok).toBe(true); |
| 92 | 100 | }, 30_000); |
| 93 | 101 | |
| @@ -108,6 +116,8 @@ describe("who an event is for", () => { | ||
| 108 | 116 | expect(eventAudience("webhook.deleted")).toBe("admins"); |
| 109 | 117 | expect(eventAudience("app.env_set")).toBe("admins"); |
| 110 | 118 | expect(eventAudience("app.env_deleted")).toBe("admins"); |
| 119 | expect(eventAudience("app.egress.granted")).toBe("admins"); | |
| 120 | expect(eventAudience("app.egress.revoked")).toBe("admins"); | |
| 111 | 121 | expect(eventAudience("repo.settings_updated")).toBe("admins"); |
| 112 | 122 | expect(eventAudience("prompt.told")).toBe("admins"); |
| 113 | 123 | expect(eventAudience("prompt.source_added")).toBe("admins"); |
| @@ -134,6 +144,16 @@ describe("who an event is for", () => { | ||
| 134 | 144 | expect(alices).not.toContain("repo.pinned"); // bob's, even to an admin |
| 135 | 145 | }); |
| 136 | 146 | |
| 147 | test("a private destination and its granter are for the repository's admins", () => { | |
| 148 | const all = listEvents(app.db, { repoId: repo.id }); | |
| 149 | const text = (viewer: User | undefined) => | |
| 150 | JSON.stringify(all.filter(audienceFilter(app.db, viewer))); | |
| 151 | expect(text(undefined)).not.toContain(EGRESS); | |
| 152 | expect(text(bob)).not.toContain(EGRESS); | |
| 153 | expect(text(alice)).toContain(EGRESS); | |
| 154 | expect(text(alice)).toContain(alice.id); | |
| 155 | }); | |
| 156 | ||
| 137 | 157 | test("the JSON outbox never tells a reader a webhook's URL or a variable's name", async () => { |
| 138 | 158 | for (const headers of [{}, { authorization: `Bearer ${bobToken}` }]) { |
| 139 | 159 | const text = await fetch(`${base}/api/v1/repos/alice/open/events`, { |
new above
offline
Right place and right audience. The prefix covers both granted and revoked.