storage-keys.ts209 lines · main
1import { newId, NotFoundError } from '@briven/shared';
2import { and, eq, sql } from 'drizzle-orm';
3
4import { getDb } from '../db/client.js';
5import { storageKeys } from '../db/schema.js';
6import { log } from '../lib/logger.js';
7import {
8 bucketNameFor,
9 createScopedKey,
10 ensureBucket,
11 isMinioAdminConfigured,
12 removeScopedKey,
13} from './minio-admin.js';
14
15/**
16 * Per-project storage keys — mint a bucket-scoped S3 service-account key a
17 * customer can plug into any S3 tool. The secret is returned ONCE at creation
18 * and never stored (MinIO holds it); we keep only the access-key id + metadata,
19 * so revoke = remove the MinIO service account + mark the row.
20 *
21 * The control-plane table is created idempotently on first use (same pattern as
22 * auth-origin-allowlist) so no drizzle migration/snapshot is needed.
23 */
24
25let tableReady = false;
26async function ensureTable(): Promise<void> {
27 if (tableReady) return;
28 await getDb().execute(
29 sql.raw(
30 `CREATE TABLE IF NOT EXISTS "storage_keys" (
31 "id" text PRIMARY KEY NOT NULL,
32 "project_id" text NOT NULL,
33 "name" text NOT NULL,
34 "access_key_id" text NOT NULL,
35 "suffix" varchar(4) NOT NULL,
36 "bucket" text NOT NULL,
37 "enabled" boolean DEFAULT true NOT NULL,
38 "created_by" text,
39 "created_at" timestamp with time zone DEFAULT now() NOT NULL,
40 "revoked_at" timestamp with time zone
41 )`,
42 ),
43 );
44 await getDb().execute(
45 sql.raw(
46 `CREATE UNIQUE INDEX IF NOT EXISTS "storage_keys_access_key_idx" ON "storage_keys" ("access_key_id")`,
47 ),
48 );
49 await getDb().execute(
50 sql.raw(`CREATE INDEX IF NOT EXISTS "storage_keys_project_idx" ON "storage_keys" ("project_id")`),
51 );
52 tableReady = true;
53}
54
55export interface StorageKeyRecord {
56 id: string;
57 name: string;
58 accessKeyId: string;
59 suffix: string;
60 bucket: string;
61 enabled: boolean;
62 createdAt: string;
63 revokedAt: string | null;
64}
65
66export interface CreatedStorageKey {
67 record: StorageKeyRecord;
68 /** Full S3 credentials — returned ONCE, never stored. */
69 endpoint: string;
70 bucket: string;
71 accessKey: string;
72 secretKey: string;
73}
74
75function toRecord(r: {
76 id: string;
77 name: string;
78 accessKeyId: string;
79 suffix: string;
80 bucket: string;
81 enabled: boolean;
82 createdAt: Date;
83 revokedAt: Date | null;
84}): StorageKeyRecord {
85 return {
86 id: r.id,
87 name: r.name,
88 accessKeyId: r.accessKeyId,
89 suffix: r.suffix,
90 bucket: r.bucket,
91 enabled: r.enabled,
92 createdAt: (r.createdAt instanceof Date ? r.createdAt : new Date(r.createdAt)).toISOString(),
93 revokedAt: r.revokedAt
94 ? (r.revokedAt instanceof Date ? r.revokedAt : new Date(r.revokedAt)).toISOString()
95 : null,
96 };
97}
98
99export async function listStorageKeys(projectId: string): Promise<StorageKeyRecord[]> {
100 await ensureTable();
101 const rows = await getDb().select().from(storageKeys).where(eq(storageKeys.projectId, projectId));
102 return rows.map(toRecord).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
103}
104
105export async function createStorageKey(input: {
106 projectId: string;
107 name: string;
108 createdBy: string | null;
109 publicEndpoint: string;
110}): Promise<CreatedStorageKey> {
111 if (!isMinioAdminConfigured()) {
112 throw new NotFoundError('storage', 'object storage is not configured on this api');
113 }
114 await ensureTable();
115 const bucket = bucketNameFor(input.projectId);
116 await ensureBucket(bucket);
117 const { accessKey, secretKey } = await createScopedKey({
118 bucket,
119 // MinIO caps the service-account NAME at 32 chars — pass only the customer's
120 // key label (createScopedKey hard-caps it too). The project is already tied
121 // to the key via its dedicated bucket + inline policy.
122 name: input.name,
123 });
124 const suffix = secretKey.slice(-4);
125 const row = {
126 id: newId('sk'),
127 projectId: input.projectId,
128 name: input.name,
129 accessKeyId: accessKey,
130 suffix,
131 bucket,
132 createdBy: input.createdBy,
133 };
134 await getDb().insert(storageKeys).values(row);
135 log.info('storage_key_created', { projectId: input.projectId, bucket, accessKey });
136 return {
137 record: {
138 id: row.id,
139 name: row.name,
140 accessKeyId: accessKey,
141 suffix,
142 bucket,
143 enabled: true,
144 createdAt: new Date().toISOString(),
145 revokedAt: null,
146 },
147 endpoint: input.publicEndpoint,
148 bucket,
149 accessKey,
150 secretKey,
151 };
152}
153
154/**
155 * Standard project setup: ensure the project's MinIO bucket exists and mint a
156 * default storage key if the project has none yet. Best-effort for callers that
157 * must not fail project creation when MinIO is down/unconfigured.
158 *
159 * Returns the one-time secret payload when a new key was minted; `null` when
160 * storage is off, a key already exists, or mint failed (logged).
161 */
162export async function ensureDefaultProjectStorage(input: {
163 projectId: string;
164 createdBy: string | null;
165 publicEndpoint: string;
166 keyName?: string;
167}): Promise<CreatedStorageKey | null> {
168 if (!isMinioAdminConfigured()) {
169 log.info('project_storage_skip_not_configured', { projectId: input.projectId });
170 return null;
171 }
172 try {
173 const existing = await listStorageKeys(input.projectId);
174 if (existing.some((k) => !k.revokedAt)) {
175 // Bucket already in use — still ensure it exists (idempotent).
176 await ensureBucket(bucketNameFor(input.projectId));
177 return null;
178 }
179 return await createStorageKey({
180 projectId: input.projectId,
181 name: input.keyName ?? 'default',
182 createdBy: input.createdBy,
183 publicEndpoint: input.publicEndpoint,
184 });
185 } catch (err) {
186 log.warn('project_storage_auto_setup_failed', {
187 projectId: input.projectId,
188 message: err instanceof Error ? err.message : String(err),
189 });
190 return null;
191 }
192}
193
194export async function revokeStorageKey(projectId: string, keyId: string): Promise<void> {
195 await ensureTable();
196 const db = getDb();
197 const [row] = await db
198 .select({ accessKeyId: storageKeys.accessKeyId })
199 .from(storageKeys)
200 .where(and(eq(storageKeys.id, keyId), eq(storageKeys.projectId, projectId)))
201 .limit(1);
202 if (!row) throw new NotFoundError('storage_key', keyId);
203 await removeScopedKey(row.accessKeyId);
204 await db
205 .update(storageKeys)
206 .set({ enabled: false, revokedAt: new Date() })
207 .where(and(eq(storageKeys.id, keyId), eq(storageKeys.projectId, projectId)));
208 log.info('storage_key_revoked', { projectId, keyId, accessKey: row.accessKeyId });
209}