mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 18:56:53 +00:00
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:
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user