Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
9c4c342
feat(observability): extend audit log, PostHog, and storage metering …
waleedlatif1 Jun 29, 2026
56835de
fix(observability): audit file/credential egress only on success
waleedlatif1 Jun 29, 2026
4bd30ae
fix(audit): record null actor for anonymous public-share downloads
waleedlatif1 Jun 29, 2026
b19912f
fix(observability): audit admin exports after zip; tidy comments
waleedlatif1 Jun 29, 2026
9da4270
fix(billing): complete the chargeback + overage financial trail
waleedlatif1 Jun 29, 2026
4aaf1b1
fix(storage): decrement copilot quota before deleting metadata
waleedlatif1 Jun 29, 2026
8064b9a
fix(observability): atomic copilot storage release; signup-blocked ac…
waleedlatif1 Jun 29, 2026
5a2191d
fix(storage): meter copilot ingest centrally for path symmetry
waleedlatif1 Jun 29, 2026
b9ac77e
fix(audit): skip WORKFLOW_EXPORTED when admin export is empty
waleedlatif1 Jun 29, 2026
9d9186e
fix(storage): settle copilot accounting before deleting the blob
waleedlatif1 Jun 29, 2026
ba23b00
fix(storage): decrement KB document storage atomically with deletion
waleedlatif1 Jun 29, 2026
59ee511
fix(audit): don't treat email verification as a login
waleedlatif1 Jun 29, 2026
f0a18ef
fix(audit): null actor FK when the user lookup throws
waleedlatif1 Jun 29, 2026
c06e507
revert(audit): drop session/account-lifecycle auth instrumentation
waleedlatif1 Jun 29, 2026
f617608
fix(storage): harden copilot+KB delete accounting against read errors…
waleedlatif1 Jun 29, 2026
f759a0d
chore(observability): drop two unused definitions
waleedlatif1 Jun 29, 2026
139174b
fix(knowledge): key hard-delete result off rows actually deleted
waleedlatif1 Jun 30, 2026
50052ba
fix(storage): gate copilot quota on all ingest paths; tidy inline com…
waleedlatif1 Jun 30, 2026
470edf7
fix(analytics): omit empty workspace_id on workspace-less file downloads
waleedlatif1 Jun 30, 2026
0433e52
fix(analytics): omit empty workspace_id/workflow_id on copilot_chat_sent
waleedlatif1 Jun 30, 2026
fe36c68
fix(analytics): clear stale org PostHog group on personal workspaces
waleedlatif1 Jun 30, 2026
e824f38
fix: address review — export audit timing, copilot delete signal, gro…
waleedlatif1 Jun 30, 2026
0c4a843
refactor(observability): final line-justification cleanup
waleedlatif1 Jun 30, 2026
1ca8d3d
fix(table): attribute import column audit to the importing user
waleedlatif1 Jun 30, 2026
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
27 changes: 26 additions & 1 deletion apps/realtime/src/handlers/variables.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
Expand All @@ -18,6 +19,9 @@ type PendingVariable = {
latest: { variableId: string; field: string; value: any; timestamp: number }
timeout: NodeJS.Timeout
opToSocket: Map<string, string>
/** Most recent writer, used as the audit actor when the coalesced update is persisted. */
actorId: string
actorName?: string
}

