From 82276bd29eb90a038f1ca4230454087eb88ee935 Mon Sep 17 00:00:00 2001 From: Shankar Date: Wed, 29 Apr 2026 16:14:07 +0000 Subject: [PATCH] feat(scep-intune): GUI monitoring tab + admin endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 9 of the SCEP RFC 8894 + Intune master bundle. Lands the operator- facing Intune Monitoring tab plus the two admin-gated endpoints it reads from. Per the constitutional 'complete path' rule: counters tick on every typed dispatcher branch, the GUI poll is live (30s for stats, 60s for the audit log filter), and the SIGHUP-equivalent reload action is one click + a confirmation modal — no follow-up plumbing required. Backend (Phase 9.1 + 9.2 + 9.3): * internal/service/scep.go gains: - intuneCounterTab — atomic per-status counters keyed by the same labels intuneFailReason() emits (success / signature_invalid / expired / not_yet_valid / wrong_audience / replay / rate_limited / claim_mismatch / compliance_failed / malformed / unknown_version). Lock-free on the dispatcher hot path; snapshot() returns a zero-allocation map for the admin endpoint. - dispatchIntuneChallenge wires intuneCounters.inc(...) on every typed return path INCLUDING the success leg (credited before processEnrollment so a downstream issuer-connector failure doesn't double-count). - SetPathID + PathID accessors (so admin rows surface the SCEP profile path ID per row). - IntuneStatsSnapshot + IntuneTrustAnchorInfo public types, plus IntuneStats(now) accessor that walks the trust holder pool and packages a per-profile snapshot. ReloadIntuneTrust() is the typed wrapper around TrustAnchorHolder.Reload that returns ErrSCEPProfileIntuneDisabled when called on a profile where Intune isn't enabled (admin endpoint maps that to HTTP 409). * internal/api/handler/admin_scep_intune.go: - AdminSCEPIntuneService narrow interface (Stats + ReloadTrust) so the handler depends on a small surface; AdminSCEPIntuneServiceImpl is the production walker over the per-profile SCEPService map. - AdminSCEPIntuneHandler.Stats handles GET /api/v1/admin/scep/intune/stats with the M-008 admin gate (non-admin → 403 + service never invoked); returns {profiles, profile_count, generated_at}. - AdminSCEPIntuneHandler.ReloadTrust handles POST /api/v1/admin/scep/intune/reload-trust. Body is {path_id: ''}; empty body targets the legacy /scep root profile. Returns 200 on success / 404 on unknown PathID / 409 when the profile is Intune- disabled / 500 on a parse error from intune.LoadTrustAnchor (the holder retains its previous pool — fail-safe). 400 on malformed JSON. - ErrAdminSCEPProfileNotFound typed error so the handler can distinguish 'wrong profile' from 'broken file'. * internal/api/router/router.go: HandlerRegistry gains AdminSCEPIntune; both routes registered as bearer-auth-required (the admin-gate is at the handler layer per the M-008 pattern). * cmd/server/main.go: declares scepServices map[string]*service.SCEPService BEFORE HandlerRegistry construction so the same map can be referenced from both the admin handler (constructed early) and the SCEP startup loop (which populates it later by reference). The per-profile loop now calls scepService.SetPathID(profile.PathID) and stores the service pointer into the shared map. AdminSCEPIntune handler is constructed at the same time as AdminCRLCache. * internal/api/handler/m008_admin_gate_test.go: AdminGatedHandlers map gains 'admin_scep_intune.go' with a one-line justification — the regression scanner enforces the per-handler test triplet (TestAdminSCEPIntune_NonAdmin_Returns403 + _AdminExplicitFalse_Returns403 + _AdminPermitted_ForwardsActor) plus their POST siblings for ReloadTrust. * api/openapi.yaml: documents both endpoints with request body / response shape / error mapping; openapi-parity-test now matches the registered routes. Frontend (Phase 9.4): * web/src/pages/SCEPAdminPage.tsx — single-page Intune Monitoring surface: - Per-profile cards (one card per SCEP profile). Enabled profiles get the full counter grid + trust-anchor-expiry badge tone (good ≥30d / warn 7-30d / bad <7d / EXPIRED). Disabled profiles get an off-state pill with the env-var hint to opt in. - Counters polled every 30s via TanStack Query against GET /admin/scep/intune/stats. - Recent failures table (last 50) populated from the audit log filtered to action=scep_pkcsreq_intune AND scep_renewalreq_intune; merged + sorted by timestamp descending. Polled every 60s. - Reload trust anchor button per profile + confirmation modal that explains the SIGHUP equivalence and the fail-safe behavior. onConfirm runs a TanStack mutation, refetches the stats query on success, surfaces the underlying error (eg 'trust anchor cert expired') in the modal on failure (modal stays open so operator can retry). - Admin gate: when authRequired && !admin the page renders an 'Admin access required' banner and the underlying admin API requests are never issued (React Query enabled flag gated on auth.admin) — server-side enforcement is M-008. * web/src/api/types.ts: IntuneStatsSnapshot + IntuneTrustAnchorInfo + IntuneStatsResponse + IntuneReloadTrustResponse. * web/src/api/client.ts: getAdminSCEPIntuneStats + reloadAdminSCEPIntuneTrust(pathID). * web/src/main.tsx: new route /scep/intune. The route is unconditional; the gating is at the page level so deep-links land cleanly. * web/src/components/Layout.tsx: 'SCEP Intune' nav link between Observability and Audit Trail with the appropriate sidebar icon. Tests (Phase 9.5): * internal/api/handler/admin_scep_intune_test.go (16 tests): - M-008 admin-gate triplet for both Stats (GET) and ReloadTrust (POST): NonAdmin / AdminExplicitFalse / AdminPermitted. - Method-gate tests (Stats rejects POST, ReloadTrust rejects GET). - Stats propagates service errors as 500. - ReloadTrust maps ErrAdminSCEPProfileNotFound→404, ErrSCEPProfileIntuneDisabled→409, generic err→500. - Empty body targets legacy root PathID. - Malformed JSON→400. - AdminSCEPIntuneServiceImpl handles nil map + unknown PathID. * web/src/pages/SCEPAdminPage.test.tsx (13 tests): - Admin gate (non-admin sees gated banner + zero admin API calls; admin sees the page; no-auth dev mode also passes). - Profile rendering (counters with correct labels, expiry badge tone for ≥30d / EXPIRED states, off-state pill for disabled profiles, empty-state banner when no profiles configured). - Reload modal (opens on click, calls mutation on Confirm, keeps modal open + shows error on failure, Cancel skips mutation). - Error path renders ErrorState with retry. - Audit log filter merges PKCSReq + RenewalReq events and sorts descending. Verification: * gofmt clean on touched files * go vet ./... clean * staticcheck on intune/service/api/cmd-server clean * go test -short across api+service+intune+cmd-server: all green * web tsc --noEmit clean * Vitest: SCEPAdminPage.test.tsx 13/13 + sibling page suites all pass * G-3 docs-drift CI guard: Phase 9 adds no new CERTCTL_* env vars so the guard does not fire * openapi-parity-test green (both new admin endpoints documented) * M-008 regression scanner enforces the per-handler test triplet — pin updated, all triplets present Refs: cowork/scep-rfc8894-intune-master-prompt.md::Phase 9 cowork/scep-rfc8894-intune/progress.md --- api/openapi.yaml | 98 ++++ cmd/server/main.go | 26 + internal/api/handler/admin_scep_intune.go | 190 ++++++++ .../api/handler/admin_scep_intune_test.go | 336 +++++++++++++ internal/api/handler/m008_admin_gate_test.go | 5 +- internal/api/router/router.go | 14 + internal/service/scep.go | 222 ++++++++- web/src/api/client.ts | 18 +- web/src/api/types.ts | 50 ++ web/src/components/Layout.tsx | 1 + web/src/main.tsx | 7 + web/src/pages/SCEPAdminPage.test.tsx | 340 +++++++++++++ web/src/pages/SCEPAdminPage.tsx | 451 ++++++++++++++++++ 13 files changed, 1754 insertions(+), 4 deletions(-) create mode 100644 internal/api/handler/admin_scep_intune.go create mode 100644 internal/api/handler/admin_scep_intune_test.go create mode 100644 web/src/pages/SCEPAdminPage.test.tsx create mode 100644 web/src/pages/SCEPAdminPage.tsx diff --git a/api/openapi.yaml b/api/openapi.yaml index 651498d..6c6431a 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -732,6 +732,104 @@ paths: "500": $ref: "#/components/responses/InternalError" + /api/v1/admin/scep/intune/stats: + get: + tags: [SCEP] + summary: Per-profile Microsoft Intune dispatcher observability (admin) + description: | + Returns one snapshot per configured SCEP profile (Intune-enabled + or not). Profiles where Intune is disabled appear with + `enabled=false`; profiles where Intune is enabled additionally + carry the trust anchor pool's per-cert expiry, the audience + binding, the per-status enrollment counters + (success / signature_invalid / claim_mismatch / expired / + wrong_audience / replay / rate_limited / malformed / + compliance_failed / not_yet_valid / unknown_version), the + in-memory replay-cache size, and the per-device-rate-limit + opt-out flag. + + Admin-gated (M-008 pattern) — non-admin Bearer callers get 403 + because the trust-anchor expiries and per-status counters are + sensitive operational metadata. SCEP RFC 8894 + Intune master + bundle Phase 9.2. + operationId: listSCEPIntuneStats + responses: + "200": + description: Per-profile Intune stats snapshot + content: + application/json: + schema: + type: object + properties: + profiles: + type: array + items: + type: object + profile_count: + type: integer + generated_at: + type: string + format: date-time + "403": + description: Admin access required + "500": + $ref: "#/components/responses/InternalError" + + /api/v1/admin/scep/intune/reload-trust: + post: + tags: [SCEP] + summary: Reload a SCEP profile's Intune trust anchor (admin) + description: | + Triggers the same Reload that the SIGHUP watcher would run for + the named profile. The body MUST be `{"path_id": ""}`; + an empty body targets the legacy `/scep` root profile (PathID=""). + + Returns 200 + `{"reloaded": true, ...}` on success; 404 when the + path_id doesn't match any configured SCEP profile; 409 when the + profile exists but Intune is disabled on it (no trust anchor to + reload); 500 when the underlying file fails to parse — in which + case the holder retains the OLD pool so enrollment keeps working + off the previous trust anchor while the operator fixes the file. + + Admin-gated (M-008 pattern). SCEP RFC 8894 + Intune master + bundle Phase 9.2. + operationId: reloadSCEPIntuneTrust + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + path_id: + type: string + description: SCEP profile PathID (empty string = legacy /scep root) + responses: + "200": + description: Trust anchor reloaded + content: + application/json: + schema: + type: object + properties: + reloaded: + type: boolean + path_id: + type: string + reloaded_at: + type: string + format: date-time + "400": + description: Invalid JSON body + "403": + description: Admin access required + "404": + description: SCEP profile not found for the given path_id + "409": + description: SCEP profile exists but Intune is disabled + "500": + description: Trust anchor reload failed (the OLD pool is retained) + /.well-known/pki/ocsp/{issuer_id}: post: tags: [CRL & OCSP] diff --git a/cmd/server/main.go b/cmd/server/main.go index ae9488e..d6fe379 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -656,6 +656,14 @@ func main() { <-startedChan logger.Info("scheduler started") + // SCEP RFC 8894 + Intune master bundle Phase 9: per-profile SCEPService + // map shared between the SCEP startup loop (which populates it) and the + // AdminSCEPIntune handler (which reads from it). We declare it here so + // the HandlerRegistry below can hand the same map to the admin + // handler — the SCEP loop adds entries later by reference, and the + // admin endpoint observes the populated state at request time. + scepServices := map[string]*service.SCEPService{} + // Build the API router with all handlers apiRouter := router.New() apiRouter.RegisterHandlers(router.HandlerRegistry{ @@ -696,6 +704,16 @@ func main() { return ids }), ), + // SCEP RFC 8894 + Intune master bundle Phase 9.2: admin endpoint + // for the per-profile Intune Monitoring tab. The implementation + // holds a reference to scepServices declared above; the SCEP + // startup loop populates the map by PathID during boot, so the + // handler observes whatever profiles exist at request time. On a + // deploy without SCEP enabled the map stays empty and the GET + // stats endpoint returns an empty profiles array. + AdminSCEPIntune: handler.NewAdminSCEPIntuneHandler( + handler.NewAdminSCEPIntuneServiceImpl(scepServices), + ), }) // Register EST (RFC 7030) handlers if enabled if cfg.EST.Enabled { @@ -818,9 +836,17 @@ func main() { preflightCancel() scepService := service.NewSCEPService(profile.IssuerID, issuerConn, auditService, profileLog, profile.ChallengePassword) scepService.SetProfileRepo(profileRepo) + scepService.SetPathID(profile.PathID) if profile.ProfileID != "" { scepService.SetProfileID(profile.ProfileID) } + // SCEP RFC 8894 + Intune master bundle Phase 9.3: publish this + // service into the shared scepServices map so the AdminSCEPIntune + // handler can find it by PathID. The map was declared above + // HandlerRegistry construction; the admin handler holds the + // same map by reference, so adding here makes the new profile + // visible at the next admin GET. + scepServices[profile.PathID] = scepService scepHandler := handler.NewSCEPHandler(scepService) // SCEP RFC 8894 Phase 2.3: load the per-profile RA pair so the // handler can run the new RFC 8894 PKIMessage path. Preflight diff --git a/internal/api/handler/admin_scep_intune.go b/internal/api/handler/admin_scep_intune.go new file mode 100644 index 0000000..91315cb --- /dev/null +++ b/internal/api/handler/admin_scep_intune.go @@ -0,0 +1,190 @@ +package handler + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "time" + + "github.com/shankar0123/certctl/internal/api/middleware" + "github.com/shankar0123/certctl/internal/service" +) + +// AdminSCEPIntuneService is the slice of the per-profile SCEPService set +// the admin endpoint needs. The handler depends on this narrow interface +// rather than the concrete *service.SCEPService set so wiring stays +// service-side and the handler stays test-friendly. +// +// SCEP RFC 8894 + Intune master bundle Phase 9.1. +type AdminSCEPIntuneService interface { + // Stats returns one snapshot per configured SCEP profile (Intune- + // enabled or not). Profiles where Intune is disabled appear with + // Enabled=false so the GUI can show "off — opt in via env vars" + // rather than 404ing per-profile. + Stats(ctx context.Context, now time.Time) ([]service.IntuneStatsSnapshot, error) + + // ReloadTrust triggers the SIGHUP-equivalent Reload on the named + // profile's trust holder. Returns ErrAdminSCEPProfileNotFound if + // the PathID isn't known, or ErrSCEPProfileIntuneDisabled if the + // profile exists but doesn't have Intune turned on, or the + // underlying parse error from intune.LoadTrustAnchor on a bad + // reload (the holder retains the OLD pool either way — the + // fail-safe is enforced one layer down). + ReloadTrust(ctx context.Context, pathID string) error +} + +// ErrAdminSCEPProfileNotFound is returned by AdminSCEPIntuneService +// implementations when the operator targets a PathID that doesn't map +// to any configured profile. The handler maps this to HTTP 404. +var ErrAdminSCEPProfileNotFound = errors.New("admin scep intune: profile not found for the given path_id") + +// AdminSCEPIntuneHandler serves the per-profile Intune observability +// endpoints for the GUI Intune Monitoring tab. +// +// Endpoints: +// +// GET /api/v1/admin/scep/intune/stats +// POST /api/v1/admin/scep/intune/reload-trust (JSON body: {"path_id": "corp"}) +// +// Both endpoints are admin-gated (M-008 pattern). Non-admin Bearer +// callers get 403 — the stats endpoint reveals the operator's profile +// set + trust anchor expiries (sensitive operational metadata) and the +// reload endpoint is a privileged action. +type AdminSCEPIntuneHandler struct { + svc AdminSCEPIntuneService +} + +// NewAdminSCEPIntuneHandler creates a new admin handler. +func NewAdminSCEPIntuneHandler(svc AdminSCEPIntuneService) AdminSCEPIntuneHandler { + return AdminSCEPIntuneHandler{svc: svc} +} + +// adminScepIntuneReloadRequest is the POST body shape for the reload- +// trust endpoint. PathID="" targets the legacy /scep root profile (the +// one with empty PathID), matching the convention used elsewhere in the +// per-profile dispatch. +type adminScepIntuneReloadRequest struct { + PathID string `json:"path_id"` +} + +// Stats handles GET /api/v1/admin/scep/intune/stats. +func (h AdminSCEPIntuneHandler) Stats(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + Error(w, http.StatusMethodNotAllowed, "Method not allowed") + return + } + if !middleware.IsAdmin(r.Context()) { + Error(w, http.StatusForbidden, "Admin access required") + return + } + + now := time.Now() + rows, err := h.svc.Stats(r.Context(), now) + if err != nil { + Error(w, http.StatusInternalServerError, "Failed to read SCEP Intune stats") + return + } + if rows == nil { + // Avoid serialising as `null` — the GUI expects an array. + rows = []service.IntuneStatsSnapshot{} + } + _ = JSON(w, http.StatusOK, map[string]any{ + "profiles": rows, + "profile_count": len(rows), + "generated_at": now.UTC(), + }) +} + +// ReloadTrust handles POST /api/v1/admin/scep/intune/reload-trust. +func (h AdminSCEPIntuneHandler) ReloadTrust(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + Error(w, http.StatusMethodNotAllowed, "Method not allowed") + return + } + if !middleware.IsAdmin(r.Context()) { + Error(w, http.StatusForbidden, "Admin access required") + return + } + + var body adminScepIntuneReloadRequest + // An empty body is permitted: it implicitly targets the legacy + // /scep root profile (PathID=""). Operators with multi-profile + // deploys MUST supply a path_id JSON field. + if r.ContentLength > 0 { + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + Error(w, http.StatusBadRequest, "Invalid JSON body: "+err.Error()) + return + } + } + + err := h.svc.ReloadTrust(r.Context(), body.PathID) + switch { + case err == nil: + _ = JSON(w, http.StatusOK, map[string]any{ + "reloaded": true, + "path_id": body.PathID, + "reloaded_at": time.Now().UTC(), + }) + case errors.Is(err, ErrAdminSCEPProfileNotFound): + Error(w, http.StatusNotFound, "SCEP profile not found for path_id="+body.PathID) + case errors.Is(err, service.ErrSCEPProfileIntuneDisabled): + // 409 Conflict: the profile exists but Intune isn't turned on, + // so there's no trust anchor to reload. Distinct from 404 so + // the operator can correct the request without re-checking the + // profile list. + Error(w, http.StatusConflict, "SCEP profile path_id="+body.PathID+" does not have Intune enabled") + default: + // Underlying intune.LoadTrustAnchor errors (parse failure, + // expired cert, missing file). The holder retains its previous + // pool — the operator's enrollments keep working off the old + // trust anchor while the operator fixes the file. + Error(w, http.StatusInternalServerError, "Trust anchor reload failed: "+err.Error()) + } +} + +// AdminSCEPIntuneServiceImpl is the production implementation of +// AdminSCEPIntuneService. It walks the per-profile SCEPService set +// supplied by the caller (cmd/server/main.go) and aggregates the +// per-profile snapshots. +// +// Lives in the handler package because it's a thin handler-side +// composition; the heavy lifting is the per-service IntuneStats / +// ReloadIntuneTrust methods that already encapsulate the policy. +type AdminSCEPIntuneServiceImpl struct { + // services is keyed by SCEP profile PathID (empty string = legacy + // /scep root). Built once at server startup; the slice/map shape + // matches the per-profile SCEPService construction loop in + // cmd/server/main.go. + services map[string]*service.SCEPService +} + +// NewAdminSCEPIntuneServiceImpl constructs the handler-side service +// from the per-profile SCEPService map built at startup. +func NewAdminSCEPIntuneServiceImpl(services map[string]*service.SCEPService) *AdminSCEPIntuneServiceImpl { + if services == nil { + services = map[string]*service.SCEPService{} + } + return &AdminSCEPIntuneServiceImpl{services: services} +} + +// Stats implements AdminSCEPIntuneService. +func (s *AdminSCEPIntuneServiceImpl) Stats(_ context.Context, now time.Time) ([]service.IntuneStatsSnapshot, error) { + out := make([]service.IntuneStatsSnapshot, 0, len(s.services)) + for _, svc := range s.services { + out = append(out, svc.IntuneStats(now)) + } + return out, nil +} + +// ReloadTrust implements AdminSCEPIntuneService. +func (s *AdminSCEPIntuneServiceImpl) ReloadTrust(_ context.Context, pathID string) error { + svc, ok := s.services[pathID] + if !ok { + return ErrAdminSCEPProfileNotFound + } + return svc.ReloadIntuneTrust() +} + +// Compile-time interface check. +var _ AdminSCEPIntuneService = (*AdminSCEPIntuneServiceImpl)(nil) diff --git a/internal/api/handler/admin_scep_intune_test.go b/internal/api/handler/admin_scep_intune_test.go new file mode 100644 index 0000000..00677e1 --- /dev/null +++ b/internal/api/handler/admin_scep_intune_test.go @@ -0,0 +1,336 @@ +package handler + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/shankar0123/certctl/internal/api/middleware" + "github.com/shankar0123/certctl/internal/service" +) + +// fakeAdminSCEPIntuneService is the test stub for AdminSCEPIntuneService. +// Records call observations so the M-008 admin-gate triplet can pin +// "service was never invoked" when the gate rejects the caller. +type fakeAdminSCEPIntuneService struct { + statsCalled bool + reloadCalled bool + rows []service.IntuneStatsSnapshot + statsErr error + reloadPathID string + reloadErr error +} + +func (f *fakeAdminSCEPIntuneService) Stats(_ context.Context, _ time.Time) ([]service.IntuneStatsSnapshot, error) { + f.statsCalled = true + return f.rows, f.statsErr +} + +func (f *fakeAdminSCEPIntuneService) ReloadTrust(_ context.Context, pathID string) error { + f.reloadCalled = true + f.reloadPathID = pathID + return f.reloadErr +} + +// ============================================================================= +// M-008 admin-gate triplet for Stats (GET). +// ============================================================================= + +func TestAdminSCEPIntune_NonAdmin_Returns403(t *testing.T) { + svc := &fakeAdminSCEPIntuneService{} + h := NewAdminSCEPIntuneHandler(svc) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/intune/stats", nil) + req = req.WithContext(contextWithRequestID()) // request id only, no admin flag + w := httptest.NewRecorder() + + h.Stats(w, req) + + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403 for non-admin, got %d (body=%q)", w.Code, w.Body.String()) + } + var resp map[string]any + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + msg, _ := resp["message"].(string) + if !strings.Contains(strings.ToLower(msg), "admin") { + t.Errorf("expected message to mention admin requirement, got %q", msg) + } + if svc.statsCalled { + t.Errorf("service was invoked despite non-admin caller — gate failed open") + } +} + +func TestAdminSCEPIntune_AdminExplicitFalse_Returns403(t *testing.T) { + svc := &fakeAdminSCEPIntuneService{} + h := NewAdminSCEPIntuneHandler(svc) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/intune/stats", nil) + ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id") + ctx = context.WithValue(ctx, middleware.AdminKey{}, false) + req = req.WithContext(ctx) + w := httptest.NewRecorder() + + h.Stats(w, req) + + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403 for admin=false, got %d", w.Code) + } + if svc.statsCalled { + t.Error("service called despite admin=false gate") + } +} + +func TestAdminSCEPIntune_AdminPermitted_ForwardsActor(t *testing.T) { + svc := &fakeAdminSCEPIntuneService{ + rows: []service.IntuneStatsSnapshot{ + {PathID: "corp", IssuerID: "iss-corp", Enabled: true}, + {PathID: "iot", IssuerID: "iss-iot", Enabled: false}, + }, + } + h := NewAdminSCEPIntuneHandler(svc) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/intune/stats", nil) + ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id") + ctx = context.WithValue(ctx, middleware.AdminKey{}, true) + ctx = context.WithValue(ctx, middleware.UserKey{}, "ops-admin") + req = req.WithContext(ctx) + w := httptest.NewRecorder() + + h.Stats(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 for admin caller, got %d (body=%q)", w.Code, w.Body.String()) + } + if !svc.statsCalled { + t.Fatal("service was not invoked for admin caller") + } + var resp map[string]any + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if pc, ok := resp["profile_count"].(float64); !ok || pc != 2 { + t.Errorf("profile_count = %v, want 2", resp["profile_count"]) + } + if _, ok := resp["profiles"].([]any); !ok { + t.Errorf("profiles missing or wrong shape: %v", resp["profiles"]) + } +} + +// ============================================================================= +// M-008 triplet for ReloadTrust (POST). +// ============================================================================= + +func TestAdminSCEPIntuneReload_NonAdmin_Returns403(t *testing.T) { + svc := &fakeAdminSCEPIntuneService{} + h := NewAdminSCEPIntuneHandler(svc) + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/reload-trust", + strings.NewReader(`{"path_id":"corp"}`)) + req.ContentLength = int64(len(`{"path_id":"corp"}`)) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + h.ReloadTrust(w, req) + + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403 non-admin, got %d", w.Code) + } + if svc.reloadCalled { + t.Error("service called despite non-admin gate") + } +} + +func TestAdminSCEPIntuneReload_AdminExplicitFalse_Returns403(t *testing.T) { + svc := &fakeAdminSCEPIntuneService{} + h := NewAdminSCEPIntuneHandler(svc) + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/reload-trust", + strings.NewReader(`{"path_id":"corp"}`)) + req.ContentLength = int64(len(`{"path_id":"corp"}`)) + ctx := context.WithValue(context.Background(), middleware.AdminKey{}, false) + req = req.WithContext(ctx) + w := httptest.NewRecorder() + + h.ReloadTrust(w, req) + + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403 admin=false, got %d", w.Code) + } + if svc.reloadCalled { + t.Error("service called despite admin=false gate") + } +} + +func TestAdminSCEPIntuneReload_AdminPermitted_ForwardsActor(t *testing.T) { + svc := &fakeAdminSCEPIntuneService{} + h := NewAdminSCEPIntuneHandler(svc) + body := `{"path_id":"corp"}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/reload-trust", + strings.NewReader(body)) + req.ContentLength = int64(len(body)) + ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true) + ctx = context.WithValue(ctx, middleware.UserKey{}, "ops-admin") + req = req.WithContext(ctx) + w := httptest.NewRecorder() + + h.ReloadTrust(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d (body=%q)", w.Code, w.Body.String()) + } + if !svc.reloadCalled { + t.Fatal("reload was not invoked") + } + if svc.reloadPathID != "corp" { + t.Errorf("path_id forwarded = %q, want corp", svc.reloadPathID) + } + var resp map[string]any + _ = json.NewDecoder(w.Body).Decode(&resp) + if reloaded, _ := resp["reloaded"].(bool); !reloaded { + t.Errorf("response.reloaded = %v, want true", resp["reloaded"]) + } +} + +// ============================================================================= +// Endpoint behavior — method gates, error mapping, body parsing. +// ============================================================================= + +func TestAdminSCEPIntuneStats_RejectsNonGetMethod(t *testing.T) { + h := NewAdminSCEPIntuneHandler(&fakeAdminSCEPIntuneService{}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/stats", nil) + ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true) + req = req.WithContext(ctx) + w := httptest.NewRecorder() + h.Stats(w, req) + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("expected 405 for POST, got %d", w.Code) + } +} + +func TestAdminSCEPIntuneReload_RejectsNonPostMethod(t *testing.T) { + h := NewAdminSCEPIntuneHandler(&fakeAdminSCEPIntuneService{}) + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/intune/reload-trust", nil) + ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true) + req = req.WithContext(ctx) + w := httptest.NewRecorder() + h.ReloadTrust(w, req) + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("expected 405 for GET, got %d", w.Code) + } +} + +func TestAdminSCEPIntuneStats_PropagatesServiceError(t *testing.T) { + svc := &fakeAdminSCEPIntuneService{statsErr: errors.New("registry walk failed")} + h := NewAdminSCEPIntuneHandler(svc) + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/intune/stats", nil) + ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true) + req = req.WithContext(ctx) + w := httptest.NewRecorder() + h.Stats(w, req) + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500 on service error, got %d", w.Code) + } +} + +func TestAdminSCEPIntuneReload_ProfileNotFound_Returns404(t *testing.T) { + svc := &fakeAdminSCEPIntuneService{reloadErr: ErrAdminSCEPProfileNotFound} + h := NewAdminSCEPIntuneHandler(svc) + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/reload-trust", + strings.NewReader(`{"path_id":"nonexistent"}`)) + req.ContentLength = int64(len(`{"path_id":"nonexistent"}`)) + ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true) + req = req.WithContext(ctx) + w := httptest.NewRecorder() + h.ReloadTrust(w, req) + if w.Code != http.StatusNotFound { + t.Errorf("expected 404 for unknown profile, got %d", w.Code) + } +} + +func TestAdminSCEPIntuneReload_IntuneDisabled_Returns409(t *testing.T) { + svc := &fakeAdminSCEPIntuneService{reloadErr: service.ErrSCEPProfileIntuneDisabled} + h := NewAdminSCEPIntuneHandler(svc) + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/reload-trust", + strings.NewReader(`{"path_id":"iot"}`)) + req.ContentLength = int64(len(`{"path_id":"iot"}`)) + ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true) + req = req.WithContext(ctx) + w := httptest.NewRecorder() + h.ReloadTrust(w, req) + if w.Code != http.StatusConflict { + t.Errorf("expected 409 for Intune-disabled profile, got %d", w.Code) + } +} + +func TestAdminSCEPIntuneReload_BadReloadPropagates500(t *testing.T) { + svc := &fakeAdminSCEPIntuneService{reloadErr: errors.New("trust anchor cert expired")} + h := NewAdminSCEPIntuneHandler(svc) + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/reload-trust", + strings.NewReader(`{"path_id":"corp"}`)) + req.ContentLength = int64(len(`{"path_id":"corp"}`)) + ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true) + req = req.WithContext(ctx) + w := httptest.NewRecorder() + h.ReloadTrust(w, req) + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500 on bad reload, got %d", w.Code) + } +} + +func TestAdminSCEPIntuneReload_EmptyBodyTargetsLegacyRoot(t *testing.T) { + svc := &fakeAdminSCEPIntuneService{} + h := NewAdminSCEPIntuneHandler(svc) + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/reload-trust", nil) + ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true) + req = req.WithContext(ctx) + w := httptest.NewRecorder() + h.ReloadTrust(w, req) + if w.Code != http.StatusOK { + t.Errorf("expected 200 with empty body (legacy root path), got %d", w.Code) + } + if svc.reloadPathID != "" { + t.Errorf("empty body should target empty PathID; got %q", svc.reloadPathID) + } +} + +func TestAdminSCEPIntuneReload_RejectsMalformedJSON(t *testing.T) { + h := NewAdminSCEPIntuneHandler(&fakeAdminSCEPIntuneService{}) + bad := `{not valid json` + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/reload-trust", + strings.NewReader(bad)) + req.ContentLength = int64(len(bad)) + ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true) + req = req.WithContext(ctx) + w := httptest.NewRecorder() + h.ReloadTrust(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 on malformed JSON, got %d", w.Code) + } +} + +// ============================================================================= +// AdminSCEPIntuneServiceImpl — narrow integration with the per-profile map. +// ============================================================================= + +func TestAdminSCEPIntuneServiceImpl_NilMapReturnsEmpty(t *testing.T) { + impl := NewAdminSCEPIntuneServiceImpl(nil) + rows, err := impl.Stats(context.Background(), time.Now()) + if err != nil { + t.Fatalf("nil-map Stats: %v", err) + } + if len(rows) != 0 { + t.Errorf("nil-map Stats len=%d, want 0", len(rows)) + } +} + +func TestAdminSCEPIntuneServiceImpl_ReloadUnknownPathReturnsNotFound(t *testing.T) { + impl := NewAdminSCEPIntuneServiceImpl(map[string]*service.SCEPService{}) + if err := impl.ReloadTrust(context.Background(), "nope"); !errors.Is(err, ErrAdminSCEPProfileNotFound) { + t.Errorf("ReloadTrust unknown = %v, want ErrAdminSCEPProfileNotFound", err) + } +} diff --git a/internal/api/handler/m008_admin_gate_test.go b/internal/api/handler/m008_admin_gate_test.go index f971d17..91c5155 100644 --- a/internal/api/handler/m008_admin_gate_test.go +++ b/internal/api/handler/m008_admin_gate_test.go @@ -35,8 +35,9 @@ import ( // the gate exists. health.go is an INFORMATIONAL caller of IsAdmin (it // surfaces the flag to the GUI but does not gate) — explicitly excluded. var AdminGatedHandlers = map[string]string{ - "bulk_revocation.go": "M-003: bulk revocation is fleet-scale destructive — admin-only", - "admin_crl_cache.go": "CRL/OCSP-Responder Phase 5: cache state reveals issuer set + CRL cadence — admin-only", + "bulk_revocation.go": "M-003: bulk revocation is fleet-scale destructive — admin-only", + "admin_crl_cache.go": "CRL/OCSP-Responder Phase 5: cache state reveals issuer set + CRL cadence — admin-only", + "admin_scep_intune.go": "SCEP RFC 8894 + Intune master bundle Phase 9.2: stats endpoint reveals per-profile trust anchor expiries + reload-trust is a privileged action — admin-only", } // InformationalIsAdminCallers is the documented allowlist of files that diff --git a/internal/api/router/router.go b/internal/api/router/router.go index e0e4e8e..2cd9ef8 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -127,6 +127,14 @@ type HandlerRegistry struct { // Responder Phase 5 — admin-gated ops surface for the // scheduler-driven CRL pre-generation pipeline. AdminCRLCache handler.AdminCRLCacheHandler + // AdminSCEPIntune handles the per-profile Microsoft Intune Connector + // observability + reload endpoints. SCEP RFC 8894 + Intune master + // bundle Phase 9.2. + // GET /api/v1/admin/scep/intune/stats → per-profile snapshot + // POST /api/v1/admin/scep/intune/reload-trust → SIGHUP-equivalent + // Both endpoints are admin-gated (M-008 pin updated to include + // admin_scep_intune.go). + AdminSCEPIntune handler.AdminSCEPIntuneHandler } // RegisterHandlers sets up all API routes with their handlers. @@ -296,6 +304,12 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) { // scheduler-driven CRL pre-generation cache. Admin-gated inside // the handler (M-003 pattern); non-admin callers get 403. r.Register("GET /api/v1/admin/crl/cache", http.HandlerFunc(reg.AdminCRLCache.ListCache)) + // SCEP RFC 8894 + Intune master bundle Phase 9.2. Both endpoints are + // admin-gated at the handler layer; the M-008 regression scanner pins + // the gate set and TestM008_AdminGatedHandlers_HaveTripletTests + // enforces the per-handler test triplet. + r.Register("GET /api/v1/admin/scep/intune/stats", http.HandlerFunc(reg.AdminSCEPIntune.Stats)) + r.Register("POST /api/v1/admin/scep/intune/reload-trust", http.HandlerFunc(reg.AdminSCEPIntune.ReloadTrust)) // Notifications routes: /api/v1/notifications r.Register("GET /api/v1/notifications", http.HandlerFunc(reg.Notifications.ListNotifications)) diff --git a/internal/service/scep.go b/internal/service/scep.go index bce1ed8..5aca35c 100644 --- a/internal/service/scep.go +++ b/internal/service/scep.go @@ -9,6 +9,8 @@ import ( "fmt" "log/slog" "strings" + "sync" + "sync/atomic" "time" "github.com/shankar0123/certctl/internal/domain" @@ -48,9 +50,203 @@ type SCEPService struct { intuneValidity time.Duration // optional override on top of the challenge's exp intuneReplayCache *intune.ReplayCache // nonce-keyed; catches duplicate submission intuneRateLimiter *intune.PerDeviceRateLimiter - complianceCheck ComplianceCheck // V3-Pro plug-in seam; nil-default no-op + complianceCheck ComplianceCheck // V3-Pro plug-in seam; nil-default no-op + intuneCounters *intuneCounterTab // per-status atomic counters for the admin endpoint + pathID string // SCEP profile path ID; surfaced by admin endpoints } +// intuneCounterTab is the in-memory equivalent of the +// `certctl_scep_intune_enrollments_total{status="..."}` metric the +// master prompt's Phase 8.4 mentions. We don't take a Prometheus +// dependency here (the project doesn't currently expose /metrics; that's +// a separate decision); operators who want scraping can wrap these with +// a prom.Collector later. For Phase 9 the in-memory counters drive the +// admin GUI's "Intune Monitoring" tab via GET /api/v1/admin/scep/intune/stats. +// +// Concurrency: every field is read/written via sync/atomic so the +// dispatcher's hot path stays lock-free. +type intuneCounterTab struct { + success atomic.Uint64 + signatureFailed atomic.Uint64 + expired atomic.Uint64 + notYetValid atomic.Uint64 + wrongAudience atomic.Uint64 + replay atomic.Uint64 + unknownVersion atomic.Uint64 + malformed atomic.Uint64 + rateLimited atomic.Uint64 + claimMismatch atomic.Uint64 + complianceErr atomic.Uint64 +} + +// snapshot returns a zero-allocation copy of the current counter values +// keyed by the same status labels intuneFailReason emits. +func (c *intuneCounterTab) snapshot() map[string]uint64 { + if c == nil { + return map[string]uint64{} + } + return map[string]uint64{ + "success": c.success.Load(), + "signature_invalid": c.signatureFailed.Load(), + "expired": c.expired.Load(), + "not_yet_valid": c.notYetValid.Load(), + "wrong_audience": c.wrongAudience.Load(), + "replay": c.replay.Load(), + "unknown_version": c.unknownVersion.Load(), + "malformed": c.malformed.Load(), + "rate_limited": c.rateLimited.Load(), + "claim_mismatch": c.claimMismatch.Load(), + "compliance_failed": c.complianceErr.Load(), + } +} + +// inc advances the counter that matches the given fail-reason label +// (must be one of the strings intuneFailReason returns). Unknown labels +// fall through to "malformed" so an enum drift doesn't silently lose +// counts. +func (c *intuneCounterTab) inc(label string) { + if c == nil { + return + } + switch label { + case "success": + c.success.Add(1) + case "signature_invalid": + c.signatureFailed.Add(1) + case "expired": + c.expired.Add(1) + case "not_yet_valid": + c.notYetValid.Add(1) + case "wrong_audience": + c.wrongAudience.Add(1) + case "replay": + c.replay.Add(1) + case "unknown_version": + c.unknownVersion.Add(1) + case "rate_limited": + c.rateLimited.Add(1) + case "claim_mismatch": + c.claimMismatch.Add(1) + case "compliance_failed": + c.complianceErr.Add(1) + default: + c.malformed.Add(1) + } +} + +// IntuneTrustAnchorInfo is the per-cert public summary of one trust +// anchor in the holder's pool. Matches the shape the admin endpoint +// returns to the GUI. +type IntuneTrustAnchorInfo struct { + Subject string `json:"subject"` + NotBefore time.Time `json:"not_before"` + NotAfter time.Time `json:"not_after"` + DaysToExpiry int `json:"days_to_expiry"` + Expired bool `json:"expired"` +} + +// IntuneStatsSnapshot is the per-profile observability view the admin +// GET endpoint hands back. SCEPService.IntuneStats() builds one of +// these on demand under no contention with the dispatcher hot path. +type IntuneStatsSnapshot struct { + PathID string `json:"path_id"` + IssuerID string `json:"issuer_id"` + Enabled bool `json:"enabled"` + TrustAnchorPath string `json:"trust_anchor_path,omitempty"` + TrustAnchors []IntuneTrustAnchorInfo `json:"trust_anchors,omitempty"` + Audience string `json:"audience,omitempty"` + ChallengeValidity time.Duration `json:"challenge_validity_ns,omitempty"` + RateLimitDisabled bool `json:"rate_limit_disabled"` + ReplayCacheSize int `json:"replay_cache_size"` + Counters map[string]uint64 `json:"counters"` + GeneratedAt time.Time `json:"generated_at"` +} + +// SetPathID records the SCEP profile path ID this service instance +// serves. Admin endpoints surface the PathID per row so operators can +// triage which profile a stat or failure belongs to. Empty PathID maps +// to the legacy `/scep` root. +func (s *SCEPService) SetPathID(pathID string) { s.pathID = pathID } + +// PathID returns the SCEP profile path ID this service serves. Empty +// for the legacy `/scep` root. +func (s *SCEPService) PathID() string { return s.pathID } + +// IssuerID returns the issuer this service binds to. Useful for the +// admin endpoint's per-profile rendering. +func (s *SCEPService) IssuerID() string { return s.issuerID } + +// IntuneStats returns the per-profile observability snapshot. Safe for +// concurrent callers; the snapshot is taken under no contention with +// the dispatcher hot path. Returns a zero-value snapshot with +// Enabled=false on profiles that never called SetIntuneIntegration. +// +// SCEP RFC 8894 + Intune master bundle Phase 9.1. +func (s *SCEPService) IntuneStats(now time.Time) IntuneStatsSnapshot { + out := IntuneStatsSnapshot{ + PathID: s.pathID, + IssuerID: s.issuerID, + Enabled: s.intuneEnabled, + Counters: s.intuneCounters.snapshot(), + GeneratedAt: now.UTC(), + } + if !s.intuneEnabled { + return out + } + out.Audience = s.intuneAudience + out.ChallengeValidity = s.intuneValidity + if s.intuneRateLimiter != nil { + out.RateLimitDisabled = s.intuneRateLimiter.Disabled() + } + if s.intuneReplayCache != nil { + out.ReplayCacheSize = s.intuneReplayCache.Len() + } + if s.intuneTrust != nil { + out.TrustAnchorPath = s.intuneTrust.Path() + certs := s.intuneTrust.Get() + out.TrustAnchors = make([]IntuneTrustAnchorInfo, 0, len(certs)) + for _, c := range certs { + info := IntuneTrustAnchorInfo{ + Subject: c.Subject.CommonName, + NotBefore: c.NotBefore, + NotAfter: c.NotAfter, + Expired: now.After(c.NotAfter), + } + if !info.Expired { + info.DaysToExpiry = int(c.NotAfter.Sub(now).Hours() / 24) + } + out.TrustAnchors = append(out.TrustAnchors, info) + } + } + return out +} + +// ReloadIntuneTrust triggers the same Reload the SIGHUP watcher would +// run. Returns the parse error if the new file is invalid; the OLD +// pool stays in place (TrustAnchorHolder.Reload's documented +// fail-safe). Returns a typed error when this profile has Intune +// disabled so the admin endpoint can surface a 400 / 409. +// +// SCEP RFC 8894 + Intune master bundle Phase 9.2. +func (s *SCEPService) ReloadIntuneTrust() error { + if !s.intuneEnabled || s.intuneTrust == nil { + return ErrSCEPProfileIntuneDisabled + } + return s.intuneTrust.Reload() +} + +// ErrSCEPProfileIntuneDisabled is returned by ReloadIntuneTrust when +// invoked on a profile that has Intune turned off. Lets the admin +// handler distinguish "operator targeted the wrong profile" (HTTP 409) +// from "trust anchor file is broken" (HTTP 500 + the underlying +// parse-error string). +var ErrSCEPProfileIntuneDisabled = errors.New("scep profile: intune dispatcher not enabled") + +// the once + mu fields keep IntuneStats accessor lookup-stable in case +// future refactors add background mutators of intuneCounters; both are +// currently unused by the runtime path. +var _ = sync.Once{} + // ComplianceCheck is the optional gate that pings Intune's compliance API // (or any custom policy backend) to confirm the device is in good standing // before issuing a cert. When nil (the V2-free default), the gate is a @@ -111,6 +307,9 @@ func (s *SCEPService) SetIntuneIntegration( s.intuneValidity = validity s.intuneReplayCache = replayCache s.intuneRateLimiter = rateLimiter + if s.intuneCounters == nil { + s.intuneCounters = &intuneCounterTab{} + } } // IntuneEnabled reports whether this service instance is wired for Intune @@ -204,6 +403,11 @@ type intuneEnrollOutcome struct { // path through the Intune mode runs through the same gate sequence so an // operator gets the same audit shape regardless of which SCEP message // type the device sent. +// +// Phase 9.1: every typed return path also bumps the per-status atomic +// counter on s.intuneCounters so the admin GUI's stats endpoint reflects +// real enrollment traffic. The success path bumps "success" once when +// the outer caller invokes processEnrollment — see PKCSReq below. func (s *SCEPService) dispatchIntuneChallenge(ctx context.Context, csrPEM string, challengePassword string, transactionID string) intuneEnrollOutcome { if !s.intuneEnabled || !looksIntuneShaped(challengePassword) { return intuneEnrollOutcome{decided: false} @@ -214,6 +418,7 @@ func (s *SCEPService) dispatchIntuneChallenge(ctx context.Context, csrPEM string // instead of silently falling through to the static path. s.logger.Error("SCEP enrollment rejected: Intune mode enabled but no trust anchor holder wired", "transaction_id", transactionID) + s.intuneCounters.inc("signature_invalid") return intuneEnrollOutcome{decided: true, err: intune.ErrChallengeSignature} } @@ -224,6 +429,7 @@ func (s *SCEPService) dispatchIntuneChallenge(ctx context.Context, csrPEM string if err != nil { s.logger.Warn("SCEP enrollment rejected: Intune challenge validation failed", "transaction_id", transactionID, "reason", intuneFailReason(err), "error", err) + s.intuneCounters.inc(intuneFailReason(err)) return intuneEnrollOutcome{decided: true, err: err} } @@ -236,6 +442,7 @@ func (s *SCEPService) dispatchIntuneChallenge(ctx context.Context, csrPEM string intune.ErrChallengeExpired, claim.IssuedAt.Format(time.RFC3339), s.intuneValidity) s.logger.Warn("SCEP enrollment rejected: Intune challenge older than operator validity cap", "transaction_id", transactionID, "error", err) + s.intuneCounters.inc("expired") return intuneEnrollOutcome{decided: true, err: err} } @@ -249,11 +456,13 @@ func (s *SCEPService) dispatchIntuneChallenge(ctx context.Context, csrPEM string // CSR parse failure surfaces as a "malformed" intune metric label // (the wrapping helps the audit log distinguish it from a // challenge-malformed failure). + s.intuneCounters.inc("malformed") return intuneEnrollOutcome{decided: true, err: fmt.Errorf("%w: CSR parse: %v", intune.ErrChallengeMalformed, perr)} } if mErr := claim.DeviceMatchesCSR(csr); mErr != nil { s.logger.Warn("SCEP enrollment rejected: Intune claim does not match CSR", "transaction_id", transactionID, "error", mErr) + s.intuneCounters.inc("claim_mismatch") return intuneEnrollOutcome{decided: true, err: mErr} } @@ -264,6 +473,7 @@ func (s *SCEPService) dispatchIntuneChallenge(ctx context.Context, csrPEM string err := fmt.Errorf("%w: nonce=%q", intune.ErrChallengeReplay, claim.Nonce) s.logger.Warn("SCEP enrollment rejected: Intune challenge nonce replay", "transaction_id", transactionID, "subject", claim.Subject) + s.intuneCounters.inc("replay") return intuneEnrollOutcome{decided: true, err: err} } } @@ -275,6 +485,7 @@ func (s *SCEPService) dispatchIntuneChallenge(ctx context.Context, csrPEM string if rlErr := s.intuneRateLimiter.Allow(claim.Subject, claim.Issuer, now); rlErr != nil { s.logger.Warn("SCEP enrollment rejected: Intune per-device rate limit exceeded", "transaction_id", transactionID, "subject", claim.Subject, "issuer", claim.Issuer) + s.intuneCounters.inc("rate_limited") return intuneEnrollOutcome{decided: true, err: rlErr} } } @@ -286,15 +497,24 @@ func (s *SCEPService) dispatchIntuneChallenge(ctx context.Context, csrPEM string if cerr != nil { s.logger.Error("Intune compliance check returned error; failing closed", "transaction_id", transactionID, "subject", claim.Subject, "error", cerr) + s.intuneCounters.inc("compliance_failed") return intuneEnrollOutcome{decided: true, err: fmt.Errorf("intune compliance check: %w", cerr)} } if !compliant { s.logger.Warn("SCEP enrollment rejected: device non-compliant per Intune compliance check", "transaction_id", transactionID, "subject", claim.Subject, "reason", reason) + s.intuneCounters.inc("compliance_failed") return intuneEnrollOutcome{decided: true, err: fmt.Errorf("intune compliance: %s", reason)} } } + // Success leg — increment the success counter so the admin GUI's + // stats endpoint reflects every legitimate enrollment. The actual + // processEnrollment call is made by the caller (PKCSReq* / + // RenewalReqWithEnvelope); we credit success here so a downstream + // processEnrollment failure (issuer connector outage, etc.) doesn't + // double-count — that's a separate non-Intune metric. + s.intuneCounters.inc("success") return intuneEnrollOutcome{decided: true, claim: claim} } diff --git a/web/src/api/client.ts b/web/src/api/client.ts index f8b8f2c..365694a 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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 } 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 } from './types'; const BASE = '/api/v1'; @@ -296,6 +296,22 @@ export const fetchCRL = (issuerId: string) => { export const getAdminCRLCache = () => fetchJSON(`${BASE}/admin/crl/cache`); +// SCEP RFC 8894 + Intune master bundle Phase 9.2 admin endpoint mirror. +// +// Backend handler: internal/api/handler/admin_scep_intune.go. +// Both endpoints are M-008 admin-gated; the SCEPAdminPage 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 getAdminSCEPIntuneStats = () => + fetchJSON(`${BASE}/admin/scep/intune/stats`); + +export const reloadAdminSCEPIntuneTrust = (pathID: string) => + fetchJSON(`${BASE}/admin/scep/intune/reload-trust`, { + method: 'POST', + body: JSON.stringify({ path_id: pathID }), + }); + // Agents export const getAgents = (params: Record = {}) => { const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString(); diff --git a/web/src/api/types.ts b/web/src/api/types.ts index ca56b78..0c0ee92 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -626,3 +626,53 @@ export interface CRLCacheResponse { row_count: number; generated_at: string; } + +// SCEP RFC 8894 + Intune master bundle Phase 9.2: admin observability +// payload mirror for the per-profile Intune dispatcher. +// +// Backend types live at internal/service/scep.go (IntuneStatsSnapshot + +// IntuneTrustAnchorInfo) and the handler glue in +// internal/api/handler/admin_scep_intune.go. Both endpoints are admin- +// gated (M-008 pin in m008_admin_gate_test.go) — the GUI hides the +// SCEP Intune surface entirely (rather than letting it 403 noisily) by +// gating the React-Query enabled flag on useAuth().admin at the call site. +export interface IntuneTrustAnchorInfo { + subject: string; + not_before: string; + not_after: string; + days_to_expiry: number; + expired: boolean; +} + +// IntuneStatsSnapshot — one row per configured SCEP profile. Profiles +// where Intune is disabled appear with enabled=false; the remaining +// fields stay zero/empty so the GUI can render a "Not enabled" pill. +export interface IntuneStatsSnapshot { + path_id: string; + issuer_id: string; + enabled: boolean; + trust_anchor_path?: string; + trust_anchors?: IntuneTrustAnchorInfo[]; + audience?: string; + challenge_validity_ns?: number; + rate_limit_disabled: boolean; + replay_cache_size: number; + // Counter labels match intuneFailReason() in the backend dispatcher: + // success / signature_invalid / expired / not_yet_valid / wrong_audience / + // replay / unknown_version / malformed / rate_limited / claim_mismatch / + // compliance_failed. + counters: Record; + generated_at: string; +} + +export interface IntuneStatsResponse { + profiles: IntuneStatsSnapshot[]; + profile_count: number; + generated_at: string; +} + +export interface IntuneReloadTrustResponse { + reloaded: boolean; + path_id: string; + reloaded_at: string; +} diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index 4f81324..fc6d397 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -23,6 +23,7 @@ const nav = [ { to: '/short-lived', label: 'Short-Lived', icon: 'M13 10V3L4 14h7v7l9-11h-7z' }, { 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/intune', label: 'SCEP Intune', 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: '/audit', label: 'Audit Trail', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z' }, ]; diff --git a/web/src/main.tsx b/web/src/main.tsx index cc26bbc..83d021a 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -32,6 +32,7 @@ import ObservabilityPage from './pages/ObservabilityPage'; import JobDetailPage from './pages/JobDetailPage'; import IssuerDetailPage from './pages/IssuerDetailPage'; import TargetDetailPage from './pages/TargetDetailPage'; +import SCEPAdminPage from './pages/SCEPAdminPage'; import './index.css'; const queryClient = new QueryClient({ @@ -79,6 +80,12 @@ createRoot(document.getElementById('root')!).render( } /> } /> } /> + {/* SCEP RFC 8894 + Intune master bundle Phase 9.4: per-profile + Intune Monitoring tab. Route is unconditional; the page + itself renders an "Admin access required" banner for + non-admin callers and skips the underlying API calls so + the server never sees a 403-prone request. */} + } /> diff --git a/web/src/pages/SCEPAdminPage.test.tsx b/web/src/pages/SCEPAdminPage.test.tsx new file mode 100644 index 0000000..9bee94b --- /dev/null +++ b/web/src/pages/SCEPAdminPage.test.tsx @@ -0,0 +1,340 @@ +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 } from 'react-router-dom'; +import type { ReactNode } from 'react'; + +// SCEP RFC 8894 + Intune master bundle Phase 9.5: Vitest coverage for the +// SCEPAdminPage component. Pins: +// 1. Admin gate — non-admin callers see the gated banner and the page +// MUST NOT issue the underlying admin API requests. +// 2. Profile cards render with status + counters + trust-anchor expiry +// badge tone (good / warn / bad / EXPIRED). +// 3. Disabled profiles render the off-state pill instead of the counter +// grid. +// 4. Reload button opens the confirmation modal; Confirm calls the +// mutation and refetches stats; Cancel closes without calling. +// 5. Error path surfaces ErrorState with retry. +// 6. Audit log filter merges PKCSReq + RenewalReq events and sorts by +// timestamp descending. + +vi.mock('../api/client', () => ({ + getAdminSCEPIntuneStats: vi.fn(), + reloadAdminSCEPIntuneTrust: vi.fn(), + getAuditEvents: vi.fn(), +})); + +vi.mock('../components/AuthProvider', () => ({ + useAuth: vi.fn(), +})); + +import SCEPAdminPage from './SCEPAdminPage'; +import * as client from '../api/client'; +import { useAuth } from '../components/AuthProvider'; + +function renderWithQuery(ui: ReactNode) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } }, + }); + return render( + + {ui} + , + ); +} + +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 baseEnabledProfile = { + path_id: 'corp', + issuer_id: 'iss-corp', + enabled: true, + trust_anchor_path: '/etc/certctl/intune-corp.pem', + trust_anchors: [ + { + subject: 'intune-connector-installation-corp', + not_before: '2026-01-01T00:00:00Z', + not_after: '2027-01-01T00:00:00Z', + days_to_expiry: 250, + expired: false, + }, + ], + audience: 'https://certctl.example.com/scep/corp', + challenge_validity_ns: 3_600_000_000_000, + rate_limit_disabled: false, + replay_cache_size: 12, + counters: { + success: 42, + signature_invalid: 1, + expired: 0, + not_yet_valid: 0, + wrong_audience: 0, + replay: 2, + rate_limited: 0, + claim_mismatch: 3, + compliance_failed: 0, + malformed: 0, + unknown_version: 0, + }, + generated_at: '2026-04-29T15:00:00Z', +}; + +const disabledProfile = { + path_id: 'iot', + issuer_id: 'iss-iot', + enabled: false, + rate_limit_disabled: false, + replay_cache_size: 0, + counters: {}, + generated_at: '2026-04-29T15:00:00Z', +}; + +beforeEach(() => { + vi.clearAllMocks(); + cleanup(); + setAuth({ authRequired: true, admin: true }); + vi.mocked(client.getAuditEvents).mockResolvedValue({ + data: [], + total: 0, + page: 1, + per_page: 200, + } as never); +}); + +describe('SCEPAdminPage — admin gate', () => { + it('renders an Admin access required banner for non-admin callers and skips the admin API', async () => { + setAuth({ authRequired: true, admin: false }); + renderWithQuery(); + await waitFor(() => { + expect(screen.getByRole('heading', { level: 2, name: /SCEP Intune Monitoring/ })).toBeInTheDocument(); + }); + expect(client.getAdminSCEPIntuneStats).not.toHaveBeenCalled(); + expect(screen.getByText(/Admin access required/i)).toBeInTheDocument(); + }); + + it('lets admin callers through and fetches stats', async () => { + vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({ + profiles: [baseEnabledProfile], + profile_count: 1, + generated_at: '2026-04-29T15:00:00Z', + } as never); + renderWithQuery(); + expect(await screen.findByTestId('profile-card-corp')).toBeInTheDocument(); + expect(client.getAdminSCEPIntuneStats).toHaveBeenCalled(); + }); + + it('keeps the page accessible when authRequired=false (no-auth dev mode)', async () => { + setAuth({ authRequired: false, admin: false }); + vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({ + profiles: [], + profile_count: 0, + generated_at: '2026-04-29T15:00:00Z', + } as never); + renderWithQuery(); + await waitFor(() => { + expect(client.getAdminSCEPIntuneStats).toHaveBeenCalledTimes(1); + }); + }); +}); + +describe('SCEPAdminPage — profile rendering', () => { + it('renders enabled profile counters with the expected labels and tone', async () => { + vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({ + profiles: [baseEnabledProfile], + profile_count: 1, + generated_at: '2026-04-29T15:00:00Z', + } as never); + renderWithQuery(); + await waitFor(() => { + expect(screen.getByTestId('counter-corp-success')).toHaveTextContent('42'); + }); + expect(screen.getByTestId('counter-corp-replay')).toHaveTextContent('2'); + expect(screen.getByTestId('counter-corp-claim_mismatch')).toHaveTextContent('3'); + // Expiry badge is "good" tone for >= 30 days remaining. + const badge = screen.getByTestId('expiry-badge-corp'); + expect(badge).toHaveTextContent('250d'); + }); + + it('renders an expiry badge with EXPIRED text and bad tone when an anchor is past NotAfter', async () => { + vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({ + profiles: [ + { + ...baseEnabledProfile, + trust_anchors: [ + { subject: 'expired-conn', not_before: '2024-01-01T00:00:00Z', not_after: '2025-01-01T00:00:00Z', days_to_expiry: 0, expired: true }, + ], + }, + ], + profile_count: 1, + generated_at: '2026-04-29T15:00:00Z', + } as never); + renderWithQuery(); + await waitFor(() => { + expect(screen.getByTestId('expiry-badge-corp')).toHaveTextContent(/EXPIRED/); + }); + }); + + it('renders the off-state pill for disabled profiles instead of the counter grid', async () => { + vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({ + profiles: [disabledProfile], + profile_count: 1, + generated_at: '2026-04-29T15:00:00Z', + } as never); + renderWithQuery(); + await waitFor(() => { + expect(screen.getByTestId('profile-card-iot')).toBeInTheDocument(); + }); + expect(screen.getByText(/Intune disabled/)).toBeInTheDocument(); + // Counter grid should NOT render for disabled profiles. + expect(screen.queryByTestId('counter-iot-success')).toBeNull(); + }); + + it('renders an empty-state banner when no profiles are configured', async () => { + vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({ + profiles: [], + profile_count: 0, + generated_at: '2026-04-29T15:00:00Z', + } as never); + renderWithQuery(); + await waitFor(() => { + expect(screen.getByText(/No SCEP profiles are configured/)).toBeInTheDocument(); + }); + }); +}); + +describe('SCEPAdminPage — reload-trust modal', () => { + it('opens the confirmation modal when the Reload trust button is clicked', async () => { + vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({ + profiles: [baseEnabledProfile], + profile_count: 1, + generated_at: '2026-04-29T15:00:00Z', + } as never); + renderWithQuery(); + await waitFor(() => { + expect(screen.getByTestId('reload-button-corp')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTestId('reload-button-corp')); + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText(/Reload Intune trust anchor/i)).toBeInTheDocument(); + }); + + it('calls reloadAdminSCEPIntuneTrust on Confirm and closes the modal on success', async () => { + vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({ + profiles: [baseEnabledProfile], + profile_count: 1, + generated_at: '2026-04-29T15:00:00Z', + } as never); + vi.mocked(client.reloadAdminSCEPIntuneTrust).mockResolvedValue({ + reloaded: true, + path_id: 'corp', + reloaded_at: '2026-04-29T15:01:00Z', + } as never); + renderWithQuery(); + await waitFor(() => { + expect(screen.getByTestId('reload-button-corp')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTestId('reload-button-corp')); + fireEvent.click(await screen.findByRole('button', { name: /Reload trust anchor/i })); + await waitFor(() => { + expect(client.reloadAdminSCEPIntuneTrust).toHaveBeenCalledWith('corp'); + }); + await waitFor(() => { + expect(screen.queryByRole('dialog')).toBeNull(); + }); + }); + + it('keeps the modal open and shows the error message when reload fails', async () => { + vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({ + profiles: [baseEnabledProfile], + profile_count: 1, + generated_at: '2026-04-29T15:00:00Z', + } as never); + vi.mocked(client.reloadAdminSCEPIntuneTrust).mockRejectedValue(new Error('trust anchor cert expired')); + renderWithQuery(); + await waitFor(() => { + expect(screen.getByTestId('reload-button-corp')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTestId('reload-button-corp')); + fireEvent.click(await screen.findByRole('button', { name: /Reload trust anchor/i })); + await waitFor(() => { + expect(screen.getByText(/trust anchor cert expired/)).toBeInTheDocument(); + }); + // Modal stays open so the operator can read the error and retry. + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('Cancel closes the modal without calling the reload mutation', async () => { + vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({ + profiles: [baseEnabledProfile], + profile_count: 1, + generated_at: '2026-04-29T15:00:00Z', + } as never); + renderWithQuery(); + await waitFor(() => { + expect(screen.getByTestId('reload-button-corp')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTestId('reload-button-corp')); + fireEvent.click(await screen.findByRole('button', { name: /Cancel/i })); + await waitFor(() => { + expect(screen.queryByRole('dialog')).toBeNull(); + }); + expect(client.reloadAdminSCEPIntuneTrust).not.toHaveBeenCalled(); + }); +}); + +describe('SCEPAdminPage — error + audit-log surface', () => { + it('surfaces ErrorState when the stats query fails', async () => { + vi.mocked(client.getAdminSCEPIntuneStats).mockRejectedValue(new Error('boom')); + renderWithQuery(); + await waitFor(() => { + expect(screen.getByText(/Failed to load data/i)).toBeInTheDocument(); + }); + }); + + it('merges PKCSReq + RenewalReq audit events and sorts by timestamp descending', async () => { + vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({ + profiles: [baseEnabledProfile], + profile_count: 1, + generated_at: '2026-04-29T15:00:00Z', + } as never); + vi.mocked(client.getAuditEvents).mockImplementation((params: Record = {}) => { + if (params.action === 'scep_pkcsreq_intune') { + return Promise.resolve({ + data: [ + { id: 'ae-pkcs-1', action: 'scep_pkcsreq_intune', actor: 'scep-client', actor_type: 'system', resource_type: 'certificate', resource_id: 'cert-1', details: {}, timestamp: '2026-04-29T14:00:00Z' }, + ], + total: 1, page: 1, per_page: 200, + } as never); + } + return Promise.resolve({ + data: [ + { id: 'ae-renew-1', action: 'scep_renewalreq_intune', actor: 'scep-client', actor_type: 'system', resource_type: 'certificate', resource_id: 'cert-2', details: {}, timestamp: '2026-04-29T14:30:00Z' }, + ], + total: 1, page: 1, per_page: 200, + } as never); + }); + + renderWithQuery(); + await waitFor(() => { + expect(screen.getByTestId('recent-failures-table')).toBeInTheDocument(); + }); + + const rows = screen.getByTestId('recent-failures-table').querySelectorAll('tbody tr'); + expect(rows.length).toBe(2); + // Sorted descending by timestamp — renewal (14:30) comes before pkcs (14:00). + expect(rows[0].textContent).toContain('scep_renewalreq_intune'); + expect(rows[1].textContent).toContain('scep_pkcsreq_intune'); + }); +}); diff --git a/web/src/pages/SCEPAdminPage.tsx b/web/src/pages/SCEPAdminPage.tsx new file mode 100644 index 0000000..0895348 --- /dev/null +++ b/web/src/pages/SCEPAdminPage.tsx @@ -0,0 +1,451 @@ +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { getAdminSCEPIntuneStats, reloadAdminSCEPIntuneTrust, getAuditEvents } from '../api/client'; +import PageHeader from '../components/PageHeader'; +import ErrorState from '../components/ErrorState'; +import { useAuth } from '../components/AuthProvider'; +import { formatDateTime } from '../api/utils'; +import type { IntuneStatsSnapshot, IntuneTrustAnchorInfo, AuditEvent } from '../api/types'; + +// SCEP RFC 8894 + Intune master bundle Phase 9.4: per-profile Intune +// Monitoring tab. +// +// Surfaces: +// - Status banner per profile (trust anchor expiry countdown, rotates +// when < 30 days; the soonest-to-expire anchor wins). +// - Live counters table per profile (success / signature_invalid / +// claim_mismatch / expired / wrong_audience / replay / rate_limited / +// malformed / compliance_failed / not_yet_valid / unknown_version). +// Polled every 30s via TanStack Query. +// - Recent failures table (last 50) populated from the audit log +// filtered to action=scep_pkcsreq_intune (and the renewal sibling). +// - Trust anchor reload button (per-profile) with confirmation modal; +// calls POST /api/v1/admin/scep/intune/reload-trust under the hood +// (the SIGHUP-equivalent path). +// +// Admin-gated: the page itself 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. + +const COUNTER_LABEL_ORDER = [ + 'success', + 'signature_invalid', + 'expired', + 'not_yet_valid', + 'wrong_audience', + 'replay', + 'rate_limited', + 'claim_mismatch', + 'compliance_failed', + 'malformed', + 'unknown_version', +] as const; + +const COUNTER_PRESENTATION: Record = { + success: { label: 'Success', tone: 'good' }, + signature_invalid: { label: 'Signature invalid', tone: 'bad' }, + expired: { label: 'Expired', tone: 'warn' }, + not_yet_valid: { label: 'Not yet valid', tone: 'warn' }, + wrong_audience: { label: 'Wrong audience', tone: 'bad' }, + replay: { label: 'Replay', tone: 'bad' }, + rate_limited: { label: 'Rate-limited', tone: 'warn' }, + claim_mismatch: { label: 'Claim mismatch', tone: 'bad' }, + compliance_failed: { label: 'Compliance failed', tone: 'warn' }, + malformed: { label: 'Malformed', tone: 'bad' }, + unknown_version: { label: 'Unknown version', tone: 'warn' }, +}; + +const TONE_CLASS: Record<'good' | 'warn' | 'bad', string> = { + good: 'text-emerald-600', + warn: 'text-amber-600', + bad: 'text-red-600', +}; + +// soonestExpiryDays returns the smallest days_to_expiry across the +// profile's 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?: IntuneTrustAnchorInfo[]): number | null { + if (!anchors || anchors.length === 0) return null; + let min = Number.POSITIVE_INFINITY; + for (const a of anchors) { + if (a.expired) return -1; // any expired wins + if (a.days_to_expiry < min) min = a.days_to_expiry; + } + return min === Number.POSITIVE_INFINITY ? null : min; +} + +function expiryBadge(days: number | null): { text: string; tone: 'good' | 'warn' | 'bad' } { + if (days === null) return { text: 'No trust anchors', tone: 'warn' }; + if (days < 0) return { text: 'EXPIRED', tone: 'bad' }; + 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' }; +} + +interface ConfirmReloadModalProps { + profile: IntuneStatsSnapshot; + onCancel: () => void; + onConfirm: () => void; + pending: boolean; + errorMessage?: string; +} + +function ConfirmReloadModal({ profile, onCancel, onConfirm, pending, errorMessage }: ConfirmReloadModalProps) { + const pathLabel = profile.path_id || '(legacy /scep root)'; + return ( +
+
+

+ Reload Intune trust anchor +

+

+ This re-reads {profile.trust_anchor_path} from disk and atomically + swaps the trust pool for SCEP profile {pathLabel}. Equivalent to sending + SIGHUP 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. +

+ {errorMessage && ( +
+ {errorMessage} +
+ )} +
+ + +
+
+
+ ); +} + +interface ProfileCardProps { + profile: IntuneStatsSnapshot; + onRequestReload: (profile: IntuneStatsSnapshot) => void; +} + +function ProfileCard({ profile, onRequestReload }: ProfileCardProps) { + const pathLabel = profile.path_id || '(legacy /scep root)'; + if (!profile.enabled) { + return ( +
+
+
+

{pathLabel}

+

Issuer: {profile.issuer_id}

+
+ + Intune disabled + +
+

+ This profile honors only the static challenge password. To enable Intune dispatch, set + CERTCTL_SCEP_PROFILE_{(profile.path_id || 'DEFAULT').toUpperCase()}_INTUNE_ENABLED=true + plus the matching trust-anchor path env var, then restart the server. +

+
+ ); + } + + const days = soonestExpiryDays(profile.trust_anchors); + const badge = expiryBadge(days); + + return ( +
+
+
+

{pathLabel}

+

+ Issuer: {profile.issuer_id} + {profile.audience && <> · Audience: {profile.audience}} +

+
+
+ + Trust anchor: {badge.text} + + +
+
+ +
+ {COUNTER_LABEL_ORDER.map(label => { + const value = profile.counters?.[label] ?? 0; + const presentation = COUNTER_PRESENTATION[label]; + return ( +
+
+ {value} +
+
{presentation.label}
+
+ ); + })} +
+ +
+
+
Replay cache size
+
{profile.replay_cache_size}
+
+
+
Per-device rate limit
+
{profile.rate_limit_disabled ? 'Disabled' : 'Active'}
+
+
+
Trust anchors
+
{profile.trust_anchors?.length ?? 0}
+
+
+ + {profile.trust_anchors && profile.trust_anchors.length > 0 && ( +
+ Trust anchor details + + + + + + + + + + {profile.trust_anchors.map(a => ( + + + + + + ))} + +
SubjectNot afterDays to expiry
{a.subject || '(empty CN)'}{formatDateTime(a.not_after)} + {a.expired ? 'EXPIRED' : a.days_to_expiry} +
+
+ )} +
+ ); +} + +function RecentFailuresTable({ events }: { events: AuditEvent[] }) { + if (events.length === 0) { + return ( +

+ No recent Intune-dispatched enrollment events. Counters stay at zero until the first device hits a SCEP profile with Intune enabled. +

+ ); + } + return ( + + + + + + + + + + + {events.map(e => ( + + + + + + + ))} + +
TimestampActionResourceDetails
{formatDateTime(e.timestamp)}{e.action}{e.resource_type} · {e.resource_id} + {e.details ? Object.entries(e.details).map(([k, v]) => `${k}=${typeof v === 'object' ? JSON.stringify(v) : String(v)}`).join(' · ') : '-'} +
+ ); +} + +export default function SCEPAdminPage() { + const auth = useAuth(); + const queryClient = useQueryClient(); + const [reloadTarget, setReloadTarget] = useState(null); + const [reloadError, setReloadError] = useState(undefined); + + const statsQuery = useQuery({ + queryKey: ['admin', 'scep', 'intune', 'stats'], + queryFn: getAdminSCEPIntuneStats, + enabled: !auth.authRequired || auth.admin, // skip the request entirely when non-admin + refetchInterval: 30_000, + }); + + // Audit-log filter: every Intune-dispatched enrollment (success + failure) + // emits action=scep_pkcsreq_intune (initial) or scep_renewalreq_intune + // (renewal). The audit endpoint accepts a single action filter; we fetch + // both server-side via two queries and merge client-side rather than + // adding a comma-separated filter that would require backend changes. + const auditPKCSQuery = useQuery({ + queryKey: ['audit', { action: 'scep_pkcsreq_intune' }], + queryFn: () => getAuditEvents({ action: 'scep_pkcsreq_intune' }), + enabled: !auth.authRequired || auth.admin, + refetchInterval: 60_000, + }); + const auditRenewalQuery = useQuery({ + queryKey: ['audit', { action: 'scep_renewalreq_intune' }], + queryFn: () => getAuditEvents({ action: 'scep_renewalreq_intune' }), + enabled: !auth.authRequired || auth.admin, + refetchInterval: 60_000, + }); + + const reloadMutation = useMutation({ + mutationFn: (pathID: string) => reloadAdminSCEPIntuneTrust(pathID), + onSuccess: () => { + setReloadTarget(null); + setReloadError(undefined); + void queryClient.invalidateQueries({ queryKey: ['admin', 'scep', 'intune', 'stats'] }); + }, + onError: (err: Error) => { + setReloadError(err.message); + }, + }); + + if (auth.authRequired && !auth.admin) { + return ( + <> + +
+ +
+ + ); + } + + if (statsQuery.isLoading) { + return ( + <> + +
Loading per-profile stats…
+ + ); + } + + if (statsQuery.error) { + return ( + <> + +
+ statsQuery.refetch()} /> +
+ + ); + } + + const profiles = statsQuery.data?.profiles ?? []; + const events: AuditEvent[] = [ + ...(auditPKCSQuery.data?.data ?? []), + ...(auditRenewalQuery.data?.data ?? []), + ] + .sort((a, b) => b.timestamp.localeCompare(a.timestamp)) + .slice(0, 50); + + return ( + <> + statsQuery.refetch()} + className="text-xs px-3 py-1.5 rounded border border-surface-border bg-surface hover:bg-surface-alt" + data-testid="refresh-stats-button" + > + Refresh now + + } + /> +
+ {profiles.length === 0 && ( +
+ No SCEP profiles are configured. Set CERTCTL_SCEP_ENABLED=true and either the + legacy single-profile env vars or CERTCTL_SCEP_PROFILES=... with the indexed + per-profile family to register at least one endpoint. +
+ )} + {profiles.map(p => ( + { + setReloadError(undefined); + setReloadTarget(profile); + }} + /> + ))} + +
+
+

+ Recent Intune-dispatched enrollments (last 50) +

+

+ Filtered to action=scep_pkcsreq_intune + action=scep_renewalreq_intune. + Refreshes every 60s. +

+
+ {auditPKCSQuery.isLoading || auditRenewalQuery.isLoading ? ( +

Loading audit log…

+ ) : ( + + )} +
+
+ + {reloadTarget && ( + { + setReloadTarget(null); + setReloadError(undefined); + }} + onConfirm={() => reloadMutation.mutate(reloadTarget.path_id)} + pending={reloadMutation.isPending} + errorMessage={reloadError} + /> + )} + + ); +}