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
281 lines
9.8 KiB
TypeScript
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;
|
|
}
|
|
}
|