Add OIDC login and per-domain authorization
Authentication previously implied full authorization: any valid token could manage every host, DNS provider, domain, and user. This adds SSO login and a per-domain rights model. OIDC login (authorization_code + PKCE): - conf.oidc + conf.auth blocks; clientSecret in (gitignored) secrets.js. - utils/oidc.js (state/PKCE, code exchange, userinfo) using global fetch. - models/oidc_state.js: short-lived state store, auto-expiring via model-redis 1.5 per-key TTL. - routes/auth.js: GET /auth/oidc/start + /auth/oidc/callback; JIT-provisions a local user, mints an AuthToken carrying the SSO groups, hands the token to the browser via a URL fragment. "Log in with SSO" button on the login page. Authorization (groups + app overrides, per-domain, with ownership): - models/grant.js + utils/roles.js (pure, unit-tested): effective rights from conf.auth (admin users/groups, group->role map), Grant records (user|group -> global|domain -> viewer|manager|admin), and ownership (created_by). Roles rank admin > manager(owner) > viewer. - AuthToken stores session groups; middleware/auth.js exposes req.groups. - middleware/authz.js: requireAdmin, requireDomainRole(minRole, resolveDomain), filterViewable. Applied across routes: host mutations need manager on the host's domain; reads are filtered to visible domains; DNS providers, user management, and grant management are global-admin-only; certs need viewer. - routes/grant.js: admin CRUD for grants. Anti-lockout via conf.auth.adminUsers plus migrations/grant_bootstrap.js. Frontend: /me returns effective rights; nav gates Users/Grants to admins; grants management page; OIDC token-fragment handling in app-base.js. Tests: utils/roles and utils/oidc unit-tested (no redis); wired into the test scripts. Full suite 89 pass. Also verified end-to-end against redis (grant resolution, middleware allow/deny/403, list filtering) and the OIDC pure flow. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Pure authorization role logic — no redis, no I/O — so it can be unit tested
|
||||
* in isolation. models/grant.js supplies the data (grant records, owned
|
||||
* domains, conf.auth) and this module collapses it into effective rights and
|
||||
* answers allow/deny questions.
|
||||
*
|
||||
* Roles rank: admin > manager (owner/full over a domain) > viewer.
|
||||
*/
|
||||
|
||||
const ROLE_RANK = {viewer: 1, manager: 2, admin: 3};
|
||||
|
||||
function rank(role){
|
||||
return ROLE_RANK[role] || 0;
|
||||
}
|
||||
|
||||
// Whichever of two roles is stronger; either may be null/undefined.
|
||||
function maxRole(a, b){
|
||||
if(rank(a) >= rank(b)) return a || b || null;
|
||||
return b || a || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse config, grants, and ownership into effective rights.
|
||||
*
|
||||
* @param {Object} identity - {username, groups: string[]}
|
||||
* @param {Object} data
|
||||
* - grants: [{subjectType, subject, scope, domain, role}]
|
||||
* - ownedDomains: string[] (domains the user owns via created_by)
|
||||
* - authConf: conf.auth ({adminUsers, adminGroups, groupRoleMap})
|
||||
* @returns {Object} { isAdmin, global: role|null, domains: {domain: role} }
|
||||
*/
|
||||
function resolveEffective(identity, data){
|
||||
let username = identity && identity.username;
|
||||
let groups = (identity && identity.groups) || [];
|
||||
let grants = (data && data.grants) || [];
|
||||
let ownedDomains = (data && data.ownedDomains) || [];
|
||||
let authConf = (data && data.authConf) || {};
|
||||
|
||||
let result = {isAdmin: false, global: null, domains: {}};
|
||||
|
||||
// 1) Config-driven global admin (anti-lockout bootstrap).
|
||||
if((authConf.adminUsers || []).includes(username)) result.isAdmin = true;
|
||||
for(let g of groups){
|
||||
if((authConf.adminGroups || []).includes(g)) result.isAdmin = true;
|
||||
}
|
||||
|
||||
// 2) Config-driven group role defaults.
|
||||
let groupRoleMap = authConf.groupRoleMap || {};
|
||||
for(let g of groups){
|
||||
let m = groupRoleMap[g];
|
||||
if(!m) continue;
|
||||
if(m.role === 'admin' && (m.scope === 'global' || !m.scope)){
|
||||
result.isAdmin = true;
|
||||
}else if(m.scope === 'global'){
|
||||
result.global = maxRole(result.global, m.role);
|
||||
}else if(m.domain){
|
||||
result.domains[m.domain] = maxRole(result.domains[m.domain], m.role);
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Grant records for this user or any of their groups.
|
||||
for(let grant of grants){
|
||||
let matches = (grant.subjectType === 'user' && grant.subject === username)
|
||||
|| (grant.subjectType === 'group' && groups.includes(grant.subject));
|
||||
if(!matches) continue;
|
||||
|
||||
if(grant.scope === 'global'){
|
||||
if(grant.role === 'admin') result.isAdmin = true;
|
||||
else result.global = maxRole(result.global, grant.role);
|
||||
}else{
|
||||
result.domains[grant.domain] = maxRole(result.domains[grant.domain], grant.role);
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Ownership: manager rights over every owned domain.
|
||||
for(let domain of ownedDomains){
|
||||
result.domains[domain] = maxRole(result.domains[domain], 'manager');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Effective role on one domain, folding in admin and any global role.
|
||||
function roleForDomain(effective, domain){
|
||||
if(effective.isAdmin) return 'admin';
|
||||
return maxRole(effective.global, effective.domains[domain]);
|
||||
}
|
||||
|
||||
// Does `effective` meet or exceed `minRole` for `domain`?
|
||||
function allows(effective, minRole, domain){
|
||||
return rank(roleForDomain(effective, domain)) >= rank(minRole);
|
||||
}
|
||||
|
||||
// Domain names the identity can at least view (excludes the global-role case,
|
||||
// which callers treat as "sees everything").
|
||||
function visibleDomains(effective){
|
||||
return Object.keys(effective.domains).filter(d => rank(effective.domains[d]) >= rank('viewer'));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ROLE_RANK,
|
||||
rank,
|
||||
maxRole,
|
||||
resolveEffective,
|
||||
roleForDomain,
|
||||
allows,
|
||||
visibleDomains,
|
||||
};
|
||||
Reference in New Issue
Block a user