mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
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:
1 parent
b4aed4e485
commit
df0ece6fb4
4 files changed
+64
-34
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user