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
@@ -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();
});
});