// Copyright 2026 certctl LLC. All rights reserved. // SPDX-License-Identifier: BUSL-1.1 package handler import ( "context" "encoding/json" "net/http" "github.com/certctl-io/certctl/internal/api/middleware" "github.com/certctl-io/certctl/internal/domain" ) // BulkRenewalService defines the service interface for bulk certificate // renewal. Mirrors BulkRevocationService — handler doesn't import the // concrete service struct so tests can inject a mock without pulling in // the full service-layer dependency graph. type BulkRenewalService interface { BulkRenew(ctx context.Context, criteria domain.BulkRenewalCriteria, actor string) (*domain.BulkRenewalResult, error) } // BulkRenewalHandler handles HTTP requests for bulk renewal operations. type BulkRenewalHandler struct { svc BulkRenewalService } // NewBulkRenewalHandler creates a new BulkRenewalHandler. func NewBulkRenewalHandler(svc BulkRenewalService) BulkRenewalHandler { return BulkRenewalHandler{svc: svc} } // bulkRenewRequest mirrors the BulkRenewalCriteria JSON shape (the // handler decodes into this struct then hands a domain.BulkRenewalCriteria // to the service — same indirection as bulkRevokeRequest in // bulk_revocation.go). type bulkRenewRequest struct { ProfileID string `json:"profile_id,omitempty"` OwnerID string `json:"owner_id,omitempty"` AgentID string `json:"agent_id,omitempty"` IssuerID string `json:"issuer_id,omitempty"` TeamID string `json:"team_id,omitempty"` CertificateIDs []string `json:"certificate_ids,omitempty"` } // BulkRenew handles POST /api/v1/certificates/bulk-renew // // L-1 closure (cat-l-fa0c1ac07ab5): pre-L-1 the GUI looped // `await triggerRenewal(id)` over the selection. Post-L-1 it POSTs once // and the server enqueues N renewal jobs server-side, returning a // per-cert {certificate_id, job_id} envelope. // // Request shape mirrors BulkRevokeRequest (criteria-mode + IDs-mode); // the "renew all certs of profile X before its CA changes" use case is // why criteria-mode is supported in addition to explicit IDs. // // Auth: any authenticated caller can renew certs they have read-access // to (matches POST /api/v1/certificates/{id}/renew). NOT admin-gated // like bulk-revoke — bulk-renew is non-destructive (worst case it // kicks off some redundant ACME orders) so we don't need the // fleet-scale-destruction gate. func (h BulkRenewalHandler) BulkRenew(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { Error(w, http.StatusMethodNotAllowed, "Method not allowed") return } requestID := middleware.GetRequestID(r.Context()) var req bulkRenewRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID) return } criteria := domain.BulkRenewalCriteria{ ProfileID: req.ProfileID, OwnerID: req.OwnerID, AgentID: req.AgentID, IssuerID: req.IssuerID, TeamID: req.TeamID, CertificateIDs: req.CertificateIDs, } if criteria.IsEmpty() { ErrorWithRequestID(w, http.StatusBadRequest, "At least one filter criterion is required (profile_id, owner_id, agent_id, issuer_id, team_id, or certificate_ids)", requestID) return } actor := resolveActor(r.Context()) result, err := h.svc.BulkRenew(r.Context(), criteria, actor) if err != nil { ErrorWithRequestID(w, http.StatusInternalServerError, "Bulk renewal failed: "+err.Error(), requestID) return } JSON(w, http.StatusOK, result) }