From e8d04203c38b0d65d65826b68f2dbcc2010727db Mon Sep 17 00:00:00 2001 From: William Mantly Date: Tue, 4 Aug 2026 23:00:25 -0400 Subject: [PATCH] fix: group names match docs, dedupe resource groups, agent 404, shared-secrets + vault apps, promote + plugin logs (v1.27.0) (#168) - 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 --- CHANGELOG.md | 12 +++++ docs/vault.md | 18 +++++++ nodejs/package-lock.json | 4 +- nodejs/package.json | 2 +- nodejs/routes/api_agent.js | 12 ++--- nodejs/routes/api_directory_admin.js | 70 ++++++++++++++++------------ nodejs/routes/api_shared_secrets.js | 11 +++-- nodejs/routes/discovery.js | 7 ++- nodejs/tests/access_request.test.js | 12 +++-- nodejs/tests/groups.test.js | 49 +++++++++++-------- nodejs/utils/groups.js | 43 ++++++++++------- nodejs/utils/vault_broker.js | 21 +++++++++ nodejs/views/directory.ejs | 37 +++++++++++++++ nodejs/views/vault.ejs | 52 ++++++++++++++++++++- 14 files changed, 264 insertions(+), 86 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14ca780..a87931e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +# 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: 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: groups no longer appear 3× under a resource — the Directory self-heal (which runs on every load) was creating duplicate `ResourceGroup` links; linking is now idempotent (check-then-create). +- fix: `/api/agent/nodes` no longer 404s — the agent REST router is mounted unconditionally instead of being gated on the WebSocket server being up. +- fix: `POST /api/shared-secrets/` rejected valid slugs — the slug regex now allows underscores (was hyphens-only). +- fix: `GET /api/shared-secrets/` crashed with `s.path is not a function` — the list spread dropped the instance's `path()` method; now uses the static `SharedSecret.pathFor`. +- fix: promoting a discovered inventory resource crashed with `Resource.update is not a function` — `update` is an instance method; the promote handler now loads an instance and calls `update()` on it. +- feat: Vault → Apps tab now lists minted app tokens (the "Minted apps" list) — each is a scoped OpenBao credential for an external service; sso renews them and the list shows renewal state, so a minted credential no longer vanishes after its once-only token display. New `GET /api/vault/apps`. +- feat: Vault page documents itself — a `/docs/vault` help icon in the header, and the doc now covers the Apps + Shared tabs. +- feat: discovery plugin cards show last-run time + status (ok/error) and a Logs button that opens the captured run log. + # v1.26.1 - fix: the legacy `app_super_admin` group is gone — `SUPER_ADMIN_GROUP` (nested into every resource's `_admin` group by auto-provisioning) is now `god_admin`, and `docker-entrypoint.sh` no longer seeds or nests `app_super_admin` (god_admin is nested into the `app_sso_*` groups directly). `isSuperAdmin` still recognizes a pre-existing `app_super_admin` as a migration alias, so an old deployment isn't stripped of rights until it's rebuilt. diff --git a/docs/vault.md b/docs/vault.md index c79b7df..29fec4f 100644 --- a/docs/vault.md +++ b/docs/vault.md @@ -28,6 +28,24 @@ You can access the Vault UI from the application's top navigation bar. The secrets are stored in an OpenBao backend configured in development mode. The default KV (Key-Value) version 2 engine is mounted at `secret/`. The built-in UI uses the `/api/vault/secret/` API endpoints to interact with OpenBao. +## Apps tab (admin) + +The **Apps** tab mints a scoped OpenBao token for an **external application** so it can read its own configuration out of OpenBao — a downstream-app credential, not a per-user secret. + +1. Enter an app **name** (e.g. `my-service`) and click **Mint token**. +2. A token is shown **once** — copy it into the external app now; it cannot be recovered later. The app uses it as the `X-Vault-Token` header against `secret/apps//*` (see the connection convention shown on the page). +3. The **Minted apps** list shows every token you've created (metadata only — the token itself is never stored). sso keeps each token alive by renewing it periodically, so a downstream app's credential stays valid as long as sso runs. If an app shows a **renewal error**, re-mint it here — that revokes the old token and issues a fresh one. + +The token is scoped to `secret/apps//*` only (policy `app-`), so a compromised token can't touch any other secret. + +## Shared tab + +The **Shared** tab lets you share a secret with another user (or app) without copying the value around. + +1. **New** — give the secret a name (slug) and its JSON data. The owner has full read/write on `secret/shared//`. +2. Open a secret and use **Grants** to share it with a user or app; the grantee's OpenBao policy is edited immediately so the share takes effect with no token re-mint. Revoking a grant removes access at the ACL. +3. The data itself is read through the normal Vault proxy using each user's own session, so OpenBao enforces read access per-request. + ## API Access If you need to programmatically access the secrets, you can interact directly with the OpenBao API using the root token (in dev mode): diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index acf5544..bd4694b 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -1,12 +1,12 @@ { "name": "t42-sso-manager", - "version": "1.26.1", + "version": "1.27.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "t42-sso-manager", - "version": "1.26.1", + "version": "1.27.0", "license": "MIT", "dependencies": { "@fortawesome/fontawesome-free": "^7.3.0", diff --git a/nodejs/package.json b/nodejs/package.json index 36c5aac..f80740a 100755 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -1,6 +1,6 @@ { "name": "t42-sso-manager", - "version": "1.26.1", + "version": "1.27.0", "description": "A very simple LDAP management and SSO system", "author": [ { diff --git a/nodejs/routes/api_agent.js b/nodejs/routes/api_agent.js index 72ec137..bf6aaab 100644 --- a/nodejs/routes/api_agent.js +++ b/nodejs/routes/api_agent.js @@ -8,12 +8,11 @@ const agentManager = require('../utils/agent_manager'); const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin']; module.exports = function initAgentWebSockets(app) { - if (!app.wss) { - console.warn("WebSocket server for agents is not initialized."); - return; - } - - app.wss.on('connection', (ws, req) => { + // Only the WebSocket handler needs the WS server. The REST routes mounted + // below (/api/agent/*) must work regardless of the WS server state -- gating + // them on `app.wss` made them 404 whenever it wasn't initialized. + if (app.wss) { + app.wss.on('connection', (ws, req) => { const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); const token = url.searchParams.get('token') || req.headers['authorization']; @@ -74,6 +73,7 @@ module.exports = function initAgentWebSockets(app) { })); } 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 diff --git a/nodejs/routes/api_directory_admin.js b/nodejs/routes/api_directory_admin.js index 9d84223..c994d1e 100644 --- a/nodejs/routes/api_directory_admin.js +++ b/nodejs/routes/api_directory_admin.js @@ -61,6 +61,17 @@ async function ensureGroup(name, ownerDn, description) { } } +// Link a group to a resource only if that link doesn't already exist. The +// ResourceGroup table has no unique constraint on (resourceId, groupCn), so a +// naive create on every Directory self-heal (which runs ensureSiteGroups / +// provisionResourceGroups on each load) was accumulating duplicate links -- the +// "groups appear 3x under a resource" bug. Always check first. +async function ensureResourceGroup(resourceId, groupCn, accessLevel) { + const existing = await ResourceGroup.list({ where: { resourceId, groupCn } }); + if (existing.length) return existing[0]; + return ResourceGroup.create({ resourceId, groupCn, accessLevel }); +} + // Provision the site-level groups + the aggregates the per-resource groups nest // into. Idempotent -- called on every directory list so a site seeded by an // older release gets its groups without a rebuild: @@ -79,19 +90,20 @@ async function ensureSiteGroups(siteSlug, ownerDn, siteName, siteResourceId) { // groups as member. const link = async (cn, isAdmin) => { if (!siteResourceId) return; - await ResourceGroup.create({ resourceId: siteResourceId, groupCn: cn, accessLevel: isAdmin ? 'owner' : 'member' }).catch(() => {}); + await ensureResourceGroup(siteResourceId, cn, isAdmin ? 'owner' : 'member'); }; const sAdmin = groups.siteSuperAdminCns(siteSlug); await ensureGroup(sAdmin, ownerDn, `Site admin for ${siteName || siteSlug}`); await link(sAdmin, true); + // The kind-scoped aggregates are CREATED here (per-resource groups nest into + // them), but are NOT linked to the site resource: a site carries only the god + // and site-wide groups (S_super_admin, S_everyone), per the user's model. The + // aggregates have no modal home; site-wide access is granted via S_super_admin + // and per-resource access via the host/app groups. for (const kind of ['host', 'app']) { - const aggAdmin = groups.aggregateGroupCns(siteSlug, kind, 'admin'); - const aggAccess = groups.aggregateGroupCns(siteSlug, kind, 'access'); - await ensureGroup(aggAdmin, ownerDn, `Admin on all ${kind}s at ${siteSlug}`); - await ensureGroup(aggAccess, ownerDn, `Access to all ${kind}s at ${siteSlug}`); - await link(aggAdmin, true); - await link(aggAccess, false); + await ensureGroup(groups.aggregateGroupCns(siteSlug, kind, 'admin'), ownerDn, `Admin on all ${kind}s at ${siteSlug}`); + await ensureGroup(groups.aggregateGroupCns(siteSlug, kind, 'access'), ownerDn, `Access to all ${kind}s at ${siteSlug}`); } await ensureGroup(groups.siteEveryoneCns(siteSlug), ownerDn, `All users at ${siteSlug}`); await link(groups.siteEveryoneCns(siteSlug), false); @@ -114,29 +126,30 @@ async function ensureSiteGroups(siteSlug, ownerDn, siteName, siteResourceId) { // Provision the per-resource groups for a host/app and nest them into the site // aggregates (so a site/aggregate admin reaches this resource by membership). -// The specific group name uses the resource's slug verbatim -// (`{site}_{slug}_{level}` -- the kind is carried in the slug, e.g. `host_theta-env`); -// `kind` (host/app) selects which aggregate the group nests into: +// Group names follow docs/GROUPS.md §2: `{site}_{kind}_{nameSlug}_{level}` where +// nameSlug is the resource name with the kind prefix stripped (`host_theta-env` -> +// `theta-env`). `kind` (host/app) both goes in the name and selects the aggregate: // -// {site}_{slug}_admin -> {site}_{slug}_access -// {site}_{slug}_admin -> {site}_{kind}s_admin (aggregate) -// {site}_{slug}_access -> {site}_{kind}s_access (aggregate) -// god_admin -> {site}_{slug}_admin (global super admin) +// {site}_{kind}_{slug}_admin -> {site}_{kind}_{slug}_access +// {site}_{kind}_{slug}_admin -> {site}_{kind}s_admin (aggregate) +// {site}_{kind}_{slug}_access -> {site}_{kind}s_access (aggregate) +// god_admin -> {site}_{kind}_{slug}_admin (global super admin) async function provisionResourceGroups(resource, kind, siteSlug, ownerDn) { - const accessCn = groups.resourceGroupCns(siteSlug, resource.slug, 'access'); - const adminCn = groups.resourceGroupCns(siteSlug, resource.slug, 'admin'); + const nameSlug = groups.resourceNameSlug(resource.slug); + const accessCn = groups.resourceGroupCns(siteSlug, kind, nameSlug, 'access'); + const adminCn = groups.resourceGroupCns(siteSlug, kind, nameSlug, 'admin'); await ensureGroup(accessCn, ownerDn, `Access group for ${resource.name}`); await ensureGroup(adminCn, ownerDn, `Admin group for ${resource.name}`); // Link both groups to the resource so the Directory can show/revoke them. - await ResourceGroup.create({ resourceId: resource.id, groupCn: accessCn, accessLevel: 'member' }).catch(() => {}); - await ResourceGroup.create({ resourceId: resource.id, groupCn: adminCn, accessLevel: 'owner' }).catch(() => {}); + await ensureResourceGroup(resource.id, accessCn, 'member'); + await ensureResourceGroup(resource.id, adminCn, 'owner'); await nestGroup(adminCn, accessCn); // administering implies using await nestGroup(adminCn, groups.aggregateGroupCns(siteSlug, kind, 'admin')); // aggregate admin reaches this resource await nestGroup(accessCn, groups.aggregateGroupCns(siteSlug, kind, 'access')); // aggregate access reaches this resource - await nestGroup(SUPER_ADMIN_GROUP, adminCn); // legacy cross-app super admin + await nestGroup(SUPER_ADMIN_GROUP, adminCn); // global super admin } // The group CNs it is valid to associate with a given resource (docs/GROUPS.md @@ -147,25 +160,24 @@ async function provisionResourceGroups(resource, kind, siteSlug, ownerDn) { // capability groups following the same shapes. function validGroupCnsForResource(resource, siteSlug) { const valid = new Set(); + // A site resource only carries god_admin (added by the route) + the site-wide + // groups (S_super_admin, S_everyone). The kind-scoped host/app aggregates and + // specific groups belong to host/app resources, not to the site. if (resource.kind === 'site') { valid.add(groups.siteSuperAdminCns(siteSlug)); valid.add(groups.siteEveryoneCns(siteSlug)); - for (const k of ['host', 'app']) { - valid.add(groups.aggregateGroupCns(siteSlug, k, 'admin')); - valid.add(groups.aggregateGroupCns(siteSlug, k, 'access')); - } - return { valid, capRe: new RegExp(`^${siteSlug}_(hosts|apps)_[a-z0-9-]+$`) }; + return { valid, capRe: new RegExp(`^${siteSlug}_super_admin$|^${siteSlug}_everyone$`) }; } const kind = groupKind(resource); // 'host'|'app'|null if (kind) { - const slug = resource.slug; // verbatim (kind is carried in the slug) - valid.add(groups.resourceGroupCns(siteSlug, slug, 'admin')); - valid.add(groups.resourceGroupCns(siteSlug, slug, 'access')); + const nameSlug = groups.resourceNameSlug(resource.slug); + valid.add(groups.resourceGroupCns(siteSlug, kind, nameSlug, 'admin')); + valid.add(groups.resourceGroupCns(siteSlug, kind, nameSlug, 'access')); valid.add(groups.aggregateGroupCns(siteSlug, kind, 'admin')); valid.add(groups.aggregateGroupCns(siteSlug, kind, 'access')); valid.add(groups.siteSuperAdminCns(siteSlug)); valid.add(groups.siteEveryoneCns(siteSlug)); - return { valid, capRe: new RegExp(`^${siteSlug}_(${slug}_|${kind}s_)[a-z0-9-]+$`) }; + return { valid, capRe: new RegExp(`^${siteSlug}_${kind}_${nameSlug}_[a-z0-9-]+$|^${siteSlug}_${kind}s_[a-z0-9-]+$`) }; } // oauth/container etc. — only the global god_admin makes sense to pin here. valid.add(groups.siteSuperAdminCns(siteSlug)); @@ -439,7 +451,7 @@ router.post('/groups', async (req, res, next) => { } } - const g = await ResourceGroup.create(req.body); + const g = await ensureResourceGroup(req.body.resourceId, groupCn, req.body.accessLevel); res.json({ results: g }); } catch (err) { next(err); } }); diff --git a/nodejs/routes/api_shared_secrets.js b/nodejs/routes/api_shared_secrets.js index 67ff73b..71b3b92 100644 --- a/nodejs/routes/api_shared_secrets.js +++ b/nodejs/routes/api_shared_secrets.js @@ -24,7 +24,10 @@ const { SharedSecretGrant } = require('../models/shared_secret_grant'); const vaultBroker = require('../utils/vault_broker'); const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin']; -const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/; +// Allow hyphens AND underscores (matching the plugin-instance slug convention); +// only reject values that can't be a sane secret path segment (spaces, slashes, +// leading non-alnum, too long). +const SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/; const router = express.Router(); @@ -77,7 +80,9 @@ router.get('/', async (req, res, next) => { if (byId.has(g.id)) continue; // already owner byId.set(g.id, { role: 'grantee', ...g }); } - res.json({ items: [...byId.values()].map(s => ({ id: s.id, slug: s.slug, ownerUid: s.ownerUid, description: s.description, path: s.path(), role: s.role })) }); + // The `{ role, ...s }` spread above copies only own properties, so the + // instance method `path()` is dropped -- call the static builder instead. + res.json({ items: [...byId.values()].map(s => ({ id: s.id, slug: s.slug, ownerUid: s.ownerUid, description: s.description, path: SharedSecret.pathFor(s.ownerUid, s.slug), role: s.role })) }); } catch (e) { next(e); } }); @@ -86,7 +91,7 @@ router.post('/', async (req, res, next) => { try { const uid = req.user.uid; const slug = String(req.body.slug || '').trim().toLowerCase(); - if (!SLUG_RE.test(slug)) return res.status(400).json({ error: 'slug must be lowercase letters/digits/hyphens, 1-64 chars' }); + if (!SLUG_RE.test(slug)) return res.status(400).json({ error: 'slug must be lowercase letters/digits/hyphens/underscores, 1-64 chars' }); const description = String(req.body.description || '').trim(); const data = (req.body.data && typeof req.body.data === 'object') ? req.body.data : {}; diff --git a/nodejs/routes/discovery.js b/nodejs/routes/discovery.js index ea57436..b63d518 100644 --- a/nodejs/routes/discovery.js +++ b/nodejs/routes/discovery.js @@ -186,8 +186,11 @@ router.post('/promote/:slug', async (req, res, next) => { const meta = resource.metadata || {}; meta.managed = true; - await Resource.update(resource.id, { metadata: meta }); - + // `Resource.update` is not a static — `update` is an instance method + // (@simpleworkjs/orm). Load a fresh instance and call it on that. + const inst = await Resource.get(resource.id); + await inst.update({ metadata: meta }); + res.json(envelope({ success: true, groups: [accessGroup, adminGroup] })); } catch (err) { next(err); } }); diff --git a/nodejs/tests/access_request.test.js b/nodejs/tests/access_request.test.js index ba5e7cc..d73a615 100644 --- a/nodejs/tests/access_request.test.js +++ b/nodejs/tests/access_request.test.js @@ -44,9 +44,10 @@ beforeAll(async () => { expect(host.status).toBe(200); hostId = host.body.results.id; - // Creating a host auto-provisions __access / _admin. - accessGroupCn = `${siteSlug}_${hostSlug}_access`; - const adminGroupCn = `${siteSlug}_${hostSlug}_admin`; + // Creating a host auto-provisions _host__access / _admin + // (docs/GROUPS.md §2 — the kind is part of the name). + accessGroupCn = `${siteSlug}_host_${hostSlug}_access`; + const adminGroupCn = `${siteSlug}_host_${hostSlug}_admin`; // The creator is seeded into both groups -- groupOfNames requires at least // one member, so Group.add puts the owner's DN there -- and _admin is nested @@ -213,8 +214,9 @@ describe('Access requests — withdrawal', () => { expect(host.status).toBe(200); // Same as the top-level setup: step out of the auto-created groups the - // creator is seeded into, or this is a request for access already held. - for (const cn of [`${siteSlug}_${slug}_admin`, `${siteSlug}_${slug}_access`]) { + // creator is seeded into (docs/GROUPS.md §2 — kind is part of the name), + // or this is a request for access already held. + for (const cn of [`${siteSlug}_host_${slug}_admin`, `${siteSlug}_host_${slug}_access`]) { await request(app) .delete(`/api/group/${encodeURIComponent(cn)}/test`) .set('auth-token', token); diff --git a/nodejs/tests/groups.test.js b/nodejs/tests/groups.test.js index 9ead71a..beeec52 100644 --- a/nodejs/tests/groups.test.js +++ b/nodejs/tests/groups.test.js @@ -12,11 +12,13 @@ const { GOD_ADMIN, } = require('../utils/groups'); -// Resource fixtures mirror the directory's real slugs: hosts carry a `host_` -// prefix, services/apps are stored bare. The group-model builders use these -// verbatim (no re-slugifying, no kind insertion) -- see groups.js. +// Resource fixtures mirror the directory: hosts carry a `host_` prefix, services +// are stored bare. The builders take the *name* slug (kind stripped) + a kind, so +// a host `host_web-01` gives `main-office_host_web-01_*` and a service `emby` +// gives `main-office_app_emby_*` -- matching docs/GROUPS.md §2. const HOST = { site: 'main-office', kind: 'host', slug: 'host_web-01' }; const APP = { site: 'main-office', kind: 'app', slug: 'emby' }; +const SERVICE = { site: 'main-office', kind: 'service', slug: 'emby' }; const OTHER_SITE_HOST = { site: 'branch-office', kind: 'host', slug: 'host_db' }; describe('slugify', () => { @@ -30,9 +32,13 @@ describe('slugify', () => { }); describe('group cn builders', () => { - test('per-resource uses the resource slug verbatim (kind is carried in the slug)', () => { - expect(resourceGroupCns('main-office', 'host_web-01', 'admin')).toBe('main-office_host_web-01_admin'); - expect(resourceGroupCns('main-office', 'emby', 'access')).toBe('main-office_emby_access'); + test('per-resource names the kind + name slug (docs §2)', () => { + expect(resourceGroupCns('main-office', 'host', 'web-01', 'admin')).toBe('main-office_host_web-01_admin'); + expect(resourceGroupCns('main-office', 'app', 'emby', 'access')).toBe('main-office_app_emby_access'); + }); + test('a prefixed site slug is kept verbatim; the resource name slug is kind-stripped', () => { + expect(resourceGroupCns('site_local', 'host', 'theta-env', 'access')).toBe('site_local_host_theta-env_access'); + expect(resourceGroupCns('site_local', 'app', 'sso-manager', 'access')).toBe('site_local_app_sso-manager_access'); }); test('aggregate uses the plural kind', () => { expect(aggregateGroupCns('main-office', 'host', 'admin')).toBe('main-office_hosts_admin'); @@ -42,15 +48,13 @@ describe('group cn builders', () => { expect(siteSuperAdminCns('main-office')).toBe('main-office_super_admin'); expect(siteEveryoneCns('main-office')).toBe('main-office_everyone'); }); - test('a directory site slug with a kind prefix is kept verbatim, not re-slugified', () => { - // Resource slugs are `site_local` / `host_theta-env` -- re-slugifying the - // site (`site_local` -> `site-local`) would corrupt the delimiter. + test('a directory site slug with a kind prefix is kept verbatim', () => { expect(siteSuperAdminCns('site_local')).toBe('site_local_super_admin'); expect(siteEveryoneCns('site_local')).toBe('site_local_everyone'); expect(aggregateGroupCns('site_local', 'host', 'admin')).toBe('site_local_hosts_admin'); - expect(resourceGroupCns('site_local', 'host_theta-env', 'access')).toBe('site_local_host_theta-env_access'); }); - test('invalid kind throws (aggregates only — per-resource has no kind arg)', () => { + test('invalid kind throws', () => { + expect(() => resourceGroupCns('s', 'service', 'x', 'admin')).toThrow(); expect(() => aggregateGroupCns('s', 'service', 'admin')).toThrow(); }); }); @@ -89,32 +93,37 @@ describe('hasPermission — inheritance', () => { }); test('specific host group grants only that host', () => { - const cn = resourceGroupCns('main-office', 'host_web-01', 'admin'); + const cn = resourceGroupCns('main-office', 'host', 'web-01', 'admin'); expect(hasPermission([cn], HOST, 'admin')).toBe(true); expect(hasPermission([cn], OTHER_SITE_HOST, 'admin')).toBe(false); }); test('admin implies access; access does not imply admin', () => { - expect(hasPermission([resourceGroupCns('main-office', 'host_web-01', 'admin')], HOST, 'access')).toBe(true); - expect(hasPermission([resourceGroupCns('main-office', 'host_web-01', 'access')], HOST, 'admin')).toBe(false); + expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'admin')], HOST, 'access')).toBe(true); + expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'access')], HOST, 'admin')).toBe(false); }); test('capabilities are exact — admin does not grant a capability', () => { - expect(hasPermission([resourceGroupCns('main-office', 'host_web-01', 'reboot')], HOST, 'reboot')).toBe(true); - expect(hasPermission([resourceGroupCns('main-office', 'host_web-01', 'admin')], HOST, 'reboot')).toBe(false); - // aggregate capability + expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'reboot')], HOST, 'reboot')).toBe(true); + expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'admin')], HOST, 'reboot')).toBe(false); expect(hasPermission(['main-office_hosts_reboot'], HOST, 'reboot')).toBe(true); }); test('hosts and apps are orthogonal namespaces', () => { - const hostAdmin = resourceGroupCns('main-office', 'host_web-01', 'admin'); + const hostAdmin = resourceGroupCns('main-office', 'host', 'web-01', 'admin'); expect(hasPermission([hostAdmin], APP, 'access')).toBe(false); - const appAdmin = resourceGroupCns('main-office', 'emby', 'admin'); + const appAdmin = resourceGroupCns('main-office', 'app', 'emby', 'admin'); expect(hasPermission([appAdmin], APP, 'access')).toBe(true); }); + test('a service maps to the app kind (docs §11)', () => { + // The directory `service` kind is the group model's `app`. + expect(hasPermission([resourceGroupCns('main-office', 'app', 'emby', 'admin')], SERVICE, 'admin')).toBe(true); + expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'admin')], SERVICE, 'admin')).toBe(false); + }); + test('cross-site isolation', () => { - const mainHostAdmin = resourceGroupCns('main-office', 'host_web-01', 'admin'); + const mainHostAdmin = resourceGroupCns('main-office', 'host', 'web-01', 'admin'); expect(hasPermission([mainHostAdmin], OTHER_SITE_HOST, 'access')).toBe(false); expect(hasPermission(['branch-office_hosts_admin'], OTHER_SITE_HOST, 'admin')).toBe(true); }); diff --git a/nodejs/utils/groups.js b/nodejs/utils/groups.js index ae0d42a..c00574c 100644 --- a/nodejs/utils/groups.js +++ b/nodejs/utils/groups.js @@ -41,17 +41,23 @@ function assertKind(kind) { if (!KINDS.includes(kind)) throw new Error(`invalid resource kind: ${kind} (must be host or app)`); } -// {site}_{slug}_{level} — the per-resource group for one resource. -// -// Both `site` and `slug` are the resource slugs verbatim (e.g. `site_local`, -// `host_theta-env`), NOT slugified or kind-inserted: directory resource slugs -// carry their kind as a prefix (`host_theta-env`), so `site_local` + `host_theta-env` -// yields `site_local_host_theta-env_access`. Services are stored without a -// prefix (`sso-manager`), yielding `site_local_sso-manager_access`. This is the -// convention the auto-provisioner, the resolver, and the access-request tests -// all share -- re-slugifying or inserting a kind would double the delimiter. -function resourceGroupCns(site, slug, level) { - return `${site}_${slug}_${level}`; +// Strip the kind prefix a directory resource slug may carry (`host_theta-env` -> +// `theta-env`), leaving the resource's name slug. Services are stored bare +// (`sso-manager`), so this is a no-op for them. +function resourceNameSlug(slug) { + return String(slug || '').replace(/^(site|host|app)_/, ''); +} + +// {site}_{kind}_{nameSlug}_{level} — the per-resource group for ONE resource. +// Matches docs/GROUPS.md §2 (`S_host__` / `S_app__`): +// `site` is the site resource's slug verbatim (`site_local`), `kind` is the +// group-model kind (`host`/`app`), `nameSlug` is the resource's name (kind +// stripped, e.g. `theta-env` from `host_theta-env`). So a host `host_theta-env` +// yields `site_local_host_theta-env_access` and a service `sso-manager` yields +// `site_local_app_sso-manager_access`. +function resourceGroupCns(site, kind, nameSlug, level) { + assertKind(kind); + return `${site}_${kind}_${slugify(nameSlug)}_${level}`; } // {site}_hosts_ / {site}_apps_ (plural kind — the aggregate). @@ -94,11 +100,13 @@ function levelGrants(level, wanted) { // granted groups (see permission.onResource). This keeps the function pure over // the user's membership only. function hasPermission(memberOf, resource, level) { - // `site` and `slug` are used verbatim (resource slugs may carry a kind prefix, - // e.g. `site_local` / `host_theta-env`) -- see resourceGroupCns. + // `site` is used verbatim (`site_local`); `kind` maps the directory `service` + // kind onto the group model's `app` (docs/GROUPS.md §11 — consoles/services are + // apps); `nameSlug` is the resource name with any kind prefix stripped. const site = resource && resource.site; - const kind = resource && resource.kind; - const slug = resource && resource.slug; + const rawKind = resource && resource.kind; + const kind = rawKind === 'service' ? 'app' : rawKind; + const nameSlug = resourceNameSlug(resource && resource.slug); const set = new Set(memberOf || []); if (set.has(GOD_ADMIN)) return true; @@ -107,13 +115,13 @@ function hasPermission(memberOf, resource, level) { if (isKnownLevel(level)) { // admin / access if (set.has(aggregateGroupCns(site, kind, level))) return true; - if (set.has(resourceGroupCns(site, slug, level))) return true; + if (set.has(resourceGroupCns(site, kind, nameSlug, level))) return true; if (level === 'access' && hasPermission(memberOf, resource, 'admin')) return true; return false; } // Opaque capability — exact aggregate or specific grant only. if (set.has(aggregateGroupCns(site, kind, level))) return true; - if (set.has(resourceGroupCns(site, slug, level))) return true; + if (set.has(resourceGroupCns(site, kind, nameSlug, level))) return true; return false; } @@ -122,6 +130,7 @@ module.exports = { KNOWN_LEVELS, KINDS, slugify, + resourceNameSlug, resourceGroupCns, aggregateGroupCns, siteSuperAdminCns, diff --git a/nodejs/utils/vault_broker.js b/nodejs/utils/vault_broker.js index 99879fd..8c2220f 100644 --- a/nodejs/utils/vault_broker.js +++ b/nodejs/utils/vault_broker.js @@ -410,6 +410,27 @@ mintAppRouter.post('/', async (req, res, next) => { } }); +// List the minted external-app tokens (metadata only — the token itself is shown +// once at mint and never stored; the accessor is a renewal/revoke handle and is +// never exposed). Lets the Apps tab show what has been minted instead of a +// credential vanishing into the void. +mintAppRouter.get('/', async (req, res, next) => { + try { + await permission.byGroup(req.user, [ADMIN_GROUP]); + const rows = await VaultAppToken.list(); + res.json({ apps: rows.map((r) => ({ + name: r.name, + createdBy: r.created_by, + createdOn: r.created_on, + lastRenewedAt: r.lastRenewedAt || null, + lastError: r.lastError || null, + })) }); + } catch (e) { + if (e.status === 401) return res.status(403).json({ error: 'admin only' }); + next(e); + } +}); + module.exports = { getOrCreateUserToken, getOrCreateAdminToken, diff --git a/nodejs/views/directory.ejs b/nodejs/views/directory.ejs index 3e5a27d..160bb9b 100644 --- a/nodejs/views/directory.ejs +++ b/nodejs/views/directory.ejs @@ -1651,15 +1651,28 @@ discoveryPlugins.forEach(p => { const badgeClass = p.enabled ? 'bg-success' : 'bg-secondary'; const statusText = p.enabled ? 'Loaded' : 'Unloaded'; + // Last-run state is surfaced by the plugins API (lastRunAt/lastStatus/ + // lastError/lastLog) but was dropped here; show it so a plugin that errors + // is visible without digging into logs. + const runOk = p.lastStatus === 'ok'; + const runErr = p.lastStatus === 'error'; + const runState = p.lastRunAt + ? `${runOk ? 'ok' : runErr ? 'error' : esc(p.lastStatus) || 'ran'} ${fmtRunTs(p.lastRunAt)}` + : 'Never run'; + const logsBtn = (p.lastLog || p.lastError) + ? `` + : ''; const card = `
${p.name} ${p.pluginType}
${p.slug} | Schedule: ${p.cron}
+
Last run: ${runState}
${statusText} + ${logsBtn} @@ -1671,6 +1684,30 @@ }); } + // "Never run" when a discovery plugin has no run yet; otherwise relative time. + function fmtRunTs(ts) { + if (!ts) return 'Never run'; + const m = moment(ts); + return m.isValid() ? m.fromNow() : 'Never run'; + } + + // Modal showing the discovery plugin's last run log + error (from the plugins + // API's lastLog/lastError fields). Logs can be long, so render in a scrollable + //
 rather than a toast.
