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
@@ -38,6 +38,17 @@ function mockAdmiralAdmin() {
} as unknown as ReturnType<typeof LicenseContext.useLicense>);
}
function mockCommunityAdmin() {
vi.mocked(AuthContext.useAuth).mockReturnValue({
isAdmin: true,
can: () => false,
} as unknown as ReturnType<typeof AuthContext.useAuth>);
vi.mocked(LicenseContext.useLicense).mockReturnValue({
isPaid: false,
license: null,
} as unknown as ReturnType<typeof LicenseContext.useLicense>);
}
function mockSkipperAdmin() {
vi.mocked(AuthContext.useAuth).mockReturnValue({
isAdmin: true,
@@ -199,20 +210,40 @@ describe('useViewNavigationState', () => {
// ── navItems: community user ───────────────────────────────────────────────
it('navItems for community non-paid user contains base items only', () => {
it('navItems for community non-admin user contains base items only and hides admin-only Logs', () => {
const { result } = renderHook(() => useViewNavigationState());
const values = result.current.navItems.map(i => i.value);
expect(values).toContain('dashboard');
expect(values).toContain('fleet');
expect(values).toContain('resources');
expect(values).toContain('templates');
expect(values).toContain('global-observability');
// Logs is an admin-only operator view; a non-admin must not see the entry.
expect(values).not.toContain('global-observability');
expect(values).not.toContain('auto-updates');
expect(values).not.toContain('host-console');
expect(values).not.toContain('audit-log');
expect(values).not.toContain('scheduled-ops');
});
it('shows the admin-only Logs entry for an admin on any tier (role gate, not tier gate)', () => {
mockCommunityAdmin();
const { result } = renderHook(() => useViewNavigationState());
expect(result.current.navItems.map(i => i.value)).toContain('global-observability');
});
it('redirects a non-admin off the Logs view when reached via a deep-link event', () => {
const onNavigateToDashboard = vi.fn();
// Community (non-admin) is the beforeEach default.
const { result } = renderHook(() => useViewNavigationState({ onNavigateToDashboard }));
act(() => {
window.dispatchEvent(
new CustomEvent(SENCHO_NAVIGATE_EVENT, { detail: { view: 'global-observability' } }),
);
});
expect(result.current.activeView).toBe('dashboard');
expect(onNavigateToDashboard).toHaveBeenCalled();
});
// ── navItems: admiral admin ────────────────────────────────────────────────
it('navItems for admiral paid admin contains all items', () => {
@@ -104,8 +104,10 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
{ value: 'fleet', label: 'Fleet', icon: Radar },
{ value: 'resources', label: 'Resources', icon: HardDrive },
{ value: 'templates', label: 'App Store', icon: CloudDownload },
{ value: 'global-observability', label: 'Logs', icon: Activity },
];
// The aggregated Logs feed crosses every managed stack, so it is an
// admin-only operator view (the backend gates the same routes on admin).
if (isAdmin) items.push({ value: 'global-observability', label: 'Logs', icon: Activity });
if (isPaid && isAdmin) {
items.push({ value: 'auto-updates', label: 'Auto-Update', icon: RefreshCw });
items.push({ value: 'scheduled-ops', label: 'Schedules', icon: Clock });
@@ -120,12 +122,17 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
}, [isAdmin, isPaid, license?.variant, can, isRemote]);
useEffect(() => {
if (isRemote && HUB_ONLY_VIEWS.has(activeView)) {
// Redirect off a view the active context can't reach: a hub-only view while
// a remote node is active, or the admin-only Logs view as a non-admin (e.g.
// arrived via a deep-link event rather than the now-hidden nav item).
const blockedByRemote = isRemote && HUB_ONLY_VIEWS.has(activeView);
const blockedByRole = !isAdmin && activeView === 'global-observability';
if (blockedByRemote || blockedByRole) {
onNavigateToDashboard?.();
setActiveView('dashboard');
setFilterNodeId(null);
}
}, [isRemote, activeView, onNavigateToDashboard]);
}, [isRemote, isAdmin, activeView, onNavigateToDashboard]);
return {
activeView, setActiveView,