mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-25 09:34:44 +08:00
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:
@@ -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',
|
||||
])
|
||||
|
||||
@@ -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,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: {
|
||||
|
||||
@@ -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)}`
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user