Files
API/docs/calendar-auth-migration.md
T
Paddy b848d6eab9 Move the calendar onto the shared session cookie
Step 4 of docs/calendar-auth-migration.md, and the close of
DEFERRED_SECURITY.md item 1: no calendar route reads sessionId/sessionKey from
the query string any more, so a live credential no longer travels through
access logs, browser history and Referer headers.

The four write routes sit behind requireAppAccess('calendar'), which also
narrows who may edit from "any activated @nachklang.art account" to an
explicit per-user permission. They answer 401 signed out and 403 without the
permission, where they previously answered 403 for both.

The three read routes cannot use the middleware: one URL serves an anonymous
visitor, an iCal subscription holding a shared password, and a signed-in
editor who should see drafts. They resolve the session optionally instead, and
a signed-in user without the calendar permission is treated as anonymous
rather than refused - so they keep the public calendar access anyone has.

That public calendar staying anonymous is load-bearing: nachklang.art reads it
to show the next upcoming event. It is now pinned at both the password-table
and the route level, and so is the rule that a shared password can never be
used to write.

credentials.service.ts loses its session half and becomes the password table
it always wanted to be. The shared passwords survive only for iCal clients,
which cannot send a cookie.

Writes record the author as an admin user id and no longer have a legacy int
to write, which is what migration 003 makes room for.

