From 8a9de94d249542a5e87e34f55518401e2653a83a Mon Sep 17 00:00:00 2001 From: William Mantly Date: Tue, 4 Aug 2026 19:07:51 -0400 Subject: [PATCH] release/v1.26.0: complete group model, enforce naming, fix docs + status dots (#166) * feat: complete the group model (god_admin, site groups, aggregates), enforce naming, fix docs 500s + status dots (v1.26.0) - seed god_admin + nest into app_super_admin; auto-provision site groups (S_super_admin, S_hosts_*/S_apps_* aggregates, S_everyone) on site create + self-heal on Directory load - map service resources to the app kind (site_local_app__*); nest per-resource groups into site aggregates (physical inheritance lattice) - enforce the group naming convention server-side on POST /groups; surface god_admin + site groups on the site resource modal - fix in-app /docs/ 500s (Dockerfile never copied docs/); serve doc images at /docs/images - fix Directory status dots (neutral grey when agent endpoint unreachable); align Profile/API cards full-width - group resolver: keep the site slug verbatim (site_local not re-slugified) - bump to 1.26.0 * fix: use verbatim resource slugs in group names (matches access-request tests + live convention) The group naming inserts a kind segment (resourceGroupCns(site, kind, slug, level)), but the access-request tests + the live directory convention are verbatim ({site}_{slug}_{level} -- the kind is carried in the resource slug, e.g. host_theta-env). For bare test slugs this produced site_x_host_artest-host_x_access instead of the expected site_x_artest-host_x_access, so the requester was never removed from the auto-provisioned access group and every request 409'd. resourceGroupCns is now (site, slug, level) with the verbatim slug; the kind is used only to pick the aggregate the group nests into. --- .dockerignore | 3 + CHANGELOG.md | 9 + Dockerfile.openldap | 5 + docker-entrypoint.sh | 17 +- nodejs/package-lock.json | 4 +- nodejs/package.json | 2 +- nodejs/routes/api_directory_admin.js | 252 ++++++++++++++++++++++----- nodejs/routes/docs.js | 2 +- nodejs/tests/groups.test.js | 49 +++--- nodejs/utils/groups.js | 31 ++-- nodejs/views/directory.ejs | 26 ++- nodejs/views/profile.ejs | 4 +- 12 files changed, 322 insertions(+), 82 deletions(-) diff --git a/.dockerignore b/.dockerignore index 4bd7ec3..60f96f5 100644 --- a/.dockerignore +++ b/.dockerignore @@ -19,6 +19,9 @@ !API.md !directory_spec.md !docs/**/*.md +# The screenshots the README (served at /docs/overview) links. `COPY docs /docs` +# in Dockerfile.openldap needs these present in the build context. +!docs/images/** # Tests (excluded from production builds; test-runner Dockerfile copies them explicitly) # nodejs/tests/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bf7cc5..e5491ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +# v1.26.0 +- feat: complete the group model (docs/GROUPS.md) — `god_admin` is now seeded into LDAP and nested into `app_super_admin`; every site auto-provisions `{site}_super_admin`, `{site}_hosts_*`/`{site}_apps_*` aggregates and `{site}_everyone`; per-resource `_admin`/`_access` groups (named `{site}_{slug}_{level}`, the kind carried in the resource slug) are nested into the site aggregates so the inheritance lattice exists in LDAP, not just in the resolver. Site/aggregate groups are self-healed idempotently on every Directory load, so a directory seeded by an older release picks them up without a rebuild. +- feat: the naming convention is now enforced server-side — `POST /api/directory-admin/groups` rejects a group CN that isn't a valid group for the target resource (its own `_admin`/`_access`/capability, a site aggregate, a site-level group, or `god_admin`), so the free-text field can no longer mint `*_accessmember`-style names +- feat: `god_admin` is managed from the Directory — the site resource modal surfaces `god_admin` + the site-level groups as associated groups, so its members (and the site's) are editable right there +- fix: Directory agent status dots no longer paint every host red when the `/api/agent/nodes` endpoint is unreachable (older app or transient outage) — they now show a neutral grey "agent service unreachable" instead of a false alarm +- fix: Profile + API Tokens cards are both full-width on the profile page (the API card was a narrower centered block) +- fix: in-app `/docs/` pages returned 500 — `Dockerfile.openldap` never copied the `docs/` tree into the image (only the root README/CHANGELOG/API/directory_spec), so every page but those few hit a missing-file error; the whole `docs/` dir now ships, and doc images are served at `/docs/images` +- test: group resolver tests now cover the prefixed site-slug convention (`site_local_...` is kept verbatim, not re-slugified to `site-local`) + # v1.25.0 - feat: hierarchical group & permission model (docs/GROUPS.md) — god_admin, {site}_super_admin, {site}_hosts_*/{site}_apps_* aggregates, and per-resource {site}_host__admin/access/; inheritance resolver (admin implies access, capabilities explicit), meta everyone/{site}_everyone groups - feat: remove the standalone Groups page — group management is tied to adopted Directory resources (help link to the model in the Directory toolbar) diff --git a/Dockerfile.openldap b/Dockerfile.openldap index 5e8d2a7..d6622e1 100644 --- a/Dockerfile.openldap +++ b/Dockerfile.openldap @@ -184,6 +184,11 @@ COPY README.md /README.md COPY CHANGELOG.md /CHANGELOG.md COPY API.md /API.md COPY directory_spec.md /directory_spec.md +# The docs/*.md tree (plus the images the docs link) is read at runtime too, so +# the whole docs/ dir must land at /docs. Without this every in-app /docs/ +# page other than the root-level README/CHANGELOG/API/directory_spec 500s on the +# fs.readFileSync in routes/docs.js (files missing from the image). +COPY docs /docs # Baked commit hash from the gitinfo stage (see build_info.js). COPY --from=gitinfo /commit.txt ./.build_commit diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index c17a98f..c3f30c7 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -355,7 +355,11 @@ EOF # Required SSO groups. The app gates admin/invite/oauth-admin on these; # app_sso_service_account is a marker (not a permission gate) for # non-person accounts -- see the Users page. - for group in app_super_admin app_sso_admin app_sso_invite app_sso_oauth_admin app_sso_service_account; do + # + # god_admin is the global super group (docs/GROUPS.md §2), the top of the + # group-inheritance lattice. It is seeded here so it exists from first boot; + # the theta-suite bootstrap puts the first admin person into it. + for group in god_admin app_super_admin app_sso_admin app_sso_invite app_sso_oauth_admin app_sso_service_account; do ldapadd -x -D "$LDAP_BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost:389 << EOF || true dn: cn=${group},ou=groups,${LDAP_BASE_DN} objectClass: groupOfNames @@ -385,6 +389,17 @@ member: cn=app_super_admin,ou=groups,${LDAP_BASE_DN} EOF done info "Nested app_super_admin into the SSO admin groups" + + # god_admin is the top of the lattice; nesting it into app_super_admin + # (which is itself nested into the app_sso_* groups above) makes it + # resolve to everything app_super_admin holds at the LDAP level too. + ldapmodify -x -D "$LDAP_BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost:389 >/dev/null 2>&1 << EOF || true +dn: cn=app_super_admin,ou=groups,${LDAP_BASE_DN} +changetype: modify +add: member +member: cn=god_admin,ou=groups,${LDAP_BASE_DN} +EOF + info "Nested god_admin into app_super_admin" fi info "LDAP directory initialized" diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 4cebfad..6bb7b47 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -1,12 +1,12 @@ { "name": "t42-sso-manager", - "version": "1.25.0", + "version": "1.26.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "t42-sso-manager", - "version": "1.25.0", + "version": "1.26.0", "license": "MIT", "dependencies": { "@fortawesome/fontawesome-free": "^7.3.0", diff --git a/nodejs/package.json b/nodejs/package.json index baa9b92..1e5abc8 100755 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -1,6 +1,6 @@ { "name": "t42-sso-manager", - "version": "1.25.0", + "version": "1.26.0", "description": "A very simple LDAP management and SSO system", "author": [ { diff --git a/nodejs/routes/api_directory_admin.js b/nodejs/routes/api_directory_admin.js index bbf3c77..5695cdd 100644 --- a/nodejs/routes/api_directory_admin.js +++ b/nodejs/routes/api_directory_admin.js @@ -8,6 +8,7 @@ const { cnFromDn } = require('../utils/user_groups'); const { projectResources } = require('@simpleworkjs/directory-schema'); const SUPER_ADMIN_GROUP = permission.SUPER_ADMIN_GROUP; +const groups = require('../utils/groups'); // Make `childCn` a member of `parentCn`, i.e. everyone in the child is // transitively in the parent. Idempotent and non-fatal: "already a member" is @@ -29,6 +30,148 @@ async function nestGroup(childCn, parentCn) { } } +// ── Group-model provisioning (docs/GROUPS.md) ─────────────────────────────── +// The directory is the single place groups are created, as a projection of the +// resource graph. These helpers materialize the group-inheritance lattice for +// a resource so it exists in LDAP as well as in the resolver (utils/groups.js). +// All of them are idempotent, so calling them again for a resource a newer +// release is backfilling is a no-op. + +// Map a directory resource kind onto a group-model kind (GROUPS.md §2). +// host -> host; service -> app (services/consoles are the group model's "apps"); +// site gets site-level groups (handled separately); oauth/container get no +// per-resource groups (oauth clients hang off their owning service). +function groupKind(resource) { + if (resource.kind === 'host') return 'host'; + if (resource.kind === 'service') return 'app'; + return null; +} + +// Create a groupOfNames if it doesn't already exist. Idempotent; `ownerDn` +// seeds the mandatory first member. Returns true when created. +async function ensureGroup(name, ownerDn, description) { + try { + await Group.add({ name, owner: ownerDn, description }); + return true; + } catch (err) { + if (err.name !== 'EntryAlreadyExistsError' && err.code !== 68) { + console.error(`ensureGroup: failed to create ${name}:`, err); + } + return false; + } +} + +// 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: +// +// god_admin -> {site}_super_admin +// {site}_super_admin -> {site}_hosts_admin, {site}_apps_admin +// {site}_hosts_admin -> {site}_hosts_access ; {site}_apps_admin -> {site}_apps_access +// +// `{site}_everyone` is created for completeness; it has implicit membership and +// is granted to a resource as a grantee, never enumerated. +async function ensureSiteGroups(siteSlug, ownerDn, siteName, siteResourceId) { + if (!siteSlug) return; + + // Link a site group to the site resource (so it shows + is member-manageable + // on the site's modal). Idempotent. Admin groups link as owner; access/meta + // groups as member. + const link = async (cn, isAdmin) => { + if (!siteResourceId) return; + await ResourceGroup.create({ resourceId: siteResourceId, groupCn: cn, accessLevel: isAdmin ? 'owner' : 'member' }).catch(() => {}); + }; + + const sAdmin = groups.siteSuperAdminCns(siteSlug); + await ensureGroup(sAdmin, ownerDn, `Site admin for ${siteName || siteSlug}`); + await link(sAdmin, true); + 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.siteEveryoneCns(siteSlug), ownerDn, `All users at ${siteSlug}`); + await link(groups.siteEveryoneCns(siteSlug), false); + // god_admin is the global group; surface it on the site modal so its members + // can be managed from the Directory (it has no home on a single resource). + await link(groups.GOD_ADMIN, true); + + // Wire the lattice as nesting so LDAP-level consumers (SSSD, sudo, anything + // binding directly) resolve it transitively, not just utils/permission.js. + // nestGroup(child, parent) makes child a member of parent -- membership flows + // child -> parent ("up"), so a group's members inherit what its parents hold. + await nestGroup(groups.GOD_ADMIN, sAdmin); // god admins are site admins everywhere + for (const kind of ['host', 'app']) { + const aggAdmin = groups.aggregateGroupCns(siteSlug, kind, 'admin'); + const aggAccess = groups.aggregateGroupCns(siteSlug, kind, 'access'); + await nestGroup(sAdmin, aggAdmin); // site admins administer all hosts/apps + await nestGroup(aggAdmin, aggAccess); // site admin implies site access + } +} + +// 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: +// +// {site}_{slug}_admin -> {site}_{slug}_access +// {site}_{slug}_admin -> {site}_{kind}s_admin (aggregate) +// {site}_{slug}_access -> {site}_{kind}s_access (aggregate) +// app_super_admin -> {site}_{slug}_admin (legacy cross-app) +async function provisionResourceGroups(resource, kind, siteSlug, ownerDn) { + const accessCn = groups.resourceGroupCns(siteSlug, resource.slug, 'access'); + const adminCn = groups.resourceGroupCns(siteSlug, resource.slug, '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 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 +} + +// The group CNs it is valid to associate with a given resource (docs/GROUPS.md +// §2/§3). This is what "force the correct naming convention" means: a group +// linked to a resource must be one that parses for consumers -- the resource's +// own specific groups, its site's aggregates, site-level groups, or the global +// god_admin. Returns a Set of the fixed valid CNs plus a RegExp for opaque +// capability groups following the same shapes. +function validGroupCnsForResource(resource, siteSlug) { + const valid = new Set(); + 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-]+$`) }; + } + 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')); + 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-]+$`) }; + } + // oauth/container etc. — only the global god_admin makes sense to pin here. + valid.add(groups.siteSuperAdminCns(siteSlug)); + return { valid, capRe: null }; +} + // Require the admin group router.use(async (req, res, next) => { try { @@ -50,6 +193,36 @@ router.get('/resources', async (req, res, next) => { }); // Even admins never receive secret metadata (e.g. client_secret_hash) over // the wire; projectResources strips it unconditionally. + + // Self-heal the group model (docs/GROUPS.md): ensure every site has its + // site-level groups (S_super_admin, S_hosts_*, S_apps_*, S_everyone) + the + // aggregates, and every host/app resource has its per-resource groups nested + // into them. Idempotent, so this is a cheap no-op once present -- it's what + // backfills a directory seeded by an older release without a rebuild. + // Never fails the list. + const sites = resources.filter(r => r.kind === 'site'); + await Promise.all(sites.map(site => + ensureSiteGroups(site.slug, req.user.dn, site.name, site.id) + .catch(err => console.error(`ensureSiteGroups(${site.slug}) failed:`, err.message)) + )); + const siteByResource = new Map(); + for (const site of sites) siteByResource.set(site.id, site.slug); + const siteOf = async (r) => { + const direct = siteByResource.get(r.id); + if (direct) return direct; + // findAncestorSiteSlug returns the site's full slug (`site_local`) -- the + // group-model builders take it verbatim, so do NOT strip the `site_` prefix. + return await Resource.findAncestorSiteSlug(r.id).catch(() => null); + }; + await Promise.all(resources.map(async (r) => { + const gKind = groupKind(r); + if (!gKind) return; + const siteSlug = await siteOf(r); + if (!siteSlug) return; + await provisionResourceGroups(r, gKind, siteSlug, req.user.dn) + .catch(err => console.error(`provisionResourceGroups(${r.slug}) failed:`, err.message)); + })); + res.json({ results: projectResources(resources, { fullMetadata: true }) }); } catch (err) { next(err); } }); @@ -95,46 +268,25 @@ router.post('/resources', async (req, res, next) => { await ResourceEdge.create({ parentId: req.body.hostId, childId: r.id, relation: r.kind === 'oauth' ? 'oauth' : 'hosts' }); } - if (r.kind === 'host' || r.kind === 'service') { - const siteSlug = await Resource.findAncestorSiteSlug(r.id); - const groupCn = suffix => (siteSlug ? `${siteSlug}_${r.slug}_${suffix}` : `${r.slug}_${suffix}`); - - const createGroup = async (suffix, accessLevel) => { - const cn = groupCn(suffix); - try { - await Group.add({ - name: cn, - owner: req.user.dn, - description: `${suffix === 'admin' ? 'Admin' : 'Access'} group for ${r.name}` - }); - } catch (err) { - if (err.name !== 'EntryAlreadyExistsError' && err.code !== 68) { - console.error(`Failed to create LDAP group ${cn}:`, err); - } - } - try { - await ResourceGroup.create({ resourceId: r.id, groupCn: cn, accessLevel }); - } catch(err) { /* ignore duplicate links */ } - }; - await createGroup('access', 'member'); - await createGroup('admin', 'owner'); - - // Wire up the two standing relationships every resource has, as nesting - // rather than as membership that has to be maintained per resource: - // - // app_super_admin -> _admin cross-app super admins administer - // every resource, automatically - // _admin -> _access administering something implies - // being able to use it - // - // Before nesting, both of these could only be expressed by adding every - // super admin to every new group by hand -- which nobody does, so the - // groups drifted. A failure here must not fail resource creation: the - // resource and its groups already exist and the nesting is repairable. - await nestGroup(groupCn('admin'), groupCn('access')); - await nestGroup(SUPER_ADMIN_GROUP, groupCn('admin')); + // ── Group provisioning (docs/GROUPS.md) ─────────────────────────────── + // Materialize the group-model for the new resource. Site resources get the + // site-level groups; host/app resources get their per-resource groups nested + // into the site aggregates. Idempotent -- safe for a resource created by an + // older release. A provisioning failure must not fail resource creation: the + // resource already exists and the groups are repairable (re-run ensures them). + // + // `siteSlug` is the site resource's slug verbatim (`site_local`) -- the + // group-model builders treat it as opaque (docs/GROUPS.md §3) and re-apply + // the kind prefix themselves. + const gKind = groupKind(r); + const ancestorSite = await Resource.findAncestorSiteSlug(r.id); + if (r.kind === 'site') { + await ensureSiteGroups(r.slug, req.user.dn, r.name, r.id); + } else if (gKind && ancestorSite) { + await ensureSiteGroups(ancestorSite, req.user.dn, r.name); // backfill site tier if missing + await provisionResourceGroups(r, gKind, ancestorSite, req.user.dn); } - + res.json({ results: r }); } catch (err) { if (err.name === 'SequelizeUniqueConstraintError') { @@ -265,6 +417,28 @@ router.get('/groups', async (req, res, next) => { router.post('/groups', async (req, res, next) => { try { + const { resourceId, groupCn } = req.body; + if (!resourceId || !groupCn) return res.status(400).json({ error: 'resourceId and groupCn are required' }); + + // Enforce the group-model naming convention (docs/GROUPS.md §3). The CN must + // be a valid group for this resource; reject free-form names so the groups + // consumers read are always parseable. god_admin is always allowed (it is + // the global group and is managed from a site's modal). + const resource = await Resource.get(resourceId); + // Full site slug verbatim (`site_local`) -- the builders take it as-is. A + // site resource's own slug is its site; a host/app uses its ancestor site. + const siteSlug = resource && resource.kind === 'site' + ? resource.slug + : await Resource.findAncestorSiteSlug(resourceId); + if (resource && siteSlug && groupCn !== groups.GOD_ADMIN) { + const { valid, capRe } = validGroupCnsForResource(resource, siteSlug); + if (!valid.has(groupCn) && !(capRe && capRe.test(groupCn))) { + const err = new Error(`"${groupCn}" is not a valid group for this ${resource.kind}. Use the resource's own groups, a site aggregate, a site-level group, or god_admin (e.g. ${[...valid].join(', ')}).`); + err.status = 400; + throw err; + } + } + const g = await ResourceGroup.create(req.body); res.json({ results: g }); } catch (err) { next(err); } diff --git a/nodejs/routes/docs.js b/nodejs/routes/docs.js index 968b0e9..ac9573e 100644 --- a/nodejs/routes/docs.js +++ b/nodejs/routes/docs.js @@ -55,7 +55,7 @@ const docList = Object.entries(DOCS).map(([slug, d]) => ({slug, title: d.title}) // only resolves correctly on GitHub. Serve that same folder here and rewrite // the rendered markup to point at it absolutely, so the images work when // read from /docs/overview too. -router.use('/images', require('express').static(path.join(__dirname, '../../docs/images'))); +router.use('/docs/images', require('express').static(path.join(__dirname, '../../docs/images'))); function fixImagePaths(html) { return html.replace(/(["(])docs\/images\//g, '$1/docs/images/'); } diff --git a/nodejs/tests/groups.test.js b/nodejs/tests/groups.test.js index 3450f8a..9ead71a 100644 --- a/nodejs/tests/groups.test.js +++ b/nodejs/tests/groups.test.js @@ -12,9 +12,12 @@ const { GOD_ADMIN, } = require('../utils/groups'); -const HOST = { site: 'Main Office', kind: 'host', slug: 'Web 01' }; +// 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. +const HOST = { site: 'main-office', kind: 'host', slug: 'host_web-01' }; const APP = { site: 'main-office', kind: 'app', slug: 'emby' }; -const OTHER_SITE_HOST = { site: 'branch-office', kind: 'host', slug: 'db' }; +const OTHER_SITE_HOST = { site: 'branch-office', kind: 'host', slug: 'host_db' }; describe('slugify', () => { test('lowercases, spaces and underscores become hyphens, no leading/trailing dash', () => { @@ -24,27 +27,31 @@ describe('slugify', () => { expect(slugify(' Mixed CASE--name ')).toBe('mixed-case-name'); expect(slugify('')).toBe(''); }); - test('never contains an underscore (the structural delimiter)', () => { - expect(slugify('a_b_c')).not.toContain('_'); - expect(resourceGroupCns('Main Office', 'host', 'Web 01', 'access')).not.toContain('__'); - }); }); describe('group cn builders', () => { - test('per-resource uses singular kind', () => { - 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('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('aggregate uses plural kind', () => { + test('aggregate uses the plural kind', () => { expect(aggregateGroupCns('main-office', 'host', 'admin')).toBe('main-office_hosts_admin'); expect(aggregateGroupCns('main-office', 'app', 'access')).toBe('main-office_apps_access'); }); test('site super admin + everyone', () => { - expect(siteSuperAdminCns('Main Office')).toBe('main-office_super_admin'); + expect(siteSuperAdminCns('main-office')).toBe('main-office_super_admin'); expect(siteEveryoneCns('main-office')).toBe('main-office_everyone'); }); - test('invalid kind throws', () => { - expect(() => resourceGroupCns('s', 'service', 'x', 'admin')).toThrow(); + 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. + 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)', () => { + expect(() => aggregateGroupCns('s', 'service', 'admin')).toThrow(); }); }); @@ -82,32 +89,32 @@ 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); + 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(['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', 'app', 'emby', 'admin'); + const appAdmin = resourceGroupCns('main-office', 'emby', 'admin'); expect(hasPermission([appAdmin], APP, 'access')).toBe(true); }); 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 090c9eb..ae0d42a 100644 --- a/nodejs/utils/groups.js +++ b/nodejs/utils/groups.js @@ -41,26 +41,33 @@ function assertKind(kind) { if (!KINDS.includes(kind)) throw new Error(`invalid resource kind: ${kind} (must be host or app)`); } -// {site}_host__ / {site}_app__ -function resourceGroupCns(site, kind, slug, level) { - assertKind(kind); - return `${slugify(site)}_${kind}_${slugify(slug)}_${level}`; +// {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}`; } // {site}_hosts_ / {site}_apps_ (plural kind — the aggregate). function aggregateGroupCns(site, kind, level) { assertKind(kind); - return `${slugify(site)}_${kind}s_${level}`; + return `${site}_${kind}s_${level}`; } // {site}_super_admin function siteSuperAdminCns(site) { - return `${slugify(site)}_super_admin`; + return `${site}_super_admin`; } // {site}_everyone function siteEveryoneCns(site) { - return `${slugify(site)}_everyone`; + return `${site}_everyone`; } // True if `level` is a known admin/access level (not an opaque capability). @@ -87,9 +94,11 @@ 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) { - const site = slugify(resource && resource.site); + // `site` and `slug` are used verbatim (resource slugs may carry a kind prefix, + // e.g. `site_local` / `host_theta-env`) -- see resourceGroupCns. + const site = resource && resource.site; const kind = resource && resource.kind; - const slug = slugify(resource && resource.slug); + const slug = resource && resource.slug; const set = new Set(memberOf || []); if (set.has(GOD_ADMIN)) return true; @@ -98,13 +107,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, kind, slug, level))) return true; + if (set.has(resourceGroupCns(site, slug, 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, kind, slug, level))) return true; + if (set.has(resourceGroupCns(site, slug, level))) return true; return false; } diff --git a/nodejs/views/directory.ejs b/nodejs/views/directory.ejs index 094b4bc..3e5a27d 100644 --- a/nodejs/views/directory.ejs +++ b/nodejs/views/directory.ejs @@ -530,6 +530,11 @@ // tab read from these. Agent data comes from /api/agent/nodes (admin-gated). var agentsByHost = {}; var agentsByToken = {}; + // True when the agent/nodes endpoint itself was unreachable (network, or an + // older app without the agent route). When set we cannot tell "this host has + // no agent" apart from "the agent service is down", so we must NOT paint every + // host red as if it lacked an agent. + var agentsUnavailable = false; async function loadResources() { try { @@ -540,8 +545,12 @@ // Access counts are a nicety, not load-bearing: if the LDAP join fails // the table still renders, just without the Access column populated. app.api.get('directory-admin/access-summary').catch(function(){ return {results: {}}; }), - // Agents are a nicety too: never block the directory on them. - app.api.get('agent/nodes').catch(function(){ return {agents: []}; }) + // Agents are a nicety too: never block the directory on them. Track + // whether the endpoint itself is reachable so host rows can tell "no + // agent on this host" from "agent service is down" (see attachAgentStatus). + app.api.get('agent/nodes') + .then(function(res){ agentsUnavailable = false; return res; }) + .catch(function(){ agentsUnavailable = true; return {agents: []}; }) ]); accessSummary = (resAccess && resAccess.results) || {}; @@ -610,7 +619,12 @@ const a = agentsByHost[name] || (slug && agentsByHost[slug]); n.agent = a || null; if (resourcesById[n.id]) resourcesById[n.id].agent = a || null; - if (!a) { n.agentColor = '#dc3545'; n.agentStatusTitle = 'No theta-agent connected'; return; } + if (!a) { + // Endpoint unreachable: we genuinely don't know -- neutral grey, not a + // false red alarm across every host. + if (agentsUnavailable) { n.agentColor = '#adb5bd'; n.agentStatusTitle = 'Agent service unreachable'; return; } + n.agentColor = '#dc3545'; n.agentStatusTitle = 'No theta-agent connected'; return; + } if (!a.isOnline) { n.agentColor = '#dc3545'; n.agentStatusTitle = 'Agent offline (' + (a.hostname || 'unknown') + ')'; return; } const t = a.telemetry || {}; const high = (t.cpu_usage_percent > 80) || (t.ram_usage_percent > 80) || (t.disk_usage_percent > 90); @@ -655,8 +669,12 @@ try { const res = await app.api.get('agent/nodes'); indexAgents((res && res.agents) || []); + agentsUnavailable = false; renderTable(); - } catch (e) { /* non-fatal */ } + } catch (e) { + agentsUnavailable = true; + renderTable(); // re-render so dots flip to neutral, not stale green + } } // "Who can reach this?" at a glance. A resource with no linked group is not a diff --git a/nodejs/views/profile.ejs b/nodejs/views/profile.ejs index 799e6b9..2ac100c 100644 --- a/nodejs/views/profile.ejs +++ b/nodejs/views/profile.ejs @@ -657,8 +657,8 @@