fix(settings): stop non-admin sessions polling admin infrastructure endpoints

Settings -> Infrastructure reads /api/connections, /api/config/nodes,
/api/system/settings, /api/truenas/connections and /api/vmware/connections on
mount and then polls /api/connections every 15s and /api/discover every 30s.
Every one of those is RequireAdmin, so an authenticated non-admin rendered a
page where nothing loaded while each poll reprinted "Non-admin user attempted
to access admin endpoint" at warn level. Measured on a proxy-auth viewer
session: 6 denials/minute from this page alone on an idle tab.

Serve infrastructureRead alongside the other settings capabilities, derived
from the same canAccessAdminSurface(settings:read) expression the routes
enforce, and gate the nav item on it. Two follow-on fixes were needed because
the page is not the only mount point:

- DEFAULT_SETTINGS_TAB is infrastructure-systems, so the blocked-route
  fallback pointed straight back at the tab it had just refused. It now falls
  back to the first tab the session can actually reach.
- Settings.tsx constructs useInfrastructureSettingsState for every settings
  tab, so the discovery poller and the TrueNAS/VMware mount fetches ran no
  matter which tab was open. They now wait on the same capability. The
  TrueNAS/VMware loads moved from onMount to an effect so admins still load
  once the capability resolves, rather than sampling it before it exists.

