Compare commits

...

20 Commits

Author SHA1 Message Date
wmantly 60ed6b462f Merge pull request #214 from theta42/feat/permission-edit
feat: edit permission entries (v1.35.0)
2026-08-06 10:40:09 -04:00
wmantly 255835af7a feat: edit permission entries (v1.35.0)
Pull Request Tests / Run Tests (18.x) (push) Successful in 49s
Pull Request Tests / Run Tests (20.x) (push) Successful in 40s
Pull Request Tests / Run Tests (22.x) (push) Successful in 41s
Pull Request Tests / Test Summary (push) Successful in 4s
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>
2026-08-06 10:34:11 -04:00
wmantly 954cda5844 Merge pull request #213 from theta42/fix/host-sso-group-suggestions
feat: SSO group autocomplete for per-host SSO allow-lists (v1.34.0)
2026-08-05 18:55:10 -04:00
wmantly 1e38ef8dc5 feat: SSO group autocomplete for per-host SSO allow-lists (v1.34.0)
Pull Request Tests / Run Tests (18.x) (push) Successful in 33s
Pull Request Tests / Run Tests (20.x) (push) Successful in 28s
Pull Request Tests / Run Tests (22.x) (push) Successful in 31s
Pull Request Tests / Test Summary (push) Successful in 3s
The per-host "Allowed groups" field suggested only local groups,
permission subjects and conf.auth maps. None of those can ever match an
SSO-gated host: its allow-list is checked against the `groups` claim the
SSO issues (utils/host_sso.js), so only SSO groups are candidates.

Adds a conf.sso block (url + read-only apiToken, minted by theta-suite's
bootstrap) and a cached /api/group lookup merged into the suggestions.
Degrades silently to the previous local-only list when unset, and never
fails the request.

Authenticates with `Authorization: Bearer <token>` -- the SSO's
`auth-token` header is for browser session UUIDs and rejects a minted
API token.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 18:42:50 -04:00
wmantly bbaa006925 Merge pull request #212 from theta42/fix/version-1.33.0
chore: sync package.json to 1.33.0
2026-08-04 16:52:45 -04:00
wmantly eb9388a4b1 chore: sync package.json + lockfile to v1.33.0 tag
Pull Request Tests / Run Tests (18.x) (push) Successful in 32s
Pull Request Tests / Run Tests (20.x) (push) Successful in 26s
Pull Request Tests / Run Tests (22.x) (push) Successful in 29s
Pull Request Tests / Test Summary (push) Successful in 4s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 16:50:43 -04:00
wmantly 9dce4b6c24 Merge pull request #211 from theta42/release/v1.33.0
feat: error page, admin-only DNS, navbar active styling (v1.33.0)
2026-08-04 15:09:31 -04:00
wmantly 1bbf593232 feat: error page, admin-only DNS page, navbar active styling (v1.33.0)
Pull Request Tests / Run Tests (18.x) (push) Successful in 30s
Pull Request Tests / Run Tests (20.x) (push) Successful in 26s
Pull Request Tests / Run Tests (22.x) (push) Successful in 33s
Pull Request Tests / Test Summary (push) Successful in 4s
- Add SSO-style error page (views/error.ejs) and render it for browser
  navigation in the error handler (API still returns JSON).
