Files
certctl/internal/api/handler/approval.go
T
shankar0123 19706e56b3 chore: drop 'Infisical' label from internal references
Strategic naming cleanup. Earlier doc-comments + commit messages framed Rank
4 / Rank 5 / Rank 7 work as 'Rank N of the 2026-05-03 Infisical deep-research
deliverable' — the 'Infisical' qualifier was a holdover from the original
deep-research framing where Infisical (a competing secrets-management
platform) was the comparator. Keeping the comparator's name in our source
adds noise without value; an external reader sees 'Infisical' and assumes a
dependency or shared lineage rather than reading it as the competitive
context it was.

Mechanical sed across 34 files (32 source / docs + 2 follow-up Python passes
to collapse 'deep-research deep-research' duplicates that emerged where the
original phrase wrapped across lines):

  s|Infisical deep-research|deep-research|g
  s|infisical-deep-research-results|deep-research-results-2026-05-03|g
  s|infisical-deep-research-prompt|deep-research-prompt-2026-05-03|g
  s|infisical-deep-research|deep-research|g
  s|Infisical|deep-research|g
  s|deep-research deep-research|deep-research|g  # collapse-pass

Net diff: 63 insertions / 64 deletions across cmd/, docs/, internal/,
migrations/. Pure text substitution; zero behavior change. Code path
unchanged — go vet clean, tests for TestApproval pass on both
internal/service and internal/api/handler packages.

Workspace docs (cowork/) carry the same references and will be swept
separately — they're not under certctl/ git control. The two filename
references (cowork/infisical-deep-research-results.md +
cowork/infisical-deep-research-prompt.md) get renamed alongside that sweep
to deep-research-results-2026-05-03.md /
deep-research-prompt-2026-05-03.md so cross-references in the certctl
repo doc-comments resolve cleanly.
2026-05-04 01:15:01 +00:00

202 lines
6.3 KiB
Go

package handler
import (
"context"
"encoding/json"
"errors"
"net/http"
"strconv"
"github.com/certctl-io/certctl/internal/api/middleware"
"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 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 middleware.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(middleware.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],
})
}