From 82391874ee1ca4f20188f32c5ec7d2bc4e7ee7bb Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Wed, 17 Jun 2026 17:13:04 -0400 Subject: [PATCH] feat(19-05): add break-glass reset-admin CLI (dev-only, .dockerignore'd) - Create apps/api/scripts/reset-admin.ts (AUTH-LOCAL-11, D-13, D-15) - Dev-only guard as FIRST executable statement (NODE_ENV=production throws) - Inline scrypt PHC hashPassword (cannot import compiled TS, Pitfall 11) - Parse --username/--password from argv; never log password value (T-19-26) - --dry-run validates args + DB connection without writing - Upserts users (is_admin=true) + local_credentials rows idempotently - Script excluded from prod image via .dockerignore apps/api/scripts/ (IMG-02) - dry-run: exit=0, no password in output verified --- apps/api/scripts/reset-admin.ts | 149 ++++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 apps/api/scripts/reset-admin.ts diff --git a/apps/api/scripts/reset-admin.ts b/apps/api/scripts/reset-admin.ts new file mode 100644 index 0000000..ec1675d --- /dev/null +++ b/apps/api/scripts/reset-admin.ts @@ -0,0 +1,149 @@ +/** + * reset-admin.ts — Break-glass CLI: create or reset a local admin account (D-13). + * + * Usage (dev only): + * docker exec -it familysync-api node --import=tsx/esm scripts/reset-admin.ts \ + * --username admin --password '' + * + * Flags: + * --username Required. Username to create/reset. + * --password Required. New password (never logged). + * --dry-run Validate args + DB connection without writing. + * + * Security (T-19-25, T-19-26, D-13, D-15): + * - FIRST statement: dev-only guard — throws when NODE_ENV=production (defense-in-depth). + * - This script is also excluded from the production image via .dockerignore apps/api/scripts/ (IMG-02). + * - The password value is NEVER logged or printed. + * - hashPassword is inlined (scrypt PHC) — cannot import compiled TS from a plain script (Pitfall 11). + * + * DB: + * Reads DB_HOST/DB_PORT/DB_USER/DB_PASSWORD/DB_NAME env (same defaults as global-setup.ts). + * Upserts users row (is_admin=true, claimed=true) then upserts local_credentials row. + * Idempotent: safe to run multiple times with the same username. + */ + +// ── DEV-ONLY GUARD — must be the FIRST executable statement (T-19-25 / D-15) ──────────── +if (process.env.NODE_ENV === 'production') { + throw new Error( + 'reset-admin refused: NODE_ENV=production. ' + + 'This CLI creates/resets local admin credentials and must NEVER run in production. ' + + 'The script is also excluded from the production image via .dockerignore apps/api/scripts/ (IMG-02).', + ); +} + +import { createConnection } from 'mysql2/promise'; +import { scryptSync, randomBytes } from 'node:crypto'; + +// ── Inline hashPassword (PHC-style scrypt) ─────────────────────────────────────────────── +// Cannot import compiled TS from a plain Node.js script at runtime (Pitfall 11). +// Copy of the 5-line implementation from apps/api/src/auth/localCredentials.ts. +const SCRYPT_N = 16384; +const SCRYPT_R = 8; +const SCRYPT_P = 1; +const KEY_LEN = 32; + +function hashPassword(password: string): string { + const salt = randomBytes(16); + const hash = scryptSync(password, salt, KEY_LEN, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P }); + return ['scrypt', SCRYPT_N, SCRYPT_R, SCRYPT_P, salt.toString('base64url'), hash.toString('base64url')].join( + '$', + ); +} + +// ── CLI arg parsing (no new deps — process.argv only) ─────────────────────────────────── +function parseArgs(argv: string[]): Record { + const result: Record = {}; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg.startsWith('--')) { + const key = arg.slice(2); + const value = argv[i + 1] !== undefined && !argv[i + 1].startsWith('--') ? argv[i + 1] : ''; + result[key] = value; + if (value) i++; // skip the value token + } + } + return result; +} + +const args = parseArgs(process.argv.slice(2)); + +// ── Validate required args ──────────────────────────────────────────────────────────────── +const username = args['username']; +const password = args['password']; +const dryRun = Object.prototype.hasOwnProperty.call(args, 'dry-run'); + +if (!username || username.trim() === '') { + console.error('reset-admin: --username is required'); + process.exit(1); +} +if (!dryRun && (!password || password.trim() === '')) { + console.error('reset-admin: --password is required (use --dry-run to test without writing)'); + process.exit(1); +} +if (dryRun && !password) { + // In dry-run mode a placeholder password is acceptable — skip real validation + console.log('[dry-run] Args validated: --username present, --dry-run active (no write will occur)'); +} + +// ── DB connection ───────────────────────────────────────────────────────────────────────── +const conn = await createConnection({ + host: process.env.DB_HOST ?? '127.0.0.1', + port: Number(process.env.DB_PORT ?? 3306), + user: process.env.DB_USER ?? 'familysync', + password: process.env.DB_PASSWORD ?? '', + database: process.env.DB_NAME ?? 'familysync', +}); + +try { + // Verify DB connectivity (used by --dry-run to confirm connection works) + await conn.query('SELECT 1'); + console.log('[reset-admin] DB connection OK'); + + if (dryRun) { + console.log('[dry-run] Connection verified. Exiting without writing.'); + await conn.end(); + process.exit(0); + } + + // ── Upsert users row ───────────────────────────────────────────────────────────────── + // Find existing user by username (via local_credentials join) or create a new one. + // is_admin=true + claimed=true for break-glass recovery (D-13). + // Never logs the password value (T-19-26). + const [lcRows] = await conn.execute<{ user_id: number }[]>( + 'SELECT user_id FROM local_credentials WHERE username = ? LIMIT 1', + [username], + ); + + let userId: number; + + if (lcRows.length > 0) { + // Existing local_credentials row — update password and ensure is_admin + userId = lcRows[0].user_id; + await conn.execute('UPDATE users SET is_admin = true, claimed = true WHERE id = ?', [userId]); + console.log(`[reset-admin] Found existing user id=${userId} for username="${username}"`); + } else { + // No existing row — insert a new user + const displayName = username; + const [insertResult] = await conn.execute<{ insertId: number }>( + `INSERT INTO users (oidc_iss, oidc_sub, display_name, color, is_admin, claimed) + VALUES (NULL, NULL, ?, '#4A90D9', true, true)`, + [displayName], + ); + userId = (insertResult as unknown as { insertId: number }).insertId; + console.log(`[reset-admin] Created new user id=${userId} for username="${username}"`); + } + + // ── Upsert local_credentials row ───────────────────────────────────────────────────── + const passwordHash = hashPassword(password!); + await conn.execute( + `INSERT INTO local_credentials (user_id, username, password_hash) + VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE password_hash = VALUES(password_hash), username = VALUES(username)`, + [userId, username, passwordHash], + ); + + console.log(`[reset-admin] Local credential upserted for user id=${userId} username="${username}"`); + console.log(`[reset-admin] Done. User id=${userId} is now a local admin.`); +} finally { + await conn.end(); +}