mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Add MSP isolation E2E spec: token scoping, split-port ingest, signed webhook delivery
Automates the docs/MSP.md validation checklist and the WEBHOOKS.md delivery contract against the real server, covering the cross-layer seams unit tests cannot see (today's two isolation bugs both lived there): - org-bound token allowed in its own org, denied 403 in sibling orgs AND the default org (leaked client-site token must not read the provider estate) - dedicated agent-ingest port serves only /api/agents/* (management paths 404, agent route auth-gated) - client-org webhook delivery through the instance-wide private-target allowlist saved in default-org context, with HMAC signature verified against the documented recipe, X-Pulse-Event-ID idempotency header, and tenant id/name stamped in the payload - restart inheritance: after docker restart the persisted allowlist still applies to lazily recreated tenant monitors (skips when the docker CLI cannot manage the container) Compose: pulse-test gains PULSE_AGENT_INGEST_PORT=7656 (+ port map) and a host-gateway extra_host so the spec's capture listener is reachable from the container.
This commit is contained in:
@@ -42,8 +42,12 @@ services:
|
||||
container_name: pulse-test-server
|
||||
ports:
|
||||
- "${PULSE_E2E_PORT:-7655}:7655"
|
||||
- "${PULSE_E2E_AGENT_PORT:-7656}:7656"
|
||||
environment:
|
||||
- TZ=UTC
|
||||
# Dedicated agent-ingest listener so E2E can prove the split-port
|
||||
# isolation contract (only /api/agents/* served on this port).
|
||||
- PULSE_AGENT_INGEST_PORT=7656
|
||||
# Point to mock GitHub server
|
||||
- PULSE_UPDATE_SERVER=http://mock-github:8080
|
||||
# Use /data which is already owned by pulse user via entrypoint
|
||||
@@ -57,6 +61,9 @@ services:
|
||||
- PULSE_UPDATE_STAGE_DELAY_MS=250
|
||||
volumes:
|
||||
- test-data:/data
|
||||
extra_hosts:
|
||||
# Lets webhook E2E point Pulse at a capture listener on the test host.
|
||||
- "host.docker.internal:host-gateway"
|
||||
depends_on:
|
||||
seed-bootstrap-token:
|
||||
condition: service_completed_successfully
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import { createHmac } from 'node:crypto';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import http from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import {
|
||||
apiRequest,
|
||||
createOrg,
|
||||
deleteOrg,
|
||||
ensureAuthenticated,
|
||||
isMultiTenantEnabled,
|
||||
} from './helpers';
|
||||
|
||||
/**
|
||||
* MSP isolation E2E: automates the validation checklist from docs/MSP.md
|
||||
* against the real server, plus the signed webhook delivery contract from
|
||||
* docs/WEBHOOKS.md. These are the cross-layer seams unit tests cannot see:
|
||||
* org-bound token scoping at the authorization boundary, instance-wide
|
||||
* webhook security settings reaching tenant org managers, the dedicated
|
||||
* agent-ingest listener, and signed tenant-stamped webhook delivery.
|
||||
*/
|
||||
|
||||
type APITokenCreateResponse = {
|
||||
token?: string;
|
||||
};
|
||||
|
||||
type CapturedDelivery = {
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
body: string;
|
||||
};
|
||||
|
||||
const WEBHOOK_SIGNING_SECRET = 'e2e-msp-signing-secret';
|
||||
|
||||
// Private ranges that cover Docker bridge/compose networks and the
|
||||
// host-gateway address, so the capture listener passes SSRF validation once
|
||||
// the instance-wide allowlist is configured.
|
||||
const PRIVATE_ALLOWLIST = '10.0.0.0/8,172.16.0.0/12,192.168.0.0/16';
|
||||
|
||||
const agentPortBaseURL = () => {
|
||||
const host = process.env.PULSE_E2E_HOST || '127.0.0.1';
|
||||
const port = process.env.PULSE_E2E_AGENT_PORT || '7656';
|
||||
return `http://${host}:${port}`;
|
||||
};
|
||||
|
||||
function startCaptureServer(): Promise<{
|
||||
server: http.Server;
|
||||
port: number;
|
||||
deliveries: CapturedDelivery[];
|
||||
}> {
|
||||
const deliveries: CapturedDelivery[] = [];
|
||||
const server = http.createServer((req, res) => {
|
||||
let body = '';
|
||||
req.on('data', (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
req.on('end', () => {
|
||||
deliveries.push({ headers: { ...req.headers }, body });
|
||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||
res.end('ok');
|
||||
});
|
||||
});
|
||||
return new Promise((resolve, reject) => {
|
||||
server.on('error', reject);
|
||||
// Bind all interfaces so the Pulse container can reach us through
|
||||
// host.docker.internal (mapped via extra_hosts host-gateway).
|
||||
server.listen(0, '0.0.0.0', () => {
|
||||
resolve({ server, port: (server.address() as AddressInfo).port, deliveries });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function setWebhookAllowlist(page: any, cidrs: string) {
|
||||
const res = await apiRequest(page, '/api/system/settings/update', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: { webhookAllowedPrivateCIDRs: cidrs },
|
||||
});
|
||||
expect(res.ok(), `allowlist update: ${res.status()}`).toBeTruthy();
|
||||
}
|
||||
|
||||
test.describe('MSP isolation E2E', () => {
|
||||
test('org-bound token is scoped away from other orgs and the default org', async ({ page }) => {
|
||||
await ensureAuthenticated(page);
|
||||
const mtEnabled = await isMultiTenantEnabled(page);
|
||||
test.skip(!mtEnabled, 'Multi-tenant feature not enabled in this environment');
|
||||
|
||||
const orgA = await createOrg(page, `MSP Client A ${Date.now()}`);
|
||||
const orgB = await createOrg(page, `MSP Client B ${Date.now()}`);
|
||||
|
||||
try {
|
||||
const createTokenRes = await apiRequest(page, '/api/security/tokens', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Pulse-Org-ID': orgA.id,
|
||||
},
|
||||
data: { name: `msp-e2e-read-${Date.now()}`, scopes: ['monitoring:read'] },
|
||||
});
|
||||
expect(createTokenRes.ok()).toBeTruthy();
|
||||
const token = ((await createTokenRes.json()) as APITokenCreateResponse).token?.trim() || '';
|
||||
expect(token.length).toBeGreaterThan(0);
|
||||
|
||||
const probe = async (orgID: string | null) => {
|
||||
const headers: Record<string, string> = { 'X-API-Token': token };
|
||||
if (orgID) headers['X-Pulse-Org-ID'] = orgID;
|
||||
const res = await apiRequest(page, '/api/alerts/active', { headers });
|
||||
return res.status();
|
||||
};
|
||||
|
||||
// Own org: allowed (org-bound token auto-routes without a header too).
|
||||
expect(await probe(orgA.id)).toBe(200);
|
||||
expect(await probe(null)).toBe(200);
|
||||
// Sibling client org: denied.
|
||||
expect(await probe(orgB.id)).toBe(403);
|
||||
// Default org: denied. A leaked client-site token must not read the
|
||||
// provider's own estate (docs/MSP.md validation checklist item 4).
|
||||
expect(await probe('default')).toBe(403);
|
||||
} finally {
|
||||
await deleteOrg(page, orgB.id);
|
||||
await deleteOrg(page, orgA.id);
|
||||
}
|
||||
});
|
||||
|
||||
test('agent ingest port serves only the agent surface', async ({ request }) => {
|
||||
// docs/MSP.md validation checklist item 1: management paths must 404 on
|
||||
// the dedicated agent port; the agent route exists but demands auth.
|
||||
const base = agentPortBaseURL();
|
||||
const root = await request.get(`${base}/`).catch(() => null);
|
||||
test.skip(!root, 'Agent ingest port not reachable in this environment');
|
||||
|
||||
expect(root!.status()).toBe(404);
|
||||
const login = await request.get(`${base}/api/login`);
|
||||
expect(login.status()).toBe(404);
|
||||
const state = await request.get(`${base}/api/state`);
|
||||
expect(state.status()).toBe(404);
|
||||
const report = await request.post(`${base}/api/agents/agent/report`, { data: {} });
|
||||
expect([401, 403]).toContain(report.status());
|
||||
});
|
||||
|
||||
test('client org webhook delivery: instance-wide allowlist, tenant stamp, HMAC signature', async ({ page }) => {
|
||||
await ensureAuthenticated(page);
|
||||
const mtEnabled = await isMultiTenantEnabled(page);
|
||||
test.skip(!mtEnabled, 'Multi-tenant feature not enabled in this environment');
|
||||
|
||||
const { server, port, deliveries } = await startCaptureServer();
|
||||
const org = await createOrg(page, `MSP Webhook Client ${Date.now()}`);
|
||||
|
||||
try {
|
||||
// Allowlist saved in the DEFAULT org context must reach the client
|
||||
// org's notification manager (instance-wide setting propagation).
|
||||
await setWebhookAllowlist(page, PRIVATE_ALLOWLIST);
|
||||
|
||||
const fire = async () => {
|
||||
const res = await apiRequest(page, '/api/notifications/webhooks/test', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Pulse-Org-ID': org.id,
|
||||
},
|
||||
data: {
|
||||
name: 'msp-e2e-psa',
|
||||
url: `http://host.docker.internal:${port}/hook`,
|
||||
service: 'generic',
|
||||
enabled: true,
|
||||
signingSecret: WEBHOOK_SIGNING_SECRET,
|
||||
},
|
||||
});
|
||||
const payload = (await res.json()) as { success?: boolean; error?: string };
|
||||
expect(res.ok(), `webhook test endpoint: ${res.status()}`).toBeTruthy();
|
||||
expect(payload.success, `webhook delivery failed: ${payload.error || ''}`).toBeTruthy();
|
||||
};
|
||||
|
||||
await fire();
|
||||
expect(deliveries.length).toBe(1);
|
||||
|
||||
const delivery = deliveries[0];
|
||||
const signature = String(delivery.headers['x-pulse-signature'] || '');
|
||||
const timestamp = String(delivery.headers['x-pulse-timestamp'] || '');
|
||||
const eventID = String(delivery.headers['x-pulse-event-id'] || '');
|
||||
|
||||
// Delivery contract (docs/WEBHOOKS.md): idempotency token and signed
|
||||
// payload headers.
|
||||
expect(eventID).toMatch(/:alert$/);
|
||||
expect(timestamp).toMatch(/^\d+$/);
|
||||
expect(signature.startsWith('v1=')).toBeTruthy();
|
||||
const expected =
|
||||
'v1=' +
|
||||
createHmac('sha256', WEBHOOK_SIGNING_SECRET)
|
||||
.update(`${timestamp}.${delivery.body}`)
|
||||
.digest('hex');
|
||||
expect(signature).toBe(expected);
|
||||
|
||||
// Tenant identity must be stamped in the payload so a shared PSA
|
||||
// endpoint can route by client.
|
||||
const body = JSON.parse(delivery.body) as {
|
||||
tenant?: { id?: string; name?: string };
|
||||
alert?: { level?: string; type?: string };
|
||||
};
|
||||
expect(body.tenant?.id).toBe(org.id);
|
||||
expect(body.tenant?.name?.length || 0).toBeGreaterThan(0);
|
||||
expect(['warning', 'critical']).toContain(body.alert?.level || '');
|
||||
|
||||
// Restart inheritance: after a server restart (tenant monitors are
|
||||
// recreated lazily) the persisted allowlist must still apply without
|
||||
// anyone re-saving settings. Skip when the docker CLI cannot manage
|
||||
// the test container (e.g. local runs against a managed stack).
|
||||
const inspect = spawnSync('docker', ['inspect', 'pulse-test-server'], { stdio: 'ignore' });
|
||||
if (inspect.status === 0) {
|
||||
const restart = spawnSync('docker', ['restart', 'pulse-test-server'], {
|
||||
stdio: 'ignore',
|
||||
timeout: 120_000,
|
||||
});
|
||||
expect(restart.status).toBe(0);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const res = await apiRequest(page, '/api/health').catch(() => null);
|
||||
return res ? res.status() : 0;
|
||||
},
|
||||
{ timeout: 90_000, intervals: [2_000] },
|
||||
)
|
||||
.toBe(200);
|
||||
|
||||
await ensureAuthenticated(page);
|
||||
await fire();
|
||||
expect(deliveries.length).toBe(2);
|
||||
const second = deliveries[1];
|
||||
expect(String(second.headers['x-pulse-signature'] || '').startsWith('v1=')).toBeTruthy();
|
||||
expect((JSON.parse(second.body) as { tenant?: { id?: string } }).tenant?.id).toBe(org.id);
|
||||
}
|
||||
} finally {
|
||||
await setWebhookAllowlist(page, '').catch(() => {});
|
||||
await deleteOrg(page, org.id).catch(() => {});
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user