feat(sharing): carry the acting account on the session row

Migration 0293 adds `sessions.acting_as_user_id`. NULL, which is what
every session has carried until now, means the browser is acting as the
account it signed in as.

It is a selector and not an authorization. The resolver joins it against
a live grant on every request, so a value stranded here by a grant that
has since been revoked or expired confers nothing. Nothing sweeps the
column for correctness; the sweeps that exist are housekeeping, so a
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:
deleting the session's owner should take the session with it, which is
the existing `user_id` cascade, while deleting the account being acted on
must leave the delegate's browser session alive. A cascade on this side
would sign a delegate out of their own account because somebody else
deleted theirs.
This commit is contained in:
Marc-André Bombeck committed 2026-08-02 11:31:41 +02:00
1 parent bf9bd924b2
commit 18f3fd6b50
2 files changed
+62 -2

No files matched your search

@@ -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");
+26 -2
View File
@@ -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])