refactor(scep-gui): rebrand SCEP admin surface to per-profile tabbed interface (Profiles + Intune + Recent Activity)

Phase 9 follow-up to the SCEP RFC 8894 + Intune master bundle. The
Phase 9.4 GUI shipped 'SCEP Intune Monitoring' at /scep/intune, which
made the per-profile observability surface look Intune-only — operators
running EJBCA + Jamf would never click that nav link expecting per-
profile RA cert + mTLS observability. The page is per-profile keyed
under the hood; this commit rebrands + restructures so the surface
matches what operators actually need.

Spec: cowork/scep-gui-restructure-prompt.md.

User-visible change:

  - Nav link renamed: 'SCEP Intune' → 'SCEP Admin'.
  - Route: /scep is the new canonical path; /scep/intune kept as a
    backward-compat alias that lands directly on the Intune tab.
  - Page header: 'SCEP Administration'.
  - Three tabs:
      * Profiles (default) — per-profile lean cards with RA cert
        expiry countdown, mTLS sibling-route status badge, Intune
        enabled/disabled badge, challenge-password-set indicator.
        'View Intune details →' link on Intune-enabled cards
        deep-links into the Intune tab.
      * Intune Monitoring — the existing Phase 9.4 deep-dive
        (per-status counters, trust anchor expiry, recent failures
        table, reload-trust button + confirmation modal).
      * Recent Activity — full SCEP audit log filter merging all
        four action codes (scep_pkcsreq + scep_renewalreq +
        scep_pkcsreq_intune + scep_renewalreq_intune); chip filters
        for All / Initial / Renewal / Intune / Static.

Backend:

  * internal/service/scep.go — new SCEPProfileStatsSnapshot type +
    IntuneSection sub-block + ProfileStats(now) accessor. Adds
    raCertSubject/raCertNotBefore/raCertNotAfter + mtlsEnabled +
    mtlsTrustBundlePath fields with SetRACert + SetMTLSConfig setters.
    Existing IntuneStatsSnapshot + IntuneStats(now) preserved
    UNCHANGED for /admin/scep/intune/stats backward compat (the
    JSON shape stays byte-stable for external consumers — the
    aliasing approach the prompt initially suggested doesn't work
    because the new shape nests Intune while the old one is flat).
    ChallengePasswordSet is derived from challengePassword != ''
    (the secret value itself is never surfaced).

  * internal/api/handler/admin_scep_intune.go — new Profiles handler
    method on AdminSCEPIntuneHandler with the same M-008 admin gate.
    AdminSCEPIntuneServiceImpl extended (in place; same
    map[string]*service.SCEPService) to satisfy the new
    AdminSCEPProfileService interface. Single handler file gets the
    third method so the M-008 pin entry count stays steady (no new
    file, no new triplet of admin-gate test files — just three new
    Profiles tests inside the existing test file).

  * internal/api/router/router.go — one new route
    'GET /api/v1/admin/scep/profiles' registered to
    reg.AdminSCEPIntune.Profiles. HandlerRegistry unchanged.

  * api/openapi.yaml — new operation 'listSCEPProfiles' documenting
    the request body / response shape / error mapping. Existing
    Intune entries unchanged.

  * cmd/server/main.go — per-profile loop now calls
    scepService.SetMTLSConfig(profile.MTLSEnabled,
    profile.MTLSClientCATrustBundlePath) right after SetPathID, and
    scepService.SetRACert(raCert) right after loadSCEPRAPair returns
    the leaf cert. Both setters are nil-safe.

  * internal/api/handler/m008_admin_gate_test.go — extended the
    existing admin_scep_intune.go entry's justification to mention
    the third endpoint. No new map entry needed (file already
    listed).

Backend tests (8 new):

  * TestAdminSCEPProfiles_NonAdmin_Returns403
  * TestAdminSCEPProfiles_AdminExplicitFalse_Returns403
  * TestAdminSCEPProfiles_AdminPermitted_ForwardsActor — also pins
    that Intune-enabled profiles emit an 'intune' sub-block while
    Intune-disabled profiles OMIT it.
  * TestAdminSCEPProfiles_RejectsNonGetMethod
  * TestAdminSCEPProfiles_PropagatesServiceError
  * TestAdminSCEPProfilesServiceImpl_NilMapReturnsEmpty
  * (existing 16 Phase 9 admin tests still pass — backward-compat
    preserved)

Frontend:

  * web/src/api/types.ts — new SCEPProfileStatsSnapshot +
    IntuneSection + SCEPProfilesResponse types. Existing
    IntuneStatsSnapshot et al unchanged.
  * web/src/api/client.ts — new getAdminSCEPProfiles helper.
  * web/src/pages/SCEPAdminPage.tsx — full rewrite as the tabbed
    surface. Reuses the existing ConfirmReloadModal and Intune
    deep-dive card components verbatim; adds ProfileSummaryCard
    (lean card for the Profiles tab) and ActivityTab. URL state
    sync via useSearchParams so deep links survive reloads + browser
    back/forward. The legacy /scep/intune route alias defaults the
    activeTab to 'intune' on mount.
  * web/src/main.tsx — new <Route path='scep' /> + preserved
    <Route path='scep/intune' /> alias. Both render SCEPAdminPage.
  * web/src/components/Layout.tsx — nav link rebranded:
    label 'SCEP Intune' → 'SCEP Admin', to '/scep/intune' → '/scep'.

Frontend tests (20 — full rebuild):

  * Admin gate (non-admin sees gated banner + zero admin API calls)
  * Profiles tab default + Intune tab tabswitch + ?tab=intune deep
    link + legacy /scep/intune alias all land on Intune
  * Profiles tab status badges (Intune + mTLS + challenge-set)
    reflect each profile's flags
  * RA cert expiry tone bands (good ≥30d / warn 7-30d / bad <7d /
    EXPIRED) verified across three fixture profiles
  * 'View Intune details →' only renders for Intune-enabled
    profiles AND switches tabs on click
  * Empty-state banner when no profiles configured
  * Intune tab counters render with the existing Phase 9 deep-dive
    shape; reload modal Open/Confirm/Cancel/Error paths all pinned
  * Recent Activity tab merges all four SCEP audit actions across
    four parallel useQuery calls; filter chips
    (all/initial/renewal/intune/static) narrow correctly
  * Error path surfaces ErrorState on the active tab

Docs:

  * docs/scep-intune.md — Operational monitoring section heading
    expanded to '(SCEP Administration → Intune Monitoring tab)'.
    Page-surface description rewritten for the tabbed shape;
    admin-endpoints list extended with the new /admin/scep/profiles
    entry.
  * docs/architecture.md — Microsoft Intune Connector trust anchor
    subsection updated to reference the Intune Monitoring tab inside
    the SCEP Administration page + lists all three admin endpoints.
  * docs/legacy-est-scep.md — forward-ref expanded with a parallel
    sentence for the per-profile observability surface (independent
    of Intune).
  * README.md — Enrollment Protocols bullet for Intune updated to
    'admin GUI SCEP Administration page at /scep' with the three
    tabs called out.

