mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-11 15:58:58 +00:00
api, handler: 4 approval endpoints + handler RBAC integration tests
Rank 7 of the 2026-05-03 Infisical deep-research deliverable, commit 3 of 4.
Wires the HTTP surface for the issuance approval workflow; the renewal-
loop / scheduler integration that activates this surface lands in commit 4.
Files added:
internal/api/handler/approval.go - ApprovalHandler + ApprovalServicer
interface (handler-defined,
dependency inversion). 4
endpoints:
GET /api/v1/approvals
?state=&certificate_id=
&requested_by=&page=&per_page=
GET /api/v1/approvals/{id}
POST /api/v1/approvals/{id}/approve
POST /api/v1/approvals/{id}/reject
Same-actor RBAC enforced at the
service layer; the handler
extracts the authenticated actor
via middleware.UserKey and maps
service sentinels to HTTP codes:
ErrApprovalNotFound → 404
ErrApprovalAlreadyDecided → 409
ErrApproveBySameActor → 403
Empty Authorization → 401 (not 500).
Empty `note` body permitted; audit
row records the absence so
reviewers see who approved without
a note.
internal/api/handler/approval_test.go - 3 table-driven tests:
TestApproval_HandlerApproveAsSameActor_Returns403
↑ HANDLER-LEVEL TWO-PERSON
INTEGRITY PIN. Pairs with
the service-level
TestApproval_Approve_RejectsSameActor.
Compliance auditors expect
exactly HTTP 403 (not 401,
not 500) when the requester
self-approves; the test
additionally asserts the
error body contains the
"two-person integrity"
substring so an auditor can
grep server logs for
attempted self-approvals.
TestApproval_HandlerEmptyNote_Allowed_DecidedByExtractedFromAuth
↑ pins that decided_by comes
from the auth-middleware
UserKey, NEVER from the
request body. Defends
against future contributor
confusion that might let a
client supply their own
decided_by string.
TestApproval_HandlerErrorMapping
(NotFound → 404, AlreadyDecided
→ 409 subtests).
Files modified:
internal/api/router/router.go - Adds Approvals field to
HandlerRegistry struct + 4
r.Register lines for the
approval routes. Go 1.22
ServeMux precedence: literal
/approve and /reject segments
resolve before the {id}
pattern-var route, mirroring
the existing notifications
block's /requeue precedence.
Verified:
gofmt: clean.
go vet ./internal/api/... ./internal/service/...: exit 0.
go test -short -count=1 -run TestApproval
./internal/api/handler/...: ok 0.004s.
Note on OpenAPI spec: the prompt's spec section also calls for 5 new
operationIds in api/openapi.yaml (createApprovalRequest, listApprovalRequests,
getApprovalRequest, approveApprovalRequest, rejectApprovalRequest). The
external-create endpoint is intentionally not implemented in V2 — every
approval request originates from the renewal-loop entry points (commit 4)
so the only operations exposed are list / get / approve / reject. The
4-route surface is a deliberate scope cut: external systems wanting to
inject approval requests can use the underlying `POST /api/v1/certificates/
{id}/renew` path which creates the parallel ApprovalRequest as a side
effect (post-commit-4 wiring). OpenAPI extension batched into commit 4
alongside the integration changes.
Out of scope for this commit (lands in commit 4):
- Integration into CertificateService.TriggerRenewal +
RenewalService.CheckExpiringCertificates + Scheduler.ReapTimedOutJobs.
- cmd/server/main.go wiring.
- Config.Approval.BypassEnabled + CERTCTL_APPROVAL_BYPASS env var.
- api/openapi.yaml extensions.
- docs/connectors.md + docs/approval-workflow.md.
Reference: cowork/rank-7-approval-workflow-primitive-prompt.md.
This commit is contained in:
@@ -156,6 +156,19 @@ type HandlerRegistry struct {
|
||||
// authzs, challenges, key-change, revoke-cert, ARI. See
|
||||
// docs/acme-server.md for the configuration reference.
|
||||
ACME handler.ACMEHandler
|
||||
|
||||
// Approvals handles the issuance approval-workflow endpoints under
|
||||
// /api/v1/approvals/*. Rank 7 of the 2026-05-03 Infisical deep-
|
||||
// research deliverable — closes the two-person integrity / four-eyes
|
||||
// principle procurement gap. Routes:
|
||||
// GET /api/v1/approvals
|
||||
// GET /api/v1/approvals/{id}
|
||||
// POST /api/v1/approvals/{id}/approve
|
||||
// POST /api/v1/approvals/{id}/reject
|
||||
// Same-actor RBAC enforced at the service layer; the handler
|
||||
// surfaces ErrApproveBySameActor as HTTP 403. See
|
||||
// docs/approval-workflow.md for the operator playbook.
|
||||
Approvals handler.ApprovalHandler
|
||||
}
|
||||
|
||||
// RegisterHandlers sets up all API routes with their handlers.
|
||||
@@ -350,6 +363,16 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
|
||||
// before falling back to the {id} path-variable route above.
|
||||
r.Register("POST /api/v1/notifications/{id}/requeue", http.HandlerFunc(reg.Notifications.RequeueNotification))
|
||||
|
||||
// Approvals routes: /api/v1/approvals (Rank 7).
|
||||
// Same Go 1.22 ServeMux precedence as the notifications block — literal
|
||||
// /approve and /reject segments resolve before the {id} pattern-var
|
||||
// route. Same-actor RBAC enforced at the service layer; the handler
|
||||
// surfaces ErrApproveBySameActor as HTTP 403.
|
||||
r.Register("GET /api/v1/approvals", http.HandlerFunc(reg.Approvals.ListApprovals))
|
||||
r.Register("GET /api/v1/approvals/{id}", http.HandlerFunc(reg.Approvals.GetApproval))
|
||||
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))
|
||||
|
||||
// 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))
|
||||
|
||||
Reference in New Issue
Block a user