Files
simstudioai--sim/apps/sim/lib/workspaces/policy.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (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 / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

407 lines
13 KiB
TypeScript

import { db } from '@sim/db'
import { member, type WorkspaceMode, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
import { and, count, eq, isNull } from 'drizzle-orm'
import { getOrganizationSubscription } from '@/lib/billing/core/billing'
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
import { getUserOrganization } from '@/lib/billing/organizations/membership'
import type { PlanCategory } from '@/lib/billing/plan-helpers'
import { getPlanType, isEnterprise, isMax, isPro, isTeam } from '@/lib/billing/plan-helpers'
import { hasUsableSubscriptionStatus } from '@/lib/billing/subscriptions/utils'
import { isBillingEnabled } from '@/lib/core/config/env-flags'
import { UPGRADE_TO_INVITE_REASON } from '@/lib/workspaces/policy-constants'
const logger = createLogger('WorkspacePolicy')
export const WORKSPACE_MODE = {
PERSONAL: 'personal',
ORGANIZATION: 'organization',
GRANDFATHERED_SHARED: 'grandfathered_shared',
} as const satisfies Record<string, WorkspaceMode>
interface WorkspaceOwnershipState {
organizationId: string | null
workspaceMode: WorkspaceMode
billedAccountUserId: string
ownerId: string
}
export {
CONTACT_OWNER_TO_UPGRADE_REASON,
UPGRADE_TO_INVITE_REASON,
} from '@/lib/workspaces/policy-constants'
export interface WorkspaceInvitePolicy {
allowed: boolean
reason: string | null
requiresSeat: boolean
organizationId: string | null
upgradeRequired: boolean
}
export interface WorkspaceCreationPolicy {
canCreate: boolean
workspaceMode: WorkspaceMode
organizationId: string | null
billedAccountUserId: string
maxWorkspaces: number | null
currentWorkspaceCount: number
reason: string | null
status: number
}
interface GetWorkspaceCreationPolicyParams {
userId: string
activeOrganizationId?: string | null
/**
* When true, `activeOrganizationId` is authoritative: it is used exactly as given
* (including `null`, which means a personal workspace) and never falls back to the
* caller's membership org. Forks set this so the child always lands in the SOURCE's
* org, not whatever org the acting user happens to belong to.
*/
pinOrganization?: boolean
}
export function isOrganizationWorkspace(
workspaceState: Pick<WorkspaceOwnershipState, 'workspaceMode' | 'organizationId'>
): boolean {
return (
workspaceState.workspaceMode === WORKSPACE_MODE.ORGANIZATION &&
workspaceState.organizationId !== null &&
workspaceState.organizationId.length > 0
)
}
/**
* Computes whether new members can be invited to the given workspace
* under the active product policy.
*
* Any paid billed account (Pro, Team, or Enterprise) may invite — the
* seat, and any Pro→Team upgrade, is provisioned when the invitee
* accepts, so invites are no longer seat-gated at this layer. Free
* accounts are blocked with an upgrade tooltip because there is no
* payment method to charge at acceptance.
*
* - `organization`: allowed; the org already holds a Team/Enterprise
* subscription. Only Enterprise keeps an invite-time `requiresSeat`
* gate (fixed seats).
* - `personal` / `grandfathered_shared`: allowed when the billed user is
* Pro/Team/Enterprise. For Pro, acceptance creates the org and moves the
* subscription to the Team tier.
*
* Billing-disabled deployments always allow invites. Existing members
* keep their access — this policy only governs *new* invitations.
*/
export async function getWorkspaceInvitePolicy(
workspaceState: WorkspaceOwnershipState
): Promise<WorkspaceInvitePolicy> {
const billedPlanCategory = isBillingEnabled
? await resolveBilledPlanCategory(workspaceState)
: 'free'
return evaluateWorkspaceInvitePolicy(workspaceState, { billedPlanCategory })
}
/**
* Pure evaluator — given the billed account's resolved plan category,
* returns the policy synchronously. Exposed so bulk callers (e.g. listing
* every workspace a user can see) can batch the subscription lookups by
* unique billed account user rather than re-querying per workspace.
*/
export function evaluateWorkspaceInvitePolicy(
workspaceState: WorkspaceOwnershipState,
context: { billedPlanCategory: PlanCategory }
): WorkspaceInvitePolicy {
if (!isBillingEnabled) {
return {
allowed: true,
reason: null,
requiresSeat: false,
organizationId: workspaceState.organizationId,
upgradeRequired: false,
}
}
if (workspaceState.workspaceMode === WORKSPACE_MODE.ORGANIZATION) {
if (workspaceState.organizationId === null || context.billedPlanCategory === 'free') {
return blockInvite(workspaceState.organizationId)
}
return {
allowed: true,
reason: null,
requiresSeat: context.billedPlanCategory === 'enterprise',
organizationId: workspaceState.organizationId,
upgradeRequired: false,
}
}
switch (context.billedPlanCategory) {
case 'pro':
case 'team':
return {
allowed: true,
reason: null,
requiresSeat: false,
organizationId: workspaceState.organizationId,
upgradeRequired: false,
}
case 'enterprise':
return {
allowed: true,
reason: null,
requiresSeat: true,
organizationId: workspaceState.organizationId,
upgradeRequired: false,
}
default:
return blockInvite(workspaceState.organizationId)
}
}
function blockInvite(organizationId: string | null): WorkspaceInvitePolicy {
return {
allowed: false,
reason: UPGRADE_TO_INVITE_REASON,
requiresSeat: false,
organizationId,
upgradeRequired: true,
}
}
async function resolveBilledPlanCategory(
workspaceState: WorkspaceOwnershipState
): Promise<PlanCategory> {
if (
workspaceState.workspaceMode === WORKSPACE_MODE.ORGANIZATION &&
workspaceState.organizationId
) {
return getInvitePlanCategoryForOrganization(workspaceState.organizationId)
}
return getInvitePlanCategoryForUser(workspaceState.billedAccountUserId)
}
/**
* Resolve the invite-governing plan category for an organization from its
* subscription. Exposed so bulk callers can batch by unique organization id.
* Returns `'free'` when there is no usable subscription so lapsed orgs are
* blocked consistently with accept-time provisioning.
*/
export async function getInvitePlanCategoryForOrganization(
organizationId: string
): Promise<PlanCategory> {
try {
const orgSub = await getOrganizationSubscription(organizationId)
if (!orgSub || !hasUsableSubscriptionStatus(orgSub.status)) return 'free'
return getPlanType(orgSub.plan)
} catch (error) {
logger.error('Failed to resolve organization subscription for invite policy', {
organizationId,
error,
})
return 'free'
}
}
/**
* Resolve the invite-governing plan category for a single billed account
* user. Exposed so bulk callers can batch by unique user id. Returns
* `'free'` when there is no usable paid subscription.
*/
export async function getInvitePlanCategoryForUser(userId: string): Promise<PlanCategory> {
try {
const sub = await getHighestPrioritySubscription(userId)
if (!sub || !hasUsableSubscriptionStatus(sub.status)) return 'free'
return getPlanType(sub.plan)
} catch (error) {
logger.error('Failed to resolve subscription for invite policy', { userId, error })
return 'free'
}
}
export async function getWorkspaceCreationPolicy({
userId,
activeOrganizationId,
pinOrganization = false,
}: GetWorkspaceCreationPolicyParams): Promise<WorkspaceCreationPolicy> {
const membership = await getUserOrganization(userId)
const organizationId = pinOrganization
? (activeOrganizationId ?? null)
: (activeOrganizationId ?? membership?.organizationId ?? null)
const orgRole =
organizationId == null
? undefined
: membership?.organizationId === organizationId
? membership.role
: (
await db
.select({ role: member.role })
.from(member)
.where(and(eq(member.userId, userId), eq(member.organizationId, organizationId)))
.limit(1)
)[0]?.role
if (activeOrganizationId && !orgRole) {
const billedAccountUserId = await requireOrganizationOwnerId(activeOrganizationId)
return {
canCreate: false,
workspaceMode: WORKSPACE_MODE.ORGANIZATION,
organizationId: activeOrganizationId,
billedAccountUserId,
maxWorkspaces: null,
currentWorkspaceCount: 0,
reason: 'Only organization owners and admins can create organization workspaces.',
status: 403,
}
}
if (!isBillingEnabled) {
if (organizationId && orgRole) {
const billedAccountUserId = await requireOrganizationOwnerId(organizationId)
if (!isOrgAdminRole(orgRole)) {
return {
canCreate: false,
workspaceMode: WORKSPACE_MODE.ORGANIZATION,
organizationId,
billedAccountUserId,
maxWorkspaces: null,
currentWorkspaceCount: 0,
reason: 'Only organization owners and admins can create organization workspaces.',
status: 403,
}
}
return {
canCreate: true,
workspaceMode: WORKSPACE_MODE.ORGANIZATION,
organizationId,
billedAccountUserId,
maxWorkspaces: null,
currentWorkspaceCount: 0,
reason: null,
status: 200,
}
}
const currentWorkspaceCount = await countNonOrganizationOwnedWorkspaces(userId)
return {
canCreate: true,
workspaceMode: WORKSPACE_MODE.PERSONAL,
organizationId: null,
billedAccountUserId: userId,
maxWorkspaces: null,
currentWorkspaceCount,
reason: null,
status: 200,
}
}
if (organizationId && orgRole) {
const organizationSubscription = await getOrganizationSubscription(organizationId)
if (
organizationSubscription &&
hasUsableSubscriptionStatus(organizationSubscription.status) &&
(isTeam(organizationSubscription.plan) || isEnterprise(organizationSubscription.plan))
) {
const billedAccountUserId = await requireOrganizationOwnerId(organizationId)
if (!isOrgAdminRole(orgRole)) {
return {
canCreate: false,
workspaceMode: WORKSPACE_MODE.ORGANIZATION,
organizationId,
billedAccountUserId,
maxWorkspaces: null,
currentWorkspaceCount: 0,
reason: 'Only organization owners and admins can create organization workspaces.',
status: 403,
}
}
return {
canCreate: true,
workspaceMode: WORKSPACE_MODE.ORGANIZATION,
organizationId,
billedAccountUserId,
maxWorkspaces: null,
currentWorkspaceCount: 0,
reason: null,
status: 200,
}
}
}
const highestPrioritySubscription = await getHighestPrioritySubscription(userId)
const plan = highestPrioritySubscription?.plan
const maxWorkspaces = isMax(plan) ? 10 : isPro(plan) ? 3 : 1
const currentWorkspaceCount = await countNonOrganizationOwnedWorkspaces(userId)
if (currentWorkspaceCount >= maxWorkspaces) {
return {
canCreate: false,
workspaceMode: WORKSPACE_MODE.PERSONAL,
organizationId: null,
billedAccountUserId: userId,
maxWorkspaces,
currentWorkspaceCount,
reason: `This plan supports up to ${maxWorkspaces} personal workspace${maxWorkspaces === 1 ? '' : 's'}.`,
status: 403,
}
}
return {
canCreate: true,
workspaceMode: WORKSPACE_MODE.PERSONAL,
organizationId: null,
billedAccountUserId: userId,
maxWorkspaces,
currentWorkspaceCount,
reason: null,
status: 200,
}
}
async function countNonOrganizationOwnedWorkspaces(userId: string): Promise<number> {
const [result] = await db
.select({ value: count() })
.from(workspace)
.where(and(eq(workspace.ownerId, userId), isNull(workspace.organizationId)))
return result?.value ?? 0
}
/**
* Returns the userId of the organization owner, or `null` if the
* organization has no owner row. Unexpected DB errors propagate to the
* caller so data-integrity issues surface loudly rather than being
* silently fallen back to the caller's identity.
*/
export async function getOrganizationOwnerId(organizationId: string): Promise<string | null> {
const [ownerMembership] = await db
.select({ userId: member.userId })
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.role, 'owner')))
.limit(1)
return ownerMembership?.userId ?? null
}
/**
* Like `getOrganizationOwnerId` but throws when no owner row exists.
* Use when the caller needs a guaranteed billed-account userId — every
* Better Auth organization is expected to have exactly one owner, so a
* missing owner is a data-integrity issue that should surface loudly.
*/
async function requireOrganizationOwnerId(organizationId: string): Promise<string> {
const ownerId = await getOrganizationOwnerId(organizationId)
if (!ownerId) {
logger.error('Organization is missing its owner membership row', { organizationId })
throw new Error(`Organization ${organizationId} has no owner membership`)
}
return ownerId
}