fix(networking): hold unsafe network deletions and stabilize aggregate reads (#1850)

* fix(networking): hold unsafe network deletions and stabilize aggregate reads

Networking audit hardening:

- Fail closed when deleting unlabeled external networks while any stack
  fails to render or the Docker runtime is unreachable: declarations
  cannot be verified, so the backend 409s with a typed code and the
  inventory table holds the delete affordance instead of letting the
  confirm dialog surprise the operator.
- Bound concurrent compose renders during network delete verification
  with the shared render semaphore and mapWithConcurrency.
- Add a short-TTL memo for the node networking aggregate keyed by node
  and request variant, invalidated eagerly on stack, exposure-intent,
  dossier, and network mutations, with stale-on-error serves flagged as
  degradedCache and surfaced in the overview.
- Resolve node-scoped stack edit permissions against the active node in
  the findings action list.
- Developer-mode debug logs for aggregate serving and delete-guard
  outcomes.

Tests: cache unit suite, hardening integration suite, and component
coverage for the permission threading and delete-affordance holds.

* chore(networking): add missing EOF newline in aggregate cache module
This commit is contained in:
Anso
2026-08-28 12:55:19 +00:00
committed by GitHub
parent 392bc15d91
commit cc4a6571c7
19 changed files with 530 additions and 13 deletions
@@ -0,0 +1,148 @@
/**
* Networking aggregate memo: read endpoints share one computation per node and
* requested variant within the TTL window, a mutation invalidation forces the
* next read to recompute, and a base (no-topology) read never satisfies a
* topology read. In-flight joining itself is covered by CacheService's own
* suite; these tests pin the key derivation this module adds on top.
*/
import { describe, it, expect } from 'vitest';
import {
fetchNodeNetworkingAggregateWithMeta,
invalidateNodeNetworkingAggregate,
networkingAggregateCacheKey,
} from '../services/network/networkingAggregateCache';
import type { NetworkingAggregateOptions } from '../services/network/networkingAggregateCache';
import type { NodeNetworkingAggregate } from '../services/network/networkingTypes';
function fakeAggregate(networkCount: number): NodeNetworkingAggregate {
return {
overview: {
runtimeAvailable: true,
networkCount,
stackCount: 0,
connectedContainerCount: 0,
systemNetworkCount: 0,
senchoManagedNetworkCount: 0,
composeManagedNetworkCount: 0,
unmanagedNetworkCount: 0,
externalDependencyNetworkCount: 0,
exposedStackCount: 0,
unknownExposureStackCount: 0,
missingExternalCount: 0,
networkCollisionCount: 0,
findingCount: 0,
degradedCache: false,
renderFailedStacks: [],
},
networks: [],
findings: [],
stackFacts: [],
runtimeAvailable: true,
recentActivity: [],
};
}
function fakeTopology(): NodeNetworkingAggregate['topology'] {
return { networks: [{ id: 'n1', name: 'net', driver: 'bridge', scope: '', stack: null, isSystem: false, ingress: false, ownership: 'unmanaged', declaredByStacks: [], declaredExternalByStacks: [], isExternalDependency: false, findingIds: [], containers: [] }], includeSystem: false };
}
const NO_OPTIONS: NetworkingAggregateOptions = {};
async function fetchMemo(nodeId: number, options: NetworkingAggregateOptions, compute: () => Promise<NodeNetworkingAggregate>) {
const { value } = await fetchNodeNetworkingAggregateWithMeta(nodeId, options, compute);
return value;
}
describe('networkingAggregateCache', () => {
it('computes once per TTL window per node and variant', async () => {
invalidateNodeNetworkingAggregate(1);
let calls = 0;
const first = await fetchMemo(1, NO_OPTIONS, async () => fakeAggregate(++calls));
const second = await fetchMemo(1, NO_OPTIONS, async () => fakeAggregate(++calls));
expect(first.overview.networkCount).toBe(1);
expect(second.overview.networkCount).toBe(1);
});
it('keeps nodes isolated', async () => {
invalidateNodeNetworkingAggregate(2);
invalidateNodeNetworkingAggregate(3);
const [a, b] = await Promise.all([
fetchMemo(2, NO_OPTIONS, async () => fakeAggregate(2)),
fetchMemo(3, NO_OPTIONS, async () => fakeAggregate(3)),
]);
expect(a.overview.networkCount).toBe(2);
expect(b.overview.networkCount).toBe(3);
});
it('does not serve a base read to a topology request', async () => {
invalidateNodeNetworkingAggregate(5);
const base = await fetchMemo(5, NO_OPTIONS, async () => fakeAggregate(1));
expect(base.topology).toBeUndefined();
let topologyComputes = 0;
const topology = await fetchMemo(5, { includeTopology: true }, async () => {
topologyComputes += 1;
return { ...fakeAggregate(1), topology: fakeTopology() };
});
expect(topologyComputes).toBe(1);
expect(topology.topology?.networks).toHaveLength(1);
// The base entry stays warm and is served for base reads.
let baseComputes = 0;
const warmedBase = await fetchMemo(5, NO_OPTIONS, async () => {
baseComputes += 1;
return fakeAggregate(99);
});
expect(baseComputes).toBe(0);
expect(warmedBase.overview.networkCount).toBe(1);
});
it('treats includeSystem variants as distinct entries', async () => {
invalidateNodeNetworkingAggregate(7);
await fetchMemo(7, { includeTopology: true }, async () => fakeAggregate(1));
let computes = 0;
await fetchMemo(7, { includeTopology: true, includeSystem: true }, async () => {
computes += 1;
return fakeAggregate(1);
});
expect(computes).toBe(1);
});
it('builds a distinct key per variant', () => {
const keys = new Set([
networkingAggregateCacheKey(9, {}),
networkingAggregateCacheKey(9, { includeTopology: true }),
networkingAggregateCacheKey(9, { includeTopology: true, includeSystem: true }),
networkingAggregateCacheKey(10, {}),
]);
expect(keys.size).toBe(4);
});
it('recomputes after an explicit invalidation (mutation path)', async () => {
invalidateNodeNetworkingAggregate(4);
let calls = 0;
await fetchMemo(4, NO_OPTIONS, async () => fakeAggregate(++calls));
invalidateNodeNetworkingAggregate(4);
const after = await fetchMemo(4, NO_OPTIONS, async () => fakeAggregate(++calls));
expect(after.overview.networkCount).toBe(2);
});
it('invalidation drops every variant of the node', async () => {
invalidateNodeNetworkingAggregate(6);
await fetchMemo(6, NO_OPTIONS, async () => fakeAggregate(1));
await fetchMemo(6, { includeTopology: true }, async () => fakeAggregate(1));
invalidateNodeNetworkingAggregate(6);
let calls = 0;
await fetchMemo(6, NO_OPTIONS, async () => { calls += 1; return fakeAggregate(1); });
await fetchMemo(6, { includeTopology: true }, async () => { calls += 1; return fakeAggregate(1); });
expect(calls).toBe(2);
});
});
@@ -0,0 +1,151 @@
/**
* Networking hardening integration tests:
* - the aggregate memo serves repeat reads within its TTL,
* - a base (no-topology) read never satisfies a topology request,
* - exposure-intent writes invalidate the memo so the next overview reflects
* them immediately,
* - the delete guard 409s with stack-declaration-unknown for an unlabeled
* external network while a stack fails to render.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
import fs from 'fs';
import path from 'path';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
import DockerController from '../services/DockerController';
import { ComposeService } from '../services/ComposeService';
import { DatabaseService } from '../services/DatabaseService';
let tmpDir: string;
let app: import('express').Express;
let authHeader: string;
const STACK = 'hardnet';
const NET_ID = 'deadbeefdead';
function stubHealthy() {
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
getDependencySnapshot: vi.fn().mockResolvedValue({
containers: [],
networks: [
{ id: NET_ID, name: 'shared_ext', driver: 'bridge', scope: 'local', isSystem: false, composeProject: null, stack: null },
],
volumes: [],
}),
inspectNetwork: vi.fn().mockResolvedValue({ Id: NET_ID, Name: 'shared_ext', Driver: 'bridge', Scope: 'local', Labels: {}, Containers: {} }),
removeNetwork: vi.fn().mockResolvedValue(undefined),
} as unknown as DockerController);
vi.spyOn(ComposeService, 'getInstance').mockReturnValue({
renderConfig: vi.fn().mockResolvedValue({
rendered: JSON.stringify({
name: STACK,
services: { web: { image: 'nginx:latest', ports: ['8080:80'] } },
networks: {},
volumes: {},
}),
stderr: '', code: 0, timedOut: false,
}),
} as unknown as ComposeService);
}
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
authHeader = `Bearer ${jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '5m' })}`;
});
afterAll(() => cleanupTestDb(tmpDir));
describe('networking hardening', () => {
let stackDir: string;
beforeEach(() => {
stackDir = path.join(process.env.COMPOSE_DIR as string, STACK);
fs.mkdirSync(stackDir, { recursive: true });
fs.writeFileSync(path.join(stackDir, 'compose.yaml'), 'services:\n web:\n image: nginx:latest\n');
stubHealthy();
});
afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(stackDir, { recursive: true, force: true });
});
it('serves repeat overview reads from one computation within the TTL window', async () => {
const first = await request(app).get('/api/networking/overview').set('Authorization', authHeader);
expect(first.status).toBe(200);
const snapshotSpy = DockerController.getInstance(1).getDependencySnapshot as ReturnType<typeof vi.fn>;
const callsBefore = snapshotSpy.mock.calls.length;
const second = await request(app).get('/api/networking/overview').set('Authorization', authHeader);
expect(second.status).toBe(200);
expect(second.body.schemaVersion).toBe(first.body.schemaVersion);
expect(DockerController.getInstance(1).getDependencySnapshot as ReturnType<typeof vi.fn>).toHaveBeenCalledTimes(callsBefore);
});
it('serves real topology data when overview warmed the cache first', async () => {
await request(app).get('/api/networking/overview').set('Authorization', authHeader);
const res = await request(app)
.get('/api/networking/topology')
.set('Authorization', authHeader);
expect(res.status).toBe(200);
expect((res.body.networks as Array<{ name: string }>)).toContainEqual(
expect.objectContaining({ name: 'shared_ext' }),
);
});
it('reflects an exposure-intent change in the next overview read', async () => {
const initial = await request(app).get('/api/networking/overview').set('Authorization', authHeader);
expect(initial.status).toBe(200);
// The fixture publishes 8080:80 with no intent set, so the pre-write read
// must carry the unclassified finding; asserting both directions makes an
// invalidation regression loud instead of silently vacuous.
const hadUnclassifiedBefore = ((initial.body.findings ?? []) as Array<{ kind: string; stack?: string }>)
.some((f) => f.kind === 'exposure-unclassified' && f.stack === STACK);
expect(hadUnclassifiedBefore).toBe(true);
const put = await request(app)
.put(`/api/stacks/${STACK}/exposure`)
.set('Authorization', authHeader)
.send({ service: '', intent: 'internal' });
expect(put.status).toBe(200);
const res = await request(app).get('/api/networking/overview').set('Authorization', authHeader);
expect(res.status).toBe(200);
const unclassified = (res.body.findings as Array<{ kind: string; stack?: string }>)
.find((f) => f.kind === 'exposure-unclassified' && f.stack === STACK);
expect(unclassified).toBeUndefined();
DatabaseService.getInstance().deleteStackExposureIntents(1, STACK);
});
it('409s an unlabeled external network delete while a stack is unrenderable', async () => {
(ComposeService.getInstance(1).renderConfig as ReturnType<typeof vi.fn>).mockResolvedValue({
rendered: null,
stderr: 'redacted render failure',
code: 1,
timedOut: false,
});
const removeSpy = vi.spyOn(DockerController.getInstance(1), 'removeNetwork');
const res = await request(app)
.post('/api/system/networks/delete')
.set('Authorization', authHeader)
.send({ id: NET_ID });
expect(res.status).toBe(409);
expect(res.body.code).toBe('stack-declaration-unknown');
expect(removeSpy).not.toHaveBeenCalled();
});
it('allows deleting the same network once every stack renders again', async () => {
const removeSpy = vi.spyOn(DockerController.getInstance(1), 'removeNetwork');
const res = await request(app)
.post('/api/system/networks/delete')
.set('Authorization', authHeader)
.send({ id: NET_ID });
expect(res.status).toBe(200);
expect(removeSpy).toHaveBeenCalledOnce();
});
});
@@ -12,6 +12,7 @@ import { ComposeService } from '../services/ComposeService';
import { DatabaseService } from '../services/DatabaseService';
import { evaluateNetworkDeleteGuard } from '../services/network/networkDeleteGuards';
import SelfIdentityService from '../services/SelfIdentityService';
import { invalidateNodeNetworkingAggregate } from '../services/network/networkingAggregateCache';
let tmpDir: string;
let app: import('express').Express;
@@ -85,6 +86,7 @@ describe('networking operator routes', () => {
afterEach(() => {
vi.restoreAllMocks();
invalidateNodeNetworkingAggregate(1);
fs.rmSync(stackDir, { recursive: true, force: true });
});
@@ -196,6 +198,19 @@ describe('evaluateNetworkDeleteGuard', () => {
expect(guard.code).toBe('stack-declaration-unknown');
});
it('fails closed for an unlabeled external network while a stack is unrenderable', () => {
const snapshot = {
containers: [],
networks: [{ id: 'n1', name: 'shared_ext', driver: 'bridge', scope: 'local', isSystem: false, composeProject: null, stack: null }],
volumes: [],
};
const guard = evaluateNetworkDeleteGuard('n1', snapshot, [
{ stack: STACK, renderable: false, renderError: 'compose file invalid', runtime: 'available', networks: [{ key: 'shared_ext', name: 'shared_ext', external: true, internal: false, createdByStack: false }], services: [], drift: { runtimeOnlyAttachments: [], declaredButUnused: [], missingFromRuntime: [], foreignNetworkAttachments: [] }, missingExternalNetworks: [] },
]);
expect(guard.blocked).toBe(true);
expect(guard.code).toBe('stack-declaration-unknown');
});
it('blocks a system network ahead of every other reason', () => {
const snapshot = {
containers: [], volumes: [],
@@ -13,6 +13,7 @@ import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './he
import DockerController from '../services/DockerController';
import { ComposeService } from '../services/ComposeService';
import { DatabaseService } from '../services/DatabaseService';
import { invalidateNodeNetworkingAggregate } from '../services/network/networkingAggregateCache';
let tmpDir: string;
let app: import('express').Express;
@@ -140,6 +141,7 @@ describe('networking collision correctness', () => {
afterEach(() => {
vi.restoreAllMocks();
invalidateNodeNetworkingAggregate(1);
fs.rmSync(dirA, { recursive: true, force: true });
fs.rmSync(dirB, { recursive: true, force: true });
});