Drop the calendar's legacy authentication path

Step 5, the last one, of docs/calendar-auth-migration.md. Step 4 is deployed
and verified, which is what this was waiting on: it removes the fallbacks that
step 4 still leaned on.

Gone: src/models/calendar/users/ entirely - registration, login, activation,
both password-reset routes, and the session checking that the feedback and
tickets admin areas used to authenticate against - along with its mount. That
was the API's last unauthenticated account-creation and mail-sending endpoint.
A survey confirmed nothing outside that directory imported it and nothing else
touched its tables.

Also gone: the two joins against the calendar users table in events.service.ts
and the created_by_id / version_created_by_id columns they read, from the SQL,
the row mapper, the Event interface and the swagger schema; and X-Session-Id /
X-Session-Key from the CORS allowedHeaders, which nothing has read since the
first cutover and nothing has sent since the second.

An event's author still renders, because migration 002 snapshotted the names
before this could erase them. That was brought forward from this step on
purpose, and it is the reason 004 can rename the accounts aside at all.

The accounts are renamed rather than dropped - they still hold e-mail addresses
and password hashes, and a rename makes them unreachable without destroying
anything. InnoDB rewires the sessions foreign key to the new name; verified on
MariaDB 11, along with the whole 001-004 chain from the pre-cutover production
schema, which lands byte-identical to a fresh dev database.

Migration 004 must be applied AFTER deploying, not before - the reverse of step
4, whose migration only added things. Its own header and the runbook both say
so, since getting it wrong by analogy is the obvious mistake.

DEFERRED_SECURITY.md items 3 and 4 close with it: the activation and reset
tokens that never expired are gone along with the code that issued them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-06 23:32:59 +02:00
parent 13a0c07d1b
commit aa95ab2745
17 changed files with 253 additions and 1370 deletions
+25 -45
View File
@@ -9,49 +9,35 @@ import logger from '../../../middleware/logger.js';
dotenv.config();
/**
* Step 3 of docs/calendar-auth-migration.md: the dual read.
* How an event's creator is resolved, after step 5 of
* docs/calendar-auth-migration.md removed the legacy path.
*
* An event records its creator twice - `created_by_id`, the legacy INT into
* the calendar database's own `users` table, and `created_by_user_id`, the
* admin module's VARCHAR(36) id. Old rows have only the first, rows written
* after the step 4 cutover will have only the second, and the two live in
* different databases, so this file has to read both and prefer the new one.
* The creator is only ever rendered as a name - nothing authorises on it, there
* is no "only the creator may edit" rule anywhere - which is why an unresolvable
* one degrades to blank rather than to an error.
*
* The one thing the creator is used for is a display name. Nothing authorises
* on it - there is no "only the creator may edit" rule anywhere - which is why
* a name that cannot be resolved degrades to blank instead of to an error.
* Two sources remain, weaker first:
*
* That name has three possible sources, and they are tried weakest first:
* 1. `created_by_name`, a snapshot of the name as it stood when the calendar
* had its own `users` table. Migration 002 took it, migration 004 dropped
* the table it was taken from, and nothing has written it since. It exists
* so the authorship of pre-cutover events survived that removal.
* 2. The admin module's `user.name`, looked up live for rows carrying an admin
* id. It wins, because it is the only one that follows a rename.
*
* 1. LEGACY - joining the calendar's own `users` table on `created_by_id`.
* 2. `created_by_name`, the snapshot migration 002 took of exactly that join,
* so the authorship of pre-cutover events survives step 5 dropping the
* table. An archive: nothing writes it after the backfill.
* 3. The admin module's `user.name`, looked up live for rows that carry an
* admin id. It wins because it is the only one that follows a rename.
*
* Writes only ever set the admin id: since the step 4 cutover there is no
* calendar user id to write, which is why migration 003 made `created_by_id`
* nullable. The reads below still handle rows that predate that.
*
* Removal note: everything marked LEGACY below comes out in step 5, together
* with the `users`/`sessions` tables and the `created_by_id` columns. The
* snapshot stays - it is the reason step 5 can drop them.
* The third source - joining the calendar's own `users` table on
* `created_by_id` - is gone with the table and the column.
*/
/**
* The one SELECT the four read paths share. It was copied out four times
* before, which is precisely why the dual read had to be added in four
* places; callers append their own WHERE and ORDER BY.
* The one SELECT the four read paths share; callers append their own WHERE and
* ORDER BY. `v.*` carries the version row's own creator columns, so only the
* `events` columns need naming.
*
* `v.*` carries `version_created_by_user_id` and `version_created_by_name`
* along with the rest of the version row, so only the `events` columns need
* naming. The two joined names are aliased `legacy_*` because the unprefixed
* names are now real columns.
* There are no joins to a users table any more. There is no users table.
*/
const EVENT_SELECT = `
SELECT e.calendar_id, e.uuid, e.created_date, e.created_by_id, e.created_by_user_id, e.created_by_name,
u.full_name as legacy_created_by_name, u2.full_name as legacy_last_modified_by_name, v.* FROM events e
SELECT e.calendar_id, e.uuid, e.created_date, e.created_by_user_id, e.created_by_name, v.* FROM events e
INNER JOIN (
SELECT event_id, MAX(event_version_id) AS latest_version
FROM event_versions
@@ -59,9 +45,7 @@ const EVENT_SELECT = `
) latest_versions
ON e.event_id = latest_versions.event_id
INNER JOIN event_versions v
ON v.event_id = latest_versions.event_id AND v.event_version_id = latest_versions.latest_version
LEFT OUTER JOIN users u ON u.user_id = e.created_by_id
LEFT OUTER JOIN users u2 ON u2.user_id = v.version_created_by_id`;
ON v.event_id = latest_versions.event_id AND v.event_version_id = latest_versions.latest_version`;
/**
* Maps a result row to an Event. `status` is included only where it always
@@ -80,15 +64,11 @@ const toEvent = (row: any, includeStatus: boolean): Event => {
createdDate: row.created_date,
lastModifiedDate: row.version_created_at,
location: row.location,
// Name resolution, weakest first: the LEGACY join against the calendar
// users table, then the snapshot taken in migration 002, then - in
// resolveAdminNames below - the live admin name, which wins because it
// is the only one that follows an account being renamed.
createdBy: row.created_by_name ?? row.legacy_created_by_name,
createdById: row.created_by_id,
// The archived name. resolveAdminNames below overwrites it for rows that
// carry an admin id, which is the only source that follows a rename.
createdBy: row.created_by_name,
createdByUserId: row.created_by_user_id ?? null,
lastModifiedBy: row.version_created_by_name ?? row.legacy_last_modified_by_name,
lastModifiedById: row.version_created_by_id,
lastModifiedBy: row.version_created_by_name,
lastModifiedByUserId: row.version_created_by_user_id ?? null,
url: row.url,
wholeDay: row.whole_day,
@@ -112,7 +92,7 @@ const toEvent = (row: any, includeStatus: boolean): Event => {
* A failure here is swallowed on purpose. These endpoints include the public
* calendar the website reads anonymously, and a name is decoration: if the
* admin database is unreachable, an event should still render with whatever
* the legacy join produced rather than 500 the whole listing. The alternative
* the snapshot holds rather than 500 the whole listing. The alternative
* would widen the public calendar's blast radius to include the admin
* database, which it has never depended on before.
*/