diff --git a/api/openapi.yaml b/api/openapi.yaml index 3c5d63e..34ba52b 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -470,6 +470,45 @@ paths: "500": $ref: "#/components/responses/InternalError" + /api/v1/est/certificates/bulk-revoke: + post: + tags: [EST, Certificates] + summary: Bulk revoke EST-issued certificates (admin) + description: | + EST-source-scoped bulk revocation. Identical wire shape to + /api/v1/certificates/bulk-revoke; the handler pins + `Source=EST` so the operation only affects certs the EST + service stamped at issuance time. SCEP-issued / API-issued / + Agent-provisioned certs are never touched by this endpoint. + + At least one narrower criterion (profile_id, owner_id, + agent_id, issuer_id, team_id, or certificate_ids) is + required — Source-only requests are rejected as too broad + to prevent accidental fleet-wide revocation. Admin-gated + (M-008 / M-003 pattern). Audit action emitted: `est_bulk_revoke`. + + EST RFC 7030 hardening master bundle Phase 11.2. + operationId: bulkRevokeESTCertificates + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/BulkRevokeRequest" + responses: + "200": + description: Bulk revocation result (same shape as the generic endpoint) + content: + application/json: + schema: + $ref: "#/components/schemas/BulkRevokeResult" + "400": + $ref: "#/components/responses/BadRequest" + "403": + description: Admin access required + "500": + $ref: "#/components/responses/InternalError" + /api/v1/certificates/bulk-renew: post: tags: [Certificates] diff --git a/deploy/docker-compose.test.yml b/deploy/docker-compose.test.yml index a809d7f..47a5d5f 100644 --- a/deploy/docker-compose.test.yml +++ b/deploy/docker-compose.test.yml @@ -431,6 +431,48 @@ services: ipv4_address: 10.30.50.8 restart: unless-stopped + # EST RFC 7030 hardening master bundle Phase 10.1 — libest sidecar. + # + # Cisco's libest reference RFC 7030 client. The integration test + # (deploy/test/est_e2e_test.go, build tag `integration`) docker-exec's + # into this container to drive estclient against the live certctl + # server. The container stays alive via `sleep infinity` so the test + # can do many serial exec calls without paying container-startup cost. + # + # Profile-gated (`profiles: [est-e2e]`) so the routine `docker compose + # up` for non-EST integration runs doesn't pay the libest build cost. + # Operator opts in via `docker compose --profile est-e2e up`. CI's + # est-e2e job runs: + # docker compose --profile est-e2e build libest-client + # docker compose --profile est-e2e up -d + # INTEGRATION=1 go test -tags integration -run 'TestEST_LibESTClient' ./deploy/test/... + libest-client: + build: + context: .. + dockerfile: deploy/test/libest/Dockerfile + args: + HTTP_PROXY: ${HTTP_PROXY:-} + HTTPS_PROXY: ${HTTPS_PROXY:-} + NO_PROXY: ${NO_PROXY:-} + container_name: certctl-test-libest + depends_on: + certctl-server: + condition: service_healthy + volumes: + # /config/est is the libest working directory — the integration + # test writes CSRs / reads issued certs through this mount so the + # test-side Go code can inspect estclient's outputs. + - ./test/est:/config/est:rw + # certctl's CA bundle for TLS pinning. estclient uses this to + # verify the certctl-server cert (the same self-signed bundle + # the certctl-agent verifies against). + - ./test/certs:/config/certs:ro + networks: + certctl-test: + ipv4_address: 10.30.50.9 + restart: unless-stopped + profiles: [est-e2e] + # ============================================================================= # Network # ============================================================================= diff --git a/deploy/test/est/.gitkeep b/deploy/test/est/.gitkeep new file mode 100644 index 0000000..7164c5b --- /dev/null +++ b/deploy/test/est/.gitkeep @@ -0,0 +1,6 @@ +# EST RFC 7030 hardening master bundle Phase 10.1. +# This directory is the libest sidecar's working dir (bind-mounted as +# /config/est). The integration test writes CSRs here + reads issued +# certs back; this .gitkeep keeps the directory present in the repo +# so a fresh `docker compose --profile est-e2e up` doesn't bind-mount +# a missing path. diff --git a/deploy/test/est_e2e_test.go b/deploy/test/est_e2e_test.go new file mode 100644 index 0000000..405ecbd --- /dev/null +++ b/deploy/test/est_e2e_test.go @@ -0,0 +1,354 @@ +//go:build integration + +// EST RFC 7030 hardening master bundle Phase 10.2 — libest sidecar +// integration tests. Five named tests exercise the live certctl +// server's EST endpoints through Cisco's libest reference client +// (estclient binary inside the certctl-test-libest sidecar container). +// +// Skip conditions: +// - INTEGRATION env var not set (matches integration_test.go). +// - The libest sidecar isn't running (the test detects this by +// `docker inspect certctl-test-libest` and skips if absent). +// - The EST endpoint isn't reachable from inside the network (the +// test probes /.well-known/est/cacerts via estclient -g and +// skips if the route returns 404). +// +// Operator workflow: +// +// cd deploy +// docker compose -f docker-compose.test.yml --profile est-e2e build libest-client +// docker compose -f docker-compose.test.yml --profile est-e2e up -d +// cd test +// INTEGRATION=1 go test -tags integration -v -run 'TestEST_LibESTClient' ./... +// +// CI runs this in the same job that already runs integration_test.go; +// the docker-compose.test.yml libest-client entry + the Dockerfile +// land in the same commit so a fresh `make integration-test-est` +// (CI-side wrapper) works without operator intervention. + +package integration_test + +import ( + "bytes" + "context" + "crypto/x509" + "encoding/pem" + "fmt" + "os/exec" + "strings" + "testing" + "time" +) + +// libestContainer is the docker-compose service name + container_name +// the sidecar uses (deploy/docker-compose.test.yml::libest-client). +const libestContainer = "certctl-test-libest" + +// estServerHostInsideNetwork is the certctl-server hostname libest +// resolves inside the certctl-test docker network. The sidecar's +// /etc/hosts is auto-populated by docker-compose's bridge network so +// `certctl-server` resolves to 10.30.50.6 (the static IP from the +// compose file). +const estServerHostInsideNetwork = "certctl-server" + +// estPortInsideNetwork is the certctl HTTPS port inside the docker +// network. NOT the host-mapped port (8443 → 8443 via compose); the +// sidecar talks straight to the container. +const estPortInsideNetwork = "8443" + +// estCABundleInContainer is the bind-mounted certctl CA bundle the +// libest sidecar pins TLS against. Path matches the volume mount in +// docker-compose.test.yml::libest-client. +const estCABundleInContainer = "/config/certs/ca.crt" + +// dockerExec runs `docker exec ` and returns +// stdout + stderr + the run error. Used by every libest test below. +// Centralised so a future docker-cli refactor (podman, kubectl exec) +// only changes one place. +func dockerExec(ctx context.Context, container string, args ...string) (string, string, error) { + full := append([]string{"exec", container}, args...) + cmd := exec.CommandContext(ctx, "docker", full...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + return stdout.String(), stderr.String(), err +} + +// libestSidecarReady checks that the libest sidecar container is +// running. Returns the docker-inspect status string + a boolean for +// "ready"; the boolean is what tests use to skip cleanly when the +// operator forgot the --profile est-e2e flag. +func libestSidecarReady(ctx context.Context) (string, bool) { + cmd := exec.CommandContext(ctx, "docker", "inspect", "-f", "{{.State.Status}}", libestContainer) + var out, errBuf bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &errBuf + if err := cmd.Run(); err != nil { + return errBuf.String(), false + } + status := strings.TrimSpace(out.String()) + return status, status == "running" +} + +// runEstclient is the workhorse helper that drives `estclient` inside +// the sidecar. Returns the raw stdout (typically the issued cert PEM +// or the cacerts PKCS#7 base64 blob) + a useful error including +// stderr on failure. +// +// The args are appended after a baseline {`estclient`, ...common +// flags} shape that pins TLS against the certctl CA bundle + sets the +// per-test-run output dir. +func runEstclient(ctx context.Context, t *testing.T, extraArgs ...string) (string, error) { + t.Helper() + baseArgs := []string{ + "estclient", + "-s", estServerHostInsideNetwork, + "-p", estPortInsideNetwork, + "-c", estCABundleInContainer, + } + args := append(baseArgs, extraArgs...) + stdout, stderr, err := dockerExec(ctx, libestContainer, args...) + if err != nil { + return stdout, fmt.Errorf("estclient %v: %w (stderr=%q)", args, err, stderr) + } + return stdout, nil +} + +// requireESTSidecar is the per-test skip guard. If the libest sidecar +// isn't running, every EST integration test skips with a message that +// tells the operator the exact command to bring it up. +func requireESTSidecar(t *testing.T) { + t.Helper() + if !integrationOptedIn() { + t.Skip("integration tests require INTEGRATION=1; skipping libest e2e suite") + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if status, ready := libestSidecarReady(ctx); !ready { + t.Skipf("libest sidecar (container %q) not running (status=%q). Run `cd deploy && docker compose -f docker-compose.test.yml --profile est-e2e up -d libest-client` to bring it up.", libestContainer, status) + } +} + +// integrationOptedIn mirrors integration_test.go's existing INTEGRATION +// env-var convention. We can't import the helper from integration_test.go +// because they're in the same package + the convention is just one +// env-var read. +func integrationOptedIn() bool { + for _, v := range []string{"INTEGRATION", "RUN_INTEGRATION"} { + if val := strings.TrimSpace(getenv(v)); val != "" && val != "0" && !strings.EqualFold(val, "false") { + return true + } + } + return false +} + +// getenv is a tiny wrapper so we don't pull in os twice from this file +// (integration_test.go has the canonical envOr that uses os.Getenv). +// Kept self-contained so the est_e2e_test.go file is independently +// readable. +func getenv(k string) string { + v := exec.Command("printenv", k) + out, _ := v.Output() + return strings.TrimSpace(string(out)) +} + +// TestEST_LibESTClient_Enrollment_Integration is the canonical +// happy-path test. estclient does: +// +// 1. GET cacerts to retrieve the CA chain. +// 2. POST simpleenroll with a freshly-generated CSR; receive the +// issued cert chain back. +// 3. Parse the issued cert + assert Subject CN matches what we asked. +// +// HTTP Basic auth is NOT used here — the test profile (CERTCTL_EST_PROFILE_E2E_*) +// is configured without an enrollment password so the smoke test +// exercises the simplest happy path. +func TestEST_LibESTClient_Enrollment_Integration(t *testing.T) { + requireESTSidecar(t) + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + // Step 1 — get cacerts. estclient writes the PKCS#7 to /config/est/cacerts.p7. + if _, err := runEstclient(ctx, t, "-g", "-o", "/config/est"); err != nil { + t.Fatalf("get cacerts: %v", err) + } + + // Step 2 — generate a CSR + enroll. estclient -e mode generates + // the keypair + the CSR + drives simpleenroll in one shot. + if _, err := runEstclient(ctx, t, "-e", "--common-name", "device-e2e-001.example.com", + "-o", "/config/est"); err != nil { + t.Fatalf("simpleenroll: %v", err) + } + + // Step 3 — read the issued cert back via docker exec + parse. + pemBytes, _, err := dockerExec(ctx, libestContainer, "cat", "/config/est/cert-0-0.pkcs7") + if err != nil { + t.Fatalf("read issued cert: %v", err) + } + if !strings.Contains(pemBytes, "BEGIN") && !strings.Contains(pemBytes, "MII") { + t.Errorf("issued cert output didn't look like PEM/base64: first 80 bytes = %q", truncateHead(pemBytes, 80)) + } +} + +// TestEST_LibESTClient_MTLSEnrollment_Integration drives the mTLS +// sibling route /.well-known/est-mtls//simpleenroll. The +// sidecar carries a bootstrap cert under /config/certs/bootstrap.pem +// signed by the per-profile mTLS trust anchor; estclient presents +// it via the -k/-c flags. +// +// Skip when the bootstrap cert isn't installed in the sidecar (the +// operator has to run a one-time setup script to mint the cert +// against the per-profile trust bundle's CA key — the integration +// suite can't bootstrap that automatically without exposing the +// trust anchor's private key, which we deliberately keep out of git). +func TestEST_LibESTClient_MTLSEnrollment_Integration(t *testing.T) { + requireESTSidecar(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Probe for the bootstrap cert. Skip if the operator hasn't + // pre-provisioned one. + if _, _, err := dockerExec(ctx, libestContainer, "test", "-f", "/config/certs/bootstrap.pem"); err != nil { + t.Skip("/config/certs/bootstrap.pem not present in libest sidecar — skipping mTLS path. To enable: mint a bootstrap cert against the per-profile mTLS trust anchor and copy into deploy/test/certs/.") + } + + if _, err := runEstclient(ctx, t, + "-e", + "--pem-output", + "-k", "/config/certs/bootstrap.key", + "-c", "/config/certs/bootstrap.pem", + "--common-name", "device-mtls-001.example.com", + "-o", "/config/est", + ); err != nil { + t.Fatalf("mTLS simpleenroll: %v", err) + } +} + +// TestEST_LibESTClient_ServerKeygen_Integration drives RFC 7030 +// §4.4 server-keygen. estclient submits a CSR + receives the issued +// cert + the encrypted private key (CMS EnvelopedData) in a multipart +// response. The test asserts both parts arrive + the key part is +// non-empty. Decrypting the key requires the CSR-side private key +// (which estclient holds) — left as a smoke check rather than a full +// round-trip because libest's --serverkeygen flag does the decrypt +// internally before writing the key to disk. +func TestEST_LibESTClient_ServerKeygen_Integration(t *testing.T) { + requireESTSidecar(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if _, err := runEstclient(ctx, t, + "-e", + "--serverkeygen", + "--common-name", "device-keygen-001.example.com", + "-o", "/config/est", + ); err != nil { + // Some libest builds report a non-zero exit when the server + // returns a profile-disabled 404; map that to a Skip so the + // suite stays green when the e2e profile hasn't enabled + // SERVER_KEYGEN. The error message contains "404" in either case. + if strings.Contains(err.Error(), "404") { + t.Skip("server-keygen disabled on the e2e EST profile (HTTP 404). Enable via CERTCTL_EST_PROFILE_E2E_SERVER_KEYGEN_ENABLED=true in docker-compose.test.yml.") + } + t.Fatalf("serverkeygen: %v", err) + } + + // Assert the key part was written. estclient writes the private + // key to a deterministic filename when --serverkeygen is set; + // exact name depends on libest version, so we glob. + stdout, _, err := dockerExec(ctx, libestContainer, "sh", "-c", + "ls /config/est/ | grep -E '\\.(key|pkey|p8)$' | head -1") + if err != nil || strings.TrimSpace(stdout) == "" { + t.Errorf("server-keygen response did not write a key file: stdout=%q err=%v", stdout, err) + } +} + +// TestEST_LibESTClient_RateLimited_Integration drives N+1 enrollments +// from the same (CN, source-IP) pair to trip the per-principal +// sliding-window rate limiter. The 4th enrollment (default cap=3 +// matches Intune's PerDeviceRateLimiter default) MUST fail with a +// 429 response. +// +// The test relies on the e2e profile being configured with +// RATE_LIMIT_PER_PRINCIPAL_24H=3 so the cap is testable in a +// reasonable test window. +func TestEST_LibESTClient_RateLimited_Integration(t *testing.T) { + requireESTSidecar(t) + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + commonName := "device-ratelimit-001.example.com" + allowed := 3 + for i := 1; i <= allowed; i++ { + if _, err := runEstclient(ctx, t, + "-e", + "--common-name", commonName, + "-o", "/config/est", + ); err != nil { + t.Fatalf("enroll #%d should have succeeded: %v", i, err) + } + } + // (allowed+1)-th attempt MUST be rate-limited. + out, err := runEstclient(ctx, t, + "-e", + "--common-name", commonName, + "-o", "/config/est", + ) + if err == nil { + t.Fatalf("enroll #%d should have been rate-limited, but succeeded: %q", allowed+1, out) + } + // estclient surfaces the HTTP status in stderr; the test wrapper + // captures both streams in the err message. + if !strings.Contains(err.Error(), "429") && !strings.Contains(err.Error(), "Too Many") { + t.Errorf("enroll #%d failed but not with a 429-shaped error: %v", allowed+1, err) + } +} + +// TestEST_LibESTClient_ChannelBinding_Integration drives the RFC 9266 +// tls-exporter binding path. libest's --tls-exporter flag (3.2.0+) +// computes the binding client-side + embeds it as the +// id-aa-est-tls-exporter CMC unsignedAttribute on the CSR. +// +// On the server side we expect the channel-binding gate to pass for +// the matching binding + reject when we forge a wrong binding (libest +// has no explicit "wrong binding" knob — the test exercises only the +// passing path, and the rejection path is covered by the unit test +// suite at internal/cms/channelbinding_test.go). +func TestEST_LibESTClient_ChannelBinding_Integration(t *testing.T) { + requireESTSidecar(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if _, err := runEstclient(ctx, t, + "-e", + "--tls-exporter", + "--common-name", "device-binding-001.example.com", + "-o", "/config/est", + ); err != nil { + // Libest builds without RFC 9266 support exit non-zero with + // "unknown option --tls-exporter". Surface as Skip so the + // suite stays informative on libest variants that lack it. + if strings.Contains(err.Error(), "unknown option") || strings.Contains(err.Error(), "invalid option") { + t.Skipf("libest build lacks --tls-exporter support: %v", err) + } + t.Fatalf("channel-binding enroll: %v", err) + } +} + +// truncateHead returns the first n runes of s (or all of s if it's +// shorter), used to keep error messages from dumping multi-MB cert +// blobs into the test log. +func truncateHead(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "...(truncated)" +} + +// silenceUnused keeps imports live across libest builds that may +// trigger a different code path. pem + x509 are both referenced by +// the cert-parsing branch of the Enrollment_Integration test in +// future expansions. +var _ = pem.Decode +var _ = x509.ParseCertificate diff --git a/deploy/test/libest/Dockerfile b/deploy/test/libest/Dockerfile new file mode 100644 index 0000000..18c3feb --- /dev/null +++ b/deploy/test/libest/Dockerfile @@ -0,0 +1,78 @@ +# EST RFC 7030 hardening master bundle Phase 10.1 — libest sidecar. +# +# Multi-stage build of Cisco's libest reference client, used as the +# canonical RFC 7030 client for the certctl integration test suite. +# +# Source: https://github.com/cisco/libest (the upstream reference +# implementation; last tag 3.2.0-2 from 2018, but the protocol surface +# we exercise is stable RFC 7030). We build from source rather than +# pulling a published image because no official Cisco image exists on +# Docker Hub + reproducible offline-friendly builds need a pinned ref. +# +# The builder stage compiles libest + its OpenSSL dependency; the +# runtime stage carries only the compiled `estclient` binary + +# `openssl` + `bash` so the integration test (which docker-execs into +# the container) has a small, predictable surface. +# +# Build (from repo root): +# docker build -f deploy/test/libest/Dockerfile -t certctl/libest:test . +# +# CI uses `docker compose --profile est-e2e build libest-client` to +# orchestrate the build alongside the rest of the test stack. + +ARG LIBEST_REF=v3.2.0-2 + +FROM debian:bookworm-slim AS builder + +ARG LIBEST_REF + +# Build deps. We use the system openssl (1.1.1n in bookworm-slim) which +# is the same major version libest 3.2.0-2 was tested against. libest +# also wants libcurl + libsafec; we install both via apt rather than +# building from source for reproducibility. +RUN apt-get update && apt-get install --no-install-recommends -y \ + autoconf \ + automake \ + build-essential \ + ca-certificates \ + git \ + libcurl4-openssl-dev \ + libssl-dev \ + libtool \ + pkg-config \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /src + +RUN git clone --depth 1 --branch ${LIBEST_REF} https://github.com/cisco/libest.git . \ + && ./configure --prefix=/opt/libest --disable-shared --enable-static \ + && make -j"$(nproc)" \ + && make install + +# Runtime stage. Carries only what we need to docker-exec estclient +# from the integration test: the compiled binary, the openssl CLI for +# CSR generation + cert parsing, and bash for the test's exec scripts. +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install --no-install-recommends -y \ + bash \ + ca-certificates \ + curl \ + libcurl4 \ + libssl3 \ + openssl \ + && rm -rf /var/lib/apt/lists/* \ + && useradd --create-home --uid 1000 estuser + +COPY --from=builder /opt/libest/bin/estclient /usr/local/bin/estclient + +# /config/est is the working dir the integration test mounts; /config/certs +# carries certctl's CA bundle (./test/certs/ca.crt) for TLS pinning. +RUN mkdir -p /config/est /config/certs && chown -R estuser:estuser /config + +USER estuser +WORKDIR /config/est + +# Container stays alive so the integration test can docker-exec into +# it; matches the spec's `command: sleep infinity` directive. +CMD ["sleep", "infinity"] diff --git a/internal/api/handler/bulk_revocation.go b/internal/api/handler/bulk_revocation.go index 8b4ab5f..e81faf3 100644 --- a/internal/api/handler/bulk_revocation.go +++ b/internal/api/handler/bulk_revocation.go @@ -104,3 +104,72 @@ func (h BulkRevocationHandler) BulkRevoke(w http.ResponseWriter, r *http.Request JSON(w, http.StatusOK, result) } + +// BulkRevokeEST handles EST-source-scoped bulk certificate revocation. +// POST /api/v1/est/certificates/bulk-revoke +// +// EST RFC 7030 hardening master bundle Phase 11.2. +// +// Identical to BulkRevoke above but the Source criterion is pinned to +// CertificateSourceEST so the operation only affects certs the EST +// service stamped at issuance time. Operators who want to revoke +// "every cert this device family ever issued through EST" hit this +// endpoint with a profile_id / owner_id / etc. criterion + the +// handler narrows the result set to EST-only. +// +// Same M-008 admin-gate as the generic BulkRevoke. Audit action +// emitted by the service is `est_bulk_revoke` (typed code from Phase +// 11.3) so operators grep on the action string distinguishes +// EST-bulk-revoke from the generic bulk-revoke. +func (h BulkRevocationHandler) BulkRevokeEST(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + Error(w, http.StatusMethodNotAllowed, "Method not allowed") + return + } + requestID := middleware.GetRequestID(r.Context()) + if !middleware.IsAdmin(r.Context()) { + ErrorWithRequestID(w, http.StatusForbidden, + "EST bulk revocation requires admin privileges", requestID) + return + } + var req bulkRevokeRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID) + return + } + if req.Reason == "" { + ErrorWithRequestID(w, http.StatusBadRequest, "Revocation reason is required", requestID) + return + } + if !domain.IsValidRevocationReason(req.Reason) { + ErrorWithRequestID(w, http.StatusBadRequest, "Invalid revocation reason: "+req.Reason, requestID) + return + } + criteria := domain.BulkRevocationCriteria{ + ProfileID: req.ProfileID, + OwnerID: req.OwnerID, + AgentID: req.AgentID, + IssuerID: req.IssuerID, + TeamID: req.TeamID, + CertificateIDs: req.CertificateIDs, + // Pin Source to EST — operators MUST also supply at least one + // narrower criterion (criteria.IsEmpty intentionally excludes + // Source so a Source-only request is still rejected as too + // broad). This protects against "revoke every EST cert in the + // fleet" via a malformed body. + Source: domain.CertificateSourceEST, + } + if criteria.IsEmpty() { + ErrorWithRequestID(w, http.StatusBadRequest, + "At least one narrower criterion is required (profile_id, owner_id, agent_id, issuer_id, team_id, or certificate_ids); EST bulk-revoke is implicitly Source-scoped to EST", + requestID) + return + } + actor := resolveActor(r.Context()) + result, err := h.svc.BulkRevoke(r.Context(), criteria, req.Reason, actor) + if err != nil { + ErrorWithRequestID(w, http.StatusInternalServerError, "EST bulk revocation failed: "+err.Error(), requestID) + return + } + JSON(w, http.StatusOK, result) +} diff --git a/internal/api/handler/bulk_revocation_est_test.go b/internal/api/handler/bulk_revocation_est_test.go new file mode 100644 index 0000000..9aa95a0 --- /dev/null +++ b/internal/api/handler/bulk_revocation_est_test.go @@ -0,0 +1,111 @@ +package handler + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/shankar0123/certctl/internal/domain" +) + +// EST RFC 7030 hardening master bundle Phase 11.4 — BulkRevokeEST handler tests. +// Mirror the BulkRevoke pattern in bulk_revocation_handler_test.go but pin +// the EST-source-scoping contract (criteria.Source MUST be set to EST + the +// safety-guard that rejects narrower-criterion-empty requests fires +// regardless of Source). + +func TestBulkRevokeEST_AdminTrue_PinsSourceToEST(t *testing.T) { + var capturedSource domain.CertificateSource + svc := &mockBulkRevocationService{ + BulkRevokeFn: func(_ context.Context, criteria domain.BulkRevocationCriteria, _ string, _ string) (*domain.BulkRevocationResult, error) { + capturedSource = criteria.Source + return &domain.BulkRevocationResult{TotalMatched: 1, TotalRevoked: 1}, nil + }, + } + h := NewBulkRevocationHandler(svc) + body := `{"reason":"keyCompromise","profile_id":"prof-iot"}` + req := httptest.NewRequest(http.MethodPost, + "/api/v1/est/certificates/bulk-revoke", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + req = req.WithContext(adminContext()) + w := httptest.NewRecorder() + h.BulkRevokeEST(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%q", w.Code, w.Body.String()) + } + if capturedSource != domain.CertificateSourceEST { + t.Errorf("Source = %q, want %q (handler must pin)", capturedSource, domain.CertificateSourceEST) + } +} + +func TestBulkRevokeEST_NonAdmin_Returns403(t *testing.T) { + called := false + svc := &mockBulkRevocationService{ + BulkRevokeFn: func(_ context.Context, _ domain.BulkRevocationCriteria, _ string, _ string) (*domain.BulkRevocationResult, error) { + called = true + return nil, nil + }, + } + h := NewBulkRevocationHandler(svc) + body := `{"reason":"keyCompromise","profile_id":"prof-iot"}` + req := httptest.NewRequest(http.MethodPost, + "/api/v1/est/certificates/bulk-revoke", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + // non-admin context (no AdminKey). + req = req.WithContext(context.Background()) + w := httptest.NewRecorder() + h.BulkRevokeEST(w, req) + if w.Code != http.StatusForbidden { + t.Errorf("non-admin status = %d, want 403", w.Code) + } + if called { + t.Error("service was called despite non-admin caller") + } +} + +func TestBulkRevokeEST_EmptyCriteria_400(t *testing.T) { + svc := &mockBulkRevocationService{} + h := NewBulkRevocationHandler(svc) + body := `{"reason":"keyCompromise"}` // no narrower criterion + req := httptest.NewRequest(http.MethodPost, + "/api/v1/est/certificates/bulk-revoke", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + req = req.WithContext(adminContext()) + w := httptest.NewRecorder() + h.BulkRevokeEST(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("empty-criterion status = %d, want 400", w.Code) + } + if !strings.Contains(w.Body.String(), "criterion") { + t.Errorf("error body should mention criterion; got %q", w.Body.String()) + } +} + +func TestBulkRevokeEST_InvalidReason_400(t *testing.T) { + svc := &mockBulkRevocationService{} + h := NewBulkRevocationHandler(svc) + body := `{"reason":"not-a-valid-reason","profile_id":"prof-iot"}` + req := httptest.NewRequest(http.MethodPost, + "/api/v1/est/certificates/bulk-revoke", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + req = req.WithContext(adminContext()) + w := httptest.NewRecorder() + h.BulkRevokeEST(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("invalid-reason status = %d, want 400", w.Code) + } +} + +func TestBulkRevokeEST_MethodNotAllowed(t *testing.T) { + svc := &mockBulkRevocationService{} + h := NewBulkRevocationHandler(svc) + req := httptest.NewRequest(http.MethodGet, "/api/v1/est/certificates/bulk-revoke", nil) + w := httptest.NewRecorder() + h.BulkRevokeEST(w, req) + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("GET against POST-only endpoint status = %d, want 405", w.Code) + } +} diff --git a/internal/api/handler/cisco_ios_quirks_test.go b/internal/api/handler/cisco_ios_quirks_test.go new file mode 100644 index 0000000..dc4c113 --- /dev/null +++ b/internal/api/handler/cisco_ios_quirks_test.go @@ -0,0 +1,132 @@ +package handler + +import ( + "crypto/tls" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/shankar0123/certctl/internal/domain" +) + +// EST RFC 7030 hardening master bundle Phase 10.3 — Cisco IOS quirk +// fixtures. Each fixture is a captured-shape CSR that exercises one +// of the documented IOS wire-format deviations from the EST §4.2.1 +// happy-path; the test pins that ESTHandler.readCSRFromRequest + +// the broader handler pipeline accept each shape without operator +// intervention. +// +// Fixtures live under testdata/cisco_ios_*.txt — kept as plain-text +// copies so a future reader can `cat` them + understand the shape +// without re-deriving from a binary blob. + +// loadCiscoFixture reads the named testdata file. Path-traversal-safe +// because the fixture name is a compile-time constant per call site; +// we keep filepath.Clean for hygiene. +func loadCiscoFixture(t *testing.T, name string) string { + t.Helper() + body, err := os.ReadFile(filepath.Clean(filepath.Join("testdata", name))) + if err != nil { + t.Fatalf("read fixture %q: %v", name, err) + } + return string(body) +} + +// TestESTCiscoIOSQuirk_15xPEMUploadAccepted exercises the documented +// IOS 15.x quirk: the device sends Content-Type `application/x-pem-file` +// (PEM-encoded) instead of the EST §4.2.1 canonical +// `application/pkcs10` (base64-DER). The handler's readCSRFromRequest +// dispatches on body-prefix (`-----BEGIN CERTIFICATE REQUEST-----`) +// rather than Content-Type, so the upload should parse cleanly + the +// service should see a properly-formed CSR. +func TestESTCiscoIOSQuirk_15xPEMUploadAccepted(t *testing.T) { + body := loadCiscoFixture(t, "cisco_ios_15x_pem_csr.txt") + if !strings.HasPrefix(body, "-----BEGIN CERTIFICATE REQUEST-----") { + t.Fatalf("fixture corrupted: expected PEM prefix, got %q", body[:60]) + } + + svc := &mockESTService{EnrollResult: ciscoQuirkOKResult(t)} + h := NewESTHandler(svc) + + req := httptest.NewRequest(http.MethodPost, + "/.well-known/est/corp/simpleenroll", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/x-pem-file") // the IOS 15.x quirk + req.TLS = &tls.ConnectionState{HandshakeComplete: true, Version: tls.VersionTLS13} + w := httptest.NewRecorder() + h.SimpleEnroll(w, req) + + if w.Code != http.StatusOK { + t.Errorf("IOS 15.x PEM upload status = %d, want 200; body=%q", w.Code, w.Body.String()) + } +} + +// TestESTCiscoIOSQuirk_16xTrailingNewlinesAccepted exercises the +// documented IOS 16.x quirk: an extra trailing newline after the +// base64 body. The handler's strings.TrimSpace pass MUST tolerate +// any number of trailing whitespace bytes without surfacing as a +// malformed-CSR rejection. +func TestESTCiscoIOSQuirk_16xTrailingNewlinesAccepted(t *testing.T) { + body := loadCiscoFixture(t, "cisco_ios_16x_trailing_newline_csr.txt") + if !strings.HasSuffix(body, "\n\n\n") && !strings.HasSuffix(body, "\n\n") { + tail := body + if len(tail) > 10 { + tail = body[len(body)-10:] + } + t.Fatalf("fixture corrupted: expected ≥2 trailing newlines; got tail=%q", tail) + } + + svc := &mockESTService{EnrollResult: ciscoQuirkOKResult(t)} + h := NewESTHandler(svc) + + req := httptest.NewRequest(http.MethodPost, + "/.well-known/est/corp/simpleenroll", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/pkcs10") + req.TLS = &tls.ConnectionState{HandshakeComplete: true, Version: tls.VersionTLS13} + w := httptest.NewRecorder() + h.SimpleEnroll(w, req) + + if w.Code != http.StatusOK { + t.Errorf("IOS 16.x trailing-newlines status = %d, want 200; body=%q", w.Code, w.Body.String()) + } +} + +// TestESTCiscoIOSQuirk_CRLFBase64Accepted exercises the documented +// CRLF-line-ending quirk. Some IOS versions emit base64-DER with +// CRLF wrapping (the RFC 2045 §6.8 wire shape) rather than bare LF +// (the JSON-via-curl shape). The handler must strip both CRLF + LF +// before passing to base64.StdEncoding.DecodeString. +func TestESTCiscoIOSQuirk_CRLFBase64Accepted(t *testing.T) { + body := loadCiscoFixture(t, "cisco_ios_crlf_b64_csr.txt") + if !strings.Contains(body, "\r\n") { + t.Fatalf("fixture corrupted: expected CRLF-wrapped body; first 80 = %q", body[:80]) + } + + svc := &mockESTService{EnrollResult: ciscoQuirkOKResult(t)} + h := NewESTHandler(svc) + + req := httptest.NewRequest(http.MethodPost, + "/.well-known/est/corp/simpleenroll", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/pkcs10") + req.TLS = &tls.ConnectionState{HandshakeComplete: true, Version: tls.VersionTLS13} + w := httptest.NewRecorder() + h.SimpleEnroll(w, req) + + if w.Code != http.StatusOK { + t.Errorf("CRLF-wrapped base64 status = %d, want 200; body=%q", w.Code, w.Body.String()) + } +} + +// ciscoQuirkOKResult is the service-side response the mock returns for +// every Cisco-quirk happy-path test. The cert content doesn't matter — +// what matters is that the handler reaches the service call (i.e. it +// successfully parsed the CSR), so we hand back a hard-coded EC cert +// PEM that pkcs7.PEMToDERChain accepts cleanly. +func ciscoQuirkOKResult(t *testing.T) *domain.ESTEnrollResult { + t.Helper() + return &domain.ESTEnrollResult{ + CertPEM: "-----BEGIN CERTIFICATE-----\nMIIBnDCCAUOgAwIBAgIBATAKBggqhkjOPQQDAjAUMRIwEAYDVQQDDAljaXNjby10\nZXN0MB4XDTI1MDEwMTAwMDAwMFoXDTM1MTIzMTAwMDAwMFowFDESMBAGA1UEAwwJ\nY2lzY28tdGVzdDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABAfNh1+nAo15qVMF\nh0w4EQfHBn5zQgEDLkJhpZ+9PqJkgqdSwJgC+4Ah+UWrJOO6+P9YOPXqkSQU0E2X\n3/Ms2DyjUzBRMB0GA1UdDgQWBBSm1U4Fmh4j9eJDVa8qBOrkxqLhajAfBgNVHSME\nGDAWgBSm1U4Fmh4j9eJDVa8qBOrkxqLhajAPBgNVHRMBAf8EBTADAQH/MAoGCCqG\nSM49BAMCA0gAMEUCIQCY7d0XHVz7AmAFZrYTIVFmRn/PV+0qRu9HSqwvU1HYNgIg\nXKJM6e/0ckLhqLGB1lN9Bz/cvyZuYIcHLgMrlvNUwYE=\n-----END CERTIFICATE-----\n", + } +} diff --git a/internal/api/handler/testdata/cisco_ios_15x_pem_csr.txt b/internal/api/handler/testdata/cisco_ios_15x_pem_csr.txt new file mode 100644 index 0000000..42d1d51 --- /dev/null +++ b/internal/api/handler/testdata/cisco_ios_15x_pem_csr.txt @@ -0,0 +1,8 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIIBHDCBwwIBADAnMSUwIwYDVQQDExxkZXZpY2UtY2lzY28tMTV4LmV4YW1wbGUu +Y29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEBfqE3v4r/07DDezeXNHXFPsn +YvmAD8mpnlCZ1Pa8pXUDSxxfHZ9m/JHoXc+3/8c600ZP+IMaP2NZQba+lo53rKA6 +MDgGCSqGSIb3DQEJDjErMCkwJwYDVR0RBCAwHoIcZGV2aWNlLWNpc2NvLTE1eC5l +eGFtcGxlLmNvbTAKBggqhkjOPQQDAgNIADBFAiEA75uwUhlbytlHRADC84bwz4uc +X7OG5SwpWLx8lqIt304CIDsYVz0CaWKklgyVHA5E2EkTA83p/fsqooycE+81jhiy +-----END CERTIFICATE REQUEST----- diff --git a/internal/api/handler/testdata/cisco_ios_16x_trailing_newline_csr.txt b/internal/api/handler/testdata/cisco_ios_16x_trailing_newline_csr.txt new file mode 100644 index 0000000..ffda298 --- /dev/null +++ b/internal/api/handler/testdata/cisco_ios_16x_trailing_newline_csr.txt @@ -0,0 +1,3 @@ +MIIBHDCBwwIBADAnMSUwIwYDVQQDExxkZXZpY2UtY2lzY28tMTZ4LmV4YW1wbGUuY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEKKkWJlc/Ew/iM/B1PB7PgceMAG4lXj15LvlNQzZTF8yz4WyeGxzlQFrADQm5Ufhihir+syBUuUR356Ov7vS4r6A6MDgGCSqGSIb3DQEJDjErMCkwJwYDVR0RBCAwHoIcZGV2aWNlLWNpc2NvLTE2eC5leGFtcGxlLmNvbTAKBggqhkjOPQQDAgNIADBFAiEA21LN5VSneM+2hyN2K1YOzPpkmzNkAHu2ff8DBNzhqjQCIDe5NnSaNa7TzxTQAXsRUJoOITllKgCaNyZptTKZcTII + + diff --git a/internal/api/handler/testdata/cisco_ios_crlf_b64_csr.txt b/internal/api/handler/testdata/cisco_ios_crlf_b64_csr.txt new file mode 100644 index 0000000..bb855e7 --- /dev/null +++ b/internal/api/handler/testdata/cisco_ios_crlf_b64_csr.txt @@ -0,0 +1,7 @@ +MIIBHTCBxQIBADAoMSYwJAYDVQQDEx1kZXZpY2UtY2lzY28tY3JsZi5leGFtcGxl +LmNvbTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJdkH3YYwI7NmFW5z8pRWaSN +RprlyI8aqn7GX1Z+qcBwmvskW5Y21VsQGQlYHYb/sIIXHRr+uAigNVhnlQf+ShWg +OzA5BgkqhkiG9w0BCQ4xLDAqMCgGA1UdEQQhMB+CHWRldmljZS1jaXNjby1jcmxm +LmV4YW1wbGUuY29tMAoGCCqGSM49BAMCA0cAMEQCIEbYyU5slKbF/HmTqywElydE +1K5785vZo7bngwBSpwBsAiANMZhP1NykOfyyN1rM4v3jrisTq/u4i3QHNOnVgHN1 +7Q== diff --git a/internal/api/router/router.go b/internal/api/router/router.go index cc9edad..f298d8b 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -188,6 +188,10 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) { // errors[]} out). L-1 master added bulk-renew + bulk-reassign // alongside the pre-existing bulk-revoke. r.Register("POST /api/v1/certificates/bulk-revoke", http.HandlerFunc(reg.BulkRevocation.BulkRevoke)) + // EST RFC 7030 hardening Phase 11.2 — Source-scoped EST bulk-revoke. + // Same handler instance + same admin gate; the BulkRevokeEST method + // pins Source=EST so the operation only affects EST-issued certs. + r.Register("POST /api/v1/est/certificates/bulk-revoke", http.HandlerFunc(reg.BulkRevocation.BulkRevokeEST)) r.Register("POST /api/v1/certificates/bulk-renew", http.HandlerFunc(reg.BulkRenewal.BulkRenew)) r.Register("POST /api/v1/certificates/bulk-reassign", http.HandlerFunc(reg.BulkReassignment.BulkReassign)) r.Register("GET /api/v1/certificates", http.HandlerFunc(reg.Certificates.ListCertificates)) diff --git a/internal/domain/certificate.go b/internal/domain/certificate.go index eceffd8..b7ef335 100644 --- a/internal/domain/certificate.go +++ b/internal/domain/certificate.go @@ -26,8 +26,41 @@ type ManagedCertificate struct { RevocationReason string `json:"revocation_reason,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` + + // Source tags how this managed certificate was created. EST RFC 7030 + // hardening master bundle Phase 11.1 — operators bulk-revoke + // EST-issued certs by filtering on Source=EST. Empty value preserves + // the v2.X.0 behavior (the bulk-revoke handler treats empty as + // equivalent to legacy/manual; new EST issuances stamp Source=EST, + // new SCEP issuances will eventually stamp Source=SCEP under a + // future bundle). + Source CertificateSource `json:"source,omitempty"` } +// CertificateSource is the enum of provenance values stamped on each +// managed-certificate row when it's created. The empty string is the +// back-compat default — pre-Phase-11 rows have it set to "" by the +// migration's DEFAULT clause; the bulk-revoke filter treats empty as +// "any source" so existing call paths see no behavior change. +// +// EST RFC 7030 hardening master bundle Phase 11.1. +type CertificateSource string + +const ( + // CertificateSourceUnspecified preserves the v2.X.0 default (""). + CertificateSourceUnspecified CertificateSource = "" + // CertificateSourceEST stamps every cert issued through one of the + // EST endpoints (simpleenroll / simplereenroll / serverkeygen). + CertificateSourceEST CertificateSource = "EST" + // CertificateSourceSCEP / API / Agent reserve future provenance + // values — not stamped today; SCEP-issued certs continue to land + // with Source="" until a follow-up bundle wires the stamp at the + // SCEP service layer. + CertificateSourceSCEP CertificateSource = "SCEP" + CertificateSourceAPI CertificateSource = "API" + CertificateSourceAgent CertificateSource = "Agent" +) + // CertificateVersion represents a specific version of a certificate. type CertificateVersion struct { ID string `json:"id"` diff --git a/internal/domain/revocation.go b/internal/domain/revocation.go index 80735ff..8779c44 100644 --- a/internal/domain/revocation.go +++ b/internal/domain/revocation.go @@ -52,9 +52,20 @@ type BulkRevocationCriteria struct { IssuerID string `json:"issuer_id,omitempty"` TeamID string `json:"team_id,omitempty"` CertificateIDs []string `json:"certificate_ids,omitempty"` + // Source filters by ManagedCertificate.Source provenance value. + // Empty matches any source (back-compat with v2.X.0 callers); the + // EST bulk-revoke endpoint pins this to CertificateSourceEST so an + // operator hitting POST /api/v1/est/certificates/bulk-revoke only + // affects EST-issued certs, never SCEP/API/Agent-provisioned ones. + // + // EST RFC 7030 hardening master bundle Phase 11.2. + Source CertificateSource `json:"source,omitempty"` } -// IsEmpty returns true if no filter criteria are set. +// IsEmpty returns true if no filter criteria are set. Source alone does +// NOT count as a criterion — a Source=EST request without any narrower +// criterion (profile_id, owner_id, etc.) is rejected as too broad, +// because it would revoke EVERY EST-issued cert in the deployment. func (c BulkRevocationCriteria) IsEmpty() bool { return c.ProfileID == "" && c.OwnerID == "" && c.AgentID == "" && c.IssuerID == "" && c.TeamID == "" && len(c.CertificateIDs) == 0 @@ -62,11 +73,11 @@ func (c BulkRevocationCriteria) IsEmpty() bool { // BulkRevocationResult contains the outcome of a bulk revocation operation. type BulkRevocationResult struct { - TotalMatched int `json:"total_matched"` - TotalRevoked int `json:"total_revoked"` - TotalSkipped int `json:"total_skipped"` - TotalFailed int `json:"total_failed"` - Errors []BulkRevocationError `json:"errors,omitempty"` + TotalMatched int `json:"total_matched"` + TotalRevoked int `json:"total_revoked"` + TotalSkipped int `json:"total_skipped"` + TotalFailed int `json:"total_failed"` + Errors []BulkRevocationError `json:"errors,omitempty"` } // BulkRevocationError records a per-certificate revocation failure. diff --git a/internal/repository/postgres/certificate.go b/internal/repository/postgres/certificate.go index 5845e26..df3ea94 100644 --- a/internal/repository/postgres/certificate.go +++ b/internal/repository/postgres/certificate.go @@ -179,7 +179,7 @@ func (r *CertificateRepository) List(ctx context.Context, filter *repository.Cer query := fmt.Sprintf(` SELECT id, name, common_name, sans, environment, owner_id, team_id, issuer_id, renewal_policy_id, - certificate_profile_id, status, expires_at, tags, last_renewal_at, last_deployment_at, revoked_at, revocation_reason, created_at, updated_at + certificate_profile_id, status, expires_at, tags, last_renewal_at, last_deployment_at, revoked_at, revocation_reason, source, created_at, updated_at FROM managed_certificates %s ORDER BY %s %s @@ -200,13 +200,14 @@ func (r *CertificateRepository) List(ctx context.Context, filter *repository.Cer var sans pq.StringArray var profileID sql.NullString var revocationReason sql.NullString + var source sql.NullString err := rows.Scan( &cert.ID, &cert.Name, &cert.CommonName, &sans, &cert.Environment, &cert.OwnerID, &cert.TeamID, &cert.IssuerID, &cert.RenewalPolicyID, &profileID, &cert.Status, &cert.ExpiresAt, &tagsJSON, &cert.LastRenewalAt, &cert.LastDeploymentAt, &cert.RevokedAt, &revocationReason, - &cert.CreatedAt, &cert.UpdatedAt) + &source, &cert.CreatedAt, &cert.UpdatedAt) if err != nil { return nil, 0, fmt.Errorf("failed to scan certificate: %w", err) @@ -219,6 +220,10 @@ func (r *CertificateRepository) List(ctx context.Context, filter *repository.Cer if revocationReason.Valid { cert.RevocationReason = revocationReason.String } + // Phase 11.1: source column. + if source.Valid { + cert.Source = domain.CertificateSource(source.String) + } // Unmarshal tags if len(tagsJSON) > 0 { @@ -259,7 +264,7 @@ func (r *CertificateRepository) List(ctx context.Context, filter *repository.Cer func (r *CertificateRepository) Get(ctx context.Context, id string) (*domain.ManagedCertificate, error) { row := r.db.QueryRowContext(ctx, ` SELECT id, name, common_name, sans, environment, owner_id, team_id, issuer_id, renewal_policy_id, - certificate_profile_id, status, expires_at, tags, last_renewal_at, last_deployment_at, revoked_at, revocation_reason, created_at, updated_at + certificate_profile_id, status, expires_at, tags, last_renewal_at, last_deployment_at, revoked_at, revocation_reason, source, created_at, updated_at FROM managed_certificates WHERE id = $1 `, id) @@ -286,7 +291,7 @@ func (r *CertificateRepository) GetByIssuerAndSerial(ctx context.Context, issuer row := r.db.QueryRowContext(ctx, ` SELECT mc.id, mc.name, mc.common_name, mc.sans, mc.environment, mc.owner_id, mc.team_id, mc.issuer_id, mc.renewal_policy_id, mc.certificate_profile_id, mc.status, mc.expires_at, - mc.tags, mc.last_renewal_at, mc.last_deployment_at, mc.revoked_at, mc.revocation_reason, + mc.tags, mc.last_renewal_at, mc.last_deployment_at, mc.revoked_at, mc.revocation_reason, mc.source, mc.created_at, mc.updated_at FROM managed_certificates mc JOIN certificate_versions cv ON cv.certificate_id = mc.id @@ -331,14 +336,14 @@ func (r *CertificateRepository) Create(ctx context.Context, cert *domain.Managed err = r.db.QueryRowContext(ctx, ` INSERT INTO managed_certificates ( id, name, common_name, sans, environment, owner_id, team_id, issuer_id, renewal_policy_id, - certificate_profile_id, status, expires_at, tags, last_renewal_at, last_deployment_at, revoked_at, revocation_reason, created_at, updated_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) + certificate_profile_id, status, expires_at, tags, last_renewal_at, last_deployment_at, revoked_at, revocation_reason, source, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20) RETURNING id `, cert.ID, cert.Name, cert.CommonName, pq.Array(cert.SANs), cert.Environment, cert.OwnerID, cert.TeamID, cert.IssuerID, cert.RenewalPolicyID, profileID, cert.Status, cert.ExpiresAt, tagsJSON, cert.LastRenewalAt, cert.LastDeploymentAt, - cert.RevokedAt, revocationReason, + cert.RevokedAt, revocationReason, string(cert.Source), cert.CreatedAt, cert.UpdatedAt).Scan(&cert.ID) if err != nil { @@ -382,12 +387,13 @@ func (r *CertificateRepository) Update(ctx context.Context, cert *domain.Managed last_deployment_at = $13, revoked_at = $14, revocation_reason = $15, - updated_at = $16 - WHERE id = $17 + source = $16, + updated_at = $17 + WHERE id = $18 `, cert.Name, cert.CommonName, pq.Array(cert.SANs), cert.Environment, cert.OwnerID, cert.TeamID, cert.IssuerID, profileID, cert.Status, cert.ExpiresAt, tagsJSON, cert.LastRenewalAt, cert.LastDeploymentAt, - cert.RevokedAt, revocationReason, cert.UpdatedAt, cert.ID) + cert.RevokedAt, revocationReason, string(cert.Source), cert.UpdatedAt, cert.ID) if err != nil { return fmt.Errorf("failed to update certificate: %w", err) @@ -491,7 +497,7 @@ func (r *CertificateRepository) CreateVersion(ctx context.Context, version *doma func (r *CertificateRepository) GetExpiringCertificates(ctx context.Context, before time.Time) ([]*domain.ManagedCertificate, error) { rows, err := r.db.QueryContext(ctx, ` SELECT id, name, common_name, sans, environment, owner_id, team_id, issuer_id, renewal_policy_id, - certificate_profile_id, status, expires_at, tags, last_renewal_at, last_deployment_at, revoked_at, revocation_reason, created_at, updated_at + certificate_profile_id, status, expires_at, tags, last_renewal_at, last_deployment_at, revoked_at, revocation_reason, source, created_at, updated_at FROM managed_certificates WHERE expires_at < $1 AND status != $2 ORDER BY expires_at ASC @@ -510,13 +516,14 @@ func (r *CertificateRepository) GetExpiringCertificates(ctx context.Context, bef var sans pq.StringArray var profileID sql.NullString var revocationReason sql.NullString + var source sql.NullString err := rows.Scan( &cert.ID, &cert.Name, &cert.CommonName, &sans, &cert.Environment, &cert.OwnerID, &cert.TeamID, &cert.IssuerID, &cert.RenewalPolicyID, &profileID, &cert.Status, &cert.ExpiresAt, &tagsJSON, &cert.LastRenewalAt, &cert.LastDeploymentAt, &cert.RevokedAt, &revocationReason, - &cert.CreatedAt, &cert.UpdatedAt) + &source, &cert.CreatedAt, &cert.UpdatedAt) if err != nil { return nil, fmt.Errorf("failed to scan certificate: %w", err) @@ -529,6 +536,9 @@ func (r *CertificateRepository) GetExpiringCertificates(ctx context.Context, bef if revocationReason.Valid { cert.RevocationReason = revocationReason.String } + if source.Valid { + cert.Source = domain.CertificateSource(source.String) + } // Unmarshal tags if len(tagsJSON) > 0 { @@ -668,13 +678,14 @@ func (r *CertificateRepository) scanCertificate(ctx context.Context, scanner int var sans pq.StringArray var profileID sql.NullString var revocationReason sql.NullString + var source sql.NullString err := scanner.Scan( &cert.ID, &cert.Name, &cert.CommonName, &sans, &cert.Environment, &cert.OwnerID, &cert.TeamID, &cert.IssuerID, &cert.RenewalPolicyID, &profileID, &cert.Status, &cert.ExpiresAt, &tagsJSON, &cert.LastRenewalAt, &cert.LastDeploymentAt, &cert.RevokedAt, &revocationReason, - &cert.CreatedAt, &cert.UpdatedAt) + &source, &cert.CreatedAt, &cert.UpdatedAt) if err != nil { return nil, fmt.Errorf("failed to scan certificate: %w", err) @@ -687,6 +698,12 @@ func (r *CertificateRepository) scanCertificate(ctx context.Context, scanner int if revocationReason.Valid { cert.RevocationReason = revocationReason.String } + // Phase 11.1: source column ships with default '' so every existing + // row scans into CertificateSourceUnspecified ("") — back-compat for + // the bulk-revoke filter, which treats empty as "any source". + if source.Valid { + cert.Source = domain.CertificateSource(source.String) + } // Unmarshal tags if len(tagsJSON) > 0 { diff --git a/internal/service/bulk_revocation.go b/internal/service/bulk_revocation.go index be4d1f8..c56c187 100644 --- a/internal/service/bulk_revocation.go +++ b/internal/service/bulk_revocation.go @@ -151,7 +151,24 @@ func (s *BulkRevocationService) resolveCertificates(ctx context.Context, criteri filtered = append(filtered, cert) } } - return filtered, nil + certs = filtered + } + + // EST RFC 7030 hardening master bundle Phase 11.2: per-source + // post-filter. Empty Source matches anything (back-compat); a + // non-empty Source narrows the result set to only certs stamped + // with that provenance value. Filter is applied here rather than + // in the SQL query so existing CertificateFilter callers are + // unaffected; the small per-cert pass is fine because bulk-revoke + // is already a low-frequency operation. + if criteria.Source != "" { + var bySource []*domain.ManagedCertificate + for _, cert := range certs { + if cert.Source == criteria.Source { + bySource = append(bySource, cert) + } + } + certs = bySource } return certs, nil diff --git a/internal/service/est.go b/internal/service/est.go index 011b9c1..a4aa783 100644 --- a/internal/service/est.go +++ b/internal/service/est.go @@ -88,15 +88,23 @@ func (s *ESTService) GetCACerts(ctx context.Context) (string, error) { // SimpleEnroll processes an initial enrollment request. // RFC 7030 Section 4.2: /simpleenroll accepts a PKCS#10 CSR and returns a signed cert. +// +// Phase 11.3: typed audit codes — the inner processEnrollment emits +// `est_simple_enroll_success` on success + `est_simple_enroll_failed` +// on any rejection. The legacy bare `est_simple_enroll` is retained +// for back-compat (the GUI's activity-tab chip-filter matches by +// prefix so both shapes render under the same chip). func (s *ESTService) SimpleEnroll(ctx context.Context, csrPEM string) (*domain.ESTEnrollResult, error) { - return s.processEnrollment(ctx, csrPEM, "est_simple_enroll") + return s.processEnrollment(ctx, csrPEM, "est_simple_enroll", + AuditActionESTSimpleEnrollSuccess, AuditActionESTSimpleEnrollFailed) } // SimpleReEnroll processes a re-enrollment request. // RFC 7030 Section 4.2.2: /simplereenroll is functionally identical to /simpleenroll // but is used when renewing an existing certificate. func (s *ESTService) SimpleReEnroll(ctx context.Context, csrPEM string) (*domain.ESTEnrollResult, error) { - return s.processEnrollment(ctx, csrPEM, "est_simple_reenroll") + return s.processEnrollment(ctx, csrPEM, "est_simple_reenroll", + AuditActionESTSimpleReEnrollSuccess, AuditActionESTSimpleReEnrollFailed) } // GetCSRAttrs returns the CSR attributes the server wants clients to include. @@ -180,28 +188,58 @@ func (s *ESTService) GetCSRAttrs(ctx context.Context) ([]byte, error) { } // processEnrollment handles the common enrollment logic for both simpleenroll and simplereenroll. -func (s *ESTService) processEnrollment(ctx context.Context, csrPEM string, auditAction string) (*domain.ESTEnrollResult, error) { +// +// Phase 11.3 split-emit: every audit RecordEvent call goes to BOTH the +// legacy bare action code (auditAction param, e.g. "est_simple_enroll") +// AND the typed success/failed code (typedSuccess / typedFailed params) +// so existing GUI activity-tab chip filters stay green while operators +// gain the typed grep surface. +func (s *ESTService) processEnrollment(ctx context.Context, csrPEM, auditAction, typedSuccess, typedFailed string) (*domain.ESTEnrollResult, error) { + // emitFailed is the in-line helper that records BOTH the bare + + // typed failed-event so every error path stays one-liner. Returns + // the input err verbatim so call sites stay one-shot. + emitFailed := func(reason string, err error) { + if s.auditService == nil { + return + } + details := map[string]interface{}{ + "reason": reason, + "error": err.Error(), + "protocol": "EST", + "issuer_id": s.issuerID, + } + if s.profileID != "" { + details["profile_id"] = s.profileID + } + _ = s.auditService.RecordEvent(ctx, "est-client", "system", auditAction+"_failed", "certificate", "", details) + _ = s.auditService.RecordEvent(ctx, "est-client", "system", typedFailed, "certificate", "", details) + } + _ = emitFailed // referenced inside the body below // Parse the CSR to extract CN and SANs block, _ := pem.Decode([]byte(csrPEM)) if block == nil { s.counters.inc(estCounterCSRInvalid) + emitFailed("csr_pem_decode", fmt.Errorf("invalid CSR PEM")) return nil, fmt.Errorf("invalid CSR PEM") } csr, err := x509.ParseCertificateRequest(block.Bytes) if err != nil { s.counters.inc(estCounterCSRInvalid) + emitFailed("csr_parse", err) return nil, fmt.Errorf("failed to parse CSR: %w", err) } if err := csr.CheckSignature(); err != nil { s.counters.inc(estCounterCSRSignatureMismatch) + emitFailed("csr_signature", err) return nil, fmt.Errorf("CSR signature verification failed: %w", err) } commonName := csr.Subject.CommonName if commonName == "" { s.counters.inc(estCounterCSRInvalid) + emitFailed("csr_missing_cn", fmt.Errorf("missing CN")) return nil, fmt.Errorf("CSR must include a Common Name") } @@ -231,6 +269,15 @@ func (s *ESTService) processEnrollment(ctx context.Context, csrPEM string, audit } if _, csrErr := ValidateCSRAgainstProfile(csrPEM, profile); csrErr != nil { s.counters.inc(estCounterCSRPolicyViolation) + // Emit BOTH the typed-failed code (for the Activity tab) AND + // the standalone est_csr_policy_violation code (for the + // per-failure-mode counter that ops greppers prefer). + emitFailed("csr_policy_violation", csrErr) + if s.auditService != nil { + _ = s.auditService.RecordEvent(ctx, "est-client", "system", + AuditActionESTCSRPolicyViolation, "certificate", "", + map[string]interface{}{"error": csrErr.Error(), "issuer_id": s.issuerID, "profile_id": s.profileID}) + } s.logger.Error("EST enrollment rejected: crypto policy violation", "action", auditAction, "common_name", commonName, @@ -262,6 +309,7 @@ func (s *ESTService) processEnrollment(ctx context.Context, csrPEM string, audit result, err := s.issuer.IssueCertificate(ctx, commonName, sans, csrPEM, ekus, maxTTLSeconds, mustStaple) if err != nil { s.counters.inc(estCounterIssuerError) + emitFailed("issuer_error", err) s.logger.Error("EST enrollment failed", "action", auditAction, "common_name", commonName, @@ -276,7 +324,10 @@ func (s *ESTService) processEnrollment(ctx context.Context, csrPEM string, audit s.counters.inc(estCounterSuccessSimpleEnroll) } - // Audit the enrollment + // Audit the enrollment — split-emit per Phase 11.3: legacy bare + // action code (back-compat for the GUI activity tab + existing + // audit-log analysers) + typed _success suffix variant + the + // canonical typed code from the AuditAction* constants. if s.auditService != nil { details := map[string]interface{}{ "common_name": commonName, @@ -289,6 +340,7 @@ func (s *ESTService) processEnrollment(ctx context.Context, csrPEM string, audit details["profile_id"] = s.profileID } _ = s.auditService.RecordEvent(ctx, "est-client", "system", auditAction, "certificate", result.Serial, details) + _ = s.auditService.RecordEvent(ctx, "est-client", "system", typedSuccess, "certificate", result.Serial, details) } s.logger.Info("EST enrollment successful", @@ -524,6 +576,8 @@ func (s *ESTService) SimpleServerKeygen(ctx context.Context, csrPEM string) (*ES details["profile_id"] = s.profileID } _ = s.auditService.RecordEvent(ctx, "est-client", "system", "est_server_keygen", "certificate", issued.Serial, details) + // Phase 11.3: typed _success suffix for the operator grep surface. + _ = s.auditService.RecordEvent(ctx, "est-client", "system", AuditActionESTServerKeygenSuccess, "certificate", issued.Serial, details) } s.logger.Info("EST serverkeygen successful", "common_name", commonName, "serial", issued.Serial, diff --git a/internal/service/est_audit_actions.go b/internal/service/est_audit_actions.go new file mode 100644 index 0000000..4a44df4 --- /dev/null +++ b/internal/service/est_audit_actions.go @@ -0,0 +1,40 @@ +package service + +// EST RFC 7030 hardening master bundle Phase 11.3 — typed audit action +// codes. Each maps to a unique counter label so operators grep the +// audit log on these exact strings. +// +// Naming contract: every code is `est__` where +// +// = simple_enroll | simple_reenroll | server_keygen | auth_failed_ | rate_limited | csr_policy_violation | bulk_revoke | trust_anchor_reloaded +// = success | failed (only on the three success/failure-paired flows) +// +// Pre-Phase-11 the audit log carried bare action codes (est_simple_enroll +// without the _success suffix). The GUI activity-tab filter chips +// (web/src/pages/ESTAdminPage.tsx) match by `startsWith()` after the +// Phase 11 cutover so both old + new strings continue to render under +// the right chip. +const ( + // Three success/failure-paired enrollment flows. The success codes + // share a prefix with the legacy bare codes so a deployment running + // the old audit-log analyser continues to find every enrollment. + AuditActionESTSimpleEnrollSuccess = "est_simple_enroll_success" + AuditActionESTSimpleEnrollFailed = "est_simple_enroll_failed" + AuditActionESTSimpleReEnrollSuccess = "est_simple_reenroll_success" + AuditActionESTSimpleReEnrollFailed = "est_simple_reenroll_failed" + AuditActionESTServerKeygenSuccess = "est_server_keygen_success" + AuditActionESTServerKeygenFailed = "est_server_keygen_failed" + + // Per-mode auth-failure codes. Emitted by the handler at the auth- + // gate trip points so operators can filter "Basic-auth failures + // from this source IP" cleanly. + AuditActionESTAuthFailedBasic = "est_auth_failed_basic" + AuditActionESTAuthFailedMTLS = "est_auth_failed_mtls" + AuditActionESTAuthFailedChannelBinding = "est_auth_failed_channel_binding" + + // Operational events. + AuditActionESTRateLimited = "est_rate_limited" + AuditActionESTCSRPolicyViolation = "est_csr_policy_violation" + AuditActionESTBulkRevoke = "est_bulk_revoke" + AuditActionESTTrustAnchorReloaded = "est_trust_anchor_reloaded" +) diff --git a/internal/service/est_audit_actions_test.go b/internal/service/est_audit_actions_test.go new file mode 100644 index 0000000..3883250 --- /dev/null +++ b/internal/service/est_audit_actions_test.go @@ -0,0 +1,156 @@ +package service + +import ( + "context" + "errors" + "io" + "log/slog" + "strings" + "testing" + + "github.com/shankar0123/certctl/internal/domain" +) + +// EST RFC 7030 hardening master bundle Phase 11.4 — audit-code assertions. +// Drive each code path through a real ESTService instance + assert the +// typed action codes land in the audit log alongside the legacy bare +// codes (back-compat preservation). + +func newAuditAssertService(t *testing.T) (*ESTService, *mockAuditRepo) { + t.Helper() + auditRepo := newMockAuditRepository() + auditSvc := NewAuditService(auditRepo) + silent := slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError + 10})) + svc := NewESTService("iss-corp", &mockIssuerConnector{}, auditSvc, silent) + return svc, auditRepo +} + +// auditActions returns the action codes recorded across every audit +// event in the repo, in emission order. Used to assert that the +// typed _success / _failed events fire in the right order alongside +// the legacy bare codes. +func auditActions(repo *mockAuditRepo) []string { + out := make([]string, 0, len(repo.Events)) + for _, e := range repo.Events { + out = append(out, e.Action) + } + return out +} + +func TestESTAudit_SimpleEnrollSuccess_EmitsLegacyAndTyped(t *testing.T) { + svc, repo := newAuditAssertService(t) + csrPEM := generateCSRPEM(t, "device.example.com", []string{"device.example.com"}) + if _, err := svc.SimpleEnroll(context.Background(), csrPEM); err != nil { + t.Fatalf("SimpleEnroll: %v", err) + } + got := auditActions(repo) + wantBare := "est_simple_enroll" + wantTyped := AuditActionESTSimpleEnrollSuccess // est_simple_enroll_success + if !stringSliceContains(got, wantBare) { + t.Errorf("missing legacy bare code %q in %v", wantBare, got) + } + if !stringSliceContains(got, wantTyped) { + t.Errorf("missing typed code %q in %v", wantTyped, got) + } +} + +func TestESTAudit_SimpleReEnrollSuccess_EmitsTyped(t *testing.T) { + svc, repo := newAuditAssertService(t) + csrPEM := generateCSRPEM(t, "device.example.com", nil) + if _, err := svc.SimpleReEnroll(context.Background(), csrPEM); err != nil { + t.Fatalf("SimpleReEnroll: %v", err) + } + if !stringSliceContains(auditActions(repo), AuditActionESTSimpleReEnrollSuccess) { + t.Errorf("missing %q; got %v", AuditActionESTSimpleReEnrollSuccess, auditActions(repo)) + } +} + +func TestESTAudit_IssuerError_EmitsTypedFailed(t *testing.T) { + auditRepo := newMockAuditRepository() + auditSvc := NewAuditService(auditRepo) + silent := slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError + 10})) + svc := NewESTService("iss-corp", &mockIssuerConnector{Err: errors.New("CA down")}, auditSvc, silent) + csrPEM := generateCSRPEM(t, "device.example.com", nil) + if _, err := svc.SimpleEnroll(context.Background(), csrPEM); err == nil { + t.Fatal("expected enroll error") + } + if !stringSliceContains(auditActions(auditRepo), AuditActionESTSimpleEnrollFailed) { + t.Errorf("missing typed failure code; got %v", auditActions(auditRepo)) + } + // And the bare _failed variant for back-compat: + if !stringSliceContains(auditActions(auditRepo), "est_simple_enroll_failed") { + t.Errorf("missing bare _failed variant; got %v", auditActions(auditRepo)) + } +} + +func TestESTAudit_PolicyViolation_EmitsTypedAndStandalone(t *testing.T) { + svc, repo := newAuditAssertService(t) + repoMock := newMockProfileRepository() + svc.SetProfileRepo(repoMock) + svc.SetProfileID("prof-tight") + repoMock.AddProfile(&domain.CertificateProfile{ + ID: "prof-tight", + Name: "tight", + AllowedKeyAlgorithms: []domain.KeyAlgorithmRule{{Algorithm: "RSA", MinSize: 4096}}, // ECDSA-P256 CSR fails + Enabled: true, + }) + csrPEM := generateCSRPEM(t, "device.example.com", nil) // ECDSA-P256 + if _, err := svc.SimpleEnroll(context.Background(), csrPEM); err == nil { + t.Fatal("expected policy violation error") + } + got := auditActions(repo) + if !stringSliceContains(got, AuditActionESTCSRPolicyViolation) { + t.Errorf("missing standalone policy-violation code %q; got %v", AuditActionESTCSRPolicyViolation, got) + } + if !stringSliceContains(got, AuditActionESTSimpleEnrollFailed) { + t.Errorf("missing typed failed code; got %v", got) + } +} + +func TestESTAudit_AuditCodesAreUniqueStrings(t *testing.T) { + // Tiny invariant test: every audit-action constant is a non-empty + // distinct string. Prevents a future cut-paste typo where two + // constants share the same value. + codes := []string{ + AuditActionESTSimpleEnrollSuccess, + AuditActionESTSimpleEnrollFailed, + AuditActionESTSimpleReEnrollSuccess, + AuditActionESTSimpleReEnrollFailed, + AuditActionESTServerKeygenSuccess, + AuditActionESTServerKeygenFailed, + AuditActionESTAuthFailedBasic, + AuditActionESTAuthFailedMTLS, + AuditActionESTAuthFailedChannelBinding, + AuditActionESTRateLimited, + AuditActionESTCSRPolicyViolation, + AuditActionESTBulkRevoke, + AuditActionESTTrustAnchorReloaded, + } + seen := map[string]bool{} + for _, c := range codes { + if c == "" { + t.Errorf("empty audit-action constant") + } + if !strings.HasPrefix(c, "est_") { + t.Errorf("audit-action constant %q must start with est_", c) + } + if seen[c] { + t.Errorf("duplicate audit-action constant: %q", c) + } + seen[c] = true + } +} + +func stringSliceContains(haystack []string, needle string) bool { + for _, s := range haystack { + if s == needle { + return true + } + } + return false +} + +// silenceUnusedDomain keeps the domain import live when the policy- +// violation test compiles even if a future refactor removes the only +// reference site. +var _ domain.CertificateProfile diff --git a/internal/service/est_counters.go b/internal/service/est_counters.go index 6ca8c98..f007abd 100644 --- a/internal/service/est_counters.go +++ b/internal/service/est_counters.go @@ -1,6 +1,7 @@ package service import ( + "context" "sync/atomic" "time" @@ -174,11 +175,27 @@ func (s *ESTService) Stats(now time.Time) ESTStatsSnapshot { // // Returns ErrESTMTLSDisabled when the profile doesn't have an mTLS // trust anchor configured (admin handler maps to HTTP 409). +// +// Phase 11.3: emits AuditActionESTTrustAnchorReloaded on successful +// reload so operators have a typed grep target for "who rotated the +// trust bundle for which profile + when". func (s *ESTService) ReloadTrust() error { if s.estTrustAnchor == nil { return ErrESTMTLSDisabled } - return s.estTrustAnchor.Reload() + if err := s.estTrustAnchor.Reload(); err != nil { + return err + } + if s.auditService != nil { + details := map[string]interface{}{ + "path_id": s.estPathIDForLog, + "trust_anchor_path": s.estTrustAnchor.Path(), + "protocol": "EST", + } + _ = s.auditService.RecordEvent(context.Background(), "est-admin", "system", + AuditActionESTTrustAnchorReloaded, "trust_anchor", s.estPathIDForLog, details) + } + return nil } // ErrESTMTLSDisabled signals the admin handler that an EST profile diff --git a/migrations/000023_managed_certificates_source.down.sql b/migrations/000023_managed_certificates_source.down.sql new file mode 100644 index 0000000..1cf55e8 --- /dev/null +++ b/migrations/000023_managed_certificates_source.down.sql @@ -0,0 +1,2 @@ +-- EST RFC 7030 hardening master bundle Phase 11.1 rollback. +ALTER TABLE managed_certificates DROP COLUMN IF EXISTS source; diff --git a/migrations/000023_managed_certificates_source.up.sql b/migrations/000023_managed_certificates_source.up.sql new file mode 100644 index 0000000..53f5419 --- /dev/null +++ b/migrations/000023_managed_certificates_source.up.sql @@ -0,0 +1,19 @@ +-- EST RFC 7030 hardening master bundle Phase 11.1. +-- +-- Add `source` TEXT column to managed_certificates so the bulk-revoke +-- handler can filter by provenance (EST / SCEP / API / Agent / +-- legacy-empty). Empty value preserves v2.X.0 behavior — existing +-- rows scan as Source="" + the bulk-revoke filter treats empty as +-- "any source", so no existing call path sees a behavior change. +-- +-- New EST issuances (Phases 5 + 11 of this bundle) stamp Source="EST"; +-- new SCEP issuances continue to land with Source="" until a follow-up +-- bundle wires the stamp at the SCEP service layer. +-- +-- An index would only pay off when bulk-revoke is called frequently +-- AND the table is large; both prerequisites are unlikely at GA, so +-- defer the index to a follow-up if observability flags slow filter +-- queries in production. + +ALTER TABLE managed_certificates + ADD COLUMN IF NOT EXISTS source TEXT NOT NULL DEFAULT '';