Verification:
  * gofmt clean on touched files
  * go vet ./... clean
  * staticcheck on intune+service+handler+router+cmd-server clean
  * go test -short across intune+service+handler+router+cmd-server:
    all green (existing Phase 9 tests + new Profiles tests)
  * Frontend tsc --noEmit clean
  * Vitest: 20/20 SCEPAdminPage tests + 3/3 sibling AuditPage tests
    pass
  * G-3 docs-drift CI guard reproduced locally: clean (no new env
    vars; existing CERTCTL_SCEP_ allowlist prefix covers everything)
  * M-009 hard-zero useMutation guard reproduced locally: clean
    (the existing reload mutation already used useTrackedMutation
    from the Phase 9 follow-up commit 28e277a)
  * openapi-parity test green (new GET /api/v1/admin/scep/profiles
    operation documented)
  * M-008 admin-gate scanner green (existing admin_scep_intune.go
    entry covers all three handler methods; the test scanner
    enforces the triplet by file, not by endpoint, and the new
    Profiles triplet was added to the existing test file)

Backward compat preserved:
  * /api/v1/admin/scep/intune/stats unchanged — same JSON shape,
    same error codes, same M-008 gate
  * /api/v1/admin/scep/intune/reload-trust unchanged
  * /scep/intune route still works (alias to /scep with activeTab=intune)
  * IntuneStatsSnapshot Go type unchanged
  * IntuneStats(now) accessor unchanged

Refs: cowork/scep-gui-restructure-prompt.md
      cowork/scep-rfc8894-intune-master-prompt.md::Phase 9
      Phase 11.5 (SCEP probe in scanner — opt-in) and Phase 12
      (release prep + tag) of the master bundle resume after this.
