mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 16:39:42 +02:00
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:
+106
-4
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user