feat: admin lockdown, so several admins do not all get to change everything

With more than one admin, every setting in the panel was editable by all of
them — prompts, model policy, retrieval budgets, SMTP, email templates.
ADMIN_LOCKDOWN separates running the service from changing how it behaves.

It is an environment variable on purpose: a setting could be switched off by
the very admin it restrains, so lifting this needs host access and a restart.

The server is the control. One gate refuses configuration writes rather than
a check in each of the fifteen write routes, because that list grows and a
route added later would quietly miss it. Reads always pass — lockdown hides
nothing. Day-to-day operation stays available: invitations, announcements,
registration, feature flags, and the test endpoints, which persist nothing.
A setting invented later is locked until someone deliberately makes it
editable, rather than defaulting to open.

The panel disables what it cannot save and says why, but that is courtesy;
the refusal is what enforces it.

Two things this taught me, both fixed: my first version painted the panel
from an IIFE, which the module conventions forbid, and fetched the whole
config a second time just to read one flag — breaking the test that pins
admin loaders firing exactly once. The state now rides on the invites
response the panel already requests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
Daniel 2026-09-10 23:20:29 +02:00
parent 39c1663334
commit 272ea94768
5 changed files with 214 additions and 2 deletions

View file

@ -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

View file

@ -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 = '<i class="fas fa-lock"></i> <strong>Admin lockdown is on.</strong> ' +
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() {});
}

View file

@ -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' });

View file

@ -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
};

View file

@ -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\)/);
});