feat: roles follow the SSO's groups, on every sign-in
Some checks failed
Forgejo Docker Build / Root app tests (push) Successful in 49s
Forgejo Docker Build / Build Docker image (push) Successful in 8s
Forgejo Docker Build / End-to-end (browser) (push) Failing after 8s

Both PedsHub apps now take admin and moderator from the same two Authentik
groups. oidc.admin_groups and oidc.moderator_groups name them; unset means
local roles stand. Applied at every sign-in so removal at the SSO demotes
here, and never applied to the last admin — a group edit must not be able
to lock everyone out of the panel.

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 13:54:12 +02:00
parent e306c3ce28
commit f748e02063
3 changed files with 81 additions and 4 deletions

View file

@ -10,8 +10,11 @@ signed state cookie, PKCE, nonce, `email_verified` required before an existing
local account is linked, `sub` mismatch refused, session row written before the local account is linked, `sub` mismatch refused, session row written before the
cookie is set. New accounts are created at the SSO from an invitation link cookie is set. New accounts are created at the SSO from an invitation link
(`invite.py` there); an SSO account whose email matches a local one signs into (`invite.py` there); an SSO account whose email matches a local one signs into
that account. The sections below describe the local machinery that remains that account. Roles follow the SSO's groups on every sign-in when
behind the switch. `oidc.admin_groups` / `oidc.moderator_groups` name them (production:
`pedshub-admins`, `pedshub-moderators`; anyone in neither is a user); the last
admin is never demoted by a claim. The sections below describe the local
machinery that remains behind the switch.
## Password hashing ## Password hashing

View file

