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