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

Introduces src/models/admin/, a dedicated identity and permissions module on
its own nachklang_admin database, and the shared authenticator that feedback
and tickets will move onto in the cutover step. Nothing swaps over yet:
feedback.auth.ts and tickets.auth.ts still authenticate against the legacy
calendar sessions, so production behaviour is unchanged.

- better-auth 1.7 mounted at /admin/auth/*, sessions as httpOnly cookies
  scoped to .nachklang.art so one sign-in covers every *.nachklang.art app.
- Accounts are invite-only: public sign-up is disabled, and the invitations
  plugin is the only code that creates users. Tokens are stored as SHA-256
  hashes and travel in the request body, never in a URL.
- Per-app permissions in user_app_permissions; requireAppAccess(app) queries
  the database on every request (no cookie cache) so disabling a user or
  revoking a session takes effect immediately.
- ADMIN_BOOTSTRAP_EMAIL guarantees a way in on an empty database, idempotently
  and without crashing the API if the database is unreachable at boot.
- Guards prevent an admin from removing their own admin permission, disabling
  themselves, or stripping the last active admin.

The admin pool uses the callback-style mysql2, not mysql2/promise: Kysely's
MysqlDialect drives the pool with callbacks, and the promise wrapper ignores
them, so every query hangs silently. Only the integration tests caught this.

Schema in sql/admin/001_init.sql, derived from getAuthTables() on the
installed better-auth rather than the published CLI, which lags the library
and omits account.issuer.

app.ts is split into src/app.factory.ts so the integration tests drive the
real middleware order rather than a copy of it.

Tests: 131 unit, plus 41 integration tests against a throwaway MariaDB
started by test/integration/setup.ts (docker or podman).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-05 18:03:08 +02:00
parent bf7f45acce
commit 7aac07a013
37 changed files with 5620 additions and 320 deletions
+91
View File
@@ -0,0 +1,91 @@
import {vi, describe, it, expect, beforeEach, type Mock} from 'vitest';
vi.mock('../../src/models/admin/users/users.admin.service.js', () => ({
countActiveAdmins: vi.fn(),
findUserByEmail: vi.fn(),
grantPermission: vi.fn()
}));
vi.mock('../../src/models/admin/invitations/invitations.service.js', () => ({
hasOpenInvitationFor: vi.fn(),
createInvitation: vi.fn()
}));
vi.mock('../../src/models/admin/admin.mail.js', () => ({
sendInvitationMail: vi.fn()
}));
vi.mock('../../src/models/admin/admin.config.js', () => ({
ADMIN_BOOTSTRAP_EMAIL: 'boss@nachklang.art',
ADMIN_APP_URL: 'http://localhost:3002',
isProd: false
}));
import * as UsersService from '../../src/models/admin/users/users.admin.service.js';
import * as InvitationsService from '../../src/models/admin/invitations/invitations.service.js';
import {sendInvitationMail} from '../../src/models/admin/admin.mail.js';
import {bootstrapAdmin} from '../../src/models/admin/admin.bootstrap.js';
const countActiveAdmins = UsersService.countActiveAdmins as Mock;
const findUserByEmail = UsersService.findUserByEmail as Mock;
const grantPermission = UsersService.grantPermission as Mock;
const hasOpenInvitationFor = InvitationsService.hasOpenInvitationFor as Mock;
const createInvitation = InvitationsService.createInvitation as Mock;
const mockMail = sendInvitationMail as Mock;
beforeEach(() => {
countActiveAdmins.mockReset();
findUserByEmail.mockReset();
grantPermission.mockReset();
hasOpenInvitationFor.mockReset();
createInvitation.mockReset();
mockMail.mockReset();
mockMail.mockResolvedValue(true);
createInvitation.mockResolvedValue({id: 1, token: 'raw-token', expiresAt: new Date()});
});
describe('bootstrapAdmin', () => {
it('does nothing when an active admin already exists', async () => {
countActiveAdmins.mockResolvedValue(1);
await bootstrapAdmin();
expect(createInvitation).not.toHaveBeenCalled();
expect(grantPermission).not.toHaveBeenCalled();
});
it('grants admin directly when the bootstrap address is already a user', async () => {
countActiveAdmins.mockResolvedValue(0);
findUserByEmail.mockResolvedValue({id: 'u9', email: 'boss@nachklang.art'});
await bootstrapAdmin();
expect(grantPermission).toHaveBeenCalledWith('u9', 'admin', null);
expect(createInvitation).not.toHaveBeenCalled();
});
it('does not re-invite (or re-mail) while an open invitation exists', async () => {
countActiveAdmins.mockResolvedValue(0);
findUserByEmail.mockResolvedValue(null);
hasOpenInvitationFor.mockResolvedValue(true);
await bootstrapAdmin();
expect(createInvitation).not.toHaveBeenCalled();
expect(mockMail).not.toHaveBeenCalled();
});
it('invites with the admin permission when there is nothing to work with', async () => {
countActiveAdmins.mockResolvedValue(0);
findUserByEmail.mockResolvedValue(null);
hasOpenInvitationFor.mockResolvedValue(false);
await bootstrapAdmin();
expect(createInvitation).toHaveBeenCalledWith('boss@nachklang.art', 'Nachklang Admin', ['admin'], null);
expect(mockMail).toHaveBeenCalled();
});
it('never throws when the database is unreachable at boot', async () => {
countActiveAdmins.mockRejectedValue(new Error('connect ECONNREFUSED'));
await expect(bootstrapAdmin()).resolves.toBeUndefined();
});
});
+71
View File
@@ -0,0 +1,71 @@
import {vi, describe, it, expect, beforeEach, type Mock} from 'vitest';
vi.mock('../../src/common/common.mail.js', () => ({
MailService: {sendMail: vi.fn()}
}));
vi.mock('../../src/models/admin/admin.config.js', () => ({
ADMIN_APP_URL: 'https://admin.nachklang.art'
}));
import {MailService} from '../../src/common/common.mail.js';
import {sendInvitationMail, sendPasswordResetMail} from '../../src/models/admin/admin.mail.js';
const sendMail = MailService.sendMail as Mock;
beforeEach(() => {
sendMail.mockReset();
sendMail.mockResolvedValue(true);
});
describe('sendInvitationMail', () => {
it('points at the admin app and carries the token in the query string', async () => {
await sendInvitationMail('a@nachklang.art', 'Anna', 'tok-en_123', new Date('2026-09-12T10:00:00Z'));
const [to, subject, text, options] = sendMail.mock.calls[0];
expect(to).toBe('a@nachklang.art');
expect(subject).toBeTruthy();
expect(text).toContain('https://admin.nachklang.art/accept-invite?token=tok-en_123');
expect(options.html).toContain('https://admin.nachklang.art/accept-invite?token=tok-en_123');
});
it('url-encodes a token containing url-significant characters', async () => {
await sendInvitationMail('a@nachklang.art', 'Anna', 'a+b/c=d', new Date());
const [, , text] = sendMail.mock.calls[0];
expect(text).toContain('token=a%2Bb%2Fc%3Dd');
});
it('sends both a text and an html part', async () => {
await sendInvitationMail('a@nachklang.art', 'Anna', 'tok', new Date());
const [, , text, options] = sendMail.mock.calls[0];
expect(text.length).toBeGreaterThan(0);
expect(options.html).toContain('<html');
});
it('escapes a name that contains html', async () => {
await sendInvitationMail('a@nachklang.art', '<script>alert(1)</script>', 'tok', new Date());
const [, , , options] = sendMail.mock.calls[0];
expect(options.html).not.toContain('<script>');
expect(options.html).toContain('&lt;script&gt;');
});
it('reports a delivery failure to the caller rather than throwing', async () => {
sendMail.mockResolvedValue(false);
await expect(sendInvitationMail('a@nachklang.art', 'Anna', 'tok', new Date())).resolves.toBe(false);
});
});
describe('sendPasswordResetMail', () => {
it('uses the url better-auth generated, unchanged', async () => {
const url = 'https://api.nachklang.art/admin/auth/reset-password/abc?callbackURL=x';
await sendPasswordResetMail('a@nachklang.art', 'Anna', url);
const [, , text, options] = sendMail.mock.calls[0];
expect(text).toContain(url);
expect(options.html).toContain('https://api.nachklang.art/admin/auth/reset-password/abc');
});
});
+174
View File
@@ -0,0 +1,174 @@
import {vi, describe, it, expect, beforeEach, type Mock} from 'vitest';
import {Request, Response} from 'express';
vi.mock('../../src/models/admin/admin.auth.js', () => ({
auth: {api: {getSession: vi.fn()}}
}));
vi.mock('../../src/models/admin/users/users.admin.service.js', () => ({
loadAccess: vi.fn()
}));
import {auth} from '../../src/models/admin/admin.auth.js';
import * as UsersService from '../../src/models/admin/users/users.admin.service.js';
import {requireAppAccess, requireSignedIn, resolveAccess} from '../../src/models/admin/admin.middleware.js';
const mockGetSession = auth.api.getSession as unknown as Mock;
const mockLoadAccess = UsersService.loadAccess as Mock;
const makeReq = (): Request => ({headers: {cookie: 'nachklang.session_token=abc'}} as unknown as Request);
const makeRes = (): Response => {
const res: any = {};
res.status = vi.fn().mockReturnValue(res);
res.send = vi.fn().mockReturnValue(res);
res.locals = {};
return res as Response;
};
const activeUser = {
id: 'u1',
email: 'a@nachklang.art',
displayName: 'A',
disabled: false,
apps: ['feedback', 'admin']
};
describe('resolveAccess', () => {
beforeEach(() => {
mockGetSession.mockReset();
mockLoadAccess.mockReset();
});
it('returns null without a valid session', async () => {
mockGetSession.mockResolvedValue(null);
expect(await resolveAccess(makeReq())).toBeNull();
expect(mockLoadAccess).not.toHaveBeenCalled();
});
it('returns null when the session points at a user row that is gone', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue(null);
expect(await resolveAccess(makeReq())).toBeNull();
});
it('resolves identity and permissions in a single permission query', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue(activeUser);
expect(await resolveAccess(makeReq())).toEqual({
id: 'u1',
email: 'a@nachklang.art',
displayName: 'A',
disabled: false,
apps: ['feedback', 'admin']
});
// No cookieCache: exactly one lookup per request, never zero.
expect(mockLoadAccess).toHaveBeenCalledTimes(1);
});
});
describe('requireSignedIn', () => {
beforeEach(() => {
mockGetSession.mockReset();
mockLoadAccess.mockReset();
});
it('401s without a session', async () => {
mockGetSession.mockResolvedValue(null);
const res = makeRes();
const next = vi.fn();
await requireSignedIn(makeReq(), res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(next).not.toHaveBeenCalled();
});
it('403s a disabled user that still holds a valid cookie', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue({...activeUser, disabled: true});
const res = makeRes();
const next = vi.fn();
await requireSignedIn(makeReq(), res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(next).not.toHaveBeenCalled();
});
it('admits a signed-in user with no app permissions at all', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue({...activeUser, apps: []});
const res = makeRes();
const next = vi.fn();
await requireSignedIn(makeReq(), res, next);
expect(next).toHaveBeenCalled();
expect(res.locals.admin.apps).toEqual([]);
});
});
describe('requireAppAccess', () => {
beforeEach(() => {
mockGetSession.mockReset();
mockLoadAccess.mockReset();
});
it('401s without a session', async () => {
mockGetSession.mockResolvedValue(null);
const res = makeRes();
const next = vi.fn();
await requireAppAccess('feedback')(makeReq(), res, next);
expect(res.status).toHaveBeenCalledWith(401);
});
it('403s a signed-in user without that app permission', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue({...activeUser, apps: ['feedback']});
const res = makeRes();
const next = vi.fn();
await requireAppAccess('tickets')(makeReq(), res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(next).not.toHaveBeenCalled();
});
it('403s a disabled user even when they hold the permission', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue({...activeUser, disabled: true});
const res = makeRes();
const next = vi.fn();
await requireAppAccess('feedback')(makeReq(), res, next);
expect(res.status).toHaveBeenCalledWith(403);
});
it('passes through and exposes the identity the feedback/tickets services expect', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue(activeUser);
const res = makeRes();
const next = vi.fn();
await requireAppAccess('feedback')(makeReq(), res, next);
expect(next).toHaveBeenCalled();
expect(res.locals.admin).toMatchObject({id: 'u1', email: 'a@nachklang.art', displayName: 'A'});
});
it('500s (never allows through) when the permission query throws', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockRejectedValue(new Error('db down'));
const res = makeRes();
const next = vi.fn();
await requireAppAccess('feedback')(makeReq(), res, next);
expect(res.status).toHaveBeenCalledWith(500);
expect(next).not.toHaveBeenCalled();
});
});
+165
View File
@@ -0,0 +1,165 @@
import {vi, describe, it, expect, beforeEach, type Mock} from 'vitest';
import express from 'express';
import request from 'supertest';
vi.mock('../../src/models/admin/users/users.admin.service.js', () => ({
listUsers: vi.fn(),
getUserDetail: vi.fn(),
loadAccess: vi.fn(),
setPermissions: vi.fn(),
disableUser: vi.fn(),
enableUser: vi.fn(),
revokeSession: vi.fn(),
countActiveAdmins: vi.fn(),
userExists: vi.fn()
}));
import * as UsersService from '../../src/models/admin/users/users.admin.service.js';
import {usersAdminRouter} from '../../src/models/admin/users/users.admin.router.js';
const service = UsersService as unknown as Record<string, Mock>;
// The router always runs behind requireAppAccess('admin'), which is what puts
// res.locals.admin there; this stands in for it.
const makeApp = (callerId = 'me') => {
const app = express();
app.use(express.json());
app.use((req, res, next) => {
res.locals.admin = {id: callerId, email: 'me@nachklang.art', displayName: 'Me', apps: ['admin']};
next();
});
app.use('/admin/users', usersAdminRouter);
return app;
};
beforeEach(() => {
for (const fn of Object.values(service)) {
if (typeof fn?.mockReset === 'function') {
fn.mockReset();
}
}
service.getUserDetail.mockResolvedValue({id: 'other', apps: []});
service.userExists.mockResolvedValue(true);
});
describe('PUT /admin/users/:id/permissions', () => {
it('rejects an unknown app name', async () => {
const res = await request(makeApp()).put('/admin/users/other/permissions').send({apps: ['calendar', 'nope']});
expect(res.status).toBe(400);
expect(service.setPermissions).not.toHaveBeenCalled();
});
it('rejects a non-array body', async () => {
const res = await request(makeApp()).put('/admin/users/other/permissions').send({apps: 'admin'});
expect(res.status).toBe(400);
});
it('404s for an unknown user', async () => {
service.userExists.mockResolvedValue(false);
const res = await request(makeApp()).put('/admin/users/ghost/permissions').send({apps: []});
expect(res.status).toBe(404);
expect(service.setPermissions).not.toHaveBeenCalled();
});
it('refuses to remove the caller\'s own admin permission', async () => {
service.loadAccess.mockResolvedValue({id: 'me', disabled: false, apps: ['admin']});
service.countActiveAdmins.mockResolvedValue(5);
const res = await request(makeApp('me')).put('/admin/users/me/permissions').send({apps: ['feedback']});
expect(res.status).toBe(409);
expect(service.setPermissions).not.toHaveBeenCalled();
});
// Defence in depth: with the caller themselves being an active admin this
// count cannot actually reach 1 in production, but the guard is what makes
// that safe to rely on rather than to reason about.
it('refuses to remove the last remaining active admin', async () => {
service.loadAccess.mockResolvedValue({id: 'other', disabled: false, apps: ['admin']});
service.countActiveAdmins.mockResolvedValue(1);
const res = await request(makeApp('me')).put('/admin/users/other/permissions').send({apps: []});
expect(res.status).toBe(409);
expect(service.setPermissions).not.toHaveBeenCalled();
});
it('allows removing an admin while another active admin remains', async () => {
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']});
expect(res.status).toBe(200);
expect(service.setPermissions).toHaveBeenCalledWith('other', ['tickets'], 'me');
});
it('allows granting permissions to someone who has none', async () => {
service.loadAccess.mockResolvedValue({id: 'other', disabled: false, apps: []});
const res = await request(makeApp('me')).put('/admin/users/other/permissions').send({apps: ['feedback', 'tickets']});
expect(res.status).toBe(200);
// Nothing is being taken away, so the last-admin count is not consulted.
expect(service.countActiveAdmins).not.toHaveBeenCalled();
});
});
describe('POST /admin/users/:id/disable', () => {
it('refuses to disable the caller', async () => {
const res = await request(makeApp('me')).post('/admin/users/me/disable');
expect(res.status).toBe(409);
expect(service.disableUser).not.toHaveBeenCalled();
});
it('refuses to disable the last active admin', async () => {
service.loadAccess.mockResolvedValue({id: 'other', disabled: false, apps: ['admin']});
service.countActiveAdmins.mockResolvedValue(1);
const res = await request(makeApp('me')).post('/admin/users/other/disable');
expect(res.status).toBe(409);
expect(service.disableUser).not.toHaveBeenCalled();
});
it('disables a non-admin user', async () => {
service.loadAccess.mockResolvedValue({id: 'other', disabled: false, apps: ['feedback']});
const res = await request(makeApp('me')).post('/admin/users/other/disable');
expect(res.status).toBe(200);
expect(service.disableUser).toHaveBeenCalledWith('other');
});
it('404s for an unknown user', async () => {
service.loadAccess.mockResolvedValue(null);
const res = await request(makeApp('me')).post('/admin/users/ghost/disable');
expect(res.status).toBe(404);
});
});
describe('DELETE /admin/users/:id/sessions/:sid', () => {
it('404s when the session does not belong to that user', async () => {
service.revokeSession.mockResolvedValue(false);
const res = await request(makeApp()).delete('/admin/users/other/sessions/s1');
expect(res.status).toBe(404);
});
it('204s on a successful revoke', async () => {
service.revokeSession.mockResolvedValue(true);
const res = await request(makeApp()).delete('/admin/users/other/sessions/s1');
expect(res.status).toBe(204);
expect(service.revokeSession).toHaveBeenCalledWith('other', 's1');
});
});