Skip to content

Commit 5dfb7ac

Browse files
authored
Merge pull request #1105 from jmpsec/nav-osctrld-gate
Hide osctrld-only UI when osctrld is not enabled
2 parents 66d6e7d + 8b10621 commit 5dfb7ac

19 files changed

Lines changed: 414 additions & 31 deletions

‎cmd/api/handlers/features.go‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ type FeaturesResponse struct {
3434
// --vuln-enabled — when false the /api/v1/vulnerabilities routes are
3535
// not registered and the vulnerability tables are not created.
3636
Vulnerabilities bool `json:"vulnerabilities"`
37+
// Osctrld is true when osctrl-tls runs with --enable-osctrld. Read from
38+
// the service config row osctrl-tls maintains in the shared database.
39+
Osctrld bool `json:"osctrld"`
3740
}
3841

3942
// FeaturesHandler — GET /api/v1/features.
@@ -61,6 +64,7 @@ func (h *HandlersApi) FeaturesHandler(w http.ResponseWriter, r *http.Request) {
6164
FileExplorer: h.OsqueryValues.Query && h.OsqueryValues.FileExplorer,
6265
Health: h.Health != nil,
6366
Vulnerabilities: h.Vulns != nil,
67+
Osctrld: h.ServiceConfig.OsctrldEnabled(),
6468
})
6569
}
6670

‎cmd/api/handlers/features_test.go‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ import (
99
"github.com/jmpsec/osctrl/pkg/alerts"
1010
"github.com/jmpsec/osctrl/pkg/config"
1111
"github.com/jmpsec/osctrl/pkg/servicecommands"
12+
"github.com/jmpsec/osctrl/pkg/serviceconfig"
13+
"gorm.io/driver/sqlite"
14+
"gorm.io/gorm"
1215
)
1316

1417
func TestFeaturesHandlerReportsPostureDisabledByDefault(t *testing.T) {
@@ -270,3 +273,35 @@ func containsFeatureTopic(topics []string, want string) bool {
270273
}
271274
return false
272275
}
276+
277+
func TestFeaturesHandlerReportsOsctrld(t *testing.T) {
278+
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory"), &gorm.Config{})
279+
if err != nil {
280+
t.Fatal(err)
281+
}
282+
mgr := serviceconfig.NewServiceConfigManager(db)
283+
for _, enabled := range []bool{false, true} {
284+
p := &config.ServiceParameters{Osctrld: &config.YAMLConfigurationOsctrld{Enabled: enabled}}
285+
if err := mgr.Seed(config.ServiceTLS, p, serviceconfig.NoEnvironmentID); err != nil {
286+
t.Fatal(err)
287+
}
288+
h := &HandlersApi{ServiceConfig: mgr}
289+
w := httptest.NewRecorder()
290+
h.FeaturesHandler(w, httptest.NewRequest(http.MethodGet, "/api/v1/features", nil))
291+
var raw map[string]any
292+
if err := json.Unmarshal(w.Body.Bytes(), &raw); err != nil {
293+
t.Fatal(err)
294+
}
295+
if got, ok := raw["osctrld"]; !ok || got != enabled {
296+
t.Fatalf("osctrld: got %v (present=%v) want %v", got, ok, enabled)
297+
}
298+
}
299+
// No manager: key still present, false.
300+
w := httptest.NewRecorder()
301+
(&HandlersApi{}).FeaturesHandler(w, httptest.NewRequest(http.MethodGet, "/api/v1/features", nil))
302+
var raw map[string]any
303+
_ = json.Unmarshal(w.Body.Bytes(), &raw)
304+
if got, ok := raw["osctrld"]; !ok || got != false {
305+
t.Fatalf("nil manager: got %v present=%v", got, ok)
306+
}
307+
}

