Permissions: rename Grants, add wildcards, local groups, profile
- Rename Grant -> Permission end-to-end (model, routes, view, frontend, bootstrap) and add an idempotent redis migration for existing records. - utils/roles.js: glob domain matching (* = one label, ** = any depth) against the full host; authz passes the full hostname. - Local groups: LocalGroup model + admin routes/UI; membership merged into Permission.effectiveFor so app groups behave like SSO groups. - Subject autocomplete via GET /api/permission/subjects (users + derived groups). - User profile page (/profile) and username in the navbar; /api/user/me now returns merged/local/external groups. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
'use strict';
|
||||
|
||||
const Table = require('.');
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const roles = require('../utils/roles');
|
||||
const ModelPs = require('../utils/model_pubsub');
|
||||
|
||||
/**
|
||||
* 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
|
||||
* defaults can also be seeded from conf.auth.groupRoleMap.
|
||||
*
|
||||
* subjectType : 'user' | 'group'
|
||||
* subject : username or group name
|
||||
* scope : 'global' | 'domain'
|
||||
* domain : domain pattern when scope==='domain' (else '*'). May be a
|
||||
* glob: "*" = one label, "**" = any depth (see utils/roles).
|
||||
* role : 'admin' | 'manager' | 'viewer'
|
||||
*
|
||||
* 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 Permission extends Table{
|
||||
static _key = 'id';
|
||||
static _keyMap = {
|
||||
'created_by': {isRequired: true, type: 'string', min: 3, max: 500},
|
||||
'created_on': {default: function(){return (new Date).getTime()}},
|
||||
'id': {isRequired: true, type: 'string', min: 3, max: 1100},
|
||||
'subjectType': {isRequired: true, type: 'string'},
|
||||
'subject': {isRequired: true, type: 'string', min: 1, max: 500},
|
||||
'scope': {default: 'domain', isRequired: true, type: 'string'},
|
||||
'domain': {default: '*', isRequired: false, type: 'string'},
|
||||
'role': {isRequired: true, type: 'string'},
|
||||
}
|
||||
|
||||
static roles = ['viewer', 'manager', 'admin'];
|
||||
// Re-export the pure helpers so existing callers (middleware/authz) can use
|
||||
// them off the model.
|
||||
static rank = roles.rank;
|
||||
static maxRole = roles.maxRole;
|
||||
static roleForDomain = roles.roleForDomain;
|
||||
static allows = roles.allows;
|
||||
static visibleDomains = roles.visibleDomains;
|
||||
|
||||
// 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 || '*')}`;
|
||||
}
|
||||
|
||||
static async create(data){
|
||||
if(!this.roles.includes(data.role)){
|
||||
throw this.errors.ObjectValidateError([{key: 'role', message: `role must be one of ${this.roles.join(', ')}`}]);
|
||||
}
|
||||
if(!['user', 'group'].includes(data.subjectType)){
|
||||
throw this.errors.ObjectValidateError([{key: 'subjectType', message: `subjectType must be 'user' or 'group'`}]);
|
||||
}
|
||||
if(data.scope === 'global') data.domain = '*';
|
||||
data.id = this.mkId(data);
|
||||
// Upsert: replace an existing identical-scoped permission instead of 409ing.
|
||||
try{
|
||||
let existing = await this.get(data.id);
|
||||
if(existing) await existing.remove();
|
||||
}catch(error){ /* not found is fine */ }
|
||||
|
||||
return super.create(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: {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 = [];
|
||||
try{
|
||||
grants = await this.listDetail();
|
||||
}catch(error){ grants = []; }
|
||||
|
||||
// Ownership: a user has manager rights over every domain they (or the
|
||||
// DNS provider they created) own. Domain.created_by already carries the
|
||||
// provider creator, so a single Domain scan covers both.
|
||||
let ownedDomains = [];
|
||||
if(username){
|
||||
try{
|
||||
let Domain = require('.').models.Domain;
|
||||
ownedDomains = (await Domain.listDetail())
|
||||
.filter(d => d.created_by === username)
|
||||
.map(d => d.domain);
|
||||
}catch(error){ /* domains unavailable, skip ownership */ }
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Permission.register(ModelPs(Permission));
|
||||
|
||||
module.exports = {Permission};
|
||||
Reference in New Issue
Block a user