From 2acc3644c49af0ab630df414d5d9e41e552002a7 Mon Sep 17 00:00:00 2001 From: William Mantly Date: Sat, 11 Jul 2026 10:54:15 -0400 Subject: [PATCH] Permissions: rename Grants, add wildcards, local groups, profile - Rename Grant -> Permission end-to-end (model, routes, view, frontend, bootstrap) and add an idempotent redis migration for existing records. - utils/roles.js: glob domain matching (* = one label, ** = any depth) against the full host; authz passes the full hostname. - Local groups: LocalGroup model + admin routes/UI; membership merged into Permission.effectiveFor so app groups behave like SSO groups. - Subject autocomplete via GET /api/permission/subjects (users + derived groups). - User profile page (/profile) and username in the navbar; /api/user/me now returns merged/local/external groups. Co-Authored-By: Claude Opus 4.8 --- nodejs/middleware/authz.js | 23 +-- ...t_bootstrap.js => permission_bootstrap.js} | 18 +-- .../migrations/rename_grant_to_permission.js | 69 +++++++++ nodejs/models/index.js | 3 +- nodejs/models/local_group.js | 70 +++++++++ nodejs/models/{grant.js => permission.js} | 53 +++++-- nodejs/public/lib/js/app-base.js | 51 +++++- nodejs/routes/api.js | 7 +- nodejs/routes/dns.js | 6 +- nodejs/routes/grant.js | 42 ----- nodejs/routes/group.js | 61 ++++++++ nodejs/routes/permission.js | 70 +++++++++ nodejs/routes/render.js | 12 +- nodejs/routes/user.js | 6 +- nodejs/test/unit/roles.test.js | 67 ++++++++ nodejs/utils/roles.js | 59 ++++++- nodejs/views/groups.ejs | 145 ++++++++++++++++++ nodejs/views/{grants.ejs => permissions.ejs} | 86 ++++++++--- nodejs/views/profile.ejs | 102 ++++++++++++ nodejs/views/top.ejs | 28 +++- 20 files changed, 858 insertions(+), 120 deletions(-) rename nodejs/migrations/{grant_bootstrap.js => permission_bootstrap.js} (51%) create mode 100644 nodejs/migrations/rename_grant_to_permission.js create mode 100644 nodejs/models/local_group.js rename nodejs/models/{grant.js => permission.js} (62%) delete mode 100644 nodejs/routes/grant.js create mode 100644 nodejs/routes/group.js create mode 100644 nodejs/routes/permission.js create mode 100644 nodejs/views/groups.ejs rename nodejs/views/{grants.ejs => permissions.ejs} (51%) create mode 100644 nodejs/views/profile.ejs diff --git a/nodejs/middleware/authz.js b/nodejs/middleware/authz.js index b1d7c1f..f4a154b 100644 --- a/nodejs/middleware/authz.js +++ b/nodejs/middleware/authz.js @@ -1,13 +1,13 @@ 'use strict'; -const {Grant} = require('../models/grant'); +const {Permission} = require('../models/permission'); 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 + * rights are resolved once per request via Permission.effectiveFor and cached on * req._effective. Roles: admin > manager (owner/full over a domain) > viewer. */ @@ -30,7 +30,7 @@ function toDomain(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({ + req._effective = await Permission.effectiveFor({ username: reqUsername(req), groups: req.groups || [], }); @@ -66,11 +66,14 @@ 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.')); + // Match against the full hostname so subdomain wildcards resolve; + // plain patterns still cover their subdomains (see roles.domainMatch). + let target = resolveDomain(req); + if(!target) return next(forbidden('Could not determine the target domain.')); + target = String(target).toLowerCase().trim(); - if(Grant.allows(effective, minRole, domain)) return next(); - return next(forbidden(`You need '${minRole}' rights on ${domain}.`)); + if(Permission.allows(effective, minRole, target)) return next(); + return next(forbidden(`You need '${minRole}' rights on ${toDomain(target)}.`)); }catch(error){ return next(error); } @@ -98,12 +101,12 @@ const resolve = { */ async function filterViewable(req, records, getDomain){ let effective = await getEffective(req); - if(effective.isAdmin || Grant.rank(effective.global) >= Grant.rank('viewer')){ + if(effective.isAdmin || Permission.rank(effective.global) >= Permission.rank('viewer')){ return records; } return records.filter(function(record){ - let domain = toDomain(getDomain(record)); - return Grant.allows(effective, 'viewer', domain); + // Full host; domainMatch handles exact, subdomain, and wildcard patterns. + return Permission.allows(effective, 'viewer', getDomain(record)); }); } diff --git a/nodejs/migrations/grant_bootstrap.js b/nodejs/migrations/permission_bootstrap.js similarity index 51% rename from nodejs/migrations/grant_bootstrap.js rename to nodejs/migrations/permission_bootstrap.js index 77487fa..d9c04b2 100644 --- a/nodejs/migrations/grant_bootstrap.js +++ b/nodejs/migrations/permission_bootstrap.js @@ -1,21 +1,21 @@ 'use strict'; /** - * Bootstrap a global-admin Grant for a user so there is always someone who can - * manage the system after per-domain authorization is enabled. + * Bootstrap a global-admin Permission for a user so there is always someone who + * can manage the system after per-domain authorization is enabled. * * Usage: - * node migrations/grant_bootstrap.js [username] + * node migrations/permission_bootstrap.js [username] * * Defaults to the first entry in conf.auth.adminUsers (or 'proxyadmin2'). * Note: members of conf.auth.adminUsers / conf.auth.adminGroups are already - * treated as admins without a Grant; this just makes it explicit/visible in the - * grant list and survives config changes. + * treated as admins without a Permission; this just makes it explicit/visible in + * the permission list and survives config changes. */ const conf = require('@simpleworkjs/conf'); require('../models'); // register all models -const {Grant} = require('../models/grant'); +const {Permission} = require('../models/permission'); (async function(){ try{ @@ -23,7 +23,7 @@ const {Grant} = require('../models/grant'); || (conf.auth && conf.auth.adminUsers && conf.auth.adminUsers[0]) || 'proxyadmin2'; - let grant = await Grant.create({ + let permission = await Permission.create({ subjectType: 'user', subject: username, scope: 'global', @@ -31,9 +31,9 @@ const {Grant} = require('../models/grant'); created_by: username, }); - console.log(`Granted global admin to "${username}":`, grant.id); + console.log(`Granted global admin to "${username}":`, permission.id); }catch(error){ - console.error('grant_bootstrap error', error); + console.error('permission_bootstrap error', error); }finally{ process.exit(0); } diff --git a/nodejs/migrations/rename_grant_to_permission.js b/nodejs/migrations/rename_grant_to_permission.js new file mode 100644 index 0000000..0632002 --- /dev/null +++ b/nodejs/migrations/rename_grant_to_permission.js @@ -0,0 +1,69 @@ +'use strict'; + +/** + * Data migration for the Grant -> Permission rename. + * + * model-redis namespaces keys by the JS class name, so renaming the class moved + * storage from `Grant` / `Grant_` to `Permission*`. + * This copies every old Grant record into the Permission model (ids are + * unchanged — mkId never encoded the word "grant") and then removes the old + * records. Idempotent: safe to re-run (already-migrated ids just upsert; missing + * old records are skipped). + * + * Usage: + * node migrations/rename_grant_to_permission.js + */ + +const Table = require('../models'); // base Table (shares the app's client/prefix) +require('../models'); // register all models (incl. Permission) +const {Permission} = require('../models/permission'); + +// A throwaway model whose class name is literally "Grant" so it reads the old +// namespace regardless of the configured key prefix. +class Grant extends Table{ + static _key = 'id'; + static _keyMap = Permission._keyMap; +} +Grant.register(); + +(async function(){ + try{ + let old = []; + try{ + old = await Grant.listDetail(); + }catch(error){ + console.log('No legacy Grant records found; nothing to migrate.'); + process.exit(0); + } + + console.log(`Found ${old.length} Grant record(s) to migrate.`); + let migrated = 0; + for(let g of old){ + // Permission.create is an upsert on the deterministic id. + await Permission.create({ + subjectType: g.subjectType, + subject: g.subject, + scope: g.scope, + domain: g.domain, + role: g.role, + created_by: g.created_by, + created_on: g.created_on, + }); + migrated++; + } + + // Remove the legacy records now that they live under Permission. + for(let g of old){ + try{ + let inst = await Grant.get(g.id); + await inst.remove(); + }catch(error){ /* already gone */ } + } + + console.log(`Migrated ${migrated} record(s) Grant -> Permission and removed the old entries.`); + process.exit(0); + }catch(error){ + console.error('rename_grant_to_permission error', error); + process.exit(1); + } +})(); diff --git a/nodejs/models/index.js b/nodejs/models/index.js index 47912e5..e71cc09 100644 --- a/nodejs/models/index.js +++ b/nodejs/models/index.js @@ -11,5 +11,6 @@ require('./dynamic_record'); require('./host'); require('./token'); require('./user'); -require('./grant'); +require('./local_group'); +require('./permission'); require('./oidc_state'); diff --git a/nodejs/models/local_group.js b/nodejs/models/local_group.js new file mode 100644 index 0000000..f8a83f1 --- /dev/null +++ b/nodejs/models/local_group.js @@ -0,0 +1,70 @@ +'use strict'; + +const Table = require('.'); +const ModelPs = require('../utils/model_pubsub'); + +/** + * LocalGroup + * + * An app-managed group with an explicit member list. Local groups behave exactly + * like groups carried from SSO/LDAP: their names can be used as a Permission + * subject (subjectType: 'group'), and in conf.auth.adminGroups / groupRoleMap. + * Membership is merged into a session's identity by Permission.effectiveFor. + */ +class LocalGroup extends Table{ + static _key = 'name'; + static _keyMap = { + 'name': {isRequired: true, type: 'string', min: 1, max: 100}, + 'members': {default: function(){return []}, isRequired: false, type: 'object'}, + 'created_by': {isRequired: true, type: 'string', min: 3, max: 500}, + 'created_on': {default: function(){return (new Date).getTime()}}, + 'updated_on': {default: function(){return (new Date).getTime()}, always: true}, + } + + // Normalize a group name to a slug (lowercase, safe chars) so it round-trips + // cleanly through URLs and matches consistently against session groups. + static slug(name){ + return String(name || '').trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, ''); + } + + static async create(data){ + data.name = this.slug(data.name); + if(!data.name){ + throw this.errors.ObjectValidateError([{key: 'name', message: 'A group name is required.'}]); + } + if(!Array.isArray(data.members)) data.members = []; + return super.create(data); + } + + async addMember(username){ + username = String(username || '').trim(); + if(!username){ + throw this.constructor.errors.ObjectValidateError([{key: 'username', message: 'A username is required.'}]); + } + let members = Array.isArray(this.members) ? this.members : []; + if(members.includes(username)) return this; + return this.update({members: [...members, username]}); + } + + async removeMember(username){ + let members = (Array.isArray(this.members) ? this.members : []).filter(m => m !== username); + return this.update({members}); + } + + // Expose the members as {group, username} objects (so the UI's per-member + // remove button knows which group it belongs to) plus a count. Flows through + // both the REST list and websocket payloads. + toJSON(){ + let base = super.toJSON(); + let members = Array.isArray(base.members) ? base.members : []; + return { + ...base, + memberList: members.map(u => ({group: base.name, username: u})), + memberCount: members.length, + }; + } +} + +LocalGroup.register(ModelPs(LocalGroup)); + +module.exports = {LocalGroup}; diff --git a/nodejs/models/grant.js b/nodejs/models/permission.js similarity index 62% rename from nodejs/models/grant.js rename to nodejs/models/permission.js index 3ef0f6a..2287366 100644 --- a/nodejs/models/grant.js +++ b/nodejs/models/permission.js @@ -3,9 +3,10 @@ const Table = require('.'); const conf = require('@simpleworkjs/conf'); const roles = require('../utils/roles'); +const ModelPs = require('../utils/model_pubsub'); /** - * Grant + * Permission * * Assigns a role to a subject (a user or a group), either globally or for a * single domain. Per-user overrides and group defaults both live here; group @@ -14,13 +15,17 @@ const roles = require('../utils/roles'); * subjectType : 'user' | 'group' * subject : username or group name * scope : 'global' | 'domain' - * domain : domain name when scope==='domain' (else '*') + * domain : domain pattern when scope==='domain' (else '*'). May be a + * glob: "*" = one label, "**" = any depth (see utils/roles). * role : 'admin' | 'manager' | 'viewer' * - * See Grant.effectiveFor() for how these, plus ownership (created_by) and - * conf.auth, collapse into a request's effective rights. + * See Permission.effectiveFor() for how these, plus ownership (created_by), + * local groups, and conf.auth, collapse into a request's effective rights. + * + * (Formerly "Grant" — the redis namespace moved from proxy_Grant* to + * proxy_Permission* via migrations/rename_grant_to_permission.js.) */ -class Grant extends Table{ +class Permission extends Table{ static _key = 'id'; static _keyMap = { 'created_by': {isRequired: true, type: 'string', min: 3, max: 500}, @@ -42,7 +47,7 @@ class Grant extends Table{ static allows = roles.allows; static visibleDomains = roles.visibleDomains; - // Deterministic id so the same (subject, scope, domain) grant is a single + // Deterministic id so the same (subject, scope, domain) permission is a single // record — re-granting updates rather than duplicating. static mkId({subjectType, subject, scope, domain}){ return `${subjectType}:${subject}:${scope || 'domain'}:${scope === 'global' ? '*' : (domain || '*')}`; @@ -57,7 +62,7 @@ class Grant extends Table{ } if(data.scope === 'global') data.domain = '*'; data.id = this.mkId(data); - // Upsert: replace an existing identical-scoped grant instead of 409ing. + // Upsert: replace an existing identical-scoped permission instead of 409ing. try{ let existing = await this.get(data.id); if(existing) await existing.remove(); @@ -67,18 +72,36 @@ class Grant extends Table{ } /** - * Collapse conf.auth, grant records, and resource ownership into the - * effective rights for a session identity. + * Collapse conf.auth, permission records, local groups, and resource + * ownership into the effective rights for a session identity. * * @param {Object} identity - {username, groups: string[]} - * @returns {Object} { isAdmin, global: role|null, domains: {domain: role} } + * @returns {Object} { isAdmin, global: role|null, domains: {pattern: role}, + * groups: string[], localGroups: string[] } * - isAdmin: full access to everything. * - global: a non-admin global role (manager/viewer) applied to every * domain the user can see. * - domains: explicit per-domain roles (includes owned domains). + * - groups: external groups merged with local-group memberships. + * - localGroups: just the app-managed groups this user belongs to. */ static async effectiveFor(identity){ let username = identity && identity.username; + let groups = (identity && identity.groups) || []; + + // Local groups are app-managed and behave exactly like SSO/LDAP groups: + // merge the user's memberships into the identity before resolving. + let localGroups = []; + try{ + let LocalGroup = require('.').models.LocalGroup; + if(LocalGroup && username){ + localGroups = (await LocalGroup.listDetail()) + .filter(g => Array.isArray(g.members) && g.members.includes(username)) + .map(g => g.name); + } + }catch(error){ /* local groups unavailable, skip */ } + + let mergedGroups = [...new Set([...groups, ...localGroups])]; // Fetch the redis-backed inputs, then hand off to the pure resolver. let grants = []; @@ -99,14 +122,18 @@ class Grant extends Table{ }catch(error){ /* domains unavailable, skip ownership */ } } - return roles.resolveEffective(identity, { + let effective = roles.resolveEffective({username, groups: mergedGroups}, { grants, ownedDomains, authConf: conf.auth || {}, }); + // Expose the group breakdown for self-service display (/me, profile). + effective.groups = mergedGroups; + effective.localGroups = localGroups; + return effective; } } -Grant.register(); +Permission.register(ModelPs(Permission)); -module.exports = {Grant}; +module.exports = {Permission}; diff --git a/nodejs/public/lib/js/app-base.js b/nodejs/public/lib/js/app-base.js index 5227e9e..1463c93 100644 --- a/nodejs/public/lib/js/app-base.js +++ b/nodejs/public/lib/js/app-base.js @@ -308,26 +308,67 @@ app.user = (function(app){ })(app); -app.grant = (function(app){ +app.permission = (function(app){ function list(callback){ - app.api.get('grant/', function(error, data){ + app.api.get('permission/', function(error, data){ + callback(error, data); + }); + } + + function subjects(callback){ + app.api.get('permission/subjects', function(error, data){ callback(error, data); }); } function add(args, callback){ - app.api.post('grant/', args, function(error, data){ + app.api.post('permission/', args, function(error, data){ callback(error, data); }); } function remove(id, callback){ - app.api.delete('grant/' + encodeURIComponent(id), function(error, data){ + app.api.delete('permission/' + encodeURIComponent(id), function(error, data){ callback(error, data); }); } - return {list, add, remove}; + return {list, subjects, add, remove}; + +})(app); + +app.group = (function(app){ + function list(callback){ + app.api.get('group/', function(error, data){ + callback(error, data); + }); + } + + function add(args, callback){ + app.api.post('group/', args, function(error, data){ + callback(error, data); + }); + } + + function remove(name, callback){ + app.api.delete('group/' + encodeURIComponent(name), function(error, data){ + callback(error, data); + }); + } + + function addMember(name, username, callback){ + app.api.post('group/' + encodeURIComponent(name) + '/members', {username}, function(error, data){ + callback(error, data); + }); + } + + function removeMember(name, username, callback){ + app.api.delete('group/' + encodeURIComponent(name) + '/members/' + encodeURIComponent(username), function(error, data){ + callback(error, data); + }); + } + + return {list, add, remove, addMember, removeMember}; })(app); diff --git a/nodejs/routes/api.js b/nodejs/routes/api.js index 3b92587..23e0e3c 100644 --- a/nodejs/routes/api.js +++ b/nodejs/routes/api.js @@ -22,7 +22,10 @@ router.use('/dns', middleware.auth, require('./dns')); // API routes for working with hosts. All endpoints need to be have valid user. router.use('/cert', middleware.auth, require('./cert')); -// Grant management (who can manage which domains) is global-admin-only. -router.use('/grant', middleware.auth, authz.requireAdmin, require('./grant')); +// Permission management (who can manage which domains) is global-admin-only. +router.use('/permission', middleware.auth, authz.requireAdmin, require('./permission')); + +// Local group management is global-admin-only. +router.use('/group', middleware.auth, authz.requireAdmin, require('./group')); module.exports = router; \ No newline at end of file diff --git a/nodejs/routes/dns.js b/nodejs/routes/dns.js index d798efc..8d23925 100644 --- a/nodejs/routes/dns.js +++ b/nodejs/routes/dns.js @@ -3,7 +3,7 @@ const router = require('express').Router(); const {DnsProvider, Domain, DynamicRecord} = require('../models').models; const authz = require('../middleware/authz'); -const {Grant} = require('../models/grant'); +const {Permission} = require('../models/permission'); const {getPublicIp} = require('../utils/public_ip'); const Model = DnsProvider; @@ -123,7 +123,7 @@ router.post('/dynamic/:id/refresh', async function(req, res, next){ try{ let record = await DynamicRecord.get(req.params.id); let effective = await authz.getEffective(req); - if(!Grant.allows(effective, 'manager', authz.toDomain(record.domain))){ + if(!Permission.allows(effective, 'manager', authz.toDomain(record.domain))){ let error = new Error('Forbidden'); error.name = 'Forbidden'; error.status = 403; error.message = `You need 'manager' rights on ${record.domain}.`; throw error; @@ -141,7 +141,7 @@ router.delete('/dynamic/:id', async function(req, res, next){ try{ let record = await DynamicRecord.get(req.params.id); let effective = await authz.getEffective(req); - if(!Grant.allows(effective, 'manager', authz.toDomain(record.domain))){ + if(!Permission.allows(effective, 'manager', authz.toDomain(record.domain))){ let error = new Error('Forbidden'); error.name = 'Forbidden'; error.status = 403; error.message = `You need 'manager' rights on ${record.domain}.`; throw error; diff --git a/nodejs/routes/grant.js b/nodejs/routes/grant.js deleted file mode 100644 index 85b317f..0000000 --- a/nodejs/routes/grant.js +++ /dev/null @@ -1,42 +0,0 @@ -'use strict'; - -const router = require('express').Router(); -const {Grant} = require('../models/grant'); -const {reqUsername} = require('../middleware/authz'); - -// All grant management is admin-only; the gate is applied where this router is -// mounted (routes/api.js). - -router.get('/', async function(req, res, next){ - try{ - return res.json({results: await Grant.listDetail()}); - }catch(error){ - next(error); - } -}); - -router.post('/', async function(req, res, next){ - try{ - req.body.created_by = reqUsername(req); - let grant = await Grant.create(req.body); - return res.json({ - message: `Granted ${req.body.role} to ${req.body.subjectType} "${req.body.subject}"` + - (req.body.scope === 'global' ? ' globally.' : ` on ${req.body.domain}.`), - ...grant, - }); - }catch(error){ - next(error); - } -}); - -router.delete('/:id', async function(req, res, next){ - try{ - let grant = await Grant.get(req.params.id); - await grant.remove(); - return res.json({message: `Grant ${req.params.id} removed.`}); - }catch(error){ - next(error); - } -}); - -module.exports = router; diff --git a/nodejs/routes/group.js b/nodejs/routes/group.js new file mode 100644 index 0000000..6606ed9 --- /dev/null +++ b/nodejs/routes/group.js @@ -0,0 +1,61 @@ +'use strict'; + +const router = require('express').Router(); +const {LocalGroup} = require('../models/local_group'); +const {reqUsername} = require('../middleware/authz'); + +// Local-group management is admin-only; the gate is applied where this router is +// mounted (routes/api.js). + +router.get('/', async function(req, res, next){ + try{ + return res.json({results: await LocalGroup.listDetail()}); + }catch(error){ + next(error); + } +}); + +router.post('/', async function(req, res, next){ + try{ + let group = await LocalGroup.create({ + name: req.body.name, + members: Array.isArray(req.body.members) ? req.body.members : [], + created_by: reqUsername(req), + }); + return res.json({message: `Group "${group.name}" created.`, ...group}); + }catch(error){ + next(error); + } +}); + +router.delete('/:name', async function(req, res, next){ + try{ + let group = await LocalGroup.get(req.params.name); + await group.remove(); + return res.json({message: `Group "${req.params.name}" removed.`}); + }catch(error){ + next(error); + } +}); + +router.post('/:name/members', async function(req, res, next){ + try{ + let group = await LocalGroup.get(req.params.name); + group = await group.addMember(req.body.username); + return res.json({message: `Added "${req.body.username}" to "${group.name}".`, ...group}); + }catch(error){ + next(error); + } +}); + +router.delete('/:name/members/:username', async function(req, res, next){ + try{ + let group = await LocalGroup.get(req.params.name); + group = await group.removeMember(req.params.username); + return res.json({message: `Removed "${req.params.username}" from "${group.name}".`, ...group}); + }catch(error){ + next(error); + } +}); + +module.exports = router; diff --git a/nodejs/routes/permission.js b/nodejs/routes/permission.js new file mode 100644 index 0000000..da713e5 --- /dev/null +++ b/nodejs/routes/permission.js @@ -0,0 +1,70 @@ +'use strict'; + +const router = require('express').Router(); +const conf = require('@simpleworkjs/conf'); +const {Permission} = require('../models/permission'); +const {LocalGroup} = require('../models/local_group'); +const {User} = require('../models').models; +const {reqUsername} = require('../middleware/authz'); + +// All permission management is admin-only; the gate is applied where this router +// is mounted (routes/api.js). + +router.get('/', async function(req, res, next){ + try{ + return res.json({results: await Permission.listDetail()}); + }catch(error){ + next(error); + } +}); + +// Autocomplete source for the "Subject" field: known usernames and group names. +// Groups are derived (no group registry beyond local groups): local groups + +// group-subjects already used in permissions + conf.auth admin/role-map groups. +router.get('/subjects', async function(req, res, next){ + try{ + let users = (await User.list()) || []; + + let groups = new Set(); + try{ + for(let g of await LocalGroup.list()) groups.add(g); + }catch(error){ /* none */ } + try{ + for(let p of await Permission.listDetail()){ + if(p.subjectType === 'group' && p.subject) groups.add(p.subject); + } + }catch(error){ /* none */ } + for(let g of (conf.auth && conf.auth.adminGroups) || []) groups.add(g); + for(let g of Object.keys((conf.auth && conf.auth.groupRoleMap) || {})) groups.add(g); + + return res.json({users, groups: [...groups].sort()}); + }catch(error){ + next(error); + } +}); + +router.post('/', async function(req, res, next){ + try{ + req.body.created_by = reqUsername(req); + let permission = await Permission.create(req.body); + return res.json({ + message: `Granted ${req.body.role} to ${req.body.subjectType} "${req.body.subject}"` + + (req.body.scope === 'global' ? ' globally.' : ` on ${req.body.domain}.`), + ...permission, + }); + }catch(error){ + next(error); + } +}); + +router.delete('/:id', async function(req, res, next){ + try{ + let permission = await Permission.get(req.params.id); + await permission.remove(); + return res.json({message: `Permission ${req.params.id} removed.`}); + }catch(error){ + next(error); + } +}); + +module.exports = router; diff --git a/nodejs/routes/render.js b/nodejs/routes/render.js index d98fd5d..57e8bc9 100644 --- a/nodejs/routes/render.js +++ b/nodejs/routes/render.js @@ -42,8 +42,16 @@ router.get('/users', async function(req, res, next) { res.render('users', {...values}); }); -router.get('/grants', async function(req, res, next) { - res.render('grants', {...values}); +router.get('/permissions', async function(req, res, next) { + res.render('permissions', {...values}); +}); + +router.get('/groups', async function(req, res, next) { + res.render('groups', {...values}); +}); + +router.get('/profile', async function(req, res, next) { + res.render('profile', {...values}); }); // Bare /login (the OIDC callback redirect target) and /login/. diff --git a/nodejs/routes/user.js b/nodejs/routes/user.js index 7530d73..4be45a0 100755 --- a/nodejs/routes/user.js +++ b/nodejs/routes/user.js @@ -45,7 +45,11 @@ router.get('/me', async function(req, res, next){ let effective = await authz.getEffective(req); return res.json({ username: authz.reqUsername(req), - groups: req.groups || [], + // Merged groups (external + local); localGroups is the app-managed + // subset, externalGroups the ones from SSO/LDAP. + groups: effective.groups || req.groups || [], + localGroups: effective.localGroups || [], + externalGroups: req.groups || [], isAdmin: effective.isAdmin, global: effective.global, domains: effective.domains, diff --git a/nodejs/test/unit/roles.test.js b/nodejs/test/unit/roles.test.js index eb0c025..e508d86 100644 --- a/nodejs/test/unit/roles.test.js +++ b/nodejs/test/unit/roles.test.js @@ -157,6 +157,73 @@ describe('roles.resolveEffective', () => { }); }); +describe('roles.domainMatch', () => { + test('exact and subdomain coverage for a plain pattern', () => { + assert.ok(roles.domainMatch('example.com', 'example.com')); + assert.ok(roles.domainMatch('example.com', 'api.example.com')); + assert.ok(roles.domainMatch('example.com', 'a.b.example.com')); + assert.ok(!roles.domainMatch('example.com', 'notexample.com')); + assert.ok(!roles.domainMatch('example.com', 'example.org')); + }); + test('single-label wildcard *.d matches exactly one label', () => { + assert.ok(roles.domainMatch('*.example.com', 'a.example.com')); + assert.ok(!roles.domainMatch('*.example.com', 'a.b.example.com')); + assert.ok(!roles.domainMatch('*.example.com', 'example.com')); + }); + test('deep wildcard **.d matches apex and any depth', () => { + assert.ok(roles.domainMatch('**.example.com', 'example.com')); + assert.ok(roles.domainMatch('**.example.com', 'a.example.com')); + assert.ok(roles.domainMatch('**.example.com', 'a.b.c.example.com')); + assert.ok(!roles.domainMatch('**.example.com', 'example.org')); + }); + test('bare * matches any single-label host only', () => { + assert.ok(roles.domainMatch('*', 'localhost')); + assert.ok(!roles.domainMatch('*', 'example.com')); + }); + test('bare ** matches everything', () => { + assert.ok(roles.domainMatch('**', 'localhost')); + assert.ok(roles.domainMatch('**', 'a.b.example.com')); + }); + test('is case-insensitive', () => { + assert.ok(roles.domainMatch('Example.COM', 'API.example.com')); + assert.ok(roles.domainMatch('*.Example.com', 'A.example.com')); + }); + test('empty / missing inputs do not match', () => { + assert.ok(!roles.domainMatch('', 'example.com')); + assert.ok(!roles.domainMatch('example.com', '')); + assert.ok(!roles.domainMatch(null, 'example.com')); + }); +}); + +describe('roles.roleForDomain with wildcard grants', () => { + test('a **.example.com viewer grant covers apex and subdomains', () => { + const e = effective({username: 'jane', groups: []}, { + grants: [{subjectType: 'user', subject: 'jane', scope: 'domain', domain: '**.example.com', role: 'viewer'}], + }); + assert.strictEqual(roles.allows(e, 'viewer', 'example.com'), true); + assert.strictEqual(roles.allows(e, 'viewer', 'a.b.example.com'), true); + assert.strictEqual(roles.allows(e, 'viewer', 'other.com'), false); + }); + test('a *.example.com grant matches one label deep only', () => { + const e = effective({username: 'jane', groups: []}, { + grants: [{subjectType: 'user', subject: 'jane', scope: 'domain', domain: '*.example.com', role: 'manager'}], + }); + assert.strictEqual(roles.allows(e, 'manager', 'a.example.com'), true); + assert.strictEqual(roles.allows(e, 'manager', 'a.b.example.com'), false); + assert.strictEqual(roles.allows(e, 'manager', 'example.com'), false); + }); + test('strongest matching pattern wins', () => { + const e = effective({username: 'jane', groups: []}, { + grants: [ + {subjectType: 'user', subject: 'jane', scope: 'domain', domain: '**.example.com', role: 'viewer'}, + {subjectType: 'user', subject: 'jane', scope: 'domain', domain: 'api.example.com', role: 'manager'}, + ], + }); + assert.strictEqual(roles.roleForDomain(e, 'api.example.com'), 'manager'); + assert.strictEqual(roles.roleForDomain(e, 'www.example.com'), 'viewer'); + }); +}); + describe('roles.rank / maxRole', () => { test('rank ordering', () => { assert.ok(roles.rank('admin') > roles.rank('manager')); diff --git a/nodejs/utils/roles.js b/nodejs/utils/roles.js index 7f35f44..e80efb2 100644 --- a/nodejs/utils/roles.js +++ b/nodejs/utils/roles.js @@ -82,10 +82,62 @@ function resolveEffective(identity, data){ return result; } -// Effective role on one domain, folding in admin and any global role. -function roleForDomain(effective, domain){ +/** + * Match a permission's domain pattern against a full hostname. + * + * A pattern with no wildcard matches the host exactly, or any subdomain of it + * (so a permission on "example.com" still covers "api.example.com", preserving + * the pre-wildcard behavior). Wildcards operate on dot-separated labels: + * "*" consumes exactly one label ("*.example.com" -> "a.example.com") + * "**" consumes zero or more labels ("**.example.com" -> "example.com", + * "a.b.example.com") + * The whole host must be consumed. Bare "*" matches any single-label host; bare + * "**" matches everything. + */ +function domainMatch(pattern, host){ + if(!pattern || !host) return false; + pattern = String(pattern).toLowerCase().trim(); + host = String(host).toLowerCase().trim(); + if(!host) return false; + + if(!pattern.includes('*')){ + return host === pattern || host.endsWith('.' + pattern); + } + return globLabels(pattern.split('.'), host.split('.')); +} + +// Two-pointer globstar over label arrays; backtracking handles multiple "**". +function globLabels(p, h){ + let pi = 0, hi = 0; + let star = -1, starHi = 0; + while(hi < h.length){ + if(pi < p.length && p[pi] === '**'){ + // Assume "**" matches nothing for now; remember it to backtrack. + star = pi; starHi = hi; pi++; + }else if(pi < p.length && (p[pi] === '*' || p[pi] === h[hi])){ + pi++; hi++; + }else if(star !== -1){ + // Let the most recent "**" swallow one more label. + pi = star + 1; starHi++; hi = starHi; + }else{ + return false; + } + } + while(pi < p.length && p[pi] === '**') pi++; + return pi === p.length; +} + +// Effective role on one host, folding in admin, any global role, and every +// domain pattern (incl. wildcards) that matches the host. +function roleForDomain(effective, host){ if(effective.isAdmin) return 'admin'; - return maxRole(effective.global, effective.domains[domain]); + let role = effective.global; + for(let pattern in effective.domains){ + if(domainMatch(pattern, host)){ + role = maxRole(role, effective.domains[pattern]); + } + } + return role; } // Does `effective` meet or exceed `minRole` for `domain`? @@ -104,6 +156,7 @@ module.exports = { rank, maxRole, resolveEffective, + domainMatch, roleForDomain, allows, visibleDomains, diff --git a/nodejs/views/groups.ejs b/nodejs/views/groups.ejs new file mode 100644 index 0000000..69f2bb7 --- /dev/null +++ b/nodejs/views/groups.ejs @@ -0,0 +1,145 @@ +<%- include('top') %> + + + + + + + + + +<%- include('bottom') %> diff --git a/nodejs/views/grants.ejs b/nodejs/views/permissions.ejs similarity index 51% rename from nodejs/views/grants.ejs rename to nodejs/views/permissions.ejs index d82331c..98262dc 100644 --- a/nodejs/views/grants.ejs +++ b/nodejs/views/permissions.ejs @@ -12,36 +12,70 @@ .card-title{ font-weight: bold; } + .field-hint{ + font-size: .8rem; + } + + + @@ -117,14 +153,14 @@ Delete - + {{ subjectType }} {{ subject }} {{ scope }} {{ domain }} {{ role }} - diff --git a/nodejs/views/profile.ejs b/nodejs/views/profile.ejs new file mode 100644 index 0000000..0bf5cbd --- /dev/null +++ b/nodejs/views/profile.ejs @@ -0,0 +1,102 @@ +<%- include('top') %> + + + + + + +
+
+
+
+ + My Profile +
+ +
+

+ +
+
Access
+
+
+ +
+
Groups
+
+
+ +
Domain permissions
+ + + +
DomainRole
+
+
+
+
+<%- include('bottom') %> diff --git a/nodejs/views/top.ejs b/nodejs/views/top.ejs index d52ee61..33a974f 100755 --- a/nodejs/views/top.ejs +++ b/nodejs/views/top.ejs @@ -57,8 +57,18 @@ + +