Skip to content

Commit 409676e

Browse files
committed
fix(observability): tag org-scoped audit metadata with organizationId, guard concurrent table delete
The org-scoped self-service audit-log endpoint matches org-level rows via metadata.organizationId (or resourceType=organization). Six recordAudit call sites (subscription create/cancel, admin credit issuance, threshold overage billing, charge disputes, credit purchase, invoice payment succeeded/failed) tagged org-scoped events with a differently-named key (referenceId/entityId/targetOrgId), making them invisible to org admins querying their own audit trail despite being stored in the DB. Add the missing organizationId key everywhere the pattern was missed. Also guard deleteTable's archive UPDATE with isNull(archivedAt) so a concurrent duplicate delete request is a no-op instead of re-archiving and re-firing a duplicate TABLE_DELETED audit row. Adds test coverage for the ORG_MEMBER_ADDED audit/analytics emission in acceptInvitation, which previously had none.
1 parent f8c01ce commit 409676e

7 files changed

Lines changed: 95 additions & 4 deletions

File tree

apps/sim/app/api/v1/admin/credits/route.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,9 @@ export const POST = withRouteHandler(
219219
description: `Admin API issued $${Number(amount).toFixed(2)} credits to ${entityType} ${entityId}`,
220220
metadata: {
221221
targetUserId: resolvedUserId,
222-
...(entityType === 'organization' ? { targetOrgId: entityId } : {}),
222+
...(entityType === 'organization'
223+
? { targetOrgId: entityId, organizationId: entityId }
224+
: {}),
223225
entityType,
224226
amount,
225227
currency: 'usd',

apps/sim/lib/billing/threshold-billing.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -612,6 +612,7 @@ async function checkAndBillOrganizationOverageThreshold(organizationId: string):
612612
metadata: {
613613
entityType: 'organization',
614614
referenceId: organizationId,
615+
organizationId,
615616
plan: orgSubscription.plan,
616617
amount,
617618
currency: 'usd',

apps/sim/lib/billing/webhooks/disputes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ function recordDisputeInstrumentation(
5656
metadata: {
5757
entityType: entity.type,
5858
entityId: entity.id,
59+
...(entity.type === 'organization' ? { organizationId: entity.id } : {}),
5960
customerId,
6061
amount,
6162
currency: dispute.currency,

apps/sim/lib/billing/webhooks/invoices.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -750,6 +750,7 @@ async function handleCreditPurchaseSuccess(invoice: Stripe.Invoice): Promise<voi
750750
metadata: {
751751
entityType,
752752
entityId,
753+
...(entityType === 'organization' ? { organizationId: entityId } : {}),
753754
amount,
754755
currency: 'usd',
755756
purchasedBy: purchasedBy ?? null,
@@ -928,6 +929,7 @@ export async function handleInvoicePaymentSucceeded(event: Stripe.Event) {
928929
metadata: {
929930
entityType,
930931
referenceId: sub.referenceId,
932+
...(entityType === 'organization' ? { organizationId: sub.referenceId } : {}),
931933
plan: sub.plan,
932934
amount: amountPaid,
933935
currency: invoice.currency ?? 'usd',
@@ -1009,6 +1011,7 @@ export async function handleInvoicePaymentFailed(event: Stripe.Event) {
10091011
metadata: {
10101012
entityType: failureEntityType,
10111013
referenceId: sub.referenceId,
1014+
...(failureEntityType === 'organization' ? { organizationId: sub.referenceId } : {}),
10121015
plan: sub.plan,
10131016
amount: failedAmount,
10141017
currency: invoice.currency ?? 'usd',

apps/sim/lib/billing/webhooks/subscription.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,9 @@ export async function handleSubscriptionCreated(
286286

287287
if (wasFreePreviously && isPaidPlan) {
288288
const actorId = await resolveSubscriptionActorId(subscriptionData.referenceId)
289+
const isOrgScoped = await isSubscriptionOrgScoped({
290+
referenceId: subscriptionData.referenceId,
291+
})
289292
recordAudit({
290293
actorId,
291294
action: AuditAction.SUBSCRIPTION_CREATED,
@@ -296,6 +299,7 @@ export async function handleSubscriptionCreated(
296299
plan: subscriptionData.plan,
297300
status: subscriptionData.status,
298301
referenceId: subscriptionData.referenceId,
302+
...(isOrgScoped ? { organizationId: subscriptionData.referenceId } : {}),
299303
},
300304
})
301305
captureServerEvent(subscriptionData.referenceId, 'subscription_created', {
@@ -386,6 +390,7 @@ export async function handleSubscriptionDeleted(
386390
metadata: {
387391
plan: subscription.plan,
388392
referenceId: subscription.referenceId,
393+
organizationId: subscription.referenceId,
389394
kind: 'enterprise',
390395
},
391396
})
@@ -488,7 +493,8 @@ export async function handleSubscriptionDeleted(
488493
let membersSynced = 0
489494
let workspacesDetached = 0
490495

491-
if (await isSubscriptionOrgScoped(subscription)) {
496+
const isOrgScoped = await isSubscriptionOrgScoped(subscription)
497+
if (isOrgScoped) {
492498
const dormantResult = await transitionOrganizationToDormantState(
493499
subscription.referenceId,
494500
subscription.id
@@ -522,6 +528,7 @@ export async function handleSubscriptionDeleted(
522528
plan: subscription.plan,
523529
referenceId: subscription.referenceId,
524530
totalOverage,
531+
...(isOrgScoped ? { organizationId: subscription.referenceId } : {}),
525532
},
526533
})
527534
captureServerEvent(subscription.referenceId, 'subscription_cancelled', {

apps/sim/lib/invitations/core.test.ts

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* @vitest-environment node
33
*/
4-
import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
4+
import { auditMock, dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
55
import { beforeEach, describe, expect, it, vi } from 'vitest'
66

77
const {
@@ -72,6 +72,8 @@ vi.mock('@/lib/credentials/environment', () => ({
7272
syncWorkspaceEnvCredentials: mockSyncWorkspaceEnvCredentials,
7373
}))
7474

75+
vi.mock('@sim/audit', () => auditMock)
76+
7577
import { acceptInvitation } from '@/lib/invitations/core'
7678

7779
function queueWhereResponses(responses: unknown[][]) {
@@ -319,6 +321,81 @@ describe('acceptInvitation', () => {
319321
actorId: 'invitee-user',
320322
})
321323
expect(mockSetActiveOrganizationForCurrentSession).toHaveBeenCalledWith('org-new')
324+
expect(auditMock.recordAudit).toHaveBeenCalledWith(
325+
expect.objectContaining({
326+
actorId: 'invitee-user',
327+
action: auditMock.AuditAction.ORG_MEMBER_ADDED,
328+
resourceType: auditMock.AuditResourceType.ORGANIZATION,
329+
resourceId: 'org-new',
330+
metadata: expect.objectContaining({ invitationId: 'inv-1', memberRole: 'member' }),
331+
})
332+
)
333+
})
334+
335+
it('does not record an ORG_MEMBER_ADDED audit for a user who is already a member', async () => {
336+
mockGetWorkspaceWithOwner.mockResolvedValueOnce({
337+
id: 'workspace-1',
338+
name: 'Workspace',
339+
ownerId: 'owner-1',
340+
organizationId: 'org-1',
341+
workspaceMode: 'organization',
342+
billedAccountUserId: 'owner-1',
343+
})
344+
mockEnsureTeamOrganizationForAcceptance.mockResolvedValueOnce({
345+
success: true,
346+
organizationId: 'org-1',
347+
fixedSeats: false,
348+
})
349+
mockEnsureUserInOrganization.mockResolvedValueOnce({
350+
success: true,
351+
alreadyMember: true,
352+
billingActions: { proUsageSnapshotted: false, proCancelledAtPeriodEnd: false },
353+
})
354+
355+
queueWhereResponses([
356+
[
357+
{
358+
id: 'inv-1',
359+
kind: 'workspace',
360+
email: 'invitee@example.com',
361+
organizationId: 'org-1',
362+
membershipIntent: 'internal',
363+
inviterId: 'owner-1',
364+
role: 'member',
365+
status: 'pending',
366+
token: 'tok-1',
367+
expiresAt: new Date(Date.now() + 60_000),
368+
createdAt: new Date(),
369+
updatedAt: new Date(),
370+
},
371+
],
372+
[
373+
{
374+
id: 'grant-1',
375+
workspaceId: 'workspace-1',
376+
permission: 'write',
377+
workspaceName: 'Workspace',
378+
},
379+
],
380+
[{ name: 'Acme' }],
381+
[{ name: 'Owner', email: 'owner@example.com' }],
382+
[{ id: 'member-1' }],
383+
])
384+
385+
const result = await acceptInvitation({
386+
userId: 'invitee-user',
387+
userEmail: 'invitee@example.com',
388+
invitationId: 'inv-1',
389+
token: 'tok-1',
390+
})
391+
392+
expect(result.success).toBe(true)
393+
if (result.success) {
394+
expect(result.membershipAlreadyExists).toBe(true)
395+
}
396+
expect(auditMock.recordAudit).not.toHaveBeenCalledWith(
397+
expect.objectContaining({ action: auditMock.AuditAction.ORG_MEMBER_ADDED })
398+
)
322399
})
323400

324401
it('does not reconcile seats for an Enterprise organization (fixed seats)', async () => {

apps/sim/lib/table/service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -653,7 +653,7 @@ export async function deleteTable(
653653
const result = await db
654654
.update(userTableDefinitions)
655655
.set({ archivedAt: now, updatedAt: now })
656-
.where(eq(userTableDefinitions.id, tableId))
656+
.where(and(eq(userTableDefinitions.id, tableId), isNull(userTableDefinitions.archivedAt)))
657657
.returning({
658658
createdBy: userTableDefinitions.createdBy,
659659
workspaceId: userTableDefinitions.workspaceId,

0 commit comments

Comments
 (0)