feat(multiuser): phase 5a, admin user-management API

- routes/admin-routes.ts: GET/POST /api/admin/users, PATCH/DELETE
  /api/admin/users/:username, reset-password, logout. Multi-user only (404
  otherwise), requireAdmin, last-admin invariants, one-time-password on create /
  reset (returned once + mustChangePassword), disable/reset/delete revoke cookie
  sessions, delete kills the user's live sessions first (normal teardown) and can
  delete their space (guarded). Per-user stats (live/active sessions, case count).
- web/admin-audit.ts: append-only ~/.codeman/admin-audit.jsonl (timestamp, acting
  admin, action, target, IP) for every user-management action.
- SSE admin:usersChanged + auth:passwordChangeRequired (sse-events.ts + constants.js).

fix(user-store): serialize users.json read-modify-write

touchLastLogin fires on every Basic auth (fire-and-forget) and was racing route
writes (create/update), clobbering records — a real corruption bug surfaced by
the admin tests. All mutators now run under a single write lock, and
touchLastLogin is throttled to once/minute per user to bound disk churn.

Tests: test/admin-routes.test.ts (8, live server) + user-store lock verified by
the existing user-store suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-07-20 04:26:44 +02:00
parent 2a06f7a5a8
commit dafe3de185
8 changed files with 479 additions and 62 deletions
+93 -62
View File
@@ -198,6 +198,22 @@ async function writeUsers(users: UserRecord[]): Promise<void> {
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<unknown> = Promise.resolve();
function withUsersLock<T>(fn: () => Promise<T>): Promise<T> {
const run = mutateChain.then(fn, fn);
mutateChain = run.then(
() => undefined,
() => undefined
);
return run;
}
export async function hasUsers(): Promise<boolean> {
return (await readUsers()).length > 0;
}
@@ -259,25 +275,27 @@ export async function createUser(opts: CreateUserOptions): Promise<UserRecord> {
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;
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. */
@@ -290,13 +308,15 @@ export async function setPassword(
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;
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 {
@@ -308,39 +328,48 @@ export interface UpdateUserPatch {
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');
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');
}
// 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;
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. */
/**
* 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<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);
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 */
}
@@ -348,16 +377,18 @@ export async function touchLastLogin(username: string): Promise<void> {
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);
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);
});
}
/**
+28
View File
@@ -0,0 +1,28 @@
/**
* @fileoverview Append-only admin audit log (~/.codeman/admin-audit.jsonl).
*
* Every user-management action (create/patch/reset/delete/logout/assign) writes one
* JSON line: timestamp, acting admin, action, target, request IP. Same idiom as
* session-lifecycle.jsonl. Best-effort: a write failure never blocks the action.
*/
import fs from 'node:fs/promises';
import { dataPath } from '../config/instance.js';
export interface AdminAuditEntry {
ts: number;
admin: string;
action: string;
target?: string;
ip?: string;
detail?: Record<string, unknown>;
}
export async function appendAdminAudit(entry: Omit<AdminAuditEntry, 'ts'>): Promise<void> {
try {
const line = JSON.stringify({ ts: Date.now(), ...entry }) + '\n';
await fs.appendFile(dataPath('admin-audit.jsonl'), line, { mode: 0o600 });
} catch {
/* best-effort audit; never block the action */
}
}
+3
View File
@@ -481,6 +481,9 @@ const SSE_EVENTS = {
DOCKER_IMAGE_BUILD_PROGRESS: 'docker:imageBuildProgress',
DOCKER_IMAGE_BUILD_COMPLETE: 'docker:imageBuildComplete',
DOCKER_IMAGE_BUILD_FAILED: 'docker:imageBuildFailed',
// Multi-user (admin-only / targeted)
ADMIN_USERS_CHANGED: 'admin:usersChanged',
AUTH_PASSWORD_CHANGE_REQUIRED: 'auth:passwordChangeRequired',
};
// ═══════════════════════════════════════════════════════════════
+196
View File
@@ -0,0 +1,196 @@
/**
* @fileoverview Admin user-management routes (multi-user mode only).
*
* All handlers: 404 unless multi-user mode is active, requireAdmin, and audit-logged
* to ~/.codeman/admin-audit.jsonl. Endpoints (docs/multi-user-plan.md section 8):
* GET /api/admin/users
* POST /api/admin/users
* PATCH /api/admin/users/:username
* POST /api/admin/users/:username/reset-password
* POST /api/admin/users/:username/logout
* DELETE /api/admin/users/:username
*
* Self-service GET /api/me + POST /api/me/password live in me-routes.ts.
*/
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { readdirSync } from 'node:fs';
import { ApiErrorCode, createErrorResponse } from '../../types.js';
import { isMultiUserMode, userCasesDir } from '../../config/multiuser.js';
import {
createUser,
deleteUser,
deleteUserSpace,
findUser,
generateOneTimePassword,
readUsers,
setPassword,
toPublicUser,
updateUser,
UserStoreError,
} from '../../user-store.js';
import { getAuthUser, requireAdmin, revokeUserSessions } from '../route-helpers.js';
import { appendAdminAudit } from '../admin-audit.js';
import { SseEvent } from '../sse-events.js';
import type { AuthPort } from '../ports/auth-port.js';
import type { SessionPort } from '../ports/session-port.js';
import type { EventPort } from '../ports/event-port.js';
const CreateUserSchema = z.object({
username: z.string().min(1).max(64),
role: z.enum(['admin', 'user']).default('user'),
password: z.string().min(8).max(1024).optional(),
canBypassPermissions: z.boolean().optional(),
});
const UpdateUserSchema = z.object({
role: z.enum(['admin', 'user']).optional(),
disabled: z.boolean().optional(),
canBypassPermissions: z.boolean().optional(),
});
const DeleteUserSchema = z.object({ deleteSpace: z.boolean().optional() });
/** Map a UserStoreError's code onto the API error code + status. */
function storeError(reply: FastifyReply, err: unknown): ReturnType<typeof createErrorResponse> {
if (err instanceof UserStoreError) {
const code = ApiErrorCode[err.code as keyof typeof ApiErrorCode] ?? ApiErrorCode.INVALID_INPUT;
reply.code(
err.code === 'USER_EXISTS' || err.code === 'LAST_ADMIN' ? 409 : err.code === 'USER_NOT_FOUND' ? 404 : 400
);
return createErrorResponse(code, err.message);
}
reply.code(500);
return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, err instanceof Error ? err.message : 'error');
}
export function registerAdminRoutes(app: FastifyInstance, ctx: SessionPort & AuthPort & EventPort): void {
// Gate: admin routes exist only in multi-user mode, and only for admins.
const gate = (req: FastifyRequest, reply: FastifyReply): boolean => {
if (!isMultiUserMode()) {
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Not found'));
return false;
}
return requireAdmin(req, reply);
};
const audit = (req: FastifyRequest, action: string, target?: string, detail?: Record<string, unknown>) =>
void appendAdminAudit({ admin: getAuthUser(req).username, action, target, ip: req.ip, detail });
// Count a user's live sessions + active cookie sessions + case folders.
const statsFor = (username: string) => {
let liveSessions = 0;
for (const s of ctx.sessions.values()) if (s.owner === username) liveSessions++;
let activeSessions = 0;
if (ctx.authSessions) for (const [, rec] of ctx.authSessions) if (rec.username === username) activeSessions++;
let caseCount = 0;
try {
caseCount = readdirSync(userCasesDir(username), { withFileTypes: true }).filter((e) => e.isDirectory()).length;
} catch {
/* no cases dir yet */
}
return { liveSessions, activeSessions, caseCount };
};
app.get('/api/admin/users', async (req, reply) => {
if (!gate(req, reply)) return;
const users = await readUsers(true);
return {
success: true,
data: users.map((u) => ({ ...toPublicUser(u), stats: statsFor(u.username) })),
};
});
app.post('/api/admin/users', async (req, reply) => {
if (!gate(req, reply)) return;
const parsed = CreateUserSchema.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, parsed.error.issues[0]?.message ?? 'Invalid input');
}
// No password given: generate a one-time password, returned ONCE, force change.
const oneTime = parsed.data.password ? undefined : generateOneTimePassword();
try {
const user = await createUser({
username: parsed.data.username,
role: parsed.data.role,
password: parsed.data.password ?? oneTime!,
canBypassPermissions: parsed.data.canBypassPermissions,
mustChangePassword: !parsed.data.password,
});
audit(req, 'user.create', user.username, { role: user.role });
ctx.broadcast(SseEvent.AdminUsersChanged, {});
return { success: true, data: { user: toPublicUser(user), oneTimePassword: oneTime } };
} catch (err) {
return storeError(reply, err);
}
});
app.patch('/api/admin/users/:username', async (req, reply) => {
if (!gate(req, reply)) return;
const { username } = req.params as { username: string };
const parsed = UpdateUserSchema.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, parsed.error.issues[0]?.message ?? 'Invalid input');
}
try {
const user = await updateUser(username, parsed.data);
// Disabling revokes the user's cookie sessions.
if (parsed.data.disabled) revokeUserSessions(ctx.authSessions, username);
audit(req, 'user.update', user.username, parsed.data);
ctx.broadcast(SseEvent.AdminUsersChanged, {});
return { success: true, data: { user: toPublicUser(user) } };
} catch (err) {
return storeError(reply, err);
}
});
app.post('/api/admin/users/:username/reset-password', async (req, reply) => {
if (!gate(req, reply)) return;
const { username } = req.params as { username: string };
if (!(await findUser(username))) {
reply.code(404);
return createErrorResponse(ApiErrorCode.USER_NOT_FOUND, 'No such user');
}
const oneTime = generateOneTimePassword();
try {
await setPassword(username, oneTime, { mustChangePassword: true });
revokeUserSessions(ctx.authSessions, username);
audit(req, 'user.reset-password', username);
ctx.broadcast(SseEvent.AdminUsersChanged, {});
return { success: true, data: { oneTimePassword: oneTime } };
} catch (err) {
return storeError(reply, err);
}
});
app.post('/api/admin/users/:username/logout', async (req, reply) => {
if (!gate(req, reply)) return;
const { username } = req.params as { username: string };
const revoked = revokeUserSessions(ctx.authSessions, username);
audit(req, 'user.logout', username, { revoked });
return { success: true, data: { revoked } };
});
app.delete('/api/admin/users/:username', async (req, reply) => {
if (!gate(req, reply)) return;
const { username } = req.params as { username: string };
const parsed = DeleteUserSchema.safeParse(req.body ?? {});
const deleteSpace = parsed.success ? parsed.data.deleteSpace : false;
try {
// Kill the user's live sessions first (normal kill flow, incl. docker/remote
// teardown), before removing the record.
const owned = [...ctx.sessions.values()].filter((s) => s.owner === username).map((s) => s.id);
for (const id of owned) {
await ctx.cleanupSession(id, true, 'admin_delete_user').catch(() => {});
}
revokeUserSessions(ctx.authSessions, username);
await deleteUser(username); // throws LAST_ADMIN / USER_NOT_FOUND
if (deleteSpace) await deleteUserSpace(username);
audit(req, 'user.delete', username, { deleteSpace, killedSessions: owned.length });
ctx.broadcast(SseEvent.AdminUsersChanged, {});
return { success: true, data: { username, deletedSpace: !!deleteSpace } };
} catch (err) {
return storeError(reply, err);
}
});
}
+1
View File
@@ -20,4 +20,5 @@ export { registerOrchestratorRoutes } from './orchestrator-routes.js';
export { registerClipboardRoutes } from './clipboard-routes.js';
export { registerSearchRoutes } from './search-routes.js';
export { registerMeRoutes } from './me-routes.js';
export { registerAdminRoutes } from './admin-routes.js';
export { registerWsRoutes } from './ws-routes.js';
+2
View File
@@ -158,6 +158,7 @@ import {
registerOrchestratorRoutes,
registerCronRoutes,
registerMeRoutes,
registerAdminRoutes,
registerWsRoutes,
} from './routes/index.js';
import { CronService } from '../cron/cron-service.js';
@@ -906,6 +907,7 @@ export class WebServer extends EventEmitter {
registerClipboardRoutes(this.app, ctx);
registerSearchRoutes(this.app, ctx);
registerMeRoutes(this.app, ctx);
registerAdminRoutes(this.app, ctx);
registerOrchestratorRoutes(this.app, ctx);
// Cron: build the service from the same context, recompute
+9
View File
@@ -386,6 +386,13 @@ export const DockerImageBuildComplete = 'docker:imageBuildComplete' as const;
/** The agent base image build failed. */
export const DockerImageBuildFailed = 'docker:imageBuildFailed' as const;
// ─── Multi-user (admin-only / targeted) ──────────────────────────────────────
/** The user roster changed (admin-only); the Users panel re-fetches. */
export const AdminUsersChanged = 'admin:usersChanged' as const;
/** A user must change their password (targeted); the frontend shows the modal. */
export const AuthPasswordChangeRequired = 'auth:passwordChangeRequired' as const;
// ─── Namespace Re-export ─────────────────────────────────────────────────────
/**
@@ -576,4 +583,6 @@ export const SseEvent = {
DockerImageBuildProgress,
DockerImageBuildComplete,
DockerImageBuildFailed,
AdminUsersChanged,
AuthPasswordChangeRequired,
} as const;