feat: roles follow the SSO's groups, on every sign-in
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:
parent
e306c3ce28
commit
f748e02063
3 changed files with 81 additions and 4 deletions
|
|
@ -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
|
||||
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
|
||||
that account. The sections below describe the local machinery that remains
|
||||
behind the switch.
|
||||
that account. Roles follow the SSO's groups on every sign-in when
|
||||
`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
|
||||
|
||||
|
|
|
|||
|
|
@ -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) ──────────
|
||||
router.get('/oidc', async function(req, res) {
|
||||
try {
|
||||
|
|
@ -239,6 +257,25 @@ router.get('/oidc/callback', async function(req, res) {
|
|||
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
|
||||
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 ──────────────────────────────────────────────
|
||||
router.get('/oidc/config', authMiddleware, adminMiddleware, async function(req, res) {
|
||||
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 = {};
|
||||
for (var i = 0; i < keys.length; 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 ───────────────────────────────────────
|
||||
router.put('/oidc/config', authMiddleware, adminMiddleware, async function(req, res) {
|
||||
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;
|
||||
|
||||
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.roleFromGroups = roleFromGroups;
|
||||
module.exports.parseGroupList = parseGroupList;
|
||||
|
|
|
|||
34
test/oidc-roles.test.js
Normal file
34
test/oidc-roles.test.js
Normal 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'\]/);
|
||||
});
|
||||
Loading…
Reference in a new issue