mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-07 07:56:05 +00:00
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:
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user