fix(deploy-enforcement): surface scan-policy blocks on update and sidebar deploys (#1248)

* fix(deploy-enforcement): surface scan-policy blocks on update and sidebar deploys

A blocked deploy only opened the policy dialog from the editor deploy
button. The update action and the sidebar context-menu deploy/update
fell through to a generic error toast, so an admin could not review the
violations or bypass the block from those entry points. Route the 409
policy response through a shared handler on all three paths and make the
"Deploy anyway" bypass retry the originating action (deploy or update)
so an update bypass still re-pulls images.

Also:
- Correct the "Block on deploy" policy-editor helper text, which
  described post-deploy alerting rather than the pre-flight rejection it
  actually performs.
- Dispatch the documented scan_finding warning (policy name and the
  offending images) when a scheduled auto-update or auto-start is
  blocked, instead of recording an opaque failure.
- Add a standard log line when the gate blocks a deploy, plus
  developer-mode diagnostics for the matched policy and per-image
  severity decision.
- Fix deploy-enforcement docs: complete the enforced entry-point list,
  correct the policy-precedence wording, and remove inaccurate tier and
  audit-actor claims.

* fix(deploy-enforcement): surface policy block on rollback and name images in remote auto-update alert

Addresses two gaps found in independent review:

- Rollback is a policy-gated deploy path (it restores the saved files then
  re-runs the gate before redeploying), but the frontend treated a blocked
  rollback as a generic error toast. Route the 409 through the same handler
  as deploy and update so the block dialog opens, and let an admin "Deploy
  anyway" retry the rollback with the bypass flag (the rollback route already
  honors it).
- The remote auto-update path dispatched its policy-block warning without the
  offending image refs, unlike the local scheduler. Append the images so the
  alert matches the documented contract on every node.

Also list rollback as an enforced entry point in the docs and clarify that
Git Source enforcement covers both the create-time deploy and a manual
apply-with-deploy.
This commit is contained in:
Anso
2026-05-29 08:48:50 -04:00
committed by GitHub
parent 45844b92ca
commit b33a0e8422
11 changed files with 396 additions and 66 deletions
@@ -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);
+2 -1
View File
@@ -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;
+25
View File
@@ -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 };
}
+42 -9
View File
@@ -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<void> {
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<void> {
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<string> {
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