mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 18:01:37 +00:00
feat(scep-intune): GUI monitoring tab + admin endpoints
Phase 9 of the SCEP RFC 8894 + Intune master bundle. Lands the operator-
facing Intune Monitoring tab plus the two admin-gated endpoints it reads
from. Per the constitutional 'complete path' rule: counters tick on
every typed dispatcher branch, the GUI poll is live (30s for stats,
60s for the audit log filter), and the SIGHUP-equivalent reload action
is one click + a confirmation modal — no follow-up plumbing required.
Backend (Phase 9.1 + 9.2 + 9.3):
* internal/service/scep.go gains:
- intuneCounterTab — atomic per-status counters keyed by the same
labels intuneFailReason() emits (success / signature_invalid /
expired / not_yet_valid / wrong_audience / replay / rate_limited /
claim_mismatch / compliance_failed / malformed / unknown_version).
Lock-free on the dispatcher hot path; snapshot() returns a
zero-allocation map for the admin endpoint.
- dispatchIntuneChallenge wires intuneCounters.inc(...) on every
typed return path INCLUDING the success leg (credited before
processEnrollment so a downstream issuer-connector failure
doesn't double-count).
- SetPathID + PathID accessors (so admin rows surface the SCEP
profile path ID per row).
- IntuneStatsSnapshot + IntuneTrustAnchorInfo public types, plus
IntuneStats(now) accessor that walks the trust holder pool and
packages a per-profile snapshot. ReloadIntuneTrust() is the
typed wrapper around TrustAnchorHolder.Reload that returns
ErrSCEPProfileIntuneDisabled when called on a profile where
Intune isn't enabled (admin endpoint maps that to HTTP 409).
* internal/api/handler/admin_scep_intune.go:
- AdminSCEPIntuneService narrow interface (Stats + ReloadTrust)
so the handler depends on a small surface; AdminSCEPIntuneServiceImpl
is the production walker over the per-profile SCEPService map.
- AdminSCEPIntuneHandler.Stats handles GET /api/v1/admin/scep/intune/stats
with the M-008 admin gate (non-admin → 403 + service never
invoked); returns {profiles, profile_count, generated_at}.
- AdminSCEPIntuneHandler.ReloadTrust handles POST
/api/v1/admin/scep/intune/reload-trust. Body is {path_id: '<id>'};
empty body targets the legacy /scep root profile. Returns 200 on
success / 404 on unknown PathID / 409 when the profile is Intune-
disabled / 500 on a parse error from intune.LoadTrustAnchor (the
holder retains its previous pool — fail-safe). 400 on malformed
JSON.
- ErrAdminSCEPProfileNotFound typed error so the handler can
distinguish 'wrong profile' from 'broken file'.
* internal/api/router/router.go: HandlerRegistry gains
AdminSCEPIntune; both routes registered as bearer-auth-required
(the admin-gate is at the handler layer per the M-008 pattern).
* cmd/server/main.go: declares scepServices map[string]*service.SCEPService
BEFORE HandlerRegistry construction so the same map can be referenced
from both the admin handler (constructed early) and the SCEP startup
loop (which populates it later by reference). The per-profile loop
now calls scepService.SetPathID(profile.PathID) and stores the service
pointer into the shared map. AdminSCEPIntune handler is constructed
at the same time as AdminCRLCache.
* internal/api/handler/m008_admin_gate_test.go: AdminGatedHandlers
map gains 'admin_scep_intune.go' with a one-line justification —
the regression scanner enforces the per-handler test triplet
(TestAdminSCEPIntune_NonAdmin_Returns403 + _AdminExplicitFalse_Returns403
+ _AdminPermitted_ForwardsActor) plus their POST siblings for
ReloadTrust.
* api/openapi.yaml: documents both endpoints with request body /
response shape / error mapping; openapi-parity-test now matches
the registered routes.
Frontend (Phase 9.4):
* web/src/pages/SCEPAdminPage.tsx — single-page Intune Monitoring
surface:
- Per-profile cards (one card per SCEP profile). Enabled profiles
get the full counter grid + trust-anchor-expiry badge tone
(good ≥30d / warn 7-30d / bad <7d / EXPIRED). Disabled profiles
get an off-state pill with the env-var hint to opt in.
- Counters polled every 30s via TanStack Query against
GET /admin/scep/intune/stats.
- Recent failures table (last 50) populated from the audit log
filtered to action=scep_pkcsreq_intune AND scep_renewalreq_intune;
merged + sorted by timestamp descending. Polled every 60s.
- Reload trust anchor button per profile + confirmation modal that
explains the SIGHUP equivalence and the fail-safe behavior.
onConfirm runs a TanStack mutation, refetches the stats query
on success, surfaces the underlying error (eg 'trust anchor
cert expired') in the modal on failure (modal stays open so
operator can retry).
- Admin gate: when authRequired && !admin the page renders an
'Admin access required' banner and the underlying admin API
requests are never issued (React Query enabled flag gated on
auth.admin) — server-side enforcement is M-008.
* web/src/api/types.ts: IntuneStatsSnapshot + IntuneTrustAnchorInfo +
IntuneStatsResponse + IntuneReloadTrustResponse.
* web/src/api/client.ts: getAdminSCEPIntuneStats +
reloadAdminSCEPIntuneTrust(pathID).
* web/src/main.tsx: new route /scep/intune. The route is unconditional;
the gating is at the page level so deep-links land cleanly.
* web/src/components/Layout.tsx: 'SCEP Intune' nav link between
Observability and Audit Trail with the appropriate sidebar icon.
Tests (Phase 9.5):
* internal/api/handler/admin_scep_intune_test.go (16 tests):
- M-008 admin-gate triplet for both Stats (GET) and ReloadTrust
(POST): NonAdmin / AdminExplicitFalse / AdminPermitted.
- Method-gate tests (Stats rejects POST, ReloadTrust rejects GET).
- Stats propagates service errors as 500.
- ReloadTrust maps ErrAdminSCEPProfileNotFound→404,
ErrSCEPProfileIntuneDisabled→409, generic err→500.
- Empty body targets legacy root PathID.
- Malformed JSON→400.
- AdminSCEPIntuneServiceImpl handles nil map + unknown PathID.
* web/src/pages/SCEPAdminPage.test.tsx (13 tests):
- Admin gate (non-admin sees gated banner + zero admin API calls;
admin sees the page; no-auth dev mode also passes).
- Profile rendering (counters with correct labels, expiry badge
tone for ≥30d / EXPIRED states, off-state pill for disabled
profiles, empty-state banner when no profiles configured).
- Reload modal (opens on click, calls mutation on Confirm,
keeps modal open + shows error on failure, Cancel skips
mutation).
- Error path renders ErrorState with retry.
- Audit log filter merges PKCSReq + RenewalReq events and sorts
descending.
Verification:
* gofmt clean on touched files
* go vet ./... clean
* staticcheck on intune/service/api/cmd-server clean
* go test -short across api+service+intune+cmd-server: all green
* web tsc --noEmit clean
* Vitest: SCEPAdminPage.test.tsx 13/13 + sibling page suites all
pass
* G-3 docs-drift CI guard: Phase 9 adds no new CERTCTL_* env vars
so the guard does not fire
* openapi-parity-test green (both new admin endpoints documented)
* M-008 regression scanner enforces the per-handler test triplet —
pin updated, all triplets present
Refs: cowork/scep-rfc8894-intune-master-prompt.md::Phase 9
cowork/scep-rfc8894-intune/progress.md
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
||||
"github.com/shankar0123/certctl/internal/service"
|
||||
)
|
||||
|
||||
// AdminSCEPIntuneService is the slice of the per-profile SCEPService set
|
||||
// the admin endpoint needs. The handler depends on this narrow interface
|
||||
// rather than the concrete *service.SCEPService set so wiring stays
|
||||
// service-side and the handler stays test-friendly.
|
||||
//
|
||||
// SCEP RFC 8894 + Intune master bundle Phase 9.1.
|
||||
type AdminSCEPIntuneService interface {
|
||||
// Stats returns one snapshot per configured SCEP profile (Intune-
|
||||
// enabled or not). Profiles where Intune is disabled appear with
|
||||
// Enabled=false so the GUI can show "off — opt in via env vars"
|
||||
// rather than 404ing per-profile.
|
||||
Stats(ctx context.Context, now time.Time) ([]service.IntuneStatsSnapshot, error)
|
||||
|
||||
// ReloadTrust triggers the SIGHUP-equivalent Reload on the named
|
||||
// profile's trust holder. Returns ErrAdminSCEPProfileNotFound if
|
||||
// the PathID isn't known, or ErrSCEPProfileIntuneDisabled if the
|
||||
// profile exists but doesn't have Intune turned on, or the
|
||||
// underlying parse error from intune.LoadTrustAnchor on a bad
|
||||
// reload (the holder retains the OLD pool either way — the
|
||||
// fail-safe is enforced one layer down).
|
||||
ReloadTrust(ctx context.Context, pathID string) error
|
||||
}
|
||||
|
||||
// ErrAdminSCEPProfileNotFound is returned by AdminSCEPIntuneService
|
||||
// implementations when the operator targets a PathID that doesn't map
|
||||
// to any configured profile. The handler maps this to HTTP 404.
|
||||
var ErrAdminSCEPProfileNotFound = errors.New("admin scep intune: profile not found for the given path_id")
|
||||
|
||||
// AdminSCEPIntuneHandler serves the per-profile Intune observability
|
||||
// endpoints for the GUI Intune Monitoring tab.
|
||||
//
|
||||
// Endpoints:
|
||||
//
|
||||
// GET /api/v1/admin/scep/intune/stats
|
||||
// POST /api/v1/admin/scep/intune/reload-trust (JSON body: {"path_id": "corp"})
|
||||
//
|
||||
// Both endpoints are admin-gated (M-008 pattern). Non-admin Bearer
|
||||
// callers get 403 — the stats endpoint reveals the operator's profile
|
||||
// set + trust anchor expiries (sensitive operational metadata) and the
|
||||
// reload endpoint is a privileged action.
|
||||
type AdminSCEPIntuneHandler struct {
|
||||
svc AdminSCEPIntuneService
|
||||
}
|
||||
|
||||
// NewAdminSCEPIntuneHandler creates a new admin handler.
|
||||
func NewAdminSCEPIntuneHandler(svc AdminSCEPIntuneService) AdminSCEPIntuneHandler {
|
||||
return AdminSCEPIntuneHandler{svc: svc}
|
||||
}
|
||||
|
||||
// adminScepIntuneReloadRequest is the POST body shape for the reload-
|
||||
// trust endpoint. PathID="" targets the legacy /scep root profile (the
|
||||
// one with empty PathID), matching the convention used elsewhere in the
|
||||
// per-profile dispatch.
|
||||
type adminScepIntuneReloadRequest struct {
|
||||
PathID string `json:"path_id"`
|
||||
}
|
||||
|
||||
// Stats handles GET /api/v1/admin/scep/intune/stats.
|
||||
func (h AdminSCEPIntuneHandler) Stats(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
if !middleware.IsAdmin(r.Context()) {
|
||||
Error(w, http.StatusForbidden, "Admin access required")
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
rows, err := h.svc.Stats(r.Context(), now)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to read SCEP Intune stats")
|
||||
return
|
||||
}
|
||||
if rows == nil {
|
||||
// Avoid serialising as `null` — the GUI expects an array.
|
||||
rows = []service.IntuneStatsSnapshot{}
|
||||
}
|
||||
_ = JSON(w, http.StatusOK, map[string]any{
|
||||
"profiles": rows,
|
||||
"profile_count": len(rows),
|
||||
"generated_at": now.UTC(),
|
||||
})
|
||||
}
|
||||
|
||||
// ReloadTrust handles POST /api/v1/admin/scep/intune/reload-trust.
|
||||
func (h AdminSCEPIntuneHandler) ReloadTrust(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
if !middleware.IsAdmin(r.Context()) {
|
||||
Error(w, http.StatusForbidden, "Admin access required")
|
||||
return
|
||||
}
|
||||
|
||||
var body adminScepIntuneReloadRequest
|
||||
// An empty body is permitted: it implicitly targets the legacy
|
||||
// /scep root profile (PathID=""). Operators with multi-profile
|
||||
// deploys MUST supply a path_id JSON field.
|
||||
if r.ContentLength > 0 {
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid JSON body: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
err := h.svc.ReloadTrust(r.Context(), body.PathID)
|
||||
switch {
|
||||
case err == nil:
|
||||
_ = JSON(w, http.StatusOK, map[string]any{
|
||||
"reloaded": true,
|
||||
"path_id": body.PathID,
|
||||
"reloaded_at": time.Now().UTC(),
|
||||
})
|
||||
case errors.Is(err, ErrAdminSCEPProfileNotFound):
|
||||
Error(w, http.StatusNotFound, "SCEP profile not found for path_id="+body.PathID)
|
||||
case errors.Is(err, service.ErrSCEPProfileIntuneDisabled):
|
||||
// 409 Conflict: the profile exists but Intune isn't turned on,
|
||||
// so there's no trust anchor to reload. Distinct from 404 so
|
||||
// the operator can correct the request without re-checking the
|
||||
// profile list.
|
||||
Error(w, http.StatusConflict, "SCEP profile path_id="+body.PathID+" does not have Intune enabled")
|
||||
default:
|
||||
// Underlying intune.LoadTrustAnchor errors (parse failure,
|
||||
// expired cert, missing file). The holder retains its previous
|
||||
// pool — the operator's enrollments keep working off the old
|
||||
// trust anchor while the operator fixes the file.
|
||||
Error(w, http.StatusInternalServerError, "Trust anchor reload failed: "+err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// AdminSCEPIntuneServiceImpl is the production implementation of
|
||||
// AdminSCEPIntuneService. It walks the per-profile SCEPService set
|
||||
// supplied by the caller (cmd/server/main.go) and aggregates the
|
||||
// per-profile snapshots.
|
||||
//
|
||||
// Lives in the handler package because it's a thin handler-side
|
||||
// composition; the heavy lifting is the per-service IntuneStats /
|
||||
// ReloadIntuneTrust methods that already encapsulate the policy.
|
||||
type AdminSCEPIntuneServiceImpl struct {
|
||||
// services is keyed by SCEP profile PathID (empty string = legacy
|
||||
// /scep root). Built once at server startup; the slice/map shape
|
||||
// matches the per-profile SCEPService construction loop in
|
||||
// cmd/server/main.go.
|
||||
services map[string]*service.SCEPService
|
||||
}
|
||||
|
||||
// NewAdminSCEPIntuneServiceImpl constructs the handler-side service
|
||||
// from the per-profile SCEPService map built at startup.
|
||||
func NewAdminSCEPIntuneServiceImpl(services map[string]*service.SCEPService) *AdminSCEPIntuneServiceImpl {
|
||||
if services == nil {
|
||||
services = map[string]*service.SCEPService{}
|
||||
}
|
||||
return &AdminSCEPIntuneServiceImpl{services: services}
|
||||
}
|
||||
|
||||
// Stats implements AdminSCEPIntuneService.
|
||||
func (s *AdminSCEPIntuneServiceImpl) Stats(_ context.Context, now time.Time) ([]service.IntuneStatsSnapshot, error) {
|
||||
out := make([]service.IntuneStatsSnapshot, 0, len(s.services))
|
||||
for _, svc := range s.services {
|
||||
out = append(out, svc.IntuneStats(now))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ReloadTrust implements AdminSCEPIntuneService.
|
||||
func (s *AdminSCEPIntuneServiceImpl) ReloadTrust(_ context.Context, pathID string) error {
|
||||
svc, ok := s.services[pathID]
|
||||
if !ok {
|
||||
return ErrAdminSCEPProfileNotFound
|
||||
}
|
||||
return svc.ReloadIntuneTrust()
|
||||
}
|
||||
|
||||
// Compile-time interface check.
|
||||
var _ AdminSCEPIntuneService = (*AdminSCEPIntuneServiceImpl)(nil)
|
||||
@@ -0,0 +1,336 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
||||
"github.com/shankar0123/certctl/internal/service"
|
||||
)
|
||||
|
||||
// fakeAdminSCEPIntuneService is the test stub for AdminSCEPIntuneService.
|
||||
// Records call observations so the M-008 admin-gate triplet can pin
|
||||
// "service was never invoked" when the gate rejects the caller.
|
||||
type fakeAdminSCEPIntuneService struct {
|
||||
statsCalled bool
|
||||
reloadCalled bool
|
||||
rows []service.IntuneStatsSnapshot
|
||||
statsErr error
|
||||
reloadPathID string
|
||||
reloadErr error
|
||||
}
|
||||
|
||||
func (f *fakeAdminSCEPIntuneService) Stats(_ context.Context, _ time.Time) ([]service.IntuneStatsSnapshot, error) {
|
||||
f.statsCalled = true
|
||||
return f.rows, f.statsErr
|
||||
}
|
||||
|
||||
func (f *fakeAdminSCEPIntuneService) ReloadTrust(_ context.Context, pathID string) error {
|
||||
f.reloadCalled = true
|
||||
f.reloadPathID = pathID
|
||||
return f.reloadErr
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// M-008 admin-gate triplet for Stats (GET).
|
||||
// =============================================================================
|
||||
|
||||
func TestAdminSCEPIntune_NonAdmin_Returns403(t *testing.T) {
|
||||
svc := &fakeAdminSCEPIntuneService{}
|
||||
h := NewAdminSCEPIntuneHandler(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/intune/stats", nil)
|
||||
req = req.WithContext(contextWithRequestID()) // request id only, no admin flag
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Stats(w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 for non-admin, got %d (body=%q)", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]any
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
msg, _ := resp["message"].(string)
|
||||
if !strings.Contains(strings.ToLower(msg), "admin") {
|
||||
t.Errorf("expected message to mention admin requirement, got %q", msg)
|
||||
}
|
||||
if svc.statsCalled {
|
||||
t.Errorf("service was invoked despite non-admin caller — gate failed open")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSCEPIntune_AdminExplicitFalse_Returns403(t *testing.T) {
|
||||
svc := &fakeAdminSCEPIntuneService{}
|
||||
h := NewAdminSCEPIntuneHandler(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/intune/stats", nil)
|
||||
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
|
||||
ctx = context.WithValue(ctx, middleware.AdminKey{}, false)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Stats(w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 for admin=false, got %d", w.Code)
|
||||
}
|
||||
if svc.statsCalled {
|
||||
t.Error("service called despite admin=false gate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSCEPIntune_AdminPermitted_ForwardsActor(t *testing.T) {
|
||||
svc := &fakeAdminSCEPIntuneService{
|
||||
rows: []service.IntuneStatsSnapshot{
|
||||
{PathID: "corp", IssuerID: "iss-corp", Enabled: true},
|
||||
{PathID: "iot", IssuerID: "iss-iot", Enabled: false},
|
||||
},
|
||||
}
|
||||
h := NewAdminSCEPIntuneHandler(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/intune/stats", nil)
|
||||
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
|
||||
ctx = context.WithValue(ctx, middleware.AdminKey{}, true)
|
||||
ctx = context.WithValue(ctx, middleware.UserKey{}, "ops-admin")
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Stats(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 for admin caller, got %d (body=%q)", w.Code, w.Body.String())
|
||||
}
|
||||
if !svc.statsCalled {
|
||||
t.Fatal("service was not invoked for admin caller")
|
||||
}
|
||||
var resp map[string]any
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if pc, ok := resp["profile_count"].(float64); !ok || pc != 2 {
|
||||
t.Errorf("profile_count = %v, want 2", resp["profile_count"])
|
||||
}
|
||||
if _, ok := resp["profiles"].([]any); !ok {
|
||||
t.Errorf("profiles missing or wrong shape: %v", resp["profiles"])
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// M-008 triplet for ReloadTrust (POST).
|
||||
// =============================================================================
|
||||
|
||||
func TestAdminSCEPIntuneReload_NonAdmin_Returns403(t *testing.T) {
|
||||
svc := &fakeAdminSCEPIntuneService{}
|
||||
h := NewAdminSCEPIntuneHandler(svc)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/reload-trust",
|
||||
strings.NewReader(`{"path_id":"corp"}`))
|
||||
req.ContentLength = int64(len(`{"path_id":"corp"}`))
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.ReloadTrust(w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 non-admin, got %d", w.Code)
|
||||
}
|
||||
if svc.reloadCalled {
|
||||
t.Error("service called despite non-admin gate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSCEPIntuneReload_AdminExplicitFalse_Returns403(t *testing.T) {
|
||||
svc := &fakeAdminSCEPIntuneService{}
|
||||
h := NewAdminSCEPIntuneHandler(svc)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/reload-trust",
|
||||
strings.NewReader(`{"path_id":"corp"}`))
|
||||
req.ContentLength = int64(len(`{"path_id":"corp"}`))
|
||||
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, false)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.ReloadTrust(w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 admin=false, got %d", w.Code)
|
||||
}
|
||||
if svc.reloadCalled {
|
||||
t.Error("service called despite admin=false gate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSCEPIntuneReload_AdminPermitted_ForwardsActor(t *testing.T) {
|
||||
svc := &fakeAdminSCEPIntuneService{}
|
||||
h := NewAdminSCEPIntuneHandler(svc)
|
||||
body := `{"path_id":"corp"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/reload-trust",
|
||||
strings.NewReader(body))
|
||||
req.ContentLength = int64(len(body))
|
||||
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
|
||||
ctx = context.WithValue(ctx, middleware.UserKey{}, "ops-admin")
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.ReloadTrust(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d (body=%q)", w.Code, w.Body.String())
|
||||
}
|
||||
if !svc.reloadCalled {
|
||||
t.Fatal("reload was not invoked")
|
||||
}
|
||||
if svc.reloadPathID != "corp" {
|
||||
t.Errorf("path_id forwarded = %q, want corp", svc.reloadPathID)
|
||||
}
|
||||
var resp map[string]any
|
||||
_ = json.NewDecoder(w.Body).Decode(&resp)
|
||||
if reloaded, _ := resp["reloaded"].(bool); !reloaded {
|
||||
t.Errorf("response.reloaded = %v, want true", resp["reloaded"])
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Endpoint behavior — method gates, error mapping, body parsing.
|
||||
// =============================================================================
|
||||
|
||||
func TestAdminSCEPIntuneStats_RejectsNonGetMethod(t *testing.T) {
|
||||
h := NewAdminSCEPIntuneHandler(&fakeAdminSCEPIntuneService{})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/stats", nil)
|
||||
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
h.Stats(w, req)
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("expected 405 for POST, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSCEPIntuneReload_RejectsNonPostMethod(t *testing.T) {
|
||||
h := NewAdminSCEPIntuneHandler(&fakeAdminSCEPIntuneService{})
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/intune/reload-trust", nil)
|
||||
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
h.ReloadTrust(w, req)
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("expected 405 for GET, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSCEPIntuneStats_PropagatesServiceError(t *testing.T) {
|
||||
svc := &fakeAdminSCEPIntuneService{statsErr: errors.New("registry walk failed")}
|
||||
h := NewAdminSCEPIntuneHandler(svc)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/intune/stats", nil)
|
||||
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
h.Stats(w, req)
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500 on service error, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSCEPIntuneReload_ProfileNotFound_Returns404(t *testing.T) {
|
||||
svc := &fakeAdminSCEPIntuneService{reloadErr: ErrAdminSCEPProfileNotFound}
|
||||
h := NewAdminSCEPIntuneHandler(svc)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/reload-trust",
|
||||
strings.NewReader(`{"path_id":"nonexistent"}`))
|
||||
req.ContentLength = int64(len(`{"path_id":"nonexistent"}`))
|
||||
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
h.ReloadTrust(w, req)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404 for unknown profile, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSCEPIntuneReload_IntuneDisabled_Returns409(t *testing.T) {
|
||||
svc := &fakeAdminSCEPIntuneService{reloadErr: service.ErrSCEPProfileIntuneDisabled}
|
||||
h := NewAdminSCEPIntuneHandler(svc)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/reload-trust",
|
||||
strings.NewReader(`{"path_id":"iot"}`))
|
||||
req.ContentLength = int64(len(`{"path_id":"iot"}`))
|
||||
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
h.ReloadTrust(w, req)
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Errorf("expected 409 for Intune-disabled profile, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSCEPIntuneReload_BadReloadPropagates500(t *testing.T) {
|
||||
svc := &fakeAdminSCEPIntuneService{reloadErr: errors.New("trust anchor cert expired")}
|
||||
h := NewAdminSCEPIntuneHandler(svc)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/reload-trust",
|
||||
strings.NewReader(`{"path_id":"corp"}`))
|
||||
req.ContentLength = int64(len(`{"path_id":"corp"}`))
|
||||
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
h.ReloadTrust(w, req)
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500 on bad reload, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSCEPIntuneReload_EmptyBodyTargetsLegacyRoot(t *testing.T) {
|
||||
svc := &fakeAdminSCEPIntuneService{}
|
||||
h := NewAdminSCEPIntuneHandler(svc)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/reload-trust", nil)
|
||||
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
h.ReloadTrust(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200 with empty body (legacy root path), got %d", w.Code)
|
||||
}
|
||||
if svc.reloadPathID != "" {
|
||||
t.Errorf("empty body should target empty PathID; got %q", svc.reloadPathID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSCEPIntuneReload_RejectsMalformedJSON(t *testing.T) {
|
||||
h := NewAdminSCEPIntuneHandler(&fakeAdminSCEPIntuneService{})
|
||||
bad := `{not valid json`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/reload-trust",
|
||||
strings.NewReader(bad))
|
||||
req.ContentLength = int64(len(bad))
|
||||
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
h.ReloadTrust(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 on malformed JSON, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// AdminSCEPIntuneServiceImpl — narrow integration with the per-profile map.
|
||||
// =============================================================================
|
||||
|
||||
func TestAdminSCEPIntuneServiceImpl_NilMapReturnsEmpty(t *testing.T) {
|
||||
impl := NewAdminSCEPIntuneServiceImpl(nil)
|
||||
rows, err := impl.Stats(context.Background(), time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("nil-map Stats: %v", err)
|
||||
}
|
||||
if len(rows) != 0 {
|
||||
t.Errorf("nil-map Stats len=%d, want 0", len(rows))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSCEPIntuneServiceImpl_ReloadUnknownPathReturnsNotFound(t *testing.T) {
|
||||
impl := NewAdminSCEPIntuneServiceImpl(map[string]*service.SCEPService{})
|
||||
if err := impl.ReloadTrust(context.Background(), "nope"); !errors.Is(err, ErrAdminSCEPProfileNotFound) {
|
||||
t.Errorf("ReloadTrust unknown = %v, want ErrAdminSCEPProfileNotFound", err)
|
||||
}
|
||||
}
|
||||
@@ -35,8 +35,9 @@ import (
|
||||
// the gate exists. health.go is an INFORMATIONAL caller of IsAdmin (it
|
||||
// surfaces the flag to the GUI but does not gate) — explicitly excluded.
|
||||
var AdminGatedHandlers = map[string]string{
|
||||
"bulk_revocation.go": "M-003: bulk revocation is fleet-scale destructive — admin-only",
|
||||
"admin_crl_cache.go": "CRL/OCSP-Responder Phase 5: cache state reveals issuer set + CRL cadence — admin-only",
|
||||
"bulk_revocation.go": "M-003: bulk revocation is fleet-scale destructive — admin-only",
|
||||
"admin_crl_cache.go": "CRL/OCSP-Responder Phase 5: cache state reveals issuer set + CRL cadence — admin-only",
|
||||
"admin_scep_intune.go": "SCEP RFC 8894 + Intune master bundle Phase 9.2: stats endpoint reveals per-profile trust anchor expiries + reload-trust is a privileged action — admin-only",
|
||||
}
|
||||
|
||||
// InformationalIsAdminCallers is the documented allowlist of files that
|
||||
|
||||
@@ -127,6 +127,14 @@ type HandlerRegistry struct {
|
||||
// Responder Phase 5 — admin-gated ops surface for the
|
||||
// scheduler-driven CRL pre-generation pipeline.
|
||||
AdminCRLCache handler.AdminCRLCacheHandler
|
||||
// AdminSCEPIntune handles the per-profile Microsoft Intune Connector
|
||||
// observability + reload endpoints. SCEP RFC 8894 + Intune master
|
||||
// bundle Phase 9.2.
|
||||
// GET /api/v1/admin/scep/intune/stats → per-profile snapshot
|
||||
// POST /api/v1/admin/scep/intune/reload-trust → SIGHUP-equivalent
|
||||
// Both endpoints are admin-gated (M-008 pin updated to include
|
||||
// admin_scep_intune.go).
|
||||
AdminSCEPIntune handler.AdminSCEPIntuneHandler
|
||||
}
|
||||
|
||||
// RegisterHandlers sets up all API routes with their handlers.
|
||||
@@ -296,6 +304,12 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
|
||||
// scheduler-driven CRL pre-generation cache. Admin-gated inside
|
||||
// the handler (M-003 pattern); non-admin callers get 403.
|
||||
r.Register("GET /api/v1/admin/crl/cache", http.HandlerFunc(reg.AdminCRLCache.ListCache))
|
||||
// SCEP RFC 8894 + Intune master bundle Phase 9.2. Both endpoints are
|
||||
// admin-gated at the handler layer; the M-008 regression scanner pins
|
||||
// the gate set and TestM008_AdminGatedHandlers_HaveTripletTests
|
||||
// enforces the per-handler test triplet.
|
||||
r.Register("GET /api/v1/admin/scep/intune/stats", http.HandlerFunc(reg.AdminSCEPIntune.Stats))
|
||||
r.Register("POST /api/v1/admin/scep/intune/reload-trust", http.HandlerFunc(reg.AdminSCEPIntune.ReloadTrust))
|
||||
|
||||
// Notifications routes: /api/v1/notifications
|
||||
r.Register("GET /api/v1/notifications", http.HandlerFunc(reg.Notifications.ListNotifications))
|
||||
|
||||
Reference in New Issue
Block a user