feat: edit permission entries (v1.35.0)
The Permissions page only offered Delete, so changing a role or scope meant removing the grant and re-adding it from memory. A permission's id is derived from (subjectType, subject, scope, domain), so changing any of those is a different record rather than an update. The new PUT creates the new grant and removes the superseded one in that order, so an edit can never leave the old grant behind still conferring access. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,7 @@
|
||||
## v1.35.0
|
||||
- feat: **permission entries can be edited.** The Permissions page only offered Delete, so changing a role or scope meant removing the grant and re-adding it from memory. New `PUT /api/permission/:id` plus an Edit modal pre-filled from the record.
|
||||
- fix: a permission's id is derived from (subjectType, subject, scope, domain), so changing any of those is a *different* record, not an update. The endpoint creates the new grant and removes the superseded one in that order, so an edit can never leave the old grant behind still conferring access.
|
||||
|
||||
## v1.34.0
|
||||
- feat: the per-host SSO **Allowed groups** field now autocompletes from the SSO directory's groups. Suggestions previously came only from local groups, permission subjects and `conf.auth` maps — none of which can match an SSO-gated host, because its allow-list is checked against the `groups` claim the SSO issues. New `conf.sso` block (`url` + read-only `apiToken`, minted by theta-suite's bootstrap); results are cached for 5 minutes and the endpoint degrades silently to the old local-only list when unset.
|
||||
- fix: the SSO group lookup authenticates with `Authorization: Bearer <token>`, not the `auth-token` header — the latter is for browser session UUIDs and would be rejected for a minted API token.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "proxy-api",
|
||||
"version": "1.34.0",
|
||||
"version": "1.35.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "proxy-api",
|
||||
"version": "1.34.0",
|
||||
"version": "1.35.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "proxy-api",
|
||||
"version": "1.34.0",
|
||||
"version": "1.35.0",
|
||||
"author": [
|
||||
{
|
||||
"name": "William Mantly",
|
||||
|
||||
@@ -466,13 +466,19 @@ app.permission = (function(app){
|
||||
});
|
||||
}
|
||||
|
||||
function update(id, args, callback){
|
||||
app.api.put('permission/' + encodeURIComponent(id), args, function(error, data){
|
||||
callback(error, data);
|
||||
});
|
||||
}
|
||||
|
||||
function remove(id, callback){
|
||||
app.api.delete('permission/' + encodeURIComponent(id), function(error, data){
|
||||
callback(error, data);
|
||||
});
|
||||
}
|
||||
|
||||
return {list, subjects, add, remove};
|
||||
return {list, subjects, add, update, remove};
|
||||
|
||||
})(app);
|
||||
|
||||
|
||||
@@ -57,6 +57,47 @@ router.post('/', async function(req, res, next){
|
||||
}
|
||||
});
|
||||
|
||||
// Edit an existing grant.
|
||||
//
|
||||
// The record id is derived from (subjectType, subject, scope, domain)
|
||||
// -- Permission.mkId -- so changing any of those is a DIFFERENT record, not an
|
||||
// in-place update. Changing only the role is a true update. Handle both here so
|
||||
// the UI can offer a single "edit" instead of making the operator delete and
|
||||
// re-add, and so a subject/scope change can never leave the old grant behind
|
||||
// still conferring access.
|
||||
router.put('/:id', async function(req, res, next){
|
||||
try{
|
||||
let existing = await Permission.get(req.params.id);
|
||||
if(!existing) return res.status(404).json({message: `Permission ${req.params.id} not found.`});
|
||||
|
||||
let next_ = {
|
||||
subjectType: req.body.subjectType !== undefined ? req.body.subjectType : existing.subjectType,
|
||||
subject: req.body.subject !== undefined ? req.body.subject : existing.subject,
|
||||
scope: req.body.scope !== undefined ? req.body.scope : existing.scope,
|
||||
domain: req.body.domain !== undefined ? req.body.domain : existing.domain,
|
||||
role: req.body.role !== undefined ? req.body.role : existing.role,
|
||||
created_by: reqUsername(req),
|
||||
};
|
||||
if(next_.scope === 'global') next_.domain = '*';
|
||||
|
||||
// create() upserts on the new id, so this is safe in either direction;
|
||||
// remove the old record afterwards only when the identity actually moved.
|
||||
let permission = await Permission.create(next_);
|
||||
let newId = Permission.mkId(next_);
|
||||
if(newId !== req.params.id){
|
||||
try{ await existing.remove(); }catch(error){ /* already replaced */ }
|
||||
}
|
||||
|
||||
return res.json({
|
||||
message: `Updated ${next_.subjectType} "${next_.subject}" to ${next_.role}` +
|
||||
(next_.scope === 'global' ? ' globally.' : ` on ${next_.domain}.`),
|
||||
...permission,
|
||||
});
|
||||
}catch(error){
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id', async function(req, res, next){
|
||||
try{
|
||||
let permission = await Permission.get(req.params.id);
|
||||
|
||||
@@ -6,11 +6,20 @@
|
||||
|
||||
<script type="text/javascript">
|
||||
|
||||
// Kept alongside the rendered scope so the Edit modal can pre-fill from the
|
||||
// record without a second round-trip ($.scope exposes push/empty/remove,
|
||||
// not a lookup by key).
|
||||
var permissionsById = {};
|
||||
|
||||
function loadPermissions() {
|
||||
app.permission.list(function(error, data){
|
||||
if(error) return app.messages.action(error, $('#permissions-list'), 'danger');
|
||||
permissionsById = {};
|
||||
$.scope.Permission.empty();
|
||||
for(let p of (data.results || [])) $.scope.Permission.push(p);
|
||||
for(let p of (data.results || [])){
|
||||
permissionsById[p.id] = p;
|
||||
$.scope.Permission.push(p);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -82,6 +91,76 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Edit an existing grant. A permission's id is derived from
|
||||
// (subjectType, subject, scope, domain), so changing any of those replaces
|
||||
// the record rather than updating it -- the API handles that and removes the
|
||||
// superseded grant, which is why this is a single Edit rather than making
|
||||
// the operator delete and re-add (and risk leaving the old grant in place).
|
||||
function permissionEditOpen(id){
|
||||
var p = permissionsById[id] || {};
|
||||
function sel(v, want){ return v === want ? ' selected' : ''; }
|
||||
app.modal.open({title: 'Edit Permission', bodyHtml:
|
||||
'<div class="mb-3">'
|
||||
+ '<label class="form-label fw-bold">Subject type</label>'
|
||||
+ '<select class="form-select" id="perm-edit-subjectType">'
|
||||
+ '<option value="user"' + sel(p.subjectType, 'user') + '>User</option>'
|
||||
+ '<option value="group"' + sel(p.subjectType, 'group') + '>Group</option>'
|
||||
+ '</select>'
|
||||
+ '</div>'
|
||||
+ '<div class="mb-3">'
|
||||
+ '<label class="form-label fw-bold">Subject (username or group)</label>'
|
||||
+ '<input type="text" class="form-control" id="perm-edit-subject" list="subjectUsers" autocomplete="off" value="' + app.util.escapeHtml(p.subject || '') + '" />'
|
||||
+ '</div>'
|
||||
+ '<div class="mb-3">'
|
||||
+ '<label class="form-label fw-bold">Scope</label>'
|
||||
+ '<select class="form-select" id="perm-edit-scope">'
|
||||
+ '<option value="domain"' + sel(p.scope, 'domain') + '>Domain</option>'
|
||||
+ '<option value="global"' + sel(p.scope, 'global') + '>Global</option>'
|
||||
+ '</select>'
|
||||
+ '</div>'
|
||||
+ '<div class="mb-3">'
|
||||
+ '<label class="form-label fw-bold">Domain (for domain scope)</label>'
|
||||
+ '<input type="text" class="form-control" id="perm-edit-domain" autocomplete="off" value="' + app.util.escapeHtml(p.domain || '') + '" />'
|
||||
+ '<div class="form-text 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="mb-3">'
|
||||
+ '<label class="form-label fw-bold">Role</label>'
|
||||
+ '<select class="form-select" id="perm-edit-role">'
|
||||
+ '<option value="viewer"' + sel(p.role, 'viewer') + '>Viewer (read)</option>'
|
||||
+ '<option value="manager"' + sel(p.role, 'manager') + '>Manager (full over domain)</option>'
|
||||
+ '<option value="admin"' + sel(p.role, 'admin') + '>Admin (global only)</option>'
|
||||
+ '</select>'
|
||||
+ '</div>'
|
||||
+ '<hr />'
|
||||
+ '<div class="d-flex justify-content-end gap-2">'
|
||||
+ '<button type="button" class="btn btn-secondary" onclick="app.modal.close()">Cancel</button>'
|
||||
+ '<button type="button" class="btn btn-primary" onclick="permissionEditSave(\'' + id + '\')">Save changes</button>'
|
||||
+ '</div>',
|
||||
});
|
||||
}
|
||||
|
||||
function permissionEditSave(id){
|
||||
var payload = {
|
||||
subjectType: $('#perm-edit-subjectType').val(),
|
||||
subject: ($('#perm-edit-subject').val() || '').trim(),
|
||||
scope: $('#perm-edit-scope').val(),
|
||||
domain: ($('#perm-edit-domain').val() || '').trim(),
|
||||
role: $('#perm-edit-role').val(),
|
||||
};
|
||||
if(!payload.subject){
|
||||
return app.messages.toast('Subject is required', 'warning');
|
||||
}
|
||||
app.permission.update(id, payload, function(error, data){
|
||||
if(error) return app.messages.action((data && data.message) || error, $('#permissions-list'), 'danger');
|
||||
app.modal.close();
|
||||
loadPermissions();
|
||||
});
|
||||
}
|
||||
|
||||
function removePermission(id){
|
||||
app.permission.remove(id, function(error, data){
|
||||
if(error) return app.messages.action(error, $('#permissions-list'), 'danger');
|
||||
@@ -148,7 +227,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="permissionEditOpen('{{id}}')">
|
||||
<i class="fa-solid fa-pen me-1"></i> Edit
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removePermission('{{id}}')">
|
||||
<i class="fa-solid fa-trash me-1"></i> Delete
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user