Files
vibn-frontend/lib/workspace-gcs.ts
Mark Henderson 3192e0f7b9 fix(coolify): strip is_build_time from env writes; add reveal + GCS
Coolify v4's POST/PATCH /applications/{uuid}/envs only accepts key,
value, is_preview, is_literal, is_multiline, is_shown_once. Sending
is_build_time triggers a 422 "This field is not allowed." — it's now
a derived read-only flag (is_buildtime) computed from Dockerfile ARG
usage. Breaks agents trying to upsert env vars.

Three-layer fix so this can't regress:
  - lib/coolify.ts: COOLIFY_ENV_WRITE_FIELDS whitelist enforced at the
    network boundary, regardless of caller shape
  - app/api/workspaces/[slug]/apps/[uuid]/envs: stops forwarding the
    field; returns a deprecation warning when callers send it; GET
    reads both is_buildtime and is_build_time for version parity
  - app/api/mcp/route.ts: same treatment in the MCP dispatcher;
    AI_CAPABILITIES.md doc corrected

Also bundles (not related to the above):
  - Workspace API keys are now revealable from settings. New
    key_encrypted column stores AES-256-GCM(VIBN_SECRETS_KEY, token).
    POST /api/workspaces/[slug]/keys/[keyId]/reveal returns plaintext
    for session principals only; API-key principals cannot reveal
    siblings. Legacy keys stay valid for auth but can't reveal.
  - P5.3 Object storage: lib/gcp/storage.ts + lib/workspace-gcs.ts
    idempotently provision a per-workspace GCS bucket, service
    account, IAM binding and HMAC key. New POST /api/workspaces/
    [slug]/storage/buckets endpoint. Migration script + smoke test
    included. Proven end-to-end against prod master-ai-484822.

Made-with: Cursor
2026-04-23 11:46:50 -07:00

281 lines
9.8 KiB
TypeScript

/**
* 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<WorkspaceGcs | null> {
return queryOne<WorkspaceGcs>(
`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<WorkspaceGcsResult> {
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;
}
}