7aac07a013
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>
75 lines
4.4 KiB
Markdown
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`.
|