3 Commits

Author SHA1 Message Date
Paddy e62b46945a Add a CLIENT_IP_HEADERS escape hatch and stop the auth handler hanging
Two changes, both from working out how to verify the client-IP configuration
on the Plesk vhost before deploying.

CLIENT_IP_HEADERS=none now trusts no header at all. This covers the one case
where the wrong setting is worse than no setting: if the proxy does not
overwrite the header we trust, any client can send it and mint itself an
unlimited brute-force budget against /sign-in. Falling back to the shared
rate-limit bucket is a nuisance - one noisy client can lock everyone out for
ten seconds at a time - but it fails closed, and it can be reverted from the
environment without a deploy. An empty or unset value still means "use the
default": a stray blank line in a .env must not silently change how requests
are bucketed, so only the explicit word does that. An empty array is what
better-auth reads as "no headers"; it falls back to its own default only when
the option is absent, and [] is truthy.

The TRUSTED_PROXY_IPS boot warning was overstating the risk. It now says that
a single-value header needs no trusted proxies, so seeing it on a plain
single-proxy setup is expected rather than a problem to chase.

Separately: a rejected promise from better-auth's handler used to escape as an
unhandled rejection, leaving the request hanging forever with no response while
the process logged an uncaughtException. Express 4 does not await an async
handler, and nearly every better-auth route touches the admin database, so any
database blip would have done this. Found by pointing ADMIN_DB at a database
the user cannot open while testing the hatch. The handler now answers 503, so
the caller learns and the other domains keep serving; verified as a 24ms
response instead of a hang, with / and /feedback/admin/me unaffected.

test/admin/admin.config.test.ts is new: it pins both branches of the hatch and
the rule that an unset NODE_ENV counts as production. It stubs dotenv, because
admin.config would otherwise read the repo's own .env and quietly reintroduce
NODE_ENV=development - the exact value several of those cases exist to remove.

157 unit tests and 41 integration tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 12:39:33 +02:00
Paddy 27eb301086 Fail CORS closed when NODE_ENV is unset
The CORS origin check used `NODE_ENV !== 'production'` to decide whether to
allow loopback and private-LAN origins. That is not the same question as "is
this a dev machine" when NODE_ENV is unset - which is exactly what a fresh
Plesk vhost gives you. On such a host the check called it dev and answered
`Access-Control-Allow-Origin: http://localhost:<any port>` together with
`Access-Control-Allow-Credentials: true`. With the admin session cookie scoped
to .nachklang.art, that let any page served from localhost on a signed-in
admin's machine read their data cross-origin.

admin.config.ts already resolves this correctly - only an explicit
'development' or 'test' relaxes anything, so unset is treated as production -
so the CORS check now derives from its `isProd` rather than re-deriving its own
answer from NODE_ENV. Two places asking the same question two different ways
was the bug.

Verified against the built app with a vhost-like environment (no NODE_ENV):
before, Origin http://localhost:9999 came back allowed with credentials; after,
it is rejected, while https://tickets.nachklang.art and
https://admin.nachklang.art are still allowed with credentials. With
NODE_ENV=development localhost is still allowed, so local development and the
LAN exception for real-device testing are unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 11:54:42 +02:00
Paddy 33489585a0 Model permissions as (app, role) and name passkeys from their AAGUID
Two changes to the admin module, both made now because it is not deployed yet
and neither is free later.

Permissions were "an app", with a `role` column reserved for a future
fine-grained model. Reviewing whether that reservation was enough found three
problems:

- Every row was written with role = 'admin', hardcoded, and the column
  defaulted to it. On a `tickets` row that reads as "tickets administrator"
  when it only ever meant "has access", and once real roles existed there
  would have been no way to tell an old plain grant from a deliberate one.
- The role never left the database. /admin/me, the user list, the user detail
  and both write endpoints all spoke apps: AppName[]. Adding roles would have
  been a breaking change to /admin/me - and after the cutover that endpoint
  has two more consumers, turning a local edit into a coordinated deploy of
  three apps.
- The key (user_id, app) allowed one role per app, i.e. a tier rather than a
  set of capabilities. Choosing later means an ALTER on a live table.

So: the key is now (user_id, app, role), the role is `access`, and APP_ROLES
in admin.schema.ts is the contract - a role not listed there is rejected with
400 rather than written. permissions: [{app, role}] is on the wire alongside
the derived apps: AppName[], which is kept because the three frontends only
ever ask "may I show this app?". Both write endpoints accept either shape, and
the invitation column (now `permissions`) is parsed leniently: invitations live
seven days, so a deploy that changes the shape has in-flight rows in the old
one. requireAppAccess(app, role?) takes an optional role; nothing passes one
yet.

countActiveAdminsForUpdate now counts DISTINCT users rather than rows. With
several roles per app, counting rows would make a single admin holding two
roles look like two admins and defeat the last-admin guard at exactly the
moment it matters.

Separately, passkey registration now fills `name` from the authenticator's
AAGUID via registration.afterVerification and better-auth's own
getAuthenticatorName, yielding "1Password", "iCloud Keychain", "Windows Hello".
Without it the column stayed NULL and the account page could only label every
passkey "Passkey" - useless when someone has to remove the one on the device
they just lost. A client-supplied name still wins; an unknown AAGUID still
leaves it blank.

148 unit tests (up from 131, including the new admin.schema.test.ts) and 41
integration tests pass. The integration suite applies sql/admin/001_init.sql,
so the new key is exercised rather than trusted.

