fix(multiuser): close cross-user web-layer scoping holes found in review

The opt-in multi-user feature's only enforcement is web-layer scoping
(all sessions share one OS account). An adversarial review found 8 critical
+ 7 high cross-user holes that defeated it, plus mediums; all fixed here.
Single-user (flag-off) behavior stays byte-identical apart from documented
consistency deltas.

Ownership / confinement:
- DELETE /api/sessions (bulk) + /:id now owner-scope / findSessionOrFail
- quick-start, cron (create+fire), scheduled runs confine workingDir to the
  owner's space; case link/docker-link/docker-import confine the host path
- resolveCasePath no longer resolves linked cases for non-admins; foreign
  remote/docker cases are skipped (fall through to the caller's own local case)
- history, subagents/workflows, mux-sessions, orchestrator, cron run-history,
  away-digest, and remote/docker host reads are owner- or admin-scoped

Permission policy (section 6.3):
- non-granted users are downgraded at every spawn site incl. legacy
  /api/scheduled, PlanOrchestrator one-shots, remote launch, and the cron-fire
  gemini/codex bypass switches; resolveClaudeModeForUsername now fails closed

Auth / store:
- verify-first login throttle (a correct password is never locked out),
  /ws terminal subject to the change-password lockbox, cookie fast-path
  re-validates identity live, role/grant changes revoke sessions, admin delete
  runs the last-admin guard before any teardown
- users.json: distinguish missing (ENOENT) from corrupt/unreadable so a bad
  read can't overwrite all accounts; unique per-process temp write path

Event streams:
- debounced session:updated + batched task:updated, clipboard, and push
  notifications route by owner (fail closed); getLightState hides machine-wide
  globalStats from non-admins

Tests: two suites updated to assert the fixed (secure) behavior. tsc, eslint,
and test:ci all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-07-20 12:33:12 +02:00
parent c3b0dc345b
commit ccb3afc9ee
29 changed files with 757 additions and 230 deletions
+35 -14
View File
@@ -174,27 +174,46 @@ export function invalidateUsersCache(): void {
export async function readUsers(force = false): Promise<UserRecord[]> {
const now = Date.now();
if (!force && cache && now - cache.ts < CACHE_TTL_MS) return cache.users;
let raw: string;
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 [];
raw = await fs.readFile(dataPath(USERS_FILE), 'utf-8');
} catch (err) {
// ENOENT is the ONLY legitimately-empty store (first boot). Any other read
// error (EIO/EACCES/EMFILE/EBUSY) is a transient/permission failure, NOT an
// empty store — do NOT cache [] and do NOT let it look empty, or a following
// createUser/bootstrap would overwrite users.json and destroy every account.
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
cache = { users: [], ts: now };
return [];
}
throw err;
}
// A present-but-corrupt file (invalid JSON) must also fail loud rather than
// read as empty, so mutators/bootstrap abort instead of clobbering it.
const parsed = JSON.parse(raw) as Partial<UsersFile>;
const users = Array.isArray(parsed.users) ? parsed.users : [];
cache = { users, ts: now };
return users;
}
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`;
// Unique per-writer tmp name (pid + random) so the CLI (`codeman users …`) and
// the live server — designed to write this file concurrently across processes —
// never share a single `users.json.tmp` inode and tear each other's payload.
// Matches the state-store.ts / self-update.ts convention.
const tmpPath = `${finalPath}.${process.pid}.${randomBytes(6).toString('hex')}.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);
try {
await fs.writeFile(tmpPath, JSON.stringify(payload, null, 2), { mode: 0o600 });
await fs.chmod(tmpPath, 0o600).catch(() => {});
await fs.rename(tmpPath, finalPath);
} catch (err) {
await fs.unlink(tmpPath).catch(() => {});
throw err;
}
cache = { users, ts: Date.now() };
}
@@ -461,7 +480,9 @@ export async function resolveClaudeModeForUsername(
): Promise<ClaudeMode> {
const fallback: ClaudeMode = globalMode ?? 'dangerously-skip-permissions';
if (!isMultiUserMode() || !username) return fallback;
// Fail closed: an unknown/deleted owner in multi-user mode is treated as a
// non-granted regular user so a stale-owned spawn (e.g. an orphaned cron job)
// is downgraded to `auto` rather than inheriting the global bypass.
const user = await findUser(username);
if (!user) return fallback;
return resolveClaudeModeForUser(globalMode, user);
return resolveClaudeModeForUser(globalMode, user ?? { role: 'user' });
}