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
@@ -3,6 +3,7 @@
"pages": [
"index",
"sso",
"session-policies",
"access-control",
"custom-blocks",
"whitelabeling",
@@ -0,0 +1,76 @@
---
title: Session Policies
description: Set session lifetime limits and sign out every member of your organization at once
---
import { FAQ } from '@/components/ui/faq'
Session Policies let organization owners and admins on Enterprise plans control how long member sign-in sessions last, and sign out every member org-wide in one action. Policies apply to every member of the organization on every device.
---
## Setup
Go to **Settings → Security → Session policies** in your organization settings.
Both limits are optional. Leave a field empty to keep the default behavior: sessions last 30 days and extend automatically while a member stays active.
---
## Settings
### Max session lifetime
Caps how long a session can exist from the moment a member signs in, regardless of activity. When the limit is reached, the member must sign in again.
Use this to enforce periodic re-authentication — for example, a value of `168` requires everyone to sign in again at least weekly. Accepts 1 to 8760 hours (1 year).
### Idle timeout
Signs a member out after this many hours without activity. Activity extends the session, so members who use Sim regularly stay signed in; dormant sessions expire.
Accepts 48 to 8760 hours. The 48-hour minimum exists because session activity is recorded at most once per day — a shorter window could sign out members who are actively working.
### Sign out all members
The **Sign out all members** action immediately revokes every member session in the organization except your own. Members are signed out on their next request — typically within a minute — and must sign in again.
Use this after a security incident, an offboarding wave, or before tightening a policy you want to take effect everywhere at once.
---
## How enforcement works
- **New sign-ins** get an expiry that respects the policy from the moment the session is created.
- **Existing sessions** are shortened immediately when you save a tighter policy — no member keeps a longer session than the new policy allows.
- **Loosening a policy never extends existing sessions.** Members pick up the longer limit the next time they sign in.
- Changes propagate to active members within about a minute; there is no need to redeploy or wait for sessions to naturally expire.
---
## FAQ
<FAQ
items={[
{
question: 'Do session policies apply to SSO sign-ins?',
answer:
'Yes. Sessions created through SSO follow the same lifetime and idle limits as any other sign-in method. Your identity provider may enforce its own, stricter session rules on top.',
},
{
question: 'What happens to a member who is working when their session expires?',
answer:
'They are redirected to sign in again on their next request. Unsaved workflow changes in the editor are preserved by the collaborative canvas, which continuously syncs edits.',
},
{
question: 'Does "Sign out all members" affect API keys or running workflows?',
answer:
'No. It revokes browser sign-in sessions only. API keys, deployed workflows, webhooks, and schedules keep working — manage those separately from the API keys settings.',
},
{
question: 'Why is the minimum idle timeout 48 hours?',
answer:
'Session activity is recorded at most once per 24 hours for performance. The minimum is twice that window so a member who stays active always registers activity before the idle limit can expire their session.',
},
]}
/>
@@ -0,0 +1,172 @@
/**
* @vitest-environment node
*/
import { member, organization } from '@sim/db/schema'
import {
createMockRequest,
dbChainMock,
dbChainMockFns,
queueTableRows,
resetDbChainMock,
} from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockGetSession, mockIsEnterprise, mockEagerClamp, mockRecordAudit } = vi.hoisted(() => ({
mockGetSession: vi.fn(),
mockIsEnterprise: vi.fn(),
mockEagerClamp: vi.fn(),
mockRecordAudit: vi.fn(),
}))
vi.mock('@sim/db', () => dbChainMock)
vi.mock('@/lib/auth', () => ({
getSession: mockGetSession,
}))
vi.mock('@/lib/auth/session-policy', () => ({
eagerClampOrgSessions: mockEagerClamp,
invalidateSessionPolicyCache: vi.fn(),
}))
vi.mock('@/lib/auth/security-policy', () => ({
invalidateSecurityPolicyVersionCache: vi.fn(),
}))
vi.mock('@/lib/billing/core/subscription', () => ({
isOrganizationOnEnterprisePlan: mockIsEnterprise,
}))
vi.mock('@/lib/core/config/env-flags', () => ({
isBillingEnabled: true,
}))
vi.mock('@sim/audit', () => ({
recordAudit: mockRecordAudit,
AuditAction: {
ORGANIZATION_SESSION_POLICY_UPDATED: 'organization.session_policy.updated',
},
AuditResourceType: { ORGANIZATION: 'organization' },
}))
import { GET, PUT } from '@/app/api/organizations/[id]/session-policy/route'
const ORG_ID = 'org-1'
const routeContext = { params: Promise.resolve({ id: ORG_ID }) }
describe('session policy route', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
mockGetSession.mockResolvedValue({
user: { id: 'user-1', name: 'Admin', email: 'admin@acme.dev' },
session: { token: 'tok-1' },
})
mockIsEnterprise.mockResolvedValue(true)
})
describe('GET', () => {
it('returns 401 when unauthenticated', async () => {
mockGetSession.mockResolvedValue(null)
const response = await GET(createMockRequest('GET'), routeContext)
expect(response.status).toBe(401)
})
it('returns 403 for non-members', async () => {
queueTableRows(member, [])
const response = await GET(createMockRequest('GET'), routeContext)
expect(response.status).toBe(403)
})
it('returns the configured policy for members', async () => {
queueTableRows(member, [{ id: 'member-1' }])
queueTableRows(organization, [
{ sessionPolicySettings: { maxSessionHours: 72, idleTimeoutHours: null } },
])
const response = await GET(createMockRequest('GET'), routeContext)
expect(response.status).toBe(200)
const body = await response.json()
expect(body.data).toEqual({
isEnterprise: true,
configured: { maxSessionHours: 72, idleTimeoutHours: null },
})
})
})
describe('PUT', () => {
function putRequest(body: unknown) {
return createMockRequest('PUT', body)
}
it('rejects non-admin members', async () => {
queueTableRows(member, [{ role: 'member' }])
const response = await PUT(
putRequest({ maxSessionHours: 72, idleTimeoutHours: null }),
routeContext
)
expect(response.status).toBe(403)
})
it('rejects an idle timeout below the cookie-cache window', async () => {
queueTableRows(member, [{ role: 'admin' }])
const response = await PUT(
putRequest({ maxSessionHours: null, idleTimeoutHours: 5 }),
routeContext
)
expect(response.status).toBe(400)
})
it('rejects non-enterprise organizations', async () => {
queueTableRows(member, [{ role: 'owner' }])
mockIsEnterprise.mockResolvedValue(false)
const response = await PUT(
putRequest({ maxSessionHours: 72, idleTimeoutHours: null }),
routeContext
)
expect(response.status).toBe(403)
})
it('saves the policy, eagerly clamps sessions, and bumps the version', async () => {
queueTableRows(member, [{ role: 'owner' }])
queueTableRows(organization, [{ name: 'Acme' }])
dbChainMockFns.returning.mockResolvedValueOnce([{ id: ORG_ID }])
const response = await PUT(
putRequest({ maxSessionHours: 72, idleTimeoutHours: 48 }),
routeContext
)
expect(response.status).toBe(200)
const body = await response.json()
expect(body.data.configured).toEqual({ maxSessionHours: 72, idleTimeoutHours: 48 })
expect(mockEagerClamp).toHaveBeenCalledWith(
ORG_ID,
{ maxSessionHours: 72, idleTimeoutHours: 48 },
expect.anything()
)
// The version bump rides the settings UPDATE (single round trip).
expect(dbChainMockFns.set).toHaveBeenCalledWith(
expect.objectContaining({ securityPolicyVersion: expect.anything() })
)
expect(mockRecordAudit).toHaveBeenCalledWith(
expect.objectContaining({ action: 'organization.session_policy.updated' })
)
})
it('clearing both fields still saves and delegates the no-op to the clamp', async () => {
queueTableRows(member, [{ role: 'owner' }])
queueTableRows(organization, [{ name: 'Acme' }])
dbChainMockFns.returning.mockResolvedValueOnce([{ id: ORG_ID }])
const response = await PUT(
putRequest({ maxSessionHours: null, idleTimeoutHours: null }),
routeContext
)
expect(response.status).toBe(200)
expect(mockEagerClamp).toHaveBeenCalledWith(
ORG_ID,
{ maxSessionHours: null, idleTimeoutHours: null },
expect.anything()
)
})
})
})
@@ -0,0 +1,190 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import type { SessionPolicySettings } from '@sim/db/schema'
import { member, organization } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
import { and, eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { updateOrganizationSessionPolicyContract } from '@/lib/api/contracts/organization'
import { parseRequest, validationErrorResponse } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { invalidateSecurityPolicyVersionCache } from '@/lib/auth/security-policy'
import { eagerClampOrgSessions, invalidateSessionPolicyCache } from '@/lib/auth/session-policy'
import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription'
import { isBillingEnabled } from '@/lib/core/config/env-flags'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('SessionPolicyAPI')
function normalizeConfigured(settings: SessionPolicySettings | null | undefined) {
return {
maxSessionHours: settings?.maxSessionHours ?? null,
idleTimeoutHours: settings?.idleTimeoutHours ?? null,
}
}
/**
* GET /api/organizations/[id]/session-policy
* Returns the organization's session policy. Accessible by any member.
*/
export const GET = withRouteHandler(
async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: organizationId } = await params
const [memberEntry] = await db
.select({ id: member.id })
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
.limit(1)
if (!memberEntry) {
return NextResponse.json(
{ error: 'Forbidden - Not a member of this organization' },
{ status: 403 }
)
}
const [org] = await db
.select({ sessionPolicySettings: organization.sessionPolicySettings })
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)
if (!org) {
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
}
const isEnterprise = !isBillingEnabled || (await isOrganizationOnEnterprisePlan(organizationId))
return NextResponse.json({
success: true,
data: {
isEnterprise,
configured: normalizeConfigured(org.sessionPolicySettings),
},
})
}
)
/**
* PUT /api/organizations/[id]/session-policy
* Updates the organization's session policy and bumps the security-policy
* version so existing cached session cookies re-validate against the new
* policy. Requires enterprise plan and owner/admin role.
*/
export const PUT = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(updateOrganizationSessionPolicyContract, request, context, {
validationErrorResponse: (err) => validationErrorResponse(err, 'Invalid request body'),
})
if (!parsed.success) return parsed.response
const { id: organizationId } = parsed.data.params
const body = parsed.data.body
const [memberEntry] = await db
.select({ role: member.role })
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
.limit(1)
if (!memberEntry) {
return NextResponse.json(
{ error: 'Forbidden - Not a member of this organization' },
{ status: 403 }
)
}
if (!isOrgAdminRole(memberEntry.role)) {
return NextResponse.json(
{ error: 'Forbidden - Only organization owners and admins can update the session policy' },
{ status: 403 }
)
}
if (isBillingEnabled) {
const hasEnterprise = await isOrganizationOnEnterprisePlan(organizationId)
if (!hasEnterprise) {
return NextResponse.json(
{ error: 'Session policies are available on Enterprise plans only' },
{ status: 403 }
)
}
}
const [currentOrg] = await db
.select({ name: organization.name })
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)
if (!currentOrg) {
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
}
const merged = {
maxSessionHours: body.maxSessionHours,
idleTimeoutHours: body.idleTimeoutHours,
}
// Settings write (with the version bump riding the same row) and the
// eager clamp of existing sessions commit atomically — a stored policy is
// never left unenforced by a partial failure.
const updated = await db.transaction(async (tx) => {
const [row] = await tx
.update(organization)
.set({
sessionPolicySettings: merged,
securityPolicyVersion: sql`${organization.securityPolicyVersion} + 1`,
updatedAt: new Date(),
})
.where(eq(organization.id, organizationId))
.returning({ id: organization.id })
if (!row) return null
await eagerClampOrgSessions(organizationId, merged, tx)
return row
})
if (!updated) {
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
}
invalidateSessionPolicyCache(organizationId)
invalidateSecurityPolicyVersionCache(organizationId)
logger.info('Updated organization session policy', { organizationId })
recordAudit({
workspaceId: null,
actorId: session.user.id,
action: AuditAction.ORGANIZATION_SESSION_POLICY_UPDATED,
resourceType: AuditResourceType.ORGANIZATION,
resourceId: organizationId,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: currentOrg.name,
description: 'Updated session policy',
metadata: { changes: body },
request,
})
return NextResponse.json({
success: true,
data: {
isEnterprise: true,
configured: normalizeConfigured(merged),
},
})
}
)
@@ -0,0 +1,134 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { member, organization, session as sessionTable } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
import { and, eq, inArray, isNull, ne, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { revokeOrganizationSessionsContract } from '@/lib/api/contracts/organization'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { invalidateSecurityPolicyVersionCache } from '@/lib/auth/security-policy'
import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription'
import { isBillingEnabled } from '@/lib/core/config/env-flags'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('OrgSessionsRevokeAPI')
/**
* POST /api/organizations/[id]/sessions/revoke
* Deletes every member session in the organization except the caller's
* current one, then bumps the security-policy version so cached session
* cookies invalidate on their next request. Requires enterprise plan and
* owner/admin role. Impersonation sessions are platform tooling and spared.
*/
export const POST = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(revokeOrganizationSessionsContract, request, context)
if (!parsed.success) return parsed.response
const { id: organizationId } = parsed.data.params
const [memberEntry] = await db
.select({ role: member.role })
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
.limit(1)
if (!memberEntry) {
return NextResponse.json(
{ error: 'Forbidden - Not a member of this organization' },
{ status: 403 }
)
}
if (!isOrgAdminRole(memberEntry.role)) {
return NextResponse.json(
{ error: 'Forbidden - Only organization owners and admins can revoke sessions' },
{ status: 403 }
)
}
if (isBillingEnabled) {
const hasEnterprise = await isOrganizationOnEnterprisePlan(organizationId)
if (!hasEnterprise) {
return NextResponse.json(
{ error: 'Session management is available on Enterprise plans only' },
{ status: 403 }
)
}
}
const [org] = await db
.select({ name: organization.name })
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)
if (!org) {
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
}
// When the caller is a platform admin impersonating an org member, the
// current token is the impersonation session — also spare the admin's own
// real sessions so ending impersonation doesn't leave them signed out.
const impersonatorId =
(session.session as { impersonatedBy?: string | null }).impersonatedBy ?? null
// Delete and version bump commit atomically: a bump failure must roll the
// delete back, or members would stay authenticated from the cookie cache
// for up to 24h with their DB sessions already gone.
const revoked = await db.transaction(async (tx) => {
const deleted = await tx
.delete(sessionTable)
.where(
and(
inArray(
sessionTable.userId,
tx
.select({ userId: member.userId })
.from(member)
.where(eq(member.organizationId, organizationId))
),
isNull(sessionTable.impersonatedBy),
ne(sessionTable.token, session.session.token),
...(impersonatorId ? [ne(sessionTable.userId, impersonatorId)] : [])
)
)
.returning({ id: sessionTable.id })
await tx
.update(organization)
.set({ securityPolicyVersion: sql`${organization.securityPolicyVersion} + 1` })
.where(eq(organization.id, organizationId))
return deleted
})
invalidateSecurityPolicyVersionCache(organizationId)
logger.info('Revoked organization sessions', {
organizationId,
revokedSessions: revoked.length,
})
recordAudit({
workspaceId: null,
actorId: session.user.id,
action: AuditAction.ORGANIZATION_SESSIONS_REVOKED,
resourceType: AuditResourceType.ORGANIZATION,
resourceId: organizationId,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: org.name,
description: `Revoked ${revoked.length} member session${revoked.length === 1 ? '' : 's'}`,
metadata: { revokedSessions: revoked.length },
request,
})
return NextResponse.json({
success: true,
data: { revokedSessions: revoked.length },
})
}
)
@@ -81,6 +81,11 @@ const AuditLogs = dynamic(() =>
import('@/ee/audit-logs/components/audit-logs').then((m) => m.AuditLogs)
)
const SSO = dynamic(() => import('@/ee/sso/components/sso-settings').then((m) => m.SSO))
const SessionPolicySettings = dynamic(() =>
import('@/ee/session-policy/components/session-policy-settings').then(
(m) => m.SessionPolicySettings
)
)
const DataRetentionSettings = dynamic(() =>
import('@/ee/data-retention/components/data-retention-settings').then(
(m) => m.DataRetentionSettings
@@ -158,6 +163,9 @@ export function SettingsPage({ section }: SettingsPageProps) {
/>
)}
{effectiveSection === 'sso' && organizationId && <SSO organizationId={organizationId} />}
{effectiveSection === 'sessions' && organizationId && (
<SessionPolicySettings key={organizationId} organizationId={organizationId} />
)}
{effectiveSection === 'data-retention' && organizationId && (
<DataRetentionSettings organizationId={organizationId} />
)}
@@ -39,6 +39,7 @@ describe('unified settings navigation', () => {
{ id: 'inbox', label: 'Sim mailer', section: 'system' },
{ id: 'recently-deleted', label: 'Recently deleted', section: 'system' },
{ id: 'sso', label: 'Single sign-on', section: 'enterprise' },
{ id: 'sessions', label: 'Session policies', section: 'enterprise' },
{ id: 'data-retention', label: 'Data retention', section: 'enterprise' },
{ id: 'data-drains', label: 'Data drains', section: 'enterprise' },
{ id: 'whitelabeling', label: 'Whitelabeling', section: 'enterprise' },
@@ -42,6 +42,7 @@ describe('settings navigation boundaries', () => {
'inbox',
'recently-deleted',
'sso',
'sessions',
'data-retention',
'data-drains',
'whitelabeling',
@@ -63,6 +64,7 @@ describe('settings navigation boundaries', () => {
'access-control',
'audit-logs',
'sso',
'sessions',
'data-retention',
'data-drains',
'whitelabeling',
@@ -118,6 +120,7 @@ describe('settings navigation boundaries', () => {
'data-drains',
'data-retention',
'organization',
'sessions',
'sso',
'whitelabeling',
])
+24 -3
View File
@@ -1,6 +1,7 @@
import type { ComponentType } from 'react'
import {
ClipboardList,
Clock,
Database,
HexSimple,
Key,
@@ -41,6 +42,7 @@ export type OrganizationSettingsSection =
| 'access-control'
| 'audit-logs'
| 'sso'
| 'sessions'
| 'data-retention'
| 'data-drains'
| 'whitelabeling'
@@ -94,6 +96,7 @@ export type UnifiedSettingsSection =
| 'workflow-mcp-servers'
| 'inbox'
| 'admin'
| 'sessions'
| 'data-retention'
| 'data-drains'
| 'mothership'
@@ -168,6 +171,7 @@ const SETTINGS_SELF_HOSTED_OVERRIDES = {
dataDrains: isTruthy(getEnv('NEXT_PUBLIC_DATA_DRAINS_ENABLED')),
dataRetention: isTruthy(getEnv('NEXT_PUBLIC_DATA_RETENTION_ENABLED')),
inbox: isTruthy(getEnv('NEXT_PUBLIC_INBOX_ENABLED')),
sessionPolicies: isTruthy(getEnv('NEXT_PUBLIC_SESSION_POLICIES_ENABLED')),
sso: isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED')),
whitelabeling: isTruthy(getEnv('NEXT_PUBLIC_WHITELABELING_ENABLED')),
} as const
@@ -537,6 +541,22 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[]
organization: { id: 'sso', group: 'security', order: 4 },
},
},
{
label: 'Session policies',
icon: Clock,
docsLink: 'https://docs.sim.ai/platform/enterprise/session-policies',
unified: {
id: 'sessions',
description: 'Limit session lifetimes and sign out members org-wide.',
group: 'enterprise',
requiresHosted: true,
requiresEnterprise: true,
selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.sessionPolicies,
},
planes: {
organization: { id: 'sessions', group: 'security', order: 5 },
},
},
{
label: 'Data retention',
icon: Database,
@@ -551,7 +571,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[]
selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.dataRetention,
},
planes: {
organization: { id: 'data-retention', group: 'enterprise', order: 5 },
organization: { id: 'data-retention', group: 'enterprise', order: 6 },
},
},
{
@@ -567,7 +587,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[]
selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.dataDrains,
},
planes: {
organization: { id: 'data-drains', group: 'enterprise', order: 6 },
organization: { id: 'data-drains', group: 'enterprise', order: 7 },
},
},
{
@@ -583,7 +603,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[]
selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.whitelabeling,
},
planes: {
organization: { id: 'whitelabeling', group: 'enterprise', order: 7 },
organization: { id: 'whitelabeling', group: 'enterprise', order: 8 },
},
},
{
@@ -719,6 +739,7 @@ export function getOrganizationSettingsFeatures(
'access-control': SETTINGS_SELF_HOSTED_OVERRIDES.accessControl,
'audit-logs': SETTINGS_SELF_HOSTED_OVERRIDES.auditLogs,
sso: SETTINGS_SELF_HOSTED_OVERRIDES.sso,
sessions: SETTINGS_SELF_HOSTED_OVERRIDES.sessionPolicies,
'data-retention': SETTINGS_SELF_HOSTED_OVERRIDES.dataRetention,
'data-drains': SETTINGS_SELF_HOSTED_OVERRIDES.dataDrains,
whitelabeling: SETTINGS_SELF_HOSTED_OVERRIDES.whitelabeling,
@@ -23,6 +23,11 @@ const AuditLogs = dynamic(() =>
import('@/ee/audit-logs/components/audit-logs').then((module) => module.AuditLogs)
)
const SSO = dynamic(() => import('@/ee/sso/components/sso-settings').then((module) => module.SSO))
const SessionPolicySettings = dynamic(() =>
import('@/ee/session-policy/components/session-policy-settings').then(
(module) => module.SessionPolicySettings
)
)
const DataRetentionSettings = dynamic(() =>
import('@/ee/data-retention/components/data-retention-settings').then(
(module) => module.DataRetentionSettings
@@ -63,6 +68,9 @@ export function OrganizationSettingsRenderer({
}
if (section === 'audit-logs') return <AuditLogs organizationId={organizationId} />
if (section === 'sso') return <SSO organizationId={organizationId} />
if (section === 'sessions') {
return <SessionPolicySettings key={organizationId} organizationId={organizationId} />
}
if (section === 'data-retention') {
return <DataRetentionSettings organizationId={organizationId} />
}
@@ -0,0 +1,215 @@
'use client'
import { useEffect, useState } from 'react'
import { ChipConfirmModal, ChipInput, Label, toast } from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
import {
MAX_SESSION_POLICY_HOURS,
MIN_IDLE_TIMEOUT_HOURS,
MIN_SESSION_LIFETIME_HOURS,
} from '@/lib/api/contracts/organization'
import { isBillingEnabled } from '@/lib/core/config/env-flags'
import { saveDiscardActions } from '@/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard'
import {
useOrganizationSessionPolicy,
useRevokeOrganizationSessions,
useUpdateOrganizationSessionPolicy,
} from '@/ee/session-policy/hooks/session-policy'
interface SessionPolicySettingsProps {
organizationId: string
}
interface HourFieldProps {
id: string
title: string
hint: string
value: string
onChange: (value: string) => void
}
function HourField({ id, title, hint, value, onChange }: HourFieldProps) {
return (
<div className='flex flex-col gap-[9px]'>
<Label htmlFor={id} className='font-normal text-[var(--text-muted)]'>
{title}
</Label>
<ChipInput
id={id}
type='number'
inputMode='numeric'
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder='No limit'
className='w-[220px]'
inputClassName='[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none'
/>
<p className='text-[var(--text-muted)] text-caption'>{hint}</p>
</div>
)
}
function parseHours(value: string): number | null {
if (value.trim() === '') return null
const parsed = Number(value)
return Number.isInteger(parsed) ? parsed : Number.NaN
}
export function SessionPolicySettings({ organizationId }: SessionPolicySettingsProps) {
const { data, isLoading } = useOrganizationSessionPolicy(organizationId)
const updatePolicy = useUpdateOrganizationSessionPolicy()
const revokeSessions = useRevokeOrganizationSessions()
const [maxSessionHours, setMaxSessionHours] = useState('')
const [idleTimeoutHours, setIdleTimeoutHours] = useState('')
const [savedMaxSessionHours, setSavedMaxSessionHours] = useState('')
const [savedIdleTimeoutHours, setSavedIdleTimeoutHours] = useState('')
const [showRevokeConfirm, setShowRevokeConfirm] = useState(false)
const [formInitialized, setFormInitialized] = useState(false)
useEffect(() => {
if (!data || formInitialized) return
const max = data.configured.maxSessionHours?.toString() ?? ''
const idle = data.configured.idleTimeoutHours?.toString() ?? ''
setMaxSessionHours(max)
setIdleTimeoutHours(idle)
setSavedMaxSessionHours(max)
setSavedIdleTimeoutHours(idle)
setFormInitialized(true)
}, [data, formInitialized])
const hasChanges =
formInitialized &&
(maxSessionHours !== savedMaxSessionHours || idleTimeoutHours !== savedIdleTimeoutHours)
useSettingsUnsavedGuard({ isDirty: hasChanges })
if (isLoading) {
return (
<SettingsPanel>
<SettingsEmptyState>Loading session policy...</SettingsEmptyState>
</SettingsPanel>
)
}
if (isBillingEnabled && data && !data.isEnterprise) {
return (
<SettingsPanel>
<SettingsEmptyState>
Session policies are available on Enterprise plans only.
</SettingsEmptyState>
</SettingsPanel>
)
}
async function handleSave() {
const max = parseHours(maxSessionHours)
const idle = parseHours(idleTimeoutHours)
if (
Number.isNaN(max) ||
(max !== null && (max < MIN_SESSION_LIFETIME_HOURS || max > MAX_SESSION_POLICY_HOURS))
) {
toast.error(
`Max session lifetime must be a whole number between ${MIN_SESSION_LIFETIME_HOURS} and ${MAX_SESSION_POLICY_HOURS}`
)
return
}
if (
Number.isNaN(idle) ||
(idle !== null && (idle < MIN_IDLE_TIMEOUT_HOURS || idle > MAX_SESSION_POLICY_HOURS))
) {
toast.error(
`Idle timeout must be a whole number between ${MIN_IDLE_TIMEOUT_HOURS} and ${MAX_SESSION_POLICY_HOURS}`
)
return
}
try {
const result = await updatePolicy.mutateAsync({
orgId: organizationId,
settings: { maxSessionHours: max, idleTimeoutHours: idle },
})
const savedMax = result.data.configured.maxSessionHours?.toString() ?? ''
const savedIdle = result.data.configured.idleTimeoutHours?.toString() ?? ''
setMaxSessionHours(savedMax)
setIdleTimeoutHours(savedIdle)
setSavedMaxSessionHours(savedMax)
setSavedIdleTimeoutHours(savedIdle)
toast.success('Session policy updated')
} catch (error) {
toast.error(getErrorMessage(error, 'Failed to update session policy'))
}
}
function handleDiscard() {
setMaxSessionHours(savedMaxSessionHours)
setIdleTimeoutHours(savedIdleTimeoutHours)
}
async function handleConfirmRevoke() {
try {
const result = await revokeSessions.mutateAsync({ orgId: organizationId })
setShowRevokeConfirm(false)
toast.success(
`Signed out ${result.data.revokedSessions} session${result.data.revokedSessions === 1 ? '' : 's'}`
)
} catch (error) {
toast.error(getErrorMessage(error, 'Failed to revoke sessions'))
}
}
return (
<>
<SettingsPanel
actions={[
{
text: 'Sign out all members',
variant: 'destructive',
onSelect: () => setShowRevokeConfirm(true),
},
...saveDiscardActions({
dirty: hasChanges,
saving: updatePolicy.isPending,
onSave: handleSave,
onDiscard: handleDiscard,
}),
]}
>
<div className='flex flex-col gap-7'>
<HourField
id='max-session-hours'
title='Max session lifetime (hours)'
hint='Members must sign in again this many hours after signing in, regardless of activity. Leave empty for the default 30-day sliding session.'
value={maxSessionHours}
onChange={setMaxSessionHours}
/>
<HourField
id='idle-timeout-hours'
title='Idle timeout (hours)'
hint={`Sessions expire after this many hours without activity. Minimum ${MIN_IDLE_TIMEOUT_HOURS} hours.`}
value={idleTimeoutHours}
onChange={setIdleTimeoutHours}
/>
</div>
</SettingsPanel>
<ChipConfirmModal
open={showRevokeConfirm}
onOpenChange={setShowRevokeConfirm}
title='Sign out all members'
text={[
'Every member of this organization will be signed out on their next request, except your current session. Members will need to sign in again.',
]}
confirm={{
label: 'Sign out all',
onClick: handleConfirmRevoke,
pending: revokeSessions.isPending,
pendingLabel: 'Signing out...',
}}
/>
</>
)
}
@@ -0,0 +1,69 @@
'use client'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { requestJson } from '@/lib/api/client/request'
import {
getOrganizationSessionPolicyContract,
type OrganizationSessionPolicy,
revokeOrganizationSessionsContract,
type UpdateOrganizationSessionPolicyBody,
updateOrganizationSessionPolicyContract,
} from '@/lib/api/contracts/organization'
export type SessionPolicyResponse = OrganizationSessionPolicy
export const SESSION_POLICY_STALE_TIME = 60 * 1000
export const sessionPolicyKeys = {
all: ['sessionPolicy'] as const,
settings: (orgId: string) => [...sessionPolicyKeys.all, 'settings', orgId] as const,
}
async function fetchSessionPolicy(
orgId: string,
signal?: AbortSignal
): Promise<SessionPolicyResponse> {
const { data } = await requestJson(getOrganizationSessionPolicyContract, {
params: { id: orgId },
signal,
})
return data
}
export function useOrganizationSessionPolicy(orgId: string | undefined) {
return useQuery({
queryKey: sessionPolicyKeys.settings(orgId ?? ''),
queryFn: ({ signal }) => fetchSessionPolicy(orgId as string, signal),
enabled: Boolean(orgId),
staleTime: SESSION_POLICY_STALE_TIME,
})
}
interface UpdateSessionPolicyVariables {
orgId: string
settings: UpdateOrganizationSessionPolicyBody
}
export function useUpdateOrganizationSessionPolicy() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ orgId, settings }: UpdateSessionPolicyVariables) =>
requestJson(updateOrganizationSessionPolicyContract, {
params: { id: orgId },
body: settings,
}),
onSettled: (_data, _error, { orgId }) => {
queryClient.invalidateQueries({ queryKey: sessionPolicyKeys.settings(orgId) })
},
})
}
export function useRevokeOrganizationSessions() {
return useMutation({
mutationFn: ({ orgId }: { orgId: string }) =>
requestJson(revokeOrganizationSessionsContract, {
params: { id: orgId },
}),
})
}
@@ -142,6 +142,65 @@ export const organizationDataRetentionResponseSchema = z.object({
data: organizationDataRetentionDataSchema,
})
/**
* Session-policy bounds — the single source for the contract validation, the
* server-side clamp (`@/lib/auth/session-policy`), and the settings UI.
* `MIN_IDLE_TIMEOUT_HOURS` is twice the session cookie-cache window (24h):
* cached reads never record activity, so a continuously active user only
* refreshes their session when the cookie cache expires. A floor of one
* window would sign out active users exactly at the cache boundary; two
* windows guarantees a DB-path refresh lands before the idle limit can.
*/
export const MIN_SESSION_LIFETIME_HOURS = 1
export const MIN_IDLE_TIMEOUT_HOURS = 48
export const MAX_SESSION_POLICY_HOURS = 8760
export const updateOrganizationSessionPolicyBodySchema = z.object({
maxSessionHours: z
.number()
.int()
.min(MIN_SESSION_LIFETIME_HOURS, 'Max session lifetime must be at least 1 hour')
.max(MAX_SESSION_POLICY_HOURS, 'Max session lifetime cannot exceed 8760 hours (1 year)')
.nullable(),
idleTimeoutHours: z
.number()
.int()
.min(
MIN_IDLE_TIMEOUT_HOURS,
'Idle timeout must be at least 48 hours — session activity is recorded at most once per 24h cookie-cache window'
)
.max(MAX_SESSION_POLICY_HOURS, 'Idle timeout cannot exceed 8760 hours (1 year)')
.nullable(),
})
export type UpdateOrganizationSessionPolicyBody = z.input<
typeof updateOrganizationSessionPolicyBodySchema
>
const organizationSessionPolicyValuesSchema = z.object({
maxSessionHours: z.number().int().nullable(),
idleTimeoutHours: z.number().int().nullable(),
})
const organizationSessionPolicyDataSchema = z.object({
isEnterprise: z.boolean(),
configured: organizationSessionPolicyValuesSchema,
})
export type OrganizationSessionPolicy = z.output<typeof organizationSessionPolicyDataSchema>
export const organizationSessionPolicyResponseSchema = z.object({
success: z.boolean(),
data: organizationSessionPolicyDataSchema,
})
export const revokeOrganizationSessionsResponseSchema = z.object({
success: z.boolean(),
data: z.object({
revokedSessions: z.number().int().min(0),
}),
})
export const updateOrganizationWhitelabelBodySchema = z.object({
brandName: z
.string()
@@ -491,6 +550,37 @@ export const updateOrganizationDataRetentionContract = defineRouteContract({
},
})
export const getOrganizationSessionPolicyContract = defineRouteContract({
method: 'GET',
path: '/api/organizations/[id]/session-policy',
params: organizationParamsSchema,
response: {
mode: 'json',
schema: organizationSessionPolicyResponseSchema,
},
})
export const updateOrganizationSessionPolicyContract = defineRouteContract({
method: 'PUT',
path: '/api/organizations/[id]/session-policy',
params: organizationParamsSchema,
body: updateOrganizationSessionPolicyBodySchema,
response: {
mode: 'json',
schema: organizationSessionPolicyResponseSchema,
},
})
export const revokeOrganizationSessionsContract = defineRouteContract({
method: 'POST',
path: '/api/organizations/[id]/sessions/revoke',
params: organizationParamsSchema,
response: {
mode: 'json',
schema: revokeOrganizationSessionsResponseSchema,
},
})
// Read shape mirrors `OrganizationWhitelabelSettings` from
// `@/lib/branding/types`. All fields are optional (nullable on the way in
// for the PUT contract, but stored without nulls on the way out — the
+34 -1
View File
@@ -34,6 +34,8 @@ import {
import { getAccessControlConfig, isEmailBlockedByAccessControl } from '@/lib/auth/access-control'
import { createAnonymousSession, ensureAnonymousUserExists } from '@/lib/auth/anonymous'
import { getRequestedSignInProviderId, isSignInProviderAllowed } from '@/lib/auth/constants'
import { getSessionCookieCacheVersion } from '@/lib/auth/security-policy'
import { clampExpiryForSession } from '@/lib/auth/session-policy'
import { guardSubscriptionPlanWrites } from '@/lib/auth/stripe-adapter-guard'
import { sendPlanWelcomeEmail } from '@/lib/billing'
import {
@@ -222,6 +224,14 @@ export const auth = betterAuth({
cookieCache: {
enabled: true,
maxAge: 24 * 60 * 60, // 24 hours in seconds
/**
* Embeds the member org's security-policy version. Bumping the version
* (policy change, org-wide revocation) invalidates every cached session
* cookie in the org on its next request, forcing a DB session read —
* revocation latency becomes the policy cache TTL, not 24h.
*/
version: async (session) =>
getSessionCookieCacheVersion(session as { userId?: string | null }),
},
expiresIn: 30 * 24 * 60 * 60, // 30 days (how long a session can last overall)
updateAge: 24 * 60 * 60, // 24 hours (how often to refresh the expiry)
@@ -654,7 +664,7 @@ export const auth = betterAuth({
try {
// Find the first organization this user is a member of
const members = await db
.select()
.select({ organizationId: schema.member.organizationId })
.from(schema.member)
.where(eq(schema.member.userId, session.userId))
.limit(1)
@@ -665,9 +675,12 @@ export const auth = betterAuth({
organizationId: members[0].organizationId,
})
const expiresAt = await clampExpiryForSession(session, members[0].organizationId)
return {
data: {
...session,
expiresAt,
activeOrganizationId: members[0].organizationId,
},
}
@@ -685,6 +698,26 @@ export const auth = betterAuth({
}
},
},
update: {
/**
* Better Auth's sliding refresh rewrites `expiresAt` to
* `now + expiresIn` (30 days), which would silently stretch a
* policy-shortened session back out — re-clamp on every refresh.
* The current session row is read from the endpoint context; when
* it is unavailable (non-refresh update paths) the update passes
* through untouched and the next refresh re-clamps.
*/
before: async (data, ctx) => {
if (!data.expiresAt) return { data }
const current = ctx?.context?.session?.session
if (!current) return { data }
const expiresAt = await clampExpiryForSession({
...current,
expiresAt: new Date(data.expiresAt),
})
return { data: { ...data, expiresAt } }
},
},
},
},
account: {
+147
View File
@@ -0,0 +1,147 @@
import { db } from '@sim/db'
import { member, organization } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
const logger = createLogger('SecurityPolicy')
/**
* How long a resolved org security-policy version is served from process
* memory before the next request re-reads it. This TTL is the effective upper
* bound on org-wide session-revocation latency: a version bump changes the
* cookie-cache version, and every cached session cookie in the org falls
* through to a DB read within one TTL.
*/
export const SECURITY_POLICY_VERSION_CACHE_TTL_MS = 60 * 1000
const DEFAULT_VERSION = 1
interface VersionCacheEntry {
version: number
fetchedAt: number
}
const versionCache = new Map<string, VersionCacheEntry>()
/**
* Resolves the org's security-policy version — the shared monotonic counter
* behind the Better Auth cookie-cache version. It backs ALL org security
* policies (session policies today; IP allowlisting and MFA enforcement are
* planned consumers): any feature that needs cached session cookies to
* re-validate bumps this one counter.
*/
export async function getSecurityPolicyVersion(
organizationId: string | null | undefined
): Promise<number> {
if (!organizationId) return DEFAULT_VERSION
const cached = versionCache.get(organizationId)
if (cached && Date.now() - cached.fetchedAt < SECURITY_POLICY_VERSION_CACHE_TTL_MS) {
return cached.version
}
try {
const [row] = await db
.select({ version: organization.securityPolicyVersion })
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)
const version = row?.version ?? DEFAULT_VERSION
versionCache.set(organizationId, { version, fetchedAt: Date.now() })
return version
} catch (error) {
logger.error('Failed to resolve security policy version; using default', {
organizationId,
error,
})
return DEFAULT_VERSION
}
}
/** Drops the cached version for an org so the next read is fresh. */
export function invalidateSecurityPolicyVersionCache(organizationId: string): void {
versionCache.delete(organizationId)
}
interface MembershipCacheEntry {
organizationId: string | null
fetchedAt: number
}
const membershipCache = new Map<string, MembershipCacheEntry>()
/**
* Negative (non-member) membership results use a much shorter TTL than
* positive ones: a user's cached `null` would otherwise let them dodge a new
* org's policy for the full TTL after joining through ANY path — including
* ones outside this codebase (Better Auth SSO JIT provisioning). Positive
* results change only through leave/transfer, which invalidate explicitly.
*/
const NEGATIVE_MEMBERSHIP_CACHE_TTL_MS = 15 * 1000
/** Drops the cached membership for a user (call when they join/leave an org). */
export function invalidateMembershipCache(userId: string): void {
membershipCache.delete(userId)
}
/**
* Resolves the org a user belongs to (users belong to at most one org),
* served from a short TTL cache. Org security policies govern MEMBERS, not
* just sessions that happen to carry an `activeOrganizationId` — a session
* created before the user joined an org has none, and without this fallback
* such sessions would dodge cookie-cache invalidation (and therefore
* org-wide revocation) for up to the 24h cookie lifetime.
*/
export async function getMemberOrganizationId(
userId: string | null | undefined
): Promise<string | null> {
if (!userId) return null
const cached = membershipCache.get(userId)
if (cached) {
const ttl = cached.organizationId
? SECURITY_POLICY_VERSION_CACHE_TTL_MS
: NEGATIVE_MEMBERSHIP_CACHE_TTL_MS
if (Date.now() - cached.fetchedAt < ttl) return cached.organizationId
}
try {
const [row] = await db
.select({ organizationId: member.organizationId })
.from(member)
.where(eq(member.userId, userId))
.limit(1)
const organizationId = row?.organizationId ?? null
membershipCache.set(userId, { organizationId, fetchedAt: Date.now() })
return organizationId
} catch (error) {
logger.error('Failed to resolve org membership; treating session as org-less', {
userId,
error,
})
return null
}
}
/**
* Cookie-cache version for a session, consumed by Better Auth's
* `session.cookieCache.version`. Embeds the member org's security-policy
* version so bumps propagate to cached cookies. Resolved from the user's
* MEMBERSHIP, never the session's `activeOrganizationId` — that field goes
* stale on join/leave/transfer (it is only written at session creation), and
* a stale org here would let cookies dodge the destination org's version
* bumps for up to the 24h cookie lifetime. Sessions of non-members use the
* static default.
*/
export async function getSessionCookieCacheVersion(session: {
userId?: string | null
}): Promise<string> {
const organizationId = await getMemberOrganizationId(session.userId)
if (!organizationId) return 'none'
// The org id is part of the version so moving between orgs always changes
// the string — two orgs whose counters happen to hold the same number must
// not produce interchangeable cookie versions.
return `${organizationId}:${await getSecurityPolicyVersion(organizationId)}`
}
+84
View File
@@ -0,0 +1,84 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { MIN_IDLE_TIMEOUT_HOURS } from '@/lib/api/contracts/organization'
import { clampSessionExpiry, type ResolvedSessionPolicy } from '@/lib/auth/session-policy'
const HOUR_MS = 60 * 60 * 1000
function policy(overrides: Partial<ResolvedSessionPolicy> = {}): ResolvedSessionPolicy {
return { maxSessionHours: null, idleTimeoutHours: null, ...overrides }
}
describe('clampSessionExpiry', () => {
const createdAt = new Date('2026-07-22T00:00:00Z')
const now = new Date('2026-07-22T12:00:00Z')
/** Better Auth's sliding refresh proposes now + 30 days. */
const proposed = new Date(now.getTime() + 30 * 24 * HOUR_MS)
it('returns the proposed time unchanged when no policy fields are set', () => {
expect(clampSessionExpiry(policy(), createdAt, proposed, now).getTime()).toBe(
proposed.getTime()
)
})
it('caps absolute lifetime at createdAt + maxSessionHours', () => {
const result = clampSessionExpiry(policy({ maxSessionHours: 24 }), createdAt, proposed, now)
expect(result.getTime()).toBe(createdAt.getTime() + 24 * HOUR_MS)
})
it('re-clamps a sliding refresh that would stretch the session back out', () => {
// 12h into a 24h-max session, a refresh proposing +30d must still end at
// createdAt + 24h — this is the regression the update hook exists for.
const midSession = new Date(createdAt.getTime() + 12 * HOUR_MS)
const refreshProposal = new Date(midSession.getTime() + 30 * 24 * HOUR_MS)
const result = clampSessionExpiry(
policy({ maxSessionHours: 24 }),
createdAt,
refreshProposal,
midSession
)
expect(result.getTime()).toBe(createdAt.getTime() + 24 * HOUR_MS)
})
it('caps idle expiry at now + idleTimeoutHours', () => {
const result = clampSessionExpiry(policy({ idleTimeoutHours: 48 }), createdAt, proposed, now)
expect(result.getTime()).toBe(now.getTime() + 48 * HOUR_MS)
})
it('floors idleTimeoutHours at twice the cookie-cache window', () => {
const result = clampSessionExpiry(policy({ idleTimeoutHours: 1 }), createdAt, proposed, now)
expect(result.getTime()).toBe(now.getTime() + MIN_IDLE_TIMEOUT_HOURS * HOUR_MS)
})
it('applies the stricter of max lifetime and idle timeout', () => {
const result = clampSessionExpiry(
policy({ maxSessionHours: 8760, idleTimeoutHours: 48 }),
createdAt,
proposed,
now
)
expect(result.getTime()).toBe(now.getTime() + 48 * HOUR_MS)
const nearEnd = new Date(createdAt.getTime() + 71 * HOUR_MS)
const endOfLife = clampSessionExpiry(
policy({ maxSessionHours: 72, idleTimeoutHours: 48 }),
createdAt,
new Date(nearEnd.getTime() + 30 * 24 * HOUR_MS),
nearEnd
)
expect(endOfLife.getTime()).toBe(createdAt.getTime() + 72 * HOUR_MS)
})
it('never extends a proposal already shorter than the policy', () => {
const shortProposal = new Date(now.getTime() + 1 * HOUR_MS)
const result = clampSessionExpiry(
policy({ maxSessionHours: 720, idleTimeoutHours: 720 }),
createdAt,
shortProposal,
now
)
expect(result.getTime()).toBe(shortProposal.getTime())
})
})
+229
View File
@@ -0,0 +1,229 @@
import { db } from '@sim/db'
import type { SessionPolicySettings } from '@sim/db/schema'
import { organization } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq, sql } from 'drizzle-orm'
import { MIN_IDLE_TIMEOUT_HOURS } from '@/lib/api/contracts/organization'
import { getMemberOrganizationId, invalidateMembershipCache } from '@/lib/auth/security-policy'
import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription'
import { isBillingEnabled } from '@/lib/core/config/env-flags'
const logger = createLogger('SessionPolicy')
/** How long a resolved org session policy is served from process memory. */
export const SESSION_POLICY_CACHE_TTL_MS = 60 * 1000
const HOUR_MS = 60 * 60 * 1000
export interface ResolvedSessionPolicy {
maxSessionHours: number | null
idleTimeoutHours: number | null
}
interface PolicyCacheEntry {
policy: ResolvedSessionPolicy
fetchedAt: number
}
const policyCache = new Map<string, PolicyCacheEntry>()
const NO_POLICY: ResolvedSessionPolicy = {
maxSessionHours: null,
idleTimeoutHours: null,
}
/**
* Resolves the EFFECTIVE session policy for an organization, served from a
* short TTL cache. Returns a no-op policy for personal (org-less) sessions
* and — mirroring data-retention's plan-gated effective settings — for
* hosted orgs no longer on an Enterprise plan: stored limits stop enforcing
* automatically on downgrade, since the enterprise-gated settings UI can no
* longer manage them.
*/
export async function getSessionPolicy(
organizationId: string | null | undefined
): Promise<ResolvedSessionPolicy> {
if (!organizationId) return NO_POLICY
const cached = policyCache.get(organizationId)
if (cached && Date.now() - cached.fetchedAt < SESSION_POLICY_CACHE_TTL_MS) {
return cached.policy
}
try {
const [row] = await db
.select({ settings: organization.sessionPolicySettings })
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)
const settings: SessionPolicySettings = row?.settings ?? {}
const hasBounds = Boolean(settings.maxSessionHours || settings.idleTimeoutHours)
const isEntitled =
!hasBounds || !isBillingEnabled || (await isOrganizationOnEnterprisePlan(organizationId))
const policy: ResolvedSessionPolicy = isEntitled
? {
maxSessionHours: settings.maxSessionHours ?? null,
idleTimeoutHours: settings.idleTimeoutHours ?? null,
}
: NO_POLICY
policyCache.set(organizationId, { policy, fetchedAt: Date.now() })
return policy
} catch (error) {
logger.error('Failed to resolve session policy; applying no policy', {
organizationId,
error,
})
return NO_POLICY
}
}
/** Drops the cached policy for an org so the next read is fresh. */
export function invalidateSessionPolicyCache(organizationId: string): void {
policyCache.delete(organizationId)
}
/**
* Clamps a proposed session `expiresAt` to the org policy:
* `min(proposed, createdAt + maxSessionHours, now + idleTimeoutHours)`.
*
* Better Auth's sliding refresh rewrites `expiresAt` to `now + expiresIn`
* (30 days) on every refresh, which would silently stretch a shortened
* session back out — so this clamp must run in BOTH the session create and
* session update database hooks. The idle floor guards values that bypassed
* contract validation (legacy rows, direct DB writes).
*/
export function clampSessionExpiry(
policy: ResolvedSessionPolicy,
createdAt: Date,
proposedExpiresAt: Date,
now: Date = new Date()
): Date {
let clamped = proposedExpiresAt.getTime()
if (policy.maxSessionHours) {
clamped = Math.min(clamped, createdAt.getTime() + policy.maxSessionHours * HOUR_MS)
}
if (policy.idleTimeoutHours) {
const idleHours = Math.max(policy.idleTimeoutHours, MIN_IDLE_TIMEOUT_HOURS)
clamped = Math.min(clamped, now.getTime() + idleHours * HOUR_MS)
}
return new Date(clamped)
}
/**
* Session shape shared by the Better Auth create/update database hooks —
* the fields the clamp guards need.
*/
interface ClampableSession {
userId?: string | null
impersonatedBy?: string | null
createdAt?: Date | string | null
expiresAt?: Date | string | null
}
/**
* Applies the org session policy to a session's proposed `expiresAt` from a
* Better Auth database hook. The governing org is the user's MEMBERSHIP —
* never the session row's `activeOrganizationId`, which goes stale on
* join/leave/transfer — matching the cookie-cache version resolution, so
* every member session (including ones created before the user joined or
* carried across a transfer) is governed consistently. Callers that have
* JUST resolved the membership themselves (the session create hook) pass it
* as `freshMembershipOrgId` to skip the duplicate lookup. Returns the
* original date when no clamp applies: impersonation sessions are
* platform-admin tooling with their own short expiry, and non-member
* sessions have no policy.
*/
export async function clampExpiryForSession(
session: ClampableSession,
freshMembershipOrgId?: string | null
): Promise<Date | undefined> {
// Better Auth context values can cross a serialization boundary — normalize
// date fields in case they arrive as ISO strings rather than Dates.
const expiresAt = session.expiresAt ? new Date(session.expiresAt) : undefined
if (!expiresAt || session.impersonatedBy) {
return expiresAt
}
const organizationId =
freshMembershipOrgId !== undefined
? freshMembershipOrgId
: await getMemberOrganizationId(session.userId)
if (!organizationId) return expiresAt
const policy = await getSessionPolicy(organizationId)
const createdAt = session.createdAt ? new Date(session.createdAt) : new Date()
return clampSessionExpiry(policy, createdAt, expiresAt)
}
/**
* Eagerly clamps every existing member session to the given policy in a
* single SQL statement — the SQL twin of {@link clampSessionExpiry}, kept in
* this module so the two encodings of the clamp cannot drift. Runs when a
* policy is saved so tightening applies without waiting for each session's
* next refresh; `LEAST` never extends an already-shorter expiry, and
* impersonation sessions are exempt. Targets sessions by org MEMBERSHIP (not
* `active_organization_id`) — the same scope the hooks govern via the
* membership fallback. No-ops when the policy sets no bounds.
*/
export async function eagerClampOrgSessions(
organizationId: string,
policy: ResolvedSessionPolicy,
executor: Pick<typeof db, 'execute'> = db
): Promise<void> {
const bounds = clampBoundsSql(policy)
if (!bounds) return
await executor.execute(sql`
UPDATE "session" SET expires_at = LEAST(${bounds})
WHERE impersonated_by IS NULL
AND user_id IN (
SELECT user_id FROM member WHERE organization_id = ${organizationId}
)
`)
}
/**
* Applies the org's session policy to a user who just JOINED the org:
* invalidates their cached membership (so the cookie-version and hook-clamp
* fallbacks see the new org immediately) and clamps their pre-join sessions,
* which otherwise keep their old expiry until the next sliding refresh.
* Best-effort by design — a failure here must never fail the join; the
* update-hook clamp self-heals within one refresh cycle.
*/
export async function applySessionPolicyToNewMember(
userId: string,
organizationId: string
): Promise<void> {
try {
invalidateMembershipCache(userId)
const policy = await getSessionPolicy(organizationId)
const bounds = clampBoundsSql(policy)
if (!bounds) return
await db.execute(sql`
UPDATE "session" SET expires_at = LEAST(${bounds})
WHERE user_id = ${userId} AND impersonated_by IS NULL
`)
} catch (error) {
logger.error('Failed to apply session policy to new member; next refresh re-clamps', {
userId,
organizationId,
error,
})
}
}
/** SQL argument list for the LEAST() clamp, or null when the policy is empty. */
function clampBoundsSql(policy: ResolvedSessionPolicy) {
const bounds = [sql`expires_at`]
if (policy.maxSessionHours) {
const maxSecs = policy.maxSessionHours * 3600
bounds.push(sql`created_at + make_interval(secs => ${maxSecs})`)
}
if (policy.idleTimeoutHours) {
const idleSecs = Math.max(policy.idleTimeoutHours, MIN_IDLE_TIMEOUT_HOURS) * 3600
bounds.push(sql`now() + make_interval(secs => ${idleSecs})`)
}
if (bounds.length === 1) return null
return sql.join(bounds, sql`, `)
}
@@ -24,6 +24,8 @@ import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { normalizeEmail } from '@sim/utils/string'
import { and, count, eq, inArray, isNull, ne, or, sql } from 'drizzle-orm'
import { invalidateMembershipCache } from '@/lib/auth/security-policy'
import { applySessionPolicyToNewMember } from '@/lib/auth/session-policy'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import {
assertNoUnresolvedEnterpriseIssuance,
@@ -630,6 +632,12 @@ export async function ensureUserInOrganization(
const result = await addUserToOrganization(params)
if (result.success) {
// Invalidates the membership cache and clamps pre-join sessions to the
// org policy — same treatment as invite acceptance. Best-effort.
await applySessionPolicyToNewMember(params.userId, params.organizationId)
}
return {
...result,
alreadyMember: false,
@@ -1343,7 +1351,7 @@ export async function transferUserBetweenOrganizations(
}
try {
return await withInvitationSafeOrganizationAccessMutation(
const transferResult = await withInvitationSafeOrganizationAccessMutation(
{
userId: params.userId,
organizationId: params.sourceOrganizationId,
@@ -1513,6 +1521,11 @@ export async function transferUserBetweenOrganizations(
}
}
)
// The transferred member's fallbacks must resolve to the destination org
// immediately, and their sessions clamp to its policy — same treatment as
// invite acceptance. Best-effort.
await applySessionPolicyToNewMember(params.userId, params.destinationOrganizationId)
return transferResult
} catch (error) {
logger.error('Failed to transfer organization member', { ...params, error })
return { success: false, error: getErrorMessage(error), ...emptyResult }
@@ -1733,6 +1746,10 @@ export async function removeUserFromOrganization(
billingActions.workspaceAccessRevoked = result.workspaceIdsToRevoke.length
billingActions.pendingInvitationsCancelled = result.pendingInvitationsCancelled
// The departed member's cookie-version/hook-clamp fallbacks must stop
// resolving to this org immediately, not after the membership-cache TTL.
invalidateMembershipCache(userId)
if (result.usageCaptured > 0) {
logger.info('Captured departed member usage', {
organizationId,
+2
View File
@@ -566,6 +566,7 @@ export const env = createEnv({
NEXT_PUBLIC_AUDIT_LOGS_ENABLED: z.boolean().optional(), // Enable audit logs on self-hosted (bypasses hosted requirements)
NEXT_PUBLIC_DATA_RETENTION_ENABLED: z.boolean().optional(), // Enable data retention settings on self-hosted (bypasses hosted requirements)
NEXT_PUBLIC_DATA_DRAINS_ENABLED: z.boolean().optional(), // Enable data drains on self-hosted (bypasses hosted requirements)
NEXT_PUBLIC_SESSION_POLICIES_ENABLED: z.boolean().optional(), // Enable org session policies on self-hosted (bypasses hosted requirements)
NEXT_PUBLIC_FORKING_ENABLED: z.boolean().optional(), // Enable workspace forking on self-hosted (bypasses hosted requirements)
NEXT_PUBLIC_WORKFLOW_COLUMNS_ENABLED: z.boolean().optional(), // Show the "Workflow" column type in user tables (defaults to false)
NEXT_PUBLIC_ORGANIZATIONS_ENABLED: z.boolean().optional(), // Enable organizations on self-hosted (bypasses plan requirements)
@@ -606,6 +607,7 @@ export const env = createEnv({
NEXT_PUBLIC_AUDIT_LOGS_ENABLED: process.env.NEXT_PUBLIC_AUDIT_LOGS_ENABLED,
NEXT_PUBLIC_DATA_RETENTION_ENABLED: process.env.NEXT_PUBLIC_DATA_RETENTION_ENABLED,
NEXT_PUBLIC_DATA_DRAINS_ENABLED: process.env.NEXT_PUBLIC_DATA_DRAINS_ENABLED,
NEXT_PUBLIC_SESSION_POLICIES_ENABLED: process.env.NEXT_PUBLIC_SESSION_POLICIES_ENABLED,
NEXT_PUBLIC_FORKING_ENABLED: process.env.NEXT_PUBLIC_FORKING_ENABLED,
NEXT_PUBLIC_WORKFLOW_COLUMNS_ENABLED: process.env.NEXT_PUBLIC_WORKFLOW_COLUMNS_ENABLED,
NEXT_PUBLIC_ORGANIZATIONS_ENABLED: process.env.NEXT_PUBLIC_ORGANIZATIONS_ENABLED,
+5
View File
@@ -19,6 +19,7 @@ import { generateId } from '@sim/utils/id'
import { normalizeEmail } from '@sim/utils/string'
import { and, eq, inArray, isNull, lte, ne, sql } from 'drizzle-orm'
import { setActiveOrganizationForCurrentSession } from '@/lib/auth/active-organization'
import { applySessionPolicyToNewMember } from '@/lib/auth/session-policy'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import {
acquireOrganizationMutationLock,
@@ -646,6 +647,10 @@ async function runInvitationAcceptancePostCommitEffects(
}
if (effects.organizationId && effects.memberRole) {
// Pre-join sessions keep their old expiry until the next sliding refresh;
// apply the org's session policy to them now (best-effort, never throws).
await applySessionPolicyToNewMember(input.userId, effects.organizationId)
recordAudit({
workspaceId: null,
actorId: input.userId,
+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',
+2 -2
View File
@@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries')
const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors')
const BASELINE = {
totalRoutes: 967,
zodRoutes: 967,
totalRoutes: 969,
zodRoutes: 969,
nonZodRoutes: 0,
} as const