diff --git a/prisma/migrations/0293_session_acting_as/migration.sql b/prisma/migrations/0293_session_acting_as/migration.sql new file mode 100644 index 000000000..c9c4f8e9d --- /dev/null +++ b/prisma/migrations/0293_session_acting_as/migration.sql @@ -0,0 +1,36 @@ +-- v1.36.0 — the cookie transport's acting-account carrier. +-- +-- One nullable column, and the whole reason it is a column rather than +-- something the client sends: a browser cannot address this row. The session +-- cookie carries a 32-byte secret whose hash is the lookup key, so nothing +-- reachable from the client names, reads, or writes `acting_as_user_id`. The +-- only writer is the switch endpoint, which validates the grant first. +-- +-- It is still a SELECTOR and not an authorization. The resolver joins it +-- against a live row in `account_grants` on every request, so a value stranded +-- here by a grant that has since been revoked or expired confers nothing at +-- all. Nothing sweeps this column for correctness — sweeping it (on revocation, +-- on account deletion) is housekeeping so the delegate's browser lands back in +-- its own account instead of on a wall of 403s. +-- +-- ON DELETE SET NULL, not CASCADE. The two ends mean opposite things here: +-- deleting the session's OWNER should take the session with it (that is the +-- existing `user_id` cascade), while deleting the account being ACTED ON must +-- leave the delegate's browser session alive and simply put it back into its +-- own record. A CASCADE on this side would sign a delegate out of their own +-- account because somebody else deleted theirs. +-- +-- The index is not for the resolver — that reads by primary key. It is for the +-- two sweeps that go the other way: revocation clearing every session of the +-- delegate pointing at this owner, and the SET NULL above when an account is +-- deleted. Both scan on this column. + +ALTER TABLE "sessions" ADD COLUMN "acting_as_user_id" TEXT; + +ALTER TABLE "sessions" + ADD CONSTRAINT "sessions_acting_as_user_id_fkey" + FOREIGN KEY ("acting_as_user_id") REFERENCES "users"("id") + ON DELETE SET NULL ON UPDATE CASCADE; + +CREATE INDEX "sessions_acting_as_user_id_idx" + ON "sessions" ("acting_as_user_id"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bd0ce0168..467dc5386 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -695,7 +695,10 @@ model User { // Relations passkeys Passkey[] - sessions Session[] + sessions Session[] @relation("SessionOwner") + // v1.36.0 — browser sessions belonging to OTHER accounts that are currently + // acting on this one. Named so the delete cascade has somewhere to point. + sessionsActingOnMe Session[] @relation("SessionActingAs") challenges AuthChallenge[] // v1.23 — second-factor stores (kept separate from `passkeys`, which is the // passwordless-primary credential set; an MFA credential is meaningless @@ -1132,9 +1135,30 @@ model Session { // v1.23 — sliding "last seen" for the user-facing active-session list. lastActiveAt DateTime? @map("last_active_at") - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + /// v1.36.0 — the cookie transport's acting-account carrier. NULL (the only + /// value any session has had until now) means the browser is acting as the + /// account it signed in as. A non-NULL value names the account whose record + /// this session is currently reading, and it is a SELECTOR, never an + /// authorization: the resolver joins it against a live grant on every single + /// request, so a value left here by a grant that has since ended confers + /// exactly nothing. + /// + /// Server-side by construction — the column lives on a row the client cannot + /// address (the cookie carries a secret, not this id), and the only writer is + /// the switch endpoint, which validates the grant before it stamps. + /// + /// The FK is ON DELETE SET NULL rather than CASCADE: deleting the OWNER must + /// not delete the delegate's browser session, it must put that browser back + /// into its own account. + actingAsUserId String? @map("acting_as_user_id") + + user User @relation("SessionOwner", fields: [userId], references: [id], onDelete: Cascade) + actingAs User? @relation("SessionActingAs", fields: [actingAsUserId], references: [id], onDelete: SetNull) @@index([userId]) + // Revocation clears every session pointing at the owner, and deleting an + // account nulls the same set. Both sweep on this column. + @@index([actingAsUserId]) // v1.18.7 — the sliding-30-day session reaper sweeps on `expiresAt`; without // this index the cleanup degrades to a sequential scan on a busy instance. @@index([expiresAt])