Skip to content

Commit a52d7cc

Browse files
committed
fix(sso): keep subscription admission consistent
1 parent 5ca892e commit a52d7cc

5 files changed

Lines changed: 60 additions & 11 deletions

File tree

apps/sim/ee/sso/components/sso-form.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { AuthFormMessage, AuthSubmitButton } from '@/app/(auth)/components'
1313

1414
const logger = createLogger('SSOForm')
1515
const SSO_SIGN_IN_ERROR = 'Unable to start SSO. Check your email and try again.'
16-
const SSO_ERROR_MESSAGES: Record<string, string> = {
16+
const SSO_ERROR_MESSAGES = {
1717
account_not_found: 'No account found. Please contact your administrator to set up SSO access.',
1818
sso_failed: 'SSO authentication failed. Please try again.',
1919
invalid_provider: 'SSO provider not configured correctly.',
@@ -23,7 +23,7 @@ const SSO_ERROR_MESSAGES: Record<string, string> = {
2323
'This Sim account is already a member of another organization. Leave that organization before trying again, or ask an administrator for external workspace access.',
2424
sso_provisioning_failed:
2525
'SSO succeeded, but organization access could not be set up safely. No session was created; please try again or contact your administrator.',
26-
}
26+
} as const
2727

2828
const validateEmailField = (emailValue: string): string[] => {
2929
const errors: string[] = []
@@ -50,7 +50,9 @@ export default function SSOForm({ registrationDisabled }: SSOFormProps) {
5050
const searchParams = useSearchParams()
5151
const errorCode = searchParams?.get('error') ?? null
5252
const initialError = errorCode
53-
? SSO_ERROR_MESSAGES[errorCode] || 'SSO authentication failed. Please try again.'
53+
? Object.hasOwn(SSO_ERROR_MESSAGES, errorCode)
54+
? SSO_ERROR_MESSAGES[errorCode as keyof typeof SSO_ERROR_MESSAGES]
55+
: 'SSO authentication failed. Please try again.'
5456
: null
5557

5658
return (

apps/sim/lib/auth/sso/application/admit-sso-user.test.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ describe('SSO JIT admission', () => {
246246

247247
it('uses elastic Team seats and reconciles the billed count after admission', async () => {
248248
queueIdentity()
249-
queueTableRows(schemaMock.subscription, [{ plan: 'team' }])
249+
queueTableRows(schemaMock.subscription, [{ id: 'subscription-current', plan: 'team' }])
250250

251251
await expect(execute()).resolves.toEqual({
252252
kind: 'provisioned',
@@ -258,11 +258,30 @@ describe('SSO JIT admission', () => {
258258
organizationId: 'org-1',
259259
role: 'member',
260260
skipSeatValidation: true,
261+
organizationSubscriptionId: 'subscription-current',
261262
})
262263
expect(mockReconcileOrganizationSeats).toHaveBeenCalledWith({
263264
organizationId: 'org-1',
264265
reason: 'sso-jit-member-added',
265266
actorId: 'user-1',
267+
subscriptionId: 'subscription-current',
268+
})
269+
})
270+
271+
it('pins fixed Enterprise capacity checks to the subscription used for plan classification', async () => {
272+
queueIdentity()
273+
queueTableRows(schemaMock.subscription, [{ id: 'subscription-current', plan: 'enterprise' }])
274+
275+
await expect(execute()).resolves.toEqual({
276+
kind: 'provisioned',
277+
organizationId: 'org-1',
278+
memberId: 'member-1',
279+
})
280+
expect(mockEnsureUserInOrganizationTx).toHaveBeenCalledWith(dbChainMock.db, {
281+
userId: 'user-1',
282+
organizationId: 'org-1',
283+
role: 'member',
284+
organizationSubscriptionId: 'subscription-current',
266285
})
267286
})
268287

apps/sim/lib/auth/sso/application/admit-sso-user.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ interface SuccessfulAdmission {
6464
result: SsoJitAdmissionResult
6565
userName?: string
6666
userEmail?: string
67+
organizationSubscriptionId?: string
6768
}
6869

6970
async function runAdmissionTransaction(
@@ -225,15 +226,15 @@ async function runAdmissionTransaction(
225226
}
226227

227228
const [organizationSubscription] = await tx
228-
.select({ plan: subscription.plan })
229+
.select({ id: subscription.id, plan: subscription.plan })
229230
.from(subscription)
230231
.where(
231232
and(
232233
eq(subscription.referenceId, provider.organizationId),
233234
inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES)
234235
)
235236
)
236-
.orderBy(desc(subscription.periodStart))
237+
.orderBy(desc(subscription.periodStart), desc(subscription.id))
237238
.limit(1)
238239

239240
const membershipResult = await ensureUserInOrganizationTx(tx, {
@@ -242,6 +243,9 @@ async function runAdmissionTransaction(
242243
role: 'member',
243244
/** Team seats grow to the committed member count; Enterprise remains fixed-capacity. */
244245
...(isTeam(organizationSubscription?.plan) ? { skipSeatValidation: true } : {}),
246+
...(organizationSubscription?.id
247+
? { organizationSubscriptionId: organizationSubscription.id }
248+
: {}),
245249
})
246250

247251
if (!membershipResult.success || !membershipResult.memberId) {
@@ -258,6 +262,9 @@ async function runAdmissionTransaction(
258262

259263
return {
260264
...attribution,
265+
...(organizationSubscription?.id
266+
? { organizationSubscriptionId: organizationSubscription.id }
267+
: {}),
261268
result: {
262269
kind: membershipResult.alreadyMember ? 'already-member' : 'provisioned',
263270
organizationId: provider.organizationId,
@@ -315,6 +322,9 @@ async function runProvisioningPostCommitEffects(
315322
organizationId,
316323
reason: 'sso-jit-member-added',
317324
actorId: userId,
325+
...(admission.organizationSubscriptionId
326+
? { subscriptionId: admission.organizationSubscriptionId }
327+
: {}),
318328
})
319329
} catch (error) {
320330
logger.error('Failed to reconcile organization seats after SSO JIT admission', {

apps/sim/lib/billing/organizations/membership.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { createLogger } from '@sim/logger'
2323
import { getErrorMessage } from '@sim/utils/errors'
2424
import { generateId } from '@sim/utils/id'
2525
import { normalizeEmail } from '@sim/utils/string'
26-
import { and, count, eq, inArray, isNull, ne, or, sql } from 'drizzle-orm'
26+
import { and, count, desc, eq, inArray, isNull, ne, or, sql } from 'drizzle-orm'
2727
import { invalidateMembershipCache } from '@/lib/auth/security-policy'
2828
import { applySessionPolicyToNewMember } from '@/lib/auth/session-policy'
2929
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
@@ -429,6 +429,8 @@ export interface AddMemberParams {
429429
skipBillingLogic?: boolean
430430
/** Skip seat validation (default: false) */
431431
skipSeatValidation?: boolean
432+
/** Restrict billing decisions to an already-resolved entitled organization subscription. */
433+
organizationSubscriptionId?: string
432434
/** When provided, the acceptor's own pending invitation is excluded from the seat count during validation. */
433435
acceptingInvitationId?: string
434436
}
@@ -593,6 +595,7 @@ export async function ensureUserInOrganizationTx(
593595
role,
594596
skipBillingLogic = false,
595597
skipSeatValidation = false,
598+
organizationSubscriptionId,
596599
} = params
597600
const emptyBillingActions = {
598601
proUsageSnapshotted: false,
@@ -665,9 +668,13 @@ export async function ensureUserInOrganizationTx(
665668
.where(
666669
and(
667670
eq(subscriptionTable.referenceId, organizationId),
668-
inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES)
671+
inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES),
672+
organizationSubscriptionId
673+
? eq(subscriptionTable.id, organizationSubscriptionId)
674+
: undefined
669675
)
670676
)
677+
.orderBy(desc(subscriptionTable.periodStart), desc(subscriptionTable.id))
671678
.limit(1)
672679
if (!organizationSubscription || !isPaid(organizationSubscription.plan)) {
673680
return {
@@ -724,9 +731,13 @@ export async function ensureUserInOrganizationTx(
724731
.where(
725732
and(
726733
eq(subscriptionTable.referenceId, organizationId),
727-
inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES)
734+
inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES),
735+
organizationSubscriptionId
736+
? eq(subscriptionTable.id, organizationSubscriptionId)
737+
: undefined
728738
)
729739
)
740+
.orderBy(desc(subscriptionTable.periodStart), desc(subscriptionTable.id))
730741
.limit(1)
731742
return organizationSubscription && isPaid(organizationSubscription.plan)
732743
? applyPaidOrgJoinBillingTx(tx, userId, organizationId)
@@ -2008,6 +2019,7 @@ export async function addUserToOrganization(params: AddMemberParams): Promise<Ad
20082019
role,
20092020
skipBillingLogic = false,
20102021
skipSeatValidation = false,
2022+
organizationSubscriptionId,
20112023
acceptingInvitationId,
20122024
} = params
20132025

@@ -2066,6 +2078,7 @@ export async function addUserToOrganization(params: AddMemberParams): Promise<Ad
20662078
role,
20672079
skipBillingLogic,
20682080
skipSeatValidation,
2081+
organizationSubscriptionId,
20692082
acceptingInvitationId,
20702083
})
20712084
)

apps/sim/lib/billing/organizations/seats.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
22
import { db } from '@sim/db'
33
import { member, subscription } from '@sim/db/schema'
44
import { createLogger } from '@sim/logger'
5-
import { and, count, eq, inArray } from 'drizzle-orm'
5+
import { and, count, desc, eq, inArray } from 'drizzle-orm'
66
import { syncSubscriptionUsageLimits } from '@/lib/billing/organization'
77
import { isTeam } from '@/lib/billing/plan-helpers'
88
import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
@@ -24,6 +24,8 @@ export interface ReconcileOrganizationSeatsResult {
2424
interface ReconcileOrganizationSeatsParams {
2525
organizationId: string
2626
reason: string
27+
/** Restrict reconciliation to an already-resolved entitled organization subscription. */
28+
subscriptionId?: string
2729
/**
2830
* Real `user.id` of the actor whose action triggered this reconcile, used to
2931
* attribute the seat-change audit log and analytics event. Omit for system
@@ -50,6 +52,7 @@ interface ReconcileOrganizationSeatsParams {
5052
export async function reconcileOrganizationSeats({
5153
organizationId,
5254
reason,
55+
subscriptionId,
5356
actorId,
5457
}: ReconcileOrganizationSeatsParams): Promise<ReconcileOrganizationSeatsResult> {
5558
if (!isBillingEnabled) {
@@ -74,9 +77,11 @@ export async function reconcileOrganizationSeats({
7477
.where(
7578
and(
7679
eq(subscription.referenceId, organizationId),
77-
inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES)
80+
inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES),
81+
subscriptionId ? eq(subscription.id, subscriptionId) : undefined
7882
)
7983
)
84+
.orderBy(desc(subscription.periodStart), desc(subscription.id))
8085
.for('update')
8186
.limit(1)
8287

0 commit comments

Comments
 (0)