diff --git a/apps/api/src/cloud-security/azure-remediation.service.ts b/apps/api/src/cloud-security/azure-remediation.service.ts index fe2bd0ad5b..846d037d56 100644 --- a/apps/api/src/cloud-security/azure-remediation.service.ts +++ b/apps/api/src/cloud-security/azure-remediation.service.ts @@ -1,6 +1,8 @@ import { Injectable, Logger } from '@nestjs/common'; import { db, Prisma } from '@db'; +import { getManifest } from '@trycompai/integration-platform'; import { CredentialVaultService } from '../integration-platform/services/credential-vault.service'; +import { OAuthCredentialsService } from '../integration-platform/services/oauth-credentials.service'; import { AiRemediationService } from './ai-remediation.service'; import { AzureSecurityService } from './providers/azure-security.service'; import { parseAzurePermissionError } from './remediation-error.utils'; @@ -36,6 +38,7 @@ export class AzureRemediationService { constructor( private readonly credentialVaultService: CredentialVaultService, + private readonly oauthCredentialsService: OAuthCredentialsService, private readonly aiRemediationService: AiRemediationService, private readonly azureSecurityService: AzureSecurityService, ) {} @@ -455,31 +458,13 @@ export class AzureRemediationService { throw new Error('No rollback steps available for this action.'); } - // Get fresh access token - const credentials = await this.resolveCredentials( + // Get fresh access token (auto-refreshes if expired) + const accessToken = await this.getValidAzureToken( action.connectionId, action.organizationId, ); - if (!credentials) { - throw new Error('Cannot retrieve Azure credentials for rollback.'); - } - - // OAuth flow: token from vault; legacy: SP client credentials - let accessToken = credentials.access_token as string | undefined; - if ( - !accessToken && - credentials.tenantId && - credentials.clientId && - credentials.clientSecret - ) { - accessToken = await this.azureSecurityService.getAccessToken( - credentials.tenantId as string, - credentials.clientId as string, - credentials.clientSecret as string, - ); - } if (!accessToken) { - throw new Error('Cannot obtain Azure access token for rollback.'); + throw new Error('Cannot obtain Azure access token for rollback. Please reconnect the integration.'); } this.logger.log( @@ -638,6 +623,56 @@ export class AzureRemediationService { // --- Private helpers --- + /** + * Get a valid Azure access token, refreshing if expired. + */ + private async getValidAzureToken( + connectionId: string, + organizationId: string, + ): Promise { + const manifest = getManifest('azure'); + const oauthConfig = manifest?.auth?.type === 'oauth2' ? manifest.auth.config : null; + + if (oauthConfig) { + const oauthCreds = await this.oauthCredentialsService.getCredentials( + 'azure', + organizationId, + ); + if (oauthCreds) { + const token = await this.credentialVaultService.getValidAccessToken( + connectionId, + { + tokenUrl: oauthConfig.tokenUrl, + clientId: oauthCreds.clientId, + clientSecret: oauthCreds.clientSecret, + clientAuthMethod: oauthConfig.clientAuthMethod, + }, + ); + if (token) return token; + } + } + + // Fallback: try raw credentials (legacy SP or expired token) + const credentials = + await this.credentialVaultService.getDecryptedCredentials(connectionId); + if (!credentials) return null; + + if (credentials.access_token) { + return credentials.access_token as string; + } + + // Legacy service principal flow + if (credentials.tenantId && credentials.clientId && credentials.clientSecret) { + return this.azureSecurityService.getAccessToken( + credentials.tenantId as string, + credentials.clientId as string, + credentials.clientSecret as string, + ); + } + + return null; + } + private async resolveCredentials( connectionId: string, organizationId: string, @@ -655,30 +690,16 @@ export class AzureRemediationService { organizationId: string, checkResultId: string, ) { - const credentials = await this.resolveCredentials( - connectionId, - organizationId, - ); - - let accessToken: string | null = null; - // OAuth flow: token from vault - if (credentials?.access_token) { - accessToken = credentials.access_token as string; - } - // Legacy SP flow fallback - if ( - !accessToken && - credentials?.tenantId && - credentials?.clientId && - credentials?.clientSecret - ) { - accessToken = await this.azureSecurityService.getAccessToken( - credentials.tenantId as string, - credentials.clientId as string, - credentials.clientSecret as string, - ); + const connection = await db.integrationConnection.findFirst({ + where: { id: connectionId, organizationId, status: 'active' }, + include: { provider: true }, + }); + if (!connection || connection.provider.slug !== 'azure') { + throw new Error('Azure connection not found or not active'); } + const accessToken = await this.getValidAzureToken(connectionId, organizationId); + const checkResult = await db.integrationCheckResult.findFirst({ where: { id: checkResultId, diff --git a/apps/api/src/integration-platform/services/credential-vault.service.ts b/apps/api/src/integration-platform/services/credential-vault.service.ts index 755625254f..09901b12a5 100644 --- a/apps/api/src/integration-platform/services/credential-vault.service.ts +++ b/apps/api/src/integration-platform/services/credential-vault.service.ts @@ -197,14 +197,28 @@ export class CredentialVaultService { } /** - * Get decrypted credentials for a connection + * Get decrypted credentials for a connection. + * Prefers the explicitly marked active version, falls back to latest by version number. */ async getDecryptedCredentials( connectionId: string, ): Promise | null> { - const latestVersion = - await this.credentialRepository.findLatestByConnection(connectionId); - if (!latestVersion) return null; + // Prefer the active credential version set during token storage/refresh + const connection = await this.connectionRepository.findById(connectionId); + let version = connection?.activeCredentialVersionId + ? await this.credentialRepository.findById( + connection.activeCredentialVersionId, + ) + : null; + + // Fall back to latest version by version number + if (!version) { + version = + await this.credentialRepository.findLatestByConnection(connectionId); + } + if (!version) return null; + + const latestVersion = version; const encryptedPayload = latestVersion.encryptedPayload as Record< string, @@ -297,8 +311,66 @@ export class CredentialVaultService { } /** - * Refresh OAuth tokens using the refresh token - * Returns the new access token, or null if refresh failed + * Attempt a single token refresh request to the OAuth provider. + * Returns the new access token on success, or null on failure. + */ + private async attemptTokenRefresh( + connectionId: string, + refreshToken: string, + config: TokenRefreshConfig, + ): Promise<{ token?: string; status?: number; errorBody?: string }> { + const body = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + }); + + const headers: Record = { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }; + + // Per OAuth 2.0 RFC 6749 Section 2.3.1, when using HTTP Basic auth (header), + // client credentials should NOT be included in the request body + if (config.clientAuthMethod === 'header') { + const credentials = Buffer.from( + `${config.clientId}:${config.clientSecret}`, + ).toString('base64'); + headers['Authorization'] = `Basic ${credentials}`; + } else { + body.set('client_id', config.clientId); + body.set('client_secret', config.clientSecret); + } + + const refreshEndpoint = config.refreshUrl || config.tokenUrl; + const response = await fetch(refreshEndpoint, { + method: 'POST', + headers, + body: body.toString(), + }); + + if (!response.ok) { + const errorBody = await response.text(); + return { status: response.status, errorBody }; + } + + const tokens: OAuthTokens = await response.json(); + + const tokensToStore: OAuthTokens = { + access_token: tokens.access_token, + refresh_token: tokens.refresh_token || refreshToken, + token_type: tokens.token_type, + expires_in: tokens.expires_in, + scope: tokens.scope, + }; + + await this.storeOAuthTokens(connectionId, tokensToStore); + return { token: tokens.access_token }; + } + + /** + * Refresh OAuth tokens using the refresh token. + * Retries once after a short delay before marking the connection as error. + * Returns the new access token, or null if refresh failed. */ async refreshOAuthTokens( connectionId: string, @@ -315,76 +387,55 @@ export class CredentialVaultService { try { this.logger.log(`Refreshing OAuth tokens for connection ${connectionId}`); - // Build the token request - const body = new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: refreshToken, - }); - - const headers: Record = { - 'Content-Type': 'application/x-www-form-urlencoded', - Accept: 'application/json', - }; - - // Add client credentials based on auth method - // Per OAuth 2.0 RFC 6749 Section 2.3.1, when using HTTP Basic auth (header), - // client credentials should NOT be included in the request body - if (config.clientAuthMethod === 'header') { - const credentials = Buffer.from( - `${config.clientId}:${config.clientSecret}`, - ).toString('base64'); - headers['Authorization'] = `Basic ${credentials}`; - } else { - // Default: send in body - body.set('client_id', config.clientId); - body.set('client_secret', config.clientSecret); + // First attempt + const first = await this.attemptTokenRefresh( + connectionId, + refreshToken, + config, + ); + if (first.token) { + this.logger.log( + `Successfully refreshed OAuth tokens for connection ${connectionId}`, + ); + return first.token; } - // Use refreshUrl if provided, otherwise fall back to tokenUrl - const refreshEndpoint = config.refreshUrl || config.tokenUrl; - - const response = await fetch(refreshEndpoint, { - method: 'POST', - headers, - body: body.toString(), - }); + // Retry once after 2 seconds for transient failures (rate limits, network blips) + this.logger.warn( + `Token refresh attempt 1 failed for connection ${connectionId}: HTTP ${first.status} — ${first.errorBody ?? '(no body)'}. Retrying in 2s...`, + ); + await new Promise((r) => setTimeout(r, 2000)); - if (!response.ok) { - await response.text(); // consume body - this.logger.error( - `Token refresh failed for connection ${connectionId}: ${response.status}`, + const second = await this.attemptTokenRefresh( + connectionId, + refreshToken, + config, + ); + if (second.token) { + this.logger.log( + `Successfully refreshed OAuth tokens for connection ${connectionId} on retry`, ); - - // If refresh token is invalid/expired, mark connection as error - if (response.status === 400 || response.status === 401) { - await this.connectionRepository.update(connectionId, { - status: 'error', - errorMessage: - 'OAuth token expired. Please reconnect the integration.', - }); - } - - return null; + return second.token; } - const tokens: OAuthTokens = await response.json(); - - // Store the new tokens - // Note: Some providers return a new refresh token, some don't - const tokensToStore: OAuthTokens = { - access_token: tokens.access_token, - refresh_token: tokens.refresh_token || refreshToken, // Keep old refresh token if not provided - token_type: tokens.token_type, - expires_in: tokens.expires_in, - scope: tokens.scope, - }; + // Both attempts failed — log the full error and mark connection + this.logger.error( + `Token refresh failed for connection ${connectionId} after 2 attempts: HTTP ${second.status} — ${second.errorBody ?? '(no body)'}`, + ); - await this.storeOAuthTokens(connectionId, tokensToStore); + if ( + second.status === 400 || + second.status === 401 || + second.status === 403 + ) { + await this.connectionRepository.update(connectionId, { + status: 'error', + errorMessage: + 'OAuth token expired. Please reconnect the integration.', + }); + } - this.logger.log( - `Successfully refreshed OAuth tokens for connection ${connectionId}`, - ); - return tokens.access_token; + return null; } catch (error) { this.logger.error( `Error refreshing tokens for connection ${connectionId}:`, @@ -402,10 +453,9 @@ export class CredentialVaultService { connectionId: string, refreshConfig?: TokenRefreshConfig, ): Promise { - // Check if we need to refresh - const needsRefresh = await this.needsRefresh(connectionId); + const shouldRefresh = await this.needsRefresh(connectionId); - if (needsRefresh && refreshConfig) { + if (shouldRefresh && refreshConfig) { const newToken = await this.refreshOAuthTokens( connectionId, refreshConfig, @@ -416,7 +466,6 @@ export class CredentialVaultService { // If refresh failed, try to use existing token (might still work briefly) } - // Get current credentials const credentials = await this.getDecryptedCredentials(connectionId); return typeof credentials?.access_token === 'string' ? credentials.access_token diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/single-fix.ts b/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/single-fix.ts new file mode 100644 index 0000000000..9c99cb2ab5 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/single-fix.ts @@ -0,0 +1,93 @@ +'use server'; + +import { auth as triggerAuth, tasks } from '@trigger.dev/sdk'; +import { auth } from '@/utils/auth'; +import { headers } from 'next/headers'; + +interface PreviewInput { + connectionId: string; + checkResultId: string; + remediationKey: string; + cachedPermissions?: string[]; +} + +export async function startPreview( + input: PreviewInput, +): Promise<{ data?: { runId: string; accessToken: string }; error?: string }> { + try { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session?.user?.id) { + return { error: 'Unauthorized' }; + } + + const organizationId = session.session?.activeOrganizationId; + if (!organizationId) { + return { error: 'No active organization' }; + } + + const handle = await tasks.trigger('remediate-preview', { + connectionId: input.connectionId, + organizationId, + checkResultId: input.checkResultId, + remediationKey: input.remediationKey, + userId: session.user.id, + cachedPermissions: input.cachedPermissions, + }); + + const accessToken = await triggerAuth.createPublicToken({ + scopes: { read: { runs: [handle.id] } }, + }); + + return { data: { runId: handle.id, accessToken } }; + } catch (err) { + console.error('Failed to start preview:', err); + return { error: err instanceof Error ? err.message : 'Failed to load preview' }; + } +} + +interface SingleFixInput { + connectionId: string; + checkResultId: string; + remediationKey: string; + acknowledgment?: string; +} + +export async function startSingleFix( + input: SingleFixInput, +): Promise<{ data?: { runId: string; accessToken: string }; error?: string }> { + try { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session?.user?.id) { + return { error: 'Unauthorized' }; + } + + const organizationId = session.session?.activeOrganizationId; + if (!organizationId) { + return { error: 'No active organization' }; + } + + const handle = await tasks.trigger('remediate-single', { + connectionId: input.connectionId, + organizationId, + checkResultId: input.checkResultId, + remediationKey: input.remediationKey, + userId: session.user.id, + acknowledgment: input.acknowledgment, + }); + + const accessToken = await triggerAuth.createPublicToken({ + scopes: { read: { runs: [handle.id] } }, + }); + + return { data: { runId: handle.id, accessToken } }; + } catch (err) { + console.error('Failed to start single fix:', err); + return { error: err instanceof Error ? err.message : 'Failed to start fix' }; + } +} diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/RemediationDialog.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/RemediationDialog.tsx index 53f2da103b..7b6676a5a8 100644 --- a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/RemediationDialog.tsx +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/RemediationDialog.tsx @@ -1,6 +1,5 @@ 'use client'; -import { useApi } from '@/hooks/use-api'; import { Badge } from '@trycompai/ui/badge'; import { Button } from '@trycompai/ui/button'; import { @@ -10,12 +9,27 @@ import { DialogHeader, DialogTitle, } from '@trycompai/ui/dialog'; +import { useRealtimeRun } from '@trigger.dev/react-hooks'; import { AlertTriangle, ListOrdered, Loader2, RotateCcw } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { toast } from 'sonner'; +import { startPreview, startSingleFix } from '../actions/single-fix'; import { AcknowledgmentPanel } from './AcknowledgmentPanel'; import { PermissionErrorPanel } from './PermissionErrorPanel'; +interface PreviewProgress { + phase: 'analyzing' | 'complete' | 'failed'; + error?: string; + preview?: PreviewData; +} + +interface SingleFixProgress { + phase: 'executing' | 'success' | 'failed' | 'needs_permissions'; + error?: string; + actionId?: string; + permissionError?: { missingActions: string[]; fixScript?: string }; +} + interface RemediationDialogProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -258,7 +272,6 @@ export function RemediationDialog({ description, onComplete, }: RemediationDialogProps) { - const api = useApi(); const [preview, setPreview] = useState(null); const [isLoadingPreview, setIsLoadingPreview] = useState(false); const [isExecuting, setIsExecuting] = useState(false); @@ -268,51 +281,121 @@ export function RemediationDialog({ const [permissionError, setPermissionError] = useState<{ missingActions: string[]; fixScript?: string } | null>(null); const [acknowledgment, setAcknowledgment] = useState(null); + // Trigger.dev state for preview (async) + const [previewRunId, setPreviewRunId] = useState(null); + const [previewAccessToken, setPreviewAccessToken] = useState(null); + + // Trigger.dev state for execute (async) + const [executeRunId, setExecuteRunId] = useState(null); + const [executeAccessToken, setExecuteAccessToken] = useState(null); + + const { run: previewRun } = useRealtimeRun(previewRunId ?? '', { + accessToken: previewAccessToken ?? undefined, + enabled: Boolean(previewRunId && previewAccessToken), + }); + + const { run: executeRun } = useRealtimeRun(executeRunId ?? '', { + accessToken: executeAccessToken ?? undefined, + enabled: Boolean(executeRunId && executeAccessToken), + }); + // Ref to store permissions across rechecks (avoids stale closure in useCallback) const permissionsRef = useRef(undefined); + // Watch preview task progress + useEffect(() => { + const progress = (previewRun?.metadata as { progress?: PreviewProgress } | undefined)?.progress; + if (!progress || progress.phase === 'analyzing') return; + + if (progress.phase === 'complete' && progress.preview) { + const previewData = progress.preview as unknown as PreviewData; + setPreview(previewData); + if (previewData.allRequiredPermissions) { + permissionsRef.current = previewData.allRequiredPermissions; + } + setIsLoadingPreview(false); + setPreviewRunId(null); + setPreviewAccessToken(null); + } else if (progress.phase === 'failed') { + setError(progress.error || 'Failed to load preview'); + setIsLoadingPreview(false); + setPreviewRunId(null); + setPreviewAccessToken(null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [previewRun?.metadata]); + + // Watch execute task progress + useEffect(() => { + const progress = (executeRun?.metadata as { progress?: SingleFixProgress } | undefined)?.progress; + if (!progress || progress.phase === 'executing') return; + + if (progress.phase === 'success') { + setIsExecuting(false); + setPreview(null); + setError(null); + setSucceeded(true); + toast.success('Fix applied successfully'); + onComplete?.(); + setTimeout(() => { + onOpenChange(false); + setSucceeded(false); + setExecuteRunId(null); + setExecuteAccessToken(null); + }, 4000); + } else if (progress.phase === 'failed') { + setIsExecuting(false); + setError(progress.error || 'Remediation failed'); + setExecuteRunId(null); + setExecuteAccessToken(null); + } else if (progress.phase === 'needs_permissions') { + setIsExecuting(false); + setError(progress.error || 'Missing permissions'); + if (progress.permissionError) { + setPermissionError(progress.permissionError); + } + setExecuteRunId(null); + setExecuteAccessToken(null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [executeRun?.metadata]); + const loadPreview = useCallback(async (recheck = false) => { setIsLoadingPreview(true); setError(null); try { - const response = await api.post( - '/v1/cloud-security/remediation/preview', - { - connectionId, - checkResultId, - remediationKey, - // On recheck, send the cached permissions so backend doesn't re-run AI - ...(recheck && permissionsRef.current && { - cachedPermissions: permissionsRef.current, - }), - }, - ); - if (response.error) { - setError( - typeof response.error === 'string' - ? response.error - : 'Failed to load preview', - ); + const result = await startPreview({ + connectionId, + checkResultId, + remediationKey, + ...(recheck && permissionsRef.current && { + cachedPermissions: permissionsRef.current, + }), + }); + if (result.error || !result.data) { + setError(result.error || 'Failed to load preview'); + setIsLoadingPreview(false); return; } - const previewData = response.data as PreviewData; - setPreview(previewData); - // Store permissions in ref so Recheck can access them without stale closure - if (previewData.allRequiredPermissions) { - permissionsRef.current = previewData.allRequiredPermissions; - } + // Task started — preview effect handles the rest + setPreviewRunId(result.data.runId); + setPreviewAccessToken(result.data.accessToken); } catch { setError('Failed to load preview'); - } finally { setIsLoadingPreview(false); } - }, [api, connectionId, checkResultId, remediationKey]); + }, [connectionId, checkResultId, remediationKey]); useEffect(() => { if (!open) return; setError(null); setPermissionError(null); setAcknowledgment(null); + setPreviewRunId(null); + setPreviewAccessToken(null); + setExecuteRunId(null); + setExecuteAccessToken(null); + setSucceeded(false); // Guided-only: skip API call, use local data if (guidedOnly && guidedSteps) { @@ -339,45 +422,22 @@ export function RemediationDialog({ setError(null); setPermissionError(null); try { - const response = await api.post<{ - status: string; - error?: string; - permissionError?: { missingActions: string[]; fixScript?: string }; - }>( - '/v1/cloud-security/remediation/execute', - { connectionId, checkResultId, remediationKey, acknowledgment }, - ); - if (response.error) { - const msg = - typeof response.error === 'string' - ? response.error - : 'Remediation failed'; - setError(msg); + const result = await startSingleFix({ + connectionId, + checkResultId, + remediationKey, + acknowledgment: acknowledgment ?? undefined, + }); + if (result.error || !result.data) { + setError(result.error || 'Failed to start fix'); + setIsExecuting(false); return; } - - const data = response.data; - if (data?.status === 'success') { - setPreview(null); - setError(null); - setSucceeded(true); - toast.success('Fix applied successfully'); - // Trigger re-scan, then close dialog after user sees confirmation - onComplete?.(); - setTimeout(() => { - onOpenChange(false); - setSucceeded(false); - }, 4000); - } else { - const msg = data?.error || 'Remediation failed'; - setError(msg); - if (data?.permissionError) { - setPermissionError(data.permissionError); - } - } + // Task started — execute effect handles the rest + setExecuteRunId(result.data.runId); + setExecuteAccessToken(result.data.accessToken); } catch { - setError('Remediation failed. Please try again.'); - } finally { + setError('Failed to start fix'); setIsExecuting(false); } }; diff --git a/apps/app/src/trigger/tasks/cloud-security/remediate-batch.ts b/apps/app/src/trigger/tasks/cloud-security/remediate-batch.ts index 3e00ae2567..282564a309 100644 --- a/apps/app/src/trigger/tasks/cloud-security/remediate-batch.ts +++ b/apps/app/src/trigger/tasks/cloud-security/remediate-batch.ts @@ -135,7 +135,7 @@ async function persistProgress(batchId: string, progress: BatchProgress) { export const remediateBatch = task({ id: 'remediate-batch', - maxDuration: 1000 * 60 * 30, + maxDuration: 60 * 30, // 30 minutes (seconds, not ms) retry: { maxAttempts: 1 }, run: async (payload: { batchId: string; diff --git a/apps/app/src/trigger/tasks/cloud-security/remediate-preview.ts b/apps/app/src/trigger/tasks/cloud-security/remediate-preview.ts new file mode 100644 index 0000000000..b24114b4f2 --- /dev/null +++ b/apps/app/src/trigger/tasks/cloud-security/remediate-preview.ts @@ -0,0 +1,82 @@ +import { logger, metadata, task } from '@trigger.dev/sdk'; + +interface PreviewProgress { + phase: 'analyzing' | 'complete' | 'failed'; + error?: string; + preview?: Record; +} + +const getApiBaseUrl = () => + process.env.NEXT_PUBLIC_API_URL || process.env.API_BASE_URL || 'http://localhost:3333'; + +function makeHeaders(organizationId: string, userId?: string): Record { + return { + 'Content-Type': 'application/json', + 'x-service-token': process.env.SERVICE_TOKEN_TRIGGER!, + 'x-organization-id': organizationId, + ...(userId && { 'x-user-id': userId }), + }; +} + +function sync(progress: PreviewProgress) { + metadata.set('progress', JSON.parse(JSON.stringify(progress))); +} + +export const remediatePreview = task({ + id: 'remediate-preview', + maxDuration: 60 * 3, // 3 minutes + retry: { maxAttempts: 1 }, + run: async (payload: { + connectionId: string; + organizationId: string; + checkResultId: string; + remediationKey: string; + userId: string; + cachedPermissions?: string[]; + }) => { + const { connectionId, organizationId, checkResultId, remediationKey, userId, cachedPermissions } = payload; + + logger.info(`Preview: ${remediationKey} on ${checkResultId} (user: ${userId})`); + + const progress: PreviewProgress = { phase: 'analyzing' }; + sync(progress); + + try { + const url = `${getApiBaseUrl()}/v1/cloud-security/remediation/preview`; + const resp = await fetch(url, { + method: 'POST', + headers: makeHeaders(organizationId, userId), + body: JSON.stringify({ + connectionId, + checkResultId, + remediationKey, + ...(cachedPermissions && { cachedPermissions }), + }), + }); + + const json = await resp.json(); + + if (!resp.ok) { + const errorMsg = (json as { message?: string }).message ?? `HTTP ${resp.status}`; + progress.phase = 'failed'; + progress.error = errorMsg; + sync(progress); + logger.error(`Preview failed: ${errorMsg}`); + return { success: false, error: errorMsg }; + } + + progress.phase = 'complete'; + progress.preview = json as Record; + sync(progress); + logger.info(`Preview complete for ${remediationKey}`); + return { success: true, preview: json }; + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + progress.phase = 'failed'; + progress.error = errorMsg; + sync(progress); + logger.error(`Preview exception: ${errorMsg}`); + return { success: false, error: errorMsg }; + } + }, +}); diff --git a/apps/app/src/trigger/tasks/cloud-security/remediate-single.ts b/apps/app/src/trigger/tasks/cloud-security/remediate-single.ts new file mode 100644 index 0000000000..988536d5d5 --- /dev/null +++ b/apps/app/src/trigger/tasks/cloud-security/remediate-single.ts @@ -0,0 +1,107 @@ +import { logger, metadata, task } from '@trigger.dev/sdk'; + +interface SingleFixProgress { + phase: 'executing' | 'success' | 'failed' | 'needs_permissions'; + error?: string; + actionId?: string; + permissionError?: { missingActions: string[]; fixScript?: string }; +} + +const getApiBaseUrl = () => + process.env.NEXT_PUBLIC_API_URL || process.env.API_BASE_URL || 'http://localhost:3333'; + +function makeHeaders(organizationId: string, userId?: string): Record { + return { + 'Content-Type': 'application/json', + 'x-service-token': process.env.SERVICE_TOKEN_TRIGGER!, + 'x-organization-id': organizationId, + ...(userId && { 'x-user-id': userId }), + }; +} + +function sync(progress: SingleFixProgress) { + metadata.set('progress', JSON.parse(JSON.stringify(progress))); +} + +interface ExecuteResult { + actionId?: string; + status: string; + error?: string; + permissionError?: { missingActions: string[]; fixScript?: string }; +} + +export const remediateSingle = task({ + id: 'remediate-single', + maxDuration: 60 * 5, // 5 minutes (seconds, not ms) + retry: { maxAttempts: 1 }, + run: async (payload: { + connectionId: string; + organizationId: string; + checkResultId: string; + remediationKey: string; + userId: string; + acknowledgment?: string; + }) => { + const { connectionId, organizationId, checkResultId, remediationKey, userId, acknowledgment } = payload; + + logger.info(`Single fix: ${remediationKey} on ${checkResultId} (user: ${userId})`); + + const progress: SingleFixProgress = { phase: 'executing' }; + sync(progress); + + try { + const url = `${getApiBaseUrl()}/v1/cloud-security/remediation/execute`; + const resp = await fetch(url, { + method: 'POST', + headers: makeHeaders(organizationId, userId), + body: JSON.stringify({ + connectionId, + checkResultId, + remediationKey, + acknowledgment, + }), + }); + + const json = (await resp.json()) as ExecuteResult; + + if (!resp.ok) { + const errorMsg = (json as { message?: string }).message ?? `HTTP ${resp.status}`; + progress.phase = 'failed'; + progress.error = errorMsg; + sync(progress); + logger.error(`Single fix failed: ${errorMsg}`); + return { success: false, error: errorMsg }; + } + + if (json.status === 'success') { + progress.phase = 'success'; + progress.actionId = json.actionId; + sync(progress); + logger.info(`Single fix succeeded: ${json.actionId}`); + return { success: true, actionId: json.actionId }; + } + + if (json.permissionError) { + progress.phase = 'needs_permissions'; + progress.error = json.error ?? 'Missing permissions'; + progress.permissionError = json.permissionError; + sync(progress); + logger.warn(`Single fix needs permissions: ${json.permissionError.missingActions.join(', ')}`); + return { success: false, needsPermissions: true, permissionError: json.permissionError }; + } + + progress.phase = 'failed'; + progress.error = json.error ?? 'Remediation failed'; + sync(progress); + logger.error(`Single fix failed: ${json.error}`); + return { success: false, error: json.error }; + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + progress.phase = 'failed'; + progress.error = errorMsg; + sync(progress); + logger.error(`Single fix exception: ${errorMsg}`); + return { success: false, error: errorMsg }; + } + }, +});