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,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>
|
||||
@@ -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