/** * 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 { const token = await getGcpAccessToken(); const headers: Record = { 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(res: Response, context: string): Promise { 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 { 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(res, 'getServiceAccount'); } export async function createServiceAccount(opts: { accountId: string; displayName: string; description?: string; }): Promise { 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(res, 'createServiceAccount'); } /** * Idempotently ensures the workspace's SA exists. Returns its email. */ export async function ensureWorkspaceServiceAccount(opts: { slug: string; workspaceName?: string; }): Promise { 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/. */ name: string; /** Base64-encoded JSON keyfile (Google's privateKeyData format). */ privateKeyData: string; } export async function createServiceAccountKey(saEmail: string): Promise { 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(res, 'createServiceAccountKey'); }