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.tsBlame279 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
18b210dClaude185/**
186 * Substitute `${{ secrets.NAME }}` references inside a template string with
187 * the corresponding plaintext from `secrets`. Pure helper — no DB, no
188 * side effects. Designed to feed the workflow runner's per-step `run:`
189 * value before handing it to `bash -c`.
190 *
191 * Behaviour:
192 * - Whitespace inside the `{{ ... }}` is tolerated: `${{secrets.X}}`,
193 * `${{ secrets.X }}`, `${{ secrets . X }}`.
194 * - Names that don't appear in `secrets` are left intact (so the
195 * unsubstituted token shows up in logs and surfaces the misconfig
196 * loudly — matches the loadSecretsContext docstring contract).
197 * - Names that don't match the GitHub-Actions secret-name grammar
198 * (`[A-Z_][A-Z0-9_]*`) are also left intact, defence-in-depth
199 * against malformed YAML producing weird tokens.
200 * - The function never throws on bad input; non-string templates
201 * return `""`.
202 *
203 * Pure regex; no exec-on-string allocations beyond the single replace.
204 */
205export function substituteSecrets(
206 template: string,
207 secrets: Record<string, string>
208): string {
209 if (typeof template !== "string") return "";
210 if (!template || !secrets) return template || "";
211 // ${{ <ws>* secrets <ws>* . <ws>* NAME <ws>* }}
212 // We split the regex on the dot so we can tolerate whitespace either
213 // side of it; the NAME group is captured with the strict grammar so a
214 // malformed identifier won't accidentally substitute.
215 return template.replace(
216 /\$\{\{\s*secrets\s*\.\s*([A-Z_][A-Z0-9_]*)\s*\}\}/g,
217 (match, name: string) => {
218 if (Object.prototype.hasOwnProperty.call(secrets, name)) {
219 return secrets[name];
220 }
221 return match;
222 }
223 );
224}
abfa9adClaude225/**
226 * Build the `{ NAME: plaintext }` map consumed by the runner's
227 * `substituteSecrets(template, secrets)` calls.
228 *
229 * Graceful-degrade semantics (intentional — the runner MUST NOT crash here):
230 * - Master key missing → returns `{}`. A warning is logged. Every
231 * `${{ secrets.X }}` token will pass through untouched, making the
232 * misconfiguration visible in job logs.
233 * - Individual decryption failure (tampered row, key rotated, etc.) → that
234 * secret is skipped; others still load.
235 * - DB error → returns `{}`.
236 *
237 * Note: this returns the raw map, not a `{ok,...}` tuple, because the runner
238 * wants a hot path with no branching at call sites.
239 */
240export async function loadSecretsContext(
241 repoId: string
242): Promise<Record<string, string>> {
243 if (typeof repoId !== "string" || repoId.length === 0) {
244 return {};
245 }
246 if (!getMasterKey()) {
247 console.error(
248 "[workflow-secrets] loadSecretsContext: WORKFLOW_SECRETS_KEY missing; secrets will not be substituted"
249 );
250 return {};
251 }
252
253 let rows: { name: string; encryptedValue: string }[];
254 try {
255 rows = await db
256 .select({
257 name: workflowSecrets.name,
258 encryptedValue: workflowSecrets.encryptedValue,
259 })
260 .from(workflowSecrets)
261 .where(eq(workflowSecrets.repositoryId, repoId));
262 } catch (err) {
263 console.error("[workflow-secrets] loadSecretsContext db error:", err);
264 return {};
265 }
266
267 const out: Record<string, string> = {};
268 for (const row of rows) {
269 const dec = decryptSecret(row.encryptedValue);
270 if (!dec.ok) {
271 console.error(
272 `[workflow-secrets] decrypt failed for secret "${row.name}": ${dec.error}`
273 );
274 continue;
275 }
276 out[row.name] = dec.plaintext;
277 }
278 return out;
279}