mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 16:39:42 +02:00
feat(multiuser): phase 1, user store, mode plumbing, CLI
Opt-in multi-user foundation (off by default; no behavior change without CODEMAN_MULTIUSER/--multiuser): - src/config/multiuser.ts: isMultiUserMode(), getUserSpacesDir()/userCasesDir(), maxUsers(), maxSessionsPerUser() (per-user fairness cap = global/2). - src/types/user.ts: UserRecord/PasswordHash/AuthUser/PublicUser/UserRole. - src/user-store.ts: ~/.codeman/users.json (atomic tmp+rename, mode 0600, short TTL cache). scrypt hashing with per-record params + timingSafeEqual verify plus rehash detection; createUser/setPassword/updateUser/deleteUser with last-admin invariants; guarded deleteUserSpace (symlink + realpath confinement, section 8); pure section-6.3 resolvers (resolveClaudeModeForUser downgrades bypass to auto for non-granted users; canRunPrivilegedCommands); bootstrapInitialAdmin. - src/cli.ts: "codeman users add|passwd|list|rm" (hidden prompt or --password-stdin) operating directly on users.json; a --multiuser flag on the web command. Tests: test/user-store.test.ts (29 tests: hashing/verify/rehash, username validation, atomic 0600 write, last-admin invariants, 6.3 resolvers, delete-space guards, bootstrap). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+166
@@ -584,7 +584,11 @@ program
|
|||||||
'--allow-unauthenticated-network',
|
'--allow-unauthenticated-network',
|
||||||
'Allow non-loopback web access without CODEMAN_PASSWORD (dangerous; terminal control is exposed)'
|
'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) => {
|
.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 { startWebServer } = await import('./web/server.js');
|
||||||
const host = options.host;
|
const host = options.host;
|
||||||
const port = parseInt(options.port, 10);
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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 <name>')
|
||||||
|
.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 <name>')
|
||||||
|
.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 <name> --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 <name>')
|
||||||
|
.description('Delete a user')
|
||||||
|
.option('--delete-space', "Also delete the user's ~/codeman-users/<name> 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
|
program
|
||||||
.command('doctor')
|
.command('doctor')
|
||||||
.alias('check-deps')
|
.alias('check-deps')
|
||||||
|
|||||||
@@ -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: `<USER_SPACES_DIR>/<username>[/segments]`. */
|
||||||
|
export function userSpacePath(username: string, ...segments: string[]): string {
|
||||||
|
return join(getUserSpacesDir(), username, ...segments);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Absolute path to a user's cases dir: `<USER_SPACES_DIR>/<username>/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));
|
||||||
|
}
|
||||||
@@ -69,3 +69,4 @@ export * from './orchestrator.js';
|
|||||||
export * from './update.js';
|
export * from './update.js';
|
||||||
export * from './workflow-run.js';
|
export * from './workflow-run.js';
|
||||||
export * from './search.js';
|
export * from './search.js';
|
||||||
|
export * from './user.js';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<Buffer>;
|
||||||
|
|
||||||
|
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<PasswordHash> {
|
||||||
|
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<boolean> {
|
||||||
|
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<UserRecord[]> {
|
||||||
|
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<UsersFile>;
|
||||||
|
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<void> {
|
||||||
|
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<boolean> {
|
||||||
|
return (await readUsers()).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findUser(username: string): Promise<UserRecord | undefined> {
|
||||||
|
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<UserRecord> {
|
||||||
|
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<UserRecord> {
|
||||||
|
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<UserRecord> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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 (`<USER_SPACES_DIR>/<username>`) 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<void> {
|
||||||
|
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<ClaudeMode> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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<string, string | undefined> = {};
|
||||||
|
|
||||||
|
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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user