Add admin identity module: better-auth, per-app permissions, invitations #12

Merged
Paddy merged 5 commits from feature/admin-auth-module into master 2026-09-06 10:41:55 +00:00
15 changed files with 381 additions and 88 deletions
Showing only changes of commit dbcd5b56f6 - Show all commits
+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,29 +106,45 @@ 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, {
name: invitation.name, email: invitation.email,
// Accepting a link sent to that mailbox *is* the name: invitation.name,
// proof of address ownership, so there is no // Accepting a link sent to that mailbox *is*
// separate verification mail (plan decision 15). // the proof of address ownership, so there is
emailVerified: true, // no separate verification mail (plan
disabled: false // decision 15).
}, emailVerified: true,
{method: 'email-password'} disabled: false
); },
{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);
@@ -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();
}; };