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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2177-clouds-step-2-settings-cloud-accounts-at → main 3 commits fork 5 threads · 5 unresolved
README.md +17 −2
| @@ -749,7 +749,7 @@ something only a passport may reach. | ||
| 749 | 749 | ## Cloud accounts |
| 750 | 750 | |
| 751 | 751 | **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 | |
| 753 | 753 | makes the forge check it with read-only calls (listed in the header of |
| 754 | 754 | `src/cloud/providers.ts`) and say what it can see; a key the provider rejects |
| 755 | 755 | 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 | ||
| 765 | 765 | monthly saving estimate, skipping findings already open. It runs in the forge, |
| 766 | 766 | not as a crew run; AWS audits look at us-east-1 only; over-provisioned |
| 767 | 767 | 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. | |
| 769 | 784 | |
| 770 | 785 | ## Share to the forge |
| 771 | 786 |
docs/CLOUD.md +6 −1
| @@ -18,7 +18,7 @@ Beside `forge.json`, at the root of the repository: | ||
| 18 | 18 | { "uses": [{ "vendor": "s3", "use": "backup", "why": "off-machine copy of the encrypted snapshot" }] } |
| 19 | 19 | |
| 20 | 20 | |
| 21 | - `vendor`: `cloudflare`, `fly`, `hetzner` or `s3`. | |
| 21 | - `vendor`: `cloudflare`, `fly`, `hetzner`, `s3`, `github`, `vercel`, `digitalocean` or `azure`. | |
| 22 | 22 | - `use`: `compute`, `storage`, `backup`, `dns` or `cdn`. |
| 23 | 23 | - `why`: one line, up to 300 characters. |
| 24 | 24 | |
| @@ -30,3 +30,8 @@ No file means no uses. A malformed file is refused with one sentence | ||
| 30 | 30 | `mayUse(declared, vendor, use)` in `src/apps/cloud.ts` is the only way a |
| 31 | 31 | vendor call is allowed. Later steps call it before every vendor request; it is |
| 32 | 32 | 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 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import type { App } from "../app.ts"; |
| 11 | import { gatedFetch } from "../cloud/gate.ts"; | |
| 11 | 12 | import { audit, isProvider, verify, type Fetch, type Provider } from "../cloud/providers.ts"; |
| 12 | 13 | import { |
| 13 | 14 | CLOUD_NAME, |
| 14 | 15 | forgetCloudAccount, |
| 16 | getCloudAccount, | |
| 15 | 17 | recordVerified, |
| 18 | renameCloudAccount, | |
| 16 | 19 | secretNameOf, |
| 17 | 20 | setWriteAllowed, |
| 18 | 21 | } from "../core/cloud_account.ts"; |
| @@ -40,6 +43,10 @@ function target( | ||
| 40 | 43 | return ok({ provider, name, secret: secretNameOf(provider, name) }); |
| 41 | 44 | } |
| 42 | 45 | |
| 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 | ||
| 43 | 50 | /** Verify, then seal. Returns the one-sentence summary. */ |
| 44 | 51 | export async function addAccount( |
| 45 | 52 | app: App, |
| @@ -52,7 +59,7 @@ export async function addAccount( | ||
| 52 | 59 | if (!who.ok) return who; |
| 53 | 60 | const t = target(provider, name); |
| 54 | 61 | 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")); | |
| 56 | 63 | if (!v.ok) return v; |
| 57 | 64 | const set = putSecret(app, who.value, "forge", "", t.value.secret, value); |
| 58 | 65 | 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 | ||
| 68 | 75 | })[secret]; |
| 69 | 76 | } |
| 70 | 77 | |
| 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."); | |
| 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 | ||
| 71 | 124 | export async function reverify( |
| 72 | 125 | app: App, |
| 73 | 126 | actor: User | undefined, |
| @@ -80,7 +133,7 @@ export async function reverify( | ||
| 80 | 133 | if (!t.ok) return t; |
| 81 | 134 | const value = open(app, who.value, t.value.secret, `verify:${t.value.provider}`); |
| 82 | 135 | 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")); | |
| 84 | 137 | if (!v.ok) return v; |
| 85 | 138 | recordVerified(app.db, t.value.provider, t.value.name, v.value.summary); |
| 86 | 139 | return ok(v.value.summary); |
| @@ -141,7 +194,7 @@ export async function auditAccount( | ||
| 141 | 194 | if (!repo) return err("Name a repository as owner/name to file the findings in."); |
| 142 | 195 | const value = open(app, who.value, t.value.secret, `audit:${t.value.provider}:${id()}`); |
| 143 | 196 | 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")); | |
| 145 | 198 | if (!found.ok) return found; |
| 146 | 199 | let opened = 0; |
| 147 | 200 | for (const f of found.value) { |
src/apps/manifest.ts +1 −1
| @@ -366,7 +366,7 @@ export function parseCheck( | ||
| 366 | 366 | } |
| 367 | 367 | |
| 368 | 368 | /** 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; | |
| 370 | 370 | /** What a vendor may be used for; a closed set. */ |
| 371 | 371 | export const CLOUD_USES = ["compute", "storage", "backup", "dns", "cdn"] as const; |
| 372 | 372 | 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 |
||
| 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: |
||
| 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 @@ | ||
| 13 | 13 | * (client-credentials sign-in), GET management.azure.com |
| 14 | 14 | * /subscriptions, /disks, /publicIPAddresses |
| 15 | 15 | * fly POST api.fly.io/graphql — `query` documents only |
| 16 | * github GET api.github.com/user | |
| 17 | * vercel GET api.vercel.com/v2/user | |
| 16 | 18 | * backblaze GET api.backblazeb2.com/b2api/v2/b2_authorize_account, then |
| 17 | 19 | * GET <apiUrl>/b2api/v2/b2_list_buckets (apiUrl must be a |
| 18 | 20 | * *.backblazeb2.com host) |
| @@ -22,6 +24,7 @@ | ||
| 22 | 24 | */ |
| 23 | 25 | |
| 24 | 26 | import { createHmac, createHash } from "node:crypto"; |
| 27 | import type { CloudVendor } from "../apps/manifest.ts"; | |
| 25 | 28 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 26 | 29 | |
| 27 | 30 | export const PROVIDERS = [ |
| @@ -31,9 +34,27 @@ export const PROVIDERS = [ | ||
| 31 | 34 | "azure", |
| 32 | 35 | "fly", |
| 33 | 36 | "backblaze", |
| 37 | "github", | |
| 38 | "vercel", | |
| 34 | 39 | ] as const; |
| 35 | 40 | export type Provider = (typeof PROVIDERS)[number]; |
| 36 | 41 | |
| 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 | ||
| 37 | 58 | export function isProvider(v: string): v is Provider { |
| 38 | 59 | return (PROVIDERS as readonly string[]).includes(v); |
| 39 | 60 | } |
| @@ -45,6 +66,8 @@ export const LABEL: Record<Provider, string> = { | ||
| 45 | 66 | azure: "Azure", |
| 46 | 67 | fly: "Fly.io", |
| 47 | 68 | backblaze: "Backblaze B2", |
| 69 | github: "GitHub", | |
| 70 | vercel: "Vercel", | |
| 48 | 71 | }; |
| 49 | 72 | |
| 50 | 73 | /** What to paste, per provider, in a sentence. */ |
| @@ -55,6 +78,8 @@ export const shape: Record<Provider, string> = { | ||
| 55 | 78 | azure: "TENANT_ID:CLIENT_ID:CLIENT_SECRET of a Reader service principal", |
| 56 | 79 | fly: "a deploy or read-only org token", |
| 57 | 80 | 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", | |
| 58 | 83 | }; |
| 59 | 84 | |
| 60 | 85 | export type Fetch = (url: string, init?: RequestInit) => Promise<Response>; |
| @@ -248,6 +273,22 @@ export async function verify( | ||
| 248 | 273 | canWrite: null, |
| 249 | 274 | }); |
| 250 | 275 | } |
| 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 | } | |
| 251 | 292 | case "backblaze": { |
| 252 | 293 | const c = await b2(f, value); |
| 253 | 294 | if (!c) return err("A Backblaze credential is KEY_ID:APPLICATION_KEY."); |
| @@ -347,6 +388,10 @@ export async function audit( | ||
| 347 | 388 | 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 | 389 | break; |
| 349 | 390 | } |
| 391 | case "github": | |
| 392 | case "vercel": | |
| 393 | // Nothing to audit for cost; the token is only verified. | |
| 394 | break; | |
| 350 | 395 | case "backblaze": |
| 351 | 396 | // Bucket size needs a file listing per bucket; not attempted. |
| 352 | 397 | break; |
src/core/cloud_account.ts +12
| @@ -121,6 +121,18 @@ export function setWriteAllowed( | ||
| 121 | 121 | ); |
| 122 | 122 | } |
| 123 | 123 | |
| 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 | ||
| 124 | 136 | export function forgetCloudAccount( |
| 125 | 137 | db: Database, |
| 126 | 138 | 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"; | ||
| 63 | 63 | import { looksForEnv } from "../core/looks.ts"; |
| 64 | 64 | import { aheadCount, resolveOid } from "../git/merge.ts"; |
| 65 | 65 | 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"; | |
| 66 | 69 | import { alreadyDoneSummary, namedCommits, onBase } from "./already.ts"; |
| 67 | 70 | import { BUDGETS, budgetSummary } from "../kernel/budgets.ts"; |
| 68 | 71 | import { createLog, redactValues } from "../kernel/log.ts"; |
| @@ -737,10 +740,14 @@ async function work( | ||
| 737 | 740 | // redaction from this moment, dropped in the `finally` below — a crash |
| 738 | 741 | // included. |
| 739 | 742 | const author = getUserById(app.db, issue.authorId); |
| 740 | const declared = parseDeclaration( | |
| 743 | const asked = parseDeclaration( | |
| 741 | 744 | agent.brief, |
| 742 | 745 | author !== undefined && canWrite(app.db, repo, author) ? issue.body : "", |
| 743 | 746 | ); |
| 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; |
||
| 744 | 751 | grant = grantSecrets(app.db, app.config.dataDir, app.bus, declared, { |
| 745 | 752 | id: run.id, |
| 746 | 753 | repoId: repo.id, |
src/http/server.ts +6
| @@ -134,6 +134,9 @@ import { releasesPage, rollbackSubmit } from "../web/pages/releases.ts"; | ||
| 134 | 134 | import { |
| 135 | 135 | cloudAdd, |
| 136 | 136 | cloudAudit, |
| 137 | cloudAuditPage, | |
| 138 | cloudRename, | |
| 139 | cloudRotate, | |
| 137 | 140 | cloudPage, |
| 138 | 141 | cloudRemove, |
| 139 | 142 | cloudVerify, |
| @@ -746,6 +749,9 @@ export function buildRouter(): Router<Handler> { | ||
| 746 | 749 | router.add("POST", "/settings/cloud/verify", cloudVerify); |
| 747 | 750 | router.add("POST", "/settings/cloud/write", cloudWrite); |
| 748 | 751 | 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); | |
| 749 | 755 | router.add("POST", "/settings/cloud/remove", cloudRemove); |
| 750 | 756 | router.add("GET", "/settings/access", accessPage); |
| 751 | 757 | router.add("GET", "/settings/resilience", domainsPage); |
src/web/pages/cloud.ts +76 −6
| @@ -11,9 +11,13 @@ import { | ||
| 11 | 11 | allowWrite, |
| 12 | 12 | auditAccount, |
| 13 | 13 | removeAccount, |
| 14 | renameAccount, | |
| 14 | 15 | reverify, |
| 16 | rotateAccount, | |
| 15 | 17 | } 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"; | |
| 17 | 21 | import { |
| 18 | 22 | listCloudAccounts, |
| 19 | 23 | recentCloudReads, |
| @@ -37,20 +41,20 @@ function requireAdmin(ctx: Ctx): Response | undefined { | ||
| 37 | 41 | |
| 38 | 42 | /** A run id in a read event, as words: audits and checks name themselves. */ |
| 39 | 43 | 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}`; | |
| 41 | 45 | |
| 42 | 46 | const hidden = (provider: string, name: string): Html => |
| 43 | 47 | html`<input type="hidden" name="provider" value="${provider}" /> |
| 44 | 48 | <input type="hidden" name="name" value="${name}" />`; |
| 45 | 49 | |
| 46 | function accountsCard(reads: CloudRead[], ctx: Ctx): Html { | |
| 50 | function accountsCard(reads: CloudRead[], uses: Map<string, VendorUse[]>, ctx: Ctx): Html { | |
| 47 | 51 | const accounts = listCloudAccounts(ctx.app.db); |
| 48 | 52 | const rows = accounts.map((a) => { |
| 49 | 53 | const last = reads.find((r) => r.provider === a.provider && r.name === a.name); |
| 50 | 54 | return row({ |
| 51 | 55 | lead: icon("key"), |
| 52 | 56 | 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"} · | |
| 54 | 58 | ${last ? html`last used ${time(last.at)} by ${runWords(last.run)}` : "never used"} · |
| 55 | 59 | ${a.writeAllowed ? "writes allowed" : "read-only"}`, |
| 56 | 60 | trail: html`<div class="row-actions"> |
| @@ -68,6 +72,19 @@ function accountsCard(reads: CloudRead[], ctx: Ctx): Html { | ||
| 68 | 72 | aria-label="Repository for audit findings" maxlength="200" required /> |
| 69 | 73 | ${btn("Audit", { size: "sm", label: `Audit ${a.name}` })} |
| 70 | 74 | </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> | |
| 71 | 88 | <form method="post" action="/settings/cloud/remove"> |
| 72 | 89 | ${hidden(a.provider, a.name)}${btn("Remove", { size: "sm", icon: "x", label: `Remove ${a.name}` })} |
| 73 | 90 | </form> |
| @@ -124,17 +141,19 @@ function readsCard(reads: CloudRead[]): Html { | ||
| 124 | 141 | } |
| 125 | 142 | |
| 126 | 143 | // GET /settings/cloud |
| 127 | export function cloudPage(ctx: Ctx): Response { | |
| 144 | export async function cloudPage(ctx: Ctx): Promise<Response> { | |
| 128 | 145 | const denied = requireAdmin(ctx); |
| 129 | 146 | if (denied) return denied; |
| 130 | 147 | const reads = recentCloudReads(ctx.app.db); |
| 148 | const uses = await usesByVendor(ctx.app.db, ctx.app.reposRoot, ctx.auth.user!); | |
| 131 | 149 | return page({ |
| 132 | 150 | ctx, |
| 133 | 151 | title: "Cloud accounts", |
| 134 | 152 | body: html`<div class="page-head"><h1>Cloud accounts</h1></div> |
| 135 | 153 | ${settingsLayout( |
| 136 | 154 | 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>`, | |
| 138 | 157 | "Account settings", |
| 139 | 158 | )}`, |
| 140 | 159 | }); |
| @@ -189,6 +208,57 @@ export async function cloudAudit(ctx: Ctx): Promise<Response> { | ||
| 189 | 208 | ); |
| 190 | 209 | } |
| 191 | 210 | |
| 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 | ||
| 192 | 262 | // POST /settings/cloud/remove |
| 193 | 263 | export async function cloudRemove(ctx: Ctx): Promise<Response> { |
| 194 | 264 | 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 | }); |
Low: this only checks the
cloud_accountrow. If a plain forge secret namedCLOUD_<PROVIDER>_<TO>already exists, theputSecretbelow silently overwrites it. Also refuse when that secret name is already in the store. Tracked in #2194.