Public: anyone can read this; the crew can change it.
Forge-level secrets: a sealed, write-only slot an operator can trust #356
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/335-forge-level-secrets-a-sealed-write-only → main 1 commit fork
12 files
changed +994
−3
docs/APPS.md +14
| @@ -251,6 +251,20 @@ each scope, and where two apply the narrower one wins: a test key for | ||
| 251 | 251 | previews, the live one for production, and neither process ever holds the |
| 252 | 252 | other's. Every preview is one scope, whichever pull it serves. |
| 253 | 253 | |
| 254 | ### Forge secrets | |
| 255 | ||
| 256 | A credential the forge itself needs (a DNS token to spawn forges, a bucket | |
| 257 | key for tofu state) is not an app's variable and never enters one. A forge | |
| 258 | admin seals it under **Settings → Secrets**, or on an organization's page or | |
| 259 | a repository's settings, or with `PUT /api/v1/secrets/:scope/:name` | |
| 260 | (`{ "value", "ref" }`; `ref` is an org name or `owner/name`; scope is `forge`, | |
| 261 | `org` or `repo`). Values are write-only; the list carries names, `updatedAt` | |
| 262 | and `lastReadAt`. The only read path is `resolveSecrets` in | |
| 263 | `core/secret_store.ts`: the narrowest scope wins, and each open appends a | |
| 264 | `secret.read` event (actor, run, name, scope — never the value). Rows are | |
| 265 | sealed under AAD `secret:<scope>:<name>`. Like every sealed row, they sit | |
| 266 | beside the key in the data directory and so in a snapshot. | |
| 267 | ||
| 254 | 268 | A process reads its environment once, at start, so setting or removing a |
| 255 | 269 | variable redeploys what reads it, at its current commit, and nothing else: |
| 256 | 270 | a variable for everywhere redeploys production and every open preview, one |
src/actions/secrets.ts added +117
| @@ -0,0 +1,117 @@ | ||
| 1 | /** | |
| 2 | * Forge-level secrets, as the forge's admins reach them. Writing and | |
| 3 | * removing are for a forge admin at every scope — these are the operator's | |
| 4 | * credentials, not an org's or a repo's to hold — and an everyone-else | |
| 5 | * caller is told `not_found`, never that the door exists. Reading is not | |
| 6 | * here at all: `resolveSecrets` (`core/secret_store.ts`) is the only path. | |
| 7 | * Events carry the name and the scope, never the value. | |
| 8 | */ | |
| 9 | ||
| 10 | import type { App } from "../app.ts"; | |
| 11 | import { getOrg } from "../core/orgs.ts"; | |
| 12 | import { getRepo } from "../core/repos.ts"; | |
| 13 | import { | |
| 14 | deleteSecret, | |
| 15 | isSecretScope, | |
| 16 | listSecretNames, | |
| 17 | setSecret, | |
| 18 | type SecretError, | |
| 19 | type SecretName, | |
| 20 | type SecretScope, | |
| 21 | } from "../core/secret_store.ts"; | |
| 22 | import type { User } from "../core/users.ts"; | |
| 23 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 24 | ||
| 25 | /** Where a secret lives: `forge` needs no ref, `org` an org name, `repo` `owner/name`. */ | |
| 26 | interface Target { | |
| 27 | readonly scope: SecretScope; | |
| 28 | readonly scopeId: string; | |
| 29 | readonly repoId?: string; | |
| 30 | } | |
| 31 | ||
| 32 | function resolveTarget( | |
| 33 | app: App, | |
| 34 | scope: string, | |
| 35 | ref: string, | |
| 36 | ): Result<Target, "invalid_scope" | "not_found"> { | |
| 37 | if (!isSecretScope(scope)) return err("invalid_scope"); | |
| 38 | if (scope === "forge") return ok({ scope, scopeId: "" }); | |
| 39 | if (scope === "org") { | |
| 40 | const org = getOrg(app.db, ref); | |
| 41 | return org ? ok({ scope, scopeId: org.id }) : err("not_found"); | |
| 42 | } | |
| 43 | const [owner = "", name = "", ...rest] = ref.split("/"); | |
| 44 | const repo = rest.length === 0 ? getRepo(app.db, owner, name) : undefined; | |
| 45 | return repo | |
| 46 | ? ok({ scope, scopeId: repo.id, repoId: repo.id }) | |
| 47 | : err("not_found"); | |
| 48 | } | |
| 49 | ||
| 50 | function admin(actor: User | undefined): Result<User, "not_found"> { | |
| 51 | return actor?.isAdmin ? ok(actor) : err("not_found"); | |
| 52 | } | |
| 53 | ||
| 54 | export function listSecrets( | |
| 55 | app: App, | |
| 56 | actor: User | undefined, | |
| 57 | scope: string, | |
| 58 | ref = "", | |
| 59 | ): Result<SecretName[], "not_found" | "invalid_scope"> { | |
| 60 | const who = admin(actor); | |
| 61 | if (!who.ok) return who; | |
| 62 | const target = resolveTarget(app, scope, ref); | |
| 63 | if (!target.ok) return target; | |
| 64 | return ok(listSecretNames(app.db, target.value.scope, target.value.scopeId)); | |
| 65 | } | |
| 66 | ||
| 67 | export function putSecret( | |
| 68 | app: App, | |
| 69 | actor: User | undefined, | |
| 70 | scope: string, | |
| 71 | ref: string, | |
| 72 | name: string, | |
| 73 | value: string, | |
| 74 | ): Result<SecretName, "not_found" | SecretError> { | |
| 75 | const who = admin(actor); | |
| 76 | if (!who.ok) return who; | |
| 77 | const target = resolveTarget(app, scope, ref); | |
| 78 | if (!target.ok) return target; | |
| 79 | const set = setSecret( | |
| 80 | app.db, | |
| 81 | app.config.dataDir, | |
| 82 | target.value.scope, | |
| 83 | target.value.scopeId, | |
| 84 | name, | |
| 85 | value, | |
| 86 | ); | |
| 87 | if (!set.ok) return set; | |
| 88 | app.bus.append({ | |
| 89 | kind: "secret.set", | |
| 90 | repoId: target.value.repoId, | |
| 91 | actorId: who.value.id, | |
| 92 | payload: { name, scope: target.value.scope }, | |
| 93 | }); | |
| 94 | return set; | |
| 95 | } | |
| 96 | ||
| 97 | export function removeSecret( | |
| 98 | app: App, | |
| 99 | actor: User | undefined, | |
| 100 | scope: string, | |
| 101 | ref: string, | |
| 102 | name: string, | |
| 103 | ): Result<void, "not_found" | "invalid_scope" | "no_such_secret"> { | |
| 104 | const who = admin(actor); | |
| 105 | if (!who.ok) return who; | |
| 106 | const target = resolveTarget(app, scope, ref); | |
| 107 | if (!target.ok) return target; | |
| 108 | if (!deleteSecret(app.db, target.value.scope, target.value.scopeId, name)) | |
| 109 | return err("no_such_secret"); | |
| 110 | app.bus.append({ | |
| 111 | kind: "secret.deleted", | |
| 112 | repoId: target.value.repoId, | |
| 113 | actorId: who.value.id, | |
| 114 | payload: { name, scope: target.value.scope }, | |
| 115 | }); | |
| 116 | return ok(); | |
| 117 | } |
src/core/event_audience.ts +1
| @@ -27,6 +27,7 @@ export function eventAudience(kind: string): EventAudience { | ||
| 27 | 27 | kind.startsWith("app.env_") || |
| 28 | 28 | kind.startsWith("app.domain.") || |
| 29 | 29 | kind.startsWith("prompt.") || |
| 30 | kind.startsWith("secret.") || | |
| 30 | 31 | kind === "repo.settings_updated" |
| 31 | 32 | ) |
| 32 | 33 | return "admins"; |
src/core/schema.ts +15
| @@ -922,4 +922,19 @@ export const migrations: readonly string[] = [ | ||
| 922 | 922 | ); |
| 923 | 923 | CREATE INDEX prompt_source_repo ON prompt_source(repo_id); |
| 924 | 924 | `, |
| 925 | ||
| 926 | // 34 — forge-level secrets: credentials the forge itself needs, sealed | |
| 927 | // under AAD `secret:<scope>:<name>` and never read back. `scope_id` is '' | |
| 928 | // for the forge, an org's or a repo's id otherwise. | |
| 929 | ` | |
| 930 | CREATE TABLE secret ( | |
| 931 | name TEXT NOT NULL, | |
| 932 | scope TEXT NOT NULL CHECK (scope IN ('forge','org','repo')), | |
| 933 | scope_id TEXT NOT NULL DEFAULT '', | |
| 934 | value_enc TEXT NOT NULL, | |
| 935 | updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), | |
| 936 | last_read_at TEXT, | |
| 937 | PRIMARY KEY (name, scope, scope_id) | |
| 938 | ); | |
| 939 | `, | |
| 925 | 940 | ]; |
src/core/secret_store.ts added +195
| @@ -0,0 +1,195 @@ | ||
| 1 | /** | |
| 2 | * Forge-level secrets — the rows. A credential the forge itself needs (a | |
| 3 | * DNS token to spawn forges, a bucket key for tofu state) has one home: | |
| 4 | * sealed here, at one of three scopes — the whole forge, an organization, | |
| 5 | * a repository — and never read back to a person. `resolveSecrets` is the | |
| 6 | * ONLY path a plaintext leaves, and every value it opens leaves an event | |
| 7 | * (who, which run, which name — never the value) and a `last_read_at`. | |
| 8 | * | |
| 9 | * Kept apart from `app_env` on purpose: an app's variables go into that | |
| 10 | * app's process; a secret goes only to whoever asks the resolver by name, | |
| 11 | * so a name here can shadow nothing there. Pure data apart from the event. | |
| 12 | */ | |
| 13 | ||
| 14 | import type { Database } from "bun:sqlite"; | |
| 15 | import type { EventBus } from "./events.ts"; | |
| 16 | import { createLog } from "../kernel/log.ts"; | |
| 17 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 18 | import { open, seal } from "../kernel/secret.ts"; | |
| 19 | ||
| 20 | const log = createLog("secret_store"); | |
| 21 | ||
| 22 | export type SecretScope = "forge" | "org" | "repo"; | |
| 23 | export const SECRET_SCOPES: readonly SecretScope[] = ["forge", "org", "repo"]; | |
| 24 | ||
| 25 | export function isSecretScope(value: string): value is SecretScope { | |
| 26 | return (SECRET_SCOPES as readonly string[]).includes(value); | |
| 27 | } | |
| 28 | ||
| 29 | export interface SecretName { | |
| 30 | readonly name: string; | |
| 31 | readonly scope: SecretScope; | |
| 32 | readonly scopeId: string; | |
| 33 | readonly updatedAt: string; | |
| 34 | readonly lastReadAt: string | null; | |
| 35 | } | |
| 36 | ||
| 37 | export type SecretError = "invalid_name" | "invalid_value" | "invalid_scope"; | |
| 38 | ||
| 39 | const NAME = /^[A-Z][A-Z0-9_]*$/; | |
| 40 | export const NAME_MAX = 128; | |
| 41 | export const VALUE_MAX = 64 * 1024; | |
| 42 | ||
| 43 | export function isSecretName(name: string): boolean { | |
| 44 | return name.length > 0 && name.length <= NAME_MAX && NAME.test(name); | |
| 45 | } | |
| 46 | ||
| 47 | const aad = (scope: SecretScope, name: string): string => | |
| 48 | `secret:${scope}:${name}`; | |
| 49 | ||
| 50 | /** A forge-wide secret has no owner; the others name an org or a repo by id. */ | |
| 51 | const ownerOf = (scope: SecretScope, scopeId: string): string => | |
| 52 | scope === "forge" ? "" : scopeId; | |
| 53 | ||
| 54 | interface Row { | |
| 55 | name: string; | |
| 56 | scope: string; | |
| 57 | scope_id: string; | |
| 58 | updated_at: string; | |
| 59 | last_read_at: string | null; | |
| 60 | } | |
| 61 | ||
| 62 | const toName = (r: Row): SecretName => ({ | |
| 63 | name: r.name, | |
| 64 | scope: r.scope as SecretScope, | |
| 65 | scopeId: r.scope_id, | |
| 66 | updatedAt: r.updated_at, | |
| 67 | lastReadAt: r.last_read_at, | |
| 68 | }); | |
| 69 | ||
| 70 | /** The names set at one scope, alphabetical — never a value. */ | |
| 71 | export function listSecretNames( | |
| 72 | db: Database, | |
| 73 | scope: SecretScope, | |
| 74 | scopeId = "", | |
| 75 | ): SecretName[] { | |
| 76 | return db | |
| 77 | .query<Row, [string, string]>( | |
| 78 | `SELECT name, scope, scope_id, updated_at, last_read_at FROM secret | |
| 79 | WHERE scope = ?1 AND scope_id = ?2 ORDER BY name`, | |
| 80 | ) | |
| 81 | .all(scope, ownerOf(scope, scopeId)) | |
| 82 | .map(toName); | |
| 83 | } | |
| 84 | ||
| 85 | /** Set a secret at a scope, replacing any value it had there. */ | |
| 86 | export function setSecret( | |
| 87 | db: Database, | |
| 88 | dataDir: string, | |
| 89 | scope: string, | |
| 90 | scopeId: string, | |
| 91 | name: string, | |
| 92 | value: string, | |
| 93 | ): Result<SecretName, SecretError> { | |
| 94 | if (!isSecretScope(scope)) return err("invalid_scope"); | |
| 95 | if (!isSecretName(name)) return err("invalid_name"); | |
| 96 | if (scope !== "forge" && scopeId === "") return err("invalid_scope"); | |
| 97 | // Empty would silently blank a credential. | |
| 98 | if (value === "" || value.length > VALUE_MAX || value.includes("\0")) | |
| 99 | return err("invalid_value"); | |
| 100 | const owner = ownerOf(scope, scopeId); | |
| 101 | db.query( | |
| 102 | `INSERT INTO secret (name, scope, scope_id, value_enc) VALUES (?1, ?2, ?3, ?4) | |
| 103 | ON CONFLICT(name, scope, scope_id) DO UPDATE SET | |
| 104 | value_enc = excluded.value_enc, | |
| 105 | updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`, | |
| 106 | ).run(name, scope, owner, seal(dataDir, value, aad(scope, name))); | |
| 107 | const row = db | |
| 108 | .query<Row, [string, string, string]>( | |
| 109 | "SELECT name, scope, scope_id, updated_at, last_read_at FROM secret WHERE name = ?1 AND scope = ?2 AND scope_id = ?3", | |
| 110 | ) | |
| 111 | .get(name, scope, owner)!; | |
| 112 | return ok(toName(row)); | |
| 113 | } | |
| 114 | ||
| 115 | /** Remove a secret from one scope; the wider ones keep theirs. */ | |
| 116 | export function deleteSecret( | |
| 117 | db: Database, | |
| 118 | scope: string, | |
| 119 | scopeId: string, | |
| 120 | name: string, | |
| 121 | ): boolean { | |
| 122 | if (!isSecretScope(scope)) return false; | |
| 123 | return ( | |
| 124 | db | |
| 125 | .query( | |
| 126 | "DELETE FROM secret WHERE name = ?1 AND scope = ?2 AND scope_id = ?3", | |
| 127 | ) | |
| 128 | .run(name, scope, ownerOf(scope, scopeId)).changes > 0 | |
| 129 | ); | |
| 130 | } | |
| 131 | ||
| 132 | export interface SecretReader { | |
| 133 | /** Organization and repository ids the secret is wanted for. */ | |
| 134 | readonly org?: string | undefined; | |
| 135 | readonly repo?: string | undefined; | |
| 136 | /** Who is asking and for which run, as the audit will say. */ | |
| 137 | readonly actor?: string | undefined; | |
| 138 | readonly run?: string | undefined; | |
| 139 | } | |
| 140 | ||
| 141 | /** | |
| 142 | * The ONLY read path. For each requested name the narrowest scope that has | |
| 143 | * it wins — repository, then organization, then the forge — and is opened | |
| 144 | * and returned; a name nobody set is simply absent. Each open appends a | |
| 145 | * `secret.read` event naming actor, run, name and scope, never the value, | |
| 146 | * and stamps `last_read_at`. A row that will not open (key rotated, row | |
| 147 | * tampered with) is skipped and logged by name, and leaves no read event. | |
| 148 | */ | |
| 149 | export function resolveSecrets( | |
| 150 | db: Database, | |
| 151 | dataDir: string, | |
| 152 | bus: EventBus, | |
| 153 | names: readonly string[], | |
| 154 | who: SecretReader = {}, | |
| 155 | ): Record<string, string> { | |
| 156 | const out: Record<string, string> = {}; | |
| 157 | const ladder: [SecretScope, string][] = []; | |
| 158 | if (who.repo) ladder.push(["repo", who.repo]); | |
| 159 | if (who.org) ladder.push(["org", who.org]); | |
| 160 | ladder.push(["forge", ""]); | |
| 161 | ||
| 162 | for (const name of names) { | |
| 163 | if (!isSecretName(name) || name in out) continue; | |
| 164 | for (const [scope, scopeId] of ladder) { | |
| 165 | const row = db | |
| 166 | .query<{ value_enc: string }, [string, string, string]>( | |
| 167 | "SELECT value_enc FROM secret WHERE name = ?1 AND scope = ?2 AND scope_id = ?3", | |
| 168 | ) | |
| 169 | .get(name, scope, scopeId); | |
| 170 | if (!row) continue; | |
| 171 | const opened = open(dataDir, row.value_enc, aad(scope, name)); | |
| 172 | if (!opened.ok) { | |
| 173 | log.warn("secret will not open; skipped", { | |
| 174 | name, | |
| 175 | scope, | |
| 176 | reason: opened.error, | |
| 177 | }); | |
| 178 | break; | |
| 179 | } | |
| 180 | db.query( | |
| 181 | `UPDATE secret SET last_read_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') | |
| 182 | WHERE name = ?1 AND scope = ?2 AND scope_id = ?3`, | |
| 183 | ).run(name, scope, scopeId); | |
| 184 | bus.append({ | |
| 185 | kind: "secret.read", | |
| 186 | repoId: who.repo, | |
| 187 | actorId: who.actor, | |
| 188 | payload: { name, scope, run: who.run ?? null }, | |
| 189 | }); | |
| 190 | out[name] = opened.value; | |
| 191 | break; | |
| 192 | } | |
| 193 | } | |
| 194 | return out; | |
| 195 | } |
src/http/api.ts +48
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | * back to continue); errors are `{ error, message }`. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { listSecrets, putSecret, removeSecret } from "../actions/secrets.ts"; | |
| 11 | 12 | import { proposeUpstream } from "../actions/upstream.ts"; |
| 12 | 13 | import { relationOf, syncFork, takeUpdates } from "../actions/forks.ts"; |
| 13 | 14 | import { convergeFork, convergeState } from "../crew/converge.ts"; |
| @@ -2980,6 +2981,53 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2980 | 2981 | return json({}, 200); |
| 2981 | 2982 | }); |
| 2982 | 2983 | |
| 2984 | // Forge-level secrets: a sealed, write-only slot for the operator's own | |
| 2985 | // credentials. Admins only; the list is names and timestamps, never a | |
| 2986 | // value. `ref` names the owner: an org's name, or a repository's | |
| 2987 | // `owner/name`; the forge scope takes none. | |
| 2988 | router.add("GET", "/api/v1/secrets/:scope", (ctx) => { | |
| 2989 | const denied = requireScope(ctx, "admin"); | |
| 2990 | if (denied) return denied; | |
| 2991 | const listed = listSecrets( | |
| 2992 | ctx.app, | |
| 2993 | ctx.auth.user, | |
| 2994 | ctx.params.scope ?? "", | |
| 2995 | ctx.url.searchParams.get("ref") ?? "", | |
| 2996 | ); | |
| 2997 | if (!listed.ok) return respondAccess(listed.error, 422); | |
| 2998 | return json({ items: listed.value, next_cursor: null }); | |
| 2999 | }); | |
| 3000 | ||
| 3001 | router.add("PUT", "/api/v1/secrets/:scope/:name", async (ctx) => { | |
| 3002 | const denied = requireScope(ctx, "admin"); | |
| 3003 | if (denied) return denied; | |
| 3004 | const input = await body(ctx.request); | |
| 3005 | const set = putSecret( | |
| 3006 | ctx.app, | |
| 3007 | ctx.auth.user, | |
| 3008 | ctx.params.scope ?? "", | |
| 3009 | str(input.ref), | |
| 3010 | ctx.params.name ?? "", | |
| 3011 | str(input.value), | |
| 3012 | ); | |
| 3013 | if (!set.ok) return respondAccess(set.error, 422); | |
| 3014 | return json(set.value); | |
| 3015 | }); | |
| 3016 | ||
| 3017 | router.add("DELETE", "/api/v1/secrets/:scope/:name", (ctx) => { | |
| 3018 | const denied = requireScope(ctx, "admin"); | |
| 3019 | if (denied) return denied; | |
| 3020 | const removed = removeSecret( | |
| 3021 | ctx.app, | |
| 3022 | ctx.auth.user, | |
| 3023 | ctx.params.scope ?? "", | |
| 3024 | ctx.url.searchParams.get("ref") ?? "", | |
| 3025 | ctx.params.name ?? "", | |
| 3026 | ); | |
| 3027 | if (!removed.ok) return respondAccess(removed.error, 422); | |
| 3028 | return json({}, 200); | |
| 3029 | }); | |
| 3030 | ||
| 2983 | 3031 | // The app's environment: names in, names out, and never a value back. |
| 2984 | 3032 | // `:var`, not `:name` — `:name` is the repository's. |
| 2985 | 3033 | router.add("GET", "/api/v1/repos/:owner/:name/app/env", (ctx) => { |
src/http/server.ts +4
| @@ -45,6 +45,7 @@ import { confirmPage, searchPage } from "../web/pages/search.ts"; | ||
| 45 | 45 | import { followingSubmit } from "../web/pages/following.ts"; |
| 46 | 46 | import { activityAtom } from "../web/feeds.ts"; |
| 47 | 47 | import { auditCsv, auditPage, auditProbeSubmit } from "../web/pages/audit.ts"; |
| 48 | import { secretDelete, secretSet, secretsPage } from "../web/pages/secrets.ts"; | |
| 48 | 49 | import { backupsNow, backupsPage } from "../web/pages/backups.ts"; |
| 49 | 50 | import { |
| 50 | 51 | subscriptionAdd, |
| @@ -467,6 +468,9 @@ export function buildRouter(): Router<Handler> { | ||
| 467 | 468 | router.add("POST", "/settings/subscriptions", subscriptionAdd); |
| 468 | 469 | router.add("POST", "/settings/subscriptions/:id/delete", subscriptionDelete); |
| 469 | 470 | router.add("POST", "/settings/subscriptions/:id/wake", subscriptionWake); |
| 471 | router.add("GET", "/settings/secrets", secretsPage); | |
| 472 | router.add("POST", "/settings/secrets", secretSet); | |
| 473 | router.add("POST", "/settings/secrets/delete", secretDelete); | |
| 470 | 474 | router.add("GET", "/settings/backups", backupsPage); |
| 471 | 475 | router.add("POST", "/settings/backups/now", backupsNow); |
| 472 | 476 | router.add("GET", "/settings/crew", crewPage); |
src/web/pages/org.ts +8 −1
| @@ -36,6 +36,7 @@ import { field, readForm, type Form } from "../form.ts"; | ||
| 36 | 36 | import { html, when, type Html } from "../html.ts"; |
| 37 | 37 | import { icon } from "../icons.ts"; |
| 38 | 38 | import { flashTo, page } from "../layout.ts"; |
| 39 | import { secretsSection } from "./secrets.ts"; | |
| 39 | 40 | import { agentsOnForge, grantsSection, saveGrants } from "./subscriptions.ts"; |
| 40 | 41 | import { shelfRows } from "../studio/shelf.ts"; |
| 41 | 42 | import { |
| @@ -336,11 +337,17 @@ export async function ownerProfile(ctx: Ctx): Promise<Response> { | ||
| 336 | 337 | title: "Subscriptions for every repository here", |
| 337 | 338 | })} |
| 338 | 339 | </div>`, |
| 339 | )}`; | |
| 340 | )} | |
| 341 | ${isOrg ? orgSecrets(ctx, owner.username) : null}`; | |
| 340 | 342 | |
| 341 | 343 | return page({ ctx, title: owner.username, body }); |
| 342 | 344 | } |
| 343 | 345 | |
| 346 | function orgSecrets(ctx: Ctx, name: string): Html | null { | |
| 347 | const secrets = secretsSection(ctx, "org", name); | |
| 348 | return secrets ? html`<div class="mt">${secrets}</div>` : null; | |
| 349 | } | |
| 350 | ||
| 344 | 351 | // POST /:owner/-/subscriptions |
| 345 | 352 | export async function orgSubscriptionsSave(ctx: Ctx): Promise<Response> { |
| 346 | 353 | const org = requireOrgAdmin(ctx); |
src/web/pages/origins.ts +8 −1
| @@ -69,7 +69,8 @@ export type AccountSection = | ||
| 69 | 69 | | "origins" |
| 70 | 70 | | "crew" |
| 71 | 71 | | "subscriptions" |
| 72 | | "backups"; | |
| 72 | | "backups" | |
| 73 | | "secrets"; | |
| 73 | 74 | |
| 74 | 75 | /** |
| 75 | 76 | * The settings sidebar, with Origins on it. Kept here so the account pages |
| @@ -96,6 +97,12 @@ export function accountSections( | ||
| 96 | 97 | icon: "key" as const, |
| 97 | 98 | current: current === "subscriptions", |
| 98 | 99 | }, |
| 100 | { | |
| 101 | label: "Secrets", | |
| 102 | href: "/settings/secrets", | |
| 103 | icon: "lock" as const, | |
| 104 | current: current === "secrets", | |
| 105 | }, | |
| 99 | 106 | { |
| 100 | 107 | label: "Backups", |
| 101 | 108 | href: "/settings/backups", |
src/web/pages/secrets.ts added +213
| @@ -0,0 +1,213 @@ | ||
| 1 | /** | |
| 2 | * Secrets, for a forge admin: the card that holds the operator's own | |
| 3 | * credentials — a DNS token, a bucket key — at the forge, under an | |
| 4 | * organization, or on a repository. The same card is drawn in all three | |
| 5 | * places, so the words stay one set. Values are write-only: set one, and | |
| 6 | * it is never shown again; the card lists names, when each was set, and | |
| 7 | * when something last opened it. | |
| 8 | */ | |
| 9 | ||
| 10 | import { listSecrets, putSecret, removeSecret } from "../../actions/secrets.ts"; | |
| 11 | import type { Ctx } from "../../http/context.ts"; | |
| 12 | import { | |
| 13 | NAME_MAX, | |
| 14 | type SecretName, | |
| 15 | type SecretScope, | |
| 16 | } from "../../core/secret_store.ts"; | |
| 17 | import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts"; | |
| 18 | import { field, readForm } from "../form.ts"; | |
| 19 | import { html, when, type Html } from "../html.ts"; | |
| 20 | import { flashTo, page } from "../layout.ts"; | |
| 21 | import { btn, card, icon, row, time } from "../ui.ts"; | |
| 22 | import { accountSections } from "./origins.ts"; | |
| 23 | ||
| 24 | const ERRORS: Record<string, string> = { | |
| 25 | invalid_name: | |
| 26 | "A name is capital letters, digits and underscores, starting with a letter — like CLOUDFLARE_API_TOKEN.", | |
| 27 | invalid_value: "A value is never empty and stays under 64 KiB.", | |
| 28 | invalid_scope: "That is not a place a secret can live.", | |
| 29 | not_found: "No such organization or repository.", | |
| 30 | no_such_secret: "No secret by that name there.", | |
| 31 | }; | |
| 32 | ||
| 33 | /** The page a card sends you back to: derived, never taken from the form. */ | |
| 34 | function backTo(scope: string, ref: string): string { | |
| 35 | if (scope === "org") return `/${encodeURIComponent(ref)}`; | |
| 36 | if (scope === "repo") { | |
| 37 | const [owner = "", name = ""] = ref.split("/"); | |
| 38 | return `/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/settings`; | |
| 39 | } | |
| 40 | return "/settings/secrets"; | |
| 41 | } | |
| 42 | ||
| 43 | const WHERE: Record<SecretScope, string> = { | |
| 44 | forge: "the whole forge", | |
| 45 | org: "every repository in this organization", | |
| 46 | repo: "this repository", | |
| 47 | }; | |
| 48 | ||
| 49 | /** The card: names, never values, and a form to set one. */ | |
| 50 | export function secretsCard( | |
| 51 | scope: SecretScope, | |
| 52 | ref: string, | |
| 53 | names: SecretName[], | |
| 54 | ): Html { | |
| 55 | const rows = names.map((s) => | |
| 56 | row({ | |
| 57 | lead: icon("key"), | |
| 58 | title: html`<span class="mono">${s.name}</span>`, | |
| 59 | meta: html`set ${time(s.updatedAt)} · | |
| 60 | ${ | |
| 61 | s.lastReadAt === null | |
| 62 | ? "never used" | |
| 63 | : html`last used ${time(s.lastReadAt)}` | |
| 64 | }`, | |
| 65 | trail: html`<form | |
| 66 | class="row-actions" | |
| 67 | method="post" | |
| 68 | action="/settings/secrets/delete" | |
| 69 | > | |
| 70 | <input type="hidden" name="scope" value="${scope}" /> | |
| 71 | <input type="hidden" name="ref" value="${ref}" /> | |
| 72 | <input type="hidden" name="name" value="${s.name}" /> | |
| 73 | ${btn("Remove", { | |
| 74 | size: "sm", | |
| 75 | icon: "x", | |
| 76 | label: `Remove ${s.name}`, | |
| 77 | })} | |
| 78 | </form>`, | |
| 79 | }), | |
| 80 | ); | |
| 81 | return card( | |
| 82 | html`<div class="card-body"> | |
| 83 | <p class="muted small"> | |
| 84 | What the forge itself needs and nobody should read back — an API | |
| 85 | token, a bucket key. Set one for ${WHERE[scope]}; it is sealed, shown | |
| 86 | to no one, and handed only to the run that asks for it by name. Where | |
| 87 | the same name is set more than once, the narrowest place wins: a | |
| 88 | repository over its organization over the forge. | |
| 89 | </p> | |
| 90 | </div> | |
| 91 | ${when( | |
| 92 | rows.length > 0, | |
| 93 | () => | |
| 94 | html`<ul class="list"> | |
| 95 | ${rows} | |
| 96 | </ul>`, | |
| 97 | )} | |
| 98 | <form class="edit-row" method="post" action="/settings/secrets"> | |
| 99 | <input type="hidden" name="scope" value="${scope}" /> | |
| 100 | <input type="hidden" name="ref" value="${ref}" /> | |
| 101 | <input | |
| 102 | class="input mono" | |
| 103 | type="text" | |
| 104 | name="name" | |
| 105 | placeholder="NAME" | |
| 106 | aria-label="Secret name" | |
| 107 | pattern="[A-Z][A-Z0-9_]*" | |
| 108 | maxlength="${String(NAME_MAX)}" | |
| 109 | autocomplete="off" | |
| 110 | spellcheck="false" | |
| 111 | required | |
| 112 | /> | |
| 113 | <input | |
| 114 | class="input" | |
| 115 | type="password" | |
| 116 | name="value" | |
| 117 | placeholder="Value" | |
| 118 | aria-label="Value" | |
| 119 | autocomplete="off" | |
| 120 | required | |
| 121 | /> | |
| 122 | ${btn("Seal", { variant: "primary", size: "sm", icon: "plus" })} | |
| 123 | </form>`, | |
| 124 | { | |
| 125 | head: html`${icon("key")} | |
| 126 | <h2>Secrets</h2>`, | |
| 127 | pad: false, | |
| 128 | }, | |
| 129 | ); | |
| 130 | } | |
| 131 | ||
| 132 | /** The card for a place, or nothing for anyone who is not a forge admin. */ | |
| 133 | export function secretsSection( | |
| 134 | ctx: Ctx, | |
| 135 | scope: SecretScope, | |
| 136 | ref: string, | |
| 137 | ): Html | null { | |
| 138 | const names = listSecrets(ctx.app, ctx.auth.user, scope, ref); | |
| 139 | return names.ok ? secretsCard(scope, ref, names.value) : null; | |
| 140 | } | |
| 141 | ||
| 142 | function requireAdmin(ctx: Ctx): Response | undefined { | |
| 143 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 144 | if (!ctx.auth.user.isAdmin) return notFoundPage(ctx); | |
| 145 | return undefined; | |
| 146 | } | |
| 147 | ||
| 148 | // GET /settings/secrets | |
| 149 | export function secretsPage(ctx: Ctx): Response { | |
| 150 | const denied = requireAdmin(ctx); | |
| 151 | if (denied) return denied; | |
| 152 | return page({ | |
| 153 | ctx, | |
| 154 | title: "Secrets", | |
| 155 | body: html`<div class="page-head"><h1>Secrets</h1></div> | |
| 156 | ${settingsLayout( | |
| 157 | accountSections("secrets", { admin: true }), | |
| 158 | secretsSection(ctx, "forge", "")!, | |
| 159 | "Account settings", | |
| 160 | )}`, | |
| 161 | }); | |
| 162 | } | |
| 163 | ||
| 164 | // POST /settings/secrets | |
| 165 | export async function secretSet(ctx: Ctx): Promise<Response> { | |
| 166 | const denied = requireAdmin(ctx); | |
| 167 | if (denied) return denied; | |
| 168 | const form = await readForm(ctx, "admin"); | |
| 169 | if (form instanceof Response) return form; | |
| 170 | const scope = field(form, "scope"); | |
| 171 | const ref = field(form, "ref"); | |
| 172 | const name = field(form, "name").trim(); | |
| 173 | const set = putSecret( | |
| 174 | ctx.app, | |
| 175 | ctx.auth.user, | |
| 176 | scope, | |
| 177 | ref, | |
| 178 | name, | |
| 179 | field(form, "value"), | |
| 180 | ); | |
| 181 | return flashTo( | |
| 182 | ctx, | |
| 183 | backTo(scope, ref), | |
| 184 | set.ok | |
| 185 | ? { message: `${name} is sealed.` } | |
| 186 | : { | |
| 187 | tone: "danger", | |
| 188 | message: ERRORS[set.error] ?? "That could not be set.", | |
| 189 | }, | |
| 190 | ); | |
| 191 | } | |
| 192 | ||
| 193 | // POST /settings/secrets/delete | |
| 194 | export async function secretDelete(ctx: Ctx): Promise<Response> { | |
| 195 | const denied = requireAdmin(ctx); | |
| 196 | if (denied) return denied; | |
| 197 | const form = await readForm(ctx, "admin"); | |
| 198 | if (form instanceof Response) return form; | |
| 199 | const scope = field(form, "scope"); | |
| 200 | const ref = field(form, "ref"); | |
| 201 | const name = field(form, "name"); | |
| 202 | const removed = removeSecret(ctx.app, ctx.auth.user, scope, ref, name); | |
| 203 | return flashTo( | |
| 204 | ctx, | |
| 205 | backTo(scope, ref), | |
| 206 | removed.ok | |
| 207 | ? { message: `${name} is removed.` } | |
| 208 | : { | |
| 209 | tone: "danger", | |
| 210 | message: ERRORS[removed.error] ?? "Nothing was removed.", | |
| 211 | }, | |
| 212 | ); | |
| 213 | } |
src/web/pages/settings.ts +9 −1
| @@ -108,6 +108,7 @@ import { | ||
| 108 | 108 | timeAgo, |
| 109 | 109 | userLink, |
| 110 | 110 | } from "../ui.ts"; |
| 111 | import { secretsSection } from "./secrets.ts"; | |
| 111 | 112 | import { crewReadiness } from "./crew.ts"; |
| 112 | 113 | import { grantsSection, saveGrants } from "./subscriptions.ts"; |
| 113 | 114 | |
| @@ -324,7 +325,14 @@ export async function settingsPage(ctx: Ctx): Promise<Response> { | ||
| 324 | 325 | </form>`, |
| 325 | 326 | { head: html`<h2>General</h2>` }, |
| 326 | 327 | ); |
| 327 | return settingsPageResponse(ctx, repo, "general", "Settings", body); | |
| 328 | const secrets = secretsSection(ctx, "repo", `${repo.ownerName}/${repo.name}`); | |
| 329 | return settingsPageResponse( | |
| 330 | ctx, | |
| 331 | repo, | |
| 332 | "general", | |
| 333 | "Settings", | |
| 334 | html`${body}${secrets ? html`<div class="mt">${secrets}</div>` : null}`, | |
| 335 | ); | |
| 328 | 336 | } |
| 329 | 337 | |
| 330 | 338 | export async function settingsUpdate(ctx: Ctx): Promise<Response> { |
test/secret-store.test.ts added +362
| @@ -0,0 +1,362 @@ | ||
| 1 | /** | |
| 2 | * Forge-level secrets: sealed under `secret:<scope>:<name>`, the narrowest | |
| 3 | * scope wins, the list never carries a value, admins alone write, and every | |
| 4 | * open through the resolver leaves an event without the value in it. | |
| 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 { createRepo } from "../src/actions/repos.ts"; | |
| 13 | import { createOrg } from "../src/core/orgs.ts"; | |
| 14 | import { | |
| 15 | listSecretNames, | |
| 16 | resolveSecrets, | |
| 17 | setSecret, | |
| 18 | } from "../src/core/secret_store.ts"; | |
| 19 | import { createUser, type User } from "../src/core/users.ts"; | |
| 20 | import { open, seal } from "../src/kernel/secret.ts"; | |
| 21 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 22 | import { quietLogs } from "./helpers/globals.ts"; | |
| 23 | ||
| 24 | quietLogs(); | |
| 25 | ||
| 26 | describe("forge-level secrets", () => { | |
| 27 | let dataDir = ""; | |
| 28 | let app: App; | |
| 29 | let servers: Servers; | |
| 30 | let alice: User; | |
| 31 | let orgId = ""; | |
| 32 | let repoId = ""; | |
| 33 | let adminToken = ""; | |
| 34 | let bobToken = ""; | |
| 35 | ||
| 36 | const stored = (name: string, scope: string): string | undefined => | |
| 37 | app.db | |
| 38 | .query<{ value_enc: string }, [string, string]>( | |
| 39 | "SELECT value_enc FROM secret WHERE name = ?1 AND scope = ?2", | |
| 40 | ) | |
| 41 | .get(name, scope)?.value_enc; | |
| 42 | const ask = (names: string[], who = {}) => | |
| 43 | resolveSecrets(app.db, dataDir, app.bus, names, who); | |
| 44 | const api = (path: string, init: RequestInit = {}, token = adminToken) => | |
| 45 | fetch(`${servers.url}${path}`, { | |
| 46 | ...init, | |
| 47 | headers: { | |
| 48 | authorization: `Bearer ${token}`, | |
| 49 | "content-type": "application/json", | |
| 50 | }, | |
| 51 | }); | |
| 52 | const mint = async (username: string): Promise<string> => | |
| 53 | ( | |
| 54 | (await ( | |
| 55 | await fetch(`${servers.url}/api/v1/tokens`, { | |
| 56 | method: "POST", | |
| 57 | headers: { "content-type": "application/json" }, | |
| 58 | body: JSON.stringify({ | |
| 59 | username, | |
| 60 | password: "a-good-passphrase", | |
| 61 | name: "t", | |
| 62 | scope: "admin", | |
| 63 | }), | |
| 64 | }) | |
| 65 | ).json()) as { token: string } | |
| 66 | ).token; | |
| 67 | ||
| 68 | beforeAll(async () => { | |
| 69 | dataDir = await mkdtemp(join(tmpdir(), "forge-secret-store-")); | |
| 70 | app = createApp({ dataDir, port: 0 }); | |
| 71 | servers = await serve(app); | |
| 72 | const a = await createUser(app.db, { | |
| 73 | username: "alice", | |
| 74 | email: "[email protected]", | |
| 75 | password: "a-good-passphrase", | |
| 76 | adminIfFirst: true, | |
| 77 | }); | |
| 78 | if (!a.ok) throw new Error(a.error); | |
| 79 | alice = a.value; | |
| 80 | const b = await createUser(app.db, { | |
| 81 | username: "bob", | |
| 82 | email: "[email protected]", | |
| 83 | password: "a-good-passphrase", | |
| 84 | }); | |
| 85 | if (!b.ok) throw new Error(b.error); | |
| 86 | const org = createOrg(app.db, "acme", alice.id); | |
| 87 | if (!org.ok) throw new Error(org.error); | |
| 88 | orgId = org.value.id; | |
| 89 | const repo = await createRepo(app, alice, { | |
| 90 | name: "site", | |
| 91 | visibility: "private", | |
| 92 | }); | |
| 93 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 94 | repoId = repo.value.id; | |
| 95 | adminToken = await mint("alice"); | |
| 96 | bobToken = await mint("bob"); | |
| 97 | }); | |
| 98 | ||
| 99 | afterAll(async () => { | |
| 100 | servers.stop(); | |
| 101 | app.close(); | |
| 102 | await rm(dataDir, { recursive: true, force: true }); | |
| 103 | }); | |
| 104 | ||
| 105 | test("a value is sealed under its scope and name, and opens only there", () => { | |
| 106 | const set = setSecret(app.db, dataDir, "forge", "", "CF_TOKEN", "s3cret-1"); | |
| 107 | expect(set.ok).toBe(true); | |
| 108 | const sealed = stored("CF_TOKEN", "forge")!; | |
| 109 | expect(sealed).toStartWith("v1:"); | |
| 110 | expect(sealed).not.toContain("s3cret-1"); | |
| 111 | const right = open(dataDir, sealed, "secret:forge:CF_TOKEN"); | |
| 112 | expect(right.ok && right.value).toBe("s3cret-1"); | |
| 113 | // Lifted under another name or scope, it stops opening. | |
| 114 | expect(open(dataDir, sealed, "secret:forge:OTHER").ok).toBe(false); | |
| 115 | expect(open(dataDir, sealed, "secret:org:CF_TOKEN").ok).toBe(false); | |
| 116 | expect(ask(["CF_TOKEN"])).toEqual({ CF_TOKEN: "s3cret-1" }); | |
| 117 | }); | |
| 118 | ||
| 119 | test("the narrowest scope wins, and a name nobody set is absent", () => { | |
| 120 | setSecret(app.db, dataDir, "forge", "", "BUCKET_KEY", "forge-key"); | |
| 121 | expect(ask(["BUCKET_KEY"], { org: orgId, repo: repoId })).toEqual({ | |
| 122 | BUCKET_KEY: "forge-key", | |
| 123 | }); | |
| 124 | setSecret(app.db, dataDir, "org", orgId, "BUCKET_KEY", "org-key"); | |
| 125 | expect(ask(["BUCKET_KEY"], { org: orgId, repo: repoId })).toEqual({ | |
| 126 | BUCKET_KEY: "org-key", | |
| 127 | }); | |
| 128 | setSecret(app.db, dataDir, "repo", repoId, "BUCKET_KEY", "repo-key"); | |
| 129 | expect(ask(["BUCKET_KEY"], { org: orgId, repo: repoId })).toEqual({ | |
| 130 | BUCKET_KEY: "repo-key", | |
| 131 | }); | |
| 132 | // Without the repo or org named, only the forge's answers. | |
| 133 | expect(ask(["BUCKET_KEY"])).toEqual({ BUCKET_KEY: "forge-key" }); | |
| 134 | expect(ask(["NEVER_SET"], { repo: repoId })).toEqual({}); | |
| 135 | }); | |
| 136 | ||
| 137 | test("a row that will not open is skipped, not substituted by a wider one", () => { | |
| 138 | setSecret(app.db, dataDir, "forge", "", "ROTATED", "wide"); | |
| 139 | setSecret(app.db, dataDir, "repo", repoId, "ROTATED", "narrow"); | |
| 140 | app.db | |
| 141 | .query( | |
| 142 | "UPDATE secret SET value_enc = ?1 WHERE name = 'ROTATED' AND scope = 'repo'", | |
| 143 | ) | |
| 144 | .run(seal(dataDir, "narrow", "secret:repo:SOMETHING_ELSE")); | |
| 145 | expect(ask(["ROTATED"], { repo: repoId })).toEqual({}); | |
| 146 | }); | |
| 147 | ||
| 148 | test("names and values are validated at the boundary", () => { | |
| 149 | const bad = (scope: string, id: string, name: string, value: string) => | |
| 150 | setSecret(app.db, dataDir, scope, id, name, value); | |
| 151 | expect(bad("forge", "", "lower", "x")).toEqual({ | |
| 152 | ok: false, | |
| 153 | error: "invalid_name", | |
| 154 | }); | |
| 155 | expect(bad("forge", "", "A", "")).toEqual({ | |
| 156 | ok: false, | |
| 157 | error: "invalid_value", | |
| 158 | }); | |
| 159 | expect(bad("forge", "", "A", "a\0b")).toEqual({ | |
| 160 | ok: false, | |
| 161 | error: "invalid_value", | |
| 162 | }); | |
| 163 | expect(bad("galaxy", "", "A", "x")).toEqual({ | |
| 164 | ok: false, | |
| 165 | error: "invalid_scope", | |
| 166 | }); | |
| 167 | expect(bad("org", "", "A", "x")).toEqual({ | |
| 168 | ok: false, | |
| 169 | error: "invalid_scope", | |
| 170 | }); | |
| 171 | }); | |
| 172 | ||
| 173 | test("listing is names and timestamps, never a value", async () => { | |
| 174 | const listed = listSecretNames(app.db, "forge"); | |
| 175 | expect(listed.map((s) => s.name)).toContain("CF_TOKEN"); | |
| 176 | expect(JSON.stringify(listed)).not.toContain("s3cret-1"); | |
| 177 | ||
| 178 | const res = await api("/api/v1/secrets/forge"); | |
| 179 | expect(res.status).toBe(200); | |
| 180 | const text = await res.text(); | |
| 181 | expect(text).toContain('"name":"CF_TOKEN"'); | |
| 182 | expect(text).toContain('"lastReadAt"'); | |
| 183 | expect(text).not.toContain("s3cret-1"); | |
| 184 | expect(text).not.toContain("value_enc"); | |
| 185 | }); | |
| 186 | ||
| 187 | test("only a forge admin writes, over the API, at every scope", async () => { | |
| 188 | const put = (token: string, path: string, body: unknown) => | |
| 189 | api(path, { method: "PUT", body: JSON.stringify(body) }, token); | |
| 190 | expect( | |
| 191 | (await put(bobToken, "/api/v1/secrets/forge/NOPE", { value: "x" })) | |
| 192 | .status, | |
| 193 | ).toBe(404); | |
| 194 | expect(stored("NOPE", "forge")).toBeUndefined(); | |
| 195 | expect((await api("/api/v1/secrets/forge", {}, bobToken)).status).toBe(404); | |
| 196 | expect( | |
| 197 | ( | |
| 198 | await api( | |
| 199 | "/api/v1/secrets/forge/CF_TOKEN", | |
| 200 | { method: "DELETE" }, | |
| 201 | bobToken, | |
| 202 | ) | |
| 203 | ).status, | |
| 204 | ).toBe(404); | |
| 205 | expect(stored("CF_TOKEN", "forge")).toBeDefined(); | |
| 206 | ||
| 207 | expect( | |
| 208 | (await put(adminToken, "/api/v1/secrets/forge/API_SET", { value: "v" })) | |
| 209 | .status, | |
| 210 | ).toBe(200); | |
| 211 | expect( | |
| 212 | ( | |
| 213 | await put(adminToken, "/api/v1/secrets/org/ORG_SET", { | |
| 214 | value: "v", | |
| 215 | ref: "acme", | |
| 216 | }) | |
| 217 | ).status, | |
| 218 | ).toBe(200); | |
| 219 | expect( | |
| 220 | ( | |
| 221 | await put(adminToken, "/api/v1/secrets/repo/REPO_SET", { | |
| 222 | value: "v", | |
| 223 | ref: "alice/site", | |
| 224 | }) | |
| 225 | ).status, | |
| 226 | ).toBe(200); | |
| 227 | expect( | |
| 228 | ( | |
| 229 | await put(adminToken, "/api/v1/secrets/org/X", { | |
| 230 | value: "v", | |
| 231 | ref: "nobody", | |
| 232 | }) | |
| 233 | ).status, | |
| 234 | ).toBe(404); | |
| 235 | expect( | |
| 236 | (await put(adminToken, "/api/v1/secrets/forge/bad", { value: "v" })) | |
| 237 | .status, | |
| 238 | ).toBe(422); | |
| 239 | expect(listSecretNames(app.db, "org", orgId).map((s) => s.name)).toEqual([ | |
| 240 | "BUCKET_KEY", | |
| 241 | "ORG_SET", | |
| 242 | ]); | |
| 243 | ||
| 244 | expect( | |
| 245 | (await api("/api/v1/secrets/forge/API_SET", { method: "DELETE" })).status, | |
| 246 | ).toBe(200); | |
| 247 | expect( | |
| 248 | (await api("/api/v1/secrets/forge/API_SET", { method: "DELETE" })).status, | |
| 249 | ).toBe(422); | |
| 250 | expect(stored("API_SET", "forge")).toBeUndefined(); | |
| 251 | }); | |
| 252 | ||
| 253 | test("every open leaves an event with who, which run and what name — not the value", () => { | |
| 254 | setSecret(app.db, dataDir, "forge", "", "AUDITED", "plain-audited"); | |
| 255 | const before = app.db | |
| 256 | .query<{ n: number }, []>( | |
| 257 | "SELECT count(*) n FROM event WHERE kind = 'secret.read'", | |
| 258 | ) | |
| 259 | .get()!.n; | |
| 260 | ask(["AUDITED"], { actor: alice.id, run: "run-42", repo: repoId }); | |
| 261 | const events = app.db | |
| 262 | .query< | |
| 263 | { actor_id: string | null; repo_id: string | null; payload: string }, | |
| 264 | [] | |
| 265 | >( | |
| 266 | "SELECT actor_id, repo_id, payload FROM event WHERE kind = 'secret.read' ORDER BY id DESC", | |
| 267 | ) | |
| 268 | .all(); | |
| 269 | expect(events.length).toBe(before + 1); | |
| 270 | expect(events[0]!.actor_id).toBe(alice.id); | |
| 271 | expect(JSON.parse(events[0]!.payload)).toEqual({ | |
| 272 | name: "AUDITED", | |
| 273 | scope: "forge", | |
| 274 | run: "run-42", | |
| 275 | }); | |
| 276 | expect(JSON.stringify(events)).not.toContain("plain-audited"); | |
| 277 | // The set and delete events carry names too, and no value. | |
| 278 | const all = app.db | |
| 279 | .query<{ payload: string }, []>( | |
| 280 | "SELECT payload FROM event WHERE kind LIKE 'secret.%'", | |
| 281 | ) | |
| 282 | .all(); | |
| 283 | expect(JSON.stringify(all)).not.toContain("s3cret-1"); | |
| 284 | // And last_read_at is stamped. | |
| 285 | expect( | |
| 286 | listSecretNames(app.db, "forge").find((s) => s.name === "AUDITED")! | |
| 287 | .lastReadAt, | |
| 288 | ).not.toBeNull(); | |
| 289 | }); | |
| 290 | ||
| 291 | test("a secret never reaches an app's environment", () => { | |
| 292 | // The namespaces are apart: the app resolver reads app_env only. | |
| 293 | const row = app.db | |
| 294 | .query<{ n: number }, []>("SELECT count(*) n FROM app_env") | |
| 295 | .get()!; | |
| 296 | expect(row.n).toBe(0); | |
| 297 | }); | |
| 298 | ||
| 299 | test("the settings pages: forge, org and repo cards, admins only", async () => { | |
| 300 | const login = async (username: string): Promise<string> => { | |
| 301 | const res = await fetch(`${servers.url}/signin`, { | |
| 302 | method: "POST", | |
| 303 | redirect: "manual", | |
| 304 | headers: { | |
| 305 | "content-type": "application/x-www-form-urlencoded", | |
| 306 | origin: servers.url, | |
| 307 | }, | |
| 308 | body: new URLSearchParams({ username, password: "a-good-passphrase" }), | |
| 309 | }); | |
| 310 | return (res.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; | |
| 311 | }; | |
| 312 | const cookie = await login("alice"); | |
| 313 | const get = (path: string, c: string) => | |
| 314 | fetch(`${servers.url}${path}`, { headers: { cookie: c } }); | |
| 315 | for (const path of ["/settings/secrets", "/acme", "/alice/site/settings"]) { | |
| 316 | const page = await (await get(path, cookie)).text(); | |
| 317 | expect(page).toContain("Secrets"); | |
| 318 | expect(page).not.toContain("s3cret-1"); | |
| 319 | } | |
| 320 | expect(await (await get("/settings/secrets", cookie)).text()).toContain( | |
| 321 | "CF_TOKEN", | |
| 322 | ); | |
| 323 | const bob = await login("bob"); | |
| 324 | expect((await get("/settings/secrets", bob)).status).toBe(404); | |
| 325 | expect(await (await get("/acme", bob)).text()).not.toContain("Seal"); | |
| 326 | ||
| 327 | const set = await fetch(`${servers.url}/settings/secrets`, { | |
| 328 | method: "POST", | |
| 329 | redirect: "manual", | |
| 330 | headers: { | |
| 331 | cookie, | |
| 332 | "content-type": "application/x-www-form-urlencoded", | |
| 333 | origin: servers.url, | |
| 334 | }, | |
| 335 | body: new URLSearchParams({ | |
| 336 | scope: "forge", | |
| 337 | ref: "", | |
| 338 | name: "FORM_SET", | |
| 339 | value: "v", | |
| 340 | }), | |
| 341 | }); | |
| 342 | expect(set.status).toBe(303); | |
| 343 | expect(stored("FORM_SET", "forge")).toBeDefined(); | |
| 344 | const cross = await fetch(`${servers.url}/settings/secrets`, { | |
| 345 | method: "POST", | |
| 346 | redirect: "manual", | |
| 347 | headers: { | |
| 348 | cookie, | |
| 349 | "content-type": "application/x-www-form-urlencoded", | |
| 350 | origin: "http://evil.test", | |
| 351 | }, | |
| 352 | body: new URLSearchParams({ | |
| 353 | scope: "forge", | |
| 354 | ref: "", | |
| 355 | name: "CROSS", | |
| 356 | value: "v", | |
| 357 | }), | |
| 358 | }); | |
| 359 | expect(cross.status).toBe(403); | |
| 360 | expect(stored("CROSS", "forge")).toBeUndefined(); | |
| 361 | }); | |
| 362 | }); |
new above
offline