bf7be65b03
Jenkins Production Deployment
Reviewed-on: #12 Co-authored-by: Patrick Müller <mail@pmueller.me> Co-committed-by: Patrick Müller <mail@pmueller.me>
248 lines
7.8 KiB
TypeScript
248 lines
7.8 KiB
TypeScript
import express, {Request, Response} from 'express';
|
|
import * as UsersService from './users.admin.service.js';
|
|
import {toPermissions} from '../admin.schema.js';
|
|
import {sendServerError} from '../admin.errors.js';
|
|
|
|
export const usersAdminRouter = express.Router();
|
|
|
|
/**
|
|
* User administration. Mounted behind requireAppAccess('admin'), so every
|
|
* handler here can assume res.locals.admin is an admin.
|
|
*
|
|
* The guards below exist because this API can lock its own operators out: the
|
|
* only way to grant a permission is through these routes, so an admin who
|
|
* removes the last `admin` permission leaves nobody who can put it back short
|
|
* of a manual SQL statement in production.
|
|
*/
|
|
|
|
const conflict = (res: Response, message: string): void => {
|
|
res.status(409).send({status: 'CONFLICT', message});
|
|
};
|
|
|
|
const notFound = (res: Response): void => {
|
|
res.status(404).send({status: 'NOT_FOUND', message: 'Benutzer nicht gefunden.'});
|
|
};
|
|
|
|
/**
|
|
* @swagger
|
|
* /admin/users:
|
|
* get:
|
|
* summary: List all users with their app permissions and status
|
|
* tags: [admin]
|
|
* responses:
|
|
* 200:
|
|
* description: Success
|
|
* 401:
|
|
* description: Not signed in
|
|
* 403:
|
|
* description: Missing the admin permission
|
|
*/
|
|
usersAdminRouter.get('/', async (req: Request, res: Response) => {
|
|
try {
|
|
res.status(200).send(await UsersService.listUsers());
|
|
} catch (e: any) {
|
|
sendServerError(res, e);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @swagger
|
|
* /admin/users/{userId}:
|
|
* get:
|
|
* summary: One user with their active sessions and passkey count
|
|
* tags: [admin]
|
|
* parameters:
|
|
* - in: path
|
|
* name: userId
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Success
|
|
* 404:
|
|
* description: Unknown user
|
|
*/
|
|
usersAdminRouter.get('/:userId', async (req: Request, res: Response) => {
|
|
try {
|
|
const detail = await UsersService.getUserDetail(req.params.userId);
|
|
if (!detail) {
|
|
notFound(res);
|
|
return;
|
|
}
|
|
res.status(200).send(detail);
|
|
} catch (e: any) {
|
|
sendServerError(res, e);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @swagger
|
|
* /admin/users/{userId}/permissions:
|
|
* put:
|
|
* summary: Replace a user's app permissions
|
|
* description: Refuses to remove the caller's own admin permission or the last remaining active admin.
|
|
* tags: [admin]
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* permissions:
|
|
* type: array
|
|
* description: >
|
|
* One entry per (app, role). `access` is the only role today.
|
|
* A plain array of app names is also accepted and means the
|
|
* same at the `access` role.
|
|
* items:
|
|
* type: object
|
|
* properties:
|
|
* app:
|
|
* type: string
|
|
* enum: [calendar, feedback, tickets, admin]
|
|
* role:
|
|
* type: string
|
|
* enum: [access]
|
|
* responses:
|
|
* 200:
|
|
* description: Success
|
|
* 400:
|
|
* description: Invalid app or role
|
|
* 409:
|
|
* description: Would lock the last admin out
|
|
*/
|
|
usersAdminRouter.put('/:userId/permissions', async (req: Request, res: Response) => {
|
|
try {
|
|
const userId = req.params.userId;
|
|
|
|
// `permissions: [{app, role}]` is the real shape; `apps: ['tickets']` is
|
|
// accepted as shorthand for the same thing at the `access` role, so a
|
|
// caller that predates roles keeps working.
|
|
const permissions = toPermissions(req.body?.permissions ?? req.body?.apps);
|
|
|
|
if (!permissions) {
|
|
res.status(400).send({status: 'BAD_REQUEST', message: 'Ungültige Berechtigungsliste.'});
|
|
return;
|
|
}
|
|
|
|
if (!(await UsersService.userExists(userId))) {
|
|
notFound(res);
|
|
return;
|
|
}
|
|
|
|
const target = await UsersService.loadAccess(userId);
|
|
const keepsAdmin = permissions.some(permission => permission.app === 'admin');
|
|
const losesAdmin = Boolean(target?.apps.includes('admin')) && !keepsAdmin;
|
|
|
|
// Self-lockout is checked here because it needs the caller's identity,
|
|
// which the service has no business knowing. The last-admin check is
|
|
// NOT done here: it has to be inside the write transaction to survive
|
|
// two admins acting at the same time (see setPermissionsGuarded).
|
|
if (losesAdmin && userId === res.locals.admin.id) {
|
|
conflict(res, 'Du kannst dir die Admin-Berechtigung nicht selbst entziehen.');
|
|
return;
|
|
}
|
|
|
|
const result = await UsersService.setPermissionsGuarded(userId, permissions, res.locals.admin.id);
|
|
if (result === 'last-admin') {
|
|
conflict(res, 'Die letzte Admin-Berechtigung kann nicht entzogen werden.');
|
|
return;
|
|
}
|
|
|
|
res.status(200).send(await UsersService.getUserDetail(userId));
|
|
} catch (e: any) {
|
|
sendServerError(res, e);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @swagger
|
|
* /admin/users/{userId}/disable:
|
|
* post:
|
|
* summary: Disable a user and revoke all of their sessions
|
|
* tags: [admin]
|
|
* responses:
|
|
* 200:
|
|
* description: Success
|
|
* 409:
|
|
* description: Would disable the caller or the last admin
|
|
*/
|
|
usersAdminRouter.post('/:userId/disable', async (req: Request, res: Response) => {
|
|
try {
|
|
const userId = req.params.userId;
|
|
|
|
if (userId === res.locals.admin.id) {
|
|
conflict(res, 'Du kannst dich nicht selbst deaktivieren.');
|
|
return;
|
|
}
|
|
|
|
const target = await UsersService.loadAccess(userId);
|
|
if (!target) {
|
|
notFound(res);
|
|
return;
|
|
}
|
|
|
|
const result = await UsersService.disableUserGuarded(userId);
|
|
if (result === 'last-admin') {
|
|
conflict(res, 'Der letzte aktive Admin kann nicht deaktiviert werden.');
|
|
return;
|
|
}
|
|
|
|
res.status(200).send(await UsersService.getUserDetail(userId));
|
|
} catch (e: any) {
|
|
sendServerError(res, e);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @swagger
|
|
* /admin/users/{userId}/enable:
|
|
* post:
|
|
* summary: Re-enable a disabled user
|
|
* description: Does not restore sessions - the user signs in again.
|
|
* tags: [admin]
|
|
* responses:
|
|
* 200:
|
|
* description: Success
|
|
*/
|
|
usersAdminRouter.post('/:userId/enable', async (req: Request, res: Response) => {
|
|
try {
|
|
if (!(await UsersService.userExists(req.params.userId))) {
|
|
notFound(res);
|
|
return;
|
|
}
|
|
|
|
await UsersService.enableUser(req.params.userId);
|
|
res.status(200).send(await UsersService.getUserDetail(req.params.userId));
|
|
} catch (e: any) {
|
|
sendServerError(res, e);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @swagger
|
|
* /admin/users/{userId}/sessions/{sessionId}:
|
|
* delete:
|
|
* summary: Revoke one session of a user
|
|
* tags: [admin]
|
|
* responses:
|
|
* 204:
|
|
* description: Revoked
|
|
* 404:
|
|
* description: Unknown session for this user
|
|
*/
|
|
usersAdminRouter.delete('/:userId/sessions/:sessionId', async (req: Request, res: Response) => {
|
|
try {
|
|
const revoked = await UsersService.revokeSession(req.params.userId, req.params.sessionId);
|
|
if (!revoked) {
|
|
res.status(404).send({status: 'NOT_FOUND', message: 'Sitzung nicht gefunden.'});
|
|
return;
|
|
}
|
|
res.status(204).send();
|
|
} catch (e: any) {
|
|
sendServerError(res, e);
|
|
}
|
|
});
|