/** * Per-workspace GCS provisioning (P5.3). * * Idempotently sets up everything a workspace needs to do object storage: * 1. A dedicated GCP service account (vibn-ws-{slug}@…) * 2. A JSON keyfile for that SA (encrypted at rest) * 3. A default GCS bucket (vibn-ws-{slug}-{6char}) in northamerica-northeast1 * 4. A bucket-scoped roles/storage.objectAdmin binding for the SA * 5. An HMAC key on the SA so app code can use AWS S3 SDKs * * Persists IDs + encrypted secrets onto vibn_workspaces. Safe to re-run; * each step is idempotent and short-circuits when already complete. * * Required schema migration: scripts/migrate-workspace-gcs.sql * * The control plane itself never decrypts the per-workspace SA key — it * always authenticates as the shared `vibn-workspace-provisioner`. The * per-workspace credentials exist solely to be injected into deployed * Coolify apps as STORAGE_* env vars (see app env injection in * apps/route.ts when wired up). */ import { query, queryOne } from '@/lib/db-postgres'; import { encryptSecret, decryptSecret } from '@/lib/auth/secret-box'; import { ensureWorkspaceServiceAccount, workspaceServiceAccountEmail, createServiceAccountKey, } from '@/lib/gcp/iam'; import { createBucket, getBucket, addBucketIamBinding, createHmacKey, listHmacKeysForServiceAccount, workspaceDefaultBucketName, VIBN_GCS_LOCATION, type GcsBucket, } from '@/lib/gcp/storage'; import type { VibnWorkspace } from '@/lib/workspaces'; /** * Extra columns added by scripts/migrate-workspace-gcs.sql. We model * them as a separate interface so the existing `VibnWorkspace` shape * doesn't have to be touched until every caller is ready. */ export interface VibnWorkspaceGcs { gcp_service_account_email: string | null; gcp_service_account_key_enc: string | null; gcs_default_bucket_name: string | null; gcs_hmac_access_id: string | null; gcs_hmac_secret_enc: string | null; gcp_provision_status: 'pending' | 'partial' | 'ready' | 'error'; gcp_provision_error: string | null; } export type WorkspaceGcs = VibnWorkspace & VibnWorkspaceGcs; export async function getWorkspaceGcsState(workspaceId: string): Promise { return queryOne( `SELECT * FROM vibn_workspaces WHERE id = $1`, [workspaceId], ); } /** What we tell the API caller after a successful provision. */ export interface WorkspaceGcsResult { serviceAccountEmail: string; bucket: { name: string; location: string; selfLink?: string; timeCreated?: string; }; hmac: { accessId: string; }; status: VibnWorkspaceGcs['gcp_provision_status']; } /** * Idempotent: ensures the workspace has a GCP SA + key + default bucket * + IAM binding + HMAC key. Updates vibn_workspaces with the resulting * identifiers (key + secret stored encrypted). Returns a flat summary * suitable for sending back to the API caller. * * Throws on any irrecoverable error; transient/partial failures land in * the row's gcp_provision_status='partial' with the message in * gcp_provision_error. */ export async function ensureWorkspaceGcsProvisioned( workspace: VibnWorkspace, ): Promise { const ws = (await getWorkspaceGcsState(workspace.id)) ?? (workspace as WorkspaceGcs); // ── Short-circuit if everything is already there. if ( ws.gcp_provision_status === 'ready' && ws.gcp_service_account_email && ws.gcs_default_bucket_name && ws.gcs_hmac_access_id ) { const existing = await getBucket(ws.gcs_default_bucket_name); if (existing) { return { serviceAccountEmail: ws.gcp_service_account_email, bucket: { name: existing.name, location: existing.location, selfLink: existing.selfLink, timeCreated: existing.timeCreated, }, hmac: { accessId: ws.gcs_hmac_access_id }, status: 'ready', }; } // Bucket vanished out from under us (manual gcloud delete?). Fall // through and re-provision; the SA + HMAC can stay. } let saEmail = ws.gcp_service_account_email; let saKeyEnc = ws.gcp_service_account_key_enc; let bucketName = ws.gcs_default_bucket_name; let hmacAccessId = ws.gcs_hmac_access_id; let hmacSecretEnc = ws.gcs_hmac_secret_enc; let bucket: GcsBucket | null = null; const errors: string[] = []; // ── 1. Service account ───────────────────────────────────────────── try { const sa = await ensureWorkspaceServiceAccount({ slug: workspace.slug, workspaceName: workspace.name, }); saEmail = sa.email; } catch (err) { const msg = err instanceof Error ? err.message : String(err); errors.push(`gcp-sa: ${msg}`); saEmail = saEmail ?? workspaceServiceAccountEmail(workspace.slug); } // ── 2. SA keyfile ───────────────────────────────────────────────── // Mint once. Rotation is a separate flow (Tier 2 territory). if (!saKeyEnc && saEmail && !errors.some(e => e.startsWith('gcp-sa:'))) { try { const key = await createServiceAccountKey(saEmail); // privateKeyData is already base64; we encrypt the whole base64 // payload so the column can stay TEXT and reuse secret-box. saKeyEnc = encryptSecret(key.privateKeyData); } catch (err) { const msg = err instanceof Error ? err.message : String(err); errors.push(`gcp-sa-key: ${msg}`); } } // ── 3. Default bucket ────────────────────────────────────────────── if (!bucketName) bucketName = workspaceDefaultBucketName(workspace.slug); if (!errors.some(e => e.startsWith('gcp-sa:'))) { try { bucket = (await getBucket(bucketName)) ?? (await createBucket({ name: bucketName, location: VIBN_GCS_LOCATION, enforcePublicAccessPrevention: true, workspaceSlug: workspace.slug, })); } catch (err) { const msg = err instanceof Error ? err.message : String(err); errors.push(`gcs-bucket: ${msg}`); } } // ── 4. Bucket IAM binding for the workspace SA ───────────────────── if (bucket && saEmail) { try { await addBucketIamBinding({ bucketName: bucket.name, role: 'roles/storage.objectAdmin', member: `serviceAccount:${saEmail}`, }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); errors.push(`gcs-iam: ${msg}`); } } // ── 5. HMAC key for app code ─────────────────────────────────────── // Only mint if we don't already have one. GCS limits 5 active keys // per SA; we never want to thrash this. if (saEmail && !hmacAccessId) { try { // Defensive: if a previous run minted a key but failed before // saving, reuse the existing ACTIVE one instead of stacking. const existingHmacs = await listHmacKeysForServiceAccount(saEmail); const active = existingHmacs.find(k => k.state === 'ACTIVE'); if (active) { hmacAccessId = active.accessId; // We can't recover the secret of a previously-minted key; leave // the encrypted secret null and let the operator rotate if they // need it injected. } else { const minted = await createHmacKey(saEmail); hmacAccessId = minted.accessId; hmacSecretEnc = encryptSecret(minted.secret); } } catch (err) { const msg = err instanceof Error ? err.message : String(err); errors.push(`gcs-hmac: ${msg}`); } } const allReady = !!(saEmail && saKeyEnc && bucket && hmacAccessId && errors.length === 0); const status: VibnWorkspaceGcs['gcp_provision_status'] = allReady ? 'ready' : errors.length > 0 ? 'partial' : 'pending'; await query( `UPDATE vibn_workspaces SET gcp_service_account_email = COALESCE($2, gcp_service_account_email), gcp_service_account_key_enc = COALESCE($3, gcp_service_account_key_enc), gcs_default_bucket_name = COALESCE($4, gcs_default_bucket_name), gcs_hmac_access_id = COALESCE($5, gcs_hmac_access_id), gcs_hmac_secret_enc = COALESCE($6, gcs_hmac_secret_enc), gcp_provision_status = $7, gcp_provision_error = $8, updated_at = now() WHERE id = $1`, [ workspace.id, saEmail, saKeyEnc, bucket?.name ?? bucketName, hmacAccessId, hmacSecretEnc, status, errors.length ? errors.join('; ') : null, ], ); if (!saEmail) throw new Error(`workspace-gcs: SA email never resolved: ${errors.join('; ')}`); if (!bucket) throw new Error(`workspace-gcs: bucket never created: ${errors.join('; ')}`); return { serviceAccountEmail: saEmail, bucket: { name: bucket.name, location: bucket.location, selfLink: bucket.selfLink, timeCreated: bucket.timeCreated, }, hmac: { accessId: hmacAccessId ?? '' }, status, }; } /** * Decrypt the workspace's HMAC secret for STORAGE_SECRET_ACCESS_KEY env * injection. Returns null when not provisioned or decrypt fails. * * Callers MUST treat this as shown-once material: log neither the * value nor anything that contains it. */ export function getWorkspaceGcsHmacCredentials(ws: WorkspaceGcs): { accessId: string; secret: string; } | null { if (!ws.gcs_hmac_access_id || !ws.gcs_hmac_secret_enc) return null; try { return { accessId: ws.gcs_hmac_access_id, secret: decryptSecret(ws.gcs_hmac_secret_enc), }; } catch (err) { console.error('[workspace-gcs] failed to decrypt HMAC secret for', ws.slug, err); return null; } }