mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 14:51:30 +00:00
cli: promote --force on renew + require --reason on revoke (closes P3-1, P3-2)
Closes findings P3-1 and P3-2 from the 2026-05-05 CLI/API/MCP↔GUI parity
audit (cowork/cli-gui-parity-audit-2026-05-05/RESULTS.md). Both findings
flagged hidden defaults that the CLI was sending without exposing them
to operators: `force=false` baked into every renew payload, and a silent
fallback to `reason="unspecified"` whenever --reason was omitted.
P3-1 — promote --force on `certs renew` (full end-to-end plumbing)
The pre-2026-05-05 CLI sent `{"force": false}` in the renew body. The
API handler never decoded it — a textbook "lying field" per the
operator's CLAUDE.md "complete path, not the easy path" rule: the body
field stored a value, claimed to do something, and silently did nothing
because the wire never reached the consumer. Adding a --force flag that
also went unread would have created another lying field.
This commit takes the complete path:
service.CertificateService.TriggerRenewal grew a `force bool` parameter
(internal/service/certificate.go). When force=true, the
RenewalInProgress block is overridden so operators can recover stuck
in-flight renewals where a previous job hung without releasing the
status flag. Archived and Expired remain terminal blockers regardless
of force — those are semantic dead-ends that --force should not paper
over (archived = decommissioned, expired = issue a new cert instead of
renewing a dead one).
handler.CertificateHandler.TriggerRenewal parses force from
?force=true (or ?force=1) query param, OR {"force": true} JSON body,
whichever the client picks. Defaults to false. Passes through to the
service.
internal/cli/client.go::RenewCertificate(id, force bool) sends
?force=true on the URL when --force is set. The historical hardcoded
`{"force": false}` body is gone — no more lying field.
cmd/cli/main.go dispatches `certs renew <id> [--force]` (ID-first
flag-second convention matches the existing `agents retire <id>
[--force]`).
P3-2 — require --reason on `certs revoke` (Option A: strict refusal)
The pre-2026-05-05 CLI dropped to `--reason unspecified` whenever the
operator omitted the flag. Compliance reporting (RFC 5280 §5.3.1, PCI-
DSS §3.6, HIPAA §164.312) relies on the reason code being meaningful;
silent fallback defeats the audit trail because every revocation looks
identical.
cmd/cli/main.go dispatch refuses to send when --reason is empty,
prints the canonical RFC 5280 §5.3.1 reason-code menu, and exits
non-zero.
internal/cli/client.go exposes ValidRevokeReasons() returning the
canonical camelCase list (unspecified, keyCompromise, caCompromise,
affiliationChanged, superseded, cessationOfOperation, certificateHold,
removeFromCRL, privilegeWithdrawn, aaCompromise) and
NormalizeRevokeReason() that accepts both camelCase and snake_case
inputs and normalises to the canonical wire form. Off-list reasons
are rejected at dispatch with the menu re-printed.
Test pins:
internal/cli/client_test.go::TestClient_RenewCertificate_ForceFlag —
--force=true sends ?force=true with empty body; --force=false sends
no query and no body.
internal/cli/client_test.go::TestNormalizeRevokeReason +
TestValidRevokeReasons — canonical-camelCase + snake_case + reject-
off-enum behaviour.
cmd/cli/dispatch_test.go::TestHandleCerts_Revoke_RequiresReason +
TestHandleCerts_Revoke_RejectsUnknownReason +
TestHandleCerts_Renew_ForceFlag — dispatch-layer pins for the same
contracts.
internal/api/handler/certificate_handler_test.go::TestTriggerRenewal_
ForceQueryParam — query-param passthrough (no-flag, force=true,
force=1, force=false) flows through to the service-layer parameter.
internal/service/certificate_test.go::TestTriggerRenewal_
ForceOverridesInProgress — force=false preserves the
RenewalInProgress block; force=true clears it.
Existing TestTriggerRenewal_Archived extended to assert force=true
still blocks Archived (terminal-state guarantee).
Docs: docs/reference/cli.md updated with the --force example for renew
and the strict --reason semantics for revoke (including snake_case
input acceptance).
Acceptance gate (verified):
- go build ./cmd/server/... ./cmd/agent/... ./cmd/cli/...
./cmd/mcp-server/... clean.
- go vet ./... clean.
- go test -short -count=1 ./... pass repo-wide.
- bash scripts/ci-guards/openapi-handler-parity.sh clean
(router 178, OpenAPI 144, exceptions 36 — unchanged; we add
parameter parsing, not routes).
- gofmt -l clean.
This commit is contained in:
@@ -32,7 +32,7 @@ type MockCertificateService struct {
|
||||
UpdateCertificateFn func(ctx context.Context, id string, cert domain.ManagedCertificate) (*domain.ManagedCertificate, error)
|
||||
ArchiveCertificateFn func(ctx context.Context, id string) error
|
||||
GetCertificateVersionsFn func(ctx context.Context, certID string, page, perPage int) ([]domain.CertificateVersion, int64, error)
|
||||
TriggerRenewalFn func(ctx context.Context, certID string, actor string) error
|
||||
TriggerRenewalFn func(ctx context.Context, certID string, actor string, force bool) error
|
||||
TriggerDeploymentFn func(ctx context.Context, certID string, targetID string, actor string) error
|
||||
RevokeCertificateFn func(ctx context.Context, certID string, reason string, actor string) error
|
||||
GetRevokedCertificatesFn func(ctx context.Context) ([]*domain.CertificateRevocation, error)
|
||||
@@ -84,9 +84,9 @@ func (m *MockCertificateService) GetCertificateVersions(ctx context.Context, cer
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (m *MockCertificateService) TriggerRenewal(ctx context.Context, certID string, actor string) error {
|
||||
func (m *MockCertificateService) TriggerRenewal(ctx context.Context, certID string, actor string, force bool) error {
|
||||
if m.TriggerRenewalFn != nil {
|
||||
return m.TriggerRenewalFn(ctx, certID, actor)
|
||||
return m.TriggerRenewalFn(ctx, certID, actor, force)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -690,7 +690,7 @@ func TestGetCertificateVersions_NotFound(t *testing.T) {
|
||||
// Test TriggerRenewal - success case
|
||||
func TestTriggerRenewal_Success(t *testing.T) {
|
||||
mock := &MockCertificateService{
|
||||
TriggerRenewalFn: func(_ context.Context, certID string, _ string) error {
|
||||
TriggerRenewalFn: func(_ context.Context, certID string, _ string, _ bool) error {
|
||||
if certID == "mc-prod-001" {
|
||||
return nil
|
||||
}
|
||||
@@ -722,7 +722,7 @@ func TestTriggerRenewal_Success(t *testing.T) {
|
||||
// Test TriggerRenewal - service error
|
||||
func TestTriggerRenewal_ServiceError(t *testing.T) {
|
||||
mock := &MockCertificateService{
|
||||
TriggerRenewalFn: func(_ context.Context, certID string, _ string) error {
|
||||
TriggerRenewalFn: func(_ context.Context, certID string, _ string, _ bool) error {
|
||||
return ErrMockServiceFailed
|
||||
},
|
||||
}
|
||||
@@ -739,6 +739,44 @@ func TestTriggerRenewal_ServiceError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestTriggerRenewal_ForceQueryParam pins the 2026-05-05 parity-defaults-cleanup
|
||||
// (P3-1) wire: ?force=true on the renew URL flows through to the service-layer
|
||||
// `force bool` parameter so operators can override the RenewalInProgress block.
|
||||
func TestTriggerRenewal_ForceQueryParam(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
query string
|
||||
wantForce bool
|
||||
}{
|
||||
{"no-flag", "", false},
|
||||
{"force-true", "?force=true", true},
|
||||
{"force-1", "?force=1", true},
|
||||
{"force-false", "?force=false", false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var gotForce bool
|
||||
mock := &MockCertificateService{
|
||||
TriggerRenewalFn: func(_ context.Context, _ string, _ string, force bool) error {
|
||||
gotForce = force
|
||||
return nil
|
||||
},
|
||||
}
|
||||
handler := NewCertificateHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodPost,
|
||||
"/api/v1/certificates/mc-prod-001/renew"+tc.query, nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
handler.TriggerRenewal(w, req)
|
||||
if w.Code != http.StatusAccepted {
|
||||
t.Fatalf("status: got %d want %d", w.Code, http.StatusAccepted)
|
||||
}
|
||||
if gotForce != tc.wantForce {
|
||||
t.Errorf("force passthrough: got %v want %v", gotForce, tc.wantForce)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Test TriggerDeployment - success case
|
||||
func TestTriggerDeployment_Success(t *testing.T) {
|
||||
mock := &MockCertificateService{
|
||||
|
||||
@@ -32,7 +32,7 @@ type CertificateService interface {
|
||||
UpdateCertificate(ctx context.Context, id string, cert domain.ManagedCertificate) (*domain.ManagedCertificate, error)
|
||||
ArchiveCertificate(ctx context.Context, id string) error
|
||||
GetCertificateVersions(ctx context.Context, certID string, page, perPage int) ([]domain.CertificateVersion, int64, error)
|
||||
TriggerRenewal(ctx context.Context, certID string, actor string) error
|
||||
TriggerRenewal(ctx context.Context, certID string, actor string, force bool) error
|
||||
TriggerDeployment(ctx context.Context, certID string, targetID string, actor string) error
|
||||
RevokeCertificate(ctx context.Context, certID string, reason string, actor string) error
|
||||
GetRevokedCertificates(ctx context.Context) ([]*domain.CertificateRevocation, error)
|
||||
@@ -437,7 +437,25 @@ func (h CertificateHandler) TriggerRenewal(w http.ResponseWriter, r *http.Reques
|
||||
|
||||
actor := resolveActor(r.Context())
|
||||
|
||||
if err := h.svc.TriggerRenewal(r.Context(), certID, actor); err != nil {
|
||||
// 2026-05-05 parity-defaults-cleanup (P3-1): operators can opt into
|
||||
// forcing a renewal when the cert is stuck in RenewalInProgress (a
|
||||
// previous job hung without releasing the status flag). Accepted as
|
||||
// either ?force=true query param OR {"force": true} JSON body so CLI
|
||||
// + GUI clients can pick whichever flow fits their idiom.
|
||||
force := false
|
||||
if fv := r.URL.Query().Get("force"); fv == "true" || fv == "1" {
|
||||
force = true
|
||||
}
|
||||
if !force && r.ContentLength > 0 && r.Header.Get("Content-Type") == "application/json" {
|
||||
var body struct {
|
||||
Force bool `json:"force,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err == nil {
|
||||
force = body.Force
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.svc.TriggerRenewal(r.Context(), certID, actor, force); err != nil {
|
||||
errMsg := err.Error()
|
||||
if strings.Contains(errMsg, "not found") {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
||||
|
||||
Reference in New Issue
Block a user