// User CRUD. Mounted at /api/v1/auth/users by index.js (behind the auth gate). // Flat access: any logged-in user can manage other users (spec). import express from 'express'; import pool from '../db/pool.js'; import { hashPassword } from '../auth/passwords.js'; import { DEV_USER_ID, requireAdmin } from '../middleware/auth.js'; import { accessibleProjectIds } from '../auth/authz.js'; const router = express.Router(); const MIN_PASSWORD_LEN = 12; const ROLES = ['admin', 'editor', 'viewer']; function bad(res, msg) { return res.status(400).json({ error: msg }); } // GET / — list users (real ones; dev seed hidden) router.get('/', async (_req, res, next) => { try { const { rows } = await pool.query( `SELECT id, username, display_name, role, totp_enabled, last_login_at, created_at FROM users WHERE id <> $1 ORDER BY username`, [DEV_USER_ID]); res.json(rows); } catch (err) { next(err); } }); // POST / — create user router.post('/', async (req, res, next) => { try { const { username, password, display_name, role } = req.body || {}; if (!username || typeof username !== 'string') return bad(res, 'username required'); if (!password || password.length < MIN_PASSWORD_LEN) return bad(res, 'password must be at least ' + MIN_PASSWORD_LEN + ' chars'); if (role !== undefined && !ROLES.includes(role)) return bad(res, "role must be one of: " + ROLES.join(', ')); const hash = await hashPassword(password); const { rows } = await pool.query( `INSERT INTO users (username, password_hash, display_name, role) VALUES ($1, $2, $3, $4) RETURNING id, username, display_name, role, created_at`, [username.trim(), hash, display_name || username.trim(), role || 'admin'] ); res.status(201).json(rows[0]); } catch (err) { if (err.code === '23505') return res.status(409).json({ error: 'username already exists' }); next(err); } }); // POST /:id/password — admin reset another user's password router.post('/:id/password', async (req, res, next) => { try { const { new_password } = req.body || {}; if (!new_password || new_password.length < MIN_PASSWORD_LEN) { return bad(res, 'new password must be at least ' + MIN_PASSWORD_LEN + ' chars'); } const hash = await hashPassword(new_password); const { rowCount } = await pool.query( `UPDATE users SET password_hash = $1, password_updated_at = NOW() WHERE id = $2 AND id <> $3`, [hash, req.params.id, DEV_USER_ID]); if (rowCount === 0) return res.status(404).json({ error: 'user not found' }); res.status(204).end(); } catch (err) { next(err); } }); // DELETE /:id — delete a user, except the last real user router.delete('/:id', async (req, res, next) => { try { if (req.params.id === DEV_USER_ID) return res.status(400).json({ error: 'cannot delete dev user' }); const { rows } = await pool.query( `SELECT COUNT(*)::int AS n FROM users WHERE id <> $1`, [DEV_USER_ID]); if (rows[0].n <= 1) return res.status(409).json({ error: 'cannot delete last user' }); const { rowCount } = await pool.query(`DELETE FROM users WHERE id = $1`, [req.params.id]); if (rowCount === 0) return res.status(404).json({ error: 'user not found' }); res.status(204).end(); } catch (err) { next(err); } }); // PATCH /:id { display_name?, role?, password? } — generic update. // password update goes through hashPassword; other fields are passed through. router.patch('/:id', async (req, res, next) => { try { if (req.params.id === DEV_USER_ID) return res.status(400).json({ error: 'cannot edit dev user' }); const sets = []; const vals = []; if (typeof req.body?.display_name === 'string') { sets.push('display_name = $' + (sets.length + 1)); vals.push(req.body.display_name); } if (typeof req.body?.role === 'string') { if (!ROLES.includes(req.body.role)) return bad(res, "role must be one of: " + ROLES.join(', ')); sets.push('role = $' + (sets.length + 1)); vals.push(req.body.role); } if (typeof req.body?.password === 'string') { if (req.body.password.length < MIN_PASSWORD_LEN) return bad(res, 'password must be at least ' + MIN_PASSWORD_LEN + ' chars'); sets.push('password_hash = $' + (sets.length + 1) + ', password_updated_at = NOW()'); vals.push(await hashPassword(req.body.password)); } if (sets.length === 0) return bad(res, 'nothing to update'); vals.push(req.params.id); const { rows } = await pool.query( `UPDATE users SET ${sets.join(', ')} WHERE id = $${vals.length} RETURNING id, username, display_name, role`, vals ); if (rows.length === 0) return res.status(404).json({ error: 'user not found' }); res.json(rows[0]); } catch (err) { next(err); } }); // GET /:id/access — effective per-project access for one user (admin only). // Reuses authz.accessibleProjectIds (MAX over direct user grant + every group the // user belongs to). `via` is 'direct' for a user grant, 'group:' otherwise. // When the effective level comes from several sources we report the direct grant // if present, else the first contributing group. router.get('/:id/access', requireAdmin, async (req, res, next) => { try { const { rows: urows } = await pool.query( `SELECT id, role FROM users WHERE id = $1`, [req.params.id]); if (urows.length === 0) return res.status(404).json({ error: 'user not found' }); const target = urows[0]; const { rows: groups } = await pool.query( `SELECT g.id, g.name FROM user_groups ug JOIN groups g ON g.id = ug.group_id WHERE ug.user_id = $1 ORDER BY g.name`, [target.id]); // Admins bypass scoping — every project at 'edit', via their role. const access = await accessibleProjectIds(target); if (access.all) { const { rows: projects } = await pool.query( `SELECT id, name FROM projects ORDER BY name`); return res.json({ projects: projects.map(p => ({ project_id: p.id, project_name: p.name, level: 'edit', via: 'direct', })), groups, }); } const ids = [...access.ids]; if (ids.length === 0) return res.json({ projects: [], groups }); // Resolve names + the source of each grant. groupNameById lets us label a // group-sourced grant; a direct user grant always wins the `via` label. const groupNameById = new Map(groups.map(g => [g.id, g.name])); const { rows: grants } = await pool.query( `SELECT pa.project_id, pa.subject_type, pa.subject_id, pa.level, p.name AS project_name FROM project_access pa JOIN projects p ON p.id = pa.project_id WHERE (pa.subject_type = 'user' AND pa.subject_id = $1) OR (pa.subject_type = 'group' AND pa.subject_id IN ( SELECT group_id FROM user_groups WHERE user_id = $1 ))`, [target.id]); const byProject = new Map(); for (const g of grants) { const eff = access.levelByProject.get(g.project_id); // already the MAX const via = g.subject_type === 'user' ? 'direct' : 'group:' + (groupNameById.get(g.subject_id) || g.subject_id); const prev = byProject.get(g.project_id); // Keep a row only if it carries the effective level; prefer a direct grant // when both a direct and a group grant hit the same level. if (g.level === eff && (!prev || (prev.via !== 'direct' && via === 'direct'))) { byProject.set(g.project_id, { project_id: g.project_id, project_name: g.project_name, level: eff, via, }); } } res.json({ projects: [...byProject.values()].sort((a, b) => a.project_name.localeCompare(b.project_name)), groups, }); } catch (err) { next(err); } }); // POST /:id/totp/disable — admin clears a locked-out user's 2FA WITHOUT their // password (the self-service /auth/totp/disable needs the victim's own). Mirrors // that handler's SQL but targets :id and skips the password check. Dev user blocked. router.post('/:id/totp/disable', requireAdmin, async (req, res, next) => { try { if (req.params.id === DEV_USER_ID) return res.status(400).json({ error: 'cannot edit dev user' }); const { rowCount } = await pool.query( `UPDATE users SET totp_enabled = FALSE, totp_secret = NULL, totp_last_counter = 0 WHERE id = $1 AND id <> $2`, [req.params.id, DEV_USER_ID]); if (rowCount === 0) return res.status(404).json({ error: 'user not found' }); await pool.query(`DELETE FROM user_recovery_codes WHERE user_id = $1`, [req.params.id]); res.status(204).end(); } catch (err) { next(err); } }); export default router;