/calendar/users/* is left in place: nothing calls it and a session it mints
opens nothing, but they are still live password-accepting endpoints, so
removing them belongs with the rest of the legacy path in step 5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 22:23:36 +02:00

12 KiB

Migrating the Calendar domain onto the admin identity module

Status: steps 1-4 done (2026-09-06), step 2 dropped by decision, part of step 5 brought forward. Only step 5, the removal of the legacy path, is left.

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. Steps 1-4 of that plan are now live, so the calendar is the last module still on the legacy query-parameter sessions.

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. Done 2026-09-06, as sql/calendar/001_add_admin_user_bridge.sql - the first migration this repo owns for the calendar schema, mirrored into docker/init/01-calendar-schema-dev.sql. It covers both events.created_by_user_id and event_versions.version_created_by_user_id, and carries no foreign key (see "What the code actually looks like" below). The dev seed leaves two events on the legacy path and gives one an admin id, so step 3's dual-read has both cases to exercise. Verified by applying the pre-migration schema and then the migration to a throwaway MariaDB 11 container, and diffing SHOW CREATE TABLE against a fresh dev schema: identical. Applied to the running dev database on the same day; a dev container created before then needs it applied, or recreating.

  2. Map the accounts. Dropped 2026-09-06. There is no backfill: since the creator is only ever a display name (see below), old events keep resolving through the legacy join until step 5 and then simply lose the name. Re-inviting the people who actually still need calendar access remains an operational task, but it is no longer a migration step and nothing is blocked on it.

  3. Dual-read. Change events.service.ts to prefer created_by_user_id and fall back to created_by_id. Writes fill both. Done 2026-09-06. events.service.ts now reads both columns and prefers the admin one, resolving the name through a single findDisplayNames lookup against the admin database per result set (added to users.admin.service.ts for this). Four copies of the same SELECT and four copies of the row mapper were collapsed into one of each first - the dual read would otherwise have had to be written four times.

    A name now has three possible sources, tried weakest first: the legacy join, then the created_by_name snapshot from migration 002, then the live admin lookup - which wins because it is the only one that follows an account being renamed. An admin id that no longer resolves falls back rather than blanking, and a failure to reach the admin database is caught and logged rather than propagated, so an anonymous read of the public calendar never depends on the admin database being up. Covered by test/calendar/events.service.test.ts.

    Writes are not dual-written, contrary to the original plan: before the cutover the request only ever carries a legacy session, so there is no admin id available to write. Writes start filling created_by_user_id (and stop filling created_by_id) in step 4.

  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. Done 2026-09-06. DEFERRED_SECURITY.md item 1 is closed: no route reads sessionId/sessionKey any more.

    How it came out, route by route:

    • The four write routes sit behind requireAppAccess('calendar') as middleware. They answer 401 when signed out and 403 without the permission, where they used to answer 403 for both.
    • The three read routes cannot use middleware - the same URL serves an anonymous visitor, an iCal subscription holding a shared password, and a signed-in editor who should see drafts. They call resolveAccess optionally instead (signedInEditor in the router), and a signed-in user without the calendar permission is treated as anonymous rather than refused, so they keep their access to the public calendar.
    • credentials.service.ts lost its session half entirely and is now just the password table. hasAccess(calendar, password).
    • /calendar/users/* was left alone. Nothing calls it and a session it mints opens nothing, but they are live password-accepting endpoints - step 5 removes them.

    Also: calendar.nachklang.art joined DEFAULT_APP_ORIGINS (better-auth trustedOrigins, without which sign-out from the calendar fails while everything else works), and localhost:4200 joined the dev origins for the same reason.

    Two things this step had to carry that the original sequence put in step 5:

    • sql/calendar/003_allow_null_legacy_creator.sql makes events.created_by_id nullable (MODIFY created_by_id INT NULL). It is NOT NULL today, so the first event created after the cutover would otherwise fail to insert - there is no legacy int id to write any more. event_versions.version_created_by_id is already nullable. The foreign key can stay until step 5; it permits NULL. It also re-runs 002's idempotent name backfill, to catch anything created between the two migrations. Applying it early is safe - widening a column to accept NULL cannot break the running pre-cutover build.
    • The public calendar stays anonymous. hasAccess('public') returns true before any credential check, and nachklang.art reads /calendar/events/public/json and /public/json/next with no session at all. Pinned at both levels - the password table in test/calendar/credentials.service.test.ts, the routes themselves in test/calendar/events.router.test.ts - so this cannot regress quietly.

    Deploy order: migration 003, then the API, then the calendar frontend. The frontend is broken between the last two (its old bundle sends query credentials the new API ignores), so pick a quiet moment. Production also needs calendar.nachklang.art in the admin app's NEXT_PUBLIC_ALLOWED_REDIRECT_ORIGINS, which is a build-time value: a rebuild, not a restart.

  5. Drop the legacy path. Remove users.service.ts's session handling, the sessions table, created_by_id, and the legacy half of the step 3 read (the users join and its legacy_* aliases - the snapshot fallback stays, it is what makes dropping the table safe). The names were archived ahead of time by sql/calendar/002_snapshot_legacy_creator_names.sql, so nothing is lost here. 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.

What the code actually looks like (surveyed 2026-09-06)

Four things found while doing step 1 that change how the later steps should be built:

  • created_by_id is display-only. Nothing authorises on it. events.router.ts gates PUT, POST, DELETE and /move on user?.isActive alone - there is no "only the creator may edit" rule anywhere - and the column is read back solely to render created_by_name and last_modified_by_name. That de-risks steps 2, 3 and 5 considerably: an event whose creator never gets re-invited loses a name in the UI, it does not become uneditable or invisible. It also means the step 2 backfill is best-effort, not a precondition.
  • The two schemas are separate databases. nachklang_calendar and nachklang_admin have their own connection pools (Calendar.db.ts vs the admin module's Kysely instance). So the bridging columns get no foreign key, and - the part the original sequence missed - the LEFT OUTER JOIN users that produces the creator's name cannot simply be repointed. It would have to become a cross-schema join, which hardcodes the admin database name into calendar SQL and ties the two schemas together exactly as an FK would. Recommendation for step 3: drop the join for the new path and resolve names in the service layer instead - collect the distinct ids from the result set and do one lookup against the admin users service. One extra query per listing, no coupling, and it keeps working if the admin database ever moves.
  • events.created_by_id is NOT NULL. Step 5 cannot simply stop writing it; that step has to drop the column (and its FK to users) in the same migration that stops the writes, or make it nullable first.
  • Every calendar read already hits the session table. /:calendar/json calls UserService.checkSession before falling back to credentials.service.ts, so the shared credentials are the fallback, not the primary path. Step 4 replaces the first half of that with requireAppAccess('calendar') and has to decide what happens to the second half
    • which is the first open question below.

Open questions to settle before starting

Settled 2026-09-06:

  • The shared calendar credentials keep working, but only for iCal. The web app goes cookie-only at step 4; MEMBER_CREDENTIAL and friends survive on GET /calendar/events/{calendar}/ical, which is the one case where the client genuinely cannot send a cookie. Everything else in credentials.service.ts goes with step 5. public stays anonymous everywhere - see the note under step 4.
  • The iCal export keeps its own scheme. Same reasoning; it is the reason the shared credentials survive at all rather than an exception to their removal.
  • No account backfill. See step 2 above.
  • Pre-cutover authorship is archived, not discarded. events.created_by_name and event_versions.version_created_by_name, backfilled once by migration 002 and never written again. This was originally listed as a step 5 question; it was brought forward so the data is safe well before the table that holds it is dropped.

Nothing is open. The last one - event_versions.version_created_by_id, the same INT reference on the version rows - was handled in passing: step 1 gave it a sibling bridging column, step 2 a sibling snapshot, and step 3 reads it exactly like events.