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:
145
lib/gcp/iam.ts
Normal file
145
lib/gcp/iam.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Google Cloud IAM driver — service-account creation + key minting.
|
||||
*
|
||||
* Auth uses the shared `vibn-workspace-provisioner` SA via getGcpAccessToken().
|
||||
* That SA needs `roles/iam.serviceAccountAdmin` and `roles/iam.serviceAccountKeyAdmin`
|
||||
* at the project level, plus `roles/iam.serviceAccountUser` so it can act as the
|
||||
* SAs it creates.
|
||||
*
|
||||
* All calls go through https://iam.googleapis.com/v1.
|
||||
*/
|
||||
|
||||
import { getGcpAccessToken, GCP_PROJECT_ID } from '@/lib/gcp-auth';
|
||||
|
||||
const IAM_API = 'https://iam.googleapis.com/v1';
|
||||
|
||||
async function authedFetch(
|
||||
method: 'GET' | 'POST' | 'DELETE' | 'PATCH',
|
||||
url: string,
|
||||
body?: unknown,
|
||||
): Promise<Response> {
|
||||
const token = await getGcpAccessToken();
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/json',
|
||||
};
|
||||
if (body) headers['Content-Type'] = 'application/json';
|
||||
return fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function parseOrThrow<T>(res: Response, context: string): Promise<T> {
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
throw new Error(`[gcp-iam ${context} ${res.status}] ${text.slice(0, 500)}`);
|
||||
}
|
||||
return text ? (JSON.parse(text) as T) : ({} as T);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Service-account naming
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GCP service-account IDs are 6-30 chars, [a-z][a-z0-9-]{4,28}[a-z0-9].
|
||||
* Some workspace slugs are too long or have edge characters, so normalize.
|
||||
*/
|
||||
export function workspaceServiceAccountId(slug: string): string {
|
||||
const safe = slug.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-');
|
||||
// Reserve "vibn-ws-" prefix (8 chars) → up to 22 left for the slug.
|
||||
const trimmed = safe.replace(/^-+|-+$/g, '').slice(0, 22) || 'workspace';
|
||||
const padded = trimmed.length < 4 ? `${trimmed}-ws` : trimmed;
|
||||
return `vibn-ws-${padded}`;
|
||||
}
|
||||
|
||||
export function workspaceServiceAccountEmail(slug: string, projectId = GCP_PROJECT_ID): string {
|
||||
return `${workspaceServiceAccountId(slug)}@${projectId}.iam.gserviceaccount.com`;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Service-account CRUD
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface GcpServiceAccount {
|
||||
name: string;
|
||||
email: string;
|
||||
uniqueId: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export async function getServiceAccount(email: string): Promise<GcpServiceAccount | null> {
|
||||
const url = `${IAM_API}/projects/${GCP_PROJECT_ID}/serviceAccounts/${encodeURIComponent(email)}`;
|
||||
const res = await authedFetch('GET', url);
|
||||
if (res.status === 404) return null;
|
||||
return parseOrThrow<GcpServiceAccount>(res, 'getServiceAccount');
|
||||
}
|
||||
|
||||
export async function createServiceAccount(opts: {
|
||||
accountId: string;
|
||||
displayName: string;
|
||||
description?: string;
|
||||
}): Promise<GcpServiceAccount> {
|
||||
const url = `${IAM_API}/projects/${GCP_PROJECT_ID}/serviceAccounts`;
|
||||
const res = await authedFetch('POST', url, {
|
||||
accountId: opts.accountId,
|
||||
serviceAccount: {
|
||||
displayName: opts.displayName,
|
||||
description: opts.description,
|
||||
},
|
||||
});
|
||||
// Race-safe: if it was just created concurrently, fetch the existing one.
|
||||
if (res.status === 409) {
|
||||
const email = `${opts.accountId}@${GCP_PROJECT_ID}.iam.gserviceaccount.com`;
|
||||
const existing = await getServiceAccount(email);
|
||||
if (existing) return existing;
|
||||
}
|
||||
return parseOrThrow<GcpServiceAccount>(res, 'createServiceAccount');
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotently ensures the workspace's SA exists. Returns its email.
|
||||
*/
|
||||
export async function ensureWorkspaceServiceAccount(opts: {
|
||||
slug: string;
|
||||
workspaceName?: string;
|
||||
}): Promise<GcpServiceAccount> {
|
||||
const email = workspaceServiceAccountEmail(opts.slug);
|
||||
const existing = await getServiceAccount(email);
|
||||
if (existing) return existing;
|
||||
return createServiceAccount({
|
||||
accountId: workspaceServiceAccountId(opts.slug),
|
||||
displayName: `Vibn workspace: ${opts.workspaceName ?? opts.slug}`,
|
||||
description: `Auto-provisioned by Vibn for workspace "${opts.slug}". Owns workspace-scoped GCS bucket(s) and (eventually) Cloud Tasks queues + Scheduler jobs.`,
|
||||
});
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Service-account key minting
|
||||
//
|
||||
// We mint a JSON keyfile per workspace once at provision time and store
|
||||
// it encrypted. Currently only used so app code can authenticate as the
|
||||
// workspace's SA (e.g. to call GCS / Cloud Tasks from inside a deployed
|
||||
// container). The control-plane itself uses the shared provisioner SA.
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface GcpServiceAccountKey {
|
||||
/** Resource name, e.g. projects/.../serviceAccounts/.../keys/<id>. */
|
||||
name: string;
|
||||
/** Base64-encoded JSON keyfile (Google's privateKeyData format). */
|
||||
privateKeyData: string;
|
||||
}
|
||||
|
||||
export async function createServiceAccountKey(saEmail: string): Promise<GcpServiceAccountKey> {
|
||||
const url = `${IAM_API}/projects/${GCP_PROJECT_ID}/serviceAccounts/${encodeURIComponent(
|
||||
saEmail,
|
||||
)}/keys`;
|
||||
const res = await authedFetch('POST', url, {
|
||||
privateKeyType: 'TYPE_GOOGLE_CREDENTIALS_FILE',
|
||||
keyAlgorithm: 'KEY_ALG_RSA_2048',
|
||||
});
|
||||
return parseOrThrow<GcpServiceAccountKey>(res, 'createServiceAccountKey');
|
||||
}
|
||||
Reference in New Issue
Block a user