This repository has been archived on 2026-06-07. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
master-ai/vibn-frontend/lib/gcp/iam.ts

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