@ -97,6 +97,24 @@ router.get('/oidc-status', async function(req, res) {
} }
}); });
// Which role a set of SSO group names earns. Empty settings mean "off": roles
// stay whatever they are locally. A claim that is a string is read as a
// comma- or space-separated list, since providers differ; names are compared
// case-blind; admin beats moderator; a member of neither is a user.
function parseGroupList(value) {
return String(value || '').split(/[,\s]+/).map(function (g) { return g.trim().toLowerCase(); }).filter(Boolean);
}
async function roleFromGroups(claim) {
var admins = parseGroupList(await db.getSetting('oidc.admin_groups'));
var moderators = parseGroupList(await db.getSetting('oidc.moderator_groups'));
if (!admins.length && !moderators.length) return null;
if (claim == null) return null;
var groups = Array.isArray(claim) ? claim.map(function (g) { return String(g).toLowerCase(); }) : parseGroupList(claim);
if (admins.some(function (g) { return groups.indexOf(g) !== -1; })) return 'admin';
if (moderators.some(function (g) { return groups.indexOf(g) !== -1; })) return 'moderator';
return 'user';
}
// ── GET /api/auth/oidc — initiate OIDC login (redirects to IdP) ────────── // ── GET /api/auth/oidc — initiate OIDC login (redirects to IdP) ──────────
router.get('/oidc', async function(req, res) { router.get('/oidc', async function(req, res) {
try { try {
@ -239,6 +257,25 @@ router.get('/oidc/callback', async function(req, res) {
user = await db.get('SELECT * FROM users WHERE id = ?', [result.lastInsertRowid]); user = await db.get('SELECT * FROM users WHERE id = ?', [result.lastInsertRowid]);
} }
// The role comes from the SSO's groups, on every sign-in, when the admin
// has said which groups mean what (oidc.admin_groups, oidc.moderator_groups).
// Applied every time rather than only at creation, so removal at the SSO
// demotes here too. The last admin is never demoted by a claim: a group
// edit at the SSO must not be able to lock everyone out of this panel.
var mapped = await roleFromGroups(claims.groups);
if (mapped && mapped !== user.role) {
var lastAdmin = user.role === 'admin' && mapped !== 'admin' &&
parseInt((await db.get("SELECT COUNT(*) AS count FROM users WHERE role = 'admin' AND disabled IS NOT TRUE", [])).count, 10) <= 1;
if (lastAdmin) {
console.warn('[OIDC] not demoting the last admin (user ' + user.id + ') on the SSO\'s say-so');
} else {
await db.run('UPDATE users SET role = ? WHERE id = ?', [mapped, user.id]);
await db.run('INSERT INTO audit_log (user_id, action, category, details, ip_address) VALUES (?, ?, ?, ?, ?)',
[user.id, 'oidc_role', 'auth', 'Role ' + user.role + ' → ' + mapped + ' from SSO groups', req.ip]).catch(function(){});
user.role = mapped;
}
}
// Issue JWT // Issue JWT
var token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: isMobileClient(req) ? '365d' : '30d' }); var token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: isMobileClient(req) ? '365d' : '30d' });
@ -263,7 +300,7 @@ router.get('/oidc/callback', async function(req, res) {
// ── Admin: GET OIDC config ────────────────────────────────────────────── // ── Admin: GET OIDC config ──────────────────────────────────────────────
router.get('/oidc/config', authMiddleware, adminMiddleware, async function(req, res) { router.get('/oidc/config', authMiddleware, adminMiddleware, async function(req, res) {
try { try {
var keys = ['oidc.enabled', 'oidc.issuer', 'oidc.client_id', 'oidc.client_secret', 'oidc.disable_local_auth', 'oidc.button_label', 'oidc.allowed_ips']; var keys = ['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 config = {}; var config = {};
for (var i = 0; i < keys.length; i++) { for (var i = 0; i < keys.length; i++) {
config[keys[i]] = await db.getSetting(keys[i]) || ''; config[keys[i]] = await db.getSetting(keys[i]) || '';
@ -279,7 +316,7 @@ router.get('/oidc/config', authMiddleware, adminMiddleware, async function(req,
// ── Admin: PUT update OIDC config ─────────────────────────────────────── // ── Admin: PUT update OIDC config ───────────────────────────────────────
router.put('/oidc/config', authMiddleware, adminMiddleware, async function(req, res) { router.put('/oidc/config', authMiddleware, adminMiddleware, async function(req, res) {
try { try {
var allowed = ['oidc.enabled', 'oidc.issuer', 'oidc.client_id', 'oidc.client_secret', 'oidc.disable_local_auth', 'oidc.button_label', 'oidc.allowed_ips']; 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; var updates = req.body;
for (var i = 0; i < allowed.length; i++) { for (var i = 0; i < allowed.length; i++) {
@ -296,3 +333,6 @@ router.put('/oidc/config', authMiddleware, adminMiddleware, async function(req,
}); });
module.exports = router; module.exports = router;
module.exports.roleFromGroups = roleFromGroups;
module.exports.parseGroupList = parseGroupList;

34
test/oidc-roles.test.js Normal file
View file

@ -0,0 +1,34 @@
// Roles come from the SSO's groups on every sign-in, when the admin has said
// which groups mean what; the last admin is never demoted by a claim.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const src = fs.readFileSync(path.join(__dirname, '..', 'src/routes/oidc.js'), 'utf8');
function withSettings(settings) {
const db = { getSetting: async k => settings[k] || '' };
const slice = src.slice(src.indexOf('function parseGroupList'), src.indexOf('// ── GET /api/auth/oidc — initiate'));
return new Function('db', slice + '; return roleFromGroups;')(db);
}
test('groups map to roles: admin beats moderator, neither is a user, off when unset', async () => {
const on = withSettings({ 'oidc.admin_groups': 'pedshub-admins', 'oidc.moderator_groups': 'pedshub-moderators, editors' });
assert.equal(await on(['pedshub-members', 'pedshub-admins', 'pedshub-moderators']), 'admin');
assert.equal(await on(['pedshub-members', 'Editors']), 'moderator', 'case-blind');
assert.equal(await on(['pedshub-members']), 'user');
assert.equal(await on('pedshub-members pedshub-moderators'), 'moderator', 'a string claim is a list');
assert.equal(await on(undefined), null, 'no claim: leave the role alone');
const off = withSettings({});
assert.equal(await off(['pedshub-admins']), null, 'unset settings: local roles stand');
});
test('the callback applies the mapping every sign-in and spares the last admin', () => {
const cb = src.slice(src.indexOf("router.get('/oidc/callback'"));
assert.match(cb, /var mapped = await roleFromGroups\(claims\.groups\);/);
assert.match(cb, /if \(mapped && mapped !== user\.role\)/);
assert.match(cb, /SELECT COUNT\(\*\) AS count FROM users WHERE role = 'admin' AND disabled IS NOT TRUE/);
assert.match(cb, /not demoting the last admin/);
assert.match(cb, /UPDATE users SET role = \? WHERE id = \?/);
assert.match(src, /'oidc\.admin_groups', 'oidc\.moderator_groups'\]/);
});