fix(observability): gate global logs to admins, scope to managed containers, harden SSE (#1254)

* fix(observability): gate global logs to admins, scope to managed containers, harden SSE

Make the Logs feed an administrator view enforced on both sides (requireAdmin on
the /api/logs/global poll and SSE routes; the Logs nav item plus a redirect guard
on the frontend), and scope the feed to Sencho-managed containers only via a
shared isManagedByComposeDir helper that /stats now reuses.

Harden the SSE stream: a stateful frame demuxer that survives chunk boundaries so
a Docker frame split across reads is reassembled instead of dropped or garbled; a
per-stream error listener so one broken follow stream cannot crash the event loop
(it posts a single degraded notice and keeps the others alive); a cap on
concurrent follow streams with a truncation notice; a bounded initial tail; and
backpressure that pauses the source streams when the client is slow and resumes on
drain. Bound the polling snapshot's per-container fan-out with a concurrency limit.

Add process-local, in-memory log-stream counters exposed at the admin-only
/api/system/log-stream-metrics endpoint (active connections, lines streamed,
attach and frame errors). Collapse the view to the local hub and remove the dead
remote-node handling.

* fix(observability): close remote-proxy bypass of the global-logs admin gate

The logs feed's requireAdmin lives in the local route handler, which the remote
proxy skips when forwarding a request whose nodeId targets a remote node. A hub
user could therefore request /api/logs/global*, /api/logs/global/stream, or
/api/system/log-stream-metrics with x-node-id (or ?nodeId= for the SSE transport)
pointing at a remote node and have it served as the node-proxy admin on the far
side, sidestepping the gate entirely.

Add these paths to HUB_ONLY_PREFIXES so hubOnlyGuard rejects a remote nodeId with
403 before the proxy runs, matching the existing protection on audit-log,
scheduled-tasks, and notification-routes. Add regression tests covering the
collection path, the SSE sub-path (both the x-node-id header and the ?nodeId=
query transport), and the stream-metrics endpoint.
This commit is contained in:
Anso
2026-05-29 21:09:20 -04:00
committed by GitHub
parent a5bfd48005
commit 69edb0dcbb
14 changed files with 720 additions and 115 deletions
@@ -120,6 +120,51 @@ describe('hubOnlyGuard', () => {
expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT');
});
// Regression for the Global Observability admin gate: the logs feed's
// `requireAdmin` lives in the local route handler, which the proxy skips when
// forwarding a remote nodeId. Without these prefixes the guard would let the
// request through to the proxy and a hub user could read a remote node's logs
// as the node-proxy admin. Cover the collection, the SSE sub-path, and the
// stream-metrics endpoint, with and without a trailing slash.
it('rejects /api/logs/global with 403 when nodeId targets a remote node', async () => {
const res = await request(app)
.get('/api/logs/global')
.set('Authorization', authHeader)
.set('x-node-id', String(remoteNodeId));
expect(res.status).toBe(403);
expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT');
});
it('rejects /api/logs/global/stream with 403 when nodeId targets a remote node', async () => {
const res = await request(app)
.get('/api/logs/global/stream')
.set('Authorization', authHeader)
.set('x-node-id', String(remoteNodeId));
expect(res.status).toBe(403);
expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT');
});
it('rejects /api/logs/global/stream via the ?nodeId= query param (SSE transport)', async () => {
const res = await request(app)
.get(`/api/logs/global/stream?nodeId=${remoteNodeId}`)
.set('Authorization', authHeader);
expect(res.status).toBe(403);
expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT');
});
it('rejects /api/system/log-stream-metrics with 403 when nodeId targets a remote node', async () => {
const res = await request(app)
.get('/api/system/log-stream-metrics')
.set('Authorization', authHeader)
.set('x-node-id', String(remoteNodeId));
expect(res.status).toBe(403);
expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT');
});
it('does not interfere with non-hub paths even when nodeId targets a remote node', async () => {
// /api/stacks is not hub-only and should be forwarded by the proxy.
// The exact upstream-error status is not the contract here; what