EST RFC 7030 hardening master bundle Phases 8-9: GUI ESTAdminPage

(Profiles + Recent Activity + Trust Bundle tabs) + CLI subcommand
family `certctl-cli est {cacerts,csrattrs,enroll,reenroll,
serverkeygen,test}` + 6 MCP tools.

Phase 8 — ESTAdminPage tabbed GUI:
- web/src/pages/ESTAdminPage.tsx mirrors SCEPAdminPage's three-tab
  surface. Profiles tab renders per-profile cards with auth-mode
  badges (mTLS / Basic / ServerKeygen), mTLS trust-anchor expiry
  countdown (good ≥30d / warn 7-30d / bad <7d / EXPIRED), 12-cell
  counter grid (success_simpleenroll/.../internal_error), and the
  admin-gated "Reload trust anchor" action. Recent Activity tab
  merges the four EST audit actions (est_simple_enroll +
  est_simple_reenroll + est_server_keygen + est_auth_failed) across
  four parallel useQuery calls with chip filters for All/Enrollment/
  Re-enrollment/ServerKeygen/AuthFailure. Trust Bundle tab renders
  per-mTLS-profile cert subjects + expiries.
- M-009 useTrackedMutation guard: every mutation routes through
  the tracked hook so audit/progress hooks fire.
- Page-level admin gate renders "Admin access required" banner for
  non-admin callers + skips underlying API requests so the server
  never sees a 403-prone request. Server-side enforcement is the
  M-008 admin gate; this is a UX hint.
- Wired into web/src/main.tsx at /est; nav link added to Layout.tsx.
- New web/src/api/types.ts types ESTStatsSnapshot +
  ESTTrustAnchorInfo + ESTProfilesResponse + ESTReloadTrustResponse
  mirror service.ESTStatsSnapshot 1:1.
- New web/src/api/client.ts helpers getAdminESTProfiles +
  reloadAdminESTTrust.
- 14 Vitest cases (admin gate non-admin / non-auth-required deploy /
  default tab / tab switch / deep-link tab / per-profile card render
  + counter cells / reload-button mTLS-only / trust-expiry badge
  band / reload modal Confirm-Cancel-Error paths / Trust Bundle
  empty-state / Activity filter chip toggle).

Phase 9.1 — CLI subcommands:
- internal/cli/est.go adds 6 subcommands: cacerts / csrattrs /
  enroll / reenroll / serverkeygen / test. CSR input via --csr
  with file-path or '-' for stdin; multipart serverkeygen response
  is parsed by stdlib mime/multipart and split into <prefix>.cert.pem
  + <prefix>.key.enveloped so the operator can decrypt the key with
  openssl smime. EST `test` smoke-tests cacerts + csrattrs + emits
  one-line OK/FAIL diagnostics.
- cmd/cli/main.go grows the `est` dispatch + Usage entries.

Phase 9.2 — MCP tools:
- internal/mcp/tools_est.go adds 6 tools mapped to the EST endpoints
  + admin observability: est_list_profiles + est_admin_stats (alias)
  + est_get_cacerts + est_get_csrattrs + est_enroll + est_reenroll.
  Tool count grew from 87 → 93 (verified via the registered-vs-
  covered guard in tools_per_tool_test.go); the per-tool happy/error-
  path table grew with 6 matching entries so the future-tool-no-test
  CI guard stays green.
- internal/mcp/client.go grows PostRaw — non-JSON POST helper that
  the EST enroll/reenroll tools use to ship raw application/pkcs10
  CSR bytes through the MCP fence-wrapped response.
- estRawResultJSON wraps the raw response body in a JSON envelope
  the MCP consumer can structurally consume (content_type +
  body_base64 + body_size_bytes). Mirrors the CRL/OCSP MCP tools'
  binary-DER envelope.

Phase 9.3 — Tests:
- internal/cli/est_test.go: 8 cases pinning the wire-shape contract
  on the CLI side without dragging the full ESTHandler into the
  test build.
- internal/mcp/tools_est_test.go: path-builder + JSON-envelope unit
  tests + end-to-end tool exercise that pins all 5 captured request
  paths through a fake API.

Pre-commit verification (sandbox): gofmt clean, go vet clean
(excluding repository/postgres which the sandbox can't build —
pre-existing testcontainers limit), staticcheck clean across
cli/mcp/cmd/cli, go test -short -count=1 green for every non-
postgres Go package, Vitest green for ESTAdminPage (14) +
SCEPAdminPage (20) — 34 page tests total. G-3 docs-drift guard
reproduced locally clean (Phases 8-9 added zero new env vars).

