mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user