mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 02:55:51 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
+20
@@ -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\(/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+64
@@ -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
|
||||
|
||||
+1
@@ -55,6 +55,7 @@ const mountHook = () => {
|
||||
on: () => () => {},
|
||||
},
|
||||
nodes,
|
||||
canReadInfrastructure: () => true,
|
||||
discoveryEnabled,
|
||||
setDiscoveryEnabled,
|
||||
discoverySubnet,
|
||||
|
||||
+25
@@ -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,
|
||||
|
||||
+25
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -96,6 +96,7 @@ export interface NodesConfig {
|
||||
* API response for security status
|
||||
*/
|
||||
export interface SecurityStatusSettingsCapabilities {
|
||||
infrastructureRead: boolean;
|
||||
apiAccessRead: boolean;
|
||||
apiAccessWrite: boolean;
|
||||
authenticationRead: boolean;
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user