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
@@ -0,0 +1,58 @@
/**
* Unit coverage for the cross-node-rbac capability probe used to gate
* mixed-version cross-node operations. It reads the shared remote-meta cache
* (fetching once on a cold miss) and must fail closed: a node whose capability
* cannot be determined is treated as unsupported.
*/
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
import type { RemoteMeta } from '../services/CapabilityRegistry';
let remoteSupportsCrossNodeRbac: typeof import('../helpers/remoteCapabilities').remoteSupportsCrossNodeRbac;
let NodeRegistry: typeof import('../services/NodeRegistry').NodeRegistry;
let CacheService: typeof import('../services/CacheService').CacheService;
let tmpDir: string;
const NODE_ID = 4242;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ remoteSupportsCrossNodeRbac } = await import('../helpers/remoteCapabilities'));
({ NodeRegistry } = await import('../services/NodeRegistry'));
({ CacheService } = await import('../services/CacheService'));
});
afterAll(() => cleanupTestDb(tmpDir));
afterEach(() => {
vi.restoreAllMocks();
CacheService.getInstance().invalidate(`remote-meta:${NODE_ID}`);
});
function mockMeta(meta: RemoteMeta): void {
vi.spyOn(NodeRegistry.getInstance(), 'fetchMetaForNode').mockResolvedValue(meta);
}
const ONLINE = { startedAt: null, updateError: null, online: true } as const;
describe('remoteSupportsCrossNodeRbac', () => {
it('returns true when the remote advertises cross-node-rbac', async () => {
mockMeta({ version: '0.93.0', capabilities: ['fleet', 'cross-node-rbac'], ...ONLINE });
expect(await remoteSupportsCrossNodeRbac(NODE_ID)).toBe(true);
});
it('returns false when the remote does not advertise it', async () => {
mockMeta({ version: '0.92.0', capabilities: ['fleet', 'labels'], ...ONLINE });
expect(await remoteSupportsCrossNodeRbac(NODE_ID)).toBe(false);
});
it('fails closed when the remote meta has no resolvable version', async () => {
mockMeta({ version: null, capabilities: [], startedAt: null, updateError: null, online: false });
expect(await remoteSupportsCrossNodeRbac(NODE_ID)).toBe(false);
});
it('fails closed when the meta fetch throws (unreachable)', async () => {
vi.spyOn(NodeRegistry.getInstance(), 'fetchMetaForNode').mockRejectedValue(new Error('unreachable'));
expect(await remoteSupportsCrossNodeRbac(NODE_ID)).toBe(false);
});
});