// Copyright 2026 certctl LLC. All rights reserved. // SPDX-License-Identifier: BUSL-1.1 package handler import ( "context" "encoding/json" "errors" "net/http" "strconv" "github.com/certctl-io/certctl/internal/api/middleware" "github.com/certctl-io/certctl/internal/auth" "github.com/certctl-io/certctl/internal/domain" "github.com/certctl-io/certctl/internal/repository" "github.com/certctl-io/certctl/internal/service" ) // ApprovalServicer is the handler-facing surface of the approval-workflow // service. Defined here (handler-defined service interface, dependency // inversion) so the handler stays decoupled from the concrete // *service.ApprovalService. // // Rank 7 of the 2026-05-03 Infisical deep-research deliverable, commit 3 // of 4 — the API + RBAC layer. type ApprovalServicer interface { Approve(ctx context.Context, requestID, decidedBy, note string) error Reject(ctx context.Context, requestID, decidedBy, note string) error Get(ctx context.Context, id string) (*domain.ApprovalRequest, error) List(ctx context.Context, filter *repository.ApprovalFilter) ([]*domain.ApprovalRequest, error) } // ApprovalHandler handles HTTP requests for the issuance approval workflow. // All endpoints are pinned at /api/v1/approvals/*. type ApprovalHandler struct { svc ApprovalServicer } // NewApprovalHandler constructs an ApprovalHandler with a service dependency. func NewApprovalHandler(svc ApprovalServicer) ApprovalHandler { return ApprovalHandler{svc: svc} } // approvalDecisionBody is the JSON body shape for Approve / Reject endpoints. type approvalDecisionBody struct { Note string `json:"note,omitempty"` } // ListApprovals returns paginated approval requests, optionally filtered // by ?state=, ?certificate_id=, ?requested_by=. // // GET /api/v1/approvals?state=pending&page=1&per_page=50 func (h ApprovalHandler) ListApprovals(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { Error(w, http.StatusMethodNotAllowed, "Method not allowed") return } requestID := middleware.GetRequestID(r.Context()) q := r.URL.Query() page, _ := strconv.Atoi(q.Get("page")) if page < 1 { page = 1 } perPage, _ := strconv.Atoi(q.Get("per_page")) if perPage < 1 || perPage > 500 { perPage = 50 } filter := &repository.ApprovalFilter{ State: q.Get("state"), CertificateID: q.Get("certificate_id"), RequestedBy: q.Get("requested_by"), Page: page, PerPage: perPage, } results, err := h.svc.List(r.Context(), filter) if err != nil { ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to list approval requests", requestID) return } JSON(w, http.StatusOK, map[string]interface{}{ "data": results, "page": page, "per_page": perPage, }) } // GetApproval returns a single approval request by ID. // // GET /api/v1/approvals/{id} func (h ApprovalHandler) GetApproval(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { Error(w, http.StatusMethodNotAllowed, "Method not allowed") return } requestID := middleware.GetRequestID(r.Context()) id := r.PathValue("id") if id == "" { ErrorWithRequestID(w, http.StatusBadRequest, "id required", requestID) return } req, err := h.svc.Get(r.Context(), id) if err != nil { if errors.Is(err, service.ErrApprovalNotFound) { ErrorWithRequestID(w, http.StatusNotFound, "approval request not found", requestID) return } ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to get approval request", requestID) return } JSON(w, http.StatusOK, req) } // Approve transitions a pending approval request to approved + transitions // the linked Job from AwaitingApproval to Pending. RBAC: the authenticated // actor extracted via auth.UserKey must NOT equal the request's // RequestedBy — the service-layer check enforces this and the handler // surfaces it as HTTP 403. // // POST /api/v1/approvals/{id}/approve // Body: {"note": "approved per ticket SECOPS-12345"} (optional) func (h ApprovalHandler) Approve(w http.ResponseWriter, r *http.Request) { h.decision(w, r, decisionApprove) } // Reject transitions a pending approval request to rejected + cancels // the linked Job. Same RBAC contract as Approve. // // POST /api/v1/approvals/{id}/reject // Body: {"note": "rejected: not on business-justification list"} (optional) func (h ApprovalHandler) Reject(w http.ResponseWriter, r *http.Request) { h.decision(w, r, decisionReject) } type decisionAction int const ( decisionApprove decisionAction = iota decisionReject ) func (h ApprovalHandler) decision(w http.ResponseWriter, r *http.Request, action decisionAction) { if r.Method != http.MethodPost { Error(w, http.StatusMethodNotAllowed, "Method not allowed") return } requestID := middleware.GetRequestID(r.Context()) id := r.PathValue("id") if id == "" { ErrorWithRequestID(w, http.StatusBadRequest, "id required", requestID) return } // Extract authenticated actor. The auth middleware sets UserKey to the // API-key NamedAPIKey.Name (or empty for unauthenticated). RBAC at the // service layer requires a non-empty actor. actor, _ := r.Context().Value(auth.UserKey{}).(string) if actor == "" { ErrorWithRequestID(w, http.StatusUnauthorized, "authentication required to approve / reject", requestID) return } body := approvalDecisionBody{} 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 } } var err error switch action { case decisionApprove: err = h.svc.Approve(r.Context(), id, actor, body.Note) case decisionReject: err = h.svc.Reject(r.Context(), id, actor, body.Note) } if err != nil { switch { case errors.Is(err, service.ErrApprovalNotFound): ErrorWithRequestID(w, http.StatusNotFound, err.Error(), requestID) case errors.Is(err, service.ErrApprovalAlreadyDecided): ErrorWithRequestID(w, http.StatusConflict, err.Error(), requestID) case errors.Is(err, service.ErrApproveBySameActor): // The load-bearing two-person integrity contract surface. // Compliance auditors expect this exact code path. ErrorWithRequestID(w, http.StatusForbidden, err.Error(), requestID) default: ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to record decision", requestID) } return } JSON(w, http.StatusOK, map[string]interface{}{ "id": id, "decided_by": actor, "action": map[decisionAction]string{decisionApprove: "approved", decisionReject: "rejected"}[action], }) }