Verified against a local instance behind a header-injecting proxy-auth shim:
viewer goes from 6 infrastructure denials/minute to 0, admin keeps both
pollers armed (/api/discover x4 and /api/connections x3 over 152s) with 0
denials. Re-checked at 1280x800 and 375x812 for both roles.
This commit is contained in:
rcourtman
2026-08-07 20:16:23 +01:00
parent 06d8bfe65e
commit 755a888787
22 changed files with 429 additions and 36 deletions
@@ -5953,3 +5953,36 @@ The `source` attribution parameter added to
it is read from the purchase-start query, validated, and passed to the
license-server portal handoff. No agent enrollment, report, ack, or update
route reads or emits it, and the agent-facing payload shapes are unchanged.
### Platform connection panel state waits on the infrastructure capability
`Settings.tsx` constructs `useInfrastructureSettingsState` for every settings
tab, not just Infrastructure, so its bootstrap and pollers ran on whatever
settings page a session happened to open. All of the endpoints involved are
`RequireAdmin`, which made this the largest repeating source of
`Non-admin user attempted to access admin endpoint` warn lines on an idle
instance.
`useInfrastructureSettingsState`, `useInfrastructureDiscoveryRuntimeState`,
`useTrueNASSettingsPanelState` and `useVMwareSettingsPanelState` now take the
session's `infrastructureRead` capability (see the api-contracts entry for the
served field) and hold their reads until it is granted:
- the discovery hook skips `loadDiscoveredNodes` outright and only arms its 30s
`/api/discover` interval once the capability reads true — the accessor is read
inside the effect so the interval still arms for an admin as soon as the
status resolves;
- the infrastructure bootstrap still awaits `loadSecurityStatus` (which is what
resolves the capability, and is readable by any session) but returns before
the node, discovery and system-settings loads when it is withheld;
- the TrueNAS and VMware panel loads moved from `onMount` to a once-only effect.
An `onMount` check would sample the capability before the status request
resolves and withhold the load from admins too.
`canLoad` is optional on the two panel hooks so tests and stories that construct
them directly keep the eager load.
Pinned by the capability cases in `useTrueNASSettingsPanelState.test.tsx` and
`useVMwareSettingsPanelState.test.tsx` (skipped when withheld, loads once when
the accessor flips) and by the discovery source pin in
`InfrastructureOperationsModel.test.tsx`.
@@ -9001,3 +9001,28 @@ keeps the surface that started it. Unrelated query parameters keep their
existing pass-through behavior.
`TestContract_CheckoutStartSourceAttributionReachesHandoffNeverPortal` pins
both the forwarding and the drop of a malformed value.
### Security status serves an infrastructureRead settings capability
`GET /api/security/status` adds `settingsCapabilities.infrastructureRead` to the
authenticated and privileged payloads
(`internal/api/security_status_capabilities.go`). It is derived from the same
`canAccessAdminSurface(config.ScopeSettingsRead)` expression that the routes
behind Settings → Infrastructure enforce — `/api/connections`,
`/api/config/nodes`, `/api/system/settings`, `/api/truenas/connections` and
`/api/vmware/connections` are all `RequireAdmin` + `settings:read`, and
`/api/discover` additionally requires `settings:write`. The field is additive
and marshals as `false` for any session that cannot reach those routes, so
clients that predate it are unaffected.
The capability exists because the page it describes is not merely read-only for
a non-admin, it is empty and noisy: mounting it started a 15s `/api/connections`
poll and a 30s `/api/discover` poll whose every tick logged
`Non-admin user attempted to access admin endpoint` at warn level. Serving the
capability is what lets the client decline to mount the surface at all.
`TestContract_SecurityStatusInfrastructureReadTracksSettingsReadScope` pins both
halves: the served value for a `settings:read` token versus a
`monitoring:read` token, and that the routes agree with whichever value was
served. `TestSettingsCapabilitiesMatchRouteEnforcementWithoutRBAC` keeps the
withheld case honest against live 403s.
@@ -5802,3 +5802,29 @@ pin this composition boundary.
ZFS dataset table rows inside `StoragePoolDetail` inherit their separator from
the shared `STORAGE_DETAIL_ROW_CLASS` presentation constant. The component
must not reintroduce a raw border-token class for dataset rows.
### The settings nav gates Infrastructure, and the blocked-route fallback is capability-aware
`infrastructure-systems` now declares `requiredCapability: 'infrastructureRead'`
in `frontend-modern/src/components/Settings/settingsNavCatalog.ts`, so
`shouldHideSettingsNavItem` and `shouldBlockSettingsRouteItem` withhold both the
sidebar entry and the route from a session the backend says cannot read it.
This is a different gate from `system-relay` and `support-reporting`, which stay
visible without their paid feature so the panel can render its own upgrade
prompt. Infrastructure has no upgrade story: every endpoint behind it is
`RequireAdmin`, so a non-admin got an all-empty page whose pollers logged a
warn-level denial on every tick. Hiding the item is what stops those pollers
mounting.
Because `DEFAULT_SETTINGS_TAB` *is* `infrastructure-systems`, the blocked-route
fallback in `useSettingsAccess.ts` can no longer resolve to the constant — that
sent a refused session straight back to the tab that had just refused it. It now
falls back to `flatTabs()[0]`, the first tab the session can actually reach, and
only uses `DEFAULT_SETTINGS_TAB` when no tab resolves at all.
Pinned by the `infrastructure-systems` block assertion in
`settingsArchitecture.test.ts` and by
`__tests__/infrastructureNavCapabilityGate.test.ts`, which also pins that
neither gate fires before the security status resolves — hiding on an
unresolved status would flash the default tab away from an admin on every load.
@@ -5054,3 +5054,13 @@ body, and echoed onto the cancel return URL. It is never written to config
persistence, the license store, or any recovery artifact, so the install's
storage surface is unchanged. Attribution is retained only by the commercial
backend, on the checkout intent it already owns.
### Security status capability payload gained an infrastructure field
`internal/api/` is a canonical reference in this contract's Extension Points, so
this records the additive change made in
`internal/api/security_status_capabilities.go`: the settings capability payload
served by `GET /api/security/status` now carries `infrastructureRead`, derived
from `canAccessAdminSurface(config.ScopeSettingsRead)`. No recovery or storage
route, payload, or persisted shape changes. The api-contracts entry holds the
authoritative description and its proof.
+33 -26
View File
@@ -1,30 +1,37 @@
{
"version": 1,
"base_sha": "06d8bfe65ebf3683a07d9ec7775a50a9872418ce",
"verified_at": "2026-08-07T19:15:00Z",
"result": "passed",
"base_sha": "5b07bdc3d8eab45ee004c3c71f22fd2f4d318371",
"verified_at": "2026-08-07T18:40:21Z",
"changed_paths": [
"frontend-modern/src/components/BusinessEstateCard.tsx",
"frontend-modern/src/components/Settings/useProLicensePanelState.ts",
"frontend-modern/src/utils/pricingHandoff.ts"
"frontend-modern/src/components/Settings/Settings.tsx",
"frontend-modern/src/components/Settings/settingsNavCatalog.ts",
"frontend-modern/src/components/Settings/useInfrastructureDiscoveryRuntimeState.ts",
"frontend-modern/src/components/Settings/useInfrastructureSettingsState.ts",
"frontend-modern/src/components/Settings/useSettingsAccess.ts",
"frontend-modern/src/components/Settings/useTrueNASSettingsPanelState.ts",
"frontend-modern/src/components/Settings/useVMwareSettingsPanelState.ts",
"frontend-modern/src/types/config.ts"
],
"content_sha256": {
"frontend-modern/src/components/BusinessEstateCard.tsx": "a3717b19a728ef33f775ccce714080b6affd0a56a3a3d8d7a678625a9a6af900",
"frontend-modern/src/components/Settings/useProLicensePanelState.ts": "bfed84fd6aefa15e7b2f7b3ba2c91d8f6e2379172c45bbce3822c7cb1151f776",
"frontend-modern/src/utils/pricingHandoff.ts": "7e5c6178f8811179d64894ab136a2ff4263181b61bdc7298a2d341aca9e9b0cc"
"frontend-modern/src/components/Settings/Settings.tsx": "0e400e1896d2272364f298f721ff6f7a867900cd0e49fb5c7a64432928662f6b",
"frontend-modern/src/components/Settings/settingsNavCatalog.ts": "534c501a82919927c7bfcbbffd26a0f822d362247d8339e7de54696bd9ab172a",
"frontend-modern/src/components/Settings/useInfrastructureDiscoveryRuntimeState.ts": "d56324453d07a8ec7ce81f9a385251da456cae68c2b674d692c231f86b24498e",
"frontend-modern/src/components/Settings/useInfrastructureSettingsState.ts": "406a7e7328017a501a085ce3e0754afb23d9927b4011fc4b976b5d79aa341a47",
"frontend-modern/src/components/Settings/useSettingsAccess.ts": "8e7ff8c036632b474aa9e66ba388e1fa6a514d1cf85b15ff56a581fb6ab5a163",
"frontend-modern/src/components/Settings/useTrueNASSettingsPanelState.ts": "5e7ac8b1ba5b481323eb4d7e9751919ff2478840c85532452991649939732e80",
"frontend-modern/src/components/Settings/useVMwareSettingsPanelState.ts": "cdaeb8a98deef07dee1069fd09ecd618360ba9058b197c297a8bd3ef7daddda0",
"frontend-modern/src/types/config.ts": "b918901c67294b7dcf6b0243ba92fd7a741468a009ea0a927f7caedb602ce2f9"
},
"routes": [
"/",
"/settings (Security > Roles, RBAC inline gate)",
"/settings (System > General, branding gate)",
"/settings/pulse-intelligence/billing/plan?source=gate-rbac",
"/settings/pulse-intelligence/billing/plan?intent=self_hosted_plan&source=gate-rbac",
"/settings/pulse-intelligence/billing/plan?intent=self_hosted_plan"
"/settings/infrastructure (non-admin proxy-auth session: route blocked, falls back to the first reachable tab)",
"/settings (non-admin: bare settings path resolves through the same blocked default tab)",
"/settings/infrastructure (admin proxy-auth session: page renders and loads)"
],
"viewports": [
{
"width": 1280,
"height": 720
"height": 800
},
{
"width": 375,
@@ -32,18 +39,18 @@
}
],
"states": [
"free Community tier with presentationPolicy.hideUpgrade=false, served by an isolated worktree backend on port 7698 with 6 mock PVE nodes",
"RBAC inline feature gate rendered on the Roles panel for an unentitled install",
"plan route reached with gate attribution present, and separately with no source at all to exercise the plans-page default",
"business-estate card in its eligible state (star prompt already dismissed, first-seen recorded on a prior day)",
"branding gate for white_label, an unmapped feature key that routes straight to purchase-start rather than the plan page"
"authenticated non-admin proxy-auth session (X-Remote-Role=viewer) against a local backend on port 7855 fronted by a header-injecting shim; /api/security/status reports detailLevel=authenticated and settingsCapabilities.infrastructureRead=false",
"admin proxy-auth session (X-Remote-Role=admin) on the same instance; detailLevel=privileged and infrastructureRead=true",
"settings sidebar for the non-admin: the whole Infrastructure group is absent and the sidebar starts at Monitoring > Availability checks",
"settings sidebar for the admin: Infrastructure group present and selected, discovery card shows 'Last scanned 0s ago' proving the gated load ran once the capability resolved",
"empty-estate instance (0 connected systems) so the Infrastructure page renders its onboarding empty state rather than a populated table"
],
"interactions": [
"clicked the RBAC gate 'View plans' CTA and confirmed its href carried ?source=gate-rbac, then confirmed the source survived canonical billing-route resolution on arrival",
"read the purchase-start hrefs rendered on the plan page: /auth/license-purchase-start?source=gate-rbac&feature=self_hosted_plan",
"loaded the same plan route with no source parameter and confirmed the purchase-start hrefs fell back to source=plans-page",
"clicked 'See business plans' on the business-estate card and confirmed it navigated to the plan route with ?source=estate-card and dismissed the card permanently",
"inspected the branding gate href and confirmed unmapped keys carry attribution too: /auth/license-purchase-start?source=gate-white-label&feature=white_label",
"re-exercised the attributed plan route at 375x812 and confirmed the purchase-start hrefs and layout hold at narrow width"
"deep-linked a non-admin straight to /settings/infrastructure and confirmed the route redirected to Availability checks instead of ping-ponging back to the blocked default tab",
"queried the rendered DOM for any /settings/infrastructure link or 'Infrastructure' text as the non-admin at both widths and found none",
"measured the backend warn log over a 60s idle window on the non-admin settings page before and after the gate: /api/connections 4/min and /api/discover 2/min dropped to 0, with 0 denials on /api/config/nodes, /api/system/settings, /api/truenas/connections and /api/vmware/connections",
"switched the shim to an admin role, reloaded /settings/infrastructure and confirmed the page renders, then instrumented window.fetch for 152s and observed /api/discover x4 (30s poller) and /api/connections x3 (15s ledger poller) with 0 denials",
"repeated the non-admin deep link and the admin page load at 375x812 and confirmed the same outcomes on the mobile drill-down layout",
"confirmed the footer still reads 'Pulse | Version: 4.26.0' for the non-admin throughout, since the version comes from the public /api/version rather than the admin-only update check"
]
}
@@ -82,8 +82,14 @@ const SettingsWorkspace: Component<SettingsProps> = (props) => {
setDiscoveryEnabled: discoverySettings.setDiscoveryEnabled,
applySavedDiscoverySubnet: discoverySettings.applySavedDiscoverySubnet,
});
// Mirrors the RequireAdmin + settings:read gate on every infrastructure
// endpoint. Until the security status resolves this is false, so the
// bootstrap waits rather than firing requests it may not be allowed to make.
const canReadInfrastructure = () =>
securityStatus()?.settingsCapabilities?.infrastructureRead === true;
const infrastructureSettings = useInfrastructureSettingsState({
eventBus,
canReadInfrastructure,
discoveryEnabled: discoverySettings.discoveryEnabled,
setDiscoveryEnabled: discoverySettings.setDiscoveryEnabled,
discoverySubnet: discoverySettings.discoverySubnet,
@@ -455,4 +455,24 @@ describe('infrastructure operations model', () => {
expect(discoveryStateSource).toContain('filterRepresentedDiscoveredServers');
expect(discoveryStateSource).toContain('nodes()');
});
// /api/discover is RequireAdmin + settings:write and Settings mounts this
// hook for every settings tab, so both the one-shot read and the 30s poller
// must consult the served infrastructure capability. Without the gate a
// non-admin session reprinted a warn-level denial every 30 seconds for as
// long as any settings page stayed open.
it('gates the discovery read and its poller on the served infrastructure capability', async () => {
const discoveryStateSource = await import('../useInfrastructureDiscoveryRuntimeState?raw').then(
(mod) => (mod as { default: string }).default,
);
expect(discoveryStateSource).toContain('canReadInfrastructure: Accessor<boolean>');
// The read guard sits ahead of the fetch...
expect(discoveryStateSource).toMatch(
/const loadDiscoveredNodes = async \(\) => \{\s*if \(!canReadInfrastructure\(\)\) \{\s*return;/,
);
// ...and the interval is only armed once the capability is granted.
expect(discoveryStateSource).toMatch(
/if \(!canReadInfrastructure\(\)\) \{\s*return;\s*\}\s*discoveryInterval = setInterval\(/,
);
});
});
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest';
import { getSettingsNavItem } from '../settingsNavCatalog';
import {
shouldBlockSettingsRouteItem,
shouldHideSettingsNavItem,
type SettingsNavVisibilityContext,
} from '../settingsNavVisibility';
// Settings → Infrastructure reads /api/connections, /api/config/nodes,
// /api/system/settings, /api/truenas/connections and /api/vmware/connections
// on mount, then polls /api/connections every 15s and /api/discover every 30s.
// All of those are RequireAdmin, so a session without settings:read used to
// render an all-empty page whose pollers reprinted "Non-admin user attempted
// to access admin endpoint" at warn level for as long as the tab stayed open.
// The served `infrastructureRead` capability is what stops the page mounting.
const createContext = (
overrides: Partial<SettingsNavVisibilityContext> = {},
): SettingsNavVisibilityContext => ({
hasFeature: () => false,
runtimeCapabilitiesLoaded: () => true,
hostedModeEnabled: false,
...overrides,
});
describe('infrastructure-systems capability gate', () => {
it('declares infrastructureRead as its required capability', () => {
expect(getSettingsNavItem('infrastructure-systems')?.requiredCapability).toBe(
'infrastructureRead',
);
});
it('hides the nav item and blocks the route when the capability is withheld', () => {
const context = createContext({
settingsCapabilitiesResolved: true,
settingsCapabilities: { infrastructureRead: false },
});
expect(shouldHideSettingsNavItem('infrastructure-systems', context)).toBe(true);
expect(shouldBlockSettingsRouteItem('infrastructure-systems', context)).toBe(true);
});
it('keeps the nav item and route for a session that holds the capability', () => {
const context = createContext({
settingsCapabilitiesResolved: true,
settingsCapabilities: { infrastructureRead: true },
});
expect(shouldHideSettingsNavItem('infrastructure-systems', context)).toBe(false);
expect(shouldBlockSettingsRouteItem('infrastructure-systems', context)).toBe(false);
});
it('does not hide the page before the security status resolves', () => {
// Hiding on an unresolved status would flash the default settings tab away
// from an admin on every load.
const context = createContext({
settingsCapabilitiesResolved: false,
settingsCapabilities: null,
});
expect(shouldHideSettingsNavItem('infrastructure-systems', context)).toBe(false);
expect(shouldBlockSettingsRouteItem('infrastructure-systems', context)).toBe(false);
});
});
@@ -338,6 +338,23 @@ describe('settings architecture guardrails', () => {
);
});
it('gates the Infrastructure nav item on the served infrastructureRead capability', () => {
// Unlike system-relay, Infrastructure is not a paid-feature teaser: every
// endpoint behind it is RequireAdmin, so a session without settings:read
// gets an all-empty page whose 15s and 30s pollers log a warn-level denial
// on every tick. Hiding the item is what stops the pollers mounting.
const infrastructureNavBlock = settingsNavCatalogSource.match(
/id: 'infrastructure-systems',[\s\S]*?requiredCapability: 'infrastructureRead',\n\s*},/,
);
expect(infrastructureNavBlock?.[0]).toBeTruthy();
// DEFAULT_SETTINGS_TAB points at the tab we just made blockable, so the
// fallback must resolve a reachable tab rather than the hardcoded default,
// or a non-admin lands back on the blocked route.
expect(settingsAccessSource).toContain('const fallbackTab = flatTabs()[0]?.id');
expect(settingsAccessSource).toContain('setActiveTab(fallbackTab)');
});
it('keeps the external-agent (MCP) connector setup findable from sidebar search', () => {
// The Assistant page hosts the pulse-mcp connector setup, but its label and
// copy can't carry every term users search for. The nav item's search-only
@@ -55,6 +55,7 @@ const mountHook = () => {
on: () => () => {},
},
nodes,
canReadInfrastructure: () => true,
discoveryEnabled,
setDiscoveryEnabled,
discoverySubnet,
@@ -1,4 +1,5 @@
import { renderHook, waitFor } from '@solidjs/testing-library';
import { createSignal } from 'solid-js';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { TrueNASAPI } from '@/api/truenas';
import { notificationStore } from '@/stores/notifications';
@@ -58,6 +59,30 @@ describe('useTrueNASSettingsPanelState', () => {
vi.clearAllMocks();
});
// /api/truenas/connections is RequireAdmin, and Settings constructs this hook
// for every settings tab, so a non-admin session used to 403 on any settings
// page it opened.
it('skips the connection load for a session without the infrastructure capability', async () => {
const { result } = renderHook(() => useTrueNASSettingsPanelState({ canLoad: () => false }));
await waitFor(() => expect(result.loading()).toBe(false));
expect(TrueNASAPI.listConnections).not.toHaveBeenCalled();
expect(result.connections()).toEqual([]);
});
// The capability resolves asynchronously, so sampling it once at mount would
// withhold the load from admins too.
it('loads once the infrastructure capability resolves', async () => {
vi.mocked(TrueNASAPI.listConnections).mockResolvedValue([]);
const [canLoad, setCanLoad] = createSignal(false);
renderHook(() => useTrueNASSettingsPanelState({ canLoad }));
expect(TrueNASAPI.listConnections).not.toHaveBeenCalled();
setCanLoad(true);
await waitFor(() => expect(TrueNASAPI.listConnections).toHaveBeenCalledTimes(1));
});
it('treats a 404 list response as a feature-disabled integration state', async () => {
vi.mocked(TrueNASAPI.listConnections).mockRejectedValueOnce({
status: 404,
@@ -1,4 +1,5 @@
import { renderHook, waitFor } from '@solidjs/testing-library';
import { createSignal } from 'solid-js';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { VMwareAPI } from '@/api/vmware';
import { notificationStore } from '@/stores/notifications';
@@ -58,6 +59,30 @@ describe('useVMwareSettingsPanelState', () => {
vi.clearAllMocks();
});
// /api/vmware/connections is RequireAdmin, and Settings constructs this hook
// for every settings tab, so a non-admin session used to 403 on any settings
// page it opened.
it('skips the connection load for a session without the infrastructure capability', async () => {
const { result } = renderHook(() => useVMwareSettingsPanelState({ canLoad: () => false }));
await waitFor(() => expect(result.loading()).toBe(false));
expect(VMwareAPI.listConnections).not.toHaveBeenCalled();
expect(result.connections()).toEqual([]);
});
// The capability resolves asynchronously, so sampling it once at mount would
// withhold the load from admins too.
it('loads once the infrastructure capability resolves', async () => {
vi.mocked(VMwareAPI.listConnections).mockResolvedValue([]);
const [canLoad, setCanLoad] = createSignal(false);
renderHook(() => useVMwareSettingsPanelState({ canLoad }));
expect(VMwareAPI.listConnections).not.toHaveBeenCalled();
setCanLoad(true);
await waitFor(() => expect(VMwareAPI.listConnections).toHaveBeenCalledTimes(1));
});
it('treats a 404 list response as a feature-disabled integration state', async () => {
vi.mocked(VMwareAPI.listConnections).mockRejectedValueOnce({
status: 404,
@@ -38,6 +38,12 @@ export const SETTINGS_NAV_GROUPS: SettingsNavGroup[] = [
label: 'Infrastructure',
icon: Server,
iconProps: { strokeWidth: 2 },
// Every data source on this page is RequireAdmin + settings:read, and
// the page mounts 15s/30s pollers against two of them. Without the
// gate a non-admin session renders an all-empty page that reprints a
// "Non-admin user attempted to access admin endpoint" warn line
// roughly every four seconds.
requiredCapability: 'infrastructureRead',
},
],
},
@@ -37,6 +37,10 @@ type RawDiscoveredServer = {
interface UseInfrastructureDiscoveryRuntimeStateParams {
eventBus: InfrastructureEventBus;
nodes: Accessor<NodeConfigWithStatus[]>;
// /api/discover is RequireAdmin + settings:write. Settings.tsx mounts this
// hook for every settings tab, so without the gate a non-admin session polls
// an endpoint it can never reach for as long as Settings stays open.
canReadInfrastructure: Accessor<boolean>;
discoveryEnabled: Accessor<boolean>;
setDiscoveryEnabled: Setter<boolean>;
discoverySubnet: Accessor<string>;
@@ -59,6 +63,7 @@ interface UseInfrastructureDiscoveryRuntimeStateParams {
export const useInfrastructureDiscoveryRuntimeState = ({
eventBus,
nodes,
canReadInfrastructure,
discoveryEnabled,
setDiscoveryEnabled,
discoverySubnet,
@@ -167,6 +172,9 @@ export const useInfrastructureDiscoveryRuntimeState = ({
};
const loadDiscoveredNodes = async () => {
if (!canReadInfrastructure()) {
return;
}
try {
const { apiFetch } = await import('@/utils/apiClient');
const response = await apiFetch('/api/discover');
@@ -531,6 +539,12 @@ export const useInfrastructureDiscoveryRuntimeState = ({
discoveryInterval = undefined;
}
// Reading the accessor here makes the effect re-run once the security
// status resolves, so an admin still gets the poller armed on load.
if (!canReadInfrastructure()) {
return;
}
discoveryInterval = setInterval(() => {
void loadDiscoveredNodes();
}, 30000);
@@ -12,6 +12,11 @@ type InfrastructureEventBus = {
interface UseInfrastructureSettingsStateParams {
eventBus: InfrastructureEventBus;
// Every endpoint this hook reads is RequireAdmin, but Settings.tsx mounts it
// for every settings tab. Without the gate a non-admin session fires the
// whole bootstrap (nodes, discovery, system settings, TrueNAS, VMware) on any
// settings page and then keeps polling discovery.
canReadInfrastructure: Accessor<boolean>;
discoveryEnabled: Accessor<boolean>;
setDiscoveryEnabled: Setter<boolean>;
discoverySubnet: Accessor<string>;
@@ -38,6 +43,7 @@ interface UseInfrastructureSettingsStateParams {
export function useInfrastructureSettingsState({
eventBus,
canReadInfrastructure,
discoveryEnabled,
setDiscoveryEnabled,
discoverySubnet,
@@ -68,12 +74,13 @@ export function useInfrastructureSettingsState({
savingTemperatureSetting,
setSavingTemperatureSetting,
});
const trueNASSettings = useTrueNASSettingsPanelState();
const vmwareSettings = useVMwareSettingsPanelState();
const trueNASSettings = useTrueNASSettingsPanelState({ canLoad: canReadInfrastructure });
const vmwareSettings = useVMwareSettingsPanelState({ canLoad: canReadInfrastructure });
const discoveryRuntime = useInfrastructureDiscoveryRuntimeState({
eventBus,
nodes: configuredNodes.nodes,
canReadInfrastructure,
discoveryEnabled,
setDiscoveryEnabled,
discoverySubnet,
@@ -132,7 +139,12 @@ export function useInfrastructureSettingsState({
onMount(async () => {
try {
// loadSecurityStatus is what resolves canReadInfrastructure, so it always
// runs; /api/security/status is readable by any session.
await loadSecurityStatus();
if (!canReadInfrastructure()) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 50));
await configuredNodes.loadNodes();
await new Promise((resolve) => setTimeout(resolve, 50));
@@ -182,7 +182,12 @@ export function useSettingsAccess({
if (currentRouteStillAllowed) {
return;
}
setActiveTab(DEFAULT_SETTINGS_TAB);
// The default tab is itself gated now (Infrastructure needs
// settings:read), so falling back to it unconditionally would strand a
// non-admin session on a blocked tab. Prefer the first tab this session
// can actually reach.
const fallbackTab = flatTabs()[0]?.id ?? DEFAULT_SETTINGS_TAB;
setActiveTab(fallbackTab);
}
});
@@ -1,4 +1,4 @@
import { createMemo, createSignal, onMount } from 'solid-js';
import { createEffect, createMemo, createSignal } from 'solid-js';
import { notificationStore } from '@/stores/notifications';
import { logger } from '@/utils/logger';
import type { MonitoredSystemLedgerPreviewResponse } from '@/api/monitoredSystemLedger';
@@ -177,7 +177,11 @@ const buildConnectionInput = (form: TrueNASConnectionFormState): TrueNASConnecti
return input;
};
export function useTrueNASSettingsPanelState() {
// `canLoad` gates the mount fetch: /api/truenas/connections is RequireAdmin and
// this hook is constructed for every settings tab, so a non-admin session would
// otherwise 403 on any settings page. Defaults to enabled for callers (tests,
// stories) that have no capability to hand it.
export function useTrueNASSettingsPanelState({ canLoad }: { canLoad?: () => boolean } = {}) {
const [connections, setConnections] = createSignal<TrueNASConnection[]>([]);
const [loading, setLoading] = createSignal(true);
const [loadingError, setLoadingError] = createSignal<string | null>(null);
@@ -235,7 +239,17 @@ export function useTrueNASSettingsPanelState() {
}
};
onMount(() => {
// The capability resolves asynchronously (Settings awaits /api/security/status
// after mount), so this waits for it rather than reading it once at mount —
// an onMount check would see `false` for admins too and never load.
let connectionsRequested = false;
createEffect(() => {
if (connectionsRequested) return;
if (canLoad && !canLoad()) {
setLoading(false);
return;
}
connectionsRequested = true;
void loadConnections();
});
@@ -1,4 +1,4 @@
import { createMemo, createSignal, onMount } from 'solid-js';
import { createEffect, createMemo, createSignal } from 'solid-js';
import type { MonitoredSystemLedgerPreviewResponse } from '@/api/monitoredSystemLedger';
import {
VMwareAPI,
@@ -146,7 +146,10 @@ const buildConnectionInput = (form: VMwareConnectionFormState): VMwareConnection
};
};
export function useVMwareSettingsPanelState() {
// See useTrueNASSettingsPanelState: /api/vmware/connections is RequireAdmin and
// this hook is constructed for every settings tab, so the mount fetch has to be
// gated on the session's infrastructure capability.
export function useVMwareSettingsPanelState({ canLoad }: { canLoad?: () => boolean } = {}) {
const [connections, setConnections] = createSignal<VMwareConnection[]>([]);
const [loading, setLoading] = createSignal(true);
const [loadingError, setLoadingError] = createSignal<string | null>(null);
@@ -208,7 +211,16 @@ export function useVMwareSettingsPanelState() {
}
};
onMount(() => {
// See useTrueNASSettingsPanelState: wait for the capability to resolve rather
// than sampling it once at mount.
let connectionsRequested = false;
createEffect(() => {
if (connectionsRequested) return;
if (canLoad && !canLoad()) {
setLoading(false);
return;
}
connectionsRequested = true;
void loadConnections();
});
+1
View File
@@ -96,6 +96,7 @@ export interface NodesConfig {
* API response for security status
*/
export interface SecurityStatusSettingsCapabilities {
infrastructureRead: boolean;
apiAccessRead: boolean;
apiAccessWrite: boolean;
authenticationRead: boolean;
+56
View File
@@ -10939,6 +10939,62 @@ func TestContract_BusinessScaleEstateThresholds(t *testing.T) {
}
}
// Settings → Infrastructure reads only RequireAdmin + settings:read endpoints
// and then polls two of them, so the served capability has to track the scope
// exactly. A capability that over-reports puts a non-admin back on a page whose
// every request is refused and logged at warn level; one that under-reports
// hides a page the caller can use.
func TestContract_SecurityStatusInfrastructureReadTracksSettingsReadScope(t *testing.T) {
cases := []struct {
name string
scopes []string
want bool
}{
{"settings read grants the infrastructure surface", []string{config.ScopeSettingsRead}, true},
{"monitoring read alone withholds it", []string{config.ScopeMonitoringRead}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rawToken := fmt.Sprintf("contract-infra-scope-%s.12345678", strings.ReplaceAll(tc.name, " ", "-"))
record := newTokenRecord(t, rawToken, tc.scopes, nil)
cfg := newTestConfigWithTokens(t, record)
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
req := httptest.NewRequest(http.MethodGet, "/api/security/status", nil)
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("security status = %d, want 200 (%s)", rec.Code, rec.Body.String())
}
var payload struct {
SettingsCapabilities securityStatusSettingsCapabilities `json:"settingsCapabilities"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode security status payload: %v", err)
}
if payload.SettingsCapabilities.InfrastructureRead != tc.want {
t.Fatalf("settingsCapabilities.infrastructureRead = %v, want %v",
payload.SettingsCapabilities.InfrastructureRead, tc.want)
}
// The capability is only honest if the routes behind the page agree.
for _, path := range []string{"/api/config/nodes", "/api/system/settings"} {
probe := httptest.NewRequest(http.MethodGet, path, nil)
probe.Header.Set("X-API-Token", rawToken)
probeRec := httptest.NewRecorder()
router.Handler().ServeHTTP(probeRec, probe)
refused := probeRec.Code == http.StatusForbidden
if refused == tc.want {
t.Fatalf("GET %s = %d, which contradicts infrastructureRead=%v", path, probeRec.Code, tc.want)
}
}
})
}
}
func TestContract_SecurityStatusSplitsAuditLogCapabilityFromSettingsRead(t *testing.T) {
prevAuthorizer := authpkg.GetAuthorizer()
authpkg.SetAuthorizer(&allowRulesAuthorizer{
@@ -12,6 +12,14 @@ import (
)
type securityStatusSettingsCapabilities struct {
// InfrastructureRead mirrors the RequireAdmin + settings:read gate that
// every data source behind Settings → Infrastructure enforces
// (/api/connections, /api/config/nodes, /api/system/settings,
// /api/truenas/connections, /api/vmware/connections, and /api/discover,
// which needs settings:write on top). Serving it lets the nav hide a page
// that would otherwise mount 15s and 30s pollers whose every request is
// refused and logged at warn level.
InfrastructureRead bool `json:"infrastructureRead"`
APIAccessRead bool `json:"apiAccessRead"`
APIAccessWrite bool `json:"apiAccessWrite"`
AuthenticationRead bool `json:"authenticationRead"`
@@ -247,6 +255,7 @@ func (r *Router) securityStatusSettingsCapabilitiesFromSnapshot(snapshot securit
canManageRoles := snapshot.passesPrivilegedSessionGate() && canManageUsers
return securityStatusSettingsCapabilities{
InfrastructureRead: canReadSettings,
APIAccessRead: r.canAccessPermissionSurface(snapshot, internalauth.ActionAdmin, internalauth.ResourceUsers, config.ScopeSettingsRead),
APIAccessWrite: r.canAccessPermissionSurface(snapshot, internalauth.ActionAdmin, internalauth.ResourceUsers, config.ScopeSettingsWrite),
AuthenticationRead: canReadSettings,
@@ -76,6 +76,7 @@ func TestSettingsCapabilitiesMatchRouteEnforcementWithoutRBAC(t *testing.T) {
"singleSignOnWrite",
"authenticationRead",
"authenticationWrite",
"infrastructureRead",
} {
if caps[key] != false {
t.Fatalf("%s = %v for a non-admin session, want false", key, caps[key])
@@ -87,6 +88,10 @@ func TestSettingsCapabilitiesMatchRouteEnforcementWithoutRBAC(t *testing.T) {
for _, probe := range []struct{ method, path string }{
{http.MethodGet, "/api/security/tokens"},
{http.MethodGet, "/api/security/sso/providers"},
// Settings → Infrastructure reads these on mount and then polls two of
// them, so a withheld infrastructureRead has to line up with a refusal.
{http.MethodGet, "/api/config/nodes"},
{http.MethodGet, "/api/system/settings"},
} {
req := httptest.NewRequest(probe.method, probe.path, nil)
req.AddCookie(capabilitySessionCookie(t, "sso:outsider@example.com"))
@@ -109,7 +114,7 @@ func TestSettingsCapabilitiesGrantConfiguredAdminWithoutRBAC(t *testing.T) {
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
caps := fetchSettingsCapabilities(t, router, capabilitySessionCookie(t, "admin"))
for _, key := range []string{"apiAccessRead", "apiAccessWrite", "singleSignOnRead", "singleSignOnWrite"} {
for _, key := range []string{"apiAccessRead", "apiAccessWrite", "singleSignOnRead", "singleSignOnWrite", "infrastructureRead"} {
if caps[key] != true {
t.Fatalf("%s = %v for the configured admin, want true", key, caps[key])
}