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
146 lines
6.1 KiB
TypeScript
146 lines
6.1 KiB
TypeScript
/**
|
|
* 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');
|
|
}
|