diff --git a/README.md b/README.md index 6501c75..afa2789 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ gantt |----------|----------|----------| | EST (Enrollment over Secure Transport) | RFC 7030 | Device enrollment, WiFi/802.1X, IoT | | SCEP (Simple Certificate Enrollment Protocol) | RFC 8894 | MDM platforms (Jamf, Intune), network devices, ChromeOS. Full RFC 8894 wire format: EnvelopedData decryption, signerInfo POPO verification, CertRep PKIMessage builder; PKCSReq + RenewalReq + GetCertInitial messageType dispatch; multi-profile dispatch (`/scep/`); per-profile RA cert + key. Lightweight raw-CSR clients keep working via the legacy MVP fall-through path. | -| **Microsoft Intune SCEP fleet (drop-in NDES replacement)** | RFC 8894 + Intune Connector signed-challenge dispatcher | Per-profile Intune dispatcher validates the Connector's signed challenge against an operator-supplied trust anchor; binds device claim to CSR (set-equality on CN + SAN-DNS/RFC822/UPN); replay cache + per-device rate limit; `SIGHUP`-reloadable trust pool; admin GUI Intune Monitoring tab (per-status counters, expiry countdown, recent failures). See [`docs/scep-intune.md`](docs/scep-intune.md) for the migration playbook + Microsoft support statement. | +| **Microsoft Intune SCEP fleet (drop-in NDES replacement)** | RFC 8894 + Intune Connector signed-challenge dispatcher | Per-profile Intune dispatcher validates the Connector's signed challenge against an operator-supplied trust anchor; binds device claim to CSR (set-equality on CN + SAN-DNS/RFC822/UPN); replay cache + per-device rate limit; `SIGHUP`-reloadable trust pool; admin GUI **SCEP Administration** page at `/scep` (Profiles tab with per-profile RA cert expiry + mTLS status, Intune Monitoring tab with per-status counters + reload, Recent Activity tab with full SCEP audit log filter). See [`docs/scep-intune.md`](docs/scep-intune.md) for the migration playbook + Microsoft support statement. | | ACME v2 | RFC 8555 | Public CA automated issuance (Let's Encrypt, ZeroSSL) | | ACME ARI (Renewal Information) | RFC 9773 | CA-directed renewal timing — the CA tells you when to renew | diff --git a/api/openapi.yaml b/api/openapi.yaml index 6c6431a..574400b 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -732,6 +732,54 @@ paths: "500": $ref: "#/components/responses/InternalError" + /api/v1/admin/scep/profiles: + get: + tags: [SCEP] + summary: Per-profile SCEP administration overview (admin) + description: | + Returns one snapshot per configured SCEP profile in the + SCEPProfileStatsSnapshot shape: always-present per-profile + fields (path_id, issuer_id, challenge_password_set, RA cert + subject + NotBefore/NotAfter + days-to-expiry, mTLS + sibling-route status, mTLS trust bundle path) plus an + optional `intune` sub-block when the profile has + INTUNE_ENABLED=true. + + Profiles where Intune is disabled appear with the `intune` + field omitted (rather than null) so the GUI's per-profile + card can render the lean shape without an Intune deep-dive + button. Profiles where Intune is enabled also appear in the + sibling /api/v1/admin/scep/intune/stats endpoint with the + flat Phase 9.2 shape preserved for backward compat. + + Admin-gated (M-008 pattern). Non-admin Bearer callers get + HTTP 403 — the snapshot reveals the operator's profile set, + RA cert expiries, and mTLS bundle paths (sensitive + operational metadata). SCEP RFC 8894 + Intune master bundle + Phase 9 follow-up. + operationId: listSCEPProfiles + responses: + "200": + description: Per-profile SCEP administration 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/stats: get: tags: [SCEP] diff --git a/cmd/server/main.go b/cmd/server/main.go index d6fe379..d48ff3d 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -837,6 +837,12 @@ func main() { scepService := service.NewSCEPService(profile.IssuerID, issuerConn, auditService, profileLog, profile.ChallengePassword) scepService.SetProfileRepo(profileRepo) scepService.SetPathID(profile.PathID) + // SCEP RFC 8894 + Intune master bundle Phase 9 follow-up: + // surface mTLS sibling-route status in the per-profile snapshot + // the new /admin/scep/profiles endpoint emits. The actual mTLS + // trust pool wiring lives further down in the if profile.MTLSEnabled + // block; this just records the flag + bundle path for observability. + scepService.SetMTLSConfig(profile.MTLSEnabled, profile.MTLSClientCATrustBundlePath) if profile.ProfileID != "" { scepService.SetProfileID(profile.ProfileID) } @@ -859,6 +865,11 @@ func main() { os.Exit(1) } scepHandler.SetRAPair(raCert, raKey) + // SCEP RFC 8894 + Intune master bundle Phase 9 follow-up: + // surface RA cert metadata (subject + NotBefore + NotAfter) in + // the per-profile snapshot so the new /admin/scep/profiles + // endpoint can drive the GUI's RA expiry countdown badge. + scepService.SetRACert(raCert) // SCEP RFC 8894 + Intune master bundle Phase 8: per-profile Intune // dispatcher wire-in. Builds the trust-anchor holder, replay cache, diff --git a/docs/architecture.md b/docs/architecture.md index b617dad..9dd444b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -863,12 +863,16 @@ startup via `intune.LoadTrustAnchor` (refuses to boot on empty bundle / parse error / past-`NotAfter` cert) and reloads atomically on `SIGHUP` (mirrors the server TLS-cert hot-reload pattern). A bad reload keeps the OLD pool in place — operators get a recoverable -failure window rather than a service-down. The admin GUI's SCEP -Intune Monitoring tab + admin endpoints -(`GET /api/v1/admin/scep/intune/stats`, -`POST /api/v1/admin/scep/intune/reload-trust`) are M-008 admin-gated; -non-admin Bearer callers get HTTP 403 because the trust-anchor -expiries are sensitive operational metadata. +failure window rather than a service-down. The admin GUI's +**Intune Monitoring** tab inside the SCEP Administration page (`/scep`) +and the parallel admin endpoints +(`GET /api/v1/admin/scep/profiles` for the always-present per-profile +overview that drives the Profiles tab, +`GET /api/v1/admin/scep/intune/stats` for the Intune deep dive, +`POST /api/v1/admin/scep/intune/reload-trust` for the SIGHUP-equivalent) +are all M-008 admin-gated; non-admin Bearer callers get HTTP 403 +because the trust-anchor expiries + RA cert expiries + mTLS bundle +paths are sensitive operational metadata. See [`scep-intune.md`](scep-intune.md) for the full migration playbook + Microsoft support statement. diff --git a/docs/legacy-est-scep.md b/docs/legacy-est-scep.md index 6971835..d6fa3b1 100644 --- a/docs/legacy-est-scep.md +++ b/docs/legacy-est-scep.md @@ -503,6 +503,12 @@ otherwise. field mapping, trust-anchor extraction recipe, troubleshooting matrix, operational monitoring, V3-Pro deferrals, and the Microsoft support statement (with Microsoft Learn URLs procurement teams ask for). +- **For per-profile SCEP observability** (RA cert expiry countdown, + mTLS sibling-route status, challenge-password-set indicator, and + the full SCEP audit log filter), the admin GUI page lives at `/scep` + with three tabs: **Profiles** (default), **Intune Monitoring**, + **Recent Activity**. See `scep-intune.md::Operational monitoring` + for the Intune-specific tab inside it. ## Related docs diff --git a/docs/scep-intune.md b/docs/scep-intune.md index dddf361..7cb3d56 100644 --- a/docs/scep-intune.md +++ b/docs/scep-intune.md @@ -245,9 +245,21 @@ common root cause and the operator action. | `malformed` | Sporadic, low-volume | Malformed challenge bytes — almost always a network proxy mangling the request body, or the Connector logging itself out mid-handshake. Capture a packet trace; the Connector should re-emit on the next device retry. | | `compliance_failed` | V3-Pro only | The pluggable compliance check returned non-compliant. The audit-log details carries the reason string from Microsoft Graph. V2 deployments never see this counter tick. | -## Operational monitoring +## Operational monitoring (SCEP Administration → Intune Monitoring tab) -The Phase 9 admin GUI surface (`/scep/intune`) shows: +The admin GUI surface for SCEP lives at `/scep` and is structured as +three tabs: **Profiles** (default landing — every configured SCEP +profile, lean cards with always-present fields), **Intune Monitoring** +(the Intune-specific deep-dive described below), and **Recent Activity** +(full SCEP audit log filter). Operators monitoring an Intune deployment +spend most of their time on the Intune Monitoring tab, deep-linkable via +`/scep?tab=intune` or the legacy alias `/scep/intune`. The Profiles tab +gives the at-a-glance per-profile health (RA cert expiry, mTLS status, +Intune enabled/disabled badge, challenge-password-set indicator) and a +"View Intune details →" link from each Intune-enabled card that switches +into this tab filtered to that profile. + +The Intune Monitoring tab shows: - **Per-profile cards** — one card per SCEP profile, with the trust anchor expiry countdown badge: @@ -266,11 +278,21 @@ The Phase 9 admin GUI surface (`/scep/intune`) shows: Bad reloads keep the OLD pool in place; the modal stays open with the underlying error so the operator can correct the file and retry. -Both admin endpoints (`GET /api/v1/admin/scep/intune/stats` and -`POST /api/v1/admin/scep/intune/reload-trust`) are M-008 admin-gated. -Non-admin Bearer callers get HTTP 403 + a clear message; the GUI -hides the page entirely for non-admin users (UX hint; server-side -enforcement is independent). +Three admin endpoints back the page: + +- `GET /api/v1/admin/scep/profiles` — per-profile snapshot for the + Profiles tab; surfaces RA cert subject + NotAfter + days-to-expiry, + mTLS sibling-route status + bundle path, challenge-password-set flag, + and an optional `intune` sub-block for Intune-enabled profiles. +- `GET /api/v1/admin/scep/intune/stats` — Intune-specific deep-dive + for the Intune Monitoring tab; per-status counters + trust anchor + pool details. Backward-compat shape preserved from Phase 9. +- `POST /api/v1/admin/scep/intune/reload-trust` — SIGHUP-equivalent + trust anchor reload, body `{"path_id": ""}`. + +All three are M-008 admin-gated. Non-admin Bearer callers get HTTP 403 ++ a clear message; the GUI hides the page entirely for non-admin users +(UX hint; server-side enforcement is independent). ### Recommended alert thresholds diff --git a/internal/api/handler/admin_scep_intune.go b/internal/api/handler/admin_scep_intune.go index 91315cb..001a631 100644 --- a/internal/api/handler/admin_scep_intune.go +++ b/internal/api/handler/admin_scep_intune.go @@ -16,14 +16,20 @@ import ( // 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. +// SCEP RFC 8894 + Intune master bundle Phase 9.1, extended in the +// Phase 9 follow-up (cowork/scep-gui-restructure-prompt.md) with +// Profiles for the per-profile SCEP Administration tab. 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. + // enabled or not) in the Phase 9.1 flat shape. Backward-compat for + // the existing /admin/scep/intune/stats endpoint. Stats(ctx context.Context, now time.Time) ([]service.IntuneStatsSnapshot, error) + // Profiles returns one snapshot per configured SCEP profile in the + // new shape (always-present per-profile fields + optional Intune + // sub-block). Backs the new /admin/scep/profiles endpoint. + Profiles(ctx context.Context, now time.Time) ([]service.SCEPProfileStatsSnapshot, 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 @@ -39,18 +45,20 @@ type AdminSCEPIntuneService interface { // 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. +// AdminSCEPIntuneHandler serves the per-profile SCEP observability +// endpoints for the GUI SCEP Administration page. // // Endpoints: // -// GET /api/v1/admin/scep/intune/stats -// POST /api/v1/admin/scep/intune/reload-trust (JSON body: {"path_id": "corp"}) +// GET /api/v1/admin/scep/profiles — Phase 9 follow-up +// GET /api/v1/admin/scep/intune/stats — Phase 9.2 +// POST /api/v1/admin/scep/intune/reload-trust — Phase 9.2 (JSON body: {"path_id": "corp"}) // -// Both endpoints are admin-gated (M-008 pattern). Non-admin Bearer +// All three 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. +// set + trust anchor expiries (sensitive operational metadata), the +// profiles endpoint additionally reveals RA cert expiries + mTLS bundle +// paths, and the reload endpoint is a privileged action. type AdminSCEPIntuneHandler struct { svc AdminSCEPIntuneService } @@ -68,6 +76,42 @@ type adminScepIntuneReloadRequest struct { PathID string `json:"path_id"` } +// Profiles handles GET /api/v1/admin/scep/profiles. +// +// Phase 9 follow-up endpoint backing the SCEP Administration page's +// Profiles tab. Returns one snapshot per configured SCEP profile in +// the SCEPProfileStatsSnapshot shape (always-present per-profile +// fields + optional Intune sub-block). +// +// Same M-008 admin gate as Stats. Profiles where Intune is disabled +// appear with Intune=null in the response. +func (h AdminSCEPIntuneHandler) Profiles(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.Profiles(r.Context(), now) + if err != nil { + Error(w, http.StatusInternalServerError, "Failed to read SCEP profiles") + return + } + if rows == nil { + // Avoid serialising as `null` — the GUI expects an array. + rows = []service.SCEPProfileStatsSnapshot{} + } + _ = JSON(w, http.StatusOK, map[string]any{ + "profiles": rows, + "profile_count": len(rows), + "generated_at": now.UTC(), + }) +} + // Stats handles GET /api/v1/admin/scep/intune/stats. func (h AdminSCEPIntuneHandler) Stats(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { @@ -177,6 +221,18 @@ func (s *AdminSCEPIntuneServiceImpl) Stats(_ context.Context, now time.Time) ([] return out, nil } +// Profiles implements AdminSCEPIntuneService for the new +// /admin/scep/profiles endpoint. Walks the same per-profile SCEPService +// map but emits the SCEPProfileStatsSnapshot shape (always-present +// fields + optional Intune sub-block). +func (s *AdminSCEPIntuneServiceImpl) Profiles(_ context.Context, now time.Time) ([]service.SCEPProfileStatsSnapshot, error) { + out := make([]service.SCEPProfileStatsSnapshot, 0, len(s.services)) + for _, svc := range s.services { + out = append(out, svc.ProfileStats(now)) + } + return out, nil +} + // ReloadTrust implements AdminSCEPIntuneService. func (s *AdminSCEPIntuneServiceImpl) ReloadTrust(_ context.Context, pathID string) error { svc, ok := s.services[pathID] diff --git a/internal/api/handler/admin_scep_intune_test.go b/internal/api/handler/admin_scep_intune_test.go index 00677e1..3aebee1 100644 --- a/internal/api/handler/admin_scep_intune_test.go +++ b/internal/api/handler/admin_scep_intune_test.go @@ -18,12 +18,15 @@ import ( // 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 + statsCalled bool + profilesCalled bool + reloadCalled bool + rows []service.IntuneStatsSnapshot + profileRows []service.SCEPProfileStatsSnapshot + statsErr error + profilesErr error + reloadPathID string + reloadErr error } func (f *fakeAdminSCEPIntuneService) Stats(_ context.Context, _ time.Time) ([]service.IntuneStatsSnapshot, error) { @@ -31,6 +34,11 @@ func (f *fakeAdminSCEPIntuneService) Stats(_ context.Context, _ time.Time) ([]se return f.rows, f.statsErr } +func (f *fakeAdminSCEPIntuneService) Profiles(_ context.Context, _ time.Time) ([]service.SCEPProfileStatsSnapshot, error) { + f.profilesCalled = true + return f.profileRows, f.profilesErr +} + func (f *fakeAdminSCEPIntuneService) ReloadTrust(_ context.Context, pathID string) error { f.reloadCalled = true f.reloadPathID = pathID @@ -334,3 +342,154 @@ func TestAdminSCEPIntuneServiceImpl_ReloadUnknownPathReturnsNotFound(t *testing. t.Errorf("ReloadTrust unknown = %v, want ErrAdminSCEPProfileNotFound", err) } } + +// ============================================================================= +// M-008 admin-gate triplet for Profiles (GET) — Phase 9 follow-up endpoint. +// ============================================================================= + +func TestAdminSCEPProfiles_NonAdmin_Returns403(t *testing.T) { + svc := &fakeAdminSCEPIntuneService{} + h := NewAdminSCEPIntuneHandler(svc) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/profiles", nil) + req = req.WithContext(contextWithRequestID()) // request id only, no admin flag + w := httptest.NewRecorder() + + h.Profiles(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.profilesCalled { + t.Errorf("service was invoked despite non-admin caller — gate failed open") + } +} + +func TestAdminSCEPProfiles_AdminExplicitFalse_Returns403(t *testing.T) { + svc := &fakeAdminSCEPIntuneService{} + h := NewAdminSCEPIntuneHandler(svc) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/profiles", 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.Profiles(w, req) + + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403 for admin=false, got %d", w.Code) + } + if svc.profilesCalled { + t.Error("service called despite admin=false gate") + } +} + +func TestAdminSCEPProfiles_AdminPermitted_ForwardsActor(t *testing.T) { + svc := &fakeAdminSCEPIntuneService{ + profileRows: []service.SCEPProfileStatsSnapshot{ + { + PathID: "corp", + IssuerID: "iss-corp", + ChallengePasswordSet: true, + MTLSEnabled: true, + Intune: &service.IntuneSection{ + Audience: "https://certctl.example.com/scep/corp", + }, + }, + { + PathID: "iot", + IssuerID: "iss-iot", + ChallengePasswordSet: true, + // Intune nil — disabled + }, + }, + } + h := NewAdminSCEPIntuneHandler(svc) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/profiles", 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.Profiles(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.profilesCalled { + 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"]) + } + rows, ok := resp["profiles"].([]any) + if !ok || len(rows) != 2 { + t.Fatalf("profiles missing or wrong shape: %v", resp["profiles"]) + } + // Find the Intune-enabled vs Intune-disabled row by path_id and + // assert the Intune sub-block is present/absent accordingly. + for _, raw := range rows { + row := raw.(map[string]any) + switch row["path_id"] { + case "corp": + if _, has := row["intune"]; !has { + t.Errorf("expected corp profile to carry an intune sub-block") + } + case "iot": + if _, has := row["intune"]; has { + t.Errorf("expected iot profile to OMIT the intune sub-block (Intune disabled)") + } + } + } +} + +func TestAdminSCEPProfiles_RejectsNonGetMethod(t *testing.T) { + h := NewAdminSCEPIntuneHandler(&fakeAdminSCEPIntuneService{}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/profiles", nil) + ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true) + req = req.WithContext(ctx) + w := httptest.NewRecorder() + h.Profiles(w, req) + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("expected 405 for POST, got %d", w.Code) + } +} + +func TestAdminSCEPProfiles_PropagatesServiceError(t *testing.T) { + svc := &fakeAdminSCEPIntuneService{profilesErr: errors.New("registry walk failed")} + h := NewAdminSCEPIntuneHandler(svc) + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/profiles", nil) + ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true) + req = req.WithContext(ctx) + w := httptest.NewRecorder() + h.Profiles(w, req) + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500 on service error, got %d", w.Code) + } +} + +func TestAdminSCEPProfilesServiceImpl_NilMapReturnsEmpty(t *testing.T) { + impl := NewAdminSCEPIntuneServiceImpl(nil) + rows, err := impl.Profiles(context.Background(), time.Now()) + if err != nil { + t.Fatalf("nil-map Profiles: %v", err) + } + if len(rows) != 0 { + t.Errorf("nil-map Profiles len=%d, want 0", len(rows)) + } +} diff --git a/internal/api/handler/m008_admin_gate_test.go b/internal/api/handler/m008_admin_gate_test.go index 91c5155..8b0cd43 100644 --- a/internal/api/handler/m008_admin_gate_test.go +++ b/internal/api/handler/m008_admin_gate_test.go @@ -35,9 +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", - "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", + "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 + Phase 9 follow-up: profiles + stats endpoints reveal per-profile RA cert expiries + Intune trust anchor expiries + mTLS bundle paths; 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 2cd9ef8..b161178 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -304,10 +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 + // SCEP RFC 8894 + Intune master bundle Phase 9.2 + Phase 9 follow-up + // (cowork/scep-gui-restructure-prompt.md). All three 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/profiles", http.HandlerFunc(reg.AdminSCEPIntune.Profiles)) 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)) diff --git a/internal/service/scep.go b/internal/service/scep.go index 5aca35c..e116d4b 100644 --- a/internal/service/scep.go +++ b/internal/service/scep.go @@ -53,6 +53,18 @@ type SCEPService struct { 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 + + // Per-profile metadata surfaced by the new /admin/scep/profiles + // endpoint. SCEP RFC 8894 + Intune master bundle Phase 9 follow-up + // (cowork/scep-gui-restructure-prompt.md). All fields are nil/zero + // when the operator runs without Intune AND without mTLS — we still + // surface the always-present challenge-password-set + RA cert + // expiry on the Profiles tab for those. + raCertSubject string + raCertNotBefore time.Time + raCertNotAfter time.Time + mtlsEnabled bool + mtlsTrustBundlePath string } // intuneCounterTab is the in-memory equivalent of the @@ -235,6 +247,142 @@ func (s *SCEPService) ReloadIntuneTrust() error { return s.intuneTrust.Reload() } +// SetRACert records the RA cert metadata the admin Profiles endpoint +// surfaces (subject + NotBefore + NotAfter for the expiry countdown). +// Called from cmd/server/main.go right after loadSCEPRAPair returns the +// leaf cert. Nil-safe — passing nil leaves the fields zero-valued so +// the snapshot's RACertSubject is empty (the GUI then renders +// "RA cert not loaded"). +// +// SCEP RFC 8894 + Intune master bundle Phase 9 follow-up. +func (s *SCEPService) SetRACert(cert *x509.Certificate) { + if cert == nil { + return + } + s.raCertSubject = cert.Subject.CommonName + s.raCertNotBefore = cert.NotBefore + s.raCertNotAfter = cert.NotAfter +} + +// SetMTLSConfig records this profile's mTLS sibling-route status for +// the admin Profiles endpoint. The trust bundle PATH is surfaced (not +// the bundle contents) so operators can correlate against their own +// secret manager / file system audit. Called from cmd/server/main.go +// in the per-profile loop, parallel to SetIntuneIntegration. +// +// SCEP RFC 8894 + Intune master bundle Phase 9 follow-up. +func (s *SCEPService) SetMTLSConfig(enabled bool, bundlePath string) { + s.mtlsEnabled = enabled + s.mtlsTrustBundlePath = bundlePath +} + +// SCEPProfileStatsSnapshot is the per-profile observability shape the +// new /admin/scep/profiles endpoint emits. Surfaces every always- +// present per-profile field PLUS an optional Intune sub-block. +// Profiles that don't have Intune enabled get Intune=nil (the GUI +// renders the lean per-profile card without the Intune deep-dive +// button). +// +// Distinct from IntuneStatsSnapshot (which the existing +// /admin/scep/intune/stats endpoint emits) so the existing endpoint's +// JSON shape stays byte-stable for external consumers — backward +// compatibility for the Phase 9 admin contract. +// +// SCEP RFC 8894 + Intune master bundle Phase 9 follow-up +// (cowork/scep-gui-restructure-prompt.md). +type SCEPProfileStatsSnapshot struct { + // Always-present per-profile fields. + PathID string `json:"path_id"` + IssuerID string `json:"issuer_id"` + ChallengePasswordSet bool `json:"challenge_password_set"` + RACertSubject string `json:"ra_cert_subject,omitempty"` + RACertNotBefore time.Time `json:"ra_cert_not_before,omitempty"` + RACertNotAfter time.Time `json:"ra_cert_not_after,omitempty"` + RACertDaysToExpiry int `json:"ra_cert_days_to_expiry"` + RACertExpired bool `json:"ra_cert_expired"` + MTLSEnabled bool `json:"mtls_enabled"` + MTLSTrustBundlePath string `json:"mtls_trust_bundle_path,omitempty"` + GeneratedAt time.Time `json:"generated_at"` + + // Optional Intune sub-block; nil when this profile has Intune + // disabled. Mirrors the IntuneStatsSnapshot fields minus the + // always-present per-profile ones (which now live on the parent). + Intune *IntuneSection `json:"intune,omitempty"` +} + +// IntuneSection is the Intune-specific data a per-profile snapshot +// carries when INTUNE_ENABLED=true. Same fields as IntuneStatsSnapshot +// minus the always-present per-profile ones (PathID, IssuerID, +// GeneratedAt) which live on SCEPProfileStatsSnapshot. +type IntuneSection struct { + 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"` +} + +// ProfileStats returns the per-profile observability snapshot in the +// new shape (always-present fields + optional Intune sub-block). +// Safe for concurrent callers; reads only; uses the same atomic +// counter snapshots as IntuneStats. +// +// SCEP RFC 8894 + Intune master bundle Phase 9 follow-up. +func (s *SCEPService) ProfileStats(now time.Time) SCEPProfileStatsSnapshot { + out := SCEPProfileStatsSnapshot{ + PathID: s.pathID, + IssuerID: s.issuerID, + ChallengePasswordSet: s.challengePassword != "", + RACertSubject: s.raCertSubject, + RACertNotBefore: s.raCertNotBefore, + RACertNotAfter: s.raCertNotAfter, + MTLSEnabled: s.mtlsEnabled, + MTLSTrustBundlePath: s.mtlsTrustBundlePath, + GeneratedAt: now.UTC(), + } + if !s.raCertNotAfter.IsZero() { + out.RACertExpired = now.After(s.raCertNotAfter) + if !out.RACertExpired { + out.RACertDaysToExpiry = int(s.raCertNotAfter.Sub(now).Hours() / 24) + } + } + if !s.intuneEnabled { + return out + } + intuneSection := IntuneSection{ + Audience: s.intuneAudience, + ChallengeValidity: s.intuneValidity, + Counters: s.intuneCounters.snapshot(), + } + if s.intuneRateLimiter != nil { + intuneSection.RateLimitDisabled = s.intuneRateLimiter.Disabled() + } + if s.intuneReplayCache != nil { + intuneSection.ReplayCacheSize = s.intuneReplayCache.Len() + } + if s.intuneTrust != nil { + intuneSection.TrustAnchorPath = s.intuneTrust.Path() + certs := s.intuneTrust.Get() + intuneSection.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) + } + intuneSection.TrustAnchors = append(intuneSection.TrustAnchors, info) + } + } + out.Intune = &intuneSection + return out +} + // 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) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 365694a..4d0ef2f 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, IntuneStatsResponse, IntuneReloadTrustResponse } from './types'; +import type { Certificate, CertificateVersion, Agent, Job, Notification, AuditEvent, PolicyRule, PolicyViolation, RenewalPolicy, Issuer, Target, CertificateProfile, Owner, Team, AgentGroup, PaginatedResponse, DashboardSummary, CertificateStatusCount, ExpirationBucket, JobTrendDataPoint, IssuanceRateDataPoint, MetricsResponse, DiscoveredCertificate, DiscoveryScan, DiscoverySummary, NetworkScanTarget, EndpointHealthCheck, HealthHistoryEntry, HealthCheckSummary, AgentDependencyCounts, RetireAgentResponse, BlockedByDependenciesResponse, CRLCacheResponse, IntuneStatsResponse, IntuneReloadTrustResponse, SCEPProfilesResponse } from './types'; const BASE = '/api/v1'; @@ -312,6 +312,14 @@ export const reloadAdminSCEPIntuneTrust = (pathID: string) => body: JSON.stringify({ path_id: pathID }), }); +// SCEP RFC 8894 + Intune master bundle Phase 9 follow-up +// (cowork/scep-gui-restructure-prompt.md): per-profile SCEP admin +// surface backing the Profiles tab on the SCEP Administration page. +// M-008 admin-gated; same gating semantics as the existing +// getAdminSCEPIntuneStats helper. +export const getAdminSCEPProfiles = () => + fetchJSON(`${BASE}/admin/scep/profiles`); + // 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 0c0ee92..68da9934 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -676,3 +676,46 @@ export interface IntuneReloadTrustResponse { path_id: string; reloaded_at: string; } + +// SCEP RFC 8894 + Intune master bundle Phase 9 follow-up +// (cowork/scep-gui-restructure-prompt.md): per-profile SCEP admin +// snapshot. Backs the new /api/v1/admin/scep/profiles endpoint and +// the Profiles tab on the SCEP Administration page. +// +// Distinct from IntuneStatsSnapshot (which mirrors the existing +// /admin/scep/intune/stats endpoint) so the existing endpoint's JSON +// shape stays byte-stable for external consumers — backward-compat +// for the Phase 9 admin contract. The Profiles endpoint nests Intune +// data under a single optional `intune` field; the legacy Intune +// endpoint keeps the flat shape. +export interface IntuneSection { + trust_anchor_path?: string; + trust_anchors?: IntuneTrustAnchorInfo[]; + audience?: string; + challenge_validity_ns?: number; + rate_limit_disabled: boolean; + replay_cache_size: number; + counters: Record; +} + +export interface SCEPProfileStatsSnapshot { + path_id: string; + issuer_id: string; + challenge_password_set: boolean; + ra_cert_subject?: string; + ra_cert_not_before?: string; + ra_cert_not_after?: string; + ra_cert_days_to_expiry: number; + ra_cert_expired: boolean; + mtls_enabled: boolean; + mtls_trust_bundle_path?: string; + generated_at: string; + // nil/undefined when Intune is disabled on this profile. + intune?: IntuneSection; +} + +export interface SCEPProfilesResponse { + profiles: SCEPProfileStatsSnapshot[]; + profile_count: number; + generated_at: string; +} diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index fc6d397..17be72d 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -23,7 +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: '/scep', label: 'SCEP Admin', icon: 'M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z' }, { to: '/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 83d021a..69e4c44 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -80,11 +80,16 @@ createRoot(document.getElementById('root')!).render( } /> } /> } /> - {/* SCEP RFC 8894 + Intune master bundle Phase 9.4: per-profile - Intune Monitoring tab. Route is unconditional; the page + {/* SCEP RFC 8894 + Intune master bundle Phase 9.4 (initial) + + Phase 9 follow-up (rebrand): per-profile SCEP + Administration page with Profiles / Intune Monitoring / + Recent Activity tabs. 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. */} + } /> + {/* Backward-compat alias for external bookmarks the Phase 9 + release advertised. Lands on the Intune Monitoring tab. */} } /> diff --git a/web/src/pages/SCEPAdminPage.test.tsx b/web/src/pages/SCEPAdminPage.test.tsx index 9bee94b..1a4adef 100644 --- a/web/src/pages/SCEPAdminPage.test.tsx +++ b/web/src/pages/SCEPAdminPage.test.tsx @@ -1,24 +1,32 @@ 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 { MemoryRouter, Routes, Route } 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. +// SCEP RFC 8894 + Intune master bundle Phase 9 follow-up +// (cowork/scep-gui-restructure-prompt.md): Vitest coverage for the +// rebranded SCEP Administration page. Pins: +// 1. Admin gate — non-admin sees the gated banner; admin requests are +// never issued. +// 2. Tab navigation — Profiles is the default; clicking each tab +// switches surface; ?tab=intune deep-links land on Intune; the +// legacy /scep/intune route alias also lands on Intune. +// 3. Profiles tab — per-profile lean cards; status badges reflect +// Intune + mTLS + challenge-password-set; RA cert expiry badge +// tone bands (good ≥30d / warn 7-30d / bad <7d / EXPIRED); +// "View Intune details →" link only renders for Intune-enabled +// profiles AND switches to the Intune tab on click. +// 4. Intune tab — counters render with the existing Phase 9 deep-dive +// shape; reload modal opens / Confirm calls mutation / Cancel +// skips mutation / Error keeps modal open + surfaces message. +// 5. Recent Activity tab — merges all four SCEP audit actions across +// four parallel useQuery calls; filter chips narrow to the +// requested subset. +// 6. Error path — surfaces ErrorState on the active tab. vi.mock('../api/client', () => ({ + getAdminSCEPProfiles: vi.fn(), getAdminSCEPIntuneStats: vi.fn(), reloadAdminSCEPIntuneTrust: vi.fn(), getAuditEvents: vi.fn(), @@ -32,13 +40,18 @@ import SCEPAdminPage from './SCEPAdminPage'; import * as client from '../api/client'; import { useAuth } from '../components/AuthProvider'; -function renderWithQuery(ui: ReactNode) { +function renderWithRoute(initialPath: string, ui: ReactNode) { const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } }, }); return render( - {ui} + + + + + + , ); } @@ -57,47 +70,71 @@ function setAuth(opts: { authRequired: boolean; admin: boolean }) { }); } -const baseEnabledProfile = { +const corpProfileSummary = { + path_id: 'corp', + issuer_id: 'iss-corp', + challenge_password_set: true, + ra_cert_subject: 'ra-corp', + ra_cert_not_before: '2026-01-01T00:00:00Z', + ra_cert_not_after: '2027-01-01T00:00:00Z', + ra_cert_days_to_expiry: 250, + ra_cert_expired: false, + mtls_enabled: true, + mtls_trust_bundle_path: '/etc/certctl/mtls-corp.pem', + generated_at: '2026-04-29T15:00:00Z', + intune: { + trust_anchor_path: '/etc/certctl/intune-corp.pem', + trust_anchors: [ + { subject: 'intune-conn', 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 }, + }, +}; + +const iotProfileSummary = { + path_id: 'iot', + issuer_id: 'iss-iot', + challenge_password_set: true, + ra_cert_subject: 'ra-iot', + ra_cert_not_before: '2026-01-01T00:00:00Z', + ra_cert_not_after: '2026-05-15T00:00:00Z', + ra_cert_days_to_expiry: 16, + ra_cert_expired: false, + mtls_enabled: false, + generated_at: '2026-04-29T15:00:00Z', + // Intune disabled — no intune field +}; + +const expiredProfileSummary = { + path_id: 'legacy', + issuer_id: 'iss-old', + challenge_password_set: true, + ra_cert_subject: 'ra-old', + ra_cert_not_before: '2024-01-01T00:00:00Z', + ra_cert_not_after: '2025-01-01T00:00:00Z', + ra_cert_days_to_expiry: 0, + ra_cert_expired: true, + mtls_enabled: false, + generated_at: '2026-04-29T15:00:00Z', +}; + +const corpIntuneStats = { 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, - }, + { subject: 'intune-conn', 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: {}, + counters: { success: 42, signature_invalid: 1, claim_mismatch: 3, replay: 2 }, generated_at: '2026-04-29T15:00:00Z', }; @@ -113,138 +150,236 @@ beforeEach(() => { } as never); }); +// ============================================================================= +// Admin gate. +// ============================================================================= + 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(); + renderWithRoute('/scep', ); await waitFor(() => { - expect(screen.getByRole('heading', { level: 2, name: /SCEP Intune Monitoring/ })).toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 2, name: /SCEP Administration/ })).toBeInTheDocument(); }); + expect(client.getAdminSCEPProfiles).not.toHaveBeenCalled(); 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], + it('lets admin callers through and fetches the per-profile snapshot', async () => { + vi.mocked(client.getAdminSCEPProfiles).mockResolvedValue({ + profiles: [corpProfileSummary], profile_count: 1, generated_at: '2026-04-29T15:00:00Z', } as never); - renderWithQuery(); + renderWithRoute('/scep', ); + expect(await screen.findByTestId('profile-summary-corp')).toBeInTheDocument(); + expect(client.getAdminSCEPProfiles).toHaveBeenCalled(); + // Default tab is Profiles → Intune stats endpoint NOT called yet + expect(client.getAdminSCEPIntuneStats).not.toHaveBeenCalled(); + }); +}); + +// ============================================================================= +// Tab navigation + deep links. +// ============================================================================= + +describe('SCEPAdminPage — tab navigation', () => { + it('renders Profiles tab as default', async () => { + vi.mocked(client.getAdminSCEPProfiles).mockResolvedValue({ + profiles: [corpProfileSummary], + profile_count: 1, + generated_at: '2026-04-29T15:00:00Z', + } as never); + renderWithRoute('/scep', ); + expect(await screen.findByTestId('profile-summary-corp')).toBeInTheDocument(); + expect(screen.getByTestId('tab-profiles').getAttribute('aria-pressed')).toBe('true'); + }); + + it('switches to Intune tab on click and triggers the Intune stats fetch', async () => { + vi.mocked(client.getAdminSCEPProfiles).mockResolvedValue({ + profiles: [corpProfileSummary], + profile_count: 1, + generated_at: '2026-04-29T15:00:00Z', + } as never); + vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({ + profiles: [corpIntuneStats], + profile_count: 1, + generated_at: '2026-04-29T15:00:00Z', + } as never); + renderWithRoute('/scep', ); + await screen.findByTestId('profile-summary-corp'); + fireEvent.click(screen.getByTestId('tab-intune')); 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, + it('?tab=intune deep-link lands on Intune tab', async () => { + vi.mocked(client.getAdminSCEPProfiles).mockResolvedValue({ + profiles: [corpProfileSummary], + profile_count: 1, generated_at: '2026-04-29T15:00:00Z', } as never); - renderWithQuery(); + vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({ + profiles: [corpIntuneStats], + profile_count: 1, + generated_at: '2026-04-29T15:00:00Z', + } as never); + renderWithRoute('/scep?tab=intune', ); await waitFor(() => { - expect(client.getAdminSCEPIntuneStats).toHaveBeenCalledTimes(1); + expect(screen.getByTestId('tab-intune').getAttribute('aria-pressed')).toBe('true'); }); }); + + it('legacy /scep/intune route alias lands on Intune tab', async () => { + vi.mocked(client.getAdminSCEPProfiles).mockResolvedValue({ + profiles: [corpProfileSummary], + profile_count: 1, + generated_at: '2026-04-29T15:00:00Z', + } as never); + vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({ + profiles: [corpIntuneStats], + profile_count: 1, + generated_at: '2026-04-29T15:00:00Z', + } as never); + renderWithRoute('/scep/intune', ); + await waitFor(() => { + expect(screen.getByTestId('tab-intune').getAttribute('aria-pressed')).toBe('true'); + }); + }); + + it('switches to Activity tab and merges the four SCEP audit actions', async () => { + vi.mocked(client.getAdminSCEPProfiles).mockResolvedValue({ + profiles: [corpProfileSummary], + profile_count: 1, + generated_at: '2026-04-29T15:00:00Z', + } as never); + vi.mocked(client.getAuditEvents).mockImplementation((params: Record = {}) => { + const events: Record = { + scep_pkcsreq: [{ id: 'a1', action: 'scep_pkcsreq', actor: 'scep-client', actor_type: 'system', resource_type: 'certificate', resource_id: 'c1', details: {}, timestamp: '2026-04-29T14:00:00Z' }], + scep_renewalreq: [{ id: 'a2', action: 'scep_renewalreq', actor: 'scep-client', actor_type: 'system', resource_type: 'certificate', resource_id: 'c2', details: {}, timestamp: '2026-04-29T14:10:00Z' }], + scep_pkcsreq_intune: [{ id: 'a3', action: 'scep_pkcsreq_intune', actor: 'scep-client', actor_type: 'system', resource_type: 'certificate', resource_id: 'c3', details: {}, timestamp: '2026-04-29T14:20:00Z' }], + scep_renewalreq_intune: [{ id: 'a4', action: 'scep_renewalreq_intune', actor: 'scep-client', actor_type: 'system', resource_type: 'certificate', resource_id: 'c4', details: {}, timestamp: '2026-04-29T14:30:00Z' }], + }; + const action = params.action ?? ''; + return Promise.resolve({ + data: events[action] ?? [], + total: events[action]?.length ?? 0, + page: 1, + per_page: 200, + } as never); + }); + renderWithRoute('/scep', ); + await screen.findByTestId('profile-summary-corp'); + fireEvent.click(screen.getByTestId('tab-activity')); + await screen.findByTestId('activity-tab'); + const table = await screen.findByTestId('activity-events-table'); + const rows = table.querySelectorAll('tbody tr'); + expect(rows.length).toBe(4); + // Sorted descending → renewal_intune (14:30) is first + expect(rows[0].textContent).toContain('scep_renewalreq_intune'); + }); }); -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, +// ============================================================================= +// Profiles tab — lean cards. +// ============================================================================= + +describe('SCEPAdminPage — Profiles tab cards', () => { + it('renders status badges for Intune + mTLS + challenge-password-set', async () => { + vi.mocked(client.getAdminSCEPProfiles).mockResolvedValue({ + profiles: [corpProfileSummary, iotProfileSummary], + profile_count: 2, 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'); + renderWithRoute('/scep', ); + await screen.findByTestId('profile-summary-corp'); + const corpBadges = screen.getByTestId('profile-badges-corp'); + expect(corpBadges.textContent).toContain('Intune enabled'); + expect(corpBadges.textContent).toContain('mTLS enabled'); + expect(corpBadges.textContent).toContain('Challenge password set'); + const iotBadges = screen.getByTestId('profile-badges-iot'); + expect(iotBadges.textContent).toContain('Intune disabled'); + expect(iotBadges.textContent).toContain('mTLS disabled'); }); - 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, + it('RA cert expiry badge tone reflects the days-to-expiry band', async () => { + vi.mocked(client.getAdminSCEPProfiles).mockResolvedValue({ + profiles: [corpProfileSummary, iotProfileSummary, expiredProfileSummary], + profile_count: 3, generated_at: '2026-04-29T15:00:00Z', } as never); - renderWithQuery(); - await waitFor(() => { - expect(screen.getByTestId('expiry-badge-corp')).toHaveTextContent(/EXPIRED/); - }); + renderWithRoute('/scep', ); + expect(await screen.findByTestId('ra-expiry-badge-corp')).toHaveTextContent('250d'); + expect(screen.getByTestId('ra-expiry-badge-iot')).toHaveTextContent(/16d remaining \(rotate soon\)/); + expect(screen.getByTestId('ra-expiry-badge-legacy')).toHaveTextContent(/EXPIRED/); }); - it('renders the off-state pill for disabled profiles instead of the counter grid', async () => { + it('"View Intune details →" only renders for Intune-enabled profiles AND switches tabs', async () => { + vi.mocked(client.getAdminSCEPProfiles).mockResolvedValue({ + profiles: [corpProfileSummary, iotProfileSummary], + profile_count: 2, + generated_at: '2026-04-29T15:00:00Z', + } as never); vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({ - profiles: [disabledProfile], + profiles: [corpIntuneStats], 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(); + renderWithRoute('/scep', ); + await screen.findByTestId('profile-summary-corp'); + expect(screen.getByTestId('view-intune-details-corp')).toBeInTheDocument(); + expect(screen.queryByTestId('view-intune-details-iot')).toBeNull(); + fireEvent.click(screen.getByTestId('view-intune-details-corp')); + expect(await screen.findByTestId('profile-card-corp')).toBeInTheDocument(); + expect(screen.getByTestId('tab-intune').getAttribute('aria-pressed')).toBe('true'); }); it('renders an empty-state banner when no profiles are configured', async () => { - vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({ + vi.mocked(client.getAdminSCEPProfiles).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(); - }); + renderWithRoute('/scep', ); + expect(await screen.findByText(/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], +// ============================================================================= +// Intune tab — reload modal + counters. +// ============================================================================= + +describe('SCEPAdminPage — Intune tab', () => { + function gotoIntune() { + vi.mocked(client.getAdminSCEPProfiles).mockResolvedValue({ + profiles: [corpProfileSummary], 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(); + vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({ + profiles: [corpIntuneStats], + profile_count: 1, + generated_at: '2026-04-29T15:00:00Z', + } as never); + renderWithRoute('/scep?tab=intune', ); + } + + it('renders counters with the expected labels and tones', async () => { + gotoIntune(); + expect(await screen.findByTestId('counter-corp-success')).toHaveTextContent('42'); + expect(screen.getByTestId('counter-corp-signature_invalid')).toHaveTextContent('1'); + expect(screen.getByTestId('counter-corp-claim_mismatch')).toHaveTextContent('3'); }); - 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); + it('opens the reload modal and calls the mutation on Confirm', async () => { 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(); - }); + gotoIntune(); + expect(await screen.findByTestId('reload-button-corp')).toBeInTheDocument(); fireEvent.click(screen.getByTestId('reload-button-corp')); fireEvent.click(await screen.findByRole('button', { name: /Reload trust anchor/i })); await waitFor(() => { @@ -255,36 +390,21 @@ describe('SCEPAdminPage — reload-trust modal', () => { }); }); - 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); + it('keeps the modal open and shows the error when reload fails', async () => { vi.mocked(client.reloadAdminSCEPIntuneTrust).mockRejectedValue(new Error('trust anchor cert expired')); - renderWithQuery(); - await waitFor(() => { - expect(screen.getByTestId('reload-button-corp')).toBeInTheDocument(); - }); + gotoIntune(); + expect(await screen.findByTestId('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(); - }); + gotoIntune(); + expect(await screen.findByTestId('reload-button-corp')).toBeInTheDocument(); fireEvent.click(screen.getByTestId('reload-button-corp')); fireEvent.click(await screen.findByRole('button', { name: /Cancel/i })); await waitFor(() => { @@ -294,47 +414,87 @@ describe('SCEPAdminPage — reload-trust modal', () => { }); }); -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(); - }); - }); +// ============================================================================= +// Recent Activity tab — filter chips. +// ============================================================================= - it('merges PKCSReq + RenewalReq audit events and sorts by timestamp descending', async () => { - vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({ - profiles: [baseEnabledProfile], +describe('SCEPAdminPage — Activity tab filter', () => { + beforeEach(() => { + vi.mocked(client.getAdminSCEPProfiles).mockResolvedValue({ + profiles: [corpProfileSummary], 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); - } + const lookup: Record = { + scep_pkcsreq: [{ id: 'p1', action: 'scep_pkcsreq', actor: 's', actor_type: 'system', resource_type: 'certificate', resource_id: 'c1', details: {}, timestamp: '2026-04-29T14:00:00Z' }], + scep_renewalreq: [{ id: 'p2', action: 'scep_renewalreq', actor: 's', actor_type: 'system', resource_type: 'certificate', resource_id: 'c2', details: {}, timestamp: '2026-04-29T14:01:00Z' }], + scep_pkcsreq_intune: [{ id: 'p3', action: 'scep_pkcsreq_intune', actor: 's', actor_type: 'system', resource_type: 'certificate', resource_id: 'c3', details: {}, timestamp: '2026-04-29T14:02:00Z' }], + scep_renewalreq_intune: [{ id: 'p4', action: 'scep_renewalreq_intune', actor: 's', actor_type: 'system', resource_type: 'certificate', resource_id: 'c4', details: {}, timestamp: '2026-04-29T14:03:00Z' }], + }; 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, + data: lookup[params.action ?? ''] ?? [], + total: 1, + page: 1, + per_page: 200, } as never); }); + }); - renderWithQuery(); - await waitFor(() => { - expect(screen.getByTestId('recent-failures-table')).toBeInTheDocument(); - }); + it('filter=all shows all four actions', async () => { + renderWithRoute('/scep?tab=activity', ); + await screen.findByTestId('activity-tab'); + const table = await screen.findByTestId('activity-events-table'); + expect(table.querySelectorAll('tbody tr').length).toBe(4); + }); - const rows = screen.getByTestId('recent-failures-table').querySelectorAll('tbody tr'); + it('filter=intune narrows to just the two _intune actions', async () => { + renderWithRoute('/scep?tab=activity', ); + await screen.findByTestId('activity-tab'); + fireEvent.click(screen.getByTestId('activity-filter-intune')); + const table = await screen.findByTestId('activity-events-table'); + const rows = 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'); + for (const r of rows) { + expect(r.textContent).toMatch(/_intune/); + } + }); + + it('filter=renewal narrows to just the two renewal actions', async () => { + renderWithRoute('/scep?tab=activity', ); + await screen.findByTestId('activity-tab'); + fireEvent.click(screen.getByTestId('activity-filter-renewal')); + const table = await screen.findByTestId('activity-events-table'); + const rows = table.querySelectorAll('tbody tr'); + expect(rows.length).toBe(2); + for (const r of rows) { + expect(r.textContent).toContain('scep_renewalreq'); + } + }); + + it('filter=static narrows to just the two non-Intune actions', async () => { + renderWithRoute('/scep?tab=activity', ); + await screen.findByTestId('activity-tab'); + fireEvent.click(screen.getByTestId('activity-filter-static')); + const table = await screen.findByTestId('activity-events-table'); + const rows = table.querySelectorAll('tbody tr'); + expect(rows.length).toBe(2); + for (const r of rows) { + expect(r.textContent).not.toMatch(/_intune/); + } + }); +}); + +// ============================================================================= +// Error path. +// ============================================================================= + +describe('SCEPAdminPage — error surfacing', () => { + it('surfaces ErrorState on the active tab when its query fails', async () => { + vi.mocked(client.getAdminSCEPProfiles).mockRejectedValue(new Error('boom-profiles')); + renderWithRoute('/scep', ); + await waitFor(() => { + expect(screen.getByText(/Failed to load data/i)).toBeInTheDocument(); + }); }); }); diff --git a/web/src/pages/SCEPAdminPage.tsx b/web/src/pages/SCEPAdminPage.tsx index 0e4b863..ceb7dc4 100644 --- a/web/src/pages/SCEPAdminPage.tsx +++ b/web/src/pages/SCEPAdminPage.tsx @@ -1,28 +1,50 @@ -import { useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { getAdminSCEPIntuneStats, reloadAdminSCEPIntuneTrust, getAuditEvents } from '../api/client'; +import { useLocation, useSearchParams } from 'react-router-dom'; +import { + getAdminSCEPIntuneStats, + getAdminSCEPProfiles, + reloadAdminSCEPIntuneTrust, + getAuditEvents, +} from '../api/client'; import PageHeader from '../components/PageHeader'; import ErrorState from '../components/ErrorState'; import { useAuth } from '../components/AuthProvider'; import { useTrackedMutation } from '../hooks/useTrackedMutation'; import { formatDateTime } from '../api/utils'; -import type { IntuneStatsSnapshot, IntuneTrustAnchorInfo, AuditEvent } from '../api/types'; +import type { + IntuneStatsSnapshot, + IntuneTrustAnchorInfo, + AuditEvent, + SCEPProfileStatsSnapshot, +} from '../api/types'; -// SCEP RFC 8894 + Intune master bundle Phase 9.4: per-profile Intune -// Monitoring tab. +// SCEP RFC 8894 + Intune master bundle Phase 9 follow-up +// (cowork/scep-gui-restructure-prompt.md): per-profile SCEP +// administration page with three tabs. // -// 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). +// Profiles (default) — every configured SCEP profile, lean card per +// profile with always-present fields (RA cert +// expiry, mTLS sibling-route status, +// challenge-password-set indicator). Cards on +// Intune-enabled profiles get a "View Intune +// details →" link that deep-links to the +// Intune tab filtered to that profile. +// Intune Monitoring — the existing Phase 9.4 deep-dive. Per-profile +// counters (success / signature_invalid / +// claim_mismatch / expired / wrong_audience / +// replay / rate_limited / malformed / +// compliance_failed / not_yet_valid / +// unknown_version), trust anchor expiry +// countdown, recent failures table, reload- +// trust button + confirmation modal. Polled +// every 30s via TanStack Query. +// Recent Activity — full SCEP audit log filter covering all four +// action codes (scep_pkcsreq, scep_renewalreq, +// scep_pkcsreq_intune, scep_renewalreq_intune). +// Merged + sorted descending by timestamp. +// Filter chips for All / Initial / Renewal / +// Intune / Static. Polled every 60s. // // Admin-gated: the page itself renders an "Admin access required" banner // for non-admin callers and never issues the underlying admin requests. @@ -62,28 +84,179 @@ const TONE_CLASS: Record<'good' | 'warn' | 'bad', string> = { bad: 'text-red-600', }; +type TabId = 'profiles' | 'intune' | 'activity'; +type ActivityFilter = 'all' | 'initial' | 'renewal' | 'intune' | 'static'; + +const TAB_LABELS: Record = { + profiles: 'Profiles', + intune: 'Intune Monitoring', + activity: 'Recent Activity', +}; + +const SCEP_AUDIT_ACTIONS = [ + 'scep_pkcsreq', + 'scep_renewalreq', + 'scep_pkcsreq_intune', + 'scep_renewalreq_intune', +] as const; + +// ============================================================================= +// Tone + badge helpers (shared across tabs). +// ============================================================================= + +function expiryBadge(days: number | null, expired: boolean): { text: string; tone: 'good' | 'warn' | 'bad' } { + if (expired) return { text: 'EXPIRED', tone: 'bad' }; + if (days === null) return { text: 'Not loaded', tone: 'warn' }; + if (days < 7) return { text: `${days}d remaining`, tone: 'bad' }; + if (days < 30) return { text: `${days}d remaining (rotate soon)`, tone: 'warn' }; + return { text: `${days}d remaining`, tone: 'good' }; +} + +function badgeClass(tone: 'good' | 'warn' | 'bad'): string { + if (tone === 'good') return 'bg-emerald-100 text-emerald-800'; + if (tone === 'warn') return 'bg-amber-100 text-amber-800'; + return 'bg-red-100 text-red-800'; +} + +function pillClass(active: boolean): string { + return active + ? 'bg-brand-100 text-brand-800 border-brand-300' + : 'bg-surface-alt text-ink-muted border-surface-border'; +} + // soonestExpiryDays returns the smallest days_to_expiry across the -// profile's 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). +// profile's Intune 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.expired) return -1; 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' }; +// ============================================================================= +// Profiles tab — per-profile lean card with always-present fields. +// ============================================================================= + +interface ProfilesTabProps { + profiles: SCEPProfileStatsSnapshot[]; + isLoading: boolean; + onViewIntuneDetails: (pathID: string) => void; } +function ProfilesTab({ profiles, isLoading, onViewIntuneDetails }: ProfilesTabProps) { + if (isLoading) { + return

Loading profiles…

; + } + if (profiles.length === 0) { + return ( +
+ 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. +
+ ); + } + return ( + <> + {profiles.map(p => ( + + ))} + + ); +} + +interface ProfileSummaryCardProps { + profile: SCEPProfileStatsSnapshot; + onViewIntuneDetails: (pathID: string) => void; +} + +function ProfileSummaryCard({ profile, onViewIntuneDetails }: ProfileSummaryCardProps) { + const pathLabel = profile.path_id || '(legacy /scep root)'; + const intuneEnabled = !!profile.intune; + const raBadge = expiryBadge( + profile.ra_cert_subject ? profile.ra_cert_days_to_expiry : null, + profile.ra_cert_expired, + ); + + return ( +
+
+
+

{pathLabel}

+

Issuer: {profile.issuer_id}

+
+ + RA cert: {raBadge.text} + +
+ +
+ + Challenge password{profile.challenge_password_set ? ' set' : ' MISSING'} + + + mTLS {profile.mtls_enabled ? 'enabled' : 'disabled'} + + + Intune {intuneEnabled ? 'enabled' : 'disabled'} + +
+ +
+
+
RA cert subject
+
{profile.ra_cert_subject || '(not loaded)'}
+
+ {profile.ra_cert_not_after && ( +
+
RA cert expires
+
{formatDateTime(profile.ra_cert_not_after)}
+
+ )} + {profile.mtls_enabled && profile.mtls_trust_bundle_path && ( +
+
mTLS trust bundle
+
{profile.mtls_trust_bundle_path}
+
+ )} +
+ + {intuneEnabled && ( +
+ +
+ )} +
+ ); +} + +// ============================================================================= +// Intune Monitoring tab — the existing Phase 9.4 deep-dive surface. +// ============================================================================= + interface ConfirmReloadModalProps { profile: IntuneStatsSnapshot; onCancel: () => void; @@ -140,39 +313,74 @@ function ConfirmReloadModal({ profile, onCancel, onConfirm, pending, errorMessag ); } -interface ProfileCardProps { - profile: IntuneStatsSnapshot; +interface IntuneTabProps { + profiles: IntuneStatsSnapshot[]; + isLoading: boolean; onRequestReload: (profile: IntuneStatsSnapshot) => void; + highlightPathID: string | null; + events: AuditEvent[]; + eventsLoading: boolean; } -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. -

