Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ Use this to enforce periodic re-authentication — for example, a value of `168`

### 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.
Signs a member out after a period without activity. Activity extends the session, so members who use Sim regularly stay signed in; dormant sessions expire.

Activity is recorded at most once per 24 hours, so the actual sign-out lands between (value − 24) and (value) hours after a member's last use — never later. A value of `48` means a dormant session ends somewhere between 24 and 48 hours after last activity.

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.

Expand Down Expand Up @@ -72,5 +74,10 @@ Use this after a security incident, an offboarding wave, or before tightening a
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.',
},
{
question: 'Why was a member signed out earlier than the idle timeout I set?',
answer:
'Because activity is recorded at most once per 24 hours, the last recorded activity can be up to a day older than a member\'s actual last use. Sign-out therefore lands between (value − 24) and (value) hours after they stop working. It never lands later than the value you set, so the policy is never exceeded.',
},
]}
/>
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,10 @@ const { mockIsEnterprise, mockEagerClamp, mockRecordAudit } = vi.hoisted(() => (

vi.mock('@/lib/auth/session-policy', () => ({
eagerClampOrgSessions: mockEagerClamp,
invalidateSessionPolicyCache: vi.fn(),
}))

vi.mock('@/lib/auth/security-policy', () => ({
invalidateSecurityPolicyVersionCache: vi.fn(),
setSecurityPolicyVersion: vi.fn(),
}))

vi.mock('@/lib/billing/core/subscription', () => ({
Expand Down Expand Up @@ -128,7 +127,7 @@ describe('session policy route', () => {
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 }])
dbChainMockFns.returning.mockResolvedValueOnce([{ id: ORG_ID, securityPolicyVersion: 2 }])

const response = await PUT(
putRequest({ maxSessionHours: 72, idleTimeoutHours: 48 }),
Expand All @@ -154,7 +153,7 @@ describe('session policy route', () => {
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 }])
dbChainMockFns.returning.mockResolvedValueOnce([{ id: ORG_ID, securityPolicyVersion: 2 }])

const response = await PUT(
putRequest({ maxSessionHours: null, idleTimeoutHours: null }),
Expand Down
9 changes: 4 additions & 5 deletions apps/sim/app/api/organizations/[id]/session-policy/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ 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 { setSecurityPolicyVersion } from '@/lib/auth/security-policy'
import { eagerClampOrgSessions } 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'
Expand Down Expand Up @@ -150,7 +150,7 @@ export const PUT = withRouteHandler(
updatedAt: new Date(),
})
.where(eq(organization.id, organizationId))
.returning({ id: organization.id })
.returning({ securityPolicyVersion: organization.securityPolicyVersion })
if (!row) return null
await eagerClampOrgSessions(organizationId, merged, tx)
return row
Expand All @@ -160,8 +160,7 @@ export const PUT = withRouteHandler(
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
}

invalidateSessionPolicyCache(organizationId)
invalidateSecurityPolicyVersionCache(organizationId)
setSecurityPolicyVersion(organizationId, updated.securityPolicyVersion)

logger.info('Updated organization session policy', { organizationId })

Expand Down
247 changes: 247 additions & 0 deletions apps/sim/app/api/organizations/[id]/sessions/revoke/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
/**
* @vitest-environment node
*/
import { member, organization, session as sessionTable } from '@sim/db/schema'
import {
authMockFns,
createMockRequest,
dbChainMockFns,
queueTableRows,
resetDbChainMock,
resetEnvFlagsMock,
setEnvFlags,
} from '@sim/testing'
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'

const { mockIsEnterprise, mockRecordAudit, mockSetVersion } = vi.hoisted(() => ({
mockIsEnterprise: vi.fn(),
mockRecordAudit: vi.fn(),
mockSetVersion: vi.fn(),
}))

vi.mock('@/lib/auth/security-policy', () => ({
setSecurityPolicyVersion: mockSetVersion,
}))

vi.mock('@/lib/billing/core/subscription', () => ({
isOrganizationOnEnterprisePlan: mockIsEnterprise,
}))

vi.mock('@sim/audit', () => ({
recordAudit: mockRecordAudit,
AuditAction: { ORGANIZATION_SESSIONS_REVOKED: 'organization.sessions.revoked' },
AuditResourceType: { ORGANIZATION: 'organization' },
}))

import { POST } from '@/app/api/organizations/[id]/sessions/revoke/route'

const mockGetSession = authMockFns.mockGetSession

const ORG_ID = 'org-1'
const CALLER_TOKEN = 'tok-caller'
const routeContext = { params: Promise.resolve({ id: ORG_ID }) }

interface MockCondition {
type: string
left?: unknown
right?: unknown
column?: unknown
conditions?: MockCondition[]
}

/** Flattens the nested `and(...)` the route builds into a flat condition list. */
function flattenConditions(condition: MockCondition): MockCondition[] {
if (condition.type !== 'and') return [condition]
return (condition.conditions ?? []).flatMap(flattenConditions)
}

/**
* The condition list passed to the session DELETE's `.where(...)`. Identified by
* its `inArray` over `session.user_id` rather than by call position, since the
* version-bump UPDATE issues its own `.where(...)` on the same shared spy.
*/
function deleteConditions(): MockCondition[] {
expect(dbChainMockFns.delete).toHaveBeenCalledWith(sessionTable)
const match = dbChainMockFns.where.mock.calls
.map(([arg]) => flattenConditions(arg as MockCondition))
.find((conditions) =>
conditions.some(
(condition) => condition.type === 'inArray' && condition.column === sessionTable.userId
)
)
expect(match).toBeDefined()
return match as MockCondition[]
}

function authenticateAs(overrides: Record<string, unknown> = {}) {
mockGetSession.mockResolvedValue({
user: { id: 'user-1', name: 'Admin', email: 'admin@acme.dev' },
session: { token: CALLER_TOKEN, ...overrides },
})
}

beforeAll(() => {
setEnvFlags({ isBillingEnabled: true })
})

afterAll(resetEnvFlagsMock)

describe('org sessions revoke route', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
authenticateAs()
mockIsEnterprise.mockResolvedValue(true)
})

describe('authorization', () => {
it('returns 401 when unauthenticated', async () => {
mockGetSession.mockResolvedValue(null)
const response = await POST(createMockRequest('POST'), routeContext)
expect(response.status).toBe(401)
expect(dbChainMockFns.delete).not.toHaveBeenCalled()
})

it('returns 403 for non-members', async () => {
queueTableRows(member, [])
const response = await POST(createMockRequest('POST'), routeContext)
expect(response.status).toBe(403)
expect(dbChainMockFns.delete).not.toHaveBeenCalled()
})

it('returns 403 for members without an admin role', async () => {
queueTableRows(member, [{ role: 'member' }])
const response = await POST(createMockRequest('POST'), routeContext)
expect(response.status).toBe(403)
expect(dbChainMockFns.delete).not.toHaveBeenCalled()
})

it('returns 403 for non-enterprise organizations', async () => {
queueTableRows(member, [{ role: 'owner' }])
mockIsEnterprise.mockResolvedValue(false)
const response = await POST(createMockRequest('POST'), routeContext)
expect(response.status).toBe(403)
expect(dbChainMockFns.delete).not.toHaveBeenCalled()
})

it('returns 404 when the organization does not exist', async () => {
queueTableRows(member, [{ role: 'owner' }])
queueTableRows(organization, [])
const response = await POST(createMockRequest('POST'), routeContext)
expect(response.status).toBe(404)
expect(dbChainMockFns.delete).not.toHaveBeenCalled()
})

it('allows admins as well as owners', async () => {
queueTableRows(member, [{ role: 'admin' }])
queueTableRows(organization, [{ name: 'Acme' }])
dbChainMockFns.returning
.mockResolvedValueOnce([{ id: 's-1' }])
.mockResolvedValueOnce([{ securityPolicyVersion: 5 }])
const response = await POST(createMockRequest('POST'), routeContext)
expect(response.status).toBe(200)
})
})

describe('revocation', () => {
beforeEach(() => {
queueTableRows(member, [{ role: 'owner' }])
queueTableRows(organization, [{ name: 'Acme' }])
})

it('reports the revoked count, bumps the version, and publishes it', async () => {
dbChainMockFns.returning
.mockResolvedValueOnce([{ id: 's-1' }, { id: 's-2' }])
.mockResolvedValueOnce([{ securityPolicyVersion: 5 }])

const response = await POST(createMockRequest('POST'), routeContext)

expect(response.status).toBe(200)
expect(await response.json()).toEqual({ success: true, data: { revokedSessions: 2 } })
expect(dbChainMockFns.set).toHaveBeenCalledWith(
expect.objectContaining({ securityPolicyVersion: expect.anything() })
)
expect(mockSetVersion).toHaveBeenCalledWith(ORG_ID, 5)
expect(mockRecordAudit).toHaveBeenCalledWith(
expect.objectContaining({
action: 'organization.sessions.revoked',
resourceId: ORG_ID,
metadata: { revokedSessions: 2 },
})
)
})

it("never deletes the caller's own session", async () => {
dbChainMockFns.returning
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ securityPolicyVersion: 5 }])
await POST(createMockRequest('POST'), routeContext)

expect(deleteConditions()).toContainEqual(
expect.objectContaining({ type: 'ne', right: CALLER_TOKEN })
)
})

it('spares impersonation sessions', async () => {
dbChainMockFns.returning
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ securityPolicyVersion: 5 }])
await POST(createMockRequest('POST'), routeContext)

expect(deleteConditions()).toContainEqual(
expect.objectContaining({ type: 'isNull', column: sessionTable.impersonatedBy })
)
})

