diff --git a/.env.example b/.env.example index 9cfa0a2e..c3f62d94 100644 --- a/.env.example +++ b/.env.example @@ -208,6 +208,20 @@ DB_PASSWORD=pedscribe_secret_change_me # ── Mail ──────────────────────────────────────────────────────────────────── # SMTP_SECURE=false # true for implicit TLS (port 465) +# ── Admin lockdown ────────────────────────────────────────────────────────── +# With several admins, everything in the admin panel is editable by all of +# them. Set this and configuration becomes read-only in the panel and refused +# by the server: prompts, model policy, retrieval limits and budgets, TTS/STT, +# embeddings, SMTP and email templates. Day-to-day operation stays available — +# announcements, registration and invitations, feature flags, site details. +# A setting added later is locked until it is deliberately added to the +# editable list, rather than defaulting to open. +# +# It is deliberately an environment variable: a setting could be switched off +# by the very admin it restrains, so lifting it needs host access and a +# restart. Read access is unaffected — locked settings stay visible. +# ADMIN_LOCKDOWN=false + # ── Identity and limits ───────────────────────────────────────────────────── # SITE_NAME=Pediatric AI Scribe # API_RATE_LIMIT_MAX=200 # requests per window across /api diff --git a/public/js/admin.js b/public/js/admin.js index 4644d426..7ed48c36 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -1462,6 +1462,50 @@ initImageSettings(); } +// ============================================================ +// ADMIN LOCKDOWN (display) +// The server refuses locked writes regardless; this only stops an admin +// filling in a field that was never going to save. Settings stay visible, so +// the configuration can still be read. +// +// The state rides along on the invites response rather than a request of its +// own: the admin panel already makes enough calls on open. +// ============================================================ +{ + window.applyAdminLockdown = function(state) { + if (!state || !state.enabled) return; + var panel = document.getElementById('admin-tab'); + if (!panel || !panel.querySelector('.card')) return; + lockdownBanner(panel, state); + lockdownFields(panel, state); + }; + + function lockdownBanner(panel, state) { + if (document.getElementById('admin-lockdown-banner')) return; + var note = document.createElement('div'); + note.id = 'admin-lockdown-banner'; + note.style.cssText = 'margin:0 0 12px;padding:10px 14px;border:1px solid var(--amber);background:var(--amber-light);border-radius:8px;font-size:13px;color:var(--g800);'; + note.innerHTML = ' Admin lockdown is on. ' + + adminEscapeHtml(state.reason) + ' Settings are shown but cannot be changed here.'; + panel.insertBefore(note, panel.firstChild); + } + + // Everything inside the panel, except the controls that stay operational and + // the buttons that only read. + function lockdownFields(panel, state) { + var editable = ['admin-invite', 'btn-create-invite', 'cms-ann', 'announcement', + 'admin-users-search', 'registration']; + panel.querySelectorAll('input, select, textarea, button').forEach(function(el) { + var id = el.id || ''; + if (editable.some(function(prefix) { return id.indexOf(prefix) === 0; })) return; + var label = (el.textContent || '') + ' ' + id; + if (/search|test|discover|refresh|reload|retry|copy/i.test(label)) return; + el.disabled = true; + el.title = state.reason; + }); + } +} + // ============================================================ // ADMIN REGISTRATION INVITES // The code exists in readable form exactly once: in the response to creating @@ -1500,6 +1544,8 @@ initImageSettings(); var toggle = document.getElementById('admin-invite-only'); if (toggle) toggle.checked = !!data.inviteOnly; renderInvites(data.invites || []); + // Painted after the panel has rendered, from the same response. + if (typeof window.applyAdminLockdown === 'function') window.applyAdminLockdown(data.lockdown); }) .catch(function() {}); } diff --git a/src/routes/adminConfig.js b/src/routes/adminConfig.js index 30099f4e..0cfea4bd 100644 --- a/src/routes/adminConfig.js +++ b/src/routes/adminConfig.js @@ -19,6 +19,26 @@ var { getLiteLLMEmbeddingModels } = require('../utils/embeddings'); router.use(authMiddleware); +var lockdown = require('../utils/adminLockdown'); + +// One gate rather than a check in every write route, because that list grows +// and a new route added later would quietly miss it. Reads always pass — +// lockdown hides nothing, it only refuses changes. +// +// Under lockdown a write is refused unless it is day-to-day operation: +// - invitations, which is running the service, not configuring it; +// - the "test" endpoints, which send a probe and persist nothing; +// - /config/:key, which decides per key — some keys stay editable, and that +// route applies lockdown.isLocked() itself. +// Everything else — model policy, SMTP, prompts, resets — is configuration. +var OPERATIONAL_WRITE = /^\/invites(\/|$)|\/test(-email)?$|^\/config\/[^/]+$/; + +router.use(function(req, res, next) { + if (!lockdown.enabled() || req.method === 'GET' || req.method === 'HEAD') return next(); + if (OPERATIONAL_WRITE.test(req.path)) return next(); + return res.status(403).json({ error: lockdown.refusal(req.path.replace(/^\/config\//, '')) }); +}); + function liteLLMBaseUrl() { return (process.env.LITELLM_API_BASE || '').replace(/\/+$/, '').replace(/\/v1\/?$/, ''); } @@ -91,7 +111,7 @@ router.get('/config', async function(req, res) { rows.push({ key: dbKey, value: PROMPTS[key], updated_at: null }); } }); - res.json({ success: true, config: rows, conversationBudget: budget }); + res.json({ success: true, config: rows, conversationBudget: budget, lockdown: lockdown.state() }); } catch (e) { res.status(e.statusCode || 500).json({ error: 'Request failed' }); } }); @@ -680,7 +700,7 @@ var invites = require('../utils/registrationInvites'); router.get('/invites', async function(req, res) { try { - res.json({ success: true, invites: await invites.list(), inviteOnly: await invites.inviteOnly() }); + res.json({ success: true, invites: await invites.list(), inviteOnly: await invites.inviteOnly(), lockdown: lockdown.state() }); } catch (e) { return serverError(res, 'Invites list', e, 'Could not list invitations'); } }); @@ -884,6 +904,12 @@ router.put('/config/:key(*)', async function(req, res) { return res.status(400).json({ error: 'Unknown config key' }); } + // Some settings stay editable under lockdown; the rest are refused here, + // whatever the UI showed. + if (lockdown.isLocked(key)) { + return res.status(403).json({ error: lockdown.refusal(key) }); + } + // Model policy mutations must use the validated model endpoints. if (key.startsWith('models.')) return res.status(400).json({ error: 'Use the model configuration endpoints' }); if (key.startsWith('feature.') && !['true', 'false'].includes(String(value))) return res.status(400).json({ error: 'Feature value must be true or false' }); diff --git a/src/utils/adminLockdown.js b/src/utils/adminLockdown.js new file mode 100644 index 00000000..329773b8 --- /dev/null +++ b/src/utils/adminLockdown.js @@ -0,0 +1,79 @@ +// ============================================================ +// ADMIN LOCKDOWN +// With several admins, everything in the admin panel is editable by all of +// them — prompts, model policy, retrieval limits, SMTP. Lockdown separates +// "runs the service day to day" from "changes how the service behaves". +// +// It is an environment variable, deliberately, not a setting. A setting could +// be switched off by any admin, which would make it decoration. Only someone +// with access to the host and a restart can lift it. +// +// Locked settings stay visible — an admin should be able to see how the system +// is configured — but the field is read-only and the server refuses the write. +// The refusal is the real control; the read-only field is only courtesy. +// ============================================================ + +// Everything that changes how the assistant answers, what it costs, or how mail +// leaves the building. +var LOCKED_PREFIXES = Object.freeze([ + 'prompt.', // every prompt, including the clinical ones + 'clinical_assistant.', // models, allowlists, retrieval limits, budgets + 'models.', // model policy: default, custom, enabled set + 'tts.', + 'stt.', + 'embeddings.', + 'smtp.', // where mail goes and who it authenticates as + 'email.' // the templates that mail sends +]); + +// Day-to-day operation stays with ordinary admins. +var EDITABLE_WHEN_LOCKED = Object.freeze([ + 'announcement.', + 'registration_enabled', + 'registration_invite_only', + 'feature.', + 'site.' +]); + +function enabled(env) { + var raw = (env || process.env).ADMIN_LOCKDOWN; + return String(raw == null ? '' : raw).toLowerCase() === 'true'; +} + +// A key is locked when lockdown is on and it is not on the day-to-day list. +// Anything unrecognised is locked: a setting added later should need a +// deliberate decision to become editable, rather than defaulting to open. +function isLocked(key, env) { + if (!enabled(env)) return false; + var name = String(key || ''); + for (var i = 0; i < EDITABLE_WHEN_LOCKED.length; i++) { + var allowed = EDITABLE_WHEN_LOCKED[i]; + if (name === allowed || (allowed.endsWith('.') && name.startsWith(allowed))) return false; + } + return true; +} + +// What the admin UI needs to render itself correctly. +function state(env) { + return { + enabled: enabled(env), + lockedPrefixes: LOCKED_PREFIXES.slice(), + editablePrefixes: EDITABLE_WHEN_LOCKED.slice(), + reason: 'ADMIN_LOCKDOWN is set on the server. Locked settings can only be changed by someone with host access.' + }; +} + +// One message, so every refusal reads the same wherever it comes from. +function refusal(key) { + return 'This setting is locked. The server is in admin lockdown (ADMIN_LOCKDOWN), ' + + 'so "' + key + '" can only be changed by someone with host access.'; +} + +module.exports = { + LOCKED_PREFIXES, + EDITABLE_WHEN_LOCKED, + enabled, + isLocked, + state, + refusal +}; diff --git a/test/backend-hardening.test.js b/test/backend-hardening.test.js index 26067df2..d272ec8b 100644 --- a/test/backend-hardening.test.js +++ b/test/backend-hardening.test.js @@ -192,3 +192,50 @@ test('registration invites are single-use, expiring, and safe to store', () => { // And the rejection must not say which of the four reasons applied. assert.match(auth, /may have expired, been revoked, or already been used/); }); + +// With several admins, everything in the panel was editable by all of them. +// Lockdown separates running the service from changing how it behaves. +test('admin lockdown refuses configuration writes at the server', () => { + const lockdown = require('../src/utils/adminLockdown'); + const off = {}; + const on = { ADMIN_LOCKDOWN: 'true' }; + + // It is an environment variable, not a setting: a setting could be switched + // off by the very admin it restrains. + assert.equal(lockdown.enabled(off), false); + assert.equal(lockdown.enabled({ ADMIN_LOCKDOWN: 'false' }), false); + assert.equal(lockdown.enabled(on), true); + + // Off, nothing is locked. + assert.equal(lockdown.isLocked('prompt.hpi', off), false); + + // On, configuration is locked and day-to-day operation is not. + for (const key of ['prompt.hpi', 'clinical_assistant.chat_model', 'models.default', + 'tts.voice', 'stt.model', 'embeddings.model', 'smtp.host', 'email.verify.subject']) { + assert.equal(lockdown.isLocked(key, on), true, key + ' is locked'); + } + for (const key of ['announcement.text', 'registration_enabled', + 'registration_invite_only', 'feature.memories', 'site.name']) { + assert.equal(lockdown.isLocked(key, on), false, key + ' stays editable'); + } + + // A setting added later is locked until someone decides otherwise, rather + // than defaulting to open. + assert.equal(lockdown.isLocked('something.invented.later', on), true); + + const fs = require('node:fs'); + const path = require('node:path'); + const admin = fs.readFileSync(path.join(__dirname, '..', 'src/routes/adminConfig.js'), 'utf8'); + // One gate, not a check per route: a route added later cannot miss it. + assert.match(admin, /if \(!lockdown\.enabled\(\) \|\| req\.method === 'GET' \|\| req\.method === 'HEAD'\) return next\(\);/); + assert.match(admin, /if \(OPERATIONAL_WRITE\.test\(req\.path\)\) return next\(\);/); + assert.match(admin, /return res\.status\(403\)\.json\(\{ error: lockdown\.refusal/); + // And the per-key rule for the generic settings route. + assert.match(admin, /if \(lockdown\.isLocked\(key\)\) \{\s*\n\s*return res\.status\(403\)/); + + // The panel learns the state from a response it already fetches, rather than + // adding a request of its own on every admin open. + assert.match(admin, /invites: await invites\.list\(\), inviteOnly: await invites\.inviteOnly\(\), lockdown: lockdown\.state\(\)/); + const panel = fs.readFileSync(path.join(__dirname, '..', 'public/js/admin.js'), 'utf8'); + assert.match(panel, /window\.applyAdminLockdown\(data\.lockdown\)/); +});