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/host.js b/nodejs/models/host.js index bf1cef7..4c33072 100755 --- a/nodejs/models/host.js +++ b/nodejs/models/host.js @@ -23,7 +23,9 @@ class Host extends Table{ 'updated_by': {default:"__NONE__", isRequired: false, type: 'string',}, 'updated_on': {default: function(){return (new Date).getTime()}, always: true}, - 'host': {isRequired: true, type: 'string', min: 3, max: 500}, + // min 1 so wildcard patterns like "**" / "*" are allowed (see + // utils/hostname_validate.js; format is enforced at the route layer). + 'host': {isRequired: true, type: 'string', min: 1, max: 500}, 'ip': {isRequired: true, type: 'string', min: 3, max: 500}, 'targetPort': {isRequired: true, type: 'number', min:0, max:65535}, 'forcessl': {isRequired: false, default: true, type: 'boolean'}, 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/package.json b/nodejs/package.json index 8ce6bfc..45ef98e 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -11,10 +11,10 @@ "scripts": { "start": "node ./bin/www", "dev": "npx nodemon --ignore public/ ./bin/www", - "test": "node --test test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/unix_socket.test.js test/integration/dns_provider.test.js", - "test:unit": "node --test test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/unix_socket.test.js", + "test": "node --test test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/unix_socket.test.js test/integration/dns_provider.test.js", + "test:unit": "node --test test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/unix_socket.test.js", "test:integration": "node --test test/integration/dns_provider.test.js", - "test:watch": "node --test --watch test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/unix_socket.test.js test/integration/dns_provider.test.js" + "test:watch": "node --test --watch test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/unix_socket.test.js test/integration/dns_provider.test.js" }, "engines": { "node": ">=18.0.0" diff --git a/nodejs/public/css/styles.css b/nodejs/public/css/styles.css index 14a6177..7fa9bbc 100755 --- a/nodejs/public/css/styles.css +++ b/nodejs/public/css/styles.css @@ -11,8 +11,3 @@ nav.navbar{ .card-title{ font-weight: bold; } - -.actionMessage{ - position: fixed; -} - 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/public/lib/js/val.js b/nodejs/public/lib/js/val.js index b372555..90c8742 100755 --- a/nodejs/public/lib/js/val.js +++ b/nodejs/public/lib/js/val.js @@ -61,7 +61,7 @@ //checks if empty to stop processing if(!isNaN(options) && value.length === 0) { }else if(rule in settings.rule){ - let message = settings.rule[rule].apply(this, [value, options]); + message = settings.rule[rule].apply(this, [value, options]); } this.validateMessage(message) @@ -93,41 +93,95 @@ }( jQuery )); -$.validateSettings({ - rule:{ - ip: function( value ) { - value = value.split( '.' ); - - if ( value.length != 4 ) { - return "Malformed IP"; - } - - $.each( value, function( key, value ) { - if( value > 255 || value < 0 ) { +// Host / target validation, mirrored from the backend (utils/hostname_validate.js): +// a bare hostname or IPv4 address, no protocol / "/" / ":" / whitespace. The +// incoming host may be a wildcard ("*.example.com"); the target may not. +(function(){ + var LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i; + var HOSTNAME = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i; + var FORBIDDEN = /[\s/:]/; + + function isIPv4( value ) { + var parts = value.split( '.' ); + if ( parts.length !== 4 ) return false; + return parts.every( function( p ) { + return /^(0|[1-9]\d{0,2})$/.test( p ) && Number( p ) <= 255; + }); + } + + // Incoming-host pattern: labels may be normal, "*" (one fragment), or "**" + // (any number of fragments, incl. a bare "**" global catch-all). + function isHostPattern( value ) { + if ( value.length > 253 ) return false; + return value.split( '.' ).every( function( l ) { + return l === '*' || l === '**' || LABEL.test( l ); + }); + } + + function forbidden( value ) { + return FORBIDDEN.test( value ) || value.includes( '://' ); + } + + // Incoming host: IPv4 or a wildcard host pattern. + function checkHost( value ) { + if ( typeof value !== 'string' || value.length === 0 ) return "Required"; + if ( forbidden( value ) ) return 'No protocol, "/", or ":"'; + if ( isIPv4( value ) || isHostPattern( value ) ) return; + return "Enter a valid host or wildcard (*, **)"; + } + + // Downstream target: IPv4 or a strict hostname, no wildcard. + function checkTarget( value ) { + if ( typeof value !== 'string' || value.length === 0 ) return "Required"; + if ( forbidden( value ) ) return 'No protocol, "/", or ":"'; + if ( isIPv4( value ) || HOSTNAME.test( value ) ) return; + return "Enter a valid hostname or IP"; + } + + $.validateSettings({ + rule:{ + ip: function( value ) { + value = value.split( '.' ); + + if ( value.length != 4 ) { return "Malformed IP"; } - }); - }, - host: function( value ) { - var reg = /^(?=.{1,255}$)[0-9A-Za-z](?:(?:[0-9A-Za-z]|-){0,61}[0-9A-Za-z])?(?:\.[0-9A-Za-z](?:(?:[0-9A-Za-z]|-){0,61}[0-9A-Za-z])?)*\.?$/; - if ( reg.test( value ) === false ) { - return "Invalid"; - } - }, + $.each( value, function( key, value ) { + if( value > 255 || value < 0 ) { + return "Malformed IP"; + } + }); + }, - user: function( value ) { - var reg = /^[a-z0-9\_\-\@\.]{1,32}$/; - if ( reg.test( value ) === false ) { - return "Invalid"; - } - }, - - password: function( value ) { - var reg = /^(?=[^\d_].*?\d)\w(\w|[!@#$%]){1,48}/; - if ( reg.test( value ) === false ) { - return "Weak password, Try again"; + // Incoming host name — hostname, IPv4, or wildcard pattern (*, **). + host: function( value ) { + return checkHost( value ); + }, + + // Downstream target — hostname or IPv4, no wildcard. + target: function( value ) { + return checkTarget( value ); + }, + + // Back-compat alias (no wildcard). + hostname: function( value ) { + return checkTarget( value ); + }, + + user: function( value ) { + var reg = /^[a-z0-9\_\-\@\.]{1,32}$/; + if ( reg.test( value ) === false ) { + return "Invalid"; + } + }, + + password: function( value ) { + var reg = /^(?=[^\d_].*?\d)\w(\w|[!@#$%]){1,48}/; + if ( reg.test( value ) === false ) { + return "Weak password, Try again"; + } } } - } -}); \ No newline at end of file + }); +})(); \ No newline at end of file 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/host.js b/nodejs/routes/host.js index de971d6..75fcb6c 100755 --- a/nodejs/routes/host.js +++ b/nodejs/routes/host.js @@ -4,9 +4,17 @@ const router = require('express').Router(); const {Host, Domain} = require('../models').models; const authz = require('../middleware/authz'); const {normalizeHostFeatures} = require('../utils/host_features'); +const {collectHostFieldErrors} = require('../utils/hostname_validate'); const Model = Host; +// Reject a malformed host/target before it reaches the model. Throws a 422 +// ObjectValidateError (per-field keys) that the frontend surfaces inline. +function validateHostFields(body){ + let errors = collectHostFieldErrors(body); + if(errors.length) throw Model.errors.ObjectValidateError(errors); +} + router.get('/', async function(req, res, next){ try{ let results = await Model[req.query.detail ? "listDetail" : "list"](); @@ -25,6 +33,7 @@ router.get('/', async function(req, res, next){ router.post('/', authz.requireDomainRole('manager', authz.resolve.hostBody), async function(req, res, next){ try{ req.body.created_by = authz.reqUsername(req); + validateHostFields(req.body); normalizeHostFeatures(req.body); let item = await Model.create(req.body); @@ -89,6 +98,7 @@ router.get('/:item', authz.requireDomainRole('viewer', authz.resolve.hostParam), router.put('/:item', authz.requireDomainRole('manager', authz.resolve.hostParam), async function(req, res, next){ try{ req.body.updated_by = authz.reqUsername(req); + validateHostFields(req.body); normalizeHostFeatures(req.body); let item = await Model.get(req.params.item); item = await item.update(req.body); 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/hostname_validate.test.js b/nodejs/test/unit/hostname_validate.test.js new file mode 100644 index 0000000..42ae4c3 --- /dev/null +++ b/nodejs/test/unit/hostname_validate.test.js @@ -0,0 +1,120 @@ +'use strict'; + +const {describe, test} = require('node:test'); +const assert = require('node:assert'); + +const { + isValidIPv4, + isValidHostname, + isValidHostPattern, + isValidHostField, + isValidTargetField, + collectHostFieldErrors, +} = require('../../utils/hostname_validate'); + +describe('isValidIPv4', () => { + test('accepts dotted quads in range', () => { + assert.ok(isValidIPv4('10.10.10.10')); + assert.ok(isValidIPv4('0.0.0.0')); + assert.ok(isValidIPv4('255.255.255.255')); + }); + test('rejects out-of-range, wrong length, leading zeros, junk', () => { + assert.ok(!isValidIPv4('256.1.1.1')); + assert.ok(!isValidIPv4('1.2.3')); + assert.ok(!isValidIPv4('1.2.3.4.5')); + assert.ok(!isValidIPv4('01.2.3.4')); + assert.ok(!isValidIPv4('a.b.c.d')); + }); +}); + +describe('isValidHostname (strict, for target)', () => { + test('accepts dotted hostnames with an alphabetic TLD', () => { + assert.ok(isValidHostname('example.com')); + assert.ok(isValidHostname('app.internal.net')); + }); + test('rejects bare labels, numeric TLDs, wildcards', () => { + assert.ok(!isValidHostname('localhost')); + assert.ok(!isValidHostname('10.10.10.10')); + assert.ok(!isValidHostname('*.example.com')); + assert.ok(!isValidHostname('')); + }); +}); + +describe('isValidHostPattern (incoming host)', () => { + test('accepts plain hostnames and single/double wildcards', () => { + assert.ok(isValidHostPattern('proxy.cloud-ops.net')); + assert.ok(isValidHostPattern('*.example.com')); + assert.ok(isValidHostPattern('**.mysite.com')); + assert.ok(isValidHostPattern('payments.**')); + assert.ok(isValidHostPattern('**')); // global catch-all + assert.ok(isValidHostPattern('*')); + assert.ok(isValidHostPattern('a.*.b.**.c')); + }); + test('rejects empty labels, edge hyphens, "***"', () => { + assert.ok(!isValidHostPattern('a..b')); + assert.ok(!isValidHostPattern('.example.com')); + assert.ok(!isValidHostPattern('example.com.')); + assert.ok(!isValidHostPattern('-bad.example.com')); + assert.ok(!isValidHostPattern('***.example.com')); + assert.ok(!isValidHostPattern('')); + }); +}); + +describe('isValidHostField (host: pattern or IP, no forbidden chars)', () => { + test('accepts wildcard patterns and IPv4', () => { + assert.ok(isValidHostField('**')); + assert.ok(isValidHostField('**.mysite.com')); + assert.ok(isValidHostField('payments.**')); + assert.ok(isValidHostField('10.10.10.10')); + }); + test('rejects protocol, path, port, whitespace', () => { + assert.ok(!isValidHostField('http://x.com')); + assert.ok(!isValidHostField('x.com:8080')); + assert.ok(!isValidHostField('x.com/y')); + assert.ok(!isValidHostField('a b.com')); + assert.ok(!isValidHostField('')); + }); +}); + +describe('isValidTargetField (target: hostname or IP, no wildcard)', () => { + test('accepts hostnames and IPv4', () => { + assert.ok(isValidTargetField('app.internal.net')); + assert.ok(isValidTargetField('10.0.0.5')); + }); + test('rejects wildcards, protocol, port, path', () => { + assert.ok(!isValidTargetField('*.example.com')); + assert.ok(!isValidTargetField('**')); + assert.ok(!isValidTargetField('http://10.0.0.5')); + assert.ok(!isValidTargetField('10.0.0.5:443')); + }); +}); + +describe('collectHostFieldErrors', () => { + test('no errors when both fields are valid', () => { + assert.deepStrictEqual( + collectHostFieldErrors({host: 'api.example.com', ip: '10.0.0.5'}), + [] + ); + }); + test('wildcard host with concrete target is allowed', () => { + assert.deepStrictEqual( + collectHostFieldErrors({host: '**.example.com', ip: 'app.internal.net'}), + [] + ); + assert.deepStrictEqual(collectHostFieldErrors({host: '**'}), []); + assert.deepStrictEqual(collectHostFieldErrors({host: 'payments.**'}), []); + }); + test('flags an invalid host with a port', () => { + let errs = collectHostFieldErrors({host: 'api.example.com:8080', ip: '10.0.0.5'}); + assert.strictEqual(errs.length, 1); + assert.strictEqual(errs[0].key, 'host'); + }); + test('flags a wildcard target (not allowed) and a protocol target', () => { + assert.strictEqual(collectHostFieldErrors({ip: '*.example.com'})[0].key, 'ip'); + assert.strictEqual(collectHostFieldErrors({ip: 'http://10.0.0.5'})[0].key, 'ip'); + }); + test('skips absent / empty fields (model handles presence)', () => { + assert.deepStrictEqual(collectHostFieldErrors({}), []); + assert.deepStrictEqual(collectHostFieldErrors({host: '', ip: undefined}), []); + }); +}); 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/hostname_validate.js b/nodejs/utils/hostname_validate.js new file mode 100644 index 0000000..73f52ea --- /dev/null +++ b/nodejs/utils/hostname_validate.js @@ -0,0 +1,96 @@ +'use strict'; + +/** + * Validation for the user-supplied host / target fields on a Host entry. + * + * Neither field may carry a scheme (http://), a path ("/"), a port or ":" of any + * kind, or whitespace. + * + * host (incoming) — an IPv4 address or a hostname pattern whose dot-separated + * labels may be normal DNS labels or wildcard fragments: + * "*" matches exactly one subdomain fragment + * "**" matches any number of fragments + * e.g. "*.example.com", "**.mysite.com", "payments.**", and + * a bare "**" as a global catch-all. (Matched by + * Host.lookUp in models/host.js.) + * ip (target) — a concrete destination: an IPv4 address or a strict + * hostname (dotted, alphabetic TLD). No wildcards. + * + * Pure (no I/O) so it can be unit tested and reused. Enforced at the route layer + * (routes/host.js) so internally-created entries (wildcard children, on-demand + * cache) are unaffected. + */ + +// A single DNS label. +const LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i; +// A strict hostname: dotted labels + alphabetic TLD (for the target). +const HOSTNAME = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i; +// Scheme, path, port, or whitespace — anything that means it isn't a bare host. +const FORBIDDEN = /[\s/:]/; + +function isValidIPv4(value){ + if(typeof value !== 'string') return false; + let parts = value.split('.'); + if(parts.length !== 4) return false; + // Each octet 0-255, no leading zeros (0 itself is fine). + return parts.every(p => /^(0|[1-9]\d{0,2})$/.test(p) && Number(p) <= 255); +} + +// A strict, concrete hostname (used for the downstream target). No wildcards. +function isValidHostname(value){ + return typeof value === 'string' && HOSTNAME.test(value); +} + +// An incoming-host pattern: dot-separated labels, each a normal label or a +// wildcard fragment ("*" / "**"). A bare "**" is the global catch-all. +function isValidHostPattern(value){ + if(typeof value !== 'string' || value.length === 0 || value.length > 253) return false; + return value.split('.').every(l => l === '*' || l === '**' || LABEL.test(l)); +} + +// The incoming `host` field: IPv4 or a wildcard host pattern, no forbidden chars. +function isValidHostField(value){ + if(typeof value !== 'string' || value.length === 0) return false; + if(FORBIDDEN.test(value)) return false; + return isValidIPv4(value) || isValidHostPattern(value); +} + +// The `ip` (target) field: IPv4 or a strict hostname, no forbidden chars. +function isValidTargetField(value){ + if(typeof value !== 'string' || value.length === 0) return false; + if(FORBIDDEN.test(value)) return false; + return isValidIPv4(value) || isValidHostname(value); +} + +const NO_CHARS = 'no protocol, "/", or ":".'; + +/** + * Collect {key, message} errors for whichever of host / ip are present on the + * body. Absent fields are skipped (presence/length is handled by the model), so + * this works for both create (both present) and partial update. + */ +function collectHostFieldErrors(body){ + let errors = []; + body = body || {}; + + if(body.host !== undefined && body.host !== null && body.host !== ''){ + if(!isValidHostField(body.host)){ + errors.push({key: 'host', message: `Host must be a hostname, IP, or wildcard pattern (*, **) — ${NO_CHARS}`}); + } + } + if(body.ip !== undefined && body.ip !== null && body.ip !== ''){ + if(!isValidTargetField(body.ip)){ + errors.push({key: 'ip', message: `Target must be a valid hostname or IP address — ${NO_CHARS}`}); + } + } + return errors; +} + +module.exports = { + isValidIPv4, + isValidHostname, + isValidHostPattern, + isValidHostField, + isValidTargetField, + collectHostFieldErrors, +}; 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/hosts.ejs b/nodejs/views/hosts.ejs index 89a5342..8ef3c2e 100755 --- a/nodejs/views/hosts.ejs +++ b/nodejs/views/hosts.ejs @@ -368,7 +368,7 @@ Incoming Host Name
- +
@@ -419,7 +419,7 @@ - + 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 @@ + +