This commit is contained in:
shankar0123
2026-04-29 17:46:42 +00:00
parent 5d080c86fd
commit 0be889ff1d
17 changed files with 1387 additions and 366 deletions
+1 -1
View File
@@ -108,7 +108,7 @@ gantt
|----------|----------|----------| |----------|----------|----------|
| EST (Enrollment over Secure Transport) | RFC 7030 | Device enrollment, WiFi/802.1X, IoT | | 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/<pathID>`); per-profile RA cert + key. Lightweight raw-CSR clients keep working via the legacy MVP fall-through path. | | 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/<pathID>`); 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 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 | | ACME ARI (Renewal Information) | RFC 9773 | CA-directed renewal timing — the CA tells you when to renew |
+48
View File
@@ -732,6 +732,54 @@ paths:
"500": "500":
$ref: "#/components/responses/InternalError" $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: /api/v1/admin/scep/intune/stats:
get: get:
tags: [SCEP] tags: [SCEP]
+11
View File
@@ -837,6 +837,12 @@ func main() {
scepService := service.NewSCEPService(profile.IssuerID, issuerConn, auditService, profileLog, profile.ChallengePassword) scepService := service.NewSCEPService(profile.IssuerID, issuerConn, auditService, profileLog, profile.ChallengePassword)
scepService.SetProfileRepo(profileRepo) scepService.SetProfileRepo(profileRepo)
scepService.SetPathID(profile.PathID) 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 != "" { if profile.ProfileID != "" {
scepService.SetProfileID(profile.ProfileID) scepService.SetProfileID(profile.ProfileID)
} }
@@ -859,6 +865,11 @@ func main() {
os.Exit(1) os.Exit(1)
} }
scepHandler.SetRAPair(raCert, raKey) 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 // SCEP RFC 8894 + Intune master bundle Phase 8: per-profile Intune
// dispatcher wire-in. Builds the trust-anchor holder, replay cache, // dispatcher wire-in. Builds the trust-anchor holder, replay cache,
+10 -6
View File
@@ -863,12 +863,16 @@ startup via `intune.LoadTrustAnchor` (refuses to boot on empty
bundle / parse error / past-`NotAfter` cert) and reloads atomically bundle / parse error / past-`NotAfter` cert) and reloads atomically
on `SIGHUP` (mirrors the server TLS-cert hot-reload pattern). A bad on `SIGHUP` (mirrors the server TLS-cert hot-reload pattern). A bad
reload keeps the OLD pool in place — operators get a recoverable reload keeps the OLD pool in place — operators get a recoverable
failure window rather than a service-down. The admin GUI's SCEP failure window rather than a service-down. The admin GUI's
Intune Monitoring tab + admin endpoints **Intune Monitoring** tab inside the SCEP Administration page (`/scep`)
(`GET /api/v1/admin/scep/intune/stats`, and the parallel admin endpoints
`POST /api/v1/admin/scep/intune/reload-trust`) are M-008 admin-gated; (`GET /api/v1/admin/scep/profiles` for the always-present per-profile
non-admin Bearer callers get HTTP 403 because the trust-anchor overview that drives the Profiles tab,
expiries are sensitive operational metadata. `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 See [`scep-intune.md`](scep-intune.md) for the full migration playbook
+ Microsoft support statement. + Microsoft support statement.
+6
View File
@@ -503,6 +503,12 @@ otherwise.
field mapping, trust-anchor extraction recipe, troubleshooting matrix, field mapping, trust-anchor extraction recipe, troubleshooting matrix,
operational monitoring, V3-Pro deferrals, and the Microsoft support operational monitoring, V3-Pro deferrals, and the Microsoft support
statement (with Microsoft Learn URLs procurement teams ask for). 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 ## Related docs
+29 -7
View File
@@ -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. | | `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. | | `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 - **Per-profile cards** — one card per SCEP profile, with the trust
anchor expiry countdown badge: 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 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. the underlying error so the operator can correct the file and retry.
Both admin endpoints (`GET /api/v1/admin/scep/intune/stats` and Three admin endpoints back the page:
`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 - `GET /api/v1/admin/scep/profiles` — per-profile snapshot for the
hides the page entirely for non-admin users (UX hint; server-side Profiles tab; surfaces RA cert subject + NotAfter + days-to-expiry,
enforcement is independent). 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": "<pathID>"}`.
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 ### Recommended alert thresholds
+67 -11
View File
@@ -16,14 +16,20 @@ import (
// rather than the concrete *service.SCEPService set so wiring stays // rather than the concrete *service.SCEPService set so wiring stays
// service-side and the handler stays test-friendly. // 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 { type AdminSCEPIntuneService interface {
// Stats returns one snapshot per configured SCEP profile (Intune- // Stats returns one snapshot per configured SCEP profile (Intune-
// enabled or not). Profiles where Intune is disabled appear with // enabled or not) in the Phase 9.1 flat shape. Backward-compat for
// Enabled=false so the GUI can show "off — opt in via env vars" // the existing /admin/scep/intune/stats endpoint.
// rather than 404ing per-profile.
Stats(ctx context.Context, now time.Time) ([]service.IntuneStatsSnapshot, error) 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 // ReloadTrust triggers the SIGHUP-equivalent Reload on the named
// profile's trust holder. Returns ErrAdminSCEPProfileNotFound if // profile's trust holder. Returns ErrAdminSCEPProfileNotFound if
// the PathID isn't known, or ErrSCEPProfileIntuneDisabled if the // 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. // 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") var ErrAdminSCEPProfileNotFound = errors.New("admin scep intune: profile not found for the given path_id")
// AdminSCEPIntuneHandler serves the per-profile Intune observability // AdminSCEPIntuneHandler serves the per-profile SCEP observability
// endpoints for the GUI Intune Monitoring tab. // endpoints for the GUI SCEP Administration page.
// //
// Endpoints: // Endpoints:
// //
// GET /api/v1/admin/scep/intune/stats // GET /api/v1/admin/scep/profiles — Phase 9 follow-up
// POST /api/v1/admin/scep/intune/reload-trust (JSON body: {"path_id": "corp"}) // 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 // callers get 403 — the stats endpoint reveals the operator's profile
// set + trust anchor expiries (sensitive operational metadata) and the // set + trust anchor expiries (sensitive operational metadata), the
// reload endpoint is a privileged action. // profiles endpoint additionally reveals RA cert expiries + mTLS bundle
// paths, and the reload endpoint is a privileged action.
type AdminSCEPIntuneHandler struct { type AdminSCEPIntuneHandler struct {
svc AdminSCEPIntuneService svc AdminSCEPIntuneService
} }
@@ -68,6 +76,42 @@ type adminScepIntuneReloadRequest struct {
PathID string `json:"path_id"` 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. // Stats handles GET /api/v1/admin/scep/intune/stats.
func (h AdminSCEPIntuneHandler) Stats(w http.ResponseWriter, r *http.Request) { func (h AdminSCEPIntuneHandler) Stats(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
@@ -177,6 +221,18 @@ func (s *AdminSCEPIntuneServiceImpl) Stats(_ context.Context, now time.Time) ([]
return out, nil 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. // ReloadTrust implements AdminSCEPIntuneService.
func (s *AdminSCEPIntuneServiceImpl) ReloadTrust(_ context.Context, pathID string) error { func (s *AdminSCEPIntuneServiceImpl) ReloadTrust(_ context.Context, pathID string) error {
svc, ok := s.services[pathID] svc, ok := s.services[pathID]
+165 -6
View File
@@ -18,12 +18,15 @@ import (
// Records call observations so the M-008 admin-gate triplet can pin // Records call observations so the M-008 admin-gate triplet can pin
// "service was never invoked" when the gate rejects the caller. // "service was never invoked" when the gate rejects the caller.
type fakeAdminSCEPIntuneService struct { type fakeAdminSCEPIntuneService struct {
statsCalled bool statsCalled bool
reloadCalled bool profilesCalled bool
rows []service.IntuneStatsSnapshot reloadCalled bool
statsErr error rows []service.IntuneStatsSnapshot
reloadPathID string profileRows []service.SCEPProfileStatsSnapshot
reloadErr error statsErr error
profilesErr error
reloadPathID string
reloadErr error
} }
func (f *fakeAdminSCEPIntuneService) Stats(_ context.Context, _ time.Time) ([]service.IntuneStatsSnapshot, 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 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 { func (f *fakeAdminSCEPIntuneService) ReloadTrust(_ context.Context, pathID string) error {
f.reloadCalled = true f.reloadCalled = true
f.reloadPathID = pathID f.reloadPathID = pathID
@@ -334,3 +342,154 @@ func TestAdminSCEPIntuneServiceImpl_ReloadUnknownPathReturnsNotFound(t *testing.
t.Errorf("ReloadTrust unknown = %v, want ErrAdminSCEPProfileNotFound", err) 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))
}
}
+3 -3
View File
@@ -35,9 +35,9 @@ import (
// the gate exists. health.go is an INFORMATIONAL caller of IsAdmin (it // the gate exists. health.go is an INFORMATIONAL caller of IsAdmin (it
// surfaces the flag to the GUI but does not gate) — explicitly excluded. // surfaces the flag to the GUI but does not gate) — explicitly excluded.
var AdminGatedHandlers = map[string]string{ var AdminGatedHandlers = map[string]string{
"bulk_revocation.go": "M-003: bulk revocation is fleet-scale destructive — 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_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", "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 // InformationalIsAdminCallers is the documented allowlist of files that
+3 -1
View File
@@ -304,10 +304,12 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
// scheduler-driven CRL pre-generation cache. Admin-gated inside // scheduler-driven CRL pre-generation cache. Admin-gated inside
// the handler (M-003 pattern); non-admin callers get 403. // the handler (M-003 pattern); non-admin callers get 403.
r.Register("GET /api/v1/admin/crl/cache", http.HandlerFunc(reg.AdminCRLCache.ListCache)) 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 // admin-gated at the handler layer; the M-008 regression scanner pins
// the gate set and TestM008_AdminGatedHandlers_HaveTripletTests // the gate set and TestM008_AdminGatedHandlers_HaveTripletTests
// enforces the per-handler test triplet. // 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("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)) r.Register("POST /api/v1/admin/scep/intune/reload-trust", http.HandlerFunc(reg.AdminSCEPIntune.ReloadTrust))
+148
View File
@@ -53,6 +53,18 @@ type SCEPService struct {
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 intuneCounters *intuneCounterTab // per-status atomic counters for the admin endpoint
pathID string // SCEP profile path ID; surfaced by admin endpoints 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 // intuneCounterTab is the in-memory equivalent of the
@@ -235,6 +247,142 @@ func (s *SCEPService) ReloadIntuneTrust() error {
return s.intuneTrust.Reload() 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 // ErrSCEPProfileIntuneDisabled is returned by ReloadIntuneTrust when
// invoked on a profile that has Intune turned off. Lets the admin // invoked on a profile that has Intune turned off. Lets the admin
// handler distinguish "operator targeted the wrong profile" (HTTP 409) // handler distinguish "operator targeted the wrong profile" (HTTP 409)
+9 -1
View File
@@ -1,4 +1,4 @@
import type { Certificate, CertificateVersion, Agent, Job, Notification, AuditEvent, PolicyRule, PolicyViolation, RenewalPolicy, Issuer, Target, CertificateProfile, Owner, Team, AgentGroup, PaginatedResponse, DashboardSummary, CertificateStatusCount, ExpirationBucket, JobTrendDataPoint, IssuanceRateDataPoint, MetricsResponse, DiscoveredCertificate, DiscoveryScan, DiscoverySummary, NetworkScanTarget, EndpointHealthCheck, HealthHistoryEntry, HealthCheckSummary, AgentDependencyCounts, RetireAgentResponse, BlockedByDependenciesResponse, CRLCacheResponse, IntuneStatsResponse, IntuneReloadTrustResponse } 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'; const BASE = '/api/v1';
@@ -312,6 +312,14 @@ export const reloadAdminSCEPIntuneTrust = (pathID: string) =>
body: JSON.stringify({ path_id: pathID }), 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<SCEPProfilesResponse>(`${BASE}/admin/scep/profiles`);
// Agents // Agents
export const getAgents = (params: Record<string, string> = {}) => { export const getAgents = (params: Record<string, string> = {}) => {
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString(); const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
+43
View File
@@ -676,3 +676,46 @@ export interface IntuneReloadTrustResponse {
path_id: string; path_id: string;
reloaded_at: 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<string, number>;
}
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;
}
+1 -1
View File
@@ -23,7 +23,7 @@ const nav = [
{ to: '/short-lived', label: 'Short-Lived', icon: 'M13 10V3L4 14h7v7l9-11h-7z' }, { 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: '/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: '/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' }, { to: '/audit', label: 'Audit Trail', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z' },
]; ];
+7 -2
View File
@@ -80,11 +80,16 @@ createRoot(document.getElementById('root')!).render(
<Route path="health-monitor" element={<HealthMonitorPage />} /> <Route path="health-monitor" element={<HealthMonitorPage />} />
<Route path="digest" element={<DigestPage />} /> <Route path="digest" element={<DigestPage />} />
<Route path="observability" element={<ObservabilityPage />} /> <Route path="observability" element={<ObservabilityPage />} />
{/* SCEP RFC 8894 + Intune master bundle Phase 9.4: per-profile {/* SCEP RFC 8894 + Intune master bundle Phase 9.4 (initial)
Intune Monitoring tab. Route is unconditional; the page + 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 itself renders an "Admin access required" banner for
non-admin callers and skips the underlying API calls so non-admin callers and skips the underlying API calls so
the server never sees a 403-prone request. */} the server never sees a 403-prone request. */}
<Route path="scep" element={<SCEPAdminPage />} />
{/* Backward-compat alias for external bookmarks the Phase 9
release advertised. Lands on the Intune Monitoring tab. */}
<Route path="scep/intune" element={<SCEPAdminPage />} /> <Route path="scep/intune" element={<SCEPAdminPage />} />
</Route> </Route>
</Routes> </Routes>
+335 -175
View File
@@ -1,24 +1,32 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, cleanup, fireEvent } from '@testing-library/react'; import { render, screen, waitFor, cleanup, fireEvent } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 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'; import type { ReactNode } from 'react';
// SCEP RFC 8894 + Intune master bundle Phase 9.5: Vitest coverage for the // SCEP RFC 8894 + Intune master bundle Phase 9 follow-up
// SCEPAdminPage component. Pins: // (cowork/scep-gui-restructure-prompt.md): Vitest coverage for the
// 1. Admin gate — non-admin callers see the gated banner and the page // rebranded SCEP Administration page. Pins:
// MUST NOT issue the underlying admin API requests. // 1. Admin gate — non-admin sees the gated banner; admin requests are
// 2. Profile cards render with status + counters + trust-anchor expiry // never issued.
// badge tone (good / warn / bad / EXPIRED). // 2. Tab navigation — Profiles is the default; clicking each tab
// 3. Disabled profiles render the off-state pill instead of the counter // switches surface; ?tab=intune deep-links land on Intune; the
// grid. // legacy /scep/intune route alias also lands on Intune.
// 4. Reload button opens the confirmation modal; Confirm calls the // 3. Profiles tab — per-profile lean cards; status badges reflect
// mutation and refetches stats; Cancel closes without calling. // Intune + mTLS + challenge-password-set; RA cert expiry badge
// 5. Error path surfaces ErrorState with retry. // tone bands (good ≥30d / warn 7-30d / bad <7d / EXPIRED);
// 6. Audit log filter merges PKCSReq + RenewalReq events and sorts by // "View Intune details →" link only renders for Intune-enabled
// timestamp descending. // 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', () => ({ vi.mock('../api/client', () => ({
getAdminSCEPProfiles: vi.fn(),
getAdminSCEPIntuneStats: vi.fn(), getAdminSCEPIntuneStats: vi.fn(),
reloadAdminSCEPIntuneTrust: vi.fn(), reloadAdminSCEPIntuneTrust: vi.fn(),
getAuditEvents: vi.fn(), getAuditEvents: vi.fn(),
@@ -32,13 +40,18 @@ import SCEPAdminPage from './SCEPAdminPage';
import * as client from '../api/client'; import * as client from '../api/client';
import { useAuth } from '../components/AuthProvider'; import { useAuth } from '../components/AuthProvider';
function renderWithQuery(ui: ReactNode) { function renderWithRoute(initialPath: string, ui: ReactNode) {
const qc = new QueryClient({ const qc = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } }, defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } },
}); });
return render( return render(
<QueryClientProvider client={qc}> <QueryClientProvider client={qc}>
<MemoryRouter>{ui}</MemoryRouter> <MemoryRouter initialEntries={[initialPath]}>
<Routes>
<Route path="/scep" element={ui} />
<Route path="/scep/intune" element={ui} />
</Routes>
</MemoryRouter>
</QueryClientProvider>, </QueryClientProvider>,
); );
} }
@@ -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', path_id: 'corp',
issuer_id: 'iss-corp', issuer_id: 'iss-corp',
enabled: true, enabled: true,
trust_anchor_path: '/etc/certctl/intune-corp.pem', trust_anchor_path: '/etc/certctl/intune-corp.pem',
trust_anchors: [ 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 },
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', audience: 'https://certctl.example.com/scep/corp',
challenge_validity_ns: 3_600_000_000_000, challenge_validity_ns: 3_600_000_000_000,
rate_limit_disabled: false, rate_limit_disabled: false,
replay_cache_size: 12, replay_cache_size: 12,
counters: { counters: { success: 42, signature_invalid: 1, claim_mismatch: 3, replay: 2 },
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', generated_at: '2026-04-29T15:00:00Z',
}; };
@@ -113,138 +150,236 @@ beforeEach(() => {
} as never); } as never);
}); });
// =============================================================================
// Admin gate.
// =============================================================================
describe('SCEPAdminPage — admin gate', () => { describe('SCEPAdminPage — admin gate', () => {
it('renders an Admin access required banner for non-admin callers and skips the admin API', async () => { it('renders an Admin access required banner for non-admin callers and skips the admin API', async () => {
setAuth({ authRequired: true, admin: false }); setAuth({ authRequired: true, admin: false });
renderWithQuery(<SCEPAdminPage />); renderWithRoute('/scep', <SCEPAdminPage />);
await waitFor(() => { 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(client.getAdminSCEPIntuneStats).not.toHaveBeenCalled();
expect(screen.getByText(/Admin access required/i)).toBeInTheDocument(); expect(screen.getByText(/Admin access required/i)).toBeInTheDocument();
}); });
it('lets admin callers through and fetches stats', async () => { it('lets admin callers through and fetches the per-profile snapshot', async () => {
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({ vi.mocked(client.getAdminSCEPProfiles).mockResolvedValue({
profiles: [baseEnabledProfile], profiles: [corpProfileSummary],
profile_count: 1, profile_count: 1,
generated_at: '2026-04-29T15:00:00Z', generated_at: '2026-04-29T15:00:00Z',
} as never); } as never);
renderWithQuery(<SCEPAdminPage />); renderWithRoute('/scep', <SCEPAdminPage />);
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', <SCEPAdminPage />);
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', <SCEPAdminPage />);
await screen.findByTestId('profile-summary-corp');
fireEvent.click(screen.getByTestId('tab-intune'));
expect(await screen.findByTestId('profile-card-corp')).toBeInTheDocument(); expect(await screen.findByTestId('profile-card-corp')).toBeInTheDocument();
expect(client.getAdminSCEPIntuneStats).toHaveBeenCalled(); expect(client.getAdminSCEPIntuneStats).toHaveBeenCalled();
}); });
it('keeps the page accessible when authRequired=false (no-auth dev mode)', async () => { it('?tab=intune deep-link lands on Intune tab', async () => {
setAuth({ authRequired: false, admin: false }); vi.mocked(client.getAdminSCEPProfiles).mockResolvedValue({
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({ profiles: [corpProfileSummary],
profiles: [], profile_count: 1,
profile_count: 0,
generated_at: '2026-04-29T15:00:00Z', generated_at: '2026-04-29T15:00:00Z',
} as never); } as never);
renderWithQuery(<SCEPAdminPage />); vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({
profiles: [corpIntuneStats],
profile_count: 1,
generated_at: '2026-04-29T15:00:00Z',
} as never);
renderWithRoute('/scep?tab=intune', <SCEPAdminPage />);
await waitFor(() => { 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', <SCEPAdminPage />);
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<string, string> = {}) => {
const events: Record<string, unknown[]> = {
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', <SCEPAdminPage />);
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 () => { // Profiles tab — lean cards.
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({ // =============================================================================
profiles: [baseEnabledProfile],
profile_count: 1, 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', generated_at: '2026-04-29T15:00:00Z',
} as never); } as never);
renderWithQuery(<SCEPAdminPage />); renderWithRoute('/scep', <SCEPAdminPage />);
await waitFor(() => { await screen.findByTestId('profile-summary-corp');
expect(screen.getByTestId('counter-corp-success')).toHaveTextContent('42'); const corpBadges = screen.getByTestId('profile-badges-corp');
}); expect(corpBadges.textContent).toContain('Intune enabled');
expect(screen.getByTestId('counter-corp-replay')).toHaveTextContent('2'); expect(corpBadges.textContent).toContain('mTLS enabled');
expect(screen.getByTestId('counter-corp-claim_mismatch')).toHaveTextContent('3'); expect(corpBadges.textContent).toContain('Challenge password set');
// Expiry badge is "good" tone for >= 30 days remaining. const iotBadges = screen.getByTestId('profile-badges-iot');
const badge = screen.getByTestId('expiry-badge-corp'); expect(iotBadges.textContent).toContain('Intune disabled');
expect(badge).toHaveTextContent('250d'); expect(iotBadges.textContent).toContain('mTLS disabled');
}); });
it('renders an expiry badge with EXPIRED text and bad tone when an anchor is past NotAfter', async () => { it('RA cert expiry badge tone reflects the days-to-expiry band', async () => {
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({ vi.mocked(client.getAdminSCEPProfiles).mockResolvedValue({
profiles: [ profiles: [corpProfileSummary, iotProfileSummary, expiredProfileSummary],
{ profile_count: 3,
...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', generated_at: '2026-04-29T15:00:00Z',
} as never); } as never);
renderWithQuery(<SCEPAdminPage />); renderWithRoute('/scep', <SCEPAdminPage />);
await waitFor(() => { expect(await screen.findByTestId('ra-expiry-badge-corp')).toHaveTextContent('250d');
expect(screen.getByTestId('expiry-badge-corp')).toHaveTextContent(/EXPIRED/); 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({ vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({
profiles: [disabledProfile], profiles: [corpIntuneStats],
profile_count: 1, profile_count: 1,
generated_at: '2026-04-29T15:00:00Z', generated_at: '2026-04-29T15:00:00Z',
} as never); } as never);
renderWithQuery(<SCEPAdminPage />); renderWithRoute('/scep', <SCEPAdminPage />);
await waitFor(() => { await screen.findByTestId('profile-summary-corp');
expect(screen.getByTestId('profile-card-iot')).toBeInTheDocument(); expect(screen.getByTestId('view-intune-details-corp')).toBeInTheDocument();
}); expect(screen.queryByTestId('view-intune-details-iot')).toBeNull();
expect(screen.getByText(/Intune disabled/)).toBeInTheDocument(); fireEvent.click(screen.getByTestId('view-intune-details-corp'));
// Counter grid should NOT render for disabled profiles. expect(await screen.findByTestId('profile-card-corp')).toBeInTheDocument();
expect(screen.queryByTestId('counter-iot-success')).toBeNull(); expect(screen.getByTestId('tab-intune').getAttribute('aria-pressed')).toBe('true');
}); });
it('renders an empty-state banner when no profiles are configured', async () => { it('renders an empty-state banner when no profiles are configured', async () => {
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({ vi.mocked(client.getAdminSCEPProfiles).mockResolvedValue({
profiles: [], profiles: [],
profile_count: 0, profile_count: 0,
generated_at: '2026-04-29T15:00:00Z', generated_at: '2026-04-29T15:00:00Z',
} as never); } as never);
renderWithQuery(<SCEPAdminPage />); renderWithRoute('/scep', <SCEPAdminPage />);
await waitFor(() => { expect(await screen.findByText(/No SCEP profiles are configured/)).toBeInTheDocument();
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 () => { // Intune tab — reload modal + counters.
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({ // =============================================================================
profiles: [baseEnabledProfile],
describe('SCEPAdminPage — Intune tab', () => {
function gotoIntune() {
vi.mocked(client.getAdminSCEPProfiles).mockResolvedValue({
profiles: [corpProfileSummary],
profile_count: 1, profile_count: 1,
generated_at: '2026-04-29T15:00:00Z', generated_at: '2026-04-29T15:00:00Z',
} as never); } as never);
renderWithQuery(<SCEPAdminPage />); vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({
await waitFor(() => { profiles: [corpIntuneStats],
expect(screen.getByTestId('reload-button-corp')).toBeInTheDocument(); profile_count: 1,
}); generated_at: '2026-04-29T15:00:00Z',
fireEvent.click(screen.getByTestId('reload-button-corp')); } as never);
expect(await screen.findByRole('dialog')).toBeInTheDocument(); renderWithRoute('/scep?tab=intune', <SCEPAdminPage />);
expect(screen.getByText(/Reload Intune trust anchor/i)).toBeInTheDocument(); }
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 () => { it('opens the reload modal and calls the mutation on Confirm', 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({ vi.mocked(client.reloadAdminSCEPIntuneTrust).mockResolvedValue({
reloaded: true, reloaded: true,
path_id: 'corp', path_id: 'corp',
reloaded_at: '2026-04-29T15:01:00Z', reloaded_at: '2026-04-29T15:01:00Z',
} as never); } as never);
renderWithQuery(<SCEPAdminPage />); gotoIntune();
await waitFor(() => { expect(await screen.findByTestId('reload-button-corp')).toBeInTheDocument();
expect(screen.getByTestId('reload-button-corp')).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId('reload-button-corp')); fireEvent.click(screen.getByTestId('reload-button-corp'));
fireEvent.click(await screen.findByRole('button', { name: /Reload trust anchor/i })); fireEvent.click(await screen.findByRole('button', { name: /Reload trust anchor/i }));
await waitFor(() => { 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 () => { it('keeps the modal open and shows the error 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')); vi.mocked(client.reloadAdminSCEPIntuneTrust).mockRejectedValue(new Error('trust anchor cert expired'));
renderWithQuery(<SCEPAdminPage />); gotoIntune();
await waitFor(() => { expect(await screen.findByTestId('reload-button-corp')).toBeInTheDocument();
expect(screen.getByTestId('reload-button-corp')).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId('reload-button-corp')); fireEvent.click(screen.getByTestId('reload-button-corp'));
fireEvent.click(await screen.findByRole('button', { name: /Reload trust anchor/i })); fireEvent.click(await screen.findByRole('button', { name: /Reload trust anchor/i }));
await waitFor(() => { await waitFor(() => {
expect(screen.getByText(/trust anchor cert expired/)).toBeInTheDocument(); 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(); expect(screen.getByRole('dialog')).toBeInTheDocument();
}); });
it('Cancel closes the modal without calling the reload mutation', async () => { it('Cancel closes the modal without calling the reload mutation', async () => {
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({ gotoIntune();
profiles: [baseEnabledProfile], expect(await screen.findByTestId('reload-button-corp')).toBeInTheDocument();
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(screen.getByTestId('reload-button-corp'));
fireEvent.click(await screen.findByRole('button', { name: /Cancel/i })); fireEvent.click(await screen.findByRole('button', { name: /Cancel/i }));
await waitFor(() => { 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 () => { // Recent Activity tab — filter chips.
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 () => { describe('SCEPAdminPage — Activity tab filter', () => {
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({ beforeEach(() => {
profiles: [baseEnabledProfile], vi.mocked(client.getAdminSCEPProfiles).mockResolvedValue({
profiles: [corpProfileSummary],
profile_count: 1, profile_count: 1,
generated_at: '2026-04-29T15:00:00Z', generated_at: '2026-04-29T15:00:00Z',
} as never); } as never);
vi.mocked(client.getAuditEvents).mockImplementation((params: Record<string, string> = {}) => { vi.mocked(client.getAuditEvents).mockImplementation((params: Record<string, string> = {}) => {
if (params.action === 'scep_pkcsreq_intune') { const lookup: Record<string, unknown[]> = {
return Promise.resolve({ 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' }],
data: [ 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' }],
{ 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' }, 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' }],
total: 1, page: 1, per_page: 200, };
} as never);
}
return Promise.resolve({ return Promise.resolve({
data: [ data: lookup[params.action ?? ''] ?? [],
{ 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,
total: 1, page: 1, per_page: 200, per_page: 200,
} as never); } as never);
}); });
});
renderWithQuery(<SCEPAdminPage />); it('filter=all shows all four actions', async () => {
await waitFor(() => { renderWithRoute('/scep?tab=activity', <SCEPAdminPage />);
expect(screen.getByTestId('recent-failures-table')).toBeInTheDocument(); 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', <SCEPAdminPage />);
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); expect(rows.length).toBe(2);
// Sorted descending by timestamp — renewal (14:30) comes before pkcs (14:00). for (const r of rows) {
expect(rows[0].textContent).toContain('scep_renewalreq_intune'); expect(r.textContent).toMatch(/_intune/);
expect(rows[1].textContent).toContain('scep_pkcsreq_intune'); }
});
it('filter=renewal narrows to just the two renewal actions', async () => {
renderWithRoute('/scep?tab=activity', <SCEPAdminPage />);
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', <SCEPAdminPage />);
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', <SCEPAdminPage />);
await waitFor(() => {
expect(screen.getByText(/Failed to load data/i)).toBeInTheDocument();
});
}); });
}); });
+501 -152
View File
@@ -1,28 +1,50 @@
import { useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query'; 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 PageHeader from '../components/PageHeader';
import ErrorState from '../components/ErrorState'; import ErrorState from '../components/ErrorState';
import { useAuth } from '../components/AuthProvider'; import { useAuth } from '../components/AuthProvider';
import { useTrackedMutation } from '../hooks/useTrackedMutation'; import { useTrackedMutation } from '../hooks/useTrackedMutation';
import { formatDateTime } from '../api/utils'; 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 // SCEP RFC 8894 + Intune master bundle Phase 9 follow-up
// Monitoring tab. // (cowork/scep-gui-restructure-prompt.md): per-profile SCEP
// administration page with three tabs.
// //
// Surfaces: // Profiles (default) — every configured SCEP profile, lean card per
// - Status banner per profile (trust anchor expiry countdown, rotates // profile with always-present fields (RA cert
// when < 30 days; the soonest-to-expire anchor wins). // expiry, mTLS sibling-route status,
// - Live counters table per profile (success / signature_invalid / // challenge-password-set indicator). Cards on
// claim_mismatch / expired / wrong_audience / replay / rate_limited / // Intune-enabled profiles get a "View Intune
// malformed / compliance_failed / not_yet_valid / unknown_version). // details →" link that deep-links to the
// Polled every 30s via TanStack Query. // Intune tab filtered to that profile.
// - Recent failures table (last 50) populated from the audit log // Intune Monitoring — the existing Phase 9.4 deep-dive. Per-profile
// filtered to action=scep_pkcsreq_intune (and the renewal sibling). // counters (success / signature_invalid /
// - Trust anchor reload button (per-profile) with confirmation modal; // claim_mismatch / expired / wrong_audience /
// calls POST /api/v1/admin/scep/intune/reload-trust under the hood // replay / rate_limited / malformed /
// (the SIGHUP-equivalent path). // 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 // Admin-gated: the page itself renders an "Admin access required" banner
// for non-admin callers and never issues the underlying admin requests. // 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', bad: 'text-red-600',
}; };
type TabId = 'profiles' | 'intune' | 'activity';
type ActivityFilter = 'all' | 'initial' | 'renewal' | 'intune' | 'static';
const TAB_LABELS: Record<TabId, string> = {
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 // soonestExpiryDays returns the smallest days_to_expiry across the
// profile's trust anchor pool. Returns null when the pool is empty (the // profile's Intune trust anchor pool. Returns null when the pool is
// per-profile preflight should have refused this state at boot, but // empty (the per-profile preflight should have refused this state at
// defensive in case the holder is reloaded mid-flight to an empty file). // boot, but defensive in case the holder is reloaded mid-flight to an
// empty file).
function soonestExpiryDays(anchors?: IntuneTrustAnchorInfo[]): number | null { function soonestExpiryDays(anchors?: IntuneTrustAnchorInfo[]): number | null {
if (!anchors || anchors.length === 0) return null; if (!anchors || anchors.length === 0) return null;
let min = Number.POSITIVE_INFINITY; let min = Number.POSITIVE_INFINITY;
for (const a of anchors) { 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; if (a.days_to_expiry < min) min = a.days_to_expiry;
} }
return min === Number.POSITIVE_INFINITY ? null : min; 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' }; // Profiles tab — per-profile lean card with always-present fields.
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' }; interface ProfilesTabProps {
return { text: `${days}d remaining`, tone: 'good' }; profiles: SCEPProfileStatsSnapshot[];
isLoading: boolean;
onViewIntuneDetails: (pathID: string) => void;
} }
function ProfilesTab({ profiles, isLoading, onViewIntuneDetails }: ProfilesTabProps) {
if (isLoading) {
return <p className="text-sm text-ink-muted px-1 py-6">Loading profiles</p>;
}
if (profiles.length === 0) {
return (
<div className="rounded border border-amber-300 bg-amber-50 p-4 text-sm text-amber-900">
No 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>
);
}
return (
<>
{profiles.map(p => (
<ProfileSummaryCard
key={p.path_id || '(root)'}
profile={p}
onViewIntuneDetails={onViewIntuneDetails}
/>
))}
</>
);
}
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 (
<section
className="bg-surface border border-surface-border rounded-lg p-5 mb-4"
data-testid={`profile-summary-${profile.path_id}`}
>
<header className="flex items-center justify-between mb-3">
<div>
<h3 className="text-base font-semibold text-ink">{pathLabel}</h3>
<p className="text-xs text-ink-muted">Issuer: {profile.issuer_id}</p>
</div>
<span
className={`text-xs px-2 py-0.5 rounded-full font-medium ${badgeClass(raBadge.tone)}`}
data-testid={`ra-expiry-badge-${profile.path_id}`}
>
RA cert: {raBadge.text}
</span>
</header>
<div className="flex flex-wrap gap-2 mb-3" data-testid={`profile-badges-${profile.path_id}`}>
<span className={`text-[11px] uppercase tracking-wide px-2 py-0.5 rounded border ${pillClass(profile.challenge_password_set)}`}>
Challenge password{profile.challenge_password_set ? ' set' : ' MISSING'}
</span>
<span className={`text-[11px] uppercase tracking-wide px-2 py-0.5 rounded border ${pillClass(profile.mtls_enabled)}`}>
mTLS {profile.mtls_enabled ? 'enabled' : 'disabled'}
</span>
<span className={`text-[11px] uppercase tracking-wide px-2 py-0.5 rounded border ${pillClass(intuneEnabled)}`}>
Intune {intuneEnabled ? 'enabled' : 'disabled'}
</span>
</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">RA cert subject</dt>
<dd className="font-mono text-[11px]">{profile.ra_cert_subject || '(not loaded)'}</dd>
</div>
{profile.ra_cert_not_after && (
<div>
<dt className="font-semibold text-ink">RA cert expires</dt>
<dd>{formatDateTime(profile.ra_cert_not_after)}</dd>
</div>
)}
{profile.mtls_enabled && profile.mtls_trust_bundle_path && (
<div>
<dt className="font-semibold text-ink">mTLS trust bundle</dt>
<dd className="font-mono text-[11px]">{profile.mtls_trust_bundle_path}</dd>
</div>
)}
</dl>
{intuneEnabled && (
<div className="mt-4 pt-3 border-t border-surface-border flex justify-end">
<button
type="button"
onClick={() => onViewIntuneDetails(profile.path_id)}
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
data-testid={`view-intune-details-${profile.path_id}`}
>
View Intune details
</button>
</div>
)}
</section>
);
}
// =============================================================================
// Intune Monitoring tab — the existing Phase 9.4 deep-dive surface.
// =============================================================================
interface ConfirmReloadModalProps { interface ConfirmReloadModalProps {
profile: IntuneStatsSnapshot; profile: IntuneStatsSnapshot;
onCancel: () => void; onCancel: () => void;
@@ -140,39 +313,74 @@ function ConfirmReloadModal({ profile, onCancel, onConfirm, pending, errorMessag
); );
} }
interface ProfileCardProps { interface IntuneTabProps {
profile: IntuneStatsSnapshot; profiles: IntuneStatsSnapshot[];
isLoading: boolean;
onRequestReload: (profile: IntuneStatsSnapshot) => void; onRequestReload: (profile: IntuneStatsSnapshot) => void;
highlightPathID: string | null;
events: AuditEvent[];
eventsLoading: boolean;
} }
function ProfileCard({ profile, onRequestReload }: ProfileCardProps) { function IntuneTab({ profiles, isLoading, onRequestReload, highlightPathID, events, eventsLoading }: IntuneTabProps) {
const pathLabel = profile.path_id || '(legacy /scep root)'; if (isLoading) {
if (!profile.enabled) { return <p className="text-sm text-ink-muted px-1 py-6">Loading Intune monitoring data</p>;
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 intuneProfiles = profiles.filter(p => p.enabled);
return (
<>
{intuneProfiles.length === 0 && (
<div className="rounded border border-amber-300 bg-amber-50 p-4 text-sm text-amber-900 mb-4">
No SCEP profile has Intune enabled. Set
<code className="mx-1">CERTCTL_SCEP_PROFILE_&lt;NAME&gt;_INTUNE_ENABLED=true</code>
plus the matching trust-anchor path env var, then restart the server.
</div>
)}
{intuneProfiles.map(p => (
<IntuneProfileCard
key={p.path_id || '(root)'}
profile={p}
onRequestReload={onRequestReload}
highlighted={highlightPathID === p.path_id}
/>
))}
<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>
{eventsLoading ? (
<p className="text-sm text-ink-muted px-4 py-6">Loading audit log</p>
) : (
<RecentEventsTable events={events.slice(0, 50)} testID="intune-failures-table" emptyMessage="No recent Intune-dispatched enrollment events. Counters stay at zero until the first device hits a SCEP profile with Intune enabled." />
)}
</section>
</>
);
}
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 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 ( return (
<section className="bg-surface border border-surface-border rounded-lg p-5 mb-4" data-testid={`profile-card-${profile.path_id}`}> <section className={cardClass} data-testid={`profile-card-${profile.path_id}`}>
<header className="flex items-center justify-between mb-3"> <header className="flex items-center justify-between mb-3">
<div> <div>
<h3 className="text-base font-semibold text-ink">{pathLabel}</h3> <h3 className="text-base font-semibold text-ink">{pathLabel}</h3>
@@ -183,13 +391,7 @@ function ProfileCard({ profile, onRequestReload }: ProfileCardProps) {
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span <span
className={`text-xs px-2 py-0.5 rounded-full font-medium ${ className={`text-xs px-2 py-0.5 rounded-full font-medium ${badgeClass(badge.tone)}`}
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}`} data-testid={`expiry-badge-${profile.path_id}`}
> >
Trust anchor: {badge.text} 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 (
<section className="bg-surface border border-surface-border rounded-lg" data-testid="activity-tab">
<div className="px-4 py-3 border-b border-surface-border">
<h3 className="text-sm font-semibold text-ink">SCEP enrollment audit log (last 100)</h3>
<p className="text-xs text-ink-muted mb-3">
Merged across <code>scep_pkcsreq</code> + <code>scep_renewalreq</code> +
<code> scep_pkcsreq_intune</code> + <code>scep_renewalreq_intune</code>. Refreshes every 60s.
</p>
<div className="flex flex-wrap gap-2" data-testid="activity-filter-chips">
{(['all', 'initial', 'renewal', 'intune', 'static'] as const).map(f => (
<button
key={f}
type="button"
onClick={() => setFilter(f)}
className={`text-xs px-2 py-1 rounded border ${
filter === f
? 'bg-brand-100 text-brand-800 border-brand-300'
: 'bg-surface text-ink-muted border-surface-border hover:bg-surface-alt'
}`}
data-testid={`activity-filter-${f}`}
>
{f === 'all' ? 'All' : f.charAt(0).toUpperCase() + f.slice(1)}
</button>
))}
</div>
</div>
{isLoading ? (
<p className="text-sm text-ink-muted px-4 py-6">Loading audit log</p>
) : (
<RecentEventsTable
events={filtered.slice(0, 100)}
testID="activity-events-table"
emptyMessage={
events.length === 0
? 'No SCEP enrollment events recorded yet.'
: 'No events match the current filter — try a different chip.'
}
/>
)}
</section>
);
}
// =============================================================================
// Shared events table.
// =============================================================================
interface RecentEventsTableProps {
events: AuditEvent[];
testID: string;
emptyMessage: string;
}
function RecentEventsTable({ events, testID, emptyMessage }: RecentEventsTableProps) {
if (events.length === 0) { if (events.length === 0) {
return ( return <p className="text-sm text-ink-muted px-4 py-6">{emptyMessage}</p>;
<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 ( return (
<table className="w-full text-sm" data-testid="recent-failures-table"> <table className="w-full text-sm" data-testid={testID}>
<thead className="text-xs text-ink-muted uppercase tracking-wide"> <thead className="text-xs text-ink-muted uppercase tracking-wide">
<tr> <tr>
<th className="py-2 pl-4 pr-2 text-left">Timestamp</th> <th className="py-2 pl-4 pr-2 text-left">Timestamp</th>
@@ -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() { export default function SCEPAdminPage() {
const auth = useAuth(); const auth = useAuth();
const adminAccess = !auth.authRequired || auth.admin;
const [searchParams, setSearchParams] = useSearchParams();
const location = useLocation();
const [activeTab, setActiveTab] = useState<TabId>(() => pickInitialTab(searchParams, location.pathname));
const [highlightPathID, setHighlightPathID] = useState<string | null>(searchParams.get('profile'));
const [reloadTarget, setReloadTarget] = useState<IntuneStatsSnapshot | null>(null); const [reloadTarget, setReloadTarget] = useState<IntuneStatsSnapshot | null>(null);
const [reloadError, setReloadError] = useState<string | undefined>(undefined); const [reloadError, setReloadError] = useState<string | undefined>(undefined);
const [activityFilter, setActivityFilter] = useState<ActivityFilter>('all');
const statsQuery = useQuery({ // Keep URL in sync with tab + highlighted profile so deep links survive
queryKey: ['admin', 'scep', 'intune', 'stats'], // page reloads + browser back/forward.
queryFn: getAdminSCEPIntuneStats, useEffect(() => {
enabled: !auth.authRequired || auth.admin, // skip the request entirely when non-admin 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, refetchInterval: 30_000,
}); });
// Audit-log filter: every Intune-dispatched enrollment (success + failure) // Intune deep-dive data (Intune tab).
// emits action=scep_pkcsreq_intune (initial) or scep_renewalreq_intune const intuneStatsQuery = useQuery({
// (renewal). The audit endpoint accepts a single action filter; we fetch queryKey: ['admin', 'scep', 'intune', 'stats'],
// both server-side via two queries and merge client-side rather than queryFn: getAdminSCEPIntuneStats,
// adding a comma-separated filter that would require backend changes. enabled: adminAccess && activeTab === 'intune',
const auditPKCSQuery = useQuery({ refetchInterval: 30_000,
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,
}); });
// Bundle-8 / M-009 invalidation contract: trust-anchor reload changes // Audit log queries — four parallel queries (one per SCEP action) so
// both the per-profile trust pool (reflected in IntuneStats) AND every // both the Intune tab's recent-failures table and the Activity tab's
// recently-failed Intune enrollment counter that might now succeed on // full SCEP audit feed can pull from the same React Query cache.
// retry. We invalidate the stats key so the per-profile trust-anchor const auditQueries = SCEP_AUDIT_ACTIONS.map(action =>
// panel reflects the new pool immediately; the audit log queries // eslint-disable-next-line react-hooks/rules-of-hooks
// remain on their 60s timer (a SIGHUP-equivalent reload doesn't useQuery({
// backfill new audit rows). 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< const reloadMutation = useTrackedMutation<
Awaited<ReturnType<typeof reloadAdminSCEPIntuneTrust>>, Awaited<ReturnType<typeof reloadAdminSCEPIntuneTrust>>,
Error, Error,
string string
>({ >({
mutationFn: (pathID: string) => reloadAdminSCEPIntuneTrust(pathID), mutationFn: (pathID: string) => reloadAdminSCEPIntuneTrust(pathID),
invalidates: [['admin', 'scep', 'intune', 'stats']], invalidates: [
['admin', 'scep', 'intune', 'stats'],
['admin', 'scep', 'profiles'],
],
onSuccess: () => { onSuccess: () => {
setReloadTarget(null); setReloadTarget(null);
setReloadError(undefined); setReloadError(undefined);
@@ -354,53 +696,36 @@ export default function SCEPAdminPage() {
if (auth.authRequired && !auth.admin) { if (auth.authRequired && !auth.admin) {
return ( return (
<> <>
<PageHeader title="SCEP Intune Monitoring" subtitle="Admin-only observability surface" /> <PageHeader title="SCEP Administration" subtitle="Admin-only observability surface" />
<div className="p-6"> <div className="p-6">
<ErrorState <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.')} error={new Error('Admin access required: this page exposes per-profile RA cert expiries, mTLS bundle paths, Intune trust anchor expiries, and an admin-only reload action. Sign in with an admin-tagged API key to view it.')}
/> />
</div> </div>
</> </>
); );
} }
if (statsQuery.isLoading) { const profiles = profilesQuery.data?.profiles ?? [];
return ( const intuneProfiles = intuneStatsQuery.data?.profiles ?? [];
<>
<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) { const handleViewIntuneDetails = (pathID: string) => {
return ( setHighlightPathID(pathID);
<> setActiveTab('intune');
<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 ( return (
<> <>
<PageHeader <PageHeader
title="SCEP Intune Monitoring" title="SCEP Administration"
subtitle={`${profiles.length} SCEP profile${profiles.length === 1 ? '' : 's'} configured · counters auto-refresh every 30s`} subtitle={`${profiles.length} SCEP profile${profiles.length === 1 ? '' : 's'} configured · per-profile observability + Intune monitoring + recent activity`}
action={ action={
<button <button
type="button" type="button"
onClick={() => 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" className="text-xs px-3 py-1.5 rounded border border-surface-border bg-surface hover:bg-surface-alt"
data-testid="refresh-stats-button" data-testid="refresh-stats-button"
> >
@@ -408,41 +733,65 @@ export default function SCEPAdminPage() {
</button> </button>
} }
/> />
<div className="border-b border-surface-border bg-surface px-6">
<nav className="flex gap-1 -mb-px" data-testid="scep-admin-tabs">
{(['profiles', 'intune', 'activity'] as TabId[]).map(t => (
<button
key={t}
type="button"
onClick={() => setActiveTab(t)}
className={`px-4 py-2.5 text-sm border-b-2 transition-colors ${
activeTab === t
? 'border-brand-500 text-brand-700 font-semibold'
: 'border-transparent text-ink-muted hover:text-ink hover:border-surface-border'
}`}
data-testid={`tab-${t}`}
aria-pressed={activeTab === t}
>
{TAB_LABELS[t]}
</button>
))}
</nav>
</div>
<div className="p-6 overflow-y-auto"> <div className="p-6 overflow-y-auto">
{profiles.length === 0 && ( {profilesQuery.error && activeTab === 'profiles' && (
<div className="rounded border border-amber-300 bg-amber-50 p-4 text-sm text-amber-900 mb-4"> <ErrorState error={profilesQuery.error as Error} onRetry={() => profilesQuery.refetch()} />
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 => ( {intuneStatsQuery.error && activeTab === 'intune' && (
<ProfileCard <ErrorState error={intuneStatsQuery.error as Error} onRetry={() => intuneStatsQuery.refetch()} />
key={p.path_id || '(root)'} )}
profile={p}
{activeTab === 'profiles' && !profilesQuery.error && (
<ProfilesTab
profiles={profiles}
isLoading={profilesQuery.isLoading}
onViewIntuneDetails={handleViewIntuneDetails}
/>
)}
{activeTab === 'intune' && !intuneStatsQuery.error && (
<IntuneTab
profiles={intuneProfiles}
isLoading={intuneStatsQuery.isLoading}
onRequestReload={profile => { onRequestReload={profile => {
setReloadError(undefined); setReloadError(undefined);
setReloadTarget(profile); setReloadTarget(profile);
}} }}
highlightPathID={highlightPathID}
events={intuneOnlyEvents}
eventsLoading={auditLoading}
/> />
))} )}
<section className="bg-surface border border-surface-border rounded-lg mt-6"> {activeTab === 'activity' && (
<div className="px-4 py-3 border-b border-surface-border"> <ActivityTab
<h3 className="text-sm font-semibold text-ink"> events={allAuditEvents}
Recent Intune-dispatched enrollments (last 50) isLoading={auditLoading}
</h3> filter={activityFilter}
<p className="text-xs text-ink-muted"> setFilter={setActivityFilter}
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> </div>
{reloadTarget && ( {reloadTarget && (