feat(multiuser): phase 3, ownership threading + scoping

Threads per-user ownership through sessions, cases, cron, and the permission
policy. All scoping is a no-op in single-user mode (isMultiUserMode() guards).

Sessions
- Session.owner stamped at every create path from req.authUser / job.owner:
  POST /api/sessions, /api/run, /api/quick-start, ralph start, cron launch,
  plan generation. Round-trips through recovery (MuxSession.owner mirror, read
  muxSession.owner ?? savedState?.owner) and the mux layer.
- findSessionOrFail(ctx, id, req) now does a NOT_FOUND owner check (never 403, so
  other users' session existence is not leaked); wired at ~50 call sites.
- List endpoints filtered by owner: GET /api/sessions, /api/sessions/unified
  (live+persisted+lifecycle scoped, host-wide transcripts admin-only), cron jobs.

Permission policy (section 6.3)
- resolveClaudeModeForUsername wraps getClaudeModeConfig at every spawn site so a
  non-granted user is forced to --permission-mode auto (bypass -> auto), including
  recovery (or a reboot would un-downgrade). buildPromptArgs now respects the
  session's claudeMode, closing the one-shot (runPrompt) bypass hole.
- Shell mode and cron launchCommand require canBypassPermissions: 403 at
  POST /api/sessions, /api/quick-start create, cron job create, AND cron fire time
  (re-checked against the owner's current grant).

Cases
- resolveCasesDir(user): per-user ~/codeman-users/<name>/cases in multi-user, the
  shared ~/codeman-cases otherwise. All case CRUD + ralph + plan + quick-start
  resolve through it. resolveCasePath is owner-aware.
- GET /api/cases scoped per user (own folders; legacy linked cases admin-only;
  remote/docker cases owner-filtered). RemoteCase/DockerCase gain owner, stamped
  at link/quickcreate/import.
- Remote + Docker host CRUD is admin-only.
- Non-admin workingDir confinement (the linchpin): realpath must resolve inside the
  user's space, enforced at POST /api/sessions and /api/run BEFORE any disk write.

Limits
- sessionCapacityState / sessionCapacityMessage centralize the global + per-user
  cap (CODEMAN_MAX_SESSIONS_PER_USER, default global/2), replacing the 6 copy-pasted
  MAX_CONCURRENT_SESSIONS checks.

Tests: test/ownership-scoping.test.ts (case isolation, host-CRUD gate, workingDir +
shell gates, and the scoping helpers). Deferred to phase 4: WS owner gate, SSE
fan-out filtering, file-route preview/thumbnail helper scoping, push routing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-07-20 04:02:46 +02:00
parent 4d8857f72a
commit 453605a58f
19 changed files with 680 additions and 164 deletions
+106 -4
View File
@@ -6,13 +6,14 @@
*/
import { join, resolve, relative, isAbsolute } from 'node:path';
import { realpathSync } from 'node:fs';
import { realpathSync, existsSync, mkdirSync } from 'node:fs';
import fs from 'node:fs/promises';
import { homedir } from 'node:os';
import type { z } from 'zod';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { Session } from '../session.js';
import { ApiErrorCode, createErrorResponse, type AuthUser } from '../types.js';
import { MAX_CONCURRENT_SESSIONS } from '../config/map-limits.js';
import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js';
import { SseEvent } from './sse-events.js';
import type { SessionPort } from './ports/session-port.js';
@@ -20,7 +21,7 @@ import type { EventPort } from './ports/event-port.js';
import type { AuthSessionRecord } from './ports/auth-port.js';
import type { StaleExpirationMap } from '../utils/index.js';
import { dataPath } from '../config/instance.js';
import { isMultiUserMode } from '../config/multiuser.js';
import { isMultiUserMode, maxSessionsPerUser, userCasesDir } from '../config/multiuser.js';
import { SYNTHETIC_ADMIN } from '../user-store.js';
// Shared path constants used across route modules. CASES_DIR (project folders)
@@ -104,17 +105,118 @@ export function canAccessOwned(user: AuthUser, owner: string | undefined): boole
return !!owner && owner === user.username;
}
/**
* The owner to stamp on a resource created by this request: the requesting user in
* multi-user mode, or undefined in single-user (so state stays owner-free and the
* flag can be removed later without leaving stray owners).
*/
export function ownerFor(req: FastifyRequest): string | undefined {
return isMultiUserMode() ? getAuthUser(req).username : undefined;
}
/**
* The cases directory for a request/user: the shared ~/codeman-cases in single-user
* mode, or the per-user ~/codeman-users/<username>/cases in multi-user (created
* lazily). Admins are NOT auto-scoped here — an admin acting on a specific user's
* case resolves through the owner-aware case resolver instead.
*/
export function resolveCasesDir(user?: AuthUser): string {
if (!isMultiUserMode() || !user) return CASES_DIR;
const dir = userCasesDir(user.username);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
return dir;
}
/**
* Realpath-confine a non-admin's requested working directory to their own case
* space in multi-user mode. Returns true if allowed. Admins and single-user mode
* are unrestricted. The path need not exist yet (checked against its nearest
* existing ancestor) so newly-created case dirs pass. This is the load-bearing
* rule (plan 6.2/14.7): every file-serving surface downstream trusts workingDir.
*/
export function isWorkingDirAllowed(user: AuthUser, workingDir: string): boolean {
if (!isMultiUserMode() || user.role === 'admin') return true;
const base = userCasesDir(user.username);
// Resolve the deepest existing ancestor to defeat symlink escapes without
// requiring the leaf to exist yet.
const resolveExisting = (p: string): string => {
let cur = resolve(p);
// walk up until an existing path is found
for (;;) {
try {
return realpathSync(cur);
} catch {
const parent = resolve(cur, '..');
if (parent === cur) return cur;
cur = parent;
}
}
};
let realBase: string;
try {
realBase = realpathSync(base);
} catch {
// base does not exist yet — create it so confinement has a stable anchor
mkdirSync(base, { recursive: true });
realBase = realpathSync(base);
}
const realTarget = resolveExisting(workingDir);
if (realTarget === realBase) return true;
const rel = relative(realBase, realTarget);
return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
}
/** Whether the caller is an admin (or single-user mode, where the sole user is admin). */
export function isAdmin(req: FastifyRequest): boolean {
return !isMultiUserMode() || getAuthUser(req).role === 'admin';
}
/**
* First line of admin-only handlers: 403 FORBIDDEN + returns false when the caller
* is not an admin. Always true in single-user mode (the sole user is the admin).
*/
export function requireAdmin(req: FastifyRequest, reply: FastifyReply): boolean {
if (!isMultiUserMode()) return true;
if (getAuthUser(req).role === 'admin') return true;
if (isAdmin(req)) return true;
reply.code(403).send(createErrorResponse(ApiErrorCode.FORBIDDEN));
return false;
}
/**
* Session-capacity check, centralized so the global cap AND the per-user cap are
* enforced everywhere a session is created (the check was copy-pasted at 6 sites).
* Pure: takes the sessions Map so it composes with ctx.sessions / this.sessions /
* this.deps.sessions callers. Per-user cap only applies in multi-user mode.
*/
export function sessionCapacityState(
sessions: ReadonlyMap<string, Session>,
owner?: string
): { atGlobalCap: boolean; atUserCap: boolean } {
const atGlobalCap = sessions.size >= MAX_CONCURRENT_SESSIONS;
let atUserCap = false;
if (isMultiUserMode() && owner) {
let count = 0;
for (const s of sessions.values()) if (s.owner === owner) count++;
atUserCap = count >= maxSessionsPerUser();
}
return { atGlobalCap, atUserCap };
}
/**
* Route sugar: the human-readable error message when at capacity, else null. The
* caller wraps it in createErrorResponse with its own error code (OPERATION_FAILED
* vs SESSION_BUSY, matching the pre-existing per-route codes).
*/
export function sessionCapacityMessage(sessions: ReadonlyMap<string, Session>, owner?: string): string | null {
const { atGlobalCap, atUserCap } = sessionCapacityState(sessions, owner);
if (atGlobalCap) {
return `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached. Delete some sessions first.`;
}
if (atUserCap) {
return `Your session limit (${maxSessionsPerUser()}) reached. Delete some of your sessions first.`;
}
return null;
}
/**
* Revoke every cookie session belonging to a user (optionally keeping one token,
* e.g. the caller's own during a self-service password change). Returns the count.