-
- ); +function IntuneTab({ profiles, isLoading, onRequestReload, highlightPathID, events, eventsLoading }: IntuneTabProps) { + if (isLoading) { + return

Loading Intune monitoring data…

; } + const intuneProfiles = profiles.filter(p => p.enabled); + return ( + <> + {intuneProfiles.length === 0 && ( +
+ No SCEP profile has Intune enabled. Set + CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_ENABLED=true + plus the matching trust-anchor path env var, then restart the server. +
+ )} + {intuneProfiles.map(p => ( + + ))} +
+
+

+ Recent Intune-dispatched enrollments (last 50) +

+

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

+
+ {eventsLoading ? ( +

Loading audit log…

+ ) : ( + + )} +
+ + ); +} + +interface IntuneProfileCardProps { + profile: IntuneStatsSnapshot; + onRequestReload: (profile: IntuneStatsSnapshot) => void; + highlighted: boolean; +} + +function IntuneProfileCard({ profile, onRequestReload, highlighted }: IntuneProfileCardProps) { + const pathLabel = profile.path_id || '(legacy /scep root)'; const days = soonestExpiryDays(profile.trust_anchors); - const badge = expiryBadge(days); + const badge = expiryBadge(days, days !== null && days < 0); + const cardClass = highlighted + ? 'bg-surface border-2 border-brand-400 rounded-lg p-5 mb-4 shadow-sm' + : 'bg-surface border border-surface-border rounded-lg p-5 mb-4'; return ( -
+

{pathLabel}

@@ -183,13 +391,7 @@ function ProfileCard({ profile, onRequestReload }: ProfileCardProps) {
Trust anchor: {badge.text} @@ -264,16 +466,93 @@ function ProfileCard({ profile, onRequestReload }: ProfileCardProps) { ); } -function RecentFailuresTable({ events }: { events: AuditEvent[] }) { +// ============================================================================= +// Recent Activity tab — full SCEP audit log filter. +// ============================================================================= + +interface ActivityTabProps { + events: AuditEvent[]; + isLoading: boolean; + filter: ActivityFilter; + setFilter: (f: ActivityFilter) => void; +} + +function activityFilterMatches(filter: ActivityFilter, action: string): boolean { + switch (filter) { + case 'all': + return true; + case 'initial': + return action === 'scep_pkcsreq' || action === 'scep_pkcsreq_intune'; + case 'renewal': + return action === 'scep_renewalreq' || action === 'scep_renewalreq_intune'; + case 'intune': + return action === 'scep_pkcsreq_intune' || action === 'scep_renewalreq_intune'; + case 'static': + return action === 'scep_pkcsreq' || action === 'scep_renewalreq'; + } +} + +function ActivityTab({ events, isLoading, filter, setFilter }: ActivityTabProps) { + const filtered = events.filter(e => activityFilterMatches(filter, e.action)); + return ( +
+
+

SCEP enrollment audit log (last 100)

+

+ Merged across scep_pkcsreq + scep_renewalreq + + scep_pkcsreq_intune + scep_renewalreq_intune. Refreshes every 60s. +

+
+ {(['all', 'initial', 'renewal', 'intune', 'static'] as const).map(f => ( + + ))} +
+
+ {isLoading ? ( +

Loading audit log…

+ ) : ( + + )} +
+ ); +} + +// ============================================================================= +// Shared events table. +// ============================================================================= + +interface RecentEventsTableProps { + events: AuditEvent[]; + testID: string; + emptyMessage: string; +} + +function RecentEventsTable({ events, testID, emptyMessage }: RecentEventsTableProps) { 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

{emptyMessage}

; } return ( - +
@@ -298,50 +577,113 @@ function RecentFailuresTable({ events }: { events: AuditEvent[] }) { ); } +// ============================================================================= +// Top-level page. +// ============================================================================= + +function pickTabFromQuery(value: string | null): TabId { + if (value === 'intune' || value === 'activity') return value; + return 'profiles'; +} + +// pickInitialTab honors three signals (precedence high → low): +// 1. ?tab=intune|activity in the query string (deep link) +// 2. Pathname ending in /scep/intune (legacy route alias from +// Phase 9.4; preserved so external bookmarks land on Intune) +// 3. Default to 'profiles' +function pickInitialTab(searchParams: URLSearchParams, pathname: string): TabId { + const fromQuery = searchParams.get('tab'); + if (fromQuery === 'intune' || fromQuery === 'activity') return fromQuery; + if (pathname.endsWith('/scep/intune')) return 'intune'; + return 'profiles'; +} + export default function SCEPAdminPage() { const auth = useAuth(); + const adminAccess = !auth.authRequired || auth.admin; + const [searchParams, setSearchParams] = useSearchParams(); + const location = useLocation(); + + const [activeTab, setActiveTab] = useState(() => pickInitialTab(searchParams, location.pathname)); + const [highlightPathID, setHighlightPathID] = useState(searchParams.get('profile')); const [reloadTarget, setReloadTarget] = useState(null); const [reloadError, setReloadError] = useState(undefined); + const [activityFilter, setActivityFilter] = useState('all'); - const statsQuery = useQuery({ - queryKey: ['admin', 'scep', 'intune', 'stats'], - queryFn: getAdminSCEPIntuneStats, - enabled: !auth.authRequired || auth.admin, // skip the request entirely when non-admin + // Keep URL in sync with tab + highlighted profile so deep links survive + // page reloads + browser back/forward. + useEffect(() => { + const next = new URLSearchParams(searchParams); + if (activeTab === 'profiles') { + next.delete('tab'); + } else { + next.set('tab', activeTab); + } + if (highlightPathID && activeTab === 'intune') { + next.set('profile', highlightPathID); + } else { + next.delete('profile'); + } + if (next.toString() !== searchParams.toString()) { + setSearchParams(next, { replace: true }); + } + }, [activeTab, highlightPathID, searchParams, setSearchParams]); + + // Always-present per-profile data (Profiles tab). + const profilesQuery = useQuery({ + queryKey: ['admin', 'scep', 'profiles'], + queryFn: getAdminSCEPProfiles, + enabled: adminAccess, 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, + // Intune deep-dive data (Intune tab). + const intuneStatsQuery = useQuery({ + queryKey: ['admin', 'scep', 'intune', 'stats'], + queryFn: getAdminSCEPIntuneStats, + enabled: adminAccess && activeTab === 'intune', + refetchInterval: 30_000, }); - // Bundle-8 / M-009 invalidation contract: trust-anchor reload changes - // both the per-profile trust pool (reflected in IntuneStats) AND every - // recently-failed Intune enrollment counter that might now succeed on - // retry. We invalidate the stats key so the per-profile trust-anchor - // panel reflects the new pool immediately; the audit log queries - // remain on their 60s timer (a SIGHUP-equivalent reload doesn't - // backfill new audit rows). + // Audit log queries — four parallel queries (one per SCEP action) so + // both the Intune tab's recent-failures table and the Activity tab's + // full SCEP audit feed can pull from the same React Query cache. + const auditQueries = SCEP_AUDIT_ACTIONS.map(action => + // eslint-disable-next-line react-hooks/rules-of-hooks + useQuery({ + queryKey: ['audit', { action }], + queryFn: () => getAuditEvents({ action }), + enabled: adminAccess && (activeTab === 'intune' || activeTab === 'activity'), + refetchInterval: 60_000, + }), + ); + const allAuditEvents: AuditEvent[] = useMemo(() => { + const merged: AuditEvent[] = []; + for (const q of auditQueries) { + if (q.data?.data) merged.push(...q.data.data); + } + return merged.sort((a, b) => b.timestamp.localeCompare(a.timestamp)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [auditQueries.map(q => q.dataUpdatedAt).join('|')]); + const auditLoading = auditQueries.some(q => q.isLoading); + const intuneOnlyEvents = useMemo( + () => + allAuditEvents.filter( + e => e.action === 'scep_pkcsreq_intune' || e.action === 'scep_renewalreq_intune', + ), + [allAuditEvents], + ); + const reloadMutation = useTrackedMutation< Awaited>, Error, string >({ mutationFn: (pathID: string) => reloadAdminSCEPIntuneTrust(pathID), - invalidates: [['admin', 'scep', 'intune', 'stats']], + invalidates: [ + ['admin', 'scep', 'intune', 'stats'], + ['admin', 'scep', 'profiles'], + ], onSuccess: () => { setReloadTarget(null); setReloadError(undefined); @@ -354,53 +696,36 @@ export default function SCEPAdminPage() { if (auth.authRequired && !auth.admin) { return ( <> - +
); } - if (statsQuery.isLoading) { - return ( - <> - -
Loading per-profile stats…
- - ); - } + const profiles = profilesQuery.data?.profiles ?? []; + const intuneProfiles = intuneStatsQuery.data?.profiles ?? []; - 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); + const handleViewIntuneDetails = (pathID: string) => { + setHighlightPathID(pathID); + setActiveTab('intune'); + }; return ( <> statsQuery.refetch()} + onClick={() => { + void profilesQuery.refetch(); + if (activeTab === 'intune') void intuneStatsQuery.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" > @@ -408,41 +733,65 @@ export default function SCEPAdminPage() { } /> +
+ +
+
- {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. -
+ {profilesQuery.error && activeTab === 'profiles' && ( + profilesQuery.refetch()} /> )} - {profiles.map(p => ( - intuneStatsQuery.refetch()} /> + )} + + {activeTab === 'profiles' && !profilesQuery.error && ( + + )} + + {activeTab === 'intune' && !intuneStatsQuery.error && ( + { setReloadError(undefined); setReloadTarget(profile); }} + highlightPathID={highlightPathID} + events={intuneOnlyEvents} + eventsLoading={auditLoading} /> - ))} + )} -
-
-

- 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…

- ) : ( - - )} -
+ {activeTab === 'activity' && ( + + )}
{reloadTarget && (
Timestamp