Files
sso-manager-node/nodejs/routes/group.js
wmantly 4a592f9795 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>
2026-07-31 01:22:08 -04:00

224 lines
7.1 KiB
JavaScript

'use strict';
const router = require('express').Router();
const {User} = require('../models/user_ldap');
const {Group} = require('../models/group_ldap');
const permission = require('../utils/permission');
router.get('/', async function(req, res, next){
try{
let member = req.query.member ? await User.get(req.query.member) : {}
return res.json({
results: await Group[req.query.detail ? "listDetail" : "list"](member.dn)
});
}catch(error){
next(error);
}
});
router.post('/', async function(req, res, next){
try{
await permission.byGroup(req.user, ['app_sso_admin']);
req.body.owner = req.user.dn;
return res.json({
results: await Group.add(req.body),
message: `${req.body.name} was added!`
})
}catch(error){
next(error);
}
});
router.get('/:name', async function(req, res, next){
try{
return res.json({
results: await Group.get(req.params.name)
});
}catch(error){
next(error);
}
});
// ── Nested groups ───────────────────────────────────────────────────────────
// A groupOfNames `member` may be any DN, including another group's, which is
// how nesting is stored. These routes are mounted before /:group/:uid so the
// literal "nested"/"effective" path segments are not swallowed by that
// wildcard, which would otherwise try to resolve them as a uid.
// GET /api/group/:group/effective — who this group actually grants, split into
// directly-listed users, the groups nested into it, and the full transitive set
// of users. The UI shows "3 direct, 12 effective"; a plain member read cannot
// answer that, and on a server with nestgroup it silently returns the expanded
// list with no indication which entries are direct.
router.get('/:group/effective', async function(req, res, next){
try{
return res.json({ results: await Group.effectiveMembers(req.params.group) });
}catch(error){
next(error);
}
});
// PUT /api/group/:group/nested/:child — nest :child inside :group.
router.put('/:group/nested/:child', async function(req, res, next){
try{
await permission.byGroup(req.user, ['app_sso_admin'], [req.params.group]);
const parent = await Group.get(req.params.group);
const child = await Group.get(req.params.child);
if(parent.dn === child.dn){
return res.status(400).json({message: 'A group cannot contain itself.'});
}
// Refuse rather than rely on the resolver's depth cap: a cycle makes
// "who is in this group" unanswerable, and the cap would quietly return
// a truncated answer instead of an error anyone would notice.
if(await Group.wouldCycle(req.params.group, child.dn)){
return res.status(409).json({
message: `"${req.params.child}" already contains "${req.params.group}" — nesting them would create a loop.`
});
}
const results = await parent.addMember({dn: child.dn});
User.clearCache();
return res.json({
results,
message: `Nested ${req.params.child} inside ${req.params.group}.`
});
}catch(error){
if(error.name === 'TypeOrValueExistsError' || error.code === 20){
return res.status(409).json({message: `"${req.params.child}" is already nested in "${req.params.group}".`});
}
next(error);
}
});
// DELETE /api/group/:group/nested/:child — un-nest.
router.delete('/:group/nested/:child', async function(req, res, next){
try{
await permission.byGroup(req.user, ['app_sso_admin'], [req.params.group]);
const parent = await Group.get(req.params.group);
const child = await Group.get(req.params.child);
const results = await parent.removeMember({dn: child.dn});
User.clearCache();
return res.json({
results,
message: `Removed ${req.params.child} from ${req.params.group}.`
});
}catch(error){
// groupOfNames requires at least one member, so emptying a group is a
// schema violation rather than a permission problem. Surfacing the raw
// error as a 500 makes it look like a bug in the server; it is really a
// "you cannot do that, and here is why" -- the same reason the last user
// cannot be removed from a group either.
if(error.name === 'ObjectClassViolationError' || error.code === 65){
return res.status(409).json({
message: `"${req.params.child}" is the only member of "${req.params.group}". A group must keep at least one member — add another first.`
});
}
next(error);
}
});
router.put('/owner/:group/:uid', async function(req, res, next){
try{
await permission.byGroup(req.user, ['app_sso_admin'], [req.params.group]);
var group = await Group.get(req.params.group);
var user = await User.get(req.params.uid);
return res.json({
results: await group.addOwner(user),
message: `Added owner ${req.params.uid} to ${req.params.group} group.`
});
}catch(error){
next(error);
}
});
router.delete('/owner/:group/:uid', async function(req, res, next){
try{
await permission.byGroup(req.user, ['app_sso_admin'], [req.params.group]);
var group = await Group.get(req.params.group);
var user = await User.get(req.params.uid);
return res.json({
results: await group.removeOwner(user),
message: `Removed Owner ${req.params.uid} from ${req.params.group} group.`
});
}catch(error){
next(error);
}
});
router.put('/:group/:uid', async function(req, res, next){
try{
await permission.byGroup(req.user, ['app_sso_admin'], [req.params.group]);
var group = await Group.get(req.params.group);
var user = await User.get(req.params.uid);
const results = await group.addMember(user);
// Group membership feeds directly into cached-User-derived state
// (isServiceAccount, isAdmin, group-gated nav/UI) -- without this,
// a membership change here is invisible for up to the cache's TTL.
User.clearCache();
return res.json({
results,
message: `Added user ${req.params.uid} to ${req.params.group} group.`
});
}catch(error){
// Already a member -- surfaced as a plain 500 before, which read as a
// server fault for what is really a no-op. Common in practice because
// groupOfNames needs at least one member, so whoever creates a group is
// seeded into it and is then "added" again by the obvious next click.
if(error.name === 'TypeOrValueExistsError' || error.code === 20){
return res.status(409).json({
message: `"${req.params.uid}" is already a member of "${req.params.group}".`
});
}
next(error);
}
});
router.delete('/:group/:uid', async function(req, res, next){
try{
await permission.byGroup(req.user, ['app_sso_admin'], [req.params.group]);
var group = await Group.get(req.params.group);
var user = await User.get(req.params.uid);
const results = await group.removeMember(user);
User.clearCache();
return res.json({
results,
message: `Removed user ${req.params.uid} from ${req.params.group} group.`
});
}catch(error){
next(error);
}
});
router.delete('/:group', async function(req, res, next){
try{
await permission.byGroup(req.user, ['app_sso_admin'], [req.params.group]);
var group = await Group.get(req.params.group);
return res.json({
removed: await group.remove(),
results: group,
message: `Group ${req.params.group} Deleted`
});
}catch(error){
next(error);
}
});
module.exports = router;