Release 1.11.0: end-user catalog, access requests, nested groups

Closes the end-user half of the directory and adds nested LDAP groups.

The directory could describe the lab but could not tell anyone what they had
or how to reach it, and several of the paths meant to do so were silently
returning nothing:

  - GET /api/discovery/me resolved groups from req.user.groups, which does not
    exist (req.user carries memberOf), so it returned only isPublic resources
    for every human caller -- "My Services" was blank for everyone. The same
    read made isDirectoryAdmin() false for real admins.
  - The portal's "Discover More Services" called the admin-gated endpoint and
    swallowed the 403, so it never rendered for non-admins at all.
  - Services reported no address, because /me had reimplemented getMyAccess
    without its parent-walking resolution.

Adds the catalog at /, self-service access requests, and admin access
visibility (per-resource counts, and the reverse "what can this user reach").

Nested groups come in two halves. groupOfNames.member already accepts a group
DN, so nesting needs no schema -- what it needs is resolution, which no
released OpenLDAP performs. The all-in-one image therefore builds slapd from a
pinned master commit for the nestgroup overlay, and the app computes the
closure itself when pointed at a server without it. Both paths are covered.

member-values is deliberately left out of nestgroup-flags: it expands `member`
when reading a group, which destroys the distinction between "listed here" and
"reachable through a nested group" and is not recoverable afterwards.

Full suite green in both resolution modes: 215 passed, 2 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 01:22:08 -04:00
parent aa2592ea4e
commit 4a592f9795
33 changed files with 2314 additions and 246 deletions
+14 -9
View File
@@ -5,22 +5,27 @@ const {Group} = require('../models/group_ldap');
const SUPER_ADMIN_GROUP = 'app_super_admin';
let byGroup = async function(user, groups, ownerOf){
// Membership is resolved once, transitively: a user placed in an admin group
// through a nested group is as much a member as one listed on it directly.
// Checking `group.member.includes(user.dn)` per group -- as this used to --
// only ever sees the literal member list and would deny them.
let memberOfCns = [];
try{
let superAdmin = await Group.get(SUPER_ADMIN_GROUP);
if(superAdmin.member.includes(user.dn)) return true
memberOfCns = await Group.list(user.dn);
}catch(error){
// group not found, continue checking
// Fall through to the per-group checks below rather than hard-failing;
// they still catch direct membership if the resolver is unavailable.
}
if(memberOfCns.includes(SUPER_ADMIN_GROUP)) return true;
for(let group of groups){
try{
group = await Group.get(group);
if(group.member.includes(user.dn)) return true
}catch(error){
// group not found, continue checking
}
if(memberOfCns.includes(group)) return true;
}
// `owner` is deliberately NOT transitive. It designates accountable people,
// and inheriting ownership through a nested group would hand approval rights
// to anyone transitively in it -- an escalation nobody asked for.
for(let group of ownerOf || []){
try{
group = await Group.get(group);
+4
View File
@@ -38,6 +38,10 @@ module.exports = {
// app-base.js, which reveals .group-required-<cn> for each group the user is
// in (plus the synthetic `admin` group when user/me reports isAdmin).
nav: [
// Ungated on purpose: the catalog is the one page that exists for
// ordinary users. Before this, every nav item was admin-only and a
// non-admin had no signposted destination at all.
{href: '/', icon: 'fa-solid fa-compass', label: 'Catalog', groups: []},
{href: '/users', icon: 'fa-solid fa-users', label: 'Users', groups: ['app_sso_admin', 'admin']},
{href: '/groups', icon: 'fa-solid fa-users-viewfinder', label: 'Groups', groups: ['app_sso_admin', 'admin']},
{href: '/directory', icon: 'fa-solid fa-server', label: 'Directory', groups: ['app_sso_admin', 'app_sso_directory_admin', 'admin']},
+56
View File
@@ -0,0 +1,56 @@
'use strict';
// Resolve a request user's LDAP group CNs.
//
// Why this exists: `req.user` is a `User.get()` result, which carries
// `memberOf` -- a list of full group DNs -- and has no `groups` property at
// all. Anything reading `req.user.groups` therefore silently sees an empty
// list rather than failing, which is how GET /api/discovery/me came to return
// only `isPublic` resources for every human caller, and how
// isDirectoryAdmin() came to be false even for real directory admins.
//
// routes/user.js:83 already derives the admin gate from `memberOf` the same
// way, so the overlay is known to be populated in production; the Group.list()
// fallback covers a user object assembled without it (and costs an LDAP round
// trip, so it is genuinely the fallback).
const { Group } = require('../models/group_ldap');
// 'cn=app_sso_admin,ou=groups,dc=example,dc=com' -> 'app_sso_admin'
function cnFromDn(dn) {
return String(dn).split(',')[0].replace(/^cn=/i, '');
}
async function groupCns(user) {
if (!user || user.isMachine) return [];
// Group.list(dn) resolves nested groups transitively. `memberOf` cannot: the
// memberof overlay records only direct membership, so a user who reaches a
// resource group through a nested group is absent from it entirely. That
// makes memberOf a fallback for when there is no DN to query with, never the
// preferred source -- reading it first would silently drop every nested grant.
if (user.dn) {
try {
return await Group.list(user.dn);
} catch (err) {
console.error(`groupCns: LDAP lookup failed for ${user.uid}:`, err.message);
}
}
if (Array.isArray(user.memberOf)) return user.memberOf.map(cnFromDn);
// memberOf is single-valued when the user is in exactly one group.
if (user.memberOf) return [cnFromDn(user.memberOf)];
return [];
}
// The shape @simpleworkjs/directory-schema's isDirectoryAdmin() expects: it
// matches against `.groups`, which the raw request user does not have.
async function withGroups(user) {
if (!user) return user;
return Object.assign(Object.create(Object.getPrototypeOf(user) || Object.prototype), user, {
groups: await groupCns(user),
});
}
module.exports = { groupCns, withGroups, cnFromDn };