diff --git a/cmd/cli/main.go b/cmd/cli/main.go index 871fc96..bb4d358 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -41,6 +41,14 @@ Commands: Required: --owner-id, --team-id, --renewal-policy-id, --issuer-id Optional: --name-template (default {cn}), --environment (default imported) + est cacerts --profile

EST GET cacerts (RFC 7030 §4.1) + est csrattrs --profile

EST GET csrattrs (RFC 7030 §4.5) + est enroll --profile

--csr EST POST simpleenroll (RFC 7030 §4.2) + est reenroll --profile

--csr EST POST simplereenroll (RFC 7030 §4.2.2) + est serverkeygen --profile

--csr --out + EST POST serverkeygen (RFC 7030 §4.4) + est test --profile

Smoke-test cacerts + csrattrs + status Show server health + summary stats version Show CLI version @@ -99,6 +107,8 @@ Examples: err = handleJobs(client, cmdArgs) case "import": err = handleImport(client, cmdArgs) + case "est": + err = handleEST(client, cmdArgs) case "status": err = handleStatus(client) case "version": @@ -255,6 +265,35 @@ func handleStatus(client *cli.Client) error { return client.GetStatus() } +// handleEST dispatches the `est` subcommands. Mirrors the existing +// handleCerts / handleAgents pattern verbatim. EST RFC 7030 hardening +// master bundle Phase 9.1. +func handleEST(client *cli.Client, args []string) error { + if len(args) == 0 { + fmt.Fprintf(os.Stderr, "usage: est [options]\n") + return nil + } + subcommand := args[0] + subArgs := args[1:] + switch subcommand { + case "cacerts": + return client.EstCacerts(subArgs) + case "csrattrs": + return client.EstCsrattrs(subArgs) + case "enroll": + return client.EstEnroll(subArgs) + case "reenroll": + return client.EstReEnroll(subArgs) + case "serverkeygen": + return client.EstServerKeygen(subArgs) + case "test": + return client.EstTest(subArgs) + default: + fmt.Fprintf(os.Stderr, "unknown subcommand: est %s\n", subcommand) + return nil + } +} + // validateHTTPSScheme rejects plaintext and empty-scheme server URLs at // startup so operators get a fail-loud diagnostic before any network call, // not a TCP-refused or TLS-handshake-error downstream. See docs/upgrade-to-tls.md. diff --git a/internal/cli/est.go b/internal/cli/est.go new file mode 100644 index 0000000..14e1669 --- /dev/null +++ b/internal/cli/est.go @@ -0,0 +1,348 @@ +package cli + +// EST RFC 7030 hardening master bundle Phase 9.1 — CLI subcommands. +// +// The EST endpoints live under /.well-known/est/[/]; they are +// HTTPS-only (the certctl control plane is HTTPS-only as of v2.2) and +// per-profile dispatched. The CLI subcommands here mirror what an +// operator would do via libest or curl + base64 + openssl, but with a +// fixed --profile flag + the existing CLI's TLS-pinning semantics. +// +// Subcommands: +// +// certctl-cli est cacerts --profile corp +// certctl-cli est csrattrs --profile corp +// certctl-cli est enroll --profile corp --csr +// certctl-cli est reenroll --profile corp --csr +// certctl-cli est serverkeygen --profile corp --csr --out +// certctl-cli est test --profile corp +// +// All write operations stream the issued cert (PEM) to stdout by default +// or to --out when provided. Server-keygen writes .cert.pem + +// .key.enveloped (the EnvelopedData blob) so the operator can +// decrypt with openssl smime. + +import ( + "bytes" + "encoding/base64" + "encoding/pem" + "flag" + "fmt" + "io" + "mime" + "mime/multipart" + "net/http" + "net/url" + "os" + "strings" +) + +// estPath builds the per-profile EST URL fragment. PathID="" maps to +// the legacy /.well-known/est/ root for backward compat with v2.0.x +// single-profile deploys. +func (c *Client) estPath(profile, op string) string { + if profile == "" { + return "/.well-known/est/" + op + } + return "/.well-known/est/" + profile + "/" + op +} + +// estPostBody POSTs the given body bytes to the EST endpoint with the +// EST-required Content-Type. Returns the raw response body so the +// caller can write the PEM/PKCS#7/multipart bytes through to disk +// without further decoding (the CLI is the device's smime/openssl +// pipeline; we don't second-guess the wire format). +func (c *Client) estPostBody(path, contentType string, body []byte) ([]byte, http.Header, error) { + u, err := url.JoinPath(c.baseURL, path) + if err != nil { + return nil, nil, fmt.Errorf("invalid URL: %w", err) + } + req, err := http.NewRequest("POST", u, strings.NewReader(string(body))) + if err != nil { + return nil, nil, fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Content-Type", contentType) + if c.apiKey != "" { + req.Header.Set("Authorization", "Bearer "+c.apiKey) + } + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, fmt.Errorf("reading response: %w", err) + } + if resp.StatusCode >= 400 { + return nil, resp.Header, fmt.Errorf("EST error (HTTP %d): %s", resp.StatusCode, string(respBody)) + } + return respBody, resp.Header, nil +} + +// estGet performs a GET against the EST endpoint and returns the raw +// response body. Used by cacerts (HTTP 200) and csrattrs (HTTP 200 or +// 204 — both are valid contracts). +func (c *Client) estGet(path string) ([]byte, int, http.Header, error) { + u, err := url.JoinPath(c.baseURL, path) + if err != nil { + return nil, 0, nil, fmt.Errorf("invalid URL: %w", err) + } + req, err := http.NewRequest("GET", u, nil) + if err != nil { + return nil, 0, nil, fmt.Errorf("creating request: %w", err) + } + if c.apiKey != "" { + req.Header.Set("Authorization", "Bearer "+c.apiKey) + } + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, 0, nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, resp.StatusCode, resp.Header, fmt.Errorf("reading response: %w", err) + } + if resp.StatusCode >= 400 { + return nil, resp.StatusCode, resp.Header, fmt.Errorf("EST error (HTTP %d): %s", resp.StatusCode, string(body)) + } + return body, resp.StatusCode, resp.Header, nil +} + +// readCSRBytes resolves --csr to actual CSR bytes. "-" reads from +// stdin (so the operator can pipe `openssl req -new …` directly into +// us); otherwise it's a filesystem path. +func readCSRBytes(path string) ([]byte, error) { + if path == "-" { + return io.ReadAll(os.Stdin) + } + return os.ReadFile(path) +} + +// EstCacerts implements `certctl-cli est cacerts --profile

`. +// Writes the base64-wrapped PKCS#7 certs-only response to stdout (the +// canonical EST §4.1.3 wire shape). +func (c *Client) EstCacerts(args []string) error { + fs := flag.NewFlagSet("est cacerts", flag.ContinueOnError) + profile := fs.String("profile", "", "EST profile PathID (empty = legacy root)") + out := fs.String("out", "-", "output file path; '-' = stdout") + if err := fs.Parse(args); err != nil { + return err + } + body, _, _, err := c.estGet(c.estPath(*profile, "cacerts")) + if err != nil { + return err + } + return writeOutput(*out, body) +} + +// EstCsrattrs implements `certctl-cli est csrattrs --profile

`. +// Writes the base64-encoded ASN.1 SEQUENCE OF OID body to stdout. The +// 204-No-Content case (no profile-derived hints) prints an empty +// payload + a STDERR diagnostic so an operator running the smoke-test +// case knows the endpoint succeeded. +func (c *Client) EstCsrattrs(args []string) error { + fs := flag.NewFlagSet("est csrattrs", flag.ContinueOnError) + profile := fs.String("profile", "", "EST profile PathID (empty = legacy root)") + out := fs.String("out", "-", "output file path; '-' = stdout") + if err := fs.Parse(args); err != nil { + return err + } + body, status, _, err := c.estGet(c.estPath(*profile, "csrattrs")) + if err != nil { + return err + } + if status == http.StatusNoContent { + fmt.Fprintln(os.Stderr, "no csrattrs hints configured (HTTP 204)") + return nil + } + return writeOutput(*out, body) +} + +// EstEnroll implements `certctl-cli est enroll --profile

--csr `. +// POSTs the CSR to /simpleenroll. The CSR body is sent as-is (whether +// PEM or base64-DER); the server's readCSRFromRequest handles either. +func (c *Client) EstEnroll(args []string) error { + return c.estEnrollOp("simpleenroll", "est enroll", args) +} + +// EstReEnroll implements `certctl-cli est reenroll --profile

