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

75 lines
4.4 KiB
Markdown

# 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`.