Files
API/docs/calendar-auth-migration.md
Paddy 7aac07a013 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>
2026-09-05 18:03:08 +02:00

4.4 KiB

Migrating the Calendar domain onto the admin identity module

Status: not started. Written 2026-09-05 alongside the admin module (step 2 of docs/plan-admin-auth.md in the nachklang-admin repo), which deliberately left the calendar alone.

Why the calendar was left out

The admin module replaced authentication for feedback and tickets by swapping one middleware. The calendar cannot be done that way, because its user identity is woven into its data:

  • users/sessions live in the calendar database and are the same tables the feedback and tickets admin areas used to authenticate against.
  • events.created_by_id is an INT foreign key into users.user_id. The admin module's user ids are VARCHAR(36) strings. Migrating identity means migrating that column and every query that joins it.
  • The Angular frontend passes sessionId/sessionKey as query parameters (DEFERRED_SECURITY.md item 1). Cookie sessions remove the parameters entirely, so every calendar route signature and the frontend's HTTP layer change together.
  • credentials.service.ts implements a second, parallel authorisation model: the MEMBER_CREDENTIAL / CHOIR_CREDENTIAL / MANAGEMENT_CREDENTIAL shared secrets that let non-users read specific calendars. That has no equivalent in the admin module and is not a per-user permission at all.

What already exists today: calendar is a value in the user_app_permissions.app enum, so permissions can be granted before anything else moves.

What is in place to build on

  • Cookie sessions across *.nachklang.art, and requireAppAccess('calendar') in src/models/admin/admin.middleware.ts - usable the moment a calendar route wants it.
  • res.locals.admin is {id, email, displayName, apps}; id is the string user id.
  • Invitations, disable/enable and session revocation already cover calendar users, because they are properties of the account rather than of an app.

Suggested sequence

Each step is meant to leave production working on its own.

  1. Add a bridging column. ALTER TABLE events ADD COLUMN created_by_user_id VARCHAR(36) NULL, indexed. Nothing reads it yet.
  2. Map the accounts. For every legacy users row that should survive, invite the person through the admin UI. On acceptance, backfill events.created_by_user_id from events.created_by_id via an email-to-new-id mapping. Everyone not re-invited keeps working on the legacy path until step 4.
  3. Dual-read. Change events.service.ts to prefer created_by_user_id and fall back to created_by_id. Writes fill both. This is the only step that is temporary code, and it should carry a removal note pointing at step 5.
  4. Switch the routes. Replace the query-parameter session checks in events.router.ts and users.router.ts with requireAppAccess('calendar'), and change the Angular frontend to withCredentials: true against the same origin list. Deploy the API first; the calendar frontend is broken between the two deploys, so pick a quiet time. This closes DEFERRED_SECURITY.md item 1.
  5. Drop the legacy path. Remove users.service.ts's session handling, the sessions table, created_by_id, and the dual-read from step 3. Legacy /calendar/users/* stays only if something still calls it - otherwise delete it too. X-Session-Id / X-Session-Key can then come out of the CORS allowedHeaders list in src/app.factory.ts.

Open questions to settle before starting

  • The shared calendar credentials. Do MEMBER_CREDENTIAL and friends stay as a separate mechanism (they serve people with no account at all, and iCal clients that cannot send headers), or do read-only accounts replace them? This is a product decision, not a technical one, and it decides how much of credentials.service.ts survives.
  • The iCal export. GET /calendar/events/{calendar}/ical takes a password in the query string on purpose, because iCal clients cannot send headers. Cookie sessions do not help here; this endpoint likely keeps its own scheme.
  • Which legacy accounts to keep. Step 2 is the moment to not re-invite people who no longer need access.
  • event_versions.version_created_by_id. The same INT reference again, joined in events.service.ts for the "last modified by" name. It has to move with events, and it is the reason step 1's bridging column needs a sibling on event_versions.