--csr `. +func (c *Client) EstReEnroll(args []string) error { + return c.estEnrollOp("simplereenroll", "est reenroll", args) +} + +// estEnrollOp shares the body of EstEnroll + EstReEnroll. The only +// difference between the two is the URL suffix (simpleenroll vs +// simplereenroll); the audit-action distinction is server-side. +func (c *Client) estEnrollOp(op, helpName string, args []string) error { + fs := flag.NewFlagSet(helpName, flag.ContinueOnError) + profile := fs.String("profile", "", "EST profile PathID (empty = legacy root)") + csrPath := fs.String("csr", "", "path to PKCS#10 CSR (PEM or base64-DER); '-' = stdin") + out := fs.String("out", "-", "output file path; '-' = stdout") + if err := fs.Parse(args); err != nil { + return err + } + if *csrPath == "" { + return fmt.Errorf("--csr is required (path to a PKCS#10 CSR file or '-' for stdin)") + } + csrBytes, err := readCSRBytes(*csrPath) + if err != nil { + return fmt.Errorf("read CSR: %w", err) + } + // EST §4.2.1: client MAY send PEM or base64-DER. The server's + // readCSRFromRequest handles either; we forward what the operator + // supplied. Content-Type is application/pkcs10 per RFC 7030. + body, _, err := c.estPostBody(c.estPath(*profile, op), "application/pkcs10", csrBytes) + if err != nil { + return err + } + return writeOutput(*out, body) +} + +// EstServerKeygen implements `certctl-cli est serverkeygen --profile

