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 ────────────────────────────────────────────────────────────────────
|
# ── Mail ────────────────────────────────────────────────────────────────────
|
||||||
# SMTP_SECURE=false # true for implicit TLS (port 465)
|
# 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 ─────────────────────────────────────────────────────
|
# ── Identity and limits ─────────────────────────────────────────────────────
|
||||||
# SITE_NAME=Pediatric AI Scribe
|
# SITE_NAME=Pediatric AI Scribe
|
||||||
# API_RATE_LIMIT_MAX=200 # requests per window across /api
|
# 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
|
// ADMIN REGISTRATION INVITES
|
||||||
// The code exists in readable form exactly once: in the response to creating
|
// 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');
|
var toggle = document.getElementById('admin-invite-only');
|
||||||
if (toggle) toggle.checked = !!data.inviteOnly;
|
if (toggle) toggle.checked = !!data.inviteOnly;
|
||||||
renderInvites(data.invites || []);
|
renderInvites(data.invites || []);
|
||||||
|
// Painted after the panel has rendered, from the same response.
|
||||||
|
if (typeof window.applyAdminLockdown === 'function') window.applyAdminLockdown(data.lockdown);
|
||||||
})
|
})
|
||||||
.catch(function() {});
|
.catch(function() {});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,26 @@ var { getLiteLLMEmbeddingModels } = require('../utils/embeddings');
|
||||||
|
|
||||||
router.use(authMiddleware);
|
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() {
|
function liteLLMBaseUrl() {
|
||||||
return (process.env.LITELLM_API_BASE || '').replace(/\/+$/, '').replace(/\/v1\/?$/, '');
|
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 });
|
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' }); }
|
} 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) {
|
router.get('/invites', async function(req, res) {
|
||||||
try {
|
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'); }
|
} 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' });
|
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.
|
// 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('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' });
|
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.
|
// And the rejection must not say which of the four reasons applied.
|
||||||
assert.match(auth, /may have expired, been revoked, or already been used/);
|
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