import express from 'express'; import {fromNodeHeaders} from 'better-auth/node'; import {auth} from './admin.auth.js'; import * as UsersService from './users/users.admin.service.js'; import {AppName, AppPermission, AppRole} from './admin.schema.js'; import {sendServerError} from './admin.errors.js'; /** * The one authenticator for every admin area in this API. It replaces * feedback.auth.ts and tickets.auth.ts, which each re-implemented the same * header-session check against the calendar users table. * * Two things are checked on every request, deliberately without any caching: * that the session cookie is valid (better-auth), and that the user is still * enabled and still holds the permission for this app (one database query). * That is what makes "disable a user" and "revoke a session" take effect * immediately rather than whenever a cached session happens to expire. */ // The shape the feedback and tickets services already expect - unchanged, so // nothing downstream of the authenticator needs to know this file replaced // their own. export interface AdminIdentity { id: string; email: string; displayName: string; } export interface AdminAccess extends AdminIdentity { disabled: boolean; /** Every (app, role) grant. */ permissions: AppPermission[]; /** The distinct apps those grants cover. */ apps: AppName[]; } const unauthorized = (res: express.Response): void => { res.status(401).send({status: 'UNAUTHORIZED', message: 'Anmeldung erforderlich.'}); }; const forbidden = (res: express.Response, message: string): void => { res.status(403).send({status: 'FORBIDDEN', message}); }; /** * Resolves the session cookie to a user with their permissions, or null. * One database query, no cache. Throws only on infrastructure errors. */ export const resolveAccess = async (req: express.Request): Promise => { const session = await auth.api.getSession({headers: fromNodeHeaders(req.headers)}); if (!session?.user) { return null; } const access = await UsersService.loadAccess(session.user.id); if (!access) { return null; } return { id: access.id, email: access.email, displayName: access.displayName, disabled: access.disabled, permissions: access.permissions, apps: access.apps }; }; /** * Any signed-in account, no permission required. Used by /admin/me and the * account-management routes: a user with no app permissions at all still has * to be able to see that, and to manage their own password and passkeys. * * The disabled check is not redundant with the session-create hook: that hook * stops a disabled user from signing in, this stops one who was disabled while * holding a live cookie. Disabling revokes sessions, so the window is small - * but "small" is not "closed". */ export const requireSignedIn: express.RequestHandler = async (req, res, next) => { try { const access = await resolveAccess(req); if (!access) { unauthorized(res); return; } if (access.disabled) { forbidden(res, 'Dieses Konto ist deaktiviert.'); return; } res.locals.admin = access; next(); } catch (e: any) { sendServerError(res, e); } }; /** * The gate every admin area sits behind. `requireAppAccess('feedback')` is * what feedback.auth.ts's requireAdminAuth used to be, except that it now * answers 403 for a signed-in user without that app's permission instead of * letting any activated @nachklang.art account in. * * The optional second argument narrows it to one role within the app. Nothing * passes it today - every app has exactly the `access` role - but it is the * seam a finer permission arrives through. */ export const requireAppAccess = (app: AppName, role?: AppRole): express.RequestHandler => { return async (req, res, next) => { try { const access = await resolveAccess(req); if (!access) { unauthorized(res); return; } if (access.disabled) { forbidden(res, 'Dieses Konto ist deaktiviert.'); return; } // Without a role this asks "may they open this app at all?", which is // any grant on it. With one it asks for that specific grant - the hook // a finer permission plugs into, without touching existing call sites. const allowed = role === undefined ? access.apps.includes(app) : access.permissions.some(permission => permission.app === app && permission.role === role); if (!allowed) { forbidden(res, 'Für diesen Bereich fehlt dir die Berechtigung.'); return; } res.locals.admin = access; next(); } catch (e: any) { sendServerError(res, e); } }; };