Skip to content

Commit 2b5a92a

Browse files
authored
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)
1 parent 8cce661 commit 2b5a92a

27 files changed

Lines changed: 18955 additions & 7 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 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.
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 48 hours?',
72+
answer:
73+
'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.',
74+
},
75+
]}
76+
/>
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { member, organization } from '@sim/db/schema'
5+
import {
6+
createMockRequest,
7+
dbChainMock,
8+
dbChainMockFns,
9+
queueTableRows,
10+
resetDbChainMock,
11+
} from '@sim/testing'
12+
import { beforeEach, describe, expect, it, vi } from 'vitest'
13+
14+
const { mockGetSession, mockIsEnterprise, mockEagerClamp, mockRecordAudit } = vi.hoisted(() => ({
15+
mockGetSession: vi.fn(),
16+
mockIsEnterprise: vi.fn(),
17+
mockEagerClamp: vi.fn(),
18+
mockRecordAudit: vi.fn(),
19+
}))
20+
21+
vi.mock('@sim/db', () => dbChainMock)
22+
23+
vi.mock('@/lib/auth', () => ({
24+
getSession: mockGetSession,
25+
}))
26+
27+
vi.mock('@/lib/auth/session-policy', () => ({
28+
eagerClampOrgSessions: mockEagerClamp,
29+
invalidateSessionPolicyCache: vi.fn(),
30+
}))
31+
32+
vi.mock('@/lib/auth/security-policy', () => ({
33+
invalidateSecurityPolicyVersionCache: vi.fn(),
34+
}))
35+
36+
vi.mock('@/lib/billing/core/subscription', () => ({
37+
isOrganizationOnEnterprisePlan: mockIsEnterprise,
38+
}))
39+
40+
vi.mock('@/lib/core/config/env-flags', () => ({
41+
isBillingEnabled: true,
42+
}))
43+
44+
vi.mock('@sim/audit', () => ({
45+
recordAudit: mockRecordAudit,
46+
AuditAction: {
47+
ORGANIZATION_SESSION_POLICY_UPDATED: 'organization.session_policy.updated',
48+
},
49+
AuditResourceType: { ORGANIZATION: 'organization' },
50+
}))
51+
52+
import { GET, PUT } from '@/app/api/organizations/[id]/session-policy/route'
53+
54+
const ORG_ID = 'org-1'
55+
const routeContext = { params: Promise.resolve({ id: ORG_ID }) }
56+
57+
describe('session policy route', () => {
58+
beforeEach(() => {
59+
vi.clearAllMocks()
60+
resetDbChainMock()
61+
mockGetSession.mockResolvedValue({
62+
user: { id: 'user-1', name: 'Admin', email: 'admin@acme.dev' },
63+
session: { token: 'tok-1' },
64+
})
65+
mockIsEnterprise.mockResolvedValue(true)
66+
})
67+
68+
describe('GET', () => {
69+
it('returns 401 when unauthenticated', async () => {
70+
mockGetSession.mockResolvedValue(null)
71+
const response = await GET(createMockRequest('GET'), routeContext)
72+
expect(response.status).toBe(401)
73+
})
74+
75+
it('returns 403 for non-members', async () => {
76+
queueTableRows(member, [])
77+
const response = await GET(createMockRequest('GET'), routeContext)
78+
expect(response.status).toBe(403)
79+
})
80+
81+
it('returns the configured policy for members', async () => {
82+
queueTableRows(member, [{ id: 'member-1' }])
83+
queueTableRows(organization, [
84+
{ sessionPolicySettings: { maxSessionHours: 72, idleTimeoutHours: null } },
85+
])
86+
const response = await GET(createMockRequest('GET'), routeContext)
87+
expect(response.status).toBe(200)
88+
const body = await response.json()
89+
expect(body.data).toEqual({
90+
isEnterprise: true,
91+
configured: { maxSessionHours: 72, idleTimeoutHours: null },
92+
})
93+
})
94+
})
95+
96+
describe('PUT', () => {
97+
function putRequest(body: unknown) {
98+
return createMockRequest('PUT', body)
99+
}
100+
101+
it('rejects non-admin members', async () => {
102+
queueTableRows(member, [{ role: 'member' }])
103+
const response = await PUT(
104+
putRequest({ maxSessionHours: 72, idleTimeoutHours: null }),
105+
routeContext
106+
)
107+
expect(response.status).toBe(403)
108+
})
109+
110+
it('rejects an idle timeout below the cookie-cache window', async () => {
111+
queueTableRows(member, [{ role: 'admin' }])
112+
const response = await PUT(
113+
putRequest({ maxSessionHours: null, idleTimeoutHours: 5 }),
114+
routeContext
115+
)
116+
expect(response.status).toBe(400)
117+
})
118+
119+
it('rejects non-enterprise organizations', async () => {
120+
queueTableRows(member, [{ role: 'owner' }])
121+
mockIsEnterprise.mockResolvedValue(false)
122+
const response = await PUT(
123+
putRequest({ maxSessionHours: 72, idleTimeoutHours: null }),
124+
routeContext
125+
)
126+
expect(response.status).toBe(403)
127+
})
128+
129+
it('saves the policy, eagerly clamps sessions, and bumps the version', async () => {
130+
queueTableRows(member, [{ role: 'owner' }])
131+
queueTableRows(organization, [{ name: 'Acme' }])
132+
dbChainMockFns.returning.mockResolvedValueOnce([{ id: ORG_ID }])
133+
134+
const response = await PUT(
135+
putRequest({ maxSessionHours: 72, idleTimeoutHours: 48 }),
136+
routeContext
137+
)
138+
expect(response.status).toBe(200)
139+
const body = await response.json()
140+
expect(body.data.configured).toEqual({ maxSessionHours: 72, idleTimeoutHours: 48 })
141+
expect(mockEagerClamp).toHaveBeenCalledWith(
142+
ORG_ID,
143+
{ maxSessionHours: 72, idleTimeoutHours: 48 },
144+
expect.anything()
145+
)
146+
// The version bump rides the settings UPDATE (single round trip).
147+
expect(dbChainMockFns.set).toHaveBeenCalledWith(
148+
expect.objectContaining({ securityPolicyVersion: expect.anything() })
149+
)
150+
expect(mockRecordAudit).toHaveBeenCalledWith(
151+
expect.objectContaining({ action: 'organization.session_policy.updated' })
152+
)
153+
})
154+
155+
it('clearing both fields still saves and delegates the no-op to the clamp', async () => {
156+
queueTableRows(member, [{ role: 'owner' }])
157+
queueTableRows(organization, [{ name: 'Acme' }])
158+
dbChainMockFns.returning.mockResolvedValueOnce([{ id: ORG_ID }])
159+
160+
const response = await PUT(
161+
putRequest({ maxSessionHours: null, idleTimeoutHours: null }),
162+
routeContext
163+
)
164+
expect(response.status).toBe(200)
165+
expect(mockEagerClamp).toHaveBeenCalledWith(
166+
ORG_ID,
167+
{ maxSessionHours: null, idleTimeoutHours: null },
168+
expect.anything()
169+
)
170+
})
171+
})
172+
})

0 commit comments

Comments
 (0)