Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/docs/content/docs/en/platform/enterprise/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"pages": [
"index",
"sso",
"session-policies",
"access-control",
"custom-blocks",
"whitelabeling",
Expand Down
76 changes: 76 additions & 0 deletions apps/docs/content/docs/en/platform/enterprise/session-policies.mdx
Original file line number Diff line number Diff line change
@@ -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.',
},
]}
/>
172 changes: 172 additions & 0 deletions apps/sim/app/api/organizations/[id]/session-policy/route.test.ts
Original file line number Diff line number Diff line change
@@ -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()
)
})
})
})
Loading
Loading