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
+195 -34
View File
@@ -3,8 +3,32 @@ const router = require('express').Router();
const permission = require('../utils/permission');
const { Resource, ResourceEdge, ResourceGroup } = require('../models/resource');
const { Group } = require('../models/group_ldap');
const { User } = require('../models/user_ldap');
const { cnFromDn } = require('../utils/user_groups');
const { projectResources } = require('@simpleworkjs/directory-schema');
const SUPER_ADMIN_GROUP = permission.SUPER_ADMIN_GROUP;
// 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
// the goal state, and a missing group (e.g. app_super_admin absent on a
// directory seeded by an older entrypoint) is a reason to skip, not to fail the
// caller's real work.
async function nestGroup(childCn, parentCn) {
try {
const parent = await Group.get(parentCn);
const child = await Group.get(childCn);
if (await Group.wouldCycle(parentCn, child.dn)) {
console.error(`nestGroup: refusing ${childCn} -> ${parentCn} (would create a cycle)`);
return;
}
await parent.addMember({ dn: child.dn });
} catch (err) {
const benign = err.name === 'TypeOrValueExistsError' || err.code === 20 || err.name === 'GroupNotFound';
if (!benign) console.error(`nestGroup: ${childCn} -> ${parentCn} failed:`, err.message);
}
}
// Require the admin group
router.use(async (req, res, next) => {
try {
@@ -68,8 +92,10 @@ router.post('/resources', async (req, res, next) => {
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 = siteSlug ? `${siteSlug}_${r.slug}_${suffix}` : `${r.slug}_${suffix}`;
const cn = groupCn(suffix);
try {
await Group.add({
name: cn,
@@ -87,6 +113,21 @@ router.post('/resources', async (req, res, next) => {
};
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 -> <slug>_admin cross-app super admins administer
// every resource, automatically
// <slug>_admin -> <slug>_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'));
}
res.json({ results: r });
@@ -103,18 +144,8 @@ router.post('/resources', async (req, res, next) => {
router.put('/resources/:id', async (req, res, next) => {
try {
let r;
if (req.body.kind === 'oauth') {
const { OAuthClient } = require('../models/oauth_client');
r = await OAuthClient.get(req.params.id);
} else {
r = await Resource.get(req.params.id);
}
if (!r) return res.status(404).json({ error: 'Not found' });
req.body.updated_by = req.user.uid;
req.body.updated_on = Date.now();
// Validate before loading anything -- a rejected body should never have
// touched the store.
if (req.body.kind === 'host' && !req.body.hostId) {
return res.status(400).json({ error: 'Hosts must have a parent Site or Host' });
}
@@ -124,14 +155,20 @@ router.put('/resources/:id', async (req, res, next) => {
if (req.body.kind === 'oauth' && !req.body.hostId) {
return res.status(400).json({ error: 'OAuth Integrations must have a parent Service' });
}
let updated;
if (req.body.kind === 'oauth') {
updated = await r.update(req.body);
} else {
updated = await r.update(req.body);
}
// OAuthClient is a wrapper over the same `resource` row, but its .update()
// handles the oauth-specific body fields (redirect_uris, scopes,
// token_lifetime) that a bare Resource would drop into metadata unvalidated.
const { OAuthClient } = require('../models/oauth_client');
const model = req.body.kind === 'oauth' ? OAuthClient : Resource;
const r = await model.get(req.params.id);
if (!r) return res.status(404).json({ error: 'Not found' });
req.body.updated_by = req.user.uid;
req.body.updated_on = Date.now();
const updated = await r.update(req.body);
if ((updated.kind === 'host' || updated.kind === 'service' || updated.kind === 'oauth') && req.body.hostId !== undefined) {
const existingEdges = await ResourceEdge.list({ where: { childId: r.id } });
for (const e of existingEdges) {
@@ -163,13 +200,18 @@ router.delete('/resources/:id', async (req, res, next) => {
try {
const r = await Resource.get(req.params.id);
if (!r) return res.status(404).json({ error: 'Not found' });
await r.delete();
// Also delete edges and groups involving this resource
// Clear the dependents FIRST. There is no transaction here, so ordering is
// the only thing protecting us: if a dependent delete throws after the
// resource row is gone, the leftovers are edges/links pointing at a
// nonexistent id -- invisible in the UI and poisonous to getGraph(). Failing
// with the resource still present is the recoverable direction (retry the
// delete); the caller sees the error either way.
const edgesParent = await ResourceEdge.list({ where: { parentId: req.params.id } });
const edgesChild = await ResourceEdge.list({ where: { childId: req.params.id } });
const groups = await ResourceGroup.list({ where: { resourceId: req.params.id } });
for (const e of [...edgesParent, ...edgesChild]) await e.delete();
for (const g of groups) await g.delete();
await r.delete();
res.json({ results: true });
} catch (err) { next(err); }
});
@@ -222,19 +264,138 @@ router.delete('/groups/:id', async (req, res, next) => {
} catch (err) { next(err); }
});
// --- Access visibility ---
//
// The two questions an access-control pane has to answer, neither of which the
// directory could answer before: "who can reach this resource" (a column on the
// table, rather than three clicks into a modal) and "what can this user reach"
// (which had no UI at all). Both are joins of the same two sets, so both are
// served from one cached Group.listDetail() rather than a lookup per row.
// dn -> uid, so member DNs can be reported as the uids admins actually think in.
async function dnToUidMap() {
const users = await User.listDetail();
return new Map(users.map(u => [String(u.dn).toLowerCase(), u.uid]));
}
// GET /access-summary — { resourceId: { groups: [...], memberCount } }
router.get('/access-summary', async (req, res, next) => {
try {
const [links, groups, uidByDn] = await Promise.all([
ResourceGroup.list(),
Group.listDetail(),
dnToUidMap(),
]);
const groupByCn = new Map(groups.map(g => [g.cn, g]));
const summary = {};
for (const link of links) {
const group = groupByCn.get(link.groupCn);
// A link whose LDAP group has been deleted out from under it: report it
// rather than skipping, since a dangling link grants nothing and the
// admin needs to see that it is dead.
//
// Counts come from the transitive closure, not from `member`. Reading the
// attribute would report only who is listed on the group, missing anyone
// who reaches it through a nested group -- and since app_super_admin is
// nested into every resource's _admin group, that is not an edge case.
let members = [];
if (group) {
const eff = await Group.effectiveMembers(link.groupCn);
members = eff.effective.map(dn => uidByDn.get(String(dn).toLowerCase()) || cnFromDn(dn));
}
const entry = summary[link.resourceId] || (summary[link.resourceId] = { groups: [], members: [] });
entry.groups.push({
cn: link.groupCn,
accessLevel: link.accessLevel,
exists: !!group,
memberCount: members.length,
});
for (const uid of members) {
if (!entry.members.includes(uid)) entry.members.push(uid);
}
}
for (const id of Object.keys(summary)) {
summary[id].memberCount = summary[id].members.length;
}
res.json({ results: summary });
} catch (err) { next(err); }
});
// GET /user-access/:uid — every resource a given user can reach, and via which
// group. This is the reverse lookup; previously an admin could only see their
// own access, via /api/discovery/me.
router.get('/user-access/:uid', async (req, res, next) => {
try {
const user = await User.get({ uid: req.params.uid });
if (!user) return res.status(404).json({ error: 'User not found' });
const dn = String(user.dn).toLowerCase();
const groups = await Group.listDetail();
const memberOf = groups
.filter(g => [].concat(g.member || []).some(m => String(m).toLowerCase() === dn))
.map(g => g.cn);
const [links, resources] = await Promise.all([ResourceGroup.list(), Resource.list()]);
const byId = new Map(resources.map(r => [r.id, r]));
const results = [];
for (const link of links) {
if (!memberOf.includes(link.groupCn)) continue;
const resource = byId.get(link.resourceId);
if (!resource) continue;
results.push({
id: resource.id,
name: resource.name,
slug: resource.slug,
kind: resource.kind,
groupCn: link.groupCn,
accessLevel: link.accessLevel,
});
}
res.json({ results: { uid: user.uid, groups: memberOf, resources: results } });
} catch (err) { next(err); }
});
// Tail the last `lines` lines of a log file without shelling out. Reads at most
// the trailing MAX_TAIL_BYTES so an unrotated multi-GB log can't blow up the
// heap. A missing/unreadable file is normal (the log only exists once slapd has
// written to it), so it yields '' rather than an error.
const MAX_TAIL_BYTES = 256 * 1024;
async function tailFile(filePath, lines = 100) {
const fs = require('fs/promises');
let fh;
try {
fh = await fs.open(filePath, 'r');
const { size } = await fh.stat();
const start = Math.max(0, size - MAX_TAIL_BYTES);
const buf = Buffer.alloc(Math.min(size, MAX_TAIL_BYTES));
await fh.read(buf, 0, buf.length, start);
const text = buf.toString('utf8');
// A partial first line when we started mid-file; drop it.
const rows = (start > 0 ? text.slice(text.indexOf('\n') + 1) : text).split('\n');
return rows.slice(-lines).join('\n');
} catch (err) {
return '';
} finally {
if (fh) await fh.close().catch(() => {});
}
}
router.get('/audit-logs', async (req, res, next) => {
try {
const fs = require('fs');
const { execSync } = require('child_process');
let ldapLogs = '';
let oauthLogs = '';
let auditLogs = '';
try { ldapLogs = execSync('tail -n 100 /var/lib/ldap/slapd.log 2>/dev/null').toString(); } catch(e){}
try { oauthLogs = execSync('tail -n 100 /var/lib/ldap/oauth.log 2>/dev/null').toString(); } catch(e){}
try { auditLogs = execSync('tail -n 100 /var/lib/ldap/auditlog.ldif 2>/dev/null').toString(); } catch(e){}
res.json({ results: { ldap: ldapLogs, oauth: oauthLogs, audit: auditLogs } });
const [ldap, oauth, audit] = await Promise.all([
tailFile('/var/lib/ldap/slapd.log'),
tailFile('/var/lib/ldap/oauth.log'),
tailFile('/var/lib/ldap/auditlog.ldif'),
]);
res.json({ results: { ldap, oauth, audit } });
} catch (err) { next(err); }
});