Skip to content

Commit 592f969

Browse files
committed
fix(auth): preserve SSO audit events across application operations
1 parent 7a5f87b commit 592f969

7 files changed

Lines changed: 93 additions & 9 deletions

File tree

‎apps/sim/app/api/auth/sso/providers/[providerId]/route.test.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ describe('DELETE /api/auth/sso/providers/[providerId]', () => {
2828
beforeEach(() => {
2929
resetDbChainMock()
3030
mockGetSession.mockResolvedValue({ user: { id: 'u1' }, session: { id: 'session-1' } })
31-
dbChainMockFns.returning.mockResolvedValue([{ id: 'row-1' }])
31+
dbChainMockFns.returning.mockResolvedValue([
32+
{ id: 'row-1', providerId: 'acme-okta', organizationId: null, domain: 'acme.com' },
33+
])
3234
})
3335

3436
it('leaves domains alone when deleting a personal provider', async () => {
@@ -76,7 +78,9 @@ describe('DELETE /api/auth/sso/providers/[providerId]', () => {
7678
expect(refused.status).toBe(403)
7779

7880
resetDbChainMock()
79-
dbChainMockFns.returning.mockResolvedValue([{ id: 'row-1' }])
81+
dbChainMockFns.returning.mockResolvedValue([
82+
{ id: 'row-1', providerId: 'acme-okta', organizationId: null, domain: 'acme.com' },
83+
])
8084
queueTableRows(schemaMock.ssoProvider, [
8185
{
8286
id: 'row-1',

‎apps/sim/app/api/organizations/[id]/domains/[domainId]/route.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ describe('remove org domain route', () => {
5656
* trust in the same transaction, or the authorization outlives the ownership.
5757
*/
5858
it('revokes SSO domain trust for providers on the removed domain', async () => {
59+
queueTableRows(member, [{ role: 'owner' }])
5960
queueTableRows(member, [{ role: 'owner' }])
6061
dbChainMockFns.returning.mockResolvedValueOnce([{ domain: 'acme.com' }])
6162
const res = await DELETE(createMockRequest('DELETE'), routeContext)
@@ -70,6 +71,7 @@ describe('remove org domain route', () => {
7071
* the proof was deleted.
7172
*/
7273
it('matches the provider domain the way it was grandfathered (wildcard-tolerant)', async () => {
74+
queueTableRows(member, [{ role: 'owner' }])
7375
queueTableRows(member, [{ role: 'owner' }])
7476
dbChainMockFns.returning.mockResolvedValueOnce([{ domain: 'acme.com' }])
7577
await DELETE(createMockRequest('DELETE'), routeContext)
@@ -80,6 +82,7 @@ describe('remove org domain route', () => {
8082
})
8183

8284
it('does not revoke trust when no domain was removed', async () => {
85+
queueTableRows(member, [{ role: 'owner' }])
8386
queueTableRows(member, [{ role: 'owner' }])
8487
dbChainMockFns.returning.mockResolvedValueOnce([]) // delete matched nothing
8588
const res = await DELETE(createMockRequest('DELETE'), routeContext)

‎apps/sim/app/api/organizations/[id]/domains/[domainId]/verify/route.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ const PENDING_ROW = {
4343

4444
/** Queues the membership + pending-row lookups shared by the happy path. */
4545
function queueAdminWithPendingRow() {
46+
queueTableRows(member, [{ role: 'owner' }])
4647
queueTableRows(member, [{ role: 'owner' }])
4748
queueTableRows(ssoDomain, [PENDING_ROW]) // row lookup
4849
}

‎apps/sim/app/api/organizations/[id]/domains/route.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ describe('org domains route', () => {
107107

108108
it('re-adds an existing pending domain idempotently without rotating its token', async () => {
109109
queueTableRows(member, [{ role: 'owner' }]) // membership
110+
queueTableRows(member, [{ role: 'owner' }])
110111
queueTableRows(ssoDomain, []) // verified-elsewhere check → none
111112
queueTableRows(ssoDomain, [
112113
{
@@ -131,8 +132,10 @@ describe('org domains route', () => {
131132

132133
it('stays idempotent when a concurrent claim wins the unique index race', async () => {
133134
queueTableRows(member, [{ role: 'owner' }]) // membership
135+
queueTableRows(member, [{ role: 'owner' }])
134136
queueTableRows(ssoDomain, []) // verified-elsewhere check → none
135137
queueTableRows(ssoDomain, []) // org-domains read → none existing, under the cap
138+
queueTableRows(ssoDomain, [])
136139
// insert().returning() loses the race and hits sso_domain_org_domain_unique
137140
dbChainMockFns.returning.mockRejectedValueOnce(
138141
Object.assign(new Error('duplicate key'), { code: '23505' })

‎apps/sim/lib/auth/sso/application/provider-registration.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { AuditAction, AuditResourceType } from '@sim/audit'
12
import type { Principal } from '@sim/auth/principal'
23
import { db, ssoDomain, ssoProvider } from '@sim/db'
34
import { keepDomainSignInProvider, ssoProviderDomainKey } from '@sim/db/sso-primary-provider'
@@ -685,4 +686,20 @@ export const saveSsoProvider = defineOrganizationConfigurationUseCase({
685686
invalidateSsoPolicyCache(orgId)
686687
return result
687688
},
689+
projectAudit: ({ input, context, result }) => ({
690+
action: result.created
691+
? AuditAction.ORGANIZATION_SSO_PROVIDER_CREATED
692+
: AuditAction.ORGANIZATION_SSO_PROVIDER_UPDATED,
693+
resourceType: AuditResourceType.ORGANIZATION,
694+
resourceId: context.organizationId,
695+
description: result.created
696+
? 'Created organization SSO provider'
697+
: 'Updated organization SSO provider',
698+
metadata: {
699+
providerId: result.providerId,
700+
providerType: result.providerType,
701+
domain: normalizeSSODomain(input.domain),
702+
jitProvisioningEnabled: input.jitProvisioningEnabled,
703+
},
704+
}),
688705
})

‎apps/sim/lib/auth/sso/application/provider-settings.integration.ts‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ describe('Organization SSO administration through API credentials', () => {
138138
afterAll(async () => {
139139
if (!runtime) return
140140
const { db, schema, eq, inArray } = runtime
141+
await db.delete(schema.auditLog).where(eq(schema.auditLog.resourceId, organizationId))
141142
await db.delete(schema.organization).where(eq(schema.organization.id, organizationId))
142143
await db.delete(schema.user).where(inArray(schema.user.id, [userId, outsiderId]))
143144
})
@@ -175,14 +176,17 @@ describe('Organization SSO administration through API credentials', () => {
175176

176177
it('creates and edits a provider without minting a browser session, then deletes it', async () => {
177178
const { db, schema, eq } = runtime
179+
const request = { headers: new Headers({ 'user-agent': 'SSO administration audit fixture' }) }
178180
const created = await runtime.saveSsoProvider.execute({
179181
principal,
180182
input: { ...config(), domain: domain.toUpperCase() },
183+
request,
181184
})
182185
expect(created).toMatchObject({ providerId, created: true })
183186
const edited = await runtime.saveSsoProvider.execute({
184187
principal,
185188
input: { ...config(), domain: domain.toUpperCase(), cert: 'rotated signing certificate' },
189+
request,
186190
})
187191
expect(edited.created).toBe(false)
188192
const [row] = await db
@@ -194,13 +198,37 @@ describe('Organization SSO administration through API credentials', () => {
194198
expect(
195199
await db.select().from(schema.session).where(eq(schema.session.userId, userId))
196200
).toHaveLength(0)
197-
await runtime.deleteSsoProvider.execute({ principal, input: { organizationId, providerId } })
201+
await runtime.deleteSsoProvider.execute({
202+
principal,
203+
input: { organizationId, providerId },
204+
request,
205+
})
198206
expect(
199207
await db
200208
.select()
201209
.from(schema.ssoProvider)
202210
.where(eq(schema.ssoProvider.providerId, providerId))
203211
).toHaveLength(0)
212+
const history = () =>
213+
db.select().from(schema.auditLog).where(eq(schema.auditLog.resourceId, organizationId))
214+
await expect
215+
.poll(async () => (await history()).map((entry) => entry.action).sort())
216+
.toEqual([
217+
'organization.sso_provider.created',
218+
'organization.sso_provider.deleted',
219+
'organization.sso_provider.updated',
220+
])
221+
for (const entry of await history()) {
222+
expect(entry).toMatchObject({
223+
actorId: userId,
224+
resourceType: 'organization',
225+
resourceId: organizationId,
226+
userAgent: 'SSO administration audit fixture',
227+
metadata: { organizationId, providerId, domain, actor: { kind: principal.kind } },
228+
})
229+
expect(JSON.stringify(entry)).not.toContain(config().cert)
230+
expect(JSON.stringify(entry)).not.toContain('rotated signing certificate')
231+
}
204232
})
205233

206234
it('refuses unverified domains and conceals organizations from outsiders', async () => {

‎apps/sim/lib/auth/sso/application/provider-settings.ts‎

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { AuditAction, AuditResourceType } from '@sim/audit'
12
import type { Principal } from '@sim/auth/principal'
23
import { db, ssoDomain, ssoProvider } from '@sim/db'
34
import {
@@ -21,11 +22,13 @@ import { markSignInProviders } from '@/lib/auth/sso/primary-provider'
2122
import { lockSsoProvider } from '@/lib/auth/sso/provider-lock'
2223
import { invalidateSsoPolicyCache } from '@/lib/auth/sso-policy'
2324
import { acquireOrganizationMutationLock } from '@/lib/billing/organizations/membership'
25+
import { recordProjectedUseCaseAuditEntries } from '@/lib/core/application/authorized-workspace-use-case'
2426
import { ForbiddenOperationError } from '@/lib/core/application/forbidden'
2527
import { requireOAuthOperationScope } from '@/lib/core/application/oauth-authorization'
2628
import type { OperationUseCase } from '@/lib/core/application/operation'
2729
import { authorizeOrganizationOperation } from '@/lib/core/application/organization-authorization'
2830
import { PrincipalKindAuthorizationError } from '@/lib/core/application/workspace-authorization'
31+
import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types'
2932
import { OrchestrationError } from '@/lib/core/orchestration/types'
3033

3134
interface ProviderScope {
@@ -155,7 +158,8 @@ export const listSsoProviders: OperationUseCase<
155158

156159
async function executeDeleteProvider(
157160
principal: Principal,
158-
input: { organizationId?: string; providerId: string }
161+
input: { organizationId?: string; providerId: string },
162+
request?: OrchestrationRequestContext
159163
) {
160164
requireProviderPrincipal(principal, ssoProviderOperations.delete)
161165
const [provider] = await db
@@ -193,14 +197,38 @@ async function executeDeleteProvider(
193197
const deleted = await tx
194198
.delete(ssoProvider)
195199
.where(and(eq(ssoProvider.id, provider.id), ownerClause))
196-
.returning({ id: ssoProvider.id })
200+
.returning({
201+
id: ssoProvider.id,
202+
providerId: ssoProvider.providerId,
203+
organizationId: ssoProvider.organizationId,
204+
domain: ssoProvider.domain,
205+
})
197206
if (deleted.length && provider.organizationId)
198207
await forgetPrimaryProvider(tx, provider.organizationId, provider.providerId)
199208
return deleted
200209
})
201-
if (!removed.length) throw new OrchestrationError('not_found', 'Provider not found')
202-
if (provider.organizationId) invalidateSsoPolicyCache(provider.organizationId)
203-
return { providerId: provider.providerId }
210+
const deleted = removed[0]
211+
if (!deleted) throw new OrchestrationError('not_found', 'Provider not found')
212+
if (deleted.organizationId) {
213+
invalidateSsoPolicyCache(deleted.organizationId)
214+
recordProjectedUseCaseAuditEntries(
215+
ssoProviderOperations.delete,
216+
null,
217+
principal,
218+
request,
219+
[
220+
{
221+
action: AuditAction.ORGANIZATION_SSO_PROVIDER_DELETED,
222+
resourceType: AuditResourceType.ORGANIZATION,
223+
resourceId: deleted.organizationId,
224+
description: 'Deleted organization SSO provider',
225+
metadata: { providerId: deleted.providerId, domain: deleted.domain },
226+
},
227+
],
228+
deleted.organizationId
229+
)
230+
}
231+
return { providerId: deleted.providerId }
204232
}
205233

206234
export const deleteSsoProvider: OperationUseCase<
@@ -209,7 +237,7 @@ export const deleteSsoProvider: OperationUseCase<
209237
{ providerId: string }
210238
> = {
211239
operation: ssoProviderOperations.delete,
212-
execute: ({ principal, input }) => executeDeleteProvider(principal, input),
240+
execute: ({ principal, input, request }) => executeDeleteProvider(principal, input, request),
213241
}
214242

215243
export const getSsoProvider: OperationUseCase<

0 commit comments

Comments
 (0)