Files
sso-manager-node/nodejs/routes/user.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

396 lines
12 KiB
JavaScript
Executable File

'use strict';
const router = require('express').Router();
const {User} = require('../models/user');
const {Group} = require('../models/group_ldap');
const permission = require('../utils/permission');
const {groupCns} = require('../utils/user_groups');
const {UserVerification} = require('../models/verification');
const {InviteToken} = require('../models/token');
router.get('/', async function(req, res, next){
try{
await permission.byGroup(req.user, ['app_sso_admin'])
return res.json({
results: await User[req.query.detail ? "listDetail" : "list"](),
});
}catch(error){
next(error);
}
});
router.post('/', async function(req, res, next){
try{
await permission.byGroup(req.user, ['app_sso_admin'])
req.body.created_by = req.user.uid
req.body.manager = [req.user.dn];
let user = await User.add(req.body);
const verif = await UserVerification.getOrCreate(user.uid);
const updates = { password_must_change: true };
if (req.body.tosAgree) updates.tos_accepted = true, updates.tos_accepted_at = Date.now();
await verif.update(updates);
// Service accounts (a Unix account an app/service runs as, not a
// person) are marked by membership in app_sso_service_account rather
// than a schema change -- see models/user_ldap.js User.listDetail.
if (req.body.isServiceAccount === true || req.body.isServiceAccount === 'true' || req.body.isServiceAccount === 'on') {
try {
const group = await Group.get('app_sso_service_account');
await group.addMember(user);
// User.add() already cached `user` (via its own internal
// User.get()) before this group membership existed, so the
// cached isServiceAccount would be stuck wrong for 5 minutes
// (the cache TTL) without this -- re-fetch after clearing.
User.clearCache();
user = await User.get(user.uid);
} catch (error) {
console.error(`user.add: failed to mark ${user.uid} as a service account:`, error.message);
}
}
return res.json({results: user, message: `User ${user.uid} created.`});
}catch(error){
next(error);
}
});
router.delete('/:uid', async function(req, res, next){
try{
let user;
if(req.params.uid.toLowerCase() === req.user.uid.toLowerCase()){
user = req.user;
}else{
user = await User.get(req.params.uid);
await permission.byGroup(req.user, ['app_sso_admin'])
}
return res.json({uid: req.params.uid, results: await user.remove()})
}catch(error){
next(error);
}
});
router.get('/me', async function(req, res, next){
try{
const user = JSON.parse(JSON.stringify(await User.get({uid: req.user.uid})));
// The shared client framework gates the UI on a single effective-rights
// flag (the OIDC-client apps send the same key). Here "admin" means
// membership in app_sso_admin or the cross-app app_super_admin group.
//
// Resolved via groupCns rather than read off `memberOf` directly: with
// nested groups, memberOf is only transitive when the directory carries
// the nestgroup overlay. Against a server without it, an admin who holds
// the group through nesting would get isAdmin=false here and silently
// lose the whole admin UI -- while still passing every server-side
// permission check, which resolves nesting properly. groupCns gives the
// same answer in both modes.
const groups = await groupCns(user);
user.groups = groups;
user.isAdmin = groups.includes('app_sso_admin') || groups.includes(permission.SUPER_ADMIN_GROUP);
return res.json(user);
}catch(error){
next(error);
}
});
router.post('/accept-tos', async function(req, res, next){
try{
const verif = await UserVerification.getOrCreate(req.user.uid);
await verif.markTosAccepted();
User.clearCache();
return res.json({ success: true });
}catch(error){
next(error);
}
});
router.put('/password', async function(req, res, next){
try{
const result = await req.user.setPassword(req.body);
const verif = await UserVerification.getOrCreate(req.user.uid);
await verif.update({ password_must_change: false });
User.clearCache();
return res.json({results: result, message: 'Password changed.'});
}catch(error){
next(error);
}
});
router.put('/:uid/password', async function(req, res, next){
try{
let user;
if(req.params.uid.toLowerCase() === req.user.uid.toLowerCase()){
user = req.user;
}else{
user = await User.get(req.params.uid);
await permission.byGroup(req.user, ['app_sso_admin'])
}
const result = await user.setPassword(req.body);
if(req.params.uid.toLowerCase() !== req.user.uid.toLowerCase()){
const verif = await UserVerification.getOrCreate(user.uid);
await verif.update({ password_must_change: true });
}
return res.json({
results: result,
message: `User ${user.uid} password changed.`
});
}catch(error){
next(error);
}
});
router.put('/:uid/active', async function(req, res, next){
try{
await permission.byGroup(req.user, ['app_sso_admin']);
const user = await User.get(req.params.uid);
const active = req.body.active !== false && req.body.active !== 'false';
await user.setActive(active);
return res.json({
uid: req.params.uid,
active,
message: `User ${req.params.uid} ${active ? 'activated' : 'deactivated'}`
});
}catch(error){
next(error);
}
});
router.get('/:uid/group-members', async function(req, res, next){
try{
await permission.byGroup(req.user, ['app_sso_admin']);
return res.json({results: await User.getPersonalGroupMembers(req.params.uid)});
}catch(error){
next(error);
}
});
router.put('/:uid/group-member/:memberUid', async function(req, res, next){
try{
await permission.byGroup(req.user, ['app_sso_admin']);
await User.addPersonalGroupMember(req.params.uid, req.params.memberUid);
return res.json({
results: true,
message: `Added ${req.params.memberUid} to ${req.params.uid}'s group`
});
}catch(error){
next(error);
}
});
router.delete('/:uid/group-member/:memberUid', async function(req, res, next){
try{
await permission.byGroup(req.user, ['app_sso_admin']);
await User.removePersonalGroupMember(req.params.uid, req.params.memberUid);
return res.json({
results: true,
message: `Removed ${req.params.memberUid} from ${req.params.uid}'s group`
});
}catch(error){
next(error);
}
});
router.put('/:uid', async function(req, res, next){
try{
let user;
if(req.params.uid.toLowerCase() === req.user.uid.toLowerCase()){
user = req.user;
}else{
user = await User.get(req.params.uid);
const isManager = (user.manager || []).includes(req.user.dn);
if(!isManager) await permission.byGroup(req.user, ['app_sso_admin'])
}
// The manager picker is a tag widget backed by a single newline-separated
// hidden input (see public/js/app.js app.ui.userSelect), same convention
// as oauth_client.js's allowed_groups.
if (typeof req.body.manager === 'string') {
req.body.manager = req.body.manager.split('\n').map(s => s.trim()).filter(Boolean);
}
return res.json({
results: await user.update(req.body),
message: `Updated ${req.params.uid} user`
});
}catch(error){
next(error);
}
});
router.post('/invite', async function(req, res, next){
try{
await permission.byGroup(req.user, ['app_sso_admin', 'app_sso_invite']);
const { mail, groups = [] } = req.body;
const token = await req.user.invite({
mail,
groups,
url: `${req.protocol}://${req.hostname}`,
});
return res.json({
token: token.token,
link: `${req.protocol}://${req.hostname}/login/invite/${token.token}`,
mail_sent: !!mail,
});
}catch(error){
next(error);
}
});
router.get('/invite', async function(req, res, next){
try{
await permission.byGroup(req.user, ['app_sso_admin', 'app_sso_invite']);
const isAdmin = await permission.byGroup(req.user, ['app_sso_admin']).then(() => true).catch(() => false);
const all = await InviteToken.list();
const visible = isAdmin ? all : all.filter(t => t.created_by === req.user.uid);
const results = visible.map(t => ({ token: t.token, ...t }));
return res.json({ results });
}catch(error){
next(error);
}
});
router.put('/invite/:token', async function(req, res, next){
try{
await permission.byGroup(req.user, ['app_sso_admin', 'app_sso_invite']);
const token = await InviteToken.get(req.params.token);
const isAdmin = await permission.byGroup(req.user, ['app_sso_admin']).then(() => true).catch(() => false);
if (!isAdmin && token.created_by !== req.user.uid) {
const err = new Error('Insufficient Permission'); err.status = 401; throw err;
}
if (!token.is_valid) {
const err = new Error('Token is no longer valid'); err.status = 400; throw err;
}
const update = {};
if (req.body.groups !== undefined) {
update.groups = JSON.stringify([].concat(req.body.groups || []));
}
if (req.body.mail !== undefined && req.body.mail !== token.mail) {
if (req.body.mail) {
await User.verifyEmail({ token: token.token, mail: req.body.mail, url: `${req.protocol}://${req.hostname}` });
const refreshed = await InviteToken.get(token.token);
update.mail = refreshed.mail;
update.mail_token = refreshed.mail_token;
} else {
update.mail = '__NONE__';
update.mail_token = '__NONE__';
}
}
if (Object.keys(update).length) await token.update(update);
const updated = await InviteToken.get(req.params.token);
return res.json({ results: { token: updated.token, ...updated } });
}catch(error){
next(error);
}
});
router.delete('/invite/:token', async function(req, res, next){
try{
await permission.byGroup(req.user, ['app_sso_admin', 'app_sso_invite']);
const token = await InviteToken.get(req.params.token);
const isAdmin = await permission.byGroup(req.user, ['app_sso_admin']).then(() => true).catch(() => false);
if (!isAdmin && token.created_by !== req.user.uid) {
const err = new Error('Insufficient Permission'); err.status = 401; throw err;
}
await token.update({ is_valid: false });
return res.json({ results: true });
}catch(error){
next(error);
}
});
router.post('/key', async function(req, res, next){
try{
let added = await User.addSSHkey({
uid: req.user.uid,
key: req.body.key
});
return res.status(added === true ? 200 : 400).json({
message: added
});
}catch(error){
next(error);
}
});
router.get('/:uid/verification', async function(req, res, next){
try{
await permission.byGroup(req.user, ['app_sso_admin']);
const verif = await UserVerification.getOrCreate(req.params.uid);
return res.json({
uid: req.params.uid,
emailVerified: verif.email_verified,
emailVerifiedAt: verif.email_verified_at || null,
phoneVerified: verif.phone_verified,
phoneVerifiedAt: verif.phone_verified_at || null,
tosAccepted: verif.tos_accepted,
tosAcceptedAt: verif.tos_accepted_at || null,
});
}catch(error){
next(error);
}
});
router.get('/stats', async function(req, res, next){
try{
await permission.byGroup(req.user, ['app_sso_admin']);
const [users, groups] = await Promise.all([User.listDetail(), Group.list()]);
const active = users.filter(u => !u.pwdAccountLockedTime);
const inactive = users.filter(u => u.pwdAccountLockedTime);
const recent = [...users]
.sort((a, b) => (b.createTimestamp || '').localeCompare(a.createTimestamp || ''))
.slice(0, 10)
.map(u => ({ uid: u.uid, givenName: u.givenName, sn: u.sn, mail: u.mail, createTimestamp: u.createTimestamp }));
return res.json({
totalUsers: users.length,
activeUsers: active.length,
inactiveUsers: inactive.length,
totalGroups: groups.length,
recentSignups: recent,
inactiveList: inactive.map(u => ({ uid: u.uid, givenName: u.givenName, sn: u.sn, mail: u.mail })),
});
}catch(error){
next(error);
}
});
router.get('/export', async function(req, res, next){
try{
await permission.byGroup(req.user, ['app_sso_admin']);
const users = await User.listDetail();
const headers = ['uid', 'givenName', 'sn', 'mail', 'mobile', 'uidNumber', 'isActive', 'createTimestamp'];
const rows = users.map(u => headers.map(h => {
const v = u[h] || '';
return `"${String(v).replace(/"/g, '""')}"`;
}).join(','));
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', 'attachment; filename="users.csv"');
return res.send([headers.join(','), ...rows].join('\n'));
}catch(error){
next(error);
}
});
router.get('/:uid', async function(req, res, next){
try{
return res.json({
results: await User.get(req.params.uid),
});
}catch(error){
next(error);
}
});
module.exports = router;