mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Adds AuditLog.actorUserId (migration 0294) and threads it centrally, out of the wide-event context the acting-account resolver already stamps. No call site passes an actor, so no route can forget one. NULL keeps meaning "acted for themselves" — the reading every existing row carries. The threading fills the column only when a request resolved an acting account AND the row is being filed under that account, so a self-action, a refusal, and a delegate's row about themselves all stay NULL rather than acquiring an id that would rewrite what the history says. Delegated reads coalesce to one row per owner, delegate and day, through a partial unique index and an insert that expects to lose the race. The alternative is a row per request on the busiest table in the schema and an activity view made of identical lines. The column carries no foreign key, against the grain of user_id beside it: SET NULL there erases an account from its own history, while here it would turn "the delegate opened this record" into "the owner opened this record". An id that no longer resolves reads as a deleted account; an id that silently became NULL reads as a lie.
67 lines
3.9 KiB
SQL
67 lines
3.9 KiB
SQL
-- v1.36.0 — who acted, when it was not the person the row is filed under.
|
|
--
|
|
-- The audit trail has answered one question until now: what happened to this
|
|
-- account. Delegation splits it in two — what happened to this account, and
|
|
-- who was at the keyboard — and a single `user_id` cannot hold both answers.
|
|
--
|
|
-- NULL is the whole design of this column. Every row written before today
|
|
-- means "this account acted for itself", and that reading has to survive: a
|
|
-- backfill, a default, or a threading rule that helpfully stamps the caller's
|
|
-- own id would rewrite the meaning of every historical row at once, silently,
|
|
-- with nothing to compare against afterwards. So the column arrives nullable,
|
|
-- unbackfilled, and the code that fills it (`src/lib/auth/audit.ts`) fills it
|
|
-- only when the request actually resolved an acting account.
|
|
--
|
|
-- A delegated action files under the OWNER, with the delegate here. The owner's
|
|
-- record was the thing touched, and "what happened to my data" is the owner's
|
|
-- question to ask.
|
|
--
|
|
-- No foreign key, deliberately, and against the grain of `user_id` right above
|
|
-- it. `user_id` is ON DELETE SET NULL because an erased account should drop out
|
|
-- of its own history. Applying that rule here would not erase, it would falsify:
|
|
-- nulling the actor turns "the delegate opened this record" into "the owner
|
|
-- opened this record", which is a sentence the trail would then assert about a
|
|
-- day it did not happen. An id that no longer resolves to a user reads as a
|
|
-- deleted account and is honest about it. The 365-day retention purge
|
|
-- (`src/lib/jobs/audit-log-cleanup.ts`) bounds how long the id lives either way.
|
|
|
|
ALTER TABLE "audit_logs" ADD COLUMN "actor_user_id" TEXT;
|
|
|
|
-- The owner's "who touched my record" view: their rows, delegated ones only,
|
|
-- newest first. Every existing index on this table leads with `user_id` and
|
|
-- then `action` or `created_at`, so without this one that read scans an
|
|
-- account's entire history — on the table the schema itself calls the
|
|
-- highest-churn one — to surface the few rows that carry an actor.
|
|
CREATE INDEX "audit_logs_user_id_actor_user_id_created_at_idx"
|
|
ON "audit_logs" ("user_id", "actor_user_id", "created_at" DESC);
|
|
|
|
-- Delegated reads coalesce to one row per (owner, delegate, day).
|
|
--
|
|
-- Without this, a delegate paging through a record writes an audit row per
|
|
-- request, on the table that is already the busiest one here, and the owner's
|
|
-- activity view becomes a wall of identical lines nobody can read. One row a
|
|
-- day answers the question the owner is actually asking — "was she in my record
|
|
-- yesterday" — and the counter inside it answers "how much".
|
|
--
|
|
-- The uniqueness is what makes the coalescing atomic rather than a read
|
|
-- followed by a hopeful write: the writer INSERTs and lets the index send it
|
|
-- into the ON CONFLICT arm, so two concurrent delegated reads cannot both
|
|
-- decide the day's row is missing.
|
|
--
|
|
-- The day is a bare cast because `created_at` on this table is TIMESTAMP
|
|
-- WITHOUT TIME ZONE holding UTC — the whole application writes it that way, so
|
|
-- `::date` is already the UTC day and needs no conversion. It also has to be
|
|
-- bare: an index expression must be IMMUTABLE, and any form that converts a
|
|
-- zone at read time (`AT TIME ZONE`, casting through timestamptz) is only
|
|
-- STABLE, because its answer depends on a session setting an operator can
|
|
-- change. Postgres refuses to index it, which is the right refusal: a day
|
|
-- boundary that moves with a container's TZ would split or merge days the
|
|
-- trail has already recorded.
|
|
--
|
|
-- Partial on the action so it constrains exactly the rows the writer emits and
|
|
-- nothing else: `auth.login` rows carry no actor, and two of them on one day
|
|
-- for one account are entirely ordinary.
|
|
CREATE UNIQUE INDEX "audit_logs_delegated_access_day_key"
|
|
ON "audit_logs" ("user_id", "actor_user_id", (("created_at")::date))
|
|
WHERE "action" = 'sharing.record.accessed';
|