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:
parent
39c1663334
commit
272ea94768
5 changed files with 214 additions and 2 deletions
14
.env.example
14
.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
|
||||
|
|
|
|||
|
|
@ -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() {});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
79
src/utils/adminLockdown.js
Normal file
79
src/utils/adminLockdown.js
Normal 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
|
||||
};
|
||||
|
|
@ -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\)/);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue