feat(scep-intune): GUI monitoring tab + admin endpoints

Phase 9 of the SCEP RFC 8894 + Intune master bundle. Lands the operator-
facing Intune Monitoring tab plus the two admin-gated endpoints it reads
from. Per the constitutional 'complete path' rule: counters tick on
every typed dispatcher branch, the GUI poll is live (30s for stats,
60s for the audit log filter), and the SIGHUP-equivalent reload action
is one click + a confirmation modal — no follow-up plumbing required.

Backend (Phase 9.1 + 9.2 + 9.3):

  * internal/service/scep.go gains:
    - intuneCounterTab — atomic per-status counters keyed by the same
      labels intuneFailReason() emits (success / signature_invalid /
      expired / not_yet_valid / wrong_audience / replay / rate_limited /
      claim_mismatch / compliance_failed / malformed / unknown_version).
      Lock-free on the dispatcher hot path; snapshot() returns a
      zero-allocation map for the admin endpoint.
    - dispatchIntuneChallenge wires intuneCounters.inc(...) on every
      typed return path INCLUDING the success leg (credited before
      processEnrollment so a downstream issuer-connector failure
      doesn't double-count).
    - SetPathID + PathID accessors (so admin rows surface the SCEP
      profile path ID per row).
    - IntuneStatsSnapshot + IntuneTrustAnchorInfo public types, plus
      IntuneStats(now) accessor that walks the trust holder pool and
      packages a per-profile snapshot. ReloadIntuneTrust() is the
      typed wrapper around TrustAnchorHolder.Reload that returns
      ErrSCEPProfileIntuneDisabled when called on a profile where
      Intune isn't enabled (admin endpoint maps that to HTTP 409).

  * internal/api/handler/admin_scep_intune.go:
    - AdminSCEPIntuneService narrow interface (Stats + ReloadTrust)
      so the handler depends on a small surface; AdminSCEPIntuneServiceImpl
      is the production walker over the per-profile SCEPService map.
    - AdminSCEPIntuneHandler.Stats handles GET /api/v1/admin/scep/intune/stats
      with the M-008 admin gate (non-admin → 403 + service never
      invoked); returns {profiles, profile_count, generated_at}.
    - AdminSCEPIntuneHandler.ReloadTrust handles POST
      /api/v1/admin/scep/intune/reload-trust. Body is {path_id: '<id>'};
      empty body targets the legacy /scep root profile. Returns 200 on
      success / 404 on unknown PathID / 409 when the profile is Intune-
      disabled / 500 on a parse error from intune.LoadTrustAnchor (the
      holder retains its previous pool — fail-safe). 400 on malformed
      JSON.
    - ErrAdminSCEPProfileNotFound typed error so the handler can
      distinguish 'wrong profile' from 'broken file'.

  * internal/api/router/router.go: HandlerRegistry gains
    AdminSCEPIntune; both routes registered as bearer-auth-required
    (the admin-gate is at the handler layer per the M-008 pattern).

  * cmd/server/main.go: declares scepServices map[string]*service.SCEPService
    BEFORE HandlerRegistry construction so the same map can be referenced
    from both the admin handler (constructed early) and the SCEP startup
    loop (which populates it later by reference). The per-profile loop
    now calls scepService.SetPathID(profile.PathID) and stores the service
    pointer into the shared map. AdminSCEPIntune handler is constructed
    at the same time as AdminCRLCache.

  * internal/api/handler/m008_admin_gate_test.go: AdminGatedHandlers
    map gains 'admin_scep_intune.go' with a one-line justification —
    the regression scanner enforces the per-handler test triplet
    (TestAdminSCEPIntune_NonAdmin_Returns403 + _AdminExplicitFalse_Returns403
    + _AdminPermitted_ForwardsActor) plus their POST siblings for
    ReloadTrust.

  * api/openapi.yaml: documents both endpoints with request body /
    response shape / error mapping; openapi-parity-test now matches
    the registered routes.

Frontend (Phase 9.4):

  * web/src/pages/SCEPAdminPage.tsx — single-page Intune Monitoring
    surface:
    - Per-profile cards (one card per SCEP profile). Enabled profiles
      get the full counter grid + trust-anchor-expiry badge tone
      (good ≥30d / warn 7-30d / bad <7d / EXPIRED). Disabled profiles
      get an off-state pill with the env-var hint to opt in.
    - Counters polled every 30s via TanStack Query against
      GET /admin/scep/intune/stats.
    - Recent failures table (last 50) populated from the audit log
      filtered to action=scep_pkcsreq_intune AND scep_renewalreq_intune;
      merged + sorted by timestamp descending. Polled every 60s.
    - Reload trust anchor button per profile + confirmation modal that
      explains the SIGHUP equivalence and the fail-safe behavior.
      onConfirm runs a TanStack mutation, refetches the stats query
      on success, surfaces the underlying error (eg 'trust anchor
      cert expired') in the modal on failure (modal stays open so
      operator can retry).
    - Admin gate: when authRequired && !admin the page renders an
      'Admin access required' banner and the underlying admin API
      requests are never issued (React Query enabled flag gated on
      auth.admin) — server-side enforcement is M-008.

  * web/src/api/types.ts: IntuneStatsSnapshot + IntuneTrustAnchorInfo +
    IntuneStatsResponse + IntuneReloadTrustResponse.

  * web/src/api/client.ts: getAdminSCEPIntuneStats +
    reloadAdminSCEPIntuneTrust(pathID).

  * web/src/main.tsx: new route /scep/intune. The route is unconditional;
    the gating is at the page level so deep-links land cleanly.

  * web/src/components/Layout.tsx: 'SCEP Intune' nav link between
    Observability and Audit Trail with the appropriate sidebar icon.

Tests (Phase 9.5):

  * internal/api/handler/admin_scep_intune_test.go (16 tests):
    - M-008 admin-gate triplet for both Stats (GET) and ReloadTrust
      (POST): NonAdmin / AdminExplicitFalse / AdminPermitted.
    - Method-gate tests (Stats rejects POST, ReloadTrust rejects GET).
    - Stats propagates service errors as 500.
    - ReloadTrust maps ErrAdminSCEPProfileNotFound→404,
      ErrSCEPProfileIntuneDisabled→409, generic err→500.
    - Empty body targets legacy root PathID.
    - Malformed JSON→400.
    - AdminSCEPIntuneServiceImpl handles nil map + unknown PathID.

  * web/src/pages/SCEPAdminPage.test.tsx (13 tests):
    - Admin gate (non-admin sees gated banner + zero admin API calls;
      admin sees the page; no-auth dev mode also passes).
    - Profile rendering (counters with correct labels, expiry badge
      tone for ≥30d / EXPIRED states, off-state pill for disabled
      profiles, empty-state banner when no profiles configured).
    - Reload modal (opens on click, calls mutation on Confirm,
      keeps modal open + shows error on failure, Cancel skips
      mutation).
    - Error path renders ErrorState with retry.
    - Audit log filter merges PKCSReq + RenewalReq events and sorts
      descending.

Verification:

  * gofmt clean on touched files
  * go vet ./... clean
  * staticcheck on intune/service/api/cmd-server clean
  * go test -short across api+service+intune+cmd-server: all green
  * web tsc --noEmit clean
  * Vitest: SCEPAdminPage.test.tsx 13/13 + sibling page suites all
    pass
  * G-3 docs-drift CI guard: Phase 9 adds no new CERTCTL_* env vars
    so the guard does not fire
  * openapi-parity-test green (both new admin endpoints documented)
  * M-008 regression scanner enforces the per-handler test triplet —
    pin updated, all triplets present

Refs: cowork/scep-rfc8894-intune-master-prompt.md::Phase 9
      cowork/scep-rfc8894-intune/progress.md
This commit is contained in:
Shankar
2026-04-29 16:14:07 +00:00
parent 2263e2886b
commit 82276bd29e
13 changed files with 1754 additions and 4 deletions
+98
View File
@@ -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": "<pathID>"}`;
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]
+26
View File
@@ -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
+190
View File
@@ -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)
@@ -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)
}
}
+3 -2
View File
@@ -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
+14
View File
@@ -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))
+221 -1
View File
@@ -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}
}
+17 -1
View File
@@ -1,4 +1,4 @@
import type { Certificate, CertificateVersion, Agent, Job, Notification, AuditEvent, PolicyRule, PolicyViolation, RenewalPolicy, Issuer, Target, CertificateProfile, Owner, Team, AgentGroup, PaginatedResponse, DashboardSummary, CertificateStatusCount, ExpirationBucket, JobTrendDataPoint, IssuanceRateDataPoint, MetricsResponse, DiscoveredCertificate, DiscoveryScan, DiscoverySummary, NetworkScanTarget, EndpointHealthCheck, HealthHistoryEntry, HealthCheckSummary, AgentDependencyCounts, RetireAgentResponse, BlockedByDependenciesResponse, CRLCacheResponse } 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<CRLCacheResponse>(`${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<IntuneStatsResponse>(`${BASE}/admin/scep/intune/stats`);
export const reloadAdminSCEPIntuneTrust = (pathID: string) =>
fetchJSON<IntuneReloadTrustResponse>(`${BASE}/admin/scep/intune/reload-trust`, {
method: 'POST',
body: JSON.stringify({ path_id: pathID }),
});
// Agents
export const getAgents = (params: Record<string, string> = {}) => {
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
+50
View File
@@ -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<string, number>;
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;
}
+1
View File
@@ -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' },
];
+7
View File
@@ -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(
<Route path="health-monitor" element={<HealthMonitorPage />} />
<Route path="digest" element={<DigestPage />} />
<Route path="observability" element={<ObservabilityPage />} />
{/* 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. */}
<Route path="scep/intune" element={<SCEPAdminPage />} />
</Route>
</Routes>
</BrowserRouter>
+340
View File
@@ -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(
<QueryClientProvider client={qc}>
<MemoryRouter>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}
function setAuth(opts: { authRequired: boolean; admin: boolean }) {
vi.mocked(useAuth).mockReturnValue({
loading: false,
authRequired: opts.authRequired,
authenticated: true,
authType: 'apikey',
user: 'tester',
admin: opts.admin,
login: async () => {},
logout: () => {},
error: null,
});
}
const 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(<SCEPAdminPage />);
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(<SCEPAdminPage />);
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(<SCEPAdminPage />);
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(<SCEPAdminPage />);
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(<SCEPAdminPage />);
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(<SCEPAdminPage />);
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(<SCEPAdminPage />);
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(<SCEPAdminPage />);
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(<SCEPAdminPage />);
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(<SCEPAdminPage />);
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(<SCEPAdminPage />);
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(<SCEPAdminPage />);
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<string, string> = {}) => {
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(<SCEPAdminPage />);
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');
});
});
+451
View File
@@ -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<string, { label: string; tone: 'good' | 'warn' | 'bad' }> = {
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 (
<div
role="dialog"
aria-labelledby="reload-trust-title"
aria-modal="true"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
>
<div className="bg-surface w-full max-w-md rounded-lg shadow-xl border border-surface-border p-6">
<h3 id="reload-trust-title" className="text-base font-semibold text-ink mb-2">
Reload Intune trust anchor
</h3>
<p className="text-sm text-ink-muted mb-4">
This re-reads <code className="text-xs">{profile.trust_anchor_path}</code> from disk and atomically
swaps the trust pool for SCEP profile <strong>{pathLabel}</strong>. Equivalent to sending
<code className="text-xs"> SIGHUP </code> to the server. If the new file fails to parse, the
previous trust pool stays in place enrollments keep working off the old trust anchor while you
fix the file.
</p>
{errorMessage && (
<div className="mb-3 rounded border border-red-300 bg-red-50 p-3 text-xs text-red-800">
{errorMessage}
</div>
)}
<div className="flex justify-end gap-2">
<button
type="button"
onClick={onCancel}
disabled={pending}
className="px-3 py-1.5 text-sm rounded border border-surface-border bg-surface hover:bg-surface-alt"
>
Cancel
</button>
<button
type="button"
onClick={onConfirm}
disabled={pending}
className="px-3 py-1.5 text-sm rounded bg-brand-500 text-white hover:bg-brand-600 disabled:opacity-50"
>
{pending ? 'Reloading…' : 'Reload trust anchor'}
</button>
</div>
</div>
</div>
);
}
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 (
<section className="bg-surface border border-surface-border rounded-lg p-5 mb-4" data-testid={`profile-card-${profile.path_id}`}>
<header className="flex items-center justify-between mb-3">
<div>
<h3 className="text-base font-semibold text-ink">{pathLabel}</h3>
<p className="text-xs text-ink-muted">Issuer: {profile.issuer_id}</p>
</div>
<span className="text-xs px-2 py-0.5 rounded-full bg-surface-alt text-ink-muted">
Intune disabled
</span>
</header>
<p className="text-sm text-ink-muted">
This profile honors only the static challenge password. To enable Intune dispatch, set
<code className="mx-1">CERTCTL_SCEP_PROFILE_{(profile.path_id || 'DEFAULT').toUpperCase()}_INTUNE_ENABLED=true</code>
plus the matching trust-anchor path env var, then restart the server.
</p>
</section>
);
}
const days = soonestExpiryDays(profile.trust_anchors);
const badge = expiryBadge(days);
return (
<section className="bg-surface border border-surface-border rounded-lg p-5 mb-4" data-testid={`profile-card-${profile.path_id}`}>
<header className="flex items-center justify-between mb-3">
<div>
<h3 className="text-base font-semibold text-ink">{pathLabel}</h3>
<p className="text-xs text-ink-muted">
Issuer: {profile.issuer_id}
{profile.audience && <> · Audience: <code>{profile.audience}</code></>}
</p>
</div>
<div className="flex items-center gap-3">
<span
className={`text-xs px-2 py-0.5 rounded-full font-medium ${
badge.tone === 'good'
? 'bg-emerald-100 text-emerald-800'
: badge.tone === 'warn'
? 'bg-amber-100 text-amber-800'
: 'bg-red-100 text-red-800'
}`}
data-testid={`expiry-badge-${profile.path_id}`}
>
Trust anchor: {badge.text}
</span>
<button
type="button"
onClick={() => onRequestReload(profile)}
className="text-xs px-2 py-1 rounded border border-surface-border bg-surface hover:bg-surface-alt"
data-testid={`reload-button-${profile.path_id}`}
>
Reload trust
</button>
</div>
</header>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
{COUNTER_LABEL_ORDER.map(label => {
const value = profile.counters?.[label] ?? 0;
const presentation = COUNTER_PRESENTATION[label];
return (
<div key={label} className="border border-surface-border rounded p-2">
<div className={`text-lg font-semibold ${TONE_CLASS[presentation.tone]}`} data-testid={`counter-${profile.path_id}-${label}`}>
{value}
</div>
<div className="text-[11px] text-ink-muted uppercase tracking-wide">{presentation.label}</div>
</div>
);
})}
</div>
<dl className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs text-ink-muted">
<div>
<dt className="font-semibold text-ink">Replay cache size</dt>
<dd>{profile.replay_cache_size}</dd>
</div>
<div>
<dt className="font-semibold text-ink">Per-device rate limit</dt>
<dd>{profile.rate_limit_disabled ? 'Disabled' : 'Active'}</dd>
</div>
<div>
<dt className="font-semibold text-ink">Trust anchors</dt>
<dd>{profile.trust_anchors?.length ?? 0}</dd>
</div>
</dl>
{profile.trust_anchors && profile.trust_anchors.length > 0 && (
<details className="mt-3 text-xs text-ink-muted">
<summary className="cursor-pointer font-semibold text-ink">Trust anchor details</summary>
<table className="mt-2 w-full text-left">
<thead>
<tr className="text-[11px] text-ink-muted uppercase">
<th className="py-1 pr-2">Subject</th>
<th className="py-1 pr-2">Not after</th>
<th className="py-1">Days to expiry</th>
</tr>
</thead>
<tbody>
{profile.trust_anchors.map(a => (
<tr key={`${profile.path_id}-${a.subject}-${a.not_after}`} className="border-t border-surface-border">
<td className="py-1 pr-2 font-mono">{a.subject || '(empty CN)'}</td>
<td className="py-1 pr-2">{formatDateTime(a.not_after)}</td>
<td className={`py-1 ${a.expired ? 'text-red-600 font-semibold' : ''}`}>
{a.expired ? 'EXPIRED' : a.days_to_expiry}
</td>
</tr>
))}
</tbody>
</table>
</details>
)}
</section>
);
}
function RecentFailuresTable({ events }: { events: AuditEvent[] }) {
if (events.length === 0) {
return (
<p className="text-sm text-ink-muted px-4 py-6">
No recent Intune-dispatched enrollment events. Counters stay at zero until the first device hits a SCEP profile with Intune enabled.
</p>
);
}
return (
<table className="w-full text-sm" data-testid="recent-failures-table">
<thead className="text-xs text-ink-muted uppercase tracking-wide">
<tr>
<th className="py-2 pl-4 pr-2 text-left">Timestamp</th>
<th className="py-2 pr-2 text-left">Action</th>
<th className="py-2 pr-2 text-left">Resource</th>
<th className="py-2 pr-4 text-left">Details</th>
</tr>
</thead>
<tbody>
{events.map(e => (
<tr key={e.id} className="border-t border-surface-border">
<td className="py-2 pl-4 pr-2 font-mono text-xs">{formatDateTime(e.timestamp)}</td>
<td className="py-2 pr-2">{e.action}</td>
<td className="py-2 pr-2">{e.resource_type} · <code className="text-xs">{e.resource_id}</code></td>
<td className="py-2 pr-4 text-xs text-ink-muted">
{e.details ? Object.entries(e.details).map(([k, v]) => `${k}=${typeof v === 'object' ? JSON.stringify(v) : String(v)}`).join(' · ') : '-'}
</td>
</tr>
))}
</tbody>
</table>
);
}
export default function SCEPAdminPage() {
const auth = useAuth();
const queryClient = useQueryClient();
const [reloadTarget, setReloadTarget] = useState<IntuneStatsSnapshot | null>(null);
const [reloadError, setReloadError] = useState<string | undefined>(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 (
<>
<PageHeader title="SCEP Intune Monitoring" subtitle="Admin-only observability surface" />
<div className="p-6">
<ErrorState
error={new Error('Admin access required: this page exposes per-profile trust anchor expiries and an admin-only reload action. Sign in with an admin-tagged API key to view it.')}
/>
</div>
</>
);
}
if (statsQuery.isLoading) {
return (
<>
<PageHeader title="SCEP Intune Monitoring" subtitle="Per-profile dispatcher state" />
<div className="p-6 text-sm text-ink-muted">Loading per-profile stats</div>
</>
);
}
if (statsQuery.error) {
return (
<>
<PageHeader title="SCEP Intune Monitoring" subtitle="Per-profile dispatcher state" />
<div className="p-6">
<ErrorState error={statsQuery.error as Error} onRetry={() => statsQuery.refetch()} />
</div>
</>
);
}
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 (
<>
<PageHeader
title="SCEP Intune Monitoring"
subtitle={`${profiles.length} SCEP profile${profiles.length === 1 ? '' : 's'} configured · counters auto-refresh every 30s`}
action={
<button
type="button"
onClick={() => 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
</button>
}
/>
<div className="p-6 overflow-y-auto">
{profiles.length === 0 && (
<div className="rounded border border-amber-300 bg-amber-50 p-4 text-sm text-amber-900 mb-4">
No SCEP profiles are configured. Set <code>CERTCTL_SCEP_ENABLED=true</code> and either the
legacy single-profile env vars or <code>CERTCTL_SCEP_PROFILES=...</code> with the indexed
per-profile family to register at least one endpoint.
</div>
)}
{profiles.map(p => (
<ProfileCard
key={p.path_id || '(root)'}
profile={p}
onRequestReload={profile => {
setReloadError(undefined);
setReloadTarget(profile);
}}
/>
))}
<section className="bg-surface border border-surface-border rounded-lg mt-6">
<div className="px-4 py-3 border-b border-surface-border">
<h3 className="text-sm font-semibold text-ink">
Recent Intune-dispatched enrollments (last 50)
</h3>
<p className="text-xs text-ink-muted">
Filtered to <code>action=scep_pkcsreq_intune</code> + <code>action=scep_renewalreq_intune</code>.
Refreshes every 60s.
</p>
</div>
{auditPKCSQuery.isLoading || auditRenewalQuery.isLoading ? (
<p className="text-sm text-ink-muted px-4 py-6">Loading audit log</p>
) : (
<RecentFailuresTable events={events} />
)}
</section>
</div>
{reloadTarget && (
<ConfirmReloadModal
profile={reloadTarget}
onCancel={() => {
setReloadTarget(null);
setReloadError(undefined);
}}
onConfirm={() => reloadMutation.mutate(reloadTarget.path_id)}
pending={reloadMutation.isPending}
errorMessage={reloadError}
/>
)}
</>
);
}