mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-04 06:35:29 +00:00
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:
@@ -22,6 +22,11 @@ const getContainersByStack = vi.fn();
|
||||
const stopContainer = vi.fn();
|
||||
const restartContainer = vi.fn();
|
||||
const invalidateNodeCaches = vi.fn();
|
||||
const remoteSupportsCrossNodeRbac = vi.fn();
|
||||
|
||||
vi.mock('../helpers/remoteCapabilities', () => ({
|
||||
remoteSupportsCrossNodeRbac,
|
||||
}));
|
||||
|
||||
vi.mock('../services/FileSystemService', () => ({
|
||||
FileSystemService: {
|
||||
@@ -83,6 +88,9 @@ beforeEach(() => {
|
||||
getContainersByStack.mockResolvedValue([{ Id: 'container-1' }]);
|
||||
stopContainer.mockResolvedValue(undefined);
|
||||
restartContainer.mockResolvedValue(undefined);
|
||||
// Default: remotes are upgraded and honor the exact-stack allowlist. The
|
||||
// mixed-version cases below flip this to false to exercise the gate.
|
||||
remoteSupportsCrossNodeRbac.mockResolvedValue(true);
|
||||
activeBulkActions.clear();
|
||||
db.getDb().prepare('DELETE FROM stack_label_assignments').run();
|
||||
db.getDb().prepare('DELETE FROM stack_labels').run();
|
||||
@@ -845,6 +853,60 @@ describe('POST /api/fleet/labels/fleet-stop confirmed-target allowlist', () => {
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/nodeId/);
|
||||
});
|
||||
|
||||
it('refuses a real stop to a remote lacking cross-node-rbac and reports it as needing upgrade', async () => {
|
||||
remoteSupportsCrossNodeRbac.mockResolvedValue(false);
|
||||
const remoteId = db.addNode({
|
||||
name: 'old-remote', type: 'remote', api_url: 'http://old.example:1852',
|
||||
api_token: 'tok', compose_dir: '/app/compose', is_default: false,
|
||||
});
|
||||
try {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/labels/fleet-stop')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ labelName: 'any-label', targets: [{ nodeId: remoteId, stackNames: ['alpha'] }] });
|
||||
expect(res.status).toBe(200);
|
||||
const node = res.body.results.find((r: { nodeId: number }) => r.nodeId === remoteId);
|
||||
expect(node.reachable).toBe(false);
|
||||
expect(node.error).toMatch(/upgrade/i);
|
||||
expect(node.stackResults).toEqual([{ stackName: 'alpha', success: false, error: 'Node must be upgraded to honor an exact-stack stop' }]);
|
||||
// The destructive local-stop receiver is never contacted on an old remote.
|
||||
expect(fetchSpy.mock.calls.some(c => String(c[0]).endsWith('/api/fleet-actions/labels/local-stop'))).toBe(false);
|
||||
} finally {
|
||||
db.deleteNode(remoteId);
|
||||
}
|
||||
});
|
||||
|
||||
it('fails a node whose stop results name stacks outside the confirmed set', async () => {
|
||||
remoteSupportsCrossNodeRbac.mockResolvedValue(true);
|
||||
const remoteId = db.addNode({
|
||||
name: 'lying-remote', type: 'remote', api_url: 'http://lying.example:1852',
|
||||
api_token: 'tok', compose_dir: '/app/compose', is_default: false,
|
||||
});
|
||||
try {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||
ok: true, status: 200,
|
||||
json: async () => ({
|
||||
matched: true,
|
||||
results: [
|
||||
{ stackName: 'alpha', success: true },
|
||||
{ stackName: 'unconfirmed-extra', success: true },
|
||||
],
|
||||
}),
|
||||
} as unknown as Response);
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/labels/fleet-stop')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ labelName: 'any-label', targets: [{ nodeId: remoteId, stackNames: ['alpha'] }] });
|
||||
expect(res.status).toBe(200);
|
||||
const node = res.body.results.find((r: { nodeId: number }) => r.nodeId === remoteId);
|
||||
expect(node.reachable).toBe(false);
|
||||
expect(node.error).toMatch(/outside the confirmed set/i);
|
||||
} finally {
|
||||
db.deleteNode(remoteId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/fleet/labels/fleet-stop with dryRun: true', () => {
|
||||
|
||||
Reference in New Issue
Block a user