diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index eacb5a6af..93e23af50 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -1238,6 +1238,12 @@ instead of lifecycle-local centered icon/text shells. ## Extension Points +The authenticated runtime-display projection under shared `internal/api/` may +carry the effective global `disableDockerUpdateActions` boolean so non-admin +viewers render container updates read-only. This is API/settings presentation +only: it neither disables image-update detection nor changes agent update, +registration, profile, command, enrollment, or fleet-control authority. + Manual scoped Patrol work that reaches `internal/api/ai_handlers.go` (such as an alert-initiated targeted Patrol check via `POST /api/ai/patrol/run`) is investigation-only over agent-reporting resources: it must not alter agent diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 690ae81c4..0887fff7d 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -9198,7 +9198,8 @@ case honest against live 403s on `/api/config/nodes` and `/api/system/settings`. `internal/api/router_routes_registration.go`, uses `RequireAuth` plus `monitoring:read`, matching the sibling `/api/runtime/branding` route. Its explicit `RuntimeDisplayResponse` whitelist contains only `theme`, -`fullWidthMode`, `disableDockerUpdateActions`, and `reduceProUpsellNoise`. +`fullWidthMode`, `disableDockerUpdateActions`, `telemetryEnabled`, and +`reduceProUpsellNoise`. Authenticated app bootstrap needs those values for every role. It must not read them from `GET /api/system/settings`, whose `RequireAdmin` plus `settings:read` @@ -9207,12 +9208,22 @@ URL, webhook-network, telemetry, and login configuration. The admin route and payload remain unchanged; the new route never embeds or projects `config.SystemSettings`, so future settings fields are not published by omission. `disableDockerUpdateActions` comes from effective runtime config so -the environment override remains authoritative. +the environment override remains authoritative. `telemetryEnabled` is the +effective boolean only: it lets a read-only General panel reflect the +operator's global privacy choice, but it never exposes the telemetry preview, +rotating install ID, environment overrides, or any other admin setting. + +When the General settings state owner receives the expected 403 from the admin +settings route, it must fall back to this narrow runtime projection for those +two global booleans and synchronize the shared Docker-action store. It must not +retry through a broader route or replace an operator-disabled telemetry value +with the frontend default. `TestContract_RuntimeDisplayServesPresentationValuesWithoutAdmin` pins a viewer receiving 200 from the runtime route while still receiving 403 from the admin route. `TestHandleGetRuntimeDisplay_PublishesOnlyPresentationFields` pins the -serialized whitelist, and the frontend bootstrap tests pin that +serialized whitelist, the frontend settings/API tests pin the read-only +fallback and effective boolean values, and the frontend bootstrap tests pin that `useAppRuntimeState` never calls `getSystemSettings()`. ### Security status names every admin-only Settings panel capability diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index 316c56d33..24b0911ab 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -312,6 +312,14 @@ the `white_label` branding entitlement. metadata, or remediation output. 2. Change security policy, hardening guidance, or supported auth boundaries through `SECURITY.md`. 3. Change telemetry/privacy settings state handling through `frontend-modern/src/components/Settings/useSystemSettingsState.ts`. + A non-admin General panel may read the effective `telemetryEnabled` boolean + from the authenticated `/api/runtime/display` whitelist after the complete + admin settings request is refused. That fallback must expose no preview + payload, install ID, environment override, origin, webhook-network, or login + configuration, and it must preserve an operator-selected `false` rather than + reverting to the frontend's enabled default. The same response may carry + the effective Docker-action display boolean because both values are needed + to render read-only global state; this does not widen settings write access. Relay runtime access through `internal/api/router.go` must stay behind the existing protected route and API-token gates. Testable router seams may expose relay status to onboarding validation, but they must not broaden diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index dbc0998c8..a02da100e 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -198,6 +198,12 @@ states do not change the underlying storage evidence or active lifecycle. ## Extension Points +The authenticated runtime-display projection under shared `internal/api/` may +publish effective Docker-action visibility and outbound-telemetry booleans to +read-only viewers. Those presentation/privacy values are not backup evidence, +storage health, recovery readiness, restore scope, or remediation authority, +and storage/recovery consumers must not infer any of those states from them. + Agent-token validation added to `internal/api/connections_aggregator.go` and its runtime input assembly in `internal/api/connections_alerts.go` is fleet-governance evidence only. A missing, revoked, or expired host-agent diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index cb8f0eebd..2aa407d78 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,17 +1,17 @@ { "version": 1, - "base_sha": "791a2f86bf16871eefe19acf3bedadb4d0d3edaa", - "verified_at": "2026-08-11T14:40:54Z", + "base_sha": "501575524519294805e98b47dd3f0b746df8d175", + "verified_at": "2026-08-11T18:56:00Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/utils/alertDestinationsPresentation.ts" + "frontend-modern/src/api/settings.ts", + "frontend-modern/src/components/Settings/useSystemSettingsState.ts" ], "content_sha256": { - "frontend-modern/src/utils/alertDestinationsPresentation.ts": "f0b392eb161f3117f543c6a97fcede9f2c42dd079eb3d40a3e491e7f5c287ebd" + "frontend-modern/src/api/settings.ts": "160fdba69870f19682cb234eb582f0f6ea6fb83ef67e0ca3662d2ac0da4b4325", + "frontend-modern/src/components/Settings/useSystemSettingsState.ts": "6e3fc2e3ea80458b30178463fea8f74ffff3498e992c6aa38578daa684653a99" }, - "routes": [ - "/alerts/notifications" - ], + "routes": ["/settings/system-general", "/docker/containers"], "viewports": [ { "width": 1440, @@ -23,17 +23,15 @@ } ], "states": [ - "Notifications with one failed delivery retained for 7 days and two dead-lettered deliveries retained for 30 days.", - "The delivery warning explained that expired records are removed hourly and that the warning clears after the last retained failure reaches its retention limit when no new terminal failures occur.", - "Desktop Notifications at 1440 by 1000 with the warning and Refresh delivery status control visible and no document or body horizontal overflow.", - "Narrow Notifications at 390 by 844 with the warning and refresh control visible and no document or body horizontal overflow.", - "Healthy queue state after controlled fixture cleanup, with the warning removed." + "The running mock backend had Docker update actions disabled and outbound usage telemetry disabled, while the complete admin settings request was deliberately answered with 403 to exercise the viewer fallback.", + "System General reflected Hide update buttons as enabled and Outbound usage telemetry as disabled from the authenticated runtime projection at desktop and narrow widths.", + "Docker Containers rendered no Update or Update all action buttons while the global hide policy was active.", + "System General had no document or body horizontal overflow at either viewport." ], "interactions": [ - "Inserted three synthetic terminal queue records with no destination or customer data into the ignored isolated runtime database, then opened Alerts and Notifications through visible navigation controls.", - "Confirmed the retained-delivery counts, hourly cleanup explanation, retention-limit clearing condition, and refresh control in the real warning card.", - "Refreshed delivery status at desktop width and confirmed the warning remained while retained failures existed.", - "Repeated the warning and overflow checks at the narrow viewport.", - "Deleted the three synthetic records, refreshed delivery status, and confirmed the warning cleared." + "Started the current managed mock runtime and persisted disableDockerUpdateActions=true plus telemetryEnabled=false through the live settings API.", + "Intercepted only GET /api/system/settings with the expected 403 refusal, then opened System General through the current browser build and confirmed the fallback values from GET /api/runtime/display.", + "Inspected full-page desktop and narrow screenshots and measured document and body overflow.", + "Navigated to Docker Containers at both viewports and confirmed the shared global policy left zero Update or Update all buttons." ] } diff --git a/frontend-modern/src/api/__tests__/settings.test.ts b/frontend-modern/src/api/__tests__/settings.test.ts index d85e9a666..53dd87d82 100644 --- a/frontend-modern/src/api/__tests__/settings.test.ts +++ b/frontend-modern/src/api/__tests__/settings.test.ts @@ -215,6 +215,7 @@ describe('SettingsAPI', () => { theme: 'dark', fullWidthMode: true, disableDockerUpdateActions: true, + telemetryEnabled: false, reduceProUpsellNoise: false, }; vi.mocked(apiFetchJSON).mockResolvedValueOnce(display); diff --git a/frontend-modern/src/api/settings.ts b/frontend-modern/src/api/settings.ts index 8b0618fac..d5426f370 100644 --- a/frontend-modern/src/api/settings.ts +++ b/frontend-modern/src/api/settings.ts @@ -177,6 +177,7 @@ export interface RuntimeDisplayResponse { theme?: string; fullWidthMode?: boolean; disableDockerUpdateActions?: boolean; + telemetryEnabled?: boolean; reduceProUpsellNoise?: boolean; } diff --git a/frontend-modern/src/components/Settings/__tests__/useSystemSettingsState.branchcov0722pm.test.ts b/frontend-modern/src/components/Settings/__tests__/useSystemSettingsState.branchcov0722pm.test.ts index ce567526d..81656acb9 100644 --- a/frontend-modern/src/components/Settings/__tests__/useSystemSettingsState.branchcov0722pm.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/useSystemSettingsState.branchcov0722pm.test.ts @@ -11,6 +11,7 @@ import { useSystemSettingsState } from '../useSystemSettingsState'; // identical to the sibling test. const mocks = vi.hoisted(() => ({ getSystemSettingsMock: vi.fn(), + getRuntimeDisplayMock: vi.fn(), updateSystemSettingsMock: vi.fn(), getTelemetryPreviewMock: vi.fn(), resetTelemetryInstallIDMock: vi.fn(), @@ -35,6 +36,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/api/settings', () => ({ SettingsAPI: { getSystemSettings: mocks.getSystemSettingsMock, + getRuntimeDisplay: mocks.getRuntimeDisplayMock, getTelemetryPreview: mocks.getTelemetryPreviewMock, resetTelemetryInstallID: mocks.resetTelemetryInstallIDMock, updateSystemSettings: mocks.updateSystemSettingsMock, @@ -100,6 +102,7 @@ const flushAsync = async () => { describe('useSystemSettingsState branch coverage', () => { beforeEach(() => { mocks.getSystemSettingsMock.mockResolvedValue({}); + mocks.getRuntimeDisplayMock.mockResolvedValue({}); mocks.updateSystemSettingsMock.mockResolvedValue(undefined); mocks.loadRuntimeBrandingMock.mockResolvedValue(undefined); mocks.getUpdatePlanMock.mockResolvedValue({ @@ -154,6 +157,26 @@ describe('useSystemSettingsState branch coverage', () => { return mounted; }; + describe('read-only runtime settings fallback', () => { + it('shows effective Docker-action and telemetry state when admin settings are forbidden', async () => { + mocks.getSystemSettingsMock.mockRejectedValueOnce(new Error('Admin privileges required')); + mocks.getRuntimeDisplayMock.mockResolvedValueOnce({ + disableDockerUpdateActions: true, + telemetryEnabled: false, + }); + const { hookState, dispose } = mountHook(); + + await hookState.initializeSystemSettingsState(); + await flushAsync(); + + expect(mocks.getRuntimeDisplayMock).toHaveBeenCalledTimes(1); + expect(hookState.disableDockerUpdateActions()).toBe(true); + expect(hookState.telemetryEnabled()).toBe(false); + expect(mocks.updateDockerUpdateActionsSettingMock).toHaveBeenCalledWith(true); + dispose(); + }); + }); + describe.each([ { name: 'temperatureMonitoringLocked', diff --git a/frontend-modern/src/components/Settings/useSystemSettingsState.ts b/frontend-modern/src/components/Settings/useSystemSettingsState.ts index 4e5ac318b..ea295770d 100644 --- a/frontend-modern/src/components/Settings/useSystemSettingsState.ts +++ b/frontend-modern/src/components/Settings/useSystemSettingsState.ts @@ -179,6 +179,19 @@ export function useSystemSettingsState({ } } catch (error) { logger.error('Failed to load settings', error); + + // Viewers cannot read the admin settings payload, but they still need to + // see the effective server-wide state of read-only controls. Use the + // deliberately narrow authenticated-session projection for those values. + try { + const runtimeDisplay = await SettingsAPI.getRuntimeDisplay(); + const dockerActionsDisabled = runtimeDisplay.disableDockerUpdateActions ?? false; + setDisableDockerUpdateActions(dockerActionsDisabled); + updateDockerUpdateActionsSetting(dockerActionsDisabled); + setTelemetryEnabled(runtimeDisplay.telemetryEnabled ?? true); + } catch (runtimeDisplayError) { + logger.warn('Failed to load read-only runtime settings', runtimeDisplayError); + } } try { diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index e34209f8e..95b68966b 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -24109,6 +24109,9 @@ func TestContract_RuntimeDisplayServesPresentationValuesWithoutAdmin(t *testing. settings := config.DefaultSystemSettings() settings.Theme = "dark" settings.FullWidthMode = true + settings.DisableDockerUpdateActions = true + telemetryEnabled := false + settings.TelemetryEnabled = &telemetryEnabled if err := persistence.SaveSystemSettings(*settings); err != nil { t.Fatalf("save system settings: %v", err) } @@ -24143,6 +24146,12 @@ func TestContract_RuntimeDisplayServesPresentationValuesWithoutAdmin(t *testing. if !display.FullWidthMode { t.Fatal("fullWidthMode = false, want true") } + if !display.DisableDockerUpdateActions { + t.Fatal("disableDockerUpdateActions = false, want true") + } + if display.TelemetryEnabled { + t.Fatal("telemetryEnabled = true, want false") + } } // The refusal itself is the contract; its log severity is not. This pins both diff --git a/internal/api/runtime_display.go b/internal/api/runtime_display.go index 9abd770d6..5e0583d0f 100644 --- a/internal/api/runtime_display.go +++ b/internal/api/runtime_display.go @@ -20,6 +20,7 @@ type RuntimeDisplayResponse struct { Theme string `json:"theme"` FullWidthMode bool `json:"fullWidthMode"` DisableDockerUpdateActions bool `json:"disableDockerUpdateActions"` + TelemetryEnabled bool `json:"telemetryEnabled"` ReduceProUpsellNoise bool `json:"reduceProUpsellNoise"` } @@ -31,11 +32,14 @@ func (h *SystemSettingsHandler) HandleGetRuntimeDisplay(w http.ResponseWriter, r return } - response := RuntimeDisplayResponse{} + response := RuntimeDisplayResponse{TelemetryEnabled: true} if h != nil && h.config != nil { // Match HandleGetSystemSettings so the environment override wins over - // the persisted value for every role. + // the persisted value for every role. These booleans reveal only the + // effective operator policy, not the admin-only configuration or + // telemetry payload. response.DisableDockerUpdateActions = h.config.DisableDockerUpdateActions + response.TelemetryEnabled = h.config.TelemetryEnabled } if h == nil || h.persistence == nil { @@ -53,6 +57,12 @@ func (h *SystemSettingsHandler) HandleGetRuntimeDisplay(w http.ResponseWriter, r response.Theme = settings.Theme response.FullWidthMode = settings.FullWidthMode response.ReduceProUpsellNoise = settings.ReduceProUpsellNoise + if h.config == nil || (!h.config.EnvOverrides["disableDockerUpdateActions"] && !h.config.EnvOverrides["PULSE_DISABLE_DOCKER_UPDATE_ACTIONS"]) { + response.DisableDockerUpdateActions = settings.DisableDockerUpdateActions + } + if settings.TelemetryEnabled != nil && (h.config == nil || (!h.config.EnvOverrides["telemetryEnabled"] && !h.config.EnvOverrides["PULSE_TELEMETRY"])) { + response.TelemetryEnabled = *settings.TelemetryEnabled + } } if err := utils.WriteJSONResponse(w, response); err != nil { diff --git a/internal/api/runtime_display_test.go b/internal/api/runtime_display_test.go index 4042248c1..bc3de0151 100644 --- a/internal/api/runtime_display_test.go +++ b/internal/api/runtime_display_test.go @@ -76,6 +76,7 @@ func TestHandleGetRuntimeDisplay_PublishesOnlyPresentationFields(t *testing.T) { "theme": {}, "fullWidthMode": {}, "disableDockerUpdateActions": {}, + "telemetryEnabled": {}, "reduceProUpsellNoise": {}, } for key := range raw { @@ -94,7 +95,12 @@ func TestHandleGetRuntimeDisplay_UsesEffectiveDockerUpdateActionsSetting(t *test settings := config.DefaultSystemSettings() settings.DisableDockerUpdateActions = false - handler := newRuntimeDisplayHandler(t, &config.Config{DisableDockerUpdateActions: true}, settings) + handler := newRuntimeDisplayHandler(t, &config.Config{ + DisableDockerUpdateActions: true, + EnvOverrides: map[string]bool{ + "PULSE_DISABLE_DOCKER_UPDATE_ACTIONS": true, + }, + }, settings) got, _ := fetchRuntimeDisplay(t, handler) if !got.DisableDockerUpdateActions { @@ -102,6 +108,24 @@ func TestHandleGetRuntimeDisplay_UsesEffectiveDockerUpdateActionsSetting(t *test } } +func TestHandleGetRuntimeDisplay_UsesEffectiveTelemetrySetting(t *testing.T) { + settings := config.DefaultSystemSettings() + enabled := true + settings.TelemetryEnabled = &enabled + + handler := newRuntimeDisplayHandler(t, &config.Config{ + TelemetryEnabled: false, + EnvOverrides: map[string]bool{ + "PULSE_TELEMETRY": true, + }, + }, settings) + got, _ := fetchRuntimeDisplay(t, handler) + + if got.TelemetryEnabled { + t.Fatal("telemetryEnabled = true, want the effective config override to win") + } +} + func TestHandleGetRuntimeDisplay_RejectsNonGET(t *testing.T) { handler := newRuntimeDisplayHandler(t, &config.Config{}, config.DefaultSystemSettings()) req := httptest.NewRequest(http.MethodPost, "/api/runtime/display", nil) diff --git a/internal/api/system_settings_telemetry_test.go b/internal/api/system_settings_telemetry_test.go index 2f8577ced..7f46bd61f 100644 --- a/internal/api/system_settings_telemetry_test.go +++ b/internal/api/system_settings_telemetry_test.go @@ -197,6 +197,29 @@ func TestTelemetryUpdate_GetReturnsEffectiveValue(t *testing.T) { } } +func TestRuntimeDisplayPublishesOnlyEffectiveTelemetryState(t *testing.T) { + settings := config.DefaultSystemSettings() + persistedEnabled := true + settings.TelemetryEnabled = &persistedEnabled + handler := newRuntimeDisplayHandler(t, &config.Config{ + TelemetryEnabled: false, + EnvOverrides: map[string]bool{ + "PULSE_TELEMETRY": true, + }, + }, settings) + + display, raw := fetchRuntimeDisplay(t, handler) + if display.TelemetryEnabled { + t.Fatal("runtime telemetry state = enabled, want effective disabled override") + } + if _, ok := raw["telemetryEnabled"]; !ok { + t.Fatal("runtime display omitted telemetryEnabled") + } + if _, ok := raw["telemetryPreview"]; ok { + t.Fatal("runtime display exposed admin-only telemetry preview") + } +} + func TestTelemetryPreview_ReturnsCurrentPayload(t *testing.T) { tempDir := t.TempDir() cfg := &config.Config{