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
@@ -110,7 +110,7 @@ export function ShellOverlays({
canBypass={isAdmin}
bypassing={policyBypassing}
onClose={() => setPolicyBlock(null)}
onBypass={stackActions.bypassPolicyAndDeploy}
onBypass={stackActions.bypassPolicyAndRetry}
/>
{/* Git Source Panel */}
@@ -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() {
@@ -66,26 +66,28 @@ function makeStackListState(over: Partial<StackListState> = {}): StackListState
return { ...base, ...over } as unknown as StackListState;
}
function makeOverlay(): OverlayState {
function makeOverlay(over: Partial<OverlayState> = {}): 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<typeof useStackActions>[0]['runWithLog'] = async (_p, run) =>
run(Promise.resolve(), 'test-session');
function setup(over: { editorState?: Partial<EditorState> } = {}) {
function setup(over: { editorState?: Partial<EditorState>; overlay?: Partial<OverlayState> } = {}) {
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();
});
});
@@ -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<void> => {
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,