diff --git a/backend/src/__tests__/scheduler-service.test.ts b/backend/src/__tests__/scheduler-service.test.ts index 34b88450..5ac83a9c 100644 --- a/backend/src/__tests__/scheduler-service.test.ts +++ b/backend/src/__tests__/scheduler-service.test.ts @@ -26,6 +26,7 @@ const { mockRunCommand, mockDeployStack, mockBackupStackFiles, + mockEnforcePolicyPreDeploy, } = vi.hoisted(() => ({ mockGetDueScheduledTasks: vi.fn().mockReturnValue([]), mockCreateScheduledTaskRun: vi.fn().mockReturnValue(1), @@ -66,6 +67,7 @@ const { mockRunCommand: vi.fn().mockResolvedValue(undefined), mockDeployStack: vi.fn().mockResolvedValue(undefined), mockBackupStackFiles: vi.fn().mockResolvedValue(undefined), + mockEnforcePolicyPreDeploy: vi.fn(), })); vi.mock('../services/DatabaseService', () => ({ @@ -185,10 +187,16 @@ vi.mock('../services/TrivyService', () => ({ }, })); +vi.mock('../services/PolicyEnforcement', () => ({ + enforcePolicyPreDeploy: mockEnforcePolicyPreDeploy, +})); + import { SchedulerService } from '../services/SchedulerService'; beforeEach(() => { vi.clearAllMocks(); + // Default: the scan-policy gate allows. Individual tests override to a block. + mockEnforcePolicyPreDeploy.mockResolvedValue({ ok: true, bypassed: false, violations: [] }); (SchedulerService as any).instance = undefined; }); @@ -812,6 +820,73 @@ describe('SchedulerService - executeUpdate', () => { ); }); + it('dispatches a scan_finding warning and skips the stack when a policy blocks the update', async () => { + mockGetScheduledTask.mockReturnValue({ + id: 88, + name: 'blocked-update', + action: 'update', + cron_expression: '0 4 * * *', + enabled: true, + target_id: 'web-app', + node_id: 1, + created_by: 'admin', + last_status: null, + }); + mockGetContainersByStack.mockResolvedValue([{ Id: 'c1', Image: 'nginx:1.14' }]); + mockCheckImage.mockResolvedValue({ hasUpdate: true }); + mockEnforcePolicyPreDeploy.mockResolvedValue({ + ok: false, + bypassed: false, + policy: { id: 1, name: 'block-high', max_severity: 'HIGH' }, + violations: [{ imageRef: 'nginx:1.14', severity: 'CRITICAL', criticalCount: 2, highCount: 5, scanId: 7 }], + }); + + const svc = SchedulerService.getInstance(); + await svc.triggerTask(88); + + // Gate blocked it: the stack must not be updated. + expect(mockUpdateStack).not.toHaveBeenCalled(); + // A scan_finding warning naming the policy and the offending image fired. + const warn = mockDispatchAlert.mock.calls.find((c) => c[0] === 'warning' && c[1] === 'scan_finding'); + expect(warn).toBeDefined(); + expect(warn![2]).toContain('block-high'); + expect(warn![2]).toContain('nginx:1.14'); + // The run completes (skip-and-continue), not a hard task failure. + expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith(1, expect.objectContaining({ status: 'success' })); + }); + + it('reports a failed run and an Auto-start warning when a policy blocks a scheduled auto-start', async () => { + mockGetScheduledTask.mockReturnValue({ + id: 89, + name: 'blocked-start', + action: 'auto_start', + cron_expression: '0 4 * * *', + enabled: true, + target_id: 'web-app', + node_id: 1, + created_by: 'admin', + last_status: null, + }); + mockEnforcePolicyPreDeploy.mockResolvedValue({ + ok: false, + bypassed: false, + policy: { id: 1, name: 'block-high', max_severity: 'HIGH' }, + violations: [{ imageRef: 'nginx:1.14', severity: 'CRITICAL', criticalCount: 1, highCount: 0, scanId: 3 }], + }); + + const svc = SchedulerService.getInstance(); + await svc.triggerTask(89); + + // Gate blocked it: the stack must not start. + expect(mockDeployStack).not.toHaveBeenCalled(); + const warn = mockDispatchAlert.mock.calls.find((c) => c[0] === 'warning' && c[1] === 'scan_finding'); + expect(warn).toBeDefined(); + expect(warn![2]).toContain('Auto-start'); + expect(warn![2]).toContain('block-high'); + // Auto-start does not skip-and-continue; the run is recorded as a failure. + expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith(1, expect.objectContaining({ status: 'failure' })); + }); + it('exposes isTaskRunning status', async () => { const svc = SchedulerService.getInstance(); expect(svc.isTaskRunning(999)).toBe(false); diff --git a/backend/src/routes/imageUpdates.ts b/backend/src/routes/imageUpdates.ts index babcdcfd..5a9e2d35 100644 --- a/backend/src/routes/imageUpdates.ts +++ b/backend/src/routes/imageUpdates.ts @@ -288,7 +288,8 @@ autoUpdateRouter.post('/execute', authMiddleware, async (req: Request, res: Resp }), ); if (!autoUpdateGate.ok) { - const blockedMsg = `Policy "${autoUpdateGate.policy?.name}" blocked auto-update: ${autoUpdateGate.violations.length} image(s) exceed ${autoUpdateGate.policy?.max_severity}`; + const blockedImages = autoUpdateGate.violations.map((v) => v.imageRef).join(', '); + const blockedMsg = `Policy "${autoUpdateGate.policy?.name}" blocked auto-update: ${autoUpdateGate.violations.length} image(s) exceed ${autoUpdateGate.policy?.max_severity}${blockedImages ? ` (${blockedImages})` : ''}`; NotificationService.getInstance().dispatchAlert('warning', 'scan_finding', blockedMsg, { stackName, actor: 'system:image-update' }); results.push(`Stack "${stackName}": ${blockedMsg}`); continue; diff --git a/backend/src/services/PolicyEnforcement.ts b/backend/src/services/PolicyEnforcement.ts index 6e3e249a..04213f65 100644 --- a/backend/src/services/PolicyEnforcement.ts +++ b/backend/src/services/PolicyEnforcement.ts @@ -18,6 +18,7 @@ import TrivyService from './TrivyService'; import { isSeverityAtLeast } from '../utils/severity'; import { validateImageRef } from '../utils/image-ref'; import { getErrorMessage } from '../utils/errors'; +import { isDebugEnabled } from '../utils/debug'; export interface PolicyViolation { imageRef: string; @@ -140,6 +141,14 @@ export async function enforcePolicyForImageRefs( return { ok: true, bypassed: false, policy, violations: [], trivyMissing: true }; } + const debug = isDebugEnabled(); + if (debug) { + console.log( + '[Policy:debug] Evaluating "%s" against policy "%s" (max=%s, images=%d)', + sanitizeForLog(stackName), sanitizeForLog(policy.name), policy.max_severity, imageRefs.length, + ); + } + const violations: PolicyViolation[] = []; for (const imageRef of imageRefs) { if (!validateImageRef(imageRef)) { @@ -157,6 +166,12 @@ export async function enforcePolicyForImageRefs( try { const scan = await svc.scanImagePreflight(imageRef, nodeId, stackName); const severity = scan.highest_severity ?? 'UNKNOWN'; + if (debug) { + console.log( + '[Policy:debug] %s scanned: highest=%s vs max=%s', + sanitizeForLog(imageRef), severity, policy.max_severity, + ); + } if (isSeverityAtLeast(severity, policy.max_severity)) { violations.push({ imageRef, @@ -198,8 +213,18 @@ export async function enforcePolicyForImageRefs( } catch (err) { console.error('[Policy] Failed to record bypass audit entry:', err); } + if (debug) { + console.log( + '[Policy:debug] Bypass by "%s" for "%s" (%d violation(s))', + sanitizeForLog(opts.actor), sanitizeForLog(stackName), violations.length, + ); + } return { ok: true, bypassed: true, policy, violations }; } + console.warn( + '[Policy] Blocked deploy for "%s": %d image(s) exceed %s (policy "%s")', + sanitizeForLog(stackName), violations.length, policy.max_severity, sanitizeForLog(policy.name), + ); return { ok: false, bypassed: false, policy, violations }; } diff --git a/backend/src/services/SchedulerService.ts b/backend/src/services/SchedulerService.ts index 53e7746f..8c349d18 100644 --- a/backend/src/services/SchedulerService.ts +++ b/backend/src/services/SchedulerService.ts @@ -18,7 +18,8 @@ import TrivyService from './TrivyService'; import type { ScanAllNodeImagesResult } from './TrivyService'; import TrivyInstaller from './TrivyInstaller'; import { CloudBackupService } from './CloudBackupService'; -import { assertPolicyGateAllows, buildSystemPolicyGateOptions } from '../helpers/policyGate'; +import { buildSystemPolicyGateOptions } from '../helpers/policyGate'; +import { enforcePolicyPreDeploy } from './PolicyEnforcement'; const TRIVY_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; const TRIVY_UPDATE_CHECK_STARTUP_DELAY_MS = 5 * 60 * 1000; @@ -171,6 +172,40 @@ export class SchedulerService { .catch(err => console.error('[SchedulerService] Notification dispatch failed:', getErrorMessage(err, 'unknown error'))); } + /** + * Run the pre-deploy scan-policy gate for a scheduler-driven action. On a + * block, dispatch the documented `scan_finding` warning naming the policy + * and the offending images, then throw so the caller records the outcome: + * the auto-update loop catches per stack and continues the rest of the run, + * while a single-stack auto-start surfaces as a task failure. The gate + * fails open when Trivy is missing and is evaluation-only when the node's + * local tier is unpaid. + */ + private async enforceSchedulerPolicyGate( + stackName: string, + nodeId: number, + action: 'Auto-start' | 'Auto-update', + auditPath: string, + ): Promise { + const actor = action === 'Auto-start' ? 'scheduler:auto-start' : 'scheduler:auto-update'; + const gate = await enforcePolicyPreDeploy( + stackName, + nodeId, + buildSystemPolicyGateOptions(actor, { auditPath }), + ); + if (gate.ok) return; + const images = gate.violations.map((v) => v.imageRef).join(', '); + this.safeDispatch( + 'warning', + 'scan_finding', + `${action} blocked for "${stackName}" by policy "${gate.policy?.name}": ${gate.violations.length} image(s) exceed ${gate.policy?.max_severity}${images ? ` (${images})` : ''}`, + stackName, + ); + throw new Error( + `${action} blocked by policy "${gate.policy?.name}": ${gate.violations.length} image(s) exceed ${gate.policy?.max_severity}`, + ); + } + private async tick(): Promise { if (this.isProcessing) { console.warn('[SchedulerService] Tick skipped: previous tick still processing'); @@ -436,12 +471,11 @@ export class SchedulerService { private async executeAutoStart(task: ScheduledTask): Promise { this.assertStackTarget(task, 'Auto-start'); - await assertPolicyGateAllows( + await this.enforceSchedulerPolicyGate( task.target_id, task.node_id, - buildSystemPolicyGateOptions('scheduler:auto-start', { - auditPath: `/api/scheduled-tasks/${task.id}/run`, - }), + 'Auto-start', + `/api/scheduled-tasks/${task.id}/run`, ); await ComposeService.getInstance(task.node_id).deployStack(task.target_id); return `Started stack "${task.target_id}"`; @@ -712,12 +746,11 @@ export class SchedulerService { return `Stack "${stackName}": all images up to date.`; } - await assertPolicyGateAllows( + await this.enforceSchedulerPolicyGate( stackName, nodeId, - buildSystemPolicyGateOptions('scheduler:auto-update', { - auditPath: `/api/scheduled-tasks/auto-update/${stackName}`, - }), + 'Auto-update', + `/api/scheduled-tasks/auto-update/${stackName}`, ); // Atomic backup/rollback is a paid capability. Every path that reaches // this method is already paid-gated (the scheduler tick and the manual diff --git a/docs/features/deploy-enforcement.mdx b/docs/features/deploy-enforcement.mdx index 3007814b..1ae31d49 100644 --- a/docs/features/deploy-enforcement.mdx +++ b/docs/features/deploy-enforcement.mdx @@ -6,7 +6,7 @@ description: "Block deploys that violate a scan policy before docker compose up Deploy enforcement is the pre-flight half of Sencho's vulnerability workflow. When a [scan policy](/features/vulnerability-scanning#scan-policies) with **Block on deploy** enabled matches a stack, Sencho scans every image referenced by the stack's compose file before starting any container. If any image meets or exceeds the policy's severity threshold, the deploy is rejected and the stack never starts. Detection always continues post-deploy and on a schedule, so images that develop new vulnerabilities after the initial deploy still surface through alerts. - Deploy enforcement requires a **Skipper** or **Admiral** license. Policies on Community are evaluation-only and cannot block deploys. + Deploy enforcement and scan policies require a **Skipper** or **Admiral** license. ## Configuring a block policy @@ -31,7 +31,7 @@ The editor exposes the five fields that govern enforcement: | **Block on deploy** | When on, the pre-flight gate hard-rejects deploys that violate the threshold. When off, the policy still evaluates post-deploy and scheduled scans and dispatches warning alerts on violations. | | **Enabled** | Disabled policies are skipped during evaluation. | -The editor sets the pattern, severity, and toggles. Per-node scoping is set via the [Security API](/api-reference/security#scan-policies) (`node_id`) or replicated from a control node via [Fleet Federation](/features/fleet-federation). The most specific enabled policy that matches the stack on the target node is the one that runs. +The editor sets the pattern, severity, and toggles. Per-node scoping is set via the [Security API](/api-reference/security#scan-policies) (`node_id`) or replicated from a control node via [Fleet Federation](/features/fleet-federation). When more than one enabled policy matches a stack on the target node, a node-scoped policy wins over a fleet-wide one, a policy with a stack pattern wins over a catch-all, and the lowest-numbered policy breaks any remaining tie. ## How enforcement runs @@ -39,14 +39,18 @@ Sencho applies the pre-flight gate on every code path that can start a compose s - **Deploy** from the stack page. - **Update** (re-pull plus restart). -- **Deploy from a Git Source** when the initial deploy is requested at create time. +- **Rollback** to a saved backup, which restores the previous files and re-runs the gate before redeploying. +- **Deploy from a Git Source**, both the initial deploy at create time and a manual apply-with-deploy of a pulled commit. - **Template deploy** from the App Store. - **Bulk deploy** from the [Stack Labels](/features/stack-labels) page. -- **Auto-update scheduler** (the gate is hard-enforced here; see [Auto-update scheduler interaction](#auto-update-scheduler-interaction)). +- **Fleet deploy** to a node from the fleet view. +- **Webhook-triggered deploys**. +- **Mesh cascade redeploys** when a dependency change ripples to a stack. +- **Scheduled auto-start and auto-update** (see [Auto-update scheduler interaction](#auto-update-scheduler-interaction)). On every one of these actions, Sencho: -1. Looks up the most specific enabled policy that matches the stack on the target node. +1. Picks the matching enabled policy by precedence (node-scoped over fleet-wide, stack-pattern over catch-all, then lowest id) on the target node. 2. If the policy has **Block on deploy** off, lets the deploy proceed and evaluates the post-deploy scan against the policy for alerting. 3. If **Block on deploy** is on, enumerates the stack's images with `docker compose config --images`, runs a pre-flight Trivy scan against each one, and compares the highest severity in each scan against the policy threshold. 4. If every image is below the threshold, the deploy proceeds. A post-deploy drift scan still runs in the background. @@ -88,7 +92,7 @@ API callers can pass `?ignorePolicy=true` on any deploy endpoint to request a by ### Auto-update scheduler interaction -The auto-update scheduler runs deploys without an interactive user, so the bypass flag does not apply. When a scheduled auto-update is rejected by a policy, Sencho dispatches a `scan_finding` warning alert with the stack name, the policy name, and the offending images, then skips that stack and continues the rest of the schedule. The audit-log entry records the actor as `auto-update:` for runs invoked by a logged-in user, or `auto-update:scheduler` for unattended runs. Re-enabling that stack in auto-update means either bringing the image down to compliant severity or relaxing the policy. +The auto-update scheduler runs deploys without an interactive user, so the bypass flag does not apply. When a scheduled auto-update is rejected by a policy, Sencho dispatches a `scan_finding` warning alert with the stack name, the policy name, and the offending images, then skips that stack and continues the rest of the schedule. Re-enabling that stack in auto-update means either bringing the image down to compliant severity or relaxing the policy. A scheduled auto-start that is rejected is reported as a failed run with the same warning alert. ## Drift detection keeps running @@ -122,7 +126,7 @@ Neither drift mechanism blocks, stops, or quarantines a running stack automatica Enforcement fails closed when the compose file cannot be parsed. The synthetic `(compose parse error)` violation prevents a malformed file from slipping past the gate. Open the stack's [file explorer](/features/stack-file-explorer) and fix the YAML; the deploy succeeds once the file parses cleanly and every image clears the threshold. - A scheduled auto-update that pulls an image violating a matching policy is skipped, not blocked with a 409. The skip is announced through a `scan_finding` warning alert and recorded in the [Audit Log](/features/audit-log) with the actor `auto-update:` (the user who launched the run) or `auto-update:scheduler` for unattended runs. To unblock the schedule, either upgrade the image to a compliant version or relax the policy threshold for that stack. + A scheduled auto-update that pulls an image violating a matching policy is skipped, not blocked with a 409. The skip is announced through a `scan_finding` warning alert naming the policy and the offending images. To unblock the schedule, either upgrade the image to a compliant version or relax the policy threshold for that stack. Combine two mechanisms: scope the policy to a specific node (policies scoped to a node win over global ones) and tighten the stack-pattern glob. For example, a policy with `stack_pattern=prod-*` scoped to your production node fires only on `prod-*` stacks deployed to that node. diff --git a/frontend/src/components/EditorLayout/ShellOverlays.tsx b/frontend/src/components/EditorLayout/ShellOverlays.tsx index eb5f654e..4fc5bd05 100644 --- a/frontend/src/components/EditorLayout/ShellOverlays.tsx +++ b/frontend/src/components/EditorLayout/ShellOverlays.tsx @@ -110,7 +110,7 @@ export function ShellOverlays({ canBypass={isAdmin} bypassing={policyBypassing} onClose={() => setPolicyBlock(null)} - onBypass={stackActions.bypassPolicyAndDeploy} + onBypass={stackActions.bypassPolicyAndRetry} /> {/* Git Source Panel */} diff --git a/frontend/src/components/EditorLayout/hooks/useOverlayState.ts b/frontend/src/components/EditorLayout/hooks/useOverlayState.ts index 63e443b9..17f28ece 100644 --- a/frontend/src/components/EditorLayout/hooks/useOverlayState.ts +++ b/frontend/src/components/EditorLayout/hooks/useOverlayState.ts @@ -1,7 +1,7 @@ import { useState, useCallback, useEffect } from 'react'; import { SENCHO_OPEN_LOGS_EVENT } from '@/lib/events'; import type { SenchoOpenLogsDetail } from '@/lib/events'; -import type { PolicyBlockPayload } from '../../stack/PolicyBlockDialog'; +import type { PolicyBlockPayload, PolicyBlockableAction } from '../../stack/PolicyBlockDialog'; import type { Node } from '@/context/NodeContext'; type DiffPreview = { @@ -12,7 +12,12 @@ type DiffPreview = { fileName: string; }; -type PolicyBlock = { stackName: string; payload: PolicyBlockPayload }; +type PolicyBlock = { + stackName: string; + stackFile: string; + action: PolicyBlockableAction; + payload: PolicyBlockPayload; +}; type Container = { id: string; name: string }; export function useOverlayState() { diff --git a/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts b/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts index db32bb43..4c9c5135 100644 --- a/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts +++ b/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts @@ -66,26 +66,28 @@ function makeStackListState(over: Partial = {}): StackListState return { ...base, ...over } as unknown as StackListState; } -function makeOverlay(): OverlayState { +function makeOverlay(over: Partial = {}): OverlayState { return { setPendingUnsavedLoad: vi.fn(), setPendingUnsavedNode: vi.fn(), pendingUnsavedLoad: null, pendingUnsavedNode: null, + policyBlock: null, setPolicyBlock: vi.fn(), setPolicyBypassing: vi.fn(), setDiffPreview: vi.fn(), + ...over, } as unknown as OverlayState; } const runWithLog: Parameters[0]['runWithLog'] = async (_p, run) => run(Promise.resolve(), 'test-session'); -function setup(over: { editorState?: Partial } = {}) { +function setup(over: { editorState?: Partial; overlay?: Partial } = {}) { const editorState = makeEditorState(over.editorState); const stackListState = makeStackListState(); const navState = { setActiveView: vi.fn() } as unknown as NavState; - const overlayState = makeOverlay(); + const overlayState = makeOverlay(over.overlay); const { result } = renderHook(() => useStackActions({ @@ -177,3 +179,126 @@ describe('useStackActions.handleSaveAndDeploy', () => { expect(calls.some(c => String(c).includes('/deploy'))).toBe(true); }); }); + +describe('useStackActions policy-block dialog wiring', () => { + const policyPayload = { + error: 'Policy "block-high" blocked deploy: 1 image(s) exceed HIGH', + policy: { id: 1, name: 'block-high', maxSeverity: 'HIGH' }, + violations: [{ imageRef: 'nginx:1.14', severity: 'CRITICAL', criticalCount: 2, highCount: 5, scanId: 9 }], + }; + const mouseEvent = { preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as React.MouseEvent; + + beforeEach(() => { + vi.mocked(apiFetch).mockReset(); + }); + + it('opens the dialog with action "deploy" when an editor deploy is blocked', async () => { + vi.mocked(apiFetch).mockResolvedValueOnce(new Response(JSON.stringify(policyPayload), { status: 409 })); + const { result, overlayState } = setup(); + await result.current.deployStack(mouseEvent); + expect(overlayState.setPolicyBlock).toHaveBeenCalledWith( + expect.objectContaining({ stackName: 'web', stackFile: 'web.yml', action: 'deploy' }), + ); + }); + + it('opens the dialog with action "update" when an update is blocked', async () => { + vi.mocked(apiFetch).mockResolvedValueOnce(new Response(JSON.stringify(policyPayload), { status: 409 })); + const { result, overlayState } = setup(); + await result.current.updateStack(mouseEvent); + expect(overlayState.setPolicyBlock).toHaveBeenCalledWith( + expect.objectContaining({ stackName: 'web', stackFile: 'web.yml', action: 'update' }), + ); + }); + + it('opens the dialog with action "deploy" when a sidebar deploy is blocked', async () => { + vi.mocked(apiFetch).mockResolvedValueOnce(new Response(JSON.stringify(policyPayload), { status: 409 })); + const { result, overlayState } = setup(); + await result.current.executeStackActionByFile('web.yml', 'deploy', 'deploy'); + expect(overlayState.setPolicyBlock).toHaveBeenCalledWith( + expect.objectContaining({ stackName: 'web', stackFile: 'web.yml', action: 'deploy' }), + ); + }); + + it('does not open the dialog for a stack-op-in-progress 409', async () => { + const inProgress = JSON.stringify({ + code: 'stack_op_in_progress', + inProgress: { action: 'deploy', startedAt: Date.now(), user: 'someone' }, + }); + vi.mocked(apiFetch).mockResolvedValueOnce(new Response(inProgress, { status: 409 })); + const { result, overlayState } = setup(); + await result.current.updateStack(mouseEvent); + expect(overlayState.setPolicyBlock).not.toHaveBeenCalled(); + }); + + it('opens the dialog with action "update" via the sidebar update entry point', async () => { + vi.mocked(apiFetch).mockResolvedValueOnce(new Response(JSON.stringify(policyPayload), { status: 409 })); + const { result, overlayState } = setup(); + await result.current.executeStackActionByFile('web.yml', 'update', 'update'); + expect(overlayState.setPolicyBlock).toHaveBeenCalledWith( + expect.objectContaining({ stackName: 'web', stackFile: 'web.yml', action: 'update' }), + ); + }); + + it('opens the dialog with action "rollback" when a rollback is blocked', async () => { + vi.mocked(apiFetch).mockResolvedValueOnce(new Response(JSON.stringify(policyPayload), { status: 409 })); + const { result, overlayState } = setup(); + await result.current.rollbackStack(); + expect(overlayState.setPolicyBlock).toHaveBeenCalledWith( + expect.objectContaining({ stackName: 'web', stackFile: 'web.yml', action: 'rollback' }), + ); + }); +}); + +describe('useStackActions.bypassPolicyAndRetry', () => { + const payload = { + error: 'blocked', + policy: { id: 1, name: 'block-high', maxSeverity: 'HIGH' }, + violations: [{ imageRef: 'nginx:1.14', severity: 'CRITICAL', criticalCount: 1, highCount: 0, scanId: 1 }], + }; + + beforeEach(() => { + vi.mocked(apiFetch).mockReset(); + }); + + it('retries an update bypass against the update endpoint with ?ignorePolicy=true', async () => { + vi.mocked(apiFetch).mockResolvedValueOnce(new Response(null, { status: 200 })); // update OK + vi.mocked(apiFetch).mockResolvedValueOnce(new Response('[]', { status: 200 })); // containers refresh + const { result } = setup({ + overlay: { policyBlock: { stackName: 'web', stackFile: 'web.yml', action: 'update', payload } as never }, + }); + await result.current.bypassPolicyAndRetry(); + const urls = vi.mocked(apiFetch).mock.calls.map(c => String(c[0])); + expect(urls).toContain('/stacks/web/update?ignorePolicy=true'); + expect(urls.some(u => u.includes('/deploy'))).toBe(false); + }); + + it('retries a deploy bypass against the deploy endpoint with ?ignorePolicy=true', async () => { + vi.mocked(apiFetch).mockResolvedValueOnce(new Response(null, { status: 200 })); // deploy OK + vi.mocked(apiFetch).mockResolvedValueOnce(new Response('[]', { status: 200 })); // containers refresh + const { result } = setup({ + overlay: { policyBlock: { stackName: 'web', stackFile: 'web.yml', action: 'deploy', payload } as never }, + }); + await result.current.bypassPolicyAndRetry(); + const urls = vi.mocked(apiFetch).mock.calls.map(c => String(c[0])); + expect(urls).toContain('/stacks/web/deploy?ignorePolicy=true'); + expect(urls.some(u => u.includes('/update'))).toBe(false); + }); + + it('retries a rollback bypass against the rollback endpoint with ?ignorePolicy=true', async () => { + vi.mocked(apiFetch).mockResolvedValueOnce(new Response(null, { status: 200 })); // rollback OK + vi.mocked(apiFetch).mockResolvedValueOnce(new Response('content', { status: 200 })); // content reload + vi.mocked(apiFetch).mockResolvedValueOnce(new Response(JSON.stringify({ exists: true }), { status: 200 })); // backup info + const { result } = setup({ + overlay: { policyBlock: { stackName: 'web', stackFile: 'web.yml', action: 'rollback', payload } as never }, + }); + await result.current.bypassPolicyAndRetry(); + const urls = vi.mocked(apiFetch).mock.calls.map(c => String(c[0])); + expect(urls).toContain('/stacks/web.yml/rollback?ignorePolicy=true'); + }); + + it('does nothing when no policy block is stored', async () => { + const { result } = setup({ overlay: { policyBlock: null as never } }); + await result.current.bypassPolicyAndRetry(); + expect(apiFetch).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/EditorLayout/hooks/useStackActions.ts b/frontend/src/components/EditorLayout/hooks/useStackActions.ts index 64268077..e1753ac2 100644 --- a/frontend/src/components/EditorLayout/hooks/useStackActions.ts +++ b/frontend/src/components/EditorLayout/hooks/useStackActions.ts @@ -9,7 +9,7 @@ import type { Node } from '@/context/NodeContext'; import type { ActionVerb } from '@/context/DeployFeedbackContext'; import type { StackAction } from '../EditorView'; import type { NotificationItem } from '../../dashboard/types'; -import type { PolicyBlockPayload } from '../../stack/PolicyBlockDialog'; +import type { PolicyBlockPayload, PolicyBlockableAction } from '../../stack/PolicyBlockDialog'; interface RunResult { ok: boolean; @@ -506,6 +506,29 @@ export function useStackActions(options: UseStackActionsOptions) { } }; + // Parse a 409 body for a scan-policy block. When it is one, record it (with + // the originating action and file so the bypass retries the right endpoint) + // so PolicyBlockDialog can open, and return the policy name. Returns null + // when the body is not a policy block (e.g. a stack-op-in-progress 409). + const tryOpenPolicyBlock = ( + rawBody: string, + stackName: string, + stackFile: string, + action: PolicyBlockableAction, + ): string | null => { + let parsed: PolicyBlockPayload | null = null; + try { + parsed = JSON.parse(rawBody) as PolicyBlockPayload; + } catch { + /* not JSON */ + } + if (parsed && parsed.policy && Array.isArray(parsed.violations)) { + overlayState.setPolicyBlock({ stackName, stackFile, action, payload: parsed }); + return parsed.policy.name; + } + return null; + }; + const runDeploy = async ( stackName: string, stackFile: string, @@ -524,35 +547,21 @@ export function useStackActions(options: UseStackActionsOptions) { if (!response.ok) { const rawBody = await response.text(); if (response.status === 409) { + // Either 409 sub-case (op-in-progress or policy block) leaves the + // stack in its prior state; undo the optimistic "running" flip once. + if (previousStatus !== undefined) + stackListState.setOptimisticStatus(stackFile, previousStatus as 'running' | 'exited'); const inProgress = parseStackOpInProgress(rawBody); if (inProgress) { const message = stackOpInProgressMessage(stackName, inProgress); - if (previousStatus !== undefined) - stackListState.setOptimisticStatus( - stackFile, - previousStatus as 'running' | 'exited', - ); toast.error(message); return { ok: false, errorMessage: message }; } - let parsed: PolicyBlockPayload | null = null; - try { - parsed = JSON.parse(rawBody) as PolicyBlockPayload; - } catch { - /* not JSON */ - } - if (parsed && parsed.policy && Array.isArray(parsed.violations)) { - overlayState.setPolicyBlock({ stackName, payload: parsed }); - if (previousStatus !== undefined) - stackListState.setOptimisticStatus( - stackFile, - previousStatus as 'running' | 'exited', - ); - toast.error(`Deploy blocked by policy "${parsed.policy.name}"`); - return { - ok: false, - errorMessage: `Deploy blocked by policy "${parsed.policy.name}"`, - }; + const blockedBy = tryOpenPolicyBlock(rawBody, stackName, stackFile, 'deploy'); + if (blockedBy) { + const message = `Deploy blocked by policy "${blockedBy}"`; + toast.error(message); + return { ok: false, errorMessage: message }; } } throw parseStackActionError(rawBody, 'Deploy failed'); @@ -614,39 +623,69 @@ export function useStackActions(options: UseStackActionsOptions) { await deployStack(e); }; - const bypassPolicyAndDeploy = async () => { + // Admin "Deploy anyway": re-issue the blocked action with ?ignorePolicy=true. + // Retries whichever action triggered the block (deploy or update) so an + // update bypass still re-pulls images, matching the backend bypass on each + // endpoint. The server ignores the flag unless the caller is an admin. + const bypassPolicyAndRetry = async () => { const policyBlock = overlayState.policyBlock; if (!policyBlock) return; - const { stackName } = policyBlock; - const existingFile = - stackListState.selectedFile?.replace(/\.(yml|yaml)$/, '') === stackName - ? stackListState.selectedFile - : (stackListState.files.find(f => f.replace(/\.(yml|yaml)$/, '') === stackName) ?? `${stackName}.yml`); + const { stackName, stackFile, action } = policyBlock; + const existingFile = stackListState.files.includes(stackFile) + ? stackFile + : (stackListState.files.find(f => f.replace(/\.(yml|yaml)$/, '') === stackName) ?? stackFile); overlayState.setPolicyBypassing(true); - stackListState.setStackAction(existingFile, 'deploy'); try { - await runWithLog({ stackName, action: 'deploy' }, (started, ds) => - runDeploy(stackName, existingFile, true, started, ds), - ); + if (action === 'update') { + await runStackAction(existingFile, 'update', 'update', 'running', 'Stack updated successfully!', true); + } else if (action === 'rollback') { + await rollbackStack(true); + } else { + stackListState.setStackAction(existingFile, 'deploy'); + try { + await runWithLog({ stackName, action: 'deploy' }, (started, ds) => + runDeploy(stackName, existingFile, true, started, ds), + ); + } finally { + stackListState.clearStackAction(existingFile); + stackListState.refreshStacks(true); + } + } } finally { overlayState.setPolicyBypassing(false); - stackListState.clearStackAction(existingFile); - stackListState.refreshStacks(true); } }; - const rollbackStack = async () => { + const rollbackStack = async (ignorePolicy = false) => { if (!stackListState.selectedFile || stackListState.isStackBusy(stackListState.selectedFile)) return; const stackFile = stackListState.selectedFile; + const stackName = stackFile.replace(/\.(yml|yaml)$/, ''); stackListState.setStackAction(stackFile, 'rollback'); stackListState.setOptimisticStatus(stackFile, 'running'); try { - const res = await apiFetch(`/stacks/${stackFile}/rollback`, { method: 'POST' }); + const path = ignorePolicy + ? `/stacks/${stackFile}/rollback?ignorePolicy=true` + : `/stacks/${stackFile}/rollback`; + const res = await apiFetch(path, { method: 'POST' }); if (!res.ok) { - const err = await res.json(); - throw new Error(err?.error || 'Rollback failed'); + const rawBody = await res.text(); + if (res.status === 409) { + const inProgress = parseStackOpInProgress(rawBody); + if (inProgress) { + const message = stackOpInProgressMessage(stackName, inProgress); + toast.error(message); + return; + } + const blockedBy = tryOpenPolicyBlock(rawBody, stackName, stackFile, 'rollback'); + if (blockedBy) { + toast.error(`Rollback blocked by policy "${blockedBy}"`); + return; + } + } + throw parseStackActionError(rawBody, 'Rollback failed'); } + overlayState.setPolicyBlock(null); toast.success('Stack rolled back successfully.'); const contentRes = await apiFetch(`/stacks/${stackFile}`); const text = await contentRes.text(); @@ -713,6 +752,7 @@ export function useStackActions(options: UseStackActionsOptions) { endpoint: string, optimisticStatus: 'running' | 'exited', successMessage: string, + ignorePolicy = false, ): Promise => { if (stackListState.isStackBusy(stackFile)) return; const stackName = stackFile.replace(/\.(yml|yaml)$/, ''); @@ -723,7 +763,10 @@ export function useStackActions(options: UseStackActionsOptions) { await runWithLog({ stackName, action }, async (started, ds) => { await started; try { - const response = await apiFetch(`/stacks/${stackName}/${endpoint}`, withDeploySession(ds, { method: 'POST' })); + const url = ignorePolicy + ? `/stacks/${stackName}/${endpoint}?ignorePolicy=true` + : `/stacks/${stackName}/${endpoint}`; + const response = await apiFetch(url, withDeploySession(ds, { method: 'POST' })); if (!response.ok) { const errText = await response.text(); if (response.status === 409) { @@ -733,6 +776,14 @@ export function useStackActions(options: UseStackActionsOptions) { toast.error(message); return { ok: false as const, errorMessage: message }; } + if (action === 'update') { + const blockedBy = tryOpenPolicyBlock(errText, stackName, stackFile, 'update'); + if (blockedBy) { + const message = `Update blocked by policy "${blockedBy}"`; + toast.error(message); + return { ok: false as const, errorMessage: message }; + } + } } const actionError = parseStackActionError(errText, `${action} failed`); return { @@ -741,6 +792,7 @@ export function useStackActions(options: UseStackActionsOptions) { rolledBack: actionError.rolledBack, }; } + overlayState.setPolicyBlock(null); toast.success(successMessage); if (action === 'update') stackListState.fetchImageUpdates(); if (stackListState.selectedFile === stackFile) { @@ -894,6 +946,13 @@ export function useStackActions(options: UseStackActionsOptions) { toast.error(stackOpInProgressMessage(stackName, inProgress)); return; } + if (action === 'deploy' || action === 'update') { + const blockedBy = tryOpenPolicyBlock(errText, stackName, stackFile, action); + if (blockedBy) { + toast.error(`${action === 'update' ? 'Update' : 'Deploy'} blocked by policy "${blockedBy}"`); + return; + } + } } throw parseStackActionError(errText, `${action} failed`); } @@ -1000,7 +1059,7 @@ export function useStackActions(options: UseStackActionsOptions) { scanStackConfig, runDeploy, deployStack, - bypassPolicyAndDeploy, + bypassPolicyAndRetry, stopStack, restartStack, serviceAction, diff --git a/frontend/src/components/settings/SecuritySection.tsx b/frontend/src/components/settings/SecuritySection.tsx index 9bca3cff..fdc833bc 100644 --- a/frontend/src/components/settings/SecuritySection.tsx +++ b/frontend/src/components/settings/SecuritySection.tsx @@ -547,7 +547,7 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {

- Emit a critical alert when this policy is violated after a deploy. + Reject a deploy before containers start when any image meets or exceeds the threshold. With this off, the policy only evaluates and raises an alert.