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

Forge-level secrets: a sealed, write-only slot an operator can trust #356

Merged opened this in answer to

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

Merged

scout/forge:scout/335-forge-level-secrets-a-sealed-write-only → main 1 commit fork

  • Merged. scout/335-forge-level-secrets-a-sealed-write-only was merged into main as 4d684d7.

Review

Sign in to review this change.
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
251251 previews, the live one for production, and neither process ever holds the
252252 other's. Every preview is one scope, whichever pull it serves.
253253
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
254268 A process reads its environment once, at start, so setting or removing a
255269 variable redeploys what reads it, at its current commit, and nothing else:
256270 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 {
2727 kind.startsWith("app.env_") ||
2828 kind.startsWith("app.domain.") ||
2929 kind.startsWith("prompt.") ||
30 kind.startsWith("secret.") ||
3031 kind === "repo.settings_updated"
3132 )
3233 return "admins";
src/core/schema.ts +15
@@ -922,4 +922,19 @@ export const migrations: readonly string[] = [
922922 );
923923 CREATE INDEX prompt_source_repo ON prompt_source(repo_id);
924924 `,
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 `,
925940 ];
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 @@
88 * back to continue); errors are `{ error, message }`.
99 */
1010
11 import { listSecrets, putSecret, removeSecret } from "../actions/secrets.ts";
1112 import { proposeUpstream } from "../actions/upstream.ts";
1213 import { relationOf, syncFork, takeUpdates } from "../actions/forks.ts";
1314 import { convergeFork, convergeState } from "../crew/converge.ts";
@@ -2980,6 +2981,53 @@ export function apiRoutes(router: Router<Handler>): void {
29802981 return json({}, 200);
29812982 });
29822983
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
29833031 // The app's environment: names in, names out, and never a value back.
29843032 // `:var`, not `:name` — `:name` is the repository's.
29853033 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";
4545 import { followingSubmit } from "../web/pages/following.ts";
4646 import { activityAtom } from "../web/feeds.ts";
4747 import { auditCsv, auditPage, auditProbeSubmit } from "../web/pages/audit.ts";
48 import { secretDelete, secretSet, secretsPage } from "../web/pages/secrets.ts";
4849 import { backupsNow, backupsPage } from "../web/pages/backups.ts";
4950 import {
5051 subscriptionAdd,
@@ -467,6 +468,9 @@ export function buildRouter(): Router<Handler> {
467468 router.add("POST", "/settings/subscriptions", subscriptionAdd);
468469 router.add("POST", "/settings/subscriptions/:id/delete", subscriptionDelete);
469470 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);
470474 router.add("GET", "/settings/backups", backupsPage);
471475 router.add("POST", "/settings/backups/now", backupsNow);
472476 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";
3636 import { html, when, type Html } from "../html.ts";
3737 import { icon } from "../icons.ts";
3838 import { flashTo, page } from "../layout.ts";
39 import { secretsSection } from "./secrets.ts";
3940 import { agentsOnForge, grantsSection, saveGrants } from "./subscriptions.ts";
4041 import { shelfRows } from "../studio/shelf.ts";
4142 import {
@@ -336,11 +337,17 @@ export async function ownerProfile(ctx: Ctx): Promise<Response> {
336337 title: "Subscriptions for every repository here",
337338 })}
338339 </div>`,
339 )}`;
340 )}
341 ${isOrg ? orgSecrets(ctx, owner.username) : null}`;
340342
341343 return page({ ctx, title: owner.username, body });
342344 }
343345
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
344351 // POST /:owner/-/subscriptions
345352 export async function orgSubscriptionsSave(ctx: Ctx): Promise<Response> {
346353 const org = requireOrgAdmin(ctx);
src/web/pages/origins.ts +8 −1
@@ -69,7 +69,8 @@ export type AccountSection =
6969 | "origins"
7070 | "crew"
7171 | "subscriptions"
72 | "backups";
72 | "backups"
73 | "secrets";
7374
7475 /**
7576 * The settings sidebar, with Origins on it. Kept here so the account pages
@@ -96,6 +97,12 @@ export function accountSections(
9697 icon: "key" as const,
9798 current: current === "subscriptions",
9899 },
100 {
101 label: "Secrets",
102 href: "/settings/secrets",
103 icon: "lock" as const,
104 current: current === "secrets",
105 },
99106 {
100107 label: "Backups",
101108 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 {
108108 timeAgo,
109109 userLink,
110110 } from "../ui.ts";
111 import { secretsSection } from "./secrets.ts";
111112 import { crewReadiness } from "./crew.ts";
112113 import { grantsSection, saveGrants } from "./subscriptions.ts";
113114
@@ -324,7 +325,14 @@ export async function settingsPage(ctx: Ctx): Promise<Response> {
324325 </form>`,
325326 { head: html`<h2>General</h2>` },
326327 );
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 );
328336 }
329337
330338 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs