fix: lockdown also freezes the SSO settings; the gate no longer keeps a door for invites; lockdown documented

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
Daniel 2026-09-13 15:25:41 +02:00
parent c7864f763e
commit 6c689b420c
4 changed files with 29 additions and 1 deletions

View file

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

View file

@ -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();

View file

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

View file

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