6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
230 lines
8.4 KiB
TypeScript
230 lines
8.4 KiB
TypeScript
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 { isOrganizationFeatureEntitled } from '@/lib/billing/core/subscription'
|
|
import { isSessionPoliciesEnabled } 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 || (await isOrganizationFeatureEntitled(organizationId, isSessionPoliciesEnabled))
|
|
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`, `)
|
|
}
|