import { Pool } from "pg"; import type { TenantRequest, TenantRequestStatus } from "@/types"; import { listTenants, getTenant } from "./k8s"; // --------------------------------------------------------------------------- // Connection pool (singleton) // --------------------------------------------------------------------------- let pool: Pool | null = null; function getPool(): Pool { if (!pool) { const connectionString = process.env.DATABASE_URL ?? "postgresql://portal:portal@portal-db-rw.portal.svc:5432/portal"; pool = new Pool({ connectionString, max: 5 }); } return pool; } // --------------------------------------------------------------------------- // Schema migration (auto-run on first query) // --------------------------------------------------------------------------- // Notes on the Slice 3 changes // ---------------------------- // 1. Removed `UNIQUE` from `zitadel_org_id` in the CREATE TABLE for fresh // installs, AND emit a defensive `DROP CONSTRAINT IF EXISTS` for // existing installs whose schema was created pre-Slice-3. The // constraint was Postgres-autonamed; the name is deterministic. // 2. Added `instance_name TEXT` — the customer's human label per // instance (e.g. "Production", "Dev"). NULL is fine and means "use // the company name for display". // 3. Added a unique index on `tenant_name WHERE NOT NULL`. Multiple // rows in the table can have NULL tenant_name (pending/rejected // requests), but every approved row points to a distinct K8s CR. // 4. Added `(zitadel_org_id, status)` index for the list-by-org queries // introduced this slice. const MIGRATION_SQL = ` CREATE TABLE IF NOT EXISTS tenant_requests ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), zitadel_org_id TEXT NOT NULL, zitadel_user_id TEXT NOT NULL, company_name TEXT NOT NULL, instance_name TEXT, contact_name TEXT NOT NULL, contact_email TEXT NOT NULL, agent_name TEXT NOT NULL DEFAULT 'Assistant', soul_md TEXT, agents_md TEXT, packages TEXT[] DEFAULT '{}', billing_address JSONB DEFAULT '{}', billing_notes TEXT, status TEXT NOT NULL DEFAULT 'pending', admin_notes TEXT, tenant_name TEXT, encrypted_secrets BYTEA, is_personal BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_tenant_requests_status ON tenant_requests(status); CREATE INDEX IF NOT EXISTS idx_tenant_requests_org_id ON tenant_requests(zitadel_org_id); CREATE INDEX IF NOT EXISTS idx_tenant_requests_org_status ON tenant_requests(zitadel_org_id, status); CREATE UNIQUE INDEX IF NOT EXISTS uniq_tenant_requests_tenant_name ON tenant_requests(tenant_name) WHERE tenant_name IS NOT NULL; -- Idempotent column adds for existing databases ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS encrypted_secrets BYTEA; ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS agents_md TEXT; ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS instance_name TEXT; ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS is_personal BOOLEAN NOT NULL DEFAULT FALSE; -- Slice 3: drop the legacy 1-org-1-request constraint if it exists ALTER TABLE tenant_requests DROP CONSTRAINT IF EXISTS tenant_requests_zitadel_org_id_key; -- Workspace templates: admin-editable default content for workspace files CREATE TABLE IF NOT EXISTS workspace_templates ( file_key TEXT PRIMARY KEY, content TEXT NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- --------------------------------------------------------------------------- -- Slice 6: per-tenant user assignments -- --------------------------------------------------------------------------- -- -- Each row grants ONE user visibility into ONE tenant within their own -- ZITADEL org. Used to narrow the customer 'user' role from "everything -- in the org" to "only the tenants I've been assigned to". Owners and -- platform users bypass this table entirely. -- -- Composite PK is (tenant_name, zitadel_user_id) — a user is either -- assigned to a tenant or not, no degree. -- -- The zitadel_org_id column is denormalised onto every row so cascade -- cleanups when a user leaves an org can be expressed as a single -- DELETE WHERE zitadel_org_id=$1 AND zitadel_user_id=$2 — without -- joining tenant_requests. The assigned_by column tracks which user -- (the owner usually) granted the assignment, for audit. -- -- Cascade on tenant deletion is enforced in application code (the -- admin delete handler calls removeAllAssignmentsForTenant) rather -- than via FK — there's no FK target, since K8s CRs aren't a Postgres -- table. CREATE TABLE IF NOT EXISTS tenant_user_assignments ( tenant_name TEXT NOT NULL, zitadel_org_id TEXT NOT NULL, zitadel_user_id TEXT NOT NULL, assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(), assigned_by TEXT NOT NULL, PRIMARY KEY (tenant_name, zitadel_user_id) ); CREATE INDEX IF NOT EXISTS idx_tua_user ON tenant_user_assignments(zitadel_user_id); CREATE INDEX IF NOT EXISTS idx_tua_org ON tenant_user_assignments(zitadel_org_id); `; let migrated = false; export async function ensureSchema(): Promise { if (migrated) return; await getPool().query(MIGRATION_SQL); migrated = true; } // --------------------------------------------------------------------------- // Workspace templates // --------------------------------------------------------------------------- /** * Get a workspace template by file key (e.g. "SOUL.md", "AGENTS.md", "TOOLS.md"). * Returns null if no template is stored for this key. */ export async function getWorkspaceTemplate( fileKey: string ): Promise { await ensureSchema(); const result = await getPool().query<{ content: string }>( "SELECT content FROM workspace_templates WHERE file_key = $1", [fileKey] ); return result.rows[0]?.content ?? null; } /** * Upsert a workspace template. */ export async function setWorkspaceTemplate( fileKey: string, content: string ): Promise { await ensureSchema(); await getPool().query( `INSERT INTO workspace_templates (file_key, content, updated_at) VALUES ($1, $2, now()) ON CONFLICT (file_key) DO UPDATE SET content = $2, updated_at = now()`, [fileKey, content] ); } /** * List all workspace templates. */ export async function listWorkspaceTemplates(): Promise< Array<{ fileKey: string; content: string; updatedAt: string }> > { await ensureSchema(); const result = await getPool().query( "SELECT file_key, content, updated_at FROM workspace_templates ORDER BY file_key" ); return result.rows.map((r: any) => ({ fileKey: r.file_key, content: r.content, updatedAt: r.updated_at?.toISOString?.() ?? r.updated_at, })); } // --------------------------------------------------------------------------- // Tenant requests CRUD // --------------------------------------------------------------------------- export async function createTenantRequest( params: Omit & { encryptedSecrets?: Buffer; } ): Promise { await ensureSchema(); const result = await getPool().query( `INSERT INTO tenant_requests (zitadel_org_id, zitadel_user_id, company_name, instance_name, contact_name, contact_email, agent_name, soul_md, agents_md, packages, billing_address, billing_notes, encrypted_secrets, is_personal) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING *`, [ params.zitadelOrgId, params.zitadelUserId, params.companyName, params.instanceName ?? null, params.contactName, params.contactEmail, params.agentName, params.soulMd, params.agentsMd ?? null, params.packages, JSON.stringify(params.billingAddress), params.billingNotes, params.encryptedSecrets ?? null, params.isPersonal ?? false, ] ); return mapRow(result.rows[0]); } export async function getTenantRequestById( id: string ): Promise { await ensureSchema(); const result = await getPool().query( "SELECT * FROM tenant_requests WHERE id = $1", [id] ); return result.rows[0] ? mapRow(result.rows[0]) : null; } /** * Slice 3: returns ALL requests for an org, most recent first. * * Replaces the pre-Slice-3 `getTenantRequestByOrgId` which returned the * single most recent row. Callers that previously assumed one-row-per-org * must now iterate or pick by status. The intent is explicit at every * call site, which is the point of the rename. * * Includes rows in every status (pending, approved, provisioning, active, * rejected, deleted). For "active or in-flight only" filtering, see * {@link listActiveTenantRequestsByOrgId}. */ export async function listTenantRequestsByOrgId( orgId: string ): Promise { await ensureSchema(); const result = await getPool().query( "SELECT * FROM tenant_requests WHERE zitadel_org_id = $1 ORDER BY created_at DESC", [orgId] ); return result.rows.map(mapRow); } /** * As {@link listTenantRequestsByOrgId} but excludes terminal-failed states * (rejected, deleted). Useful for the dashboard which wants to show * pending/approved/provisioning/active tenants and pending requests, not * historical rejections. */ export async function listActiveTenantRequestsByOrgId( orgId: string ): Promise { await ensureSchema(); const result = await getPool().query( `SELECT * FROM tenant_requests WHERE zitadel_org_id = $1 AND status NOT IN ('deleted', 'rejected') ORDER BY created_at DESC`, [orgId] ); return result.rows.map(mapRow); } /** * Returns the most recent approved-or-active request for an org. Used to * seed billing/contact defaults when a customer creates an additional * instance — saves them re-typing data already on file. * * Returns null if the org has never had an approved instance (e.g. first * registration is still pending). */ export async function getMostRecentApprovedRequestForOrg( orgId: string ): Promise { await ensureSchema(); const result = await getPool().query( `SELECT * FROM tenant_requests WHERE zitadel_org_id = $1 AND status IN ('approved', 'provisioning', 'active') ORDER BY created_at DESC LIMIT 1`, [orgId] ); return result.rows[0] ? mapRow(result.rows[0]) : null; } export async function listTenantRequests( status?: TenantRequestStatus ): Promise { await ensureSchema(); const result = status ? await getPool().query( "SELECT * FROM tenant_requests WHERE status = $1 ORDER BY created_at DESC", [status] ) : await getPool().query( "SELECT * FROM tenant_requests ORDER BY created_at DESC" ); return result.rows.map(mapRow); } export async function updateTenantRequestStatus( id: string, status: TenantRequestStatus, extra?: { adminNotes?: string | null; tenantName?: string; clearAdminNotes?: boolean; } ): Promise { await ensureSchema(); const sets = ["status = $2", "updated_at = now()"]; const values: any[] = [id, status]; let idx = 3; if (extra?.adminNotes !== undefined) { sets.push(`admin_notes = $${idx}`); values.push(extra.adminNotes); idx++; } if (extra?.clearAdminNotes) { sets.push("admin_notes = NULL"); } if (extra?.tenantName) { sets.push(`tenant_name = $${idx}`); values.push(extra.tenantName); idx++; } const result = await getPool().query( `UPDATE tenant_requests SET ${sets.join(", ")} WHERE id = $1 RETURNING *`, values ); return mapRow(result.rows[0]); } /** * Clear the encrypted_secrets column after secrets have been written to OpenBao. * Called during admin approval after successful vault writes. */ export async function clearEncryptedSecrets(requestId: string): Promise { await ensureSchema(); await getPool().query( "UPDATE tenant_requests SET encrypted_secrets = NULL, updated_at = now() WHERE id = $1", [requestId] ); } /** * Wrapper around domain-check.ts that injects the portal's connection pool. * Kept here so route handlers don't need direct access to the pool. */ export async function checkDuplicateDomain(email: string) { await ensureSchema(); // Lazy import to keep db.ts free of fetch/AbortSignal at module load time. const { checkRegistrationDomain } = await import("./domain-check"); return checkRegistrationDomain(getPool(), email); } /** * Mark a single tenant request as "deleted" when the associated tenant CR * is deleted. With multi-tenant per org this affects exactly one row, * since tenant_name is unique by index. The customer's other instances * are untouched. */ export async function markTenantRequestDeletedByTenantName( tenantName: string ): Promise { await ensureSchema(); await getPool().query( "UPDATE tenant_requests SET status = 'deleted', tenant_name = NULL, updated_at = now() WHERE tenant_name = $1", [tenantName] ); } /** * Delete a tenant request row entirely. Used when a customer re-submits * after their previous tenant was deleted by admin. */ export async function deleteTenantRequest(id: string): Promise { await ensureSchema(); await getPool().query("DELETE FROM tenant_requests WHERE id = $1", [id]); } /** * Sync provisioning statuses: for all requests with status "provisioning", * check if the PiecedTenant CR has reached "Ready" and update to "active". * * Slice 3 note: with multi-tenant per org, this iterates each row * individually (keyed by its own tenant_name), so multiple in-flight * tenants in the same org are handled correctly. */ export async function syncProvisioningStatuses(): Promise { await ensureSchema(); const result = await getPool().query( "SELECT * FROM tenant_requests WHERE status = 'provisioning'" ); for (const row of result.rows) { const mapped = mapRow(row); if (!mapped.tenantName) continue; try { const tenant = await getTenant(mapped.tenantName); if ( tenant?.status?.phase === "Ready" || tenant?.status?.phase === "Running" ) { await updateTenantRequestStatus(mapped.id, "active"); } } catch { // Tenant might not exist yet — skip } } } // --------------------------------------------------------------------------- // Row mapper // --------------------------------------------------------------------------- function mapRow(row: any): TenantRequest { return { id: row.id, zitadelOrgId: row.zitadel_org_id, zitadelUserId: row.zitadel_user_id, companyName: row.company_name, instanceName: row.instance_name ?? null, contactName: row.contact_name, contactEmail: row.contact_email, agentName: row.agent_name, soulMd: row.soul_md, agentsMd: row.agents_md ?? null, packages: row.packages ?? [], billingAddress: row.billing_address ?? {}, billingNotes: row.billing_notes, status: row.status as TenantRequestStatus, adminNotes: row.admin_notes, tenantName: row.tenant_name, encryptedSecrets: row.encrypted_secrets ?? null, isPersonal: row.is_personal ?? false, createdAt: row.created_at?.toISOString?.() ?? row.created_at, updatedAt: row.updated_at?.toISOString?.() ?? row.updated_at, }; } // --------------------------------------------------------------------------- // Slice 6: tenant ↔ user assignments // --------------------------------------------------------------------------- /** * One assignment grants one user visibility into one tenant. Returned * shape is the camelCase mirror of the Postgres row. */ export interface TenantUserAssignment { tenantName: string; zitadelOrgId: string; zitadelUserId: string; assignedAt: string; assignedBy: string; } function mapAssignmentRow(row: any): TenantUserAssignment { return { tenantName: row.tenant_name, zitadelOrgId: row.zitadel_org_id, zitadelUserId: row.zitadel_user_id, assignedAt: row.assigned_at?.toISOString?.() ?? row.assigned_at, assignedBy: row.assigned_by, }; } /** * Returns the set of tenant CR names assigned to the given user. * * Hot path on every read for `user`-role customers, so it's intentionally * a single indexed lookup. The returned array is small (a handful of * tenants per user); callers usually wrap it in a Set. * * Note: this does NOT cross-check the org id — assignments are per-user, * and a user's org context comes from their JWT. If a user's * authorization is revoked at the ZITADEL level, their JWT ceases to * carry the customer role and they can't reach the dashboard at all; * the orphan rows are cleaned up the next time their org membership * is re-evaluated (Slice 7's removeAllAssignmentsForUser). */ export async function listTenantAssignmentsForUser( userId: string ): Promise { await ensureSchema(); const result = await getPool().query<{ tenant_name: string }>( "SELECT tenant_name FROM tenant_user_assignments WHERE zitadel_user_id = $1", [userId] ); return result.rows.map((r) => r.tenant_name); } /** * Returns all assignments for a single tenant. Used by the team UI * (Slice 7) to render "who has access to this instance". Includes * `assignedBy` and `assignedAt` for audit display. */ export async function listAssignmentsForTenant( tenantName: string ): Promise { await ensureSchema(); const result = await getPool().query( "SELECT * FROM tenant_user_assignments WHERE tenant_name = $1 ORDER BY assigned_at DESC", [tenantName] ); return result.rows.map(mapAssignmentRow); } /** * Grant a user access to a tenant. Idempotent — a duplicate INSERT * is silently ignored via ON CONFLICT, and the existing * `assigned_at`/`assigned_by` are preserved (we don't update them on * re-assign). * * Caller is responsible for verifying: * - The actor (`assignedBy`) holds owner/platform role in `orgId`. * - The target user (`userId`) is actually a member of the same * ZITADEL org. We don't validate this here — the team UI fetches * the org's user list from ZITADEL and selects from it. * - The tenant CR exists and is labelled with the same `orgId`. */ export async function addTenantAssignment(params: { tenantName: string; orgId: string; userId: string; assignedBy: string; }): Promise { await ensureSchema(); await getPool().query( `INSERT INTO tenant_user_assignments (tenant_name, zitadel_org_id, zitadel_user_id, assigned_by) VALUES ($1, $2, $3, $4) ON CONFLICT (tenant_name, zitadel_user_id) DO NOTHING`, [params.tenantName, params.orgId, params.userId, params.assignedBy] ); } /** * Revoke a user's access to a tenant. No-op if the row doesn't exist. */ export async function removeTenantAssignment( tenantName: string, userId: string ): Promise { await ensureSchema(); await getPool().query( "DELETE FROM tenant_user_assignments WHERE tenant_name = $1 AND zitadel_user_id = $2", [tenantName, userId] ); } /** * Cascade cleanup: drop ALL assignments for a tenant when the tenant * itself is deleted. Called from the admin delete handler. * * Without this, an orphan row would stick around forever — a future * tenant with the same name (won't happen given Slice 1's UUID-suffix * naming, but defense in depth) would inherit the old assignments. */ export async function removeAllAssignmentsForTenant( tenantName: string ): Promise { await ensureSchema(); await getPool().query( "DELETE FROM tenant_user_assignments WHERE tenant_name = $1", [tenantName] ); } /** * Cascade cleanup: drop ALL assignments for a user within a specific * org. Used by Slice 7's "remove member" flow when an owner kicks a * user out of the org. Scoped by `orgId` so a user with assignments in * org A doesn't lose them when removed from org B (multi-org users * exist when a person registers personally and is also invited to a * company). */ export async function removeAllAssignmentsForUser( orgId: string, userId: string ): Promise { await ensureSchema(); await getPool().query( "DELETE FROM tenant_user_assignments WHERE zitadel_org_id = $1 AND zitadel_user_id = $2", [orgId, userId] ); }