Read calendar event creators from the admin module, and archive the old ones (#14)
Jenkins Production Deployment
Jenkins Production Deployment
Reviewed-on: #14 Co-authored-by: Patrick Müller <mail@pmueller.me> Co-committed-by: Patrick Müller <mail@pmueller.me>
This commit was merged in pull request #14.
This commit is contained in:
+225
-34
@@ -1,8 +1,18 @@
|
||||
# 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.
|
||||
Status: **steps 1-4 implemented 2026-09-06, not yet merged or deployed.** Step 2 dropped by
|
||||
decision, part of step 5 brought forward. Only step 5, the removal of the legacy path, is
|
||||
left to write.
|
||||
|
||||
> Read the deploy checklist under step 4 before applying anything. "Done" below means the
|
||||
> code exists on a branch, **not** that production has it - and in particular production has
|
||||
> none of the three migrations. Step 5 is scoped but deliberately unstarted: it must not be
|
||||
> built on top of a step 4 that has not been deployed and watched.
|
||||
|
||||
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
|
||||
|
||||
@@ -38,37 +48,218 @@ permissions can be granted before anything else moves.
|
||||
|
||||
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`.
|
||||
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 checklist
|
||||
|
||||
Production has **none** of the three migrations: 001 and 002 were only ever applied to the
|
||||
dev database. The API build below selects `created_by_user_id` and `created_by_name` on
|
||||
every read, so deploying it against a database missing them fails every calendar request
|
||||
including the anonymous public feed the website uses. In order:
|
||||
|
||||
1. **Apply `sql/calendar/001`, `002`, `003`, in that order**, against `CALENDAR_DB`. All
|
||||
three are re-runnable, so applying one that is already applied is a no-op. Verify
|
||||
before continuing:
|
||||
`SHOW COLUMNS FROM events LIKE '%by_user%'; SHOW COLUMNS FROM events LIKE '%by_name%';`
|
||||
- four rows across the two tables, and `created_by_id` nullable.
|
||||
2. **Check `APP_ORIGINS` on the API vhost.** `calendar.nachklang.art` is in the code's
|
||||
default list, but the environment variable *replaces* that list rather than adding to
|
||||
it - so if it is set at all (the tickets/feedback cutover may have set it), append
|
||||
`https://calendar.nachklang.art` or the calendar's sign-out will 403 while everything
|
||||
else works. That is the failure mode the comment in `admin.config.ts` warns about.
|
||||
3. **Deploy the API.**
|
||||
4. **Deploy the calendar frontend immediately after.** Do not leave a gap - see below.
|
||||
5. **Re-run 002's two `UPDATE` statements.** Between step 1 and step 3 the old API was
|
||||
still writing `created_by_id` with no snapshot; those few rows would otherwise lose
|
||||
their author at step 5.
|
||||
6. **Rebuild the admin app** if `NEXT_PUBLIC_ALLOWED_REDIRECT_ORIGINS` does not already
|
||||
contain `https://calendar.nachklang.art`. It is a **build-time** value, so a restart
|
||||
does nothing.
|
||||
|
||||
**The window between steps 3 and 4 does not look broken, which is the danger.** The old
|
||||
Angular bundle starts by calling `POST /calendar/users/checkSessionValid`, and those
|
||||
legacy routes are untouched - so it still succeeds and the page renders as signed in. What
|
||||
the user then sees is an empty event table and saves that silently do nothing. It looks
|
||||
like the calendar lost its data, not like a deploy in progress. Keep the gap to minutes,
|
||||
or take the frontend offline for it.
|
||||
|
||||
**One-way door:** any iCal subscription whose URL carries `?sessionId=&sessionKey=` rather
|
||||
than `?password=` stops working permanently. The shared-password URLs are unaffected.
|
||||
5. **Drop the legacy path.** Not started - and deliberately not started until step 4 has been
|
||||
deployed and watched, because it removes the fallback step 4 still leans on. Scoped and
|
||||
decided 2026-09-06; what follows is the agreed shape, not a suggestion.
|
||||
|
||||
**Prerequisite: step 4 live in production and behaving.** Until then the legacy join is
|
||||
what renders the author of every pre-cutover event, and the legacy routes are what an old
|
||||
cached bundle talks to. Doing this first turns a recoverable deploy into an unrecoverable
|
||||
one.
|
||||
|
||||
Code, in one branch:
|
||||
|
||||
- **Delete `src/models/calendar/users/` entirely** - `users.router.ts`, `users.service.ts`,
|
||||
`session.interface.ts`, `user.interface.ts` - and the `calendarRouter.use('/users', ...)`
|
||||
line in `Calendar.router.ts`. *(Decided: delete outright rather than unmount.)* This
|
||||
removes the last unauthenticated account-creation and mail-sending endpoint in the API.
|
||||
A survey on 2026-09-06 confirmed nothing outside that directory imports it, and nothing
|
||||
outside it touches the `users`/`sessions` tables except the two joins below.
|
||||
- **Drop the legacy half of the read** in `events.service.ts`: the two
|
||||
`LEFT OUTER JOIN users` clauses, the `legacy_*` aliases, and `created_by_id` /
|
||||
`version_created_by_id` from the SELECT and the row mapper. The snapshot fallback stays -
|
||||
it is what makes this safe. Remove `createdById` / `lastModifiedById` from
|
||||
`event.interface.ts` and their (already deprecated) swagger properties.
|
||||
- **Remove `X-Session-Id` / `X-Session-Key`** from the CORS `allowedHeaders` in
|
||||
`src/app.factory.ts`. Nothing has sent them since the tickets and feedback frontends were
|
||||
redeployed.
|
||||
- **Drop the obsolete test mocks**: `test/feedback/feedback.auth.test.ts`,
|
||||
`test/tickets/tickets.auth.test.ts` and `test/admin/auth-binding.ts` each mock
|
||||
`calendar/users/users.service.js` and assert `checkSession` is never called. That
|
||||
tripwire is meaningless once the module does not exist; remove the mock and the
|
||||
assertion, keep the rest.
|
||||
|
||||
Database, as `sql/calendar/004_*.sql`:
|
||||
|
||||
- Drop the foreign keys `events_users_user_id_fk` and `event_versions_users_user_id_fk`,
|
||||
then the `created_by_id` and `version_created_by_id` columns.
|
||||
- **`RENAME TABLE users TO users_legacy_archive`**, same for `sessions`. *(Decided: rename
|
||||
rather than drop.)* The reasoning: the display names are already snapshotted so nothing
|
||||
visible depends on these rows, but they still hold the old e-mail addresses and password
|
||||
hashes, and a rename makes the tables unreachable without destroying anything. Dropping
|
||||
them later is one statement, at a moment when nobody is mid-deploy.
|
||||
- Mirror all of it in `docker/init/01-calendar-schema-dev.sql` (the archive tables need no
|
||||
mirror - a fresh dev database has nothing to archive).
|
||||
|
||||
Documentation: `DEFERRED_SECURITY.md` items **3** (activation token has no expiry) and
|
||||
**4** (password reset token has no expiry) close outright - both describe code that ceases
|
||||
to exist. Item 2 (no event ownership check) stays open.
|
||||
|
||||
Two consequences to accept explicitly rather than discover:
|
||||
|
||||
- Any activation or password-reset e-mail already sent points at
|
||||
`api.nachklang.art/calendar/users/activate` and becomes a 404. Those links were only ever
|
||||
valid for legacy accounts, which no longer open anything.
|
||||
- `Event.createdById` disappears from the API response. The Angular frontend never read it
|
||||
(its `Event` model has only `createdBy`, the name), so this is not a breaking change for
|
||||
the only known consumer - but it is a wire-format removal, so check anything else that
|
||||
reads `/calendar/events/*/json` first.
|
||||
|
||||
## 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
|
||||
|
||||
- **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`.
|
||||
**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`.
|
||||
|
||||
Reference in New Issue
Block a user