Add admin identity module: better-auth, per-app permissions, invitations (#12)
Jenkins Production Deployment
Jenkins Production Deployment
Reviewed-on: #12 Co-authored-by: Patrick Müller <mail@pmueller.me> Co-committed-by: Patrick Müller <mail@pmueller.me>
This commit was merged in pull request #12.
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user