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

Cloud accounts: an operator panel that holds every provider key sealed, read-only first, and shows what each read was for #378

Merged opened this in answer to

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

Merged

scout/forge:scout/340-cloud-accounts-an-operator-panel-that-ho → main 2 commits fork

  • Merged. scout/340-cloud-accounts-an-operator-panel-that-ho was merged into main as a616e64.

Review

Sign in to review this change.
11 files changed +1194 −6
README.md +21
@@ -323,6 +323,27 @@ holds, where it goes, and the one command that restores it.
323323 grew up in switched off: what a host owes it, the moves in order, and what is
324324 given up.
325325
326 ## Cloud accounts
327
328 **Settings → Cloud accounts** (admins) holds provider keys — Cloudflare,
329 DigitalOcean, AWS, Azure, Fly, Backblaze — sealed and write-only. Pasting one
330 makes the forge check it with read-only calls (listed in the header of
331 `src/cloud/providers.ts`) and say what it can see; a key the provider rejects
332 is not stored. It is sealed at forge scope as `CLOUD_<PROVIDER>_<NAME>` (secret
333 names are capitals, so `cloudflare/main` is `CLOUD_CLOUDFLARE_MAIN`). The page
334 lists names, the last check, and which run last opened each key; "Every read"
335 lists each open, and a run's page lists the secrets it held. A run gets a key
336 by declaring it like any secret. **Write allowed** is off by default: a run
337 that adds `writes: yes` to its front-matter and declares a cloud key whose
338 switch is off fails with that reason. The switch gates the forge's hand-out,
339 not the key — a provider key that can write still can, so paste read-only keys.
340 **Audit** reads one account and opens one `[audit]` issue per finding (unattached
341 volumes, powered-off droplets, unassociated IPs, never-active zones) with a
342 monthly saving estimate, skipping findings already open. It runs in the forge,
343 not as a crew run; AWS audits look at us-east-1 only; over-provisioned
344 droplets and forgotten DNS records need metrics or resolution and are not
345 attempted; Backblaze audits find nothing yet. No dependency was added.
346
326347 ## Develop
327348
328349 ```sh
forge.json +1 −1
@@ -6,7 +6,7 @@
66 "ready": 90
77 },
88 "check": {
9 "run": "bun x tsc --noEmit && bun scripts/test-shards.ts --timeout 60000 test/crew.test.ts test/crew-wrapup.test.ts test/crew-limits.test.ts test/crew-sandbox.test.ts test/steer.test.ts test/landing.test.ts test/decide.test.ts test/ledger.test.ts test/checks.test.ts test/dependencies.test.ts test/autonomy.test.ts test/studio.test.ts test/studio-words.test.ts test/forks.test.ts test/fork-flow.test.ts test/cross-repo-pull.test.ts test/pulls.test.ts test/apps.test.ts test/apps-adversarial.test.ts test/apps-busy.test.ts test/web.test.ts test/git-trust.test.ts test/helpers.test.ts test/shards.test.ts test/migrate.test.ts test/vitals.test.ts test/asks.test.ts test/feed.test.ts test/feed-page.test.ts test/offline.test.ts test/share.test.ts test/forge-cloudflare.test.ts",
9 "run": "bun x tsc --noEmit && bun scripts/test-shards.ts --timeout 60000 test/crew.test.ts test/crew-wrapup.test.ts test/crew-limits.test.ts test/crew-sandbox.test.ts test/steer.test.ts test/landing.test.ts test/decide.test.ts test/ledger.test.ts test/checks.test.ts test/dependencies.test.ts test/autonomy.test.ts test/studio.test.ts test/studio-words.test.ts test/forks.test.ts test/fork-flow.test.ts test/cross-repo-pull.test.ts test/pulls.test.ts test/apps.test.ts test/apps-adversarial.test.ts test/apps-busy.test.ts test/web.test.ts test/git-trust.test.ts test/helpers.test.ts test/shards.test.ts test/migrate.test.ts test/vitals.test.ts test/asks.test.ts test/feed.test.ts test/feed-page.test.ts test/offline.test.ts test/share.test.ts test/cloud.test.ts test/run-secrets.test.ts test/forge-cloudflare.test.ts",
1010 "timeout": 1800
1111 }
1212 }
src/actions/cloud.ts added +169
@@ -0,0 +1,169 @@
1 /**
2 * Cloud accounts, as a forge admin reaches them. A credential is checked
3 * with the provider's read-only calls (`cloud/providers.ts`) BEFORE it is
4 * sealed, so a bad paste is never stored; sealing goes through `putSecret`.
5 * Every later open of the value — a re-verify, an audit — goes through
6 * `resolveSecrets`, so it shows up as a `secret.read` event naming its run.
7 * Anyone who is not a forge admin is told `not_found`.
8 */
9
10 import type { App } from "../app.ts";
11 import { audit, isProvider, verify, type Fetch, type Provider } from "../cloud/providers.ts";
12 import {
13 CLOUD_NAME,
14 forgetCloudAccount,
15 recordVerified,
16 secretNameOf,
17 setWriteAllowed,
18 } from "../core/cloud_account.ts";
19 import { createIssue } from "../core/issues.ts";
20 import { getRepo } from "../core/repos.ts";
21 import { resolveSecrets } from "../core/secret_store.ts";
22 import type { User } from "../core/users.ts";
23 import { id } from "../kernel/id.ts";
24 import { err, ok, type Result } from "../kernel/result.ts";
25 import { putSecret, removeSecret } from "./secrets.ts";
26
27 /** Swapped in tests; the real forge always uses `fetch`. */
28 export const net: { fetch: Fetch } = { fetch: (u, i) => fetch(u, i) };
29
30 const admin = (a: User | undefined): Result<User, string> =>
31 a?.isAdmin ? ok(a) : err("not_found");
32
33 function target(
34 provider: string,
35 name: string,
36 ): Result<{ provider: Provider; name: string; secret: string }, string> {
37 if (!isProvider(provider)) return err("That is not a provider the forge knows.");
38 if (!CLOUD_NAME.test(name))
39 return err("A name is lowercase letters, digits and underscores, starting with a letter.");
40 return ok({ provider, name, secret: secretNameOf(provider, name) });
41 }
42
43 /** Verify, then seal. Returns the one-sentence summary. */
44 export async function addAccount(
45 app: App,
46 actor: User | undefined,
47 provider: string,
48 name: string,
49 value: string,
50 ): Promise<Result<string, string>> {
51 const who = admin(actor);
52 if (!who.ok) return who;
53 const t = target(provider, name);
54 if (!t.ok) return t;
55 const v = await verify(t.value.provider, value, net.fetch);
56 if (!v.ok) return v;
57 const set = putSecret(app, who.value, "forge", "", t.value.secret, value);
58 if (!set.ok) return err("That credential cannot be stored.");
59 recordVerified(app.db, t.value.provider, t.value.name, v.value.summary);
60 return ok(v.value.summary);
61 }
62
63 /** Open the sealed value for one named purpose, which the read event records. */
64 function open(app: App, actor: User, secret: string, run: string): string | undefined {
65 return resolveSecrets(app.db, app.config.dataDir, app.bus, [secret], {
66 actor: actor.id,
67 run,
68 })[secret];
69 }
70
71 export async function reverify(
72 app: App,
73 actor: User | undefined,
74 provider: string,
75 name: string,
76 ): Promise<Result<string, string>> {
77 const who = admin(actor);
78 if (!who.ok) return who;
79 const t = target(provider, name);
80 if (!t.ok) return t;
81 const value = open(app, who.value, t.value.secret, `verify:${t.value.provider}`);
82 if (value === undefined) return err("No such credential.");
83 const v = await verify(t.value.provider, value, net.fetch);
84 if (!v.ok) return v;
85 recordVerified(app.db, t.value.provider, t.value.name, v.value.summary);
86 return ok(v.value.summary);
87 }
88
89 export function allowWrite(
90 app: App,
91 actor: User | undefined,
92 provider: string,
93 name: string,
94 allowed: boolean,
95 ): Result<void, string> {
96 const who = admin(actor);
97 if (!who.ok) return who;
98 if (!setWriteAllowed(app.db, provider, name, allowed)) return err("No such credential.");
99 app.bus.append({
100 kind: "cloud.write_allowed",
101 actorId: who.value.id,
102 payload: { provider, name, allowed },
103 });
104 return ok();
105 }
106
107 export function removeAccount(
108 app: App,
109 actor: User | undefined,
110 provider: string,
111 name: string,
112 ): Result<void, string> {
113 const who = admin(actor);
114 if (!who.ok) return who;
115 const t = target(provider, name);
116 if (!t.ok) return t;
117 const gone = removeSecret(app, who.value, "forge", "", t.value.secret);
118 if (!gone.ok) return err("No such credential.");
119 forgetCloudAccount(app.db, t.value.provider, t.value.name);
120 return ok();
121 }
122
123 /**
124 * A read-only audit of one credential: one issue per finding in the repo
125 * named `owner/name`, skipping a finding whose issue is already open.
126 * Returns how many issues it opened and how many findings it saw.
127 */
128 export async function auditAccount(
129 app: App,
130 actor: User | undefined,
131 provider: string,
132 name: string,
133 repoRef: string,
134 ): Promise<Result<{ opened: number; found: number }, string>> {
135 const who = admin(actor);
136 if (!who.ok) return who;
137 const t = target(provider, name);
138 if (!t.ok) return t;
139 const [owner = "", rname = "", ...rest] = repoRef.trim().split("/");
140 const repo = rest.length === 0 ? getRepo(app.db, owner, rname) : undefined;
141 if (!repo) return err("Name a repository as owner/name to file the findings in.");
142 const value = open(app, who.value, t.value.secret, `audit:${t.value.provider}:${id()}`);
143 if (value === undefined) return err("No such credential.");
144 const found = await audit(t.value.provider, value, net.fetch);
145 if (!found.ok) return found;
146 let opened = 0;
147 for (const f of found.value) {
148 const title = `[audit] ${f.title}`;
149 const dup = app.db
150 .query("SELECT 1 FROM issue WHERE repo_id = ?1 AND title = ?2 AND state = 'open'")
151 .get(repo.id, title);
152 if (dup) continue;
153 const made = createIssue(app.db, {
154 repoId: repo.id,
155 authorId: who.value.id,
156 title: title.slice(0, 200),
157 body: f.body,
158 });
159 if (!made.ok) continue;
160 app.bus.append({
161 kind: "issue.opened",
162 repoId: repo.id,
163 actorId: who.value.id,
164 payload: { number: made.value.number },
165 });
166 opened++;
167 }
168 return ok({ opened, found: found.value.length });
169 }
src/cloud/providers.ts added +358
@@ -0,0 +1,358 @@
1 /**
2 * Cloud providers: what each one's credential looks like, and the read-only
3 * calls the forge makes with it. Every call here is a GET, a list, or an
4 * identity lookup — nothing creates, changes or deletes. The complete list:
5 *
6 * cloudflare GET api.cloudflare.com/client/v4/user/tokens/verify, /zones
7 * digitalocean GET api.digitalocean.com/v2/account, /projects, /droplets,
8 * /volumes, /reserved_ips
9 * aws POST sts.amazonaws.com Action=GetCallerIdentity (a read),
10 * POST ec2.us-east-1.amazonaws.com Action=DescribeVolumes,
11 * DescribeAddresses (reads, despite the verb)
12 * azure POST login.microsoftonline.com/<tenant>/oauth2/v2.0/token
13 * (client-credentials sign-in), GET management.azure.com
14 * /subscriptions, /disks, /publicIPAddresses
15 * fly POST api.fly.io/graphql — `query` documents only
16 * backblaze GET api.backblazeb2.com/b2api/v2/b2_authorize_account, then
17 * GET <apiUrl>/b2api/v2/b2_list_buckets (apiUrl must be a
18 * *.backblazeb2.com host)
19 *
20 * Hosts are fixed here, never taken from input. Providers whose credential
21 * has several parts take them joined by `:` — see `shape`.
22 */
23
24 import { createHmac, createHash } from "node:crypto";
25 import { err, ok, type Result } from "../kernel/result.ts";
26
27 export const PROVIDERS = [
28 "cloudflare",
29 "digitalocean",
30 "aws",
31 "azure",
32 "fly",
33 "backblaze",
34 ] as const;
35 export type Provider = (typeof PROVIDERS)[number];
36
37 export function isProvider(v: string): v is Provider {
38 return (PROVIDERS as readonly string[]).includes(v);
39 }
40
41 export const LABEL: Record<Provider, string> = {
42 cloudflare: "Cloudflare",
43 digitalocean: "DigitalOcean",
44 aws: "AWS",
45 azure: "Azure",
46 fly: "Fly.io",
47 backblaze: "Backblaze B2",
48 };
49
50 /** What to paste, per provider, in a sentence. */
51 export const shape: Record<Provider, string> = {
52 cloudflare: "an API token (read-only scope is enough)",
53 digitalocean: "a personal access token (read scope is enough)",
54 aws: "ACCESS_KEY_ID:SECRET_ACCESS_KEY of a read-only IAM user",
55 azure: "TENANT_ID:CLIENT_ID:CLIENT_SECRET of a Reader service principal",
56 fly: "a deploy or read-only org token",
57 backblaze: "KEY_ID:APPLICATION_KEY with read-only capabilities",
58 };
59
60 export type Fetch = (url: string, init?: RequestInit) => Promise<Response>;
61
62 export interface Verified {
63 /** One sentence: what the credential can see. */
64 readonly summary: string;
65 /** True when the provider's own answer shows the credential can write. */
66 readonly canWrite: boolean | null;
67 }
68
69 export interface Finding {
70 readonly title: string;
71 readonly body: string;
72 /** Estimated saving, USD per month. */
73 readonly saving: number;
74 }
75
76 const FAIL = "The provider did not accept that credential.";
77
78 async function json(f: Fetch, url: string, init: RequestInit = {}): Promise<any> {
79 const res = await f(url, { ...init, signal: AbortSignal.timeout(10_000) });
80 if (!res.ok) throw new Error(`${new URL(url).host} answered ${res.status}`);
81 return await res.json();
82 }
83
84 const bearer = (t: string) => ({ authorization: `Bearer ${t}` });
85 const money = (n: number) => `$${n.toFixed(2)}`;
86 const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? "" : "s"}`;
87
88 /** Split a multi-part credential into exactly `n` non-empty parts. */
89 function parts(value: string, n: number): string[] | undefined {
90 const p = value.split(":");
91 return p.length === n && p.every((x) => x !== "" && /^[\x21-\x7e]+$/.test(x))
92 ? p
93 : undefined;
94 }
95
96 // ---- AWS Signature V4 (query API, POST form body) ----
97
98 async function awsCall(
99 f: Fetch,
100 key: string,
101 secret: string,
102 service: string,
103 host: string,
104 region: string,
105 body: string,
106 ): Promise<string> {
107 const now = new Date();
108 const amz = now.toISOString().replace(/[-:]|\.\d{3}/g, "");
109 const day = amz.slice(0, 8);
110 const hash = (s: string) => createHash("sha256").update(s).digest("hex");
111 const hmac = (k: Buffer | string, s: string) =>
112 createHmac("sha256", k).update(s).digest();
113 const headers = `content-type:application/x-www-form-urlencoded\nhost:${host}\nx-amz-date:${amz}\n`;
114 const signed = "content-type;host;x-amz-date";
115 const canonical = `POST\n/\n\n${headers}\n${signed}\n${hash(body)}`;
116 const scope = `${day}/${region}/${service}/aws4_request`;
117 const sts = `AWS4-HMAC-SHA256\n${amz}\n${scope}\n${hash(canonical)}`;
118 const k = hmac(hmac(hmac(hmac(`AWS4${secret}`, day), region), service), "aws4_request");
119 const sig = createHmac("sha256", k).update(sts).digest("hex");
120 const res = await f(`https://${host}/`, {
121 method: "POST",
122 headers: {
123 "content-type": "application/x-www-form-urlencoded",
124 "x-amz-date": amz,
125 authorization: `AWS4-HMAC-SHA256 Credential=${key}/${scope}, SignedHeaders=${signed}, Signature=${sig}`,
126 },
127 body,
128 signal: AbortSignal.timeout(10_000),
129 });
130 if (!res.ok) throw new Error(`${host} answered ${res.status}`);
131 return await res.text();
132 }
133
134 const tag = (xml: string, t: string): string[] =>
135 [...xml.matchAll(new RegExp(`<${t}>([^<]*)</${t}>`, "g"))].map((m) => m[1]!);
136
137 // ---- Azure ----
138
139 async function azureToken(f: Fetch, value: string): Promise<string | undefined> {
140 const p = parts(value, 3);
141 if (!p || !/^[0-9a-zA-Z.-]+$/.test(p[0]!)) return undefined;
142 const res = await json(
143 f,
144 `https://login.microsoftonline.com/${p[0]}/oauth2/v2.0/token`,
145 {
146 method: "POST",
147 headers: { "content-type": "application/x-www-form-urlencoded" },
148 body: new URLSearchParams({
149 grant_type: "client_credentials",
150 client_id: p[1]!,
151 client_secret: p[2]!,
152 scope: "https://management.azure.com/.default",
153 }),
154 },
155 );
156 return typeof res.access_token === "string" ? res.access_token : undefined;
157 }
158
159 const ARM = "https://management.azure.com";
160
161 // ---- Fly ----
162
163 const fly = (f: Fetch, token: string, query: string) =>
164 json(f, "https://api.fly.io/graphql", {
165 method: "POST",
166 headers: { ...bearer(token), "content-type": "application/json" },
167 body: JSON.stringify({ query }),
168 });
169
170 // ---- Backblaze ----
171
172 async function b2(f: Fetch, value: string) {
173 const p = parts(value, 2);
174 if (!p) return undefined;
175 const auth = await json(
176 f,
177 "https://api.backblazeb2.com/b2api/v2/b2_authorize_account",
178 {
179 headers: {
180 authorization: `Basic ${Buffer.from(`${p[0]}:${p[1]}`).toString("base64")}`,
181 },
182 },
183 );
184 const api = new URL(String(auth.apiUrl));
185 if (api.protocol !== "https:" || !api.hostname.endsWith(".backblazeb2.com"))
186 throw new Error("unexpected B2 api host");
187 return { auth, api: api.origin };
188 }
189
190 /** Sign in read-only and say what the credential can see. Never throws. */
191 export async function verify(
192 provider: Provider,
193 value: string,
194 f: Fetch = fetch,
195 ): Promise<Result<Verified, string>> {
196 try {
197 switch (provider) {
198 case "cloudflare": {
199 const v = await json(f, "https://api.cloudflare.com/client/v4/user/tokens/verify", {
200 headers: bearer(value),
201 }).catch(() => undefined);
202 const z = await json(f, "https://api.cloudflare.com/client/v4/zones?per_page=50", {
203 headers: bearer(value),
204 });
205 const names: string[] = (z.result ?? []).map((r: any) => String(r.name));
206 const shown = names.slice(0, 5).join(", ");
207 return ok({
208 summary: `Cloudflare token${v?.result?.status ? ` (${v.result.status})` : ""} sees ${plural(names.length, "zone")}${shown ? `: ${shown}` : ""}; the API does not say which permissions it holds.`,
209 canWrite: null,
210 });
211 }
212 case "digitalocean": {
213 const h = bearer(value);
214 const a = await json(f, "https://api.digitalocean.com/v2/account", { headers: h });
215 const p = await json(f, "https://api.digitalocean.com/v2/projects", { headers: h }).catch(() => undefined);
216 const d = await json(f, "https://api.digitalocean.com/v2/droplets?per_page=1", { headers: h }).catch(() => undefined);
217 return ok({
218 summary: `DigitalOcean account ${a.account?.email ?? "?"} (${a.account?.status ?? "?"}) has ${p ? plural(p.projects?.length ?? 0, "project") : "projects hidden from this token"} and ${d ? plural(d.meta?.total ?? 0, "droplet") : "droplets hidden from this token"}; DigitalOcean does not expose a token's scope.`,
219 canWrite: null,
220 });
221 }
222 case "aws": {
223 const p = parts(value, 2);
224 if (!p) return err("An AWS credential is ACCESS_KEY_ID:SECRET_ACCESS_KEY.");
225 const x = await awsCall(f, p[0]!, p[1]!, "sts", "sts.amazonaws.com", "us-east-1",
226 "Action=GetCallerIdentity&Version=2011-06-15");
227 return ok({
228 summary: `AWS identity ${tag(x, "Arn")[0] ?? "?"} in account ${tag(x, "Account")[0] ?? "?"}; AWS shows who the key is, not what its policy allows.`,
229 canWrite: null,
230 });
231 }
232 case "azure": {
233 const t = await azureToken(f, value);
234 if (!t) return err("An Azure credential is TENANT_ID:CLIENT_ID:CLIENT_SECRET.");
235 const s = await json(f, `${ARM}/subscriptions?api-version=2022-12-01`, { headers: bearer(t) });
236 const subs: string[] = (s.value ?? []).map((r: any) => String(r.displayName));
237 return ok({
238 summary: `Azure principal sees ${plural(subs.length, "subscription")}${subs.length ? `: ${subs.slice(0, 5).join(", ")}` : ""}; roles are not read.`,
239 canWrite: null,
240 });
241 }
242 case "fly": {
243 const r = await fly(f, value, "query { organizations { nodes { slug } } apps { nodes { name } } }");
244 if (r.errors) return err(FAIL);
245 const orgs = (r.data?.organizations?.nodes ?? []).map((o: any) => String(o.slug));
246 return ok({
247 summary: `Fly token sees ${plural(orgs.length, "organization")}${orgs.length ? ` (${orgs.slice(0, 5).join(", ")})` : ""} and ${plural(r.data?.apps?.nodes?.length ?? 0, "app")}.`,
248 canWrite: null,
249 });
250 }
251 case "backblaze": {
252 const c = await b2(f, value);
253 if (!c) return err("A Backblaze credential is KEY_ID:APPLICATION_KEY.");
254 const caps: string[] = c.auth.allowed?.capabilities ?? [];
255 const writes = caps.some((x) => /^(write|delete|share|bypass)/i.test(x));
256 const l = await json(f, `${c.api}/b2api/v2/b2_list_buckets`, {
257 method: "POST",
258 headers: { authorization: String(c.auth.authorizationToken), "content-type": "application/json" },
259 body: JSON.stringify({ accountId: c.auth.accountId }),
260 });
261 return ok({
262 summary: `Backblaze key sees ${plural(l.buckets?.length ?? 0, "bucket")}; its own capabilities: ${caps.join(", ") || "none"}${writes ? " — it CAN write" : " — read-only"}.`,
263 canWrite: writes,
264 });
265 }
266 }
267 } catch (e) {
268 return err(e instanceof Error && /answered 40[13]|answered 400/.test(e.message) ? FAIL : `Could not verify: ${e instanceof Error ? e.message : "failed"}`);
269 }
270 }
271
272 /** Read-only audit: findings with estimated monthly savings. Changes nothing. */
273 export async function audit(
274 provider: Provider,
275 value: string,
276 f: Fetch = fetch,
277 ): Promise<Result<Finding[], string>> {
278 const found: Finding[] = [];
279 const add = (title: string, detail: string, saving: number) =>
280 found.push({
281 title,
282 body: `${detail}\n\nEstimated saving: about ${money(saving)} a month.\n\nFound by a read-only cloud audit; nothing was changed. Check before you delete: the forge cannot see what depends on it.`,
283 saving,
284 });
285 try {
286 switch (provider) {
287 case "digitalocean": {
288 const h = bearer(value);
289 const base = "https://api.digitalocean.com/v2";
290 const v = await json(f, `${base}/volumes?per_page=200`, { headers: h });
291 for (const x of v.volumes ?? [])
292 if ((x.droplet_ids ?? []).length === 0)
293 add(`DigitalOcean: unattached volume ${x.name}`, `Volume ${x.name} (${x.size_gigabytes} GB, ${x.region?.slug}) is attached to no droplet.`, x.size_gigabytes * 0.1);
294 const d = await json(f, `${base}/droplets?per_page=200`, { headers: h });
295 for (const x of d.droplets ?? [])
296 if (x.status === "off")
297 add(`DigitalOcean: powered-off droplet ${x.name}`, `Droplet ${x.name} (${x.size_slug}) is off but still billed at its full size price.`, Number(x.size?.price_monthly ?? 0));
298 const r = await json(f, `${base}/reserved_ips?per_page=200`, { headers: h }).catch(() => ({}));
299 for (const x of r.reserved_ips ?? [])
300 if (!x.droplet) add(`DigitalOcean: unassigned reserved IP ${x.ip}`, `Reserved IP ${x.ip} points at no droplet.`, 4);
301 break;
302 }
303 case "aws": {
304 const p = parts(value, 2);
305 if (!p) return err("An AWS credential is ACCESS_KEY_ID:SECRET_ACCESS_KEY.");
306 const host = "ec2.us-east-1.amazonaws.com";
307 const call = (a: string) => awsCall(f, p[0]!, p[1]!, "ec2", host, "us-east-1", `Action=${a}&Version=2016-11-15`);
308 const vols = await call("DescribeVolumes");
309 for (const m of vols.matchAll(/<item>(?:(?!<\/item>)[\s\S])*?<volumeId>([^<]+)<\/volumeId>(?:(?!<\/item>)[\s\S])*?<size>(\d+)<\/size>(?:(?!<\/item>)[\s\S])*?<status>available<\/status>/g))
310 add(`AWS: unattached volume ${m[1]}`, `EBS volume ${m[1]} (${m[2]} GiB, us-east-1) is available, attached to no instance.`, Number(m[2]) * 0.08);
311 const addr = await call("DescribeAddresses");
312 for (const m of addr.matchAll(/<item>((?:(?!<\/item>)[\s\S])*?)<\/item>/g))
313 if (!/<associationId>/.test(m[1]!))
314 add(`AWS: unassociated Elastic IP ${tag(m[1]!, "publicIp")[0] ?? "?"}`, `Elastic IP ${tag(m[1]!, "publicIp")[0] ?? "?"} (us-east-1) is associated with nothing.`, 3.65);
315 break;
316 }
317 case "cloudflare": {
318 const z = await json(f, "https://api.cloudflare.com/client/v4/zones?per_page=50", { headers: bearer(value) });
319 for (const x of z.result ?? [])
320 if (x.status !== "active")
321 add(`Cloudflare: zone ${x.name} never went active`, `Zone ${x.name} is "${x.status}": its nameservers were never switched, so its DNS records are not serving anything.`, 0);
322 break;
323 }
324 case "azure": {
325 const t = await azureToken(f, value);
326 if (!t) return err("An Azure credential is TENANT_ID:CLIENT_ID:CLIENT_SECRET.");
327 const s = await json(f, `${ARM}/subscriptions?api-version=2022-12-01`, { headers: bearer(t) });
328 for (const sub of s.value ?? []) {
329 const id = encodeURIComponent(String(sub.subscriptionId));
330 const disks = await json(f, `${ARM}/subscriptions/${id}/providers/Microsoft.Compute/disks?api-version=2023-04-02`, { headers: bearer(t) });
331 for (const x of disks.value ?? [])
332 if (x.properties?.diskState === "Unattached")
333 add(`Azure: unattached disk ${x.name}`, `Disk ${x.name} (${x.properties.diskSizeGB} GB, ${x.location}) in ${sub.displayName} is unattached.`, x.properties.diskSizeGB * 0.05);
334 const ips = await json(f, `${ARM}/subscriptions/${id}/providers/Microsoft.Network/publicIPAddresses?api-version=2023-05-01`, { headers: bearer(t) });
335 for (const x of ips.value ?? [])
336 if (!x.properties?.ipConfiguration)
337 add(`Azure: unassociated public IP ${x.name}`, `Public IP ${x.name} in ${sub.displayName} is associated with nothing.`, 3.65);
338 }
339 break;
340 }
341 case "fly": {
342 const r = await fly(f, value, "query { apps { nodes { name volumes { nodes { name sizeGb attachedMachine { id } } } } } }");
343 if (r.errors) return err(FAIL);
344 for (const a of r.data?.apps?.nodes ?? [])
345 for (const v of a.volumes?.nodes ?? [])
346 if (!v.attachedMachine)
347 add(`Fly: unattached volume ${v.name} in ${a.name}`, `Volume ${v.name} (${v.sizeGb} GB) of app ${a.name} is attached to no machine.`, v.sizeGb * 0.15);
348 break;
349 }
350 case "backblaze":
351 // Bucket size needs a file listing per bucket; not attempted.
352 break;
353 }
354 } catch (e) {
355 return err(`Audit stopped: ${e instanceof Error ? e.message : "failed"}`);
356 }
357 return ok(found.slice(0, 50));
358 }
src/core/cloud_account.ts added +171
@@ -0,0 +1,171 @@
1 /**
2 * Cloud accounts — the rows around a sealed provider credential. The value
3 * lives in `secret` (core/secret_store.ts) at forge scope under
4 * `CLOUD_<PROVIDER>_<NAME>` (a secret name is capitals, so `cloudflare/main`
5 * is stored as `CLOUD_CLOUDFLARE_MAIN`); this table holds what is safe to
6 * show: when it was last verified, what the check said, and whether runs
7 * may write with it. Write is off until someone turns it on.
8 */
9
10 import type { Database } from "bun:sqlite";
11 import { isProvider, type Provider } from "../cloud/providers.ts";
12
13 const made = new WeakSet<Database>();
14 function table(db: Database): void {
15 if (made.has(db)) return;
16 db.run(`CREATE TABLE IF NOT EXISTS cloud_account (
17 provider TEXT NOT NULL,
18 name TEXT NOT NULL,
19 summary TEXT NOT NULL DEFAULT '',
20 verified_at TEXT,
21 write_allowed INTEGER NOT NULL DEFAULT 0,
22 PRIMARY KEY (provider, name)
23 )`);
24 made.add(db);
25 }
26
27 export const CLOUD_NAME = /^[a-z][a-z0-9_]{0,31}$/;
28 const PREFIX = "CLOUD_";
29
30 export const secretNameOf = (provider: Provider, name: string): string =>
31 `${PREFIX}${provider.toUpperCase()}_${name.toUpperCase()}`;
32
33 /** The provider and name a secret name stands for, if it is a cloud one. */
34 export function accountOfSecret(
35 secret: string,
36 ): { provider: Provider; name: string } | undefined {
37 if (!secret.startsWith(PREFIX)) return undefined;
38 const rest = secret.slice(PREFIX.length);
39 const i = rest.indexOf("_");
40 const provider = rest.slice(0, i).toLowerCase();
41 const name = rest.slice(i + 1).toLowerCase();
42 return i > 0 && isProvider(provider) && name !== ""
43 ? { provider, name }
44 : undefined;
45 }
46
47 export interface CloudAccount {
48 readonly provider: Provider;
49 readonly name: string;
50 readonly summary: string;
51 readonly verifiedAt: string | null;
52 readonly writeAllowed: boolean;
53 }
54
55 interface Row {
56 provider: string;
57 name: string;
58 summary: string;
59 verified_at: string | null;
60 write_allowed: number;
61 }
62
63 const toAccount = (r: Row): CloudAccount => ({
64 provider: r.provider as Provider,
65 name: r.name,
66 summary: r.summary,
67 verifiedAt: r.verified_at,
68 writeAllowed: r.write_allowed === 1,
69 });
70
71 export function listCloudAccounts(db: Database): CloudAccount[] {
72 table(db);
73 return db
74 .query<Row, []>("SELECT * FROM cloud_account ORDER BY provider, name")
75 .all()
76 .map(toAccount);
77 }
78
79 export function getCloudAccount(
80 db: Database,
81 provider: string,
82 name: string,
83 ): CloudAccount | undefined {
84 table(db);
85 const r = db
86 .query<Row, [string, string]>(
87 "SELECT * FROM cloud_account WHERE provider = ?1 AND name = ?2",
88 )
89 .get(provider, name);
90 return r ? toAccount(r) : undefined;
91 }
92
93 export function recordVerified(
94 db: Database,
95 provider: Provider,
96 name: string,
97 summary: string,
98 ): void {
99 table(db);
100 db.query(
101 `INSERT INTO cloud_account (provider, name, summary, verified_at)
102 VALUES (?1, ?2, ?3, strftime('%Y-%m-%dT%H:%M:%fZ','now'))
103 ON CONFLICT(provider, name) DO UPDATE SET
104 summary = excluded.summary, verified_at = excluded.verified_at`,
105 ).run(provider, name, summary.slice(0, 600));
106 }
107
108 export function setWriteAllowed(
109 db: Database,
110 provider: string,
111 name: string,
112 allowed: boolean,
113 ): boolean {
114 table(db);
115 return (
116 db
117 .query(
118 "UPDATE cloud_account SET write_allowed = ?3 WHERE provider = ?1 AND name = ?2",
119 )
120 .run(provider, name, allowed ? 1 : 0).changes > 0
121 );
122 }
123
124 export function forgetCloudAccount(
125 db: Database,
126 provider: string,
127 name: string,
128 ): void {
129 table(db);
130 db.query("DELETE FROM cloud_account WHERE provider = ?1 AND name = ?2").run(
131 provider,
132 name,
133 );
134 }
135
136 /**
137 * Whether a run that needs writes may hold this secret. A name that is not a
138 * cloud credential is not this module's to gate; a cloud one with no row, or
139 * with the switch off, is refused.
140 */
141 export function writeRefused(db: Database, secret: string): boolean {
142 const acct = accountOfSecret(secret);
143 if (!acct) return false;
144 return !getCloudAccount(db, acct.provider, acct.name)?.writeAllowed;
145 }
146
147 export interface CloudRead {
148 readonly at: string;
149 readonly provider: Provider;
150 readonly name: string;
151 readonly run: string | null;
152 }
153
154 /** Recent opens of cloud credentials, newest first, from the event outbox. */
155 export function recentCloudReads(db: Database, limit = 30): CloudRead[] {
156 const rows = db
157 .query<{ created_at: string; payload: string }, [number]>(
158 `SELECT created_at, payload FROM event
159 WHERE kind = 'secret.read'
160 AND json_extract(payload, '$.name') LIKE 'CLOUD\\_%' ESCAPE '\\'
161 ORDER BY id DESC LIMIT ?1`,
162 )
163 .all(limit);
164 const out: CloudRead[] = [];
165 for (const r of rows) {
166 const p = JSON.parse(r.payload) as { name: string; run: string | null };
167 const acct = accountOfSecret(p.name);
168 if (acct) out.push({ at: r.created_at, ...acct, run: p.run });
169 }
170 return out;
171 }
src/crew/grant.ts +17 −4
@@ -16,6 +16,7 @@
1616 import type { Database } from "bun:sqlite";
1717 import type { EventBus } from "../core/events.ts";
1818 import { recordGrant, releaseGrant } from "../core/run_secret.ts";
19 import { writeRefused } from "../core/cloud_account.ts";
1920 import { isSecretName, resolveScoped } from "../core/secret_store.ts";
2021 import { holdValues, releaseValues } from "../kernel/log.ts";
2122
@@ -25,6 +26,8 @@ export interface Declaration {
2526 readonly hosts: readonly string[];
2627 /** The host each name is for, where it said. */
2728 readonly hostOf: Readonly<Record<string, string>>;
29 /** The run said it needs to write (`writes: yes`). Reads are the default. */
30 readonly writes: boolean;
2831 /** Entries that were not accepted, with why — said in the run's log. */
2932 readonly refused: readonly string[];
3033 }
@@ -41,10 +44,12 @@ export function parseDeclaration(...texts: readonly string[]): Declaration {
4144 const hosts = new Set<string>();
4245 const hostOf: Record<string, string> = {};
4346 const refused: string[] = [];
47 let writes = false;
4448 for (const text of texts) {
4549 const m = /^\s*---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(text);
4650 if (m === null) continue;
4751 for (const line of m[1]!.split(/\r?\n/)) {
52 if (/^writes\s*:\s*(yes|true)$/i.test(line.trim())) writes = true;
4853 const kv = /^secrets\s*:\s*(.*)$/i.exec(line.trim());
4954 if (kv === null) continue;
5055 for (const entry of kv[1]!.split(",")) {
@@ -72,7 +77,7 @@ export function parseDeclaration(...texts: readonly string[]): Declaration {
7277 }
7378 }
7479 }
75 return { names, hosts: [...hosts], hostOf, refused };
80 return { names, hosts: [...hosts], hostOf, writes, refused };
7681 }
7782
7883 export interface Grant {
@@ -80,6 +85,8 @@ export interface Grant {
8085 readonly env: Record<string, string>;
8186 /** Names held, for the log. */
8287 readonly names: readonly string[];
88 /** Cloud credentials withheld because the run needs writes and they are not write allowed. */
89 readonly writeRefused: readonly string[];
8390 /** Declared but set at no scope. */
8491 readonly missing: readonly string[];
8592 /** Extra egress hosts for this run only. */
@@ -95,10 +102,15 @@ export function grantSecrets(
95102 declared: Declaration,
96103 run: { id: string; repoId: string; ownerId: string; actorId: string },
97104 ): Grant {
105 // Checked before anything is opened: a refused credential leaves no read.
106 const denied = declared.writes
107 ? declared.names.filter((n) => writeRefused(db, n))
108 : [];
109 const asked = declared.names.filter((n) => !denied.includes(n));
98110 const found =
99 declared.names.length === 0
111 asked.length === 0
100112 ? {}
101 : resolveScoped(db, dataDir, bus, declared.names, {
113 : resolveScoped(db, dataDir, bus, asked, {
102114 repo: run.repoId,
103115 org: run.ownerId,
104116 actor: run.actorId,
@@ -118,7 +130,8 @@ export function grantSecrets(
118130 return {
119131 env,
120132 names,
121 missing: declared.names.filter((n) => !(n in found)),
133 writeRefused: denied,
134 missing: asked.filter((n) => !(n in found)),
122135 // Only a secret that was actually granted widens the egress.
123136 hosts: [...new Set(names.flatMap((n) => declared.hostOf[n] ?? []))],
124137 release: () => {
src/crew/runner.ts +8
@@ -457,6 +457,14 @@ async function work(
457457 actorId: agent.user.id,
458458 });
459459 for (const why of declared.refused) write(`secret refused: ${why}`);
460 if (grant.writeRefused.length > 0) {
461 const names = grant.writeRefused.join(", ");
462 write(`refused: this run needs writes and ${names} is not write allowed`);
463 return {
464 state: "failed",
465 summary: `refused: ${names} is not write allowed (Settings → Cloud accounts)`,
466 };
467 }
460468 if (grant.names.length > 0)
461469 write(`secrets: holding ${grant.names.join(", ")} for this run`);
462470 for (const name of grant.missing) write(`secret not set: ${name}`);
src/http/server.ts +14
@@ -53,6 +53,14 @@ import {
5353 backupsPage,
5454 backupsTest,
5555 } from "../web/pages/backups.ts";
56 import {
57 cloudAdd,
58 cloudAudit,
59 cloudPage,
60 cloudRemove,
61 cloudVerify,
62 cloudWrite,
63 } from "../web/pages/cloud.ts";
5664 import {
5765 subscriptionAdd,
5866 subscriptionDelete,
@@ -489,6 +497,12 @@ export function buildRouter(): Router<Handler> {
489497 router.add("GET", "/settings/secrets", secretsPage);
490498 router.add("POST", "/settings/secrets", secretSet);
491499 router.add("POST", "/settings/secrets/delete", secretDelete);
500 router.add("GET", "/settings/cloud", cloudPage);
501 router.add("POST", "/settings/cloud", cloudAdd);
502 router.add("POST", "/settings/cloud/verify", cloudVerify);
503 router.add("POST", "/settings/cloud/write", cloudWrite);
504 router.add("POST", "/settings/cloud/audit", cloudAudit);
505 router.add("POST", "/settings/cloud/remove", cloudRemove);
492506 router.add("GET", "/settings/backups", backupsPage);
493507 router.add("POST", "/settings/backups/now", backupsNow);
494508 router.add("POST", "/settings/backups/destination", backupsDestination);
src/web/pages/cloud.ts added +200
@@ -0,0 +1,200 @@
1 /**
2 * Cloud accounts, for a forge admin: every provider credential the forge
3 * holds, sealed and write-only. The page lists provider, name, what the
4 * last read-only check said, when it ran, and which run last opened the
5 * value — never the value. Below, every recent read, so a use is a visible
6 * event. "Write allowed" is off until someone turns it on here.
7 */
8
9 import {
10 addAccount,
11 allowWrite,
12 auditAccount,
13 removeAccount,
14 reverify,
15 } from "../../actions/cloud.ts";
16 import { LABEL, PROVIDERS, shape } from "../../cloud/providers.ts";
17 import {
18 listCloudAccounts,
19 recentCloudReads,
20 type CloudRead,
21 } from "../../core/cloud_account.ts";
22 import type { Ctx } from "../../http/context.ts";
23 import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts";
24 import { field, readForm } from "../form.ts";
25 import { html, when, type Html } from "../html.ts";
26 import { flashTo, page } from "../layout.ts";
27 import { btn, card, icon, row, time } from "../ui.ts";
28 import { accountSections } from "./origins.ts";
29
30 const BACK = "/settings/cloud";
31
32 function requireAdmin(ctx: Ctx): Response | undefined {
33 if (!ctx.auth.user) return signInFirst(ctx);
34 if (!ctx.auth.user.isAdmin) return notFoundPage(ctx);
35 return undefined;
36 }
37
38 /** A run id in a read event, as words: audits and checks name themselves. */
39 const runWords = (run: string | null): string =>
40 run === null ? "no run" : run.startsWith("audit:") ? `audit (${run.split(":")[1]})` : run.startsWith("verify:") ? "verify" : `run ${run}`;
41
42 const hidden = (provider: string, name: string): Html =>
43 html`<input type="hidden" name="provider" value="${provider}" />
44 <input type="hidden" name="name" value="${name}" />`;
45
46 function accountsCard(reads: CloudRead[], ctx: Ctx): Html {
47 const accounts = listCloudAccounts(ctx.app.db);
48 const rows = accounts.map((a) => {
49 const last = reads.find((r) => r.provider === a.provider && r.name === a.name);
50 return row({
51 lead: icon("key"),
52 title: html`${LABEL[a.provider]} / <span class="mono">${a.name}</span>`,
53 meta: html`${a.summary}<br />verified ${a.verifiedAt ? time(a.verifiedAt) : "never"} ·
54 ${last ? html`last used ${time(last.at)} by ${runWords(last.run)}` : "never used"} ·
55 ${a.writeAllowed ? "writes allowed" : "read-only"}`,
56 trail: html`<div class="row-actions">
57 <form method="post" action="/settings/cloud/verify">
58 ${hidden(a.provider, a.name)}${btn("Verify", { size: "sm", label: `Verify ${a.name}` })}
59 </form>
60 <form method="post" action="/settings/cloud/write">
61 ${hidden(a.provider, a.name)}
62 <input type="hidden" name="allowed" value="${a.writeAllowed ? "0" : "1"}" />
63 ${btn(a.writeAllowed ? "Turn writes off" : "Allow writes", { size: "sm", label: `Write switch for ${a.name}` })}
64 </form>
65 <form method="post" action="/settings/cloud/audit" class="row-actions">
66 ${hidden(a.provider, a.name)}
67 <input class="input" type="text" name="repo" placeholder="owner/repo for findings"
68 aria-label="Repository for audit findings" maxlength="200" required />
69 ${btn("Audit", { size: "sm", label: `Audit ${a.name}` })}
70 </form>
71 <form method="post" action="/settings/cloud/remove">
72 ${hidden(a.provider, a.name)}${btn("Remove", { size: "sm", icon: "x", label: `Remove ${a.name}` })}
73 </form>
74 </div>`,
75 });
76 });
77 return card(
78 html`<div class="card-body">
79 <p class="muted small">
80 Paste a credential once: the forge checks it with read-only calls,
81 says what it can see, then seals it. It is never shown again. Give it
82 the narrowest scope the provider offers — an audit only reads. A run
83 that declares <span class="mono">writes: yes</span> is refused a
84 credential until its write switch is on; the switch cannot make a
85 provider key less powerful than it already is.
86 </p>
87 </div>
88 ${when(rows.length > 0, () => html`<ul class="list">${rows}</ul>`)}
89 <form class="edit-row" method="post" action="/settings/cloud">
90 <select class="input" name="provider" aria-label="Provider" required>
91 ${PROVIDERS.map((p) => html`<option value="${p}">${LABEL[p]}</option>`)}
92 </select>
93 <input class="input mono" type="text" name="name" placeholder="name, e.g. main"
94 aria-label="Name" pattern="[a-z][a-z0-9_]{0,31}" maxlength="32"
95 autocomplete="off" spellcheck="false" required />
96 <input class="input" type="password" name="value" placeholder="Credential"
97 aria-label="Credential" autocomplete="off" required />
98 ${btn("Verify and seal", { variant: "primary", size: "sm", icon: "plus" })}
99 </form>
100 <div class="card-body">
101 <p class="muted small">
102 ${PROVIDERS.map((p) => html`${LABEL[p]}: ${shape[p]}. `)}
103 </p>
104 </div>`,
105 { head: html`${icon("key")}<h2>Cloud accounts</h2>`, pad: false },
106 );
107 }
108
109 function readsCard(reads: CloudRead[]): Html {
110 return card(
111 reads.length === 0
112 ? html`<p class="muted small card-body">No credential has been read yet.</p>`
113 : html`<ul class="list">
114 ${reads.map((r) =>
115 row({
116 lead: icon("eye"),
117 title: html`${LABEL[r.provider]} / <span class="mono">${r.name}</span>`,
118 meta: html`${runWords(r.run)} · ${time(r.at)}`,
119 }),
120 )}
121 </ul>`,
122 { head: html`${icon("eye")}<h2>Every read</h2>`, pad: false },
123 );
124 }
125
126 // GET /settings/cloud
127 export function cloudPage(ctx: Ctx): Response {
128 const denied = requireAdmin(ctx);
129 if (denied) return denied;
130 const reads = recentCloudReads(ctx.app.db);
131 return page({
132 ctx,
133 title: "Cloud accounts",
134 body: html`<div class="page-head"><h1>Cloud accounts</h1></div>
135 ${settingsLayout(
136 accountSections("cloud", { admin: true }),
137 html`${accountsCard(reads, ctx)}<div class="mt">${readsCard(reads)}</div>`,
138 "Account settings",
139 )}`,
140 });
141 }
142
143 const said = (ctx: Ctx, r: { ok: boolean }, good: string, bad: string | undefined) =>
144 flashTo(ctx, BACK, r.ok ? { message: good } : { tone: "danger", message: bad ?? "Not found." });
145
146 // POST /settings/cloud
147 export async function cloudAdd(ctx: Ctx): Promise<Response> {
148 const denied = requireAdmin(ctx);
149 if (denied) return denied;
150 const form = await readForm(ctx, "admin");
151 if (form instanceof Response) return form;
152 const r = await addAccount(ctx.app, ctx.auth.user, field(form, "provider"), field(form, "name").trim(), field(form, "value").trim());
153 return said(ctx, r, r.ok ? `Sealed. ${r.value}` : "", r.ok ? undefined : r.error);
154 }
155
156 // POST /settings/cloud/verify
157 export async function cloudVerify(ctx: Ctx): Promise<Response> {
158 const denied = requireAdmin(ctx);
159 if (denied) return denied;
160 const form = await readForm(ctx, "admin");
161 if (form instanceof Response) return form;
162 const r = await reverify(ctx.app, ctx.auth.user, field(form, "provider"), field(form, "name"));
163 return said(ctx, r, r.ok ? r.value : "", r.ok ? undefined : r.error);
164 }
165
166 // POST /settings/cloud/write
167 export async function cloudWrite(ctx: Ctx): Promise<Response> {
168 const denied = requireAdmin(ctx);
169 if (denied) return denied;
170 const form = await readForm(ctx, "admin");
171 if (form instanceof Response) return form;
172 const on = field(form, "allowed") === "1";
173 const r = allowWrite(ctx.app, ctx.auth.user, field(form, "provider"), field(form, "name"), on);
174 return said(ctx, r, on ? "Runs that need writes may now hold it." : "Writes are off.", r.ok ? undefined : r.error);
175 }
176
177 // POST /settings/cloud/audit
178 export async function cloudAudit(ctx: Ctx): Promise<Response> {
179 const denied = requireAdmin(ctx);
180 if (denied) return denied;
181 const form = await readForm(ctx, "admin");
182 if (form instanceof Response) return form;
183 const r = await auditAccount(ctx.app, ctx.auth.user, field(form, "provider"), field(form, "name"), field(form, "repo"));
184 return said(
185 ctx,
186 r,
187 r.ok ? `Audit read ${r.value.found} finding(s); opened ${r.value.opened} issue(s). Nothing was changed.` : "",
188 r.ok ? undefined : r.error,
189 );
190 }
191
192 // POST /settings/cloud/remove
193 export async function cloudRemove(ctx: Ctx): Promise<Response> {
194 const denied = requireAdmin(ctx);
195 if (denied) return denied;
196 const form = await readForm(ctx, "admin");
197 if (form instanceof Response) return form;
198 const r = removeAccount(ctx.app, ctx.auth.user, field(form, "provider"), field(form, "name"));
199 return said(ctx, r, "Removed.", r.ok ? undefined : r.error);
200 }
src/web/pages/origins.ts +8 −1
@@ -70,7 +70,8 @@ export type AccountSection =
7070 | "crew"
7171 | "subscriptions"
7272 | "backups"
73 | "secrets";
73 | "secrets"
74 | "cloud";
7475
7576 /**
7677 * The settings sidebar, with Origins on it. Kept here so the account pages
@@ -103,6 +104,12 @@ export function accountSections(
103104 icon: "lock" as const,
104105 current: current === "secrets",
105106 },
107 {
108 label: "Cloud accounts",
109 href: "/settings/cloud",
110 icon: "key" as const,
111 current: current === "cloud",
112 },
106113 {
107114 label: "Backups",
108115 href: "/settings/backups",
test/cloud.test.ts added +227
@@ -0,0 +1,227 @@
1 /**
2 * Cloud accounts: a credential is verified read-only before it is sealed,
3 * never listed with its value, every open is a visible read, an audit opens
4 * issues and changes nothing, and a run that needs writes is refused while
5 * the switch is off.
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 {
14 addAccount,
15 allowWrite,
16 auditAccount,
17 net,
18 removeAccount,
19 reverify,
20 } from "../src/actions/cloud.ts";
21 import { createRepo } from "../src/actions/repos.ts";
22 import { verify } from "../src/cloud/providers.ts";
23 import { listCloudAccounts, recentCloudReads } from "../src/core/cloud_account.ts";
24 import { listSecretNames } from "../src/core/secret_store.ts";
25 import { createUser, type User } from "../src/core/users.ts";
26 import { grantSecrets, parseDeclaration } from "../src/crew/grant.ts";
27 import { serve, type Servers } from "../src/http/server.ts";
28 import { quietLogs } from "./helpers/globals.ts";
29
30 quietLogs();
31
32 const TOKEN = "do-live-9Zq4TxLm7Kp2Vb8nWcR5";
33 const calls: { url: string; method: string; auth: string }[] = [];
34
35 /** A fake DigitalOcean: records every request, answers like the real API. */
36 net.fetch = async (url, init) => {
37 calls.push({
38 url,
39 method: init?.method ?? "GET",
40 auth: String((init?.headers as Record<string, string> | undefined)?.authorization ?? ""),
41 });
42 const bad = !String((init?.headers as any)?.authorization).includes(TOKEN);
43 const u = new URL(url);
44 const reply = (o: unknown) => new Response(JSON.stringify(o), { status: bad ? 401 : 200 });
45 if (u.pathname === "/v2/account") return reply({ account: { email: "[email protected]", status: "active" } });
46 if (u.pathname === "/v2/projects") return reply({ projects: [{}, {}] });
47 if (u.pathname === "/v2/droplets") return reply({ meta: { total: 3 }, droplets: [{ name: "old", status: "off", size_slug: "s-2vcpu-4gb", size: { price_monthly: 24 } }] });
48 if (u.pathname === "/v2/volumes") return reply({ volumes: [{ name: "vol1", size_gigabytes: 100, region: { slug: "nyc1" }, droplet_ids: [] }, { name: "vol2", size_gigabytes: 10, droplet_ids: [1] }] });
49 if (u.pathname === "/v2/reserved_ips") return reply({ reserved_ips: [] });
50 return new Response("{}", { status: 404 });
51 };
52
53 describe("cloud accounts", () => {
54 let dataDir = "";
55 let app: App;
56 let servers: Servers;
57 let alice: User;
58 let bob: User;
59 let repoId = "";
60
61 beforeAll(async () => {
62 dataDir = await mkdtemp(join(tmpdir(), "forge-cloud-"));
63 app = createApp({ dataDir, port: 0 });
64 servers = await serve(app);
65 const a = await createUser(app.db, { username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
66 const b = await createUser(app.db, { username: "bob", email: "[email protected]", password: "a-good-passphrase" });
67 if (!a.ok || !b.ok) throw new Error("users");
68 alice = a.value;
69 bob = b.value;
70 const r = await createRepo(app, alice, { name: "ops", visibility: "private" });
71 if (!r.ok) throw new Error("repo");
72 repoId = r.value.id;
73 });
74 afterAll(async () => {
75 servers.stop();
76 app.close();
77 await rm(dataDir, { recursive: true, force: true });
78 });
79
80 test("a rejected credential is not stored; only admins may add", async () => {
81 expect((await addAccount(app, alice, "digitalocean", "main", "wrong-token-123")).ok).toBe(false);
82 expect(listCloudAccounts(app.db)).toEqual([]);
83 expect(listSecretNames(app.db, "forge")).toEqual([]);
84 const denied = await addAccount(app, bob, "digitalocean", "main", TOKEN);
85 expect(denied).toEqual({ ok: false, error: "not_found" });
86 expect((await addAccount(app, alice, "nope", "main", TOKEN)).ok).toBe(false);
87 expect((await addAccount(app, alice, "digitalocean", "Bad Name", TOKEN)).ok).toBe(false);
88 });
89
90 test("verify then seal: one sentence, only GETs, value never listed", async () => {
91 calls.length = 0;
92 const r = await addAccount(app, alice, "digitalocean", "main", TOKEN);
93 expect(r.ok).toBe(true);
94 expect(r.ok && r.value).toContain("[email protected]");
95 expect(r.ok && r.value).toContain("2 projects");
96 expect(calls.every((c) => c.method === "GET")).toBe(true);
97 expect(listSecretNames(app.db, "forge").map((s) => s.name)).toEqual(["CLOUD_DIGITALOCEAN_MAIN"]);
98 const [acct] = listCloudAccounts(app.db);
99 expect(acct).toMatchObject({ provider: "digitalocean", name: "main", writeAllowed: false });
100 expect(JSON.stringify(acct)).not.toContain(TOKEN);
101 });
102
103 test("a re-verify and an audit are visible reads naming their run", async () => {
104 expect((await reverify(app, alice, "digitalocean", "main")).ok).toBe(true);
105 expect((await reverify(app, bob, "digitalocean", "main")).ok).toBe(false);
106 const done = await auditAccount(app, alice, "digitalocean", "main", "alice/ops");
107 expect(done).toEqual({ ok: true, value: { opened: 2, found: 2 } });
108 const runs = recentCloudReads(app.db).map((r) => r.run ?? "");
109 expect(runs.some((x) => x === "verify:digitalocean")).toBe(true);
110 expect(runs.some((x) => x.startsWith("audit:digitalocean:"))).toBe(true);
111 expect(JSON.stringify(recentCloudReads(app.db))).not.toContain(TOKEN);
112 expect(calls.every((c) => c.method === "GET")).toBe(true);
113 });
114
115 test("an audit files one issue per finding, with the saving, and does not duplicate", async () => {
116 const titles = app.db
117 .query<{ title: string; body: string }, [string]>("SELECT title, body FROM issue WHERE repo_id = ?1 ORDER BY number")
118 .all(repoId);
119 expect(titles.map((t) => t.title)).toEqual([
120 "[audit] DigitalOcean: unattached volume vol1",
121 "[audit] DigitalOcean: powered-off droplet old",
122 ]);
123 expect(titles[0]!.body).toContain("$10.00 a month");
124 expect(titles[1]!.body).toContain("$24.00 a month");
125 const again = await auditAccount(app, alice, "digitalocean", "main", "alice/ops");
126 expect(again).toEqual({ ok: true, value: { opened: 0, found: 2 } });
127 expect((await auditAccount(app, alice, "digitalocean", "main", "alice/missing")).ok).toBe(false);
128 });
129
130 test("a run that needs writes is refused while the switch is off, and says so", () => {
131 const run = { id: "r1", repoId, ownerId: alice.id, actorId: alice.id };
132 const reads = () => recentCloudReads(app.db).length;
133 const before = reads();
134 const refused = grantSecrets(app.db, dataDir, app.bus, parseDeclaration("---\nsecrets: CLOUD_DIGITALOCEAN_MAIN\nwrites: yes\n---"), run);
135 expect(refused.writeRefused).toEqual(["CLOUD_DIGITALOCEAN_MAIN"]);
136 expect(refused.env).toEqual({});
137 expect(reads()).toBe(before);
138
139 // Read-only runs are the default and get it.
140 const ro = grantSecrets(app.db, dataDir, app.bus, parseDeclaration("---\nsecrets: CLOUD_DIGITALOCEAN_MAIN\n---"), { ...run, id: "r2" });
141 expect(ro.names).toEqual(["CLOUD_DIGITALOCEAN_MAIN"]);
142 ro.release();
143
144 expect(allowWrite(app, bob, "digitalocean", "main", true).ok).toBe(false);
145 expect(allowWrite(app, alice, "digitalocean", "main", true).ok).toBe(true);
146 const rw = grantSecrets(app.db, dataDir, app.bus, parseDeclaration("---\nsecrets: CLOUD_DIGITALOCEAN_MAIN\nwrites: yes\n---"), { ...run, id: "r3" });
147 expect(rw.writeRefused).toEqual([]);
148 expect(rw.names).toEqual(["CLOUD_DIGITALOCEAN_MAIN"]);
149 rw.release();
150 allowWrite(app, alice, "digitalocean", "main", false);
151 });
152
153 test("the page shows names and reads, never the value, and only to admins", async () => {
154 const login = async (username: string) =>
155 ((await fetch(`${servers.url}/signin`, {
156 method: "POST",
157 redirect: "manual",
158 headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url },
159 body: new URLSearchParams({ username, password: "a-good-passphrase" }),
160 })).headers.get("set-cookie") ?? "").split(";")[0] ?? "";
161 const get = async (c: string) => fetch(`${servers.url}/settings/cloud`, { headers: { cookie: c } });
162 const page = await (await get(await login("alice"))).text();
163 expect(page).toContain("Cloud accounts");
164 expect(page).toContain("DigitalOcean");
165 expect(page).toContain("verify");
166 expect(page).toContain("read-only");
167 expect(page).not.toContain(TOKEN);
168 expect((await get(await login("bob"))).status).toBe(404);
169 const cross = await fetch(`${servers.url}/settings/cloud/write`, {
170 method: "POST",
171 redirect: "manual",
172 headers: { cookie: await login("alice"), "content-type": "application/x-www-form-urlencoded", origin: "https://evil.example" },
173 body: new URLSearchParams({ provider: "digitalocean", name: "main", allowed: "1" }),
174 });
175 expect(cross.status).toBe(403);
176 expect(listCloudAccounts(app.db)[0]!.writeAllowed).toBe(false);
177 });
178
179 test("remove drops the sealed value and the row", () => {
180 expect(removeAccount(app, bob, "digitalocean", "main").ok).toBe(false);
181 expect(removeAccount(app, alice, "digitalocean", "main").ok).toBe(true);
182 expect(listCloudAccounts(app.db)).toEqual([]);
183 expect(listSecretNames(app.db, "forge")).toEqual([]);
184 });
185 });
186
187 describe("providers", () => {
188 test("backblaze reports the key's own capabilities, and that it can write", async () => {
189 const f = async (url: string) =>
190 new Response(
191 JSON.stringify(
192 url.includes("authorize")
193 ? { apiUrl: "https://api001.backblazeb2.com", accountId: "a", authorizationToken: "t", allowed: { capabilities: ["listBuckets", "writeFiles"] } }
194 : { buckets: [{}] },
195 ),
196 );
197 const r = await verify("backblaze", "id:key", f);
198 expect(r.ok && r.value.canWrite).toBe(true);
199 expect(r.ok && r.value.summary).toContain("CAN write");
200 });
201
202 test("backblaze refuses an api host outside backblazeb2.com", async () => {
203 const f = async () => new Response(JSON.stringify({ apiUrl: "https://evil.example", allowed: {} }));
204 expect((await verify("backblaze", "id:key", f)).ok).toBe(false);
205 });
206
207 test("aws signs a GetCallerIdentity and reads the account", async () => {
208 let seen: { url: string; auth: string; body: string } | undefined;
209 const f = async (url: string, init?: RequestInit) => {
210 seen = { url, auth: String((init?.headers as any).authorization), body: String(init?.body) };
211 return new Response("<Account>123456789012</Account><Arn>arn:aws:iam::123456789012:user/ro</Arn>");
212 };
213 const r = await verify("aws", "AKIDEXAMPLE:secret", f);
214 expect(r.ok && r.value.summary).toContain("user/ro");
215 expect(seen!.url).toBe("https://sts.amazonaws.com/");
216 expect(seen!.body).toBe("Action=GetCallerIdentity&Version=2011-06-15");
217 expect(seen!.auth).toMatch(/^AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE\/\d{8}\/us-east-1\/sts\/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=[0-9a-f]{64}$/);
218 });
219
220 test("a malformed multi-part credential is refused before any request", async () => {
221 let n = 0;
222 const f = async () => (n++, new Response("{}"));
223 expect((await verify("aws", "no-colon", f)).ok).toBe(false);
224 expect((await verify("azure", "a:b", f)).ok).toBe(false);
225 expect(n).toBe(0);
226 });
227 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs