+13
-10
@@ -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);
|
||||
}
|
||||
})();
|
||||
@@ -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'},
|
||||
|
||||
@@ -11,5 +11,6 @@ require('./dynamic_record');
|
||||
require('./host');
|
||||
require('./token');
|
||||
require('./user');
|
||||
require('./grant');
|
||||
require('./local_group');
|
||||
require('./permission');
|
||||
require('./oidc_state');
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -11,8 +11,3 @@ nav.navbar{
|
||||
.card-title{
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.actionMessage{
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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>.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}), []);
|
||||
});
|
||||
});
|
||||
@@ -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'));
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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') %>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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', '');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user