fix(multi-user): make server-wide settings writes admin-only

In multi-user mode any signed-in non-admin could rewrite the server-wide
~/.codeman/settings.json through PUT /api/settings (feature switches, the
plan-usage collection switch, run mode, respawn presets, the tunnel), although
docs/multi-user-plan.md makes settings writes admin-only.

PUT /api/settings now answers 403 FORBIDDEN for a non-admin before anything is
parsed, written, toggled or started. The other request paths that write a
server-wide setting get the same gate: PUT /api/execution/model-config (the
modelConfig key), PUT /api/config (state.json app config) and
PUT /api/cases/order (the one global caseOrder list). Quick-start and
ralph-loop start no longer record a non-admin's lastUsedCase in settings.json,
where every user could read it. GET /api/settings stays readable by everyone;
single-user mode is unchanged (requireAdmin short-circuits there).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-16 12:06:05 +02:00
1 parent b4aed4e485
commit df0ece6fb4
4 files changed
+64 -34

No files matched your search

+8 -1
View File
@@ -60,6 +60,7 @@ import {
isAdmin,
isWorkingDirAllowed,
ownerFor,
requireAdmin,
resolveCasesDir,
SETTINGS_PATH,
validatePathWithinBase,
@@ -1665,7 +1666,13 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
// ========== Reorder Cases ==========
app.put('/api/cases/order', async (req): Promise<ApiResponse<{ order: string[] }>> => {
app.put('/api/cases/order', async (req, reply): Promise<ApiResponse<{ order: string[] }> | undefined> => {
// `caseOrder` is ONE list in the server-wide settings.json, read by every user's
// GET /api/cases, so a non-admin's reorder would replace everyone's order (and
// publish their case names to every user through GET /api/settings). Admin-only in
// multi-user mode, like PUT /api/settings; a per-user order would need per-user
// storage. No-op in single-user mode.
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
const { order } = parseBody(CaseOrderSchema, req.body, 'Invalid order data');
try {
+17 -11
View File
@@ -16,6 +16,7 @@ import { SseEvent } from '../sse-events.js';
import {
autoConfigureRalph,
getAuthUser,
isAdmin,
ownerFor,
resolveCasesDir,
sessionCapacityMessage,
@@ -463,20 +464,25 @@ export function registerRalphRoutes(
});
}
// Save lastUsedCase
try {
let settings: Record<string, unknown> = {};
// Save lastUsedCase. It is a key of the server-wide settings.json, readable by every
// user through GET /api/settings, so a non-admin's case name is not written there in
// multi-user mode (writes are admin-only: PUT /api/settings). isAdmin is always true
// in single-user mode.
if (isAdmin(req)) {
try {
settings = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf-8'));
let settings: Record<string, unknown> = {};
try {
settings = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf-8'));
} catch {
/* ignore */
}
settings.lastUsedCase = caseName;
const dir = dirname(SETTINGS_PATH);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
fs.writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2)).catch(() => {}); // Ignore - persisting lastUsedCase is non-critical
} catch {
/* ignore */
/* non-critical */
}
settings.lastUsedCase = caseName;
const dir = dirname(SETTINGS_PATH);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
fs.writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2)).catch(() => {}); // Ignore - persisting lastUsedCase is non-critical
} catch {
/* non-critical */
}
const sessionId = session.id;
+24 -19
View File
@@ -4477,28 +4477,33 @@ export function registerSessionRoutes(
).catch(() => {});
}
// Save lastUsedCase to settings for TUI/web sync
try {
const settingsFilePath = SETTINGS_PATH;
let settings: Record<string, unknown> = {};
// Save lastUsedCase to settings for TUI/web sync. It is a key of the server-wide
// settings.json, readable by every user through GET /api/settings, so a non-admin's
// case name is not written there in multi-user mode (writes are admin-only, like
// PUT /api/settings). isAdmin is always true in single-user mode.
if (isAdmin(req)) {
try {
settings = JSON.parse(await fs.readFile(settingsFilePath, 'utf-8'));
const settingsFilePath = SETTINGS_PATH;
let settings: Record<string, unknown> = {};
try {
settings = JSON.parse(await fs.readFile(settingsFilePath, 'utf-8'));
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
}
settings.lastUsedCase = caseName;
const dir = dirname(settingsFilePath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
// Use async write to avoid blocking event loop
fs.writeFile(settingsFilePath, JSON.stringify(settings, null, 2)).catch((err) => {
// Non-critical but log for debugging
console.warn('[Server] Failed to save settings (lastUsedCase):', err);
});
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
}
settings.lastUsedCase = caseName;
const dir = dirname(settingsFilePath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
// Use async write to avoid blocking event loop
fs.writeFile(settingsFilePath, JSON.stringify(settings, null, 2)).catch((err) => {
// Non-critical but log for debugging
console.warn('[Server] Failed to save settings (lastUsedCase):', err);
});
} catch (err) {
// Non-critical but log for debugging
console.warn('[Server] Failed to prepare settings update:', err);
console.warn('[Server] Failed to prepare settings update:', err);
}
}
return {
+15 -3
View File
@@ -869,7 +869,10 @@ export function registerSystemRoutes(
return { config: ctx.store.getConfig() };
});
app.put('/api/config', async (req) => {
app.put('/api/config', async (req, reply) => {
// Process-wide app config in state.json (poll interval, timeouts, the session cap,
// respawn defaults): admin-only in multi-user mode, like PUT /api/settings.
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
const configData = parseBody(ConfigUpdateSchema, req.body, 'Invalid config');
ctx.store.setConfig(configData as Partial<ReturnType<typeof ctx.store.getConfig>>);
return { config: ctx.store.getConfig() };
@@ -953,7 +956,13 @@ export function registerSystemRoutes(
return readJsonConfig(SETTINGS_PATH, 'settings', {});
});
app.put('/api/settings', async (req) => {
app.put('/api/settings', async (req, reply) => {
// Multi-user mode: settings.json is ONE file for the whole server (feature switches,
// the plan-usage collection switch, run mode, respawn presets, the tunnel), so a write
// is admin-only, like every other server-wide setting (docs/multi-user-plan.md,
// Settings row). Refused FIRST: nothing is parsed, written, toggled or started for a
// non-admin. GET above stays readable by everyone. No-op in single-user mode.
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
const settings = parseBody(SettingsUpdateSchema, req.body, 'Invalid settings') as Record<string, unknown>;
// COD-55: enabling the Cloudflare tunnel publishes the whole app (full terminal
@@ -1092,7 +1101,10 @@ export function registerSystemRoutes(
return { success: true, data: settings.modelConfig || {} };
});
app.put('/api/execution/model-config', async (req) => {
app.put('/api/execution/model-config', async (req, reply) => {
// The `modelConfig` key of the server-wide settings.json (cron's and the plan
// orchestrator's default model): admin-only in multi-user mode, like PUT /api/settings.
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
const modelConfig = parseBody(ModelConfigUpdateSchema, req.body, 'Invalid model config') as Record<string, unknown>;
try {