‎frontend/src/api/features.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ export interface Features {
1717
* /api/v1/vulnerabilities routes are absent and the SPA hides every
1818
* vulnerability surface. */
1919
vulnerabilities?: boolean;
20+
/** osctrl-tls runs with --enable-osctrld. Absent on older APIs, which
21+
* the SPA treats as disabled. Gates Extensions and Osquery versions. */
22+
osctrld?: boolean;
2023
accelerated: boolean;
2124
console?: boolean;
2225
file_explorer: boolean;

‎frontend/src/components/chrome/SideNav.test.tsx‎

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ describe('SideNav extensions entry', () => {
8484
});
8585

8686
it('is shown to super admins', async () => {
87-
mockGetFeatures.mockResolvedValue(baseFeatures);
87+
mockGetFeatures.mockResolvedValue({ ...baseFeatures, osctrld: true });
8888
mockGetMe.mockResolvedValue({ admin: true, permissions: {} });
8989
renderNav();
9090
const link = await screen.findByRole('link', { name: 'Extensions' });
@@ -106,7 +106,7 @@ describe('SideNav osquery versions entry', () => {
106106
});
107107

108108
it('is shown to super admins', async () => {
109-
mockGetFeatures.mockResolvedValue(baseFeatures);
109+
mockGetFeatures.mockResolvedValue({ ...baseFeatures, osctrld: true });
110110
mockGetMe.mockResolvedValue({ admin: true, permissions: {} });
111111
renderNav();
112112
const link = await screen.findByRole('link', { name: 'Osquery versions' });
@@ -121,3 +121,36 @@ describe('SideNav osquery versions entry', () => {
121121
expect(screen.queryByRole('link', { name: 'Osquery versions' })).not.toBeInTheDocument();
122122
});
123123
});
124+
125+
describe('SideNav osctrld-gated admin entries', () => {
126+
beforeEach(() => {
127+
vi.clearAllMocks();
128+
});
129+
const names = ['Extensions', 'Osquery versions'];
130+
const nonAdmin = { admin: false, permissions: { 'env-uuid': { user: true, query: false, carve: false, admin: true } } };
131+
132+
it('shows both to super admins when osctrld is enabled', async () => {
133+
mockGetFeatures.mockResolvedValue({ ...baseFeatures, osctrld: true });
134+
mockGetMe.mockResolvedValue({ admin: true, permissions: {} });
135+
renderNav();
136+
await settle();
137+
for (const n of names) expect(await screen.findByRole('link', { name: n })).toBeInTheDocument();
138+
});
139+
140+
it.each([['false', { osctrld: false }], ['absent', {}]])('hides both when osctrld is %s', async (_l, extra) => {
141+
mockGetFeatures.mockResolvedValue({ ...baseFeatures, service_config: true, ...extra });
142+
mockGetMe.mockResolvedValue({ admin: true, permissions: {} });
143+
renderNav();
144+
await settle();
145+
for (const n of names) expect(screen.queryByRole('link', { name: n })).not.toBeInTheDocument();
146+
expect(screen.getByRole('link', { name: 'Service Config' })).toBeInTheDocument();
147+
});
148+
149+
it('never shows them to non-super-admins', async () => {
150+
mockGetFeatures.mockResolvedValue({ ...baseFeatures, osctrld: true });
151+
mockGetMe.mockResolvedValue(nonAdmin);
152+
renderNav();
153+
await settle();
154+
for (const n of names) expect(screen.queryByRole('link', { name: n })).not.toBeInTheDocument();
155+
});
156+
});

‎frontend/src/components/chrome/SideNav.tsx‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { EnvSwitcher } from './EnvSwitcher';
3131
import { listEnvironments } from '$/api/environments';
3232
import { getMe } from '$/api/users';
3333
import { getFeatures } from '$/api/features';
34+
import { useOsctrldEnabled } from '$/lib/useOsctrldEnabled';
3435
import type { EnvAccess } from '$/api/types';
3536
import { readActiveEnvironment, writeActiveEnvironment } from '$/lib/environment-scope';
3637
import {
@@ -204,6 +205,7 @@ export function SideNav({ className, collapsed, previewsEnabled = true }: SideNa
204205
// Service Config is opt-in server-side (service.serviceConfigEnabled).
205206
// When it is off the endpoints do not exist, so hide the entry rather
206207
// than link to a page that can only fail.
208+
const { enabled: osctrldEnabled } = useOsctrldEnabled();
207209
const { data: features } = useQuery({
208210
queryKey: ['features'],
209211
queryFn: () => getFeatures(),
@@ -594,24 +596,24 @@ export function SideNav({ className, collapsed, previewsEnabled = true }: SideNa
594596
>
595597
{t('nav.logSinks')}
596598
</NavItem>}
597-
<NavItem
599+
{osctrldEnabled && <NavItem
598600
collapsed={collapsed}
599601
active={isExtensionsActive}
600602
to="/_app/extensions"
601603
tone="violet"
602604
icon={<Puzzle size={14} strokeWidth={1.8} />}
603605
>
604606
{t('nav.extensions')}
605-
</NavItem>
606-
<NavItem
607+
</NavItem>}
608+
{osctrldEnabled && <NavItem
607609
collapsed={collapsed}
608610
active={isOsqueryVersionsActive}
609611
to="/_app/osquery-versions"
610612
tone="violet"
611613
icon={<PackageCheck size={14} strokeWidth={1.8} />}
612614
>
613615
Osquery versions
614-
</NavItem>
616+
</NavItem>}
615617
{features?.alerts && <NavItem
616618
collapsed={collapsed}
617619
active={isAlertsActive}

‎frontend/src/features/dashboard/DashboardPage.test.tsx‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,13 @@ describe('DashboardPage', () => {
264264
expect(screen.getByText(/prod · osquery activity within the last 24 hours/)).toBeInTheDocument();
265265
});
266266

267+
// Derived from node data, independent of osctrld: it must render without any osctrld feature flag.
268+
it('still shows the osquery agent versions panel (not osctrld-gated)', async () => {
269+
mockGetStats.mockResolvedValue(makeStatsResponse());
270+
renderWithProviders(makeTestRouter());
271+
expect(await screen.findByRole('heading', { name: 'osquery agent versions' })).toBeInTheDocument();
272+
});
273+
267274
it('renders KPI card labels from the stats response', async () => {
268275
mockGetStats.mockResolvedValue(makeStatsResponse());
269276
renderWithProviders(makeTestRouter());

‎frontend/src/features/environments/EnvConfigPage.test.tsx‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ describe('EnvConfigPage', () => {
207207
});
208208
mockGetPostureProfiles.mockResolvedValue([]);
209209
mockGetVulnProfiles.mockResolvedValue([]);
210-
mockGetFeatures.mockResolvedValue({ posture: false, service_config: false, accelerated: false, file_explorer: false });
210+
mockGetFeatures.mockResolvedValue({ posture: false, service_config: false, accelerated: false, file_explorer: false, osctrld: true });
211211
});
212212

213213
it('inherits the global value, saves an override, and resets to inheritance', async () => {
@@ -552,6 +552,26 @@ describe('EnvConfigPage', () => {
552552
expect(mockGetEnvExtensions).not.toHaveBeenCalled();
553553
});
554554

555+
it.each([['false', { osctrld: false }], ['absent', {}]])('hides the Extensions and Osquery tabs when osctrld is %s', async (_l, extra) => {
556+
mockGetFeatures.mockResolvedValue({ posture: false, service_config: false, accelerated: false, file_explorer: false, ...extra });
557+
renderWithProviders();
558+
await screen.findByRole('tab', { name: 'Settings' });
559+
await waitFor(() => expect(mockGetFeatures).toHaveBeenCalled());
560+
expect(screen.queryByRole('tab', { name: 'Extensions' })).not.toBeInTheDocument();
561+
expect(screen.queryByRole('tab', { name: 'Osquery' })).not.toBeInTheDocument();
562+
// The environment's own osquery options/flags configuration is not osctrld-dependent.
563+
expect(screen.getByRole('tab', { name: 'Options' })).toBeInTheDocument();
564+
expect(mockGetEnvExtensions).not.toHaveBeenCalled();
565+
});
566+
567+
it('hides the Extensions and Osquery tabs while features load', async () => {
568+
mockGetFeatures.mockReturnValue(new Promise(() => {}));
569+
renderWithProviders();
570+
await screen.findByRole('tab', { name: 'Settings' });
571+
expect(screen.queryByRole('tab', { name: 'Extensions' })).not.toBeInTheDocument();
572+
expect(screen.queryByRole('tab', { name: 'Osquery' })).not.toBeInTheDocument();
573+
});
574+
555575
it('hides the posture tab when posture is disabled', async () => {
556576
renderWithProviders();
557577

‎frontend/src/features/environments/EnvConfigPage.tsx‎

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
import { AuthError, ApiError } from '$/api/client';
1818
import { getPostureProfiles } from '$/api/nodes';
1919
import { getFeatures } from '$/api/features';
20+
import { useOsctrldEnabled } from '$/lib/useOsctrldEnabled';
2021
import type { PostureProfile } from '$/api/types';
2122
import { EnvExtensionsTab } from './EnvExtensionsTab';
2223
import { EnvOsqueryTab } from './EnvOsqueryTab';
@@ -147,6 +148,11 @@ export function EnvConfigPage() {
147148
queryFn: () => getFeatures(),
148149
staleTime: 5 * 60_000,
149150
});
151+
const { enabled: osctrldEnabled } = useOsctrldEnabled();
152+
// A selected osctrld tab falls back to the default once osctrld is off.
153+
useEffect(() => {
154+
if (!osctrldEnabled && (activeTab === 'extensions' || activeTab === 'osquery')) setActiveTab('settings');
155+
}, [osctrldEnabled, activeTab]);
150156
const postureEnabled = featuresQuery.data?.posture === true;
151157
const vulnsEnabled = featuresQuery.data?.vulnerabilities === true;
152158
const postureProfilesQuery = useQuery({
@@ -385,18 +391,22 @@ export function EnvConfigPage() {
385391
onClick={() => setActiveTab('posture')}
386392
/>
387393
)}
388-
<TabButton
389-
id="extensions"
390-
label="Extensions"
391-
active={activeTab === 'extensions'}
392-
onClick={() => setActiveTab('extensions')}
393-
/>
394-
<TabButton
395-
id="osquery"
396-
label="Osquery"
397-
active={activeTab === 'osquery'}
398-
onClick={() => setActiveTab('osquery')}
399-
/>
394+
{osctrldEnabled && (
395+
<>
396+
<TabButton
397+
id="extensions"
398+
label="Extensions"
399+
active={activeTab === 'extensions'}
400+
onClick={() => setActiveTab('extensions')}
401+
/>
402+
<TabButton
403+
id="osquery"
404+
label="Osquery"
405+
active={activeTab === 'osquery'}
406+
onClick={() => setActiveTab('osquery')}
407+
/>
408+
</>
409+
)}
400410
<TabButton
401411
id="assembled"
402412
label={t('envConfig.fullConfiguration')}
@@ -438,8 +448,8 @@ export function EnvConfigPage() {
438448
<AssembledConfigCard env={env} />
439449
)}
440450

441-
{activeTab === 'extensions' && <EnvExtensionsTab env={env} />}
442-
{activeTab === 'osquery' && <EnvOsqueryTab env={env} />}
451+
{osctrldEnabled && activeTab === 'extensions' && <EnvExtensionsTab env={env} />}
452+
{osctrldEnabled && activeTab === 'osquery' && <EnvOsqueryTab env={env} />}
443453

444454
{activeTab === 'posture' && postureEnabled && (
445455
<PostureScheduleEditor

‎frontend/src/features/nodes/NodeConfigTab.test.tsx‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { Features } from '$/api/features';
12
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
23
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
34
import userEvent from '@testing-library/user-event';
@@ -20,6 +21,8 @@ vi.mock('$/api/nodes', () => ({
2021
getNodeFlags: api.getNodeFlags,
2122
}));
2223

24+
vi.mock('$/api/features', () => ({ getFeatures: () => mockFeatures() }));
25+
2326
vi.mock('$/api/extensions', () => ({
2427
getNodeExtensions: api.getNodeExtensions,
2528
}));
@@ -46,6 +49,8 @@ vi.mock('$/components/forms/CodeEditor', () => ({
4649
),
4750
}));
4851

52+
const baseFeatures: Features = { posture: false, service_config: false, accelerated: false, file_explorer: false };
53+
const mockFeatures = vi.fn<() => Promise<Features>>();
4954
const ENV_CFG = '{"options":{"a":"1"}}';
5055
const APPLIED_CFG = '{"options":{"a":"2"}}';
5156

@@ -83,6 +88,7 @@ describe('NodeConfigTab', () => {
8388
api.getNodeConfig.mockResolvedValue(makeConfig());
8489
api.putNodeConfig.mockResolvedValue(makeConfig({ override: '{"x":1}' }));
8590
api.deleteNodeConfig.mockResolvedValue(makeConfig());
91+
mockFeatures.mockResolvedValue({ ...baseFeatures, osctrld: true });
8692
});
8793

8894
it('shows the applied configuration read-only by default', async () => {
@@ -259,4 +265,24 @@ describe('NodeConfigTab', () => {
259265
expect(await screen.findByText('No extensions apply to this host.')).toBeInTheDocument();
260266
expect(api.getNodeExtensions).toHaveBeenCalledWith('prod', 'uuid-1');
261267
});
268+
it.each([['false', { osctrld: false }], ['absent', {}]])('hides Flags, Extensions and Osquery version when osctrld is %s', async (_l, extra) => {
269+
mockFeatures.mockResolvedValue({ ...baseFeatures, ...extra });
270+
renderTab();
271+
expect(await screen.findByLabelText('Applied configuration')).toBeInTheDocument();
272+
await waitFor(() => expect(mockFeatures).toHaveBeenCalled());
273+
for (const n of ['Flags', 'Extensions', 'Osquery version', 'Configuration']) {
274+
expect(screen.queryByRole('button', { name: n })).not.toBeInTheDocument();
275+
}
276+
expect(screen.getByRole('button', { name: 'Edit override' })).toBeInTheDocument();
277+
expect(api.getNodeFlags).not.toHaveBeenCalled();
278+
expect(api.getNodeExtensions).not.toHaveBeenCalled();
279+
});
280+
281+
it('shows no kind switcher while features load', async () => {
282+
mockFeatures.mockReturnValue(new Promise(() => {}));
283+
renderTab();
284+
expect(await screen.findByLabelText('Applied configuration')).toBeInTheDocument();
285+
expect(screen.queryByRole('group', { name: 'Override type' })).not.toBeInTheDocument();
286+
expect(api.getNodeFlags).not.toHaveBeenCalled();
287+
});
262288
});

‎frontend/src/features/nodes/NodeConfigTab.tsx‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { DiffView } from '$/components/forms/DiffView';
99
import { NodeExtensionsView } from './NodeExtensionsView';
1010
import { NodeOsqueryView } from './NodeOsqueryView';
1111
import { NodeFlagsView } from './NodeFlagsView';
12+
import { useOsctrldEnabled } from '$/lib/useOsctrldEnabled';
1213
import { plainBtn, toLocalInput, VIEWS, warnBtn, type View } from './overrideShared';
1314

1415
const EDITOR_LABELS: Record<Exclude<View, 'diff'>, string> = {
@@ -271,10 +272,14 @@ const KINDS: { id: Kind; label: string }[] = [
271272

272273
/** Per-node overrides: osquery configuration (JSON), startup flags (flagfile) or extensions. */
273274
export function NodeConfigTab({ env, uuid, hostname }: { env: string; uuid: string; hostname: string }) {
274-
const [kind, setKind] = useState<Kind>('configuration');
275+
const [picked, setKind] = useState<Kind>('configuration');
276+
const { enabled: osctrldEnabled } = useOsctrldEnabled();
277+
// Flags, extensions and osquery version are all applied by osctrld; without
278+
// it only the plain configuration override remains, so no switcher.
279+
const kind: Kind = osctrldEnabled ? picked : 'configuration';
275280
return (
276281
<div className="space-y-3">
277-
<div role="group" aria-label="Override type" className="flex gap-1">
282+
{osctrldEnabled && <div role="group" aria-label="Override type" className="flex gap-1">
278283
{KINDS.map(({ id, label }) => (
279284
<button
280285
key={id}
@@ -286,7 +291,7 @@ export function NodeConfigTab({ env, uuid, hostname }: { env: string; uuid: stri
286291
{label}
287292
</button>
288293
))}
289-
</div>
294+
</div>}
290295
{kind === 'configuration' ? (
291296
<ConfigView env={env} uuid={uuid} hostname={hostname} />
292297
) : kind === 'flags' ? (

0 commit comments

Comments
 (0)