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:
@@ -6,6 +6,8 @@ async function auth(req, res, next){
|
||||
try{
|
||||
req.token = await Auth.checkToken(req.header('auth-token'));
|
||||
req.user = req.token.user;
|
||||
// Session group memberships captured at login, used by authz middleware.
|
||||
req.groups = typeof req.token.groupsArray === 'function' ? req.token.groupsArray() : [];
|
||||
return next();
|
||||
}catch(error){
|
||||
next(error);
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
'use strict';
|
||||
|
||||
const {Grant} = require('../models/grant');
|
||||
const tldExtract = require('tld-extract').parse_host;
|
||||
|
||||
/**
|
||||
* Authorization middleware.
|
||||
*
|
||||
* Builds on middleware/auth.js (which sets req.user + req.groups). Effective
|
||||
* rights are resolved once per request via Grant.effectiveFor and cached on
|
||||
* req._effective. Roles: admin > manager (owner/full over a domain) > viewer.
|
||||
*/
|
||||
|
||||
// The username for the request, tolerating a missing user relation by falling
|
||||
// back to the token's created_by (which is the username).
|
||||
function reqUsername(req){
|
||||
return (req.user && req.user.username) || (req.token && req.token.created_by) || null;
|
||||
}
|
||||
|
||||
// Normalize a host or domain string to its registrable domain.
|
||||
function toDomain(value){
|
||||
if(!value) return value;
|
||||
try{
|
||||
return tldExtract(value).domain;
|
||||
}catch(error){
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve (and cache) the effective rights for this request.
|
||||
async function getEffective(req){
|
||||
if(req._effective) return req._effective;
|
||||
req._effective = await Grant.effectiveFor({
|
||||
username: reqUsername(req),
|
||||
groups: req.groups || [],
|
||||
});
|
||||
return req._effective;
|
||||
}
|
||||
|
||||
function forbidden(message){
|
||||
let error = new Error('Forbidden');
|
||||
error.name = 'Forbidden';
|
||||
error.message = message || 'You do not have permission to perform this action.';
|
||||
error.status = 403;
|
||||
return error;
|
||||
}
|
||||
|
||||
// Global-admin-only gate (user management, DNS providers, grant management).
|
||||
async function requireAdmin(req, res, next){
|
||||
try{
|
||||
let effective = await getEffective(req);
|
||||
if(effective.isAdmin) return next();
|
||||
return next(forbidden('Administrator access required.'));
|
||||
}catch(error){
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Require at least `minRole` on the domain resolved from the request.
|
||||
*
|
||||
* @param {string} minRole - 'viewer' | 'manager'
|
||||
* @param {Function} resolveDomain - (req) => host|domain string
|
||||
*/
|
||||
function requireDomainRole(minRole, resolveDomain){
|
||||
return async function(req, res, next){
|
||||
try{
|
||||
let effective = await getEffective(req);
|
||||
let domain = toDomain(resolveDomain(req));
|
||||
if(!domain) return next(forbidden('Could not determine the target domain.'));
|
||||
|
||||
if(Grant.allows(effective, minRole, domain)) return next();
|
||||
return next(forbidden(`You need '${minRole}' rights on ${domain}.`));
|
||||
}catch(error){
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Common domain resolvers for route wiring.
|
||||
const resolve = {
|
||||
// A host lives in req.params.item (e.g. api.example.com -> example.com).
|
||||
hostParam: req => req.params.item,
|
||||
// A host being created lives in the request body.
|
||||
hostBody: req => req.body && req.body.host,
|
||||
// A domain (or provider domain) name in req.params.item.
|
||||
domainParam: req => req.params.item,
|
||||
};
|
||||
|
||||
/**
|
||||
* Filter a list of records to those the request may at least view.
|
||||
* Admins and holders of a global role see everything; otherwise a record is
|
||||
* kept when its domain (via `getDomain`) is one the user has rights on.
|
||||
*
|
||||
* @param {Object} req
|
||||
* @param {Array} records
|
||||
* @param {Function} getDomain - (record) => host|domain string
|
||||
*/
|
||||
async function filterViewable(req, records, getDomain){
|
||||
let effective = await getEffective(req);
|
||||
if(effective.isAdmin || Grant.rank(effective.global) >= Grant.rank('viewer')){
|
||||
return records;
|
||||
}
|
||||
return records.filter(function(record){
|
||||
let domain = toDomain(getDomain(record));
|
||||
return Grant.allows(effective, 'viewer', domain);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getEffective,
|
||||
requireAdmin,
|
||||
requireDomainRole,
|
||||
filterViewable,
|
||||
resolve,
|
||||
toDomain,
|
||||
reqUsername,
|
||||
};
|
||||
Reference in New Issue
Block a user