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>
141 lines
4.5 KiB
TypeScript
141 lines
4.5 KiB
TypeScript
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<AdminAccess | null> => {
|
|
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);
|
|
}
|
|
};
|
|
};
|