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
+6
View File
@@ -8,6 +8,7 @@ import { applySuppressions } from '../utils/suppression-filter';
import { SENCHO_ROLLBACK_HOLD_SQL_LIKE } from '../utils/senchoRollbackHold';
import type { AuditStatsInput } from './AuditAnomalyService';
import { EXPOSURE_INTENTS, type ExposureIntent } from './network/types';
import { invalidateNodeNetworkingAggregate } from './network/networkingAggregateCache';
import { HIGH_EPSS_THRESHOLD } from './securityPosture';
import type { BackendScheduledAction } from './scheduledActionRegistry';
import { stackPatternMatches } from '../helpers/stackPattern';
@@ -3852,11 +3853,13 @@ export class DatabaseService {
now,
now
);
invalidateNodeNetworkingAggregate(nodeId);
return this.getStackDossier(nodeId, stackName) as StackDossier;
}
public deleteStackDossier(nodeId: number, stackName: string): void {
this.db.prepare('DELETE FROM stack_dossiers WHERE node_id = ? AND stack_name = ?').run(nodeId, stackName);
invalidateNodeNetworkingAggregate(nodeId);
}
/**
@@ -3947,16 +3950,19 @@ export class DatabaseService {
updated_at = excluded.updated_at,
updated_by = excluded.updated_by`
).run(nodeId, stackName, service, intent, Date.now(), updatedBy);
invalidateNodeNetworkingAggregate(nodeId);
}
/** Clear one intent row, leaving that scope unset; consumers treat a service with no row as inheriting the stack intent. */
public deleteStackExposureIntent(nodeId: number, stackName: string, service: string): void {
this.db.prepare('DELETE FROM stack_exposure_intent WHERE node_id = ? AND stack_name = ? AND service = ?').run(nodeId, stackName, service);
invalidateNodeNetworkingAggregate(nodeId);
}
/** Clear every intent row for a stack (used when the stack is deleted). */
public deleteStackExposureIntents(nodeId: number, stackName: string): void {
this.db.prepare('DELETE FROM stack_exposure_intent WHERE node_id = ? AND stack_name = ?').run(nodeId, stackName);
invalidateNodeNetworkingAggregate(nodeId);
}
// --- Stack Exposure (Compose reachability descriptor) ---
@@ -59,7 +59,10 @@ export function evaluateNetworkDeleteGuard(
return { blocked: true, code: 'stack-declared', error: 'This network is declared by a Compose stack.' };
}
if (hasUnrenderable && (net.composeProject || net.stack)) {
// Fail closed while any stack is unrenderable: an external network normally
// carries no compose project label, so without this check a broken declaring
// stack would let its declared network slip through as "undeclared".
if (hasUnrenderable) {
return {
blocked: true,
code: 'stack-declaration-unknown',
@@ -19,10 +19,40 @@ import { getErrorMessage } from '../../utils/errors';
import { sanitizeForLog } from '../../utils/safeLog';
import { mapWithConcurrency } from '../../utils/mapWithConcurrency';
import { withComposeRenderSlot } from './composeRenderSemaphore';
import { fetchNodeNetworkingAggregateWithMeta as fetchMemoized, NETWORKING_AGGREGATE_TTL_MS } from './networkingAggregateCache';
import { isDebugEnabled } from '../../utils/debug';
export async function buildNodeNetworkingAggregate(
nodeId: number,
options: { includeTopology?: boolean; includeSystem?: boolean },
): Promise<NodeNetworkingAggregate> {
const startedAt = Date.now();
const { value: aggregate, outcome } = await fetchMemoized(nodeId, options, () => computeNodeNetworkingAggregate(nodeId, options));
if (outcome === 'stale') {
// Stale-on-error fallback: the recompute threw and the memo served the
// last good aggregate. Mark it so the UI can say so instead of
// presenting confidently stale data as fresh.
aggregate.overview.degradedCache = true;
}
if (isDebugEnabled()) {
console.debug('[Networking:debug] Aggregate served', {
nodeId,
outcome,
ms: Date.now() - startedAt,
stackCount: aggregate.stackFacts.length,
networkCount: aggregate.overview.networkCount,
findingCount: aggregate.findings.length,
renderFailedStacks: aggregate.overview.renderFailedStacks.length,
ttlMs: NETWORKING_AGGREGATE_TTL_MS,
variant: options.includeTopology === true ? 'topology' : 'base',
});
}
return aggregate;
}
async function computeNodeNetworkingAggregate(
nodeId: number,
options: { includeTopology?: boolean; includeSystem?: boolean },
): Promise<NodeNetworkingAggregate> {
const fsSvc = FileSystemService.getInstance(nodeId);
const stacks = await fsSvc.getStacks();
@@ -138,6 +168,7 @@ function buildOverview(
f.kind === 'network-name-collision' || f.kind === 'alias-collision' || f.kind === 'service-name-collision',
).length,
findingCount: findings.length,
degradedCache: false,
renderFailedStacks,
};
}
@@ -0,0 +1,39 @@
/**
* Short-TTL memo for the per-node networking aggregate. Read endpoints share one
* computation per window instead of re-rendering every stack per request;
* mutations invalidate eagerly so deletes and creates never serve a stale view
* beyond the request that performed them. Kept as a leaf module so mutation
* helpers and services can invalidate without importing the aggregate pipeline
* back into themselves.
*
* The cache key encodes the requested variant (topology / includeSystem) as
* well as the node: a plain overview read must never satisfy a topology
* request, whose aggregate additionally carries the topology graph.
*/
import { CacheService, type CacheFetchOutcome } from '../CacheService';
import type { NodeNetworkingAggregate } from './networkingTypes';
const NAMESPACE = 'networking-aggregate';
export const NETWORKING_AGGREGATE_TTL_MS = 8_000;
export type NetworkingAggregateOptions = { includeTopology?: boolean; includeSystem?: boolean };
export function networkingAggregateCacheKey(nodeId: number, options: NetworkingAggregateOptions): string {
if (options.includeTopology === true) {
return `${NAMESPACE}:${nodeId}:topology:${options.includeSystem === true}`;
}
return `${NAMESPACE}:${nodeId}:base`;
}
export async function fetchNodeNetworkingAggregateWithMeta(
nodeId: number,
options: NetworkingAggregateOptions,
compute: () => Promise<NodeNetworkingAggregate>,
): Promise<{ value: NodeNetworkingAggregate; outcome: CacheFetchOutcome }> {
return CacheService.getInstance().getOrFetchWithMeta(networkingAggregateCacheKey(nodeId, options), NETWORKING_AGGREGATE_TTL_MS, compute);
}
export function invalidateNodeNetworkingAggregate(nodeId: number): void {
CacheService.getInstance().invalidateNamespace(`${NAMESPACE}:${nodeId}`);
}
@@ -138,6 +138,8 @@ export interface NodeNetworkingOverview {
missingExternalCount: number;
networkCollisionCount: number;
findingCount: number;
/** True when this aggregate was served from the memo after a recompute failure (stale-on-error fallback). */
degradedCache: boolean;
renderFailedStacks: string[];
}