mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 23:11:32 +00:00
36e722ba12
Uncommitted migration work at the time of branch cleanup. Tagged as checkpoint/m1-migration-wip so the commit survives git gc --prune=now. Session context: Phase 3 Part B+C of the M-1 sentinel error migration was in progress. 38 modified files, 4 new files (errors.go + errors_test.go in internal/service/ and internal/api/handler/). Resume from this commit via 'git checkout checkpoint/m1-migration-wip'.
118 lines
5.7 KiB
Go
118 lines
5.7 KiB
Go
package handler
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
|
|
"github.com/lib/pq"
|
|
|
|
"github.com/shankar0123/certctl/internal/repository"
|
|
"github.com/shankar0123/certctl/internal/service"
|
|
)
|
|
|
|
// errToStatus is the single choke point that maps a service-layer or
|
|
// repository-layer error to its HTTP status code. Before M-1 (P2), 42 switch
|
|
// branches across 11 handler files classified errors via
|
|
// `strings.Contains(err.Error(), ...)` substring matching — a pattern that
|
|
// made every HTTP status mapping one sentinel-message reword away from silent
|
|
// regression (see M-003 self-approval privilege boundary: a reword of
|
|
// ErrSelfApproval.Error() would have demoted 403 Forbidden to 500 Internal
|
|
// Server Error with no compile-time error, no test failure, and no observable
|
|
// external signal).
|
|
//
|
|
// All handler branches now route through this function via errors.Is and
|
|
// errors.As, which walks the wrap chain built by fmt.Errorf("%w: ...", ...).
|
|
// The generic sentinels live in internal/service/errors.go; domain-specific
|
|
// sentinels (ErrSelfApproval, ErrAgentIsSentinel, ErrBlockedByDependencies,
|
|
// ErrForceReasonRequired, ErrAgentNotFound) wrap those generics via %w so both
|
|
// errors.Is(err, ErrSelfApproval) and errors.Is(err, ErrForbidden) succeed on
|
|
// the same wrapped error.
|
|
//
|
|
// # Dispatch order
|
|
//
|
|
// 1. ErrAgentRetired → 410 Gone. Tested FIRST. It is deliberately NOT wrapped
|
|
// under any generic sentinel — 410 Gone is semantically distinct from
|
|
// 403/404/409 (permanently-terminated resource identity that drives
|
|
// deterministic agent-binary shutdown at cmd/agent/main.go:1291). Must
|
|
// short-circuit before any generic check so wrapping can never demote it.
|
|
// 2. ErrNotFound → 404 Not Found. Both service.ErrNotFound and
|
|
// repository.ErrNotFound route here — repositories wrap sql.ErrNoRows with
|
|
// repository.ErrNotFound so a "row not found" escapes the repo layer as a
|
|
// typed sentinel rather than an untyped fmt.Errorf string. Tested BEFORE
|
|
// ErrForbidden so RFC 7235's preference for hiding resource existence from
|
|
// unauthorized callers is preserved (a caller who cannot see a resource
|
|
// should get 404, not 403).
|
|
// 3. ErrUnauthenticated → 401 Unauthorized. SCEP challenge-password mismatch
|
|
// and similar credential failures.
|
|
// 4. ErrForbidden → 403 Forbidden. M-003 gate. Tested BEFORE ErrValidation so
|
|
// double-wrapping (e.g., a future fmt.Errorf("%w: ctx", ErrSelfApproval)
|
|
// in a wrapping call site) cannot demote 403 to 400.
|
|
// 5. ErrConflict / repository.ErrRenewalPolicyDuplicateName /
|
|
// repository.ErrRenewalPolicyInUse → 409 Conflict. The repo-layer sentinels
|
|
// are routed here explicitly so handlers do not need their own dispatch
|
|
// tree for G-1's renewal-policy FK + unique-name violations.
|
|
// 6. ErrValidation → 400 Bad Request. Generic input validation / malformed
|
|
// request bodies / invalid state transitions that the caller could correct
|
|
// by changing their request.
|
|
// 7. ErrUnprocessable → 422 Unprocessable Entity. Distinct from
|
|
// ErrValidation: ErrValidation is "caller sent bad input" (400), while
|
|
// ErrUnprocessable is "caller's input was fine but our stored data can't
|
|
// satisfy the operation" — e.g., an X.509 PEM in the inventory that fails
|
|
// to decode. The pre-M-1 ExportPKCS12 handler pinned 422 on
|
|
// strings.Contains(err.Error(), "cannot be parsed"); the sentinel makes
|
|
// that dispatch survive message rewording.
|
|
// 8. ErrNotImplemented → 501 Not Implemented. Reserved for feature-flag-gated
|
|
// code paths.
|
|
// 9. *pq.Error fallback on SQLSTATE 23503 (FK violation) / 23505 (unique
|
|
// violation) → 409 Conflict. Final branch before the default 500. Anything
|
|
// that reaches here is technically a code smell (the repository layer
|
|
// should normally wrap driver errors into a typed sentinel) but the status
|
|
// mapping is still correct.
|
|
//
|
|
// # Why a function, not a middleware
|
|
//
|
|
// Handlers must continue to call [Error] / [ErrorWithRequestID] with a
|
|
// caller-chosen human-readable message (sometimes the wrapped err.Error(),
|
|
// sometimes a redacted "internal error" for 500s per F-002). This function
|
|
// gives handlers the status code; the handler keeps control of the body.
|
|
func errToStatus(err error) int {
|
|
if err == nil {
|
|
return http.StatusOK
|
|
}
|
|
|
|
switch {
|
|
case errors.Is(err, service.ErrAgentRetired):
|
|
return http.StatusGone // 410 — must short-circuit before generic dispatch
|
|
case errors.Is(err, service.ErrNotFound),
|
|
errors.Is(err, repository.ErrNotFound):
|
|
return http.StatusNotFound // 404 — before ErrForbidden (RFC 7235 existence hiding)
|
|
case errors.Is(err, service.ErrUnauthenticated):
|
|
return http.StatusUnauthorized // 401
|
|
case errors.Is(err, service.ErrForbidden):
|
|
return http.StatusForbidden // 403 — before ErrValidation (preserves M-003 gate under double-wrap)
|
|
case errors.Is(err, service.ErrConflict),
|
|
errors.Is(err, repository.ErrRenewalPolicyDuplicateName),
|
|
errors.Is(err, repository.ErrRenewalPolicyInUse):
|
|
return http.StatusConflict // 409
|
|
case errors.Is(err, service.ErrValidation):
|
|
return http.StatusBadRequest // 400
|
|
case errors.Is(err, service.ErrUnprocessable):
|
|
return http.StatusUnprocessableEntity // 422 — stored-data-unparseable, not caller-input-bad
|
|
case errors.Is(err, service.ErrNotImplemented):
|
|
return http.StatusNotImplemented // 501
|
|
}
|
|
|
|
// Driver-level fallback. Raw *pq.Error escaping the repository layer is a
|
|
// code smell but a real escape hatch today — we still want a correct 409
|
|
// instead of a generic 500 for FK/unique violations.
|
|
var pgErr *pq.Error
|
|
if errors.As(err, &pgErr) {
|
|
switch pgErr.Code {
|
|
case "23503", "23505":
|
|
return http.StatusConflict
|
|
}
|
|
}
|
|
|
|
return http.StatusInternalServerError
|
|
}
|