diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 00847e9c4..cce527314 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,45 +1,34 @@ { "version": 1, "result": "passed", - "base_sha": "1019310adf4aebaa88d5de0933be95dfc61284c8", - "verified_at": "2026-08-07T12:40:13Z", + "base_sha": "f0e2243b44b7e40eb6c3af624c4f9337cdf2f4d4", + "verified_at": "2026-08-07T18:44:06Z", "changed_paths": [ - "frontend-modern/src/App.tsx", - "frontend-modern/src/components/BusinessEstateCard.tsx", - "frontend-modern/src/components/Settings/ProLicensePanel.tsx", - "frontend-modern/src/components/Settings/settingsNavCatalog.ts", - "frontend-modern/src/stores/sessionCapabilities.ts", - "frontend-modern/src/types/config.ts" + "frontend-modern/src/App.tsx" ], "content_sha256": { - "frontend-modern/src/App.tsx": "7c22f48d04053556f07df34511b49847e9f9b2bd1217f819ff7d8b4f41ed83ef", - "frontend-modern/src/components/BusinessEstateCard.tsx": "af4463ea3a33de6a3a24b8ed89d41fcc42ceadb12736b6443a88556e18c88362", - "frontend-modern/src/components/Settings/ProLicensePanel.tsx": "c9d3a0405c626370239eaa3ff5fd1f7118887553ab8e80ef26b55008b817af3f", - "frontend-modern/src/components/Settings/settingsNavCatalog.ts": "b6a8f9c622c72a86dc7a0034c479e86fb9a1919ad0eb4a5fcd81222242fae865", - "frontend-modern/src/stores/sessionCapabilities.ts": "455c254df2ea7236bb9a1202e848f7226b963e1e36c761799d6eeca70a845b19", - "frontend-modern/src/types/config.ts": "36f050dba43af75781f98435faaa9921072d696d6133fb7f10bdfe04506c39bd" + "frontend-modern/src/App.tsx": "a41475370826987f90ffd476983fb7abd02ba87afbbee46cfb7f160c5c649dd7" }, "routes": [ "/", - "/settings/pulse-intelligence/billing/plan", - "/settings/security-roles" + "/settings/infrastructure" ], "states": [ - "free Community tier with hideUpgrade=false served by the rebuilt dev backend (verified via /api/security/status)", - "sessionCapabilities.businessEstate=true from 6 mock PVE nodes on the free tier", - "business-estate card eligible state (star prompt dismissed, first-seen recorded on a prior day), re-exercised after the storage keys moved local to the component", - "business-estate card permanently dismissed state after each dismissal action", - "settings navigation for a free install showing Data & Reports, Roles, Users, Audit Log, Audit Webhooks, Remote Access, and Plans & Billing", - "Roles panel feature gate with visible View plans CTA on the free tier", - "Plans & Billing Community plan with the MSPs and multi-client providers section" + "authenticated NON-ADMIN session (proxy auth, X-Remote-Role=viewer): /api/security/status serves detailLevel=authenticated, requiresAuth=true, proxyAuthIsAdmin=false, and a direct GET /api/updates/status returns 403", + "authenticated ADMIN session (proxy auth, X-Remote-Role=admin): /api/security/status serves detailLevel=privileged, proxyAuthIsAdmin=true, and a direct GET /api/updates/status returns 200", + "non-admin app shell fully rendered with the global banner block mounted (hasSettingsAccess is true for a cookie/proxy session carrying no token scopes), confirming the new gate is what suppresses the watcher rather than the pre-existing kiosk/settings-access gate", + "GlobalUpdateProgressWatcher absent for the non-admin session: zero GET /api/updates/status requests recorded in a 60s window at each viewport, a window that previously held roughly 13 five-second poll ticks", + "GlobalUpdateProgressWatcher active for the admin session: 22 GET /api/updates/status requests recorded across both viewports, all 200 OK, so the fallback poll is unchanged for sessions that can actually read it", + "backend log across the whole verification session contains zero /api/updates/status admin-denial warnings, down from one per five seconds per non-admin tab" ], "interactions": [ - "logged in as admin via real clicks and typing at 1280x800", - "clicked 'See business plans' on the business-estate card: navigated to /settings/pulse-intelligence/billing/plan and set the permanent dismissal key", - "reset dismissal, reloaded, clicked 'This is a homelab': card removed from DOM and dismissal persisted (repeated against the final component bytes)", - "navigated to Roles via settings navigation and confirmed the inline gate with View plans CTA", - "read the Plans & Billing page including the MSP provider section and pulserelay.pro/msp link", - "repeated the card render and homelab dismissal at mobile viewport with the card clearing the bottom navigation bar" + "started an isolated Pulse instance on a scratch data dir with PROXY_AUTH_SECRET, PROXY_AUTH_USER_HEADER=X-Remote-User, PROXY_AUTH_ROLE_HEADER=X-Remote-Role, PROXY_AUTH_ADMIN_ROLE=admin, serving the freshly built frontend bundle containing this change", + "drove the non-admin identity through a header-injecting reverse proxy on port 8902 and the admin identity through one on port 8901, so both sessions differ only in the role header", + "loaded / as the non-admin at 1280x800, waited a full 60s poll window, and read the browser network log plus the backend log: zero /api/updates/status requests", + "reloaded / as the non-admin at 375x812, waited another full 60s window, and re-read both logs: still zero /api/updates/status requests, with the settings shell and bottom navigation rendering normally", + "loaded / as the admin at 375x812 and confirmed 12 GET /api/updates/status responses at 200 OK", + "reloaded / as the admin at 1280x800 and confirmed a further 10 GET /api/updates/status responses at 200 OK", + "screenshotted the non-admin shell at both viewports to confirm the gate removes only the update poll and leaves the rendered UI intact" ], "viewports": [ { diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index a5e0656c6..aa6ba2d76 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -307,6 +307,20 @@ function App() { const hasSettingsAccess = createMemo(() => sessionHasSettingsAccess(runtime.securityStatus()?.tokenScopes), ); + // /api/updates/status and /api/updates/stream sit behind RequireAdmin + + // settings:read. /api/security/status mirrors that exact gate: detailLevel + // is 'privileged' iff the session passes canAccessAdminSurface(settings:read) + // (router_routes_auth_security.go), so an authenticated-but-unprivileged + // session (e.g. an SSO user without an RBAC admin grant) must not mount the + // watcher — its 5s fallback poll would 403 forever and spam the backend log + // (misread as an RBAC break in the #1601 rc.9 retest). Instances that + // require no auth serve detailLevel 'public' while leaving the update + // routes open, so only the authenticated non-privileged case is gated. + const canReadUpdateStatus = createMemo(() => { + const status = runtime.securityStatus(); + if (!status || !status.requiresAuth) return true; + return status.detailLevel === undefined || status.detailLevel === 'privileged'; + }); let appShellRoutePreloadCleanup: (() => void) | undefined; let appShellRoutesPreloadScheduled = false; let workspaceRedirectPending = false; @@ -512,7 +526,9 @@ function App() { - + + + {/* Main layout container - flexbox to allow AI panel to push content */}
diff --git a/frontend-modern/src/__tests__/App.architecture.test.ts b/frontend-modern/src/__tests__/App.architecture.test.ts index a34956d0f..5e4cf397e 100644 --- a/frontend-modern/src/__tests__/App.architecture.test.ts +++ b/frontend-modern/src/__tests__/App.architecture.test.ts @@ -342,6 +342,27 @@ describe('App architecture', () => { expect(appSource).not.toContain("scopes.includes('settings:read')"); }); + it('keeps non-privileged sessions off the update-status poll', () => { + // /api/updates/status and /api/updates/stream are RequireAdmin + + // settings:read routes. detailLevel === 'privileged' is the served mirror + // of that exact gate, so an authenticated session without admin privileges + // (SSO user with only read roles) must never mount the watcher — its 5s + // fallback poll would 403 on every tick and spam the backend log, which + // misled the #1601 rc.9 retest into diagnosing an RBAC break. + expect(appSource).toContain('const canReadUpdateStatus = createMemo(() => {'); + // Open instances (requiresAuth false) keep the fallback poll: the update + // routes are reachable there even though detailLevel is served as 'public'. + expect(appSource).toContain('if (!status || !status.requiresAuth) return true;'); + expect(appSource).toContain( + "return status.detailLevel === undefined || status.detailLevel === 'privileged';", + ); + const watcherGate = appSource.indexOf(''); + expect(watcherGate).toBeGreaterThan(-1); + expect(appSource.indexOf('')).toBeGreaterThan(watcherGate); + // Exactly one mount, and only the gated one. + expect(appSource.split('').length).toBe(2); + }); + it('keeps the update progress watcher aligned with the backend updater stages', () => { // The in-progress stage list must mirror internal/updates/manager.go // updateStatus emissions, including the rollback path's restoring stage,