/** * @fileoverview Multi-user store: `~/.codeman/users.json` (via `dataPath`, 0600). * * Mirrors the storage-module pattern of `remote-hosts.ts` / `docker-hosts.ts`, but * because it holds password hashes it writes atomically (tmp + rename) at mode * 0600 and keeps only a SHORT in-process cache so the CLI (`codeman users …`) can * edit the file while the server runs and have changes picked up within the TTL. * * Pure, IO-free helpers (`isValidUsername`, `hashPassword`, `verifyPasswordHash`, * `needsRehash`, `resolveClaudeModeForUser`, the last-admin invariants) are split * out so they are unit-testable without a server. Hashing is `scrypt` from * `node:crypto` (no new deps), compared via `timingSafeEqual`; parameters are * stored per record so cost can be raised later and old records rehashed on their * next successful login. * * See `docs/multi-user-plan.md` sections 4.1, 5, 6.3. */ import { existsSync, mkdirSync } from 'node:fs'; import fs from 'node:fs/promises'; import { isAbsolute, join, relative } from 'node:path'; import { randomBytes, scrypt as scryptCb, timingSafeEqual } from 'node:crypto'; import { promisify } from 'node:util'; import { dataPath, getDataDir } from './config/instance.js'; import { getUserSpacesDir, isMultiUserMode, maxUsers } from './config/multiuser.js'; import type { AuthUser, ClaudeMode, PasswordHash, PublicUser, UserRecord, UserRole, UsersFile } from './types.js'; const scrypt = promisify(scryptCb) as ( password: string | Buffer, salt: string | Buffer, keylen: number, options: { N: number; r: number; p: number; maxmem: number } ) => Promise; const USERS_FILE = 'users.json'; const CACHE_TTL_MS = 1000; const KEYLEN = 64; const SALT_BYTES = 32; /** Generous ceiling so raising N/r later does not trip scrypt's memory guard. */ const SCRYPT_MAXMEM = 256 * 1024 * 1024; /** Current hashing parameters. Stored per record; raise these to increase cost. */ export const DEFAULT_SCRYPT_PARAMS = { N: 16384, r: 8, p: 1 } as const; /** Username: lowercase, first char alphanumeric, 2-32 chars total. Becomes a folder name. */ const USERNAME_RE = /^[a-z0-9][a-z0-9_-]{1,31}$/; /** Typed error whose `.code` maps to an API errorCode at the route layer. */ export class UserStoreError extends Error { constructor( message: string, public readonly code: 'USER_EXISTS' | 'USER_NOT_FOUND' | 'LAST_ADMIN' | 'INVALID_INPUT' ) { super(message); this.name = 'UserStoreError'; } } // ─────────────────────────────── pure helpers ─────────────────────────────── export function normalizeUsername(name: string): string { return String(name ?? '') .trim() .toLowerCase(); } export function isValidUsername(name: string): boolean { return USERNAME_RE.test(normalizeUsername(name)); } /** Hash a password with the given (or current) scrypt params + a fresh random salt. */ export async function hashPassword( password: string, params: { N: number; r: number; p: number } = DEFAULT_SCRYPT_PARAMS ): Promise { const salt = randomBytes(SALT_BYTES); const derived = await scrypt(password, salt, KEYLEN, { ...params, maxmem: SCRYPT_MAXMEM }); return { algo: 'scrypt', N: params.N, r: params.r, p: params.p, salt: salt.toString('hex'), hash: derived.toString('hex'), }; } /** Constant-time verify of a password against a stored hash record. Never throws. */ export async function verifyPasswordHash(password: string, record: PasswordHash): Promise { if (!record || record.algo !== 'scrypt') return false; let salt: Buffer; let expected: Buffer; try { salt = Buffer.from(record.salt, 'hex'); expected = Buffer.from(record.hash, 'hex'); } catch { return false; } if (expected.length === 0) return false; let derived: Buffer; try { derived = await scrypt(password, salt, expected.length, { N: record.N, r: record.r, p: record.p, maxmem: SCRYPT_MAXMEM, }); } catch { return false; } if (derived.length !== expected.length) return false; return timingSafeEqual(derived, expected); } /** True when a stored hash uses weaker params than current and should be rehashed. */ export function needsRehash(record: PasswordHash, params = DEFAULT_SCRYPT_PARAMS): boolean { return record.algo !== 'scrypt' || record.N !== params.N || record.r !== params.r || record.p !== params.p; } /** URL-safe one-time password (16 chars) for admin create/reset flows. */ export function generateOneTimePassword(): string { return randomBytes(12).toString('base64url'); } export function toPublicUser(u: UserRecord): PublicUser { return { username: u.username, role: u.role, disabled: !!u.disabled, mustChangePassword: !!u.mustChangePassword, canBypassPermissions: !!u.canBypassPermissions, createdAt: u.createdAt, lastLoginAt: u.lastLoginAt, }; } export function countEnabledAdmins(users: UserRecord[]): number { return users.filter((u) => u.role === 'admin' && !u.disabled).length; } /** * Section 6.3: resolve the effective Claude permission mode for a user. Admins and * granted users get the global mode as-is; a non-granted regular user whose mode * would be `dangerously-skip-permissions` is silently downgraded to `auto` (all * other modes are already <= auto and pass through). Pure. */ export function resolveClaudeModeForUser( globalMode: ClaudeMode | undefined, grant: { role: UserRole; canBypassPermissions?: boolean } ): ClaudeMode { const mode: ClaudeMode = globalMode ?? 'dangerously-skip-permissions'; if (grant.role === 'admin' || grant.canBypassPermissions) return mode; return mode === 'dangerously-skip-permissions' ? 'auto' : mode; } /** * Section 6.3: whether a user may run arbitrary commands as the host account * (shell-mode sessions, cron `launchCommand`, other CLIs' bypass flags). Same * one-bit grant as bypass. Admins always may. */ export function canRunPrivilegedCommands(grant: { role: UserRole; canBypassPermissions?: boolean }): boolean { return grant.role === 'admin' || !!grant.canBypassPermissions; } // ─────────────────────────────── IO layer ─────────────────────────────── let cache: { users: UserRecord[]; ts: number } | null = null; /** Drop the in-process cache (called after every write; exported for tests). */ export function invalidateUsersCache(): void { cache = null; } export async function readUsers(force = false): Promise { const now = Date.now(); if (!force && cache && now - cache.ts < CACHE_TTL_MS) return cache.users; let raw: string; try { raw = await fs.readFile(dataPath(USERS_FILE), 'utf-8'); } catch (err) { // ENOENT is the ONLY legitimately-empty store (first boot). Any other read // error (EIO/EACCES/EMFILE/EBUSY) is a transient/permission failure, NOT an // empty store — do NOT cache [] and do NOT let it look empty, or a following // createUser/bootstrap would overwrite users.json and destroy every account. if ((err as NodeJS.ErrnoException).code === 'ENOENT') { cache = { users: [], ts: now }; return []; } throw err; } // A present-but-corrupt file (invalid JSON) must also fail loud rather than // read as empty, so mutators/bootstrap abort instead of clobbering it. const parsed = JSON.parse(raw) as Partial; const users = Array.isArray(parsed.users) ? parsed.users : []; cache = { users, ts: now }; return users; } async function writeUsers(users: UserRecord[]): Promise { const dir = getDataDir(); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); const finalPath = dataPath(USERS_FILE); // Unique per-writer tmp name (pid + random) so the CLI (`codeman users …`) and // the live server — designed to write this file concurrently across processes — // never share a single `users.json.tmp` inode and tear each other's payload. // Matches the state-store.ts / self-update.ts convention. const tmpPath = `${finalPath}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`; const payload: UsersFile = { version: 1, users }; try { await fs.writeFile(tmpPath, JSON.stringify(payload, null, 2), { mode: 0o600 }); await fs.chmod(tmpPath, 0o600).catch(() => {}); await fs.rename(tmpPath, finalPath); } catch (err) { await fs.unlink(tmpPath).catch(() => {}); throw err; } cache = { users, ts: Date.now() }; } /** * Serialize every read-modify-write on users.json. Without this a fire-and-forget * touchLastLogin (fired on each Basic auth) can interleave with a route's * create/update and clobber records, since both do readUsers(true) → mutate → * writeUsers against a single shared file + tmp path. */ let mutateChain: Promise = Promise.resolve(); function withUsersLock(fn: () => Promise): Promise { const run = mutateChain.then(fn, fn); mutateChain = run.then( () => undefined, () => undefined ); return run; } export async function hasUsers(): Promise { return (await readUsers()).length > 0; } // A precomputed dummy hash so an unknown/disabled user costs the same scrypt work // as a real verify (defeats username-enumeration by timing). Created once, lazily. let dummyHashPromise: Promise | null = null; function getDummyHash(): Promise { if (!dummyHashPromise) dummyHashPromise = hashPassword('codeman-timing-equalization-placeholder'); return dummyHashPromise; } /** * Verify a username/password against the store. Returns the record (plus whether it * should be rehashed) on success, or null for wrong password / unknown / disabled * user. Runs a dummy scrypt on the miss path so timing does not reveal which users * exist. Never writes (the caller decides when to persist lastLogin / rehash). */ export async function verifyPassword( username: string, password: string ): Promise<{ user: UserRecord; needsRehash: boolean } | null> { const user = await findUser(username); if (!user || user.disabled) { await verifyPasswordHash(password, await getDummyHash()); return null; } const ok = await verifyPasswordHash(password, user.password); if (!ok) return null; return { user, needsRehash: needsRehash(user.password) }; } export async function findUser(username: string): Promise { const norm = normalizeUsername(username); if (!norm) return undefined; const users = await readUsers(); return users.find((u) => u.username === norm); } export interface CreateUserOptions { username: string; role: UserRole; password: string; mustChangePassword?: boolean; canBypassPermissions?: boolean; } export async function createUser(opts: CreateUserOptions): Promise { const username = normalizeUsername(opts.username); if (!isValidUsername(username)) { throw new UserStoreError( 'Username must be lowercase, start alphanumeric, 2-32 chars ([a-z0-9_-])', 'INVALID_INPUT' ); } if (opts.role !== 'admin' && opts.role !== 'user') { throw new UserStoreError('Role must be "admin" or "user"', 'INVALID_INPUT'); } if (!opts.password || opts.password.length < 8) { throw new UserStoreError('Password must be at least 8 characters', 'INVALID_INPUT'); } return withUsersLock(async () => { const users = await readUsers(true); if (users.some((u) => u.username === username)) { throw new UserStoreError(`User "${username}" already exists`, 'USER_EXISTS'); } if (users.length >= maxUsers()) { throw new UserStoreError(`Maximum number of users (${maxUsers()}) reached`, 'INVALID_INPUT'); } const record: UserRecord = { username, role: opts.role, password: await hashPassword(opts.password), disabled: false, mustChangePassword: !!opts.mustChangePassword, canBypassPermissions: !!opts.canBypassPermissions, createdAt: Date.now(), }; users.push(record); await writeUsers(users); return record; }); } /** Set a user's password. `mustChangePassword` is left unchanged unless specified. */ export async function setPassword( username: string, password: string, opts: { mustChangePassword?: boolean } = {} ): Promise { if (!password || password.length < 8) { throw new UserStoreError('Password must be at least 8 characters', 'INVALID_INPUT'); } const norm = normalizeUsername(username); return withUsersLock(async () => { const users = await readUsers(true); const record = users.find((u) => u.username === norm); if (!record) throw new UserStoreError(`User "${norm}" not found`, 'USER_NOT_FOUND'); record.password = await hashPassword(password); if (opts.mustChangePassword !== undefined) record.mustChangePassword = opts.mustChangePassword; await writeUsers(users); return record; }); } export interface UpdateUserPatch { role?: UserRole; disabled?: boolean; canBypassPermissions?: boolean; mustChangePassword?: boolean; } export async function updateUser(username: string, patch: UpdateUserPatch): Promise { const norm = normalizeUsername(username); return withUsersLock(async () => { const users = await readUsers(true); const record = users.find((u) => u.username === norm); if (!record) throw new UserStoreError(`User "${norm}" not found`, 'USER_NOT_FOUND'); // Guard the last-enabled-admin invariant against demote/disable. const before = countEnabledAdmins(users); const projected: UserRecord = { ...record, role: patch.role ?? record.role, disabled: patch.disabled ?? record.disabled, }; const after = countEnabledAdmins(users.map((u) => (u.username === norm ? projected : u))); if (before > 0 && after === 0) { throw new UserStoreError('Cannot demote or disable the last enabled admin', 'LAST_ADMIN'); } if (patch.role !== undefined) record.role = patch.role; if (patch.disabled !== undefined) record.disabled = patch.disabled; if (patch.canBypassPermissions !== undefined) record.canBypassPermissions = patch.canBypassPermissions; if (patch.mustChangePassword !== undefined) record.mustChangePassword = patch.mustChangePassword; await writeUsers(users); return record; }); } /** * Record a successful login timestamp. Best-effort + throttled: skips the write if * the last login was within the last minute (Basic clients re-send credentials on * every request, so this fires often — the throttle keeps disk churn bounded). */ export async function touchLastLogin(username: string): Promise { const norm = normalizeUsername(username); try { await withUsersLock(async () => { const users = await readUsers(true); const record = users.find((u) => u.username === norm); if (!record) return; if (record.lastLoginAt && Date.now() - record.lastLoginAt < 60_000) return; record.lastLoginAt = Date.now(); await writeUsers(users); }); } catch { /* best-effort */ } } export async function deleteUser(username: string): Promise { const norm = normalizeUsername(username); await withUsersLock(async () => { const users = await readUsers(true); const record = users.find((u) => u.username === norm); if (!record) throw new UserStoreError(`User "${norm}" not found`, 'USER_NOT_FOUND'); const before = countEnabledAdmins(users); const remaining = users.filter((u) => u.username !== norm); const after = countEnabledAdmins(remaining); if (before > 0 && after === 0) { throw new UserStoreError('Cannot delete the last enabled admin', 'LAST_ADMIN'); } await writeUsers(remaining); }); } /** * First-boot bootstrap: in multi-user mode with no users yet, create the initial * admin from `CODEMAN_USERNAME`/`CODEMAN_PASSWORD` if both are set. Returns a * status the caller (server start / CLI) uses to decide whether to refuse boot. */ export async function bootstrapInitialAdmin(): Promise<{ status: 'created' | 'exists' | 'missing-env'; username?: string; }> { if (await hasUsers()) return { status: 'exists' }; const username = process.env.CODEMAN_USERNAME; const password = process.env.CODEMAN_PASSWORD; if (!username || !password) return { status: 'missing-env' }; const created = await createUser({ username, role: 'admin', password }); return { status: 'created', username: created.username }; } /** * Delete a user's on-disk space (`/`) with the section 8 * guard rails: the top-level dir must not be a symlink, and its realpath must * resolve strictly inside USER_SPACES_DIR (so a symlinked or `..`-escaping target * can never be used to rm an arbitrary tree). No-op if the space does not exist. */ export async function deleteUserSpace(username: string): Promise { const norm = normalizeUsername(username); if (!isValidUsername(norm)) throw new UserStoreError('Invalid username', 'INVALID_INPUT'); const root = getUserSpacesDir(); const target = join(root, norm); let lst; try { lst = await fs.lstat(target); } catch { return; // nothing to delete } if (lst.isSymbolicLink()) { throw new UserStoreError('Refusing to delete a symlinked user space', 'INVALID_INPUT'); } const realRoot = await fs.realpath(root).catch(() => root); const realTarget = await fs.realpath(target); const rel = relative(realRoot, realTarget); if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) { throw new UserStoreError('User space escapes USER_SPACES_DIR', 'INVALID_INPUT'); } await fs.rm(realTarget, { recursive: true, force: true }); } /** The synthetic admin used in single-user mode so downstream has one code path. */ export const SYNTHETIC_ADMIN: AuthUser = { username: 'admin', role: 'admin' }; /** * Whether a username may run arbitrary commands (shell mode, cron launchCommand, * other CLIs' bypass). Single-user or an unset owner: allowed. In multi-user a * MISSING user (e.g. deleted) fails closed (non-privileged). Used at cron fire time. */ export async function canUsernameRunPrivilegedCommands(username: string | undefined): Promise { if (!isMultiUserMode() || !username) return true; const user = await findUser(username); return canRunPrivilegedCommands(user ?? { role: 'user' }); } /** * Resolve the effective Claude mode for a username by looking up the grant. In * single-user mode (or for an unknown owner) the global mode passes through. */ export async function resolveClaudeModeForUsername( globalMode: ClaudeMode | undefined, username: string | undefined ): Promise { const fallback: ClaudeMode = globalMode ?? 'dangerously-skip-permissions'; if (!isMultiUserMode() || !username) return fallback; // Fail closed: an unknown/deleted owner in multi-user mode is treated as a // non-granted regular user so a stale-owned spawn (e.g. an orphaned cron job) // is downgraded to `auto` rather than inheriting the global bypass. const user = await findUser(username); return resolveClaudeModeForUser(globalMode, user ?? { role: 'user' }); }