Skip to content

Commit 8d27d15

Browse files
committed
refactor(auth): consolidate session-policy clamp semantics, shared security-policy version module, canonical bounds, docs
1 parent 15403c5 commit 8d27d15

12 files changed

Lines changed: 375 additions & 234 deletions

File tree

apps/docs/content/docs/en/platform/enterprise/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"pages": [
44
"index",
55
"sso",
6+
"session-policies",
67
"access-control",
78
"custom-blocks",
89
"whitelabeling",
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
---
2+
title: Session Policies
3+
description: Set session lifetime limits and sign out every member of your organization at once
4+
---
5+
6+
import { FAQ } from '@/components/ui/faq'
7+
8+
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.
9+
10+
---
11+
12+
## Setup
13+
14+
Go to **Settings → Security → Session policies** in your organization settings.
15+
16+
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.
17+
18+
---
19+
20+
## Settings
21+
22+
### Max session lifetime
23+
24+
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.
25+
26+
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).
27+
28+
### Idle timeout
29+
30+
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.
31+
32+
Accepts 24 to 8760 hours. The 24-hour minimum exists because session activity is recorded at most once per day — a shorter window would sign out members who are actively working.
33+
34+
### Sign out all members
35+
36+
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.
37+
38+
Use this after a security incident, an offboarding wave, or before tightening a policy you want to take effect everywhere at once.
39+
40+
---
41+
42+
## How enforcement works
43+
44+
- **New sign-ins** get an expiry that respects the policy from the moment the session is created.
45+
- **Existing sessions** are shortened immediately when you save a tighter policy — no member keeps a longer session than the new policy allows.
46+
- **Loosening a policy never extends existing sessions.** Members pick up the longer limit the next time they sign in.
47+
- Changes propagate to active members within about a minute; there is no need to redeploy or wait for sessions to naturally expire.
48+
49+
---
50+
51+
## FAQ
52+
53+
<FAQ
54+
items={[
55+
{
56+
question: 'Do session policies apply to SSO sign-ins?',
57+
answer:
58+
'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.',
59+
},
60+
{
61+
question: 'What happens to a member who is working when their session expires?',
62+
answer:
63+
'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.',
64+
},
65+
{
66+
question: 'Does "Sign out all members" affect API keys or running workflows?',
67+
answer:
68+
'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.',
69+
},
70+
{
71+
question: 'Why is the minimum idle timeout 24 hours?',
72+
answer:
73+
'Session activity is recorded at most once per 24 hours for performance. An idle timeout below that window could sign out members who are actively using Sim, so shorter values are not accepted.',
74+
},
75+
]}
76+
/>
Lines changed: 57 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1,79 +1,36 @@
11
/**
22
* @vitest-environment node
33
*/
4-
import { createMockRequest } from '@sim/testing'
4+
import { member, organization } from '@sim/db/schema'
5+
import {
6+
createMockRequest,
7+
dbChainMock,
8+
dbChainMockFns,
9+
queueTableRows,
10+
resetDbChainMock,
11+
} from '@sim/testing'
512
import { beforeEach, describe, expect, it, vi } from 'vitest'
613

