fix: gate cross-node HTTP and stop-by-label on remote RBAC capability (#1509)

An older remote node ignores the forwarded actor-role header (running proxied
requests as admin) and ignores the stop-by-label stack allowlist (stopping
every label-matched stack). The control could neither detect nor prevent this
on a mixed-version fleet.

Instances now advertise a cross-node-rbac capability, and the control refuses
to act when a remote lacks it:

- HTTP proxy: a non-admin user's request is not forwarded to a remote that does
  not advertise the capability (fails closed when it cannot be determined).
  Admins are unaffected.
- Stop-by-label: a real stop bound to a confirmed stack set is not sent to a
  remote lacking the capability; the node is reported as needing an upgrade. As
  defense in depth, a node whose results name stacks outside the confirmed set
  is failed rather than rendered as a clean stop.

Separately, the stop's lock-contention path now reports every confirmed stack
as a contention failure (including one that lost its label), so a confirmed
stack is never silently dropped and the result is never empty.
This commit is contained in:
Anso
2026-06-28 18:28:47 -04:00
committed by GitHub
parent ef164f0e5b
commit 997a6bb79a
11 changed files with 365 additions and 1 deletions
@@ -321,6 +321,30 @@ describe('local-stop behavior', () => {
}
});
it('reports every confirmed stack under lock contention, including one that lost its label', async () => {
const label = db.createLabel(nodeId, 'contend-label', 'slate');
db.setStackLabels('contend-kept', nodeId, [label.id]);
// contend-lost was confirmed in the preview but no longer carries the label.
const { activeBulkActions } = await import('../routes/labels');
activeBulkActions.add(`bulk:${nodeId}`);
try {
const res = await request(app)
.post('/api/fleet-actions/labels/local-stop')
.set('Authorization', authHeader)
.send({ labelName: 'contend-label', stackNames: ['contend-kept', 'contend-lost'] });
expect(res.status).toBe(200);
// Both confirmed stacks surface as contention failures; the lost one is
// not dropped (which would read as "no stacks assigned"), and the result
// is never empty when stacks were confirmed.
expect(res.body.results).toHaveLength(2);
const byName = Object.fromEntries(res.body.results.map((r: { stackName: string }) => [r.stackName, r]));
expect(byName['contend-kept'].error).toMatch(/already running/);
expect(byName['contend-lost'].error).toMatch(/already running/);
} finally {
activeBulkActions.delete(`bulk:${nodeId}`);
}
});
it('dry run returns dryRun:true per on-disk stack without touching Docker', async () => {
makeStack('dry-stack');
const label = db.createLabel(nodeId, 'dry-label', 'slate');