diff --git a/api/openapi.yaml b/api/openapi.yaml index 31f7779..043ab66 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -2910,6 +2910,151 @@ paths: "500": $ref: "#/components/responses/InternalError" + /api/v1/issuers/{id}/intermediates: + post: + tags: [IntermediateCAs] + summary: Create a root or child intermediate CA under the issuer + description: | + Admin-gated. Discriminator on body shape: when parent_ca_id is + empty AND root_cert_pem + key_driver_id are present, the + endpoint registers an operator-supplied root CA. Otherwise it + signs a child sub-CA cert under the named parent (RFC 5280 + §4.2.1.9 path-length tightening + §4.2.1.10 NameConstraints + subset semantics enforced at the service layer). + operationId: createIntermediateCA + parameters: + - $ref: "#/components/parameters/resourceId" + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name] + properties: + name: { type: string } + parent_ca_id: + type: string + description: Empty for root registration; non-empty for child signing + root_cert_pem: + type: string + description: Operator-supplied root cert PEM (root path only) + key_driver_id: + type: string + description: signer.Driver reference for the root key (root path only) + subject: + type: object + description: Distinguished name for child CA (child path only) + algorithm: + type: string + description: Signing algorithm for child key (default ECDSA-P256) + ttl_days: + type: integer + path_len_constraint: + type: integer + nullable: true + name_constraints: + type: array + items: { type: object } + ocsp_responder_url: + type: string + metadata: + type: object + responses: + "201": + description: IntermediateCA row created + "400": + description: Validation failed (RFC 5280 violations, malformed cert PEM, missing root bundle) + "401": + description: Authentication required + "403": + description: Admin role required + "409": + description: Parent CA not in active state + "404": + description: Parent CA not found + "500": + $ref: "#/components/responses/InternalError" + get: + tags: [IntermediateCAs] + summary: List the CA hierarchy for an issuer + description: | + Admin-gated. Returns the flat list of every IntermediateCA row + for the issuer, ordered by created_at. The caller renders the + tree from each row's parent_ca_id (nil = root). + operationId: listIntermediateCAs + parameters: + - $ref: "#/components/parameters/resourceId" + responses: + "200": + description: Flat list of CA rows + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: { type: object } + "401": + description: Authentication required + "403": + description: Admin role required + + /api/v1/intermediates/{id}: + get: + tags: [IntermediateCAs] + summary: Get a single intermediate CA by ID + operationId: getIntermediateCA + parameters: + - $ref: "#/components/parameters/resourceId" + responses: + "200": + description: IntermediateCA row + "401": + description: Authentication required + "403": + description: Admin role required + "404": + $ref: "#/components/responses/NotFound" + + /api/v1/intermediates/{id}/retire: + post: + tags: [IntermediateCAs] + summary: Retire an intermediate CA (two-phase drain) + description: | + Admin-gated. Two-phase: first call (confirm=false) transitions + active to retiring (the CA stops issuing new children but + existing children continue). Second call (confirm=true) + transitions retiring to retired (terminal). Refuses the + terminal transition if the CA still has active children — + drain-first semantics. + operationId: retireIntermediateCA + parameters: + - $ref: "#/components/parameters/resourceId" + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + note: { type: string } + confirm: { type: boolean, default: false } + responses: + "200": + description: Retire transition recorded + "401": + description: Authentication required + "403": + description: Admin role required + "404": + $ref: "#/components/responses/NotFound" + "409": + description: CA still has active children; drain them first + "500": + $ref: "#/components/responses/InternalError" + /api/v1/notifications: get: tags: [Notifications] diff --git a/cmd/server/main.go b/cmd/server/main.go index ed4ae19..6224f12 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -286,6 +286,24 @@ func main() { certificateService.SetProfileRepo(profileRepo) approvalHandler := handler.NewApprovalHandler(approvalService) + // Rank 8 of the 2026-05-03 deep-research deliverable — first-class + // CA hierarchy management (intermediate_cas table + admin-gated + // hierarchy endpoints). The service receives the issuerRepo so + // future surface area (issuer-row hierarchy_mode validation) can + // query the issuer config; for the commit-4 wiring it carries + // only the fields used today. The signer.FileDriver shared with + // the OCSP responder bootstrap path is reused here — operators + // can plug in PKCS#11 / cloud-KMS drivers via the same Driver + // interface without touching the service. See + // docs/intermediate-ca-hierarchy.md. + intermediateCARepo := postgres.NewIntermediateCARepository(db) + intermediateCAMetrics := service.NewIntermediateCAMetrics() + // Defer wiring the service + handler — signerDriver is constructed + // further down in this function alongside the OCSP responder + // bootstrap path. The service holds a reference to issuerRepo for + // future hierarchy_mode validation surface area. + _ = intermediateCAMetrics // service constructed below alongside signerDriver + notifierRegistry := make(map[string]service.Notifier) // Wire notifier connectors from config @@ -390,6 +408,15 @@ func main() { RotationGrace: cfg.OCSPResponder.RotationGrace, Validity: cfg.OCSPResponder.Validity, }) + + // Rank 8 service + handler — wired here so signerDriver is in + // scope. The same FileDriver instance feeds both the OCSP + // responder bootstrap path and the intermediate-CA hierarchy. + // Operators that swap to PKCS#11 / cloud-KMS drivers reuse the + // single Driver instance across both surfaces. + intermediateCAService := service.NewIntermediateCAService( + intermediateCARepo, issuerRepo, signerDriver, auditService, intermediateCAMetrics) + intermediateCAHandler := handler.NewIntermediateCAHandler(intermediateCAService) crlCacheService := service.NewCRLCacheService(crlCacheRepo, caOperationsSvc, issuerRegistry, logger) // Production hardening II Phase 2: OCSP response cache. Mirrors the @@ -930,6 +957,10 @@ func main() { // the 2026-05-03 Infisical deep-research deliverable. See // docs/approval-workflow.md. Approvals: approvalHandler, + // IntermediateCAs — first-class CA hierarchy management. + // Rank 8 of the 2026-05-03 deep-research deliverable. See + // docs/intermediate-ca-hierarchy.md. + IntermediateCAs: intermediateCAHandler, }) // Register EST (RFC 7030) handlers if enabled. // diff --git a/internal/api/handler/intermediate_ca.go b/internal/api/handler/intermediate_ca.go new file mode 100644 index 0000000..3e424f4 --- /dev/null +++ b/internal/api/handler/intermediate_ca.go @@ -0,0 +1,318 @@ +package handler + +import ( + "context" + "crypto/x509/pkix" + "encoding/json" + "errors" + "net/http" + "time" + + "github.com/certctl-io/certctl/internal/api/middleware" + "github.com/certctl-io/certctl/internal/crypto/signer" + "github.com/certctl-io/certctl/internal/domain" + "github.com/certctl-io/certctl/internal/service" +) + +// IntermediateCAServicer is the handler-facing surface of +// *service.IntermediateCAService. Defined here (handler-defined service +// interface, dependency inversion) so the handler stays decoupled +// from the concrete service type and tests can mock it without +// pulling the full service-layer dependency graph. +// +// Rank 8 of the 2026-05-03 deep-research deliverable, commit 4 of 5 — +// the API + RBAC layer. +type IntermediateCAServicer interface { + CreateRoot(ctx context.Context, issuerID, name, decidedBy string, + rootCertPEM []byte, keyDriverID string, opts *service.CreateRootOptions) (string, error) + CreateChild(ctx context.Context, parentCAID, name, decidedBy string, + opts *service.CreateChildOptions) (string, error) + Retire(ctx context.Context, caID, decidedBy, note string, confirm bool) error + Get(ctx context.Context, id string) (*domain.IntermediateCA, error) + LoadHierarchy(ctx context.Context, issuerID string) ([]*domain.IntermediateCA, error) +} + +// IntermediateCAHandler serves the admin-gated CA hierarchy endpoints. +// All routes are pinned at /api/v1/issuers/{id}/intermediates and +// /api/v1/intermediates/{id}. +// +// Admin gate: every method calls middleware.IsAdmin first and surfaces +// HTTP 403 for non-admin Bearer callers (M-003 admin-gating pattern, +// matches AdminCRLCacheHandler / AdminESTHandler / AdminSCEPIntuneHandler). +// CA hierarchy management is a high-blast-radius surface — adding a +// child CA mints a new sub-CA cert that becomes a trust root for every +// downstream leaf. Operators expect this gated behind admin role. +type IntermediateCAHandler struct { + svc IntermediateCAServicer +} + +// NewIntermediateCAHandler constructs the handler. +func NewIntermediateCAHandler(svc IntermediateCAServicer) IntermediateCAHandler { + return IntermediateCAHandler{svc: svc} +} + +// createIntermediateBody is the JSON body shape for POST +// /api/v1/issuers/{id}/intermediates. ParentCAID is optional — +// when absent OR empty AND RootCertPEM/KeyDriverID are present, the +// endpoint registers an operator-supplied root CA. Otherwise it +// signs a child under the named parent. +type createIntermediateBody struct { + Name string `json:"name"` + ParentCAID string `json:"parent_ca_id,omitempty"` // empty = create root + RootCertPEM string `json:"root_cert_pem,omitempty"` + KeyDriverID string `json:"key_driver_id,omitempty"` + Subject subjectBody `json:"subject,omitempty"` + Algorithm string `json:"algorithm,omitempty"` // ECDSA-P256, RSA-3072, ... + TTLDays int `json:"ttl_days,omitempty"` + PathLenConstraint *int `json:"path_len_constraint,omitempty"` + NameConstraints []domain.NameConstraint `json:"name_constraints,omitempty"` + OCSPResponderURL string `json:"ocsp_responder_url,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// subjectBody is the wire shape for an X.509 subject. Matches the +// pkix.Name fields exposed via the GUI's hierarchy form. +type subjectBody struct { + CommonName string `json:"common_name"` + Organization []string `json:"organization,omitempty"` + OrganizationalUnit []string `json:"organizational_unit,omitempty"` + Country []string `json:"country,omitempty"` + Locality []string `json:"locality,omitempty"` + Province []string `json:"province,omitempty"` +} + +func (s subjectBody) toPKIX() pkix.Name { + return pkix.Name{ + CommonName: s.CommonName, + Organization: s.Organization, + OrganizationalUnit: s.OrganizationalUnit, + Country: s.Country, + Locality: s.Locality, + Province: s.Province, + } +} + +// retireBody is the JSON body shape for POST +// /api/v1/intermediates/{id}/retire. Two-phase: first call (Confirm +// false) transitions active → retiring; second call (Confirm true) +// transitions retiring → retired and refuses if active children +// remain. +type retireBody struct { + Note string `json:"note,omitempty"` + Confirm bool `json:"confirm,omitempty"` +} + +// Create handles POST /api/v1/issuers/{id}/intermediates. Admin-gated. +// Discriminator on body shape: when ParentCAID is empty AND +// RootCertPEM + KeyDriverID are present → CreateRoot; otherwise → +// CreateChild under the named parent. +func (h IntermediateCAHandler) Create(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + Error(w, http.StatusMethodNotAllowed, "Method not allowed") + return + } + if !middleware.IsAdmin(r.Context()) { + Error(w, http.StatusForbidden, "Admin access required") + return + } + requestID := middleware.GetRequestID(r.Context()) + + issuerID := r.PathValue("id") + if issuerID == "" { + ErrorWithRequestID(w, http.StatusBadRequest, "issuer id required", requestID) + return + } + actor, _ := r.Context().Value(middleware.UserKey{}).(string) + if actor == "" { + ErrorWithRequestID(w, http.StatusUnauthorized, + "authentication required", requestID) + return + } + + var body createIntermediateBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + ErrorWithRequestID(w, http.StatusBadRequest, "invalid JSON body", requestID) + return + } + if body.Name == "" { + ErrorWithRequestID(w, http.StatusBadRequest, "name required", requestID) + return + } + + var ( + newID string + err error + ) + if body.ParentCAID == "" { + // Root CA registration path. + if body.RootCertPEM == "" || body.KeyDriverID == "" { + ErrorWithRequestID(w, http.StatusBadRequest, + "root_cert_pem + key_driver_id required when parent_ca_id is empty", + requestID) + return + } + opts := &service.CreateRootOptions{ + OCSPResponderURL: body.OCSPResponderURL, + Metadata: body.Metadata, + } + newID, err = h.svc.CreateRoot(r.Context(), issuerID, body.Name, actor, + []byte(body.RootCertPEM), body.KeyDriverID, opts) + } else { + // Child CA signing path. + alg := signer.Algorithm(body.Algorithm) + if alg == "" { + alg = signer.AlgorithmECDSAP256 + } + ttl := time.Duration(body.TTLDays) * 24 * time.Hour + opts := &service.CreateChildOptions{ + Subject: body.Subject.toPKIX(), + Algorithm: alg, + TTL: ttl, + PathLenConstraint: body.PathLenConstraint, + NameConstraints: body.NameConstraints, + OCSPResponderURL: body.OCSPResponderURL, + Metadata: body.Metadata, + } + newID, err = h.svc.CreateChild(r.Context(), body.ParentCAID, body.Name, actor, opts) + } + if err != nil { + switch { + case errors.Is(err, service.ErrIntermediateCANotFound): + ErrorWithRequestID(w, http.StatusNotFound, err.Error(), requestID) + case errors.Is(err, service.ErrCANotSelfSigned), + errors.Is(err, service.ErrCAKeyMismatch), + errors.Is(err, service.ErrPathLenExceeded), + errors.Is(err, service.ErrNameConstraintExceeded), + errors.Is(err, service.ErrInvalidCertPEM): + ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID) + case errors.Is(err, service.ErrParentCANotActive): + ErrorWithRequestID(w, http.StatusConflict, err.Error(), requestID) + default: + ErrorWithRequestID(w, http.StatusInternalServerError, + "Failed to create intermediate CA", requestID) + } + return + } + + created, err := h.svc.Get(r.Context(), newID) + if err != nil { + ErrorWithRequestID(w, http.StatusInternalServerError, + "created but failed to fetch", requestID) + return + } + JSON(w, http.StatusCreated, created) +} + +// List handles GET /api/v1/issuers/{id}/intermediates. Admin-gated. +// Returns the flat list for the issuer; callers render the tree from +// each row's parent_ca_id. +func (h IntermediateCAHandler) List(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + Error(w, http.StatusMethodNotAllowed, "Method not allowed") + return + } + if !middleware.IsAdmin(r.Context()) { + Error(w, http.StatusForbidden, "Admin access required") + return + } + requestID := middleware.GetRequestID(r.Context()) + + issuerID := r.PathValue("id") + if issuerID == "" { + ErrorWithRequestID(w, http.StatusBadRequest, "issuer id required", requestID) + return + } + rows, err := h.svc.LoadHierarchy(r.Context(), issuerID) + if err != nil { + ErrorWithRequestID(w, http.StatusInternalServerError, + "Failed to list intermediate CAs", requestID) + return + } + JSON(w, http.StatusOK, map[string]interface{}{"data": rows}) +} + +// Get handles GET /api/v1/intermediates/{id}. Admin-gated. +func (h IntermediateCAHandler) Get(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + Error(w, http.StatusMethodNotAllowed, "Method not allowed") + return + } + if !middleware.IsAdmin(r.Context()) { + Error(w, http.StatusForbidden, "Admin access required") + return + } + requestID := middleware.GetRequestID(r.Context()) + + id := r.PathValue("id") + if id == "" { + ErrorWithRequestID(w, http.StatusBadRequest, "id required", requestID) + return + } + ca, err := h.svc.Get(r.Context(), id) + if err != nil { + if errors.Is(err, service.ErrIntermediateCANotFound) { + ErrorWithRequestID(w, http.StatusNotFound, err.Error(), requestID) + return + } + ErrorWithRequestID(w, http.StatusInternalServerError, + "Failed to get intermediate CA", requestID) + return + } + JSON(w, http.StatusOK, ca) +} + +// Retire handles POST /api/v1/intermediates/{id}/retire. Admin-gated. +// Two-phase: first call (Confirm=false) sets state to retiring; +// second call (Confirm=true) sets to retired. Refuses if the CA has +// active children — drain-first semantics. +func (h IntermediateCAHandler) Retire(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + Error(w, http.StatusMethodNotAllowed, "Method not allowed") + return + } + if !middleware.IsAdmin(r.Context()) { + Error(w, http.StatusForbidden, "Admin access required") + return + } + requestID := middleware.GetRequestID(r.Context()) + + id := r.PathValue("id") + if id == "" { + ErrorWithRequestID(w, http.StatusBadRequest, "id required", requestID) + return + } + actor, _ := r.Context().Value(middleware.UserKey{}).(string) + if actor == "" { + ErrorWithRequestID(w, http.StatusUnauthorized, + "authentication required", requestID) + return + } + + body := retireBody{} + if r.Body != nil && r.ContentLength > 0 { + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + ErrorWithRequestID(w, http.StatusBadRequest, + "invalid JSON body", requestID) + return + } + } + + if err := h.svc.Retire(r.Context(), id, actor, body.Note, body.Confirm); err != nil { + switch { + case errors.Is(err, service.ErrIntermediateCANotFound): + ErrorWithRequestID(w, http.StatusNotFound, err.Error(), requestID) + case errors.Is(err, service.ErrCAStillHasActiveChildren): + ErrorWithRequestID(w, http.StatusConflict, err.Error(), requestID) + default: + ErrorWithRequestID(w, http.StatusInternalServerError, + err.Error(), requestID) + } + return + } + + JSON(w, http.StatusOK, map[string]interface{}{ + "id": id, + "decided_by": actor, + "confirmed": body.Confirm, + }) +} diff --git a/internal/api/handler/intermediate_ca_test.go b/internal/api/handler/intermediate_ca_test.go new file mode 100644 index 0000000..8318794 --- /dev/null +++ b/internal/api/handler/intermediate_ca_test.go @@ -0,0 +1,430 @@ +package handler + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "errors" + "math/big" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/certctl-io/certctl/internal/api/middleware" + "github.com/certctl-io/certctl/internal/domain" + "github.com/certctl-io/certctl/internal/service" +) + +// mockIntermediateCAService is the minimal IntermediateCAServicer for +// handler-layer tests. Captures the arguments each method was called +// with so tests can assert dispatch + RBAC behavior. +type mockIntermediateCAService struct { + createRootCalled bool + createChildCalled bool + retireCalled bool + createRootErr error + createChildErr error + retireErr error + retireConfirm bool + + // Get returns this row when nonzero; otherwise the + // IntermediateCANotFound sentinel. + getResult *domain.IntermediateCA + + // LoadHierarchy returns this slice if non-nil. + loadHierarchyResult []*domain.IntermediateCA +} + +func (m *mockIntermediateCAService) CreateRoot(ctx context.Context, issuerID, name, decidedBy string, + rootCertPEM []byte, keyDriverID string, opts *service.CreateRootOptions) (string, error) { + m.createRootCalled = true + if m.createRootErr != nil { + return "", m.createRootErr + } + return "ica-root-mock", nil +} + +func (m *mockIntermediateCAService) CreateChild(ctx context.Context, parentCAID, name, decidedBy string, + opts *service.CreateChildOptions) (string, error) { + m.createChildCalled = true + if m.createChildErr != nil { + return "", m.createChildErr + } + return "ica-child-mock", nil +} + +func (m *mockIntermediateCAService) Retire(ctx context.Context, caID, decidedBy, note string, confirm bool) error { + m.retireCalled = true + m.retireConfirm = confirm + return m.retireErr +} + +func (m *mockIntermediateCAService) Get(ctx context.Context, id string) (*domain.IntermediateCA, error) { + if m.getResult != nil { + return m.getResult, nil + } + return nil, service.ErrIntermediateCANotFound +} + +func (m *mockIntermediateCAService) LoadHierarchy(ctx context.Context, issuerID string) ([]*domain.IntermediateCA, error) { + return m.loadHierarchyResult, nil +} + +// withAdmin returns a context with the admin flag set + a non-empty +// authenticated user — the standard "admin caller" shape for these +// tests. +func withAdmin(actor string, admin bool) context.Context { + ctx := context.WithValue(context.Background(), middleware.UserKey{}, actor) + ctx = context.WithValue(ctx, middleware.AdminKey{}, admin) + return ctx +} + +// helperRootCertPEM returns a freshly-minted self-signed root cert +// PEM for the body of CreateRoot tests. +func helperRootCertPEM(t *testing.T) []byte { + t.Helper() + priv, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + subj := pkix.Name{CommonName: "Test Root"} + tmpl := &x509.Certificate{ + SerialNumber: serial, + Subject: subj, + Issuer: subj, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageCertSign, + BasicConstraintsValid: true, + IsCA: true, + } + der, _ := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv) + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) +} + +// TestIntermediateCA_Handler_NonAdmin_Returns403 pins the +// admin-gating contract. Any non-admin Bearer caller — even a valid +// authenticated one — must get HTTP 403 from every endpoint. CA +// hierarchy management is a high-blast-radius surface; the gate is +// non-negotiable. M-008 admin-gate triplet test #1. +func TestIntermediateCA_Handler_NonAdmin_Returns403(t *testing.T) { + cases := []struct { + name string + method string + path string + pathArgs map[string]string + invoke func(h IntermediateCAHandler) http.HandlerFunc + }{ + { + name: "Create", + method: http.MethodPost, + path: "/api/v1/issuers/iss-1/intermediates", + pathArgs: map[string]string{"id": "iss-1"}, + invoke: func(h IntermediateCAHandler) http.HandlerFunc { return h.Create }, + }, + { + name: "List", + method: http.MethodGet, + path: "/api/v1/issuers/iss-1/intermediates", + pathArgs: map[string]string{"id": "iss-1"}, + invoke: func(h IntermediateCAHandler) http.HandlerFunc { return h.List }, + }, + { + name: "Get", + method: http.MethodGet, + path: "/api/v1/intermediates/ica-1", + pathArgs: map[string]string{"id": "ica-1"}, + invoke: func(h IntermediateCAHandler) http.HandlerFunc { return h.Get }, + }, + { + name: "Retire", + method: http.MethodPost, + path: "/api/v1/intermediates/ica-1/retire", + pathArgs: map[string]string{"id": "ica-1"}, + invoke: func(h IntermediateCAHandler) http.HandlerFunc { return h.Retire }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + h := NewIntermediateCAHandler(&mockIntermediateCAService{}) + req := httptest.NewRequest(tc.method, tc.path, bytes.NewReader([]byte("{}"))) + for k, v := range tc.pathArgs { + req.SetPathValue(k, v) + } + // Authenticated user but admin=false. + req = req.WithContext(withAdmin("alice", false)) + w := httptest.NewRecorder() + tc.invoke(h)(w, req) + if w.Code != http.StatusForbidden { + t.Fatalf("%s: expected 403 for non-admin, got %d body=%s", tc.name, w.Code, w.Body.String()) + } + }) + } +} + +// TestIntermediateCA_Handler_AdminExplicitFalse_Returns403 pins the +// "AdminKey present but false" path — distinct from the +// AdminKey-absent path. Without this distinction a regression that +// reads AdminKey as "presence implies admin" would slip past the +// non-admin check. M-008 admin-gate triplet test #2. +func TestIntermediateCA_Handler_AdminExplicitFalse_Returns403(t *testing.T) { + h := NewIntermediateCAHandler(&mockIntermediateCAService{}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/issuers/iss-1/intermediates", + bytes.NewReader([]byte(`{"name":"r"}`))) + req.SetPathValue("id", "iss-1") + // AdminKey explicitly set to false — distinct from missing key. + ctx := context.WithValue(context.Background(), middleware.UserKey{}, "alice") + ctx = context.WithValue(ctx, middleware.AdminKey{}, false) + req = req.WithContext(ctx) + w := httptest.NewRecorder() + h.Create(w, req) + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403 for AdminKey=false, got %d", w.Code) + } +} + +// TestIntermediateCA_Handler_AdminPermitted_ForwardsActor pins the +// admin-allowed actor-attribution path. An admin caller's actor +// (UserKey context value) must be forwarded to the service so the +// audit trail records who registered the CA. M-008 admin-gate +// triplet test #3. +func TestIntermediateCA_Handler_AdminPermitted_ForwardsActor(t *testing.T) { + mock := &mockIntermediateCAService{ + getResult: &domain.IntermediateCA{ID: "ica-mock"}, + } + h := NewIntermediateCAHandler(mock) + rootPEM := helperRootCertPEM(t) + body := `{"name":"Acme Root","root_cert_pem":` + jsonString(string(rootPEM)) + + `,"key_driver_id":"/etc/certctl/keys/root.pem"}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/issuers/iss-1/intermediates", + bytes.NewReader([]byte(body))) + req.SetPathValue("id", "iss-1") + req = req.WithContext(withAdmin("admin-actor", true)) + w := httptest.NewRecorder() + h.Create(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d body=%s", w.Code, w.Body.String()) + } + if !mock.createRootCalled { + t.Fatalf("expected service dispatch with admin actor") + } +} + +// TestIntermediateCA_HandlerCreate_RootDispatch pins the body +// discriminator: empty parent_ca_id + root_cert_pem + key_driver_id +// → CreateRoot (not CreateChild). The mock service captures which +// method was called. +func TestIntermediateCA_HandlerCreate_RootDispatch(t *testing.T) { + mock := &mockIntermediateCAService{ + getResult: &domain.IntermediateCA{ID: "ica-root-mock"}, + } + h := NewIntermediateCAHandler(mock) + rootPEM := helperRootCertPEM(t) + body := `{ + "name": "Acme Root", + "root_cert_pem": ` + jsonString(string(rootPEM)) + `, + "key_driver_id": "/etc/certctl/keys/root.pem" + }` + req := httptest.NewRequest(http.MethodPost, "/api/v1/issuers/iss-1/intermediates", + bytes.NewReader([]byte(body))) + req.SetPathValue("id", "iss-1") + req = req.WithContext(withAdmin("admin-actor", true)) + w := httptest.NewRecorder() + h.Create(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d body=%s", w.Code, w.Body.String()) + } + if !mock.createRootCalled { + t.Fatalf("expected CreateRoot dispatch, got CreateChild=%v", mock.createChildCalled) + } + if mock.createChildCalled { + t.Fatalf("expected only CreateRoot, but CreateChild was also called") + } +} + +// TestIntermediateCA_HandlerCreate_ChildDispatch pins the +// discriminator's other half: parent_ca_id present → CreateChild. +func TestIntermediateCA_HandlerCreate_ChildDispatch(t *testing.T) { + mock := &mockIntermediateCAService{ + getResult: &domain.IntermediateCA{ID: "ica-child-mock"}, + } + h := NewIntermediateCAHandler(mock) + body := `{ + "name": "Acme Policy", + "parent_ca_id": "ica-root-1", + "subject": {"common_name": "Acme Policy CA", "organization": ["Acme"]}, + "algorithm": "ECDSA-P256", + "ttl_days": 1825 + }` + req := httptest.NewRequest(http.MethodPost, "/api/v1/issuers/iss-1/intermediates", + bytes.NewReader([]byte(body))) + req.SetPathValue("id", "iss-1") + req = req.WithContext(withAdmin("admin-actor", true)) + w := httptest.NewRecorder() + h.Create(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d body=%s", w.Code, w.Body.String()) + } + if !mock.createChildCalled { + t.Fatalf("expected CreateChild dispatch") + } + if mock.createRootCalled { + t.Fatalf("expected only CreateChild, but CreateRoot was also called") + } +} + +// TestIntermediateCA_HandlerCreate_BadRequestOnMissingRootBundle pins +// the validation: empty parent_ca_id + missing root_cert_pem → +// HTTP 400. +func TestIntermediateCA_HandlerCreate_BadRequestOnMissingRootBundle(t *testing.T) { + h := NewIntermediateCAHandler(&mockIntermediateCAService{}) + body := `{"name": "Some Name"}` // no parent, no root bundle + req := httptest.NewRequest(http.MethodPost, "/api/v1/issuers/iss-1/intermediates", + bytes.NewReader([]byte(body))) + req.SetPathValue("id", "iss-1") + req = req.WithContext(withAdmin("admin-actor", true)) + w := httptest.NewRecorder() + h.Create(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d body=%s", w.Code, w.Body.String()) + } +} + +// TestIntermediateCA_HandlerCreate_ServiceErrorMappings pins the +// error → HTTP code dispatch table. +func TestIntermediateCA_HandlerCreate_ServiceErrorMappings(t *testing.T) { + cases := []struct { + name string + err error + wantCode int + isRootCmd bool + }{ + {"NotSelfSigned->400", service.ErrCANotSelfSigned, http.StatusBadRequest, true}, + {"KeyMismatch->400", service.ErrCAKeyMismatch, http.StatusBadRequest, true}, + {"PathLenExceeded->400", service.ErrPathLenExceeded, http.StatusBadRequest, false}, + {"NameConstraintExceeded->400", service.ErrNameConstraintExceeded, http.StatusBadRequest, false}, + {"ParentNotActive->409", service.ErrParentCANotActive, http.StatusConflict, false}, + {"NotFound->404", service.ErrIntermediateCANotFound, http.StatusNotFound, false}, + {"Other->500", errors.New("unexpected"), http.StatusInternalServerError, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + mock := &mockIntermediateCAService{} + if tc.isRootCmd { + mock.createRootErr = tc.err + } else { + mock.createChildErr = tc.err + } + h := NewIntermediateCAHandler(mock) + var body string + if tc.isRootCmd { + rootPEM := helperRootCertPEM(t) + body = `{"name":"Root","root_cert_pem":` + jsonString(string(rootPEM)) + `,"key_driver_id":"/k"}` + } else { + body = `{"name":"Child","parent_ca_id":"ica-root-1"}` + } + req := httptest.NewRequest(http.MethodPost, "/api/v1/issuers/iss-1/intermediates", + bytes.NewReader([]byte(body))) + req.SetPathValue("id", "iss-1") + req = req.WithContext(withAdmin("admin-actor", true)) + w := httptest.NewRecorder() + h.Create(w, req) + if w.Code != tc.wantCode { + t.Fatalf("expected %d, got %d body=%s", tc.wantCode, w.Code, w.Body.String()) + } + }) + } +} + +// TestIntermediateCA_HandlerRetire_TwoPhaseConfirm pins the body's +// confirm flag passes through to the service. First call confirm=false; +// second call confirm=true (the operator explicitly terminalizes). +func TestIntermediateCA_HandlerRetire_TwoPhaseConfirm(t *testing.T) { + mock := &mockIntermediateCAService{} + h := NewIntermediateCAHandler(mock) + + // First call — confirm omitted (defaults to false). + body1 := `{"note": "drain start"}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/intermediates/ica-1/retire", + bytes.NewReader([]byte(body1))) + req.SetPathValue("id", "ica-1") + req = req.WithContext(withAdmin("admin-actor", true)) + w := httptest.NewRecorder() + h.Retire(w, req) + if w.Code != http.StatusOK { + t.Fatalf("first retire: expected 200, got %d body=%s", w.Code, w.Body.String()) + } + if mock.retireConfirm { + t.Fatalf("first retire: expected confirm=false, got true") + } + + // Second call — confirm=true. + mock.retireCalled = false + body2 := `{"note":"terminalize","confirm":true}` + req = httptest.NewRequest(http.MethodPost, "/api/v1/intermediates/ica-1/retire", + bytes.NewReader([]byte(body2))) + req.SetPathValue("id", "ica-1") + req = req.WithContext(withAdmin("admin-actor", true)) + w = httptest.NewRecorder() + h.Retire(w, req) + if w.Code != http.StatusOK { + t.Fatalf("second retire: expected 200, got %d body=%s", w.Code, w.Body.String()) + } + if !mock.retireConfirm { + t.Fatalf("second retire: expected confirm=true, got false") + } +} + +// TestIntermediateCA_HandlerRetire_StillHasActiveChildren_Returns409 +// pins the drain-first contract: ErrCAStillHasActiveChildren maps +// to HTTP 409. +func TestIntermediateCA_HandlerRetire_StillHasActiveChildren_Returns409(t *testing.T) { + mock := &mockIntermediateCAService{retireErr: service.ErrCAStillHasActiveChildren} + h := NewIntermediateCAHandler(mock) + req := httptest.NewRequest(http.MethodPost, "/api/v1/intermediates/ica-1/retire", + bytes.NewReader([]byte(`{"confirm": true}`))) + req.SetPathValue("id", "ica-1") + req = req.WithContext(withAdmin("admin-actor", true)) + w := httptest.NewRecorder() + h.Retire(w, req) + if w.Code != http.StatusConflict { + t.Fatalf("expected 409, got %d body=%s", w.Code, w.Body.String()) + } +} + +// jsonString returns a JSON-quoted Go string suitable for embedding +// in a test JSON body literal. Standard library encoding/json's +// Marshal does the same thing but the test assertions are clearer +// when we control the wrapping. +func jsonString(s string) string { + return string(mustMarshalJSONString(s)) +} + +func mustMarshalJSONString(s string) []byte { + // Trivial: wrap in quotes and escape \ and " — sufficient for + // PEM bodies (which contain newlines but no quotes). + out := make([]byte, 0, len(s)+2) + out = append(out, '"') + for _, r := range []byte(s) { + switch r { + case '"': + out = append(out, '\\', '"') + case '\\': + out = append(out, '\\', '\\') + case '\n': + out = append(out, '\\', 'n') + case '\r': + out = append(out, '\\', 'r') + case '\t': + out = append(out, '\\', 't') + default: + out = append(out, r) + } + } + out = append(out, '"') + return out +} diff --git a/internal/api/handler/m008_admin_gate_test.go b/internal/api/handler/m008_admin_gate_test.go index cb265ed..9cfb5b4 100644 --- a/internal/api/handler/m008_admin_gate_test.go +++ b/internal/api/handler/m008_admin_gate_test.go @@ -39,6 +39,7 @@ var AdminGatedHandlers = map[string]string{ "admin_crl_cache.go": "CRL/OCSP-Responder Phase 5: cache state reveals issuer set + CRL cadence — admin-only", "admin_scep_intune.go": "SCEP RFC 8894 + Intune master bundle Phase 9.2 + Phase 9 follow-up: profiles + stats endpoints reveal per-profile RA cert expiries + Intune trust anchor expiries + mTLS bundle paths; reload-trust is a privileged action — admin-only", "admin_est.go": "EST RFC 7030 hardening master bundle Phase 7.2: profiles endpoint reveals per-profile counter snapshot + mTLS trust-anchor expiries + auth modes; reload-trust is a privileged action — admin-only", + "intermediate_ca.go": "Rank 8: CA hierarchy management mints sub-CA certs that become trust roots for every downstream leaf — admin-only fleet-scale destructive surface", } // InformationalIsAdminCallers is the documented allowlist of files that diff --git a/internal/api/router/router.go b/internal/api/router/router.go index 39a86d5..cce237d 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -169,6 +169,20 @@ type HandlerRegistry struct { // surfaces ErrApproveBySameActor as HTTP 403. See // docs/approval-workflow.md for the operator playbook. Approvals handler.ApprovalHandler + + // IntermediateCAs handles the admin-gated CA-hierarchy management + // surface under /api/v1/issuers/{id}/intermediates and + // /api/v1/intermediates/{id}. Rank 8 of the 2026-05-03 deep- + // research deliverable — closes the multi-level CA hierarchy gap + // for FedRAMP boundary-CA, financial-services policy-CA, and OT + // network-CA deployments. Routes: + // POST /api/v1/issuers/{id}/intermediates + // GET /api/v1/issuers/{id}/intermediates + // GET /api/v1/intermediates/{id} + // POST /api/v1/intermediates/{id}/retire + // Admin-gated at the handler layer (M-003 pattern). See + // docs/intermediate-ca-hierarchy.md for the operator playbook. + IntermediateCAs handler.IntermediateCAHandler } // RegisterHandlers sets up all API routes with their handlers. @@ -373,6 +387,16 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) { r.Register("POST /api/v1/approvals/{id}/approve", http.HandlerFunc(reg.Approvals.Approve)) r.Register("POST /api/v1/approvals/{id}/reject", http.HandlerFunc(reg.Approvals.Reject)) + // IntermediateCA hierarchy routes (Rank 8). Admin-gated inside the + // handler (M-003 pattern); non-admin Bearer callers get 403. The + // /retire literal segment resolves before the {id} pattern-var + // route under Go 1.22 ServeMux precedence — the ordering below + // matches the notifications + approvals blocks above. + r.Register("POST /api/v1/issuers/{id}/intermediates", http.HandlerFunc(reg.IntermediateCAs.Create)) + r.Register("GET /api/v1/issuers/{id}/intermediates", http.HandlerFunc(reg.IntermediateCAs.List)) + r.Register("POST /api/v1/intermediates/{id}/retire", http.HandlerFunc(reg.IntermediateCAs.Retire)) + r.Register("GET /api/v1/intermediates/{id}", http.HandlerFunc(reg.IntermediateCAs.Get)) + // Stats routes: /api/v1/stats r.Register("GET /api/v1/stats/summary", http.HandlerFunc(reg.Stats.GetDashboardSummary)) r.Register("GET /api/v1/stats/certificates-by-status", http.HandlerFunc(reg.Stats.GetCertificatesByStatus))