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
+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') %>
@@ -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', '');
});