+// --csr --out `. The server returns multipart/mixed; we +// split into the cert part and the encrypted-key part, write each to +// .cert.pem + .key.enveloped so the operator can pipe +// the latter into `openssl smime -decrypt -inkey `. +func (c *Client) EstServerKeygen(args []string) error { + fs := flag.NewFlagSet("est serverkeygen", flag.ContinueOnError) + profile := fs.String("profile", "", "EST profile PathID (empty = legacy root)") + csrPath := fs.String("csr", "", "path to PKCS#10 CSR (PEM or base64-DER); '-' = stdin") + outPrefix := fs.String("out", "keypair", "output file prefix; produces .cert.pem + .key.enveloped") + if err := fs.Parse(args); err != nil { + return err + } + if *csrPath == "" { + return fmt.Errorf("--csr is required") + } + csrBytes, err := readCSRBytes(*csrPath) + if err != nil { + return fmt.Errorf("read CSR: %w", err) + } + body, hdr, err := c.estPostBody(c.estPath(*profile, "serverkeygen"), "application/pkcs10", csrBytes) + if err != nil { + return err + } + // Parse the multipart body so we can write each part to its own + // file. The handler emits two parts: certs-only PKCS#7 and + // EnvelopedData PKCS#7. We don't decrypt the key here — the + // operator's smime client owns the recipient private key. + contentType := hdr.Get("Content-Type") + certPart, keyPart, err := splitServerKeygenMultipart(body, contentType) + if err != nil { + return fmt.Errorf("parse multipart response: %w", err) + } + certFile := *outPrefix + ".cert.pem" + keyFile := *outPrefix + ".key.enveloped" + if err := os.WriteFile(certFile, certPart, 0o600); err != nil { + return fmt.Errorf("write cert: %w", err) + } + if err := os.WriteFile(keyFile, keyPart, 0o600); err != nil { + return fmt.Errorf("write key: %w", err) + } + fmt.Fprintf(os.Stderr, "wrote %s + %s\n", certFile, keyFile) + return nil +} + +// EstTest is a smoke-test that hits cacerts + csrattrs in sequence and +// prints a one-line OK/FAIL per endpoint. Useful for operator post- +// deploy validation: `certctl-cli est test --profile corp` returns +// exit-0 when both endpoints respond successfully. +func (c *Client) EstTest(args []string) error { + fs := flag.NewFlagSet("est test", flag.ContinueOnError) + profile := fs.String("profile", "", "EST profile PathID (empty = legacy root)") + if err := fs.Parse(args); err != nil { + return err + } + if _, _, _, err := c.estGet(c.estPath(*profile, "cacerts")); err != nil { + fmt.Fprintf(os.Stderr, "FAIL cacerts: %v\n", err) + return err + } + fmt.Fprintln(os.Stderr, "OK cacerts") + if _, status, _, err := c.estGet(c.estPath(*profile, "csrattrs")); err != nil { + fmt.Fprintf(os.Stderr, "FAIL csrattrs: %v\n", err) + return err + } else { + fmt.Fprintf(os.Stderr, "OK csrattrs (HTTP %d)\n", status) + } + return nil +} + +// writeOutput writes to disk or stdout depending on --out. Centralises +// the open-truncate-permission semantics so every subcommand uses the +// same shape. +func writeOutput(path string, body []byte) error { + if path == "-" || path == "" { + _, err := os.Stdout.Write(body) + return err + } + return os.WriteFile(path, body, 0o600) +} + +// splitServerKeygenMultipart cracks the RFC 7030 §4.4.2 multipart body +// into its two PKCS#7 parts. The caller hands us the response body + +// the Content-Type header value (which carries the boundary parameter +// produced by handler.newMultipartBoundary). +// +// Light-weight on purpose — we use stdlib mime/multipart but keep the +// helper self-contained here so the test can swap in fixture bytes +// without spinning up the full ESTHandler. +func splitServerKeygenMultipart(body []byte, contentType string) ([]byte, []byte, error) { + _, params, err := mime.ParseMediaType(contentType) + if err != nil { + return nil, nil, fmt.Errorf("parse Content-Type %q: %w", contentType, err) + } + boundary := params["boundary"] + if boundary == "" { + return nil, nil, fmt.Errorf("multipart Content-Type %q missing boundary parameter", contentType) + } + mr := multipart.NewReader(bytes.NewReader(body), boundary) + var cert, key []byte + for { + part, err := mr.NextPart() + if err == io.EOF { + break + } + if err != nil { + return nil, nil, err + } + ct := part.Header.Get("Content-Type") + // The handler wraps each part body in base64 (see writeBase64Wrapped). + raw, _ := io.ReadAll(part) + decoded, decErr := decodeBase64Wrapped(raw) + if decErr != nil { + // Some clients want the base64 verbatim; fall through to raw. + decoded = raw + } + switch { + case strings.Contains(ct, "smime-type=certs-only"): + cert = decoded + case strings.Contains(ct, "smime-type=enveloped-data"): + key = decoded + } + } + if len(cert) == 0 || len(key) == 0 { + return nil, nil, fmt.Errorf("multipart response missing required parts (cert=%d bytes, key=%d bytes)", len(cert), len(key)) + } + return cert, key, nil +} + +// decodeBase64Wrapped strips CRLF wrapping then base64-decodes. The +// EST handler emits the body as base64 with CRLF every 76 chars per +// RFC 2045; client-side decode is "rip the whitespace, base64-decode +// the rest". +func decodeBase64Wrapped(in []byte) ([]byte, error) { + stripped := strings.Map(func(r rune) rune { + if r == '\r' || r == '\n' || r == ' ' || r == '\t' { + return -1 + } + return r + }, string(in)) + return base64.StdEncoding.DecodeString(stripped) +} + +// _ = pem.Decode is referenced by est_test.go to verify the cacerts +// + enroll responses are parseable PEM. Keep the import live without +// growing the public API. +var _ = pem.Decode diff --git a/internal/cli/est_test.go b/internal/cli/est_test.go new file mode 100644 index 0000000..db1f039 --- /dev/null +++ b/internal/cli/est_test.go @@ -0,0 +1,209 @@ +package cli + +import ( + "bytes" + "crypto/tls" + "encoding/base64" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// EST RFC 7030 hardening master bundle Phase 9.3 — CLI subcommand tests. +// Exercise each EST CLI subcommand against an httptest server that +// asserts request shape (method + path + Content-Type) + emits a +// canned response body. This pins the wire-format contract on the +// CLI side without dragging the full ESTHandler into the test build. + +func newESTTestClient(t *testing.T, server *httptest.Server) *Client { + t.Helper() + // CLI defaults to TLS-1.3-min; the httptest TLS server uses an + // auto-generated leaf cert, so we set InsecureSkipVerify (the + // CLI's --insecure equivalent) for the test. + c := &Client{ + baseURL: server.URL, + apiKey: "", + format: "table", + httpClient: server.Client(), + } + // Force TLS 1.3 to mirror NewClient's production setting. + if t, ok := c.httpClient.Transport.(*http.Transport); ok && t.TLSClientConfig != nil { + t.TLSClientConfig.MinVersion = tls.VersionTLS13 + } + return c +} + +func TestEstCacerts_GetsBaseRoute(t *testing.T) { + wantPath := "/.well-known/est/corp/cacerts" + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != wantPath { + t.Errorf("got %s %s, want GET %s", r.Method, r.URL.Path, wantPath) + } + w.Header().Set("Content-Type", "application/pkcs7-mime; smime-type=certs-only") + w.Write([]byte("MIIBaseinPKCS7Body")) + })) + defer server.Close() + c := newESTTestClient(t, server) + tmp := filepath.Join(t.TempDir(), "ca.p7") + if err := c.EstCacerts([]string{"--profile", "corp", "--out", tmp}); err != nil { + t.Fatalf("EstCacerts: %v", err) + } + got, err := os.ReadFile(tmp) + if err != nil { + t.Fatalf("read out: %v", err) + } + if string(got) != "MIIBaseinPKCS7Body" { + t.Errorf("body = %q, want MIIBaseinPKCS7Body", got) + } +} + +func TestEstCsrattrs_204IsNotAnError(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/.well-known/est/corp/csrattrs" { + t.Errorf("path = %s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + c := newESTTestClient(t, server) + if err := c.EstCsrattrs([]string{"--profile", "corp"}); err != nil { + t.Errorf("204 should not surface as an error: %v", err) + } +} + +func TestEstEnroll_PostsCSRAsApplicationPKCS10(t *testing.T) { + wantBody := "-----BEGIN CERTIFICATE REQUEST-----\nXXXX\n-----END CERTIFICATE REQUEST-----" + csrPath := filepath.Join(t.TempDir(), "device.csr") + if err := os.WriteFile(csrPath, []byte(wantBody), 0o600); err != nil { + t.Fatal(err) + } + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || r.URL.Path != "/.well-known/est/corp/simpleenroll" { + t.Errorf("got %s %s", r.Method, r.URL.Path) + } + if got := r.Header.Get("Content-Type"); got != "application/pkcs10" { + t.Errorf("Content-Type = %q, want application/pkcs10", got) + } + body, _ := io.ReadAll(r.Body) + if string(body) != wantBody { + t.Errorf("body = %q, want %q", body, wantBody) + } + w.Header().Set("Content-Type", "application/pkcs7-mime; smime-type=certs-only") + w.Write([]byte("ISSUEDCERT")) + })) + defer server.Close() + c := newESTTestClient(t, server) + out := filepath.Join(t.TempDir(), "issued.p7") + if err := c.EstEnroll([]string{"--profile", "corp", "--csr", csrPath, "--out", out}); err != nil { + t.Fatalf("EstEnroll: %v", err) + } + got, _ := os.ReadFile(out) + if string(got) != "ISSUEDCERT" { + t.Errorf("issued body = %q, want ISSUEDCERT", got) + } +} + +func TestEstReEnroll_HitsRenewalPath(t *testing.T) { + csrPath := filepath.Join(t.TempDir(), "device.csr") + os.WriteFile(csrPath, []byte("dummy-csr"), 0o600) + called := false + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/.well-known/est/corp/simplereenroll" { + t.Errorf("path = %s", r.URL.Path) + } + called = true + w.Write([]byte("RENEWED")) + })) + defer server.Close() + c := newESTTestClient(t, server) + out := filepath.Join(t.TempDir(), "renewed.p7") + if err := c.EstReEnroll([]string{"--profile", "corp", "--csr", csrPath, "--out", out}); err != nil { + t.Fatalf("EstReEnroll: %v", err) + } + if !called { + t.Error("server never received the request") + } +} + +func TestEstEnroll_RequiresCSR(t *testing.T) { + c := newESTTestClient(t, httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))) + err := c.EstEnroll([]string{"--profile", "corp"}) + if err == nil || !strings.Contains(err.Error(), "csr") { + t.Errorf("expected --csr-required error, got %v", err) + } +} + +func TestEstEnroll_ServerErrorMappedToFailure(t *testing.T) { + csrPath := filepath.Join(t.TempDir(), "device.csr") + os.WriteFile(csrPath, []byte("dummy"), 0o600) + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + })) + defer server.Close() + c := newESTTestClient(t, server) + err := c.EstEnroll([]string{"--profile", "corp", "--csr", csrPath, "--out", filepath.Join(t.TempDir(), "out")}) + if err == nil || !strings.Contains(err.Error(), "HTTP 500") { + t.Errorf("expected HTTP 500 error, got %v", err) + } +} + +func TestSplitServerKeygenMultipart_RoundTrip(t *testing.T) { + // Build a multipart body with two base64-wrapped parts and assert + // the split helper hands back the matching bytes. This pins the + // CLI's parser against the handler's writer (handler emits via + // mime/multipart's same boundary semantics). + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + certPart, _ := w.CreatePart(textproto("application/pkcs7-mime; smime-type=certs-only")) + certPart.Write([]byte(base64.StdEncoding.EncodeToString([]byte("CERT_BYTES")))) + keyPart, _ := w.CreatePart(textproto("application/pkcs7-mime; smime-type=enveloped-data")) + keyPart.Write([]byte(base64.StdEncoding.EncodeToString([]byte("KEY_BYTES")))) + w.Close() + contentType := fmt.Sprintf("multipart/mixed; boundary=%s", w.Boundary()) + cert, key, err := splitServerKeygenMultipart(buf.Bytes(), contentType) + if err != nil { + t.Fatalf("splitServerKeygenMultipart: %v", err) + } + if string(cert) != "CERT_BYTES" { + t.Errorf("cert part = %q, want CERT_BYTES", cert) + } + if string(key) != "KEY_BYTES" { + t.Errorf("key part = %q, want KEY_BYTES", key) + } +} + +func TestEstTest_HitsBothEndpoints(t *testing.T) { + hits := map[string]int{} + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits[r.URL.Path]++ + if strings.HasSuffix(r.URL.Path, "/csrattrs") { + w.WriteHeader(http.StatusNoContent) + return + } + w.Write([]byte("OK")) + })) + defer server.Close() + c := newESTTestClient(t, server) + if err := c.EstTest([]string{"--profile", "corp"}); err != nil { + t.Fatalf("EstTest: %v", err) + } + if hits["/.well-known/est/corp/cacerts"] != 1 { + t.Errorf("cacerts hit count = %d, want 1", hits["/.well-known/est/corp/cacerts"]) + } + if hits["/.well-known/est/corp/csrattrs"] != 1 { + t.Errorf("csrattrs hit count = %d, want 1", hits["/.well-known/est/corp/csrattrs"]) + } +} + +// textproto builds the small multipart-header form mime/multipart's +// CreatePart wants (it's a textproto.MIMEHeader). Pulled out as a tiny +// helper so the test reads cleanly. +func textproto(contentType string) map[string][]string { + return map[string][]string{"Content-Type": {contentType}} +} diff --git a/internal/mcp/client.go b/internal/mcp/client.go index d264eac..0a8a414 100644 --- a/internal/mcp/client.go +++ b/internal/mcp/client.go @@ -92,6 +92,39 @@ func (c *Client) DeleteWithQuery(path string, query url.Values) (json.RawMessage return c.do("DELETE", path, query, nil) } +// PostRaw performs an HTTP POST with a non-JSON body and returns the raw +// response bytes + content type. Used by EST enroll / reenroll where the +// body is `application/pkcs10` (CSR bytes) and the response is +// `application/pkcs7-mime; smime-type=certs-only` (base64-wrapped). EST +// RFC 7030 hardening master bundle Phase 9.2. +func (c *Client) PostRaw(path, contentType string, body []byte) ([]byte, string, error) { + u, err := url.JoinPath(c.baseURL, path) + if err != nil { + return nil, "", fmt.Errorf("invalid URL: %w", err) + } + req, err := http.NewRequest("POST", u, bytes.NewReader(body)) + if err != nil { + return nil, "", fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Content-Type", contentType) + if c.apiKey != "" { + req.Header.Set("Authorization", "Bearer "+c.apiKey) + } + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, "", fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, "", fmt.Errorf("reading response: %w", err) + } + if resp.StatusCode >= 400 { + return nil, "", fmt.Errorf("API error (HTTP %d): %s", resp.StatusCode, string(data)) + } + return data, resp.Header.Get("Content-Type"), nil +} + // GetRaw performs an HTTP GET and returns the raw response body bytes and content type. // Used for binary responses (DER CRL, OCSP). func (c *Client) GetRaw(path string) ([]byte, string, error) { diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index 3ff1f75..a35b94d 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -29,6 +29,7 @@ func RegisterTools(s *gomcp.Server, client *Client) { registerMetricsTools(s, client) registerDigestTools(s, client) registerHealthTools(s, client) + registerESTTools(s, client) } // ── Helpers ───────────────────────────────────────────────────────── diff --git a/internal/mcp/tools_est.go b/internal/mcp/tools_est.go new file mode 100644 index 0000000..ef43bfe --- /dev/null +++ b/internal/mcp/tools_est.go @@ -0,0 +1,142 @@ +package mcp + +// EST RFC 7030 hardening master bundle Phase 9.2 — MCP tools. +// +// 6 tools mapped to the EST endpoints + admin observability: +// +// est_list_profiles → GET /api/v1/admin/est/profiles (M-008 admin-gated) +// est_get_cacerts → GET /.well-known/est/[/]cacerts +// est_get_csrattrs → GET /.well-known/est/[/]csrattrs +// est_enroll → POST /.well-known/est/[/]simpleenroll +// est_reenroll → POST /.well-known/est/[/]simplereenroll +// est_admin_stats → alias of est_list_profiles for parity with the +// SCEP admin tool naming (admin GUI uses both +// names interchangeably; we expose both for +// LLM-friendly discovery). +// +// Each tool returns the raw response body wrapped via textResult so +// the MCP fence semantics apply (LLM consumers see the body as +// untrusted data, not instructions). + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "strings" + + gomcp "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// ── Input types ───────────────────────────────────────────────────── + +type ESTProfileInput struct { + Profile string `json:"profile,omitempty" jsonschema:"EST profile PathID (empty = legacy /.well-known/est root)"` +} + +type ESTEnrollInput struct { + Profile string `json:"profile,omitempty" jsonschema:"EST profile PathID (empty = legacy /.well-known/est root)"` + CSR string `json:"csr" jsonschema:"PKCS#10 CSR — PEM-encoded or base64-DER. Required."` +} + +// ── Tool registration ────────────────────────────────────────────── + +func registerESTTools(s *gomcp.Server, c *Client) { + gomcp.AddTool(s, &gomcp.Tool{ + Name: "est_list_profiles", + Description: "List per-profile EST observability snapshot (counters + mTLS trust-anchor expiries + auth-mode posture). Admin-gated. Returns one snapshot per configured EST profile.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input struct{}) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/api/v1/admin/est/profiles", nil) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "est_admin_stats", + Description: "Alias of est_list_profiles — returns the same per-profile EST stats snapshot. Provided so LLM tool discovery surfaces both naming conventions (mirrors the SCEP admin tool naming).", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input struct{}) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/api/v1/admin/est/profiles", nil) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "est_get_cacerts", + Description: "EST GET /.well-known/est/[/]cacerts (RFC 7030 §4.1). Returns the base64-wrapped PKCS#7 certs-only response carrying the CA certificate chain. The response body is opaque from the MCP-consumer perspective; pipe into openssl smime / openssl pkcs7 to extract the chain.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input ESTProfileInput) (*gomcp.CallToolResult, any, error) { + body, contentType, err := c.GetRaw(estPathFor(input.Profile, "cacerts")) + if err != nil { + return errorResult(err) + } + return textResult(estRawResultJSON(body, contentType)) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "est_get_csrattrs", + Description: "EST GET /.well-known/est/[/]csrattrs (RFC 7030 §4.5). Returns the base64-encoded ASN.1 SEQUENCE OF OID hint list the server wants the client to include in subsequent enrollments. Empty body (HTTP 204) when no profile-derived hints are configured.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input ESTProfileInput) (*gomcp.CallToolResult, any, error) { + body, contentType, err := c.GetRaw(estPathFor(input.Profile, "csrattrs")) + if err != nil { + return errorResult(err) + } + return textResult(estRawResultJSON(body, contentType)) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "est_enroll", + Description: "EST POST /.well-known/est/[/]simpleenroll (RFC 7030 §4.2). Submits a PKCS#10 CSR (PEM or base64-DER) and receives the issued certificate chain as a base64-wrapped PKCS#7 certs-only response.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input ESTEnrollInput) (*gomcp.CallToolResult, any, error) { + if strings.TrimSpace(input.CSR) == "" { + return errorResult(fmt.Errorf("csr is required (PEM-encoded or base64-DER PKCS#10)")) + } + body, contentType, err := c.PostRaw(estPathFor(input.Profile, "simpleenroll"), + "application/pkcs10", []byte(input.CSR)) + if err != nil { + return errorResult(err) + } + return textResult(estRawResultJSON(body, contentType)) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "est_reenroll", + Description: "EST POST /.well-known/est/[/]simplereenroll (RFC 7030 §4.2.2). Same wire shape as est_enroll; the audit log distinguishes initial-vs-renewal under the `est_simple_reenroll` action code.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input ESTEnrollInput) (*gomcp.CallToolResult, any, error) { + if strings.TrimSpace(input.CSR) == "" { + return errorResult(fmt.Errorf("csr is required")) + } + body, contentType, err := c.PostRaw(estPathFor(input.Profile, "simplereenroll"), + "application/pkcs10", []byte(input.CSR)) + if err != nil { + return errorResult(err) + } + return textResult(estRawResultJSON(body, contentType)) + }) +} + +// estPathFor builds the per-profile EST URL path. Empty profile maps +// to the legacy root for backward compat with v2.0.x deploys. +func estPathFor(profile, op string) string { + if profile == "" { + return "/.well-known/est/" + op + } + return "/.well-known/est/" + profile + "/" + op +} + +// estRawResultJSON wraps the raw EST response body in a JSON envelope +// the MCP consumer can structurally consume. The body itself is base64- +// encoded so the LLM doesn't have to handle binary-safe transport; +// content_type is preserved verbatim. Mirrors the shape the CRL/OCSP +// MCP tools use for their binary DER responses. +func estRawResultJSON(body []byte, contentType string) json.RawMessage { + out := map[string]any{ + "content_type": contentType, + "body_base64": base64.StdEncoding.EncodeToString(body), + "body_size_bytes": len(body), + } + raw, _ := json.Marshal(out) + return raw +} diff --git a/internal/mcp/tools_est_test.go b/internal/mcp/tools_est_test.go new file mode 100644 index 0000000..a972912 --- /dev/null +++ b/internal/mcp/tools_est_test.go @@ -0,0 +1,168 @@ +package mcp + +import ( + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// EST RFC 7030 hardening master bundle Phase 9.3 — MCP tool tests. +// Mirror the SCEP/Intune tool test pattern: spin up a fake API, exercise +// each tool's HTTP path, assert the request shape (method + path + +// optional Content-Type/body) + the wrapped JSON response. + +// mockESTAPI returns a test server that records EST + admin EST requests. +// Differs from mockCertctlAPI by handling the raw (non-JSON) /.well-known/est/* +// surfaces — it returns binary-friendly bodies + the EST Content-Type +// the CLI wire-format tests pin. +func mockESTAPI(log *requestLog) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body := "" + if r.Body != nil { + buf := make([]byte, 8192) + n, _ := r.Body.Read(buf) + body = string(buf[:n]) + } + log.add(capturedRequest{ + Method: r.Method, + Path: r.URL.Path, + Query: r.URL.RawQuery, + Body: body, + }) + + switch { + case strings.HasPrefix(r.URL.Path, "/api/v1/admin/est/profiles"): + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "profiles": []map[string]any{{"path_id": "corp", "issuer_id": "iss-corp"}}, + "profile_count": 1, + "generated_at": "2026-04-29T00:00:00Z", + }) + case strings.HasSuffix(r.URL.Path, "/cacerts"): + w.Header().Set("Content-Type", "application/pkcs7-mime; smime-type=certs-only") + w.Write([]byte("CACERTS-PKCS7-BYTES")) + case strings.HasSuffix(r.URL.Path, "/csrattrs"): + w.Header().Set("Content-Type", "application/csrattrs") + w.Write([]byte("CSRATTRS-DER")) + case strings.HasSuffix(r.URL.Path, "/simpleenroll"): + w.Header().Set("Content-Type", "application/pkcs7-mime; smime-type=certs-only") + w.Write([]byte("ENROLLED-CERT-BYTES")) + case strings.HasSuffix(r.URL.Path, "/simplereenroll"): + w.Header().Set("Content-Type", "application/pkcs7-mime; smime-type=certs-only") + w.Write([]byte("REENROLLED-CERT-BYTES")) + default: + w.WriteHeader(http.StatusNotFound) + } + })) +} + +func TestEstPathFor(t *testing.T) { + cases := []struct { + profile string + op string + want string + }{ + {"corp", "cacerts", "/.well-known/est/corp/cacerts"}, + {"", "cacerts", "/.well-known/est/cacerts"}, + {"iot", "simpleenroll", "/.well-known/est/iot/simpleenroll"}, + } + for _, c := range cases { + got := estPathFor(c.profile, c.op) + if got != c.want { + t.Errorf("estPathFor(%q, %q) = %q, want %q", c.profile, c.op, got, c.want) + } + } +} + +func TestEstRawResultJSON_EmbedsBase64Body(t *testing.T) { + body := []byte("\x00\x01\x02\x03binary") + raw := estRawResultJSON(body, "application/pkcs7-mime") + var got map[string]any + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got["content_type"] != "application/pkcs7-mime" { + t.Errorf("content_type = %v", got["content_type"]) + } + if got["body_base64"] != base64.StdEncoding.EncodeToString(body) { + t.Errorf("body_base64 mismatch") + } + if int(got["body_size_bytes"].(float64)) != len(body) { + t.Errorf("body_size_bytes = %v, want %d", got["body_size_bytes"], len(body)) + } +} + +// TestRegisterESTTools_HitsExpectedPaths exercises each EST tool by +// driving its handler through the registered mcp.Server's transport +// surface. We use the Client directly (not the gomcp dispatch layer) +// because the per-tool handlers are closures over the Client; the +// request-shape assertion is what matters here. +func TestRegisterESTTools_AllPathsExercised(t *testing.T) { + log := &requestLog{} + api := mockESTAPI(log) + defer api.Close() + client, err := NewClient(api.URL, "test-key", "", false) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + + // est_list_profiles + est_admin_stats both hit the admin endpoint. + if _, err := client.Get("/api/v1/admin/est/profiles", nil); err != nil { + t.Errorf("admin profiles: %v", err) + } + // est_get_cacerts via GetRaw. + if body, _, err := client.GetRaw(estPathFor("corp", "cacerts")); err != nil { + t.Errorf("cacerts GET: %v", err) + } else if string(body) != "CACERTS-PKCS7-BYTES" { + t.Errorf("cacerts body = %q, want CACERTS-PKCS7-BYTES", body) + } + // est_get_csrattrs via GetRaw. + if body, _, err := client.GetRaw(estPathFor("corp", "csrattrs")); err != nil { + t.Errorf("csrattrs GET: %v", err) + } else if string(body) != "CSRATTRS-DER" { + t.Errorf("csrattrs body = %q, want CSRATTRS-DER", body) + } + // est_enroll via PostRaw. + if body, _, err := client.PostRaw(estPathFor("corp", "simpleenroll"), "application/pkcs10", + []byte("CSR-PEM-BYTES")); err != nil { + t.Errorf("simpleenroll POST: %v", err) + } else if string(body) != "ENROLLED-CERT-BYTES" { + t.Errorf("simpleenroll body = %q, want ENROLLED-CERT-BYTES", body) + } + // est_reenroll via PostRaw. + if body, _, err := client.PostRaw(estPathFor("corp", "simplereenroll"), "application/pkcs10", + []byte("CSR-PEM-BYTES")); err != nil { + t.Errorf("simplereenroll POST: %v", err) + } else if string(body) != "REENROLLED-CERT-BYTES" { + t.Errorf("simplereenroll body = %q, want REENROLLED-CERT-BYTES", body) + } + + // Pin every captured path so a future refactor that reroutes one + // tool gets caught. + wantPaths := []string{ + "/api/v1/admin/est/profiles", + "/.well-known/est/corp/cacerts", + "/.well-known/est/corp/csrattrs", + "/.well-known/est/corp/simpleenroll", + "/.well-known/est/corp/simplereenroll", + } + gotPaths := make([]string, 0, len(log.requests)) + for _, r := range log.requests { + gotPaths = append(gotPaths, r.Path) + } + for _, want := range wantPaths { + found := false + for _, got := range gotPaths { + if got == want { + found = true + break + } + } + if !found { + t.Errorf("missing request to %s; got %v", want, gotPaths) + } + } +} diff --git a/internal/mcp/tools_per_tool_test.go b/internal/mcp/tools_per_tool_test.go index 3f42723..f86ec97 100644 --- a/internal/mcp/tools_per_tool_test.go +++ b/internal/mcp/tools_per_tool_test.go @@ -359,6 +359,14 @@ var allHappyPathCases = []toolCase{ {"certctl_ready", map[string]any{}, http.MethodGet, "/ready"}, {"certctl_auth_check", map[string]any{}, http.MethodGet, "/api/v1/auth/check"}, {"certctl_auth_info", map[string]any{}, http.MethodGet, "/api/v1/auth/whoami"}, + + // EST RFC 7030 hardening Phase 9.2 — 6 EST tools. + {"est_list_profiles", map[string]any{}, http.MethodGet, "/api/v1/admin/est/profiles"}, + {"est_admin_stats", map[string]any{}, http.MethodGet, "/api/v1/admin/est/profiles"}, + {"est_get_cacerts", map[string]any{"profile": "corp"}, http.MethodGet, "/.well-known/est/corp/cacerts"}, + {"est_get_csrattrs", map[string]any{"profile": "corp"}, http.MethodGet, "/.well-known/est/corp/csrattrs"}, + {"est_enroll", map[string]any{"profile": "corp", "csr": "-----BEGIN CERTIFICATE REQUEST-----\nXXX\n-----END CERTIFICATE REQUEST-----"}, http.MethodPost, "/.well-known/est/corp/simpleenroll"}, + {"est_reenroll", map[string]any{"profile": "corp", "csr": "-----BEGIN CERTIFICATE REQUEST-----\nXXX\n-----END CERTIFICATE REQUEST-----"}, http.MethodPost, "/.well-known/est/corp/simplereenroll"}, } // TestMCP_AllTools_HappyPath dispatches every tool against the mock API in diff --git a/web/src/api/client.ts b/web/src/api/client.ts index cea9c46..cfb4435 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1,4 +1,4 @@ -import type { Certificate, CertificateVersion, Agent, Job, Notification, AuditEvent, PolicyRule, PolicyViolation, RenewalPolicy, Issuer, Target, CertificateProfile, Owner, Team, AgentGroup, PaginatedResponse, DashboardSummary, CertificateStatusCount, ExpirationBucket, JobTrendDataPoint, IssuanceRateDataPoint, MetricsResponse, DiscoveredCertificate, DiscoveryScan, DiscoverySummary, NetworkScanTarget, EndpointHealthCheck, HealthHistoryEntry, HealthCheckSummary, AgentDependencyCounts, RetireAgentResponse, BlockedByDependenciesResponse, CRLCacheResponse, IntuneStatsResponse, IntuneReloadTrustResponse, SCEPProfilesResponse, SCEPProbeResult, SCEPProbesResponse } 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, SCEPProbeResult, SCEPProbesResponse, ESTProfilesResponse, ESTReloadTrustResponse } from './types'; const BASE = '/api/v1'; @@ -320,6 +320,22 @@ export const reloadAdminSCEPIntuneTrust = (pathID: string) => export const getAdminSCEPProfiles = () => fetchJSON(`${BASE}/admin/scep/profiles`); +// EST RFC 7030 hardening master bundle Phase 7.2 admin endpoints. +// +// Backend handler: internal/api/handler/admin_est.go. +// Both endpoints are M-008 admin-gated; the ESTAdminPage component +// gates the React-Query `enabled` flag on useAuth().admin so non-admin +// callers never see the page (the route itself is also conditional on +// the admin flag in main.tsx). +export const getAdminESTProfiles = () => + fetchJSON(`${BASE}/admin/est/profiles`); + +export const reloadAdminESTTrust = (pathID: string) => + fetchJSON(`${BASE}/admin/est/reload-trust`, { + method: 'POST', + body: JSON.stringify({ path_id: pathID }), + }); + // SCEP RFC 8894 + Intune master bundle Phase 11.5: SCEP probe // (capability + posture). Synchronous — the caller blocks until the // probe completes (cap: 30s server-side). Persists to the history diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 26c2a92..c727c46 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -727,6 +727,43 @@ export interface SCEPProfilesResponse { generated_at: string; } +// EST RFC 7030 hardening master bundle Phase 7.1 / 8 GUI: +// per-profile snapshot returned by GET /api/v1/admin/est/profiles. Mirrors +// the Go-side service.ESTStatsSnapshot 1:1. +export interface ESTTrustAnchorInfo { + subject: string; + not_before: string; + not_after: string; + days_to_expiry: number; + expired: boolean; +} + +export interface ESTStatsSnapshot { + path_id: string; + issuer_id: string; + profile_id?: string; + // 12 named labels — see service/est_counters.go. + counters: Record; + mtls_enabled: boolean; + basic_auth_configured: boolean; + server_keygen_enabled: boolean; + trust_anchors?: ESTTrustAnchorInfo[]; + trust_anchor_path?: string; + now: string; +} + +export interface ESTProfilesResponse { + profiles: ESTStatsSnapshot[]; + profile_count: number; + generated_at: string; +} + +export interface ESTReloadTrustResponse { + reloaded: boolean; + path_id: string; + reloaded_at: string; +} + // SCEP RFC 8894 + Intune master bundle Phase 11.5 — SCEP probe. // // Backs the SCEP Probe section on the Network Scan page. The probe diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index 17be72d..9363ad5 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -24,6 +24,7 @@ const nav = [ { to: '/digest', label: 'Digest', icon: 'M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z' }, { to: '/observability', label: 'Observability', icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z' }, { to: '/scep', 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: '/est', label: 'EST Admin', icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z' }, { to: '/audit', label: 'Audit Trail', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z' }, ]; diff --git a/web/src/main.tsx b/web/src/main.tsx index 69e4c44..74a4005 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -33,6 +33,7 @@ import JobDetailPage from './pages/JobDetailPage'; import IssuerDetailPage from './pages/IssuerDetailPage'; import TargetDetailPage from './pages/TargetDetailPage'; import SCEPAdminPage from './pages/SCEPAdminPage'; +import ESTAdminPage from './pages/ESTAdminPage'; import './index.css'; const queryClient = new QueryClient({ @@ -91,6 +92,13 @@ createRoot(document.getElementById('root')!).render( {/* Backward-compat alias for external bookmarks the Phase 9 release advertised. Lands on the Intune Monitoring tab. */} } /> + {/* EST RFC 7030 hardening master bundle Phase 8: per-profile + EST Administration page with Profiles / Recent Activity / + Trust Bundle tabs. Same admin-gate pattern as SCEP — the + route is unconditional; the page renders an "Admin access + required" banner for non-admin callers and skips the + underlying API calls so the server never sees a 403. */} + } /> diff --git a/web/src/pages/ESTAdminPage.test.tsx b/web/src/pages/ESTAdminPage.test.tsx new file mode 100644 index 0000000..e6458c6 --- /dev/null +++ b/web/src/pages/ESTAdminPage.test.tsx @@ -0,0 +1,274 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, cleanup, fireEvent } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import type { ReactNode } from 'react'; + +// EST RFC 7030 hardening master bundle Phase 8.4 — Vitest coverage for +// the EST Administration page. Mirrors SCEPAdminPage.test.tsx's +// structure verbatim. Pins: +// 1. Admin gate — non-admin sees the gated banner; admin requests are +// never issued. +// 2. Tab navigation — Profiles is the default; clicking each tab +// switches surface; ?tab=activity / ?tab=trust deep-links land +// correctly. +// 3. Profiles tab — per-profile cards; status badges reflect mTLS + +// Basic + ServerKeygen; trust-anchor expiry badge tone bands +// (good ≥30d / warn 7-30d / bad <7d / EXPIRED); per-counter cells +// render the correct value; "Reload trust anchor" only renders for +// mTLS-enabled profiles AND opens the modal on click. +// 4. Reload modal — Confirm calls mutation / Cancel skips mutation / +// Error keeps modal open + surfaces the error message. +// 5. Recent Activity tab — merges all four EST audit actions across +// four parallel useQuery calls; filter chips narrow to the +// requested subset. +// 6. Trust Bundle tab — only mTLS profiles render; non-mTLS deploy +// sees the empty-state banner. +// 7. Error path — surfaces ErrorState on the active tab. + +vi.mock('../api/client', () => ({ + getAdminESTProfiles: vi.fn(), + reloadAdminESTTrust: vi.fn(), + getAuditEvents: vi.fn(), +})); + +vi.mock('../components/AuthProvider', () => ({ + useAuth: vi.fn(), +})); + +import ESTAdminPage from './ESTAdminPage'; +import * as client from '../api/client'; +import { useAuth } from '../components/AuthProvider'; + +function renderWithRoute(initialPath: string, ui: ReactNode) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } }, + }); + return render( + + + + + + + , + ); +} + +function setAuth(opts: { authRequired: boolean; admin: boolean }) { + vi.mocked(useAuth).mockReturnValue({ + loading: false, + authRequired: opts.authRequired, + authenticated: true, + authType: 'apikey', + user: 'tester', + admin: opts.admin, + login: async () => {}, + logout: () => {}, + error: null, + }); +} + +const corpProfile = { + path_id: 'corp', + issuer_id: 'iss-corp', + profile_id: 'prof-corp', + counters: { + success_simpleenroll: 42, + success_simplereenroll: 7, + success_serverkeygen: 3, + auth_failed_basic: 1, + auth_failed_mtls: 0, + auth_failed_channel_binding: 0, + csr_invalid: 0, + csr_policy_violation: 0, + csr_signature_mismatch: 0, + rate_limited: 2, + issuer_error: 0, + internal_error: 0, + }, + mtls_enabled: true, + basic_auth_configured: true, + server_keygen_enabled: true, + trust_anchors: [ + { + subject: 'corp-bootstrap-ca', + not_before: '2026-01-01T00:00:00Z', + not_after: '2027-01-01T00:00:00Z', + days_to_expiry: 250, + expired: false, + }, + ], + trust_anchor_path: '/etc/certctl/est-mtls-corp.pem', + now: '2026-04-29T15:00:00Z', +}; + +const iotProfile = { + path_id: 'iot', + issuer_id: 'iss-iot', + counters: { + success_simpleenroll: 9, + auth_failed_basic: 0, + } as Record, + mtls_enabled: false, + basic_auth_configured: false, + server_keygen_enabled: false, + now: '2026-04-29T15:00:00Z', +}; + +const profilesResponse = { + profiles: [corpProfile, iotProfile], + profile_count: 2, + generated_at: '2026-04-29T15:00:00Z', +}; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(client.getAdminESTProfiles).mockResolvedValue(profilesResponse as any); + vi.mocked(client.getAuditEvents).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 50 } as any); + setAuth({ authRequired: true, admin: true }); +}); + +afterEach(() => { + cleanup(); +}); + +// React's afterEach is implicit in this scope via Vitest; the explicit +// cleanup() above is safe to call even when no render happened. +function afterEach(fn: () => void) { + // re-export from vitest globals — vitest's globals expose `afterEach` + // automatically when test config has globals: true. Our config does, so + // the import is unnecessary; this thin shim documents the call site. + (globalThis as any).afterEach?.(fn); +} + +describe('ESTAdminPage — admin gate', () => { + it('non-admin sees the gated banner; admin requests never fire', async () => { + setAuth({ authRequired: true, admin: false }); + renderWithRoute('/est', ); + expect(await screen.findByText(/Admin access required/i)).toBeInTheDocument(); + await waitFor(() => { + expect(client.getAdminESTProfiles).not.toHaveBeenCalled(); + }); + }); + + it('non-auth-required deploy lets the page render and fires admin request', async () => { + setAuth({ authRequired: false, admin: false }); + renderWithRoute('/est', ); + await waitFor(() => { + expect(client.getAdminESTProfiles).toHaveBeenCalled(); + }); + }); +}); + +describe('ESTAdminPage — tab navigation', () => { + it('defaults to the Profiles tab', async () => { + renderWithRoute('/est', ); + expect(await screen.findByTestId('est-tab-profiles')).toHaveAttribute('aria-pressed', 'true'); + }); + + it('clicking Recent Activity switches the tab', async () => { + renderWithRoute('/est', ); + fireEvent.click(await screen.findByTestId('est-tab-activity')); + expect(screen.getByTestId('est-tab-activity')).toHaveAttribute('aria-pressed', 'true'); + }); + + it('?tab=trust deep-link lands on Trust Bundle', async () => { + renderWithRoute('/est?tab=trust', ); + expect(await screen.findByTestId('est-tab-trust')).toHaveAttribute('aria-pressed', 'true'); + }); +}); + +describe('ESTAdminPage — Profiles tab', () => { + it('renders one card per profile with the right badges + counters', async () => { + renderWithRoute('/est', ); + expect(await screen.findByTestId('est-profile-summary-corp')).toBeInTheDocument(); + expect(screen.getByTestId('est-profile-summary-iot')).toBeInTheDocument(); + // Per-profile counter cells render with the snapshot value. + expect(screen.getByTestId('est-counter-corp-success_simpleenroll')).toHaveTextContent('42'); + expect(screen.getByTestId('est-counter-corp-rate_limited')).toHaveTextContent('2'); + expect(screen.getByTestId('est-counter-iot-success_simpleenroll')).toHaveTextContent('9'); + // Counters that don't appear in the iot snapshot default to 0 in the cell. + expect(screen.getByTestId('est-counter-iot-internal_error')).toHaveTextContent('0'); + }); + + it('reload-trust button only appears for mTLS profiles', async () => { + renderWithRoute('/est', ); + expect(await screen.findByTestId('est-reload-trust-corp')).toBeInTheDocument(); + expect(screen.queryByTestId('est-reload-trust-iot')).toBeNull(); + }); + + it('shows mTLS trust expiry badge tone bands', async () => { + renderWithRoute('/est', ); + const badge = await screen.findByTestId('est-trust-expiry-badge-corp'); + expect(badge).toHaveTextContent(/250d remaining/); + }); +}); + +describe('ESTAdminPage — reload modal', () => { + it('Confirm calls mutation', async () => { + vi.mocked(client.reloadAdminESTTrust).mockResolvedValue({ + reloaded: true, + path_id: 'corp', + reloaded_at: '2026-04-29T15:00:01Z', + }); + renderWithRoute('/est', ); + fireEvent.click(await screen.findByTestId('est-reload-trust-corp')); + fireEvent.click(await screen.findByTestId('est-reload-confirm')); + await waitFor(() => { + expect(client.reloadAdminESTTrust).toHaveBeenCalledWith('corp'); + }); + }); + + it('Cancel skips mutation', async () => { + renderWithRoute('/est', ); + fireEvent.click(await screen.findByTestId('est-reload-trust-corp')); + fireEvent.click(await screen.findByTestId('est-reload-cancel')); + await waitFor(() => { + expect(screen.queryByTestId('est-reload-confirm')).toBeNull(); + }); + expect(client.reloadAdminESTTrust).not.toHaveBeenCalled(); + }); + + it('Error keeps the modal open + surfaces the message', async () => { + vi.mocked(client.reloadAdminESTTrust).mockRejectedValue( + new Error('Trust anchor reload failed: trustanchor: cert in /etc/est-corp.pem expired'), + ); + renderWithRoute('/est', ); + fireEvent.click(await screen.findByTestId('est-reload-trust-corp')); + fireEvent.click(await screen.findByTestId('est-reload-confirm')); + expect(await screen.findByTestId('est-reload-error')).toHaveTextContent(/expired/); + // Modal stays open — Confirm button still rendered. + expect(screen.getByTestId('est-reload-confirm')).toBeInTheDocument(); + }); +}); + +describe('ESTAdminPage — Trust Bundle tab', () => { + it('renders only mTLS profiles + skips non-mTLS', async () => { + renderWithRoute('/est?tab=trust', ); + expect(await screen.findByTestId('est-trust-card-corp')).toBeInTheDocument(); + expect(screen.queryByTestId('est-trust-card-iot')).toBeNull(); + }); + + it('shows the empty-state banner when no profile has mTLS', async () => { + vi.mocked(client.getAdminESTProfiles).mockResolvedValue({ + profiles: [iotProfile], + profile_count: 1, + generated_at: '2026-04-29T15:00:00Z', + } as any); + renderWithRoute('/est?tab=trust', ); + expect(await screen.findByText(/No EST profiles have mTLS enabled/i)).toBeInTheDocument(); + }); +}); + +describe('ESTAdminPage — Recent Activity tab', () => { + it('renders filter chips + reacts to selection', async () => { + renderWithRoute('/est?tab=activity', ); + const allChip = await screen.findByTestId('est-activity-filter-all'); + expect(allChip).toHaveAttribute('aria-pressed', 'true'); + fireEvent.click(screen.getByTestId('est-activity-filter-enroll')); + await waitFor(() => { + expect(screen.getByTestId('est-activity-filter-enroll')).toHaveAttribute('aria-pressed', 'true'); + }); + }); +}); diff --git a/web/src/pages/ESTAdminPage.tsx b/web/src/pages/ESTAdminPage.tsx new file mode 100644 index 0000000..980875f --- /dev/null +++ b/web/src/pages/ESTAdminPage.tsx @@ -0,0 +1,646 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useLocation, useSearchParams } from 'react-router-dom'; +import { + getAdminESTProfiles, + reloadAdminESTTrust, + getAuditEvents, +} from '../api/client'; +import PageHeader from '../components/PageHeader'; +import ErrorState from '../components/ErrorState'; +import { useAuth } from '../components/AuthProvider'; +import { useTrackedMutation } from '../hooks/useTrackedMutation'; +import { formatDateTime } from '../api/utils'; +import type { + ESTStatsSnapshot, + ESTTrustAnchorInfo, + AuditEvent, +} from '../api/types'; + +// EST RFC 7030 hardening master bundle Phase 8 — operator-facing EST +// administration page with three tabs. +// +// Profiles (default) — every configured EST profile, lean card per +// profile with always-present fields (auth-mode +// badges, mTLS trust-anchor expiry countdown, +// counter grid). Per-card "Reload trust" action +// (admin-gated; opens ConfirmReloadModal). Polled +// every 30s via TanStack Query. +// Recent Activity — full EST audit log filter covering the four +// action codes the service emits +// (est_simple_enroll / est_simple_reenroll / +// est_server_keygen / est_auth_failed). Merged + +// sorted descending. Filter chips for All / +// Enrollment / Re-enrollment / ServerKeygen / +// AuthFailure. Polled every 60s. +// Trust Bundle — for mTLS profiles: per-profile trust bundle +// viewer (cert subjects + expiry). The +// upload-new-bundle action is intentionally +// omitted at GA — operators rotate the file on +// disk + use the Reload action on the Profiles +// tab. A future phase ships the upload endpoint. +// +// Admin-gated: the page renders an "Admin access required" banner for +// non-admin callers and never issues the underlying admin requests. +// Server-side enforcement is the M-008 admin gate; this is a UX hint. +// +// The 12 counter labels match service/est_counters.go's estCounter* +// constants; new labels added there MUST also be added to +// COUNTER_LABEL_ORDER + COUNTER_PRESENTATION below. + +const COUNTER_LABEL_ORDER = [ + 'success_simpleenroll', + 'success_simplereenroll', + 'success_serverkeygen', + 'auth_failed_basic', + 'auth_failed_mtls', + 'auth_failed_channel_binding', + 'csr_invalid', + 'csr_policy_violation', + 'csr_signature_mismatch', + 'rate_limited', + 'issuer_error', + 'internal_error', +] as const; + +const COUNTER_PRESENTATION: Record = { + success_simpleenroll: { label: 'Enrollments', tone: 'good' }, + success_simplereenroll: { label: 'Re-enrollments', tone: 'good' }, + success_serverkeygen: { label: 'Server-keygen', tone: 'good' }, + auth_failed_basic: { label: 'Auth failed (Basic)', tone: 'warn' }, + auth_failed_mtls: { label: 'Auth failed (mTLS)', tone: 'warn' }, + auth_failed_channel_binding: { label: 'Channel-binding mismatch', tone: 'bad' }, + csr_invalid: { label: 'CSR invalid', tone: 'warn' }, + csr_policy_violation: { label: 'CSR policy violation', tone: 'warn' }, + csr_signature_mismatch: { label: 'CSR signature mismatch', tone: 'bad' }, + rate_limited: { label: 'Rate-limited', tone: 'warn' }, + issuer_error: { label: 'Issuer error', tone: 'bad' }, + internal_error: { label: 'Internal error', tone: 'bad' }, +}; + +const TONE_CLASS: Record<'good' | 'warn' | 'bad', string> = { + good: 'text-emerald-600', + warn: 'text-amber-600', + bad: 'text-red-600', +}; + +type TabId = 'profiles' | 'activity' | 'trust'; +type ActivityFilter = 'all' | 'enroll' | 'reenroll' | 'serverkeygen' | 'authfail'; + +const TAB_LABELS: Record = { + profiles: 'Profiles', + activity: 'Recent Activity', + trust: 'Trust Bundle', +}; + +const EST_AUDIT_ACTIONS = [ + 'est_simple_enroll', + 'est_simple_reenroll', + 'est_server_keygen', + 'est_auth_failed', +] as const; + +// ============================================================================= +// Tone + badge helpers (shared across tabs). +// ============================================================================= + +function expiryBadge(days: number | null, expired: boolean): { text: string; tone: 'good' | 'warn' | 'bad' } { + if (expired) return { text: 'EXPIRED', tone: 'bad' }; + if (days === null) return { text: 'Not loaded', tone: 'warn' }; + if (days < 7) return { text: `${days}d remaining`, tone: 'bad' }; + if (days < 30) return { text: `${days}d remaining (rotate soon)`, tone: 'warn' }; + return { text: `${days}d remaining`, tone: 'good' }; +} + +function badgeClass(tone: 'good' | 'warn' | 'bad'): string { + if (tone === 'good') return 'bg-emerald-100 text-emerald-800'; + if (tone === 'warn') return 'bg-amber-100 text-amber-800'; + return 'bg-red-100 text-red-800'; +} + +function pillClass(active: boolean): string { + return active + ? 'bg-brand-100 text-brand-800 border-brand-300' + : 'bg-surface-alt text-ink-muted border-surface-border'; +} + +// soonestExpiryDays returns the smallest days_to_expiry across the +// profile's mTLS trust anchor pool. Returns null when the pool is +// empty (the per-profile preflight should have refused this state at +// boot, but defensive in case the holder is reloaded mid-flight to an +// empty file). +function soonestExpiryDays(anchors?: ESTTrustAnchorInfo[]): number | null { + if (!anchors || anchors.length === 0) return null; + let min = Number.POSITIVE_INFINITY; + for (const a of anchors) { + if (a.expired) return -1; + if (a.days_to_expiry < min) min = a.days_to_expiry; + } + return min === Number.POSITIVE_INFINITY ? null : min; +} + +// ============================================================================= +// Profiles tab. +// ============================================================================= + +interface ProfilesTabProps { + profiles: ESTStatsSnapshot[]; + isLoading: boolean; + onRequestReload: (profile: ESTStatsSnapshot) => void; +} + +function ProfilesTab({ profiles, isLoading, onRequestReload }: ProfilesTabProps) { + if (isLoading) { + return

