diff --git a/docs/authentication.md b/docs/authentication.md index 68ed0412..f3cf2f8b 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -16,6 +16,20 @@ that account. Roles follow the SSO's groups on every sign-in when admin is never demoted by a claim. The sections below describe the local machinery that remains behind the switch. +## Lockdown: the admin panel as view-only + +`ADMIN_LOCKDOWN=true` in the environment (never a setting, so no admin can +switch it off) makes every setting that changes how the service behaves +read-only: prompts, model policy, the Clinical Assistant's models and limits, +speech and transcription, SMTP and mail templates, the SSO provider, and any +key added later that nobody has explicitly listed as day-to-day. The server +refuses the write with 403 (`src/utils/adminLockdown.js`; the gate at the top +of `src/routes/adminConfig.js`, and `PUT /api/auth/oidc/config` checks it +itself). The panel greys the fields and says why, but the refusal is the +control. Still editable under lockdown: the announcement banner, +`registration_enabled`, `feature.*` switches, `site.*`, and the test buttons. +Lifting it takes a host change and a restart. + ## Password hashing - Primary: **argon2id**, memory cost 19 MiB, time cost 2, parallelism 1 diff --git a/src/routes/adminConfig.js b/src/routes/adminConfig.js index f2258acd..107688aa 100644 --- a/src/routes/adminConfig.js +++ b/src/routes/adminConfig.js @@ -30,7 +30,7 @@ var lockdown = require('../utils/adminLockdown'); // - /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\/[^/]+$/; +var OPERATIONAL_WRITE = /\/test(-email)?$|^\/config\/[^/]+$/; router.use(function(req, res, next) { if (!lockdown.enabled() || req.method === 'GET' || req.method === 'HEAD') return next(); diff --git a/src/routes/oidc.js b/src/routes/oidc.js index 25a381b1..6f0fec7c 100644 --- a/src/routes/oidc.js +++ b/src/routes/oidc.js @@ -316,6 +316,10 @@ router.get('/oidc/config', authMiddleware, adminMiddleware, async function(req, // ── Admin: PUT update OIDC config ─────────────────────────────────────── router.put('/oidc/config', authMiddleware, adminMiddleware, async function(req, res) { try { + // The sign-in provider is the most consequential setting there is; under + // lockdown it is read-only like every other setting that changes how the + // service behaves. This router is not behind the admin gate, so it says so itself. + if (lockdown.enabled()) return res.status(403).json({ error: lockdown.refusal('oidc') }); var allowed = ['oidc.enabled', 'oidc.issuer', 'oidc.client_id', 'oidc.client_secret', 'oidc.disable_local_auth', 'oidc.button_label', 'oidc.allowed_ips', 'oidc.admin_groups', 'oidc.moderator_groups']; var updates = req.body; diff --git a/test/backend-hardening.test.js b/test/backend-hardening.test.js index 47ea18bb..bef9d1ae 100644 --- a/test/backend-hardening.test.js +++ b/test/backend-hardening.test.js @@ -254,3 +254,13 @@ test('the site\'s Nextcloud is filled in for people, not typed by them', () => { assert.match(js, /group\.classList\.add\('hidden'\)/); assert.match(read('public/components/settings.html'), /id="nc-url-group"/); }); + +test('lockdown covers the sign-in provider too, and the exemption list names nothing that is gone', () => { + const oidc = read('src/routes/oidc.js'); + const put = oidc.slice(oidc.indexOf("router.put('/oidc/config'"), oidc.indexOf('\n});', oidc.indexOf("router.put('/oidc/config'"))); + assert.match(put, /if \(lockdown\.enabled\(\)\) return res\.status\(403\)\.json\(\{ error: lockdown\.refusal\('oidc'\) \}\);/); + const admin = read('src/routes/adminConfig.js'); + assert.doesNotMatch(admin, /OPERATIONAL_WRITE = \/\^\\\/invites/, 'invites are gone; the gate must not keep a door for them'); + assert.match(read('docs/authentication.md'), /## Lockdown: the admin panel as view-only/); + assert.match(read('.env.example'), /# ADMIN_LOCKDOWN=false/); +});