Skip to content

Commit 0cfbce6

Browse files
feat(cloud-security): implement transaction for scan run and results creation (#2101)
Co-authored-by: Tofik Hasanov <annexcies@gmail.com>
1 parent 3f33e84 commit 0cfbce6

3 files changed

Lines changed: 132 additions & 42 deletions

File tree

apps/api/src/cloud-security/cloud-security.service.ts

Lines changed: 34 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -228,37 +228,40 @@ export class CloudSecurityService {
228228
const passedCount = findings.filter((f) => f.passed).length;
229229
const failedCount = findings.filter((f) => !f.passed).length;
230230

231-
// Create a scan run record
232-
const scanRun = await db.integrationCheckRun.create({
233-
data: {
234-
connectionId,
235-
checkId: `${provider}-security-scan`,
236-
checkName: `${provider.toUpperCase()} Security Scan`,
237-
status: 'success',
238-
startedAt: new Date(),
239-
completedAt: new Date(),
240-
totalChecked: findings.length,
241-
passedCount,
242-
failedCount,
243-
},
244-
});
245-
246-
// Store each finding as a check result
247-
if (findings.length > 0) {
248-
await db.integrationCheckResult.createMany({
249-
data: findings.map((finding) => ({
250-
checkRunId: scanRun.id,
251-
passed: finding.passed ?? false,
252-
resourceType: finding.resourceType,
253-
resourceId: finding.resourceId,
254-
title: finding.title,
255-
description: finding.description,
256-
severity: finding.passed ? 'info' : finding.severity, // Passed checks are info level
257-
remediation: finding.remediation,
258-
evidence: (finding.evidence || {}) as object,
259-
collectedAt: new Date(finding.createdAt),
260-
})),
231+
// Use a transaction to ensure atomicity - both run and results are created together
232+
await db.$transaction(async (tx) => {
233+
// Create a scan run record
234+
const scanRun = await tx.integrationCheckRun.create({
235+
data: {
236+
connectionId,
237+
checkId: `${provider}-security-scan`,
238+
checkName: `${provider.toUpperCase()} Security Scan`,
239+
status: 'success',
240+
startedAt: new Date(),
241+
completedAt: new Date(),
242+
totalChecked: findings.length,
243+
passedCount,
244+
failedCount,
245+
},
261246
});
262-
}
247+
248+
// Store each finding as a check result
249+
if (findings.length > 0) {
250+
await tx.integrationCheckResult.createMany({
251+
data: findings.map((finding) => ({
252+
checkRunId: scanRun.id,
253+
passed: finding.passed ?? false,
254+
resourceType: finding.resourceType,
255+
resourceId: finding.resourceId,
256+
title: finding.title,
257+
description: finding.description ?? '',
258+
severity: finding.passed ? 'info' : finding.severity,
259+
remediation: finding.remediation ?? null,
260+
evidence: (finding.evidence || {}) as object,
261+
collectedAt: new Date(finding.createdAt),
262+
})),
263+
});
264+
}
265+
});
263266
}
264267
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
'use server';
2+
3+
import { auth } from '@/utils/auth';
4+
import { revalidatePath } from 'next/cache';
5+
import { headers } from 'next/headers';
6+
7+
/**
8+
* Run cloud security scan for a new platform connection.
9+
* This server action calls the API and properly revalidates the cache,
10+
* ensuring consistent behavior with the legacy runTests action.
11+
*
12+
* @param connectionId - The IntegrationConnection ID (icn_...) to scan
13+
*/
14+
export const runPlatformScan = async (connectionId: string) => {
15+
const session = await auth.api.getSession({
16+
headers: await headers(),
17+
});
18+
19+
if (!session) {
20+
return {
21+
success: false,
22+
error: 'Unauthorized',
23+
};
24+
}
25+
26+
const orgId = session.session?.activeOrganizationId;
27+
if (!orgId) {
28+
return {
29+
success: false,
30+
error: 'No active organization',
31+
};
32+
}
33+
34+
try {
35+
// Call the cloud security scan API
36+
const apiUrl = process.env.NEXT_PUBLIC_API_URL || process.env.API_URL;
37+
if (!apiUrl) {
38+
return {
39+
success: false,
40+
error: 'API URL not configured',
41+
};
42+
}
43+
44+
const response = await fetch(`${apiUrl}/v1/cloud-security/scan/${connectionId}`, {
45+
method: 'POST',
46+
headers: {
47+
'Content-Type': 'application/json',
48+
'x-organization-id': orgId,
49+
},
50+
});
51+
52+
if (!response.ok) {
53+
const errorData = await response.json().catch(() => ({}));
54+
return {
55+
success: false,
56+
error: errorData.message || `Scan failed with status ${response.status}`,
57+
};
58+
}
59+
60+
const result = await response.json();
61+
62+
// Revalidate the cloud-tests page to refresh data
63+
const headersList = await headers();
64+
let path = headersList.get('x-pathname') || headersList.get('referer') || '';
65+
path = path.replace(/\/[a-z]{2}\//, '/');
66+
if (path) {
67+
revalidatePath(path);
68+
}
69+
// Also revalidate the org's cloud-tests path specifically
70+
revalidatePath(`/${orgId}/cloud-tests`);
71+
72+
return {
73+
success: true,
74+
findingsCount: result.findingsCount,
75+
provider: result.provider,
76+
scannedAt: result.scannedAt,
77+
};
78+
} catch (error) {
79+
console.error('Error running platform scan:', error);
80+
81+
return {
82+
success: false,
83+
error: error instanceof Error ? error.message : 'Failed to run scan',
84+
};
85+
}
86+
};

apps/app/src/app/(app)/[orgId]/cloud-tests/components/TestsLayout.tsx

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import { ConnectIntegrationDialog } from '@/components/integrations/ConnectIntegrationDialog';
44
import { ManageIntegrationDialog } from '@/components/integrations/ManageIntegrationDialog';
5-
import { api } from '@/lib/api-client';
65
import { Button, PageHeader, PageHeaderDescription, PageLayout } from '@trycompai/design-system';
76
import { Add, Settings } from '@trycompai/design-system/icons';
87
import { useMemo, useState } from 'react';
@@ -65,8 +64,8 @@ export function TestsLayout({ initialFindings, initialProviders, orgId }: TestsL
6564
},
6665
{
6766
fallbackData: initialFindings,
68-
refreshInterval: 5000,
6967
revalidateOnFocus: true,
68+
// No automatic polling - we manually refresh after scans via mutateFindings()
7069
},
7170
);
7271

@@ -83,9 +82,8 @@ export function TestsLayout({ initialFindings, initialProviders, orgId }: TestsL
8382
},
8483
{
8584
fallbackData: initialProviders,
86-
refreshInterval: 10000, // Refresh providers every 10 seconds
8785
revalidateOnFocus: true,
88-
revalidateOnMount: true, // Always revalidate on mount to get fresh data
86+
// No automatic polling - we manually refresh after scans via mutateProviders()
8987
},
9088
);
9189

@@ -137,7 +135,7 @@ export function TestsLayout({ initialFindings, initialProviders, orgId }: TestsL
137135

138136
setIsScanning(true);
139137
const startTime = Date.now();
140-
toast.message(`Starting ${targetProvider.name} security scan...`);
138+
toast.message(`Starting ${targetProvider.displayName || targetProvider.name} security scan...`);
141139

142140
try {
143141
if (targetProvider.isLegacy) {
@@ -154,16 +152,19 @@ export function TestsLayout({ initialFindings, initialProviders, orgId }: TestsL
154152
return null;
155153
}
156154
} else {
157-
// Use dedicated cloud security endpoint
158-
const response = await api.post(`/v1/cloud-security/scan/${targetProvider.id}`, {}, orgId);
159-
if (response.error) {
160-
console.error(`Error scanning ${targetProvider.name}:`, response.error);
161-
toast.error(`Failed to scan ${targetProvider.name}`);
155+
// Use server action for new platform connections (same pattern as legacy)
156+
// This ensures proper cache revalidation and consistent behavior
157+
const { runPlatformScan } = await import('../actions/run-platform-scan');
158+
const result = await runPlatformScan(targetProvider.id);
159+
160+
if (!result.success) {
161+
console.error('Platform scan error:', result.error);
162+
toast.error(`Scan failed: ${result.error || 'Unknown error'}`);
162163
return null;
163164
}
164165
}
165166

166-
// Refresh data to get updated results
167+
// Refresh data to get updated results (SWR cache + server cache already revalidated)
167168
await Promise.all([mutateProviders(), mutateFindings()]);
168169

169170
const elapsed = Math.round((Date.now() - startTime) / 1000);

0 commit comments

Comments
 (0)