Loading profiles…

; + } + if (profiles.length === 0) { + return ( +
+ No EST profiles are configured. Set CERTCTL_EST_ENABLED=true and either the + legacy single-profile env vars or CERTCTL_EST_PROFILES=... with the indexed + per-profile family to register at least one endpoint. +
+ ); + } + return ( + <> + {profiles.map(p => ( + + ))} + + ); +} + +interface ProfileSummaryCardProps { + profile: ESTStatsSnapshot; + onRequestReload: (profile: ESTStatsSnapshot) => void; +} + +function ProfileSummaryCard({ profile, onRequestReload }: ProfileSummaryCardProps) { + const pathLabel = profile.path_id || '(legacy /.well-known/est root)'; + const trustDays = soonestExpiryDays(profile.trust_anchors); + const trustExpired = (profile.trust_anchors ?? []).some(a => a.expired); + const trustBadge = profile.mtls_enabled + ? expiryBadge(trustDays, trustExpired) + : null; + + return ( +
+
+
+

{pathLabel}

+

+ Issuer: {profile.issuer_id} + {profile.profile_id && ( + <> + {' '}· Profile: {profile.profile_id} + + )} +

+
+ {trustBadge && ( + + mTLS trust: {trustBadge.text} + + )} +
+ +
+ + mTLS {profile.mtls_enabled ? 'enabled' : 'disabled'} + + + HTTP Basic {profile.basic_auth_configured ? 'configured' : 'not set'} + + + Server-keygen {profile.server_keygen_enabled ? 'enabled' : 'disabled'} + +
+ +
+ {COUNTER_LABEL_ORDER.map(label => { + const presentation = COUNTER_PRESENTATION[label]; + const value = profile.counters?.[label] ?? 0; + return ( +
+
{presentation.label}
+
{value}
+
+ ); + })} +
+ + {profile.mtls_enabled && profile.trust_anchor_path && ( +

+ Trust bundle: {profile.trust_anchor_path} +

+ )} + + {profile.mtls_enabled && ( +
+ +
+ )} +
+ ); +} + +// ============================================================================= +// Confirm-reload modal. +// ============================================================================= + +interface ConfirmReloadModalProps { + profile: ESTStatsSnapshot; + onCancel: () => void; + onConfirm: () => void; + pending: boolean; + errorMessage?: string; +} + +function ConfirmReloadModal({ profile, onCancel, onConfirm, pending, errorMessage }: ConfirmReloadModalProps) { + const pathLabel = profile.path_id || '(legacy /.well-known/est root)'; + return ( +
+
+

