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

Clouds, step 2: Settings → Cloud accounts attaches GitHub, Cloudflare and Vercel tokens, sealed, with one audit line per vendor call kept for 90 days #2185

Merged opened this

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

Merged

scout/forge:scout/2177-clouds-step-2-settings-cloud-accounts-at → main 3 commits fork 5 threads · 5 unresolved

  • Merged. scout/2177-clouds-step-2-settings-cloud-accounts-at was merged into main as 5a187ed.

Review

Sign in to review this change.
14 files changed +721 −14
README.md +17 −2
@@ -749,7 +749,7 @@ something only a passport may reach.
749749 ## Cloud accounts
750750
751751 **Settings → Cloud accounts** (admins) holds provider keys — Cloudflare,
752 DigitalOcean, AWS, Azure, Fly, Backblaze — sealed and write-only. Pasting one
752 DigitalOcean, AWS, Azure, Fly, Backblaze, GitHub, Vercel — sealed and write-only. Pasting one
753753 makes the forge check it with read-only calls (listed in the header of
754754 `src/cloud/providers.ts`) and say what it can see; a key the provider rejects
755755 is not stored. It is sealed at forge scope as `CLOUD_<PROVIDER>_<NAME>` (secret
@@ -765,7 +765,22 @@ volumes, powered-off droplets, unassociated IPs, never-active zones) with a
765765 monthly saving estimate, skipping findings already open. It runs in the forge,
766766 not as a crew run; AWS audits look at us-east-1 only; over-provisioned
767767 droplets and forgotten DNS records need metrics or resolution and are not
768 attempted; Backblaze audits find nothing yet. No dependency was added.
768 attempted; Backblaze, GitHub and Vercel audits find nothing yet. No dependency was added.
769
770 Each account can be **rotated** (the new value is checked, then replaces the
771 old) and **renamed** (re-sealed under the new name, write switch kept). Each
772 says in one line what it is used for now — the `cloud.json` declarations on
773 the default branch of the first 100 repositories the admin can see, cached a minute — and
774 carries a "bring it home" note (`HOME` in `src/cloud/declared.ts`). Every
775 vendor request goes through `src/cloud/gate.ts`: an admin's verify or audit is
776 audited as the operator; a hand-out of a key to a run is allowed only if that
777 repository's `cloud.json` declares the key's vendor (`VENDOR_OF`: the provider's name, but AWS and Backblaze are `s3`), and an undeclared one is
778 refused before the key is opened. One `cloud_audit` row per request (account,
779 purpose, host and path, bytes out, when, allowed or refused — no token, no
780 query) is listed at **Settings → Cloud accounts → Every vendor call today**.
781 Rows are pruned past 90 days on every write; `at` is indexed. Left out: one
782 token per vendor is not enforced (several names per vendor stay allowed), and
783 an agent's own vendor calls beyond the hand-out are not metered. Cloud accounts have no terminal or MCP door yet.
769784
770785 ## Share to the forge
771786
docs/CLOUD.md +6 −1
@@ -18,7 +18,7 @@ Beside `forge.json`, at the root of the repository:
1818 { "uses": [{ "vendor": "s3", "use": "backup", "why": "off-machine copy of the encrypted snapshot" }] }
1919 ```
2020
21 - `vendor`: `cloudflare`, `fly`, `hetzner` or `s3`.
21 - `vendor`: `cloudflare`, `fly`, `hetzner`, `s3`, `github`, `vercel`, `digitalocean` or `azure`.
2222 - `use`: `compute`, `storage`, `backup`, `dns` or `cdn`.
2323 - `why`: one line, up to 300 characters.
2424
@@ -30,3 +30,8 @@ No file means no uses. A malformed file is refused with one sentence
3030 `mayUse(declared, vendor, use)` in `src/apps/cloud.ts` is the only way a
3131 vendor call is allowed. Later steps call it before every vendor request; it is
3232 false unless that exact vendor and use are declared.
33
34 A key attached under Settings → Cloud accounts is handed to a run only when
35 the run's repository declares the key's vendor. A provider's vendor is
36 `VENDOR_OF` in `src/cloud/providers.ts`: the provider's own name, except that
37 AWS and Backblaze B2 are `s3` (both speak the S3 interface).
src/actions/cloud.ts +56 −3
@@ -8,11 +8,14 @@
88 */
99
1010 import type { App } from "../app.ts";
11 import { gatedFetch } from "../cloud/gate.ts";
1112 import { audit, isProvider, verify, type Fetch, type Provider } from "../cloud/providers.ts";
1213 import {
1314 CLOUD_NAME,
1415 forgetCloudAccount,
16 getCloudAccount,
1517 recordVerified,
18 renameCloudAccount,
1619 secretNameOf,
1720 setWriteAllowed,
1821 } from "../core/cloud_account.ts";
@@ -40,6 +43,10 @@ function target(
4043 return ok({ provider, name, secret: secretNameOf(provider, name) });
4144 }
4245
46 /** The network, behind the gate: one audit row per request, as the operator. */
47 const gated = (app: App, t: { provider: Provider; name: string }, purpose: string): Fetch =>
48 gatedFetch(app.db, { provider: t.provider, account: t.name }, purpose, net.fetch);
49
4350 /** Verify, then seal. Returns the one-sentence summary. */
4451 export async function addAccount(
4552 app: App,
@@ -52,7 +59,7 @@ export async function addAccount(
5259 if (!who.ok) return who;
5360 const t = target(provider, name);
5461 if (!t.ok) return t;
55 const v = await verify(t.value.provider, value, net.fetch);
62 const v = await verify(t.value.provider, value, gated(app, t.value, "verify"));
5663 if (!v.ok) return v;
5764 const set = putSecret(app, who.value, "forge", "", t.value.secret, value);
5865 if (!set.ok) return err("That credential cannot be stored.");
@@ -68,6 +75,52 @@ function open(app: App, actor: User, secret: string, run: string): string | unde
6875 })[secret];
6976 }
7077
78 /** Replace the sealed value of an attached account, after the same check. */
79 export async function rotateAccount(
80 app: App,
81 actor: User | undefined,
82 provider: string,
83 name: string,
84 value: string,
85 ): Promise<Result<string, string>> {
86 const who = admin(actor);
87 if (!who.ok) return who;
88 const t = target(provider, name);
89 if (!t.ok) return t;
90 if (!getCloudAccount(app.db, t.value.provider, t.value.name)) return err("No such credential.");
91 const v = await verify(t.value.provider, value, gated(app, t.value, "verify"));
92 if (!v.ok) return v;
93 const set = putSecret(app, who.value, "forge", "", t.value.secret, value);
94 if (!set.ok) return err("That credential cannot be stored.");
95 recordVerified(app.db, t.value.provider, t.value.name, v.value.summary);
96 return ok(v.value.summary);
97 }
98
99 /** Give an account a new name; the sealed value and the write switch move with it. */
100 export function renameAccount(
101 app: App,
102 actor: User | undefined,
103 provider: string,
104 name: string,
105 to: string,
106 ): Result<void, string> {
107 const who = admin(actor);
108 if (!who.ok) return who;
109 const from = target(provider, name);
110 if (!from.ok) return from;
111 const dest = target(provider, to);
112 if (!dest.ok) return dest;
113 if (!getCloudAccount(app.db, from.value.provider, from.value.name)) return err("No such credential.");
114 if (getCloudAccount(app.db, dest.value.provider, dest.value.name)) return err("An account has that name already.");

Low: this only checks the cloud_account row. If a plain forge secret named CLOUD_<PROVIDER>_<TO> already exists, the putSecret below silently overwrites it. Also refuse when that secret name is already in the store. Tracked in #2194.

115 const value = open(app, who.value, from.value.secret, `rename:${from.value.provider}`);
116 if (value === undefined) return err("No such credential.");
117 const set = putSecret(app, who.value, "forge", "", dest.value.secret, value);
118 if (!set.ok) return err("That credential cannot be stored.");
119 renameCloudAccount(app.db, from.value.provider, from.value.name, dest.value.name);
120 removeSecret(app, who.value, "forge", "", from.value.secret);
121 return ok();
122 }
123
71124 export async function reverify(
72125 app: App,
73126 actor: User | undefined,
@@ -80,7 +133,7 @@ export async function reverify(
80133 if (!t.ok) return t;
81134 const value = open(app, who.value, t.value.secret, `verify:${t.value.provider}`);
82135 if (value === undefined) return err("No such credential.");
83 const v = await verify(t.value.provider, value, net.fetch);
136 const v = await verify(t.value.provider, value, gated(app, t.value, "verify"));
84137 if (!v.ok) return v;
85138 recordVerified(app.db, t.value.provider, t.value.name, v.value.summary);
86139 return ok(v.value.summary);
@@ -141,7 +194,7 @@ export async function auditAccount(
141194 if (!repo) return err("Name a repository as owner/name to file the findings in.");
142195 const value = open(app, who.value, t.value.secret, `audit:${t.value.provider}:${id()}`);
143196 if (value === undefined) return err("No such credential.");
144 const found = await audit(t.value.provider, value, net.fetch);
197 const found = await audit(t.value.provider, value, gated(app, t.value, "audit"));
145198 if (!found.ok) return found;
146199 let opened = 0;
147200 for (const f of found.value) {
src/apps/manifest.ts +1 −1
@@ -366,7 +366,7 @@ export function parseCheck(
366366 }
367367
368368 /** The vendors a repository may name in `cloud.json`; a closed set. */
369 export const CLOUD_VENDORS = ["cloudflare", "fly", "hetzner", "s3"] as const;
369 export const CLOUD_VENDORS = ["cloudflare", "fly", "hetzner", "s3", "github", "vercel", "digitalocean", "azure"] as const;
370370 /** What a vendor may be used for; a closed set. */
371371 export const CLOUD_USES = ["compute", "storage", "backup", "dns", "cdn"] as const;
372372 export type CloudVendor = (typeof CLOUD_VENDORS)[number];
src/cloud/declared.ts added +91
@@ -0,0 +1,91 @@
1 /**
2 * What `cloud.json` declares, read from a repository's default branch, and
3 * what each vendor is used for now across the repositories an admin can see.
4 * A missing, unreadable or refused file declares nothing. Also the "bring it
5 * home" note per vendor: the way back to running without it.
6 */
7
8 import type { Database } from "bun:sqlite";
9 import { readFileAt } from "../apps/checkout.ts";
10 import { parseCloud, type CloudDeclaration } from "../apps/manifest.ts";
11 import { listReposVisibleTo, type Repo } from "../core/repos.ts";
12 import type { User } from "../core/users.ts";
13 import { resolveOid } from "../git/merge.ts";
14 import { repoDir } from "../git/repos.ts";
15 import type { Provider } from "./providers.ts";
16
17 export const HOME: Record<Provider, string> = {
18 cloudflare: "Bring it home: point DNS back at your own nameservers, then remove the token.",
19 digitalocean: "Bring it home: snapshot the droplets to your own disk, then remove the token.",
20 aws: "Bring it home: copy the volumes and buckets to your own disk, then remove the key.",
21 azure: "Bring it home: export the disks to your own disk, then remove the principal.",
22 fly: "Bring it home: run the app image on your own machine, then remove the token.",
23 backblaze: "Bring it home: copy the buckets to your own disk, then remove the key.",
24 github: "Bring it home: the forge holds the full git history; push it to your own remote and remove the token.",
25 vercel: "Bring it home: build the site on your own machine and serve it from the forge, then remove the token.",
26 };
27
28 export async function declarationsOf(
29 reposRoot: string,
30 repo: Repo,
31 ): Promise<readonly CloudDeclaration[]> {
32 const bare = repoDir(reposRoot, repo.ownerName, repo.name);
33 const tip = await resolveOid(bare, `refs/heads/${repo.defaultBranch}`);
34 if (tip === undefined) return [];
35 const parsed = parseCloud(await readFileAt(bare, tip, "cloud.json"));
36 return parsed.ok ? parsed.value : [];
37 }
38
39 export interface VendorUse {
40 readonly repo: string;
41 readonly use: string;
42 readonly why: string;
43 }
44
45 /** Declarations per repository, kept this long: the page never waits on git twice a minute. */
46 const TTL_MS = 60_000;
47 const cache = new Map<string, { at: number; uses: readonly CloudDeclaration[] }>();
48
49 async function cached(
50 reposRoot: string,
51 repo: Repo,
52 now: number,
outdated

Medium: this runs on every GET /settings/cloud and spawns git rev-parse per repository, up to 100 times, before first paint. Cache the result per repository, keyed by its default-branch tip, so the common load answers in milliseconds. This loop also groups by vendor, while the page looks it up by provider (uses.get(a.provider)). That mismatch is the same bug as in gate.ts.

53 ): Promise<readonly CloudDeclaration[]> {
54 const key = `${reposRoot}\0${repo.id}`;
55 const hit = cache.get(key);
56 if (hit && now - hit.at < TTL_MS) return hit.uses;
57 const uses = await declarationsOf(reposRoot, repo);
58 cache.set(key, { at: now, uses });
59 return uses;
60 }
61
62 /**
63 * Every declared use, by `cloud.json` vendor (look an account up with
64 * `VENDOR_OF[provider]`), across at most 100 repositories. Cached for a
65 * minute per repository, so the common load spawns no git.
66 */
67 export async function usesByVendor(
68 db: Database,
69 reposRoot: string,
70 admin: User,
71 now: number = Date.now(),
72 ): Promise<Map<string, VendorUse[]>> {
73 const repos = listReposVisibleTo(db, admin, { limit: 100 });
74 const all = await Promise.all(repos.map((r) => cached(reposRoot, r, now)));
75 const out = new Map<string, VendorUse[]>();
76 repos.forEach((repo, i) => {
77 for (const d of all[i]!) {
78 const list = out.get(d.vendor) ?? [];
79 list.push({ repo: `${repo.ownerName}/${repo.name}`, use: d.use, why: d.why });
80 out.set(d.vendor, list);
81 }
82 });
83 return out;
84 }
85
86 /** One line: what a vendor is used for now. */
87 export function usedFor(uses: readonly VendorUse[] | undefined): string {
88 if (!uses || uses.length === 0) return "Used for nothing now: no repository declares it.";
89 const shown = uses.slice(0, 3).map((u) => `${u.use} by ${u.repo} (${u.why})`);
90 return `Used for ${shown.join("; ")}${uses.length > 3 ? `; and ${uses.length - 3} more` : ""}.`;
91 }
src/cloud/gate.ts added +99
@@ -0,0 +1,99 @@
1 /**
2 * The gate every vendor call goes through (docs/CLOUD.md). A call is either
3 * an operator's (an admin verifying or auditing a token they attached) or a
4 * hand-out to a run, which `cloud.json` must declare for the key's vendor.
5 * Each writes one audit row; a refused hand-out never opens the key. A token is not an argument here, so it
6 * cannot reach a row or a message.
7 */
8
9 import type { Database } from "bun:sqlite";
10 import type { CloudDeclaration } from "../apps/manifest.ts";
11 import { accountOfSecret } from "../core/cloud_account.ts";
12 import { recordAudit } from "../core/cloud_audit.ts";
13 import { VENDOR_OF, isProvider, type Fetch } from "./providers.ts";
14
15 export interface Who {
16 readonly provider: string;
17 readonly account: string;
18 }
19
20 /** A `Fetch` for an admin's own verify or audit: one audit row per request. */
21 export function gatedFetch(
22 db: Database,
23 who: Who,
24 purpose: string,
25 f: Fetch,
26 ): Fetch {
27 return async (url, init) => {
28 const u = new URL(url);
29 recordAudit(db, {
30 provider: who.provider,
31 account: who.account,
32 purpose,
33 target: `${u.host}${u.pathname}`,
34 bytesOut: bytesOf(init),
35 allowed: true,
36 });
37 return f(url, init);
38 };
39 }
40
41 /** Bytes a request body puts on the wire. */
42 function bytesOf(init: RequestInit | undefined): number {
43 const b = init?.body;
44 if (b === undefined || b === null) return 0;
45 if (typeof b === "string") return Buffer.byteLength(b);
46 if (b instanceof URLSearchParams) return Buffer.byteLength(b.toString());
47 if (b instanceof ArrayBuffer || ArrayBuffer.isView(b)) return b.byteLength;
48 return 0;
49 }
50
51 /**
52 * A hand-out of a token to a run: allowed when `cloud.json` declares the
53 * provider's vendor (`VENDOR_OF`) for any use, audited either way. Returns
54 * whether to open the value.
55 */
56 export function handOut(
57 db: Database,
58 who: Who,
59 runId: string,
60 declared: readonly CloudDeclaration[] | undefined,
61 ): boolean {
62 const vendor = isProvider(who.provider) ? VENDOR_OF[who.provider] : undefined;
63 const use =
64 vendor === undefined
65 ? undefined
66 : declared?.find((d) => d.vendor === vendor)?.use;
67 const ok = use !== undefined;
68 recordAudit(db, {
69 provider: who.provider,
70 account: who.account,
71 purpose: `hand-out:${use ?? "undeclared"}`,
72 target: `run ${runId}`,
73 bytesOut: 0,
74 allowed: ok,
75 });
outdated

High: d.vendor comes from the cloud.json set (s3, hetzner, …) and who.provider from the provider set (aws, digitalocean, azure, backblaze, …). For aws, digitalocean, azure and backblaze this never matches, so their keys can never be handed out, and a repository declaring s3 gets nothing. Map each provider to its vendor explicitly, refuse providers that have no vendor with a clear message, and test the aws→s3 case.

76 return ok;
77 }
78
79 /**
80 * What a run is asked to hold, minus every cloud key its repository does not
81 * declare: those are named in `refused` and never reach `grantSecrets`, so
82 * they are never opened. Each cloud name is audited.
83 */
84 export function bar<T extends { names: readonly string[]; refused: readonly string[] }>(
85 db: Database,
86 asked: T,
87 runId: string,
88 declared: readonly CloudDeclaration[],
89 ): T {
90 const barred = asked.names.filter((n) => {
91 const a = accountOfSecret(n);
92 return a !== undefined && !handOut(db, { provider: a.provider, account: a.name }, runId, declared);
93 });
94 return {
95 ...asked,
96 names: asked.names.filter((n) => !barred.includes(n)),
97 refused: [...asked.refused, ...barred.map((n) => `${n}: its vendor is not declared in cloud.json`)],
98 };
99 }
src/cloud/providers.ts +45
@@ -13,6 +13,8 @@
1313 * (client-credentials sign-in), GET management.azure.com
1414 * /subscriptions, /disks, /publicIPAddresses
1515 * fly POST api.fly.io/graphql — `query` documents only
16 * github GET api.github.com/user
17 * vercel GET api.vercel.com/v2/user
1618 * backblaze GET api.backblazeb2.com/b2api/v2/b2_authorize_account, then
1719 * GET <apiUrl>/b2api/v2/b2_list_buckets (apiUrl must be a
1820 * *.backblazeb2.com host)
@@ -22,6 +24,7 @@
2224 */
2325
2426 import { createHmac, createHash } from "node:crypto";
27 import type { CloudVendor } from "../apps/manifest.ts";
2528 import { err, ok, type Result } from "../kernel/result.ts";
2629
2730 export const PROVIDERS = [
@@ -31,9 +34,27 @@ export const PROVIDERS = [
3134 "azure",
3235 "fly",
3336 "backblaze",
37 "github",
38 "vercel",
3439 ] as const;
3540 export type Provider = (typeof PROVIDERS)[number];
3641
42 /**
43 * The `cloud.json` vendor each provider's key is declared under. Total, so no
44 * attached key is unreachable: the provider's own name, but AWS and
45 * Backblaze B2 are both `s3`.
46 */
47 export const VENDOR_OF: Record<Provider, CloudVendor> = {
48 cloudflare: "cloudflare",
49 digitalocean: "digitalocean",
50 aws: "s3",
51 azure: "azure",
52 fly: "fly",
53 backblaze: "s3",
54 github: "github",
55 vercel: "vercel",
56 };
57
3758 export function isProvider(v: string): v is Provider {
3859 return (PROVIDERS as readonly string[]).includes(v);
3960 }
@@ -45,6 +66,8 @@ export const LABEL: Record<Provider, string> = {
4566 azure: "Azure",
4667 fly: "Fly.io",
4768 backblaze: "Backblaze B2",
69 github: "GitHub",
70 vercel: "Vercel",
4871 };
4972
5073 /** What to paste, per provider, in a sentence. */
@@ -55,6 +78,8 @@ export const shape: Record<Provider, string> = {
5578 azure: "TENANT_ID:CLIENT_ID:CLIENT_SECRET of a Reader service principal",
5679 fly: "a deploy or read-only org token",
5780 backblaze: "KEY_ID:APPLICATION_KEY with read-only capabilities",
81 github: "a fine-grained personal access token with read-only repository access",
82 vercel: "an access token scoped to one team",
5883 };
5984
6085 export type Fetch = (url: string, init?: RequestInit) => Promise<Response>;
@@ -248,6 +273,22 @@ export async function verify(
248273 canWrite: null,
249274 });
250275 }
276 case "github": {
277 const u = await json(f, "https://api.github.com/user", {
278 headers: { ...bearer(value), accept: "application/vnd.github+json", "user-agent": "forge" },
279 });
280 return ok({
281 summary: `GitHub token belongs to ${String(u.login ?? "?").slice(0, 80)}; GitHub does not say what a fine-grained token may write here.`,
282 canWrite: null,
283 });
284 }
285 case "vercel": {
286 const u = await json(f, "https://api.vercel.com/v2/user", { headers: bearer(value) });
287 return ok({
288 summary: `Vercel token belongs to ${String(u.user?.username ?? "?").slice(0, 80)}; Vercel does not expose a token's scope.`,
289 canWrite: null,
290 });
291 }
251292 case "backblaze": {
252293 const c = await b2(f, value);
253294 if (!c) return err("A Backblaze credential is KEY_ID:APPLICATION_KEY.");
@@ -347,6 +388,10 @@ export async function audit(
347388 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);
348389 break;
349390 }
391 case "github":
392 case "vercel":
393 // Nothing to audit for cost; the token is only verified.
394 break;
350395 case "backblaze":
351396 // Bucket size needs a file listing per bucket; not attempted.
352397 break;
src/core/cloud_account.ts +12
@@ -121,6 +121,18 @@ export function setWriteAllowed(
121121 );
122122 }
123123
124 export function renameCloudAccount(
125 db: Database,
126 provider: string,
127 name: string,
128 to: string,
129 ): void {
130 table(db);
131 db.query(
132 "UPDATE cloud_account SET name = ?3 WHERE provider = ?1 AND name = ?2",
133 ).run(provider, name, to);
134 }
135
124136 export function forgetCloudAccount(
125137 db: Database,
126138 provider: string,
src/core/cloud_audit.ts added +87
@@ -0,0 +1,87 @@
1 /**
2 * The cloud audit: one row per vendor call or hand-out, allowed or refused
3 * (`cloud/gate.ts` writes it). Rows older than `KEEP_DAYS` are pruned on
4 * write, so the table cannot grow without bound; `at` is indexed. A row
5 * names the account and the host and path called — never a token, never a
6 * query string.
7 */
8
9 import type { Database } from "bun:sqlite";
10
11 export const KEEP_DAYS = 90;
12
13 export interface AuditRow {
14 readonly id: number;
15 readonly at: string;
16 readonly provider: string;
17 readonly account: string;
18 readonly purpose: string;
19 readonly target: string;
20 readonly bytesOut: number;
21 readonly allowed: boolean;
22 }
23
24 const iso = (ms: number) => new Date(ms).toISOString();
25
26 export function recordAudit(
27 db: Database,
28 row: Omit<AuditRow, "id" | "at">,
29 now: number = Date.now(),
30 ): void {
31 db.query(
32 `INSERT INTO cloud_audit (at, provider, account, purpose, target, bytes_out, allowed)
33 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`,
34 ).run(
35 iso(now),
36 row.provider,
37 row.account,
38 row.purpose.slice(0, 80),
39 row.target.slice(0, 200),
40 Math.max(0, Math.floor(row.bytesOut)),
41 row.allowed ? 1 : 0,
42 );
43 pruneAudit(db, now);
44 }
45
46 /** Drop rows older than 90 days; the `at` index makes it a range delete. */
47 export function pruneAudit(db: Database, now: number = Date.now()): number {
48 return db
49 .query("DELETE FROM cloud_audit WHERE at < ?1")
50 .run(iso(now - KEEP_DAYS * 86_400_000)).changes;
51 }
52
53 /** Rows since the start of the UTC day of `now`, newest first. */
54 export function auditToday(
55 db: Database,
56 now: number = Date.now(),
57 limit = 500,
58 ): AuditRow[] {
59 const day = iso(now).slice(0, 10);
60 return db
61 .query<
62 {
63 id: number;
64 at: string;
65 provider: string;
66 account: string;
67 purpose: string;
68 target: string;
69 bytes_out: number;
70 allowed: number;
71 },
72 [string, number]
73 >(
74 "SELECT * FROM cloud_audit WHERE at >= ?1 ORDER BY at DESC, id DESC LIMIT ?2",
75 )
76 .all(`${day}T00:00:00.000Z`, limit)
77 .map((r) => ({
78 id: r.id,
79 at: r.at,
80 provider: r.provider,
81 account: r.account,
82 purpose: r.purpose,
83 target: r.target,
84 bytesOut: r.bytes_out,
85 allowed: r.allowed === 1,
86 }));
87 }
src/core/migrations/20261016160000-cloud-audit.ts added +17
@@ -0,0 +1,17 @@
1 // One row per vendor call the forge makes or hands out (#2177, cloud/gate.ts):
2 // which account, for what purpose, how many bytes went out, when, and whether
3 // the gate allowed it. Indexed on `at` because the page lists today's rows and
4 // the pruning on write drops rows past 90 days. Never holds a token or a query.
5 export default `
6 CREATE TABLE cloud_audit (
7 id INTEGER PRIMARY KEY,
8 at TEXT NOT NULL,
9 provider TEXT NOT NULL,
10 account TEXT NOT NULL,
11 purpose TEXT NOT NULL,
12 target TEXT NOT NULL DEFAULT '',
13 bytes_out INTEGER NOT NULL DEFAULT 0,
14 allowed INTEGER NOT NULL
15 );
16 CREATE INDEX cloud_audit_at ON cloud_audit (at);
17 `;
src/crew/runner.ts +8 −1
@@ -63,6 +63,9 @@ import { runGitCapture } from "../git/env.ts";
6363 import { looksForEnv } from "../core/looks.ts";
6464 import { aheadCount, resolveOid } from "../git/merge.ts";
6565 import { repoDir } from "../git/repos.ts";
66 import { bar } from "../cloud/gate.ts";
67 import { declarationsOf } from "../cloud/declared.ts";
68 import { accountOfSecret } from "../core/cloud_account.ts";
6669 import { alreadyDoneSummary, namedCommits, onBase } from "./already.ts";
6770 import { BUDGETS, budgetSummary } from "../kernel/budgets.ts";
6871 import { createLog, redactValues } from "../kernel/log.ts";
@@ -737,10 +740,14 @@ async function work(
737740 // redaction from this moment, dropped in the `finally` below — a crash
738741 // included.
739742 const author = getUserById(app.db, issue.authorId);
740 const declared = parseDeclaration(
743 const asked = parseDeclaration(
741744 agent.brief,
742745 author !== undefined && canWrite(app.db, repo, author) ? issue.body : "",
743746 );
747 // A cloud credential goes to a run only through the gate: the repository's
748 // cloud.json must declare the vendor, and either way a row is audited.
749 const hasCloud = asked.names.some((n) => accountOfSecret(n) !== undefined);
750 const declared = bar(app.db, asked, run.id, hasCloud ? await declarationsOf(app.reposRoot, repo) : []);

Good: the declarations come from the default branch, not from the run's branch, so a run cannot declare its own way to a key. Nothing here is integration-tested; bar and grantSecrets are tested directly. That is acceptable given that the runner needs a claude binary.

744751 grant = grantSecrets(app.db, app.config.dataDir, app.bus, declared, {
745752 id: run.id,
746753 repoId: repo.id,
src/http/server.ts +6
@@ -134,6 +134,9 @@ import { releasesPage, rollbackSubmit } from "../web/pages/releases.ts";
134134 import {
135135 cloudAdd,
136136 cloudAudit,
137 cloudAuditPage,
138 cloudRename,
139 cloudRotate,
137140 cloudPage,
138141 cloudRemove,
139142 cloudVerify,
@@ -746,6 +749,9 @@ export function buildRouter(): Router<Handler> {
746749 router.add("POST", "/settings/cloud/verify", cloudVerify);
747750 router.add("POST", "/settings/cloud/write", cloudWrite);
748751 router.add("POST", "/settings/cloud/audit", cloudAudit);
752 router.add("GET", "/settings/cloud/audit", cloudAuditPage);
753 router.add("POST", "/settings/cloud/rotate", cloudRotate);
754 router.add("POST", "/settings/cloud/rename", cloudRename);
749755 router.add("POST", "/settings/cloud/remove", cloudRemove);
750756 router.add("GET", "/settings/access", accessPage);
751757 router.add("GET", "/settings/resilience", domainsPage);
src/web/pages/cloud.ts +76 −6
@@ -11,9 +11,13 @@ import {
1111 allowWrite,
1212 auditAccount,
1313 removeAccount,
14 renameAccount,
1415 reverify,
16 rotateAccount,
1517 } from "../../actions/cloud.ts";
16 import { LABEL, PROVIDERS, shape } from "../../cloud/providers.ts";
18 import { LABEL, PROVIDERS, VENDOR_OF, isProvider, shape } from "../../cloud/providers.ts";
19 import { HOME, usedFor, usesByVendor, type VendorUse } from "../../cloud/declared.ts";
20 import { auditToday } from "../../core/cloud_audit.ts";
1721 import {
1822 listCloudAccounts,
1923 recentCloudReads,
@@ -37,20 +41,20 @@ function requireAdmin(ctx: Ctx): Response | undefined {
3741
3842 /** A run id in a read event, as words: audits and checks name themselves. */
3943 const runWords = (run: string | null): string =>
40 run === null ? "no run" : run.startsWith("audit:") ? `audit (${run.split(":")[1]})` : run.startsWith("verify:") ? "verify" : `run ${run}`;
44 run === null ? "no run" : run.startsWith("audit:") ? `audit (${run.split(":")[1]})` : run.startsWith("verify:") ? "verify" : run.startsWith("rename:") ? "rename" : `run ${run}`;
4145
4246 const hidden = (provider: string, name: string): Html =>
4347 html`<input type="hidden" name="provider" value="${provider}" />
4448 <input type="hidden" name="name" value="${name}" />`;
4549
46 function accountsCard(reads: CloudRead[], ctx: Ctx): Html {
50 function accountsCard(reads: CloudRead[], uses: Map<string, VendorUse[]>, ctx: Ctx): Html {
4751 const accounts = listCloudAccounts(ctx.app.db);
4852 const rows = accounts.map((a) => {
4953 const last = reads.find((r) => r.provider === a.provider && r.name === a.name);
5054 return row({
5155 lead: icon("key"),
5256 title: html`${LABEL[a.provider]} / <span class="mono">${a.name}</span>`,
53 meta: html`${a.summary}<br />verified ${a.verifiedAt ? time(a.verifiedAt) : "never"} ·
57 meta: html`${a.summary}<br />${usedFor(uses.get(VENDOR_OF[a.provider]))}<br />${HOME[a.provider]}<br />verified ${a.verifiedAt ? time(a.verifiedAt) : "never"} ·
5458 ${last ? html`last used ${time(last.at)} by ${runWords(last.run)}` : "never used"} ·
5559 ${a.writeAllowed ? "writes allowed" : "read-only"}`,
5660 trail: html`<div class="row-actions">
@@ -68,6 +72,19 @@ function accountsCard(reads: CloudRead[], ctx: Ctx): Html {
6872 aria-label="Repository for audit findings" maxlength="200" required />
6973 ${btn("Audit", { size: "sm", label: `Audit ${a.name}` })}
7074 </form>
75 <form method="post" action="/settings/cloud/rotate" class="row-actions">
76 ${hidden(a.provider, a.name)}
77 <input class="input" type="password" name="value" placeholder="New credential"
78 aria-label="New credential for ${a.name}" autocomplete="off" required />
79 ${btn("Rotate", { size: "sm", label: `Rotate ${a.name}` })}
80 </form>
81 <form method="post" action="/settings/cloud/rename" class="row-actions">
82 ${hidden(a.provider, a.name)}
83 <input class="input mono" type="text" name="to" placeholder="new name"
84 aria-label="New name for ${a.name}" pattern="[a-z][a-z0-9_]{0,31}" maxlength="32"
85 autocomplete="off" spellcheck="false" required />
86 ${btn("Rename", { size: "sm", label: `Rename ${a.name}` })}
87 </form>
7188 <form method="post" action="/settings/cloud/remove">
7289 ${hidden(a.provider, a.name)}${btn("Remove", { size: "sm", icon: "x", label: `Remove ${a.name}` })}
7390 </form>
@@ -124,17 +141,19 @@ function readsCard(reads: CloudRead[]): Html {
124141 }
125142
126143 // GET /settings/cloud
127 export function cloudPage(ctx: Ctx): Response {
144 export async function cloudPage(ctx: Ctx): Promise<Response> {
128145 const denied = requireAdmin(ctx);
129146 if (denied) return denied;
130147 const reads = recentCloudReads(ctx.app.db);
148 const uses = await usesByVendor(ctx.app.db, ctx.app.reposRoot, ctx.auth.user!);
131149 return page({
132150 ctx,
133151 title: "Cloud accounts",
134152 body: html`<div class="page-head"><h1>Cloud accounts</h1></div>
135153 ${settingsLayout(
136154 accountSections("cloud", { admin: true }),
137 html`${accountsCard(reads, ctx)}<div class="mt">${readsCard(reads)}</div>`,
155 html`${accountsCard(reads, uses, ctx)}<div class="mt">${readsCard(reads)}</div>
156 <p class="muted small mt"><a href="/settings/cloud/audit">Every vendor call today</a></p>`,
138157 "Account settings",
139158 )}`,
140159 });
@@ -189,6 +208,57 @@ export async function cloudAudit(ctx: Ctx): Promise<Response> {
189208 );
190209 }
191210
211 // GET /settings/cloud/audit
212 export function cloudAuditPage(ctx: Ctx): Response {
213 const denied = requireAdmin(ctx);
214 if (denied) return denied;
215 const rows = auditToday(ctx.app.db);
216 return page({
217 ctx,
218 title: "Cloud audit",
219 body: html`<div class="page-head"><h1>Cloud audit</h1></div>
220 ${settingsLayout(
221 accountSections("cloud", { admin: true }),
222 card(
223 rows.length === 0
224 ? html`<p class="muted small card-body">No vendor call today. Rows are kept 90 days.</p>`
225 : html`<ul class="list">
226 ${rows.map((r) =>
227 row({
228 lead: icon(r.allowed ? "eye" : "x"),
229 title: html`${r.allowed ? "Allowed" : "Refused"}: ${isProvider(r.provider) ? LABEL[r.provider] : r.provider} /
230 <span class="mono">${r.account}</span> for ${r.purpose}`,
231 meta: html`<span class="mono">${r.target}</span> · ${r.bytesOut} bytes out · ${time(r.at)}`,
232 }),
233 )}
234 </ul>`,
235 { head: html`${icon("eye")}<h2>Today's vendor calls</h2>`, pad: false },
236 ),
237 "Account settings",
238 )}`,
239 });
240 }
241
242 // POST /settings/cloud/rotate
243 export async function cloudRotate(ctx: Ctx): Promise<Response> {
244 const denied = requireAdmin(ctx);
245 if (denied) return denied;
246 const form = await readForm(ctx, "admin");
247 if (form instanceof Response) return form;
248 const r = await rotateAccount(ctx.app, ctx.auth.user, field(form, "provider"), field(form, "name"), field(form, "value").trim());
249 return said(ctx, r, r.ok ? `Rotated. ${r.value}` : "", r.ok ? undefined : r.error);
250 }
251
252 // POST /settings/cloud/rename
253 export async function cloudRename(ctx: Ctx): Promise<Response> {
254 const denied = requireAdmin(ctx);
255 if (denied) return denied;
256 const form = await readForm(ctx, "admin");
257 if (form instanceof Response) return form;
258 const r = renameAccount(ctx.app, ctx.auth.user, field(form, "provider"), field(form, "name"), field(form, "to").trim());
259 return said(ctx, r, "Renamed.", r.ok ? undefined : r.error);
260 }
261
192262 // POST /settings/cloud/remove
193263 export async function cloudRemove(ctx: Ctx): Promise<Response> {
194264 const denied = requireAdmin(ctx);
test/cloud-gate.test.ts added +200
@@ -0,0 +1,200 @@
1 /**
2 * Cloud accounts, step 2 (#2177): attach, rotate, rename and remove a token
3 * sealed; no page shows it; every vendor call is gated and audited; an
4 * undeclared one never reaches the network; the audit keeps 90 days.
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 {
13 addAccount,
14 net,
15 removeAccount,
16 renameAccount,
17 rotateAccount,
18 } from "../src/actions/cloud.ts";
19 import { createRepo } from "../src/actions/repos.ts";
20 import { usedFor } from "../src/cloud/declared.ts";
21 import { bar, gatedFetch, handOut } from "../src/cloud/gate.ts";
22 import { PROVIDERS, VENDOR_OF } from "../src/cloud/providers.ts";
23 import { CLOUD_VENDORS } from "../src/apps/manifest.ts";
24 import { grantSecrets, parseDeclaration } from "../src/crew/grant.ts";
25 import { auditToday, pruneAudit, recordAudit } from "../src/core/cloud_audit.ts";
26 import { listCloudAccounts } from "../src/core/cloud_account.ts";
27 import { listSecretNames, setSecret } from "../src/core/secret_store.ts";
28 import { createUser, type User } from "../src/core/users.ts";
29 import { serve, type Servers } from "../src/http/server.ts";
30 import { quietLogs } from "./helpers/globals.ts";
31
32 quietLogs();
33
34 const T1 = "ghp_first9Zq4TxLm7Kp2Vb8nWcR5";
35 const T2 = "ghp_second7Hs3DfGj6Lm1Qw0Zx8";
36 const seen: string[] = [];
37
38 net.fetch = async (url, init) => {
39 seen.push(url);
40 const auth = String((init?.headers as Record<string, string> | undefined)?.authorization ?? "");
41 const good = auth.includes(T1) || auth.includes(T2);
42 return new Response(JSON.stringify({ login: "octo" }), { status: good ? 200 : 401 });
43 };
44
45 describe("cloud gate", () => {
46 let dataDir = "";
47 let app: App;
48 let servers: Servers;
49 let alice: User;
50 let bob: User;
51 let repoId = "";
52
53 beforeAll(async () => {
54 dataDir = await mkdtemp(join(tmpdir(), "forge-cloud-gate-"));
55 app = createApp({ dataDir, port: 0 });
56 servers = await serve(app);
57 const a = await createUser(app.db, { trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
58 const b = await createUser(app.db, { trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" });
59 if (!a.ok || !b.ok) throw new Error("users");
60 alice = a.value;
61 bob = b.value;
62 const r = await createRepo(app, alice, { name: "ops", visibility: "private" });
63 if (!r.ok) throw new Error("repo");
64 repoId = r.value.id;
65 });
66 afterAll(async () => {
67 servers.stop();
68 app.close();
69 await rm(dataDir, { recursive: true, force: true });
70 });
71
72 test("attach, rotate, rename, remove — admin only, audited, sealed", async () => {
73 expect((await addAccount(app, bob, "github", "main", T1)).ok).toBe(false);
74 const added = await addAccount(app, alice, "github", "main", T1);
75 expect(added.ok && added.value).toContain("octo");
76 expect(listSecretNames(app.db, "forge").map((s) => s.name)).toEqual(["CLOUD_GITHUB_MAIN"]);
77
78 expect((await rotateAccount(app, alice, "github", "main", "bad-token-value")).ok).toBe(false);
79 expect((await rotateAccount(app, alice, "github", "nope", T2)).ok).toBe(false);
80 expect((await rotateAccount(app, alice, "github", "main", T2)).ok).toBe(true);
81
82 expect(renameAccount(app, bob, "github", "main", "work").ok).toBe(false);
83 expect(renameAccount(app, alice, "github", "main", "work").ok).toBe(true);
84 expect(listCloudAccounts(app.db).map((a) => a.name)).toEqual(["work"]);
85 expect(listSecretNames(app.db, "forge").map((s) => s.name)).toEqual(["CLOUD_GITHUB_WORK"]);
86
87 // Every request of the three verifications left a row, allowed.
88 const rows = auditToday(app.db);
89 expect(rows.length).toBeGreaterThanOrEqual(3);
90 expect(rows.some((r) => r.account === "main" && r.allowed && r.purpose === "verify")).toBe(true);
91 expect(JSON.stringify(rows)).not.toContain(T1);
92 expect(JSON.stringify(rows)).not.toContain(T2);
93 });
94
95 test("a vendor not offered, and vercel, verify the same way", async () => {
96 expect((await addAccount(app, alice, "nope", "x", T1)).ok).toBe(false);
97 const v = await addAccount(app, alice, "vercel", "site", T1);
98 expect(v.ok).toBe(true);
99 expect(removeAccount(app, alice, "vercel", "site").ok).toBe(true);
100 });
101
102 test("no page shows a sealed token, and only admins see them", async () => {
103 const login = async (username: string) =>
104 ((await fetch(`${servers.url}/signin`, {
105 method: "POST",
106 redirect: "manual",
107 headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url },
108 body: new URLSearchParams({ username, password: "a-good-passphrase" }),
109 })).headers.get("set-cookie") ?? "").split(";")[0] ?? "";
110 const cookie = await login("alice");
111 for (const path of ["/settings/cloud", "/settings/cloud/audit"]) {
112 const text = await (await fetch(`${servers.url}${path}`, { headers: { cookie } })).text();
113 expect(text).not.toContain(T1);
114 expect(text).not.toContain(T2);
115 expect(text).toContain("GitHub");
116 }
117 const main = await (await fetch(`${servers.url}/settings/cloud`, { headers: { cookie } })).text();
118 expect(main).toContain("Bring it home");
119 expect(main).toContain("Used for nothing now");
120 const bobCookie = await login("bob");
121 for (const path of ["/settings/cloud", "/settings/cloud/audit"])
122 expect((await fetch(`${servers.url}${path}`, { headers: { cookie: bobCookie } })).status).toBe(404);
123 });
124
125 test("a gated verify audits each request; bytes out counts the body", async () => {
126 seen.length = 0;
127 const f = gatedFetch(app.db, { provider: "github", account: "work" }, "verify", net.fetch);
128 await f("https://api.github.com/user?x=1", { method: "POST", body: "12345" });
129 expect(seen).toEqual(["https://api.github.com/user?x=1"]);
130 const row = auditToday(app.db).find((r) => r.purpose === "verify" && r.bytesOut === 5);
131 expect(row).toMatchObject({ allowed: true, target: "api.github.com/user" });
132 });
133
134 test("a hand-out to a run is gated and audited either way", () => {
135 const who = { provider: "vercel", account: "site" };
136 expect(handOut(app.db, who, "r1", undefined)).toBe(false);
137 expect(handOut(app.db, who, "r2", [{ vendor: "vercel", use: "cdn", why: "site" }])).toBe(true);
138 const rows = auditToday(app.db).filter((r) => r.purpose.startsWith("hand-out"));
139 expect(rows.map((r) => [r.target, r.allowed]).sort()).toEqual([["run r1", false], ["run r2", true]]);
140 });
141
142 test("every provider has a vendor cloud.json can declare; aws and backblaze are s3", () => {
143 for (const p of PROVIDERS) expect(CLOUD_VENDORS).toContain(VENDOR_OF[p]);
144 expect(VENDOR_OF.aws).toBe("s3");
145 expect(VENDOR_OF.backblaze).toBe("s3");
146 const s3 = [{ vendor: "s3", use: "backup", why: "off-machine copy" }] as const;
147 expect(handOut(app.db, { provider: "aws", account: "x" }, "r3", s3)).toBe(true);
148 expect(handOut(app.db, { provider: "backblaze", account: "x" }, "r4", s3)).toBe(true);
149 expect(handOut(app.db, { provider: "digitalocean", account: "x" }, "r5", s3)).toBe(false);
150 });
151
152 test("the runner's bar: an undeclared cloud key is refused and never opened; a declared one is granted", () => {
153 const put = (name: string) => setSecret(app.db, dataDir, "forge", "", name, "sealed-value-1234567");
154 put("CLOUD_AWS_X");
155 put("CLOUD_GITHUB_Y");
156 const asked = parseDeclaration("---\nsecrets: CLOUD_AWS_X, CLOUD_GITHUB_Y, PLAIN\n---");
157 const s3 = [{ vendor: "s3", use: "backup", why: "off-machine copy" }] as const;
158 const run = { id: "r6", repoId, ownerId: alice.id, actorId: alice.id };
159 const reads = () => app.db.query("SELECT 1 FROM event WHERE kind = 'secret.read'").all().length;
160
161 const before = reads();
162 const none = bar(app.db, asked, "r6", []);
163 expect(none.names).toEqual(["PLAIN"]);
164 expect(none.refused.join()).toContain("CLOUD_AWS_X: its vendor is not declared");
165 expect(none.refused.join()).toContain("CLOUD_GITHUB_Y");
166 const g0 = grantSecrets(app.db, dataDir, app.bus, none, run);
167 expect(g0.names).toEqual([]);
168 expect(reads()).toBe(before);
169 g0.release();
170
171 const some = bar(app.db, asked, "r6", s3);
172 expect(some.names).toEqual(["CLOUD_AWS_X", "PLAIN"]);
173 expect(some.refused.join()).toContain("CLOUD_GITHUB_Y");
174 const g1 = grantSecrets(app.db, dataDir, app.bus, some, run);
175 expect(g1.names).toEqual(["CLOUD_AWS_X"]);
176 g1.release();
177 });
178
179 test("the audit prunes a 91-day-old row and keeps an 89-day-old one", () => {
180 const now = Date.now();
181 const day = 86_400_000;
182 const base = { provider: "github", account: "old", target: "", bytesOut: 0, allowed: true };
183 app.db.run("DELETE FROM cloud_audit");
184 app.db.query("INSERT INTO cloud_audit (at, provider, account, purpose, allowed) VALUES (?1,'github','a91','p',1)").run(new Date(now - 91 * day).toISOString());
185 app.db.query("INSERT INTO cloud_audit (at, provider, account, purpose, allowed) VALUES (?1,'github','a89','p',1)").run(new Date(now - 89 * day).toISOString());
186 expect(pruneAudit(app.db, now)).toBe(1);
187 // Pruning also happens on write.
188 app.db.query("INSERT INTO cloud_audit (at, provider, account, purpose, allowed) VALUES (?1,'github','b91','p',1)").run(new Date(now - 91 * day).toISOString());
189 recordAudit(app.db, { ...base, purpose: "p" }, now);
190 const left = app.db.query<{ account: string }, []>("SELECT account FROM cloud_audit ORDER BY account").all().map((r) => r.account);
191 expect(left).toEqual(["a89", "old"]);
192 const idx = app.db.query<{ name: string }, []>("PRAGMA index_list(cloud_audit)").all();
193 expect(idx.map((i) => i.name)).toContain("cloud_audit_at");
194 });
195
196 test("the used-for line names what is declared", () => {
197 expect(usedFor(undefined)).toContain("nothing");
198 expect(usedFor([{ repo: "a/b", use: "cdn", why: "site" }])).toBe("Used for cdn by a/b (site).");
199 });
200 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs