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,140 @@
|
||||
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);
|
||||
}
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user