Add admin identity module: better-auth, per-app permissions, invitations
Introduces src/models/admin/, a dedicated identity and permissions module on its own nachklang_admin database, and the shared authenticator that feedback and tickets will move onto in the cutover step. Nothing swaps over yet: feedback.auth.ts and tickets.auth.ts still authenticate against the legacy calendar sessions, so production behaviour is unchanged. - better-auth 1.7 mounted at /admin/auth/*, sessions as httpOnly cookies scoped to .nachklang.art so one sign-in covers every *.nachklang.art app. - Accounts are invite-only: public sign-up is disabled, and the invitations plugin is the only code that creates users. Tokens are stored as SHA-256 hashes and travel in the request body, never in a URL. - Per-app permissions in user_app_permissions; requireAppAccess(app) queries the database on every request (no cookie cache) so disabling a user or revoking a session takes effect immediately. - ADMIN_BOOTSTRAP_EMAIL guarantees a way in on an empty database, idempotently and without crashing the API if the database is unreachable at boot. - Guards prevent an admin from removing their own admin permission, disabling themselves, or stripping the last active admin. The admin pool uses the callback-style mysql2, not mysql2/promise: Kysely's MysqlDialect drives the pool with callbacks, and the promise wrapper ignores them, so every query hangs silently. Only the integration tests caught this. Schema in sql/admin/001_init.sql, derived from getAuthTables() on the installed better-auth rather than the published CLI, which lags the library and omits account.issuer. app.ts is split into src/app.factory.ts so the integration tests drive the real middleware order rather than a copy of it. Tests: 131 unit, plus 41 integration tests against a throwaway MariaDB started by test/integration/setup.ts (docker or podman). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
import express, {Request, Response} from 'express';
|
||||
import * as InvitationsService from './invitations.service.js';
|
||||
import * as UsersService from '../users/users.admin.service.js';
|
||||
import {isAppName, AppName} from '../admin.schema.js';
|
||||
import {sendInvitationMail} from '../admin.mail.js';
|
||||
import {ADMIN_APP_URL} from '../admin.config.js';
|
||||
import {sendServerError} from '../admin.errors.js';
|
||||
import logger from '../../../middleware/logger.js';
|
||||
import {isProd} from '../admin.config.js';
|
||||
|
||||
export const invitationsRouter = express.Router();
|
||||
|
||||
/**
|
||||
* The admin-facing half of invitations (create, resend, revoke). The public
|
||||
* half - preview and accept - lives in invitations.plugin.ts, because
|
||||
* redeeming an invitation has to create a user through better-auth internals.
|
||||
*
|
||||
* Mounted behind requireAppAccess('admin').
|
||||
*/
|
||||
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
/**
|
||||
* Outside production the Salesforce relay is normally off, so the invitation
|
||||
* mail never arrives and only the token's hash is stored - there would be no
|
||||
* way to walk through the accept flow locally. Logging the link closes that,
|
||||
* and mirrors what the bootstrap already does. Never in production: the log
|
||||
* would then hold a live credential.
|
||||
*/
|
||||
const logInviteLinkInDev = (token: string): void => {
|
||||
if (!isProd) {
|
||||
logger.info(`Admin: invitation link ${ADMIN_APP_URL}/accept-invite?token=${encodeURIComponent(token)}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/invitations:
|
||||
* get:
|
||||
* summary: List open (unaccepted, unrevoked, unexpired) invitations
|
||||
* tags: [admin]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Success
|
||||
*/
|
||||
invitationsRouter.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
res.status(200).send(await InvitationsService.listOpenInvitations());
|
||||
} catch (e: any) {
|
||||
sendServerError(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/invitations:
|
||||
* post:
|
||||
* summary: Invite someone and mail them an acceptance link
|
||||
* tags: [admin]
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [email, name, apps]
|
||||
* properties:
|
||||
* email:
|
||||
* type: string
|
||||
* name:
|
||||
* type: string
|
||||
* apps:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Invitation created and mailed
|
||||
* 400:
|
||||
* description: Invalid input
|
||||
* 409:
|
||||
* description: A user with this address already exists
|
||||
*/
|
||||
invitationsRouter.post('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const email = String(req.body?.email || '').trim().toLowerCase();
|
||||
const name = String(req.body?.name || '').trim();
|
||||
const apps: unknown = req.body?.apps;
|
||||
|
||||
if (!EMAIL_PATTERN.test(email) || name.length === 0 || !Array.isArray(apps) || !apps.every(isAppName)) {
|
||||
res.status(400).send({status: 'BAD_REQUEST', message: 'E-Mail, Name und App-Liste sind erforderlich.'});
|
||||
return;
|
||||
}
|
||||
|
||||
// Inviting someone who already has an account would strand them on an
|
||||
// accept page that can only fail. Granting permissions on the existing
|
||||
// user is the operation they actually want.
|
||||
if (await UsersService.findUserByEmail(email)) {
|
||||
res.status(409).send({
|
||||
status: 'CONFLICT',
|
||||
message: 'Für diese E-Mail-Adresse gibt es bereits ein Konto. Vergib dort die Berechtigungen.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const invitation = await InvitationsService.createInvitation(
|
||||
email,
|
||||
name,
|
||||
apps as AppName[],
|
||||
res.locals.admin.id
|
||||
);
|
||||
|
||||
const mailed = await sendInvitationMail(email, name, invitation.token, invitation.expiresAt);
|
||||
if (!mailed) {
|
||||
logger.warn('Admin: invitation created but the mail was not accepted', {email});
|
||||
}
|
||||
logInviteLinkInDev(invitation.token);
|
||||
|
||||
res.status(201).send({id: invitation.id, email, name, expiresAt: invitation.expiresAt, mailed});
|
||||
} catch (e: any) {
|
||||
sendServerError(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/invitations/{invitationId}/resend:
|
||||
* post:
|
||||
* summary: Issue a new token for an open invitation and mail it again
|
||||
* description: The previous link stops working.
|
||||
* tags: [admin]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Resent
|
||||
* 404:
|
||||
* description: No open invitation with this id
|
||||
*/
|
||||
invitationsRouter.post('/:invitationId/resend', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const invitationId = parseInt(req.params.invitationId, 10);
|
||||
if (Number.isNaN(invitationId)) {
|
||||
res.status(400).send({status: 'BAD_REQUEST', message: 'Ungültige Einladungs-ID.'});
|
||||
return;
|
||||
}
|
||||
|
||||
const resent = await InvitationsService.resendInvitation(invitationId);
|
||||
if (!resent) {
|
||||
res.status(404).send({status: 'NOT_FOUND', message: 'Einladung nicht gefunden.'});
|
||||
return;
|
||||
}
|
||||
|
||||
const mailed = await sendInvitationMail(resent.email, resent.name, resent.token, resent.expiresAt);
|
||||
if (!mailed) {
|
||||
logger.warn('Admin: invitation resent but the mail was not accepted', {email: resent.email});
|
||||
}
|
||||
logInviteLinkInDev(resent.token);
|
||||
|
||||
res.status(200).send({id: invitationId, expiresAt: resent.expiresAt, mailed});
|
||||
} catch (e: any) {
|
||||
sendServerError(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/invitations/{invitationId}:
|
||||
* delete:
|
||||
* summary: Revoke an open invitation
|
||||
* tags: [admin]
|
||||
* responses:
|
||||
* 204:
|
||||
* description: Revoked
|
||||
* 404:
|
||||
* description: No open invitation with this id
|
||||
*/
|
||||
invitationsRouter.delete('/:invitationId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const invitationId = parseInt(req.params.invitationId, 10);
|
||||
if (Number.isNaN(invitationId)) {
|
||||
res.status(400).send({status: 'BAD_REQUEST', message: 'Ungültige Einladungs-ID.'});
|
||||
return;
|
||||
}
|
||||
|
||||
const revoked = await InvitationsService.revokeInvitation(invitationId);
|
||||
if (!revoked) {
|
||||
res.status(404).send({status: 'NOT_FOUND', message: 'Einladung nicht gefunden.'});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(204).send();
|
||||
} catch (e: any) {
|
||||
sendServerError(res, e);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user