it('scopes the delete to members of the organization', async () => {
dbChainMockFns.returning
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ securityPolicyVersion: 5 }])
await POST(createMockRequest('POST'), routeContext)

expect(deleteConditions()).toContainEqual(
expect.objectContaining({ type: 'inArray', column: sessionTable.userId })
)
})

it("also spares the impersonator's own sessions when the caller is impersonating", async () => {
authenticateAs({ impersonatedBy: 'platform-admin-1' })
dbChainMockFns.returning
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ securityPolicyVersion: 5 }])

await POST(createMockRequest('POST'), routeContext)

expect(deleteConditions()).toContainEqual(
expect.objectContaining({
type: 'ne',
left: sessionTable.userId,
right: 'platform-admin-1',
})
)
})

it('adds no impersonator exclusion for an ordinary caller', async () => {
dbChainMockFns.returning
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ securityPolicyVersion: 5 }])
await POST(createMockRequest('POST'), routeContext)

const userIdExclusions = deleteConditions().filter(
(condition) => condition.type === 'ne' && condition.left === sessionTable.userId
)
expect(userIdExclusions).toHaveLength(0)
})

it('singularizes the audit description for a single session', async () => {
dbChainMockFns.returning
.mockResolvedValueOnce([{ id: 's-1' }])
.mockResolvedValueOnce([{ securityPolicyVersion: 5 }])
await POST(createMockRequest('POST'), routeContext)

expect(mockRecordAudit).toHaveBeenCalledWith(
expect.objectContaining({ description: 'Revoked 1 member session' })
)
})
})
})
11 changes: 6 additions & 5 deletions apps/sim/app/api/organizations/[id]/sessions/revoke/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ 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 { setSecurityPolicyVersion } 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'
Expand Down Expand Up @@ -81,7 +81,7 @@ export const POST = withRouteHandler(
// 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 { revoked, version } = await db.transaction(async (tx) => {
const deleted = await tx
.delete(sessionTable)
.where(
Expand All @@ -99,13 +99,14 @@ export const POST = withRouteHandler(
)
)
.returning({ id: sessionTable.id })
await tx
const [bumped] = await tx
.update(organization)
.set({ securityPolicyVersion: sql`${organization.securityPolicyVersion} + 1` })
.where(eq(organization.id, organizationId))
return deleted
.returning({ securityPolicyVersion: organization.securityPolicyVersion })
return { revoked: deleted, version: bumped?.securityPolicyVersion }
})
invalidateSecurityPolicyVersionCache(organizationId)
if (version !== undefined) setSecurityPolicyVersion(organizationId, version)

logger.info('Revoked organization sessions', {
organizationId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ export function SessionPolicySettings({ organizationId }: SessionPolicySettingsP
<HourField
id='idle-timeout-hours'
title='Idle timeout (hours)'
hint={`Sessions expire after this many hours without activity. Minimum ${MIN_IDLE_TIMEOUT_HOURS} hours.`}
hint={`Sessions expire within this many hours of a member's last activity. Activity is recorded at most once per 24 hours, so members may be signed out up to a day earlier than the value you set — never later. Minimum ${MIN_IDLE_TIMEOUT_HOURS} hours.`}
value={idleTimeoutHours}
onChange={setIdleTimeoutHours}
/>
Expand Down
Loading
Loading