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,58 @@
|
||||
import {expect} from 'vitest';
|
||||
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';
|
||||
|
||||
const db = NachklangAdminDB.db;
|
||||
|
||||
// Dev/test cookie name: advanced.cookiePrefix is 'nachklang', and the __Secure-
|
||||
// prefix is only added over https.
|
||||
export const SESSION_COOKIE = 'nachklang.session_token';
|
||||
|
||||
/** Wipes every table between test files. Child tables first - the FKs to
|
||||
* `user` are ON DELETE CASCADE, but rateLimit and invitations are not. */
|
||||
export const resetDatabase = async (): Promise<void> => {
|
||||
await db.deleteFrom('session').execute();
|
||||
await db.deleteFrom('user_app_permissions').execute();
|
||||
await db.deleteFrom('passkey').execute();
|
||||
await db.deleteFrom('invitations').execute();
|
||||
await db.deleteFrom('user').execute();
|
||||
};
|
||||
|
||||
export const closeDatabase = async (): Promise<void> => {
|
||||
await db.destroy();
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates an invitation straight through the service (so the test gets the raw
|
||||
* token, which the API deliberately never returns) and redeems it through the
|
||||
* public endpoint. Returns an agent that carries the resulting session cookie.
|
||||
*/
|
||||
export const createAndAcceptInvitation = async (
|
||||
app: Application,
|
||||
email: string,
|
||||
name: string,
|
||||
apps: AppName[],
|
||||
password = 'devpassword123'
|
||||
) => {
|
||||
const invitation = await InvitationsService.createInvitation(email, name, apps, null);
|
||||
|
||||
const agent = request.agent(app);
|
||||
const res = await agent
|
||||
.post('/admin/auth/invitations/accept')
|
||||
.send({token: invitation.token, password});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
return {agent, userId: res.body.user.id, token: invitation.token};
|
||||
};
|
||||
|
||||
export const cookieHeader = (res: request.Response): string[] => {
|
||||
const raw = res.headers['set-cookie'];
|
||||
return Array.isArray(raw) ? raw : raw ? [raw] : [];
|
||||
};
|
||||
|
||||
export const sessionCookieFrom = (res: request.Response): string | undefined => {
|
||||
return cookieHeader(res).find(cookie => cookie.startsWith(SESSION_COOKIE));
|
||||
};
|
||||
Reference in New Issue
Block a user