7-
const {
8-
mockDbState,
9-
mockGetSession,
10-
mockIsEnterprise,
11-
mockBumpVersion,
12-
mockRecordAudit,
13-
mockExecute,
14-
mockUpdateReturning,
15-
} = vi.hoisted(() => ({
16-
mockDbState: { selectResults: [] as unknown[][] },
14+
const { mockGetSession, mockIsEnterprise, mockEagerClamp, mockRecordAudit } = vi.hoisted(() => ({
1715
mockGetSession: vi.fn(),
1816
mockIsEnterprise: vi.fn(),
19-
mockBumpVersion: vi.fn(),
17+
mockEagerClamp: vi.fn(),
2018
mockRecordAudit: vi.fn(),
21-
mockExecute: vi.fn(),
22-
mockUpdateReturning: vi.fn(),
2319
}))
2420

25-
function createSelectChain() {
26-
const chain = {
27-
from: vi.fn(),
28-
where: vi.fn(),
29-
limit: vi.fn(),
30-
}
31-
chain.from.mockReturnValue(chain)
32-
chain.where.mockReturnValue(chain)
33-
chain.limit.mockImplementation(() => Promise.resolve(mockDbState.selectResults.shift() ?? []))
34-
return chain
35-
}
36-
37-
function createUpdateChain() {
38-
const chain = {
39-
set: vi.fn(),
40-
where: vi.fn(),
41-
returning: vi.fn(),
42-
}
43-
chain.set.mockReturnValue(chain)
44-
chain.where.mockReturnValue(chain)
45-
chain.returning.mockImplementation(() => Promise.resolve(mockUpdateReturning()))
46-
return chain
47-
}
48-
49-
vi.mock('@sim/db', () => ({
50-
db: {
51-
select: vi.fn(() => createSelectChain()),
52-
update: vi.fn(() => createUpdateChain()),
53-
execute: mockExecute,
54-
},
55-
}))
56-
57-
vi.mock('@sim/db/schema', () => ({
58-
member: {
59-
id: 'member.id',
60-
organizationId: 'member.organizationId',
61-
userId: 'member.userId',
62-
role: 'member.role',
63-
},
64-
organization: {
65-
id: 'organization.id',
66-
name: 'organization.name',
67-
sessionPolicySettings: 'organization.sessionPolicySettings',
68-
},
69-
}))
21+
vi.mock('@sim/db', () => dbChainMock)
7022

7123
vi.mock('@/lib/auth', () => ({
7224
getSession: mockGetSession,
7325
}))
7426

7527
vi.mock('@/lib/auth/session-policy', () => ({
76-
bumpSecurityPolicyVersion: mockBumpVersion,
28+
eagerClampOrgSessions: mockEagerClamp,
29+
invalidateSessionPolicyCache: vi.fn(),
30+
}))
31+
32+
vi.mock('@/lib/auth/security-policy', () => ({
33+
invalidateSecurityPolicyVersionCache: vi.fn(),
7734
}))
7835

7936
vi.mock('@/lib/billing/core/subscription', () => ({
@@ -100,13 +57,12 @@ const routeContext = { params: Promise.resolve({ id: ORG_ID }) }
10057
describe('session policy route', () => {
10158
beforeEach(() => {
10259
vi.clearAllMocks()
103-
mockDbState.selectResults = []
60+
resetDbChainMock()
10461
mockGetSession.mockResolvedValue({
10562
user: { id: 'user-1', name: 'Admin', email: 'admin@acme.dev' },
10663
session: { token: 'tok-1' },
10764
})
10865
mockIsEnterprise.mockResolvedValue(true)
109-
mockExecute.mockResolvedValue(undefined)
11066
})
11167

11268
describe('GET', () => {
@@ -117,16 +73,16 @@ describe('session policy route', () => {
11773
})
11874

11975
it('returns 403 for non-members', async () => {
120-
mockDbState.selectResults = [[]]
76+
queueTableRows(member, [])
12177
const response = await GET(createMockRequest('GET'), routeContext)
12278
expect(response.status).toBe(403)
12379
})
12480

12581
it('returns the configured policy for members', async () => {
126-
mockDbState.selectResults = [
127-
[{ id: 'member-1' }],
128-
[{ sessionPolicySettings: { maxSessionHours: 72, idleTimeoutHours: null } }],
129-
]
82+
queueTableRows(member, [{ id: 'member-1' }])
83+
queueTableRows(organization, [
84+
{ sessionPolicySettings: { maxSessionHours: 72, idleTimeoutHours: null } },
85+
])
13086
const response = await GET(createMockRequest('GET'), routeContext)
13187
expect(response.status).toBe(200)
13288
const body = await response.json()
@@ -143,32 +99,37 @@ describe('session policy route', () => {
14399
}
144100

145101
it('rejects non-admin members', async () => {
146-
mockDbState.selectResults = [[{ role: 'member' }]]
147-
const response = await PUT(putRequest({ maxSessionHours: 72 }), routeContext)
102+
queueTableRows(member, [{ role: 'member' }])
103+
const response = await PUT(
104+
putRequest({ maxSessionHours: 72, idleTimeoutHours: null }),
105+
routeContext
106+
)
148107
expect(response.status).toBe(403)
149108
})
150109

151110
it('rejects an idle timeout below the cookie-cache window', async () => {
152-
mockDbState.selectResults = [[{ role: 'admin' }]]
153-
const response = await PUT(putRequest({ idleTimeoutHours: 5 }), routeContext)
111+
queueTableRows(member, [{ role: 'admin' }])
112+
const response = await PUT(
113+
putRequest({ maxSessionHours: null, idleTimeoutHours: 5 }),
114+
routeContext
115+
)
154116
expect(response.status).toBe(400)
155117
})
156118

157119
it('rejects non-enterprise organizations', async () => {
158-
mockDbState.selectResults = [[{ role: 'owner' }]]
120+
queueTableRows(member, [{ role: 'owner' }])
159121
mockIsEnterprise.mockResolvedValue(false)
160-
const response = await PUT(putRequest({ maxSessionHours: 72 }), routeContext)
122+
const response = await PUT(
123+
putRequest({ maxSessionHours: 72, idleTimeoutHours: null }),
124+
routeContext
125+
)
161126
expect(response.status).toBe(403)
162127
})
163128

164129
it('saves the policy, eagerly clamps sessions, and bumps the version', async () => {
165-
mockDbState.selectResults = [
166-
[{ role: 'owner' }],
167-
[{ name: 'Acme', sessionPolicySettings: null }],
168-
]
169-
mockUpdateReturning.mockReturnValue([
170-
{ sessionPolicySettings: { maxSessionHours: 72, idleTimeoutHours: 48 } },
171-
])
130+
queueTableRows(member, [{ role: 'owner' }])
131+
queueTableRows(organization, [{ name: 'Acme' }])
132+
dbChainMockFns.returning.mockResolvedValueOnce([{ id: ORG_ID }])
172133

173134
const response = await PUT(
174135
putRequest({ maxSessionHours: 72, idleTimeoutHours: 48 }),
@@ -177,29 +138,33 @@ describe('session policy route', () => {
177138
expect(response.status).toBe(200)
178139
const body = await response.json()
179140
expect(body.data.configured).toEqual({ maxSessionHours: 72, idleTimeoutHours: 48 })
180-
expect(mockExecute).toHaveBeenCalledTimes(1)
181-
expect(mockBumpVersion).toHaveBeenCalledWith(ORG_ID)
141+
expect(mockEagerClamp).toHaveBeenCalledWith(ORG_ID, {
142+
maxSessionHours: 72,
143+
idleTimeoutHours: 48,
144+
})
145+
// The version bump rides the settings UPDATE (single round trip).
146+
expect(dbChainMockFns.set).toHaveBeenCalledWith(
147+
expect.objectContaining({ securityPolicyVersion: expect.anything() })
148+
)
182149
expect(mockRecordAudit).toHaveBeenCalledWith(
183150
expect.objectContaining({ action: 'organization.session_policy.updated' })
184151
)
185152
})
186153

187-
it('skips the eager clamp when both policy fields are cleared', async () => {
188-
mockDbState.selectResults = [
189-
[{ role: 'owner' }],
190-
[{ name: 'Acme', sessionPolicySettings: { maxSessionHours: 72 } }],
191-
]
192-
mockUpdateReturning.mockReturnValue([
193-
{ sessionPolicySettings: { maxSessionHours: null, idleTimeoutHours: null } },
194-
])
154+
it('clearing both fields still saves and delegates the no-op to the clamp', async () => {
155+
queueTableRows(member, [{ role: 'owner' }])
156+
queueTableRows(organization, [{ name: 'Acme' }])
157+
dbChainMockFns.returning.mockResolvedValueOnce([{ id: ORG_ID }])
195158

196159
const response = await PUT(
197160
putRequest({ maxSessionHours: null, idleTimeoutHours: null }),
198161
routeContext
199162
)
200163
expect(response.status).toBe(200)
201-
expect(mockExecute).not.toHaveBeenCalled()
202-
expect(mockBumpVersion).toHaveBeenCalledWith(ORG_ID)
164+
expect(mockEagerClamp).toHaveBeenCalledWith(ORG_ID, {
165+
maxSessionHours: null,
166+
idleTimeoutHours: null,
167+
})
203168
})
204169
})
205170
})

0 commit comments

Comments
 (0)