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
+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;