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
This commit is contained in:
280
lib/workspace-gcs.ts
Normal file
280
lib/workspace-gcs.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user