Compare commits

..

1 Commits

Author SHA1 Message Date
wmantly 51b42b3d8a fix: group names match docs, dedupe resource groups, agent 404, shared-secrets + vault apps, promote + plugin logs (v1.27.0)
Pull Request Tests / Run Tests (18.x) (push) Failing after 1m43s
Pull Request Tests / Run Tests (20.x) (push) Failing after 30s
Pull Request Tests / Run Tests (22.x) (push) Failing after 27s
Pull Request Tests / Test Summary (push) Failing after 3s
- group names match docs/GROUPS.md: {site}_{kind}_{name}_{level} (kind always present; services -> app kind); updated resolver + tests + access_request test
- site resource carries only god_admin + site-wide groups
- groups no longer appear 3x: idempotent ResourceGroup linking (self-heal was creating duplicates on every Directory load)
- /api/agent/* no longer 404s: REST router mounts unconditionally (was gated on the WS server)
- shared-secrets: slug regex allows underscores; GET list uses static pathFor (fixes 's.path is not a function')
- vault Apps tab: new GET /api/vault/apps + Minted apps list + purpose text; /docs/vault help link + docs cover Apps/Shared
- discovery promote: load instance and call update() (fixes 'Resource.update is not a function')
- discovery plugin cards: last-run time/status + Logs button
2026-08-04 22:56:19 -04:00
7 changed files with 77 additions and 133 deletions
-6
View File
@@ -1,9 +1,3 @@
# v1.28.0
- fix: `/api/agent/nodes` no longer 404s — the previous "unconditional mount" was still inside the post-listen `onListen` hook, so the REST router landed *behind* app.js's terminal 404 catch-all and every `/api/agent/*` request 404'd. The router is now mounted synchronously in `app.js` before the 404 handler; only the agent WebSocket setup runs on `onListen`.
- feat: promoting a discovered inventory resource now opens the resource form pre-filled with the discovered data (name, kind, IP, subtype, …) for review; the modal's Save confirms the promote (creates the LDAP groups + marks it managed) instead of silently promoting.
- fix: Directory table no longer goes stale after add/remove edge — `addEdge`/`removeEdge` called an undefined `loadData()`, which threw and left the host/parent linkage stale until a manual refresh; they now call `loadResources()`. `addGroup`/`removeGroup` also refresh so the Access column stays accurate.
- feat: Vault page states it's powered by OpenBao (header badge linking to openbao.org).
# v1.27.0 # v1.27.0
- fix: Directory group names now match `docs/GROUPS.md` exactly — per-resource groups are `{site}_{kind}_{name}_{level}` (`site_local_host_theta-env_access`, `site_local_app_sso-manager_access`), with the kind always present and the resource name slug stripped of its kind prefix. Services map to the `app` kind. The access-request + resolver tests were updated to the documented convention. - fix: Directory group names now match `docs/GROUPS.md` exactly — per-resource groups are `{site}_{kind}_{name}_{level}` (`site_local_host_theta-env_access`, `site_local_app_sso-manager_access`), with the kind always present and the resource name slug stripped of its kind prefix. Services map to the `app` kind. The access-request + resolver tests were updated to the documented convention.
- fix: a site resource now carries only `god_admin` + the site-wide groups (`{site}_super_admin`, `{site}_everyone`); the kind-scoped aggregates are still created for nesting but are no longer surfaced on the site's modal. - fix: a site resource now carries only `god_admin` + the site-wide groups (`{site}_super_admin`, `{site}_everyone`); the kind-scoped aggregates are still created for nesting but are no longer surfaced on the site's modal.
+3 -10
View File
@@ -44,10 +44,9 @@ app.onListen.push(function(){
}); });
}); });
// Initialize Theta Agent WebSockets. The REST router is already mounted // Initialize Theta Agent WebSockets
// synchronously above (see the /api/agent mount); this hook only wires the WS. require('./routes/api_agent')(app);
require('./routes/api_agent').initAgentWebSockets(app); });
});
// Gzip text responses (HTML/JS/CSS/JSON). The admin UI loads ~13 separate, // Gzip text responses (HTML/JS/CSS/JSON). The admin UI loads ~13 separate,
// uncompressed vendor JS/CSS files on every full page navigation (a // uncompressed vendor JS/CSS files on every full page navigation (a
@@ -106,12 +105,6 @@ app.use('/api/conf', middleware.auth, require('./routes/api_conf'));
// Self-service API tokens (PATs) — owner-scoped, no admin group required. // Self-service API tokens (PATs) — owner-scoped, no admin group required.
app.use('/api/api-token', middleware.auth, require('./routes/api_token')); app.use('/api/api-token', middleware.auth, require('./routes/api_token'));
// theta-agent REST API. Mounted SYNCHRONOUSLY (before the 404 catch-all below),
// not from an onListen hook — a router registered post-listen would sit behind
// the terminal 404 handler and make every /api/agent/* request 404. The agent
// WebSocket handler (routes/api_agent.initAgentWebSockets) still runs on onListen.
app.use('/api/agent', require('./routes/api_agent'));
// OAuth 2.0 / OpenID Connect // OAuth 2.0 / OpenID Connect
app.use('/oauth', oauthRouter); app.use('/oauth', oauthRouter);
app.use('/api/oauth', middleware.auth, oauthApiRouter); app.use('/api/oauth', middleware.auth, oauthApiRouter);
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "t42-sso-manager", "name": "t42-sso-manager",
"version": "1.28.0", "version": "1.27.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "t42-sso-manager", "name": "t42-sso-manager",
"version": "1.28.0", "version": "1.27.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": "t42-sso-manager", "name": "t42-sso-manager",
"version": "1.28.0", "version": "1.27.0",
"description": "A very simple LDAP management and SSO system", "description": "A very simple LDAP management and SSO system",
"author": [ "author": [
{ {
+54 -58
View File
@@ -7,64 +7,12 @@ const agentManager = require('../utils/agent_manager');
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin']; const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
// ── REST API (mounted synchronously in app.js, BEFORE the 404 catch-all) ── module.exports = function initAgentWebSockets(app) {
// This is a plain Express Router exported directly so app.js can // Only the WebSocket handler needs the WS server. The REST routes mounted
// `app.use('/api/agent', require('./routes/api_agent'))` at require time. It // below (/api/agent/*) must work regardless of the WS server state -- gating
// must NOT be mounted from the onListen hook (which runs after the 404 // them on `app.wss` made them 404 whenever it wasn't initialized.
// catch-all is already on the stack): a router registered behind that terminal if (app.wss) {
// handler would make every /api/agent/* request 404, no matter the WS server app.wss.on('connection', (ws, req) => {
// state. The WebSocket handler is separate (initAgentWebSockets below) and is
// the only part that needs the post-listen onListen hook.
const router = express.Router();
// The agent WebSocket (/api/agent/ws) is handled by the raw `wss` upgrade server
// in bin/www with its own ?token= auth — unaffected by the express middleware
// here. These REST routes are admin-facing, so they're auth + admin gated.
router.use(middleware.auth);
router.use(async (req, res, next) => {
try {
await permission.byGroup(req.user, ADMIN_GROUPS);
next();
} catch (err) {
if (err && (err.status === 401 || err.name === 'Insufficient Permission')) {
return res.status(403).json({ status: 'error', message: 'admin only' });
}
next(err);
}
});
router.get('/nodes', (req, res) => {
res.json({
status: 'ok',
agents: agentManager.getConnectedAgents(),
publicKey: agentManager.publicKeyPem
});
});
router.post('/nodes/:token/command', (req, res) => {
const { token } = req.params;
const { command, payload, isHighRisk } = req.body;
if (!command) {
return res.status(400).json({ status: 'error', message: 'Command type is required' });
}
try {
const HIGH_RISK_COMMANDS = ['reboot', 'service_restart', 'configure_ldap', 'arbitrary_bash', 'update_binary'];
const requiresSigning = isHighRisk || HIGH_RISK_COMMANDS.includes(command);
const msg = agentManager.sendCommand(token, command, payload || {}, requiresSigning);
res.json({ status: 'ok', sentMessage: msg });
} catch (err) {
res.status(400).json({ status: 'error', message: err.message });
}
});
module.exports = router;
module.exports.initAgentWebSockets = function initAgentWebSockets(app) {
// WebSocket handler only needs the WS server; runs from the onListen hook.
if (!app.wss) return;
app.wss.on('connection', (ws, req) => {
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
const token = url.searchParams.get('token') || req.headers['authorization']; const token = url.searchParams.get('token') || req.headers['authorization'];
@@ -125,4 +73,52 @@ module.exports.initAgentWebSockets = function initAgentWebSockets(app) {
})); }));
} catch (e) {} } catch (e) {}
}); });
} // end if (app.wss)
// REST API routes for Agent Management (mounted under /api/agent). The agent
// WebSocket (/api/agent/ws) is handled by the raw `wss` upgrade server in
// bin/www with its own ?token= auth — unaffected by the express middleware
// here. These REST routes are admin-facing, so they're auth + admin gated.
const router = express.Router();
router.use(middleware.auth);
router.use(async (req, res, next) => {
try {
await permission.byGroup(req.user, ADMIN_GROUPS);
next();
} catch (err) {
if (err && (err.status === 401 || err.name === 'Insufficient Permission')) {
return res.status(403).json({ status: 'error', message: 'admin only' });
}
next(err);
}
});
router.get('/nodes', (req, res) => {
res.json({
status: 'ok',
agents: agentManager.getConnectedAgents(),
publicKey: agentManager.publicKeyPem
});
});
router.post('/nodes/:token/command', (req, res) => {
const { token } = req.params;
const { command, payload, isHighRisk } = req.body;
if (!command) {
return res.status(400).json({ status: 'error', message: 'Command type is required' });
}
try {
const HIGH_RISK_COMMANDS = ['reboot', 'service_restart', 'configure_ldap', 'arbitrary_bash', 'update_binary'];
const requiresSigning = isHighRisk || HIGH_RISK_COMMANDS.includes(command);
const msg = agentManager.sendCommand(token, command, payload || {}, requiresSigning);
res.json({ status: 'ok', sentMessage: msg });
} catch (err) {
res.status(400).json({ status: 'error', message: err.message });
}
});
app.use('/api/agent', router);
}; };
+16 -54
View File
@@ -515,9 +515,6 @@
var rawResources = []; var rawResources = [];
// resourceId -> { groups: [{cn, accessLevel, exists, memberCount}], memberCount } // resourceId -> { groups: [{cn, accessLevel, exists, memberCount}], memberCount }
var accessSummary = {}; var accessSummary = {};
// When set, the resource modal's Save promotes this discovered slug (review
// the pre-filled form, then confirm) instead of a normal resource save.
var promoteSlug = null;
$(document).ready(async function() { $(document).ready(async function() {
await loadResources(); await loadResources();
@@ -1182,29 +1179,6 @@
} }
async function saveResource() { async function saveResource() {
// Promote path: the modal was opened from a discovered inventory row, so
// Save confirms promotion (creates LDAP groups + marks managed) rather than
// a normal resource create/update.
if (promoteSlug) {
const slug = promoteSlug;
promoteSlug = null;
try {
const res = await new Promise((resolve, reject) => {
app.api.post('discovery/promote/' + slug, {}, function(err, r) {
if (err) reject(err); else resolve(r);
});
});
await loadResources();
loadDiscoveryResources();
app.modal.close();
app.messages.toast('Promoted ' + slug + (res && res.groups ? ' — created groups: ' + res.groups.join(', ') : ''), 'success');
} catch (err) {
promoteSlug = slug;
app.messages.action('Failed to promote: ' + (err.message || err), app.modal.body(), 'danger');
}
return;
}
const id = $('#res-id').val(); const id = $('#res-id').val();
const data = { const data = {
name: $('#res-name').val(), name: $('#res-name').val(),
@@ -1303,7 +1277,6 @@
allGroups.push(res.results); allGroups.push(res.results);
refreshGroupsUI(resourceId); refreshGroupsUI(resourceId);
$('#new-group-cn').val(''); $('#new-group-cn').val('');
await loadResources(); // keep the Access column in sync
} catch (err) { } catch (err) {
console.error(err); console.error(err);
app.messages.action('Failed to add group', app.modal.body(), 'danger'); app.messages.action('Failed to add group', app.modal.body(), 'danger');
@@ -1315,7 +1288,6 @@
await app.api.delete('directory-admin/groups/' + id); await app.api.delete('directory-admin/groups/' + id);
allGroups = allGroups.filter(g => g.id !== id); allGroups = allGroups.filter(g => g.id !== id);
refreshGroupsUI($('#res-id').val()); refreshGroupsUI($('#res-id').val());
await loadResources(); // keep the Access column in sync
} catch (err) { } catch (err) {
console.error(err); console.error(err);
app.messages.action('Failed to remove group', app.modal.body(), 'danger'); app.messages.action('Failed to remove group', app.modal.body(), 'danger');
@@ -1344,7 +1316,7 @@
allEdges.push(res.results); allEdges.push(res.results);
refreshEdgesUI(resourceId); refreshEdgesUI(resourceId);
$('#new-edge-target').val(''); $('#new-edge-target').val('');
await loadResources(); await loadData();
} catch (err) { } catch (err) {
console.error(err); console.error(err);
app.messages.action('Failed to add edge', app.modal.body(), 'danger'); app.messages.action('Failed to add edge', app.modal.body(), 'danger');
@@ -1356,7 +1328,7 @@
await app.api.delete('directory-admin/edges/' + id); await app.api.delete('directory-admin/edges/' + id);
allEdges = allEdges.filter(e => e.id !== id); allEdges = allEdges.filter(e => e.id !== id);
refreshEdgesUI($('#res-id').val()); refreshEdgesUI($('#res-id').val());
await loadResources(); await loadData();
} catch (err) { } catch (err) {
console.error(err); console.error(err);
app.messages.action('Failed to remove edge', app.modal.body(), 'danger'); app.messages.action('Failed to remove edge', app.modal.body(), 'danger');
@@ -1417,31 +1389,21 @@
} }
} }
// Promoting a discovered resource opens the resource form pre-filled with the
// discovered data so it can be reviewed before the resource is marked managed
// (and its LDAP groups created). The modal's Save (saveResource) sees
// promoteSlug set and calls the promote endpoint instead of a normal save.
function promoteResource(slug) { function promoteResource(slug) {
const r = allDiscoveryResources.find(x => x.slug === slug); app.api.post('discovery/promote/' + slug, {}, function(err, res) {
if (!r) { app.messages.toast('Discovered resource not found', 'danger'); return; } if(err) {
promoteSlug = slug; app.messages.toast("Error promoting resource: " + (err.message || err), 'danger');
openResourceModal('Promote Resource', null); // add-mode: groups/children tabs hidden return;
const m = r.metadata || {}; }
$('#res-name').val(r.name || ''); const resource = allDiscoveryResources.find(r => r.slug === slug);
$('#res-slug').val(r.slug || ''); if(resource) {
$('#res-kind').val(r.kind || 'host'); resource.metadata = resource.metadata || {};
$('#res-description').val(r.description || ''); resource.metadata.managed = true;
$('#res-ip').val(m.ip || ''); }
$('#res-address').val(m.address || ''); $('.actionMessage').html('<div class="alert alert-success alert-dismissible"><button type="button" class="btn-close" data-bs-dismiss="alert"></button>Successfully promoted! Created groups: ' + res.groups.join(', ') + '</div>').show();
$('#res-subtype').val(m.subType || ''); renderDiscoveryTable();
$('#res-mac').val(m.macAddress || ''); loadResources(); // Also update directory tab
$('#res-port').val(m.port || ''); });
$('#res-external-port').val(m.externalPort || '');
$('#res-icon').val(m.icon || '');
$('#res-tagline').val(m.tagline || '');
updateIconPreview();
toggleFormFields();
loadLdapGroups();
} }
// --- THETA AGENT INSTALL MODAL & WIZARD --- // --- THETA AGENT INSTALL MODAL & WIZARD ---
+1 -2
View File
@@ -4,13 +4,12 @@
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
<div class="card shadow"> <div class="card shadow">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2"> <div class="card-header">
<ul class="nav nav-tabs card-header-tabs" id="vault-tabs" role="tablist"> <ul class="nav nav-tabs card-header-tabs" id="vault-tabs" role="tablist">
<li class="nav-item"><button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-secrets" type="button"><i class="fa-solid fa-lock"></i> Secrets</button></li> <li class="nav-item"><button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-secrets" type="button"><i class="fa-solid fa-lock"></i> Secrets</button></li>
<li class="nav-item" id="vault-apps-tab" style="display:none"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-apps" type="button"><i class="fa-solid fa-key"></i> Apps</button></li> <li class="nav-item" id="vault-apps-tab" style="display:none"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-apps" type="button"><i class="fa-solid fa-key"></i> Apps</button></li>
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-shared" type="button"><i class="fa-solid fa-share-nodes"></i> Shared</button></li> <li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-shared" type="button"><i class="fa-solid fa-share-nodes"></i> Shared</button></li>
</ul> </ul>
<span class="small text-muted"><i class="fa-solid fa-database me-1"></i>Powered by <a href="https://openbao.org" target="_blank" rel="noopener">OpenBao</a></span>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
<div class="tab-content"> <div class="tab-content">