Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

workflow-secrets.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

workflow-secrets.tsBlame239 lines · 1 contributor
abfa9adClaude1/**
2 * Workflow secrets — DB-backed CRUD + runtime context loader.
3 *
4 * Sibling to `workflow-secrets-crypto.ts`, which owns the pure AES-256-GCM
5 * primitives. This file is the *only* place outside the runner that touches
6 * `workflowSecrets` rows. All public fns follow the project-wide
7 * `{ok:true,...}` / `{ok:false,error}` contract and never throw — DB errors
8 * are caught and collapsed to a terse error string.
9 *
10 * Callers:
11 * - Agent 7 (settings UI) uses `listRepoSecrets`, `upsertRepoSecret`,
12 * `deleteRepoSecret` to render + mutate the per-repo secrets table.
13 * - Agent 5 (workflow runner) calls `loadSecretsContext` once at run start
14 * to build the `{ NAME: plaintext }` map that feeds
15 * `substituteSecrets(template, secrets)` for every step's `run:` / `env:`.
16 *
17 * Security notes:
18 * - `listRepoSecrets` never returns plaintext or ciphertext — UI only sees
19 * metadata (name, createdAt, createdBy).
20 * - `deleteRepoSecret` is scoped on both (repoId, secretId) to prevent a
21 * caller with one repo's context from deleting another repo's secret by
22 * guessing a UUID.
23 * - `loadSecretsContext` silently omits secrets whose decryption fails so
24 * the `${{ secrets.NAME }}` template left intact in logs — that's a louder
25 * failure signal than substituting empty string.
26 */
27
28import { and, asc, eq } from "drizzle-orm";
29import { db } from "../db";
30import { workflowSecrets } from "../db/schema";
31import { decryptSecret, encryptSecret, getMasterKey } from "./workflow-secrets-crypto";
32
33/** Matches GitHub Actions secret-name rules: uppercase + digits + underscore,
34 * must not start with a digit, max 100 chars. Case is enforced strictly. */
35const SECRET_NAME_RE = /^[A-Z_][A-Z0-9_]*$/;
36const MAX_NAME_LEN = 100;
37
38export type SecretMetadata = {
39 id: string;
40 name: string;
41 createdAt: Date;
42 createdBy: string | null;
43};
44
45/**
46 * List a repository's secrets — metadata only.
47 *
48 * Returns rows ordered by `name` ascending. Plaintext and ciphertext are
49 * deliberately excluded so this fn is safe to call from a web handler that
50 * renders the settings page.
51 */
52export async function listRepoSecrets(
53 repoId: string
54): Promise<
55 | { ok: true; secrets: SecretMetadata[] }
56 | { ok: false; error: string }
57> {
58 if (typeof repoId !== "string" || repoId.length === 0) {
59 return { ok: false, error: "repoId is required" };
60 }
61 try {
62 const rows = await db
63 .select({
64 id: workflowSecrets.id,
65 name: workflowSecrets.name,
66 createdAt: workflowSecrets.createdAt,
67 createdBy: workflowSecrets.createdBy,
68 })
69 .from(workflowSecrets)
70 .where(eq(workflowSecrets.repositoryId, repoId))
71 .orderBy(asc(workflowSecrets.name));
72 return { ok: true, secrets: rows };
73 } catch (err) {
74 console.error("[workflow-secrets] listRepoSecrets:", err);
75 return { ok: false, error: "db query failed" };
76 }
77}
78
79/**
80 * Create or update a repository secret. Name must match `[A-Z_][A-Z0-9_]*`
81 * (max 100 chars) and value is encrypted under the master key before being
82 * written. On conflict (repo+name already exists) the existing row is
83 * replaced and its `updated_at` bumped; `created_at` and `created_by` stay
84 * pinned to the original insert for audit history.
85 */
86export async function upsertRepoSecret(args: {
87 repoId: string;
88 name: string;
89 value: string;
90 createdBy: string;
91}): Promise<{ ok: true; id: string } | { ok: false; error: string }> {
92 const { repoId, name, value, createdBy } = args;
93 if (typeof repoId !== "string" || repoId.length === 0) {
94 return { ok: false, error: "repoId is required" };
95 }
96 if (typeof createdBy !== "string" || createdBy.length === 0) {
97 return { ok: false, error: "createdBy is required" };
98 }
99 if (typeof name !== "string" || name.length === 0) {
100 return { ok: false, error: "name is required" };
101 }
102 if (name.length > MAX_NAME_LEN) {
103 return { ok: false, error: `name must be <= ${MAX_NAME_LEN} chars` };
104 }
105 if (!SECRET_NAME_RE.test(name)) {
106 return {
107 ok: false,
108 error: "name must match /^[A-Z_][A-Z0-9_]*$/ (uppercase, digits, underscore; not leading digit)",
109 };
110 }
111 if (typeof value !== "string") {
112 return { ok: false, error: "value must be a string" };
113 }
114
115 const enc = encryptSecret(value);
116 if (!enc.ok) return { ok: false, error: enc.error };
117
118 try {
119 const rows = await db
120 .insert(workflowSecrets)
121 .values({
122 repositoryId: repoId,
123 name,
124 encryptedValue: enc.ciphertext,
125 createdBy,
126 })
127 .onConflictDoUpdate({
128 target: [workflowSecrets.repositoryId, workflowSecrets.name],
129 set: {
130 encryptedValue: enc.ciphertext,
131 updatedAt: new Date(),
132 },
133 })
134 .returning({ id: workflowSecrets.id });
135 const id = rows[0]?.id;
136 if (!id) return { ok: false, error: "insert returned no row" };
137 return { ok: true, id };
138 } catch (err) {
139 console.error("[workflow-secrets] upsertRepoSecret:", err);
140 return {
141 ok: false,
142 error: `db write failed: ${err instanceof Error ? err.message : String(err)}`,
143 };
144 }
145}
146
147/**
148 * Delete a single secret by id, scoped to its repository. Both filters must
149 * match — this is defence-in-depth against a caller that has a secretId but
150 * the wrong repo context (e.g. path-param manipulation).
151 *
152 * Returns `{ok:true}` even if no row matched; the caller can't distinguish
153 * "deleted" from "never existed" and that's fine for an idempotent DELETE.
154 */
155export async function deleteRepoSecret(args: {
156 repoId: string;
157 secretId: string;
158}): Promise<{ ok: true } | { ok: false; error: string }> {
159 const { repoId, secretId } = args;
160 if (typeof repoId !== "string" || repoId.length === 0) {
161 return { ok: false, error: "repoId is required" };
162 }
163 if (typeof secretId !== "string" || secretId.length === 0) {
164 return { ok: false, error: "secretId is required" };
165 }
166 try {
167 await db
168 .delete(workflowSecrets)
169 .where(
170 and(
171 eq(workflowSecrets.id, secretId),
172 eq(workflowSecrets.repositoryId, repoId)
173 )
174 );
175 return { ok: true };
176 } catch (err) {
177 console.error("[workflow-secrets] deleteRepoSecret:", err);
178 return {
179 ok: false,
180 error: `db delete failed: ${err instanceof Error ? err.message : String(err)}`,
181 };
182 }
183}
184
185/**
186 * Build the `{ NAME: plaintext }` map consumed by the runner's
187 * `substituteSecrets(template, secrets)` calls.
188 *
189 * Graceful-degrade semantics (intentional — the runner MUST NOT crash here):
190 * - Master key missing → returns `{}`. A warning is logged. Every
191 * `${{ secrets.X }}` token will pass through untouched, making the
192 * misconfiguration visible in job logs.
193 * - Individual decryption failure (tampered row, key rotated, etc.) → that
194 * secret is skipped; others still load.
195 * - DB error → returns `{}`.
196 *
197 * Note: this returns the raw map, not a `{ok,...}` tuple, because the runner
198 * wants a hot path with no branching at call sites.
199 */
200export async function loadSecretsContext(
201 repoId: string
202): Promise<Record<string, string>> {
203 if (typeof repoId !== "string" || repoId.length === 0) {
204 return {};
205 }
206 if (!getMasterKey()) {
207 console.error(
208 "[workflow-secrets] loadSecretsContext: WORKFLOW_SECRETS_KEY missing; secrets will not be substituted"
209 );
210 return {};
211 }
212
213 let rows: { name: string; encryptedValue: string }[];
214 try {
215 rows = await db
216 .select({
217 name: workflowSecrets.name,
218 encryptedValue: workflowSecrets.encryptedValue,
219 })
220 .from(workflowSecrets)
221 .where(eq(workflowSecrets.repositoryId, repoId));
222 } catch (err) {
223 console.error("[workflow-secrets] loadSecretsContext db error:", err);
224 return {};
225 }
226
227 const out: Record<string, string> = {};
228 for (const row of rows) {
229 const dec = decryptSecret(row.encryptedValue);
230 if (!dec.ok) {
231 console.error(
232 `[workflow-secrets] decrypt failed for secret "${row.name}": ${dec.error}`
233 );
234 continue;
235 }
236 out[row.name] = dec.plaintext;
237 }
238 return out;
239}