Files
vibn-frontend/lib/gcp/iam.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

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');
}