Add admin identity module: better-auth, per-app permissions, invitations (#12)
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:
2026-09-06 10:41:54 +00:00
committed by Patrick Müller
parent ce9b173c71
commit bf7be65b03
39 changed files with 6551 additions and 320 deletions
+55
View File
@@ -0,0 +1,55 @@
import * as dotenv from 'dotenv';
import mysql from 'mysql2';
import {Kysely, MysqlDialect} from 'kysely';
import {AdminDatabase} from './admin.schema.js';
import logger from '../../middleware/logger.js';
dotenv.config();
/**
* The admin module is the one place in this API that does not use the
* `mariadb` driver: better-auth talks to the database through Kysely, whose
* MySQL dialect expects a mysql2 pool. The other domains keep their own
* `mariadb` pools (see Feedback.db.ts) - this is an addition, not a migration.
*
* The pool is the callback-style `mysql2` one, NOT `mysql2/promise`: Kysely's
* MysqlDialect calls `pool.getConnection((err, conn) => ...)`. The promise
* wrapper ignores that callback and returns a Promise instead, so every query
* through Kysely would hang forever with no error - which is exactly what it
* did until the integration tests caught it.
*
* timezone 'Z' matters: better-auth computes session and token expiry in UTC.
* Without it mysql2 would write and read those DATETIMEs in the process's local
* zone, so sessions would expire an hour early or late depending on DST.
*/
export namespace NachklangAdminDB {
export const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.ADMIN_DB,
// The other modules' pools default to 3306. This one is configurable so
// the integration tests can point at a throwaway container on another
// port without touching a developer's real .env.
port: parseInt(process.env.DB_PORT || '3306', 10),
connectionLimit: 5,
timezone: 'Z'
});
// mysql2 emits connection trouble as an event on the pool, not only as a
// rejected query. Without a listener Node turns that into an
// uncaughtException, so a database restart would take the whole API - and
// with it the calendar, feedback and tickets domains - down with it.
// Individual queries still reject, and their callers still answer 500.
pool.on('error', (err: unknown) => {
logger.error('Admin database pool error', {detail: (err as any)?.message});
});
// Handed to better-auth as `database: {dialect, type: 'mysql'}`.
export const dialect = new MysqlDialect({pool});
// Used by this module's own services for the two custom tables and for
// permission lookups that join better-auth's `user`.
export const db = new Kysely<AdminDatabase>({dialect});
}
+48
View File
@@ -0,0 +1,48 @@
import express, {Request, Response} from 'express';
import {requireAppAccess, requireSignedIn} from './admin.middleware.js';
import {usersAdminRouter} from './users/users.admin.router.js';
import {invitationsRouter} from './invitations/invitations.router.js';
/**
* The admin module's JSON routes. Deliberately *not* the better-auth handler:
* that one is mounted separately in app.ts, ahead of express.json(), because it
* needs the raw request body stream.
*
* Mounted at /admin, so the tree is:
* /admin/me any signed-in account
* /admin/users/* admin permission
* /admin/invitations/* admin permission
*/
export const adminRouter = express.Router();
/**
* @swagger
* /admin/me:
* get:
* summary: The current user's identity and app permissions
* description: Used by every frontend to decide what to show. The API remains the real gate.
* tags: [admin]
* responses:
* 200:
* description: Success
* 401:
* description: Not signed in
* 403:
* description: Account disabled
*/
adminRouter.get('/me', requireSignedIn, (req: Request, res: Response) => {
res.status(200).send({
id: res.locals.admin.id,
email: res.locals.admin.email,
fullName: res.locals.admin.displayName,
// `permissions` is the full (app, role) truth; `apps` is the distinct
// apps within it. Both are sent because the three frontends only ever ask
// "may I show this app?", and keeping `apps` means a finer permission can
// land here without a coordinated deploy of all of them.
permissions: res.locals.admin.permissions,
apps: res.locals.admin.apps
});
});
adminRouter.use('/users', requireAppAccess('admin'), usersAdminRouter);
adminRouter.use('/invitations', requireAppAccess('admin'), invitationsRouter);
+162
View File
@@ -0,0 +1,162 @@
import {betterAuth} from 'better-auth';
import {APIError} from 'better-auth/api';
import {passkey, getAuthenticatorName} from '@better-auth/passkey';
import {NachklangAdminDB} from './Admin.db.js';
import {invitationsPlugin} from './invitations/invitations.plugin.js';
import {sendPasswordResetMail} from './admin.mail.js';
import * as UsersService from './users/users.admin.service.js';
import logger from '../../middleware/logger.js';
import {
ADMIN_ALLOWED_ORIGINS,
API_BASE_URL,
BETTER_AUTH_SECRET,
CLIENT_IP_HEADERS,
PASSKEY_RP_ID,
TRUSTED_PROXY_IPS,
isProd
} from './admin.config.js';
/**
* The single better-auth instance for all *.nachklang.art apps. Mounted in
* app.ts at /admin/auth/* with better-auth's own node handler, ahead of
* express.json() (it needs the raw body stream).
*
* The session cookie is what every app trusts. Everything else in this module -
* permissions, invitations, the admin UI - hangs off it.
*/
const DAY = 60 * 60 * 24;
// Dev runs the apps on plain localhost ports; cookies ignore the port, so
// single-sign-on across them works without fake subdomains or mkcert.
const localhostOrigins = [
'http://localhost:3000',
'http://localhost:3001',
'http://localhost:3002',
'http://localhost:3003'
];
const trustedOrigins = isProd
? ADMIN_ALLOWED_ORIGINS
: Array.from(new Set([...ADMIN_ALLOWED_ORIGINS, ...localhostOrigins]));
export const auth = betterAuth({
appName: 'Nachklang',
database: {
dialect: NachklangAdminDB.dialect,
type: 'mysql'
},
basePath: '/admin/auth',
// Mandatory once crossSubDomainCookies is on: better-auth derives the
// cookie domain and its own absolute URLs from this.
baseURL: API_BASE_URL,
secret: BETTER_AUTH_SECRET,
trustedOrigins,
emailAndPassword: {
enabled: true,
// There is no public sign-up: accounts exist only through an
// invitation (see invitations.plugin.ts). This also makes
// auth.api.signUpEmail throw, which is intended.
disableSignUp: true,
sendResetPassword: async ({user, url}) => {
await sendPasswordResetMail(user.email, user.name, url);
}
},
user: {
additionalFields: {
// Not `returned`, and not settable through the API: disabling is an
// admin action on /admin/users/:id/disable, never something a
// session owner can flip on themselves.
disabled: {
type: 'boolean',
defaultValue: false,
input: false,
returned: false
}
}
},
session: {
expiresIn: 30 * DAY,
updateAge: DAY
// Deliberately no cookieCache: requireAppAccess hits the database on
// every request anyway, and a cached session would keep a disabled
// user or a revoked session alive for the cache's lifetime.
},
advanced: {
// Fixes the cookie name across releases so the frontends' middleware can
// check for it: "nachklang.session_token", or
// "__Secure-nachklang.session_token" over https.
cookiePrefix: 'nachklang',
crossSubDomainCookies: isProd
? {enabled: true, domain: '.nachklang.art'}
: {enabled: false},
ipAddress: {
// better-auth reads the request itself and does not know about
// Express's `trust proxy`, so both the header and the trusted hops
// have to be named here. Getting this wrong does not fail loudly -
// it collapses every client into one rate-limit bucket. See the
// commentary on CLIENT_IP_HEADERS in admin.config.ts.
ipAddressHeaders: CLIENT_IP_HEADERS,
...(TRUSTED_PROXY_IPS.length > 0 ? {trustedProxies: TRUSTED_PROXY_IPS} : {})
}
},
rateLimit: {
enabled: true,
// Passenger may run more than one instance; an in-memory limiter would
// then give each of them its own budget.
storage: 'database'
},
plugins: [
passkey({
rpID: PASSKEY_RP_ID,
rpName: 'Nachklang',
origin: ADMIN_ALLOWED_ORIGINS,
registration: {
// Without this, every passkey is stored with name = NULL and the
// account page can only label them all "Passkey" - useless at the
// one moment that list matters, when someone has to remove the
// passkey on the device they just lost.
//
// The AAGUID identifies the authenticator *model* (not a device
// and not a person), and better-auth ships the lookup table, so
// this yields "1Password", "iCloud Keychain", "Windows Hello".
// It only fills a blank: a name the client sent always wins, and
// an unknown AAGUID leaves the column NULL as before.
afterVerification: async ({verification}) => {
const name = getAuthenticatorName(verification.registrationInfo?.aaguid);
return name ? {name} : undefined;
}
}
}),
invitationsPlugin()
],
databaseHooks: {
session: {
create: {
before: async session => {
const access = await UsersService.loadAccess(session.userId);
if (access?.disabled) {
logger.warn('Admin: sign-in attempt by a disabled account', {userId: session.userId});
// Throwing rather than returning false: `false` aborts
// the session write silently and the caller sees a
// confusing success-shaped response with no cookie.
throw new APIError('FORBIDDEN', {
code: 'ACCOUNT_DISABLED',
message: 'Dieses Konto ist deaktiviert.'
});
}
}
}
}
}
});
export type AdminAuth = typeof auth;
+68
View File
@@ -0,0 +1,68 @@
import * as UsersService from './users/users.admin.service.js';
import * as InvitationsService from './invitations/invitations.service.js';
import {sendInvitationMail} from './admin.mail.js';
import {ACCESS_ROLE} from './admin.schema.js';
import {ADMIN_APP_URL, ADMIN_BOOTSTRAP_EMAIL, LOG_INVITE_LINKS} from './admin.config.js';
import logger from '../../middleware/logger.js';
/**
* Solves the empty-database problem: with invite-only accounts and no public
* sign-up, a fresh nachklang_admin has nobody who can invite anybody. Rather
* than a CLI script somebody has to remember to run against production, the API
* makes sure on every start that ADMIN_BOOTSTRAP_EMAIL can get in.
*
* Idempotent by design - it is safe on every restart:
* - an active admin already exists -> do nothing
* - the address exists as a user -> grant it `admin`
* - an open invitation exists -> do nothing (do not re-mail on restart)
* - otherwise -> invite, and mail the link
*
* Never throws: a database blip at boot must not stop the API from serving the
* calendar, feedback and tickets domains.
*/
export const bootstrapAdmin = async (): Promise<void> => {
try {
if (!ADMIN_BOOTSTRAP_EMAIL) {
return;
}
const email = ADMIN_BOOTSTRAP_EMAIL.trim().toLowerCase();
if ((await UsersService.countActiveAdmins()) > 0) {
return;
}
const existing = await UsersService.findUserByEmail(email);
if (existing) {
await UsersService.grantPermission(existing.id, 'admin', null);
logger.info('Admin bootstrap: granted the admin permission to the existing bootstrap user', {email});
return;
}
// An expired invitation is not "open", so the next restart re-issues
// one - which is the recovery path if the first mail never arrived.
if (await InvitationsService.hasOpenInvitationFor(email)) {
logger.info('Admin bootstrap: an open invitation already exists', {email});
return;
}
const invitation = await InvitationsService.createInvitation(
email,
'Nachklang Admin',
[{app: 'admin', role: ACCESS_ROLE}],
null
);
const mailed = await sendInvitationMail(email, 'Nachklang Admin', invitation.token, invitation.expiresAt);
logger.info('Admin bootstrap: invitation created', {email, mailed});
// With the mail relay off, the logged link is how a local setup gets its
// first admin. Explicit opt-in (see LOG_INVITE_LINKS): the link is a
// live credential, so this must never depend on NODE_ENV alone.
if (LOG_INVITE_LINKS) {
logger.info(`Admin bootstrap: ${ADMIN_APP_URL}/accept-invite?token=${invitation.token}`);
}
} catch (e: any) {
logger.error('Admin bootstrap failed', {detail: e?.message});
}
};
+170
View File
@@ -0,0 +1,170 @@
import * as crypto from 'crypto';
import * as dotenv from 'dotenv';
import logger from '../../middleware/logger.js';
dotenv.config();
/**
* One place that reads the admin module's environment. Both admin.auth.ts
* (better-auth trustedOrigins, passkey origins) and app.ts (CORS) need the
* same origin list, and a second parser would drift from this one.
*
* Read this before changing the environment handling below: several security
* properties depend on it, and they are deliberately arranged to fail *safe*.
*
* `NODE_ENV` is opt-in to relaxed behaviour, not opt-in to strict behaviour.
* Only the explicit values 'development' and 'test' relax anything; anything
* else - including NODE_ENV being unset, which is exactly what a fresh Plesk
* vhost gives you - is treated as production. The inverse arrangement is a
* trap: it degrades the cookie domain, the CORS origin list and the signing
* key all at once, and every one of those failures is silent.
*
* The signing key is never allowed to be a known constant. In dev, an unset
* BETTER_AUTH_SECRET becomes a random per-process value: sessions do not
* survive a restart, which is mildly annoying and much better than a default
* secret that can be copied out of this file and used against production.
*/
const nodeEnv = process.env.NODE_ENV;
// Explicitly relaxed environments. Everything else, unset included, is strict.
const isRelaxedEnv = nodeEnv === 'development' || nodeEnv === 'test';
export const isProd = !isRelaxedEnv;
const required = (name: string, devDefault: string): string => {
const value = process.env[name];
if (value) {
return value;
}
if (isProd) {
logger.error(
`Admin module: ${name} is not set (NODE_ENV=${nodeEnv ?? 'unset'}, so strict mode applies; ` +
'set NODE_ENV=development for local work)'
);
throw new Error(`${name} must be set unless NODE_ENV is development or test`);
}
return devDefault;
};
export const API_BASE_URL = required('API_BASE_URL', 'http://localhost:3000');
export const ADMIN_APP_URL = required('ADMIN_APP_URL', 'http://localhost:3002');
// 32+ random bytes; better-auth signs cookies and reset tokens with it.
// Rotating it invalidates every session, which is why it is not derived.
// There is no hardcoded fallback on purpose: a constant committed here would
// be a published signing key the moment someone deploys without setting it.
export const BETTER_AUTH_SECRET = required(
'BETTER_AUTH_SECRET',
crypto.randomBytes(48).toString('base64')
);
// Passkeys are bound to this: a credential registered for "nachklang.art"
// works on every *.nachklang.art host, one registered for "localhost" only
// works in dev. Changing it invalidates every registered passkey.
export const PASSKEY_RP_ID = process.env.PASSKEY_RP_ID || (isProd ? 'nachklang.art' : 'localhost');
const parseList = (value: string | undefined, fallback: string[]): string[] => {
const parsed = (value || '')
.split(',')
.map(entry => entry.trim())
.filter(entry => entry.length > 0);
return parsed.length > 0 ? parsed : fallback;
};
// The apps whose frontends may talk to /admin/* with credentials.
export const APP_ORIGINS = parseList(process.env.APP_ORIGINS, []).map(origin => origin.replace(/\/$/, ''));
// Kept in sync by construction rather than by three separate lists: the admin
// app itself always counts, and dev adds the local ports.
export const ADMIN_ALLOWED_ORIGINS = Array.from(new Set([
ADMIN_APP_URL.replace(/\/$/, ''),
...APP_ORIGINS
]));
/**
* The header the reverse proxy puts the real client IP in, and the proxy hops
* to trust when reading it.
*
* This matters more than it looks. better-auth does not know about Express's
* `trust proxy`; it reads the request itself. If it cannot resolve a client IP
* it falls back to a single shared bucket ("no-trusted-ip") for the whole
* process - and /sign-in/* carries a default of 3 requests per 10 seconds, so
* one noisy client would lock every user out of every app.
*
* Without TRUSTED_PROXY_IPS, better-auth rejects a multi-value
* x-forwarded-for outright (it cannot tell which hop is the client), which is
* exactly the case that produces that shared bucket. Set it to the address or
* CIDR of Plesk's nginx. Conversely, listing a header the proxy does not
* overwrite lets a client set its own IP and mint itself an unlimited
* brute-force budget - so the default is the single header nginx sets, not a
* permissive list.
*/
/**
* `CLIENT_IP_HEADERS=none` trusts no header at all.
*
* This is the escape hatch for the one case where the wrong setting is worse
* than no setting: if the proxy turns out NOT to overwrite the header we are
* trusting, any client can send it and mint itself an unlimited brute-force
* budget against /sign-in. Falling back to the shared bucket is bad (one noisy
* client can lock the organisation out for ten seconds at a time) but it is
* bad in a way that fails closed, and it can be reverted from the environment
* without a deploy.
*
* Reach for it only after a check has actually failed - `SELECT ipAddress FROM
* session ORDER BY createdAt DESC` showing 127.0.0.1 or NULL for a real remote
* sign-in - and take it back out once the header is configured.
*
* An empty or unset value still means "use the default", not "trust nothing":
* a stray blank line in a .env must not silently change how requests are
* bucketed. Only the explicit word does that.
*/
const TRUST_NO_HEADER = 'none';
export const TRUST_NO_CLIENT_IP_HEADER =
(process.env.CLIENT_IP_HEADERS || '').trim().toLowerCase() === TRUST_NO_HEADER;
// An empty array is what better-auth reads as "no headers": it only falls back
// to its own default when the option is absent, and `[]` is truthy.
export const CLIENT_IP_HEADERS = TRUST_NO_CLIENT_IP_HEADER
? []
: parseList(process.env.CLIENT_IP_HEADERS, ['x-real-ip']);
export const TRUSTED_PROXY_IPS = parseList(process.env.TRUSTED_PROXY_IPS, []);
if (isProd && TRUST_NO_CLIENT_IP_HEADER) {
logger.warn(
'Admin module: CLIENT_IP_HEADERS=none - no client-IP header is trusted, so every ' +
'request shares one rate-limit bucket and /sign-in allows 3 attempts per 10 seconds ' +
'for everyone combined. This is the safe fallback, not a destination: configure the ' +
'header the proxy actually sets and remove it.'
);
} else if (isProd && TRUSTED_PROXY_IPS.length === 0) {
logger.warn(
'Admin module: TRUSTED_PROXY_IPS is not set. If the proxy sends a multi-value ' +
`${CLIENT_IP_HEADERS.join('/')}, better-auth cannot resolve a client IP and every ` +
'request shares one rate-limit bucket. Verify with: SELECT `key` FROM rateLimit - ' +
'a "no-trusted-ip" row means this is happening. A single-value header needs no ' +
'trusted proxies, so this warning is expected on a plain single-proxy setup.'
);
}
export const ADMIN_BOOTSTRAP_EMAIL = process.env.ADMIN_BOOTSTRAP_EMAIL || '';
/**
* Whether to write invitation links to the log. An invitation link is a live
* account-creation credential, so this is an explicit opt-in rather than
* something inferred from NODE_ENV: local work needs it (the mail relay is
* usually off, and only the token's hash is stored, so there is otherwise no
* way to walk the accept flow), and production must never have it.
*
* Refused outright in strict mode, so setting it in a production .env by
* accident fails at boot instead of quietly filling the log with credentials.
*/
export const LOG_INVITE_LINKS = process.env.ADMIN_LOG_INVITE_LINKS === 'true' && !isProd;
if (process.env.ADMIN_LOG_INVITE_LINKS === 'true' && isProd) {
logger.error('Admin module: ADMIN_LOG_INVITE_LINKS is set outside development - refusing to log invitation tokens');
}
+17
View File
@@ -0,0 +1,17 @@
import {Response} from 'express';
import {Guid} from 'guid-typescript';
import logger from '../../middleware/logger.js';
/**
* Same catch-block convention as the feedback and tickets modules: log with a
* reference guid, never hand the real error message to the client.
*/
export const sendServerError = (res: Response, e: any): void => {
const errorGuid = Guid.create().toString();
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
res.status(500).send({
status: 'PROCESSING_ERROR',
message: 'Internal Server Error. Try again later.',
reference: errorGuid
});
};
+120
View File
@@ -0,0 +1,120 @@
/**
* Swagger component definitions for the admin module. Picked up by
* swagger-jsdoc through the `src/models/**\/*.interface.ts` glob in
* app.factory.ts.
*
* Note what is *not* documented here: the better-auth routes under
* /admin/auth/* (sign-in, sign-out, reset-password, passkey ceremonies, and
* the invitation preview/accept endpoints). better-auth owns those paths and
* their shapes; duplicating them by hand would only drift on the next upgrade.
*/
/**
* @swagger
* components:
* securitySchemes:
* AdminSessionCookie:
* type: apiKey
* in: cookie
* name: nachklang.session_token
* description: >
* Set by /admin/auth/sign-in/email. Over https the name is
* __Secure-nachklang.session_token and the cookie is scoped to
* .nachklang.art, so one sign-in covers every *.nachklang.art app.
* schemas:
* AdminApp:
* type: string
* enum: [calendar, feedback, tickets, admin]
* description: Holding "admin" is what allows managing users and invitations.
* AdminMe:
* type: object
* properties:
* id:
* type: string
* email:
* type: string
* fullName:
* type: string
* apps:
* type: array
* items:
* $ref: '#/components/schemas/AdminApp'
* AdminUserSession:
* type: object
* properties:
* id:
* type: string
* createdAt:
* type: string
* format: date-time
* expiresAt:
* type: string
* format: date-time
* ipAddress:
* type: string
* nullable: true
* userAgent:
* type: string
* nullable: true
* AdminUser:
* type: object
* properties:
* id:
* type: string
* email:
* type: string
* name:
* type: string
* apps:
* type: array
* items:
* $ref: '#/components/schemas/AdminApp'
* status:
* type: string
* enum: [aktiv, deaktiviert]
* description: Derived - there is no status column.
* createdAt:
* type: string
* format: date-time
* lastSignInAt:
* type: string
* format: date-time
* nullable: true
* description: Newest session's creation time; null once every session has expired.
* AdminUserDetail:
* allOf:
* - $ref: '#/components/schemas/AdminUser'
* - type: object
* properties:
* sessions:
* type: array
* items:
* $ref: '#/components/schemas/AdminUserSession'
* passkeyCount:
* type: integer
* AdminInvitation:
* type: object
* description: An open invitation. The token itself is never returned by any endpoint.
* properties:
* id:
* type: integer
* email:
* type: string
* name:
* type: string
* apps:
* type: array
* items:
* $ref: '#/components/schemas/AdminApp'
* invitedBy:
* type: string
* nullable: true
* description: Null for the invitation created by the ADMIN_BOOTSTRAP_EMAIL bootstrap.
* createdAt:
* type: string
* format: date-time
* expiresAt:
* type: string
* format: date-time
*/
export {};
+124
View File
@@ -0,0 +1,124 @@
import {MailService} from '../../common/common.mail.js';
import {ADMIN_APP_URL} from './admin.config.js';
/**
* The two transactional mails the admin module sends. Both go out through the
* shared MailService (Salesforce relay, see common.mail.ts), which never throws
* on a delivery failure - the invitation row and the reset token are already
* committed by the time we get here.
*
* HTML plus a plain-text body: the text part is not a fallback afterthought,
* it is what allowlist-based receivers and text-only clients actually show.
*/
const escapeHtml = (value: string): string => {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
};
// `heading` is escaped here; `paragraphs` are not, because callers pass markup
// (a <strong> around the expiry date) and escape their own interpolations.
const layout = (heading: string, paragraphs: string[], buttonLabel: string, buttonUrl: string): string => {
const body = paragraphs.map(p => `<p style="margin:0 0 16px;">${p}</p>`).join('');
return `<!doctype html>
<html lang="de">
<body style="margin:0;padding:24px;background:#f5f5f4;font-family:Helvetica,Arial,sans-serif;color:#1c1917;">
<div style="max-width:520px;margin:0 auto;background:#ffffff;border-radius:8px;padding:32px;">
<h1 style="margin:0 0 24px;font-size:20px;">${escapeHtml(heading)}</h1>
${body}
<p style="margin:24px 0;">
<a href="${escapeHtml(buttonUrl)}" style="display:inline-block;background:#1c1917;color:#ffffff;text-decoration:none;padding:12px 20px;border-radius:6px;">${escapeHtml(buttonLabel)}</a>
</p>
<p style="margin:0;font-size:13px;color:#57534e;">Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:<br>
<span style="word-break:break-all;">${escapeHtml(buttonUrl)}</span></p>
</div>
</body>
</html>`;
};
/**
* Invitation mail. The link carries the raw token in the query string; the
* admin app strips it from the URL as soon as it has read it (see the plan's
* §3b - the token must never reach an API access log or a Referer header).
*/
export const sendInvitationMail = async (
recipientAddress: string,
name: string,
token: string,
expiresAt: Date
): Promise<boolean> => {
const url = `${ADMIN_APP_URL}/accept-invite?token=${encodeURIComponent(token)}`;
const expiry = expiresAt.toLocaleDateString('de-DE', {day: '2-digit', month: '2-digit', year: 'numeric'});
const subject = 'Dein Zugang zu Nachklang';
const text = [
`Hallo ${name},`,
'',
'du wurdest eingeladen, ein Nachklang-Konto anzulegen. Über diesen Link vergibst du dein Passwort:',
'',
url,
'',
`Der Link ist bis zum ${expiry} gültig.`,
'',
'Wenn du damit nichts anfangen kannst, ignoriere diese E-Mail einfach.',
'',
'Viele Grüße',
'Nachklang e.V.'
].join('\n');
const html = layout(
`Hallo ${name},`,
[
'du wurdest eingeladen, ein Nachklang-Konto anzulegen. Über den Button vergibst du dein Passwort.',
`Der Link ist bis zum <strong>${escapeHtml(expiry)}</strong> gültig.`,
'Wenn du damit nichts anfangen kannst, ignoriere diese E-Mail einfach.'
],
'Konto einrichten',
url
);
return MailService.sendMail(recipientAddress, subject, text, {html});
};
/**
* Password reset. better-auth builds the URL (it embeds its own token and the
* redirectTo the admin app passed), so this only wraps it in our templates.
*/
export const sendPasswordResetMail = async (
recipientAddress: string,
name: string,
url: string
): Promise<boolean> => {
const subject = 'Passwort zurücksetzen';
const text = [
`Hallo ${name},`,
'',
'über diesen Link kannst du ein neues Passwort vergeben:',
'',
url,
'',
'Der Link ist eine Stunde gültig.',
'',
'Wenn du kein neues Passwort angefordert hast, ist nichts passiert - ignoriere diese E-Mail.',
'',
'Viele Grüße',
'Nachklang e.V.'
].join('\n');
const html = layout(
`Hallo ${name},`,
[
'über den Button kannst du ein neues Passwort vergeben.',
'Der Link ist eine Stunde gültig.',
'Wenn du kein neues Passwort angefordert hast, ist nichts passiert - ignoriere diese E-Mail.'
],
'Neues Passwort vergeben',
url
);
return MailService.sendMail(recipientAddress, subject, text, {html});
};
+140
View File
@@ -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);
}
};
};
+179
View File
@@ -0,0 +1,179 @@
import {Generated} from 'kysely';
/**
* Kysely table types for `nachklang_admin`. Only the columns this module
* actually reads or writes are declared - better-auth owns the full shape of
* its own tables and does not use this interface, it is here so the users and
* invitations services get compile-time checking instead of `any`.
*
* Column names follow better-auth's default "camel" casing for its tables
* (`emailVerified`, `userId`, `createdAt`); our own two tables use the
* snake_case convention of the rest of the repo's SQL.
*/
export type AppName = 'calendar' | 'feedback' | 'tickets' | 'admin';
export const APP_NAMES: AppName[] = ['calendar', 'feedback', 'tickets', 'admin'];
export const isAppName = (value: unknown): value is AppName => {
return typeof value === 'string' && (APP_NAMES as string[]).includes(value);
};
/**
* A permission is (app, role), not just an app. Today every app has exactly one
* role - `access`, "may use this app at all" - so the model looks like a plain
* list of apps and the UI renders one checkbox each. It is written this way
* anyway because the alternative gets expensive fast: `user_app_permissions`
* has primary key (user_id, app, role), so a user can hold several roles for
* the same app, and adding one later is a string in APP_ROLES plus rows - never
* a schema migration and never a change to the shape on the wire.
*
* Note the role is deliberately NOT called `admin`, which is what the column
* defaulted to before: on a `tickets` row that reads as "tickets administrator"
* when it only ever meant "has access", and once real roles exist there would
* be no way to tell the two apart.
*/
export const ACCESS_ROLE = 'access';
export type AppRole = string;
/** Every role that exists, per app, in display order. Extend to add one. */
export const APP_ROLES: Record<AppName, readonly AppRole[]> = {
calendar: [ACCESS_ROLE],
feedback: [ACCESS_ROLE],
tickets: [ACCESS_ROLE],
admin: [ACCESS_ROLE]
};
export interface AppPermission {
app: AppName;
role: AppRole;
}
export const isAppRole = (app: AppName, role: unknown): role is AppRole => {
return typeof role === 'string' && APP_ROLES[app].includes(role);
};
export const isAppPermission = (value: unknown): value is AppPermission => {
if (typeof value !== 'object' || value === null) {
return false;
}
const candidate = value as {app?: unknown; role?: unknown};
return isAppName(candidate.app) && isAppRole(candidate.app, candidate.role);
};
/**
* Normalises whatever a caller sent into a valid, duplicate-free permission
* list. Accepts the richer `{app, role}` form and the plain `AppName` form,
* because `{apps: ['tickets']}` is still what the older callers send and it
* means exactly "tickets at the access role".
*/
export const toPermissions = (value: unknown): AppPermission[] | null => {
if (!Array.isArray(value)) {
return null;
}
const permissions: AppPermission[] = [];
for (const entry of value) {
if (isAppName(entry)) {
permissions.push({app: entry, role: ACCESS_ROLE});
} else if (isAppPermission(entry)) {
permissions.push({app: entry.app, role: entry.role});
} else {
return null;
}
}
const seen = new Set<string>();
return permissions.filter(permission => {
const key = `${permission.app}:${permission.role}`;
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
};
/** The distinct apps a permission list grants any access to. */
export const appsOf = (permissions: AppPermission[]): AppName[] => {
return APP_NAMES.filter(app => permissions.some(permission => permission.app === app));
};
export interface UserTable {
id: string;
name: string;
email: string;
emailVerified: boolean;
image: string | null;
createdAt: Date;
updatedAt: Date;
// Added via better-auth `additionalFields` (see admin.auth.ts).
disabled: boolean;
}
export interface SessionTable {
id: string;
token: string;
userId: string;
expiresAt: Date;
createdAt: Date;
updatedAt: Date;
ipAddress: string | null;
userAgent: string | null;
}
export interface PasskeyTable {
id: string;
name: string | null;
userId: string;
createdAt: Date;
}
export interface UserAppPermissionTable {
user_id: string;
app: AppName;
role: AppRole;
granted_by: string | null;
granted_at: Generated<Date>;
}
export interface InvitationTable {
// AUTO_INCREMENT: present on select, never supplied on insert.
id: Generated<number>;
email: string;
name: string;
token_hash: string;
// JSON column holding an AppPermission[]. Older rows may hold a plain
// AppName[]; `parsePermissions` reads both.
permissions: string;
invited_by: string | null;
created_at: Generated<Date>;
expires_at: Date;
accepted_at: Date | null;
revoked_at: Date | null;
}
export interface VerificationTable {
id: string;
identifier: string;
value: string;
expiresAt: Date;
}
export interface RateLimitTable {
id: string;
key: string;
count: number;
lastRequest: number;
}
export interface AdminDatabase {
user: UserTable;
session: SessionTable;
passkey: PasskeyTable;
verification: VerificationTable;
rateLimit: RateLimitTable;
user_app_permissions: UserAppPermissionTable;
invitations: InvitationTable;
}
@@ -0,0 +1,196 @@
import * as z from 'zod';
import {APIError, createAuthEndpoint} from 'better-auth/api';
import {setSessionCookie} from 'better-auth/cookies';
import {createLocalAccountIssuer} from 'better-auth/db';
import {runWithTransaction} from '@better-auth/core/context';
import type {BetterAuthPlugin} from 'better-auth';
import * as InvitationsService from './invitations.service.js';
import * as UsersService from '../users/users.admin.service.js';
import logger from '../../../middleware/logger.js';
/**
* The two public endpoints of the invitation flow, implemented as a better-auth
* plugin rather than as plain Express routes on the admin router.
*
* Why a plugin: `emailAndPassword.disableSignUp` is on, which makes
* `auth.api.signUpEmail` refuse - deliberately, there is no public sign-up.
* Accepting an invitation still has to create a user, hash a password, write a
* credential account and sign the person in. All four are better-auth
* internals reachable only from inside an endpoint's context, so this is where
* account creation lives. Nothing outside this file may create users.
*
* Because they are plugin endpoints they sit under better-auth's basePath:
* POST /admin/auth/invitations/preview
* POST /admin/auth/invitations/accept
*
* The token travels in the request *body*, never in the path or query, so it
* cannot end up in an access log or a Referer header.
*/
// Unknown, expired, revoked and already-accepted tokens must be
// indistinguishable to the caller: one shared error, one shared message.
const invalidToken = (): APIError => {
return new APIError('BAD_REQUEST', {
code: 'INVALID_INVITATION',
message: 'Diese Einladung ist nicht mehr gültig.'
});
};
export const invitationsPlugin = () => {
return {
id: 'nachklang-invitations',
endpoints: {
/**
* Lets the accept-invite page show who the invitation is for before
* asking for a password. Returns only name and email - never the
* granted apps, which is information the invitee has no need for
* and an attacker with a stolen link should not get either.
*/
previewInvitation: createAuthEndpoint(
'/invitations/preview',
{
method: 'POST',
body: z.object({
token: z.string().min(1)
})
},
async ctx => {
const invitation = await InvitationsService.findByToken(ctx.body.token);
if (!invitation) {
throw invalidToken();
}
return ctx.json({email: invitation.email, name: invitation.name});
}
),
/**
* Redeems the invitation: creates the user, its credential account
* and its permissions, then signs the person straight in so they
* land in the app instead of on a login form.
*/
acceptInvitation: createAuthEndpoint(
'/invitations/accept',
{
method: 'POST',
body: z.object({
token: z.string().min(1),
password: z.string().min(8).max(128)
})
},
async ctx => {
const invitation = await InvitationsService.findByToken(ctx.body.token);
if (!invitation) {
throw invalidToken();
}
// An account for this address already exists: the right fix
// is for an admin to grant permissions on the existing user,
// not to create a second one. Reported distinctly because
// the person holds a valid token - this leaks nothing they
// do not already know about their own mailbox.
const existing = await UsersService.findUserByEmail(invitation.email);
if (existing) {
throw new APIError('CONFLICT', {
code: 'USER_ALREADY_EXISTS',
message: 'Für diese E-Mail-Adresse gibt es bereits ein Konto. Melde dich stattdessen an.'
});
}
// Claim the invitation before creating anything. The update
// is conditional on it still being open, so two concurrent
// submissions of the same link cannot both end up creating a
// user.
const claimed = await InvitationsService.markAccepted(invitation.id);
if (!claimed) {
throw invalidToken();
}
// The user and its credential account go in one better-auth
// transaction, the way better-auth's own sign-up route does
// it: a half-created account with no password is not
// recoverable through any route this API exposes.
//
// It cannot cover everything, though. `user_app_permissions`
// and `invitations` are written through this module's own
// Kysely pool, which is a different connection, so no single
// transaction spans both. What follows is therefore
// compensated by hand rather than rolled back.
let user: Awaited<ReturnType<typeof ctx.context.internalAdapter.createUser>> | null = null;
try {
user = await runWithTransaction(ctx.context.adapter, async () => {
const created = await ctx.context.internalAdapter.createUser(
{
email: invitation.email,
name: invitation.name,
// Accepting a link sent to that mailbox *is*
// the proof of address ownership, so there is
// no separate verification mail (plan
// decision 15).
emailVerified: true,
disabled: false
},
{method: 'email-password'}
);
// Same call better-auth's own sign-up route makes,
// down to the synthetic issuer - a credential account
// written any other way is not found on sign-in.
await ctx.context.internalAdapter.linkAccount({
userId: created.id,
providerId: 'credential',
issuer: createLocalAccountIssuer('credential'),
accountId: created.id,
password: await ctx.context.password.hash(ctx.body.password)
});
return created;
});
await UsersService.setPermissions(user.id, invitation.permissions, null);
const session = await ctx.context.internalAdapter.createSession(user.id);
await setSessionCookie(ctx, {session, user});
return ctx.json({
user: {id: user.id, email: user.email, name: user.name}
});
} catch (e: any) {
// Undo what committed, so the invitee can use their link
// again instead of being stranded with a burnt token, an
// account they cannot sign into, and an admin who cannot
// re-invite them (the create route 409s on an existing
// user, and there is no delete route by design).
//
// Deleting the user is safe here: it was created moments
// ago in this request, and acceptance already established
// that no account for this address existed before.
try {
if (user) {
await ctx.context.internalAdapter.deleteUser(user.id);
}
await InvitationsService.unmarkAccepted(invitation.id);
} catch (compensationError: any) {
// Now the state really is inconsistent, and only a
// human can sort it out. Say so loudly and precisely.
logger.error('Admin: invitation acceptance failed AND its rollback failed', {
invitationId: invitation.id,
email: invitation.email,
userId: user?.id,
detail: e?.message,
compensationDetail: compensationError?.message
});
throw e;
}
logger.error('Admin: invitation acceptance failed and was rolled back', {
invitationId: invitation.id,
detail: e?.message
});
throw e;
}
}
)
}
} satisfies BetterAuthPlugin;
};
@@ -0,0 +1,208 @@
import express, {Request, Response} from 'express';
import * as InvitationsService from './invitations.service.js';
import * as UsersService from '../users/users.admin.service.js';
import {toPermissions} from '../admin.schema.js';
import {sendInvitationMail} from '../admin.mail.js';
import {ADMIN_APP_URL, LOG_INVITE_LINKS} from '../admin.config.js';
import {sendServerError} from '../admin.errors.js';
import logger from '../../../middleware/logger.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@]+$/;
/**
* With the mail relay off (the normal local setup) the invitation mail never
* arrives, and only the token's hash is stored, so there would be no way to
* walk through the accept flow. Logging the link closes that.
*
* Gated on an explicit opt-in rather than on NODE_ENV: the link is a live
* account-creation credential, and "not production" is too weak a condition to
* hang that on. See LOG_INVITE_LINKS in admin.config.ts.
*/
const logInviteLinkInDev = (token: string): void => {
if (LOG_INVITE_LINKS) {
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, permissions]
* properties:
* email:
* type: string
* name:
* type: string
* permissions:
* type: array
* description: >
* One entry per (app, role). A plain array of app names is
* accepted too and means the same at the `access` role.
* items:
* type: object
* properties:
* app:
* type: string
* role:
* 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();
// Same two accepted shapes as PUT /admin/users/:id/permissions.
const permissions = toPermissions(req.body?.permissions ?? req.body?.apps);
if (!EMAIL_PATTERN.test(email) || name.length === 0 || !permissions) {
res.status(400).send({
status: 'BAD_REQUEST',
message: 'E-Mail, Name und Berechtigungen 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,
permissions,
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);
}
});
@@ -0,0 +1,239 @@
import * as crypto from 'crypto';
import {NachklangAdminDB} from '../Admin.db.js';
import {AppPermission, isAppName, isAppPermission, ACCESS_ROLE} from '../admin.schema.js';
const db = NachklangAdminDB.db;
/**
* Invitations are this API's only path to a new account (there is no public
* sign-up). The raw token exists exactly twice: in the mail we send and in the
* request body when it comes back. What we store is its SHA-256 hash, so a
* database dump does not hand out account access - the same reasoning as the
* calendar module's session key hashing, and the reason lookups go through
* `findByToken` rather than any query on a plaintext column.
*/
export const INVITATION_TTL_DAYS = 7;
export interface OpenInvitation {
id: number;
email: string;
name: string;
permissions: AppPermission[];
invitedBy: string | null;
createdAt: Date;
expiresAt: Date;
}
export interface AcceptableInvitation {
id: number;
email: string;
name: string;
permissions: AppPermission[];
}
const hashToken = (token: string): string => {
return crypto.createHash('sha256').update(token).digest('hex');
};
const generateToken = (): string => {
// 32 bytes, url-safe: it travels in a mail link's query string.
return crypto.randomBytes(32).toString('base64url');
};
/**
* Reads the stored permission list. Two shapes are accepted: the current
* `[{app, role}]`, and a bare `['tickets', ...]` from before roles existed,
* which means the same thing at the `access` role. Invitations live for seven
* days, so a deploy that changes the shape has in-flight rows in the old one -
* tolerating both is what stops those invitees from being stranded.
*
* mysql2 hands back a JSON column already parsed; a driver or column-type
* change that turns it into a string must not break the read path either.
*/
const parsePermissions = (value: unknown): AppPermission[] => {
const raw = typeof value === 'string' ? JSON.parse(value) : value;
if (!Array.isArray(raw)) {
return [];
}
return raw.flatMap((entry): AppPermission[] => {
if (isAppName(entry)) {
return [{app: entry, role: ACCESS_ROLE}];
}
return isAppPermission(entry) ? [{app: entry.app, role: entry.role}] : [];
});
};
const expiryFromNow = (): Date => {
return new Date(Date.now() + INVITATION_TTL_DAYS * 24 * 60 * 60 * 1000);
};
/**
* Creates an invitation and returns the raw token for the mail. Any earlier
* open invitation for the same address is revoked first: two valid links for
* one mailbox is a needless second live credential, and "resend" would
* otherwise quietly accumulate them.
*/
export const createInvitation = async (
email: string,
name: string,
permissions: AppPermission[],
invitedBy: string | null
): Promise<{id: number; token: string; expiresAt: Date}> => {
const token = generateToken();
const expiresAt = expiryFromNow();
const valid = permissions.filter(isAppPermission);
const id = await db.transaction().execute(async trx => {
await trx
.updateTable('invitations')
.set({revoked_at: new Date()})
.where('email', '=', email)
.where('accepted_at', 'is', null)
.where('revoked_at', 'is', null)
.execute();
const result = await trx
.insertInto('invitations')
.values({
email,
name,
token_hash: hashToken(token),
permissions: JSON.stringify(valid),
invited_by: invitedBy,
created_at: new Date(),
expires_at: expiresAt
})
.executeTakeFirst();
return Number(result.insertId);
});
return {id, token, expiresAt};
};
/**
* Looks up a still-usable invitation by raw token. Callers must not
* distinguish "unknown", "expired", "revoked" and "already accepted" to the
* client: all four answer with the same shape, so a stranger cannot probe which
* tokens ever existed.
*/
export const findByToken = async (token: string): Promise<AcceptableInvitation | null> => {
const row = await db
.selectFrom('invitations')
.select(['id', 'email', 'name', 'permissions'])
.where('token_hash', '=', hashToken(token))
.where('accepted_at', 'is', null)
.where('revoked_at', 'is', null)
.where('expires_at', '>', new Date())
.executeTakeFirst();
if (!row) {
return null;
}
return {id: row.id, email: row.email, name: row.name, permissions: parsePermissions(row.permissions)};
};
/** Marks the invitation accepted. Conditional on it still being open so two
* concurrent accepts of the same link cannot both create an account. */
export const markAccepted = async (invitationId: number): Promise<boolean> => {
const result = await db
.updateTable('invitations')
.set({accepted_at: new Date()})
.where('id', '=', invitationId)
.where('accepted_at', 'is', null)
.where('revoked_at', 'is', null)
.executeTakeFirst();
return Number(result.numUpdatedRows) > 0;
};
/**
* Reverses markAccepted. Used only to compensate a failed acceptance: the user
* could not be created, so the link must become usable again rather than
* stranding the invitee with a burnt token and no account.
*/
export const unmarkAccepted = async (invitationId: number): Promise<void> => {
await db
.updateTable('invitations')
.set({accepted_at: null})
.where('id', '=', invitationId)
.execute();
};
export const listOpenInvitations = async (): Promise<OpenInvitation[]> => {
const rows = await db
.selectFrom('invitations')
.select(['id', 'email', 'name', 'permissions', 'invited_by', 'created_at', 'expires_at'])
.where('accepted_at', 'is', null)
.where('revoked_at', 'is', null)
.where('expires_at', '>', new Date())
.orderBy('created_at', 'desc')
.execute();
return rows.map(row => ({
id: row.id,
email: row.email,
name: row.name,
permissions: parsePermissions(row.permissions),
invitedBy: row.invited_by,
createdAt: row.created_at,
expiresAt: row.expires_at
}));
};
export const getOpenInvitation = async (invitationId: number): Promise<OpenInvitation | null> => {
const all = await listOpenInvitations();
return all.find(invitation => invitation.id === invitationId) ?? null;
};
/** Resend issues a *new* token and expiry and invalidates the old one, rather
* than re-mailing the existing link: if the first mail leaked, resending it
* would extend the leak's lifetime. */
export const resendInvitation = async (
invitationId: number
): Promise<{token: string; email: string; name: string; expiresAt: Date} | null> => {
const invitation = await getOpenInvitation(invitationId);
if (!invitation) {
return null;
}
const token = generateToken();
const expiresAt = expiryFromNow();
await db
.updateTable('invitations')
.set({token_hash: hashToken(token), expires_at: expiresAt, created_at: new Date()})
.where('id', '=', invitationId)
.execute();
return {token, email: invitation.email, name: invitation.name, expiresAt};
};
export const revokeInvitation = async (invitationId: number): Promise<boolean> => {
const result = await db
.updateTable('invitations')
.set({revoked_at: new Date()})
.where('id', '=', invitationId)
.where('accepted_at', 'is', null)
.where('revoked_at', 'is', null)
.executeTakeFirst();
return Number(result.numUpdatedRows) > 0;
};
/** Used by the bootstrap to stay idempotent across restarts. */
export const hasOpenInvitationFor = async (email: string): Promise<boolean> => {
const row = await db
.selectFrom('invitations')
.select('id')
.where('email', '=', email)
.where('accepted_at', 'is', null)
.where('revoked_at', 'is', null)
.where('expires_at', '>', new Date())
.executeTakeFirst();
return Boolean(row);
};
@@ -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);
}
});
@@ -0,0 +1,439 @@
import {Transaction} from 'kysely';
import {NachklangAdminDB} from '../Admin.db.js';
import {
AdminDatabase,
AppName,
AppPermission,
AppRole,
ACCESS_ROLE,
appsOf,
isAppName,
isAppRole
} from '../admin.schema.js';
const db = NachklangAdminDB.db;
/**
* Everything that reads or writes permissions. Two callers with very different
* hot-path requirements share this file: admin.middleware.ts runs
* `loadAccess` on *every* admin-authenticated request (which is why it is one
* query joining `user.disabled` and the permission rows - see the plan's
* decision to run without better-auth's cookieCache), and the /admin/users
* routes run the rest.
*/
export interface UserAccess {
id: string;
email: string;
displayName: string;
disabled: boolean;
/** Every (app, role) grant this user holds. */
permissions: AppPermission[];
/** The distinct apps the above grants any access to. Derived, kept because
* most callers only ever ask "may they open this app at all?". */
apps: AppName[];
}
export type UserStatus = 'aktiv' | 'deaktiviert';
export interface UserListEntry {
id: string;
email: string;
name: string;
permissions: AppPermission[];
apps: AppName[];
status: UserStatus;
createdAt: Date;
lastSignInAt: Date | null;
}
export interface UserSessionEntry {
id: string;
createdAt: Date;
expiresAt: Date;
ipAddress: string | null;
userAgent: string | null;
}
export interface UserDetail extends UserListEntry {
sessions: UserSessionEntry[];
passkeyCount: number;
}
/**
* The single per-request lookup behind requireAppAccess. Returns null when the
* user row is gone; `disabled` is returned rather than filtered so the
* middleware can answer 403 (account deactivated) instead of a misleading 401.
*/
export const loadAccess = async (userId: string): Promise<UserAccess | null> => {
const rows = await db
.selectFrom('user')
.leftJoin('user_app_permissions', 'user_app_permissions.user_id', 'user.id')
.where('user.id', '=', userId)
.select([
'user.id as id',
'user.email as email',
'user.name as name',
'user.disabled as disabled',
'user_app_permissions.app as app',
'user_app_permissions.role as role'
])
.execute();
if (rows.length === 0) {
return null;
}
const permissions = toPermissionRows(rows);
return {
id: rows[0].id,
email: rows[0].email,
displayName: rows[0].name,
// MySQL TINYINT(1) comes back as 0/1 through mysql2.
disabled: Boolean(rows[0].disabled),
permissions,
apps: appsOf(permissions)
};
};
/**
* Turns joined permission rows into AppPermission[]. The left join produces one
* row with a null app for a user who holds nothing, and a role written directly
* into the database that no longer appears in APP_ROLES is dropped rather than
* trusted - the table is the store, APP_ROLES is the contract.
*/
const toPermissionRows = (rows: {app: AppName | null; role: string | null}[]): AppPermission[] => {
return rows
.filter((row): row is {app: AppName; role: string} =>
isAppName(row.app) && isAppRole(row.app, row.role))
.map(row => ({app: row.app, role: row.role}));
};
export const listUsers = async (): Promise<UserListEntry[]> => {
const users = await db
.selectFrom('user')
.select(['id', 'email', 'name', 'disabled', 'createdAt'])
.orderBy('name', 'asc')
.execute();
const permissions = await db
.selectFrom('user_app_permissions')
.select(['user_id', 'app', 'role'])
.execute();
// Last sign-in is derived from the newest session rather than stored: a
// session row is created on every sign-in and we never update its
// createdAt, so max(createdAt) is exactly that, with no extra column to
// keep in sync.
//
// The expiry filter must match getUserDetail's. better-auth only deletes an
// expired session when someone actually presents it, so expired rows linger
// - without this, the list would report a last sign-in for someone the
// detail view shows as never having signed in.
const lastSessions = await db
.selectFrom('session')
.where('expiresAt', '>', new Date())
.select(({fn}) => ['userId', fn.max('createdAt').as('lastSignInAt')])
.groupBy('userId')
.execute();
const permissionsByUser = new Map<string, AppPermission[]>();
for (const row of permissions) {
if (!isAppRole(row.app, row.role)) {
continue;
}
const held = permissionsByUser.get(row.user_id) || [];
held.push({app: row.app, role: row.role});
permissionsByUser.set(row.user_id, held);
}
const lastSignInByUser = new Map<string, Date | null>(
lastSessions.map(row => [row.userId, row.lastSignInAt as Date | null])
);
return users.map(user => {
const held = permissionsByUser.get(user.id) || [];
return {
id: user.id,
email: user.email,
name: user.name,
permissions: held,
apps: appsOf(held),
status: user.disabled ? ('deaktiviert' as const) : ('aktiv' as const),
createdAt: user.createdAt,
lastSignInAt: lastSignInByUser.get(user.id) ?? null
};
});
};
export const getUserDetail = async (userId: string): Promise<UserDetail | null> => {
const user = await db
.selectFrom('user')
.select(['id', 'email', 'name', 'disabled', 'createdAt'])
.where('id', '=', userId)
.executeTakeFirst();
if (!user) {
return null;
}
const [permissions, sessions, passkeys] = await Promise.all([
db
.selectFrom('user_app_permissions')
.select(['app', 'role'])
.where('user_id', '=', userId)
.execute(),
db
.selectFrom('session')
.select(['id', 'createdAt', 'expiresAt', 'ipAddress', 'userAgent'])
.where('userId', '=', userId)
.where('expiresAt', '>', new Date())
.orderBy('createdAt', 'desc')
.execute(),
db
.selectFrom('passkey')
.select(({fn}) => fn.countAll<number>().as('count'))
.where('userId', '=', userId)
.executeTakeFirst()
]);
const held = toPermissionRows(permissions);
return {
id: user.id,
email: user.email,
name: user.name,
permissions: held,
apps: appsOf(held),
status: user.disabled ? 'deaktiviert' : 'aktiv',
createdAt: user.createdAt,
lastSignInAt: sessions.length > 0 ? sessions[0].createdAt : null,
sessions,
passkeyCount: Number(passkeys?.count ?? 0)
};
};
/**
* Replaces a user's permission set. Written as delete-then-insert inside one
* transaction rather than a diff: the set is at most four rows, and a diff
* would only add branches for no measurable gain.
*/
/** The rows a permission list becomes. One row per (app, role). */
const permissionRows = (
userId: string,
permissions: AppPermission[],
grantedBy: string | null
) => {
return permissions.map(permission => ({
user_id: userId,
app: permission.app,
role: permission.role,
granted_by: grantedBy,
granted_at: new Date()
}));
};
/** Drops anything not in APP_ROLES and de-duplicates on (app, role). */
const validPermissions = (permissions: AppPermission[]): AppPermission[] => {
const seen = new Set<string>();
return permissions.filter(permission => {
if (!isAppName(permission.app) || !isAppRole(permission.app, permission.role)) {
return false;
}
const key = `${permission.app}:${permission.role}`;
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
};
export const setPermissions = async (
userId: string,
permissions: AppPermission[],
grantedBy: string | null
): Promise<void> => {
const valid = validPermissions(permissions);
await db.transaction().execute(async trx => {
await trx.deleteFrom('user_app_permissions').where('user_id', '=', userId).execute();
if (valid.length > 0) {
await trx
.insertInto('user_app_permissions')
.values(permissionRows(userId, valid, grantedBy))
.execute();
}
});
};
/**
* Why the guards live down here rather than in the router: they are
* check-then-act, and the check has to happen inside the same transaction as
* the write, over locked rows. Two admins each removing the other's `admin`
* permission at the same moment would otherwise both read a count of 2, both
* pass, and both commit - leaving nobody who can administer anything, with
* ADMIN_BOOTSTRAP_EMAIL at the next restart as the only way back in.
*
* `SELECT ... FOR UPDATE` makes the second transaction wait and re-read the
* count the first one just changed.
*/
export type LastAdminGuardResult = 'ok' | 'last-admin';
const countActiveAdminsForUpdate = async (trx: Transaction<AdminDatabase>): Promise<number> => {
const row = await trx
.selectFrom('user_app_permissions')
.innerJoin('user', 'user.id', 'user_app_permissions.user_id')
.where('user_app_permissions.app', '=', 'admin')
.where('user.disabled', '=', false)
// countDistinct, not countAll: with (user_id, app, role) as the key one
// user can hold several roles on `admin`, and counting rows would make a
// single admin with two roles look like two admins - defeating the guard
// at exactly the moment it matters.
.select(({fn}) => fn.count<number>('user_app_permissions.user_id').distinct().as('count'))
.forUpdate()
.executeTakeFirst();
return Number(row?.count ?? 0);
};
/**
* Replaces a user's permissions, refusing to remove the last active admin.
* Returns 'last-admin' instead of throwing so the router can answer 409.
*/
export const setPermissionsGuarded = async (
userId: string,
permissions: AppPermission[],
grantedBy: string | null
): Promise<LastAdminGuardResult> => {
const valid = validPermissions(permissions);
const keepsAdmin = valid.some(permission => permission.app === 'admin');
return db.transaction().execute(async trx => {
const target = await trx
.selectFrom('user_app_permissions')
.innerJoin('user', 'user.id', 'user_app_permissions.user_id')
.where('user_app_permissions.user_id', '=', userId)
.where('user_app_permissions.app', '=', 'admin')
.select(['user.disabled as disabled'])
.limit(1)
.forUpdate()
.executeTakeFirst();
const losesAdmin = Boolean(target) && !keepsAdmin;
if (losesAdmin && !target?.disabled && (await countActiveAdminsForUpdate(trx)) <= 1) {
return 'last-admin';
}
await trx.deleteFrom('user_app_permissions').where('user_id', '=', userId).execute();
if (valid.length > 0) {
await trx
.insertInto('user_app_permissions')
.values(permissionRows(userId, valid, grantedBy))
.execute();
}
return 'ok';
});
};
/**
* Disables a user and revokes every session, refusing to disable the last
* active admin. Same locking rationale as setPermissionsGuarded.
*/
export const disableUserGuarded = async (userId: string): Promise<LastAdminGuardResult> => {
return db.transaction().execute(async trx => {
const isAdmin = await trx
.selectFrom('user_app_permissions')
.innerJoin('user', 'user.id', 'user_app_permissions.user_id')
.where('user_app_permissions.user_id', '=', userId)
.where('user_app_permissions.app', '=', 'admin')
.where('user.disabled', '=', false)
.select('user_app_permissions.user_id')
.limit(1)
.forUpdate()
.executeTakeFirst();
if (isAdmin && (await countActiveAdminsForUpdate(trx)) <= 1) {
return 'last-admin';
}
await trx.updateTable('user').set({disabled: true}).where('id', '=', userId).execute();
await trx.deleteFrom('session').where('userId', '=', userId).execute();
return 'ok';
});
};
export const grantPermission = async (
userId: string,
app: AppName,
grantedBy: string | null,
role: AppRole = ACCESS_ROLE
): Promise<void> => {
await db
.insertInto('user_app_permissions')
.values({user_id: userId, app, role, granted_by: grantedBy, granted_at: new Date()})
// The row already existing is the success case - this is "make sure they
// hold it", not "re-grant it" - so nothing is overwritten and granted_by
// keeps naming whoever granted it first.
.onDuplicateKeyUpdate({role})
.execute();
};
/** Disabling revokes every session: a disabled user must lose access now, not
* when their 30-day cookie happens to expire. */
export const disableUser = async (userId: string): Promise<void> => {
await db.transaction().execute(async trx => {
await trx.updateTable('user').set({disabled: true}).where('id', '=', userId).execute();
await trx.deleteFrom('session').where('userId', '=', userId).execute();
});
};
export const enableUser = async (userId: string): Promise<void> => {
await db.updateTable('user').set({disabled: false}).where('id', '=', userId).execute();
};
export const revokeSession = async (userId: string, sessionId: string): Promise<boolean> => {
const result = await db
.deleteFrom('session')
.where('id', '=', sessionId)
.where('userId', '=', userId)
.executeTakeFirst();
return Number(result.numDeletedRows) > 0;
};
/**
* Guard input for the self-lockout rules: how many enabled users still hold the
* `admin` permission. Disabled admins do not count - they cannot sign in, so
* leaving only disabled admins is the same lockout as leaving none.
*/
export const countActiveAdmins = async (): Promise<number> => {
const row = await db
.selectFrom('user_app_permissions')
.innerJoin('user', 'user.id', 'user_app_permissions.user_id')
.where('user_app_permissions.app', '=', 'admin')
.where('user.disabled', '=', false)
.select(({fn}) => fn.countAll<number>().as('count'))
.executeTakeFirst();
return Number(row?.count ?? 0);
};
export const userExists = async (userId: string): Promise<boolean> => {
const row = await db.selectFrom('user').select('id').where('id', '=', userId).executeTakeFirst();
return Boolean(row);
};
export const findUserByEmail = async (email: string): Promise<{id: string; email: string} | null> => {
const row = await db
.selectFrom('user')
.select(['id', 'email'])
.where('email', '=', email)
.executeTakeFirst();
return row ?? null;
};