mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 15:09:42 +02:00
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:
+93
-62
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user