// Keyed by `${workflowId}:${variableId}:${field}`
Expand Down Expand Up @@ -177,6 +181,8 @@ export function setupVariablesHandlers(socket: AuthenticatedSocket, roomManager:
if (existing) {
clearTimeout(existing.timeout)
existing.latest = { variableId, field, value, timestamp }
existing.actorId = session.userId
existing.actorName = session.userName
if (operationId) existing.opToSocket.set(operationId, socket.id)
existing.timeout = setTimeout(async () => {
await flushVariableUpdate(workflowId, existing, roomManager)
Expand All @@ -196,6 +202,8 @@ export function setupVariablesHandlers(socket: AuthenticatedSocket, roomManager:
latest: { variableId, field, value, timestamp },
timeout,
opToSocket,
actorId: session.userId,
actorName: session.userName,
})
}
} catch (error) {
Expand Down Expand Up @@ -231,7 +239,11 @@ async function flushVariableUpdate(

try {
const workflowExists = await db
.select({ id: workflow.id })
.select({
id: workflow.id,
name: workflow.name,
workspaceId: workflow.workspaceId,
})
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
Expand Down Expand Up @@ -294,6 +306,19 @@ async function flushVariableUpdate(
})

if (updateSuccessful) {
const workflowRow = workflowExists[0]
recordAudit({
workspaceId: workflowRow.workspaceId ?? null,
actorId: pending.actorId,
actorName: pending.actorName,
action: AuditAction.WORKFLOW_VARIABLES_UPDATED,
resourceType: AuditResourceType.WORKFLOW,
resourceId: workflowId,
resourceName: workflowRow.name ?? undefined,
description: `Updated workflow variables`,
metadata: { variableId, field },
})

// Broadcast to room excluding all senders (works cross-pod via Redis adapter)
const senderSocketIds = [...pending.opToSocket.values()]
const broadcastPayload = {
Expand Down
109 changes: 109 additions & 0 deletions apps/sim/app/api/auth/oauth/token/route.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
Expand All @@ -11,6 +12,7 @@ import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types'
import { captureServerEvent } from '@/lib/posthog/server'
import {
getAtlassianServiceAccountSecret,
getCredential,
Expand Down Expand Up @@ -96,6 +98,24 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
)
}

recordAudit({
actorId: auth.userId,
action: AuditAction.CREDENTIAL_ACCESSED,
resourceType: AuditResourceType.CREDENTIAL,
resourceId: providerId,
description: `Accessed OAuth credential for provider ${providerId}`,
metadata: {
provider: providerId,
credentialType: 'oauth',
credentialAccountUserId,
},
request,
})
captureServerEvent(auth.userId, 'credential_used', {
credential_type: 'oauth',
provider_id: providerId,
})

return NextResponse.json({ accessToken }, { status: 200 })
} catch (error) {
const message = getErrorMessage(error, 'Failed to get OAuth token')
Expand All @@ -120,9 +140,39 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}

const saActorId = authz.requesterUserId
const saWorkspaceId = resolved.workspaceId ?? authz.workspaceId ?? null
const emitServiceAccountAccess = () => {
if (!saActorId) return
recordAudit({
workspaceId: saWorkspaceId,
actorId: saActorId,
action: AuditAction.CREDENTIAL_ACCESSED,
resourceType: AuditResourceType.CREDENTIAL,
resourceId: resolved.credentialId ?? credentialId,
description: `Accessed service account credential for provider ${resolved.providerId ?? 'unknown'}`,
metadata: {
provider: resolved.providerId,
credentialType: 'service_account',
},
request,
})
captureServerEvent(
saActorId,
'credential_used',
{
credential_type: 'service_account',
provider_id: resolved.providerId ?? 'unknown',
...(saWorkspaceId ? { workspace_id: saWorkspaceId } : {}),
},
saWorkspaceId ? { groups: { workspace: saWorkspaceId } } : undefined
)
}

try {
if (resolved.providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) {
const secret = await getAtlassianServiceAccountSecret(resolved.credentialId)
emitServiceAccountAccess()
return NextResponse.json(
{
accessToken: secret.apiToken,
Expand All @@ -137,6 +187,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
scopes ?? [],
impersonateEmail
)
emitServiceAccountAccess()
return NextResponse.json({ accessToken }, { status: 200 })
} catch (error) {
logger.error(`[${requestId}] Service account token error:`, error)
Expand Down Expand Up @@ -165,13 +216,42 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}

const oauthActorId = authz.requesterUserId
const oauthWorkspaceId = authz.workspaceId ?? null

try {
const { accessToken } = await refreshTokenIfNeeded(
requestId,
credential,
resolvedCredentialId
)

if (oauthActorId) {
recordAudit({
workspaceId: oauthWorkspaceId,
actorId: oauthActorId,
action: AuditAction.CREDENTIAL_ACCESSED,
resourceType: AuditResourceType.CREDENTIAL,
resourceId: resolvedCredentialId,
description: `Accessed OAuth credential for provider ${credential.providerId}`,
metadata: {
provider: credential.providerId,
credentialType: 'oauth',
},
request,
})
captureServerEvent(
oauthActorId,
'credential_used',
{
credential_type: 'oauth',
provider_id: credential.providerId,
...(oauthWorkspaceId ? { workspace_id: oauthWorkspaceId } : {}),
},
oauthWorkspaceId ? { groups: { workspace: oauthWorkspaceId } } : undefined
)
}

let instanceUrl: string | undefined
if (credential.providerId === 'salesforce' && credential.scope) {
const instanceMatch = credential.scope.match(SALESFORCE_INSTANCE_URL_REGEX)
Expand Down Expand Up @@ -247,13 +327,42 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json({ error: 'No access token available' }, { status: 400 })
}

const actorId = authz.requesterUserId
const workspaceId = authz.workspaceId ?? null

Comment thread
waleedlatif1 marked this conversation as resolved.
try {
const { accessToken } = await refreshTokenIfNeeded(
requestId,
credential,
resolvedCredentialId
)

if (actorId) {
recordAudit({
workspaceId,
actorId,
action: AuditAction.CREDENTIAL_ACCESSED,
resourceType: AuditResourceType.CREDENTIAL,
resourceId: resolvedCredentialId,
description: `Accessed OAuth credential for provider ${credential.providerId}`,
metadata: {
provider: credential.providerId,
credentialType: 'oauth',
},
request,
})
captureServerEvent(
actorId,
'credential_used',
{
credential_type: 'oauth',
provider_id: credential.providerId,
...(workspaceId ? { workspace_id: workspaceId } : {}),
},
workspaceId ? { groups: { workspace: workspaceId } } : undefined
)
}

// For Salesforce, extract instanceUrl from the scope field
let instanceUrl: string | undefined
if (credential.providerId === 'salesforce' && credential.scope) {
Expand Down
24 changes: 24 additions & 0 deletions apps/sim/app/api/billing/switch-plan/route.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { subscription as subscriptionTable } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
Expand Down Expand Up @@ -174,6 +175,29 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
{ set: { plan: targetPlanName } }
)

if (isOrgScopedSubscription(sub, userId)) {
recordAudit({
actorId: userId,
action: AuditAction.ORG_PLAN_CONVERTED,
resourceType: AuditResourceType.ORGANIZATION,
resourceId: sub.referenceId,
description: `Plan converted from ${sub.plan ?? 'unknown'} to ${targetPlanName}`,
metadata: {
organizationId: sub.referenceId,
subscriptionId: sub.id,
fromPlan: sub.plan,
toPlan: targetPlanName,
interval: targetInterval,
},
request,
})
captureServerEvent(userId, 'plan_converted', {
organization_id: sub.referenceId,
from_plan: sub.plan ?? 'unknown',
to_plan: targetPlanName,
})
}

return NextResponse.json({ success: true, plan: targetPlanName, interval: targetInterval })
} catch (error) {
logger.error('Failed to switch subscription', {
Expand Down
6 changes: 6 additions & 0 deletions apps/sim/app/api/environment/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { syncPersonalEnvCredentialsForUser } from '@/lib/credentials/environment'
import type { EnvironmentVariable } from '@/lib/environment/api'
import { captureServerEvent } from '@/lib/posthog/server'

const logger = createLogger('EnvironmentAPI')

Expand Down Expand Up @@ -89,6 +90,11 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
request: req,
})

captureServerEvent(session.user.id, 'environment_updated', {
key_count: Object.keys(variables).length,
scope: 'personal',
})

return NextResponse.json({ success: true })
} catch (error) {
logger.error(`[${requestId}] Error updating environment variables`, error)
Expand Down
20 changes: 19 additions & 1 deletion apps/sim/app/api/files/download/route.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { fileDownloadContract } from '@/lib/api/contracts/storage-transfer'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import type { StorageContext } from '@/lib/uploads/config'
import { hasCloudStorage } from '@/lib/uploads/core/storage-service'
import { verifyFileAccess } from '@/app/api/files/authorization'
Expand Down Expand Up @@ -84,10 +86,26 @@ export const POST = withRouteHandler(async (request: NextRequest) => {

logger.info(`Generated download URL for ${storageContext ?? 'inferred'} file: ${key}`)

const downloadName = name || key.split('/').pop() || 'download'
recordAudit({
workspaceId: null,
actorId: userId,
action: AuditAction.FILE_DOWNLOADED,
resourceType: AuditResourceType.FILE,
resourceName: downloadName,
description: `Downloaded file "${downloadName}"`,
metadata: { key, fileName: downloadName, context: storageContext },
request,
})
captureServerEvent(userId, 'file_downloaded', {
is_bulk: false,
Comment thread
waleedlatif1 marked this conversation as resolved.
file_count: 1,
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Download audit before transfer

Low Severity

These handlers emit FILE_DOWNLOADED audit and file_downloaded analytics when a serve URL is minted, before any file bytes are sent. A client that never follows the link still produces a download record, unlike the export route in the same PR which audits only at successful egress.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1ca8d3d. Configure here.


return NextResponse.json({
downloadUrl,
expiresIn: null,
fileName: name || key.split('/').pop() || 'download',
fileName: downloadName,
})
} catch (error) {
logger.error('Error in file download endpoint:', error)
Expand Down
Loading
Loading