feat(auth): org session policies — lifetime/idle limits, org-wide revocation (#5862)

* feat(auth): org session policies — lifetime/idle limits, org-wide revocation, cookie-cache versioning

* refactor(auth): consolidate session-policy clamp semantics, shared security-policy version module, canonical bounds, docs

* polish(session-policy): cleanup pass — muted field labels, spinner reset, state tracker, response-seeded baseline, comment trims

* fix(session-policy): govern member sessions by membership (closes revoke cookie-cache hole), normalize createdAt, remount on org switch, sync audit mock

* fix(session-policy): clamp pre-join sessions on invite acceptance, normalize expiresAt, sync unified nav test

* fix(session-policy): invalidate membership cache on removal/transfer, spare impersonator sessions in revoke-all, raise idle floor to 2x cookie window

* fix(session-policy): resolve governing org by membership only — activeOrganizationId goes stale across transfer/leave

* fix(session-policy): atomic policy save + eager clamp, asymmetric membership TTL, admin-add cache invalidation

* fix(session-policy): org-scoped cookie version string, atomic revoke delete+bump

* fix(session-policy): plan-gate effective policy so downgraded orgs stop enforcing automatically

* chore(session-policy): drop dead bumpSecurityPolicyVersion helper — call sites bump transactionally

* fix(session-policy): unify join paths on applySessionPolicyToNewMember; final audit polish (dead exports, response bound, test name)
This commit is contained in:
Waleed
2026-07-22 16:42:59 -07:00
committed by GitHub
parent 8cce661a37
commit 2b5a92a3c8
27 changed files with 18955 additions and 7 deletions
+2
View File
@@ -131,6 +131,8 @@ export const AuditAction = {
// Organizations
ORGANIZATION_CREATED: 'organization.created',
ORGANIZATION_UPDATED: 'organization.updated',
ORGANIZATION_SESSION_POLICY_UPDATED: 'organization.session_policy.updated',
ORGANIZATION_SESSIONS_REVOKED: 'organization.sessions.revoked',
ORG_MEMBER_ADDED: 'org_member.added',
ORG_MEMBER_REMOVED: 'org_member.removed',
ORG_MEMBER_ROLE_CHANGED: 'org_member.role_changed',
@@ -0,0 +1,2 @@
ALTER TABLE "organization" ADD COLUMN "session_policy_settings" json;--> statement-breakpoint
ALTER TABLE "organization" ADD COLUMN "security_policy_version" integer DEFAULT 1 NOT NULL;
File diff suppressed because it is too large Load Diff
@@ -1849,6 +1849,13 @@
"when": 1784680777879,
"tag": "0264_fat_ikaris",
"breakpoints": true
},
{
"idx": 265,
"version": "7",
"when": 1784756534601,
"tag": "0265_org_session_policy",
"breakpoints": true
}
]
}
+24
View File
@@ -1230,12 +1230,36 @@ export interface DataRetentionSettings {
retentionOverrides?: RetentionOverride[] | null
}
/**
* Org-level session policy (enterprise). Absent or empty = Better Auth
* defaults (30-day sliding sessions). `maxSessionHours` caps absolute session
* lifetime from creation; `idleTimeoutHours` caps time between refreshes.
* Enforced by clamping `session.expiresAt` in the Better Auth session
* create/update database hooks; `securityPolicyVersion` invalidates cached
* session cookies org-wide when bumped.
*/
export interface SessionPolicySettings {
/** Absolute session lifetime cap in hours from session creation. */
maxSessionHours?: number | null
/** Idle timeout in hours — session expires this long after its last refresh. */
idleTimeoutHours?: number | null
}
export const organization = pgTable('organization', {
id: text('id').primaryKey(),
name: text('name').notNull(),
slug: text('slug').notNull(),
logo: text('logo'),
metadata: json('metadata'),
sessionPolicySettings: json('session_policy_settings').$type<SessionPolicySettings>(),
/**
* Monotonic counter embedded in the Better Auth cookie-cache version for
* this org's members. Bumped on any security-policy change or org-wide
* session revocation so every cached session cookie in the org is
* invalidated (falls through to a DB session read) within the policy
* cache TTL instead of the 24h cookie-cache lifetime.
*/
securityPolicyVersion: integer('security_policy_version').notNull().default(1),
whitelabelSettings: json('whitelabel_settings').$type<{
brandName?: string
logoUrl?: string
+2
View File
@@ -105,6 +105,8 @@ export const auditMock = {
PASSWORD_RESET_REQUESTED: 'password.reset_requested',
ORGANIZATION_CREATED: 'organization.created',
ORGANIZATION_UPDATED: 'organization.updated',
ORGANIZATION_SESSION_POLICY_UPDATED: 'organization.session_policy.updated',
ORGANIZATION_SESSIONS_REVOKED: 'organization.sessions.revoked',
ORG_MEMBER_ADDED: 'org_member.added',
ORG_MEMBER_REMOVED: 'org_member.removed',
ORG_MEMBER_ROLE_CHANGED: 'org_member.role_changed',