No production migration is needed - the module is not deployed. An existing dev
database needs three statements: set role = 'access', drop and re-add the
primary key, rename invitations.apps to permissions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 11:23:08 +02:00
24 changed files with 765 additions and 127 deletions
+6
View File
@@ -52,6 +52,12 @@ ADMIN_BOOTSTRAP_EMAIL=
# 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.
# The header the reverse proxy puts the real client IP in. Must be one the proxy
# actually overwrites - trusting a header it does not set lets any client send its
# own value and bypass the sign-in rate limit entirely.
# Set to "none" to trust no header at all: every request then shares one rate-limit
# bucket, which is the safe fallback if the check below fails. Verify after deploy
# with: SELECT ipAddress FROM session ORDER BY createdAt DESC LIMIT 3;
CLIENT_IP_HEADERS=x-real-ip
TRUSTED_PROXY_IPS=
+11 -3
View File
@@ -46,9 +46,17 @@ other domain keeps the `mariadb` driver), mounted at `/admin/auth/*` for the aut
and `/admin` for the JSON routes. Sessions are httpOnly cookies scoped to
`.nachklang.art`, so one sign-in covers every app. Accounts are **invite-only** — public
sign-up is disabled, and `invitations.plugin.ts` is the only code that creates users.
Permissions are per app in `user_app_permissions`; `requireAppAccess(app)` in
`admin.middleware.ts` is the single authenticator, and it queries the database on every
request (no cookie cache) so disabling a user takes effect at once. `ADMIN_BOOTSTRAP_EMAIL`
A permission is **(app, role)** in `user_app_permissions`, keyed on
`(user_id, app, role)` so one user can hold several roles per app. `access` is the only role
today and means "may use this app at all"; `APP_ROLES` in `admin.schema.ts` is the contract,
and a role not listed there is rejected rather than written. `requireAppAccess(app)` in
`admin.middleware.ts` is the single authenticator - it takes an optional second argument to
narrow to one role, and queries the database on every request (no cookie cache) so disabling
a user takes effect at once. Two things to know before touching this: any count of admins
must count **distinct users**, not permission rows, or a single admin with two roles reads as
two and the last-admin guard stops guarding; and both write endpoints accept
`{permissions: [{app, role}]}` as well as the older `{apps: ['tickets']}`, which means the
same at the `access` role. `ADMIN_BOOTSTRAP_EMAIL`
makes sure someone can always get in on a fresh database.
*Legacy calendar* — unchanged: users need a `@nachklang.art` email, and after activation
+15 -9
View File
@@ -103,15 +103,21 @@ CREATE TABLE IF NOT EXISTS `rateLimit` (
-- ---------------------------------------------------------------------------
-- Which apps a user may administer. `admin` is just another app: holding it is
-- what lets someone manage users and invitations. `role` is reserved for
-- per-app roles later and is 'admin' for every row today.
-- what lets someone manage users and invitations. A permission is (app, role);
-- `access` is the only role today, and the key admits several per app so finer
-- ones can be added by inserting rows rather than by migrating this table.
CREATE TABLE IF NOT EXISTS `user_app_permissions` (
`user_id` VARCHAR(36) NOT NULL,
`app` ENUM('calendar','feedback','tickets','admin') NOT NULL,
`role` VARCHAR(32) NOT NULL DEFAULT 'admin',
-- One row per (user, app, role). `access` means "may use this app at all"
-- and is the only role today; the key allows several per app so a finer
-- permission can be added later by inserting rows, not by migrating.
`role` VARCHAR(32) NOT NULL DEFAULT 'access',
`granted_by` VARCHAR(36) DEFAULT NULL,
`granted_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`user_id`, `app`),
-- (user_id, app) is the leftmost prefix of this key, so the per-request
-- permission lookup needs no separate index.
PRIMARY KEY (`user_id`, `app`, `role`),
CONSTRAINT `uap_user_fk` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -122,7 +128,7 @@ CREATE TABLE IF NOT EXISTS `invitations` (
`email` VARCHAR(255) NOT NULL,
`name` VARCHAR(255) NOT NULL,
`token_hash` CHAR(64) NOT NULL,
`apps` JSON NOT NULL,
`permissions` JSON NOT NULL,
`invited_by` VARCHAR(36) DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`expires_at` DATETIME NOT NULL,
@@ -158,7 +164,7 @@ VALUES (
);
INSERT INTO `user_app_permissions` (`user_id`, `app`, `role`) VALUES
('dev-user-0000-0000-0000-000000000001', 'calendar', 'admin'),
('dev-user-0000-0000-0000-000000000001', 'feedback', 'admin'),
('dev-user-0000-0000-0000-000000000001', 'tickets', 'admin'),
('dev-user-0000-0000-0000-000000000001', 'admin', 'admin');
('dev-user-0000-0000-0000-000000000001', 'calendar', 'access'),
('dev-user-0000-0000-0000-000000000001', 'feedback', 'access'),
('dev-user-0000-0000-0000-000000000001', 'tickets', 'access'),
('dev-user-0000-0000-0000-000000000001', 'admin', 'access');
+11 -5
View File
@@ -114,15 +114,21 @@ CREATE TABLE IF NOT EXISTS `rateLimit` (
-- ---------------------------------------------------------------------------
-- Which apps a user may administer. `admin` is just another app: holding it is
-- what lets someone manage users and invitations. `role` is reserved for
-- per-app roles later and is 'admin' for every row today.
-- what lets someone manage users and invitations. A permission is (app, role);
-- `access` is the only role today, and the key admits several per app so finer
-- ones can be added by inserting rows rather than by migrating this table.
CREATE TABLE IF NOT EXISTS `user_app_permissions` (
`user_id` VARCHAR(36) NOT NULL,
`app` ENUM('calendar','feedback','tickets','admin') NOT NULL,
`role` VARCHAR(32) NOT NULL DEFAULT 'admin',
-- One row per (user, app, role). `access` means "may use this app at all"
-- and is the only role today; the key allows several per app so a finer
-- permission can be added later by inserting rows, not by migrating.
`role` VARCHAR(32) NOT NULL DEFAULT 'access',
`granted_by` VARCHAR(36) DEFAULT NULL,
`granted_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`user_id`, `app`),
-- (user_id, app) is the leftmost prefix of this key, so the per-request
-- permission lookup needs no separate index.
PRIMARY KEY (`user_id`, `app`, `role`),
CONSTRAINT `uap_user_fk` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -133,7 +139,7 @@ CREATE TABLE IF NOT EXISTS `invitations` (
`email` VARCHAR(255) NOT NULL,
`name` VARCHAR(255) NOT NULL,
`token_hash` CHAR(64) NOT NULL,
`apps` JSON NOT NULL,
`permissions` JSON NOT NULL,
`invited_by` VARCHAR(36) DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`expires_at` DATETIME NOT NULL,
+31 -3
View File
@@ -4,6 +4,7 @@ import swaggerUi from 'swagger-ui-express';
import swaggerJSDoc from 'swagger-jsdoc';
import cors from 'cors';
import {toNodeHandler} from 'better-auth/node';
import logger from './middleware/logger.js';
// Router imports
import {calendarRouter} from './models/calendar/Calendar.router.js';
@@ -11,7 +12,7 @@ import {feedbackRouter} from './models/feedback/Feedback.router.js';
import {ticketsRouter} from './models/tickets/Tickets.router.js';
import {adminRouter} from './models/admin/Admin.router.js';
import {auth} from './models/admin/admin.auth.js';
import {ADMIN_ALLOWED_ORIGINS} from './models/admin/admin.config.js';
import {ADMIN_ALLOWED_ORIGINS, isProd} from './models/admin/admin.config.js';
dotenv.config();
@@ -47,7 +48,15 @@ export const createApp = (): express.Application => {
// staging host does not need a code change here.
...ADMIN_ALLOWED_ORIGINS
];
const isDev = process.env.NODE_ENV !== 'production';
// `isProd` from admin.config, NOT `NODE_ENV !== 'production'`. The two are not
// the same when NODE_ENV is unset, which is exactly what a fresh Plesk vhost
// gives you: the old test called that "dev" and opened the loopback and
// private-LAN exceptions below. With `credentials: true` on this CORS config
// and a session cookie scoped to .nachklang.art, that let any page served
// from localhost read a signed-in admin's data cross-origin. admin.config
// treats anything but an explicit 'development'/'test' as production, so an
// unset value now fails closed.
const isDev = !isProd;
const localhostRegex = /^http:\/\/localhost:\d+$/;
// Matches http://<private-LAN-IPv4>:<port> - needed so the feedback form can
// be reached from a real phone over WiFi during dev (the phone's Origin is
@@ -83,7 +92,26 @@ export const createApp = (): express.Application => {
// better-auth's own handler, mounted before express.json(): it reads the raw
// request body stream itself and a parsed body would leave it hanging.
app.all('/admin/auth/*', toNodeHandler(auth));
//
// Wrapped, because Express 4 does not await an async handler: a rejected
// promise escapes as an unhandled rejection instead of becoming a response.
// Nearly every better-auth route touches the admin database, so a database
// blip would leave the request hanging with no answer at all while the
// process logged an uncaughtException - observed by pointing ADMIN_DB at a
// database the user cannot open. Answer 503 instead: the caller learns, and
// the other domains keep serving.
const authHandler = toNodeHandler(auth);
app.all('/admin/auth/*', (req, res) => {
Promise.resolve(authHandler(req, res)).catch((e: any) => {
logger.error('Admin auth handler failed', {path: req.path, detail: e?.message});
if (!res.headersSent) {
res.status(503).send({
status: 'SERVICE_UNAVAILABLE',
message: 'Die Anmeldung ist derzeit nicht verfügbar. Bitte versuche es später erneut.'
});
}
});
});
// here we are adding middleware to parse all incoming requests as JSON
app.use(express.json());
+5
View File
@@ -35,6 +35,11 @@ adminRouter.get('/me', requireSignedIn, (req: Request, res: Response) => {
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
});
});
+19 -2
View File
@@ -1,6 +1,6 @@
import {betterAuth} from 'better-auth';
import {APIError} from 'better-auth/api';
import {passkey} from '@better-auth/passkey';
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';
@@ -116,7 +116,24 @@ export const auth = betterAuth({
passkey({
rpID: PASSKEY_RP_ID,
rpName: 'Nachklang',
origin: ADMIN_ALLOWED_ORIGINS
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()
],
+2 -1
View File
@@ -1,6 +1,7 @@
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';
@@ -48,7 +49,7 @@ export const bootstrapAdmin = async (): Promise<void> => {
const invitation = await InvitationsService.createInvitation(
email,
'Nachklang Admin',
['admin'],
[{app: 'admin', role: ACCESS_ROLE}],
null
);
+40 -3
View File
@@ -101,16 +101,53 @@ export const ADMIN_ALLOWED_ORIGINS = Array.from(new Set([
* 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']);
/**
* `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 && TRUSTED_PROXY_IPS.length === 0) {
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 "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.'
);
}
+19 -3
View File
@@ -2,7 +2,7 @@ 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} from './admin.schema.js';
import {AppName, AppPermission, AppRole} from './admin.schema.js';
import {sendServerError} from './admin.errors.js';
/**
@@ -28,6 +28,9 @@ export interface AdminIdentity {
export interface AdminAccess extends AdminIdentity {
disabled: boolean;
/** Every (app, role) grant. */
permissions: AppPermission[];
/** The distinct apps those grants cover. */
apps: AppName[];
}
@@ -59,6 +62,7 @@ export const resolveAccess = async (req: express.Request): Promise<AdminAccess |
email: access.email,
displayName: access.displayName,
disabled: access.disabled,
permissions: access.permissions,
apps: access.apps
};
};
@@ -97,8 +101,12 @@ export const requireSignedIn: express.RequestHandler = async (req, res, next) =>
* 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): express.RequestHandler => {
export const requireAppAccess = (app: AppName, role?: AppRole): express.RequestHandler => {
return async (req, res, next) => {
try {
const access = await resolveAccess(req);
@@ -110,7 +118,15 @@ export const requireAppAccess = (app: AppName): express.RequestHandler => {
forbidden(res, 'Dieses Konto ist deaktiviert.');
return;
}
if (!access.apps.includes(app)) {
// 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;
}
+85 -3
View File
@@ -19,6 +19,87 @@ 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;
@@ -52,7 +133,7 @@ export interface PasskeyTable {
export interface UserAppPermissionTable {
user_id: string;
app: AppName;
role: string;
role: AppRole;
granted_by: string | null;
granted_at: Generated<Date>;
}
@@ -63,8 +144,9 @@ export interface InvitationTable {
email: string;
name: string;
token_hash: string;
// JSON column holding an AppName[].
apps: 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;
@@ -147,7 +147,7 @@ export const invitationsPlugin = () => {
return created;
});
await UsersService.setPermissions(user.id, invitation.apps, null);
await UsersService.setPermissions(user.id, invitation.permissions, null);
const session = await ctx.context.internalAdapter.createSession(user.id);
await setSessionCookie(ctx, {session, user});
@@ -1,7 +1,7 @@
import express, {Request, Response} from 'express';
import * as InvitationsService from './invitations.service.js';
import * as UsersService from '../users/users.admin.service.js';
import {isAppName, AppName} from '../admin.schema.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';
@@ -64,15 +64,23 @@ invitationsRouter.get('/', async (req: Request, res: Response) => {
* application/json:
* schema:
* type: object
* required: [email, name, apps]
* required: [email, name, permissions]
* properties:
* email:
* type: string
* name:
* type: string
* apps:
* 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:
@@ -86,10 +94,15 @@ invitationsRouter.post('/', async (req: Request, res: Response) => {
try {
const email = String(req.body?.email || '').trim().toLowerCase();
const name = String(req.body?.name || '').trim();
const apps: unknown = req.body?.apps;
if (!EMAIL_PATTERN.test(email) || name.length === 0 || !Array.isArray(apps) || !apps.every(isAppName)) {
res.status(400).send({status: 'BAD_REQUEST', message: 'E-Mail, Name und App-Liste sind erforderlich.'});
// 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;
}
@@ -107,7 +120,7 @@ invitationsRouter.post('/', async (req: Request, res: Response) => {
const invitation = await InvitationsService.createInvitation(
email,
name,
apps as AppName[],
permissions,
res.locals.admin.id
);
@@ -1,6 +1,6 @@
import * as crypto from 'crypto';
import {NachklangAdminDB} from '../Admin.db.js';
import {AppName, APP_NAMES, isAppName} from '../admin.schema.js';
import {AppPermission, isAppName, isAppPermission, ACCESS_ROLE} from '../admin.schema.js';
const db = NachklangAdminDB.db;
@@ -19,7 +19,7 @@ export interface OpenInvitation {
id: number;
email: string;
name: string;
apps: AppName[];
permissions: AppPermission[];
invitedBy: string | null;
createdAt: Date;
expiresAt: Date;
@@ -29,7 +29,7 @@ export interface AcceptableInvitation {
id: number;
email: string;
name: string;
apps: AppName[];
permissions: AppPermission[];
}
const hashToken = (token: string): string => {
@@ -41,11 +41,28 @@ const generateToken = (): string => {
return crypto.randomBytes(32).toString('base64url');
};
const parseApps = (value: unknown): AppName[] => {
// 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.
/**
* 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;
return Array.isArray(raw) ? raw.filter(isAppName) : [];
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 => {
@@ -61,12 +78,12 @@ const expiryFromNow = (): Date => {
export const createInvitation = async (
email: string,
name: string,
apps: AppName[],
permissions: AppPermission[],
invitedBy: string | null
): Promise<{id: number; token: string; expiresAt: Date}> => {
const token = generateToken();
const expiresAt = expiryFromNow();
const validApps = Array.from(new Set(apps)).filter(app => APP_NAMES.includes(app));
const valid = permissions.filter(isAppPermission);
const id = await db.transaction().execute(async trx => {
await trx
@@ -83,7 +100,7 @@ export const createInvitation = async (
email,
name,
token_hash: hashToken(token),
apps: JSON.stringify(validApps),
permissions: JSON.stringify(valid),
invited_by: invitedBy,
created_at: new Date(),
expires_at: expiresAt
@@ -105,7 +122,7 @@ export const createInvitation = async (
export const findByToken = async (token: string): Promise<AcceptableInvitation | null> => {
const row = await db
.selectFrom('invitations')
.select(['id', 'email', 'name', 'apps'])
.select(['id', 'email', 'name', 'permissions'])
.where('token_hash', '=', hashToken(token))
.where('accepted_at', 'is', null)
.where('revoked_at', 'is', null)
@@ -116,7 +133,7 @@ export const findByToken = async (token: string): Promise<AcceptableInvitation |
return null;
}
return {id: row.id, email: row.email, name: row.name, apps: parseApps(row.apps)};
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
@@ -149,7 +166,7 @@ export const unmarkAccepted = async (invitationId: number): Promise<void> => {
export const listOpenInvitations = async (): Promise<OpenInvitation[]> => {
const rows = await db
.selectFrom('invitations')
.select(['id', 'email', 'name', 'apps', 'invited_by', 'created_at', 'expires_at'])
.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())
@@ -160,7 +177,7 @@ export const listOpenInvitations = async (): Promise<OpenInvitation[]> => {
id: row.id,
email: row.email,
name: row.name,
apps: parseApps(row.apps),
permissions: parsePermissions(row.permissions),
invitedBy: row.invited_by,
createdAt: row.created_at,
expiresAt: row.expires_at
+23 -8
View File
@@ -1,6 +1,6 @@
import express, {Request, Response} from 'express';
import * as UsersService from './users.admin.service.js';
import {AppName, isAppName} from '../admin.schema.js';
import {toPermissions} from '../admin.schema.js';
import {sendServerError} from '../admin.errors.js';
export const usersAdminRouter = express.Router();
@@ -90,26 +90,40 @@ usersAdminRouter.get('/:userId', async (req: Request, res: Response) => {
* schema:
* type: object
* properties:
* apps:
* 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 name
* 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;
const apps: unknown = req.body?.apps;
if (!Array.isArray(apps) || !apps.every(isAppName)) {
res.status(400).send({status: 'BAD_REQUEST', message: 'Ungültige App-Liste.'});
// `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;
}
@@ -119,7 +133,8 @@ usersAdminRouter.put('/:userId/permissions', async (req: Request, res: Response)
}
const target = await UsersService.loadAccess(userId);
const losesAdmin = Boolean(target?.apps.includes('admin')) && !(apps as AppName[]).includes('admin');
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
@@ -130,7 +145,7 @@ usersAdminRouter.put('/:userId/permissions', async (req: Request, res: Response)
return;
}
const result = await UsersService.setPermissionsGuarded(userId, apps as AppName[], res.locals.admin.id);
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;
+115 -39
View File
@@ -1,6 +1,15 @@
import {Transaction} from 'kysely';
import {NachklangAdminDB} from '../Admin.db.js';
import {AdminDatabase, AppName, APP_NAMES} from '../admin.schema.js';
import {
AdminDatabase,
AppName,
AppPermission,
AppRole,
ACCESS_ROLE,
appsOf,
isAppName,
isAppRole
} from '../admin.schema.js';
const db = NachklangAdminDB.db;
@@ -18,6 +27,10 @@ export interface UserAccess {
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[];
}
@@ -27,6 +40,7 @@ export interface UserListEntry {
id: string;
email: string;
name: string;
permissions: AppPermission[];
apps: AppName[];
status: UserStatus;
createdAt: Date;
@@ -61,7 +75,8 @@ export const loadAccess = async (userId: string): Promise<UserAccess | null> =>
'user.email as email',
'user.name as name',
'user.disabled as disabled',
'user_app_permissions.app as app'
'user_app_permissions.app as app',
'user_app_permissions.role as role'
])
.execute();
@@ -69,16 +84,32 @@ export const loadAccess = async (userId: string): Promise<UserAccess | null> =>
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),
apps: rows.map(row => row.app).filter((app): app is AppName => app !== null)
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')
@@ -88,7 +119,7 @@ export const listUsers = async (): Promise<UserListEntry[]> => {
const permissions = await db
.selectFrom('user_app_permissions')
.select(['user_id', 'app'])
.select(['user_id', 'app', 'role'])
.execute();
// Last sign-in is derived from the newest session rather than stored: a
@@ -107,26 +138,33 @@ export const listUsers = async (): Promise<UserListEntry[]> => {
.groupBy('userId')
.execute();
const appsByUser = new Map<string, AppName[]>();
const permissionsByUser = new Map<string, AppPermission[]>();
for (const row of permissions) {
const apps = appsByUser.get(row.user_id) || [];
apps.push(row.app);
appsByUser.set(row.user_id, apps);
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 => ({
return users.map(user => {
const held = permissionsByUser.get(user.id) || [];
return {
id: user.id,
email: user.email,
name: user.name,
apps: appsByUser.get(user.id) || [],
status: user.disabled ? 'deaktiviert' : 'aktiv',
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> => {
@@ -141,7 +179,11 @@ export const getUserDetail = async (userId: string): Promise<UserDetail | null>
}
const [permissions, sessions, passkeys] = await Promise.all([
db.selectFrom('user_app_permissions').select('app').where('user_id', '=', userId).execute(),
db
.selectFrom('user_app_permissions')
.select(['app', 'role'])
.where('user_id', '=', userId)
.execute(),
db
.selectFrom('session')
.select(['id', 'createdAt', 'expiresAt', 'ipAddress', 'userAgent'])
@@ -156,11 +198,14 @@ export const getUserDetail = async (userId: string): Promise<UserDetail | null>
.executeTakeFirst()
]);
const held = toPermissionRows(permissions);
return {
id: user.id,
email: user.email,
name: user.name,
apps: permissions.map(row => row.app),
permissions: held,
apps: appsOf(held),
status: user.disabled ? 'deaktiviert' : 'aktiv',
createdAt: user.createdAt,
lastSignInAt: sessions.length > 0 ? sessions[0].createdAt : null,
@@ -174,25 +219,51 @@ export const getUserDetail = async (userId: string): Promise<UserDetail | null>
* 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,
apps: AppName[],
permissions: AppPermission[],
grantedBy: string | null
): Promise<void> => {
const unique = Array.from(new Set(apps)).filter(app => APP_NAMES.includes(app));
const valid = validPermissions(permissions);
await db.transaction().execute(async trx => {
await trx.deleteFrom('user_app_permissions').where('user_id', '=', userId).execute();
if (unique.length > 0) {
if (valid.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()
})))
.values(permissionRows(userId, valid, grantedBy))
.execute();
}
});
@@ -217,7 +288,11 @@ const countActiveAdminsForUpdate = async (trx: Transaction<AdminDatabase>): Prom
.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'))
// 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();
@@ -230,10 +305,11 @@ const countActiveAdminsForUpdate = async (trx: Transaction<AdminDatabase>): Prom
*/
export const setPermissionsGuarded = async (
userId: string,
apps: AppName[],
permissions: AppPermission[],
grantedBy: string | null
): Promise<LastAdminGuardResult> => {
const unique = Array.from(new Set(apps)).filter(app => APP_NAMES.includes(app));
const valid = validPermissions(permissions);
const keepsAdmin = valid.some(permission => permission.app === 'admin');
return db.transaction().execute(async trx => {
const target = await trx
@@ -242,25 +318,20 @@ export const setPermissionsGuarded = async (
.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) && !unique.includes('admin');
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 (unique.length > 0) {
if (valid.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()
})))
.values(permissionRows(userId, valid, grantedBy))
.execute();
}
@@ -281,6 +352,7 @@ export const disableUserGuarded = async (userId: string): Promise<LastAdminGuard
.where('user_app_permissions.app', '=', 'admin')
.where('user.disabled', '=', false)
.select('user_app_permissions.user_id')
.limit(1)
.forUpdate()
.executeTakeFirst();
@@ -298,12 +370,16 @@ export const disableUserGuarded = async (userId: string): Promise<LastAdminGuard
export const grantPermission = async (
userId: string,
app: AppName,
grantedBy: string | null
grantedBy: string | null,
role: AppRole = ACCESS_ROLE
): Promise<void> => {
await db
.insertInto('user_app_permissions')
.values({user_id: userId, app, role: 'admin', granted_by: grantedBy, granted_at: new Date()})
.onDuplicateKeyUpdate({role: 'admin'})
.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();
};
+6 -1
View File
@@ -79,7 +79,12 @@ describe('bootstrapAdmin', () => {
await bootstrapAdmin();
expect(createInvitation).toHaveBeenCalledWith('boss@nachklang.art', 'Nachklang Admin', ['admin'], null);
expect(createInvitation).toHaveBeenCalledWith(
'boss@nachklang.art',
'Nachklang Admin',
[{app: 'admin', role: 'access'}],
null
);
expect(mockMail).toHaveBeenCalled();
});
+109
View File
@@ -0,0 +1,109 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
// admin.config calls dotenv.config(), which would read the repo's own .env and
// quietly reintroduce NODE_ENV=development - the exact value several of these
// cases exist to remove. Stub it so the tests see only what they set.
vi.mock('dotenv', () => ({config: vi.fn()}));
/**
* admin.config reads the environment once at import, so every case here has to
* reset the module registry and re-import it. The two things worth pinning are
* the ones that are silent when wrong: which client-IP header is trusted, and
* whether an unset NODE_ENV counts as production.
*/
const ORIGINAL_ENV = {...process.env};
const loadConfig = async () => {
vi.resetModules();
return import('../../src/models/admin/admin.config.js');
};
beforeEach(() => {
process.env = {...ORIGINAL_ENV};
// dotenv.config() in admin.config does not overwrite what is already set,
// so setting these here is enough to keep the local .env out of the test.
process.env.NODE_ENV = 'test';
delete process.env.CLIENT_IP_HEADERS;
delete process.env.TRUSTED_PROXY_IPS;
});
afterEach(() => {
process.env = {...ORIGINAL_ENV};
});
describe('CLIENT_IP_HEADERS', () => {
it('defaults to the single header Plesk nginx sets', async () => {
const config = await loadConfig();
expect(config.CLIENT_IP_HEADERS).toEqual(['x-real-ip']);
expect(config.TRUST_NO_CLIENT_IP_HEADER).toBe(false);
});
it('reads a comma-separated list', async () => {
process.env.CLIENT_IP_HEADERS = 'x-real-ip, cf-connecting-ip';
const config = await loadConfig();
expect(config.CLIENT_IP_HEADERS).toEqual(['x-real-ip', 'cf-connecting-ip']);
});
it('trusts nothing when set to "none"', async () => {
// The escape hatch. An empty list is what better-auth reads as "no
// headers" - it only falls back to its own default when the option is
// absent - so this really does stop any header being believed.
process.env.CLIENT_IP_HEADERS = 'none';
const config = await loadConfig();
expect(config.CLIENT_IP_HEADERS).toEqual([]);
expect(config.TRUST_NO_CLIENT_IP_HEADER).toBe(true);
});
it('accepts the hatch case-insensitively and with stray whitespace', async () => {
process.env.CLIENT_IP_HEADERS = ' NONE ';
const config = await loadConfig();
expect(config.CLIENT_IP_HEADERS).toEqual([]);
});
it('treats an empty value as "use the default", not as the hatch', async () => {
// A blank line in a .env must not silently change how requests are
// bucketed - only the explicit word does that.
process.env.CLIENT_IP_HEADERS = '';
const config = await loadConfig();
expect(config.CLIENT_IP_HEADERS).toEqual(['x-real-ip']);
expect(config.TRUST_NO_CLIENT_IP_HEADER).toBe(false);
});
it('does not mistake a header actually named none-ish for the hatch', async () => {
process.env.CLIENT_IP_HEADERS = 'x-none';
const config = await loadConfig();
expect(config.CLIENT_IP_HEADERS).toEqual(['x-none']);
expect(config.TRUST_NO_CLIENT_IP_HEADER).toBe(false);
});
});
describe('isProd', () => {
it('is false only for the explicit relaxed environments', async () => {
process.env.NODE_ENV = 'development';
expect((await loadConfig()).isProd).toBe(false);
process.env.NODE_ENV = 'test';
expect((await loadConfig()).isProd).toBe(false);
});
it('treats an unset NODE_ENV as production, which is what a bare vhost gives', async () => {
delete process.env.NODE_ENV;
// Strict mode refuses to boot without these; supply them so the import
// gets far enough to answer the question being asked.
process.env.BETTER_AUTH_SECRET = 'x'.repeat(48);
process.env.API_BASE_URL = 'https://api.nachklang.art';
process.env.ADMIN_APP_URL = 'https://admin.nachklang.art';
expect((await loadConfig()).isProd).toBe(true);
});
it('refuses to start without a signing key outside development', async () => {
delete process.env.NODE_ENV;
delete process.env.BETTER_AUTH_SECRET;
process.env.API_BASE_URL = 'https://api.nachklang.art';
process.env.ADMIN_APP_URL = 'https://admin.nachklang.art';
await expect(loadConfig()).rejects.toThrow(/BETTER_AUTH_SECRET/);
});
});
+49 -1
View File
@@ -30,6 +30,10 @@ const activeUser = {
email: 'a@nachklang.art',
displayName: 'A',
disabled: false,
permissions: [
{app: 'feedback', role: 'access'},
{app: 'admin', role: 'access'}
],
apps: ['feedback', 'admin']
};
@@ -60,6 +64,10 @@ describe('resolveAccess', () => {
email: 'a@nachklang.art',
displayName: 'A',
disabled: false,
permissions: [
{app: 'feedback', role: 'access'},
{app: 'admin', role: 'access'}
],
apps: ['feedback', 'admin']
});
// No cookieCache: exactly one lookup per request, never zero.
@@ -127,7 +135,11 @@ describe('requireAppAccess', () => {
it('403s a signed-in user without that app permission', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue({...activeUser, apps: ['feedback']});
mockLoadAccess.mockResolvedValue({
...activeUser,
permissions: [{app: 'feedback', role: 'access'}],
apps: ['feedback']
});
const res = makeRes();
const next = vi.fn();
@@ -171,4 +183,40 @@ describe('requireAppAccess', () => {
expect(res.status).toHaveBeenCalledWith(500);
expect(next).not.toHaveBeenCalled();
});
// The seam a finer per-app permission arrives through. Nothing passes a role
// today, so these two pin the behaviour before there is anything to break.
it('403s when a specific role is required and the user only holds another', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue({
...activeUser,
permissions: [{app: 'tickets', role: 'access'}],
apps: ['tickets']
});
const res = makeRes();
const next = vi.fn();
await requireAppAccess('tickets', 'refund')(makeReq(), res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(next).not.toHaveBeenCalled();
});
it('passes when the user holds exactly the required role', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue({
...activeUser,
permissions: [
{app: 'tickets', role: 'access'},
{app: 'tickets', role: 'refund'}
],
apps: ['tickets']
});
const res = makeRes();
const next = vi.fn();
await requireAppAccess('tickets', 'refund')(makeReq(), res, next);
expect(next).toHaveBeenCalled();
});
});
+99
View File
@@ -0,0 +1,99 @@
import {describe, expect, it} from 'vitest';
import {
ACCESS_ROLE,
appsOf,
isAppPermission,
isAppRole,
toPermissions
} from '../../src/models/admin/admin.schema.js';
/**
* The permission model is (app, role). These tests pin the two properties the
* rest of the module leans on: that the older `['tickets']` shape still means
* "tickets at the access role", and that nothing outside APP_ROLES gets in.
*/
describe('toPermissions', () => {
it('reads the full (app, role) form', () => {
expect(toPermissions([{app: 'tickets', role: 'access'}])).toEqual([
{app: 'tickets', role: 'access'}
]);
});
it('reads a plain app list as that app at the access role', () => {
expect(toPermissions(['feedback', 'admin'])).toEqual([
{app: 'feedback', role: ACCESS_ROLE},
{app: 'admin', role: ACCESS_ROLE}
]);
});
it('accepts the two forms mixed, which is what a half-migrated caller sends', () => {
expect(toPermissions(['feedback', {app: 'tickets', role: 'access'}])).toEqual([
{app: 'feedback', role: ACCESS_ROLE},
{app: 'tickets', role: ACCESS_ROLE}
]);
});
it('drops duplicates of the same (app, role)', () => {
expect(toPermissions(['tickets', {app: 'tickets', role: 'access'}])).toEqual([
{app: 'tickets', role: ACCESS_ROLE}
]);
});
it('rejects rather than silently dropping an unknown app', () => {
// Silently ignoring it would let "grant calendar + nonsense" look like a
// success while granting less than the caller asked for.
expect(toPermissions(['calendar', 'nonsense'])).toBeNull();
});
it('rejects an unknown role', () => {
expect(toPermissions([{app: 'tickets', role: 'refund'}])).toBeNull();
});
it('rejects anything that is not a list', () => {
expect(toPermissions('admin')).toBeNull();
expect(toPermissions(null)).toBeNull();
expect(toPermissions({app: 'admin', role: 'access'})).toBeNull();
});
it('reads an empty list as "no permissions", not as invalid', () => {
expect(toPermissions([])).toEqual([]);
});
});
describe('isAppRole', () => {
it('accepts the access role for every app', () => {
expect(isAppRole('admin', ACCESS_ROLE)).toBe(true);
expect(isAppRole('calendar', ACCESS_ROLE)).toBe(true);
});
it('rejects a role that does not exist yet', () => {
expect(isAppRole('tickets', 'refund')).toBe(false);
});
});
describe('isAppPermission', () => {
it('needs both halves to be valid', () => {
expect(isAppPermission({app: 'tickets', role: ACCESS_ROLE})).toBe(true);
expect(isAppPermission({app: 'tickets'})).toBe(false);
expect(isAppPermission({role: ACCESS_ROLE})).toBe(false);
expect(isAppPermission(null)).toBe(false);
});
});
describe('appsOf', () => {
it('collapses several roles on one app to a single entry', () => {
// The point of the derived list: a user with two roles on tickets has
// access to tickets once, not twice.
const apps = appsOf([
{app: 'tickets', role: ACCESS_ROLE},
{app: 'tickets', role: 'future-role'},
{app: 'admin', role: ACCESS_ROLE}
]);
expect(apps).toEqual(['tickets', 'admin']);
});
it('is empty for no permissions', () => {
expect(appsOf([])).toEqual([]);
});
});
+36 -2
View File
@@ -97,7 +97,37 @@ describe('PUT /admin/users/:id/permissions', () => {
const res = await request(makeApp('me')).put('/admin/users/other/permissions').send({apps: ['tickets']});
expect(res.status).toBe(200);
expect(service.setPermissionsGuarded).toHaveBeenCalledWith('other', ['tickets'], 'me');
expect(service.setPermissionsGuarded).toHaveBeenCalledWith(
'other',
[{app: 'tickets', role: 'access'}],
'me'
);
});
it('accepts the richer {permissions} body', async () => {
service.loadAccess.mockResolvedValue({id: 'other', disabled: false, apps: []});
const res = await request(makeApp('me'))
.put('/admin/users/other/permissions')
.send({permissions: [{app: 'tickets', role: 'access'}]});
expect(res.status).toBe(200);
expect(service.setPermissionsGuarded).toHaveBeenCalledWith(
'other',
[{app: 'tickets', role: 'access'}],
'me'
);
});
it('rejects a role that does not exist', async () => {
service.loadAccess.mockResolvedValue({id: 'other', disabled: false, apps: []});
const res = await request(makeApp('me'))
.put('/admin/users/other/permissions')
.send({permissions: [{app: 'tickets', role: 'refund'}]});
expect(res.status).toBe(400);
expect(service.setPermissionsGuarded).not.toHaveBeenCalled();
});
it('allows granting permissions to someone who has none', async () => {
@@ -106,7 +136,11 @@ describe('PUT /admin/users/:id/permissions', () => {
const res = await request(makeApp('me')).put('/admin/users/other/permissions').send({apps: ['feedback', 'tickets']});
expect(res.status).toBe(200);
expect(service.setPermissionsGuarded).toHaveBeenCalledWith('other', ['feedback', 'tickets'], 'me');
expect(service.setPermissionsGuarded).toHaveBeenCalledWith(
'other',
[{app: 'feedback', role: 'access'}, {app: 'tickets', role: 'access'}],
'me'
);
});
});
+9 -8
View File
@@ -9,7 +9,8 @@ import {
createAndAcceptInvitation,
resetDatabase,
sessionCookieFrom,
SESSION_COOKIE
SESSION_COOKIE,
accessTo
} from './helpers.js';
/**
@@ -66,7 +67,7 @@ describe('invitation acceptance', () => {
});
it('sets the session cookie under the configured prefix', async () => {
const invitation = await InvitationsService.createInvitation('b@nachklang.art', 'B', ['feedback'], null);
const invitation = await InvitationsService.createInvitation('b@nachklang.art', 'B', accessTo('feedback'), null);
const res = await request(app)
.post('/admin/auth/invitations/accept')
@@ -89,7 +90,7 @@ describe('invitation acceptance', () => {
});
it('previews an invitation without revealing the granted apps', async () => {
const invitation = await InvitationsService.createInvitation('d@nachklang.art', 'D', ['admin'], null);
const invitation = await InvitationsService.createInvitation('d@nachklang.art', 'D', accessTo('admin'), null);
const res = await request(app)
.post('/admin/auth/invitations/preview')
@@ -100,7 +101,7 @@ describe('invitation acceptance', () => {
});
it('answers an unknown token exactly like an expired one', async () => {
const invitation = await InvitationsService.createInvitation('e@nachklang.art', 'E', ['feedback'], null);
const invitation = await InvitationsService.createInvitation('e@nachklang.art', 'E', accessTo('feedback'), null);
await InvitationsService.revokeInvitation(invitation.id);
const unknown = await request(app).post('/admin/auth/invitations/preview').send({token: 'no-such-token'});
@@ -111,7 +112,7 @@ describe('invitation acceptance', () => {
});
it('cannot be redeemed twice', async () => {
const invitation = await InvitationsService.createInvitation('f@nachklang.art', 'F', ['feedback'], null);
const invitation = await InvitationsService.createInvitation('f@nachklang.art', 'F', accessTo('feedback'), null);
const first = await request(app)
.post('/admin/auth/invitations/accept')
@@ -125,7 +126,7 @@ describe('invitation acceptance', () => {
});
it('rejects an expired invitation', async () => {
const invitation = await InvitationsService.createInvitation('g@nachklang.art', 'G', ['feedback'], null);
const invitation = await InvitationsService.createInvitation('g@nachklang.art', 'G', accessTo('feedback'), null);
// Reach past the service to age it: there is deliberately no API for this.
const {NachklangAdminDB} = await import('../../src/models/admin/Admin.db.js');
await NachklangAdminDB.db
@@ -142,7 +143,7 @@ describe('invitation acceptance', () => {
});
it('rejects a password below the minimum length', async () => {
const invitation = await InvitationsService.createInvitation('h@nachklang.art', 'H', ['feedback'], null);
const invitation = await InvitationsService.createInvitation('h@nachklang.art', 'H', accessTo('feedback'), null);
const res = await request(app)
.post('/admin/auth/invitations/accept')
@@ -259,7 +260,7 @@ describe('origin checks', () => {
describe('the session cookie is not readable by scripts', () => {
it('is HttpOnly and SameSite=Lax', async () => {
const invitation = await InvitationsService.createInvitation('r@nachklang.art', 'R', ['feedback'], null);
const invitation = await InvitationsService.createInvitation('r@nachklang.art', 'R', accessTo('feedback'), null);
const res = await request(app)
.post('/admin/auth/invitations/accept')
.send({token: invitation.token, password: 'devpassword123'});
+3 -3
View File
@@ -5,7 +5,7 @@ import {createApp} from '../../src/app.factory.js';
import * as InvitationsService from '../../src/models/admin/invitations/invitations.service.js';
import * as UsersService from '../../src/models/admin/users/users.admin.service.js';
import {bootstrapAdmin} from '../../src/models/admin/admin.bootstrap.js';
import {closeDatabase, createAndAcceptInvitation, resetDatabase} from './helpers.js';
import {accessTo, closeDatabase, createAndAcceptInvitation, resetDatabase} from './helpers.js';
let app: Application;
@@ -185,7 +185,7 @@ describe('invitations', () => {
it('invalidates the previous link on resend', async () => {
const {agent} = await signedInAdmin();
const original = await InvitationsService.createInvitation('new@nachklang.art', 'New', ['feedback'], null);
const original = await InvitationsService.createInvitation('new@nachklang.art', 'New', accessTo('feedback'), null);
const resent = await agent.post(`/admin/invitations/${original.id}/resend`);
expect(resent.status).toBe(200);
@@ -198,7 +198,7 @@ describe('invitations', () => {
it('revokes an invitation', async () => {
const {agent} = await signedInAdmin();
const invitation = await InvitationsService.createInvitation('new@nachklang.art', 'New', ['feedback'], null);
const invitation = await InvitationsService.createInvitation('new@nachklang.art', 'New', accessTo('feedback'), null);
expect((await agent.delete(`/admin/invitations/${invitation.id}`)).status).toBe(204);
expect((await agent.delete(`/admin/invitations/${invitation.id}`)).status).toBe(404);
+12 -3
View File
@@ -3,7 +3,7 @@ import type {Application} from 'express';
import request from 'supertest';
import {NachklangAdminDB} from '../../src/models/admin/Admin.db.js';
import * as InvitationsService from '../../src/models/admin/invitations/invitations.service.js';
import {AppName} from '../../src/models/admin/admin.schema.js';
import {ACCESS_ROLE, AppName, AppPermission, toPermissions} from '../../src/models/admin/admin.schema.js';
const db = NachklangAdminDB.db;
@@ -44,10 +44,13 @@ export const createAndAcceptInvitation = async (
app: Application,
email: string,
name: string,
apps: AppName[],
// Takes the shorthand as well as the full form: most tests only care that
// someone can open an app, and `['tickets']` says that with less noise.
grants: (AppName | AppPermission)[],
password = 'devpassword123'
) => {
const invitation = await InvitationsService.createInvitation(email, name, apps, null);
const permissions = toPermissions(grants) ?? [];
const invitation = await InvitationsService.createInvitation(email, name, permissions, null);
const agent = request.agent(app);
const res = await agent
@@ -66,3 +69,9 @@ export const cookieHeader = (res: request.Response): string[] => {
export const sessionCookieFrom = (res: request.Response): string | undefined => {
return cookieHeader(res).find(cookie => cookie.startsWith(SESSION_COOKIE));
};
/** `accessTo('feedback')` reads better than the (app, role) literal in tests
* that only care that someone can open an app. */
export const accessTo = (...apps: AppName[]): AppPermission[] => {
return apps.map(app => ({app, role: ACCESS_ROLE}));
};