/** * uat-provision/execute.ts — Idempotent provisioning against the live admin API. * * Flow (every step through the app's own endpoints — provisioning NEVER writes SQL): * 1. identity-probe the API (/api/config/features must answer 200) * 2. admin login * 3. ANTI-DRIFT GATE, before any write: list the GLOBAL role catalogue (no * X-Tenant-Slug) and join the plan's role_catalog ids onto it — a role * missing from the catalog or an id the app no longer knows aborts with * "regenerate the plan" * 4. ensure the UAT tenant (b2c create, 409 ⇒ already there) * 5. resolve the tenant id (create response, else GET tenants by slug) * 6. BOOTSTRAP the tenant: activate every application via * POST tenants/{id}/applications/bulk — roles are a global catalogue * filtered per tenant by its active applications, so a fresh B2C tenant * sees 0 roles (and grants 0 access) until this runs; replayed every run * (idempotent — a 409 means already assigned) * 7. plan per-role actions against the existing uat-users.json * 8. per role: reuse-and-verify the stored credential or create the user * (roleIds + X-Tenant-Slug), rotating through change-password when the * backend flags mustChangePassword * 9. persist uat-users.json + enforce the gitignore block. * * Every per-role failure is reported individually; one bad role does not abort the * others. The CLI fails (success:false) when ANY role ends unusable — runners need * the full credential set. */ import { randomBytes } from 'node:crypto'; import { join } from 'node:path'; import type { FetchLike } from '../lib/http.js'; import { bulkAssignApps, changePassword, createTenantB2C, createUser, findTenantBySlug, listApplications, listRoles, login, probeApi, type ApiConfig, } from '../lib/auth-client.js'; import { ensureUatGitignore, saveUsersFile, usersFileRelPath, loadUsersFile, type UatUser, type UatUsersFile, } from '../lib/users-file.js'; import { planRoleActions, resolveRoleIds, type RoleAction } from './plan-actions.js'; import type { ProvisionReport, ProvisionUserOutcome } from './types.js'; import type { ProvisionContext } from './validate.js'; export interface ExecuteDeps { /** Injectable fetch (tests). */ fetchImpl?: FetchLike; /** Injectable clock (tests) — ISO string. */ nowIso?: () => string; /** Injectable password generator (tests). */ generatePassword?: () => string; } export interface ProvisionOutcome { success: boolean; report: ProvisionReport; errors: string[]; warnings: string[]; } /** Strong throwaway password satisfying upper/lower/digit/special + length ≥ 13. */ export function defaultPasswordGenerator(): string { return `Uat!${randomBytes(5).toString('hex')}9`; } /** * Verify a credential by logging in; rotate the password through change-password * when the account is flagged mustChangePassword. Returns the FINAL password. */ async function verifyCredential( cfg: ApiConfig, email: string, password: string, role: string, nextPassword: () => string, ): Promise<{ ok: boolean; finalPassword: string; rotated: boolean; userId?: string; warning?: string; error?: string }> { const first = await login(cfg, email, password); if (!first.ok) return { ok: false, finalPassword: password, rotated: false, error: first.error }; let rotated = false; let finalPassword = password; if (first.mustChangePassword && first.token) { // A fresh strong password (not a predictable derivation) — we store it anyway. const next = nextPassword(); const change = await changePassword(cfg, first.token, password, next); if (!change.ok) { return { ok: false, finalPassword: password, rotated: false, error: `account requires a password change and change-password failed: ${change.error ?? change.status}`, }; } rotated = true; finalPassword = next; const second = await login(cfg, email, next); if (!second.ok) { return { ok: false, finalPassword: next, rotated, error: `re-login after password rotation failed: ${second.error}` }; } if (second.mustChangePassword) { return { ok: false, finalPassword: next, rotated, error: 'account still flagged mustChangePassword after rotation' }; } const hasRole = second.user?.roles.some((r) => r.toLowerCase() === role.toLowerCase()) ?? false; return { ok: true, finalPassword: next, rotated, userId: second.user?.id, warning: hasRole ? undefined : `login OK but role "${role}" absent from the user's effective roles (${second.user?.roles.join(', ') || 'none'})`, }; } const hasRole = first.user?.roles.some((r) => r.toLowerCase() === role.toLowerCase()) ?? false; return { ok: true, finalPassword, rotated, userId: first.user?.id, warning: hasRole ? undefined : `login OK but role "${role}" absent from the user's effective roles (${first.user?.roles.join(', ') || 'none'})`, }; } export async function executeProvision(ctx: ProvisionContext, deps: ExecuteDeps = {}): Promise { const nowIso = deps.nowIso ?? ((): string => new Date().toISOString()); const genPassword = deps.generatePassword ?? defaultPasswordGenerator; const errors: string[] = []; const warnings: string[] = []; const cfgBase: ApiConfig = { apiUrl: ctx.apiUrl, timeoutMs: ctx.spec.timeoutMs, fetchImpl: deps.fetchImpl }; const cfgTenant: ApiConfig = { ...cfgBase, tenantSlug: ctx.spec.tenantSlug }; const usersFileRel = usersFileRelPath(ctx.application); const usersFileAbs = join(ctx.projectRoot, usersFileRel); const report: ProvisionReport = { apiUrl: ctx.apiUrl, application: ctx.application, tenant: { name: ctx.spec.tenantName, slug: ctx.spec.tenantSlug, outcome: 'exists' }, adminEmail: ctx.adminEmail, users: [], usersFile: usersFileRel, gitignoreUpdated: false, }; // 1. API reachable? const probe = await probeApi(cfgBase); if (!probe.up) { return { success: false, report, errors: [`API not reachable at ${ctx.apiUrl} (${probe.error ?? 'no response'}). Start the app with \`ss dev up\` first.`], warnings, }; } // 2. Admin login. const admin = await login(cfgBase, ctx.adminEmail, ctx.adminPassword); if (!admin.ok || !admin.token) { return { success: false, report, errors: [ `Admin login failed for ${ctx.adminEmail} (status ${admin.status}): ${admin.error ?? 'unknown'}. Check Security.InitialAdmin in appsettings.Local.json.`, ], warnings, }; } const adminToken = admin.token; // 3. Anti-drift gate BEFORE any write: the plan's role ids must all exist in the // app's GLOBAL catalogue (cfgBase — no X-Tenant-Slug: a tenant-scoped listing is // filtered by the tenant's active applications and would be empty on a fresh one). const rolesList = await listRoles(cfgBase, adminToken); if (!rolesList.ok) { return { success: false, report, errors: [`Could not list roles: ${rolesList.error ?? rolesList.status}`], warnings, }; } const { resolved: roleIds, missingFromCatalog, driftedIds } = resolveRoleIds( ctx.roles, ctx.roleCatalog, rolesList.roles, ); if (missingFromCatalog.length > 0 || driftedIds.length > 0) { const parts: string[] = []; if (missingFromCatalog.length > 0) { parts.push(`role(s) absent from the plan's role_catalog: ${missingFromCatalog.join(', ')}`); } if (driftedIds.length > 0) { parts.push(`role id(s) the app no longer knows: ${driftedIds.join(', ')} (the plan predates a role change)`); } return { success: false, report, errors: [`Plan/app role drift — ${parts.join('; ')}. Regenerate the plan (/uat plan, or /uat run with refreshPlan=auto), then re-provision.`], warnings, }; } // 4. Ensure the UAT tenant. const tenant = await createTenantB2C(cfgBase, adminToken, { name: ctx.spec.tenantName, slug: ctx.spec.tenantSlug, description: 'Throwaway tenant for automated UAT runs', }); if (tenant.outcome === 'failed') { return { success: false, report, errors: [`Could not ensure UAT tenant "${ctx.spec.tenantSlug}": ${tenant.error ?? tenant.status}`], warnings, }; } report.tenant.outcome = tenant.outcome; // 5. Resolve the tenant id — the create response carries it; the 409/exists path // does not, so fall back to the tenant listing. let tenantId = tenant.tenantId; if (!tenantId) { const found = await findTenantBySlug(cfgBase, adminToken, ctx.spec.tenantSlug); if (!found.ok || !found.tenantId) { return { success: false, report, errors: [ `UAT tenant "${ctx.spec.tenantSlug}" reported as existing but not resolvable via GET /api/administration/tenants${found.error ? ` (${found.error})` : ''}.`, ], warnings, }; } tenantId = found.tenantId; } report.tenant.id = tenantId; // 6. Bootstrap the tenant's applications — replayed on EVERY run (idempotent): // a tenant left over from an older run may have none, and without them the // tenant grants no role and no access at all. const apps = await listApplications(cfgBase, adminToken); if (!apps.ok) { return { success: false, report, errors: [`Could not list applications for the tenant bootstrap: ${apps.error ?? apps.status}`], warnings, }; } if (apps.applications.length === 0) { return { success: false, report, errors: ['No applications discovered (GET /api/administration/applications returned none) — the UAT tenant would see no role and grant no access.'], warnings, }; } const bulk = await bulkAssignApps(cfgBase, adminToken, tenantId, { applicationIds: apps.applications.map((a) => a.id), }); if (!bulk.ok) { return { success: false, report, errors: [`Tenant application bootstrap failed (POST tenants/{id}/applications/bulk, status ${bulk.status}): ${bulk.error ?? 'unknown'}`], warnings, }; } report.tenant.applicationsAssigned = apps.applications.length; if (bulk.alreadyAssigned) { warnings.push('Tenant applications were already assigned (bulk returned 409) — continuing.'); } // 7. Plan per-role actions against the existing users file. const existing = loadUsersFile(usersFileAbs); if (existing && existing.apiUrl !== ctx.apiUrl) { warnings.push(`uat-users.json was provisioned against ${existing.apiUrl}; re-verifying against ${ctx.apiUrl}.`); } const newPasswords: Record = {}; for (const role of ctx.roles) newPasswords[role] = ctx.spec.password ?? genPassword(); const actions = planRoleActions({ roles: ctx.roles.filter((r) => roleIds[r]), existing, emailDomain: ctx.spec.emailDomain, newPasswords, }); // 8. Execute per role. const finalUsers: UatUser[] = []; for (const action of actions) { const outcome = await executeRoleAction(action, cfgTenant, adminToken, roleIds[action.role], nowIso, genPassword); report.users.push(outcome.outcome); if (outcome.warning) warnings.push(`[${action.role}] ${outcome.warning}`); if (outcome.outcome.status === 'failed') { errors.push(`[${action.role}] ${outcome.outcome.error ?? 'provisioning failed'}`); } else if (outcome.user) { finalUsers.push(outcome.user); } } // 9. Persist credentials + enforce the gitignore (even on partial success — keep what works). if (finalUsers.length > 0) { const file: UatUsersFile = { version: '1', application: ctx.application, apiUrl: ctx.apiUrl, tenant: { name: ctx.spec.tenantName, slug: ctx.spec.tenantSlug, ...(report.tenant.id ? { id: report.tenant.id } : {}) }, createdAt: existing?.createdAt ?? nowIso(), users: finalUsers, }; saveUsersFile(usersFileAbs, file); report.gitignoreUpdated = ensureUatGitignore(ctx.projectRoot); } return { success: errors.length === 0, report, errors, warnings }; } async function executeRoleAction( action: RoleAction, cfgTenant: ApiConfig, adminToken: string, roleId: string, nowIso: () => string, genPassword: () => string, ): Promise<{ outcome: ProvisionUserOutcome; user?: UatUser; warning?: string }> { const base: ProvisionUserOutcome = { role: action.role, email: action.email, status: 'failed', verified: false, passwordRotated: false, }; if (action.action === 'verify') { const verified = await verifyCredential(cfgTenant, action.email, action.password, action.role, genPassword); if (verified.ok) { return { outcome: { ...base, status: 'kept', verified: true, passwordRotated: verified.rotated }, user: { role: action.role, email: action.email, password: verified.finalPassword, ...(verified.userId ? { userId: verified.userId } : {}), verifiedAt: nowIso(), }, warning: verified.warning, }; } return { outcome: { ...base, error: `stored credential no longer logs in (${verified.error}). Delete the user in the app or remove the entry from uat-users.json, then re-provision.`, }, }; } // create const created = await createUser(cfgTenant, adminToken, { email: action.email, password: action.password, firstName: 'UAT', lastName: action.role, roleIds: [roleId], }); if (created.outcome === 'failed') { return { outcome: { ...base, error: `create user failed (status ${created.status}): ${created.error ?? 'unknown'}` } }; } if (created.outcome === 'exists') { // User pre-exists with an unknown password: try ours (covers a re-run after a lost users file with spec.password). const verified = await verifyCredential(cfgTenant, action.email, action.password, action.role, genPassword); if (verified.ok) { return { outcome: { ...base, status: 'kept', verified: true, passwordRotated: verified.rotated }, user: { role: action.role, email: action.email, password: verified.finalPassword, ...(verified.userId ? { userId: verified.userId } : {}), verifiedAt: nowIso(), }, warning: verified.warning, }; } return { outcome: { ...base, error: `user ${action.email} already exists with an unknown password. Delete it in the app (or pass "password" in the spec with its real password), then re-provision.`, }, }; } const verified = await verifyCredential(cfgTenant, action.email, action.password, action.role, genPassword); if (!verified.ok) { return { outcome: { ...base, error: `created but login-verify failed: ${verified.error}` } }; } return { outcome: { ...base, status: 'created', verified: true, passwordRotated: verified.rotated }, user: { role: action.role, email: action.email, password: verified.finalPassword, ...(created.userId ?? verified.userId ? { userId: created.userId ?? verified.userId } : {}), verifiedAt: nowIso(), }, warning: verified.warning, }; }