fix(auth): make org session policy and cookie-cache version cache-coherent#5947
fix(auth): make org session policy and cookie-cache version cache-coherent#5947waleedlatif1 wants to merge 13 commits into
Conversation
…erent The session-policy enforcement added in #5862 split an org's `securityPolicyVersion` and `sessionPolicySettings` across two independent process-local caches. On a multi-replica deployment a pod could observe the bumped version while still holding the pre-save policy, and that skew silently undid a just-saved tightening: 1. Saving a policy eagerly clamps every member session in SQL. Shortening `expires_at` makes Better Auth's refresh predicate (`expiresAt - expiresIn + updateAge <= now`) unconditionally true, so the save itself marks the whole org due for refresh. 2. The version bump expires the session-data cookie, forcing the DB path. 3. The refresh proposes `now + 30d` and `session.update.before` re-clamps it — but against the stale policy, so it passed through unclamped. 4. With `expiresAt` back at 30 days the predicate is false again, so nothing re-clamped that session for another ~24h. Both fields live on the same `organization` row, so they are now read and cached as one record: a pod that still sees version N also serves policy N and forces no cookie mismatch, removing the skew by construction rather than patching one direction of it. Session CREATE additionally reads through the cache — unlike a refresh it is not already preceded by a version-mismatch read, so a stale record would otherwise mint a full-length session. Also in this change: - A failed entitlement read no longer disables enforcement. Stored bounds are only writable by an entitled org, so their presence is the source of truth when the plan check is unavailable; `resolveOrganizationEnterprisePlan` propagates errors so "downgraded" and "check failed" stay distinguishable. This matches the reasoning already documented on the PII-redaction path. - A failed org-record read serves the last known-good record instead of falling back to defaults, which would have dropped the org's bounds entirely. - A failed membership lookup in `session.create.before` no longer skips the clamp; it falls back to the cached membership. - Both caches are bounded (expired entries first, then least-recently-refreshed) — they previously grew one entry per distinct org/user for the process life. - The cookie-cache version fn short-circuits when organizations are disabled, so those deployments pay no lookup on the authenticated hot path. - Idle-timeout copy now states the real guarantee: activity is recorded at most once per 24h, so sign-out lands between (value - 24) and (value) hours after last use, never later. Tests: new coverage for the org-record collapse, last-known-good behavior, entitlement failure, the impersonation exemption, and the previously untested revoke route (authz branches, self-token and impersonator exclusions).
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
PR SummaryHigh Risk Overview Enforcement: Cookie invalidation: Policy save and org-wide revoke call API / UX: Revoke route returns the bumped version from the transaction and publishes it; docs and session-policy UI clarify idle timeout as a window (activity logged at most every 24h), with a new FAQ. Tests: New coverage for Reviewed by Cursor Bugbot for commit 6bb1323. Configure here. |
Greptile SummaryThis follow-up strengthens organization session-policy enforcement and cache coherence.
Confidence Score: 5/5The PR appears safe to merge within the scope of the prior review threads. No blocking failures remain.
|
| Filename | Overview |
|---|---|
| apps/sim/lib/auth/security-policy.ts | Introduces bounded LRU caches, monotonic version publication, explicit membership lookup semantics, and organization-disabled short-circuiting. |
| apps/sim/lib/auth/session-policy.ts | Removes policy caching, reads stored policy settings at enforcement time, and preserves enforcement when entitlement resolution fails. |
| apps/sim/lib/auth/auth.ts | Resolves membership once during session creation, prevents expiry extension on policy-read failure, and handles post-session SSO JIT membership. |
| apps/sim/app/api/organizations/[id]/session-policy/route.ts | Publishes the committed security-policy version after atomically saving policy settings and clamping sessions. |
| apps/sim/app/api/organizations/[id]/sessions/revoke/route.ts | Returns and publishes the committed version from the atomic session-revocation transaction. |
Reviews (11): Last reviewed commit: "revert(auth): drop the TTL-overrun retry..." | Re-trigger Greptile
Self-review follow-ups on the cache-coherence change: - Splitting the entitlement check out of the combined policy cache meant it ran on every session refresh. A policy save or org-wide revoke makes every member session refresh at once, so a large org would have stampeded the billing tables with one plan check per session. Entitlement is now cached on its own TTL, independent of the org security record (a policy save invalidates the policy but says nothing about the plan). Staleness is harmless in both directions, and a failed check falls back to the last known decision. - Pruning trimmed exactly to the cap, so the next insert was over again and every subsequent write rescanned the whole map. Prune to a low water mark instead, which keeps eviction amortized O(1). - The session create hook no longer writes an `expiresAt: undefined` key when the clamp has nothing to narrow; it only ever overrides a real value. - Drop the redundant `isBillingEnabled` short-circuit — the same condition is already the first branch of `resolveOrganizationEnterprisePlan`.
Follows the pattern the two sibling enterprise-settings call sites already establish. `lib/logs/execution/logger.ts` and `lib/workflows/executor/execution-core.ts` both resolve org enterprise settings with a single indexed read at enforcement time and carry explicit comments rejecting a cached/re-checked resolution, because a stale or failed read there silently skips the control. The session policy is the same shape of thing, and it is not a hot path: it is read on sign-in and on a sliding session refresh, which the 24h cookie cache limits to roughly once per user per day. Caching it bought nothing and cost the correctness bug this branch set out to fix — so drop the cache rather than build machinery to keep two caches coherent. Better Auth also documents `cookieCache.version` as a static string bumped at deploy and offers no cross-instance invalidation, so the version TTL is inherent to the design while the policy cache never was. This removes, rather than fixes, the whole class of problems the previous revision introduced: no merged record, no cache-bypass flag, no last-known-good fallback, no entitlement cache, no destructive-invalidation window, and no ordering hazard between concurrent readers. `security-policy.ts` is back to caching only the cookie-cache version — which genuinely is per-request — plus the membership lookup. Failure posture is now chosen per call site instead of globally fail-open: - `getSessionPolicy` and `getMemberOrganizationId` propagate read errors. - The session UPDATE hook refuses to EXTEND on a failed read, keeping the member's current expiry rather than granting a fresh 30 days the policy may forbid. - The session CREATE hook allows the sign-in but logs that the session went unclamped — refusing sign-ins would turn a read blip into an org-wide outage. - `getSessionCookieCacheVersion` still never throws; it runs on every request, and its fallback only costs a cookie mismatch and a database session read. Kept from the previous revision: the entry caps with low-water-mark eviction, the organizations-disabled short-circuit, the propagating enterprise-plan resolver, and the corrected idle-timeout copy.
…resolver Review catch. `resolveOrganizationEnterprisePlan` existed to distinguish "not entitled" from "the check could not run", but it called `getOrganizationSubscriptionUsable` with the default `onError: 'return-null'` — so a transient subscription read failed to `null`, read as "no usable subscription", and reported a downgrade anyway. The one failure mode the resolver was written for was the one it still swallowed. Also guard the version cache against out-of-order writes: the counter only ever increments, so a read resolving lower than the cached value started before the newer one and must not overwrite it.
|
@cursor review |
…cache Review catch. The monotonic guard skipped STORING a superseded version but still RETURNED it, and clearing the entry on write left no floor to compare against — so an in-flight pre-bump read could land after the bump and re-seed the old version for a full TTL. Either path keeps cookie versions matched, which is precisely how a revoked session keeps serving from the cookie cache. Both routes already compute the new counter, so they now return it from the same UPDATE and publish it directly. That leaves the cache always holding a floor, makes a late read unable to win on either the return path or the store path, and drops a redundant read. Also documents the session-create trade explicitly: when both the direct membership read and the cached fallback fail, the org is unknown and the sign-in proceeds unclamped. Refusing it would convert a `member` read failure into a total authentication outage for every user, including the majority who belong to no org. The delay is bounded — the update hook clamps retroactively from `createdAt` at the next cookie-cache expiry, and any admin action bumps the version and closes the window immediately.
|
@cursor review |
Review catch, and the reviewer is right that the previous comment described behavior the code no longer had. `getSessionPolicy` now throws, so the create hook's catch covered two different failures and answered both the same way. They deserve opposite answers: - Org KNOWN — membership resolved, only the policy read failed. This user is definitely governed by an org, so the "would break sign-in for everyone" rationale simply does not apply. Fail closed. Minting a full-length session for a member whose policy could not be read is the exact outcome the feature exists to prevent, and the blast radius is org members during a window where the `organization` read fails while the `member` read just succeeded. - Org UNKNOWN — both membership reads failed, so a governed member is indistinguishable from the majority who belong to no org. Failing closed here would take authentication down for everyone to protect a policy most of them do not have. Allow, and let the update hook clamp retroactively. Throwing an APIError from this hook is the established pattern in this file — the blocked-email check directly above does the same.
|
@cursor review |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit b467685. Configure here.
Line-by-line pass over the final diff. No behavior change; every item is either a comment that had drifted from the code or state the code did not need. - The create hook's comment claimed a failed membership read still clamps "via the cached membership". Since `getMemberOrganizationId` now throws, that only holds when the cache is warm. Say what the code actually does: `null` means confirmed org-less, `undefined` means could-not-tell, and the catch branches on which one it got. - `isPolicyEnforced(orgId, hasBounds)` took a boolean parameter that inverted its own name — it returned `true` for an org with no policy at all. Split into a plain early return for the no-bounds case and `isEntitledToEnforce`, which now only answers the question its name asks. - The revoke route aliased a transaction result to rename one field; destructure it instead. - The policy UPDATE returned `id` purely as an existence check that the version column it also returns already provides. - Two test names still described invalidating a cache that is now published to.
|
@cursor review |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit b9d8047. Configure here.
- `getSessionPolicy`'s docstring still said the CREATE hook allows the sign-in on a failed read. It has refused when the governing org is known since the fail-closed split; say so. - `SECURITY_POLICY_VERSION_CACHE_TTL_MS` had no consumer outside this module, and exporting one of the two sibling TTLs but not the other was arbitrary. - Pass an explicit `ttl` for both membership outcomes instead of relying on `undefined` to mean the constructor default, so the positive/negative asymmetry is visible at the call site.
|
@cursor review |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 30d5496. Configure here.
Two defects found by an independent audit of the session lifecycle. 1. First SSO sign-in into a policy-bearing org produced an UNCLAMPED 30-day session. `@better-auth/sso` creates the session before it writes `member` (`handleOAuthUserInfo` then `assignOrganizationFromProvider`), and it writes through the adapter directly, so no member hook fires. The create hook saw no membership and applied Better Auth's default expiry — and because a 30-day expiry reads as "not due for refresh", the sliding refresh would not re-clamp it for another day. Against an 8-hour policy that is a full day of bypass, on every first SSO login, deterministically. Closed with an `after` hook that re-applies the policy once membership is visible. It runs only for sessions created without an org — the only ones that can be affected — and is best-effort so it can never break sign-in. 2. The create hook's catch branched on a local that was stale by the time it was read. When the direct membership read failed, `clampExpiryForSession` could still resolve the org from cache and then throw on the policy read; the catch saw the stale `undefined` and took the permissive branch — failing OPEN on a path whose comment promised fail-closed. Membership is now resolved once, up front, through the shared cached helper, and both the clamp and the catch use that single value. Also: normalize the update hook's fallback expiry like its happy path, and correct the `getSessionPolicy` cost note — an org with bounds stored pays the entitlement check too, so roughly four reads, not one.
|
@cursor review |
…read failure Review catch. The success path re-checks the cache after its await so a concurrent publish wins, but the catch path returned `DEFAULT_VERSION` unconditionally. The default is not the safe fallback it looks like — it is precisely the version a never-bumped org carries. So a lookup that started before a revoke, and then failed, could report `1` while this process already held the bumped counter: the cookie-cache version would still MATCH pre-bump cookies and the just-revoked session would keep being served from cache, which is the one thing the bump exists to stop. The catch now returns the cached value when one is present. It can only be equal to or higher than the default, so it forces the same revalidation or more.
|
@cursor review |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 39ef3bd. Configure here.
Review catch. The post-read monotonic guard can only out-rank a stale value while the entry that would out-rank it still exists. A read that stays in flight past the cache TTL has watched that entry expire, so it can store a pre-bump version with nothing left to catch it — and then serve it for another full TTL, keeping pre-bump cookies matched and revoked sessions on the cookie cache. Reaching that state needs an indexed single-row lookup to hang for a minute, so it is pathological rather than likely, but the fix is small: time the read and, if it outlived the TTL, read once more rather than trusting a value that can no longer be checked. Logged at warn, since a lookup that slow is itself worth seeing.
|
@cursor review |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 5b5bb5e. Configure here.
The retry added last round does not converge. Review correctly pointed out that the replacement read can itself be slow across a later bump, in exactly the same way — so the guard needs a guard, indefinitely. That is a signal the mechanism is wrong, not that it needs another layer. The root constraint: a monotonic floor kept in a TTL cache disappears with the TTL, so a read still in flight when it expires has nothing to lose against. No amount of re-reading fixes that. Closing it needs a floor that outlives the cache, which means cross-process state — the deferred Redis invalidation follow-up — not more retries here. So the retry is reverted and the limit is documented where the guard lives. The cheap post-read comparison stays: it costs two lines, needs no timing, and covers the realistic case where the newer entry is still alive. The residual is bounded — a stale read that wins serves a pre-bump version for at most one more TTL, making worst-case propagation two TTLs rather than one. That is a slower instance of a latency this feature already documents, not a new class of failure, and it is strictly better than the pre-PR behavior where the policy itself could also be stale.
|
@cursor review |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 6bb1323. Configure here.
Summary
Follow-up to #5862 (org session policies). An audit of that PR found one real correctness bug plus several smaller issues; this fixes them.
The bug. #5862 split an org's
securityPolicyVersionandsessionPolicySettingsacross two independent process-local caches (60s TTL each, invalidated only on the pod that handled the write). On a multi-replica deployment a pod could observe the bumped version while still holding the pre-save policy, and that skew silently undid a just-saved tightening:expires_atmakes Better Auth's refresh predicate (expiresAt - expiresIn + updateAge <= now,api/routes/session.mjs:205) unconditionally true — so the save itself marks the whole org due for refresh.session.mjs:96), forcing the DB path.now + 30dandsession.update.beforere-clamps it — but against the stale policy, so it passed through unclamped, reverting the eager clamp.expiresAtback at 30 days the predicate is false again, so nothing re-clamped that session for another ~24h.Net effect: enterprise session bounds silently unenforced for up to ~24h for an unpredictable subset of sessions. Worst case is an org's first policy save, where the stale value is "no policy" — i.e. zero enforcement.
values-production.yamlshipsreplicaCount: 2and the HPA maxes at 10, so this is the deployed topology.The fix. Both fields live on the same
organizationrow, so they are now read and cached as one record. A pod that still sees version N also serves policy N and forces no cookie mismatch — the skew is removed by construction rather than one direction of it being patched. Session CREATE additionally reads through the cache: unlike a refresh it is not already preceded by a version-mismatch read, so a stale record would otherwise mint a full-length session that nothing re-clamps for 24h.Also in this change
isOrganizationOnEnterprisePlanswallows its own errors and returnsfalse, sogetSessionPolicycould not distinguish "downgraded" from "the check threw" — a transient billing-read blip silently stopped enforcing a security control. AddedresolveOrganizationEnterprisePlan, which propagates errors;isOrganizationOnEnterprisePlankeeps its fail-closed contract for every existing caller. Stored bounds are only writable by an entitled org, so their presence is the source of truth when the check is unavailable — the same reasoning already documented on the PII-redaction path inlib/logs/execution/logger.ts.session.create.beforeno longer skips the clamp — it falls back to the cached membership instead of minting a 30-day session off a transient error.expiresAt = lastRefresh + Nand sign-out lands betweenN-24andNhours after last use — never later. The docs and UI hint claimed an exactN. Behavior is unchanged and always errs conservative; only the copy was wrong.Type of Change
Testing
security-policy.test.ts: the one-row collapse (the coherence guarantee), invalidation, cache bypass, last-known-good on failure, cookie-version format, org-less and orgs-disabled short-circuits.session-policy.test.ts: entitlement gating incl. the failure path, impersonation exemption, membership fallback,freshMembershipOrgIdshort-circuit, ISO-string normalization, cache bypass.sessions/revoke/route.test.ts— this route had no tests and is the only destructive endpoint in the feature. Covers all four authz branches, the 404, and the two easy-to-break carve-outs: the caller's own token and the impersonator's real sessions.lib/auth,lib/billing,api/organizations,lib/invitations,components/settings,ee.check:api-validationall pass.Not included (evaluated and deliberately skipped)
secondaryStorage(Redis-backed sessions). Verified innode_modules:storeSessionInDatabasedefaults falsy, so thesessiontable would stop being written —eagerClampOrgSessionsand the org-wide revoke-all would match zero rows and report success, writing a "Revoked 0 member sessions" audit record while every session stayed alive. It also suppressesverificationrows, breakingapps/realtimeone-time-token auth, and makes Redis a hard dependency for all authentication. It moves session rows; the stale caches hold org policy/version/membership, so it does not address this bug at all.ioredis+lib/core/config/redis.tsalready exist as an optional dependency. Kept separate because pub/sub is fire-and-forget: it narrows the window but cannot close it, so the in-process fix is required either way.NEGATIVE_MEMBERSHIP_CACHE_TTL_MSto cut hot-path queries. Measured the load first: thememberlookup is a unique-index point lookup on a tiny table, and the added volume is a small fraction of what a single authenticated request already spends on authz. Meanwhile the 15s value is load-bearing — Better Auth's SSO plugin writesmemberrows straight through the adapter and this app registers nomemberhook, so raising it would widen the revocation-bypass window for JIT-provisioned members to buy a negligible saving.Checklist