From dc4118b73c5d5e059f64653540be95e70aff9ef2 Mon Sep 17 00:00:00 2001
From: Brandon Geraci
Date: Tue, 2 Jun 2026 11:37:20 -0500
Subject: [PATCH] feat: system config feature flags and rate-limit exemption
admin UI
Add a SystemConfigProvider that fetches GET /api/v1/system/config and exposes
derived feature flags through useSystemConfig/useFeatureFlags. Gate the
scanner-dependent sidebar entries (Scan Results, DT Projects) on the reported
scanner flags, and surface the configured max upload size in the artifact
upload dropzone with a client-side oversize guard.
Add a Rate Limits admin page that shows the effective per-window limits and
lets admins view, add, and remove rate-limit exemptions for usernames, service
accounts, and CIDR ranges. The page degrades gracefully when the backend has
not shipped the exemption-management endpoints.
Includes unit tests for the new API clients and provider, updates to the
sidebar tests for flag-driven gating, and Playwright e2e specs covering
feature-flag gating and the exemption admin flow.
Closes #271
Closes #270
---
.../interactions/admin/rate-limits.spec.ts | 120 +++++
.../admin/system-config-feature-flags.spec.ts | 88 ++++
src/app/(app)/(admin)/rate-limits/page.tsx | 464 ++++++++++++++++++
.../_components/repo-detail-content.tsx | 3 +
src/components/common/file-upload.tsx | 27 +-
.../layout/__tests__/app-sidebar.test.tsx | 64 +++
src/components/layout/app-sidebar.tsx | 21 +-
src/lib/api/__tests__/rate-limits.test.ts | 118 +++++
src/lib/api/__tests__/system-config.test.ts | 95 ++++
src/lib/api/rate-limits.ts | 204 ++++++++
src/lib/api/system-config.ts | 154 ++++++
.../__tests__/system-config-provider.test.tsx | 106 ++++
src/providers/index.tsx | 9 +-
src/providers/system-config-provider.tsx | 98 ++++
14 files changed, 1565 insertions(+), 6 deletions(-)
create mode 100644 e2e/suites/interactions/admin/rate-limits.spec.ts
create mode 100644 e2e/suites/interactions/admin/system-config-feature-flags.spec.ts
create mode 100644 src/app/(app)/(admin)/rate-limits/page.tsx
create mode 100644 src/lib/api/__tests__/rate-limits.test.ts
create mode 100644 src/lib/api/__tests__/system-config.test.ts
create mode 100644 src/lib/api/rate-limits.ts
create mode 100644 src/lib/api/system-config.ts
create mode 100644 src/providers/__tests__/system-config-provider.test.tsx
create mode 100644 src/providers/system-config-provider.tsx
diff --git a/e2e/suites/interactions/admin/rate-limits.spec.ts b/e2e/suites/interactions/admin/rate-limits.spec.ts
new file mode 100644
index 00000000..eec8a512
--- /dev/null
+++ b/e2e/suites/interactions/admin/rate-limits.spec.ts
@@ -0,0 +1,120 @@
+import { test, expect } from '@playwright/test';
+
+/**
+ * Admin UI for rate-limit exemption management (issue #270).
+ *
+ * The page lives at /rate-limits and lets an admin view the effective rate
+ * limits and add/remove exemptions for users, service accounts, and CIDR
+ * ranges. The backend exemption-management endpoints may not be present on
+ * every server, so the page is designed to degrade: the config card and the
+ * exemptions section both render an "unavailable" state instead of crashing.
+ * These tests assert the UI surface and the add/remove flow, tolerating a
+ * backend that has not shipped the endpoints yet.
+ */
+test.describe('Rate limit admin', () => {
+ const consoleErrors: string[] = [];
+
+ test.beforeEach(async ({ page }) => {
+ consoleErrors.length = 0;
+ page.on('console', (msg) => {
+ if (msg.type() === 'error') consoleErrors.push(msg.text());
+ });
+ await page.goto('/rate-limits');
+ await page.waitForLoadState('domcontentloaded');
+ });
+
+ test('page loads with Rate Limits heading', async ({ page }) => {
+ await expect(
+ page.getByRole('heading', { name: /rate limits/i }).first()
+ ).toBeVisible({ timeout: 15000 });
+ });
+
+ test('shows the current rate limits and exemptions sections', async ({ page }) => {
+ await expect(
+ page.getByText(/current rate limits/i).first()
+ ).toBeVisible({ timeout: 10000 });
+ await expect(
+ page.getByText(/exemptions/i).first()
+ ).toBeVisible({ timeout: 10000 });
+ });
+
+ test('Add Exemption button opens a dialog with type, value, and note fields', async ({ page }) => {
+ const addBtn = page.getByRole('button', { name: /add exemption/i }).first();
+ await expect(addBtn).toBeVisible({ timeout: 10000 });
+ await addBtn.click();
+
+ const dialog = page.getByRole('dialog');
+ await expect(dialog).toBeVisible({ timeout: 10000 });
+
+ // Type selector (combobox) plus value and note inputs.
+ await expect(dialog.getByRole('combobox').first()).toBeVisible();
+ await expect(dialog.locator('#exemption-value')).toBeVisible();
+ await expect(dialog.locator('#exemption-note')).toBeVisible();
+
+ // Cancel closes the dialog without creating anything.
+ await dialog.getByRole('button', { name: /cancel/i }).click();
+ await expect(dialog).toBeHidden({ timeout: 5000 });
+ });
+
+ test('invalid CIDR is rejected client-side', async ({ page }) => {
+ await page.getByRole('button', { name: /add exemption/i }).first().click();
+ const dialog = page.getByRole('dialog');
+ await expect(dialog).toBeVisible({ timeout: 10000 });
+
+ // Switch the type to CIDR.
+ await dialog.getByRole('combobox').first().click();
+ const cidrOption = page.getByRole('option', { name: /cidr/i });
+ await cidrOption.click();
+
+ await dialog.locator('#exemption-value').fill('not-a-valid-cidr');
+ await dialog.getByRole('button', { name: /^add exemption$/i }).click();
+
+ // A validation toast appears and the dialog stays open.
+ await expect(page.getByText(/valid cidr/i).first()).toBeVisible({ timeout: 8000 });
+ await expect(dialog).toBeVisible();
+
+ await dialog.getByRole('button', { name: /cancel/i }).click();
+ });
+
+ test('add then remove a username exemption (when the backend supports it)', async ({ page }) => {
+ const username = `e2e-exempt-${Date.now()}`;
+
+ await page.getByRole('button', { name: /add exemption/i }).first().click();
+ const dialog = page.getByRole('dialog');
+ await expect(dialog).toBeVisible({ timeout: 10000 });
+
+ // Default type is username; just fill the value and submit.
+ await dialog.locator('#exemption-value').fill(username);
+ await dialog.locator('#exemption-note').fill('e2e test exemption');
+ await dialog.getByRole('button', { name: /^add exemption$/i }).click();
+
+ // Either the backend accepts it (row appears) or it has no exemption
+ // endpoint (failure toast). Both are acceptable, but a success must round
+ // trip to a removable row.
+ const newRow = page.getByRole('row', { name: new RegExp(username) });
+ const created = await newRow
+ .isVisible({ timeout: 8000 })
+ .catch(() => false);
+
+ if (!created) {
+ test.skip(true, 'Backend does not expose rate-limit exemption management');
+ return;
+ }
+
+ // Remove it and confirm in the alert dialog.
+ await newRow.getByRole('button', { name: new RegExp(`remove exemption ${username}`, 'i') }).click();
+ const confirm = page.getByRole('alertdialog');
+ await expect(confirm).toBeVisible({ timeout: 8000 });
+ await confirm.getByRole('button', { name: /^remove$/i }).click();
+
+ await expect(newRow).toHaveCount(0, { timeout: 10000 });
+ });
+
+ test('no uncaught console errors on load', async ({ page }) => {
+ await page.waitForTimeout(1500);
+ const fatal = consoleErrors.filter(
+ (e) => !/favicon|hydrat|ResizeObserver/i.test(e)
+ );
+ expect(fatal, fatal.join('\n')).toHaveLength(0);
+ });
+});
diff --git a/e2e/suites/interactions/admin/system-config-feature-flags.spec.ts b/e2e/suites/interactions/admin/system-config-feature-flags.spec.ts
new file mode 100644
index 00000000..993bf7e1
--- /dev/null
+++ b/e2e/suites/interactions/admin/system-config-feature-flags.spec.ts
@@ -0,0 +1,88 @@
+import { test, expect } from '@playwright/test';
+
+/**
+ * Feature-flag gating driven by GET /api/v1/system/config (issue #271).
+ *
+ * The web app fetches the backend's public runtime configuration once and uses
+ * it to decide which scanner-dependent surfaces to show and to advertise the
+ * upload-size limit. These tests verify the contract end to end against the
+ * running backend, then check that the UI reflects what the backend reports.
+ */
+test.describe('System config feature flags', () => {
+ test('public system config endpoint returns the expected shape', async ({ request }) => {
+ const resp = await request.get('/api/v1/system/config');
+ expect(resp.ok(), `system config request failed: ${resp.status()}`).toBeTruthy();
+
+ const body = await resp.json();
+ // Top-level fields the web app relies on.
+ expect(typeof body.max_upload_size_bytes).toBe('number');
+ expect(typeof body.demo_mode).toBe('boolean');
+ expect(typeof body.guest_access_enabled).toBe('boolean');
+ expect(typeof body.search_engine).toBe('string');
+ expect(typeof body.storage_backend).toBe('string');
+
+ // Nested scanner / auth flag groups used for navigation gating.
+ expect(body.scanners).toBeTruthy();
+ expect(typeof body.scanners.trivy_enabled).toBe('boolean');
+ expect(typeof body.scanners.openscap_enabled).toBe('boolean');
+ expect(typeof body.scanners.dependency_track_enabled).toBe('boolean');
+ expect(body.auth).toBeTruthy();
+ expect(typeof body.auth.oidc_enabled).toBe('boolean');
+ expect(typeof body.auth.sso_enabled).toBe('boolean');
+ });
+
+ test('scanner nav entries match the backend scanner flags', async ({ page, request }) => {
+ const resp = await request.get('/api/v1/system/config');
+ expect(resp.ok()).toBeTruthy();
+ const config = await resp.json();
+
+ await page.goto('/');
+ await page.waitForLoadState('domcontentloaded');
+
+ // The sidebar only renders for an authenticated admin; confirm it is there.
+ const nav = page.getByRole('navigation').first();
+ await expect(nav).toBeVisible({ timeout: 15000 });
+
+ // "Scan Results" is gated on Trivy or OpenSCAP being configured.
+ const scanResults = nav.getByRole('link', { name: /scan results/i });
+ const scannersOn = config.scanners.trivy_enabled || config.scanners.openscap_enabled;
+ if (scannersOn) {
+ await expect(scanResults).toBeVisible({ timeout: 10000 });
+ } else {
+ await expect(scanResults).toHaveCount(0);
+ }
+
+ // "DT Projects" is gated on the Dependency-Track integration.
+ const dtProjects = nav.getByRole('link', { name: /dt projects/i });
+ if (config.scanners.dependency_track_enabled) {
+ await expect(dtProjects).toBeVisible({ timeout: 10000 });
+ } else {
+ await expect(dtProjects).toHaveCount(0);
+ }
+ });
+
+ test('upload dialog advertises the configured max upload size', async ({ page, request }) => {
+ const resp = await request.get('/api/v1/system/config');
+ expect(resp.ok()).toBeTruthy();
+ const config = await resp.json();
+
+ // Only meaningful when the backend advertises a non-zero limit.
+ test.skip(
+ !config.max_upload_size_bytes || config.max_upload_size_bytes === 0,
+ 'Server advertises no upload size limit'
+ );
+
+ await page.goto('/repositories/e2e-maven-local');
+ await page.waitForLoadState('domcontentloaded');
+
+ const uploadTab = page.getByRole('tab', { name: /upload/i });
+ if (!(await uploadTab.isVisible({ timeout: 8000 }).catch(() => false))) {
+ test.skip(true, 'Upload tab not available for this repository');
+ return;
+ }
+ await uploadTab.click();
+
+ // The dropzone helper text includes "max " derived from system config.
+ await expect(page.getByText(/max\s/i).first()).toBeVisible({ timeout: 10000 });
+ });
+});
diff --git a/src/app/(app)/(admin)/rate-limits/page.tsx b/src/app/(app)/(admin)/rate-limits/page.tsx
new file mode 100644
index 00000000..e1d17df5
--- /dev/null
+++ b/src/app/(app)/(admin)/rate-limits/page.tsx
@@ -0,0 +1,464 @@
+"use client";
+
+import { useState } from "react";
+import { useAuth } from "@/providers/auth-provider";
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
+import { Gauge, Info, Plus, Trash2, Loader2, User, Bot, Network } from "lucide-react";
+
+import {
+ rateLimitsApi,
+ validateExemption,
+ type ExemptionType,
+ type RateLimitConfig,
+ type RateLimitExemption,
+} from "@/lib/api/rate-limits";
+import { mutationErrorToast } from "@/lib/error-utils";
+
+import { PageHeader } from "@/components/common/page-header";
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Badge } from "@/components/ui/badge";
+import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+
+export const RATE_LIMITS_QUERY_KEY = ["rate-limits"] as const;
+export const RATE_LIMIT_EXEMPTIONS_QUERY_KEY = ["rate-limit-exemptions"] as const;
+
+const TYPE_META: Record<
+ ExemptionType,
+ { label: string; icon: React.ComponentType<{ className?: string }>; placeholder: string }
+> = {
+ username: { label: "Username", icon: User, placeholder: "ci-bot" },
+ service_account: { label: "Service account", icon: Bot, placeholder: "deploy-sa" },
+ cidr: { label: "CIDR range", icon: Network, placeholder: "10.0.0.0/8" },
+};
+
+function rate(window: { limit: number; window_secs: number }): string {
+ if (window.window_secs <= 0) return `${window.limit} requests`;
+ return `${window.limit} requests / ${window.window_secs}s`;
+}
+
+// -- Current configuration card --
+
+function ConfigCard({
+ config,
+ isLoading,
+ isError,
+}: {
+ config: RateLimitConfig | undefined;
+ isLoading: boolean;
+ isError: boolean;
+}) {
+ return (
+
+
+ Current Rate Limits
+
+ Effective per-window request limits. Configured via environment
+ variables and shown read-only.
+
+
+
+ {isLoading && (
+
+
+
+ )}
+ {!isLoading && (isError || !config) && (
+
+ Rate-limit configuration is not available from this server.
+
+ )}
+ {!isLoading && config && (
+
+
+
- Authentication
+ - {rate(config.auth)}
+
+
+
API
+ {rate(config.api)}
+
+
+
Search
+ {rate(config.search)}
+
+
+
+ Service accounts globally exempt
+
+
+ {config.exempt_service_accounts ? "Yes" : "No"}
+
+
+
+ )}
+
+
+ );
+}
+
+// -- Add exemption dialog --
+
+function AddExemptionDialog() {
+ const queryClient = useQueryClient();
+ const [open, setOpen] = useState(false);
+ const [type, setType] = useState("username");
+ const [value, setValue] = useState("");
+ const [note, setNote] = useState("");
+
+ const addMutation = useMutation({
+ mutationFn: () => rateLimitsApi.addExemption({ type, value, note }),
+ onSuccess: () => {
+ toast.success("Exemption added");
+ queryClient.invalidateQueries({ queryKey: RATE_LIMIT_EXEMPTIONS_QUERY_KEY });
+ setOpen(false);
+ setValue("");
+ setNote("");
+ setType("username");
+ },
+ onError: mutationErrorToast("Failed to add exemption"),
+ });
+
+ function handleSubmit() {
+ const error = validateExemption({ type, value, note });
+ if (error) {
+ toast.error(error);
+ return;
+ }
+ addMutation.mutate();
+ }
+
+ const meta = TYPE_META[type];
+
+ return (
+
+ );
+}
+
+// -- Exemptions table --
+
+function ExemptionsTable({
+ exemptions,
+ isLoading,
+ isError,
+}: {
+ exemptions: RateLimitExemption[] | undefined;
+ isLoading: boolean;
+ isError: boolean;
+}) {
+ const queryClient = useQueryClient();
+ const [pendingDelete, setPendingDelete] = useState(null);
+
+ const removeMutation = useMutation({
+ mutationFn: (id: string) => rateLimitsApi.removeExemption(id),
+ onSuccess: () => {
+ toast.success("Exemption removed");
+ queryClient.invalidateQueries({ queryKey: RATE_LIMIT_EXEMPTIONS_QUERY_KEY });
+ setPendingDelete(null);
+ },
+ onError: mutationErrorToast("Failed to remove exemption"),
+ });
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (isError) {
+ return (
+
+ Exemptions unavailable
+
+ Unable to load rate-limit exemptions. This server may not support
+ managing exemptions through the UI yet.
+
+
+ );
+ }
+
+ if (!exemptions || exemptions.length === 0) {
+ return (
+
+ No rate-limit exemptions configured.
+
+ );
+ }
+
+ return (
+ <>
+
+
+
+ Type
+ Value
+ Note
+ Source
+
+
+
+
+ {exemptions.map((ex) => {
+ const meta = TYPE_META[ex.type];
+ const Icon = meta.icon;
+ return (
+
+
+
+
+ {meta.label}
+
+
+ {ex.value}
+
+ {ex.note || "—"}
+
+
+ {ex.source_env ? (
+ Environment
+ ) : (
+ Manual
+ )}
+
+
+
+
+
+ );
+ })}
+
+
+
+ !o && setPendingDelete(null)}
+ >
+
+
+ Remove exemption?
+
+ {pendingDelete &&
+ `${TYPE_META[pendingDelete.type].label} "${pendingDelete.value}" will be subject to rate limits again.`}
+
+
+
+
+ Cancel
+
+ {
+ e.preventDefault();
+ if (pendingDelete) removeMutation.mutate(pendingDelete.id);
+ }}
+ disabled={removeMutation.isPending}
+ >
+ {removeMutation.isPending && (
+
+ )}
+ Remove
+
+
+
+
+ >
+ );
+}
+
+// -- Page --
+
+export default function RateLimitsPage() {
+ const { user } = useAuth();
+
+ const configQuery = useQuery({
+ queryKey: RATE_LIMITS_QUERY_KEY,
+ queryFn: () => rateLimitsApi.getConfig(),
+ retry: false,
+ staleTime: 5 * 60 * 1000,
+ });
+
+ const exemptionsQuery = useQuery({
+ queryKey: RATE_LIMIT_EXEMPTIONS_QUERY_KEY,
+ queryFn: () => rateLimitsApi.listExemptions(),
+ retry: false,
+ staleTime: 60 * 1000,
+ });
+
+ if (!user?.is_admin) {
+ return (
+
+
+
+ Access Denied
+
+ You must be an administrator to manage rate-limit exemptions.
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ }
+ />
+
+
+
+ About exemptions
+
+ Exempt principals bypass rate limiting entirely. Entries marked
+ Environment come from server configuration and are read-only here.
+ Manual entries can be added and removed below.
+
+
+
+
+
+
+
+
+ Exemptions
+
+ Users, service accounts, and CIDR ranges that bypass rate limiting.
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(app)/repositories/_components/repo-detail-content.tsx b/src/app/(app)/repositories/_components/repo-detail-content.tsx
index c5309209..d70f65da 100644
--- a/src/app/(app)/repositories/_components/repo-detail-content.tsx
+++ b/src/app/(app)/repositories/_components/repo-detail-content.tsx
@@ -44,6 +44,7 @@ import { QuarantineBanner } from "@/components/common/quarantine-banner";
import { RepoSettingsTab } from "./repo-settings-tab";
import { formatBytes, REPO_TYPE_COLORS } from "@/lib/utils";
import { useAuth } from "@/providers/auth-provider";
+import { useSystemConfig } from "@/providers/system-config-provider";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -95,6 +96,7 @@ export function RepoDetailContent({ repoKey, standalone = false }: RepoDetailCon
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const { isAuthenticated, user } = useAuth();
+ const { config: systemConfig } = useSystemConfig();
// artifact search / pagination
const [searchQuery, setSearchQuery] = useState("");
@@ -697,6 +699,7 @@ export function RepoDetailContent({ repoKey, standalone = false }: RepoDetailCon
showPathInput
repositoryKey={repoKey}
onChunkedComplete={handleChunkedComplete}
+ maxUploadSizeBytes={systemConfig.max_upload_size_bytes}
/>
diff --git a/src/components/common/file-upload.tsx b/src/components/common/file-upload.tsx
index de825d72..0f7378cf 100644
--- a/src/components/common/file-upload.tsx
+++ b/src/components/common/file-upload.tsx
@@ -34,6 +34,13 @@ interface FileUploadProps {
chunkedThreshold?: number;
/** Called when chunked upload completes */
onChunkedComplete?: () => void;
+ /**
+ * Server-enforced maximum upload size in bytes, sourced from
+ * `/api/v1/system/config` (#271). When greater than 0 the limit is shown to
+ * the user and oversize files are rejected client-side before any request is
+ * sent. 0 or undefined means "no limit advertised".
+ */
+ maxUploadSizeBytes?: number;
}
function formatSpeed(bytesPerSecond: number): string {
@@ -102,6 +109,7 @@ export function FileUpload({
chunkSize,
chunkedThreshold = 100 * 1024 * 1024,
onChunkedComplete,
+ maxUploadSizeBytes = 0,
}: FileUploadProps) {
const [file, setFile] = useState(null);
const [customPath, setCustomPath] = useState("");
@@ -136,18 +144,30 @@ export function FileUpload({
const handleFile = useCallback(
(f: File) => {
- setFile(f);
setSimpleProgress(0);
setShowResumePrompt(false);
setError(null);
+ // Reject files over the server-advertised limit before selecting them,
+ // so the user gets immediate feedback instead of a failed upload (#271).
+ if (maxUploadSizeBytes > 0 && f.size > maxUploadSizeBytes) {
+ setFile(null);
+ setError(
+ `File is ${formatBytes(f.size)}, which exceeds the maximum upload size of ${formatBytes(maxUploadSizeBytes)}.`
+ );
+ if (inputRef.current) inputRef.current.value = "";
+ return;
+ }
+
+ setFile(f);
+
if (repositoryKey && f.size >= chunkedThreshold) {
if (chunked.hasPendingSession(f)) {
setShowResumePrompt(true);
}
}
},
- [repositoryKey, chunkedThreshold, chunked]
+ [repositoryKey, chunkedThreshold, chunked, maxUploadSizeBytes]
);
const handleDrop = useCallback(
@@ -316,6 +336,9 @@ export function FileUpload({
Upload a single artifact file
+ {maxUploadSizeBytes > 0 && (
+ <> (max {formatBytes(maxUploadSizeBytes)})>
+ )}
>
diff --git a/src/components/layout/__tests__/app-sidebar.test.tsx b/src/components/layout/__tests__/app-sidebar.test.tsx
index 43ca1841..c069f317 100644
--- a/src/components/layout/__tests__/app-sidebar.test.tsx
+++ b/src/components/layout/__tests__/app-sidebar.test.tsx
@@ -32,6 +32,11 @@ vi.mock("@/providers/auth-provider", () => ({
useAuth: () => mockUseAuth(),
}));
+const mockUseFeatureFlags = vi.fn();
+vi.mock("@/providers/system-config-provider", () => ({
+ useFeatureFlags: () => mockUseFeatureFlags(),
+}));
+
const mockUseQuery = vi.fn();
vi.mock("@tanstack/react-query", () => ({
useQuery: (opts: any) => mockUseQuery(opts),
@@ -90,9 +95,24 @@ vi.mock("lucide-react", () => {
Scale: icon,
FolderSearch: icon,
ClipboardCheck: icon,
+ Gauge: icon,
};
});
+// Feature flags drive scanner-dependent nav visibility (#271). Default to all
+// scanners enabled so existing assertions about the Security group still hold.
+const ALL_FLAGS_ON = {
+ scanningEnabled: true,
+ trivyEnabled: true,
+ openscapEnabled: true,
+ dependencyTrackEnabled: true,
+ ssoEnabled: false,
+ oidcEnabled: false,
+ ldapEnabled: false,
+ guestAccessEnabled: true,
+ demoMode: false,
+};
+
// ---------------------------------------------------------------------------
// Component under test
// ---------------------------------------------------------------------------
@@ -126,6 +146,7 @@ describe("AppSidebar", () => {
vi.clearAllMocks();
process.env.NEXT_PUBLIC_APP_VERSION = "1.1.0";
mockUseQuery.mockReturnValue({ data: undefined });
+ mockUseFeatureFlags.mockReturnValue(ALL_FLAGS_ON);
});
it("shows web version only when health data is not available", () => {
@@ -245,4 +266,47 @@ describe("AppSidebar", () => {
expect(screen.getByText("Operations")).toBeDefined();
expect(screen.getByText("Administration")).toBeDefined();
});
+
+ it("hides Scan Results and DT Projects when no scanner is configured (#271)", () => {
+ authState({ isAuthenticated: true, isAdmin: true });
+ mockUseFeatureFlags.mockReturnValue({
+ ...ALL_FLAGS_ON,
+ scanningEnabled: false,
+ trivyEnabled: false,
+ openscapEnabled: false,
+ dependencyTrackEnabled: false,
+ });
+
+ render();
+
+ // The Security group still renders (policies, permissions, quality gates),
+ // but the scanner-dependent entries are gone.
+ expect(screen.getByText("Security")).toBeDefined();
+ expect(screen.queryByText("Scan Results")).toBeNull();
+ expect(screen.queryByText("DT Projects")).toBeNull();
+ expect(screen.getByText("Quality Gates")).toBeDefined();
+ });
+
+ it("shows DT Projects only when Dependency-Track is enabled (#271)", () => {
+ authState({ isAuthenticated: true, isAdmin: true });
+ mockUseFeatureFlags.mockReturnValue({
+ ...ALL_FLAGS_ON,
+ trivyEnabled: false,
+ openscapEnabled: false,
+ dependencyTrackEnabled: true,
+ });
+
+ render();
+
+ expect(screen.getByText("DT Projects")).toBeDefined();
+ expect(screen.queryByText("Scan Results")).toBeNull();
+ });
+
+ it("renders the Rate Limits admin entry (#270)", () => {
+ authState({ isAuthenticated: true, isAdmin: true });
+
+ render();
+
+ expect(screen.getByText("Rate Limits")).toBeDefined();
+ });
});
diff --git a/src/components/layout/app-sidebar.tsx b/src/components/layout/app-sidebar.tsx
index 12cbe8f2..7f82648e 100644
--- a/src/components/layout/app-sidebar.tsx
+++ b/src/components/layout/app-sidebar.tsx
@@ -35,9 +35,11 @@ import {
Scale,
FolderSearch,
ClipboardCheck,
+ Gauge,
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/providers/auth-provider";
+import { useFeatureFlags } from "@/providers/system-config-provider";
import { adminApi } from "@/lib/api/admin";
import {
Sidebar,
@@ -102,6 +104,7 @@ const adminItems: NavItem[] = [
{ title: "Users", href: "/users", icon: Users },
{ title: "Groups", href: "/groups", icon: UsersRound },
{ title: "Service Accounts", href: "/service-accounts", icon: Bot },
+ { title: "Rate Limits", href: "/rate-limits", icon: Gauge },
{ title: "Backups", href: "/backups", icon: HardDrive },
{ title: "SSO Providers", href: "/settings/sso", icon: KeyRound },
{ title: "Settings", href: "/settings", icon: Settings },
@@ -143,6 +146,7 @@ export function AppSidebar() {
const pathname = usePathname();
const { isAuthenticated, user } = useAuth();
const isAdmin = user?.is_admin ?? false;
+ const flags = useFeatureFlags();
const { data: health } = useQuery({
queryKey: ["health"],
@@ -156,6 +160,21 @@ export function AppSidebar() {
? integrationItems
: integrationItems.filter((item) => item.href !== "/migration");
+ // Hide scanner-dependent security entries when the backend reports no
+ // scanner configured (#271). "Scan Results" needs Trivy or OpenSCAP;
+ // "DT Projects" needs the Dependency-Track integration. The rest of the
+ // Security group (policies, permissions, quality gates) is always shown
+ // since it doesn't depend on a scanner being wired up.
+ const visibleSecurityItems = securityItems.filter((item) => {
+ if (item.href === "/security/scans") {
+ return flags.trivyEnabled || flags.openscapEnabled;
+ }
+ if (item.href === "/security/dt-projects") {
+ return flags.dependencyTrackEnabled;
+ }
+ return true;
+ });
+
return (
@@ -204,7 +223,7 @@ export function AppSidebar() {
<>
({}));
+
+const mockApiFetch = vi.fn();
+
+vi.mock("@/lib/api/fetch", () => ({
+ apiFetch: (...args: unknown[]) => mockApiFetch(...args),
+}));
+
+describe("rateLimitsApi", () => {
+ beforeEach(() => vi.clearAllMocks());
+
+ it("parses an array exemptions response", async () => {
+ mockApiFetch.mockResolvedValue([
+ { id: "1", type: "username", value: "ci-bot", source_env: true },
+ { id: "2", type: "cidr", value: "10.0.0.0/8", note: "in-cluster" },
+ ]);
+ const mod = await import("../rate-limits");
+ const rows = await mod.rateLimitsApi.listExemptions();
+ expect(rows).toHaveLength(2);
+ expect(rows[0].type).toBe("username");
+ expect(rows[0].source_env).toBe(true);
+ expect(rows[1].note).toBe("in-cluster");
+ });
+
+ it("parses an object-wrapped exemptions response", async () => {
+ mockApiFetch.mockResolvedValue({
+ exemptions: [{ id: "3", type: "service_account", value: "deploy-sa" }],
+ });
+ const mod = await import("../rate-limits");
+ const rows = await mod.rateLimitsApi.listExemptions();
+ expect(rows).toHaveLength(1);
+ expect(rows[0].value).toBe("deploy-sa");
+ });
+
+ it("parses the rate-limit config", async () => {
+ mockApiFetch.mockResolvedValue({
+ auth: { limit: 10, window_secs: 60 },
+ api: { limit: 300, window_secs: 60 },
+ search: { limit: 100, window_secs: 60 },
+ exempt_service_accounts: true,
+ });
+ const mod = await import("../rate-limits");
+ const config = await mod.rateLimitsApi.getConfig();
+ expect(config.api.limit).toBe(300);
+ expect(config.exempt_service_accounts).toBe(true);
+ });
+
+ it("posts a trimmed create payload and parses the echoed row", async () => {
+ mockApiFetch.mockResolvedValue({
+ id: "9",
+ type: "username",
+ value: "ci-bot",
+ note: "build agent",
+ });
+ const mod = await import("../rate-limits");
+ const row = await mod.rateLimitsApi.addExemption({
+ type: "username",
+ value: " ci-bot ",
+ note: " build agent ",
+ });
+ expect(row.id).toBe("9");
+ expect(mockApiFetch).toHaveBeenCalledWith(
+ "/api/v1/admin/rate-limits/exemptions",
+ expect.objectContaining({ method: "POST" })
+ );
+ const body = JSON.parse(mockApiFetch.mock.calls[0][1].body);
+ expect(body.value).toBe("ci-bot");
+ expect(body.note).toBe("build agent");
+ });
+
+ it("url-encodes the id when removing an exemption", async () => {
+ mockApiFetch.mockResolvedValue(undefined);
+ const mod = await import("../rate-limits");
+ await mod.rateLimitsApi.removeExemption("a/b id");
+ expect(mockApiFetch).toHaveBeenCalledWith(
+ "/api/v1/admin/rate-limits/exemptions/a%2Fb%20id",
+ { method: "DELETE" }
+ );
+ });
+
+ it("throws on an unparseable config response", async () => {
+ mockApiFetch.mockResolvedValue({ auth: "bad" });
+ const mod = await import("../rate-limits");
+ await expect(mod.rateLimitsApi.getConfig()).rejects.toThrow(/did not match/);
+ });
+});
+
+describe("rate-limit validation helpers", () => {
+ it("validates IPv4 CIDR", async () => {
+ const mod = await import("../rate-limits");
+ expect(mod.isValidCidr("10.0.0.0/8")).toBe(true);
+ expect(mod.isValidCidr("192.168.1.0/24")).toBe(true);
+ expect(mod.isValidCidr("10.0.0.0/33")).toBe(false);
+ expect(mod.isValidCidr("10.0.0.256/8")).toBe(false);
+ expect(mod.isValidCidr("10.0.0.0")).toBe(false);
+ });
+
+ it("validates IPv6 CIDR", async () => {
+ const mod = await import("../rate-limits");
+ expect(mod.isValidCidr("2001:db8::/32")).toBe(true);
+ expect(mod.isValidCidr("2001:db8::/129")).toBe(false);
+ });
+
+ it("rejects empty values and bad CIDR in validateExemption", async () => {
+ const mod = await import("../rate-limits");
+ expect(mod.validateExemption({ type: "username", value: "" })).toMatch(
+ /required/
+ );
+ expect(mod.validateExemption({ type: "cidr", value: "not-a-cidr" })).toMatch(
+ /valid CIDR/
+ );
+ expect(
+ mod.validateExemption({ type: "username", value: "ci-bot" })
+ ).toBeNull();
+ });
+});
diff --git a/src/lib/api/__tests__/system-config.test.ts b/src/lib/api/__tests__/system-config.test.ts
new file mode 100644
index 00000000..e85d0cd4
--- /dev/null
+++ b/src/lib/api/__tests__/system-config.test.ts
@@ -0,0 +1,95 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+vi.mock("@/lib/sdk-client", () => ({}));
+
+const mockApiFetch = vi.fn();
+
+vi.mock("@/lib/api/fetch", () => ({
+ apiFetch: (...args: unknown[]) => mockApiFetch(...args),
+}));
+
+const VALID = {
+ max_upload_size_bytes: 10_737_418_240,
+ demo_mode: false,
+ guest_access_enabled: true,
+ scanners: {
+ trivy_enabled: true,
+ openscap_enabled: false,
+ dependency_track_enabled: false,
+ },
+ search_engine: "opensearch",
+ storage_backend: "s3",
+ auth: { oidc_enabled: true, ldap_enabled: false, sso_enabled: true },
+ oidc_issuer: "https://auth.example.com",
+ permissions: { rules_exist: true, enforcement_enabled: true },
+};
+
+describe("systemConfigApi", () => {
+ beforeEach(() => vi.clearAllMocks());
+
+ it("parses a full config response", async () => {
+ mockApiFetch.mockResolvedValue(VALID);
+ const mod = await import("../system-config");
+ const config = await mod.systemConfigApi.getConfig();
+ expect(config.max_upload_size_bytes).toBe(10_737_418_240);
+ expect(config.scanners.trivy_enabled).toBe(true);
+ expect(config.auth.sso_enabled).toBe(true);
+ expect(config.oidc_issuer).toBe("https://auth.example.com");
+ });
+
+ it("calls the public system config endpoint with GET", async () => {
+ mockApiFetch.mockResolvedValue(VALID);
+ const mod = await import("../system-config");
+ await mod.systemConfigApi.getConfig();
+ expect(mockApiFetch).toHaveBeenCalledWith("/api/v1/system/config", {
+ method: "GET",
+ });
+ });
+
+ it("accepts a response without the optional oidc_issuer", async () => {
+ const { oidc_issuer: _omit, ...withoutIssuer } = VALID;
+ void _omit;
+ mockApiFetch.mockResolvedValue(withoutIssuer);
+ const mod = await import("../system-config");
+ const config = await mod.systemConfigApi.getConfig();
+ expect(config.oidc_issuer).toBeUndefined();
+ });
+
+ it("ignores unknown forward-compatible fields", async () => {
+ mockApiFetch.mockResolvedValue({ ...VALID, future_flag: true });
+ const mod = await import("../system-config");
+ const config = await mod.systemConfigApi.getConfig();
+ expect(config.storage_backend).toBe("s3");
+ });
+
+ it("throws when required fields are missing or wrong type", async () => {
+ mockApiFetch.mockResolvedValue({ demo_mode: "nope" });
+ const mod = await import("../system-config");
+ await expect(mod.systemConfigApi.getConfig()).rejects.toThrow(
+ /did not match/
+ );
+ });
+
+ it("anyScannerEnabled reflects the scanner flags", async () => {
+ const mod = await import("../system-config");
+ expect(mod.anyScannerEnabled(mod.parseSystemConfig(VALID))).toBe(true);
+ expect(
+ mod.anyScannerEnabled(
+ mod.parseSystemConfig({
+ ...VALID,
+ scanners: {
+ trivy_enabled: false,
+ openscap_enabled: false,
+ dependency_track_enabled: false,
+ },
+ })
+ )
+ ).toBe(false);
+ });
+
+ it("exposes permissive defaults", async () => {
+ const mod = await import("../system-config");
+ expect(mod.DEFAULT_SYSTEM_CONFIG.guest_access_enabled).toBe(true);
+ expect(mod.anyScannerEnabled(mod.DEFAULT_SYSTEM_CONFIG)).toBe(false);
+ });
+});
diff --git a/src/lib/api/rate-limits.ts b/src/lib/api/rate-limits.ts
new file mode 100644
index 00000000..981fafe4
--- /dev/null
+++ b/src/lib/api/rate-limits.ts
@@ -0,0 +1,204 @@
+import { z } from "zod";
+import { apiFetch } from "@/lib/api/fetch";
+
+/**
+ * Admin client for rate-limit configuration and exemption management
+ * (#270, backend issue #680).
+ *
+ * Rate-limit exemptions were historically configured only through environment
+ * variables (`RATE_LIMIT_EXEMPT_USERNAMES`, `RATE_LIMIT_EXEMPT_SERVICE_ACCOUNTS`,
+ * `RATE_LIMIT_TRUSTED_CIDRS`). This module talks to the admin endpoints that let
+ * an operator view the effective configuration and manage exemptions from the
+ * UI. The endpoints are not in the generated SDK, so we use the shared
+ * `apiFetch` wrapper and validate responses with zod at the trust boundary.
+ *
+ * Endpoints (all under the admin-guarded router):
+ * GET /api/v1/admin/rate-limits -> RateLimitConfig
+ * GET /api/v1/admin/rate-limits/exemptions -> RateLimitExemption[]
+ * POST /api/v1/admin/rate-limits/exemptions -> RateLimitExemption
+ * DELETE /api/v1/admin/rate-limits/exemptions/{id}
+ */
+
+export type ExemptionType = "username" | "service_account" | "cidr";
+
+export interface RateLimitExemption {
+ id: string;
+ /** What the value refers to: a username, a service-account name, or a CIDR. */
+ type: ExemptionType;
+ /** The exempted value (username, service-account name, or CIDR range). */
+ value: string;
+ /** Optional operator note explaining why the exemption exists. */
+ note?: string;
+ /** When the exemption was created, ISO 8601. Optional for env-sourced rows. */
+ created_at?: string;
+ /**
+ * True when the exemption comes from an environment variable and therefore
+ * cannot be removed through the UI. Env-sourced rows are shown read-only.
+ */
+ source_env?: boolean;
+}
+
+export interface RateLimitWindow {
+ /** Requests permitted per window. */
+ limit: number;
+ /** Window length in seconds. */
+ window_secs: number;
+}
+
+export interface RateLimitConfig {
+ auth: RateLimitWindow;
+ api: RateLimitWindow;
+ search: RateLimitWindow;
+ /** Whether all service accounts are globally exempt (env toggle). */
+ exempt_service_accounts: boolean;
+}
+
+export interface CreateExemptionRequest {
+ type: ExemptionType;
+ value: string;
+ note?: string;
+}
+
+const WindowSchema = z.object({
+ limit: z.number(),
+ window_secs: z.number(),
+});
+
+const RateLimitConfigSchema = z
+ .object({
+ auth: WindowSchema,
+ api: WindowSchema,
+ search: WindowSchema,
+ exempt_service_accounts: z.boolean(),
+ })
+ .passthrough();
+
+const ExemptionTypeSchema = z.enum(["username", "service_account", "cidr"]);
+
+const ExemptionSchema = z
+ .object({
+ id: z.string(),
+ type: ExemptionTypeSchema,
+ value: z.string(),
+ note: z.string().optional(),
+ created_at: z.string().optional(),
+ source_env: z.boolean().optional(),
+ })
+ .passthrough();
+
+const ExemptionListSchema = z.union([
+ z.array(ExemptionSchema),
+ z.object({ exemptions: z.array(ExemptionSchema) }).passthrough(),
+]);
+
+export function parseExemptions(data: unknown): RateLimitExemption[] {
+ const parsed = ExemptionListSchema.safeParse(data);
+ if (!parsed.success) {
+ throw new Error("Rate-limit exemptions response did not match the expected shape");
+ }
+ const rows = Array.isArray(parsed.data) ? parsed.data : parsed.data.exemptions;
+ return rows.map((r) => ({
+ id: r.id,
+ type: r.type,
+ value: r.value,
+ note: r.note,
+ created_at: r.created_at,
+ source_env: r.source_env,
+ }));
+}
+
+export function parseRateLimitConfig(data: unknown): RateLimitConfig {
+ const parsed = RateLimitConfigSchema.safeParse(data);
+ if (!parsed.success) {
+ throw new Error("Rate-limit config response did not match the expected shape");
+ }
+ const c = parsed.data;
+ return {
+ auth: c.auth,
+ api: c.api,
+ search: c.search,
+ exempt_service_accounts: c.exempt_service_accounts,
+ };
+}
+
+/**
+ * Basic CIDR validation for client-side feedback. Accepts IPv4 and IPv6 with a
+ * prefix length. The backend performs authoritative validation; this just keeps
+ * obviously malformed input from being submitted.
+ */
+export function isValidCidr(value: string): boolean {
+ const parts = value.trim().split("/");
+ if (parts.length !== 2) return false;
+ const [addr, prefix] = parts;
+ const prefixNum = Number(prefix);
+ if (!Number.isInteger(prefixNum) || prefixNum < 0) return false;
+ const isIpv6 = addr.includes(":");
+ if (isIpv6) {
+ return prefixNum <= 128 && /^[0-9a-fA-F:]+$/.test(addr);
+ }
+ if (prefixNum > 32) return false;
+ const octets = addr.split(".");
+ if (octets.length !== 4) return false;
+ return octets.every((o) => {
+ const n = Number(o);
+ return Number.isInteger(n) && n >= 0 && n <= 255;
+ });
+}
+
+/** Validate a create request, returning an error message or null if valid. */
+export function validateExemption(req: CreateExemptionRequest): string | null {
+ const value = req.value.trim();
+ if (!value) return "Value is required";
+ if (req.type === "cidr" && !isValidCidr(value)) {
+ return "Enter a valid CIDR range, for example 10.0.0.0/8 or 2001:db8::/32";
+ }
+ return null;
+}
+
+export const rateLimitsApi = {
+ getConfig: async (): Promise => {
+ const data = await apiFetch("/api/v1/admin/rate-limits", {
+ method: "GET",
+ });
+ return parseRateLimitConfig(data);
+ },
+
+ listExemptions: async (): Promise => {
+ const data = await apiFetch(
+ "/api/v1/admin/rate-limits/exemptions",
+ { method: "GET" }
+ );
+ return parseExemptions(data);
+ },
+
+ addExemption: async (
+ req: CreateExemptionRequest
+ ): Promise => {
+ const data = await apiFetch(
+ "/api/v1/admin/rate-limits/exemptions",
+ {
+ method: "POST",
+ body: JSON.stringify({
+ type: req.type,
+ value: req.value.trim(),
+ note: req.note?.trim() || undefined,
+ }),
+ }
+ );
+ // The create response echoes the stored row; validate it the same way.
+ const parsed = ExemptionSchema.safeParse(data);
+ if (!parsed.success) {
+ throw new Error("Create exemption response did not match the expected shape");
+ }
+ return parseExemptions([parsed.data])[0];
+ },
+
+ removeExemption: async (id: string): Promise => {
+ await apiFetch(
+ `/api/v1/admin/rate-limits/exemptions/${encodeURIComponent(id)}`,
+ { method: "DELETE" }
+ );
+ },
+};
+
+export default rateLimitsApi;
diff --git a/src/lib/api/system-config.ts b/src/lib/api/system-config.ts
new file mode 100644
index 00000000..03fa2bda
--- /dev/null
+++ b/src/lib/api/system-config.ts
@@ -0,0 +1,154 @@
+import { z } from "zod";
+import { apiFetch } from "@/lib/api/fetch";
+
+/**
+ * Client for the public runtime configuration endpoint
+ * (`GET /api/v1/system/config`, backend issue #496).
+ *
+ * The endpoint requires no authentication and exposes only non-sensitive
+ * values that let the frontend adapt its behavior: upload limits, enabled
+ * integrations (scanners, auth providers), the storage and search backends,
+ * and feature flags. It is not modeled in the generated SDK yet, so we hit it
+ * through the shared `apiFetch` wrapper and validate the response with zod at
+ * the trust boundary.
+ */
+
+export interface ScannersConfig {
+ trivy_enabled: boolean;
+ openscap_enabled: boolean;
+ dependency_track_enabled: boolean;
+}
+
+export interface AuthProvidersConfig {
+ oidc_enabled: boolean;
+ ldap_enabled: boolean;
+ sso_enabled: boolean;
+}
+
+export interface PermissionsConfig {
+ rules_exist: boolean;
+ enforcement_enabled: boolean;
+}
+
+export interface SystemConfig {
+ max_upload_size_bytes: number;
+ demo_mode: boolean;
+ guest_access_enabled: boolean;
+ scanners: ScannersConfig;
+ search_engine: string;
+ storage_backend: string;
+ auth: AuthProvidersConfig;
+ oidc_issuer?: string;
+ permissions: PermissionsConfig;
+}
+
+const ScannersSchema = z.object({
+ trivy_enabled: z.boolean(),
+ openscap_enabled: z.boolean(),
+ dependency_track_enabled: z.boolean(),
+});
+
+const AuthSchema = z.object({
+ oidc_enabled: z.boolean(),
+ ldap_enabled: z.boolean(),
+ sso_enabled: z.boolean(),
+});
+
+const PermissionsSchema = z.object({
+ rules_exist: z.boolean(),
+ enforcement_enabled: z.boolean(),
+});
+
+// `.passthrough()` keeps the parser forward-compatible: a backend that adds new
+// config fields in a later release will not fail validation here, the new
+// fields are simply ignored until the web app models them.
+const SystemConfigSchema = z
+ .object({
+ max_upload_size_bytes: z.number(),
+ demo_mode: z.boolean(),
+ guest_access_enabled: z.boolean(),
+ scanners: ScannersSchema,
+ search_engine: z.string(),
+ storage_backend: z.string(),
+ auth: AuthSchema,
+ oidc_issuer: z.string().optional(),
+ permissions: PermissionsSchema,
+ })
+ .passthrough();
+
+/**
+ * Default config used before the real response arrives or when the endpoint is
+ * unavailable. Defaults are deliberately permissive (everything that affects
+ * navigation visibility defaults to enabled) so a transient fetch failure never
+ * hides a feature the operator actually configured. Scanner-gated surfaces are
+ * the exception: they default to disabled because showing an empty scanner tab
+ * is a worse experience than briefly hiding it, and the data behind those tabs
+ * is itself fetched with its own error handling.
+ */
+export const DEFAULT_SYSTEM_CONFIG: SystemConfig = {
+ max_upload_size_bytes: 0,
+ demo_mode: false,
+ guest_access_enabled: true,
+ scanners: {
+ trivy_enabled: false,
+ openscap_enabled: false,
+ dependency_track_enabled: false,
+ },
+ search_engine: "database",
+ storage_backend: "filesystem",
+ auth: {
+ oidc_enabled: false,
+ ldap_enabled: false,
+ sso_enabled: false,
+ },
+ permissions: {
+ rules_exist: false,
+ enforcement_enabled: false,
+ },
+};
+
+export function parseSystemConfig(data: unknown): SystemConfig {
+ const parsed = SystemConfigSchema.safeParse(data);
+ if (!parsed.success) {
+ throw new Error(
+ "System config response did not match the expected shape"
+ );
+ }
+ const c = parsed.data;
+ return {
+ max_upload_size_bytes: c.max_upload_size_bytes,
+ demo_mode: c.demo_mode,
+ guest_access_enabled: c.guest_access_enabled,
+ scanners: c.scanners,
+ search_engine: c.search_engine,
+ storage_backend: c.storage_backend,
+ auth: c.auth,
+ oidc_issuer: c.oidc_issuer,
+ permissions: c.permissions,
+ };
+}
+
+/** True when any vulnerability/compliance scanner integration is configured. */
+export function anyScannerEnabled(config: SystemConfig): boolean {
+ return (
+ config.scanners.trivy_enabled ||
+ config.scanners.openscap_enabled ||
+ config.scanners.dependency_track_enabled
+ );
+}
+
+export const systemConfigApi = {
+ /**
+ * Fetch public runtime configuration. Throws on network error or an
+ * unparseable response so callers can decide whether to fall back to
+ * `DEFAULT_SYSTEM_CONFIG`.
+ */
+ getConfig: async (): Promise => {
+ const data = await apiFetch("/api/v1/system/config", {
+ method: "GET",
+ });
+ return parseSystemConfig(data);
+ },
+};
+
+export default systemConfigApi;
diff --git a/src/providers/__tests__/system-config-provider.test.tsx b/src/providers/__tests__/system-config-provider.test.tsx
new file mode 100644
index 00000000..2e34fc19
--- /dev/null
+++ b/src/providers/__tests__/system-config-provider.test.tsx
@@ -0,0 +1,106 @@
+// @vitest-environment jsdom
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import "@testing-library/jest-dom/vitest";
+import { render, screen, cleanup, waitFor } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+
+vi.mock("@/lib/sdk-client", () => ({}));
+
+const mockGetConfig = vi.fn();
+vi.mock("@/lib/api/system-config", async () => {
+ const actual = await vi.importActual(
+ "@/lib/api/system-config"
+ );
+ return {
+ ...actual,
+ systemConfigApi: { getConfig: () => mockGetConfig() },
+ };
+});
+
+import {
+ SystemConfigProvider,
+ useFeatureFlags,
+ useSystemConfig,
+} from "../system-config-provider";
+
+function Consumer() {
+ const flags = useFeatureFlags();
+ const { isError } = useSystemConfig();
+ return (
+
+ {String(flags.scanningEnabled)}
+ {String(flags.dependencyTrackEnabled)}
+ {String(flags.ssoEnabled)}
+ {String(flags.guestAccessEnabled)}
+ {String(isError)}
+
+ );
+}
+
+function renderWithProvider() {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return render(
+
+
+
+
+
+ );
+}
+
+describe("SystemConfigProvider", () => {
+ beforeEach(() => vi.clearAllMocks());
+ afterEach(() => cleanup());
+
+ it("derives feature flags from a fetched config", async () => {
+ mockGetConfig.mockResolvedValue({
+ max_upload_size_bytes: 100,
+ demo_mode: false,
+ guest_access_enabled: false,
+ scanners: {
+ trivy_enabled: true,
+ openscap_enabled: false,
+ dependency_track_enabled: true,
+ },
+ search_engine: "opensearch",
+ storage_backend: "s3",
+ auth: { oidc_enabled: true, ldap_enabled: false, sso_enabled: false },
+ permissions: { rules_exist: false, enforcement_enabled: true },
+ });
+
+ renderWithProvider();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("scanning")).toHaveTextContent("true");
+ });
+ expect(screen.getByTestId("dt")).toHaveTextContent("true");
+ // sso flag is true when either sso_enabled or oidc_enabled is set.
+ expect(screen.getByTestId("sso")).toHaveTextContent("true");
+ expect(screen.getByTestId("guest")).toHaveTextContent("false");
+ });
+
+ it("falls back to permissive defaults on fetch error", async () => {
+ mockGetConfig.mockRejectedValue(new Error("network down"));
+
+ renderWithProvider();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("error")).toHaveTextContent("true");
+ });
+ // Defaults: scanners off, guest access on.
+ expect(screen.getByTestId("scanning")).toHaveTextContent("false");
+ expect(screen.getByTestId("guest")).toHaveTextContent("true");
+ });
+
+ it("throws if useSystemConfig is used outside the provider", () => {
+ function Bare() {
+ useSystemConfig();
+ return null;
+ }
+ const spy = vi.spyOn(console, "error").mockImplementation(() => {});
+ expect(() => render()).toThrow(/within a SystemConfigProvider/);
+ spy.mockRestore();
+ });
+});
diff --git a/src/providers/index.tsx b/src/providers/index.tsx
index be9a0c22..76f8b32a 100644
--- a/src/providers/index.tsx
+++ b/src/providers/index.tsx
@@ -5,14 +5,17 @@ import { QueryProvider } from "./query-provider";
import { ThemeProvider } from "./theme-provider";
import { AuthProvider } from "./auth-provider";
import { InstanceProvider } from "./instance-provider";
+import { SystemConfigProvider } from "./system-config-provider";
export function Providers({ children }: { children: ReactNode }) {
return (
-
- {children}
-
+
+
+ {children}
+
+
);
diff --git a/src/providers/system-config-provider.tsx b/src/providers/system-config-provider.tsx
new file mode 100644
index 00000000..785911ad
--- /dev/null
+++ b/src/providers/system-config-provider.tsx
@@ -0,0 +1,98 @@
+"use client";
+
+import { createContext, useContext, type ReactNode } from "react";
+import { useQuery } from "@tanstack/react-query";
+import {
+ systemConfigApi,
+ anyScannerEnabled,
+ DEFAULT_SYSTEM_CONFIG,
+ type SystemConfig,
+} from "@/lib/api/system-config";
+
+/**
+ * Provides the backend's public runtime configuration
+ * (`GET /api/v1/system/config`) to the whole app and derives the feature flags
+ * the UI uses to show or hide gated surfaces (#271).
+ *
+ * The query runs once and is cached for the session; config rarely changes at
+ * runtime and is cheap to re-fetch on a hard reload. While loading or on error
+ * the context exposes `DEFAULT_SYSTEM_CONFIG` so consumers always read a
+ * concrete object and never have to null-check.
+ */
+
+export interface FeatureFlags {
+ /** Any vulnerability or compliance scanner is configured. */
+ scanningEnabled: boolean;
+ trivyEnabled: boolean;
+ openscapEnabled: boolean;
+ /** Dependency-Track integration is wired up and reachable. */
+ dependencyTrackEnabled: boolean;
+ /** An SSO/OIDC provider is available for login. */
+ ssoEnabled: boolean;
+ oidcEnabled: boolean;
+ ldapEnabled: boolean;
+ /** Anonymous browsing/download is permitted (#850). */
+ guestAccessEnabled: boolean;
+ /** Instance is read-only (writes blocked). */
+ demoMode: boolean;
+}
+
+interface SystemConfigContextValue {
+ config: SystemConfig;
+ flags: FeatureFlags;
+ isLoading: boolean;
+ isError: boolean;
+}
+
+function deriveFlags(config: SystemConfig): FeatureFlags {
+ return {
+ scanningEnabled: anyScannerEnabled(config),
+ trivyEnabled: config.scanners.trivy_enabled,
+ openscapEnabled: config.scanners.openscap_enabled,
+ dependencyTrackEnabled: config.scanners.dependency_track_enabled,
+ ssoEnabled: config.auth.sso_enabled || config.auth.oidc_enabled,
+ oidcEnabled: config.auth.oidc_enabled,
+ ldapEnabled: config.auth.ldap_enabled,
+ guestAccessEnabled: config.guest_access_enabled,
+ demoMode: config.demo_mode,
+ };
+}
+
+const SystemConfigContext = createContext(null);
+
+export const SYSTEM_CONFIG_QUERY_KEY = ["system-config"] as const;
+
+export function SystemConfigProvider({ children }: { children: ReactNode }) {
+ const { data, isLoading, isError } = useQuery({
+ queryKey: SYSTEM_CONFIG_QUERY_KEY,
+ queryFn: () => systemConfigApi.getConfig(),
+ staleTime: 10 * 60 * 1000,
+ retry: false,
+ });
+
+ const config = data ?? DEFAULT_SYSTEM_CONFIG;
+
+ return (
+
+ {children}
+
+ );
+}
+
+/** Full system config plus loading/error state. */
+export function useSystemConfig(): SystemConfigContextValue {
+ const ctx = useContext(SystemConfigContext);
+ if (!ctx) {
+ throw new Error(
+ "useSystemConfig must be used within a SystemConfigProvider"
+ );
+ }
+ return ctx;
+}
+
+/** Just the derived feature flags, the common case for gating UI. */
+export function useFeatureFlags(): FeatureFlags {
+ return useSystemConfig().flags;
+}