diff --git a/CHANGELOG.md b/CHANGELOG.md index a388384..d63c7b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,72 @@ All notable changes to this project are documented here. Format loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`. +## [1.17.2] - 2026-08-01 + +Post-deploy fixes from testing the v1.31.0 stack, plus the SMS (VoIP.ms) and +Terms-of-Service configuration the `/conf` page was missing. Seven issues: + +### Fixed +- **Plugin slug is now auto-generated** from the instance name — the New Plugin + modal no longer asks for a Slug (it derived a stable, unique handle from the + name, appending `-2`, `-3`, … on collision). The generated slug still shows in + the table and the Edit (read-only) modal. `POST /api/plugins` `slug` is now + optional; an explicit slug is still accepted and validated. (`routes/api_plugins.js`, + `views/plugins.ejs`) +- **Plugin schedule is a dropdown**, not a raw cron box: Hourly / Daily / + Weekly, plus **Custom** which reveals the raw 5-field cron input. Stored value + is still a cron string, so the server is unchanged. (`views/plugins.ejs`) +- **`/vault` secrets list no longer 403s.** Root cause: the per-user, per-app, + and admin OpenBao policies granted `list` only on `secret/metadata/.../*` + (nested paths), never on the directory path itself — so listing a directory's + *contents* (which checks `list` on the directory, e.g. `secret/metadata/users/` + or the mount root `secret/metadata`) was denied. `vault_broker.js`'s + `userPolicyHcl`/`appPolicyHcl` now also grant `list` on the bare directory + path, and `ensurePolicy` now always re-writes the policy (idempotent) so + already-created `user-` policies pick up the new grant on the next + vault-page visit. The matching `sso-admin` mount-root grant ships in + theta-suite v1.31.1 (`setup.sh`), where `ensure_policy` is likewise made + always-write so re-running `./setup.sh` applies policy edits. +- **`/profile` no longer shows literal `{{…}}` tags.** Three template fragments + sat outside the `jq-repeat="user"` scope, so they rendered raw: the card + header `Profile: {{user.uid}}`, the `Members of {{user.uid}}'s Group` tab + label, and the Admin Actions block's `{{#isActive}}`/`{{#isInactive}}` + buttons. The header/label are now populated by JS (the `Members` label + already had a setter pointing at a missing id); the Admin Actions block is + moved inside the scope so `{{uid}}`/`{{#isActive}}`/`{{#isInactive}}` render + and the correct Activate/Deactivate button shows. (`views/profile.ejs`) +- **Editing a plugin now persists.** The Edit modal had been prefilled with the + masked secret values and rendered them as fields, but `PUT /:id` only saves + non-secret config — so an edited secret was silently dropped. The Edit modal + now shows **non-secret fields only** (secrets have their own Edit-Secrets + modal), removing the confusion. (`views/plugins.ejs`) +- **nmap plugin: "NMAP not found at command location: nmap"** — the `nmap` + binary was not installed in the app image. `Dockerfile.openldap` now `apk + add`s `nmap` in the runtime stage, and `plugins/discovery/nmap.js` translates + the opaque node-nmap spawn-missing error into an actionable `lastError`. + +### Added +- **SMS (VoIP.ms) configuration on `/conf`.** The existing VoIP.ms SMS sender + (`models/sms.js`, used for 2FA OTP delivery) was configurable only via env / + config files. It now has an SMS card on `/conf` (API username, DID, API + password), saved to OpenBao at `secret/sso-manager/conf` under `voipms`, with + the API password masked (`********`) and leave-blank-to-keep — mirroring the + SMTP card exactly. `models/sms.js` reads `conf.voipms.*` at call time, so a + saved change takes effect live without a restart. (`routes/api_conf.js`, + `views/conf.ejs`) +- **Terms of Service editor moved to `/conf`** from the admin Overview + dashboard, where it never belonged. The same `app.tos.get`/`update` flow, + the "require all users to re-accept" checkbox, and the `app_sso_admin` gate + (matching `routes/tos.js`'s PUT gate) are preserved. The Overview page keeps + stats, notifications, and metrics. (`views/conf.ejs`, `views/overview.ejs`) + +### Notes +- The `/vault` 403 fix is split across two repos: the sso-side per-user/app + policy grants and `ensurePolicy`-always-write ship here; the `sso-admin` + mount-root grant and `ensure_policy`-always-write ship in theta-suite v1.31.1. + Re-running `./setup.sh` after upgrading applies the sso-admin grant; per-user + policies self-heal on the next vault-page visit. + ## [1.17.1] - 2026-08-01 Hardens the **runtime SMTP/OAuth secret handling** on the `/conf` admin page to diff --git a/Dockerfile.openldap b/Dockerfile.openldap index 813c3b0..c373ddc 100644 --- a/Dockerfile.openldap +++ b/Dockerfile.openldap @@ -122,6 +122,7 @@ RUN apk add --no-cache \ dumb-init \ bash \ redis \ + nmap \ && rm -rf /var/cache/apk/* COPY --from=ldapbuild /opt/openldap /opt/openldap diff --git a/nodejs/package.json b/nodejs/package.json index b9b804f..6b09504 100755 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -1,6 +1,6 @@ { "name": "t42-sso-manager", - "version": "1.17.1", + "version": "1.17.2", "description": "A very simple LDAP management and SSO system", "author": [ { diff --git a/nodejs/plugins/discovery/nmap.js b/nodejs/plugins/discovery/nmap.js index 465f8a7..1f6f5fd 100644 --- a/nodejs/plugins/discovery/nmap.js +++ b/nodejs/plugins/discovery/nmap.js @@ -66,7 +66,17 @@ module.exports = { }); scan.on('error', function(error) { - reject(error); + // node-nmap's spawn-missing-binary message ("NMAP not found at command + // location: nmap") is opaque to an admin reading lastError. Translate + // it into something actionable. (The Dockerfile installs nmap in the + // app image; this only fires if someone runs outside the container or + // strips the package.) + var msg = (error && error.message) || String(error); + if (/nmap.*not found|command location/i.test(msg)) { + reject(new Error('nmap binary not installed in the container image (rebuild with Dockerfile.openldap, which apk-adds nmap)')); + } else { + reject(error); + } }); scan.startScan(); diff --git a/nodejs/routes/api_conf.js b/nodejs/routes/api_conf.js index aac87ba..3492889 100644 --- a/nodejs/routes/api_conf.js +++ b/nodejs/routes/api_conf.js @@ -21,6 +21,7 @@ const MASK = '********'; const SECRET_PATHS = [ ['smtp', 'pass'], ['oauth', 'jwtSecret'], + ['voipms', 'password'], ]; function maskSecrets(obj) { @@ -35,7 +36,8 @@ router.get('/', async (req, res) => { const editable = maskSecrets({ smtp: conf.smtp || {}, discovery: conf.discovery || {}, - oauth: conf.oauth || {} + oauth: conf.oauth || {}, + voipms: conf.voipms || {} }); res.json(editable); }); diff --git a/nodejs/routes/api_plugins.js b/nodejs/routes/api_plugins.js index efda2f2..23301a2 100644 --- a/nodejs/routes/api_plugins.js +++ b/nodejs/routes/api_plugins.js @@ -20,6 +20,29 @@ const { scheduleInstance, unscheduleInstance, runInstanceNow } = require('../ser const SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/; +// Derive a stable, unique slug from an instance name when the caller didn't +// supply one. Lowercases, collapses non-alnum runs to a single hyphen, trims, +// and prefixes `plugin-` if the result would otherwise start with a character +// SLUG_RE rejects. `isTaken(slug)` is consulted for uniqueness (a DB lookup); +// on collision we append `-2`, `-3`, … up to MAX_TRIES, then give up. +function slugify(name) { + let s = String(name || '').toLowerCase().trim(); + s = s.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); + if (!s) s = 'plugin'; + if (!/^[a-z0-9]/.test(s)) s = 'plugin-' + s; + return s.slice(0, 64); +} + +async function makeSlug(name, isTaken) { + const base = slugify(name); + if (!await isTaken(base)) return base; + for (let i = 2; i <= 16; i++) { + const cand = `${base}-${i}`.slice(0, 64); + if (!await isTaken(cand)) return cand; + } + return null; // exhausted +} + // Same gate as the directory admin API: app_sso_admin or app_sso_directory_admin // (app_super_admin is always allowed by permission.byGroup). router.use(async (req, res, next) => { @@ -82,7 +105,15 @@ router.post('/', async (req, res, next) => { if (!pluginType) return res.status(400).json({ error: 'pluginType is required' }); if (!registry.getManifest(pluginType)) return res.status(400).json({ error: `Unknown plugin type: ${pluginType}` }); if (!name) return res.status(400).json({ error: 'name is required' }); - if (!slug || !SLUG_RE.test(slug)) return res.status(400).json({ error: 'slug must be lowercase letters/digits/_/- (max 64)' }); + // Slug is optional: derive it from the name when absent. When supplied, + // validate it (admins editing via API may still pass one explicitly). + let finalSlug = slug; + if (finalSlug) { + if (!SLUG_RE.test(finalSlug)) return res.status(400).json({ error: 'slug must be lowercase letters/digits/_/- (max 64)' }); + } else { + finalSlug = await makeSlug(name, async (s) => !!(await PluginInstance.getBySlug(s))); + if (!finalSlug) return res.status(400).json({ error: 'Could not generate a unique slug from the name; supply one explicitly.' }); + } if (cron !== undefined && (typeof cron !== 'string' || !cron.trim())) return res.status(400).json({ error: 'cron must be a non-empty string' }); // `config` from the client is a flat object of all field values (secret + @@ -100,7 +131,7 @@ router.post('/', async (req, res, next) => { pluginType, category: manifest.category, name, - slug, + slug: finalSlug, enabled, cron: cron || '0 * * * *', config, diff --git a/nodejs/utils/vault_broker.js b/nodejs/utils/vault_broker.js index 821af74..80f160b 100644 --- a/nodejs/utils/vault_broker.js +++ b/nodejs/utils/vault_broker.js @@ -54,11 +54,14 @@ async function bao(method, path, body) { return res; } -// Ensure an ACL policy exists (idempotent). 200 = exists, 404 = create. +// Ensure an ACL policy exists AND carries the latest HCL. Always (re)writes — +// `bao policy write` is an idempotent overwrite — so policy edits (e.g. adding +// a list grant on a directory path) propagate on the next vault-page visit +// without an operator re-running setup.sh. Skipping on an existing policy +// would strand the old, narrower HCL forever. async function ensurePolicy(name, hcl) { const existing = await baoConf.request('GET', `sys/policies/acl/${name}`); - if (existing.status === 200) return; - if (existing.status !== 404) { + if (existing.status !== 200 && existing.status !== 404) { const t = await existing.text().catch(() => ''); throw new Error(`OpenBao policy read ${name} failed (${existing.status}) ${t}`); } @@ -80,7 +83,11 @@ async function mintToken(policies) { function userPolicyHcl(uid) { // uid is an LDAP uid (alphanumeric + a few separators); it is interpolated // into a policy path, so reject anything but a safe charset. + // The bare `secret/metadata/users/` grant is required to LIST the + // contents of the namespace: `.../*` covers nested paths but NOT the + // directory itself, so without it the /vault secrets list 403s. return `path "secret/data/users/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] } +path "secret/metadata/users/${uid}" { capabilities = ["list", "read", "delete"] } path "secret/metadata/users/${uid}/*" { capabilities = ["list", "read", "delete"] }`; } @@ -109,7 +116,10 @@ async function getOrCreateAdminToken(uid) { // ── Per-app token (minted ONCE, returned to the caller, never cached) ─────── function appPolicyHcl(name) { + // The bare `secret/metadata/apps/` grant lets an app LIST its own + // namespace root (see userPolicyHcl for why `/*` alone isn't enough). return `path "secret/data/apps/${name}/*" { capabilities = ["create", "read", "update", "delete", "list"] } +path "secret/metadata/apps/${name}" { capabilities = ["list", "read", "delete"] } path "secret/metadata/apps/${name}/*" { capabilities = ["list", "read", "delete"] }`; } diff --git a/nodejs/views/conf.ejs b/nodejs/views/conf.ejs index d9750e4..472e1c3 100644 --- a/nodejs/views/conf.ejs +++ b/nodejs/views/conf.ejs @@ -4,6 +4,7 @@ $(document).ready(function() { loadConf(); + loadTos(); }); async function loadConf() { @@ -28,6 +29,13 @@ $('#oauth-token-refresh').val(data.oauth.token_lifetime.refresh_token || 2592000); } } + + // Populate SMS (VoIP.ms) + if (data.voipms) { + $('#voipms-username').val(data.voipms.username || ''); + $('#voipms-did').val(data.voipms.did || ''); + $('#voipms-password').val(data.voipms.password || ''); + } } catch (error) { app.messages.toast('Failed to load configuration: ' + (error.message || 'Unknown error'), 'danger'); } @@ -53,6 +61,11 @@ access_token: parseInt($('#oauth-token-access').val(), 10) || 3600, refresh_token: parseInt($('#oauth-token-refresh').val(), 10) || 2592000 } + }, + voipms: { + username: $('#voipms-username').val(), + did: $('#voipms-did').val(), + password: $('#voipms-password').val() } }; @@ -74,6 +87,50 @@ el.type = 'password'; } } + + // ── Terms of Service editor ────────────────────────────────────────── + // Moved here from the admin Overview dashboard — it's a configuration + // control, so it belongs on the System Configuration page. The API is + // routes/tos.js (GET to read, PUT to save; PUT is app_sso_admin-gated, which + // matches this page's gate). app.tos.get/update are the shared frontend + // helpers (@simpleworkjs/frontend). + async function loadTos() { + try { + const tos = await app.tos.get(); + document.getElementById('tos-content').value = tos.content; + document.getElementById('tos-meta').textContent = + 'Last updated ' + moment(tos.updated_on, 'x').fromNow() + ' by ' + tos.updated_by; + } catch(e) { + console.error('Failed to load ToS:', e); + } + } + + function saveTos() { + const content = document.getElementById('tos-content').value.trim(); + const resetAcceptance = document.getElementById('tos-reset-acceptance').checked; + const msgEl = document.getElementById('tos-result'); + + if (!content) { + msgEl.className = 'alert alert-danger mt-2'; + msgEl.textContent = 'Terms of Service text cannot be empty.'; + msgEl.style.display = ''; + return; + } + + app.tos.update({content, resetAcceptance}, function(error, data) { + if (error) { + msgEl.className = 'alert alert-danger mt-2'; + msgEl.textContent = 'Failed: ' + ((data && data.message) || error); + msgEl.style.display = ''; + return; + } + msgEl.className = 'alert alert-success mt-2'; + msgEl.textContent = 'Saved.' + (data.resetCount ? ' ' + data.resetCount + ' user(s) will be asked to re-accept.' : ''); + msgEl.style.display = ''; + document.getElementById('tos-reset-acceptance').checked = false; + loadTos(); + }); + }
@@ -82,10 +139,10 @@

System Configuration

- Manage runtime configuration such as SMTP settings and OAuth parameters. - These are stored securely in OpenBao and take effect immediately. Secret fields - (the SMTP password and OAuth JWT secret) are masked — leave them unchanged to - keep the stored value. + Manage runtime configuration such as SMTP, SMS, OAuth, and Terms of Service + settings. These are stored securely in OpenBao and take effect immediately. + Secret fields (the SMTP password, OAuth JWT secret, and VoIP.ms API password) + are masked — leave them unchanged to keep the stored value.

@@ -164,6 +221,56 @@
+ +
+
+
+
+
SMS (VoIP.ms)
+
+
+

Used to deliver SMS 2FA login codes. The API password is stored in OpenBao and masked below.

+
+ + +
+
+ + +
+
+ +
+ + +
+
Leave unchanged to keep the current password stored in OpenBao. Clear and type a new value to replace it.
+
+
+
+
+ +
+
+
+
Terms of Service
+ +
+
+
+ + +
+
+ + +
+ + +
+
+
+
<%- include('bottom') %> diff --git a/nodejs/views/overview.ejs b/nodejs/views/overview.ejs index 8fea355..0559e34 100644 --- a/nodejs/views/overview.ejs +++ b/nodejs/views/overview.ejs @@ -162,50 +162,10 @@ } } - // ── Terms of Service ────────────────────────────────────────────────── - async function loadTos() { - try { - const tos = await app.tos.get(); - document.getElementById('tos-content').value = tos.content; - document.getElementById('tos-meta').textContent = - 'Last updated ' + moment(tos.updated_on, 'x').fromNow() + ' by ' + tos.updated_by; - } catch(e) { - console.error('Failed to load ToS:', e); - } - } - - function saveTos() { - const content = document.getElementById('tos-content').value.trim(); - const resetAcceptance = document.getElementById('tos-reset-acceptance').checked; - const msgEl = document.getElementById('tos-result'); - - if (!content) { - msgEl.className = 'alert alert-danger mt-2'; - msgEl.textContent = 'Terms of Service text cannot be empty.'; - msgEl.style.display = ''; - return; - } - - app.tos.update({content, resetAcceptance}, function(error, data) { - if (error) { - msgEl.className = 'alert alert-danger mt-2'; - msgEl.textContent = 'Failed: ' + ((data && data.message) || error); - msgEl.style.display = ''; - return; - } - msgEl.className = 'alert alert-success mt-2'; - msgEl.textContent = 'Saved.' + (data.resetCount ? ' ' + data.resetCount + ' user(s) will be asked to re-accept.' : ''); - msgEl.style.display = ''; - document.getElementById('tos-reset-acceptance').checked = false; - loadTos(); - }); - } - $(document).ready(function() { loadDashboard(); loadHistory(); toggleFilterInputs(); - loadTos(); loadMetrics(); }); @@ -385,30 +345,6 @@ - -
-
-
Terms of Service Editor
- -
-
-
- - -
-
- - -
- - -
-
-
diff --git a/nodejs/views/plugins.ejs b/nodejs/views/plugins.ejs index 3df1d92..92e15ea 100644 --- a/nodejs/views/plugins.ejs +++ b/nodejs/views/plugins.ejs @@ -128,12 +128,17 @@ // Build an HTML form fragment for a type's configSchema. `prefix` namespaces // the field ids so the New and Edit modals don't collide. `values` (optional) // pre-fills fields (masked secrets stay masked; non-secret values are shown). - function configFormHtml(type, prefix, values) { + // `includeSecrets` (default true) — the Edit (non-secret) modal passes false so + // secret fields are never shown there (secrets have their own modal); the New + // modal passes true so initial secrets can be set at create time. + function configFormHtml(type, prefix, values, includeSecrets) { var schema = pluginTypes[type] && pluginTypes[type].configSchema; if (!schema || !schema.length) return '

No configuration fields for this plugin.

'; + if (includeSecrets === undefined) includeSecrets = true; var v = values || {}; var html = ''; schema.forEach(function(f) { + if (!includeSecrets && f.secret) return; var val = v[f.key]; if (val === undefined || val === null) val = ''; var inputType = f.type === 'password' ? 'password' : (f.type === 'url' ? 'url' : 'text'); @@ -149,6 +154,53 @@ return html; } + // ── Schedule picker (Hourly / Daily / Weekly / Custom) ─────────────────── + // The stored value is always a 5-field cron string. A `' + opts + '' + + ''; + } + function onCronChange(prefix) { + var sel = document.getElementById(prefix + 'cron-select'); + var raw = document.getElementById(prefix + 'cron'); + if (!sel || !raw) return; + if (sel.value === 'custom') { + raw.style.display = ''; + } else { + raw.style.display = 'none'; + var preset = CRON_PRESETS.filter(function(p){ return p.key === sel.value; })[0]; + if (preset) raw.value = preset.cron; + } + } + function cronFromForm(prefix) { + var sel = document.getElementById(prefix + 'cron-select'); + if (sel && sel.value !== 'custom') { + var preset = CRON_PRESETS.filter(function(p){ return p.key === sel.value; })[0]; + if (preset) return preset.cron; + } + var raw = document.getElementById(prefix + 'cron'); + return (raw && raw.value.trim()) || '0 * * * *'; + } + // Collect a flat {field: value} object from the rendered config form. function collectConfig(type, prefix) { var schema = pluginTypes[type] && pluginTypes[type].configSchema; @@ -179,10 +231,9 @@ '
' + '
' + '
' + - '
' + - '
' + - '
' + - '
' + + '
' + + cronSelectHtml('np-', '0 * * * *') + + '
A slug is derived automatically from the name.
' + '
Configuration

Select a plugin type first.

', footer: { buttonsHtml: app.modal.footerButtons({ onSave: 'saveNewPlugin()', saveLabel: 'Create Plugin' }) } }); @@ -197,13 +248,11 @@ var type = document.getElementById('np-type').value; if (!type) return app.messages.action('Select a plugin type.', app.modal.body(), 'danger'); var name = document.getElementById('np-name').value.trim(); - var slug = document.getElementById('np-slug').value.trim(); - var cron = document.getElementById('np-cron').value.trim() || '0 * * * *'; + var cron = cronFromForm('np-'); if (!name) return app.messages.action('Name is required.', app.modal.body(), 'danger'); - if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(slug)) return app.messages.action('Slug must be lowercase letters/digits/_/- (max 64).', app.modal.body(), 'danger'); var config = collectConfig(type, 'np-'); try { - await app.api.post('plugins', { pluginType: type, name: name, slug: slug, cron: cron, config: config }); + await app.api.post('plugins', { pluginType: type, name: name, cron: cron, config: config }); app.modal.close(); app.messages.toast('Plugin created and scheduled.', 'success'); loadPlugins(); @@ -224,9 +273,10 @@ '
' + '
' + '
' + - '
' + - '
' + - '
Configuration
' + configFormHtml(p.pluginType, 'ed-', Object.assign({}, p.config, p.secrets)) + '
', + '
' + + cronSelectHtml('ed-', p.cron || '0 * * * *') + '
' + + '
Configuration
' + configFormHtml(p.pluginType, 'ed-', p.config, false) + '
' + + '
Secret fields are edited separately with the button.
', footer: { metaHtml: app.modal.formatAudit ? app.modal.formatAudit(p, { formatDate: function(ms){ return moment(ms).format('YYYY-MM-DD HH:mm'); } }) : '', buttonsHtml: app.modal.footerButtons({ onSave: 'saveEdit("' + id + '")', saveLabel: 'Save' }) @@ -238,7 +288,7 @@ var p = pluginsById[id]; if (!p) return; var name = document.getElementById('ed-name').value.trim(); - var cron = document.getElementById('ed-cron').value.trim() || '0 * * * *'; + var cron = cronFromForm('ed-'); if (!name) return app.messages.action('Name is required.', app.modal.body(), 'danger'); var config = collectConfig(p.pluginType, 'ed-'); try { diff --git a/nodejs/views/profile.ejs b/nodejs/views/profile.ejs index e846086..799e6b9 100644 --- a/nodejs/views/profile.ejs +++ b/nodejs/views/profile.ejs @@ -9,6 +9,7 @@ user.createTimestamp = moment(user.createTimestamp, "YYYYMMDDHHmmssZ").fromNow(); user.modifyTimestamp = moment(user.modifyTimestamp, "YYYYMMDDHHmmssZ").fromNow(); user.managerUids = (user.manager || []).map(app.user.dnToUid); + $('#profile-uid-header').text(user.uid); $.scope.user.update(user); }; @@ -241,7 +242,7 @@
- Profile: {{user.uid}} + Profile:
@@ -329,27 +330,27 @@

Joined: {{createTimestamp}} | Edited: {{modifyTimestamp}}

-
-
-
Admin Actions
-
- {{#isActive}} - - {{/isActive}} - {{#isInactive}} - - {{/isInactive}} - - +
+
Admin Actions
+
+ {{#isActive}} + + {{/isActive}} + {{#isInactive}} + + {{/isInactive}} + + +