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:
shankar0123
2026-05-05 19:49:34 +00:00
parent ff75361553
commit 0e06f6c4fc
9 changed files with 456 additions and 35 deletions
+95 -5
View File
@@ -179,12 +179,22 @@ func (c *Client) GetCertificate(id string) error {
}
// RenewCertificate triggers renewal for a certificate.
func (c *Client) RenewCertificate(id string) error {
body := map[string]interface{}{
"force": false,
//
// 2026-05-05 parity-defaults-cleanup (P3-1): the `force` parameter, when
// true, clears the server-side RenewalInProgress block — operators use
// this to recover from a stuck in-flight renewal where the previous job
// hung without releasing the status flag. Sent as `?force=true` query
// parameter; the historical body field `{"force": false}` is gone (it was
// a "lying field" — the API never read it). Archived and Expired remain
// terminal blockers regardless of force; --force is not a magic wand for
// terminal-state certs.
func (c *Client) RenewCertificate(id string, force bool) error {
var q url.Values
if force {
q = url.Values{"force": []string{"true"}}
}
resp, err := c.do("POST", fmt.Sprintf("/api/v1/certificates/%s/renew", id), nil, body)
resp, err := c.do("POST", fmt.Sprintf("/api/v1/certificates/%s/renew", id), q, nil)
if err != nil {
return err
}
@@ -198,14 +208,94 @@ func (c *Client) RenewCertificate(id string) error {
return c.outputJSON(result)
}
fmt.Printf("Renewal triggered for certificate %s\n", id)
if force {
fmt.Printf("Renewal force-triggered for certificate %s (RenewalInProgress block cleared)\n", id)
} else {
fmt.Printf("Renewal triggered for certificate %s\n", id)
}
if jobID, ok := result["job_id"]; ok {
fmt.Printf("Job ID: %v\n", jobID)
}
return nil
}
// canonicalRevokeReasons enumerates the RFC 5280 §5.3.1 reason codes
// accepted by `certctl-cli certs revoke --reason`. Mirrors the canonical
// camelCase surface used by the local issuer + ACME server. Underscore_lower
// variants (e.g. `key_compromise`) are accepted as a convenience and
// normalised at this layer.
//
// 2026-05-05 parity-defaults-cleanup (P3-2): exposed via ValidRevokeReasons()
// + NormalizeRevokeReason() so the CLI dispatch can validate before sending,
// AND so the empty-reason error path can print the menu of valid choices
// instead of silently sending `unspecified`.
var canonicalRevokeReasons = []string{
"unspecified",
"keyCompromise",
"caCompromise",
"affiliationChanged",
"superseded",
"cessationOfOperation",
"certificateHold",
"removeFromCRL",
"privilegeWithdrawn",
"aaCompromise",
}
// ValidRevokeReasons returns the canonical RFC 5280 §5.3.1 reason-code
// camelCase enum the CLI accepts. Used by `certctl-cli certs revoke` to
// print the menu when --reason is missing or invalid.
func ValidRevokeReasons() []string {
out := make([]string, len(canonicalRevokeReasons))
copy(out, canonicalRevokeReasons)
return out
}
// NormalizeRevokeReason maps the operator's input to the canonical
// camelCase form. Returns the canonical form + ok=true if recognised,
// otherwise the original input + ok=false. Accepts both camelCase
// ("keyCompromise") and snake_case ("key_compromise") variants.
func NormalizeRevokeReason(input string) (string, bool) {
// Direct camelCase match.
for _, r := range canonicalRevokeReasons {
if r == input {
return r, true
}
}
// snake_case → camelCase by converting the canonical entry to snake
// form and comparing.
for _, r := range canonicalRevokeReasons {
if strings.EqualFold(camelToSnake(r), input) {
return r, true
}
}
return input, false
}
// camelToSnake converts a camelCase identifier to snake_case (e.g.
// "keyCompromise" → "key_compromise") so we can compare against operator
// input that uses the snake form.
func camelToSnake(camel string) string {
out := make([]byte, 0, len(camel)+4)
for i := 0; i < len(camel); i++ {
ch := camel[i]
if ch >= 'A' && ch <= 'Z' {
if i > 0 {
out = append(out, '_')
}
out = append(out, ch+('a'-'A'))
} else {
out = append(out, ch)
}
}
return string(out)
}
// RevokeCertificate revokes a certificate.
//
// 2026-05-05 parity-defaults-cleanup (P3-2, Option A): empty reason is
// rejected at the CLI dispatch layer (see cmd/cli/main.go) — this method
// expects a pre-validated, canonical RFC 5280 reason string.
func (c *Client) RevokeCertificate(id, reason string) error {
body := map[string]interface{}{
"reason": reason,