fix: harden cross-node fleet label actions and guard container reads (#1503)

* fix: harden cross-node fleet label actions and guard container reads

Release-stabilization fixes for the Fleet Actions surface:

- Stop-by-label binds execution to the nodes shown in the confirmed
  preview. The real stop sends the confirmed node ids and the backend
  restricts the fan-out to them, so a node that was unreachable during
  preview and reconnects before the stop can no longer enter execution
  and have unlisted stacks stopped.
- Bulk label assign validates each remote node's result against the
  stacks it was asked to label: a body whose results are empty, partial,
  duplicated, or shaped wrong is a per-node failure instead of reading as
  a successful zero-stack assign. The card mirrors this, rejecting a
  missing or non-array results body and only reporting success when at
  least one stack was assigned.
- Bulk label assign re-reads authoritative per-node stacks and labels on
  demand via a Refresh control, and the confirmation lists the affected
  node and stack names rather than bare counts.
- The stack-specific and fleet container/stack read routes require the
  stack:read permission, matching the generic container and stack routes.
  Every shipped role already carries stack:read, so reachability is
  unchanged; the guard closes the routes that were auth-only.

Adds unit coverage for the assign-result validator, route coverage for
the stop allowlist and assign membership checks, and authorization
coverage for the newly guarded reads.

* test: assert the confirmed node allowlist in the fleet stop-card test

The stop-card component test pinned the real-stop request body to
{ labelName, dryRun } and broke once the stop began carrying the
confirmed-preview node ids. Update it to expect the nodeIds allowlist
derived from the resolved preview, so the test asserts the binding
rather than the pre-fix shape.
This commit is contained in:
Anso
2026-06-28 08:13:43 -04:00
committed by GitHub
parent b5810a9b55
commit 05c483f213
10 changed files with 510 additions and 31 deletions
+42
View File
@@ -51,6 +51,48 @@ export function failAllAssign(stackNames: string[], error: string): LabelAssignR
return Array.from(new Set(stackNames)).map(stackName => ({ stackName, success: false, error }));
}
function isLabelAssignResult(value: unknown): value is LabelAssignResult {
if (typeof value !== 'object' || value === null) return false;
const r = value as Record<string, unknown>;
return typeof r.stackName === 'string'
&& typeof r.success === 'boolean'
&& (r.error === undefined || typeof r.error === 'string');
}
/**
* Validate a remote node's `local-assign` 200 body before the control trusts it.
*
* Beyond the `{ created: boolean, results: LabelAssignResult[] }` shape, this
* checks result *membership*: the receiver returns exactly one row per unique
* requested stack, so a body that drops rows (an empty `results` for a non-empty
* request), duplicates a stack, or returns a stack that was never requested is a
* remote contract failure, not a clean assign. Without this, an empty `results`
* passes the bare `Array.isArray` check and the control reports the node as a
* successful zero-stack assign, which the UI then renders as success.
*
* `requestedStacks` is the per-node target list the control sent; it is deduped
* here so the caller does not have to.
*/
export function validateRemoteAssignResults(
requestedStacks: string[],
body: unknown,
): { ok: true; created: boolean; results: LabelAssignResult[] } | { ok: false } {
if (!body || typeof body !== 'object') return { ok: false };
const b = body as Record<string, unknown>;
if (typeof b.created !== 'boolean' || !Array.isArray(b.results)) return { ok: false };
const requested = new Set(requestedStacks);
const seen = new Set<string>();
const results: LabelAssignResult[] = [];
for (const row of b.results) {
if (!isLabelAssignResult(row)) return { ok: false };
if (!requested.has(row.stackName) || seen.has(row.stackName)) return { ok: false };
seen.add(row.stackName);
results.push(row);
}
if (seen.size !== requested.size) return { ok: false };
return { ok: true, created: b.created, results };
}
/**
* Validate a label template (the name/color a cross-node assign propagates).
* Mirrors the create-label rules in `routes/labels.ts` and is the single