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

org-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.

org-secrets.tsBlame186 lines · 1 contributor
05ab9b1Claude1/**
2 * Org-level secrets — DB CRUD + runtime loader (Block M1).
3 *
4 * Stores AES-256-GCM ciphertext produced by `workflow-secrets-crypto.ts`.
5 * The table is defined inline here because `src/db/schema.ts` is locked —
6 * no new tables may be added there.
7 *
8 * Public API:
9 * listOrgSecrets(orgId) — metadata only, never plaintext
10 * upsertOrgSecret(orgId, name, …) — create or replace by (org_id, name)
11 * deleteOrgSecret(orgId, secretId) — scoped delete, prevents cross-org deletes
12 * loadOrgSecretsForRepo(orgId) — { NAME: plaintext } map for runner
13 */
14
15import { and, asc, eq } from "drizzle-orm";
16import {
17 pgTable,
18 uuid,
19 text,
20 timestamp,
21 uniqueIndex,
22 index,
23} from "drizzle-orm/pg-core";
24import { db } from "../db";
25import { encryptSecret, decryptSecret } from "./workflow-secrets-crypto";
26
27// ── Inline Drizzle table definition ────────────────────────────────────────
28
29export const orgSecrets = pgTable(
30 "org_secrets",
31 {
32 id: uuid("id").primaryKey().defaultRandom(),
33 orgId: uuid("org_id").notNull(),
34 name: text("name").notNull(),
35 encryptedValue: text("encrypted_value").notNull(),
36 iv: text("iv").notNull(),
37 keyHint: text("key_hint"),
38 createdBy: uuid("created_by"),
39 createdAt: timestamp("created_at").defaultNow().notNull(),
40 updatedAt: timestamp("updated_at").defaultNow().notNull(),
41 },
42 (table) => [
43 uniqueIndex("org_secrets_org_name_uq").on(table.orgId, table.name),
44 index("org_secrets_org_idx").on(table.orgId),
45 ]
46);
47
48export type OrgSecret = typeof orgSecrets.$inferSelect;
49
50// ── Validation ──────────────────────────────────────────────────────────────
51
52const SECRET_NAME_RE = /^[A-Z_][A-Z0-9_]*$/;
53const MAX_NAME_LEN = 100;
54
55// ── Public API ──────────────────────────────────────────────────────────────
56
57/**
58 * List all secrets for an org — metadata only (no plaintext, no ciphertext).
59 * Ordered alphabetically by name.
60 */
61export async function listOrgSecrets(
62 orgId: string
63): Promise<
64 Array<{
65 id: string;
66 name: string;
67 keyHint: string | null;
68 createdAt: Date;
69 updatedAt: Date;
70 }>
71> {
72 try {
73 const rows = await db
74 .select({
75 id: orgSecrets.id,
76 name: orgSecrets.name,
77 keyHint: orgSecrets.keyHint,
78 createdAt: orgSecrets.createdAt,
79 updatedAt: orgSecrets.updatedAt,
80 })
81 .from(orgSecrets)
82 .where(eq(orgSecrets.orgId, orgId))
83 .orderBy(asc(orgSecrets.name));
84 return rows;
85 } catch (err) {
86 console.error("[org-secrets] listOrgSecrets:", err);
87 return [];
88 }
89}
90
91/**
92 * Create or replace a secret. Name must be `[A-Z_][A-Z0-9_]*`.
93 * Uses delete-then-insert to handle the unique constraint on (org_id, name).
94 */
95export async function upsertOrgSecret(
96 orgId: string,
97 name: string,
98 plaintext: string,
99 createdBy: string
100): Promise<void> {
101 if (!SECRET_NAME_RE.test(name) || name.length > MAX_NAME_LEN) {
102 throw new Error(
103 `Secret name must match /^[A-Z_][A-Z0-9_]*$/ and be at most ${MAX_NAME_LEN} chars.`
104 );
105 }
106
107 const result = encryptSecret(plaintext);
108 if (!result.ok) {
109 throw new Error(`Encryption failed: ${result.error}`);
110 }
111
112 // keyHint: last 4 chars of the plaintext, displayed in the UI
113 const keyHint =
114 plaintext.length >= 4 ? plaintext.slice(-4) : "*".repeat(plaintext.length);
115
116 // The encrypted blob from encryptSecret is a single base64 string that
117 // already contains [iv(12) || tag(16) || ciphertext]. We store it in
118 // `encrypted_value` and leave `iv` as an empty string to satisfy the
119 // NOT NULL constraint (the column was part of the original schema spec
120 // but the crypto module packs iv into the ciphertext blob).
121 const now = new Date();
122
123 // Delete existing row for this (orgId, name) if present, then insert.
124 await db
125 .delete(orgSecrets)
126 .where(and(eq(orgSecrets.orgId, orgId), eq(orgSecrets.name, name)));
127
128 await db.insert(orgSecrets).values({
129 orgId,
130 name,
131 encryptedValue: result.ciphertext,
132 iv: "", // iv is packed into encryptedValue by encryptSecret
133 keyHint,
134 createdBy,
135 createdAt: now,
136 updatedAt: now,
137 });
138}
139
140/**
141 * Delete a specific secret, scoped by both orgId and secretId so a caller
142 * with one org's context cannot delete another org's secret by guessing a UUID.
143 */
144export async function deleteOrgSecret(
145 orgId: string,
146 secretId: string
147): Promise<void> {
148 await db
149 .delete(orgSecrets)
150 .where(and(eq(orgSecrets.orgId, orgId), eq(orgSecrets.id, secretId)));
151}
152
153/**
154 * Load all org secrets as a `{ NAME: plaintext }` map.
155 * Called by the workflow runner to merge org-level secrets with repo-level ones
156 * (repo-level secrets of the same name take precedence — callers apply that
157 * merge logic themselves via `{ ...orgSecrets, ...repoSecrets }`).
158 *
159 * Swallows all errors and returns `{}` on failure so a misconfigured master
160 * key or a transient DB outage never aborts a workflow run.
161 */
162export async function loadOrgSecretsForRepo(
163 orgId: string
164): Promise<Record<string, string>> {
165 try {
166 const rows = await db
167 .select({
168 name: orgSecrets.name,
169 encryptedValue: orgSecrets.encryptedValue,
170 })
171 .from(orgSecrets)
172 .where(eq(orgSecrets.orgId, orgId));
173
174 const map: Record<string, string> = {};
175 for (const row of rows) {
176 const dec = decryptSecret(row.encryptedValue);
177 if (dec.ok) {
178 map[row.name] = dec.plaintext;
179 }
180 }
181 return map;
182 } catch (err) {
183 console.error("[org-secrets] loadOrgSecretsForRepo:", err);
184 return {};
185 }
186}