fix: unlock Community deploy policy hard-blocking (#1643)

Remove the leftover paid-only blockingEnabled switch so enabled
block-on-deploy policies enforce on every tier, matching the
documented every-tier security surface. Existing Community policies
begin blocking immediately with no migration.
This commit is contained in:
Anso
2026-07-16 15:24:05 -04:00
committed by GitHub
parent b2576ede72
commit b91025dc8b
6 changed files with 137 additions and 34 deletions
@@ -0,0 +1,134 @@
/**
* Community deploy-policy route regressions: a matching block_on_deploy policy
* must hard-block via the stack deploy route on Community (local session and
* trusted node_proxy with x-sencho-tier: community), returning the established
* 409 payload and never calling deployStack.
*/
import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest';
import request, { type Response } from 'supertest';
import jwt from 'jsonwebtoken';
import { setupTestDb, cleanupTestDb, loginAsTestAdmin, TEST_JWT_SECRET } from './helpers/setupTestDb';
import { PROXY_TIER_HEADER } from '../services/license-headers';
vi.mock('../services/FileSystemService', () => ({
FileSystemService: {
getInstance: () => ({
getStacks: vi.fn().mockResolvedValue([]),
getBaseDir: () => '/tmp/compose',
readComposeFile: vi.fn().mockResolvedValue(''),
hasComposeFile: vi.fn().mockResolvedValue(true),
}),
},
}));
let tmpDir: string;
let app: import('express').Express;
let authCookie: string;
let listImagesSpy: ReturnType<typeof vi.spyOn>;
let deploySpy: ReturnType<typeof vi.spyOn>;
let trivyAvailableSpy: ReturnType<typeof vi.spyOn>;
let scanSpy: ReturnType<typeof vi.spyOn>;
function expectPolicyHardBlock(res: Response): void {
expect(res.status).toBe(409);
expect(res.body.error).toContain('community-block-critical');
expect(res.body.policy).toMatchObject({ name: 'community-block-critical' });
expect(res.body.violations).toEqual(
expect.arrayContaining([
expect.objectContaining({ imageRef: 'nginx:bad' }),
]),
);
expect(scanSpy).toHaveBeenCalled();
expect(deploySpy).not.toHaveBeenCalled();
}
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
authCookie = await loginAsTestAdmin(app);
const { ComposeService } = await import('../services/ComposeService');
listImagesSpy = vi.spyOn(ComposeService.prototype, 'listStackImages').mockResolvedValue(['nginx:bad']);
deploySpy = vi.spyOn(ComposeService.prototype, 'deployStack').mockResolvedValue(undefined);
const TrivyService = (await import('../services/TrivyService')).default;
const trivy = TrivyService.getInstance();
trivyAvailableSpy = vi.spyOn(trivy, 'isTrivyAvailable').mockReturnValue(true);
scanSpy = vi.spyOn(trivy, 'scanImagePreflight').mockResolvedValue({
id: 42,
node_id: 1,
image_ref: 'nginx:bad',
image_digest: null,
scanned_at: Date.now(),
total_vulnerabilities: 1,
critical_count: 1,
high_count: 0,
medium_count: 0,
low_count: 0,
unknown_count: 0,
fixable_count: 0,
secret_count: 0,
misconfig_count: 0,
scanners_used: 'vuln',
highest_severity: 'CRITICAL',
os_info: null,
trivy_version: '0.50.0',
scan_duration_ms: null,
triggered_by: 'deploy-preflight',
status: 'completed',
error: null,
stack_context: 'community-block-app',
policy_evaluation: null,
});
const { DatabaseService } = await import('../services/DatabaseService');
DatabaseService.getInstance().createScanPolicy({
name: 'community-block-critical',
node_id: null,
node_identity: '',
stack_pattern: 'community-block-*',
max_severity: 'HIGH',
block_on_deploy: 1,
block_on_severity: 1,
block_on_kev: 0,
block_on_fixable: 0,
enabled: 1,
replicated_from_control: 0,
});
});
afterAll(() => {
listImagesSpy.mockRestore();
deploySpy.mockRestore();
trivyAvailableSpy.mockRestore();
scanSpy.mockRestore();
vi.restoreAllMocks();
cleanupTestDb(tmpDir);
});
beforeEach(() => {
deploySpy.mockClear();
listImagesSpy.mockClear();
scanSpy.mockClear();
});
describe('Community deploy policy hard-block (route)', () => {
it('returns 409 and skips deployStack for a local Community session', async () => {
const res = await request(app)
.post('/api/stacks/community-block-app/deploy')
.set('Cookie', authCookie);
expectPolicyHardBlock(res);
});
it('returns 409 and skips deployStack for trusted node_proxy Community tier', async () => {
const token = jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' });
const res = await request(app)
.post('/api/stacks/community-block-proxy/deploy')
.set('Authorization', `Bearer ${token}`)
.set(PROXY_TIER_HEADER, 'community');
expectPolicyHardBlock(res);
});
});
@@ -1216,12 +1216,10 @@ describe('GitSourceService.apply', () => {
const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true });
const { FileSystemService } = await import('../services/FileSystemService');
const { ComposeService } = await import('../services/ComposeService');
const { LicenseService } = await import('../services/LicenseService');
const TrivyService = (await import('../services/TrivyService')).default;
const saveSpy = vi.spyOn(FileSystemService.prototype, 'saveStackContent').mockResolvedValue();
const listImagesSpy = vi.spyOn(ComposeService.prototype, 'listStackImages').mockResolvedValue(['nginx:bad']);
const deploySpy = vi.spyOn(ComposeService.prototype, 'deployStack').mockResolvedValue();
const tierSpy = vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
const trivy = TrivyService.getInstance();
const trivyAvailableSpy = vi.spyOn(trivy, 'isTrivyAvailable').mockReturnValue(true);
const scanSpy = vi.spyOn(trivy, 'scanImagePreflight').mockResolvedValue({
@@ -1267,13 +1265,13 @@ describe('GitSourceService.apply', () => {
expect(result.applied).toBe(true);
expect(result.deployed).toBe(false);
expect(result.deployError).toContain('Policy "block-high" blocked deploy');
expect(scanSpy).toHaveBeenCalled();
expect(deploySpy).not.toHaveBeenCalled();
validateSpy.mockRestore();
saveSpy.mockRestore();
listImagesSpy.mockRestore();
deploySpy.mockRestore();
tierSpy.mockRestore();
trivyAvailableSpy.mockRestore();
scanSpy.mockRestore();
});
@@ -175,21 +175,6 @@ describe('enforcePolicyPreDeploy', () => {
expect(composeStub.listStackImages).not.toHaveBeenCalled();
});
it('allows deploy without scanning when paid-tier blocking is disabled', async () => {
dbStub.getMatchingPolicy.mockReturnValue(mkPolicy());
const result = await enforcePolicyPreDeploy('web', 1, {
bypass: false,
actor: 'u',
blockingEnabled: false,
});
expect(result.ok).toBe(true);
expect(result.violations).toEqual([]);
expect(trivyStub.isTrivyAvailable).not.toHaveBeenCalled();
expect(composeStub.listStackImages).not.toHaveBeenCalled();
});
it('fails open with a warning alert when Trivy is not installed', async () => {
dbStub.getMatchingPolicy.mockReturnValue(mkPolicy());
trivyStub.isTrivyAvailable.mockReturnValue(false);
+1 -5
View File
@@ -5,8 +5,6 @@ import { DatabaseService } from '../services/DatabaseService';
import type { ScanPolicy } from '../services/DatabaseService';
import { NotificationService } from '../services/NotificationService';
import TrivyService, { DIGEST_CACHE_TTL_MS } from '../services/TrivyService';
import { LicenseService } from '../services/LicenseService';
import { effectiveTier } from '../middleware/tierGates';
import { getErrorMessage } from '../utils/errors';
import { sanitizeForLog } from '../utils/safeLog';
import { summarizeBlockReasons } from '../utils/policy-risk';
@@ -38,7 +36,6 @@ export function buildPolicyGateOptions(
return {
bypass: overrides.bypass ?? defaultBypass,
actor: overrides.actor ?? req.user?.username ?? 'unknown',
blockingEnabled: effectiveTier(req) === 'paid',
ip: (req.ip ?? req.socket.remoteAddress ?? '') as string,
auditMethod: req.method,
auditPath: req.originalUrl || req.url,
@@ -47,12 +44,11 @@ export function buildPolicyGateOptions(
export function buildSystemPolicyGateOptions(
actor: string,
overrides: { bypass?: boolean; blockingEnabled?: boolean; auditPath?: string; auditMethod?: string } = {},
overrides: { bypass?: boolean; auditPath?: string; auditMethod?: string } = {},
): PolicyEnforcementOptions {
return {
bypass: overrides.bypass ?? false,
actor,
blockingEnabled: overrides.blockingEnabled ?? LicenseService.getInstance().getTier() === 'paid',
auditMethod: overrides.auditMethod ?? 'POST',
auditPath: overrides.auditPath,
};
@@ -53,11 +53,6 @@ export interface PolicyViolation {
export interface PolicyEnforcementOptions {
bypass: boolean;
actor: string;
/**
* Paid-tier deploy enforcement switch. Community keeps policies as
* evaluation-only and must not block compose starts.
*/
blockingEnabled?: boolean;
ip?: string;
/** HTTP method of the originating request; used for audit attribution. */
auditMethod?: string;
@@ -283,10 +278,6 @@ export async function enforcePolicyPreDeploy(
return { ok: true, bypassed: false, policy: policy ?? undefined, violations: [] };
}
if (opts.blockingEnabled === false) {
return { ok: true, bypassed: false, policy, violations: [] };
}
const svc = TrivyService.getInstance();
if (!svc.isTrivyAvailable()) {
notifyTrivyMissingOnce(nodeId, stackName);
+1 -2
View File
@@ -184,8 +184,7 @@ export class SchedulerService {
* 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.
* fails open when Trivy is missing.
*/
private async enforceSchedulerPolicyGate(
stackName: string,