/** * migration-policy.ts — PURE 3-tier risk policy for EF Core migration operations. * * Replaces the blanket "never touch migrations" rule with a verdict graded by actual * IRREVERSIBILITY, decomposed along the axes that truly carry risk: * * • target database — local dev (throwaway, recreatable) vs shared/staging/prod * • branch / history — feature·develop (malleable) vs main·release·hotfix (protected) * • destructiveness — additive Up() vs Drop/narrowing (data loss) * * Three tiers: * 🟢 green — autonomous (reversible / local / additive on a non-protected branch) * 🟡 yellow — perform, but SURFACE the diff and require explicit confirmation first * 🔴 red — refuse autonomously; a human decides (irreversible / shared / history) * * FAIL-CLOSED: any ambiguity resolves to the safest (most gated) tier. The worst case * we protect hardest is "apply to a database we WRONGLY thought was local" — so DB * classification is allowlist-based and a non-provably-local target never gates green. * * Pure — no I/O. The caller gathers inputs (branch via git, connection string from * appsettings, the generated Up() body) and ENFORCES the verdict. Branch typing reuses * `determineBaseBranch` (squash-base) so there is one source of truth for branch tiers. */ import { determineBaseBranch, type SquashBranchType, type BranchNames } from './squash-base.js'; export type { BranchNames }; export type MigrationOp = | 'create' // dotnet ef migrations add | 'remove' // dotnet ef migrations remove (drop the last, unapplied migration) | 'apply' // dotnet ef database update (→ latest) | 'revert' // dotnet ef database update (down-migration) | 'squash'; // history rewrite (governed by the dedicated squash policy) export type DbTarget = 'local' | 'remote' | 'unknown'; export type Tier = 'green' | 'yellow' | 'red'; export type PolicyAxis = 'branch' | 'db-target' | 'destructive' | 'history'; export interface DbClassification { target: DbTarget; /** The host token we classified (instance/port stripped), for the reason string. */ host: string | null; reason: string; } export interface MigrationActionInput { op: MigrationOp; /** Current git branch (e.g. `feature/x`, `develop`, `main`, `release/5.1.0`). */ branch: string; /** Optional main/develop name overrides (from the gitflow config). */ branchNames?: BranchNames; /** Target DB classification — REQUIRED for `apply`/`revert`; ignored otherwise. */ dbTarget?: DbTarget; /** For `create`: does the generated Up() drop or narrow (data loss)? */ destructive?: boolean; } export interface PolicyVerdict { tier: Tier; /** Autonomous refusal (red). A human may still authorize — this only means "not on my own". */ blocked: boolean; /** Yellow (and red): the operation must be shown + explicitly confirmed before proceeding. */ requiresConfirmation: boolean; /** Which axis drove the verdict — for a precise, non-generic message. */ axis: PolicyAxis; reason: string; } // Loopback / local-instance servers a dev DB can sit on. Everything else fails closed. const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '.', '(local)', '(localdb)']); export interface ClassifyDbOptions { /** This machine's hostname (os.hostname(), gathered by the caller — this module stays pure). * A named instance on the machine itself (`MYPC\SQLEXPRESS`) is thereby PROVABLY local. */ machineName?: string | null; /** User-ratified extra local hosts (`.gitflow/config.json` → `efcore.localHosts`). * Declaring one is a HUMAN decision, made once — never auto-written by an agent. */ extraLocalHosts?: string[]; } /** * Classify a connection string's target database. ALLOWLIST-based + fail-closed: * a well-known loopback/localdb host, the machine's OWN hostname, or a * user-ratified `efcore.localHosts` entry is `local`; an Azure/FQDN/non-loopback-IP * host is `remote`; a bare hostname or an absent server token is `unknown`. Both * `remote` and `unknown` gate as non-local (never green for an apply). * * HARD FLOOR: an Azure SQL host (`*.database.windows.net`) is NEVER local — not * even allowlisted, not even if it matched a hostname. Checked before every * widening lane so no configuration can green-light a cloud database. */ export function classifyDbTarget(connString: string | null | undefined, opts?: ClassifyDbOptions): DbClassification { if (!connString || !connString.trim()) { return { target: 'unknown', host: null, reason: 'no connection string provided' }; } const m = connString.match(/(?:server|data\s*source)\s*=\s*([^;]+)/i); if (!m) { return { target: 'unknown', host: null, reason: 'no Server= / Data Source= token in the connection string' }; } // Strip a `tcp:` prefix, then drop the instance (`\X`) and port (`,1433`) → bare host. const host = m[1].replace(/^tcp:/i, '').split(/[\\,]/)[0].trim().toLowerCase(); if (LOCAL_HOSTS.has(host) || host.startsWith('(localdb)')) { return { target: 'local', host, reason: `local dev database (server "${host}") — throwaway / recreatable` }; } if (host.endsWith('.database.windows.net')) { return { target: 'remote', host, reason: `Azure SQL database (server "${host}") — never local, changes are irreversible` }; } const machine = opts?.machineName?.trim().toLowerCase(); if (machine && host === machine) { return { target: 'local', host, reason: `server "${host}" matches this machine's hostname — provably local` }; } const declaredLocal = (opts?.extraLocalHosts ?? []).map((h) => h.trim().toLowerCase()); if (declaredLocal.includes(host)) { return { target: 'local', host, reason: `server "${host}" declared local in .gitflow/config.json (efcore.localHosts)` }; } const looksRemote = /^\d{1,3}(\.\d{1,3}){3}$/.test(host) || // a non-loopback IPv4 host.includes('.'); // any FQDN if (looksRemote) { return { target: 'remote', host, reason: `shared / remote database (server "${host}") — changes are irreversible` }; } // A bare machine name (e.g. OTHERPC\SQLEXPRESS) — cannot PROVE it is local → fail closed. return { target: 'unknown', host, reason: `server "${host}" is not a provably-local host — treated as non-local` }; } const green = (axis: PolicyAxis, reason: string): PolicyVerdict => ({ tier: 'green', blocked: false, requiresConfirmation: false, axis, reason }); const yellow = (axis: PolicyAxis, reason: string): PolicyVerdict => ({ tier: 'yellow', blocked: false, requiresConfirmation: true, axis, reason }); const red = (axis: PolicyAxis, reason: string): PolicyVerdict => ({ tier: 'red', blocked: true, requiresConfirmation: true, axis, reason }); function isProtected(t: SquashBranchType): boolean { return t === 'main' || t === 'release' || t === 'hotfix'; } /** * Grade ONE migration operation. Total + deterministic. Branch type is resolved via * `determineBaseBranch` (the squash golden-rule classifier) so tiers never diverge. */ export function decideMigrationAction(input: MigrationActionInput): PolicyVerdict { const branchType = determineBaseBranch(input.branch, input.branchNames).branchType; const db = input.dbTarget ?? 'unknown'; switch (input.op) { case 'create': { // History axis dominates: never mint a migration on a protected branch. if (branchType === 'main') { return red('branch', `"${input.branch}" is a protected production branch — never create a migration there.`); } if (branchType === 'release' || branchType === 'hotfix') { return yellow('branch', `creating a migration on a ${branchType} branch is unusual and ships fast toward prod — confirm it is intended.`); } // feature / develop / unknown: green unless the diff loses data. if (input.destructive) { return yellow('destructive', 'the generated Up() drops or narrows columns (data loss) — confirm this is an intended schema change, not an unintended rename.'); } return green('branch', `additive migration on ${branchType} — reversible code artifact; the snapshot is reconciled by squash-before-PR.`); } case 'apply': { // Target-DB axis dominates: a local dev DB is throwaway; anything else is a deploy. if (db === 'local') { return green('db-target', 'applying pending migrations to the LOCAL dev database (recreatable) — no shared state, no history change.'); } return red('db-target', `applying migrations to a ${db} database is irreversible — a human deploys to non-local targets.`); } case 'revert': { // Down-migration: drops are possible even locally, but local stays recoverable. if (db === 'local') { return yellow('db-target', 'a down-migration can drop columns even on the local DB — confirm before reverting.'); } return red('db-target', `reverting migrations on a ${db} database is irreversible data loss — a human decides.`); } case 'remove': { if (branchType === 'main') { return red('branch', `"${input.branch}" is protected — never remove a migration there.`); } return yellow('history', 'removing the last migration is safe ONLY if it was never pushed or applied elsewhere — confirm.'); } case 'squash': // History rewrite — out of scope here; the squash CLI enforces its own // missing-ref / behind-base / brute-force gates (squash-policy.ts). return red('history', 'a migration squash rewrites history — governed by the dedicated squash policy, never an autonomous action.'); default: return red('history', `unknown migration operation "${(input as MigrationActionInput).op}" — refusing by default (fail-closed).`); } } // Builder calls in a migration's Up() that DROP or destroy data. Mirrors the intent // of the gitflow commit guard's Up()-only destructive scan. Index drops are omitted // (no data loss); AlterColumn is omitted (widening is common + safe, and narrowing is // too noisy to gate on without type analysis). const DESTRUCTIVE_OPS = ['DropColumn', 'DropTable', 'DropForeignKey', 'DropPrimaryKey', 'DropSchema', 'DropSequence']; /** * Does the given Up() body contain a data-losing operation? Pure string scan over the * real builder call (`.DropColumn(`), robust to a frozen SQL literal that merely * mentions the word. The caller is responsible for passing ONLY the Up() body (the * Down() inverse legitimately contains the opposite ops). */ export function hasDestructiveUp(upBody: string | null | undefined): boolean { if (!upBody) return false; return DESTRUCTIVE_OPS.some((op) => upBody.includes(`.${op}(`)); }