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} + /> + )} + + ); +}