179 lines
8.2 KiB
TypeScript
179 lines
8.2 KiB
TypeScript
/**
|
|
* 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 '<new-password>'
|
|
*
|
|
* Flags:
|
|
* --username <name> Required. Username to create/reset.
|
|
* --password <pass> 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) ───────────────────────────────────
|
|
// WR-01: support both `--key=value` and `--key value`, and parse values EXPLICITLY rather
|
|
// than inferring an empty string whenever the next token starts with '--'. The old heuristic
|
|
// coerced `--password --foo` (and a legitimately `--`-prefixed or empty password) silently to
|
|
// ''. Here, known value-taking flags (--username, --password) always consume the next token
|
|
// verbatim as their value; the only boolean flag (--dry-run) takes no value. This keeps a
|
|
// password that begins with '--', or an intentionally empty password, intact.
|
|
const VALUE_FLAGS = new Set(['username', 'password']);
|
|
const BOOLEAN_FLAGS = new Set(['dry-run']);
|
|
|
|
function parseArgs(argv: string[]): Record<string, string | undefined> {
|
|
const result: Record<string, string | undefined> = {};
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const arg = argv[i];
|
|
if (!arg.startsWith('--')) continue;
|
|
|
|
const eq = arg.indexOf('=');
|
|
if (eq !== -1) {
|
|
// `--key=value` form — value is everything after the first '=', taken verbatim
|
|
// (so `--password=--weird` and `--password=` both work correctly).
|
|
result[arg.slice(2, eq)] = arg.slice(eq + 1);
|
|
continue;
|
|
}
|
|
|
|
const key = arg.slice(2);
|
|
if (BOOLEAN_FLAGS.has(key)) {
|
|
result[key] = ''; // presence-only flag; detected via hasOwnProperty
|
|
continue;
|
|
}
|
|
if (VALUE_FLAGS.has(key)) {
|
|
// Consume the NEXT token verbatim as the value — even if it starts with '--' or is
|
|
// empty. If there is no next token, record undefined (genuinely absent, not '').
|
|
result[key] = argv[i + 1];
|
|
if (i + 1 < argv.length) i++; // skip the consumed value token
|
|
continue;
|
|
}
|
|
// Unknown flag — record presence with no value (forward-compatible, no crash).
|
|
result[key] = '';
|
|
}
|
|
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]);
|
|
// WR-01: do not echo the username — log only the resolved user id (no credential data).
|
|
console.log(`[reset-admin] Found existing user id=${userId}`);
|
|
} 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;
|
|
// WR-01: do not echo the username — log only the resolved user id.
|
|
console.log(`[reset-admin] Created new user id=${userId}`);
|
|
}
|
|
|
|
// ── 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}`);
|
|
console.log(`[reset-admin] Done. User id=${userId} is now a local admin.`);
|
|
} finally {
|
|
await conn.end();
|
|
}
|