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:
@@ -0,0 +1,255 @@
|
||||
import {describe, it, expect, beforeAll, beforeEach, afterAll} from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type {Application} from 'express';
|
||||
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';
|
||||
|
||||
let app: Application;
|
||||
|
||||
beforeAll(() => {
|
||||
app = createApp();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDatabase();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await closeDatabase();
|
||||
});
|
||||
|
||||
/** Most tests here need somebody who may administer. */
|
||||
const signedInAdmin = async (email = 'admin@nachklang.art') => {
|
||||
return createAndAcceptInvitation(app, email, 'Admin', ['admin']);
|
||||
};
|
||||
|
||||
describe('GET /admin/users', () => {
|
||||
it('lists users with their permissions and derived status', async () => {
|
||||
const {agent} = await signedInAdmin();
|
||||
await createAndAcceptInvitation(app, 'user@nachklang.art', 'User', ['feedback']);
|
||||
|
||||
const res = await agent.get('/admin/users');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const listed = res.body.find((u: any) => u.email === 'user@nachklang.art');
|
||||
expect(listed.apps).toEqual(['feedback']);
|
||||
expect(listed.status).toBe('aktiv');
|
||||
expect(listed.lastSignInAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it('shows a disabled user as deaktiviert', async () => {
|
||||
const {agent} = await signedInAdmin();
|
||||
const other = await createAndAcceptInvitation(app, 'user@nachklang.art', 'User', ['feedback']);
|
||||
await UsersService.disableUser(other.userId);
|
||||
|
||||
const res = await agent.get('/admin/users');
|
||||
const listed = res.body.find((u: any) => u.email === 'user@nachklang.art');
|
||||
expect(listed.status).toBe('deaktiviert');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /admin/users/:id', () => {
|
||||
it('returns active sessions and the passkey count', async () => {
|
||||
const {agent} = await signedInAdmin();
|
||||
const other = await createAndAcceptInvitation(app, 'user@nachklang.art', 'User', ['feedback']);
|
||||
|
||||
const res = await agent.get(`/admin/users/${other.userId}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.sessions.length).toBe(1);
|
||||
expect(res.body.passkeyCount).toBe(0);
|
||||
});
|
||||
|
||||
it('404s for an unknown id', async () => {
|
||||
const {agent} = await signedInAdmin();
|
||||
expect((await agent.get('/admin/users/does-not-exist')).status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('permission changes', () => {
|
||||
it('replaces the permission set', async () => {
|
||||
const {agent} = await signedInAdmin();
|
||||
const other = await createAndAcceptInvitation(app, 'user@nachklang.art', 'User', ['feedback']);
|
||||
|
||||
const res = await agent
|
||||
.put(`/admin/users/${other.userId}/permissions`)
|
||||
.send({apps: ['tickets', 'calendar']});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const access = await UsersService.loadAccess(other.userId);
|
||||
expect(access?.apps.sort()).toEqual(['calendar', 'tickets']);
|
||||
});
|
||||
|
||||
it('takes effect on the next request the affected user makes', async () => {
|
||||
const {agent} = await signedInAdmin();
|
||||
const other = await createAndAcceptInvitation(app, 'user@nachklang.art', 'User', ['admin']);
|
||||
|
||||
expect((await other.agent.get('/admin/users')).status).toBe(200);
|
||||
|
||||
await agent.put(`/admin/users/${other.userId}/permissions`).send({apps: ['feedback']});
|
||||
|
||||
// No cookie cache: the very next request is already denied, on the same
|
||||
// still-valid session cookie.
|
||||
expect((await other.agent.get('/admin/users')).status).toBe(403);
|
||||
});
|
||||
|
||||
it('refuses to strip the last admin', async () => {
|
||||
const {agent, userId} = await signedInAdmin();
|
||||
|
||||
const res = await agent.put(`/admin/users/${userId}/permissions`).send({apps: ['feedback']});
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect((await UsersService.loadAccess(userId))?.apps).toContain('admin');
|
||||
});
|
||||
|
||||
it('refuses to disable the caller themselves', async () => {
|
||||
const {agent, userId} = await signedInAdmin();
|
||||
|
||||
const res = await agent.post(`/admin/users/${userId}/disable`);
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect((await UsersService.loadAccess(userId))?.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('allows disabling a second admin', async () => {
|
||||
const {agent} = await signedInAdmin();
|
||||
const second = await createAndAcceptInvitation(app, 'admin2@nachklang.art', 'Admin2', ['admin']);
|
||||
|
||||
expect((await agent.post(`/admin/users/${second.userId}/disable`)).status).toBe(200);
|
||||
expect((await second.agent.get('/admin/me')).status).toBe(401);
|
||||
});
|
||||
|
||||
it('re-enables a disabled user without restoring their old sessions', async () => {
|
||||
const {agent} = await signedInAdmin();
|
||||
const other = await createAndAcceptInvitation(app, 'user@nachklang.art', 'User', ['feedback']);
|
||||
await agent.post(`/admin/users/${other.userId}/disable`);
|
||||
|
||||
expect((await agent.post(`/admin/users/${other.userId}/enable`)).status).toBe(200);
|
||||
expect((await UsersService.loadAccess(other.userId))?.disabled).toBe(false);
|
||||
// The revoked session stays revoked; they sign in again.
|
||||
expect((await other.agent.get('/admin/me')).status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('session revocation', () => {
|
||||
it('revokes one session of another user', async () => {
|
||||
const {agent} = await signedInAdmin();
|
||||
const other = await createAndAcceptInvitation(app, 'user@nachklang.art', 'User', ['feedback']);
|
||||
|
||||
const detail = await agent.get(`/admin/users/${other.userId}`);
|
||||
const sessionId = detail.body.sessions[0].id;
|
||||
|
||||
const res = await agent.delete(`/admin/users/${other.userId}/sessions/${sessionId}`);
|
||||
expect(res.status).toBe(204);
|
||||
|
||||
expect((await other.agent.get('/admin/me')).status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invitations', () => {
|
||||
it('creates one and lists it as open', async () => {
|
||||
const {agent} = await signedInAdmin();
|
||||
|
||||
const created = await agent
|
||||
.post('/admin/invitations')
|
||||
.send({email: 'new@nachklang.art', name: 'New', apps: ['feedback']});
|
||||
|
||||
expect(created.status).toBe(201);
|
||||
// Mail is disabled in tests, and the token must never be returned.
|
||||
expect(created.body.token).toBeUndefined();
|
||||
|
||||
const list = await agent.get('/admin/invitations');
|
||||
expect(list.body.map((i: any) => i.email)).toContain('new@nachklang.art');
|
||||
});
|
||||
|
||||
it('refuses to invite an address that already has an account', async () => {
|
||||
const {agent} = await signedInAdmin();
|
||||
await createAndAcceptInvitation(app, 'user@nachklang.art', 'User', ['feedback']);
|
||||
|
||||
const res = await agent
|
||||
.post('/admin/invitations')
|
||||
.send({email: 'user@nachklang.art', name: 'User', apps: ['feedback']});
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it('rejects an invalid email or app name', async () => {
|
||||
const {agent} = await signedInAdmin();
|
||||
|
||||
expect((await agent.post('/admin/invitations').send({email: 'nope', name: 'X', apps: []})).status).toBe(400);
|
||||
expect((await agent.post('/admin/invitations').send({email: 'a@b.de', name: 'X', apps: ['nope']})).status).toBe(400);
|
||||
});
|
||||
|
||||
it('invalidates the previous link on resend', async () => {
|
||||
const {agent} = await signedInAdmin();
|
||||
const original = await InvitationsService.createInvitation('new@nachklang.art', 'New', ['feedback'], null);
|
||||
|
||||
const resent = await agent.post(`/admin/invitations/${original.id}/resend`);
|
||||
expect(resent.status).toBe(200);
|
||||
|
||||
const oldLink = await request(app)
|
||||
.post('/admin/auth/invitations/preview')
|
||||
.send({token: original.token});
|
||||
expect(oldLink.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
|
||||
it('revokes an invitation', async () => {
|
||||
const {agent} = await signedInAdmin();
|
||||
const invitation = await InvitationsService.createInvitation('new@nachklang.art', 'New', ['feedback'], null);
|
||||
|
||||
expect((await agent.delete(`/admin/invitations/${invitation.id}`)).status).toBe(204);
|
||||
expect((await agent.delete(`/admin/invitations/${invitation.id}`)).status).toBe(404);
|
||||
|
||||
const preview = await request(app)
|
||||
.post('/admin/auth/invitations/preview')
|
||||
.send({token: invitation.token});
|
||||
expect(preview.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bootstrap', () => {
|
||||
it('creates an admin invitation on an empty database', async () => {
|
||||
await bootstrapAdmin();
|
||||
|
||||
expect(await InvitationsService.hasOpenInvitationFor('boot@nachklang.art')).toBe(true);
|
||||
});
|
||||
|
||||
it('is idempotent across restarts', async () => {
|
||||
await bootstrapAdmin();
|
||||
await bootstrapAdmin();
|
||||
|
||||
const open = await InvitationsService.listOpenInvitations();
|
||||
expect(open.filter(i => i.email === 'boot@nachklang.art').length).toBe(1);
|
||||
});
|
||||
|
||||
it('grants admin to an address that already has an account', async () => {
|
||||
const user = await createAndAcceptInvitation(app, 'boot@nachklang.art', 'Boot', ['feedback']);
|
||||
|
||||
await bootstrapAdmin();
|
||||
|
||||
expect((await UsersService.loadAccess(user.userId))?.apps).toContain('admin');
|
||||
});
|
||||
|
||||
it('does nothing once an active admin exists', async () => {
|
||||
await signedInAdmin();
|
||||
|
||||
await bootstrapAdmin();
|
||||
|
||||
expect(await InvitationsService.hasOpenInvitationFor('boot@nachklang.art')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('passkey endpoints', () => {
|
||||
it('requires a session to list passkeys', async () => {
|
||||
const anonymous = await request(app).get('/admin/auth/passkey/list-user-passkeys');
|
||||
expect(anonymous.status).toBeGreaterThanOrEqual(400);
|
||||
|
||||
const {agent} = await signedInAdmin();
|
||||
const res = await agent.get('/admin/auth/passkey/list-user-passkeys');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user