From 255835af7a0bc51adfae996fdcbafb0e9bab4c85 Mon Sep 17 00:00:00 2001 From: William Mantly Date: Thu, 6 Aug 2026 10:34:11 -0400 Subject: [PATCH] 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 --- CHANGELOG.md | 4 ++ nodejs/package-lock.json | 4 +- nodejs/package.json | 2 +- nodejs/public/lib/js/app-base.js | 8 ++- nodejs/routes/permission.js | 41 +++++++++++++++ nodejs/views/permissions.ejs | 86 +++++++++++++++++++++++++++++++- 6 files changed, 139 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 074a5a0..8d4b011 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `, not the `auth-token` header — the latter is for browser session UUIDs and would be rejected for a minted API token. diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 4204e64..8ec7598 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -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", diff --git a/nodejs/package.json b/nodejs/package.json index b01c07b..d4a92ca 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -1,6 +1,6 @@ { "name": "proxy-api", - "version": "1.34.0", + "version": "1.35.0", "author": [ { "name": "William Mantly", diff --git a/nodejs/public/lib/js/app-base.js b/nodejs/public/lib/js/app-base.js index 88f4290..fdad869 100644 --- a/nodejs/public/lib/js/app-base.js +++ b/nodejs/public/lib/js/app-base.js @@ -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); diff --git a/nodejs/routes/permission.js b/nodejs/routes/permission.js index da713e5..9e3c287 100644 --- a/nodejs/routes/permission.js +++ b/nodejs/routes/permission.js @@ -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); diff --git a/nodejs/views/permissions.ejs b/nodejs/views/permissions.ejs index 64673b6..ddece57 100644 --- a/nodejs/views/permissions.ejs +++ b/nodejs/views/permissions.ejs @@ -6,11 +6,20 @@