Skip to content
Merged
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
7 changes: 4 additions & 3 deletions apps/sim/lib/billing/authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { subscription } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { APIError } from 'better-auth/api'
import { and, eq, isNotNull } from 'drizzle-orm'
import { isAwsMarketplaceEnabled } from '@/lib/billing/aws-marketplace/config'
import { getAwsMarketplaceProducts } from '@/lib/billing/aws-marketplace/config'
import { holdsMarketplaceLicense } from '@/lib/billing/aws-marketplace/repository'
import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization'
import {
Expand Down Expand Up @@ -64,8 +64,9 @@ async function assertNoBoundIncompleteSubscription(referenceId: string): Promise
* license lookup fails.
*/
async function assertNotBilledThroughMarketplace(referenceId: string): Promise<void> {
if (!isAwsMarketplaceEnabled()) return
if (!(await holdsMarketplaceLicense(referenceId))) return
const products = getAwsMarketplaceProducts()
if (products.size === 0) return
if (!(await holdsMarketplaceLicense(products, referenceId))) return

logger.warn('Blocking checkout - reference is billed through AWS Marketplace', { referenceId })
throw new APIError('CONFLICT', {
Expand Down
18 changes: 10 additions & 8 deletions apps/sim/lib/billing/aws-marketplace/application/admin.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { AuditAction, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import type { AdminMutationActor } from '@/lib/admin/dashboard'
import {
licenseAuditMetadata,
licenseAuditEntry,
requireLinkableTo,
requireNotBilledThroughStripe,
} from '@/lib/billing/aws-marketplace/application/use-cases'
import {
countUnlinkedLicenses,
Expand Down Expand Up @@ -57,6 +58,7 @@ export async function linkAwsMarketplaceLicenseAsAdmin(
if (locked.organizationId === input.organizationId) {
return { license: locked, newlyLinked: false }
}
await requireNotBilledThroughStripe(tx, input.organizationId)
return {
license: await linkLicenseForTeam(tx, input.licenseId, input.organizationId),
newlyLinked: true,
Expand All @@ -67,12 +69,12 @@ export async function linkAwsMarketplaceLicenseAsAdmin(
actorId: actor.id,
actorName: actor.name,
actorEmail: actor.email,
action: AuditAction.AWS_MARKETPLACE_LICENSE_LINKED,
resourceType: AuditResourceType.AWS_MARKETPLACE_LICENSE,
resourceId: license.id,
resourceName: license.dimension ?? 'AWS Marketplace license',
description: 'Admin linked an AWS Marketplace subscription to an organization',
metadata: { ...licenseAuditMetadata(license), organizationId: input.organizationId },
...licenseAuditEntry(
license,
AuditAction.AWS_MARKETPLACE_LICENSE_LINKED,
'Admin linked an AWS Marketplace subscription to an organization',
{ organizationId: input.organizationId }
),
})
}
return { id: license.id, organizationId: input.organizationId }
Expand Down
130 changes: 71 additions & 59 deletions apps/sim/lib/billing/aws-marketplace/application/use-cases.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AuditAction, AuditResourceType } from '@sim/audit'
import { AuditAction, type AuditActionType, AuditResourceType } from '@sim/audit'
import { db } from '@sim/db'
import type { AwsMarketplaceClaim } from '@/lib/api/contracts/aws-marketplace'
import { awsMarketplaceOperations } from '@/lib/billing/aws-marketplace/application/operations'
Expand All @@ -10,13 +10,15 @@ import {
import { verifyLicense } from '@/lib/billing/aws-marketplace/entitlements'
import {
type AwsMarketplaceLicenseRecord,
type BuyerContact,
consumeLicenseClaim,
findLicenseByClaimHash,
isNewerRead,
listAccountLicenses,
listAdministeredOrganizations,
listOrganizationLicenses,
lockLicense,
paysForSelfServePlan,
recordLicenseVerification,
saveLicenseContact,
} from '@/lib/billing/aws-marketplace/repository'
Expand All @@ -28,6 +30,7 @@ import { recordProjectedUseCaseAuditEntries } from '@/lib/core/application/autho
import { assertOperationPrincipal, type OperationUseCase } from '@/lib/core/application/operation'
import { requireOrganizationSubjectMembership } from '@/lib/core/application/organization-authorization'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import type { DbTransaction } from '@/lib/db/types'

/** The same answer as an unknown route, so a deployment that does not sell on AWS shows nothing. */
function requireMarketplaceEnabled(): void {
Expand Down Expand Up @@ -93,6 +96,22 @@ export function requireLinkableTo(
}
}

/**
* AWS bills a linked organization for the listing, so one already paying Stripe for a plan would
* pay twice. Its subscription is cancelled first, by its owner or by support.
*/
export async function requireNotBilledThroughStripe(
tx: DbTransaction,
organizationId: string
): Promise<void> {
if (await paysForSelfServePlan(tx, organizationId)) {
throw new OrchestrationError(
'conflict',
'This organization already pays for a Sim plan. Cancel that subscription, or contact support, before linking an AWS Marketplace subscription to it.'
)
}
}

/** Enough for a buyer to recognize their account, and no more for someone holding their claim. */
function maskAwsAccount(accountId: string): string {
return `••••${accountId.slice(-4)}`
Expand Down Expand Up @@ -149,12 +168,6 @@ interface ClaimInput {
claimToken: string | null
}

interface BuyerContact {
/** Whom to contact about provisioning; AWS asks the registration page to collect it. */
contactEmail: string
companyName: string | null
}

type TakeInput = ClaimInput & BuyerContact

interface LinkLicenseInput extends TakeInput {
Expand All @@ -167,8 +180,20 @@ interface LinkedLicense {
newlyLinked: boolean
}

function contactOf(input: BuyerContact) {
return { email: input.contactEmail, companyName: input.companyName }
/** The license this browser's claim names, refused unless the claim is still open. */
async function findOpenClaim(input: ClaimInput) {
const claimHash = input.claimToken ? hashClaimToken(input.claimToken) : null
const claimed = requireOpenClaim(
claimHash ? await findLicenseByClaimHash(claimHash) : null,
input.licenseId,
claimHash
)
return { claimHash, claimed }
}

/** Locks the claimed license and checks the claim again, so two tabs racing it take it once. */
async function lockOpenClaim(tx: DbTransaction, input: ClaimInput, claimHash: string | null) {
return requireOpenClaim(await lockLicense(tx, input.licenseId), input.licenseId, claimHash)
}

/**
Expand All @@ -178,12 +203,7 @@ function contactOf(input: BuyerContact) {
*/
async function verifyClaimedLicense(input: ClaimInput, organizationId: string | null) {
requireMarketplaceEnabled()
const claimHash = input.claimToken ? hashClaimToken(input.claimToken) : null
const claimed = requireOpenClaim(
claimHash ? await findLicenseByClaimHash(claimHash) : null,
input.licenseId,
claimHash
)
const { claimHash, claimed } = await findOpenClaim(input)
requireLinkableTo(claimed, organizationId)

const verifiedAt = new Date()
Expand All @@ -205,11 +225,25 @@ async function verifyClaimedLicense(input: ClaimInput, organizationId: string |
return { claimHash, verification }
}

export function licenseAuditMetadata(license: AwsMarketplaceLicenseRecord) {
/** The audit entry for a license changing hands; `metadata` adds to its terms. */
export function licenseAuditEntry(
license: AwsMarketplaceLicenseRecord,
action: AuditActionType,
description: string,
metadata: Record<string, unknown> = {}
) {
return {
dimension: license.dimension,
quantity: license.quantity,
expiresAt: license.expiresAt?.toISOString() ?? null,
action,
resourceType: AuditResourceType.AWS_MARKETPLACE_LICENSE,
resourceId: license.id,
resourceName: license.dimension ?? 'AWS Marketplace license',
description,
metadata: {
dimension: license.dimension,
quantity: license.quantity,
expiresAt: license.expiresAt?.toISOString() ?? null,
...metadata,
},
}
}

Expand All @@ -226,11 +260,7 @@ export const linkAwsMarketplaceLicense = defineAuthorizedOrganizationUseCase({
}: OrganizationUseCaseContext<LinkLicenseInput>): Promise<LinkedLicense> {
const { claimHash, verification } = await verifyClaimedLicense(input, input.organizationId)
return db.transaction(async (tx) => {
const locked = requireOpenClaim(
await lockLicense(tx, input.licenseId),
input.licenseId,
claimHash
)
const locked = await lockOpenClaim(tx, input, claimHash)
requireLinkableTo(locked, input.organizationId)
requireNotOverruled(locked, verification.verifiedAt)
// Holds the admin's membership until commit, so a concurrent demotion waits for the link.
Expand All @@ -243,24 +273,22 @@ export const linkAwsMarketplaceLicense = defineAuthorizedOrganizationUseCase({
{ executor: tx, forShare: true }
)
const newlyLinked = locked.organizationId !== input.organizationId
if (newlyLinked) await requireNotBilledThroughStripe(tx, input.organizationId)
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
const license = await consumeLicenseClaim(tx, locked, {
link: newlyLinked ? { organizationId: input.organizationId, userId: context.userId } : null,
contact: contactOf(input),
contact: input,
verification,
})
return { license, newlyLinked }
})
},
projectAudit: ({ result }) =>
result.newlyLinked
? {
action: AuditAction.AWS_MARKETPLACE_LICENSE_LINKED,
resourceType: AuditResourceType.AWS_MARKETPLACE_LICENSE,
resourceId: result.license.id,
resourceName: result.license.dimension ?? 'AWS Marketplace license',
description: 'Linked an AWS Marketplace subscription to the organization',
metadata: licenseAuditMetadata(result.license),
}
? licenseAuditEntry(
result.license,
AuditAction.AWS_MARKETPLACE_LICENSE_LINKED,
'Linked an AWS Marketplace subscription to the organization'
)
: [],
})

Expand All @@ -280,17 +308,13 @@ export const registerAwsMarketplaceLicenseToAccount: OperationUseCase<
assertOperationPrincipal(principal, awsMarketplaceOperations.registerToAccount)
const { claimHash, verification } = await verifyClaimedLicense(input, null)
const { license, newlyRegistered } = await db.transaction(async (tx) => {
const locked = requireOpenClaim(
await lockLicense(tx, input.licenseId),
input.licenseId,
claimHash
)
const locked = await lockOpenClaim(tx, input, claimHash)
requireLinkableTo(locked, null)
requireNotOverruled(locked, verification.verifiedAt)
const newlyRegistered = locked.linkedByUserId !== principal.userId
const consumed = await consumeLicenseClaim(tx, locked, {
link: newlyRegistered ? { organizationId: null, userId: principal.userId } : null,
contact: contactOf(input),
contact: input,
verification,
})
return { license: consumed, newlyRegistered }
Expand All @@ -302,14 +326,11 @@ export const registerAwsMarketplaceLicenseToAccount: OperationUseCase<
principal,
request,
[
{
action: AuditAction.AWS_MARKETPLACE_LICENSE_REGISTERED,
resourceType: AuditResourceType.AWS_MARKETPLACE_LICENSE,
resourceId: license.id,
resourceName: license.dimension ?? 'AWS Marketplace license',
description: 'Registered an AWS Marketplace subscription to an account',
metadata: licenseAuditMetadata(license),
},
licenseAuditEntry(
license,
AuditAction.AWS_MARKETPLACE_LICENSE_REGISTERED,
'Registered an AWS Marketplace subscription to an account'
),
]
)
}
Expand All @@ -330,22 +351,13 @@ export const saveAwsMarketplaceClaimContact: OperationUseCase<
async execute({ principal, input }) {
assertOperationPrincipal(principal, awsMarketplaceOperations.saveClaimContact)
requireMarketplaceEnabled()
const claimHash = input.claimToken ? hashClaimToken(input.claimToken) : null
const claimed = requireOpenClaim(
claimHash ? await findLicenseByClaimHash(claimHash) : null,
input.licenseId,
claimHash
)
const { claimHash, claimed } = await findOpenClaim(input)
const verifiedAt = new Date()
const summary = await verifyLicense(requireLicenseProduct(claimed), claimed.licenseArn)
return db.transaction(async (tx) => {
const locked = requireOpenClaim(
await lockLicense(tx, input.licenseId),
input.licenseId,
claimHash
)
const locked = await lockOpenClaim(tx, input, claimHash)
const license = await saveLicenseContact(tx, locked, {
contact: contactOf(input),
contact: input,
verification: { summary, verifiedAt },
})
return { status: license.status }
Expand Down
33 changes: 27 additions & 6 deletions apps/sim/lib/billing/aws-marketplace/checkout-guard.integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,20 @@ import { generateId } from '@sim/utils/id'
import { inArray } from 'drizzle-orm'
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'

const PRODUCT = 'product-placeholder'
const ACCOUNT = '000000000000'
const TEAM_PLAN = 'team_6000'
const RETIRED_PRODUCT = 'retired-product-placeholder'

const marketplace = vi.hoisted(() => ({ enabled: true }))
const marketplace = vi.hoisted(() => ({ enabled: true, product: 'product-placeholder' }))
const PRODUCT = marketplace.product

vi.mock('@/lib/billing/aws-marketplace/config', () => ({
isAwsMarketplaceEnabled: () => marketplace.enabled,
}))
vi.mock('@/lib/billing/aws-marketplace/config', async () => {
const { parseAwsMarketplaceProducts } = await vi.importActual<
typeof import('@/lib/billing/aws-marketplace/products')
>('@/lib/billing/aws-marketplace/products')
const sold = parseAwsMarketplaceProducts(`${marketplace.product}=contract`)
return { getAwsMarketplaceProducts: () => (marketplace.enabled ? sold : new Map()) }
})

import {
assertPersonalCheckoutAllowed,
Expand Down Expand Up @@ -61,12 +66,13 @@ async function insertLicense(link: {
organizationId: string | null
linkedByUserId: string
status: LicenseStatus
productCode?: string
}) {
const now = new Date()
await db.insert(awsMarketplaceLicense).values({
id: generateId(),
licenseArn: `license-${generateId()}`,
productCode: PRODUCT,
productCode: link.productCode ?? PRODUCT,
customerAwsAccountId: ACCOUNT,
dimension: 'enterprise',
quantity: 25,
Expand Down Expand Up @@ -135,6 +141,21 @@ describe('Stripe checkout for an AWS Marketplace-billed reference', () => {
).resolves.toBe(true)
})

it('allows an organization whose only license is on a listing this deployment no longer sells', async () => {
const ownerId = await createUser()
const organizationId = await createOwnedOrganization(ownerId)
await insertLicense({
organizationId,
linkedByUserId: ownerId,
status: 'active',
productCode: RETIRED_PRODUCT,
})

await expect(
authorizeSubscriptionReference(ownerId, organizationId, 'upgrade-subscription', TEAM_PLAN)
).resolves.toBe(true)
})

it('allows an organization with no license', async () => {
const { ownerId, organizationId } = await organizationWithLicense(null)

Expand Down
8 changes: 4 additions & 4 deletions apps/sim/lib/billing/aws-marketplace/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,10 @@ let queueClient: SQSClient | null = null
/**
* Credentials for the Marketplace and event-queue clients only. On ECS the app's env also carries
* a static IAM user's keys, which the SDK's default chain prefers and which Sim's general AWS
* clients (`getAwsCredentials()`) sign with. Infra grants ResolveCustomer, GetEntitlements, and the
* queue permissions to the ECS task role alone, and AWS asks for temporary role credentials here,
* so whenever the container credential endpoint is present these clients sign as the task role.
* Elsewhere (local development, tests) they keep the default chain.
* clients (`getAwsCredentials()`) sign with. Infra grants ResolveCustomer, GetEntitlements,
* BatchMeterUsage, and the queue permissions to the ECS task role alone, and AWS asks for
* temporary role credentials here, so whenever the container credential endpoint is present these
* clients sign as the task role. Elsewhere (local development, tests) they keep the default chain.
*/
function marketplaceClientConfig() {
const relativeUri = env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI
Expand Down
Loading
Loading