- DNS page admin-only: forceLogin(['admin']) + nav groups ['admin'].
- Navbar: username not underlined; only the active nav link is bold+underlined.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 13:26:36 -04:00
wmantly 8107755307 Merge pull request #209 from theta42/fix/sync-version-v1.32.0
fix: sync package version to v1.32.0 tag
2026-08-03 21:33:00 -04:00
wmantly 1b8ef1f848 fix: sync package version to v1.32.0 tag
The v1.32.0 release tag was created but nodejs/package.json was left at
1.14.3 (lockfile at 1.13.3), so the deployed app's buildVersion lags its
own release tag and the update-check banner falsely reports a newer
version. Bump the version fields to match the tag.
2026-08-03 21:31:31 -04:00
wmantly eb08b6b5b9 Merge pull request #207 from theta42/refactor/standalone-removal-permissions-groups-v1.32.0
feat(proxy): remove standalone users UI/nav, refactor permissions to list view with live reload, and add dynamic reload to groups v1.32.0
2026-08-03 15:29:04 -04:00
wmantly 63be1f1020 feat(proxy): remove standalone users UI/nav, refactor permissions to list view with live reload, and add dynamic reload to groups v1.32.0
Pull Request Tests / Run Tests (18.x) (push) Successful in 30s
Pull Request Tests / Run Tests (20.x) (push) Successful in 25s
Pull Request Tests / Run Tests (22.x) (push) Successful in 28s
Pull Request Tests / Test Summary (push) Successful in 4s
2026-08-03 15:27:21 -04:00
wmantly 62cdaa2cdd Merge pull request #206 from theta42/fix/bump-version-1.14.3
chore: bump package.json version to 1.14.3
2026-08-03 02:36:46 -04:00
wmantly 13a02e6850 chore: bump package.json version to 1.14.3 2026-08-03 02:35:21 -04:00
wmantly baba3a414f Merge pull request #205 from theta42/feature/v1.14.3-docs-restoration
docs: restore proxy documentation and deployment guide
2026-08-03 02:19:25 -04:00
wmantly d049b2de49 docs: restore full proxy documentation site and DEPLOYMENT.md 2026-08-03 02:18:42 -04:00
wmantly b0e8104790 Merge pull request #203 from theta42/fix/remove-missing-docs
fix: remove DEPLOYMENT.md and docs/ from Docker build context
2026-08-02 12:09:53 -04:00
wmantly 3cc769cc3c fix: remove DEPLOYMENT.md and docs/ from Docker build context
Pull Request Tests / Run Tests (18.x) (push) Successful in 27s
Pull Request Tests / Run Tests (20.x) (push) Successful in 27s
Pull Request Tests / Run Tests (22.x) (push) Successful in 29s
Pull Request Tests / Test Summary (push) Successful in 4s
2026-08-02 11:53:48 -04:00
wmantly 94ff5c76eb docs: remove standalone deployment and docs folder 2026-08-02 00:51:30 -04:00
wmantly d74e3168ed Merge pull request #202 from theta42/release-v1.14.0
Release v1.14.0
2026-08-02 00:39:12 -04:00
19 changed files with 409 additions and 129 deletions
+19 -1
View File
@@ -1,4 +1,22 @@
# v1.13.2 ## 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.
- docs: `docs/concepts-hosts.md` gains a "Putting a host behind single sign-on" section covering the per-host `/__proxy_auth` flow, the wildcard redirect URI the IdP must allow, and where group suggestions come from.
- docs: `secrets.js.example` documents the new `sso` block.
## v1.33.0
- feat: Add SSO-style error page (404/500) for browser navigation instead of a bare JSON/text response
- feat: DNS page is now admin-only (hidden from non-admins; API already admin-gated)
- feat: navbar — username no longer underlined; only the active link is bold + underlined
## v1.13.3
- fix: remove missing DEPLOYMENT.md and docs/ from Docker build context
## v1.13.2
- chore: Update CI pipeline integration - chore: Update CI pipeline integration
# Changelog # Changelog
-2
View File
@@ -115,8 +115,6 @@ COPY nodejs/api.md ./api.md
# docs/ mirrors the repo's own top-level docs/ folder. # docs/ mirrors the repo's own top-level docs/ folder.
COPY README.md /README.md COPY README.md /README.md
COPY CHANGELOG.md /CHANGELOG.md COPY CHANGELOG.md /CHANGELOG.md
COPY DEPLOYMENT.md /DEPLOYMENT.md
COPY docs /docs
# Baked commit hash from the gitinfo stage (see build_info.js). # Baked commit hash from the gitinfo stage (see build_info.js).
COPY --from=gitinfo /commit.txt ./.build_commit COPY --from=gitinfo /commit.txt ./.build_commit
+32
View File
@@ -68,6 +68,38 @@ host form whenever the name you're entering already has a matching
wildcard available to reuse — including the wildcard's own bare base wildcard available to reuse — including the wildcard's own bare base
domain (e.g. `example.com` itself, not just `something.example.com`). domain (e.g. `example.com` itself, not just `something.example.com`).
## Putting a host behind single sign-on
Each host can be gated on its own, independently of the proxy's management UI.
On the host's **Auth** tab pick **Single sign-on (SSO)** and, optionally, fill in
the **Allowed users** / **Allowed groups** lists. Empty lists mean any
authenticated user is allowed; otherwise the identity must match one of them.
The proxy runs the OIDC flow itself at `/__proxy_auth` on the protected host and
keeps a Redis-backed session in a `__proxy_sso` cookie, so the app behind it
needs no changes.
**The IdP must allow the per-host callback.** Each protected host calls back to
`https://<that-host>/__proxy_auth/callback`, which is a different URL for every
host, all against the proxy's one OAuth client. Register a wildcard redirect URI
on that client — the SSO Manager supports `*` (one label) and `**` (any number):
```
https://**.example.com/__proxy_auth/callback
https://example.com/__proxy_auth/callback
```
theta-suite's bootstrap registers both automatically, and backfills them onto an
existing client. Without them, switching a host to SSO fails at the IdP with
`400 redirect_uri is not registered for this client`.
**Group suggestions come from the SSO.** The Allowed groups field autocompletes
from the SSO directory's groups when `sso.url` and `sso.apiToken` are set in the
proxy's config (theta-suite's bootstrap mints that read-only token). Without it
the field can only suggest the proxy's local groups, which for an SSO-gated host
are rarely the ones you want — the allow-list is matched against the `groups`
claim in the SSO's token, so only SSO groups can ever match.
## Load Balancing ## Load Balancing
If you have multiple servers running the same application, you can load balance traffic across them. When editing a host, you can specify **Additional Targets** (one `IP:port` per line). The proxy will automatically distribute incoming requests across your primary target and all additional targets using a round-robin strategy, providing simple high availability and load distribution without extra configuration. If you have multiple servers running the same application, you can load balance traffic across them. When editing a host, you can specify **Additional Targets** (one `IP:port` per line). The proxy will automatically distribute incoming requests across your primary target and all additional targets using a round-robin strategy, providing simple high availability and load distribution without extra configuration.
+15
View File
@@ -21,6 +21,8 @@ module.exports = app;
// Hold onto the auth middleware // Hold onto the auth middleware
const middleware = require('./middleware/auth'); const middleware = require('./middleware/auth');
const conf = require('@simpleworkjs/conf');
const buildInfo = require('./utils/build_info');
// Grab the projects PubSub // Grab the projects PubSub
app.contoller = require('./controller'); app.contoller = require('./controller');
@@ -115,6 +117,19 @@ app.use(async function(err, req, res, next) {
res.status(status); res.status(status);
// Only expose safe, non-internal fields to the client. // Only expose safe, non-internal fields to the client.
const body = { name: err.name, message: err.message }; const body = { name: err.name, message: err.message };
// Browser navigation gets the HTML error page (shared with SSO); API
// clients get JSON.
if (req.accepts('html') && !req.originalUrl.startsWith('/api/')) {
res.render('error', {
title: conf.environment !== 'production' ? 'dev' : '',
titleIcon: conf.environment !== 'production' ? '<i class="fa-brands fa-dev"></i>' : '',
name: conf.name,
logo: conf.logo,
...buildInfo,
error: err,
});
return;
}
res.json(body); res.json(body);
}catch(error){ }catch(error){
console.error('error in the catch-all error handler', error); console.error('error in the catch-all error handler', error);
+12
View File
@@ -45,6 +45,18 @@ module.exports = {
usernameClaim: 'preferred_username', usernameClaim: 'preferred_username',
}, },
// Read-only SSO management API access, used to populate the per-host SSO
// allow-list autocomplete with the groups that actually exist in the
// directory. Without it the "Allowed groups" field can only suggest groups
// the proxy already knows locally, which for an SSO-gated host is usually
// none of the ones the operator wants. `apiToken` is a machine token minted
// by the theta-suite bootstrap and lives in secrets.js; leaving it unset
// simply falls back to the local-only suggestions.
sso: {
url: '', // e.g. https://sso.example.com
apiToken: '',
},
// Authorization: how groups map to roles, and which groups are global admin. // Authorization: how groups map to roles, and which groups are global admin.
// Per-user overrides are Grant records managed in the app. // Per-user overrides are Grant records managed in the app.
auth: { auth: {
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "proxy-api", "name": "proxy-api",
"version": "1.13.2", "version": "1.35.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "proxy-api", "name": "proxy-api",
"version": "1.13.2", "version": "1.35.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0", "@fortawesome/fontawesome-free": "^7.3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "proxy-api", "name": "proxy-api",
"version": "1.13.2", "version": "1.35.0",
"author": [ "author": [
{ {
"name": "William Mantly", "name": "William Mantly",
+6
View File
@@ -3,6 +3,12 @@ nav.navbar{
padding-right: 1em; padding-right: 1em;
} }
/* Only the active top-nav link is bold + underlined; the username is plain. */
.top-nav a.active{
font-weight: bold;
text-decoration: underline;
}
body { body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+7 -1
View File
@@ -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){ function remove(id, callback){
app.api.delete('permission/' + encodeURIComponent(id), function(error, data){ app.api.delete('permission/' + encodeURIComponent(id), function(error, data){
callback(error, data); callback(error, data);
}); });
} }
return {list, subjects, add, remove}; return {list, subjects, add, update, remove};
})(app); })(app);
+41 -2
View File
@@ -58,16 +58,55 @@ function hashHostSecrets(body){
} }
} }
// The SSO's directory groups, for the per-host SSO allow-list autocomplete.
// The host's allow-list is checked against the `groups` claim the SSO puts in
// the token (see utils/host_sso.js), so the only suggestions that can ever
// match are the SSO's own groups -- the local groups below are a fallback, not
// the real answer. Requires conf.sso.apiToken; degrades to [] without it, and
// never fails the request (the form still works, just without suggestions).
//
// Cached for a few minutes: this is typeahead fodder, and every host editor
// opening the form would otherwise hit the SSO.
let ssoGroupCache = {at: 0, groups: []};
const SSO_GROUP_TTL = 5 * 60 * 1000;
async function ssoGroups(){
let sso = conf.sso || {};
if(!sso.url || !sso.apiToken) return [];
if(Date.now() - ssoGroupCache.at < SSO_GROUP_TTL) return ssoGroupCache.groups;
try{
let res = await fetch(`${sso.url.replace(/\/$/, '')}/api/group`, {
// A minted API token (`sso_<id>_<secret>`) authenticates as a bearer
// token; the SSO's `auth-token` header is for browser session UUIDs
// only and would be rejected here.
headers: {Authorization: `Bearer ${sso.apiToken}`, Accept: 'application/json'},
signal: AbortSignal.timeout(5000),
});
if(!res.ok) throw new Error(`SSO group list failed (${res.status})`);
let body = await res.json();
// The SSO returns { results: [...] } -- either CN strings or objects.
let groups = (body && body.results || []).map(g => (typeof g === 'string' ? g : g && g.name)).filter(Boolean);
ssoGroupCache = {at: Date.now(), groups};
return groups;
}catch(error){
console.error(`[auth-suggestions] could not list SSO groups: ${error.message}`);
// Cache the failure briefly so a down SSO doesn't stall every form open.
ssoGroupCache = {at: Date.now(), groups: ssoGroupCache.groups};
return ssoGroupCache.groups;
}
}
// Autocomplete source for the per-host auth allow-lists (SSO users/groups). // Autocomplete source for the per-host auth allow-lists (SSO users/groups).
// Available to any authenticated host editor (not just global admins). Groups // Available to any authenticated host editor (not just global admins). Groups
// are derived from local groups, existing permission group-subjects, and the // are the SSO directory's groups plus local groups, existing permission
// conf.auth admin/role-map groups. // group-subjects, and the conf.auth admin/role-map groups.
router.get('/auth-suggestions', async function(req, res, next){ router.get('/auth-suggestions', async function(req, res, next){
try{ try{
let users = []; let users = [];
try{ users = (await User.list()) || []; }catch(error){ /* none */ } try{ users = (await User.list()) || []; }catch(error){ /* none */ }
let groups = new Set(); let groups = new Set();
for(let g of await ssoGroups()) groups.add(g);
try{ for(let g of await LocalGroup.list()) groups.add(g); }catch(error){ /* none */ } try{ for(let g of await LocalGroup.list()) groups.add(g); }catch(error){ /* none */ }
try{ try{
for(let p of await Permission.listDetail()){ for(let p of await Permission.listDetail()){
+41
View File
@@ -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){ router.delete('/:id', async function(req, res, next){
try{ try{
let permission = await Permission.get(req.params.id); let permission = await Permission.get(req.params.id);
+1 -1
View File
@@ -55,7 +55,7 @@ router.get('/dns', async function(req, res, next) {
router.get('/users', async function(req, res, next) { router.get('/users', async function(req, res, next) {
res.render('users', {...values}); res.redirect(301, '/hosts');
}); });
router.get('/permissions', async function(req, res, next) { router.get('/permissions', async function(req, res, next) {
+1 -2
View File
@@ -37,8 +37,7 @@ module.exports = {
// in (plus the synthetic `admin` group when user/me reports isAdmin). // in (plus the synthetic `admin` group when user/me reports isAdmin).
nav: [ nav: [
{href: '/hosts', icon: 'fa-solid fa-network-wired', label: 'Hosts', groups: []}, {href: '/hosts', icon: 'fa-solid fa-network-wired', label: 'Hosts', groups: []},
{href: '/dns', icon: 'fa-solid fa-record-vinyl', label: 'DNS', groups: []}, {href: '/dns', icon: 'fa-solid fa-record-vinyl', label: 'DNS', groups: ['admin']},
{href: '/users', icon: 'fa-solid fa-users', label: 'Users', groups: ['admin']},
{href: '/permissions', icon: 'fa-solid fa-user-shield', label: 'Permissions', groups: ['admin']}, {href: '/permissions', icon: 'fa-solid fa-user-shield', label: 'Permissions', groups: ['admin']},
{href: '/groups', icon: 'fa-solid fa-users-gear', label: 'Groups', groups: ['admin']}, {href: '/groups', icon: 'fa-solid fa-users-gear', label: 'Groups', groups: ['admin']},
], ],
+2 -2
View File
@@ -1,7 +1,7 @@
<%- include('top') %> <%- include('top') %>
<script type="text/javascript"> <script type="text/javascript">
// Require login to see this page. // Require an admin to see this page.
app.auth.forceLogin(); app.auth.forceLogin(['admin']);
</script> </script>
<style type="text/css"> <style type="text/css">
+25
View File
@@ -0,0 +1,25 @@
<%- include('top') %>
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-6 text-center">
<div class="mb-4">
<i class="fa-solid fa-triangle-exclamation text-warning" style="font-size: 4rem;"></i>
</div>
<h1 class="display-4 fw-bold text-dark"><%= error.status || 500 %></h1>
<h3 class="mb-3 text-secondary"><%= error.message || 'Something went wrong' %></h3>
<p class="text-muted mb-4">
<% if (error.status === 404) { %>
The page you are looking for doesn't exist or has been moved.
<% } else { %>
An unexpected error occurred. Please try again later.
<% } %>
</p>
<a href="/" class="btn btn-primary shadow-sm px-4 py-2">
<i class="fa-solid fa-house me-2"></i>Return to Home
</a>
</div>
</div>
</div>
<%- include('bottom') %>
+43 -40
View File
@@ -16,6 +16,14 @@
<script type="text/javascript"> <script type="text/javascript">
function loadGroups() {
app.group.list(function(error, data){
if(error) return app.messages.action(error, $('#groups-list'), 'danger');
$.scope.LocalGroup.empty();
for(let g of (data.results || [])) $.scope.LocalGroup.push(g);
});
}
// Usernames for the "add member" autocomplete (reuses the permission // Usernames for the "add member" autocomplete (reuses the permission
// subjects endpoint, which is admin-only like this page). // subjects endpoint, which is admin-only like this page).
function loadUserSuggestions(){ function loadUserSuggestions(){
@@ -28,15 +36,16 @@
function removeGroup(name){ function removeGroup(name){
app.group.remove(name, function(error, data){ app.group.remove(name, function(error, data){
if(error) return app.messages.action(error, $.scope.LocalGroup.$this, 'danger'); if(error) return app.messages.action(error, $('#groups-list'), 'danger');
$.scope.LocalGroup.remove(name); $.scope.LocalGroup.remove(name);
loadGroups();
}); });
} }
function removeMember(group, username){ function removeMember(group, username){
app.group.removeMember(group, username, function(error, data){ app.group.removeMember(group, username, function(error, data){
if(error) return app.messages.action(error, $.scope.LocalGroup.$this, 'danger'); if(error) return app.messages.action(error, $('#groups-list'), 'danger');
// websocket update echoes the new member list. loadGroups();
}); });
} }
@@ -47,17 +56,14 @@
let username = ($input.val() || '').trim(); let username = ($input.val() || '').trim();
if(!username) return; if(!username) return;
app.group.addMember(group, username, function(error, data){ app.group.addMember(group, username, function(error, data){
if(error) return app.messages.action(error, $.scope.LocalGroup.$this, 'danger'); if(error) return app.messages.action(error, $('#groups-list'), 'danger');
$input.val(''); $input.val('');
loadGroups();
}); });
} }
$(document).ready(function(){ $(document).ready(function(){
app.group.list(function(error, data){ loadGroups();
if(error) return app.messages.action(error, $.scope.LocalGroup.$this, 'danger');
for(let g of data.results) $.scope.LocalGroup.push(g);
});
loadUserSuggestions(); loadUserSuggestions();
$.scope.LocalGroup.__take = function($el){ $.scope.LocalGroup.__take = function($el){
@@ -66,14 +72,13 @@
}; };
app.subscribe(/^model:LocalGroup:create/, function(data){ app.subscribe(/^model:LocalGroup:create/, function(data){
$.scope.LocalGroup.remove(data.name); loadGroups();
$.scope.LocalGroup.unshift(data);
}); });
app.subscribe(/^model:LocalGroup:update/, function(data, topic){ app.subscribe(/^model:LocalGroup:update/, function(data, topic){
$.scope.LocalGroup.update(topic.split(':')[3], data); loadGroups();
}); });
app.subscribe(/^model:LocalGroup:remove/, function(data, topic){ app.subscribe(/^model:LocalGroup:remove/, function(data, topic){
$.scope.LocalGroup.remove(topic.split(':')[3]); loadGroups();
}); });
}); });
</script> </script>
@@ -81,61 +86,59 @@
<div class="container mt-4"> <div class="container mt-4">
<datalist id="groupUsers"></datalist> <datalist id="groupUsers"></datalist>
<div class="row" style="display:none"> <div class="row">
<div class="col-md-4"> <div class="col-md-4 mb-4">
<div class="card shadow-lg"> <div class="card shadow">
<div class="card-header text-center"> <div class="card-header d-flex justify-content-between align-items-center">
<span class="card-icon float-start"><i class="fa-solid fa-users-gear"></i></span> <div><i class="fa-solid fa-users-gear me-2"></i><strong>Add Group</strong></div>
<span class="card-title">Add Group</span> <a href="/docs/access" class="text-reset" title="Help"><i class="fa-solid fa-circle-question"></i></a>
<a href="/docs/access" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</div> </div>
<div class="card-header actionMessage" style="display:none"></div> <div class="card-header actionMessage" style="display:none"></div>
<div class="card-body"> <div class="card-body">
<form action="group/" onsubmit="formAJAX(this)"> <form action="group/" onsubmit="formAJAX(this)" evalAJAX="loadGroups(); this.reset();">
<div class="form-group"> <div class="mb-3">
<label class="control-label">Group name</label> <label class="form-label fw-bold">Group name</label>
<input type="text" class="form-control" name="name" placeholder="dns-team" autocomplete="off" /> <input type="text" class="form-control" name="name" placeholder="dns-team" autocomplete="off" required />
<div class="text-muted" style="font-size:.8rem"> <div class="form-text">
Lowercased to a slug. Use the name as a Subject (type "group") Lowercased to a slug. Use the name as a Subject (type "group") on the Permissions page.
on the Permissions page.
</div> </div>
</div> </div>
<hr /> <hr />
<button type="submit" class="btn btn-info">Add Group</button> <button type="submit" class="btn btn-primary w-100"><i class="fa-solid fa-plus me-1"></i> Add Group</button>
</form> </form>
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-8"> <div class="col-md-8 mb-4">
<div class="row row-cols-1 g-3"> <div class="row row-cols-1 g-3" id="groups-list">
<div jq-repeat="LocalGroup" jq-repeat-index="name" style="display:none" class="col"> <div jq-repeat="LocalGroup" jq-repeat-index="name" style="display:none" class="col">
<div class="card shadow-lg"> <div class="card shadow-sm border">
<div class="card-header d-flex align-items-center"> <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-icon me-2"><i class="fa-solid fa-users text-primary"></i></span>
<span class="card-title">{{ name }}</span> <strong class="me-2">{{ name }}</strong>
<span class="badge text-bg-secondary ms-2">{{ memberCount }} member(s)</span> <span class="badge text-bg-secondary">{{ memberCount }} member(s)</span>
<button type="button" class="btn btn-sm btn-outline-danger ms-auto" onclick="removeGroup('{{name}}')"> <button type="button" class="btn btn-sm btn-outline-danger ms-auto" onclick="removeGroup('{{name}}')">
<i class="fa-solid fa-trash"></i> <i class="fa-solid fa-trash"></i>
</button> </button>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="mb-2"> <div class="mb-3">
{{#memberList}} {{#memberList}}
<span class="badge text-bg-info member-pill me-1 mb-1 fs-6"> <span class="badge text-bg-info member-pill me-1 mb-1 fs-6">
{{ username }} {{ username }}
<i class="fa-solid fa-xmark ms-1" onclick="removeMember('{{group}}','{{username}}')"></i> <i class="fa-solid fa-xmark ms-1 text-danger" onclick="removeMember('{{group}}','{{username}}')"></i>
</span> </span>
{{/memberList}} {{/memberList}}
{{^memberList}} {{^memberList}}
<span class="text-muted">No members yet.</span> <span class="text-muted small">No members yet.</span>
{{/memberList}} {{/memberList}}
</div> </div>
<div class="input-group member-add" data-group="{{name}}"> <div class="input-group input-group-sm member-add" data-group="{{name}}">
<input type="text" class="form-control" list="groupUsers" placeholder="username" autocomplete="off" <input type="text" class="form-control" list="groupUsers" placeholder="Add username..." autocomplete="off"
onkeydown="if(event.key==='Enter'){event.preventDefault();addMember(this.nextElementSibling);}" /> onkeydown="if(event.key==='Enter'){event.preventDefault();addMember(this.nextElementSibling);}" />
<button type="button" class="btn btn-success" onclick="addMember(this)"> <button type="button" class="btn btn-success" onclick="addMember(this)">
<i class="fa-solid fa-user-plus"></i> Add <i class="fa-solid fa-user-plus me-1"></i> Add
</button> </button>
</div> </div>
</div> </div>
+148 -74
View File
@@ -4,21 +4,25 @@
app.auth.forceLogin(); app.auth.forceLogin();
</script> </script>
<style type="text/css">
label.control-label{
font-weight: bold;
margin-bottom: 1px;
}
.card-title{
font-weight: bold;
}
.field-hint{
font-size: .8rem;
}
</style>
<script type="text/javascript"> <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 || [])){
permissionsById[p.id] = p;
$.scope.Permission.push(p);
}
});
}
// Fill the username/group datalists that back the Subject autocomplete. // Fill the username/group datalists that back the Subject autocomplete.
function loadSubjectSuggestions(){ function loadSubjectSuggestions(){
app.permission.subjects(function(error, data){ app.permission.subjects(function(error, data){
@@ -42,63 +46,131 @@
function permissionAddOpen(){ function permissionAddOpen(){
app.modal.open({title: 'Add Permission', bodyHtml: app.modal.open({title: 'Add Permission', bodyHtml:
'<form action="permission/" onsubmit="formAJAX(this)" evalAJAX="app.modal.close();">' '<form action="permission/" onsubmit="formAJAX(this)" evalAJAX="app.modal.close(); loadPermissions();">'
+ '<div class="form-group">' + '<div class="mb-3">'
+ '<label class="control-label">Subject type</label>' + '<label class="form-label fw-bold">Subject type</label>'
+ '<select class="form-control" name="subjectType" onchange="subjectTypeChanged(this)">' + '<select class="form-select" name="subjectType" onchange="subjectTypeChanged(this)">'
+ '<option value="user">User</option>' + '<option value="user">User</option>'
+ '<option value="group">Group</option>' + '<option value="group">Group</option>'
+ '</select>' + '</select>'
+ '</div>' + '</div>'
+ '<div class="form-group">' + '<div class="mb-3">'
+ '<label class="control-label">Subject (username or group)</label>' + '<label class="form-label fw-bold">Subject (username or group)</label>'
+ '<input type="text" class="form-control" name="subject" list="subjectUsers" placeholder="alice" autocomplete="off" />' + '<input type="text" class="form-control" name="subject" list="subjectUsers" placeholder="alice" autocomplete="off" />'
+ '</div>' + '</div>'
+ '<div class="form-group">' + '<div class="mb-3">'
+ '<label class="control-label">Scope</label>' + '<label class="form-label fw-bold">Scope</label>'
+ '<select class="form-control" name="scope">' + '<select class="form-select" name="scope">'
+ '<option value="domain">Domain</option>' + '<option value="domain">Domain</option>'
+ '<option value="global">Global</option>' + '<option value="global">Global</option>'
+ '</select>' + '</select>'
+ '</div>' + '</div>'
+ '<div class="form-group">' + '<div class="mb-3">'
+ '<label class="control-label">Domain (for domain scope)</label>' + '<label class="form-label fw-bold">Domain (for domain scope)</label>'
+ '<input type="text" class="form-control" name="domain" placeholder="example.com" autocomplete="off" />' + '<input type="text" class="form-control" name="domain" placeholder="example.com" autocomplete="off" />'
+ '<div class="field-hint text-muted">' + '<div class="form-text text-muted">'
+ 'Wildcards: <code>*.example.com</code> matches one label, ' + 'Wildcards: <code>*.example.com</code> matches one label, '
+ '<code>**.example.com</code> matches any depth (incl. the apex), ' + '<code>**.example.com</code> matches any depth (incl. the apex), '
+ '<code>**</code> matches every domain.' + '<code>**</code> matches every domain.'
+ '</div>' + '</div>'
+ '</div>' + '</div>'
+ '<div class="form-group">' + '<div class="mb-3">'
+ '<label class="control-label">Role</label>' + '<label class="form-label fw-bold">Role</label>'
+ '<select class="form-control" name="role">' + '<select class="form-select" name="role">'
+ '<option value="viewer">Viewer (read)</option>' + '<option value="viewer">Viewer (read)</option>'
+ '<option value="manager">Manager (full over domain)</option>' + '<option value="manager">Manager (full over domain)</option>'
+ '<option value="admin">Admin (global only)</option>' + '<option value="admin">Admin (global only)</option>'
+ '</select>' + '</select>'
+ '</div>' + '</div>'
+ '<hr />' + '<hr />'
+ '<button type="submit" class="btn btn-info">Add Permission</button>' + '<div class="d-flex justify-content-end gap-2">'
+ '<button type="button" class="btn btn-secondary" onclick="app.modal.close()">Cancel</button>'
+ '<button type="submit" class="btn btn-primary">Add Permission</button>'
+ '</div>'
+ '</form>', + '</form>',
}); });
} }
// 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){ function removePermission(id){
app.permission.remove(id, function(error, data){ app.permission.remove(id, function(error, data){
if(error) return app.messages.action(error, $.scope.Permission.$this, 'danger'); if(error) return app.messages.action(error, $('#permissions-list'), 'danger');
// The websocket echo removes the row; drop it locally too for snappiness.
$.scope.Permission.remove(id); $.scope.Permission.remove(id);
loadPermissions();
}); });
} }
$(document).ready(function(){ $(document).ready(function(){
// Existing permissions. loadPermissions();
app.permission.list(function(error, data){
if(error) return app.messages.action(error, $.scope.Permission.$this, 'danger');
for(let p of data.results) $.scope.Permission.push(p);
});
loadSubjectSuggestions(); loadSubjectSuggestions();
$.scope.Permission.__take = function($el, item, list){ $.scope.Permission.__take = function($el, item, list){
@@ -108,12 +180,10 @@
// Live updates (model:Permission:*), so adds/removes reflect for everyone. // Live updates (model:Permission:*), so adds/removes reflect for everyone.
app.subscribe(/^model:Permission:create/, function(data){ app.subscribe(/^model:Permission:create/, function(data){
$.scope.Permission.remove(data.id); loadPermissions();
$.scope.Permission.unshift(data);
setTimeout(function(){ app.util.revealItem($('#permission-row-' + data.id)); }, 100);
}); });
app.subscribe(/^model:Permission:remove/, function(data, topic){ app.subscribe(/^model:Permission:remove/, function(data, topic){
$.scope.Permission.remove(topic.split(':')[3]); loadPermissions();
}); });
}); });
</script> </script>
@@ -122,47 +192,51 @@
<datalist id="subjectUsers"></datalist> <datalist id="subjectUsers"></datalist>
<datalist id="subjectGroups"></datalist> <datalist id="subjectGroups"></datalist>
<div class="row" style="display:none"> <div class="row">
<div class="col-12"> <div class="col-12">
<div class="card shadow-lg"> <div class="card shadow">
<div class="card-header text-center"> <div class="card-header d-flex justify-content-between align-items-center">
<span class="card-icon float-start"> <div>
<i class="fa-solid fa-list-check"></i> <i class="fa-solid fa-user-shield me-2"></i><strong>Permissions List</strong>
</span> </div>
<span class="card-title">Permissions</span> <div>
<span class="float-end"> <a href="/docs/access" class="text-reset me-3" title="Help"><i class="fa-solid fa-circle-question"></i></a>
<a href="/docs/access" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a> <button type="button" class="btn btn-sm btn-primary" onclick="permissionAddOpen()">
<button type="button" class="btn btn-sm btn-success" onclick="permissionAddOpen()"> <i class="fa-solid fa-user-shield me-1"></i> Add Permission
<i class="fa-solid fa-user-shield"></i>
Add Permission
</button> </button>
</span> </div>
</div> </div>
<div class="card-header actionMessage" style="display:none"></div> <div class="card-header actionMessage" style="display:none"></div>
<div class="card-body"> <div class="card-body p-0">
<div class="row row-cols-1 row-cols-lg-2 g-3" id="permission-cards"> <ul class="list-group list-group-flush" id="permissions-list">
<div class="col" jq-repeat="Permission" jq-repeat-index="id" id="permission-row-{{id}}" style="display:none"> <li class="list-group-item d-flex align-items-center justify-content-between py-3" jq-repeat="Permission" jq-repeat-index="id" id="permission-row-{{id}}" style="display:none">
<div class="card shadow-sm h-100"> <div class="d-flex align-items-center">
<div class="card-body"> <div class="me-3 fs-4 text-primary">
<h6 class="mb-2"> <i class="fa-solid fa-user-check"></i>
<span class="badge text-bg-secondary">{{ subjectType }}</span> </div>
{{ subject }} <div>
</h6> <h6 class="mb-1 fw-bold">
<dl class="row mb-2 small"> <span class="badge bg-secondary me-2">{{ subjectType }}</span> {{ subject }}
<dt class="col-4">Scope</dt><dd class="col-8">{{ scope }}</dd> </h6>
<dt class="col-4">Domain</dt><dd class="col-8">{{ domain }}</dd> <div class="small text-muted">
<dt class="col-4">Role</dt><dd class="col-8">{{ role }}</dd> <span class="me-3"><strong>Scope:</strong> {{ scope }}</span>
</dl> <span class="me-3"><strong>Domain:</strong> <code>{{ domain }}</code></span>
<button type="button" class="btn btn-sm btn-danger" onclick="removePermission('{{id}}')"> <span><strong>Role:</strong> <span class="badge bg-info text-dark">{{ role }}</span></span>
<i class="fa-solid fa-trash"></i> </div>
Delete </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> </button>
</div> </div>
</div> </li>
</div> </ul>
</div>
</div> </div>
</div> </div>
</div> </div>
+1 -1
View File
@@ -49,7 +49,7 @@
</ul> </ul>
<div class="form-inline mt-2 mt-md-0"> <div class="form-inline mt-2 mt-md-0">
<% if(ui.profileUrl){ %> <% if(ui.profileUrl){ %>
<a id="cl-username" class="navbar-text text-light me-3" href="<%- ui.profileUrl %>" style="display: none;"> <a id="cl-username" class="navbar-text text-light me-3 text-decoration-none" href="<%- ui.profileUrl %>" style="display: none;">
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span> <i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
</a> </a>
<% } else { %> <% } else { %>
+12
View File
@@ -42,6 +42,18 @@ module.exports = {
usernameClaim: 'preferred_username', usernameClaim: 'preferred_username',
}, },
// Read-only access to the SSO's management API, used to populate the
// per-host SSO allow-list autocomplete with the directory's actual groups.
// A host gated on SSO matches its allow-list against the `groups` claim the
// SSO issues, so only SSO groups can ever match -- without this the field
// can only suggest the proxy's own local groups. `apiToken` is a machine
// token minted by theta-suite's bootstrap; leaving it blank simply falls
// back to local-only suggestions.
sso: {
url: 'http://sso-manager:3001',
apiToken: '',
},
// Direct LDAP user lookups. ldaps:// + rejectUnauthorized:false for a // Direct LDAP user lookups. ldaps:// + rejectUnauthorized:false for a
// self-signed cert (the SSO's default), or set tlsOptions.ca to a CA path // self-signed cert (the SSO's default), or set tlsOptions.ca to a CA path
// for strict verification. bindPassword MUST match the // for strict verification. bindPassword MUST match the