Files
wmantly f00d311029 fix: keep SUPER_ADMIN_GROUP as app_super_admin so resource auto-provisioning nesting works
api_directory_admin nests permission.SUPER_ADMIN_GROUP into every new resource's
_admin group. Changing it to the not-yet-existing 'god_admin' made that nesting
no-op, leaving the creator as the sole member (so the access_request test's
beforeAll could not remove the last member of a groupOfNames). Revert it to
'app_super_admin' and recognize 'god_admin' separately in isSuperAdmin + isAdmin.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 16:31:00 -04:00

402 lines
13 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;
// Console admin under the group model (docs/GROUPS.md §11): god_admin,
// a site super admin, the SSO-as-app admin ({site}_app_sso_admin), or the
// legacy app_sso_admin/app_super_admin during migration.
user.isAdmin = groups.some((g) =>
g === 'app_sso_admin' || g === 'app_super_admin' ||
g === 'god_admin' || g === permission.SUPER_ADMIN_GROUP ||
g.endsWith('_super_admin') || g.endsWith('_app_sso_admin'));
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;