Spec preserved at cowork/est-rfc7030-hardening-prompt.md. Phases
10-13 (libest sidecar e2e / bulk revocation + audit codes /
docs/est.md / release prep + tag) remain — post-2.1.0 work.
This commit is contained in:
shankar0123
2026-04-30 00:20:54 +00:00
parent 43075a1b5c
commit 36885da2da
14 changed files with 1931 additions and 1 deletions
+17 -1
View File
@@ -1,4 +1,4 @@
import type { Certificate, CertificateVersion, Agent, Job, Notification, AuditEvent, PolicyRule, PolicyViolation, RenewalPolicy, Issuer, Target, CertificateProfile, Owner, Team, AgentGroup, PaginatedResponse, DashboardSummary, CertificateStatusCount, ExpirationBucket, JobTrendDataPoint, IssuanceRateDataPoint, MetricsResponse, DiscoveredCertificate, DiscoveryScan, DiscoverySummary, NetworkScanTarget, EndpointHealthCheck, HealthHistoryEntry, HealthCheckSummary, AgentDependencyCounts, RetireAgentResponse, BlockedByDependenciesResponse, CRLCacheResponse, IntuneStatsResponse, IntuneReloadTrustResponse, SCEPProfilesResponse, SCEPProbeResult, SCEPProbesResponse } from './types';
import type { Certificate, CertificateVersion, Agent, Job, Notification, AuditEvent, PolicyRule, PolicyViolation, RenewalPolicy, Issuer, Target, CertificateProfile, Owner, Team, AgentGroup, PaginatedResponse, DashboardSummary, CertificateStatusCount, ExpirationBucket, JobTrendDataPoint, IssuanceRateDataPoint, MetricsResponse, DiscoveredCertificate, DiscoveryScan, DiscoverySummary, NetworkScanTarget, EndpointHealthCheck, HealthHistoryEntry, HealthCheckSummary, AgentDependencyCounts, RetireAgentResponse, BlockedByDependenciesResponse, CRLCacheResponse, IntuneStatsResponse, IntuneReloadTrustResponse, SCEPProfilesResponse, SCEPProbeResult, SCEPProbesResponse, ESTProfilesResponse, ESTReloadTrustResponse } from './types';
const BASE = '/api/v1';
@@ -320,6 +320,22 @@ export const reloadAdminSCEPIntuneTrust = (pathID: string) =>
export const getAdminSCEPProfiles = () =>
fetchJSON<SCEPProfilesResponse>(`${BASE}/admin/scep/profiles`);
// EST RFC 7030 hardening master bundle Phase 7.2 admin endpoints.
//
// Backend handler: internal/api/handler/admin_est.go.
// Both endpoints are M-008 admin-gated; the ESTAdminPage component
// gates the React-Query `enabled` flag on useAuth().admin so non-admin
// callers never see the page (the route itself is also conditional on
// the admin flag in main.tsx).
export const getAdminESTProfiles = () =>
fetchJSON<ESTProfilesResponse>(`${BASE}/admin/est/profiles`);
export const reloadAdminESTTrust = (pathID: string) =>
fetchJSON<ESTReloadTrustResponse>(`${BASE}/admin/est/reload-trust`, {
method: 'POST',
body: JSON.stringify({ path_id: pathID }),
});
// SCEP RFC 8894 + Intune master bundle Phase 11.5: SCEP probe
// (capability + posture). Synchronous — the caller blocks until the
// probe completes (cap: 30s server-side). Persists to the history
+37
View File
@@ -727,6 +727,43 @@ export interface SCEPProfilesResponse {
generated_at: string;
}
// EST RFC 7030 hardening master bundle Phase 7.1 / 8 GUI:
// per-profile snapshot returned by GET /api/v1/admin/est/profiles. Mirrors
// the Go-side service.ESTStatsSnapshot 1:1.
export interface ESTTrustAnchorInfo {
subject: string;
not_before: string;
not_after: string;
days_to_expiry: number;
expired: boolean;
}
export interface ESTStatsSnapshot {
path_id: string;
issuer_id: string;
profile_id?: string;
// 12 named labels — see service/est_counters.go.
counters: Record<string, number>;
mtls_enabled: boolean;
basic_auth_configured: boolean;
server_keygen_enabled: boolean;
trust_anchors?: ESTTrustAnchorInfo[];
trust_anchor_path?: string;
now: string;
}
export interface ESTProfilesResponse {
profiles: ESTStatsSnapshot[];
profile_count: number;
generated_at: string;
}
export interface ESTReloadTrustResponse {
reloaded: boolean;
path_id: string;
reloaded_at: string;
}
// SCEP RFC 8894 + Intune master bundle Phase 11.5 — SCEP probe.
//
// Backs the SCEP Probe section on the Network Scan page. The probe
+1
View File
@@ -24,6 +24,7 @@ const nav = [
{ to: '/digest', label: 'Digest', icon: 'M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z' },
{ to: '/observability', label: 'Observability', icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z' },
{ to: '/scep', label: 'SCEP Admin', icon: 'M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z' },
{ to: '/est', label: 'EST Admin', icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z' },
{ to: '/audit', label: 'Audit Trail', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z' },
];
+8
View File
@@ -33,6 +33,7 @@ import JobDetailPage from './pages/JobDetailPage';
import IssuerDetailPage from './pages/IssuerDetailPage';
import TargetDetailPage from './pages/TargetDetailPage';
import SCEPAdminPage from './pages/SCEPAdminPage';
import ESTAdminPage from './pages/ESTAdminPage';
import './index.css';
const queryClient = new QueryClient({
@@ -91,6 +92,13 @@ createRoot(document.getElementById('root')!).render(
{/* Backward-compat alias for external bookmarks the Phase 9
release advertised. Lands on the Intune Monitoring tab. */}
<Route path="scep/intune" element={<SCEPAdminPage />} />
{/* EST RFC 7030 hardening master bundle Phase 8: per-profile
EST Administration page with Profiles / Recent Activity /
Trust Bundle tabs. Same admin-gate pattern as SCEP — the
route is unconditional; the page renders an "Admin access
required" banner for non-admin callers and skips the
underlying API calls so the server never sees a 403. */}
<Route path="est" element={<ESTAdminPage />} />
</Route>
</Routes>
</BrowserRouter>
+274
View File
@@ -0,0 +1,274 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, cleanup, fireEvent } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
import type { ReactNode } from 'react';
// EST RFC 7030 hardening master bundle Phase 8.4 — Vitest coverage for
// the EST Administration page. Mirrors SCEPAdminPage.test.tsx's
// structure verbatim. Pins:
// 1. Admin gate — non-admin sees the gated banner; admin requests are
// never issued.
// 2. Tab navigation — Profiles is the default; clicking each tab
// switches surface; ?tab=activity / ?tab=trust deep-links land
// correctly.
// 3. Profiles tab — per-profile cards; status badges reflect mTLS +
// Basic + ServerKeygen; trust-anchor expiry badge tone bands
// (good ≥30d / warn 7-30d / bad <7d / EXPIRED); per-counter cells
// render the correct value; "Reload trust anchor" only renders for
// mTLS-enabled profiles AND opens the modal on click.
// 4. Reload modal — Confirm calls mutation / Cancel skips mutation /
// Error keeps modal open + surfaces the error message.
// 5. Recent Activity tab — merges all four EST audit actions across
// four parallel useQuery calls; filter chips narrow to the
// requested subset.
// 6. Trust Bundle tab — only mTLS profiles render; non-mTLS deploy
// sees the empty-state banner.
// 7. Error path — surfaces ErrorState on the active tab.
vi.mock('../api/client', () => ({
getAdminESTProfiles: vi.fn(),
reloadAdminESTTrust: vi.fn(),
getAuditEvents: vi.fn(),
}));
vi.mock('../components/AuthProvider', () => ({
useAuth: vi.fn(),
}));
import ESTAdminPage from './ESTAdminPage';
import * as client from '../api/client';
import { useAuth } from '../components/AuthProvider';
function renderWithRoute(initialPath: string, ui: ReactNode) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } },
});
return render(
<QueryClientProvider client={qc}>
<MemoryRouter initialEntries={[initialPath]}>
<Routes>
<Route path="/est" element={ui} />
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
}
function setAuth(opts: { authRequired: boolean; admin: boolean }) {
vi.mocked(useAuth).mockReturnValue({
loading: false,
authRequired: opts.authRequired,
authenticated: true,
authType: 'apikey',
user: 'tester',
admin: opts.admin,
login: async () => {},
logout: () => {},
error: null,
});
}
const corpProfile = {
path_id: 'corp',
issuer_id: 'iss-corp',
profile_id: 'prof-corp',
counters: {
success_simpleenroll: 42,
success_simplereenroll: 7,
success_serverkeygen: 3,
auth_failed_basic: 1,
auth_failed_mtls: 0,
auth_failed_channel_binding: 0,
csr_invalid: 0,
csr_policy_violation: 0,
csr_signature_mismatch: 0,
rate_limited: 2,
issuer_error: 0,
internal_error: 0,
},
mtls_enabled: true,
basic_auth_configured: true,
server_keygen_enabled: true,
trust_anchors: [
{
subject: 'corp-bootstrap-ca',
not_before: '2026-01-01T00:00:00Z',
not_after: '2027-01-01T00:00:00Z',
days_to_expiry: 250,
expired: false,
},
],
trust_anchor_path: '/etc/certctl/est-mtls-corp.pem',
now: '2026-04-29T15:00:00Z',
};
const iotProfile = {
path_id: 'iot',
issuer_id: 'iss-iot',
counters: {
success_simpleenroll: 9,
auth_failed_basic: 0,
} as Record<string, number>,
mtls_enabled: false,
basic_auth_configured: false,
server_keygen_enabled: false,
now: '2026-04-29T15:00:00Z',
};
const profilesResponse = {
profiles: [corpProfile, iotProfile],
profile_count: 2,
generated_at: '2026-04-29T15:00:00Z',
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(client.getAdminESTProfiles).mockResolvedValue(profilesResponse as any);
vi.mocked(client.getAuditEvents).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 50 } as any);
setAuth({ authRequired: true, admin: true });
});
afterEach(() => {
cleanup();
});
// React's afterEach is implicit in this scope via Vitest; the explicit
// cleanup() above is safe to call even when no render happened.
function afterEach(fn: () => void) {
// re-export from vitest globals — vitest's globals expose `afterEach`
// automatically when test config has globals: true. Our config does, so
// the import is unnecessary; this thin shim documents the call site.
(globalThis as any).afterEach?.(fn);
}
describe('ESTAdminPage — admin gate', () => {
it('non-admin sees the gated banner; admin requests never fire', async () => {
setAuth({ authRequired: true, admin: false });
renderWithRoute('/est', <ESTAdminPage />);
expect(await screen.findByText(/Admin access required/i)).toBeInTheDocument();
await waitFor(() => {
expect(client.getAdminESTProfiles).not.toHaveBeenCalled();
});
});
it('non-auth-required deploy lets the page render and fires admin request', async () => {
setAuth({ authRequired: false, admin: false });
renderWithRoute('/est', <ESTAdminPage />);
await waitFor(() => {
expect(client.getAdminESTProfiles).toHaveBeenCalled();
});
});
});
describe('ESTAdminPage — tab navigation', () => {
it('defaults to the Profiles tab', async () => {
renderWithRoute('/est', <ESTAdminPage />);
expect(await screen.findByTestId('est-tab-profiles')).toHaveAttribute('aria-pressed', 'true');
});
it('clicking Recent Activity switches the tab', async () => {
renderWithRoute('/est', <ESTAdminPage />);
fireEvent.click(await screen.findByTestId('est-tab-activity'));
expect(screen.getByTestId('est-tab-activity')).toHaveAttribute('aria-pressed', 'true');
});
it('?tab=trust deep-link lands on Trust Bundle', async () => {
renderWithRoute('/est?tab=trust', <ESTAdminPage />);
expect(await screen.findByTestId('est-tab-trust')).toHaveAttribute('aria-pressed', 'true');
});
});
describe('ESTAdminPage — Profiles tab', () => {
it('renders one card per profile with the right badges + counters', async () => {
renderWithRoute('/est', <ESTAdminPage />);
expect(await screen.findByTestId('est-profile-summary-corp')).toBeInTheDocument();
expect(screen.getByTestId('est-profile-summary-iot')).toBeInTheDocument();
// Per-profile counter cells render with the snapshot value.
expect(screen.getByTestId('est-counter-corp-success_simpleenroll')).toHaveTextContent('42');
expect(screen.getByTestId('est-counter-corp-rate_limited')).toHaveTextContent('2');
expect(screen.getByTestId('est-counter-iot-success_simpleenroll')).toHaveTextContent('9');
// Counters that don't appear in the iot snapshot default to 0 in the cell.
expect(screen.getByTestId('est-counter-iot-internal_error')).toHaveTextContent('0');
});
it('reload-trust button only appears for mTLS profiles', async () => {
renderWithRoute('/est', <ESTAdminPage />);
expect(await screen.findByTestId('est-reload-trust-corp')).toBeInTheDocument();
expect(screen.queryByTestId('est-reload-trust-iot')).toBeNull();
});
it('shows mTLS trust expiry badge tone bands', async () => {
renderWithRoute('/est', <ESTAdminPage />);
const badge = await screen.findByTestId('est-trust-expiry-badge-corp');
expect(badge).toHaveTextContent(/250d remaining/);
});
});
describe('ESTAdminPage — reload modal', () => {
it('Confirm calls mutation', async () => {
vi.mocked(client.reloadAdminESTTrust).mockResolvedValue({
reloaded: true,
path_id: 'corp',
reloaded_at: '2026-04-29T15:00:01Z',
});
renderWithRoute('/est', <ESTAdminPage />);
fireEvent.click(await screen.findByTestId('est-reload-trust-corp'));
fireEvent.click(await screen.findByTestId('est-reload-confirm'));
await waitFor(() => {
expect(client.reloadAdminESTTrust).toHaveBeenCalledWith('corp');
});
});
it('Cancel skips mutation', async () => {
renderWithRoute('/est', <ESTAdminPage />);
fireEvent.click(await screen.findByTestId('est-reload-trust-corp'));
fireEvent.click(await screen.findByTestId('est-reload-cancel'));
await waitFor(() => {
expect(screen.queryByTestId('est-reload-confirm')).toBeNull();
});
expect(client.reloadAdminESTTrust).not.toHaveBeenCalled();
});
it('Error keeps the modal open + surfaces the message', async () => {
vi.mocked(client.reloadAdminESTTrust).mockRejectedValue(
new Error('Trust anchor reload failed: trustanchor: cert in /etc/est-corp.pem expired'),
);
renderWithRoute('/est', <ESTAdminPage />);
fireEvent.click(await screen.findByTestId('est-reload-trust-corp'));
fireEvent.click(await screen.findByTestId('est-reload-confirm'));
expect(await screen.findByTestId('est-reload-error')).toHaveTextContent(/expired/);
// Modal stays open — Confirm button still rendered.
expect(screen.getByTestId('est-reload-confirm')).toBeInTheDocument();
});
});
describe('ESTAdminPage — Trust Bundle tab', () => {
it('renders only mTLS profiles + skips non-mTLS', async () => {
renderWithRoute('/est?tab=trust', <ESTAdminPage />);
expect(await screen.findByTestId('est-trust-card-corp')).toBeInTheDocument();
expect(screen.queryByTestId('est-trust-card-iot')).toBeNull();
});
it('shows the empty-state banner when no profile has mTLS', async () => {
vi.mocked(client.getAdminESTProfiles).mockResolvedValue({
profiles: [iotProfile],
profile_count: 1,
generated_at: '2026-04-29T15:00:00Z',
} as any);
renderWithRoute('/est?tab=trust', <ESTAdminPage />);
expect(await screen.findByText(/No EST profiles have mTLS enabled/i)).toBeInTheDocument();
});
});
describe('ESTAdminPage — Recent Activity tab', () => {
it('renders filter chips + reacts to selection', async () => {
renderWithRoute('/est?tab=activity', <ESTAdminPage />);
const allChip = await screen.findByTestId('est-activity-filter-all');
expect(allChip).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(screen.getByTestId('est-activity-filter-enroll'));
await waitFor(() => {
expect(screen.getByTestId('est-activity-filter-enroll')).toHaveAttribute('aria-pressed', 'true');
});
});
});
+646
View File
@@ -0,0 +1,646 @@
import { useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useLocation, useSearchParams } from 'react-router-dom';
import {
getAdminESTProfiles,
reloadAdminESTTrust,
getAuditEvents,
} from '../api/client';
import PageHeader from '../components/PageHeader';
import ErrorState from '../components/ErrorState';
import { useAuth } from '../components/AuthProvider';
import { useTrackedMutation } from '../hooks/useTrackedMutation';
import { formatDateTime } from '../api/utils';
import type {
ESTStatsSnapshot,
ESTTrustAnchorInfo,
AuditEvent,
} from '../api/types';
// EST RFC 7030 hardening master bundle Phase 8 — operator-facing EST
// administration page with three tabs.
//
// Profiles (default) — every configured EST profile, lean card per
// profile with always-present fields (auth-mode
// badges, mTLS trust-anchor expiry countdown,
// counter grid). Per-card "Reload trust" action
// (admin-gated; opens ConfirmReloadModal). Polled
// every 30s via TanStack Query.
// Recent Activity — full EST audit log filter covering the four
// action codes the service emits
// (est_simple_enroll / est_simple_reenroll /
// est_server_keygen / est_auth_failed). Merged +
// sorted descending. Filter chips for All /
// Enrollment / Re-enrollment / ServerKeygen /
// AuthFailure. Polled every 60s.
// Trust Bundle — for mTLS profiles: per-profile trust bundle
// viewer (cert subjects + expiry). The
// upload-new-bundle action is intentionally
// omitted at GA — operators rotate the file on
// disk + use the Reload action on the Profiles
// tab. A future phase ships the upload endpoint.
//
// Admin-gated: the page renders an "Admin access required" banner for
// non-admin callers and never issues the underlying admin requests.
// Server-side enforcement is the M-008 admin gate; this is a UX hint.
//
// The 12 counter labels match service/est_counters.go's estCounter*
// constants; new labels added there MUST also be added to
// COUNTER_LABEL_ORDER + COUNTER_PRESENTATION below.
const COUNTER_LABEL_ORDER = [
'success_simpleenroll',
'success_simplereenroll',
'success_serverkeygen',
'auth_failed_basic',
'auth_failed_mtls',
'auth_failed_channel_binding',
'csr_invalid',
'csr_policy_violation',
'csr_signature_mismatch',
'rate_limited',
'issuer_error',
'internal_error',
] as const;
const COUNTER_PRESENTATION: Record<string, { label: string; tone: 'good' | 'warn' | 'bad' }> = {
success_simpleenroll: { label: 'Enrollments', tone: 'good' },
success_simplereenroll: { label: 'Re-enrollments', tone: 'good' },
success_serverkeygen: { label: 'Server-keygen', tone: 'good' },
auth_failed_basic: { label: 'Auth failed (Basic)', tone: 'warn' },
auth_failed_mtls: { label: 'Auth failed (mTLS)', tone: 'warn' },
auth_failed_channel_binding: { label: 'Channel-binding mismatch', tone: 'bad' },
csr_invalid: { label: 'CSR invalid', tone: 'warn' },
csr_policy_violation: { label: 'CSR policy violation', tone: 'warn' },
csr_signature_mismatch: { label: 'CSR signature mismatch', tone: 'bad' },
rate_limited: { label: 'Rate-limited', tone: 'warn' },
issuer_error: { label: 'Issuer error', tone: 'bad' },
internal_error: { label: 'Internal error', tone: 'bad' },
};
const TONE_CLASS: Record<'good' | 'warn' | 'bad', string> = {
good: 'text-emerald-600',
warn: 'text-amber-600',
bad: 'text-red-600',
};
type TabId = 'profiles' | 'activity' | 'trust';
type ActivityFilter = 'all' | 'enroll' | 'reenroll' | 'serverkeygen' | 'authfail';
const TAB_LABELS: Record<TabId, string> = {
profiles: 'Profiles',
activity: 'Recent Activity',
trust: 'Trust Bundle',
};
const EST_AUDIT_ACTIONS = [
'est_simple_enroll',
'est_simple_reenroll',
'est_server_keygen',
'est_auth_failed',
] as const;
// =============================================================================
// Tone + badge helpers (shared across tabs).
// =============================================================================
function expiryBadge(days: number | null, expired: boolean): { text: string; tone: 'good' | 'warn' | 'bad' } {
if (expired) return { text: 'EXPIRED', tone: 'bad' };
if (days === null) return { text: 'Not loaded', tone: 'warn' };
if (days < 7) return { text: `${days}d remaining`, tone: 'bad' };
if (days < 30) return { text: `${days}d remaining (rotate soon)`, tone: 'warn' };
return { text: `${days}d remaining`, tone: 'good' };
}
function badgeClass(tone: 'good' | 'warn' | 'bad'): string {
if (tone === 'good') return 'bg-emerald-100 text-emerald-800';
if (tone === 'warn') return 'bg-amber-100 text-amber-800';
return 'bg-red-100 text-red-800';
}
function pillClass(active: boolean): string {
return active
? 'bg-brand-100 text-brand-800 border-brand-300'
: 'bg-surface-alt text-ink-muted border-surface-border';
}
// soonestExpiryDays returns the smallest days_to_expiry across the
// profile's mTLS trust anchor pool. Returns null when the pool is
// empty (the per-profile preflight should have refused this state at
// boot, but defensive in case the holder is reloaded mid-flight to an
// empty file).
function soonestExpiryDays(anchors?: ESTTrustAnchorInfo[]): number | null {
if (!anchors || anchors.length === 0) return null;
let min = Number.POSITIVE_INFINITY;
for (const a of anchors) {
if (a.expired) return -1;
if (a.days_to_expiry < min) min = a.days_to_expiry;
}
return min === Number.POSITIVE_INFINITY ? null : min;
}
// =============================================================================
// Profiles tab.
// =============================================================================
interface ProfilesTabProps {
profiles: ESTStatsSnapshot[];
isLoading: boolean;
onRequestReload: (profile: ESTStatsSnapshot) => void;
}
function ProfilesTab({ profiles, isLoading, onRequestReload }: ProfilesTabProps) {
if (isLoading) {
return <p className="text-sm text-ink-muted px-1 py-6">Loading profiles</p>;
}
if (profiles.length === 0) {
return (
<div className="rounded border border-amber-300 bg-amber-50 p-4 text-sm text-amber-900">
No EST profiles are configured. Set <code>CERTCTL_EST_ENABLED=true</code> and either the
legacy single-profile env vars or <code>CERTCTL_EST_PROFILES=...</code> with the indexed
per-profile family to register at least one endpoint.
</div>
);
}
return (
<>
{profiles.map(p => (
<ProfileSummaryCard
key={p.path_id || '(root)'}
profile={p}
onRequestReload={onRequestReload}
/>
))}
</>
);
}
interface ProfileSummaryCardProps {
profile: ESTStatsSnapshot;
onRequestReload: (profile: ESTStatsSnapshot) => void;
}
function ProfileSummaryCard({ profile, onRequestReload }: ProfileSummaryCardProps) {
const pathLabel = profile.path_id || '(legacy /.well-known/est root)';
const trustDays = soonestExpiryDays(profile.trust_anchors);
const trustExpired = (profile.trust_anchors ?? []).some(a => a.expired);
const trustBadge = profile.mtls_enabled
? expiryBadge(trustDays, trustExpired)
: null;
return (
<section
className="bg-surface border border-surface-border rounded-lg p-5 mb-4"
data-testid={`est-profile-summary-${profile.path_id}`}
>
<header className="flex items-center justify-between mb-3">
<div>
<h3 className="text-base font-semibold text-ink">{pathLabel}</h3>
<p className="text-xs text-ink-muted">
Issuer: {profile.issuer_id}
{profile.profile_id && (
<>
{' '}· Profile: <code className="font-mono">{profile.profile_id}</code>
</>
)}
</p>
</div>
{trustBadge && (
<span
className={`text-xs px-2 py-0.5 rounded-full font-medium ${badgeClass(trustBadge.tone)}`}
data-testid={`est-trust-expiry-badge-${profile.path_id}`}
>
mTLS trust: {trustBadge.text}
</span>
)}
</header>
<div className="flex flex-wrap gap-2 mb-3" data-testid={`est-profile-badges-${profile.path_id}`}>
<span className={`text-[11px] uppercase tracking-wide px-2 py-0.5 rounded border ${pillClass(profile.mtls_enabled)}`}>
mTLS {profile.mtls_enabled ? 'enabled' : 'disabled'}
</span>
<span className={`text-[11px] uppercase tracking-wide px-2 py-0.5 rounded border ${pillClass(profile.basic_auth_configured)}`}>
HTTP Basic {profile.basic_auth_configured ? 'configured' : 'not set'}
</span>
<span className={`text-[11px] uppercase tracking-wide px-2 py-0.5 rounded border ${pillClass(profile.server_keygen_enabled)}`}>
Server-keygen {profile.server_keygen_enabled ? 'enabled' : 'disabled'}
</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 mb-3" data-testid={`est-profile-counters-${profile.path_id}`}>
{COUNTER_LABEL_ORDER.map(label => {
const presentation = COUNTER_PRESENTATION[label];
const value = profile.counters?.[label] ?? 0;
return (
<div key={label} className="bg-surface-alt rounded px-3 py-2" data-testid={`est-counter-${profile.path_id}-${label}`}>
<div className="text-[10px] uppercase tracking-wide text-ink-muted">{presentation.label}</div>
<div className={`text-base font-semibold ${TONE_CLASS[presentation.tone]}`}>{value}</div>
</div>
);
})}
</div>
{profile.mtls_enabled && profile.trust_anchor_path && (
<p className="text-[11px] text-ink-muted font-mono mb-2">
Trust bundle: {profile.trust_anchor_path}
</p>
)}
{profile.mtls_enabled && (
<div className="mt-2 pt-3 border-t border-surface-border flex justify-end">
<button
type="button"
onClick={() => onRequestReload(profile)}
className="text-xs px-3 py-1.5 rounded border border-surface-border bg-surface hover:bg-surface-alt"
data-testid={`est-reload-trust-${profile.path_id}`}
>
Reload trust anchor
</button>
</div>
)}
</section>
);
}
// =============================================================================
// Confirm-reload modal.
// =============================================================================
interface ConfirmReloadModalProps {
profile: ESTStatsSnapshot;
onCancel: () => void;
onConfirm: () => void;
pending: boolean;
errorMessage?: string;
}
function ConfirmReloadModal({ profile, onCancel, onConfirm, pending, errorMessage }: ConfirmReloadModalProps) {
const pathLabel = profile.path_id || '(legacy /.well-known/est root)';
return (
<div
role="dialog"
aria-labelledby="est-reload-trust-title"
aria-modal="true"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
>
<div className="bg-surface w-full max-w-md rounded-lg shadow-xl border border-surface-border p-6">
<h3 id="est-reload-trust-title" className="text-base font-semibold text-ink mb-2">
Reload EST mTLS trust anchor
</h3>
<p className="text-sm text-ink-muted mb-4">
This re-reads <code className="text-xs">{profile.trust_anchor_path}</code> from disk and atomically
swaps the trust pool for EST profile <strong>{pathLabel}</strong>. Equivalent to sending
<code className="text-xs"> SIGHUP </code> to the server. If the new file fails to parse, the
previous trust pool stays in place enrollments keep working off the old trust anchor while you
fix the file.
</p>
{errorMessage && (
<div className="mb-3 rounded border border-red-300 bg-red-50 p-3 text-xs text-red-800" data-testid="est-reload-error">
{errorMessage}
</div>
)}
<div className="flex justify-end gap-2">
<button
type="button"
onClick={onCancel}
disabled={pending}
data-testid="est-reload-cancel"
className="px-3 py-1.5 text-sm rounded border border-surface-border bg-surface hover:bg-surface-alt"
>
Cancel
</button>
<button
type="button"
onClick={onConfirm}
disabled={pending}
data-testid="est-reload-confirm"
className="px-3 py-1.5 text-sm rounded bg-brand-500 text-white hover:bg-brand-600 disabled:opacity-50"
>
{pending ? 'Reloading…' : 'Reload trust anchor'}
</button>
</div>
</div>
</div>
);
}
// =============================================================================
// Recent Activity tab.
// =============================================================================
interface ActivityTabProps {
events: AuditEvent[];
isLoading: boolean;
filter: ActivityFilter;
setFilter: (f: ActivityFilter) => void;
}
function activityMatches(filter: ActivityFilter, e: AuditEvent): boolean {
if (filter === 'all') return true;
if (filter === 'enroll') return e.action === 'est_simple_enroll';
if (filter === 'reenroll') return e.action === 'est_simple_reenroll';
if (filter === 'serverkeygen') return e.action === 'est_server_keygen';
if (filter === 'authfail') return e.action === 'est_auth_failed';
return false;
}
const ACTIVITY_FILTERS: { id: ActivityFilter; label: string }[] = [
{ id: 'all', label: 'All' },
{ id: 'enroll', label: 'Enrollment' },
{ id: 'reenroll', label: 'Re-enrollment' },
{ id: 'serverkeygen', label: 'Server-keygen' },
{ id: 'authfail', label: 'Auth failure' },
];
function ActivityTab({ events, isLoading, filter, setFilter }: ActivityTabProps) {
const filtered = useMemo(() => events.filter(e => activityMatches(filter, e)), [events, filter]);
return (
<>
<div className="flex flex-wrap gap-2 mb-4" data-testid="est-activity-filters">
{ACTIVITY_FILTERS.map(f => (
<button
key={f.id}
type="button"
onClick={() => setFilter(f.id)}
data-testid={`est-activity-filter-${f.id}`}
aria-pressed={filter === f.id}
className={`text-xs px-3 py-1 rounded-full border ${pillClass(filter === f.id)}`}
>
{f.label}
</button>
))}
</div>
{isLoading && <p className="text-sm text-ink-muted">Loading audit events</p>}
{!isLoading && filtered.length === 0 && (
<p className="text-sm text-ink-muted">No events match the selected filter.</p>
)}
{!isLoading && filtered.length > 0 && (
<div className="bg-surface border border-surface-border rounded-lg overflow-hidden">
<table className="w-full text-sm" data-testid="est-activity-table">
<thead className="bg-surface-alt text-ink-muted text-xs uppercase tracking-wide">
<tr>
<th className="text-left px-3 py-2">Timestamp</th>
<th className="text-left px-3 py-2">Action</th>
<th className="text-left px-3 py-2">Subject</th>
<th className="text-left px-3 py-2">Resource</th>
</tr>
</thead>
<tbody>
{filtered.slice(0, 100).map((e, i) => (
<tr key={`${e.timestamp}-${i}`} className="border-t border-surface-border">
<td className="px-3 py-2 text-xs text-ink-muted">{formatDateTime(e.timestamp)}</td>
<td className="px-3 py-2 text-xs font-mono">{e.action}</td>
<td className="px-3 py-2 text-xs">{e.actor || '—'}</td>
<td className="px-3 py-2 text-xs">{e.resource_id || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
);
}
// =============================================================================
// Trust Bundle tab.
// =============================================================================
interface TrustBundleTabProps {
profiles: ESTStatsSnapshot[];
}
function TrustBundleTab({ profiles }: TrustBundleTabProps) {
const mtlsProfiles = profiles.filter(p => p.mtls_enabled && p.trust_anchors && p.trust_anchors.length > 0);
if (mtlsProfiles.length === 0) {
return (
<div className="rounded border border-surface-border bg-surface-alt p-4 text-sm text-ink-muted">
No EST profiles have mTLS enabled. The Trust Bundle tab is only relevant when at least one
profile carries an <code>MTLS_CLIENT_CA_TRUST_BUNDLE_PATH</code>.
</div>
);
}
return (
<>
{mtlsProfiles.map(p => (
<section
key={p.path_id || '(root)'}
className="bg-surface border border-surface-border rounded-lg p-5 mb-4"
data-testid={`est-trust-card-${p.path_id}`}
>
<header className="flex items-center justify-between mb-2">
<h3 className="text-base font-semibold text-ink">{p.path_id || '(legacy root)'}</h3>
<span className="text-xs font-mono text-ink-muted">{p.trust_anchor_path}</span>
</header>
<table className="w-full text-sm">
<thead className="bg-surface-alt text-ink-muted text-xs uppercase tracking-wide">
<tr>
<th className="text-left px-3 py-2">Subject</th>
<th className="text-left px-3 py-2">Not before</th>
<th className="text-left px-3 py-2">Not after</th>
<th className="text-left px-3 py-2">Days remaining</th>
</tr>
</thead>
<tbody>
{(p.trust_anchors ?? []).map(a => (
<tr key={`${p.path_id}-${a.subject}-${a.not_after}`} className="border-t border-surface-border">
<td className="px-3 py-2 text-xs font-mono">{a.subject}</td>
<td className="px-3 py-2 text-xs">{formatDateTime(a.not_before)}</td>
<td className="px-3 py-2 text-xs">{formatDateTime(a.not_after)}</td>
<td className={`px-3 py-2 text-xs font-semibold ${a.expired ? 'text-red-600' : a.days_to_expiry < 30 ? 'text-amber-600' : 'text-emerald-600'}`}>
{a.expired ? 'EXPIRED' : `${a.days_to_expiry}d`}
</td>
</tr>
))}
</tbody>
</table>
</section>
))}
</>
);
}
// =============================================================================
// Top-level page.
// =============================================================================
function pickInitialTab(searchParams: URLSearchParams): TabId {
const fromQuery = searchParams.get('tab');
if (fromQuery === 'activity' || fromQuery === 'trust') return fromQuery;
return 'profiles';
}
export default function ESTAdminPage() {
const auth = useAuth();
const adminAccess = !auth.authRequired || auth.admin;
const [searchParams, setSearchParams] = useSearchParams();
const _location = useLocation();
void _location; // reserved for future deep-link cases (mirrors SCEPAdminPage)
const [activeTab, setActiveTab] = useState<TabId>(() => pickInitialTab(searchParams));
const [reloadTarget, setReloadTarget] = useState<ESTStatsSnapshot | null>(null);
const [reloadError, setReloadError] = useState<string | undefined>(undefined);
const [activityFilter, setActivityFilter] = useState<ActivityFilter>('all');
// Keep URL in sync with tab so deep links survive page reloads.
useEffect(() => {
const next = new URLSearchParams(searchParams);
if (activeTab === 'profiles') {
next.delete('tab');
} else {
next.set('tab', activeTab);
}
if (next.toString() !== searchParams.toString()) {
setSearchParams(next, { replace: true });
}
}, [activeTab, searchParams, setSearchParams]);
// Per-profile snapshot. Polled every 30s on the profiles tab.
const profilesQuery = useQuery({
queryKey: ['admin', 'est', 'profiles'],
queryFn: getAdminESTProfiles,
enabled: adminAccess,
refetchInterval: 30_000,
});
// EST audit-log queries — four parallel queries (one per action) so
// the activity tab can present a merged + filterable feed without a
// dedicated server endpoint.
const auditQueries = EST_AUDIT_ACTIONS.map(action =>
// eslint-disable-next-line react-hooks/rules-of-hooks
useQuery({
queryKey: ['audit', { action }],
queryFn: () => getAuditEvents({ action }),
enabled: adminAccess && activeTab === 'activity',
refetchInterval: 60_000,
}),
);
const allAuditEvents: AuditEvent[] = useMemo(() => {
const merged: AuditEvent[] = [];
for (const q of auditQueries) {
if (q.data?.data) merged.push(...q.data.data);
}
return merged.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [auditQueries.map(q => q.dataUpdatedAt).join('|')]);
const auditLoading = auditQueries.some(q => q.isLoading);
// M-009 useTrackedMutation guard: every mutation in this page MUST
// route through useTrackedMutation so the audit / progress hooks fire.
const reloadMutation = useTrackedMutation<
Awaited<ReturnType<typeof reloadAdminESTTrust>>,
Error,
string
>({
mutationFn: (pathID: string) => reloadAdminESTTrust(pathID),
invalidates: [['admin', 'est', 'profiles']],
onSuccess: () => {
setReloadTarget(null);
setReloadError(undefined);
},
onError: (err: Error) => {
setReloadError(err.message);
},
});
if (auth.authRequired && !auth.admin) {
return (
<>
<PageHeader title="EST Administration" subtitle="Admin-only observability surface" />
<div className="p-6">
<ErrorState
error={
new Error(
'Admin access required: this page exposes per-profile mTLS trust-anchor expiries, auth-mode posture, per-status enrollment counters, and an admin-only reload action. Sign in with an admin-tagged API key to view it.',
)
}
/>
</div>
</>
);
}
const profiles = profilesQuery.data?.profiles ?? [];
return (
<>
<PageHeader
title="EST Administration"
subtitle={`${profiles.length} EST profile${profiles.length === 1 ? '' : 's'} configured · per-profile observability + recent activity + trust-bundle viewer`}
action={
<button
type="button"
onClick={() => {
void profilesQuery.refetch();
}}
className="text-xs px-3 py-1.5 rounded border border-surface-border bg-surface hover:bg-surface-alt"
data-testid="est-refresh-stats-button"
>
Refresh now
</button>
}
/>
<div className="border-b border-surface-border bg-surface px-6">
<nav className="flex gap-1 -mb-px" data-testid="est-admin-tabs">
{(['profiles', 'activity', 'trust'] as TabId[]).map(t => (
<button
key={t}
type="button"
onClick={() => setActiveTab(t)}
className={`px-4 py-2.5 text-sm border-b-2 transition-colors ${
activeTab === t
? 'border-brand-500 text-brand-700 font-semibold'
: 'border-transparent text-ink-muted hover:text-ink hover:border-surface-border'
}`}
data-testid={`est-tab-${t}`}
aria-pressed={activeTab === t}
>
{TAB_LABELS[t]}
</button>
))}
</nav>
</div>
<div className="p-6 overflow-y-auto">
{profilesQuery.error && activeTab === 'profiles' && (
<ErrorState error={profilesQuery.error as Error} onRetry={() => profilesQuery.refetch()} />
)}
{activeTab === 'profiles' && !profilesQuery.error && (
<ProfilesTab
profiles={profiles}
isLoading={profilesQuery.isLoading}
onRequestReload={profile => {
setReloadError(undefined);
setReloadTarget(profile);
}}
/>
)}
{activeTab === 'activity' && (
<ActivityTab
events={allAuditEvents}
isLoading={auditLoading}
filter={activityFilter}
setFilter={setActivityFilter}
/>
)}
{activeTab === 'trust' && <TrustBundleTab profiles={profiles} />}
</div>
{reloadTarget && (
<ConfirmReloadModal
profile={reloadTarget}
onCancel={() => {
setReloadTarget(null);
setReloadError(undefined);
}}
onConfirm={() => reloadMutation.mutate(reloadTarget.path_id)}
pending={reloadMutation.isPending}
errorMessage={reloadError}
/>
)}
</>
);
}