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:
2026-07-11 10:54:15 -04:00
parent 098ff5bc9b
commit 2acc3644c4
20 changed files with 858 additions and 120 deletions
@@ -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);
}
})();