mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
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
@@ -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])
|
||||
|
||||
Reference in new issue
Block a user