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
+37 -30
View File
@@ -103,6 +103,40 @@ class Resource extends Model {
return { resources: resObjs, edges };
}
// Stamp `resolvedAddress` on each resource: its own address/ip if it has one,
// otherwise the nearest ancestor's. A service usually carries no address of
// its own -- it is reached at the host it runs on -- so "how do I reach this"
// is only answerable from the graph, never from the row alone. Every caller
// that answers that question for a user (getMyAccess, GET /api/discovery/me)
// must go through here, or services come back unreachable.
static async withResolvedAddress(resources) {
if (!resources || !resources.length) return [];
const graph = await this.getGraph();
const resolve = (resId, visited = new Set()) => {
if (visited.has(resId)) return null; // prevent cycles
visited.add(resId);
const res = graph.resources.find(r => r.id === resId);
if (!res) return null;
if (res.metadata && res.metadata.address) return res.metadata.address;
if (res.metadata && res.metadata.ip) return res.metadata.ip;
for (const edge of graph.edges.filter(e => e.childId === resId)) {
const found = resolve(edge.parentId, visited);
if (found) return found;
}
return null;
};
return resources.map(r => {
const data = r.toJSON ? r.toJSON() : { ...r };
data.metadata = data.metadata || {};
data.resolvedAddress = resolve(data.id);
return data;
});
}
static async getMyAccess(userDn) {
const userGroups = await Group.list(userDn);
if (!userGroups || userGroups.length === 0) return [];
@@ -110,38 +144,11 @@ class Resource extends Model {
const resourceGroups = await ResourceGroup.list({
where: { groupCn: { in: userGroups } }
});
const resourceIds = [...new Set(resourceGroups.map(rg => rg.resourceId))];
if (resourceIds.length === 0) return [];
const resources = await this.list({ where: { id: { in: resourceIds } } });
// Resolve inherited addresses from the graph
const graph = await this.getGraph();
function resolveHost(resId, visited = new Set()) {
if (visited.has(resId)) return null; // prevent cycles
visited.add(resId);
const res = graph.resources.find(r => r.id === resId);
if (!res) return null;
if (res.metadata && res.metadata.address) return res.metadata.address;
if (res.metadata && res.metadata.ip) return res.metadata.ip;
const parentEdges = graph.edges.filter(e => e.childId === resId);
for (const edge of parentEdges) {
const found = resolveHost(edge.parentId, visited);
if (found) return found;
}
return null;
}
return resources.map(r => {
const data = { ...r };
data.metadata = data.metadata || {};
data.resolvedAddress = resolveHost(r.id);
return data;
});
return this.withResolvedAddress(await this.list({ where: { id: { in: resourceIds } } }));
}
static fields = {