Merge pull request #115 from theta42/feat/permissions

Feat/permissions
This commit is contained in:
2026-07-11 11:22:14 -04:00
committed by GitHub
28 changed files with 1179 additions and 164 deletions
+13 -10
View File
@@ -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));
});
}
@@ -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);
}
@@ -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 `<prefix>Grant` / `<prefix>Grant_<id>` to `<prefix>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);
}
})();
+3 -1
View File
@@ -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'},
+2 -1
View File
@@ -11,5 +11,6 @@ require('./dynamic_record');
require('./host');
require('./token');
require('./user');
require('./grant');
require('./local_group');
require('./permission');
require('./oidc_state');
+70
View File
@@ -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};
@@ -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};
+3 -3
View File
@@ -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"
-5
View File
@@ -11,8 +11,3 @@ nav.navbar{
.card-title{
font-weight: bold;
}
.actionMessage{
position: fixed;
}
+46 -5
View File
@@ -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);
+87 -33
View File
@@ -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";
}
}
}
}
});
});
})();
+5 -2
View File
@@ -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;
+3 -3
View File
@@ -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;
-42
View File
@@ -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;
+61
View File
@@ -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;
+10
View File
@@ -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);
+70
View File
@@ -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;
+10 -2
View File
@@ -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/<path>.
+5 -1
View File
@@ -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,
+120
View File
@@ -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}), []);
});
});
+67
View File
@@ -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'));
+96
View File
@@ -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,
};
+56 -3
View File
@@ -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,
+145
View File
@@ -0,0 +1,145 @@
<%- include('top') %>
<script type="text/javascript">
// Require login to see this page. The API is admin-only; non-admins get 403s.
app.auth.forceLogin();
</script>
<style type="text/css">
label.control-label{
font-weight: bold;
margin-bottom: 1px;
}
.card-title{ font-weight: bold; }
.member-pill{ cursor: default; }
.member-pill i{ cursor: pointer; }
</style>
<script type="text/javascript">
// Usernames for the "add member" autocomplete (reuses the permission
// subjects endpoint, which is admin-only like this page).
function loadUserSuggestions(){
app.permission.subjects(function(error, data){
if(error || !data) return;
let $users = $('#groupUsers').empty();
for(let u of (data.users || [])) $users.append($('<option>').val(u));
});
}
function removeGroup(name){
app.group.remove(name, function(error, data){
if(error) return app.util.actionMessage(error, $.scope.LocalGroup.$this, 'danger');
$.scope.LocalGroup.remove(name);
});
}
function removeMember(group, username){
app.group.removeMember(group, username, function(error, data){
if(error) return app.util.actionMessage(error, $.scope.LocalGroup.$this, 'danger');
// websocket update echoes the new member list.
});
}
function addMember(btn){
let $wrap = $(btn).closest('.member-add');
let group = $wrap.data('group');
let $input = $wrap.find('input');
let username = ($input.val() || '').trim();
if(!username) return;
app.group.addMember(group, username, function(error, data){
if(error) return app.util.actionMessage(error, $.scope.LocalGroup.$this, 'danger');
$input.val('');
});
}
$(document).ready(function(){
app.group.list(function(error, data){
if(error) return app.util.actionMessage(error, $.scope.LocalGroup.$this, 'danger');
for(let g of data.results) $.scope.LocalGroup.push(g);
});
loadUserSuggestions();
$.scope.LocalGroup.__setTake(function($el){
$el.addClass('bg-danger');
$el.fadeOut(600, function(){ $el.remove(); });
});
app.subscribe(/^model:LocalGroup:create/, function(data){
$.scope.LocalGroup.remove(data.name);
$.scope.LocalGroup.unshift(data);
});
app.subscribe(/^model:LocalGroup:update/, function(data, topic){
$.scope.LocalGroup.update(topic.split(':')[3], data);
});
app.subscribe(/^model:LocalGroup:remove/, function(data, topic){
$.scope.LocalGroup.remove(topic.split(':')[3]);
});
});
</script>
<datalist id="groupUsers"></datalist>
<div class="row" style="display:none">
<div class="col-md-4">
<div class="card shadow-lg">
<div class="card-header text-center">
<span class="card-icon float-start"><i class="fa-solid fa-users-gear"></i></span>
<span class="card-title">Add Group</span>
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="card-body">
<form action="group/" onsubmit="formAJAX(this)">
<div class="form-group">
<label class="control-label">Group name</label>
<input type="text" class="form-control" name="name" placeholder="dns-team" autocomplete="off" />
<div class="text-muted" style="font-size:.8rem">
Lowercased to a slug. Use the name as a Subject (type "group")
on the Permissions page.
</div>
</div>
<hr />
<button type="submit" class="btn btn-info">Add Group</button>
</form>
</div>
</div>
</div>
<div class="col-md-8">
<div class="row row-cols-1 g-3">
<div jq-repeat="LocalGroup" jq-repeat-index="name" style="display:none" class="col">
<div class="card shadow-lg">
<div class="card-header d-flex align-items-center">
<span class="card-icon me-2"><i class="fa-solid fa-users"></i></span>
<span class="card-title">{{ name }}</span>
<span class="badge text-bg-secondary ms-2">{{ memberCount }} member(s)</span>
<button type="button" class="btn btn-sm btn-outline-danger ms-auto" onclick="removeGroup('{{name}}')">
<i class="fa-solid fa-trash"></i>
</button>
</div>
<div class="card-body">
<div class="mb-2">
{{#memberList}}
<span class="badge text-bg-info member-pill me-1 mb-1 fs-6">
{{ username }}
<i class="fa-solid fa-xmark ms-1" onclick="removeMember('{{group}}','{{username}}')"></i>
</span>
{{/memberList}}
{{^memberList}}
<span class="text-muted">No members yet.</span>
{{/memberList}}
</div>
<div class="input-group member-add" data-group="{{name}}">
<input type="text" class="form-control" list="groupUsers" placeholder="username" autocomplete="off"
onkeydown="if(event.key==='Enter'){event.preventDefault();addMember(this.nextElementSibling);}" />
<button type="button" class="btn btn-success" onclick="addMember(this)">
<i class="fa-solid fa-user-plus"></i> Add
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<%- include('bottom') %>
+2 -2
View File
@@ -368,7 +368,7 @@
Incoming Host Name
</label>
<div>
<input type="text" name="host" class="form-control" placeholder="ex: proxy.cloud-ops.net" validate=":3" >
<input type="text" name="host" class="form-control" placeholder="ex: proxy.cloud-ops.net, *.cloud-ops.net, **.cloud-ops.net, or **" validate="host" >
<b class="invalid-feedback"></b>
</div>
</div>
@@ -419,7 +419,7 @@
<label for="ip" class="form-label">
Target IP or Host Name
</label>
<input type="text" name="ip" class="form-control" placeholder="ex: 10.10.10.10" validate=":3" />
<input type="text" name="ip" class="form-control" placeholder="ex: 10.10.10.10 or app.internal.net" validate="target:3" />
<b class="invalid-feedback"></b>
</div>
@@ -12,36 +12,70 @@
.card-title{
font-weight: bold;
}
.field-hint{
font-size: .8rem;
}
</style>
<script type="text/javascript">
function populateGrants(){
app.grant.list(function(error, data){
if(error) return app.util.actionMessage(error, $.scope.grants.$this, 'danger');
for(let grant of data.results){
$.scope.grants.push(grant);
}
// Fill the username/group datalists that back the Subject autocomplete.
function loadSubjectSuggestions(){
app.permission.subjects(function(error, data){
if(error || !data) return;
let $users = $('#subjectUsers').empty();
for(let u of (data.users || [])) $users.append($('<option>').val(u));
let $groups = $('#subjectGroups').empty();
for(let g of (data.groups || [])) $groups.append($('<option>').val(g));
});
}
function removeGrant(id){
app.grant.remove(id, function(error, data){
if(error) return app.util.actionMessage(error, $.scope.grants.$this, 'danger');
$.scope.grants.remove(id);
// Point the Subject input at the right suggestion list for the chosen type.
function subjectTypeChanged(sel){
let $input = $(sel).closest('form').find('input[name="subject"]');
if(sel.value === 'group'){
$input.attr('list', 'subjectGroups').attr('placeholder', 'dns-team');
}else{
$input.attr('list', 'subjectUsers').attr('placeholder', 'alice');
}
}
function removePermission(id){
app.permission.remove(id, function(error, data){
if(error) return app.util.actionMessage(error, $.scope.Permission.$this, 'danger');
// The websocket echo removes the row; drop it locally too for snappiness.
$.scope.Permission.remove(id);
});
}
$(document).ready(function(){
populateGrants();
// Existing permissions.
app.permission.list(function(error, data){
if(error) return app.util.actionMessage(error, $.scope.Permission.$this, 'danger');
for(let p of data.results) $.scope.Permission.push(p);
});
$.scope.grants.__setTake(function($el, item, list){
loadSubjectSuggestions();
$.scope.Permission.__setTake(function($el, item, list){
$el.addClass('bg-danger');
$el.fadeOut(1000, function(){ $el.remove(); });
$el.fadeOut(600, function(){ $el.remove(); });
});
// Live updates (model:Permission:*), so adds/removes reflect for everyone.
app.subscribe(/^model:Permission:create/, function(data){
$.scope.Permission.remove(data.id);
$.scope.Permission.unshift(data);
});
app.subscribe(/^model:Permission:remove/, function(data, topic){
$.scope.Permission.remove(topic.split(':')[3]);
});
});
</script>
<datalist id="subjectUsers"></datalist>
<datalist id="subjectGroups"></datalist>
<div class="row" style="display:none">
<div class="col-md-4">
<div class="card shadow-lg">
@@ -50,25 +84,22 @@
<span class="card-icon float-start">
<i class="fa-solid fa-user-shield"></i>
</span>
<span class="card-title">Add Grant</span>
<span class="card-title">Add Permission</span>
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="card-body">
<form action="grant/" onsubmit="formAJAX(this)" evalAJAX="
$.scope.grants.remove(data.id);
$.scope.grants.splice(0, 0, data);
">
<form action="permission/" onsubmit="formAJAX(this)">
<div class="form-group">
<label class="control-label">Subject type</label>
<select class="form-control" name="subjectType">
<select class="form-control" name="subjectType" onchange="subjectTypeChanged(this)">
<option value="user">User</option>
<option value="group">Group</option>
</select>
</div>
<div class="form-group">
<label class="control-label">Subject (username or group)</label>
<input type="text" class="form-control" name="subject" placeholder="alice or dns-team" />
<input type="text" class="form-control" name="subject" list="subjectUsers" placeholder="alice" autocomplete="off" />
</div>
<div class="form-group">
<label class="control-label">Scope</label>
@@ -79,7 +110,12 @@
</div>
<div class="form-group">
<label class="control-label">Domain (for domain scope)</label>
<input type="text" class="form-control" name="domain" placeholder="example.com" />
<input type="text" class="form-control" name="domain" placeholder="example.com" autocomplete="off" />
<div class="field-hint text-muted">
Wildcards: <code>*.example.com</code> matches one label,
<code>**.example.com</code> matches any depth (incl. the apex),
<code>**</code> matches every domain.
</div>
</div>
<div class="form-group">
<label class="control-label">Role</label>
@@ -90,7 +126,7 @@
</select>
</div>
<hr />
<button type="submit" class="btn btn-info">Add Grant</button>
<button type="submit" class="btn btn-info">Add Permission</button>
</form>
</div>
</div>
@@ -103,7 +139,7 @@
<span class="card-icon float-start">
<i class="fa-solid fa-list-check"></i>
</span>
<span class="card-title">Grants</span>
<span class="card-title">Permissions</span>
</div>
<div class="card-header actionMessage" style="display:none"></div>
@@ -117,14 +153,14 @@
<th>Delete</th>
</thead>
<tbody>
<tr jq-repeat="grants" jq-repeat-index="id" style="display:none">
<tr jq-repeat="Permission" jq-repeat-index="id" style="display:none">
<td class="align-middle">{{ subjectType }}</td>
<td class="align-middle">{{ subject }}</td>
<td class="align-middle">{{ scope }}</td>
<td class="align-middle">{{ domain }}</td>
<td class="align-middle">{{ role }}</td>
<td class="align-middle">
<button type="button" class="btn btn-danger" onclick="removeGrant('{{id}}')">
<button type="button" class="btn btn-danger" onclick="removePermission('{{id}}')">
<i class="fa-solid fa-trash"></i>
Delete
</button>
+102
View File
@@ -0,0 +1,102 @@
<%- include('top') %>
<script type="text/javascript">
// Any authenticated user may view their own profile.
app.auth.forceLogin();
</script>
<style type="text/css">
.card-title{ font-weight: bold; }
.profile-label{ font-weight: bold; }
</style>
<script type="text/javascript">
function roleBadge(role){
let cls = role === 'admin' ? 'text-bg-danger'
: role === 'manager' ? 'text-bg-primary'
: role === 'viewer' ? 'text-bg-secondary' : 'text-bg-light';
return $('<span>').addClass('badge ' + cls).text(role);
}
function renderProfile(me){
$('#profile-username').text(me.username || '(unknown)');
// Access summary badges.
let $access = $('#profile-access').empty();
if(me.isAdmin){
$access.append($('<span>').addClass('badge text-bg-danger fs-6 me-1').text('Global administrator'));
}
if(me.global){
$access.append($('<span>').addClass('badge text-bg-primary fs-6 me-1').text('Global ' + me.global));
}
if(!me.isAdmin && !me.global){
$access.append($('<span>').addClass('text-muted').text('No global role.'));
}
// Groups (mark which are app-managed local groups).
let local = new Set(me.localGroups || []);
let $groups = $('#profile-groups').empty();
let groups = me.groups || [];
if(!groups.length){
$groups.append($('<span>').addClass('text-muted').text('Not a member of any group.'));
}
for(let g of groups){
let $b = $('<span>').addClass('badge me-1 mb-1 fs-6')
.addClass(local.has(g) ? 'text-bg-success' : 'text-bg-info').text(g);
if(local.has(g)) $b.append($('<i>').addClass('fa-solid fa-house-user ms-1').attr('title', 'local group'));
$groups.append($b);
}
// Per-domain roles.
let $domains = $('#profile-domains').empty();
let domains = me.domains || {};
let keys = Object.keys(domains).sort();
if(!keys.length){
$domains.append($('<tr>').append($('<td colspan="2">').addClass('text-muted').text('No per-domain roles.')));
}
for(let d of keys){
$domains.append($('<tr>')
.append($('<td>').addClass('align-middle').append($('<code>').text(d)))
.append($('<td>').addClass('align-middle').append(roleBadge(domains[d]))));
}
}
$(document).ready(function(){
app.api.get('user/me', function(error, data){
if(error) return app.util.actionMessage(error, $('#profile-card'), 'danger');
renderProfile(data);
});
});
</script>
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card shadow-lg" id="profile-card">
<div class="card-header text-center">
<span class="card-icon float-start"><i class="fa-solid fa-id-badge"></i></span>
<span class="card-title">My Profile</span>
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="card-body">
<h3 class="mb-3"><i class="fa-solid fa-user me-2"></i><span id="profile-username">…</span></h3>
<div class="mb-3">
<div class="profile-label">Access</div>
<div id="profile-access"></div>
</div>
<div class="mb-3">
<div class="profile-label">Groups</div>
<div id="profile-groups"></div>
</div>
<div class="mb-1 profile-label">Domain permissions</div>
<table class="table table-striped">
<thead><th>Domain</th><th>Role</th></thead>
<tbody id="profile-domains"></tbody>
</table>
</div>
</div>
</div>
</div>
<%- include('bottom') %>
+24 -4
View File
@@ -57,8 +57,18 @@
</a>
</li>
<li class="nav-item nav-admin" style="display: none;">
<a class="nav-link" href="/grants"><i class="fa-solid fa-user-shield"></i>
Grants
<a class="nav-link" href="/permissions"><i class="fa-solid fa-user-shield"></i>
Permissions
</a>
</li>
<li class="nav-item nav-admin" style="display: none;">
<a class="nav-link" href="/groups"><i class="fa-solid fa-users-gear"></i>
Groups
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/profile"><i class="fa-solid fa-id-badge"></i>
Profile
</a>
</li>
<li class="nav-item">
@@ -68,6 +78,9 @@
</li>
</ul>
<div class="form-inline mt-2 mt-md-0">
<a id="cl-username" class="navbar-text text-light me-3" href="/profile" style="display: none;">
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
</a>
<a id="cl-login-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.forceLogin()" style="display: none;">
<i class="fas fa-sign-out"></i>
Login
@@ -95,8 +108,15 @@
// Set the correct login/logout button, and reveal admin-only nav
// items for global admins.
app.auth.isLoggedIn(function(error, data){
if(data) $('#cl-logout-button').show();
else $('#cl-login-button').show();
if(data){
$('#cl-logout-button').show();
if(data.username){
$('#cl-username-text').text(data.username);
$('#cl-username').css('display', '');
}
}else{
$('#cl-login-button').show();
}
if(data && data.isAdmin) $('.nav-admin').css('display', '');
});