+  function showPluginLog(id) {
+    const p = discoveryPlugins.find(x => x.id === id);
+    if (!p) return;
+    const body = p.lastError
+      ? `
${esc(p.lastError)}
` + : ''; + const log = p.lastLog || '(no log captured for this run)'; + app.modal.open({ + title: 'Run log — ' + (p.name || p.slug), + size: 'lg', + bodyHtml: body + '
' + esc(log) + '
', + }); + } + async function toggleDiscoveryPlugin(id, state) { const endpoint = state ? 'load' : 'unload'; try { diff --git a/nodejs/views/vault.ejs b/nodejs/views/vault.ejs index 1e0e5ad..a06c6ed 100644 --- a/nodejs/views/vault.ejs +++ b/nodejs/views/vault.ejs @@ -17,7 +17,10 @@
My Secrets (personal namespace)
- +
+ + +
@@ -88,6 +91,20 @@ curl "$VAULT_ADDR/v1/secret/data/apps//conf"
+
+
+
+
+
Minted apps
+ +
+
+

Each entry is a scoped OpenBao credential an external service uses to read secret/apps/<name>/*. The token itself is shown once at mint — this list is metadata sso keeps so it can renew the token and so you can see what's been minted. If an app shows a renewal error, re-mint it here.

+
Loading…
+
+
+
+
@@ -405,12 +422,44 @@ curl "$VAULT_ADDR/v1/secret/data/apps//conf" document.getElementById('app-token').textContent = result.token; document.getElementById('app-name-display').textContent = name; document.getElementById('app-result-card').classList.remove('d-none'); + loadApps(); } catch (err) { errorEl.textContent = err.message; errorEl.classList.remove('d-none'); } } + // List the minted external-app tokens (metadata only). Makes the Apps tab show + // what's been minted instead of a credential that vanishes after the once-only + // token display. + async function loadApps() { + const $list = document.getElementById('apps-list'); + if (!$list) return; + $list.textContent = 'Loading…'; + try { + const res = await fetch('/api/vault/apps', { + headers: { 'auth-token': app.auth.getToken() } + }); + if (!res.ok) { $list.innerHTML = '
Failed to load apps.
'; return; } + const { apps = [] } = await res.json(); + if (!apps.length) { $list.innerHTML = '
No apps minted yet.
'; return; } + $list.innerHTML = '
' + apps.map(a => { + const ok = !a.lastError; + const renewed = a.lastRenewedAt ? ' · renewed ' + moment(a.lastRenewedAt).fromNow() : ' · never renewed'; + return `
+
+ ${app.util.escapeHtml(a.name)} + ${ok ? 'renewing' : 'renewal error'} +
minted ${moment(a.createdOn).format('YYYY-MM-DD HH:mm')}${renewed}
+
+ secret/apps/${app.util.escapeHtml(a.name)}/ +
`; + }).join('') + '
'; + } catch (err) { + $list.innerHTML = '
Failed to load apps: ' + app.util.escapeHtml(err.message) + '
'; + } + } + function copyText(text) { navigator.clipboard.writeText(text).then(() => app.messages.toast('Copied', 'success')); } @@ -572,6 +621,7 @@ curl "$VAULT_ADDR/v1/secret/data/apps//conf" ' Vault Secrets (admin — all of secret/)'; document.getElementById('secret-path-label').textContent = 'Secret path (under secret/)'; document.getElementById('secret-path-input').placeholder = 'e.g. apps/my-service/conf'; + loadApps(); } loadSecrets(); loadShared();