Harden the admin module after a fresh-context review

Six defects found by an independent review of 7aac07a.

Environment handling now fails safe. NODE_ENV=production was gating the
signing key, the cookie domain, the CORS origin list and invitation-token
logging all at once, and it was documented nowhere - an unset value, which is
what a fresh Plesk vhost gives you, silently degraded all four. Only
'development' and 'test' relax anything now; everything else, unset included,
is strict. The hardcoded fallback secret is gone (dev gets a random
per-process one, so no committed value can ever sign a production cookie),
and invitation-link logging is an explicit ADMIN_LOG_INVITE_LINKS opt-in that
is refused in strict mode.

Rate limiting no longer collapses into a single global bucket. Without
trustedProxies, better-auth rejects a multi-value x-forwarded-for, resolves no
client IP, and keys every request to "no-trusted-ip" - where /sign-in/*
allows 3 requests per 10 seconds, so one noisy client could lock the whole
organisation out. CLIENT_IP_HEADERS and TRUSTED_PROXY_IPS make this explicit,
the unspecified x-forwarded-for fallback is gone, and strict mode warns at
boot when no trusted proxy is configured.

Invite acceptance is transactional. The user and its credential account go in
one runWithTransaction, as better-auth's own sign-up route does. A transaction
cannot span the permission and invitation writes - those use this module's own
pool - so a failure there is compensated: the user row is deleted and the
invitation un-marked, so the link works again instead of leaving the invitee
with a burnt token and an account no route can repair.

The last-admin guards were check-then-act. Two admins each removing the
other's admin permission could both pass the check and both commit, leaving
nobody able to administer anything. The count now runs inside the write
transaction under SELECT ... FOR UPDATE.

Also: lastSignInAt filtered expired sessions in the detail endpoint but not
the list, so the two disagreed; and the integration suite never reset
rateLimit, leaving it one added sign-in away from 429s that look like auth
bugs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-05 18:28:52 +02:00
parent 7aac07a013
commit dbcd5b56f6
15 changed files with 381 additions and 88 deletions
+23 -2
View File
@@ -1,5 +1,11 @@
# Values containing #, ", \ or surrounding spaces must be single-quoted # Values containing #, ", \ or surrounding spaces must be single-quoted
# (dotenv 16 treats an unquoted # as a comment): DB_PASSWORD='abc#def' # (dotenv 16 treats an unquoted # as a comment): DB_PASSWORD='abc#def'
# REQUIRED. The admin module treats anything other than "development" or "test"
# as production: strict secrets, cross-subdomain cookies, no relaxed CORS.
# Leaving it unset is therefore safe-by-default but will refuse to boot without
# the admin secrets below. Set it to development for local work.
NODE_ENV=development
PORT=3000 PORT=3000
DB_HOST= DB_HOST=
@@ -26,8 +32,10 @@ TICKETS_RATE_LIMIT_MAX=10
TICKETS_RATE_LIMIT_WINDOW_MIN=10 TICKETS_RATE_LIMIT_WINDOW_MIN=10
ADMIN_DB= ADMIN_DB=
# 32+ random bytes, e.g. `openssl rand -base64 48`. Rotating it signs everyone # 32+ random bytes, e.g. `openssl rand -base64 48`. Mandatory outside
# out and invalidates outstanding password-reset links. # development/test - there is deliberately no fallback, since a hardcoded one
# would be a published signing key. Rotating it signs everyone out and
# invalidates outstanding password-reset links.
BETTER_AUTH_SECRET= BETTER_AUTH_SECRET=
API_BASE_URL=http://localhost:3000 API_BASE_URL=http://localhost:3000
ADMIN_APP_URL=http://localhost:3002 ADMIN_APP_URL=http://localhost:3002
@@ -39,6 +47,19 @@ PASSKEY_RP_ID=localhost
# user already exists). Idempotent, safe to leave set. # user already exists). Idempotent, safe to leave set.
ADMIN_BOOTSTRAP_EMAIL= ADMIN_BOOTSTRAP_EMAIL=
# The header the reverse proxy puts the real client IP in, and the proxy hops to
# trust. Get these right or better-auth cannot resolve a client IP and every
# request shares ONE rate-limit bucket (/sign-in/* allows 3 per 10 seconds, so
# one noisy client locks everyone out). Check with:
# SELECT `key` FROM rateLimit; -- a "no-trusted-ip" row means it is happening.
CLIENT_IP_HEADERS=x-real-ip
TRUSTED_PROXY_IPS=
# Writes invitation links to the log. That link is a live account-creation
# credential, so this is refused outside development. Needed locally, where the
# mail relay is off and only the token's hash is stored.
ADMIN_LOG_INVITE_LINKS=true
MEMBER_CREDENTIAL=123 MEMBER_CREDENTIAL=123
CHOIR_CREDENTIAL=123 CHOIR_CREDENTIAL=123
MANAGEMENT_CREDENTIAL=123 MANAGEMENT_CREDENTIAL=123
+11
View File
@@ -83,8 +83,16 @@ backslash escapes inside double quotes are expanded. Wrap any value containing `
surrounding spaces in single quotes (`DB_PASSWORD='abc#def'`), which are taken literally. surrounding spaces in single quotes (`DB_PASSWORD='abc#def'`), which are taken literally.
A truncated password shows up as MariaDB "Access denied ... (using password: YES)". A truncated password shows up as MariaDB "Access denied ... (using password: YES)".
**`NODE_ENV` is load-bearing for the admin module.** Only the explicit values
`development` and `test` relax anything; everything else, *including unset*, is treated as
production (strict secrets, cross-subdomain cookies, no localhost CORS). That direction is
deliberate: a Plesk vhost does not set `NODE_ENV`, and the inverse arrangement would
silently degrade the signing key, the cookie domain and the CORS list at once. Local work
needs `NODE_ENV=development`.
Copy `.env.example` (or create `.env`) with: Copy `.env.example` (or create `.env`) with:
``` ```
NODE_ENV=
PORT= PORT=
DB_HOST= DB_HOST=
DB_USER= DB_USER=
@@ -97,6 +105,9 @@ ADMIN_APP_URL=
APP_ORIGINS= APP_ORIGINS=
PASSKEY_RP_ID= PASSKEY_RP_ID=
ADMIN_BOOTSTRAP_EMAIL= ADMIN_BOOTSTRAP_EMAIL=
CLIENT_IP_HEADERS=
TRUSTED_PROXY_IPS=
ADMIN_LOG_INVITE_LINKS=
FEEDBACK_DB= FEEDBACK_DB=
FEEDBACK_IP_SALT= FEEDBACK_IP_SALT=
FEEDBACK_RATE_LIMIT_MAX= FEEDBACK_RATE_LIMIT_MAX=
+1
View File
@@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@better-auth/core": "^1.7.2",
"@better-auth/passkey": "^1.7.2", "@better-auth/passkey": "^1.7.2",
"app-root-path": "^3.0.0", "app-root-path": "^3.0.0",
"axios": "^1.20.0", "axios": "^1.20.0",
+1
View File
@@ -19,6 +19,7 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@better-auth/core": "^1.7.2",
"@better-auth/passkey": "^1.7.2", "@better-auth/passkey": "^1.7.2",
"app-root-path": "^3.0.0", "app-root-path": "^3.0.0",
"axios": "^1.20.0", "axios": "^1.20.0",
+8 -4
View File
@@ -10,7 +10,9 @@ import {
ADMIN_ALLOWED_ORIGINS, ADMIN_ALLOWED_ORIGINS,
API_BASE_URL, API_BASE_URL,
BETTER_AUTH_SECRET, BETTER_AUTH_SECRET,
CLIENT_IP_HEADERS,
PASSKEY_RP_ID, PASSKEY_RP_ID,
TRUSTED_PROXY_IPS,
isProd isProd
} from './admin.config.js'; } from './admin.config.js';
@@ -94,10 +96,12 @@ export const auth = betterAuth({
: {enabled: false}, : {enabled: false},
ipAddress: { ipAddress: {
// better-auth reads the request itself and does not know about // better-auth reads the request itself and does not know about
// Express's `trust proxy`, so the header has to be named here. // Express's `trust proxy`, so both the header and the trusted hops
// Verify against what Plesk's nginx actually sets before relying on // have to be named here. Getting this wrong does not fail loudly -
// the rate limiter (see the plan's pre-deploy checklist). // it collapses every client into one rate-limit bucket. See the
ipAddressHeaders: ['x-real-ip', 'x-forwarded-for'] // commentary on CLIENT_IP_HEADERS in admin.config.ts.
ipAddressHeaders: CLIENT_IP_HEADERS,
...(TRUSTED_PROXY_IPS.length > 0 ? {trustedProxies: TRUSTED_PROXY_IPS} : {})
} }
}, },
+5 -5
View File
@@ -1,7 +1,7 @@
import * as UsersService from './users/users.admin.service.js'; import * as UsersService from './users/users.admin.service.js';
import * as InvitationsService from './invitations/invitations.service.js'; import * as InvitationsService from './invitations/invitations.service.js';
import {sendInvitationMail} from './admin.mail.js'; import {sendInvitationMail} from './admin.mail.js';
import {ADMIN_APP_URL, ADMIN_BOOTSTRAP_EMAIL, isProd} from './admin.config.js'; import {ADMIN_APP_URL, ADMIN_BOOTSTRAP_EMAIL, LOG_INVITE_LINKS} from './admin.config.js';
import logger from '../../middleware/logger.js'; import logger from '../../middleware/logger.js';
/** /**
@@ -55,10 +55,10 @@ export const bootstrapAdmin = async (): Promise<void> => {
const mailed = await sendInvitationMail(email, 'Nachklang Admin', invitation.token, invitation.expiresAt); const mailed = await sendInvitationMail(email, 'Nachklang Admin', invitation.token, invitation.expiresAt);
logger.info('Admin bootstrap: invitation created', {email, mailed}); logger.info('Admin bootstrap: invitation created', {email, mailed});
// Outside production the Salesforce mail relay is usually off, so the // With the mail relay off, the logged link is how a local setup gets its
// link is logged instead - that is how a local setup gets its first // first admin. Explicit opt-in (see LOG_INVITE_LINKS): the link is a
// admin. Never in production: the log would then hold a live credential. // live credential, so this must never depend on NODE_ENV alone.
if (!isProd) { if (LOG_INVITE_LINKS) {
logger.info(`Admin bootstrap: ${ADMIN_APP_URL}/accept-invite?token=${invitation.token}`); logger.info(`Admin bootstrap: ${ADMIN_APP_URL}/accept-invite?token=${invitation.token}`);
} }
} catch (e: any) { } catch (e: any) {
+84 -14
View File
@@ -1,3 +1,4 @@
import * as crypto from 'crypto';
import * as dotenv from 'dotenv'; import * as dotenv from 'dotenv';
import logger from '../../middleware/logger.js'; import logger from '../../middleware/logger.js';
@@ -8,13 +9,28 @@ dotenv.config();
* (better-auth trustedOrigins, passkey origins) and app.ts (CORS) need the * (better-auth trustedOrigins, passkey origins) and app.ts (CORS) need the
* same origin list, and a second parser would drift from this one. * same origin list, and a second parser would drift from this one.
* *
* In production every value is mandatory: a missing BETTER_AUTH_SECRET or a * Read this before changing the environment handling below: several security
* wrong ADMIN_APP_URL is the kind of misconfiguration that fails as "login * properties depend on it, and they are deliberately arranged to fail *safe*.
* silently does nothing" hours later, so it fails at boot instead. In dev the *
* localhost defaults below let a fresh checkout run without an .env. * `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.
*/ */
export const isProd = process.env.NODE_ENV === '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 required = (name: string, devDefault: string): string => {
const value = process.env[name]; const value = process.env[name];
@@ -22,8 +38,11 @@ const required = (name: string, devDefault: string): string => {
return value; return value;
} }
if (isProd) { if (isProd) {
logger.error(`Admin module: ${name} is not set`); logger.error(
throw new Error(`${name} must be set in production`); `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; return devDefault;
}; };
@@ -33,9 +52,11 @@ export const ADMIN_APP_URL = required('ADMIN_APP_URL', 'http://localhost:3002');
// 32+ random bytes; better-auth signs cookies and reset tokens with it. // 32+ random bytes; better-auth signs cookies and reset tokens with it.
// Rotating it invalidates every session, which is why it is not derived. // 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( export const BETTER_AUTH_SECRET = required(
'BETTER_AUTH_SECRET', 'BETTER_AUTH_SECRET',
'dev-only-insecure-secret-do-not-use-in-production' crypto.randomBytes(48).toString('base64')
); );
// Passkeys are bound to this: a credential registered for "nachklang.art" // Passkeys are bound to this: a credential registered for "nachklang.art"
@@ -43,15 +64,17 @@ export const BETTER_AUTH_SECRET = required(
// works in dev. Changing it invalidates every registered passkey. // works in dev. Changing it invalidates every registered passkey.
export const PASSKEY_RP_ID = process.env.PASSKEY_RP_ID || (isProd ? 'nachklang.art' : 'localhost'); export const PASSKEY_RP_ID = process.env.PASSKEY_RP_ID || (isProd ? 'nachklang.art' : 'localhost');
// The apps whose frontends may talk to /admin/* with credentials. const parseList = (value: string | undefined, fallback: string[]): string[] => {
const parseOrigins = (value: string | undefined): string[] => { const parsed = (value || '')
return (value || '')
.split(',') .split(',')
.map(origin => origin.trim().replace(/\/$/, '')) .map(entry => entry.trim())
.filter(origin => origin.length > 0); .filter(entry => entry.length > 0);
return parsed.length > 0 ? parsed : fallback;
}; };
export const APP_ORIGINS = parseOrigins(process.env.APP_ORIGINS); // 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 // Kept in sync by construction rather than by three separate lists: the admin
// app itself always counts, and dev adds the local ports. // app itself always counts, and dev adds the local ports.
@@ -60,4 +83,51 @@ export const ADMIN_ALLOWED_ORIGINS = Array.from(new Set([
...APP_ORIGINS ...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.
*/
export const CLIENT_IP_HEADERS = parseList(process.env.CLIENT_IP_HEADERS, ['x-real-ip']);
export const TRUSTED_PROXY_IPS = parseList(process.env.TRUSTED_PROXY_IPS, []);
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.'
);
}
export const ADMIN_BOOTSTRAP_EMAIL = process.env.ADMIN_BOOTSTRAP_EMAIL || ''; 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');
}
+16
View File
@@ -72,10 +72,26 @@ export interface InvitationTable {
revoked_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 { export interface AdminDatabase {
user: UserTable; user: UserTable;
session: SessionTable; session: SessionTable;
passkey: PasskeyTable; passkey: PasskeyTable;
verification: VerificationTable;
rateLimit: RateLimitTable;
user_app_permissions: UserAppPermissionTable; user_app_permissions: UserAppPermissionTable;
invitations: InvitationTable; invitations: InvitationTable;
} }
@@ -2,6 +2,7 @@ import * as z from 'zod';
import {APIError, createAuthEndpoint} from 'better-auth/api'; import {APIError, createAuthEndpoint} from 'better-auth/api';
import {setSessionCookie} from 'better-auth/cookies'; import {setSessionCookie} from 'better-auth/cookies';
import {createLocalAccountIssuer} from 'better-auth/db'; import {createLocalAccountIssuer} from 'better-auth/db';
import {runWithTransaction} from '@better-auth/core/context';
import type {BetterAuthPlugin} from 'better-auth'; import type {BetterAuthPlugin} from 'better-auth';
import * as InvitationsService from './invitations.service.js'; import * as InvitationsService from './invitations.service.js';
import * as UsersService from '../users/users.admin.service.js'; import * as UsersService from '../users/users.admin.service.js';
@@ -105,31 +106,47 @@ export const invitationsPlugin = () => {
throw invalidToken(); 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 { try {
const user = await ctx.context.internalAdapter.createUser( user = await runWithTransaction(ctx.context.adapter, async () => {
const created = await ctx.context.internalAdapter.createUser(
{ {
email: invitation.email, email: invitation.email,
name: invitation.name, name: invitation.name,
// Accepting a link sent to that mailbox *is* the // Accepting a link sent to that mailbox *is*
// proof of address ownership, so there is no // the proof of address ownership, so there is
// separate verification mail (plan decision 15). // no separate verification mail (plan
// decision 15).
emailVerified: true, emailVerified: true,
disabled: false disabled: false
}, },
{method: 'email-password'} {method: 'email-password'}
); );
// Same call better-auth's own sign-up route makes, down to // Same call better-auth's own sign-up route makes,
// the synthetic issuer - a credential account written any // down to the synthetic issuer - a credential account
// other way would not be found on sign-in. // written any other way is not found on sign-in.
await ctx.context.internalAdapter.linkAccount({ await ctx.context.internalAdapter.linkAccount({
userId: user.id, userId: created.id,
providerId: 'credential', providerId: 'credential',
issuer: createLocalAccountIssuer('credential'), issuer: createLocalAccountIssuer('credential'),
accountId: user.id, accountId: created.id,
password: await ctx.context.password.hash(ctx.body.password) password: await ctx.context.password.hash(ctx.body.password)
}); });
return created;
});
await UsersService.setPermissions(user.id, invitation.apps, null); await UsersService.setPermissions(user.id, invitation.apps, null);
const session = await ctx.context.internalAdapter.createSession(user.id); const session = await ctx.context.internalAdapter.createSession(user.id);
@@ -139,12 +156,34 @@ export const invitationsPlugin = () => {
user: {id: user.id, email: user.email, name: user.name} user: {id: user.id, email: user.email, name: user.name}
}); });
} catch (e: any) { } catch (e: any) {
// The invitation is already marked accepted at this // Undo what committed, so the invitee can use their link
// point. Leaving it that way is deliberate: a token that // again instead of being stranded with a burnt token, an
// has been through a half-completed account creation // account they cannot sign into, and an admin who cannot
// should not stay usable. The admin can send a new // re-invite them (the create route 409s on an existing
// invitation, and this log says why one is needed. // user, and there is no delete route by design).
logger.error('Admin: invitation accepted but account creation failed', { //
// 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, invitationId: invitation.id,
detail: e?.message detail: e?.message
}); });
@@ -3,10 +3,9 @@ import * as InvitationsService from './invitations.service.js';
import * as UsersService from '../users/users.admin.service.js'; import * as UsersService from '../users/users.admin.service.js';
import {isAppName, AppName} from '../admin.schema.js'; import {isAppName, AppName} from '../admin.schema.js';
import {sendInvitationMail} from '../admin.mail.js'; import {sendInvitationMail} from '../admin.mail.js';
import {ADMIN_APP_URL} from '../admin.config.js'; import {ADMIN_APP_URL, LOG_INVITE_LINKS} from '../admin.config.js';
import {sendServerError} from '../admin.errors.js'; import {sendServerError} from '../admin.errors.js';
import logger from '../../../middleware/logger.js'; import logger from '../../../middleware/logger.js';
import {isProd} from '../admin.config.js';
export const invitationsRouter = express.Router(); export const invitationsRouter = express.Router();
@@ -21,14 +20,16 @@ export const invitationsRouter = express.Router();
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
/** /**
* Outside production the Salesforce relay is normally off, so the invitation * With the mail relay off (the normal local setup) the invitation mail never
* mail never arrives and only the token's hash is stored - there would be no * arrives, and only the token's hash is stored, so there would be no way to
* way to walk through the accept flow locally. Logging the link closes that, * walk through the accept flow. Logging the link closes that.
* and mirrors what the bootstrap already does. Never in production: the log *
* would then hold a live credential. * 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 => { const logInviteLinkInDev = (token: string): void => {
if (!isProd) { if (LOG_INVITE_LINKS) {
logger.info(`Admin: invitation link ${ADMIN_APP_URL}/accept-invite?token=${encodeURIComponent(token)}`); logger.info(`Admin: invitation link ${ADMIN_APP_URL}/accept-invite?token=${encodeURIComponent(token)}`);
} }
}; };
@@ -133,6 +133,19 @@ export const markAccepted = async (invitationId: number): Promise<boolean> => {
return Number(result.numUpdatedRows) > 0; 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[]> => { export const listOpenInvitations = async (): Promise<OpenInvitation[]> => {
const rows = await db const rows = await db
.selectFrom('invitations') .selectFrom('invitations')
+8 -5
View File
@@ -121,18 +121,21 @@ usersAdminRouter.put('/:userId/permissions', async (req: Request, res: Response)
const target = await UsersService.loadAccess(userId); const target = await UsersService.loadAccess(userId);
const losesAdmin = Boolean(target?.apps.includes('admin')) && !(apps as AppName[]).includes('admin'); const losesAdmin = Boolean(target?.apps.includes('admin')) && !(apps as AppName[]).includes('admin');
// 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) { if (losesAdmin && userId === res.locals.admin.id) {
conflict(res, 'Du kannst dir die Admin-Berechtigung nicht selbst entziehen.'); conflict(res, 'Du kannst dir die Admin-Berechtigung nicht selbst entziehen.');
return; return;
} }
// Only an enabled admin counts; see countActiveAdmins. const result = await UsersService.setPermissionsGuarded(userId, apps as AppName[], res.locals.admin.id);
if (losesAdmin && !target?.disabled && (await UsersService.countActiveAdmins()) <= 1) { if (result === 'last-admin') {
conflict(res, 'Die letzte Admin-Berechtigung kann nicht entzogen werden.'); conflict(res, 'Die letzte Admin-Berechtigung kann nicht entzogen werden.');
return; return;
} }
await UsersService.setPermissions(userId, apps as AppName[], res.locals.admin.id);
res.status(200).send(await UsersService.getUserDetail(userId)); res.status(200).send(await UsersService.getUserDetail(userId));
} catch (e: any) { } catch (e: any) {
sendServerError(res, e); sendServerError(res, e);
@@ -166,12 +169,12 @@ usersAdminRouter.post('/:userId/disable', async (req: Request, res: Response) =>
return; return;
} }
if (target.apps.includes('admin') && !target.disabled && (await UsersService.countActiveAdmins()) <= 1) { const result = await UsersService.disableUserGuarded(userId);
if (result === 'last-admin') {
conflict(res, 'Der letzte aktive Admin kann nicht deaktiviert werden.'); conflict(res, 'Der letzte aktive Admin kann nicht deaktiviert werden.');
return; return;
} }
await UsersService.disableUser(userId);
res.status(200).send(await UsersService.getUserDetail(userId)); res.status(200).send(await UsersService.getUserDetail(userId));
} catch (e: any) { } catch (e: any) {
sendServerError(res, e); sendServerError(res, e);
+106 -3
View File
@@ -1,5 +1,6 @@
import {Transaction} from 'kysely';
import {NachklangAdminDB} from '../Admin.db.js'; import {NachklangAdminDB} from '../Admin.db.js';
import {AppName, APP_NAMES} from '../admin.schema.js'; import {AdminDatabase, AppName, APP_NAMES} from '../admin.schema.js';
const db = NachklangAdminDB.db; const db = NachklangAdminDB.db;
@@ -93,10 +94,15 @@ export const listUsers = async (): Promise<UserListEntry[]> => {
// Last sign-in is derived from the newest session rather than stored: a // 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 // 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 // createdAt, so max(createdAt) is exactly that, with no extra column to
// keep in sync. Sessions are pruned on expiry, so this goes back to null // keep in sync.
// for someone who has not signed in for over 30 days. //
// 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 const lastSessions = await db
.selectFrom('session') .selectFrom('session')
.where('expiresAt', '>', new Date())
.select(({fn}) => ['userId', fn.max('createdAt').as('lastSignInAt')]) .select(({fn}) => ['userId', fn.max('createdAt').as('lastSignInAt')])
.groupBy('userId') .groupBy('userId')
.execute(); .execute();
@@ -192,6 +198,103 @@ export const setPermissions = async (
}); });
}; };
/**
* 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)
.select(({fn}) => fn.countAll<number>().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,
apps: AppName[],
grantedBy: string | null
): Promise<LastAdminGuardResult> => {
const unique = Array.from(new Set(apps)).filter(app => APP_NAMES.includes(app));
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'])
.forUpdate()
.executeTakeFirst();
const losesAdmin = Boolean(target) && !unique.includes('admin');
if (losesAdmin && !target?.disabled && (await countActiveAdminsForUpdate(trx)) <= 1) {
return 'last-admin';
}
await trx.deleteFrom('user_app_permissions').where('user_id', '=', userId).execute();
if (unique.length > 0) {
await trx
.insertInto('user_app_permissions')
.values(unique.map(app => ({
user_id: userId,
app,
role: 'admin',
granted_by: grantedBy,
granted_at: new Date()
})))
.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')
.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 ( export const grantPermission = async (
userId: string, userId: string,
app: AppName, app: AppName,
+18 -18
View File
@@ -7,7 +7,9 @@ vi.mock('../../src/models/admin/users/users.admin.service.js', () => ({
getUserDetail: vi.fn(), getUserDetail: vi.fn(),
loadAccess: vi.fn(), loadAccess: vi.fn(),
setPermissions: vi.fn(), setPermissions: vi.fn(),
setPermissionsGuarded: vi.fn(),
disableUser: vi.fn(), disableUser: vi.fn(),
disableUserGuarded: vi.fn(),
enableUser: vi.fn(), enableUser: vi.fn(),
revokeSession: vi.fn(), revokeSession: vi.fn(),
countActiveAdmins: vi.fn(), countActiveAdmins: vi.fn(),
@@ -40,6 +42,8 @@ beforeEach(() => {
} }
service.getUserDetail.mockResolvedValue({id: 'other', apps: []}); service.getUserDetail.mockResolvedValue({id: 'other', apps: []});
service.userExists.mockResolvedValue(true); service.userExists.mockResolvedValue(true);
service.setPermissionsGuarded.mockResolvedValue('ok');
service.disableUserGuarded.mockResolvedValue('ok');
}); });
describe('PUT /admin/users/:id/permissions', () => { describe('PUT /admin/users/:id/permissions', () => {
@@ -47,7 +51,7 @@ describe('PUT /admin/users/:id/permissions', () => {
const res = await request(makeApp()).put('/admin/users/other/permissions').send({apps: ['calendar', 'nope']}); const res = await request(makeApp()).put('/admin/users/other/permissions').send({apps: ['calendar', 'nope']});
expect(res.status).toBe(400); expect(res.status).toBe(400);
expect(service.setPermissions).not.toHaveBeenCalled(); expect(service.setPermissionsGuarded).not.toHaveBeenCalled();
}); });
it('rejects a non-array body', async () => { it('rejects a non-array body', async () => {
@@ -62,7 +66,7 @@ describe('PUT /admin/users/:id/permissions', () => {
const res = await request(makeApp()).put('/admin/users/ghost/permissions').send({apps: []}); const res = await request(makeApp()).put('/admin/users/ghost/permissions').send({apps: []});
expect(res.status).toBe(404); expect(res.status).toBe(404);
expect(service.setPermissions).not.toHaveBeenCalled(); expect(service.setPermissionsGuarded).not.toHaveBeenCalled();
}); });
it('refuses to remove the caller\'s own admin permission', async () => { it('refuses to remove the caller\'s own admin permission', async () => {
@@ -72,30 +76,28 @@ describe('PUT /admin/users/:id/permissions', () => {
const res = await request(makeApp('me')).put('/admin/users/me/permissions').send({apps: ['feedback']}); const res = await request(makeApp('me')).put('/admin/users/me/permissions').send({apps: ['feedback']});
expect(res.status).toBe(409); expect(res.status).toBe(409);
expect(service.setPermissions).not.toHaveBeenCalled(); expect(service.setPermissionsGuarded).not.toHaveBeenCalled();
}); });
// Defence in depth: with the caller themselves being an active admin this // The last-admin decision is made inside the write transaction (so two
// count cannot actually reach 1 in production, but the guard is what makes // admins acting at once cannot both pass a check-then-act); the router's
// that safe to rely on rather than to reason about. // job is only to turn that verdict into a 409.
it('refuses to remove the last remaining active admin', async () => { it('answers 409 when the service reports the last admin would be removed', async () => {
service.loadAccess.mockResolvedValue({id: 'other', disabled: false, apps: ['admin']}); service.loadAccess.mockResolvedValue({id: 'other', disabled: false, apps: ['admin']});
service.countActiveAdmins.mockResolvedValue(1); service.setPermissionsGuarded.mockResolvedValue('last-admin');
const res = await request(makeApp('me')).put('/admin/users/other/permissions').send({apps: []}); const res = await request(makeApp('me')).put('/admin/users/other/permissions').send({apps: []});
expect(res.status).toBe(409); expect(res.status).toBe(409);
expect(service.setPermissions).not.toHaveBeenCalled();
}); });
it('allows removing an admin while another active admin remains', async () => { it('allows removing an admin while another active admin remains', async () => {
service.loadAccess.mockResolvedValue({id: 'other', disabled: false, apps: ['admin']}); service.loadAccess.mockResolvedValue({id: 'other', disabled: false, apps: ['admin']});
service.countActiveAdmins.mockResolvedValue(2);
const res = await request(makeApp('me')).put('/admin/users/other/permissions').send({apps: ['tickets']}); const res = await request(makeApp('me')).put('/admin/users/other/permissions').send({apps: ['tickets']});
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(service.setPermissions).toHaveBeenCalledWith('other', ['tickets'], 'me'); expect(service.setPermissionsGuarded).toHaveBeenCalledWith('other', ['tickets'], 'me');
}); });
it('allows granting permissions to someone who has none', async () => { it('allows granting permissions to someone who has none', async () => {
@@ -104,8 +106,7 @@ describe('PUT /admin/users/:id/permissions', () => {
const res = await request(makeApp('me')).put('/admin/users/other/permissions').send({apps: ['feedback', 'tickets']}); const res = await request(makeApp('me')).put('/admin/users/other/permissions').send({apps: ['feedback', 'tickets']});
expect(res.status).toBe(200); expect(res.status).toBe(200);
// Nothing is being taken away, so the last-admin count is not consulted. expect(service.setPermissionsGuarded).toHaveBeenCalledWith('other', ['feedback', 'tickets'], 'me');
expect(service.countActiveAdmins).not.toHaveBeenCalled();
}); });
}); });
@@ -114,17 +115,16 @@ describe('POST /admin/users/:id/disable', () => {
const res = await request(makeApp('me')).post('/admin/users/me/disable'); const res = await request(makeApp('me')).post('/admin/users/me/disable');
expect(res.status).toBe(409); expect(res.status).toBe(409);
expect(service.disableUser).not.toHaveBeenCalled(); expect(service.disableUserGuarded).not.toHaveBeenCalled();
}); });
it('refuses to disable the last active admin', async () => { it('answers 409 when the service reports the last active admin would be disabled', async () => {
service.loadAccess.mockResolvedValue({id: 'other', disabled: false, apps: ['admin']}); service.loadAccess.mockResolvedValue({id: 'other', disabled: false, apps: ['admin']});
service.countActiveAdmins.mockResolvedValue(1); service.disableUserGuarded.mockResolvedValue('last-admin');
const res = await request(makeApp('me')).post('/admin/users/other/disable'); const res = await request(makeApp('me')).post('/admin/users/other/disable');
expect(res.status).toBe(409); expect(res.status).toBe(409);
expect(service.disableUser).not.toHaveBeenCalled();
}); });
it('disables a non-admin user', async () => { it('disables a non-admin user', async () => {
@@ -133,7 +133,7 @@ describe('POST /admin/users/:id/disable', () => {
const res = await request(makeApp('me')).post('/admin/users/other/disable'); const res = await request(makeApp('me')).post('/admin/users/other/disable');
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(service.disableUser).toHaveBeenCalledWith('other'); expect(service.disableUserGuarded).toHaveBeenCalledWith('other');
}); });
it('404s for an unknown user', async () => { it('404s for an unknown user', async () => {
+12 -2
View File
@@ -11,13 +11,23 @@ const db = NachklangAdminDB.db;
// prefix is only added over https. // prefix is only added over https.
export const SESSION_COOKIE = 'nachklang.session_token'; export const SESSION_COOKIE = 'nachklang.session_token';
/** Wipes every table between test files. Child tables first - the FKs to /**
* `user` are ON DELETE CASCADE, but rateLimit and invitations are not. */ * Wipes every table between test files. Child tables first - the FKs to `user`
* are ON DELETE CASCADE, but the rest are not.
*
* `rateLimit` matters more than it looks: the limiter is enabled during the
* suite, and better-auth caps /sign-in/* at 3 requests per 10 seconds. All
* tests resolve to the same client IP, so they share one bucket - without this
* reset the suite would start failing with 429s that look like auth bugs as
* soon as a third sign-in assertion is added.
*/
export const resetDatabase = async (): Promise<void> => { export const resetDatabase = async (): Promise<void> => {
await db.deleteFrom('session').execute(); await db.deleteFrom('session').execute();
await db.deleteFrom('user_app_permissions').execute(); await db.deleteFrom('user_app_permissions').execute();
await db.deleteFrom('passkey').execute(); await db.deleteFrom('passkey').execute();
await db.deleteFrom('invitations').execute(); await db.deleteFrom('invitations').execute();
await db.deleteFrom('verification').execute();
await db.deleteFrom('rateLimit').execute();
await db.deleteFrom('user').execute(); await db.deleteFrom('user').execute();
}; };