diff --git a/src/cli.ts b/src/cli.ts index 03883bc3..c27e8ded 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -584,7 +584,11 @@ program '--allow-unauthenticated-network', 'Allow non-loopback web access without CODEMAN_PASSWORD (dangerous; terminal control is exposed)' ) + .option('--multiuser', 'Enable opt-in multi-user mode (named users in ~/.codeman/users.json; env: CODEMAN_MULTIUSER)') .action(async (options) => { + // The flag is surfaced to the rest of the process via the env var so + // isMultiUserMode() has a single source of truth (see config/multiuser.ts). + if (options.multiuser) process.env.CODEMAN_MULTIUSER = '1'; const { startWebServer } = await import('./web/server.js'); const host = options.host; const port = parseInt(options.port, 10); @@ -626,6 +630,168 @@ program } }); +// ============ Multi-user Commands ============ +// +// Operate directly on ~/.codeman/users.json (via user-store) with NO running +// server, honoring CODEMAN_INSTANCE. This is the headless bootstrap path and the +// recovery answer to "locked out: last admin forgot password". + +/** Read a password from stdin without echoing. Falls back to plain read on non-TTY. */ +function promptHiddenPassword(question: string): Promise { + const stdin = process.stdin; + if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') { + // Non-interactive: read a single line from stdin. + return new Promise((resolve) => { + let buf = ''; + stdin.setEncoding('utf8'); + stdin.on('data', (d) => (buf += d)); + stdin.on('end', () => resolve(buf.replace(/\r?\n$/, ''))); + }); + } + return new Promise((resolve) => { + process.stdout.write(question); + let input = ''; + stdin.setRawMode(true); + stdin.resume(); + stdin.setEncoding('utf8'); + const onData = (chunk: string) => { + for (const c of chunk) { + if (c === '\n' || c === '\r' || c === '\u0004') { + stdin.setRawMode!(false); + stdin.pause(); + stdin.removeListener('data', onData); + process.stdout.write('\n'); + resolve(input); + return; + } else if (c === '\u0003') { + process.stdout.write('\n'); + process.exit(1); + } else if (c === '\u007f' || c === '\b') { + input = input.slice(0, -1); + } else { + input += c; + } + } + }; + stdin.on('data', onData); + }); +} + +function readAllStdin(): Promise { + return new Promise((resolve) => { + let buf = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', (d) => (buf += d)); + process.stdin.on('end', () => resolve(buf.replace(/\r?\n$/, ''))); + }); +} + +const usersCmd = program.command('users').description('Manage multi-user accounts (~/.codeman/users.json)'); + +usersCmd + .command('add ') + .description('Create a user (prompts for password; use --password-stdin for scripts)') + .option('--admin', 'Create as an admin') + .option('--password-stdin', 'Read the password from stdin instead of prompting') + .action(async (name, options) => { + const { createUser, isValidUsername } = await import('./user-store.js'); + if (!isValidUsername(name)) { + console.error(chalk.red('✗ Username must be lowercase, start alphanumeric, 2-32 chars ([a-z0-9_-])')); + process.exit(1); + } + try { + let password: string; + if (options.passwordStdin) { + password = await readAllStdin(); + } else { + password = await promptHiddenPassword('New password: '); + const confirm = await promptHiddenPassword('Confirm password: '); + if (password !== confirm) { + console.error(chalk.red('✗ Passwords do not match')); + process.exit(1); + } + } + if (!password || password.length < 8) { + console.error(chalk.red('✗ Password must be at least 8 characters')); + process.exit(1); + } + const user = await createUser({ username: name, role: options.admin ? 'admin' : 'user', password }); + console.log(chalk.green(`✓ Created ${user.role} "${user.username}"`)); + } catch (err) { + console.error(chalk.red(`✗ ${getErrorMessage(err)}`)); + process.exit(1); + } + }); + +usersCmd + .command('passwd ') + .description('Reset a user password') + .option('--password-stdin', 'Read the new password from stdin instead of prompting') + .action(async (name, options) => { + const { setPassword } = await import('./user-store.js'); + try { + let password: string; + if (options.passwordStdin) { + password = await readAllStdin(); + } else { + password = await promptHiddenPassword('New password: '); + const confirm = await promptHiddenPassword('Confirm password: '); + if (password !== confirm) { + console.error(chalk.red('✗ Passwords do not match')); + process.exit(1); + } + } + await setPassword(name, password, { mustChangePassword: false }); + console.log(chalk.green(`✓ Password updated for "${name}"`)); + } catch (err) { + console.error(chalk.red(`✗ ${getErrorMessage(err)}`)); + process.exit(1); + } + }); + +usersCmd + .command('list') + .alias('ls') + .description('List all users') + .action(async () => { + const { readUsers } = await import('./user-store.js'); + const users = await readUsers(true); + if (users.length === 0) { + console.log(chalk.yellow('No users defined (run: codeman users add --admin)')); + return; + } + console.log(chalk.bold('\nUsers:')); + for (const u of users) { + const role = u.role === 'admin' ? chalk.magenta('admin') : chalk.cyan('user '); + const state = u.disabled ? chalk.red('disabled') : chalk.green('enabled '); + const flags = [u.mustChangePassword ? 'must-change-pw' : '', u.canBypassPermissions ? 'can-bypass' : ''] + .filter(Boolean) + .join(' '); + console.log(` ${role} ${state} ${u.username}${flags ? chalk.gray(` [${flags}]`) : ''}`); + } + console.log(''); + }); + +usersCmd + .command('rm ') + .description('Delete a user') + .option('--delete-space', "Also delete the user's ~/codeman-users/ space") + .action(async (name, options) => { + const { deleteUser, deleteUserSpace } = await import('./user-store.js'); + try { + await deleteUser(name); + if (options.deleteSpace) { + await deleteUserSpace(name); + console.log(chalk.green(`✓ Deleted user "${name}" and their space`)); + } else { + console.log(chalk.green(`✓ Deleted user "${name}" (space left on disk)`)); + } + } catch (err) { + console.error(chalk.red(`✗ ${getErrorMessage(err)}`)); + process.exit(1); + } + }); + program .command('doctor') .alias('check-deps') diff --git a/src/config/multiuser.ts b/src/config/multiuser.ts new file mode 100644 index 00000000..cc2340b6 --- /dev/null +++ b/src/config/multiuser.ts @@ -0,0 +1,63 @@ +/** + * @fileoverview Multi-user mode gating + limits (opt-in, off by default). + * + * Multi-user mode is enabled by `codeman web --multiuser` (which sets + * `CODEMAN_MULTIUSER=1`) or the env var directly. When OFF, behavior is + * byte-identical to today: `users.json` is never read and all ownership scoping + * is bypassed. Everything here is per-instance like the rest of Codeman: a beta + * instance (`CODEMAN_INSTANCE=beta`) has its own `users.json` via `dataPath()`, + * and its user spaces live under the same shared `~/codeman-users` as prod (like + * `~/codeman-cases`), unless `CODEMAN_USER_SPACES_DIR` overrides it. + * + * See `docs/multi-user-plan.md` sections 3, 4.2, and 11. + */ + +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { MAX_CONCURRENT_SESSIONS } from './map-limits.js'; + +/** + * Whether multi-user mode is active. Read from the environment each call so it is + * stable for the process lifetime (env does not change after boot) and trivially + * overridable in tests. Accepts `1` or `true`. + */ +export function isMultiUserMode(): boolean { + const v = process.env.CODEMAN_MULTIUSER; + return v === '1' || v === 'true'; +} + +/** + * Root of per-user spaces: `~/codeman-users` (sibling of `~/codeman-cases`). + * Overridable via `CODEMAN_USER_SPACES_DIR` (used by tests). Resolved lazily so a + * test can point it at a temp dir before the first call. + */ +export function getUserSpacesDir(): string { + return process.env.CODEMAN_USER_SPACES_DIR || join(homedir(), 'codeman-users'); +} + +/** Absolute path to a user's top-level space: `/[/segments]`. */ +export function userSpacePath(username: string, ...segments: string[]): string { + return join(getUserSpacesDir(), username, ...segments); +} + +/** Absolute path to a user's cases dir: `//cases`. */ +export function userCasesDir(username: string): string { + return join(getUserSpacesDir(), username, 'cases'); +} + +/** Maximum number of user accounts (default 25, env `CODEMAN_MAX_USERS`). */ +export function maxUsers(): number { + const n = Number(process.env.CODEMAN_MAX_USERS); + return Number.isInteger(n) && n > 0 ? n : 25; +} + +/** + * Per-user concurrent-session cap (the fairness lever). Defaults to half the + * global cap; overridable via `CODEMAN_MAX_SESSIONS_PER_USER`. The global cap + * (MAX_CONCURRENT_SESSIONS) still applies on top and is shared across users. + */ +export function maxSessionsPerUser(): number { + const n = Number(process.env.CODEMAN_MAX_SESSIONS_PER_USER); + if (Number.isInteger(n) && n > 0) return n; + return Math.max(1, Math.floor(MAX_CONCURRENT_SESSIONS / 2)); +} diff --git a/src/types/index.ts b/src/types/index.ts index 5a5b0911..706f1fa6 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -69,3 +69,4 @@ export * from './orchestrator.js'; export * from './update.js'; export * from './workflow-run.js'; export * from './search.js'; +export * from './user.js'; diff --git a/src/types/user.ts b/src/types/user.ts new file mode 100644 index 00000000..904cbb9f --- /dev/null +++ b/src/types/user.ts @@ -0,0 +1,64 @@ +/** + * @fileoverview Multi-user mode types (opt-in `--multiuser`). + * + * Users live in `~/.codeman/users.json` (via `dataPath`, mode 0600). Each record + * carries a scrypt password hash with its own parameters so hashing cost can be + * raised later and old records rehashed on next login. `AuthUser` is the + * request-scoped identity decorated onto Fastify requests; in SINGLE-user mode a + * synthetic `{ username: 'admin', role: 'admin' }` is used so downstream code has + * one code path. See `src/user-store.ts` and `docs/multi-user-plan.md`. + */ + +export type UserRole = 'admin' | 'user'; + +/** Per-record scrypt parameters + salt/hash (all hex). */ +export interface PasswordHash { + algo: 'scrypt'; + N: number; + r: number; + p: number; + salt: string; + hash: string; +} + +export interface UserRecord { + /** Canonical lowercase slug; also the user's folder name under USER_SPACES_DIR. */ + username: string; + role: UserRole; + password: PasswordHash; + /** Disabled accounts fail auth closed but keep their space on disk. */ + disabled?: boolean; + /** Set by an admin reset; gates all API access until the user changes it. */ + mustChangePassword?: boolean; + /** + * Permission-mode grant (section 6.3). When false (the default for new users), + * the user's Claude sessions are forced to `--permission-mode auto`, shell mode + * and cron `launchCommand` are refused, and other CLIs' bypass flags are dropped. + */ + canBypassPermissions?: boolean; + createdAt: number; + lastLoginAt?: number; +} + +/** On-disk shape of `users.json`. */ +export interface UsersFile { + version: 1; + users: UserRecord[]; +} + +/** Request-scoped identity (decorated as `req.authUser`). */ +export interface AuthUser { + username: string; + role: UserRole; +} + +/** Admin-facing projection of a user: never carries the password hash. */ +export interface PublicUser { + username: string; + role: UserRole; + disabled: boolean; + mustChangePassword: boolean; + canBypassPermissions: boolean; + createdAt: number; + lastLoginAt?: number; +} diff --git a/src/user-store.ts b/src/user-store.ts new file mode 100644 index 00000000..444d8de9 --- /dev/null +++ b/src/user-store.ts @@ -0,0 +1,397 @@ +/** + * @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; + try { + const raw = await fs.readFile(dataPath(USERS_FILE), 'utf-8'); + const parsed = JSON.parse(raw) as Partial; + const users = Array.isArray(parsed.users) ? parsed.users : []; + cache = { users, ts: now }; + return users; + } catch { + cache = { users: [], ts: now }; + return []; + } +} + +async function writeUsers(users: UserRecord[]): Promise { + const dir = getDataDir(); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const finalPath = dataPath(USERS_FILE); + const tmpPath = `${finalPath}.tmp`; + const payload: UsersFile = { version: 1, users }; + await fs.writeFile(tmpPath, JSON.stringify(payload, null, 2), { mode: 0o600 }); + await fs.chmod(tmpPath, 0o600).catch(() => {}); + await fs.rename(tmpPath, finalPath); + cache = { users, ts: Date.now() }; +} + +export async function hasUsers(): Promise { + return (await readUsers()).length > 0; +} + +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'); + } + 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); + 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); + 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; failures are swallowed. */ +export async function touchLastLogin(username: string): Promise { + const norm = normalizeUsername(username); + try { + const users = await readUsers(true); + const record = users.find((u) => u.username === norm); + if (!record) return; + record.lastLoginAt = Date.now(); + await writeUsers(users); + } catch { + /* best-effort */ + } +} + +export async function deleteUser(username: string): Promise { + const norm = normalizeUsername(username); + 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' }; + +/** + * 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; + const user = await findUser(username); + if (!user) return fallback; + return resolveClaudeModeForUser(globalMode, user); +} diff --git a/test/user-store.test.ts b/test/user-store.test.ts new file mode 100644 index 00000000..ddd34967 --- /dev/null +++ b/test/user-store.test.ts @@ -0,0 +1,301 @@ +/** + * @fileoverview Unit tests for the multi-user store (src/user-store.ts). + * + * Pure helpers (hashing/verify/params-upgrade/username validation/6.3 resolvers) + * plus the IO layer against a per-test temp data dir (CODEMAN_DATA_DIR) so nothing + * touches the real ~/.codeman. No server, no tmux. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import fs from 'node:fs/promises'; +import { existsSync, statSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { + bootstrapInitialAdmin, + canRunPrivilegedCommands, + countEnabledAdmins, + createUser, + DEFAULT_SCRYPT_PARAMS, + deleteUser, + deleteUserSpace, + findUser, + generateOneTimePassword, + hashPassword, + hasUsers, + invalidateUsersCache, + isValidUsername, + needsRehash, + normalizeUsername, + readUsers, + resolveClaudeModeForUser, + setPassword, + toPublicUser, + touchLastLogin, + updateUser, + UserStoreError, + verifyPasswordHash, +} from '../src/user-store.js'; + +let tmpDir: string; +let spacesDir: string; +const savedEnv: Record = {}; + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codeman-users-')); + spacesDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codeman-spaces-')); + for (const k of [ + 'CODEMAN_DATA_DIR', + 'CODEMAN_USER_SPACES_DIR', + 'CODEMAN_MULTIUSER', + 'CODEMAN_MAX_USERS', + 'CODEMAN_USERNAME', + 'CODEMAN_PASSWORD', + ]) { + savedEnv[k] = process.env[k]; + } + process.env.CODEMAN_DATA_DIR = tmpDir; + process.env.CODEMAN_USER_SPACES_DIR = spacesDir; + delete process.env.CODEMAN_MAX_USERS; + invalidateUsersCache(); +}); + +afterEach(async () => { + for (const [k, v] of Object.entries(savedEnv)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + invalidateUsersCache(); + await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + await fs.rm(spacesDir, { recursive: true, force: true }).catch(() => {}); +}); + +describe('username validation', () => { + it('accepts valid slugs', () => { + for (const n of ['alice', 'bob99', 'a1', 'x_y-z', 'user-name_1']) { + expect(isValidUsername(n)).toBe(true); + } + }); + it('rejects invalid slugs', () => { + for (const n of [ + '', + 'a', + 'A', + '1', + '_leading', + '-leading', + 'has space', + 'has.dot', + 'a/b', + '..', + 'toolongxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + ]) { + expect(isValidUsername(n)).toBe(false); + } + }); + it('accepts mixed-case input by normalizing (case-insensitive usernames)', () => { + expect(isValidUsername('Alice')).toBe(true); + expect(normalizeUsername(' ALICE ')).toBe('alice'); + }); +}); + +describe('password hashing', () => { + it('round-trips a correct password and rejects a wrong one', async () => { + const h = await hashPassword('correct horse'); + expect(h.algo).toBe('scrypt'); + expect(h.salt).toMatch(/^[0-9a-f]+$/); + expect(await verifyPasswordHash('correct horse', h)).toBe(true); + expect(await verifyPasswordHash('wrong password', h)).toBe(false); + }); + it('produces a distinct salt each time', async () => { + const a = await hashPassword('same'); + const b = await hashPassword('same'); + expect(a.salt).not.toBe(b.salt); + expect(a.hash).not.toBe(b.hash); + }); + it('never throws on a malformed record', async () => { + expect(await verifyPasswordHash('x', { algo: 'scrypt', N: 1, r: 1, p: 1, salt: 'zz', hash: '' })).toBe(false); + // @ts-expect-error deliberately malformed + expect(await verifyPasswordHash('x', { algo: 'bogus' })).toBe(false); + }); + it('needsRehash detects weaker params', async () => { + const h = await hashPassword('pw', DEFAULT_SCRYPT_PARAMS); + expect(needsRehash(h)).toBe(false); + expect(needsRehash({ ...h, N: 1024 })).toBe(true); + expect(needsRehash({ ...h, algo: 'md5' as unknown as 'scrypt' })).toBe(true); + }); + it('generateOneTimePassword returns a >=8 char url-safe string', () => { + const pw = generateOneTimePassword(); + expect(pw.length).toBeGreaterThanOrEqual(8); + expect(pw).toMatch(/^[A-Za-z0-9_-]+$/); + }); +}); + +describe('resolveClaudeModeForUser (section 6.3)', () => { + it('admins are unrestricted', () => { + expect(resolveClaudeModeForUser('dangerously-skip-permissions', { role: 'admin' })).toBe( + 'dangerously-skip-permissions' + ); + }); + it('granted regular users keep bypass', () => { + expect(resolveClaudeModeForUser('dangerously-skip-permissions', { role: 'user', canBypassPermissions: true })).toBe( + 'dangerously-skip-permissions' + ); + }); + it('non-granted regular users downgrade skip -> auto', () => { + expect(resolveClaudeModeForUser('dangerously-skip-permissions', { role: 'user' })).toBe('auto'); + expect(resolveClaudeModeForUser(undefined, { role: 'user' })).toBe('auto'); + }); + it('non-granted regular users pass through modes already <= auto', () => { + expect(resolveClaudeModeForUser('auto', { role: 'user' })).toBe('auto'); + expect(resolveClaudeModeForUser('normal', { role: 'user' })).toBe('normal'); + expect(resolveClaudeModeForUser('allowedTools', { role: 'user' })).toBe('allowedTools'); + }); + it('canRunPrivilegedCommands follows the same grant', () => { + expect(canRunPrivilegedCommands({ role: 'admin' })).toBe(true); + expect(canRunPrivilegedCommands({ role: 'user', canBypassPermissions: true })).toBe(true); + expect(canRunPrivilegedCommands({ role: 'user' })).toBe(false); + }); +}); + +describe('user store IO', () => { + it('creates, reads back, and writes users.json at mode 0600 atomically', async () => { + expect(await hasUsers()).toBe(false); + const u = await createUser({ username: 'Alice', role: 'admin', password: 'password1' }); + expect(u.username).toBe('alice'); + expect(u.role).toBe('admin'); + expect(await hasUsers()).toBe(true); + + const file = path.join(tmpDir, 'users.json'); + expect(existsSync(file)).toBe(true); + // 0600 on POSIX + if (process.platform !== 'win32') { + expect(statSync(file).mode & 0o777).toBe(0o600); + } + // no leftover tmp file + expect(existsSync(file + '.tmp')).toBe(false); + + const found = await findUser('ALICE'); + expect(found?.username).toBe('alice'); + expect(toPublicUser(found!)).not.toHaveProperty('password'); + }); + + it('rejects duplicate usernames case-insensitively', async () => { + await createUser({ username: 'bob', role: 'user', password: 'password1' }); + await expect(createUser({ username: 'BOB', role: 'user', password: 'password2' })).rejects.toMatchObject({ + code: 'USER_EXISTS', + }); + }); + + it('rejects invalid username and short password', async () => { + await expect(createUser({ username: 'Bad Name', role: 'user', password: 'password1' })).rejects.toBeInstanceOf( + UserStoreError + ); + await expect(createUser({ username: 'good', role: 'user', password: 'short' })).rejects.toMatchObject({ + code: 'INVALID_INPUT', + }); + }); + + it('enforces MAX_USERS', async () => { + process.env.CODEMAN_MAX_USERS = '2'; + await createUser({ username: 'a1', role: 'admin', password: 'password1' }); + await createUser({ username: 'a2', role: 'user', password: 'password1' }); + await expect(createUser({ username: 'a3', role: 'user', password: 'password1' })).rejects.toMatchObject({ + code: 'INVALID_INPUT', + }); + }); + + it('setPassword changes the hash and can clear mustChangePassword', async () => { + await createUser({ username: 'carol', role: 'user', password: 'password1', mustChangePassword: true }); + const before = await findUser('carol'); + expect(before?.mustChangePassword).toBe(true); + await setPassword('carol', 'password2', { mustChangePassword: false }); + const after = await findUser('carol'); + expect(after?.mustChangePassword).toBe(false); + expect(await verifyPasswordHash('password2', after!.password)).toBe(true); + expect(await verifyPasswordHash('password1', after!.password)).toBe(false); + }); + + it('touchLastLogin records a timestamp', async () => { + await createUser({ username: 'dave', role: 'user', password: 'password1' }); + expect((await findUser('dave'))?.lastLoginAt).toBeUndefined(); + await touchLastLogin('dave'); + expect((await findUser('dave'))?.lastLoginAt).toBeTypeOf('number'); + }); +}); + +describe('last-admin invariants', () => { + it('cannot demote the last enabled admin', async () => { + await createUser({ username: 'root', role: 'admin', password: 'password1' }); + await createUser({ username: 'joe', role: 'user', password: 'password1' }); + expect(countEnabledAdmins(await readUsers(true))).toBe(1); + await expect(updateUser('root', { role: 'user' })).rejects.toMatchObject({ code: 'LAST_ADMIN' }); + await expect(updateUser('root', { disabled: true })).rejects.toMatchObject({ code: 'LAST_ADMIN' }); + }); + + it('cannot delete the last enabled admin', async () => { + await createUser({ username: 'root', role: 'admin', password: 'password1' }); + await expect(deleteUser('root')).rejects.toMatchObject({ code: 'LAST_ADMIN' }); + }); + + it('allows demote/delete when another admin remains', async () => { + await createUser({ username: 'root', role: 'admin', password: 'password1' }); + await createUser({ username: 'root2', role: 'admin', password: 'password1' }); + await expect(updateUser('root', { role: 'user' })).resolves.toMatchObject({ role: 'user' }); + await createUser({ username: 'root3', role: 'admin', password: 'password1' }); + await expect(deleteUser('root2')).resolves.toBeUndefined(); + }); + + it('updateUser toggles canBypassPermissions', async () => { + await createUser({ username: 'grantee', role: 'user', password: 'password1' }); + const updated = await updateUser('grantee', { canBypassPermissions: true }); + expect(updated.canBypassPermissions).toBe(true); + }); +}); + +describe('deleteUserSpace guards (section 8)', () => { + it('deletes a real space dir inside USER_SPACES_DIR', async () => { + const dir = path.join(spacesDir, 'ed', 'cases', 'proj'); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(path.join(spacesDir, 'ed', 'cases', 'proj', 'f.txt'), 'x'); + expect(existsSync(path.join(spacesDir, 'ed'))).toBe(true); + await deleteUserSpace('ed'); + expect(existsSync(path.join(spacesDir, 'ed'))).toBe(false); + }); + + it('is a no-op when the space does not exist', async () => { + await expect(deleteUserSpace('ghost')).resolves.toBeUndefined(); + }); + + it('refuses to delete a symlinked user space', async () => { + const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'codeman-outside-')); + await fs.symlink(outside, path.join(spacesDir, 'evil')); + await expect(deleteUserSpace('evil')).rejects.toMatchObject({ code: 'INVALID_INPUT' }); + // the symlink target still exists (was not followed + removed) + expect(existsSync(outside)).toBe(true); + await fs.rm(outside, { recursive: true, force: true }); + }); +}); + +describe('bootstrapInitialAdmin', () => { + it('creates the initial admin from env when no users exist', async () => { + process.env.CODEMAN_MULTIUSER = '1'; + process.env.CODEMAN_USERNAME = 'boss'; + process.env.CODEMAN_PASSWORD = 'password1'; + const r = await bootstrapInitialAdmin(); + expect(r).toMatchObject({ status: 'created', username: 'boss' }); + expect((await findUser('boss'))?.role).toBe('admin'); + }); + + it('reports missing-env when no users and no credentials', async () => { + delete process.env.CODEMAN_USERNAME; + delete process.env.CODEMAN_PASSWORD; + expect(await bootstrapInitialAdmin()).toMatchObject({ status: 'missing-env' }); + }); + + it('reports exists when users already present', async () => { + await createUser({ username: 'someone', role: 'admin', password: 'password1' }); + expect(await bootstrapInitialAdmin()).toMatchObject({ status: 'exists' }); + }); +});