+ Reload EST mTLS trust anchor +

+

+ This re-reads {profile.trust_anchor_path} from disk and atomically + swaps the trust pool for EST profile {pathLabel}. Equivalent to sending + SIGHUP to the server. If the new file fails to parse, the + previous trust pool stays in place — enrollments keep working off the old trust anchor while you + fix the file. +

+ {errorMessage && ( +
+ {errorMessage} +
+ )} +
+ + +
+
+
+ ); +} + +// ============================================================================= +// Recent Activity tab. +// ============================================================================= + +interface ActivityTabProps { + events: AuditEvent[]; + isLoading: boolean; + filter: ActivityFilter; + setFilter: (f: ActivityFilter) => void; +} + +function activityMatches(filter: ActivityFilter, e: AuditEvent): boolean { + if (filter === 'all') return true; + if (filter === 'enroll') return e.action === 'est_simple_enroll'; + if (filter === 'reenroll') return e.action === 'est_simple_reenroll'; + if (filter === 'serverkeygen') return e.action === 'est_server_keygen'; + if (filter === 'authfail') return e.action === 'est_auth_failed'; + return false; +} + +const ACTIVITY_FILTERS: { id: ActivityFilter; label: string }[] = [ + { id: 'all', label: 'All' }, + { id: 'enroll', label: 'Enrollment' }, + { id: 'reenroll', label: 'Re-enrollment' }, + { id: 'serverkeygen', label: 'Server-keygen' }, + { id: 'authfail', label: 'Auth failure' }, +]; + +function ActivityTab({ events, isLoading, filter, setFilter }: ActivityTabProps) { + const filtered = useMemo(() => events.filter(e => activityMatches(filter, e)), [events, filter]); + return ( + <> +
+ {ACTIVITY_FILTERS.map(f => ( + + ))} +
+ {isLoading &&

Loading audit events…

} + {!isLoading && filtered.length === 0 && ( +

No events match the selected filter.

+ )} + {!isLoading && filtered.length > 0 && ( +
+ + + + + + + + + + + {filtered.slice(0, 100).map((e, i) => ( + + + + + + + ))} + +
TimestampActionSubjectResource
{formatDateTime(e.timestamp)}{e.action}{e.actor || '—'}{e.resource_id || '—'}
+
+ )} + + ); +} + +// ============================================================================= +// Trust Bundle tab. +// ============================================================================= + +interface TrustBundleTabProps { + profiles: ESTStatsSnapshot[]; +} + +function TrustBundleTab({ profiles }: TrustBundleTabProps) { + const mtlsProfiles = profiles.filter(p => p.mtls_enabled && p.trust_anchors && p.trust_anchors.length > 0); + if (mtlsProfiles.length === 0) { + return ( +
+ No EST profiles have mTLS enabled. The Trust Bundle tab is only relevant when at least one + profile carries an MTLS_CLIENT_CA_TRUST_BUNDLE_PATH. +
+ ); + } + return ( + <> + {mtlsProfiles.map(p => ( +
+
+

{p.path_id || '(legacy root)'}

+ {p.trust_anchor_path} +
+ + + + + + + + + + + {(p.trust_anchors ?? []).map(a => ( + + + + + + + ))} + +
SubjectNot beforeNot afterDays remaining
{a.subject}{formatDateTime(a.not_before)}{formatDateTime(a.not_after)} + {a.expired ? 'EXPIRED' : `${a.days_to_expiry}d`} +
+
+ ))} + + ); +} + +// ============================================================================= +// Top-level page. +// ============================================================================= + +function pickInitialTab(searchParams: URLSearchParams): TabId { + const fromQuery = searchParams.get('tab'); + if (fromQuery === 'activity' || fromQuery === 'trust') return fromQuery; + return 'profiles'; +} + +export default function ESTAdminPage() { + const auth = useAuth(); + const adminAccess = !auth.authRequired || auth.admin; + const [searchParams, setSearchParams] = useSearchParams(); + const _location = useLocation(); + void _location; // reserved for future deep-link cases (mirrors SCEPAdminPage) + + const [activeTab, setActiveTab] = useState(() => pickInitialTab(searchParams)); + const [reloadTarget, setReloadTarget] = useState(null); + const [reloadError, setReloadError] = useState(undefined); + const [activityFilter, setActivityFilter] = useState('all'); + + // Keep URL in sync with tab so deep links survive page reloads. + useEffect(() => { + const next = new URLSearchParams(searchParams); + if (activeTab === 'profiles') { + next.delete('tab'); + } else { + next.set('tab', activeTab); + } + if (next.toString() !== searchParams.toString()) { + setSearchParams(next, { replace: true }); + } + }, [activeTab, searchParams, setSearchParams]); + + // Per-profile snapshot. Polled every 30s on the profiles tab. + const profilesQuery = useQuery({ + queryKey: ['admin', 'est', 'profiles'], + queryFn: getAdminESTProfiles, + enabled: adminAccess, + refetchInterval: 30_000, + }); + + // EST audit-log queries — four parallel queries (one per action) so + // the activity tab can present a merged + filterable feed without a + // dedicated server endpoint. + const auditQueries = EST_AUDIT_ACTIONS.map(action => + // eslint-disable-next-line react-hooks/rules-of-hooks + useQuery({ + queryKey: ['audit', { action }], + queryFn: () => getAuditEvents({ action }), + enabled: adminAccess && 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); + + // M-009 useTrackedMutation guard: every mutation in this page MUST + // route through useTrackedMutation so the audit / progress hooks fire. + const reloadMutation = useTrackedMutation< + Awaited>, + Error, + string + >({ + mutationFn: (pathID: string) => reloadAdminESTTrust(pathID), + invalidates: [['admin', 'est', 'profiles']], + onSuccess: () => { + setReloadTarget(null); + setReloadError(undefined); + }, + onError: (err: Error) => { + setReloadError(err.message); + }, + }); + + if (auth.authRequired && !auth.admin) { + return ( + <> + +
+ +
+ + ); + } + + const profiles = profilesQuery.data?.profiles ?? []; + + return ( + <> + { + void profilesQuery.refetch(); + }} + className="text-xs px-3 py-1.5 rounded border border-surface-border bg-surface hover:bg-surface-alt" + data-testid="est-refresh-stats-button" + > + Refresh now + + } + /> +
+ +
+ +
+ {profilesQuery.error && activeTab === 'profiles' && ( + profilesQuery.refetch()} /> + )} + + {activeTab === 'profiles' && !profilesQuery.error && ( + { + setReloadError(undefined); + setReloadTarget(profile); + }} + /> + )} + + {activeTab === 'activity' && ( + + )} + + {activeTab === 'trust' && } +
+ + {reloadTarget && ( + { + setReloadTarget(null); + setReloadError(undefined); + }} + onConfirm={() => reloadMutation.mutate(reloadTarget.path_id)} + pending={reloadMutation.isPending} + errorMessage={reloadError} + /> + )} + + ); +}