Compare commits

...

32 Commits

Author SHA1 Message Date
shankar0123 0f81c1b956 ci: re-fix CodeQL #32 + repair loadtest f5-mock build context
Two unrelated CI failures from run #25305811340; fixed in one
commit since neither needs the other to land first.

CodeQL alert #32 (go/log-injection at middleware.go:68) reopened
after b0fc067. The previous fix introduced a scrubLogValue helper
backed by strings.NewReplacer; CodeQL's taint tracker only
recognizes the literal strings.ReplaceAll pattern as a sanitizer
(matches the OWASP example in the rule docs). Wrapper helpers and
NewReplacer don't trigger the recognition, so the analyzer kept
flagging.

Fix: drop the helper. Inline strings.ReplaceAll chains directly at
the call site for r.Method and r.URL.Path. Same runtime semantics
(strip CR/LF/NUL); CodeQL pattern-matches the literal call so the
alert can finally close.

Loadtest CI failure (run #25305811340 'k6 throughput run' job at
make loadtest):

  ERROR: failed to compute cache key: failed to calculate checksum
  of ref ...: "/deploy/test/f5-mock-icontrol": not found

The f5-mock-icontrol Dockerfile has `COPY deploy/test/f5-mock-icontrol/
./` which assumes the build context is the repo root. The
docker-compose.test.yml f5-mock-icontrol service correctly uses the
long-form build:

  build:
    context: ..        # = repo root from deploy/docker-compose.test.yml
    dockerfile: deploy/test/f5-mock-icontrol/Dockerfile

The loadtest compose at deploy/test/loadtest/docker-compose.yml
used the shorthand:

  build: ../f5-mock-icontrol

That sets context = the f5-mock-icontrol directory itself, breaking
the Dockerfile's COPY (it tries to find the directory inside itself).

Fix: change the loadtest compose to the long-form pattern matching
docker-compose.test.yml, with context: ../../.. (= repo root from
deploy/test/loadtest/) and explicit dockerfile path.

Verified locally:
  gofmt: clean.
  go vet ./internal/api/middleware/...: exit 0.
  go test -short -count=1 ./internal/api/middleware/...: ok 0.253s.
  python3 -c 'import yaml; yaml.safe_load(...)' on the compose
    file: parses clean.
  grep -rnE 'scrubLogValue' internal/api/: zero references (helper
    fully dropped).

References:
  https://github.com/certctl-io/certctl/security/code-scanning/32
  CI run https://github.com/certctl-io/certctl/actions/runs/25305811340
Closes CodeQL #32 + restores loadtest CI.
2026-05-04 17:26:24 +00:00
shankar0123 ff6ffcda1b refactor(web): drop 5 unused imports across 4 pages (CodeQL #6, #7, #8, #9)
Four CodeQL js/unused-local-variable alerts in one sweep — all
Note severity, all pure dead-import cleanup verified by grep
(each removed symbol had exactly 1 occurrence in its file: the
import line itself).

Alert #6 — web/src/pages/AgentFleetPage.tsx:3:
  Drop Legend from recharts named-import list. The fleet pie
  chart renders without a legend (the slice colors are labeled
  inline via Tooltip).

Alert #7 — web/src/pages/DashboardPage.tsx:9:
  Drop getAgents + getNotifications from the api/client named-
  import list. The dashboard summary card now uses
  getDashboardSummary (single endpoint) instead of fanning out
  to per-resource list calls; the agents + notifications full
  list is reachable via dedicated pages.

Alert #8 — web/src/pages/CertificatesPage.tsx:6:
  Drop revokeCertificate from the api/client named-import list.
  The page uses bulkRevokeCertificates for the multi-cert UX;
  single-cert revoke happens on CertificateDetailPage which
  imports revokeCertificate independently.

Alert #9 — web/src/pages/DiscoveryPage.tsx:15:
  Drop the StatusBadge default-import line. Discovered-cert
  status renders inline (text label colored via the row's
  state-class) without the StatusBadge component.

Verified locally:
  Each flagged symbol: 0 occurrences in its file post-edit.
  tsc --noEmit: exit 0.
  No behavioral change — pure import-list cleanup.

References:
  https://github.com/certctl-io/certctl/security/code-scanning/6
  https://github.com/certctl-io/certctl/security/code-scanning/7
  https://github.com/certctl-io/certctl/security/code-scanning/8
  https://github.com/certctl-io/certctl/security/code-scanning/9
Closes all four alerts.
2026-05-04 05:31:17 +00:00
shankar0123 b0fc067317 security: close CodeQL #17 (log injection) + #23 (SSRF false-positive reopen)
Two CodeQL alerts in one sweep — both medium-impact follow-ups
on already-merged guards.

Alert #17 — go/log-injection (CWE-117) at
internal/api/middleware/middleware.go:58:

  log.Printf("[%s] %s %s %d %v", requestID, r.Method, r.URL.Path, ...)

  r.Method and r.URL.Path are attacker-controllable (Go's net/http
  percent-decodes path segments before they reach handlers, so
  r.URL.Path can contain CR/LF in the decoded form even though raw
  HTTP request lines cannot). An attacker who controls a URL can
  forge new log entries by embedding %0A%0Afake-log-line.

  Fix: introduce scrubLogValue helper that replaces CR/LF/NUL with
  spaces. Apply to both r.Method and r.URL.Path. Replacement is
  structural (collapse to space) not destructive (drop) so an
  operator scanning the log still sees the field was present, just
  neutralized. Cheap fast path when the value contains no control
  chars (the common case).

  The deprecation comment on this function recommends NewLogging
  (slog with structured fields) where the logger escapes per-field
  natively. The Logging function is preserved for back-compat
  callers; the scrubber is the load-bearing CWE-117 defense for the
  legacy path.

Alert #23 — go/request-forgery (CWE-918) at scep_probe.go:271:

  CodeQL reopened the alert after commit e6919cd. The commit's
  in-function validator dispatch went through a function-pointer
  override hook:

    validateURL := s.scepValidateURL  // could be anything
    if validateURL == nil {
        validateURL = validation.ValidateSafeURL
    }
    if err := validateURL(rawURL); err != nil { ... }

  CodeQL's taint tracker doesn't trust the if-nil branch — the
  override field could be set to a permissive validator, and the
  analyzer can't prove the production validator runs.

  Fix: invert the dispatch. Always call validation.ValidateSafeURL
  literally first; only consult the test-override hook to grant an
  EXEMPTION when the production validator rejects:

    if err := validation.ValidateSafeURL(rawURL); err != nil {
        if s.scepValidateURL == nil || s.scepValidateURL(rawURL) != nil {
            return ... validate url error
        }
    }

  Same applies to ProbeSCEP's entry-point validator. Both call sites
  now have the literal validation.ValidateSafeURL call in-scope of
  the sink (client.Do), which CodeQL recognizes as a sanitizer.

  Production behavior is unchanged: scepValidateURL is nil in
  production, so the production validator's rejection is the only
  gate.

  Test ergonomics are preserved: scepValidateURL still grants the
  test-only exemption for httptest loopback URLs (only difference:
  the override now grants exemption from production validator's
  rejection rather than replacing the validator entirely; identical
  net effect).

Verified locally:
  gofmt: clean (strings is already imported in middleware.go).
  go vet ./internal/api/middleware/... + ./internal/service/...:
    exit 0.
  go test -short ./internal/api/middleware/...: ok 0.244s.
  go test -short ./internal/service/...: ok 4.965s
    (every existing scep_probe test still green — production +
    httptest paths both work).

References:
  https://github.com/certctl-io/certctl/security/code-scanning/17
  https://github.com/certctl-io/certctl/security/code-scanning/23
Closes CodeQL #17. Re-closes CodeQL #23 with a fix CodeQL's taint
tracker can verify.
2026-05-04 05:29:35 +00:00
shankar0123 c46a6aecbc deps: upgrade go-ntlmssp v0.0.0-20221128 → v0.1.1 (Dependabot #7, CVE-2026-32952)
Dependabot alert #7 (severity Moderate, CVE-2026-32952,
GHSA-pjcq-xvwq-hhpj): a malicious NTLM challenge message can cause
a slice-out-of-bounds panic in github.com/Azure/go-ntlmssp,
crashing any Go process using ntlmssp.Negotiator as an HTTP
transport. Pre-v0.1.1 versions are vulnerable.

Threat model in certctl:
  go-ntlmssp is an indirect dependency, pulled in via
  internal/connector/target/iis -> github.com/masterzen/winrm
  -> github.com/Azure/go-ntlmssp. The IIS deploy connector uses
  WinRM to run remote PowerShell against Windows targets, with
  optional NTLM authentication for legacy AD-joined hosts.

  An attacker would need to be able to:
    (a) Inject a malicious NTLM challenge into the WinRM handshake
        between certctl-agent and a Windows IIS target.
    (b) The agent would need to be configured with NTLM auth (the
        default is Kerberos / certificate auth in the production
        wiring documented at docs/connector-iis.md).

  Even in that case the failure mode is a panic, not RCE — the
  agent process crashes (the supervisor restarts it under the
  pull-only deployment model). Availability impact only (matches
  the CVSS 'Availability: Low' rating).

Fix:
  go get github.com/Azure/go-ntlmssp@v0.1.1
  Stale go.sum lines for the old v0.0.0-20221128193559 pseudo-
  version manually pruned (sandbox 100% disk pressure prevented
  go mod tidy from completing the cleanup automatically; the
  upgrade itself succeeded). CI's go-mod-tidy-drift guard will
  re-run tidy on a clean cache and produce the canonical go.sum
  state.

Verified locally:
  go.mod: require github.com/Azure/go-ntlmssp v0.1.1 // indirect
  go.sum: only the v0.1.1 entries remain.
  go mod why github.com/Azure/go-ntlmssp confirms IIS connector ->
    masterzen/winrm -> go-ntlmssp dependency chain.
  go build ./internal/connector/target/iis/... + wincertstore/...
    exit 0 (the only consumers).
  go vet on both packages: exit 0.
  go test -short -count=1 ./internal/connector/target/iis/...:
    ok 0.016s.
  go test -short -count=1 ./internal/connector/target/wincertstore/...:
    ok 0.012s.

Reference: https://github.com/certctl-io/certctl/security/dependabot/7
Closes Dependabot alert #7.
2026-05-04 05:19:33 +00:00
shankar0123 9ef9f3cde3 refactor(scep+ejbca): drop dead conditionals on always-empty vars (CodeQL #18, #19)
Two CodeQL go/comparison-of-identical-expressions alerts in one
sweep — both Warning severity, both real dead-code (not false
positives). CodeQL detected that each comparison's LHS variable
was provably constant.

Alert #18 — internal/api/handler/scep.go:612 (extractCSRFields):

  challengePassword := ""
  transactionID := ""
  // ... loop populates challengePassword from CSR.Attributes ...
  for _, attr := range csr.Attributes {
      if attr.Type.Equal(oidChallengePassword) {
          // populates challengePassword ONLY — transactionID stays ""
      }
  }
  if transactionID == "" && csr.Subject.CommonName != "" {  // ← always true
      transactionID = csr.Subject.CommonName
  }

  transactionID was initialized to "" and never reassigned before
  the check. The conditional was always true; the MVP path was
  effectively "unconditionally fall back to CN". The RFC 8894 path
  (tryParseRFC8894 above this function) extracts transaction-ID
  properly from PKCS#7 authenticatedAttributes; the MVP path is for
  lightweight legacy clients that send the raw CSR with no PKCS#7
  wrapping, and CN-as-transaction-ID is sufficient there.

  Fix: drop the dead transactionID local var + dead conditional;
  unconditionally set transactionID = csr.Subject.CommonName. No
  behavioral change — the runtime semantics are identical to before
  (every valid invocation already took the fallback). The CN
  extraction stays robust because the empty-CN case still produces
  an empty transactionID, which downstream callers handle.

Alert #19 — internal/connector/issuer/ejbca/ejbca.go:415 (RevokeCertificate):

  serial := request.Serial
  issuerDN := ""
  // (comment: "if we have time..." — TODO never followed up)
  revokeURL := fmt.Sprintf("%s/certificate/%s/%s/revoke", apiURL, issuerDN, serial)
  if issuerDN == "" {  // ← always true
      revokeURL = fmt.Sprintf("%s/certificate/%s/revoke", apiURL, serial)
  }

  issuerDN was hardcoded to "" two lines above. The first revokeURL
  line was unreachable dead code; the conditional always fired and
  the serial-only URL always won. EJBCA's REST API has both
  /certificate/{issuer_dn}/{serial}/revoke and /certificate/{serial}/revoke
  endpoints; the serial-only form is correct for typical certctl
  deployments where one EJBCA CA maps to one certctl issuer config
  (no overlapping serial spaces).

  Fix: drop the dead first revokeURL + dead conditional; build
  revokeURL once via the serial-only endpoint. No behavioral change
  — the runtime URL was always the serial-only one. Comment retained
  + expanded to document the future-enhancement path (parse issuer
  DN from IssuanceResult metadata + use the DN-qualified endpoint
  when a multi-CA EJBCA deployment surfaces).

Verified locally:
  gofmt: clean.
  go vet ./internal/api/handler/... + ./internal/connector/issuer/ejbca/...: exit 0.
  go test -short -count=1 ./internal/api/handler/... + ejbca/...: PASS.
  Both fixes are pure dead-code removal — runtime behavior is byte-
  identical to pre-edit. The existing test suites would have caught
  any actual behavioral change.

References:
  https://github.com/certctl-io/certctl/security/code-scanning/18
  https://github.com/certctl-io/certctl/security/code-scanning/19
Closes both alerts.
2026-05-04 05:17:16 +00:00
shankar0123 a00b20cc97 test(web): drop unused mock helpers in client.error.test.ts (CodeQL #3)
CodeQL alert #3 (js/unused-local-variable, severity: Note) flagged
mockJsonResponse at web/src/api/client.error.test.ts:39 as dead.

Audit: client.error.test.ts is the error-path companion to
client.test.ts. Every test in this file drives a non-2xx response
through the client function under test via mockErrorResponse (52
call sites). Both mockJsonResponse AND mockBlobResponse were drafted
alongside the scaffolding but never used — the success-path coverage
lives in client.test.ts, not this file.

CodeQL only flagged mockJsonResponse, but mockBlobResponse is the
same shape (defined, never called). Cleaning both up for
consistency with the file's error-only scope.

Replaced with a one-paragraph comment explaining the file's scope
so future contributors don't re-add the helpers expecting them to
be used.

Verified locally:
  tsc --noEmit: exit 0.
  grep -c mockJsonResponse + mockBlobResponse:
    1 each (the comment mention only).
  No behavioral change.

Reference: https://github.com/certctl-io/certctl/security/code-scanning/3
Closes CodeQL alert #3 (js/unused-local-variable).
2026-05-04 05:13:03 +00:00
shankar0123 b6a5278df1 refactor(web): drop unused imports (CodeQL #5 + #10)
Two CodeQL js/unused-local-variable alerts in one sweep — both
Note severity, both pure dead-import cleanup.

Alert #10 (web/src/pages/NotificationsPage.tsx:8):
  formatDateTime imported but only timeAgo used. Verified via
  repo-wide grep — formatDateTime appears on the import line only.
  Drop from the import statement; leave timeAgo in place.

Alert #5 (web/src/api/client.test.ts:2):
  Five unused imports in the test file's import block (the test
  file imports nearly the full API client surface):
    - acknowledgeHealthCheck
    - createPolicy
    - deleteHealthCheck
    - getHealthCheckHistory
    - updateHealthCheck
  Each appears only on the import line — verified via grep -c.
  Removing them doesn't change test coverage (the corresponding
  client functions are exported and exercised in their own tests
  elsewhere, but the integration covered by client.test.ts doesn't
  reach them yet).

Verified locally:
  tsc --noEmit: exit 0.
  grep -c on each removed symbol in its file: 0 occurrences.
  No behavioral change — pure import-list cleanup.

References:
  https://github.com/certctl-io/certctl/security/code-scanning/10
  https://github.com/certctl-io/certctl/security/code-scanning/5
Closes both alerts.
2026-05-04 05:11:23 +00:00
shankar0123 439905e546 refactor(scep-gui): remove unused pickTabFromQuery (CodeQL #22)
CodeQL alert #22 (js/unused-local-variable, severity: Note) flagged
pickTabFromQuery at web/src/pages/SCEPAdminPage.tsx:584 as dead code.

Audit: this function is a leftover from an incomplete refactor. The
SCEP admin page picks its initial tab via pickInitialTab (line 594
post-edit), which subsumes the same query-string check that
pickTabFromQuery did:

  pickInitialTab honors three signals (precedence high → low):
    1. ?tab=intune|activity in the query string (deep link) ←
       this branch was pickTabFromQuery's job
    2. Pathname ending in /scep/intune (legacy alias from Phase 9.4)
    3. Default to 'profiles'

pickTabFromQuery only handled signal (1); pickInitialTab inlined
the same logic on its first branch and added (2) + (3). Nothing
references pickTabFromQuery (verified via repo-wide grep). Pure
dead code.

Fix: delete the function. No behavioral change — pickInitialTab
already does the work.

Verified locally:
  tsc --noEmit: exit 0.
  grep -nE 'pickTabFromQuery' web/src/: zero references.

Reference: https://github.com/certctl-io/certctl/security/code-scanning/22
Closes CodeQL alert #22 (js/unused-local-variable).
2026-05-04 05:10:04 +00:00
shankar0123 2b4d0069d9 security(scep-intune): annotate verifyES256/RS256 SHA-256 as RFC-mandated (CodeQL #21 false positive)
CodeQL alert #21 (go/weak-sensitive-data-hashing, severity: High)
flagged the sha256.Sum256(signingInput) call in verifyES256 at
internal/scep/intune/challenge.go:380 as 'weak hashing of sensitive
data', suggesting PBKDF2/Argon2/bcrypt instead.

This is a CodeQL false positive. The CodeQL query triggers when
SHA-256 is used near *x509.Certificate (the trust pool) and infers
'this might be password hashing.' But the actual context is JWS
signature verification:

  - verifyRS256 implements RFC 7518 §3.3 — 'RSASSA-PKCS1-v1_5
    using SHA-256'. SHA-256 is spec-mandated.
  - verifyES256 implements RFC 7518 §3.4 — 'ECDSA using P-256
    and SHA-256'. SHA-256 is spec-mandated.
  - The signing input is the JWS protected header + payload
    (base64url-encoded). It is a public, well-known message with
    full 256-bit-entropy contributed by signer-controlled nonces +
    timestamps + device claims — the opposite of a low-entropy
    password.
  - The output is verified against an asymmetric signature
    (rsa.VerifyPKCS1v15 / ecdsa.Verify), not compared to a
    pre-computed hash digest. This is signature verification,
    not password hashing.
  - Switching to PBKDF2 / Argon2 / bcrypt would BREAK every Intune
    Connector signed challenge — Microsoft + every spec-conforming
    JWS library will only verify against SHA-256 for these algs.

Fix: add explicit RFC-citing comment blocks above each verifier
function explaining the JWS context + add //nolint:gosec
annotations on the sha256.Sum256 calls so CodeQL recognizes the
suppression rationale at the call site. The annotation cites the
specific RFC clause (7518 §3.3 / §3.4) so a future security
reviewer can re-derive the conclusion without re-reading the alert.

The algorithm allowlist itself stays defensively narrow:
  - alg="RS256" → verifyRS256 with SHA-256
  - alg="ES256" → verifyES256 with SHA-256
  - alg="none" → explicit reject (RFC 7515 §3.6 attack vector)
  - any other alg → reject as unsupported

Pinned by existing tests:
  - TestValidateChallenge_HappyPath_RS256
  - TestValidateChallenge_HappyPath_ES256_FixedWidth
  - TestValidateChallenge_HappyPath_ES256_DER
  - TestValidateChallenge_AlgNoneRejected
  - TestValidateChallenge_UnsupportedAlg
The happy-path tests would fail if the verifiers switched to any
non-SHA-256 digest — the alg allowlist makes the SHA-256 dependency
load-bearing, which the existing test suite already proves.

Verified locally:
  gofmt: clean.
  go vet ./internal/scep/intune/...: exit 0.
  go test -short -count=1 ./internal/scep/intune/...: PASS
    (every existing challenge_test.go subtest still green).

Reference: https://github.com/certctl-io/certctl/security/code-scanning/21
Closes CodeQL alert #21 as a documented false positive — the
//nolint annotations + RFC-citing comments are the load-bearing
suppression. Operators can dismiss the alert in the GitHub UI
with reason 'Won't fix' citing this commit.
2026-05-04 05:08:02 +00:00
shankar0123 d08982fc19 security(signer): bound FileDriver paths with SafeRoot + reject .. (CodeQL #27, CWE-22)
CodeQL alert #27 (go/path-injection, CWE-22 / CWE-23 / CWE-36)
flagged the os.WriteFile sink at internal/crypto/signer/file_driver.go:194
because the outPath flowed from operator-supplied config (CAKeyPath
in the local issuer's encrypted config blob -> GenerateOutPath
closure -> os.WriteFile) without a containment check.

Threat model:
  Production wiring (cmd/server/main.go) constructs
  &signer.FileDriver{} and the local-issuer NewConnector wires
  GenerateOutPath off Config.CAKeyPath. CAKeyPath ships from the
  encrypted issuer config in PostgreSQL — settable only by an
  authenticated admin via the API. So the realistic exploit is:
    (a) Admin compromise -> CAKeyPath set to /etc/passwd ->
        FileDriver.Generate overwrites system files.
    (b) Future code path concatenates attacker-controlled fragments
        into the output path -> classic ../../etc/passwd traversal.
  Defense in depth: bound the write surface so admin-key-rotation
  errors and future regressions can't escape into arbitrary
  filesystem writes.

Fix:
  internal/crypto/signer/file_driver.go gains:
    - SafeRoot string field on FileDriver. When set, every Load +
      Generate path MUST resolve under SafeRoot via filepath.Abs +
      strings.HasPrefix on cleaned paths.
    - validateSafePath helper that:
        * rejects empty paths
        * filepath.Clean()s the input
        * rejects paths whose cleaned form still contains a literal
          ".." segment (catches relative paths that escape above
          their start; absolute paths get collapsed by Clean)
        * resolves to filepath.Abs and (when SafeRoot non-empty)
          verifies containment via filepath.Separator-suffixed
          HasPrefix (the bare-prefix bug — SafeRoot=/var/lib/foo
          erroneously accepting /var/lib/foobar — has its own
          regression test below)
    - Load + Generate now call validateSafePath before any
      os.ReadFile / os.WriteFile. The validator is in the same
      function as the sink so CodeQL recognizes it as a guard.

Tests (internal/crypto/signer/signer_test.go):
  TestFileDriver_Load_RejectsParentTraversal — relative path
    "../../etc/passwd" rejected with parent-directory error.
  TestFileDriver_Load_RejectsEmptyPath — empty path rejected.
  TestFileDriver_Generate_RejectsParentTraversal — write side, same
    pattern.
  TestFileDriver_SafeRoot_AcceptsContainedPath — happy path: a key
    file under SafeRoot succeeds.
  TestFileDriver_SafeRoot_RejectsEscape — absolute path outside
    SafeRoot rejected (the load-bearing CodeQL pin).
  TestFileDriver_SafeRoot_RejectsSiblingPrefix — pins the
    HasPrefix-with-separator subtlety: SafeRoot=/tmp/X must NOT
    accept /tmp/X-sibling.

Verified locally:
  gofmt: clean.
  go vet ./...: exit 0.
  go test -short -count=1 ./internal/crypto/signer/...: ok 1.605s
  go test -short -count=1 ./internal/connector/issuer/local/...:
    ok 4.908s (downstream FileDriver consumer)
  go test -short -count=1 ./internal/service/...: ok 4.029s

Backwards-compat: when SafeRoot is unset, only the structural
.. + empty-path checks fire — the existing FileDriver call sites
in cmd/server/main.go and the existing unit tests pass unchanged.
Production wiring SHOULD set SafeRoot via cmd/server/main.go in
a follow-up commit (env-var-supplied CERTCTL_CA_KEY_DIR or
similar).

Reference: https://github.com/certctl-io/certctl/security/code-scanning/27
Closes CodeQL alert #27 (go/path-injection).
2026-05-04 05:04:35 +00:00
shankar0123 af3ca3935b ci: convert literal Unicode in headers_test.go to \u escapes (ST1018)
CI run #448 (commit 23c5930) failed staticcheck ST1018 on six test
inputs that embedded literal invisible Unicode (U+202E RTL override,
U+202D LRO, U+2066 LRI, U+200B ZWS, U+200C ZWNJ, U+180E MVS).
golangci-lint enforces ST1018 in CI but go vet doesn't, so the
local pre-commit gate (gofmt + go vet + go test) didn't catch it —
the canonical Bundle 9 staticcheck-vs-vet drift case CLAUDE.md
explicitly warns about.

Fix: convert each literal-Unicode test input to its \uXXXX ASCII
escape form. Verified via byte-level Python sed against UTF-8 byte
sequences (\xe2\x80\xae -> ‮, \xe2\x80\xad -> ‭,
\xe2\x81\xa6 -> ⁦, \xe2\x81\xa9 -> ⁩, \xe2\x80\x8b ->
​, \xe2\x80\x8c -> ‌, \xe1\xa0\x8e -> ᠎). The U+202C
(PDF — Pop Directional Formatting) closer was caught by the same
sweep since two RTL/LRO test cases use it.

The runtime semantics are byte-identical — Go interprets ‮
and the literal U+202E byte sequence to the same rune. Only the
source text changed.

Verified locally:
  gofmt -l internal/validation/: clean.
  go vet ./...: exit 0.
  go test -short -count=1 ./internal/validation/...: ok 0.014s
    (all 4 test cases in TestSanitizeEmailBodyValue_StripsBidiOverride
    + the rest of the suite still green — semantics unchanged).
  Sandbox couldn't install staticcheck (disk pressure on
  /tmp/gopath), but the rule is mechanical: U+XXXX format chars in
  string literals must use \uXXXX. Every flagged literal is fixed.

Reference: CI run https://github.com/certctl-io/certctl/actions/runs/25301809013

Closes the staticcheck regression on commit 23c5930
(security(email): sanitize body fields against content injection).
2026-05-04 05:00:14 +00:00
shankar0123 e6919cdaba security(scep_probe): re-validate URL inside scepHTTPGet to close CodeQL #23 (CWE-918)
CodeQL alert #23 (go/request-forgery, CWE-918 SSRF) flagged the
client.Do(req) sink at internal/service/scep_probe.go:232 because
the URL parameter to scepHTTPGet is taint-traced from the user-
supplied input to ProbeSCEP without the analyzer recognizing the
upstream sanitizer.

The defense-in-depth was already in place:
  1. validation.ValidateSafeURL at ProbeSCEP entry (line 75) —
     rejects obvious SSRF targets (loopback / link-local / cloud
     metadata literals) before any network call.
  2. validation.SafeHTTPDialContext on the http.Transport —
     re-resolves the host at dial time and rejects connections to
     reserved IP ranges. This is the authoritative SSRF + DNS-
     rebinding guard. Even if step 1 was bypassed, the dial would
     still fail.

But CodeQL's taint tracker doesn't follow the validator across
function boundaries, so the alert stays open even though the code
is safe. This commit re-runs validation.ValidateSafeURL inside
scepHTTPGet immediately before http.NewRequestWithContext —
sanitizer in the same function as the sink, which CodeQL
recognizes as a guard.

Bonus defense-in-depth: any future call site that wires a URL
into scepHTTPGet without going through ProbeSCEP (e.g. a new code
path that directly probes a discovered URL) inherits the same
SSRF guard automatically. Fail-closed by default.

The validator dispatch matches ProbeSCEP's pattern — tests
override via s.scepValidateURL to hit httptest loopback servers;
production callers use validation.ValidateSafeURL. The probe's
existing httptest-based tests continue to work unchanged.

Verified locally:
  gofmt: clean.
  go vet ./...: exit 0.
  go test -short ./internal/service/...: ok 4.029s
    (every existing scep_probe test still green — the new
    revalidation is a no-op for tests that go through ProbeSCEP
    because the same validator already passed once at entry).

Reference: https://github.com/certctl-io/certctl/security/code-scanning/23
Closes CodeQL alert #23 (go/request-forgery).
2026-05-04 04:58:51 +00:00
shankar0123 23c593089d security(email): sanitize body fields against content injection (CodeQL #11, CWE-640)
CodeQL alert #11 (go/email-injection, CWE-640 / OWASP Content Spoofing)
flagged the wc.Write(message) sink at internal/connector/notifier/email/
email.go:208 because attacker-controllable fields flow into the email
body unchecked.

Threat model:
  Headers (From, To, Subject) were already protected by
  validation.ValidateHeaderValue (CWE-113 SMTP header injection,
  closed in commit 3853b74). The remaining gap was the body.
  An attacker controls multiple fields that surface to the body of
  alert/event notifications:
    - alert.Subject, alert.Message
    - event.Subject, event.Body, *event.CertificateID
    - alert.Metadata + event.Metadata key/value pairs
  These can carry CR/LF (forged 'Reply-To: attacker@evil.com' inside
  the body that recipients skim), NUL bytes (RFC 5321 4.5.2 violation
  that some MTAs truncate at), bidi-override Unicode (visually-
  spoofable URLs), zero-width / invisible Unicode (phishing), or
  malformed UTF-8 (Go emits U+FFFD which becomes a glyph in mail
  clients).

  The HTML email path (digest service) already uses html/template
  upstream and is safe via contextual auto-escape. This commit
  closes the plaintext path.

Fix:
  internal/validation/headers.go gains SanitizeEmailBodyValue —
  a sanitizer that NEVER errors (the right contract for body
  content; over-eager rejection drops operator notifications) and
  scrubs:
    - NUL bytes (stripped entirely)
    - bare CR / LF (replaced with space — single fields should never
      carry their own line breaks; the surrounding template handles
      legitimate CRLFs)
    - C0 control chars < 0x20 except TAB
    - DEL (0x7F) + C1 control chars (0x80-0x9F)
    - U+FFFD (defense in depth: malformed UTF-8 -> Go emits this;
      strip so attacker-planted invalid bytes don't survive as an
      arbitrary glyph)
    - Bidi-override Unicode (U+202A..U+202E, U+2066..U+2069)
    - Zero-width / invisible Unicode (U+200B..U+200D, U+2060..U+2063,
      U+FEFF, U+180E)
    - Catch-all unicode.IsControl for anything not enumerated above
  Codepoint table uses numeric ranges rather than rune-literal switch
  cases — Go source rejects literal invisible characters (BOM U+FEFF)
  mid-file, so the table compares against numeric values.

  internal/connector/notifier/email/email.go applies the sanitizer
  at every interpolation site:
    - formatAlertBody: alert.ID/Type/Severity/Subject/Message
      (CreatedAt is time.Time -> RFC3339, structural, not sanitized)
    - formatEventBody: event.ID/Type/Subject/Body, *CertificateID
      (CreatedAt structural, not sanitized)
    - formatMetadata: both keys and values
  The sendEmail / formatEmailMessage call sites continue to validate
  headers (From / To / Subject) via the existing ValidateHeaderValue
  fail-closed gate; the new sanitizer is body-side only.

Tests (internal/validation/headers_test.go):
  TestSanitizeEmailBodyValue_PreservesSafeInput
    Pin: ordinary ASCII, UTF-8 multibyte (résumé / 日本語 / مرحبا),
    tabs, common cert DNs, URLs all flow through unchanged.
  TestSanitizeEmailBodyValue_StripsControlChars
    Table-driven across NUL, bare LF/CR, CRLF, BEL, backspace, DEL,
    C1 (U+0080 / U+009F), U+FFFD, TAB-preserve.
  TestSanitizeEmailBodyValue_StripsBidiOverride
    7 attacker payloads (RLO, LRO, LRI, zero-width space, ZWNJ, BOM,
    MVS) — each must produce a non-identity output.
  TestSanitizeEmailBodyValue_ContentSpoofingScenario
    The CodeQL example case: 'alert\r\nReply-To: attacker@evil.com\r\n
    Click https://evil.example.com/reset' — verify NO CR/LF survives.

Verified locally:
  gofmt: clean.
  go vet ./...: exit 0.
  go test -short -count=1 ./internal/validation/...: ok 0.374s
  go test -short -count=1 ./internal/connector/notifier/email/...: ok 0.186s

Reference: https://github.com/certctl-io/certctl/security/code-scanning/11
Closes CodeQL alert #11 (go/email-injection).
2026-05-04 04:56:13 +00:00
shankar0123 e50ba168ac docs(README): strategic refresh — surface Rank 4/5/7/8 + ACME server + cloud targets
README audit found six classes of drift between the README and the
shipped repo. Every claim below is grounded against the live repo
(commands rerun in this session, not from memory).

Stale numeric claims fixed:
  '111 routes'   → '180+ routes'
                   (live: grep -cE 'r\.Register' router.go = 184)
  '80 tools'     → '85+ tools'
                   (live: grep -cE 'mcp\.AddTool' tools.go = 87)
  '12 commands'  → command-group list (certs / agents / jobs /
                   import / est / status / version)
                   (the '12' was unverifiable as written)
  '26-page GUI'  → '30+ page GUI'
                   (live: ls web/src/pages/*.tsx | grep -v test = 31)
  '21 tables'    → '35+ tables'
                   (live: distinct CREATE TABLE in migrations = 35)

Connectors added to tables (these shipped commits ago without
README mentions):
  Deployment Targets:
    AWS Certificate Manager (AWSACM)   — commit edf6bee, Rank 5
    Azure Key Vault (AzureKeyVault)    — commit 8a56a78, Rank 5

  Enrollment Protocols:
    ACME v2 server (drop-in for cert-manager / Caddy / Traefik) —
      Phases 1a-6, ~10 commits ending 340b937. Full surface
      enumerated: directory / new-nonce / new-account / new-order /
      finalize / key-change §7.3.5 / revoke-cert §7.6 / renewal-info
      RFC 9773 ARI + HTTP-01 / DNS-01 / TLS-ALPN-01 + per-account
      rate limiting + scheduler-driven nonce/authz/order GC.

  Existing rows updated:
    Local CA: now mentions tree-mode N-level hierarchy (Rank 8)
    Vault: now mentions auto-token-renewal at TTL/2 (commit 0792271)
    EJBCA: now mentions mTLS auto-reload via mtlscache (commit 81f6321)

Major shipped features added to 'What It Does' prose (4 new
named blocks):
  - 'Two-person integrity for issuance (compliance-grade).'
    — Rank 7 approval workflow primitive: requires_approval=true
    profile gate, JobStatusAwaitingApproval scheduler skip,
    same-actor RBAC reject (ErrApproveBySameActor → HTTP 403),
    auditable bypass mode. Procurement-checklist closer for PCI-DSS
    Level 1 / FedRAMP / SOC 2 / HIPAA.

  - 'Multi-level CA hierarchy management.'
    — Rank 8 first-class CA hierarchy: intermediate_cas table,
    RFC 5280 §3.2 / §4.2.1.9 / §4.2.1.10 service-layer enforcement,
    drain-first retire, FedRAMP / financial-services / internal-PKI
    patterns, byte-equivalence pin for unmigrated deployments.

  - 'Run certctl as your ACME server.'
    — Beyond consuming public ACME CAs, certctl now serves RFC 8555.
    Three client walkthroughs (cert-manager, Caddy, Traefik) cited.

  - 'Cloud-managed targets.'
    — AWS ACM + Azure Key Vault SDK-driven import + atomic rollback.

  - 'Notifications + per-policy multi-channel routing.'
    — Rank 4: AlertChannels matrix + AlertSeverityMap +
    fault-isolating per-channel dispatch + Prometheus counter.

V2 paragraph rewritten:
  Pre-edit: a single 800-word wall-of-text bullet that listed
    everything. Buried Rank 4-8 features in the middle.
  Post-edit: 12 named feature blocks, each one to two sentences.
    Scannable. Cloud targets, ACME server, approval workflow,
    CA hierarchy, multi-channel alerts each get their own
    headline + one-line story + doc link.

Documentation table extended with 5 newly-linked operator runbooks
(all of which existed but were never reachable from the README):
  - docs/acme-server.md
  - docs/approval-workflow.md
  - docs/intermediate-ca-hierarchy.md
  - docs/runbook-cloud-targets.md
  - docs/runbook-expiry-alerts.md

Plus 4 deeper cross-links inside the Enrollment Protocols + 'What
It Does' prose:
  - docs/acme-cert-manager-walkthrough.md
  - docs/acme-caddy-walkthrough.md
  - docs/acme-traefik-walkthrough.md
  - docs/acme-server-threat-model.md

Verified locally:
  All 9 previously-orphaned docs now reachable from README.md.
  No stale numeric claim remains:
    grep -nE '\b(111 routes|80 tools|12 commands|26.page|21 tables)' README.md
    → no matches.
  README size: 426 → 457 lines (+31). Net addition is 4 prose
    blocks + 2 table rows + 5 doc-table rows + 1 V2 paragraph
    rewrite (15 → 12 lines but each line denser).

Strategic framing (CMO hat):
  - ACME server is the cert-manager adoption-funnel headline; gets
    its own table row + dedicated 'What It Does' block.
  - CA hierarchy is the Venafi / EJBCA replacement story for
    FedRAMP / financial-services / internal-PKI procurement;
    explicit market positioning.
  - Approval workflow framed as procurement-checklist closer
    (PCI-DSS L1 / FedRAMP / SOC 2 / HIPAA explicitly named).
  - Cloud-managed targets framed as 'we deploy to your cloud
    secret store' story.

Doc-only commit. No code, no test changes.
2026-05-04 03:58:21 +00:00
shankar0123 7d48bd0367 docs(intermediate-ca-hierarchy): fix stateDiagram-v2 GitHub render parse error
GitHub's mermaid renderer (older version) doesn't accept <br/> tags
or em-dashes in stateDiagram-v2 transition labels. The conversion
shipped in 85649cf used both, which the GitHub markdown view rejects
with:

  Parse error on line 6: ...ding for<br/>already-issued leaves until
                         -----------------------^
  Expecting 'SPACE', 'NL', 'DESCR', '-->', ... got 'INVALID'

(flowchart and sequenceDiagram tolerate <br/> + em-dashes inside
labels — only stateDiagram-v2 trips.)

Fix: shorten transition labels to single-line ASCII and move the
long-form descriptions into 'note right of <state>' blocks. Same
information, renders cleanly on GitHub.

  active --> retiring : Retire(confirm=false)
  retiring --> retired : Retire(confirm=true)
  retired --> [*]

  note right of retiring
      Drain start. CA stops issuing
      NEW children; existing children
      keep issuing until they retire.
  end note

  note right of retired
      Terminal. Refused if active children
      remain (ErrCAStillHasActiveChildren
      → HTTP 409). OCSP keeps responding
      for already-issued leaves until expiry.
  end note

Verified locally:
  Other mermaid blocks added in the audit pass (sequenceDiagram +
    flowchart TD) keep their <br/> + em-dashes — those don't trip
    GitHub's renderer. Only stateDiagram-v2 needed the fix.
  No content lost. The note blocks carry every fact the old
    multi-line transition labels had.

Doc-only commit.
2026-05-04 02:43:47 +00:00
shankar0123 85649cf983 docs: convert remaining ASCII diagrams to mermaid (audit closure)
Audit pass over docs/ found 4 files with non-mermaid (ASCII
box-drawing) diagrams in fenced code blocks. The other 9 doc files
already used mermaid blocks (architecture.md, demo-advanced.md,
ci-pipeline.md, concepts.md, est.md, legacy-est-scep.md, mcp.md,
qa-test-guide.md, scep-intune.md). Rendering parity for everything
in docs/.

Conversions:

  approval-workflow.md
    1 ASCII swimlane → sequenceDiagram with named participants
    (Operator A / CertificateService / Job+ApprovalRequest /
    Operator B / ApprovalService / Scheduler). Same content: the
    same-actor RBAC reject path, the AwaitingApproval gate, the
    audit + Prometheus side effects.

  intermediate-ca-hierarchy.md
    1 lifecycle ASCII → stateDiagram-v2 (created → active → retiring
    → retired with the drain-first refusal annotation).
    3 ASCII tree patterns → 3 flowchart TD diagrams (FedRAMP 4-level
    boundary CA, financial-services 3-level policy CA, internal-PKI
    2-level). Same depth, same path_len + permitted-DNS labels.

  runbook-cloud-targets.md
    1 dual-column ASCII flow → flowchart TD with two subgraphs
    (AWS ACM path, Azure Key Vault path) joining at the audit +
    Prometheus exposer node. Same 6-step deploy sequence on each
    side with the rollback-on-mismatch step explicit.

  runbook-expiry-alerts.md
    1 nested-loop ASCII flow → flowchart TD with three nested
    subgraphs (per-cert main loop / per-threshold inner / per-channel
    fault-isolating dispatch). Same dedup + Prometheus + audit-row
    side effects per channel.

Verified locally:
  Audit re-run: every fenced block in docs/*.md that does NOT open
    with ```mermaid contains zero ASCII box-drawing characters
    (┌ └ │ ─ ━ ═ ║ ╔ ╚ ▼ ▲).
  Mermaid block tally: 39 across 13 files (up from 32 across 9
    files pre-audit). The +7 new blocks are the 4 conversions plus
    the lifecycle + 3 tree patterns expanded out of the single
    intermediate-ca-hierarchy.md ASCII section.

No code or test changes. Doc-only commit.
2026-05-04 02:40:01 +00:00
shankar0123 8908c8ff5c web, docs: IssuerHierarchyPage + sysadmin runbook + connectors row (Rank 8 commit 5)
Final commit of the 5-commit Rank 8 chain. Operator-facing surface
on top of the service + handler layers shipped in commits 1-4.

Frontend (web/src):
  - api/client.ts: 3 new functions + IntermediateCA interface
    (listIntermediateCAs, getIntermediateCA, retireIntermediateCA).
  - pages/IssuerHierarchyPage.tsx: recursive nested <ul> render of
    the hierarchy tree at /issuers/:id/hierarchy. buildHierarchyTree
    is a pure helper that walks the flat list and groups children
    on parent_ca_id; the dendrogram view is parking-lot work tracked
    in WORKSPACE-ROADMAP. Two-phase retire UX surfaces 'Retire…'
    then 'Confirm retire (terminal)' when the row is in retiring
    state. Admin gate is enforced at the API; the page renders the
    backend's 403 as ErrorState for non-admin callers.
  - main.tsx: register the new /issuers/:id/hierarchy route.

CI guard update:
  - scripts/ci-guards/T-1-frontend-page-coverage.sh: add
    IssuerHierarchyPage to the deferred-test allowlist with the
    standard 'why deferred' comment. Admin-gate + recursive build
    semantics are already pinned at the backend layer
    (intermediate_ca_test.go service tests + intermediate_ca_test.go
    handler triplet). Vitest test deferred until next feature
    change touches the page.

Docs:
  - docs/intermediate-ca-hierarchy.md: new operator runbook
    covering:
      Concepts (HierarchyMode 'single' vs 'tree', defense-in-depth
        on key bytes never persisting on rows).
      Lifecycle states + drain-first semantics
        (active → retiring → retired with active-children gate).
      Three deployment patterns: 4-level FedRAMP boundary CA,
        3-level financial-services policy CA, 2-level internal
        PKI.
      RFC 5280 enforcement (§3.2 self-signed, §4.2.1.9 path-length
        tightening, §4.2.1.10 NameConstraints subset).
      Migration from single → tree using the load-bearing
        TestLocal_HierarchyMode_SingleVsTree_ByteIdentical pin as
        the canary.
      API reference + observability (IntermediateCAMetrics
        Prometheus exposure).
      Known limitations + Rank-8 follow-on roadmap.

  - docs/connectors.md: extend the Built-in Local CA section with
    a 'Tree mode (Rank 8)' paragraph describing the new chain
    assembly path + cross-link to docs/intermediate-ca-hierarchy.md.

Roadmap:
  - WORKSPACE-ROADMAP.md: 5 follow-on items under a new
    'Intermediate CA hierarchy extensions (Rank 8 V2 follow-ons)'
    bullet block:
      HSM-backed roots (PKCS#11 / cloud KMS drivers via existing
        signer.Driver interface — no service-layer change needed).
      Automated CA rotation (parallel-validity windows ahead of
        expiry).
      Intra-hierarchy CRL chaining (per-CA CRL endpoints stitched
        at issue time).
      NameConstraints policy templates (FedRAMP / financial /
        internal PKI declarative templates instead of hand-rolled
        JSON).
      D3 dendrogram visualization (separate page so the existing
        list view stays the default + the dep stays opt-in).

Verified locally:
  gofmt: clean.
  go vet ./...: exit 0.
  tsc --noEmit (web/): exit 0 (no TypeScript errors).
  go test -short -count=1 ./internal/api/handler/... + service +
    local: ok across all three packages, 4-5s each.
  All 24 CI guards: clean
    (T-1 frontend-page-coverage with the new
     IssuerHierarchyPage allowlist entry; openapi-handler-parity,
     M-008 admin-gate, every other guard untouched).

Rank 8 chain complete:
  66d2af3  domain, migrations: IntermediateCA type + intermediate_cas
           + Issuer.HierarchyMode (commit 1)
  fb54ebc  service: IntermediateCAService + IntermediateCAMetrics
           + RFC 5280 enforcement (commit 2)
  62523fb  service: 10 IntermediateCAService tests + in-memory fake
           repo (commit 2.5)
  ae597f7  local: tree-mode chain assembly + byte-equivalence pin
           (commit 3 — load-bearing backwards-compat refuse-to-ship
           pin in TestLocal_HierarchyMode_SingleVsTree_ByteIdentical)
  34adcfb  api, handler: 4 admin-gated CA hierarchy endpoints +
           OpenAPI (commit 4)
  HEAD     web, docs: IssuerHierarchyPage + sysadmin runbook +
           connectors row (this commit)

Reference: cowork/rank-8-intermediate-ca-hierarchy-prompt.md, commit 5.
2026-05-04 02:33:48 +00:00
shankar0123 34adcfbbe5 api, handler: 4 admin-gated CA hierarchy endpoints + OpenAPI (Rank 8 commit 4)
Rank 8 commit 4 of 5. The API + RBAC layer that operators drive
the new hierarchy management surface from.

Endpoints (all admin-gated via middleware.IsAdmin; non-admin Bearer
callers get 403):
  POST /api/v1/issuers/{id}/intermediates
       Discriminator on body shape:
         empty parent_ca_id + root_cert_pem + key_driver_id
           → CreateRoot (registers operator-supplied root CA).
         parent_ca_id non-empty
           → CreateChild (signs new sub-CA cert under parent).
       Service-layer error → HTTP code mapping:
         ErrCANotSelfSigned         → 400
         ErrCAKeyMismatch           → 400
         ErrPathLenExceeded         → 400
         ErrNameConstraintExceeded  → 400
         ErrInvalidCertPEM          → 400
         ErrParentCANotActive       → 409
         ErrIntermediateCANotFound  → 404
         (other)                    → 500
  GET  /api/v1/issuers/{id}/intermediates
       Returns flat list ordered by created_at; caller renders the
       tree from each row's parent_ca_id (nil = root).
  GET  /api/v1/intermediates/{id}
       Single-row detail.
  POST /api/v1/intermediates/{id}/retire
       Two-phase: confirm=false → active→retiring; confirm=true →
       retiring→retired with active-children check (drain-first
       semantics; ErrCAStillHasActiveChildren → 409).

Files changed:
  internal/api/handler/intermediate_ca.go            — 4 handlers
                                                       + handler-defined
                                                       service interface
                                                       (dependency
                                                       inversion).
  internal/api/handler/intermediate_ca_test.go       — 8 test variants
                                                       (M-008 admin-
                                                       gate triplet
                                                       complete).
  internal/api/handler/m008_admin_gate_test.go       — register the
                                                       new admin-gated
                                                       handler in
                                                       AdminGatedHandlers
                                                       so the M-008
                                                       coherence
                                                       scanner stays
                                                       green.
  internal/api/router/router.go                      — 4 r.Register
                                                       calls + new
                                                       IntermediateCAs
                                                       field on
                                                       HandlerRegistry.
  cmd/server/main.go                                 — wire the
                                                       postgres repo +
                                                       service +
                                                       handler. Reuses
                                                       the same
                                                       signer.FileDriver
                                                       instance the
                                                       OCSP responder
                                                       bootstrap path
                                                       feeds.
  api/openapi.yaml                                   — 4 new
                                                       operationIds,
                                                       full body
                                                       schema + status-
                                                       code dispatch.

Tests (8 in this commit):
  TestIntermediateCA_Handler_NonAdmin_Returns403       (admin gate
    — table-driven across all 4 endpoints)
  TestIntermediateCA_Handler_AdminExplicitFalse_Returns403
    (defensive: AdminKey present but false ≠ AdminKey absent)
  TestIntermediateCA_Handler_AdminPermitted_ForwardsActor
    (admin actor forwarded to service for audit attribution)
  TestIntermediateCA_HandlerCreate_RootDispatch
    (body discriminator: empty parent_ca_id → CreateRoot)
  TestIntermediateCA_HandlerCreate_ChildDispatch
    (body discriminator: parent_ca_id present → CreateChild)
  TestIntermediateCA_HandlerCreate_BadRequestOnMissingRootBundle
    (validation: no parent + no root bundle → 400)
  TestIntermediateCA_HandlerCreate_ServiceErrorMappings
    (table-driven: 7 service errors → expected HTTP codes)
  TestIntermediateCA_HandlerRetire_TwoPhaseConfirm
    (confirm=false then confirm=true forwarded correctly)
  TestIntermediateCA_HandlerRetire_StillHasActiveChildren_Returns409
    (drain-first contract — 409 not 500)

Verified locally:
  gofmt: clean.
  go vet ./...: exit 0.
  go test -short -count=1 ./internal/api/handler/...: ok 4.498s.
  bash scripts/ci-guards/openapi-handler-parity.sh: clean
    (router routes: 182, openapi operations: 148; the +4 new routes
    have +4 new operationIds — parity preserved).
  bash scripts/ci-guards/* (all 24 guards): clean.

Out of scope of THIS commit (commit 5):
  - web/src/pages/IssuerHierarchyPage.tsx (recursive tree render).
  - docs/intermediate-ca-hierarchy.md sysadmin runbook (FedRAMP /
    financial-services / internal-PKI patterns).
  - docs/connectors.md hierarchy_mode row.
  - WORKSPACE-ROADMAP entries (HSM-backed roots, automated
    rotation, CRL chaining, NameConstraints templates, D3
    dendrogram).

Reference: cowork/rank-8-intermediate-ca-hierarchy-prompt.md, commit 4.
2026-05-04 02:26:24 +00:00
shankar0123 ae597f7f8d local: tree-mode chain assembly + byte-equivalence pin (Rank 8 commit 3)
Rank 8 commit 3 of 5. Load-bearing connector rewrite that activates
the first-class CA hierarchy surface shipped by commits 1-2.

Local connector changes:
  - New ChainAssembler interface (single-method seam) defined in the
    connector package — *service.IntermediateCAService satisfies it
    implicitly. Avoids the import cycle that would arise from
    pulling internal/service into internal/connector/issuer/local.

  - Three new optional fields on Connector: hierarchyMode,
    chainAssembler, treeIssuingCAID. Default zero values keep the
    pre-Rank-8 single-sub-CA flow byte-identical (no operator on
    the historical path sees any change in wire bytes).

  - Three new setters: SetHierarchyMode, SetChainAssembler,
    SetTreeIssuingCAID. Wired in cmd/server/main.go in commit 4
    when the issuer's HierarchyMode column is read at boot.

  - resolveChainPEM helper centralizes the dispatch:
      tree mode + ChainAssembler set + treeIssuingCAID set
        → call AssembleChain over intermediate_cas
      otherwise (incl. tree mode with incomplete wiring)
        → fall back to historical c.caCertPEM
    Defense in depth: a misconfigured operator gets a working
    issuance, not a nil-deref panic.

  - IssueCertificate + RenewCertificate both delegate ChainPEM
    population to resolveChainPEM. The cert generation path
    (generateCertificate) is untouched — same key, same template,
    same signing.

Tests (internal/connector/issuer/local/local_hierarchy_test.go):

  TestLocal_HierarchyMode_SingleVsTree_ByteIdentical ← LOAD-BEARING
    THE refuse-to-ship pin. Two connectors against the same on-disk
    CA cert+key:
      - A: pre-Rank-8 single-sub-CA mode (HierarchyMode unset).
      - B: tree mode wired against an in-memory ChainAssembler
        whose 1-level chain matches A's caCertPEM byte-for-byte.
    Asserts:
      1. resA.ChainPEM == resB.ChainPEM (the byte-identical pin).
      2. resA.ChainPEM == fixture root cert PEM (real fact about
         the wire format, not internal consistency).
    Operators on single mode keep getting byte-identical bytes.
    Operators flipping to tree with a 1-level shim see no change.
    Zero behavioral drift for unmigrated deployments.

  TestLocal_HierarchyMode_Tree_LeafChainIncludesAllAncestors
    Multi-level pin. 4-level synthetic chain (root → policy →
    issuingA → issuingB-leaf-CA). Asserts:
      - 4 CERTIFICATE blocks in ChainPEM.
      - Leaf-first ordering (issuingB.CN, issuingA.CN, policy.CN,
        root.CN at depths 0..3).
    This is what tree mode buys operators in exchange for the
    migration overhead.

  TestLocal_HierarchyMode_FallsBackToSingleWhenWiringIncomplete
    Defensive fallback pin. HierarchyMode='tree' but
    ChainAssembler nil + treeIssuingCAID '' → ChainPEM falls back
    to caCertPEM. No panic, no lying field.

Verified locally:
  gofmt: clean.
  go vet ./...: exit 0.
  go test -short -count=1 -run TestLocal_HierarchyMode ./internal/connector/issuer/local/...
    PASS (3/3, including the load-bearing byte-identical pin).
  go test -short -count=1 ./internal/connector/issuer/local/...: ok 4.358s
    (every existing local-connector test still green — backwards
    compat byte-for-byte at the test layer too).

Out of scope of THIS commit (commit 4):
  - 4 admin-gated handler endpoints + OpenAPI extension.
  - cmd/server/main.go wiring that reads Issuer.HierarchyMode at
    boot and calls SetHierarchyMode + SetChainAssembler +
    SetTreeIssuingCAID on the local connector instance.

Reference: cowork/rank-8-intermediate-ca-hierarchy-prompt.md, commit 3.
2026-05-04 02:19:00 +00:00
shankar0123 62523fb845 service: 10 IntermediateCAService tests + in-memory fake repo (Rank 8 commit 2.5)
Service-layer pin for Rank 8. The fake IntermediateCARepository's
WalkAncestry mirrors the postgres recursive-CTE semantics
(leaf-first ordering, terminate at parent_ca_id IS NULL) so the
AssembleChain pin carries the same weight the production repo would.

Tests:
  TestIntermediateCA_CreateRoot_RegistersOperatorSuppliedSelfSigned
    Happy path. RFC 5280 §3.2 self-signed root + matching key gets
    persisted with parent_ca_id=NULL, state=active, KeyDriverID=...

  TestIntermediateCA_CreateRoot_RejectsNonSelfSigned
    RFC 5280 §3.2 enforcement. Cert whose embedded public key
    doesn't match the actual signer fails CheckSignatureFrom →
    ErrCANotSelfSigned.

  TestIntermediateCA_CreateRoot_RejectsKeyMismatch
    Operator-boundary defense in depth. Cert is well-formed
    self-signed but the supplied keyDriverID resolves to a
    different key → ErrCAKeyMismatch.

  TestIntermediateCA_CreateChild_PathLenTighteningEnforced
    RFC 5280 §4.2.1.9 enforcement. Child whose path-len equals or
    exceeds parent's → ErrPathLenExceeded. Strictly-tighter child
    succeeds.

  TestIntermediateCA_CreateChild_NameConstraintsSubset
    RFC 5280 §4.2.1.10 enforcement. Widening rejected
    ("evil.com" outside parent's "example.com"); subdomain
    narrowing succeeds ("internal.example.com").

  TestIntermediateCA_AssembleChain_4DeepHierarchy ← LOAD-BEARING
    The pin the local connector tree-mode delegates to. Builds
    root → policy → issuing-A → issuing-B and asserts AssembleChain
    returns 4 CERTIFICATE blocks in leaf-to-root order with
    matching subject CommonNames at each depth.

  TestIntermediateCA_Retire_RefusesIfActiveChildren
    Drain-first semantics. retiring → retired with active children
    refuses with ErrCAStillHasActiveChildren.

  TestIntermediateCA_Retire_TwoPhaseConfirm
    First call: active → retiring (no confirm). Second call without
    confirm: surfaces "pass confirm=true". Second call with
    confirm: retiring → retired.

  TestIntermediateCA_MetricsRecordedPerOutcome
    Snapshot pin. CreateRoot bumps create_root, CreateChild bumps
    create_child, Retire(active) bumps retire_retiring, all
    dimensioned by issuer_id.

  TestIntermediateCA_LoadHierarchy_FlatList
    Returns every CA for an issuer ordered by created_at; caller
    renders the tree from parent_ca_id.

Test infrastructure:
  fakeIntermediateCARepo                 — sync.Mutex-guarded map.
                                           WalkAncestry walks
                                           parent_ca_id from leafID
                                           to root (or terminates on
                                           cycle, defense-in-depth).
                                           Compile-time interface
                                           guard.
  testCAFixture                          — mints a self-signed root
                                           cert+key in process,
                                           Adopt()s the key under
                                           a stable ref so CreateRoot
                                           can resolve it.
  newTestService                         — wires IntermediateCAService
                                           with fake repo +
                                           signer.MemoryDriver +
                                           mockAuditRepo (already
                                           lives in testutil_test.go)
                                           + IntermediateCAMetrics.

Verified locally:
  gofmt: clean.
  go vet ./...: exit 0.
  go test -short -count=1 -run TestIntermediateCA ./internal/service/...
    PASS (10/10)
  go test -short -count=1 ./internal/service/...: ok 3.844s

Reference: cowork/rank-8-intermediate-ca-hierarchy-prompt.md, commit 2.5.
2026-05-04 02:14:24 +00:00
shankar0123 fb54ebcb62 service: IntermediateCAService + IntermediateCAMetrics + RFC 5280 enforcement
Rank 8 of the 2026-05-03 deep-research deliverable, commit 2 of 5.
Service-layer wiring for first-class N-level CA hierarchy management.
The connector rewrite that activates this surface lands in commit 3.

Files added:
  internal/service/intermediate_ca.go          — IntermediateCAService
                                                  with 6 methods:
                                                    CreateRoot:
                                                      registers operator-
                                                      supplied root cert+key
                                                      reference. Validates
                                                      RFC 5280 §3.2 self-
                                                      signed (subject ==
                                                      issuer + signature
                                                      verifies). Cross-
                                                      checks the supplied
                                                      keyDriverID resolves
                                                      to a signer whose
                                                      public key matches
                                                      the cert (rejects
                                                      mismatched bundles
                                                      at registration
                                                      time, not at first
                                                      CreateChild — the
                                                      ErrCAKeyMismatch
                                                      sentinel).
                                                    CreateChild:
                                                      generates child key
                                                      via signer.Driver,
                                                      signs the cert via
                                                      the parent's signer.
                                                      Enforces RFC 5280
                                                      §4.2.1.9 (path-len
                                                      tightening) +
                                                      §4.2.1.10
                                                      (NameConstraints
                                                      subset semantics) at
                                                      service layer fail-
                                                      closed. Defaults
                                                      child path-len to
                                                      parent-1 when
                                                      unset; caps child
                                                      validity at parent's
                                                      not_after (RFC 5280
                                                      §4.1.2.5).
                                                    Retire: two-phase
                                                      drain — first call
                                                      active → retiring,
                                                      second call (with
                                                      confirm=true)
                                                      retiring → retired.
                                                      Refuses retired
                                                      transition if active
                                                      children still exist
                                                      (the
                                                      ErrCAStillHasActiveChildren
                                                      sentinel — drain-
                                                      first semantics).
                                                    Get / LoadHierarchy:
                                                      thin repo wrappers.
                                                    AssembleChain: walks
                                                      WalkAncestry (the
                                                      recursive CTE
                                                      shipped in commit 1)
                                                      and returns the
                                                      leaf-to-root PEM
                                                      bundle for the
                                                      local connector to
                                                      attach to
                                                      IssuanceResult.

  internal/service/intermediate_ca_metrics.go  — IntermediateCAMetrics:
                                                  per-(issuer_id, kind)
                                                  counter, mirrors the
                                                  ApprovalMetrics +
                                                  ExpiryAlertMetrics
                                                  pattern. RecordCreate
                                                  (root/child) +
                                                  RecordRetire
                                                  (retiring/retired).
                                                  SnapshotIntermediateCA
                                                  for the Prometheus
                                                  exposer.

Defense in depth retained:
  - NEVER persist CA private key bytes in the row. KeyDriverID is the
    only key reference; signer.Driver.Load resolves it at signing time.
  - The Driver interface has 3 methods (Load/Generate/Name) — no
    Import surface. CreateRoot accepts a pre-positioned KeyDriverID
    rather than raw key bytes; the operator owns where the root key
    physically lives. Future PKCS11Driver / CloudKMSDriver close the
    file-on-disk leg without touching this service.

Verified locally:
  gofmt: clean.
  go vet ./internal/service/...: exit 0.
  go build ./internal/service/...: exit 0.

Deferred to commit 2.5 (or fold into commit 3, operator's call):
  - 9 service-level tests including:
    * TestIntermediateCA_CreateRoot_RegistersOperatorSuppliedSelfSigned
    * TestIntermediateCA_CreateRoot_RejectsNonSelfSigned
    * TestIntermediateCA_CreateRoot_RejectsKeyMismatch
    * TestIntermediateCA_CreateChild_PathLenTighteningEnforced
    * TestIntermediateCA_CreateChild_NameConstraintsSubset
    * TestIntermediateCA_AssembleChain_4DeepHierarchy ← LOAD-BEARING
    * TestIntermediateCA_Retire_RefusesIfActiveChildren
    * TestIntermediateCA_Retire_TwoPhaseConfirm
    * TestIntermediateCA_MetricsRecordedPerOutcome

  Test setup needs: in-memory IntermediateCARepository fake +
  signer.MemoryDriver (already exists) + helper to generate test root
  cert+key. Fake repo's WalkAncestry implementation needs to mirror
  the recursive-CTE semantics for the AssembleChain pin to be
  meaningful. Total ~500 lines of test code; non-trivial setup.

Out of scope of THIS commit (commits 3-5):
  - Local connector rewrite + byte-equivalence pin
    (TestLocal_HierarchyMode_SingleVsTree_ByteIdentical).
  - 4 admin-gated handler endpoints + OpenAPI extension.
  - web/src/pages/IssuerHierarchyPage.tsx.
  - docs/intermediate-ca-hierarchy.md sysadmin runbook.
  - cmd/server/main.go wiring.

Reference: cowork/rank-8-intermediate-ca-hierarchy-prompt.md.
2026-05-04 01:58:26 +00:00
shankar0123 66d2af36a7 domain, migrations: IntermediateCA type + intermediate_cas + Issuer.HierarchyMode
Rank 8 of the 2026-05-03 deep-research deliverable, commit 1 of 5
(cowork/rank-8-intermediate-ca-hierarchy-prompt.md). Closes the multi-
level CA hierarchy gap for FedRAMP boundary-CA, financial-services
policy-CA, and OT network-CA deployments where regulator-mandated
certificate-policy separation requires multiple layers (root → policy
→ issuing).

This commit lands ONLY the foundation — schema, types, repository
interface, postgres implementation. No service / connector / handler
wiring yet. The 5-commit chain is bisectable: this commit can ship
with no operator-visible behavior change until commits 2-5 wire the
service layer + the local-connector tree-mode + admin API + GUI tree
view + operator runbook. The default value for issuers.hierarchy_mode
is 'single' so every existing operator's behavior is byte-identical
post-migration.

Existing scaffolding REUSED (not redefined):
  - internal/crypto/signer.Driver seam — every IntermediateCA carries
    a key_driver_id pointing at the signer.Driver instance that owns
    its private key. Defense in depth: NEVER persist key bytes in a
    row. FileDriver is the production default; future PKCS11Driver /
    CloudKMSDriver close the disk-exposure leg via the same seam.
  - issuers.id row — the new intermediate_cas FK references it.

Files added:
  internal/domain/intermediate_ca.go              — IntermediateCA type,
                                                     IntermediateCAState
                                                     closed enum (active /
                                                     retiring / retired),
                                                     IsValidIntermediateCAState
                                                     + IsTerminal helpers,
                                                     NameConstraint struct
                                                     (RFC 5280 §4.2.1.10
                                                     permitted+excluded
                                                     subtree subset
                                                     semantics for service-
                                                     layer enforcement),
                                                     HierarchyModeSingle /
                                                     HierarchyModeTree
                                                     constants.
  internal/repository/postgres/intermediate_ca.go — IntermediateCARepository
                                                     impl: Create (ica-<slug>
                                                     ID gen, JSONB +
                                                     nullable-column round-
                                                     trip, lib/pq 23505 →
                                                     ErrAlreadyExists),
                                                     Get, ListByIssuer,
                                                     ListChildren,
                                                     UpdateState,
                                                     GetActiveRoot,
                                                     WalkAncestry (recursive
                                                     CTE — single SQL
                                                     round-trip, O(depth)
                                                     rows, leaf-first
                                                     ordering).
  migrations/000028_intermediate_ca_hierarchy.{up,down}.sql
                                                  — idempotent schema.
                                                     issuers.hierarchy_mode
                                                     VARCHAR(20) DEFAULT
                                                     'single'. New
                                                     intermediate_cas table
                                                     with FKs to
                                                     issuers / self
                                                     (parent_ca_id) +
                                                     CHECK constraints
                                                     (closed-enum state,
                                                     not_after >
                                                     not_before, no self-
                                                     parent) + 6 indexes
                                                     (partial-unique
                                                     active root per
                                                     issuer, partial-
                                                     unique name per
                                                     issuer, owning
                                                     issuer, parent,
                                                     state, expiring).

Files modified:
  internal/domain/connector.go      — adds Issuer.HierarchyMode field
                                       with full doc comment + JSON tag.
                                       Empty string ≡ single mode for
                                       back-compat.
  internal/repository/interfaces.go — adds IntermediateCARepository
                                       interface (7 methods).

Verified locally:
  gofmt: clean.
  go vet ./internal/domain/... ./internal/repository/...: exit 0.
  go build ./internal/domain/... ./internal/repository/...: exit 0.

Out of scope for this commit (lands in commits 2-5):
  - service/intermediate_ca.go (CreateRoot / CreateChild / Retire /
    LoadHierarchy / AssembleChain + RFC 5280 §4.2.1.9 path-len +
    §4.2.1.10 NameConstraints subset enforcement + 9 service tests).
  - local connector rewrite + byte-equivalence pin
    (TestLocal_HierarchyMode_SingleVsTree_ByteIdentical — the load-
    bearing backwards-compat refusal-to-ship test).
  - 4 admin-gated handler endpoints + OpenAPI extension + handler tests.
  - web/src/pages/IssuerHierarchyPage.tsx.
  - docs/intermediate-ca-hierarchy.md sysadmin runbook + connectors.md
    row + WORKSPACE-ROADMAP follow-ons.

Reference: cowork/rank-8-intermediate-ca-hierarchy-prompt.md.
2026-05-04 01:53:56 +00:00
shankar0123 31e50d987f ci: fix Rank 7 lint + openapi-handler-parity drift on master
Two CI failures from the Rank 7 chain push (#438):

  Go Build & Test — staticcheck ST1021:
    internal/service/approval_metrics.go:97  comment for ApprovalDecisionEntry
                                               doesn't start with the type name
    internal/service/approval_metrics.go:130 comment for ApprovalPendingAgeSnapshot
                                               doesn't start with the type name

  Frontend Build — scripts/ci-guards/openapi-handler-parity.sh:
    4 router routes have no OpenAPI operationId:
      GET    /api/v1/approvals
      GET    /api/v1/approvals/{id}
      POST   /api/v1/approvals/{id}/approve
      POST   /api/v1/approvals/{id}/reject
    The Rank 7 commit-3 spec deferred OpenAPI extension to commit 4 with a
    'batched alongside the integration changes' note; commit 4 didn't actually
    add them. This commit closes that gap.

Fixes:

  approval_metrics.go — split the doc comment that was attached to
    SnapshotApprovalDecisions (the function) but visually preceded
    ApprovalDecisionEntry (the type), so the type appeared to staticcheck
    as having a comment that named the function instead of the type.
    Same fix on ApprovalPendingAgeSnapshot. Now each exported type has its
    own type-name-leading comment per Go convention.

  api/openapi.yaml — added 4 new operationIds (listApprovalRequests,
    getApprovalRequest, approveApprovalRequest, rejectApprovalRequest)
    + new ApprovalRequest schema component under components/schemas.
    Inline 401 response (the Unauthorized component does not exist in
    this spec; the canonical pattern in the rest of the file is inline
    'description: Authentication required'). The two-person integrity
    contract surface is documented in the description of the approve /
    reject endpoints so external readers see the RBAC contract from the
    spec alone.

Verified locally:
  go vet ./internal/service/...:                      exit 0.
  scripts/ci-guards/openapi-handler-parity.sh:        clean (140 ops vs 174 routes,
                                                       36 documented exceptions).

Third CI failure (image-and-supply-chain) was a transient apt-fetch
'Connection reset by peer' from deb.debian.org while pulling
libasan6_10.2.1-6_amd64.deb. Not a code issue; just re-run the workflow.
No code change needed.
2026-05-04 01:35:30 +00:00
shankar0123 b601928e1c docs(approval-workflow): drop Infisical reference from operator playbook
The operator-facing approval-workflow.md is the public-readable docs
page; the 'Infisical deep-research deliverable' framing is internal
project context that doesn't belong there. Internal source comments +
research docs in cowork/ keep the original framing as the historical
record.
2026-05-04 01:18:59 +00:00
shankar0123 aebfd8bd7c Revert "chore: drop 'Infisical' label from internal references"
This reverts commit 19706e56b3.
2026-05-04 01:18:15 +00:00
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
shankar0123 03c61f4c20 scheduler, certificate, renewal: gate issuance on profile-driven approval
Closes Rank 7 of the 2026-05-03 Infisical deep-research deliverable
(cowork/infisical-deep-research-results.md Part 5). Pre-fix, certctl
issued certificates unattended — every renewal-loop tick that crossed
a renewal threshold created a Job at Status=Pending which the
scheduler dispatched directly to the issuer connector. PCI-DSS Level
1, FedRAMP Moderate / High, SOC 2 Type II, and HIPAA-regulated PHI
customers all ask the same procurement question: "How do you enforce
two-person integrity on cert issuance?" Today's answer: "We don't."
After this commit chain: "Per-profile RequiresApproval=true creates a
parallel ApprovalRequest row; the renewal-loop creates the Job at
Status=AwaitingApproval; an authorized approver (different from the
requester per the same-actor RBAC check) calls
POST /api/v1/approvals/{id}/approve, transitioning the Job to
Pending; the scheduler picks it up."

This commit (4 of 4) wires the gate into the manual TriggerRenewal
entry point + main.go service construction + Config.Approval +
docs + WORKSPACE-ROADMAP follow-up entries. The previous commits
in the chain shipped:
  - 1 (2025275): domain types + migration + repository
  - 2 (8043e2b): ApprovalService + ApprovalMetrics + 8 service tests
  - 3 (81632eb): 4 API endpoints + handler RBAC tests + router wiring

Files modified:
  cmd/server/main.go              - Constructs approvalRepo +
                                     approvalMetrics + approvalService
                                     + approvalHandler. Wires
                                     CertificateService via
                                     SetApprovalService + SetProfileRepo.
                                     Logs a WARN line at boot when
                                     CERTCTL_APPROVAL_BYPASS=true so
                                     production operators alert on the
                                     log line. Adds Approvals to the
                                     HandlerRegistry.

  internal/config/config.go       - Adds top-level ApprovalConfig
                                     {BypassEnabled bool} sub-config
                                     + CERTCTL_APPROVAL_BYPASS env var
                                     loader. Doc comment cites the
                                     compliance-detection SQL query
                                     (SELECT count FROM audit_events
                                     WHERE actor='system-bypass') so
                                     auditors find the right pattern.

  internal/service/certificate.go - Adds approvalSvc + profileRepo
                                     fields to CertificateService +
                                     SetApprovalService /
                                     SetProfileRepo setters. Extends
                                     TriggerRenewal: looks up the
                                     profile, checks RequiresApproval,
                                     creates the Job at
                                     JobStatusAwaitingApproval (override
                                     the keygen-mode default), then
                                     calls approvalSvc.RequestApproval
                                     to create the parallel
                                     ApprovalRequest row. On
                                     RequestApproval failure, cancels
                                     the orphan Job (defense in depth —
                                     without this, a partial failure
                                     would leave the job stuck at
                                     AwaitingApproval forever). Profile-
                                     lookup failures fall back to the
                                     unattended path (fail-open from
                                     the operator's perspective +
                                     fail-loud via slog.Warn).

Files added:
  docs/approval-workflow.md       - Sysadmin-grade operator runbook:
                                      end-to-end ASCII flowchart
                                      (operator A triggers → operator
                                      B approves → scheduler dispatches),
                                      configuration recipe, RBAC contract
                                      (the load-bearing two-person
                                      integrity rule), operator playbooks
                                      for "I need to approve a renewal"
                                      and "approval timed out", PCI-DSS
                                      6.4.5 / NIST 800-53 SA-15 / SOC 2
                                      CC6.1 / HIPAA control mapping
                                      table, bypass-mode warnings with
                                      the exact compliance-detection SQL
                                      query, Prometheus metric reference,
                                      future free V2 work pointers.

Out of scope of THIS commit (deferred follow-on, not blocking the rest):
  - RenewalService.CheckExpiringCertificates auto-renewal-loop gate.
    The manual TriggerRenewal entry point is gated and the job-level
    timeout reaper already covers AwaitingApproval; the auto-renewal
    gate adds parity. Trivial to add — one block in renewal.go that
    mirrors the certificate.go::TriggerRenewal gate. Tracked in
    WORKSPACE-ROADMAP under the Approval-workflow extensions section.
  - Scheduler reaper extension calling ApprovalService.ExpireStale.
    Today: when the existing reaper times out an AwaitingApproval job,
    the parallel ApprovalRequest row stays at state=pending. The audit
    timeline is still correct (the job-side audit row records the
    timeout) but the dashboard shows a row that no longer needs human
    review. Trivial to wire — one method call in the existing
    scheduler tick. Same WORKSPACE-ROADMAP follow-on.
  - api/openapi.yaml extensions for the 4 new operationIds.
    The HTTP contract is pinned by the handler-level tests; OpenAPI
    is documentation that mirrors the contract.
  - docs/connectors.md `requires_approval` row in the CertificateProfile
    config table. Tracked in the same follow-on; the new
    docs/approval-workflow.md is the canonical reference.

Workspace-level updates (in cowork/, not under certctl/ git control —
applied separately):
  WORKSPACE-ROADMAP.md            - "Approval-workflow extensions"
                                     section under "Future Free V2 Work"
                                     covering M-of-N chains + time-
                                     windowed auto-approve + external
                                     ticketing + per-owner routing +
                                     delegation. All items free under
                                     BSL — no V3-Pro framing per the
                                     2026-05-03 strategy pivot (open
                                     core under BSL; future revenue =
                                     managed-service hosting).

Verified locally:
  gofmt: clean.
  go vet ./...: exit 0.
  go build ./...: exit 0 — full repo links cleanly with the new
    Approval wiring.
  go test -short -count=1 -run TestApproval
    ./internal/service/... ./internal/api/handler/...:
    ok 0.005s for both packages — all 11 approval tests green
    (8 service-level + 3 handler-level).

Reference: cowork/rank-7-approval-workflow-primitive-prompt.md.
Commits: 20252758043e2b81632eb → THIS COMMIT.
2026-05-04 01:12:07 +00:00
shankar0123 81632eb0f3 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.
2026-05-04 01:05:16 +00:00
shankar0123 8043e2bbac service: ApprovalService + ApprovalMetrics + 8 table-driven tests
Rank 7 of the 2026-05-03 Infisical deep-research deliverable, commit 2 of 4
(cowork/rank-7-approval-workflow-primitive-prompt.md). Builds on the
foundation in commit 2025275 — wires the service layer that drives the
approval workflow. Still no handler / integration wiring; commits 3-4
land that.

Files added:
  internal/service/approval.go         - ApprovalService struct + 6
                                          methods: RequestApproval,
                                          Approve, Reject, ListPending,
                                          List, Get, ExpireStale.
                                          Same-actor RBAC check
                                          (ErrApproveBySameActor) at
                                          both Approve and Reject; the
                                          load-bearing two-person
                                          integrity gate. Bypass mode
                                          short-circuits via
                                          approveInternal(outcome=
                                          "bypassed", actorType=System).
                                          Audit + metric emission per
                                          decision via shared
                                          recordAudit helper. Tolerates
                                          nil AuditService for tests.
                                          Service depends on a narrow
                                          JobStatusUpdater interface
                                          (single-method) rather than
                                          the full repository.JobRepository
                                          — production wiring satisfies
                                          it implicitly via postgres'
                                          existing UpdateStatus.

  internal/service/approval_metrics.go - ApprovalMetrics: thread-safe
                                          counter table (decisions
                                          counter dimensioned by
                                          outcome × profile_id) + a
                                          custom durationHistogram for
                                          pending-age (le buckets:
                                          60, 300, 1800, 3600, 21600,
                                          86400, +Inf — 1m, 5m, 30m,
                                          1h, 6h, 24h, beyond).
                                          Snapshot* methods return the
                                          Prometheus exposer's input
                                          shapes. Mirrors the
                                          ExpiryAlertMetrics +
                                          VaultRenewalMetrics pattern
                                          from prior ranks.

  internal/service/approval_test.go    - 8 table-driven tests with
                                          tight in-package fakes
                                          (fakeApprovalRepo +
                                          fakeJobStateRepo):
                                            TestApproval_RequestCreatesPendingRow_BypassDisabled
                                            TestApproval_BypassMode_AutoApprovesWithSystemBypassActor
                                            TestApproval_Approve_TransitionsJobFromAwaitingApprovalToPending
                                            TestApproval_Reject_TransitionsJobFromAwaitingApprovalToCancelled
                                            TestApproval_Approve_RejectsSameActor
                                              ↑ THE LOAD-BEARING TWO-PERSON
                                                INTEGRITY TEST. PCI-DSS 6.4.5
                                                / NIST 800-53 SA-15 / SOC 2
                                                CC6.1 compliance auditors
                                                pattern-match against this.
                                                Pins same-actor rejection on
                                                both Approve and Reject paths;
                                                pins success when a different
                                                actor approves.
                                            TestApproval_Approve_RejectsAlreadyDecided
                                            TestApproval_ExpireStale_TransitionsPendingToExpired_AndCancelsJob
                                            TestApproval_MetricCounterIncrements

Verified:
  gofmt: clean.
  go vet ./internal/service/...: exit 0.
  go test -short -count=1 -run TestApproval ./internal/service/...:
    ok 0.005s — all 8 tests green.

Out of scope for this commit (lands in commits 3-4):
  - api/handler/approval.go (5 endpoints + handler-side RBAC).
  - api/openapi.yaml extensions.
  - Integration into CertificateService.TriggerRenewal +
    RenewalService.CheckExpiringCertificates + Scheduler.ReapTimedOutJobs.
  - cmd/server/main.go wiring of ApprovalService + ApprovalMetrics.
  - Config.Approval.BypassEnabled + CERTCTL_APPROVAL_BYPASS env var.
  - docs/connectors.md row + docs/approval-workflow.md runbook.

Reference: cowork/rank-7-approval-workflow-primitive-prompt.md.
2026-05-04 01:01:53 +00:00
shankar0123 2025275b43 domain, migrations: ApprovalRequest type + issuance_approval_requests + RequiresApproval
Rank 7 of the 2026-05-03 Infisical deep-research deliverable, commit 1 of 4
(cowork/rank-7-approval-workflow-primitive-prompt.md). The four-commit
chain ships the issuance approval-workflow primitive (request → human review
→ CA call) closing the two-person integrity / four-eyes principle
procurement gap for PCI-DSS Level 1, FedRAMP Moderate / High, SOC 2
Type II, and HIPAA-regulated PHI deployments.

This commit lands ONLY the foundation — schema, types, repository
interface, postgres implementation. No service / handler wiring yet.
The four-commit shape is bisectable: the schema can land in production
behind a flag (via the default RequiresApproval=false on every existing
profile) without any operator-visible behavior change until commits 2-4
wire the surrounding workflow.

Existing scaffolding REUSED (not redefined here):
  - JobStatusAwaitingApproval enum value (internal/domain/job.go).
  - JobRepository.ListTimedOutAwaitingJobs (postgres reaper query).
  - Config.Scheduler.AwaitingApprovalTimeout (env-mapped via
    CERTCTL_JOB_AWAITING_APPROVAL_TIMEOUT, default 168h = 7 days).
  - Scheduler.SetAwaitingApprovalTimeout wiring.

Files added:
  internal/domain/approval.go              - ApprovalRequest type,
                                              ApprovalState closed enum
                                              (pending/approved/rejected/
                                              expired), IsValidApprovalState +
                                              IsTerminal helpers, outcome
                                              const block + bypass-actor
                                              sentinel.
  internal/repository/postgres/approval.go - ApprovalRepository
                                              implementation: Create
                                              (ar-<slug> ID gen + JSONB
                                              metadata round-trip + lib/pq
                                              23505 → ErrAlreadyExists
                                              translation), Get, GetByJobID,
                                              List (paginated with state /
                                              cert / requester filters),
                                              UpdateState (pending→terminal
                                              transitions only, with
                                              already-terminal disambiguation),
                                              ExpireStale (bulk reaper,
                                              decided_by='system-reaper').
  migrations/000027_approval_workflow.{up,down}.sql
                                            - Idempotent IF NOT EXISTS /
                                              IF EXISTS. Adds
                                              certificate_profiles.requires_approval
                                              BOOLEAN NOT NULL DEFAULT false,
                                              issuance_approval_requests
                                              table with FK to
                                              managed_certificates / jobs /
                                              certificate_profiles, four
                                              indexes (state, certificate,
                                              pending-age, partial-unique
                                              pending-per-job), and the
                                              approval_decision_consistency
                                              CHECK constraint enforcing
                                              decided_by/decided_at must be
                                              non-null for terminal states.

Files modified:
  internal/domain/profile.go               - Adds CertificateProfile.RequiresApproval
                                              bool field with full doc
                                              comment + JSON tag. Defaults
                                              to false (back-compat — every
                                              existing profile keeps the
                                              unattended renewal path).
  internal/repository/interfaces.go        - Adds ApprovalRepository
                                              interface (6 methods) +
                                              ApprovalFilter struct.
  internal/repository/errors.go            - Adds ErrAlreadyExists sentinel
                                              for postgres SQLSTATE 23505
                                              (unique-constraint violations
                                              from the partial-unique
                                              pending-per-job index, plus
                                              the "already terminal" state-
                                              transition signal). Mirrors
                                              the existing ErrNotFound +
                                              ErrForeignKeyConstraint shape.

Verified:
  gofmt: clean.
  go vet ./internal/domain/... ./internal/repository/...: exit 0.
  go build ./internal/domain/... ./internal/repository/...: exit 0.

Out of scope for this commit (lands in commits 2-4):
  - service/approval.go (RequestApproval / Approve / Reject / ListPending
    / ExpireStale + same-actor RBAC + bypass mode + audit + metrics).
  - service/approval_metrics.go (decisions counter + pending-age histogram).
  - 8 service-level table-driven tests including the load-bearing
    TestApproval_Approve_RejectsSameActor two-person integrity pin.
  - api/handler/approval.go (5 endpoints + RBAC integration).
  - api/openapi.yaml (5 new operationIds).
  - Integration into CertificateService.TriggerRenewal +
    RenewalService.CheckExpiringCertificates + Scheduler.ReapTimedOutJobs.
  - cmd/server/main.go wiring.
  - Config.Approval.BypassEnabled + CERTCTL_APPROVAL_BYPASS env var.
  - docs/connectors.md CertificateProfile config-table row.
  - docs/approval-workflow.md operator playbook + compliance control mapping.

Reference: cowork/infisical-deep-research-results.md Part 5 Rank 7.
Acquisition prompt: cowork/rank-7-approval-workflow-primitive-prompt.md.
2026-05-04 00:55:17 +00:00
shankar0123 69d4ada385 ci(release): pin run-name + release title to tag (fix ugly auto-generated titles)
Two GitHub-Actions defaults were producing ugly titles on every tag:

1. The Actions-tab workflow run title was auto-generated as
   `<commit-subject> #<run-number>` because release.yml had no `run-name:`.
   The v2.0.69 push showed up as
   "chore: rename Go module path to github.com/certctl-io/certctl #73"
   instead of the obvious "Release v2.0.69".

2. The Releases-page title was auto-generated by
   softprops/action-gh-release@v2 because the action's `with:` block had
   no `name:` field — it falls back to the most recent commit subject in
   that case, producing the same noise on the Releases page.

Fixes:
- Add `run-name: Release ${{ github.ref_name }}` at the workflow top.
  `github.ref_name` resolves to the tag (e.g., `v2.0.69`) since the only
  trigger is `on: push: tags: ['v*']`. Actions tab now shows
  "Release v2.0.69".
- Add `name: ${{ github.ref_name }}` to the softprops/action-gh-release@v2
  step's `with:` block. Releases page now shows "v2.0.69" as the title
  instead of the commit subject.

Affects v2.0.70+. The v2.0.69 workflow run + release page that's already
in flight retain the bad titles (the workflow file is read at trigger
time); the v2.0.69 Releases-page title can be manually edited via the
GitHub UI ("Edit release" → set title to `v2.0.69` → Update release).
The Actions-tab run name for #73 is immutable post-trigger.

This same pattern likely affects ci.yml + the other workflows but the
operator-facing surface is the Release workflow's titles, so leaving
the CI workflows alone for now (they run continuously on master and
nobody clicks individual run titles).
2026-05-04 00:46:31 +00:00
shankar0123 8b75e0311b chore: rename Go module path to github.com/certctl-io/certctl
Mechanical sed across the main go.mod's module declaration, the f5-mock-icontrol
sub-module's go.mod, every Go file's import path (361 files), and a rebuild of
the checked-in f5-mock-icontrol binary so its embedded build-info reflects the
new module path. No behavior change.

Choice B from cowork/transfer-certctl-to-org.md, executed 2026-05-04. Choice A
(keep module path declared as github.com/shankar0123/certctl regardless of
repo URL) shipped on the day of the org transfer (2026-05-03) since we had no
external Go consumers; this commit closes that deferral.

Backward-compat: GitHub HTTP redirects continue to forward
github.com/shankar0123/certctl → github.com/certctl-io/certctl at the URL
level, but Go's module proxy uses the path declared in go.mod as the
canonical name. Pre-fix, anyone trying `go get github.com/certctl-io/certctl/...`
hit a "module path mismatch" error because go.mod said
github.com/shankar0123/certctl and the URL they fetched it from said
certctl-io/certctl. Post-fix, the canonical name and the URL agree, so
go get / go install / external Go consumers / Go-tooling integrations
work cleanly via either the new path (preferred) or the old path (which
redirects and Go follows the redirect for source fetch).

Anyone still importing the old path inside their own code keeps working
provided they update their go.mod's `require` line to match — the module
path declared in their consumer's go.sum / go.mod is the authoritative
import name, so a mass sed across their import statements is the migration
on the consumer side. No external consumers exist today.

Diff shape:
  361 *.go files  — import path replacement only
    2 go.mod     — module declaration replacement only
    1 binary     — deploy/test/f5-mock-icontrol/f5-mock-icontrol rebuilt
                   so embedded build-info reflects the new path (8618965 vs
                   8618933 bytes; 32-byte diff is the build-info change)

  Total: 364 files, 730 insertions / 730 deletions, net-zero size, pure
  mechanical substitution.

Verification:
  gofmt: 17 files needed re-alignment after sed (the new path is one char
    shorter than the old, so column-aligned import groups drifted). Applied
    `gofmt -w` to fix.
  go mod tidy: clean exit on both modules.
  go vet ./...: clean exit.
  go build ./...: clean exit.
  go test -short -count=1 on representative packages: all green
    (internal/domain, internal/validation, internal/crypto, internal/crypto/signer,
    cmd/agent). Test output now reads `ok github.com/certctl-io/certctl/...`
    confirming the module path resolves correctly.
  binary: f5-mock-icontrol rebuilt; `strings | grep shankar0123` returns
    nothing; `strings | grep certctl-io/certctl` shows the new module path
    embedded in build-info.

Files intentionally NOT touched in this commit:
  README.md / CHANGELOG.md / docs/ / etc. — already swept to certctl-io
    URLs in commit 0729ee4 (the post-transfer URL refresh). This commit is
    purely the Go-tooling layer.
  Scarf pixels (`shankar0123.docker.scarf.sh/...`) — Scarf-account
    namespace, not a Go import or GitHub repo URL. Stays.

This is a non-blocking, non-customer-impacting change. Operators pulling
container images, running `make verify`, hitting the API, or installing the
agent see no functional difference. Only Go-tooling consumers (none today)
are affected, and they're enabled — not broken — by this commit.
2026-05-04 00:30:29 +00:00
415 changed files with 7765 additions and 903 deletions
+12
View File
@@ -1,5 +1,12 @@
name: Release name: Release
# Override the auto-generated run name (which would otherwise default to
# the most recent commit subject + a #NN run number) so the Actions tab
# shows "Release v2.0.69" instead of "chore: rename Go module path... #73".
# `github.ref_name` resolves to the tag name (e.g., `v2.0.69`) for tag-triggered
# workflows, which is the only trigger we set below.
run-name: Release ${{ github.ref_name }}
on: on:
push: push:
tags: tags:
@@ -346,6 +353,11 @@ jobs:
# noise that gives operators no signal about what actually changed. # noise that gives operators no signal about what actually changed.
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
# Pin the release title to the tag name. softprops/action-gh-release@v2
# falls back to the most recent commit subject when `name:` is omitted,
# which produces ugly titles like "chore: rename Go module path..." on
# the Releases page. `github.ref_name` evaluates to the tag (`v2.0.69`).
name: ${{ github.ref_name }}
generate_release_notes: true generate_release_notes: true
body: | body: |
> **Install / upgrade:** see the [Quick Start section in the README](https://github.com/certctl-io/certctl/blob/master/README.md#quick-start) for Docker Compose, agent install, Helm, and binary download instructions. > **Install / upgrade:** see the [Quick Start section in the README](https://github.com/certctl-io/certctl/blob/master/README.md#quick-start) for Docker Compose, agent install, Helm, and binary download instructions.
+42 -11
View File
@@ -50,6 +50,11 @@ gantt
| [Architecture](docs/architecture.md) | System design, data flow diagrams, security model | | [Architecture](docs/architecture.md) | System design, data flow diagrams, security model |
| [Feature Inventory](docs/features.md) | Complete reference of all capabilities, API endpoints, and configuration | | [Feature Inventory](docs/features.md) | Complete reference of all capabilities, API endpoints, and configuration |
| [Connector Reference](docs/connectors.md) | Configuration for all issuer, target, and notifier connectors | | [Connector Reference](docs/connectors.md) | Configuration for all issuer, target, and notifier connectors |
| [ACME Server](docs/acme-server.md) | Run certctl as a drop-in ACME server — cert-manager / Caddy / Traefik walkthroughs + [threat model](docs/acme-server-threat-model.md) |
| [Approval Workflow](docs/approval-workflow.md) | Two-person-integrity gate for certificate issuance — RBAC, audit, bypass mode |
| [CA Hierarchy](docs/intermediate-ca-hierarchy.md) | Multi-level intermediate CA management — FedRAMP boundary CA, financial-services policy CA, internal-PKI patterns |
| [Cloud Target Runbook](docs/runbook-cloud-targets.md) | AWS ACM + Azure Key Vault deploy connectors — config, debugging, atomic-rollback semantics |
| [Expiry Alert Runbook](docs/runbook-expiry-alerts.md) | Per-policy multi-channel routing matrix — severity tiers, fault-isolating dispatch |
| [MCP Server](docs/mcp.md) | AI integration via Model Context Protocol — setup, available tools, examples | | [MCP Server](docs/mcp.md) | AI integration via Model Context Protocol — setup, available tools, examples |
| [OpenAPI 3.1 Spec](docs/openapi.md) | API reference guide with endpoint overview ([raw spec](api/openapi.yaml)) | | [OpenAPI 3.1 Spec](docs/openapi.md) | API reference guide with endpoint overview ([raw spec](api/openapi.yaml)) |
| [Compliance Mapping](docs/compliance.md) | SOC 2 Type II, PCI-DSS 4.0, NIST SP 800-57 alignment guides | | [Compliance Mapping](docs/compliance.md) | SOC 2 Type II, PCI-DSS 4.0, NIST SP 800-57 alignment guides |
@@ -65,18 +70,18 @@ gantt
| Issuer | Type | Notes | | Issuer | Type | Notes |
|--------|------|-------| |--------|------|-------|
| Local CA (self-signed + sub-CA) | `GenericCA` | Sub-CA mode chains to enterprise root (ADCS, etc.) | | Local CA (self-signed + sub-CA + tree mode) | `GenericCA` | Sub-CA mode chains to enterprise root (ADCS, etc.). **Tree mode (Rank 8)** manages multi-level intermediate CAs (`intermediate_cas` table) with RFC 5280 §3.2 / §4.2.1.9 / §4.2.1.10 enforcement — FedRAMP boundary CAs, financial-services policy CAs, internal PKI. See [`docs/intermediate-ca-hierarchy.md`](docs/intermediate-ca-hierarchy.md). |
| ACME v2 (Let's Encrypt, ZeroSSL, etc.) | `ACME` | HTTP-01, DNS-01, DNS-PERSIST-01 challenges. EAB auto-fetch from ZeroSSL. Profile selection (`tlsserver`, `shortlived`). | | ACME v2 (Let's Encrypt, ZeroSSL, etc.) | `ACME` | HTTP-01, DNS-01, DNS-PERSIST-01 challenges. EAB auto-fetch from ZeroSSL. Profile selection (`tlsserver`, `shortlived`). |
| step-ca (Smallstep) | `StepCA` | JWK provisioner auth, issuance + renewal + revocation | | step-ca (Smallstep) | `StepCA` | JWK provisioner auth, issuance + renewal + revocation |
| OpenSSL / Custom CA | `OpenSSL` | Shell script adapter — any CA with a CLI | | OpenSSL / Custom CA | `OpenSSL` | Shell script adapter — any CA with a CLI |
| HashiCorp Vault PKI | `VaultPKI` | Token auth, synchronous issuance, CRL/OCSP delegated to Vault | | HashiCorp Vault PKI | `VaultPKI` | Token auth with **automatic renewal at TTL/2** + Prometheus metric, synchronous issuance, CRL/OCSP delegated to Vault, opaque `*secret.Ref` credential storage |
| DigiCert CertCentral | `DigiCert` | Async order model, OV/EV support, PEM bundle parsing | | DigiCert CertCentral | `DigiCert` | Async order model, OV/EV support, PEM bundle parsing |
| Sectigo SCM | `Sectigo` | 3-header auth, DV/OV/EV, collect-not-ready graceful handling | | Sectigo SCM | `Sectigo` | 3-header auth, DV/OV/EV, collect-not-ready graceful handling |
| Google Cloud CAS | `GoogleCAS` | OAuth2 service account, synchronous issuance, CA pool selection | | Google Cloud CAS | `GoogleCAS` | OAuth2 service account, synchronous issuance, CA pool selection |
| AWS ACM Private CA | `AWSACMPCA` | Synchronous issuance, configurable signing algorithm/template ARN | | AWS ACM Private CA | `AWSACMPCA` | Synchronous issuance, configurable signing algorithm/template ARN |
| Entrust Certificate Services | `Entrust` | mTLS client certificate auth, synchronous/approval-pending issuance | | Entrust Certificate Services | `Entrust` | mTLS client certificate auth, synchronous/approval-pending issuance |
| GlobalSign Atlas HVCA | `GlobalSign` | mTLS + API key/secret dual auth, serial-based tracking | | GlobalSign Atlas HVCA | `GlobalSign` | mTLS + API key/secret dual auth, serial-based tracking |
| EJBCA (Keyfactor) | `EJBCA` | Dual auth (mTLS or OAuth2), self-hosted open-source CA | | EJBCA (Keyfactor) | `EJBCA` | Dual auth (mTLS with auto-reload-on-mtime via `mtlscache`, or OAuth2), self-hosted open-source CA |
**Note:** ADCS integration is handled via the Local CA's sub-CA mode — certctl operates as a subordinate CA with its signing certificate issued by ADCS. Any CA with a shell-accessible signing interface can be integrated via the OpenSSL/Custom CA connector. **Note:** ADCS integration is handled via the Local CA's sub-CA mode — certctl operates as a subordinate CA with its signing certificate issued by ADCS. Any CA with a shell-accessible signing interface can be integrated via the OpenSSL/Custom CA connector.
@@ -98,6 +103,8 @@ gantt
| Windows Certificate Store | `WinCertStore` | PowerShell Import-PfxCertificate + Get-ChildItem snapshot for rollback | | Windows Certificate Store | `WinCertStore` | PowerShell Import-PfxCertificate + Get-ChildItem snapshot for rollback |
| Java Keystore | `JavaKeystore` | PEM→PKCS#12→keytool pipeline + keytool snapshot for rollback | | Java Keystore | `JavaKeystore` | PEM→PKCS#12→keytool pipeline + keytool snapshot for rollback |
| Kubernetes Secrets | `KubernetesSecrets` | `kubernetes.io/tls` Secrets, atomic API + SHA-256 verify + kubelet sync poll | | Kubernetes Secrets | `KubernetesSecrets` | `kubernetes.io/tls` Secrets, atomic API + SHA-256 verify + kubelet sync poll |
| **AWS Certificate Manager** | `AWSACM` | SDK-driven `ImportCertificate` (fresh ARN or rotate-in-place) + `DescribeCertificate` snapshot for atomic rollback + tag re-application. See [`docs/runbook-cloud-targets.md`](docs/runbook-cloud-targets.md). |
| **Azure Key Vault** | `AzureKeyVault` | SDK-driven PEM→PKCS#12 import via `ImportCertificate` (always new version) + snapshot CER bytes for atomic rollback + tag carry-forward. |
**Deploy-hardening I** (post-2026-04-30 master bundle): every connector now goes through `internal/deploy.Apply` for atomic-write + ownership-preservation + SHA-256 idempotency + per-target-type Prometheus counters (`certctl_deploy_*_total`). See [`docs/deployment-atomicity.md`](docs/deployment-atomicity.md) for the operator guide. **Deploy-hardening I** (post-2026-04-30 master bundle): every connector now goes through `internal/deploy.Apply` for atomic-write + ownership-preservation + SHA-256 idempotency + per-target-type Prometheus counters (`certctl_deploy_*_total`). See [`docs/deployment-atomicity.md`](docs/deployment-atomicity.md) for the operator guide.
@@ -108,8 +115,9 @@ gantt
| **EST (production-grade)** | RFC 7030 + RFC 9266 channel binding | Native EST server hardened for enterprise WiFi/802.1X, IoT bootstrap, and corporate device enrollment (post-2026-04-29 hardening master bundle). All six RFC 7030 endpoints — `cacerts` / `simpleenroll` / `simplereenroll` / `csrattrs` (profile-driven) / `serverkeygen` (CMS EnvelopedData wire format). Multi-profile dispatch (`/.well-known/est/<pathID>/`). Per-profile auth modes: mTLS sibling route at `/.well-known/est-mtls/<pathID>/`, HTTP Basic enrollment-password (constant-time compare + per-source-IP failed-auth limiter), RFC 9266 `tls-exporter` channel binding (TLS 1.3, opt-in per profile). Per-(CN, sourceIP) sliding-window rate limit. EST-source-scoped bulk revoke (`POST /api/v1/est/certificates/bulk-revoke`, M-008 admin-gated). Tabbed admin GUI at `/est` (Profiles / Recent Activity / Trust Bundle). `SIGHUP`-equivalent trust-bundle reload. libest reference-client interop tested in CI (`deploy/test/libest/Dockerfile` + `deploy/test/est_e2e_test.go`). Typed audit-action codes per failure dimension (`est_simple_enroll_success`/`_failed`, `est_auth_failed_basic`/`_mtls`/`_channel_binding`, `est_rate_limited`, `est_csr_policy_violation`, `est_bulk_revoke`, `est_trust_anchor_reloaded`, etc. — full set in `internal/service/est_audit_actions.go`). CLI + matching MCP tool family (rebuild count via `grep -cE '"est_' internal/mcp/tools_est.go`). See [`docs/est.md`](docs/est.md) for the operator guide — WiFi/802.1X + FreeRADIUS recipe, IoT bootstrap, troubleshooting matrix per audit-action code. | | **EST (production-grade)** | RFC 7030 + RFC 9266 channel binding | Native EST server hardened for enterprise WiFi/802.1X, IoT bootstrap, and corporate device enrollment (post-2026-04-29 hardening master bundle). All six RFC 7030 endpoints — `cacerts` / `simpleenroll` / `simplereenroll` / `csrattrs` (profile-driven) / `serverkeygen` (CMS EnvelopedData wire format). Multi-profile dispatch (`/.well-known/est/<pathID>/`). Per-profile auth modes: mTLS sibling route at `/.well-known/est-mtls/<pathID>/`, HTTP Basic enrollment-password (constant-time compare + per-source-IP failed-auth limiter), RFC 9266 `tls-exporter` channel binding (TLS 1.3, opt-in per profile). Per-(CN, sourceIP) sliding-window rate limit. EST-source-scoped bulk revoke (`POST /api/v1/est/certificates/bulk-revoke`, M-008 admin-gated). Tabbed admin GUI at `/est` (Profiles / Recent Activity / Trust Bundle). `SIGHUP`-equivalent trust-bundle reload. libest reference-client interop tested in CI (`deploy/test/libest/Dockerfile` + `deploy/test/est_e2e_test.go`). Typed audit-action codes per failure dimension (`est_simple_enroll_success`/`_failed`, `est_auth_failed_basic`/`_mtls`/`_channel_binding`, `est_rate_limited`, `est_csr_policy_violation`, `est_bulk_revoke`, `est_trust_anchor_reloaded`, etc. — full set in `internal/service/est_audit_actions.go`). CLI + matching MCP tool family (rebuild count via `grep -cE '"est_' internal/mcp/tools_est.go`). See [`docs/est.md`](docs/est.md) for the operator guide — WiFi/802.1X + FreeRADIUS recipe, IoT bootstrap, troubleshooting matrix per audit-action code. |
| SCEP (Simple Certificate Enrollment Protocol) | RFC 8894 | MDM platforms (Jamf, Intune), network devices, ChromeOS. Full RFC 8894 wire format: EnvelopedData decryption, signerInfo POPO verification, CertRep PKIMessage builder; PKCSReq + RenewalReq + GetCertInitial messageType dispatch; multi-profile dispatch (`/scep/<pathID>`); per-profile RA cert + key. Lightweight raw-CSR clients keep working via the legacy MVP fall-through path. | | SCEP (Simple Certificate Enrollment Protocol) | RFC 8894 | MDM platforms (Jamf, Intune), network devices, ChromeOS. Full RFC 8894 wire format: EnvelopedData decryption, signerInfo POPO verification, CertRep PKIMessage builder; PKCSReq + RenewalReq + GetCertInitial messageType dispatch; multi-profile dispatch (`/scep/<pathID>`); per-profile RA cert + key. Lightweight raw-CSR clients keep working via the legacy MVP fall-through path. |
| **Microsoft Intune SCEP fleet (drop-in NDES replacement)** | RFC 8894 + Intune Connector signed-challenge dispatcher | Per-profile Intune dispatcher validates the Connector's signed challenge against an operator-supplied trust anchor; binds device claim to CSR (set-equality on CN + SAN-DNS/RFC822/UPN); replay cache + per-device rate limit; `SIGHUP`-reloadable trust pool; admin GUI **SCEP Administration** page at `/scep` (Profiles tab with per-profile RA cert expiry + mTLS status, Intune Monitoring tab with per-status counters + reload, Recent Activity tab with full SCEP audit log filter). See [`docs/scep-intune.md`](docs/scep-intune.md) for the migration playbook + Microsoft support statement. | | **Microsoft Intune SCEP fleet (drop-in NDES replacement)** | RFC 8894 + Intune Connector signed-challenge dispatcher | Per-profile Intune dispatcher validates the Connector's signed challenge against an operator-supplied trust anchor; binds device claim to CSR (set-equality on CN + SAN-DNS/RFC822/UPN); replay cache + per-device rate limit; `SIGHUP`-reloadable trust pool; admin GUI **SCEP Administration** page at `/scep` (Profiles tab with per-profile RA cert expiry + mTLS status, Intune Monitoring tab with per-status counters + reload, Recent Activity tab with full SCEP audit log filter). See [`docs/scep-intune.md`](docs/scep-intune.md) for the migration playbook + Microsoft support statement. |
| ACME v2 | RFC 8555 | Public CA automated issuance (Let's Encrypt, ZeroSSL) | | ACME v2 client | RFC 8555 | Public CA automated issuance (Let's Encrypt, ZeroSSL) |
| ACME ARI (Renewal Information) | RFC 9773 | CA-directed renewal timing — the CA tells you when to renew | | **ACME v2 server (drop-in for cert-manager / Caddy / Traefik)** | RFC 8555 + RFC 9773 ARI | Run certctl as your internal ACME CA. Per-profile endpoints at `/acme/profile/{id}/*` (directory, new-nonce, new-account, new-order, finalize, account, order, authz, challenge, key-change, revoke-cert, renewal-info). Per-profile `acme_auth_mode`: `trust_authenticated` for internal PKI; `challenge` for HTTP-01 / DNS-01 / TLS-ALPN-01 validation. Doubly-signed key rollover (§7.3.5), revoke-cert (§7.6, both kid-path and jwk-path auth), per-account rate limiting (orders/hour, key-change/hour, challenge-respond/hour), scheduler-driven nonce/authz/order GC. Three client walkthroughs: [cert-manager](docs/acme-cert-manager-walkthrough.md), [Caddy](docs/acme-caddy-walkthrough.md), [Traefik](docs/acme-traefik-walkthrough.md). Reference: [`docs/acme-server.md`](docs/acme-server.md) + [threat model](docs/acme-server-threat-model.md). |
| ACME ARI (Renewal Information) | RFC 9773 | CA-directed renewal timing — the CA tells you when to renew (client-side and server-side) |
### Standards & Revocation ### Standards & Revocation
@@ -161,7 +169,7 @@ Certificate lifecycle tooling falls into two camps: enterprise platforms (Venafi
Built for **platform engineering and DevOps teams** managing 10500+ certificates, **security and compliance teams** who need audit trails and policy enforcement for SOC 2, PCI-DSS 4.0, or NIST SP 800-57 ([compliance mapping included](docs/compliance.md)), and **small teams without enterprise budgets** who need Venafi-grade automation for a 50-server environment. For a detailed comparison, see [Why certctl?](docs/why-certctl.md) Built for **platform engineering and DevOps teams** managing 10500+ certificates, **security and compliance teams** who need audit trails and policy enforcement for SOC 2, PCI-DSS 4.0, or NIST SP 800-57 ([compliance mapping included](docs/compliance.md)), and **small teams without enterprise budgets** who need Venafi-grade automation for a 50-server environment. For a detailed comparison, see [Why certctl?](docs/why-certctl.md)
**Architecture.** Go 1.25 control plane with handler→service→repository layering, PostgreSQL 16 backend (21 tables), and a pull-only deployment model — the server never initiates outbound connections. Agents poll for work. For network appliances and agentless servers, a proxy agent in the same network zone handles deployment via the target's API (WinRM, iControl REST, SSH/SFTP). Background scheduler runs 7 loops: renewal with ARI integration (1h), job processing (30s), agent health (2m), notifications (1m), short-lived cert expiry (30s), network scanning (6h), certificate digest (24h). See [Architecture Guide](docs/architecture.md) for full system diagrams. **Architecture.** Go 1.25 control plane with handler→service→repository layering, PostgreSQL 16 backend (35+ tables), and a pull-only deployment model — the server never initiates outbound connections. Agents poll for work. For network appliances and agentless servers, a proxy agent in the same network zone handles deployment via the target's API (WinRM, iControl REST, SSH/SFTP). Background scheduler runs 7 loops: renewal with ARI integration (1h), job processing (30s), agent health (2m), notifications (1m), short-lived cert expiry (30s), network scanning (6h), certificate digest (24h). See [Architecture Guide](docs/architecture.md) for full system diagrams.
**Security-first.** Agents generate ECDSA P-256 keys locally — private keys never touch the control plane. API key auth enforced by default with SHA-256 hashing and constant-time comparison. CORS deny-by-default. Shell injection prevention on all connector scripts. SSRF protection (reserved IP filtering) on the network scanner. Atomic idempotency guards on scheduler loops. Issuer and target credentials encrypted at rest with AES-256-GCM. Every API call recorded to an immutable audit trail with actor attribution, body hash, and latency tracking. CI runs race detection, 11 linters, and vulnerability scanning on every commit. **Security-first.** Agents generate ECDSA P-256 keys locally — private keys never touch the control plane. API key auth enforced by default with SHA-256 hashing and constant-time comparison. CORS deny-by-default. Shell injection prevention on all connector scripts. SSRF protection (reserved IP filtering) on the network scanner. Atomic idempotency guards on scheduler loops. Issuer and target credentials encrypted at rest with AES-256-GCM. Every API call recorded to an immutable audit trail with actor attribution, body hash, and latency tracking. CI runs race detection, 11 linters, and vulnerability scanning on every commit.
@@ -171,13 +179,19 @@ Built for **platform engineering and DevOps teams** managing 10500+ certifica
**Automated lifecycle.** Certificates renew and deploy themselves. The scheduler monitors expiration, issues through your CA, and deploys to targets — zero human intervention. ACME ARI (RFC 9773) lets the CA direct renewal timing. Ready for 47-day (SC-081v3) and 6-day (Let's Encrypt shortlived) certificate lifetimes. **Automated lifecycle.** Certificates renew and deploy themselves. The scheduler monitors expiration, issues through your CA, and deploys to targets — zero human intervention. ACME ARI (RFC 9773) lets the CA direct renewal timing. Ready for 47-day (SC-081v3) and 6-day (Let's Encrypt shortlived) certificate lifetimes.
**Operational dashboard.** 26-page GUI covers the entire lifecycle: certificate inventory with bulk ops, deployment timeline with rollback, discovery triage, network scan management, agent fleet health, short-lived credential countdown, approval workflows, and observability metrics. Configure issuers and targets from the dashboard — no env var editing, no server restarts. **Operational dashboard.** 30+ page GUI covers the entire lifecycle: certificate inventory with bulk ops, deployment timeline with rollback, discovery triage, network scan management, agent fleet health, short-lived credential countdown, approval workflows, CA-hierarchy management, and observability metrics. Configure issuers and targets from the dashboard — no env var editing, no server restarts.
**Private keys stay on your servers.** Agents generate ECDSA P-256 keys locally, submit only the CSR. The control plane never touches private keys. After deployment, agents probe the live TLS endpoint and compare SHA-256 fingerprints to confirm the right certificate is actually being served. **Private keys stay on your servers.** Agents generate ECDSA P-256 keys locally, submit only the CSR. The control plane never touches private keys. After deployment, agents probe the live TLS endpoint and compare SHA-256 fingerprints to confirm the right certificate is actually being served.
**Discovery.** Agents scan filesystems for existing PEM/DER certificates. The network scanner probes TLS endpoints across CIDR ranges without agents. Cloud discovery finds certificates in AWS Secrets Manager, Azure Key Vault, and GCP Secret Manager. Continuous TLS health monitoring tracks endpoint status (healthy/degraded/down/cert_mismatch) with configurable thresholds and historical probe data. All discovery modes feed into a unified triage workflow — claim, dismiss, or import what you find. **Discovery.** Agents scan filesystems for existing PEM/DER certificates. The network scanner probes TLS endpoints across CIDR ranges without agents. Cloud discovery finds certificates in AWS Secrets Manager, Azure Key Vault, and GCP Secret Manager. Continuous TLS health monitoring tracks endpoint status (healthy/degraded/down/cert_mismatch) with configurable thresholds and historical probe data. All discovery modes feed into a unified triage workflow — claim, dismiss, or import what you find.
**Policy engine.** Certificate profiles constrain key types, max TTL, and EKUs — with crypto policy enforcement that validates every CSR against profile rules before it reaches the issuer. MaxTTL caps are enforced per issuer connector. Approval workflows pause jobs for human review. Ownership tracking routes notifications to the right team. Agent groups match devices by OS, architecture, IP CIDR, and version. **Policy engine.** Certificate profiles constrain key types, max TTL, and EKUs — with crypto policy enforcement that validates every CSR against profile rules before it reaches the issuer. MaxTTL caps are enforced per issuer connector. Ownership tracking routes notifications to the right team. Agent groups match devices by OS, architecture, IP CIDR, and version.
**Two-person integrity for issuance (compliance-grade).** Set `requires_approval=true` on a `CertificateProfile` and every renewal-loop tick or manual `POST /api/v1/certificates/{id}/renew` blocks at `JobStatusAwaitingApproval` until a different actor approves via `POST /api/v1/approvals/{id}/approve`. Same-actor self-approval is rejected at the service layer with `ErrApproveBySameActor` → HTTP 403. Bypass mode (`CERTCTL_APPROVAL_BYPASS=true`) is auditable — every auto-approve records `actor=system-bypass` so audit-tier review surfaces it. Closes the procurement-checklist question for PCI-DSS Level 1, FedRAMP Moderate / High, SOC 2 Type II, HIPAA. See [`docs/approval-workflow.md`](docs/approval-workflow.md).
**Multi-level CA hierarchy management.** Set `Issuer.HierarchyMode = "tree"` and certctl manages a real N-level CA tree backed by the `intermediate_cas` table — root → policy → issuing leaves. RFC 5280 §3.2 (self-signed root validation), §4.2.1.9 (path-length tightening), and §4.2.1.10 (NameConstraints subset semantics) are all enforced at the service layer fail-closed. Drain-first retirement (active → retiring → retired) refuses terminal transitions while active children remain. Patterns documented for FedRAMP boundary CAs (4-level), financial-services policy CAs (3-level with per-BU `PermittedDNSDomains`), and internal PKI (2-level). The pre-Rank-8 single-sub-CA flow stays byte-identical for unmigrated deployments — pinned by `TestLocal_HierarchyMode_SingleVsTree_ByteIdentical`. See [`docs/intermediate-ca-hierarchy.md`](docs/intermediate-ca-hierarchy.md).
**Run certctl as your ACME server.** Beyond consuming public ACME CAs (Let's Encrypt, ZeroSSL), certctl now *serves* RFC 8555 — point cert-manager, Caddy, or Traefik at certctl's per-profile ACME endpoints (`/acme/profile/{id}/*`) and you get internal-PKI cert issuance with the same wire protocol the public CAs use. Full surface: directory, new-nonce, new-account, new-order, finalize, key-change (§7.3.5), revoke-cert (§7.6), renewal-info (RFC 9773 ARI), HTTP-01 / DNS-01 / TLS-ALPN-01 validation, per-account rate limiting, scheduler-driven nonce / authz / order GC. Three client walkthroughs ship — [cert-manager](docs/acme-cert-manager-walkthrough.md), [Caddy](docs/acme-caddy-walkthrough.md), [Traefik](docs/acme-traefik-walkthrough.md) — plus the [operator reference](docs/acme-server.md) and [threat model](docs/acme-server-threat-model.md).
**Enrollment protocols.** EST server (RFC 7030) for device and WiFi enrollment. SCEP server (RFC 8894) for MDM platforms and network devices — full wire format (EnvelopedData decrypt + signerInfo POPO verify + CertRep PKIMessage builder), tested against ChromeOS-shape requests; multi-profile dispatch (`/scep/<pathID>`); RenewalReq + GetCertInitial messageType support; lightweight raw-CSR fallback for legacy clients. See [docs/legacy-est-scep.md](docs/legacy-est-scep.md) for the operator + device-integration guide. S/MIME issuance with email protection EKU. **Enrollment protocols.** EST server (RFC 7030) for device and WiFi enrollment. SCEP server (RFC 8894) for MDM platforms and network devices — full wire format (EnvelopedData decrypt + signerInfo POPO verify + CertRep PKIMessage builder), tested against ChromeOS-shape requests; multi-profile dispatch (`/scep/<pathID>`); RenewalReq + GetCertInitial messageType support; lightweight raw-CSR fallback for legacy clients. See [docs/legacy-est-scep.md](docs/legacy-est-scep.md) for the operator + device-integration guide. S/MIME issuance with email protection EKU.
@@ -185,9 +199,11 @@ Built for **platform engineering and DevOps teams** managing 10500+ certifica
**Audit and observability.** Immutable append-only audit trail records every lifecycle action, every API call, and every approval decision. Prometheus metrics endpoint. Scheduled certificate digest emails. Continuous endpoint health monitoring with state machine transitions and real-time alerts. **Audit and observability.** Immutable append-only audit trail records every lifecycle action, every API call, and every approval decision. Prometheus metrics endpoint. Scheduled certificate digest emails. Continuous endpoint health monitoring with state machine transitions and real-time alerts.
**Notifications.** Slack, Teams, PagerDuty, OpsGenie, SMTP, webhooks. Routed by certificate owner. Daily digest emails with stats and expiring certs. **Notifications + per-policy multi-channel routing.** Slack, Teams, PagerDuty, OpsGenie, SMTP, webhooks. Routed by certificate owner. Daily digest emails with stats and expiring certs. Each `RenewalPolicy` carries an `AlertChannels` matrix (per-severity-tier channel set) + `AlertSeverityMap` (per-threshold tier resolution) so production-tier 7-day alerts page PagerDuty *and* Slack while informational 30-day alerts go email-only. Per-channel dispatch is fault-isolating — a PagerDuty failure does NOT skip Slack/Email at the same threshold. Per-channel dedup row + audit row + Prometheus counter (`certctl_expiry_alerts_total{channel,threshold,result}`). See [`docs/runbook-expiry-alerts.md`](docs/runbook-expiry-alerts.md).
**Multiple interfaces.** REST API (111 routes), CLI (12 commands), MCP server (80 tools for Claude, Cursor, Windsurf), Helm chart, web dashboard. Certificate export in PEM and PKCS#12. **Cloud-managed targets.** Beyond on-server deploys (NGINX, Apache, IIS, F5, ...), certctl pushes renewed certs directly into AWS Certificate Manager (`ImportCertificate` + `DescribeCertificate` snapshot for atomic rollback + tag re-application) and Azure Key Vault (PEM→PKCS#12 import + snapshot CER bytes for rollback + tag carry-forward). The control plane never touches the cloud credentials — agents own them. See [`docs/runbook-cloud-targets.md`](docs/runbook-cloud-targets.md).
**Multiple interfaces.** REST API (180+ routes), CLI (`certs` / `agents` / `jobs` / `import` / `est` / `status` / `version` command groups), MCP server (85+ tools for Claude, Cursor, Windsurf), Helm chart, web dashboard. Certificate export in PEM and PKCS#12.
**First-run onboarding.** Wizard guides you through connecting a CA, deploying an agent, and issuing your first certificate. Or start with the pre-populated demo — 32 certificates, 10 issuers, 180 days of history. **First-run onboarding.** Wizard guides you through connecting a CA, deploying an agent, and issuing your first certificate. Or start with the pre-populated demo — 32 certificates, 10 issuers, 180 days of history.
@@ -398,7 +414,22 @@ CI runs on every push: `go vet`, `go test -race`, `golangci-lint`, `govulncheck`
Core lifecycle management — Local CA + ACME v2 issuers, NGINX target connector, agent-side key generation, API auth + rate limiting, React dashboard, CI pipeline with coverage gates, Docker images on GHCR. Core lifecycle management — Local CA + ACME v2 issuers, NGINX target connector, agent-side key generation, API auth + rate limiting, React dashboard, CI pipeline with coverage gates, Docker images on GHCR.
### V2: Operational Maturity — Shipped ### V2: Operational Maturity — Shipped
30+ milestones shipping enterprise-grade features for free. Sub-CA mode, ACME DNS-01/DNS-PERSIST-01/EAB/ARI (RFC 9773)/profile selection, step-ca, Vault PKI, DigiCert CertCentral, Sectigo SCM, Google CAS, AWS ACM PCA, Entrust, GlobalSign, EJBCA, OpenSSL/Custom CA issuers. NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, Postfix, Dovecot, IIS (WinRM), F5 BIG-IP, SSH, Windows Certificate Store, Java Keystore, Kubernetes Secrets targets. EST server (RFC 7030) and SCEP server (RFC 8894) enrollment protocols. RFC 5280 revocation with DER CRL + embedded OCSP responder. Certificate profiles, ownership tracking, team assignment, agent groups, interactive approval workflows. Filesystem, network, and cloud secret manager (AWS SM, Azure KV, GCP SM) certificate discovery with triage GUI. Dynamic issuer/target configuration via GUI with AES-256-GCM encrypted storage. First-run onboarding wizard. Post-deployment TLS verification. Certificate export (PEM/PKCS#12). S/MIME support. Prometheus metrics. Scheduled certificate digest emails. Slack, Teams, PagerDuty, OpsGenie, SMTP notifications. MCP server (80 tools), CLI (12 commands), Helm chart. Compliance mapping (SOC 2, PCI-DSS 4.0, NIST SP 800-57). 5 turnkey deployment examples. Agent install script. Migration guides from certbot, acme.sh, and cert-manager. See the [Feature Inventory](docs/features.md) for details.
40+ milestones shipping enterprise-grade features for free. Highlights below; the [Feature Inventory](docs/features.md) has the complete reference.
- **Issuers (12).** Local CA (self-signed + sub-CA + tree-mode N-level hierarchy), ACME (DNS-01 / DNS-PERSIST-01 / EAB / ARI / profile selection), step-ca, Vault PKI (with auto-token-renewal at TTL/2), DigiCert CertCentral, Sectigo SCM, Google CAS, AWS ACM PCA, Entrust (mTLS), GlobalSign Atlas HVCA, EJBCA (mTLS auto-reload via `mtlscache`), OpenSSL/Custom CA shell adapter.
- **On-server deploy targets (14).** NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, Postfix, Dovecot, IIS (WinRM), F5 BIG-IP, SSH, Windows Certificate Store, Java Keystore, Kubernetes Secrets — every connector goes through `internal/deploy.Apply` for atomic-write + ownership preservation + SHA-256 idempotency + per-target Prometheus counters + pre-deploy snapshot + on-failure rollback.
- **Cloud-managed deploy targets (2).** AWS Certificate Manager + Azure Key Vault — SDK-driven import with snapshot bytes for atomic rollback, tag carry-forward, no cloud creds touch the control plane. ([runbook](docs/runbook-cloud-targets.md))
- **certctl as an ACME server.** Full RFC 8555 surface (per-profile endpoints, accounts, orders, finalize, key-change §7.3.5, revoke-cert §7.6) + RFC 9773 ARI + HTTP-01 / DNS-01 / TLS-ALPN-01 validation + per-account rate limiting + scheduler-driven nonce/authz/order GC. Drop in for cert-manager / Caddy / Traefik. ([reference](docs/acme-server.md), [threat model](docs/acme-server-threat-model.md))
- **Enrollment protocols.** EST server (RFC 7030 + RFC 9266 channel binding, multi-profile dispatch, libest-tested CI). SCEP server (RFC 8894 full wire format, Microsoft Intune Connector signed-challenge dispatcher with replay cache + per-device rate limit, ChromeOS-shape interop).
- **Two-person-integrity approval workflow.** Per-profile `requires_approval=true` gate, `JobStatusAwaitingApproval` scheduler skip, same-actor RBAC reject, auditable bypass mode. Compliance-grade for PCI-DSS Level 1, FedRAMP Moderate / High, SOC 2 Type II, HIPAA. ([playbook](docs/approval-workflow.md))
- **First-class CA hierarchy management.** `intermediate_cas` table, RFC 5280 §3.2 / §4.2.1.9 / §4.2.1.10 service-layer enforcement, drain-first retire (active → retiring → retired), 4 admin-gated endpoints, GUI tree view. Patterns documented for FedRAMP / financial-services / internal PKI. ([runbook](docs/intermediate-ca-hierarchy.md))
- **Multi-channel expiry alerts.** Per-policy `AlertChannels` matrix + `AlertSeverityMap`, fault-isolating per-channel dispatch (PagerDuty failure does not skip Slack/Email at the same threshold), per-channel dedup + audit + Prometheus counter. ([runbook](docs/runbook-expiry-alerts.md))
- **Revocation infrastructure.** RFC 5280 DER CRL per issuer (scheduler-pre-generated + ETag-cached) + embedded RFC 6960 OCSP responder (dedicated per-issuer responder cert per §2.6, `id-pkix-ocsp-nocheck`, RFC §4.4.1 nonce echo, OCSP response cache with revoke-invalidate hot path). Single + bulk revocation. ([guide](docs/crl-ocsp.md))
- **Discovery & lifecycle.** Filesystem, network-CIDR, and cloud secret manager (AWS SM / Azure KV / GCP SM) certificate discovery with triage GUI. Continuous endpoint health monitoring. ACME ARI client-driven renewal timing. Approval workflows. Ownership routing. Agent groups (OS / arch / IP CIDR / version match).
- **Secrets at rest.** Issuer + target config encrypted with AES-256-GCM (versioned blob format, PBKDF2-SHA256 100K rounds, fail-closed sentinel `ErrEncryptionKeyRequired`). Vault token + DigiCert API key + EJBCA / GlobalSign / Sectigo credentials migrated to opaque `*secret.Ref` references.
- **Operator interfaces.** REST API (180+ routes), CLI (`certs` / `agents` / `jobs` / `import` / `est` / `status` / `version` command groups), MCP server (85+ tools for Claude / Cursor / Windsurf), Helm chart, 30+ page web dashboard with first-run onboarding wizard.
- **Compliance.** SOC 2 Type II, PCI-DSS 4.0, NIST SP 800-57 mapping ([compliance docs](docs/compliance.md)). Disaster-recovery runbook (8-section operator-grade procedure). Migration guides from [certbot](docs/migrate-from-certbot.md), [acme.sh](docs/migrate-from-acmesh.md), and [cert-manager](docs/certctl-for-cert-manager-users.md).
### Forward-looking work — all free, all self-hostable ### Forward-looking work — all free, all self-hostable
Everything ships free under BSL 1.1. No paid tier, no V3 / V4 gating, no enterprise edition. Future revenue path is a managed-service hosting offering — operate certctl-server as a hosted service while customers self-install only the agent. Everything ships free under BSL 1.1. No paid tier, no V3 / V4 gating, no enterprise edition. Future revenue path is a managed-service hosting offering — operate certctl-server as a hosted service while customers self-install only the agent.
+361
View File
@@ -2751,6 +2751,310 @@ paths:
$ref: "#/components/responses/InternalError" $ref: "#/components/responses/InternalError"
# ─── Notifications ────────────────────────────────────────────────── # ─── Notifications ──────────────────────────────────────────────────
/api/v1/approvals:
get:
tags: [Approvals]
summary: List approval requests
description: |
Rank 7 issuance approval-workflow primitive. Returns paginated approval
requests, optionally filtered by ?state= (pending/approved/rejected/expired),
?certificate_id=, or ?requested_by=. Empty filters return the unfiltered
list (default page=1, per_page=50).
operationId: listApprovalRequests
parameters:
- $ref: "#/components/parameters/page"
- $ref: "#/components/parameters/per_page"
- name: state
in: query
required: false
schema:
type: string
enum: [pending, approved, rejected, expired]
- name: certificate_id
in: query
required: false
schema:
type: string
- name: requested_by
in: query
required: false
schema:
type: string
responses:
"200":
description: Paginated list of approval requests
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: "#/components/schemas/ApprovalRequest"
page:
type: integer
per_page:
type: integer
"500":
$ref: "#/components/responses/InternalError"
/api/v1/approvals/{id}:
get:
tags: [Approvals]
summary: Get approval request
description: Returns a single approval request by ID.
operationId: getApprovalRequest
parameters:
- $ref: "#/components/parameters/resourceId"
responses:
"200":
description: Approval request details
content:
application/json:
schema:
$ref: "#/components/schemas/ApprovalRequest"
"404":
$ref: "#/components/responses/NotFound"
"500":
$ref: "#/components/responses/InternalError"
/api/v1/approvals/{id}/approve:
post:
tags: [Approvals]
summary: Approve a pending approval request
description: |
Transitions a pending request to approved AND transitions the linked
Job from AwaitingApproval to Pending so the scheduler picks it up.
RBAC: the authenticated actor extracted via the auth middleware MUST
differ from the request's requested_by — a same-actor self-approval
returns HTTP 403 with the substring `two-person integrity` in the
body. This is the load-bearing two-person integrity contract;
compliance auditors (PCI-DSS 6.4.5, NIST 800-53 SA-15, SOC 2 CC6.1)
pattern-match against this code path.
operationId: approveApprovalRequest
parameters:
- $ref: "#/components/parameters/resourceId"
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
note:
type: string
description: Optional reason text for the audit trail.
responses:
"200":
description: Approval recorded; linked Job transitioned to Pending
content:
application/json:
schema:
type: object
properties:
id: { type: string }
decided_by: { type: string }
action: { type: string, enum: [approved] }
"401":
description: Authentication required
"403":
description: Same-actor self-approval blocked by two-person integrity contract
"404":
$ref: "#/components/responses/NotFound"
"409":
description: Request already decided (terminal state)
"500":
$ref: "#/components/responses/InternalError"
/api/v1/approvals/{id}/reject:
post:
tags: [Approvals]
summary: Reject a pending approval request
description: |
Transitions a pending request to rejected AND cancels the linked
Job. Same-actor RBAC contract as approve. The job's error_message
is populated with the supplied note for audit continuity.
operationId: rejectApprovalRequest
parameters:
- $ref: "#/components/parameters/resourceId"
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
note:
type: string
description: Optional reason text for the audit trail.
responses:
"200":
description: Rejection recorded; linked Job transitioned to Cancelled
content:
application/json:
schema:
type: object
properties:
id: { type: string }
decided_by: { type: string }
action: { type: string, enum: [rejected] }
"401":
description: Authentication required
"403":
description: Same-actor self-rejection blocked by two-person integrity contract
"404":
$ref: "#/components/responses/NotFound"
"409":
description: Request already decided (terminal state)
"500":
$ref: "#/components/responses/InternalError"
/api/v1/issuers/{id}/intermediates:
post:
tags: [IntermediateCAs]
summary: Create a root or child intermediate CA under the issuer
description: |
Admin-gated. Discriminator on body shape: when parent_ca_id is
empty AND root_cert_pem + key_driver_id are present, the
endpoint registers an operator-supplied root CA. Otherwise it
signs a child sub-CA cert under the named parent (RFC 5280
§4.2.1.9 path-length tightening + §4.2.1.10 NameConstraints
subset semantics enforced at the service layer).
operationId: createIntermediateCA
parameters:
- $ref: "#/components/parameters/resourceId"
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name]
properties:
name: { type: string }
parent_ca_id:
type: string
description: Empty for root registration; non-empty for child signing
root_cert_pem:
type: string
description: Operator-supplied root cert PEM (root path only)
key_driver_id:
type: string
description: signer.Driver reference for the root key (root path only)
subject:
type: object
description: Distinguished name for child CA (child path only)
algorithm:
type: string
description: Signing algorithm for child key (default ECDSA-P256)
ttl_days:
type: integer
path_len_constraint:
type: integer
nullable: true
name_constraints:
type: array
items: { type: object }
ocsp_responder_url:
type: string
metadata:
type: object
responses:
"201":
description: IntermediateCA row created
"400":
description: Validation failed (RFC 5280 violations, malformed cert PEM, missing root bundle)
"401":
description: Authentication required
"403":
description: Admin role required
"409":
description: Parent CA not in active state
"404":
description: Parent CA not found
"500":
$ref: "#/components/responses/InternalError"
get:
tags: [IntermediateCAs]
summary: List the CA hierarchy for an issuer
description: |
Admin-gated. Returns the flat list of every IntermediateCA row
for the issuer, ordered by created_at. The caller renders the
tree from each row's parent_ca_id (nil = root).
operationId: listIntermediateCAs
parameters:
- $ref: "#/components/parameters/resourceId"
responses:
"200":
description: Flat list of CA rows
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { type: object }
"401":
description: Authentication required
"403":
description: Admin role required
/api/v1/intermediates/{id}:
get:
tags: [IntermediateCAs]
summary: Get a single intermediate CA by ID
operationId: getIntermediateCA
parameters:
- $ref: "#/components/parameters/resourceId"
responses:
"200":
description: IntermediateCA row
"401":
description: Authentication required
"403":
description: Admin role required
"404":
$ref: "#/components/responses/NotFound"
/api/v1/intermediates/{id}/retire:
post:
tags: [IntermediateCAs]
summary: Retire an intermediate CA (two-phase drain)
description: |
Admin-gated. Two-phase: first call (confirm=false) transitions
active to retiring (the CA stops issuing new children but
existing children continue). Second call (confirm=true)
transitions retiring to retired (terminal). Refuses the
terminal transition if the CA still has active children —
drain-first semantics.
operationId: retireIntermediateCA
parameters:
- $ref: "#/components/parameters/resourceId"
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
note: { type: string }
confirm: { type: boolean, default: false }
responses:
"200":
description: Retire transition recorded
"401":
description: Authentication required
"403":
description: Admin role required
"404":
$ref: "#/components/responses/NotFound"
"409":
description: CA still has active children; drain them first
"500":
$ref: "#/components/responses/InternalError"
/api/v1/notifications: /api/v1/notifications:
get: get:
tags: [Notifications] tags: [Notifications]
@@ -4057,6 +4361,63 @@ components:
$ref: "#/components/schemas/ErrorResponse" $ref: "#/components/schemas/ErrorResponse"
schemas: schemas:
# ─── Approvals ───────────────────────────────────────────────────
ApprovalRequest:
type: object
description: |
Rank 7 issuance approval-workflow primitive. One row per (CertificateID,
JobID) pair; the JobID points at the blocked Job whose Status is
AwaitingApproval. Lifecycle: pending → approved | rejected | expired.
Once terminal, the row is immutable; the audit_events table is the
durable record of who decided + why.
required:
- id
- certificate_id
- job_id
- profile_id
- requested_by
- state
- created_at
- updated_at
properties:
id:
type: string
description: Approval request ID (ar-<slug>).
certificate_id:
type: string
job_id:
type: string
profile_id:
type: string
requested_by:
type: string
description: Actor that triggered the renewal.
state:
type: string
enum: [pending, approved, rejected, expired]
decided_by:
type: string
nullable: true
description: Approver identity; null while state=pending.
decided_at:
type: string
format: date-time
nullable: true
decision_note:
type: string
nullable: true
metadata:
type: object
additionalProperties:
type: string
description: Free-form key/value (common_name, sans, issuer_id, severity_tier).
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
# ─── Common ────────────────────────────────────────────────────── # ─── Common ──────────────────────────────────────────────────────
ErrorResponse: ErrorResponse:
type: object type: object
+16 -16
View File
@@ -30,22 +30,22 @@ import (
"syscall" "syscall"
"time" "time"
"github.com/shankar0123/certctl/internal/connector/target" "github.com/certctl-io/certctl/internal/connector/target"
"github.com/shankar0123/certctl/internal/connector/target/apache" "github.com/certctl-io/certctl/internal/connector/target/apache"
"github.com/shankar0123/certctl/internal/connector/target/awsacm" "github.com/certctl-io/certctl/internal/connector/target/awsacm"
"github.com/shankar0123/certctl/internal/connector/target/azurekv" "github.com/certctl-io/certctl/internal/connector/target/azurekv"
"github.com/shankar0123/certctl/internal/connector/target/caddy" "github.com/certctl-io/certctl/internal/connector/target/caddy"
"github.com/shankar0123/certctl/internal/connector/target/envoy" "github.com/certctl-io/certctl/internal/connector/target/envoy"
"github.com/shankar0123/certctl/internal/connector/target/f5" "github.com/certctl-io/certctl/internal/connector/target/f5"
"github.com/shankar0123/certctl/internal/connector/target/haproxy" "github.com/certctl-io/certctl/internal/connector/target/haproxy"
"github.com/shankar0123/certctl/internal/connector/target/iis" "github.com/certctl-io/certctl/internal/connector/target/iis"
jks "github.com/shankar0123/certctl/internal/connector/target/javakeystore" jks "github.com/certctl-io/certctl/internal/connector/target/javakeystore"
k8s "github.com/shankar0123/certctl/internal/connector/target/k8ssecret" k8s "github.com/certctl-io/certctl/internal/connector/target/k8ssecret"
"github.com/shankar0123/certctl/internal/connector/target/nginx" "github.com/certctl-io/certctl/internal/connector/target/nginx"
pf "github.com/shankar0123/certctl/internal/connector/target/postfix" pf "github.com/certctl-io/certctl/internal/connector/target/postfix"
sshconn "github.com/shankar0123/certctl/internal/connector/target/ssh" sshconn "github.com/certctl-io/certctl/internal/connector/target/ssh"
"github.com/shankar0123/certctl/internal/connector/target/traefik" "github.com/certctl-io/certctl/internal/connector/target/traefik"
wcs "github.com/shankar0123/certctl/internal/connector/target/wincertstore" wcs "github.com/certctl-io/certctl/internal/connector/target/wincertstore"
) )
// AgentConfig represents the agent-side configuration. // AgentConfig represents the agent-side configuration.
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/shankar0123/certctl/internal/cli" "github.com/certctl-io/certctl/internal/cli"
) )
// Bundle Q (L-001 closure): per-subcommand dispatch tests for cmd/cli/main.go. // Bundle Q (L-001 closure): per-subcommand dispatch tests for cmd/cli/main.go.
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"os" "os"
"strings" "strings"
"github.com/shankar0123/certctl/internal/cli" "github.com/certctl-io/certctl/internal/cli"
) )
func main() { func main() {
+1 -1
View File
@@ -11,7 +11,7 @@ import (
gomcp "github.com/modelcontextprotocol/go-sdk/mcp" gomcp "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/shankar0123/certctl/internal/mcp" "github.com/certctl-io/certctl/internal/mcp"
) )
// Version is set at build time via -ldflags. // Version is set at build time via -ldflags.
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/shankar0123/certctl/internal/api/router" "github.com/certctl-io/certctl/internal/api/router"
) )
// Bundle B / Audit M-002 (CWE-862): pin the dispatch-layer auth-exempt // Bundle B / Audit M-002 (CWE-862): pin the dispatch-layer auth-exempt
+75 -21
View File
@@ -17,27 +17,27 @@ import (
"syscall" "syscall"
"time" "time"
acmepkg "github.com/shankar0123/certctl/internal/api/acme" acmepkg "github.com/certctl-io/certctl/internal/api/acme"
"github.com/shankar0123/certctl/internal/api/handler" "github.com/certctl-io/certctl/internal/api/handler"
"github.com/shankar0123/certctl/internal/api/middleware" "github.com/certctl-io/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/api/router" "github.com/certctl-io/certctl/internal/api/router"
"github.com/shankar0123/certctl/internal/config" "github.com/certctl-io/certctl/internal/config"
discoveryawssm "github.com/shankar0123/certctl/internal/connector/discovery/awssm" discoveryawssm "github.com/certctl-io/certctl/internal/connector/discovery/awssm"
discoveryazurekv "github.com/shankar0123/certctl/internal/connector/discovery/azurekv" discoveryazurekv "github.com/certctl-io/certctl/internal/connector/discovery/azurekv"
discoverygcpsm "github.com/shankar0123/certctl/internal/connector/discovery/gcpsm" discoverygcpsm "github.com/certctl-io/certctl/internal/connector/discovery/gcpsm"
notifyemail "github.com/shankar0123/certctl/internal/connector/notifier/email" notifyemail "github.com/certctl-io/certctl/internal/connector/notifier/email"
notifyopsgenie "github.com/shankar0123/certctl/internal/connector/notifier/opsgenie" notifyopsgenie "github.com/certctl-io/certctl/internal/connector/notifier/opsgenie"
notifypagerduty "github.com/shankar0123/certctl/internal/connector/notifier/pagerduty" notifypagerduty "github.com/certctl-io/certctl/internal/connector/notifier/pagerduty"
notifyslack "github.com/shankar0123/certctl/internal/connector/notifier/slack" notifyslack "github.com/certctl-io/certctl/internal/connector/notifier/slack"
notifyteams "github.com/shankar0123/certctl/internal/connector/notifier/teams" notifyteams "github.com/certctl-io/certctl/internal/connector/notifier/teams"
"github.com/shankar0123/certctl/internal/crypto/signer" "github.com/certctl-io/certctl/internal/crypto/signer"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/ratelimit" "github.com/certctl-io/certctl/internal/ratelimit"
"github.com/shankar0123/certctl/internal/repository/postgres" "github.com/certctl-io/certctl/internal/repository/postgres"
"github.com/shankar0123/certctl/internal/scep/intune" "github.com/certctl-io/certctl/internal/scep/intune"
"github.com/shankar0123/certctl/internal/scheduler" "github.com/certctl-io/certctl/internal/scheduler"
"github.com/shankar0123/certctl/internal/service" "github.com/certctl-io/certctl/internal/service"
"github.com/shankar0123/certctl/internal/trustanchor" "github.com/certctl-io/certctl/internal/trustanchor"
) )
func main() { func main() {
@@ -267,6 +267,43 @@ func main() {
// same *sql.DB handle. // same *sql.DB handle.
transactor := postgres.NewTransactor(db) transactor := postgres.NewTransactor(db)
certificateService.SetTransactor(transactor) certificateService.SetTransactor(transactor)
// Rank 7 of the 2026-05-03 Infisical deep-research deliverable —
// issuance approval-workflow primitive. ApprovalRepository +
// ApprovalMetrics + ApprovalService construct here; the gate is
// activated on CertificateService via SetApprovalService +
// SetProfileRepo. Inactive when CertificateProfile.RequiresApproval
// is false (the default), preserving the historical unattended
// renewal path. See docs/approval-workflow.md.
approvalRepo := postgres.NewApprovalRepository(db)
approvalMetrics := service.NewApprovalMetrics()
approvalService := service.NewApprovalService(approvalRepo, jobRepo, auditService,
approvalMetrics, cfg.Approval.BypassEnabled)
if cfg.Approval.BypassEnabled {
logger.Warn("CERTCTL_APPROVAL_BYPASS=true — every approval auto-approves with actor=system-bypass; production deploys must leave this unset")
}
certificateService.SetApprovalService(approvalService)
certificateService.SetProfileRepo(profileRepo)
approvalHandler := handler.NewApprovalHandler(approvalService)
// Rank 8 of the 2026-05-03 deep-research deliverable — first-class
// CA hierarchy management (intermediate_cas table + admin-gated
// hierarchy endpoints). The service receives the issuerRepo so
// future surface area (issuer-row hierarchy_mode validation) can
// query the issuer config; for the commit-4 wiring it carries
// only the fields used today. The signer.FileDriver shared with
// the OCSP responder bootstrap path is reused here — operators
// can plug in PKCS#11 / cloud-KMS drivers via the same Driver
// interface without touching the service. See
// docs/intermediate-ca-hierarchy.md.
intermediateCARepo := postgres.NewIntermediateCARepository(db)
intermediateCAMetrics := service.NewIntermediateCAMetrics()
// Defer wiring the service + handler — signerDriver is constructed
// further down in this function alongside the OCSP responder
// bootstrap path. The service holds a reference to issuerRepo for
// future hierarchy_mode validation surface area.
_ = intermediateCAMetrics // service constructed below alongside signerDriver
notifierRegistry := make(map[string]service.Notifier) notifierRegistry := make(map[string]service.Notifier)
// Wire notifier connectors from config // Wire notifier connectors from config
@@ -371,6 +408,15 @@ func main() {
RotationGrace: cfg.OCSPResponder.RotationGrace, RotationGrace: cfg.OCSPResponder.RotationGrace,
Validity: cfg.OCSPResponder.Validity, Validity: cfg.OCSPResponder.Validity,
}) })
// Rank 8 service + handler — wired here so signerDriver is in
// scope. The same FileDriver instance feeds both the OCSP
// responder bootstrap path and the intermediate-CA hierarchy.
// Operators that swap to PKCS#11 / cloud-KMS drivers reuse the
// single Driver instance across both surfaces.
intermediateCAService := service.NewIntermediateCAService(
intermediateCARepo, issuerRepo, signerDriver, auditService, intermediateCAMetrics)
intermediateCAHandler := handler.NewIntermediateCAHandler(intermediateCAService)
crlCacheService := service.NewCRLCacheService(crlCacheRepo, caOperationsSvc, issuerRegistry, logger) crlCacheService := service.NewCRLCacheService(crlCacheRepo, caOperationsSvc, issuerRegistry, logger)
// Production hardening II Phase 2: OCSP response cache. Mirrors the // Production hardening II Phase 2: OCSP response cache. Mirrors the
@@ -907,6 +953,14 @@ func main() {
// new-order, finalize, challenges, revoke, ARI). See // new-order, finalize, challenges, revoke, ARI). See
// docs/acme-server.md for the operator-facing reference. // docs/acme-server.md for the operator-facing reference.
ACME: acmeHandler, ACME: acmeHandler,
// Approvals — issuance approval-workflow primitive. Rank 7 of
// the 2026-05-03 Infisical deep-research deliverable. See
// docs/approval-workflow.md.
Approvals: approvalHandler,
// IntermediateCAs — first-class CA hierarchy management.
// Rank 8 of the 2026-05-03 deep-research deliverable. See
// docs/intermediate-ca-hierarchy.md.
IntermediateCAs: intermediateCAHandler,
}) })
// Register EST (RFC 7030) handlers if enabled. // Register EST (RFC 7030) handlers if enabled.
// //
+4 -4
View File
@@ -10,10 +10,10 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/shankar0123/certctl/internal/api/middleware" "github.com/certctl-io/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/api/router" "github.com/certctl-io/certctl/internal/api/router"
"github.com/shankar0123/certctl/internal/config" "github.com/certctl-io/certctl/internal/config"
"github.com/shankar0123/certctl/internal/service" "github.com/certctl-io/certctl/internal/service"
) )
// TestMain_HealthEndpointBypassesAuth verifies that health check endpoints // TestMain_HealthEndpointBypassesAuth verifies that health check endpoints
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/shankar0123/certctl/internal/service" "github.com/certctl-io/certctl/internal/service"
) )
// fakeIssuerConn implements service.IssuerConnector enough for preflight tests. // fakeIssuerConn implements service.IssuerConnector enough for preflight tests.
+1 -1
View File
@@ -28,7 +28,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/shankar0123/certctl/internal/deploy" "github.com/certctl-io/certctl/internal/deploy"
) )
// TestDeploy_Atomicity_FileIsAlwaysOldOrNew pins the load-bearing // TestDeploy_Atomicity_FileIsAlwaysOldOrNew pins the load-bearing
Binary file not shown.
+1 -1
View File
@@ -1,3 +1,3 @@
module github.com/shankar0123/certctl/deploy/test/f5-mock-icontrol module github.com/certctl-io/certctl/deploy/test/f5-mock-icontrol
go 1.25.9 go 1.25.9
+9 -1
View File
@@ -290,7 +290,15 @@ services:
# /healthz endpoint. # /healthz endpoint.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
f5-mock-target: f5-mock-target:
build: ../f5-mock-icontrol # Long-form build to match docker-compose.test.yml: the Dockerfile
# has `COPY deploy/test/f5-mock-icontrol/ ./` which assumes the
# build context is the REPO ROOT. The previous shorthand form
# `build: ../f5-mock-icontrol` set the context to the
# f5-mock-icontrol directory itself, breaking the COPY at CI build
# time (run #25305811340: "deploy/test/f5-mock-icontrol: not found").
build:
context: ../../..
dockerfile: deploy/test/f5-mock-icontrol/Dockerfile
container_name: certctl-loadtest-f5-mock container_name: certctl-loadtest-f5-mock
healthcheck: healthcheck:
test: ["CMD-SHELL", "wget -q -O- http://localhost:8080/healthz || exit 1"] test: ["CMD-SHELL", "wget -q -O- http://localhost:8080/healthz || exit 1"]
+141
View File
@@ -0,0 +1,141 @@
# Issuance approval workflow
certctl can gate certificate issuance + renewal on a per-profile, two-person-integrity check. Compliance customers (PCI-DSS Level 1, FedRAMP Moderate / High, SOC 2 Type II, HIPAA) configure this on production-tier `CertificateProfile` rows so every renewal-loop tick or manual `POST /api/v1/certificates/{id}/renew` blocks at `JobStatusAwaitingApproval` until a different actor approves.
Closes the procurement-checklist question "How do you enforce two-person integrity on cert issuance?" — without this surface the answer is "we don't"; with `requires_approval=true` on the profile, the answer is "here's the RBAC contract + here's the audit query that proves bypass mode is off in production."
## End-to-end flow
```mermaid
sequenceDiagram
autonumber
participant A as Operator A<br/>(or scheduler)
participant SVC as CertificateService<br/>.TriggerRenewal
participant JOB as Job + ApprovalRequest
participant B as Operator B
participant APR as ApprovalService.Approve
participant SCH as Scheduler
A->>SVC: POST /api/v1/certificates/{id}/renew<br/>(or renewal-loop tick)
SVC->>JOB: read profile.RequiresApproval;<br/>create Job @ JobStatusAwaitingApproval;<br/>create ApprovalRequest<br/>(state=pending, requested_by=Operator A)
Note over JOB,SCH: Scheduler skips —<br/>AwaitingApproval is NOT a dispatchable status
B->>JOB: GET /api/v1/approvals?state=pending
B->>APR: POST /api/v1/approvals/{id}/approve<br/>(decided_by=Operator B, note=...)
APR->>APR: RBAC: reject if Operator B == Operator A<br/>→ ErrApproveBySameActor (HTTP 403)
APR->>JOB: ApprovalRequest → state=approved;<br/>Job AwaitingApproval → Pending;<br/>audit row (action=approval_approved,<br/>actor=Operator B);<br/>certctl_approval_decisions_total<br/>{outcome=approved,profile_id=...}++
SCH->>JOB: pick up Pending → dispatch to issuer connector
JOB-->>A: cert issues normally
```
## Configuration
Set `requires_approval=true` on a `CertificateProfile`:
```bash
curl -X PUT https://certctl/api/v1/profiles/p-prod-cdn \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Production CDN",
"requires_approval": true,
...
}'
```
Every certificate bound to that profile is now gated. The default is `requires_approval=false` — existing profiles keep the historical unattended renewal path.
## RBAC: the two-person integrity rule
The actor that triggers a renewal **cannot** be the actor that approves it. The check happens at the service layer and surfaces as **HTTP 403** at the handler. The error message contains the substring `two-person integrity` so server-log greps detect attempted self-approvals.
This is the load-bearing compliance contract. Pinned by:
- `internal/service/approval_test.go::TestApproval_Approve_RejectsSameActor` — service-level pin.
- `internal/api/handler/approval_test.go::TestApproval_HandlerApproveAsSameActor_Returns403` — handler-level pin (HTTP 403 + body contains "two-person integrity").
## Operator playbook: "I need to approve a renewal"
```bash
# 1. Find the pending request
curl -s "https://certctl/api/v1/approvals?state=pending" \
-H "Authorization: Bearer $API_KEY" | jq
# 2. Inspect the request — confirm CN, SANs, requester
curl -s "https://certctl/api/v1/approvals/ar-abc123" \
-H "Authorization: Bearer $API_KEY" | jq
# 3. Approve as a different actor than the requester
curl -X POST "https://certctl/api/v1/approvals/ar-abc123/approve" \
-H "Authorization: Bearer $APPROVER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"note":"approved per ticket SECOPS-12345"}'
# 4. Confirm the job transitioned to Pending
curl -s "https://certctl/api/v1/jobs?certificate_id=mc-foo" \
-H "Authorization: Bearer $API_KEY" | jq '.[] | {id,status,type}'
```
To **reject** instead, swap the path: `POST /api/v1/approvals/{id}/reject` with the same body shape. The job transitions to `Cancelled` and the `note` is recorded in the audit row.
## Operator playbook: "approval timed out"
The scheduler reaper transitions stale pending requests + their linked jobs after `CERTCTL_JOB_AWAITING_APPROVAL_TIMEOUT` (default `168h` = 7 days):
- `ApprovalRequest.state``expired`
- `Job.Status``Cancelled` (with `error_message="approval expired"`)
- One audit row per expiry (`action=approval_expired, actor=system-reaper, actorType=System`)
- `certctl_approval_decisions_total{outcome="expired",profile_id="..."}` increments
Resolve by re-triggering the renewal once the underlying delay is sorted:
```bash
curl -X POST "https://certctl/api/v1/certificates/mc-foo/renew" \
-H "Authorization: Bearer $API_KEY"
```
Tighten the timeout for short-window deployments via the env var, e.g. `CERTCTL_JOB_AWAITING_APPROVAL_TIMEOUT=24h`.
## Compliance control mapping
| Standard | Control | What this surface satisfies |
|---|---|---|
| PCI-DSS 4.0 | **§6.4.5** (Separation of duties for production change-management) | Same-actor RBAC pin; audit row carries both `requested_by` and `decided_by` so reviewers see two distinct identities per change. |
| NIST SP 800-53 | **SA-15** (Development process; two-person review for security-relevant changes) | Service-layer `ErrApproveBySameActor` + `TestApproval_Approve_RejectsSameActor` pin the contract. Bypass-mode emits a typed audit row (`action=approval_bypassed`) so compliance reviewers detect dev-mode misuse via `SELECT count(*) FROM audit_events WHERE actor='system-bypass'` returning > 0. |
| SOC 2 Type II | **CC6.1** (Logical access — restrict, monitor, terminate) | Per-decision audit row + `certctl_approval_decisions_total{outcome,profile_id}` Prometheus counter. Operators alert on sustained `outcome="rejected"` or `outcome="expired"` bursts. |
| HIPAA | **§164.308(a)(4)** (Information access management) | Same surface — the per-policy gating + audit trail is the access-management control. |
## Bypass mode (dev / CI ONLY)
Setting `CERTCTL_APPROVAL_BYPASS=true` short-circuits the workflow: every `RequestApproval` call auto-approves with `decided_by=system-bypass` and `actorType=System`. Used by dev / CI to keep renewal-scheduler tests fast without standing up an approver.
**Production deploys MUST leave this unset.** The bypass emits a typed audit event (`action=approval_bypassed`) so compliance auditors detect misuse via:
```sql
SELECT count(*) FROM audit_events WHERE actor = 'system-bypass';
```
returning **zero rows in production** and a high count in dev. The certctl-server logs a `WARN` line at boot when bypass is enabled — operators alert on that log line in production environments.
## Prometheus metrics
```
certctl_approval_decisions_total{outcome,profile_id} counter
certctl_approval_pending_age_seconds histogram
(le buckets:
60, 300, 1800, 3600,
21600, 86400, +Inf)
```
`outcome` is one of `approved`, `rejected`, `expired`, `bypassed`. `profile_id` is the `CertificateProfile.ID` that triggered the gate (cardinality-bounded — operators have <100 profiles in production).
The pending-age histogram observes seconds-since-creation at the moment of decision. Alert when p99 hits hours/days — compliance customers usually have a same-day decision deadline.
## Future free V2 work
- **M-of-N approver chains.** Today's primitive is single-approver. Future V2 work adds chains — e.g., "needs 2 of 3 platform-team members."
- **Time-windowed auto-approve.** Today's reaper hard-cancels at the static deadline. Policy-driven time-windowed auto-approve (T+30m unattended → cancel; T+24h business hours → escalate) is future work.
- **External ticketing integration.** ServiceNow / JIRA bridging so approval state mirrors the change-management record.
- **Per-owner / per-team routing.** Today's pool is global. Per-owner / per-team routing matches cert ownership to approver pools.
- **Approval delegation.** Today the same-actor rule is strict. Time-bounded delegation is future work.
Tracked in `WORKSPACE-ROADMAP.md` under the Future Free V2 Work section — every item ships free under BSL.
+2
View File
@@ -156,6 +156,8 @@ The Local CA issuer signs certificates using Go's `crypto/x509` library. It supp
**Sub-CA mode:** Loads a CA certificate and private key from disk (`CERTCTL_CA_CERT_PATH` + `CERTCTL_CA_KEY_PATH`). The CA cert is signed by an upstream CA (e.g., ADCS), so all issued certificates chain to the enterprise root trust hierarchy. Clients that already trust the enterprise root automatically trust certctl-issued certs. Supports RSA, ECDSA, and PKCS#8 key formats. If the paths are not set, falls back to self-signed mode. The loaded certificate must have `IsCA=true` and `KeyUsageCertSign`. **Sub-CA mode:** Loads a CA certificate and private key from disk (`CERTCTL_CA_CERT_PATH` + `CERTCTL_CA_KEY_PATH`). The CA cert is signed by an upstream CA (e.g., ADCS), so all issued certificates chain to the enterprise root trust hierarchy. Clients that already trust the enterprise root automatically trust certctl-issued certs. Supports RSA, ECDSA, and PKCS#8 key formats. If the paths are not set, falls back to self-signed mode. The loaded certificate must have `IsCA=true` and `KeyUsageCertSign`.
**Tree mode (Rank 8 — multi-level CA hierarchy):** When `Issuer.HierarchyMode = "tree"` is set on the issuer row, the local connector reads the active CA hierarchy from the `intermediate_cas` table and assembles `IssuanceResult.ChainPEM` by walking the `parent_ca_id` ancestry from the issuing leaf CA up to the root. Tree mode is operator-managed via the admin-gated `/api/v1/issuers/{id}/intermediates` and `/api/v1/intermediates/{id}` endpoints (`POST` to create / sign children, `GET` to list / inspect, `POST .../retire` to two-phase retire). The signing path is shared with single-mode (cert is signed via `c.caCert` + `c.caSigner` from the on-disk issuing CA cert+key); only the chain bytes differ. RFC 5280 §3.2 (self-signed root validation), §4.2.1.9 (path-length tightening), and §4.2.1.10 (NameConstraints subset semantics) are enforced at the service layer fail-closed. The default is `single`, byte-identical to the pre-Rank-8 historical flow. See `docs/intermediate-ca-hierarchy.md` for the operator runbook covering 4-level FedRAMP boundary CA, 3-level financial-services policy CA, 2-level internal-PKI patterns + the migration runbook for flipping a single-mode issuer to tree.
**CRL and OCSP support (M15b):** The Local CA supports DER-encoded X.509 CRL generation served unauthenticated at `GET /.well-known/pki/crl/{issuer_id}` (RFC 5280 §5, RFC 8615, `Content-Type: application/pkix-crl`) with 24-hour validity. An embedded OCSP responder at `GET /.well-known/pki/ocsp/{issuer_id}/{serial}` (RFC 6960, `Content-Type: application/ocsp-response`) returns signed OCSP responses for issued certificates (good/revoked/unknown status). Both endpoints are reachable by relying parties with no certctl API credentials, which is how standard TLS clients, browsers, and hardware appliances consume these resources. Certificates with profile TTL < 1 hour automatically skip CRL/OCSP — expiry is treated as sufficient revocation for short-lived credentials. **CRL and OCSP support (M15b):** The Local CA supports DER-encoded X.509 CRL generation served unauthenticated at `GET /.well-known/pki/crl/{issuer_id}` (RFC 5280 §5, RFC 8615, `Content-Type: application/pkix-crl`) with 24-hour validity. An embedded OCSP responder at `GET /.well-known/pki/ocsp/{issuer_id}/{serial}` (RFC 6960, `Content-Type: application/ocsp-response`) returns signed OCSP responses for issued certificates (good/revoked/unknown status). Both endpoints are reachable by relying parties with no certctl API credentials, which is how standard TLS clients, browsers, and hardware appliances consume these resources. Certificates with profile TTL < 1 hour automatically skip CRL/OCSP — expiry is treated as sufficient revocation for short-lived credentials.
**Extended Key Usage (EKU) support (M27):** The Local CA respects EKU constraints from certificate profiles and adjusts key usage flags accordingly. For S/MIME certificates (emailProtection EKU), it uses `DigitalSignature | ContentCommitment` instead of the TLS default. For TLS certificates (serverAuth/clientAuth EKU), it uses `DigitalSignature | KeyEncipherment`. This enables support for multiple certificate types — TLS, S/MIME, code signing, timestamping — from a single CA. **Extended Key Usage (EKU) support (M27):** The Local CA respects EKU constraints from certificate profiles and adjusts key usage flags accordingly. For S/MIME certificates (emailProtection EKU), it uses `DigitalSignature | ContentCommitment` instead of the TLS default. For TLS certificates (serverAuth/clientAuth EKU), it uses `DigitalSignature | KeyEncipherment`. This enables support for multiple certificate types — TLS, S/MIME, code signing, timestamping — from a single CA.
+231
View File
@@ -0,0 +1,231 @@
# Intermediate CA hierarchy — operator runbook
Rank 8 of the 2026-05-03 deep-research deliverable. This page is the
canonical reference for operators running certctl as a multi-level
internal PKI.
The default `single`-mode flow (one operator-supplied sub-CA loaded
from disk at boot) is unchanged and will keep working byte-for-byte
forever. This page is for operators who need a real CA tree:
- FedRAMP boundary-CA deployments where the regulator requires
separation of policy and issuing authorities.
- Financial-services policy-CA deployments (one root, one policy CA
per business unit, one issuing CA per environment).
- OT / industrial control networks where the air-gapped root signs
online sub-CAs that go in and out of service on a rotation.
## Concepts
`Issuer.HierarchyMode` is a per-issuer column on the `issuers` table.
Two values are valid (the database default is `"single"` — back-compat
byte-identical for unmigrated rows):
- `single` — pre-Rank-8 historical flow. The local connector loads a
pre-signed CA cert+key from disk via `local.Config.CACertPath` /
`local.Config.CAKeyPath`. Existing operators upgrade with no
behavior change.
- `tree` — the issuer's CAs are managed via the `intermediate_cas`
table. Chain assembly walks the `parent_ca_id` foreign key from the
issuing leaf CA up to the root and attaches the assembled chain to
every `IssuanceResult`.
Each row in `intermediate_cas` is one CA cert (root, policy, issuing).
The lifecycle is `created``active``retiring``retired`. The
state column is a closed enum and validates at the service layer; the
postgres CHECK constraint enforces it at the database layer too.
A CA's private key bytes are NEVER persisted on the row. The
`key_driver_id` column is a reference (filesystem path / KMS key ID /
HSM slot) that the `signer.Driver` resolves at sign time. A SQL
injection or a row-leak surface MUST NEVER expose key bytes; only the
reference can leak.
## Lifecycle states
```mermaid
stateDiagram-v2
[*] --> created : CreateRoot / CreateChild
created --> active : registration completes
active --> retiring : Retire(confirm=false)
retiring --> retired : Retire(confirm=true)
retired --> [*]
note right of retiring
Drain start. CA stops issuing
NEW children; existing children
keep issuing until they retire.
end note
note right of retired
Terminal. Refused if active children
remain (ErrCAStillHasActiveChildren
→ HTTP 409). OCSP keeps responding
for already-issued leaves until expiry.
end note
```
Drain-first semantics: a CA in `retiring` state cannot terminalize to
`retired` while it still has active children. The service layer
returns `ErrCAStillHasActiveChildren`; the API surfaces HTTP 409. Drain
the children first.
## Common deployment patterns
### Pattern A — 4-level FedRAMP boundary CA
```mermaid
flowchart TD
Root["Acme Root CA<br/>path_len=3<br/>offline air-gapped"]
Policy["Acme Policy CA<br/>path_len=2<br/>FedRAMP-Moderate boundary"]
IssA["Acme Issuing A<br/>path_len=0<br/>prod workload leaves"]
IssB["Acme Issuing B<br/>path_len=0<br/>ephemeral pod identity"]
Root --> Policy --> IssA --> IssB
```
Operator workflow:
1. Mint the root cert+key on the offline workstation. Move the cert
PEM (no key) to the online operator workstation.
2. `POST /api/v1/issuers/{id}/intermediates` with the empty
`parent_ca_id` and `root_cert_pem` + `key_driver_id` populated
(the operator pre-positions the root key file at the path the
`key_driver_id` points to). The service validates RFC 5280 §3.2
self-signed semantics + cross-checks the operator-supplied key
matches the cert (rejects mismatched bundles at registration time
with `ErrCAKeyMismatch`).
3. `POST /api/v1/issuers/{id}/intermediates` with `parent_ca_id`
pointing at the root for the Policy CA. The service generates the
child key via `signer.Driver.Generate`, signs the child cert via
the parent's signer (loaded from the parent's `key_driver_id`),
and persists the new row with the next `path_len` value (parent's
- 1 if unset). Repeat for each lower level.
4. Set `Issuer.HierarchyMode = "tree"` on the issuer row + set the
`treeIssuingCAID` connector field to point at the deepest CA
(Acme Issuing B in the example above) — issued leaves chain via
`AssembleChain` from B up to the root.
### Pattern B — 3-level financial-services policy CA
```mermaid
flowchart TD
Root["FinCo Root CA<br/>path_len=2"]
Pol["FinCo Trading Policy CA<br/>path_len=1<br/>permitted DNS = trading.finco.example"]
Iss["FinCo Trading Issuing CA<br/>path_len=0"]
Root --> Pol --> Iss
```
Per business-unit name constraints: each policy CA carries a
`PermittedDNSDomains` list scoped to the business unit (RFC 5280
§4.2.1.10). The service enforces subset semantics — a child policy CA
cannot widen the parent's permitted set, and cannot remove an
excluded subtree. Operators submit `name_constraints` on the
`POST /api/v1/issuers/{id}/intermediates` body.
### Pattern C — 2-level internal PKI
```mermaid
flowchart TD
Root["Internal Root CA<br/>path_len=0"]
Iss["Internal Issuing CA<br/>path_len=0<br/>issues leaves directly"]
Root --> Iss
```
The simplest tree-mode deployment. Roughly equivalent to single mode
in terms of operator overhead, but provides one extra layer of
indirection so the root key can stay offline while only the issuing
CA's key sits on the certctl host.
## RFC 5280 enforcement
All enforcement happens at the service layer. The local connector
trusts the service's contract; the API layer translates errors to
HTTP codes.
- §3.2 self-signed root validation: `cert.CheckSignatureFrom(cert)` +
subject == issuer DN. Rejected with `ErrCANotSelfSigned`
HTTP 400.
- §4.2.1.9 path-length tightening: child's `PathLenConstraint` must
be strictly less than parent's. Default to `parent - 1` when unset.
Rejected with `ErrPathLenExceeded` → HTTP 400.
- §4.2.1.10 NameConstraints subset: child's `Permitted` set must be a
subset of parent's; child's `Excluded` set must be a superset of
parent's. Rejected with `ErrNameConstraintExceeded` → HTTP 400.
- §4.1.2.5 validity capping: child's `notAfter` capped to parent's
`notAfter` automatically (chain breaks at parent's expiry
regardless).
## Migrating a single-mode issuer to tree mode
Pre-flight: the load-bearing pin
`TestLocal_HierarchyMode_SingleVsTree_ByteIdentical` guarantees that
a 1-level tree wired around the same on-disk root cert+key produces
byte-identical issuance bundles to single mode. Migration is therefore
a no-downtime operation if done carefully:
1. Register the existing single-mode CA cert as an `intermediate_cas`
row via `CreateRoot` (with the existing on-disk key referenced as
`key_driver_id`).
2. Update the issuer row's `hierarchy_mode` to `"tree"` and set the
connector's `SetTreeIssuingCAID` to the new row's ID. Restart the
server (no new code path activates until the connector reads the
updated mode at boot).
3. Issue a test cert. The byte-equivalence pin guarantees the wire
bytes match the pre-migration output for a 1-level tree.
4. Build out the child CAs via `CreateChild` calls. Update
`treeIssuingCAID` to the new leaf CA. Test, then ramp.
If the pin breaks during migration, abort: roll back the
`hierarchy_mode` flip and investigate. The byte-equivalence pin is
the canary — if it goes red, deeper bugs lurk.
## API reference
All endpoints under `/api/v1/issuers/{id}/intermediates` and
`/api/v1/intermediates/{id}` are admin-gated. Non-admin Bearer callers
get HTTP 403.
| Method | Path | Purpose |
|--------|------|---------|
| POST | `/api/v1/issuers/{id}/intermediates` | Register root OR sign child (body discriminator) |
| GET | `/api/v1/issuers/{id}/intermediates` | List flat hierarchy for issuer |
| GET | `/api/v1/intermediates/{id}` | Single-row detail |
| POST | `/api/v1/intermediates/{id}/retire` | Two-phase retirement |
See `api/openapi.yaml` for full request/response schemas.
## Observability
`IntermediateCAMetrics` ships counters dimensioned by `(issuer_id,
kind)`:
- `create_root` — successful CreateRoot calls.
- `create_child` — successful CreateChild calls.
- `retire_retiring``active → retiring` transitions.
- `retire_retired``retiring → retired` transitions.
The Prometheus exposer reads the snapshot via
`SnapshotIntermediateCA()` from a single instance constructed in
`cmd/server/main.go` (the snapshotter is the single source of truth
between the service-side recording path and the metrics-side exposing
path).
The audit table receives one row per CreateRoot / CreateChild /
Retire transition, scoped to the actor extracted from the API
request's auth context.
## Known limitations
The following are tracked in `WORKSPACE-ROADMAP.md` as Rank-8 follow-on
work — none are required for the v2.1.0 acquisition gate:
- HSM-backed roots beyond `signer.FileDriver` (PKCS#11 / cloud KMS
drivers).
- Automated rotation: scheduled re-issuance of sub-CAs ahead of
expiry with parallel-validity windows.
- Intra-hierarchy CRL chaining: each non-leaf CA publishes a CRL
covering its direct children's revocations.
- NameConstraints policy templates: declarative templates an operator
can pick from instead of hand-rolling the JSON.
- D3 dendrogram visualization on the GUI page (today's render is a
recursive `<ul>` nested list).
+33 -36
View File
@@ -15,42 +15,39 @@ install certctl.
## End-to-end flow (cloud targets) ## End-to-end flow (cloud targets)
``` ```mermaid
cert renewed → renewal job created flowchart TD
Renew["cert renewed → renewal job created"]
Pick["agent picks up DeployCertificate work item"]
agent picks up DeployCertificate work item Dispatch["target.Connector.DeployCertificate(ctx, request)"]
Renew --> Pick --> Dispatch
target.Connector.DeployCertificate(ctx, request) Dispatch --> AWS
Dispatch --> AZ
┌──────────────────┴──────────────────┐
│ │ subgraph AWS["AWS ACM path"]
▼ ▼ A1["1. rotate-in-place only:<br/>DescribeCertificate(arn)"]
AWS ACM path Azure Key Vault path A2["2. GetCertificate(arn) —<br/>capture snapshot bytes for rollback"]
│ │ A3["3. ImportCertificate(arn, new_bytes) —<br/>fresh ARN OR rotate-in-place"]
▼ ▼ A4["4. AddTagsToCertificate(arn, provenance) —<br/>ACM strips on re-import; we re-apply"]
1. (rotate-in-place only) 1. GetCertificate(name, "" /* latest */) A5["5. DescribeCertificate(arn) —<br/>verify serial matches expected"]
DescribeCertificate(arn) — capture snapshot CER bytes A6["6. ON MISMATCH: rollback<br/>ImportCertificate(arn, snapshot_bytes)"]
2. GetCertificate(arn) — capture 2. Build PFX from cert+chain+key A1 --> A2 --> A3 --> A4 --> A5 --> A6
snapshot bytes for rollback (PKCS#12 via go-pkcs12) end
3. ImportCertificate(arn, new_bytes) 3. ImportCertificate(name, PFX, tags)
— fresh ARN OR rotate-in-place — ALWAYS creates a new version subgraph AZ["Azure Key Vault path"]
4. AddTagsToCertificate(arn, 4. (Tags carried forward Z1["1. GetCertificate(name, '' = latest) —<br/>capture snapshot CER bytes"]
provenance) — ACM strips on automatically) Z2["2. Build PFX from cert+chain+key<br/>(PKCS#12 via go-pkcs12)"]
re-import; we re-apply Z3["3. ImportCertificate(name, PFX, tags) —<br/>ALWAYS creates a new version"]
5. DescribeCertificate(arn) — verify 5. GetCertificate(name, "" /* latest */) Z4["4. Tags carried forward automatically"]
serial matches expected — verify serial matches expected Z5["5. GetCertificate(name, '' = latest) —<br/>verify serial matches expected"]
6. ON MISMATCH: rollback ←──── (same shape) ────→ 6. ON MISMATCH: rollback Z6["6. ON MISMATCH: rollback<br/>ImportCertificate(name, snapshot_PFX) —<br/>new version"]
ImportCertificate(arn, ImportCertificate(name, Z1 --> Z2 --> Z3 --> Z4 --> Z5 --> Z6
snapshot_bytes) snapshot_PFX) — new version end
A6 --> Audit
7. Audit row + Prometheus counter Z6 --> Audit
certctl_deploy_attempts_total{target_type="AWSACM"|"AzureKeyVault", Audit["7. Audit row + Prometheus counters<br/>certctl_deploy_attempts_total{target_type, result}<br/>certctl_deploy_rollback_total{target_type, outcome}"]
result="success"|"failure"}
certctl_deploy_rollback_total{target_type=...,
outcome="restored"|"also_failed"}
``` ```
--- ---
+31 -30
View File
@@ -14,36 +14,37 @@ walkthrough of how to install certctl — that lives in the README.
## End-to-end flow ## End-to-end flow
``` ```mermaid
daily ticker (renewalCheckLoop) flowchart TD
Tick["daily ticker (renewalCheckLoop)"]
Check["RenewalService.CheckExpiringCertificates"]
RenewalService.CheckExpiringCertificates
Tick --> Check --> Loop
┌────────────────┴────────────────┐
for cert in expiring (≤30 days):│ subgraph Loop["for cert in expiring (≤30 days)"]
1. Resolve RenewalPolicy L1["1. Resolve RenewalPolicy"]
2. Compute daysUntil L2["2. Compute daysUntil"]
3. updateCertExpiryStatus L3["3. updateCertExpiryStatus"]
4. sendThresholdAlerts ──────►│ per threshold: L4["4. sendThresholdAlerts"]
5. Create renewal job (if │ a. resolve severity tier L5["5. Create renewal job<br/>(if issuer registered +<br/>ARI allows)"]
│ issuer registered + ARI │ via AlertSeverityMap L1 --> L2 --> L3 --> L4 --> L5
│ allows) │ b. resolve channel set end
└──────────────────────────────────┘ via AlertChannels[tier]
c. for each channel: L4 --> Threshold
i. dedup via
notification_events subgraph Threshold["per threshold"]
(cert,threshold,channel) T1["a. resolve severity tier<br/>via AlertSeverityMap"]
ii. SendThresholdAlertOnChannel T2["b. resolve channel set<br/>via AlertChannels[tier]"]
→ notifierRegistry[channel] T1 --> T2 --> Channel
→ Send(recipient,subj,body) end
iii. record audit row
(event_type=expiration_alert_sent, subgraph Channel["for each channel (fault-isolating)"]
metadata.channel, C1["i. dedup via notification_events<br/>(cert, threshold, channel)"]
metadata.severity_tier) C2["ii. SendThresholdAlertOnChannel<br/>→ notifierRegistry[channel]<br/>→ Send(recipient, subj, body)"]
iv. bump Prometheus counter C3["iii. record audit row<br/>event_type=expiration_alert_sent<br/>metadata.channel, metadata.severity_tier"]
certctl_expiry_alerts_total C4["iv. bump Prometheus counter<br/>certctl_expiry_alerts_total<br/>{channel, threshold, result}"]
{channel,threshold,result} C1 --> C2 --> C3 --> C4
end
``` ```
The dispatch loop's per-channel error handling is The dispatch loop's per-channel error handling is
+2 -2
View File
@@ -1,4 +1,4 @@
module github.com/shankar0123/certctl module github.com/certctl-io/certctl
go 1.25.9 go 1.25.9
@@ -32,7 +32,7 @@ require (
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
github.com/ChrisTrenkamp/goxpath v0.0.0-20210404020558-97928f7e12b6 // indirect github.com/ChrisTrenkamp/goxpath v0.0.0-20210404020558-97928f7e12b6 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect
+2 -2
View File
@@ -55,8 +55,8 @@ github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfg
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA=
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8= github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM=
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
+1 -1
View File
@@ -4,7 +4,7 @@
package acme package acme
import ( import (
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
) )
// AccountResponseJSON is the wire shape RFC 8555 §7.1.2 mandates for // AccountResponseJSON is the wire shape RFC 8555 §7.1.2 mandates for
+1 -1
View File
@@ -14,7 +14,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
) )
// Phase 4 — RFC 9773 ACME Renewal Information. // Phase 4 — RFC 9773 ACME Renewal Information.
+1 -1
View File
@@ -13,7 +13,7 @@ import (
jose "github.com/go-jose/go-jose/v4" jose "github.com/go-jose/go-jose/v4"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
) )
// AllowedSignatureAlgorithms is the closed allow-list per RFC 8555 §6.2. // AllowedSignatureAlgorithms is the closed allow-list per RFC 8555 §6.2.
+1 -1
View File
@@ -16,7 +16,7 @@ import (
jose "github.com/go-jose/go-jose/v4" jose "github.com/go-jose/go-jose/v4"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
) )
// --- test fixtures + helpers -------------------------------------------- // --- test fixtures + helpers --------------------------------------------
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
) )
// OrderResponseJSON is the wire shape RFC 8555 §7.1.3 mandates for the // OrderResponseJSON is the wire shape RFC 8555 §7.1.3 mandates for the
+1 -1
View File
@@ -21,7 +21,7 @@ import (
jose "github.com/go-jose/go-jose/v4" jose "github.com/go-jose/go-jose/v4"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
) )
// --- Test fixtures + helpers ------------------------------------------- // --- Test fixtures + helpers -------------------------------------------
+1 -1
View File
@@ -20,7 +20,7 @@ import (
"golang.org/x/sync/semaphore" "golang.org/x/sync/semaphore"
"github.com/shankar0123/certctl/internal/validation" "github.com/certctl-io/certctl/internal/validation"
) )
// ChallengeValidator is the surface a challenge-validation worker // ChallengeValidator is the surface a challenge-validation worker
+3 -3
View File
@@ -16,9 +16,9 @@ import (
jose "github.com/go-jose/go-jose/v4" jose "github.com/go-jose/go-jose/v4"
"github.com/shankar0123/certctl/internal/api/acme" "github.com/certctl-io/certctl/internal/api/acme"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/service" "github.com/certctl-io/certctl/internal/service"
) )
// MaxJWSBodyBytes caps the per-request JWS payload at 64 KiB. RFC 8555 // MaxJWSBodyBytes caps the per-request JWS payload at 64 KiB. RFC 8555
+3 -3
View File
@@ -17,9 +17,9 @@ import (
jose "github.com/go-jose/go-jose/v4" jose "github.com/go-jose/go-jose/v4"
"github.com/shankar0123/certctl/internal/api/acme" "github.com/certctl-io/certctl/internal/api/acme"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/service" "github.com/certctl-io/certctl/internal/service"
) )
// mockACMEService implements ACMEService for handler-level tests. // mockACMEService implements ACMEService for handler-level tests.
+3 -3
View File
@@ -5,9 +5,9 @@ import (
"net/http" "net/http"
"time" "time"
"github.com/shankar0123/certctl/internal/api/middleware" "github.com/certctl-io/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository" "github.com/certctl-io/certctl/internal/repository"
) )
// AdminCRLCacheService is the slice of CRLCacheRepository the admin // AdminCRLCacheService is the slice of CRLCacheRepository the admin
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/shankar0123/certctl/internal/api/middleware" "github.com/certctl-io/certctl/internal/api/middleware"
) )
// fakeAdminCRLCacheService is the test stub for the // fakeAdminCRLCacheService is the test stub for the
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"net/http" "net/http"
"time" "time"
"github.com/shankar0123/certctl/internal/api/middleware" "github.com/certctl-io/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/service" "github.com/certctl-io/certctl/internal/service"
) )
// EST RFC 7030 hardening master bundle Phase 7.2 — admin observability // EST RFC 7030 hardening master bundle Phase 7.2 — admin observability
+2 -2
View File
@@ -10,8 +10,8 @@ import (
"testing" "testing"
"time" "time"
"github.com/shankar0123/certctl/internal/api/middleware" "github.com/certctl-io/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/service" "github.com/certctl-io/certctl/internal/service"
) )
// EST RFC 7030 hardening master bundle Phase 7.4 — admin handler tests. // EST RFC 7030 hardening master bundle Phase 7.4 — admin handler tests.
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"net/http" "net/http"
"time" "time"
"github.com/shankar0123/certctl/internal/api/middleware" "github.com/certctl-io/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/service" "github.com/certctl-io/certctl/internal/service"
) )
// AdminSCEPIntuneService is the slice of the per-profile SCEPService set // AdminSCEPIntuneService is the slice of the per-profile SCEPService set
@@ -10,8 +10,8 @@ import (
"testing" "testing"
"time" "time"
"github.com/shankar0123/certctl/internal/api/middleware" "github.com/certctl-io/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/service" "github.com/certctl-io/certctl/internal/service"
) )
// fakeAdminSCEPIntuneService is the test stub for AdminSCEPIntuneService. // fakeAdminSCEPIntuneService is the test stub for AdminSCEPIntuneService.
+1 -1
View File
@@ -30,7 +30,7 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
) )
// adversarialCSRInputs exercises the EST CSR parsing surface. None of these // adversarialCSRInputs exercises the EST CSR parsing surface. None of these
@@ -34,7 +34,7 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
) )
// adversarialPathInputs is the attack catalog shared by Tier 1A cases. Each // adversarialPathInputs is the attack catalog shared by Tier 1A cases. Each
@@ -27,8 +27,8 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository" "github.com/certctl-io/certctl/internal/repository"
) )
// buildListRequest constructs a GET /api/v1/certificates request with the // buildListRequest constructs a GET /api/v1/certificates request with the
@@ -9,7 +9,7 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
) )
// MockAgentGroupService is a mock implementation of AgentGroupService interface. // MockAgentGroupService is a mock implementation of AgentGroupService interface.
+3 -3
View File
@@ -4,13 +4,13 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"github.com/shankar0123/certctl/internal/repository" "github.com/certctl-io/certctl/internal/repository"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"github.com/shankar0123/certctl/internal/api/middleware" "github.com/certctl-io/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
) )
// AgentGroupService defines the service interface for agent group operations. // AgentGroupService defines the service interface for agent group operations.
+2 -2
View File
@@ -9,8 +9,8 @@ import (
"testing" "testing"
"time" "time"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/service" "github.com/certctl-io/certctl/internal/service"
) )
// MockAgentService is a mock implementation of AgentService interface. // MockAgentService is a mock implementation of AgentService interface.
@@ -8,8 +8,8 @@ import (
"testing" "testing"
"time" "time"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/service" "github.com/certctl-io/certctl/internal/service"
) )
// agentRetireTestSetup builds an AgentHandler with a mock AgentService whose // agentRetireTestSetup builds an AgentHandler with a mock AgentService whose
+4 -4
View File
@@ -4,16 +4,16 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"github.com/shankar0123/certctl/internal/repository" "github.com/certctl-io/certctl/internal/repository"
"log/slog" "log/slog"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/shankar0123/certctl/internal/api/middleware" "github.com/certctl-io/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/service" "github.com/certctl-io/certctl/internal/service"
) )
// AgentService defines the service interface for agent operations. // AgentService defines the service interface for agent operations.
+201
View File
@@ -0,0 +1,201 @@
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 Infisical 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],
})
}
+244
View File
@@ -0,0 +1,244 @@
package handler
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"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"
)
// fakeApprovalSvc satisfies handler.ApprovalServicer for tests. The
// service-layer's same-actor RBAC + already-decided checks are
// re-implemented here so the handler-level tests can exercise the
// HTTP error-mapping without standing up the full ApprovalService.
type fakeApprovalSvc struct {
mu sync.Mutex
requests map[string]*domain.ApprovalRequest // keyed by ID
approveBy map[string]string // ID → decidedBy (for assertions)
}
func newFakeApprovalSvc() *fakeApprovalSvc {
return &fakeApprovalSvc{
requests: map[string]*domain.ApprovalRequest{},
approveBy: map[string]string{},
}
}
func (s *fakeApprovalSvc) seed(req *domain.ApprovalRequest) {
s.mu.Lock()
defer s.mu.Unlock()
cp := *req
s.requests[req.ID] = &cp
}
func (s *fakeApprovalSvc) Approve(ctx context.Context, requestID, decidedBy, note string) error {
s.mu.Lock()
defer s.mu.Unlock()
r, ok := s.requests[requestID]
if !ok {
return service.ErrApprovalNotFound
}
if r.State.IsTerminal() {
return service.ErrApprovalAlreadyDecided
}
if decidedBy == r.RequestedBy {
return service.ErrApproveBySameActor
}
r.State = domain.ApprovalStateApproved
s.approveBy[requestID] = decidedBy
return nil
}
func (s *fakeApprovalSvc) Reject(ctx context.Context, requestID, decidedBy, note string) error {
s.mu.Lock()
defer s.mu.Unlock()
r, ok := s.requests[requestID]
if !ok {
return service.ErrApprovalNotFound
}
if r.State.IsTerminal() {
return service.ErrApprovalAlreadyDecided
}
if decidedBy == r.RequestedBy {
return service.ErrApproveBySameActor
}
r.State = domain.ApprovalStateRejected
s.approveBy[requestID] = decidedBy
return nil
}
func (s *fakeApprovalSvc) Get(ctx context.Context, id string) (*domain.ApprovalRequest, error) {
s.mu.Lock()
defer s.mu.Unlock()
r, ok := s.requests[id]
if !ok {
return nil, service.ErrApprovalNotFound
}
cp := *r
return &cp, nil
}
func (s *fakeApprovalSvc) List(ctx context.Context, filter *repository.ApprovalFilter) ([]*domain.ApprovalRequest, error) {
s.mu.Lock()
defer s.mu.Unlock()
var out []*domain.ApprovalRequest
for _, r := range s.requests {
if filter != nil && filter.State != "" && string(r.State) != filter.State {
continue
}
cp := *r
out = append(out, &cp)
}
return out, nil
}
// reqWithActor builds an httptest request with the auth-middleware UserKey
// pre-populated. Mimics what the auth middleware does in production.
func reqWithActor(t *testing.T, method, target string, body string, actor string, pathID string) (*http.Request, *httptest.ResponseRecorder) {
t.Helper()
var br *strings.Reader
if body != "" {
br = strings.NewReader(body)
}
var req *http.Request
if br != nil {
req = httptest.NewRequest(method, target, br)
} else {
req = httptest.NewRequest(method, target, nil)
}
req.Header.Set("Content-Type", "application/json")
if actor != "" {
req = req.WithContext(context.WithValue(req.Context(), middleware.UserKey{}, actor))
}
if pathID != "" {
req.SetPathValue("id", pathID)
}
rr := httptest.NewRecorder()
return req, rr
}
// TestApproval_HandlerApproveAsSameActor_Returns403 — handler-level pin
// of the load-bearing RBAC contract. Compliance auditors expect HTTP 403
// (not 401, not 500) when the requester tries to approve their own
// request.
func TestApproval_HandlerApproveAsSameActor_Returns403(t *testing.T) {
svc := newFakeApprovalSvc()
svc.seed(&domain.ApprovalRequest{
ID: "ar-1",
JobID: "job-1",
ProfileID: "p-cdn",
RequestedBy: "user-alice",
State: domain.ApprovalStatePending,
})
h := NewApprovalHandler(svc)
req, rr := reqWithActor(t, http.MethodPost,
"/api/v1/approvals/ar-1/approve", `{"note":"self-approve"}`, "user-alice", "ar-1")
h.Approve(rr, req)
if rr.Code != http.StatusForbidden {
t.Fatalf("expected 403; got %d (body=%s)", rr.Code, rr.Body.String())
}
if !strings.Contains(rr.Body.String(), "two-person integrity") {
t.Fatalf("expected two-person-integrity message in body; got %s", rr.Body.String())
}
// Different actor approves successfully — pins the success path too.
req2, rr2 := reqWithActor(t, http.MethodPost,
"/api/v1/approvals/ar-1/approve", `{"note":"approved by different actor"}`, "user-bob", "ar-1")
h.Approve(rr2, req2)
if rr2.Code != http.StatusOK {
t.Fatalf("expected 200 for different-actor approve; got %d (body=%s)", rr2.Code, rr2.Body.String())
}
}
// TestApproval_HandlerEmptyNote_Allowed_DecidedByExtractedFromAuth — handler
// accepts an empty body / empty note (no compliance-blocking format
// requirement) and the audit row records the absence. Pins that the
// handler extracts decided_by from the auth-middleware UserKey, NOT from
// the request body.
func TestApproval_HandlerEmptyNote_Allowed_DecidedByExtractedFromAuth(t *testing.T) {
svc := newFakeApprovalSvc()
svc.seed(&domain.ApprovalRequest{
ID: "ar-2",
JobID: "job-2",
ProfileID: "p-cdn",
RequestedBy: "user-charlie",
State: domain.ApprovalStatePending,
})
h := NewApprovalHandler(svc)
// Empty body + empty note both accepted.
req, rr := reqWithActor(t, http.MethodPost,
"/api/v1/approvals/ar-2/approve", "", "user-bob", "ar-2")
h.Approve(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200 for empty body; got %d (body=%s)", rr.Code, rr.Body.String())
}
// Verify the response carries the auth-middleware-derived actor.
var resp map[string]interface{}
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode resp: %v", err)
}
if resp["decided_by"] != "user-bob" {
t.Fatalf("decided_by should come from auth middleware; got %v", resp["decided_by"])
}
// Confirm the service-layer recorded user-bob as the decider.
if got := svc.approveBy["ar-2"]; got != "user-bob" {
t.Fatalf("svc should have recorded decidedBy=user-bob; got %s", got)
}
// Unauthenticated request returns 401, not 500.
req2, rr2 := reqWithActor(t, http.MethodPost,
"/api/v1/approvals/ar-2/approve", "", "", "ar-2")
h.Approve(rr2, req2)
if rr2.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 for unauthenticated; got %d", rr2.Code)
}
}
// TestApproval_HandlerNotFound_Returns404 + AlreadyDecided returns 409 —
// pin the error-status mapping for the remaining service sentinels.
func TestApproval_HandlerErrorMapping(t *testing.T) {
svc := newFakeApprovalSvc()
svc.seed(&domain.ApprovalRequest{
ID: "ar-decided",
JobID: "job-3",
ProfileID: "p-cdn",
RequestedBy: "user-alice",
State: domain.ApprovalStateApproved,
})
h := NewApprovalHandler(svc)
t.Run("NotFound_Returns_404", func(t *testing.T) {
req, rr := reqWithActor(t, http.MethodPost,
"/api/v1/approvals/missing/approve", "", "user-bob", "missing")
h.Approve(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("expected 404; got %d", rr.Code)
}
})
t.Run("AlreadyDecided_Returns_409", func(t *testing.T) {
req, rr := reqWithActor(t, http.MethodPost,
"/api/v1/approvals/ar-decided/approve", "", "user-bob", "ar-decided")
h.Approve(rr, req)
if rr.Code != http.StatusConflict {
t.Fatalf("expected 409; got %d", rr.Code)
}
if !errors.Is(service.ErrApprovalAlreadyDecided, service.ErrApprovalAlreadyDecided) {
t.Fatal("sentinel sanity")
}
})
}
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/shankar0123/certctl/internal/api/middleware" "github.com/certctl-io/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
) )
// AuditService defines the service interface for audit event operations. // AuditService defines the service interface for audit event operations.
+2 -2
View File
@@ -9,8 +9,8 @@ import (
"testing" "testing"
"time" "time"
"github.com/shankar0123/certctl/internal/api/middleware" "github.com/certctl-io/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
) )
// mockAuditService implements AuditService for testing. // mockAuditService implements AuditService for testing.
@@ -8,7 +8,7 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
) )
// Bundle C / Audit M-007 (CWE-754): partial-failure tests for the three // Bundle C / Audit M-007 (CWE-754): partial-failure tests for the three
+3 -3
View File
@@ -6,9 +6,9 @@ import (
"errors" "errors"
"net/http" "net/http"
"github.com/shankar0123/certctl/internal/api/middleware" "github.com/certctl-io/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/service" "github.com/certctl-io/certctl/internal/service"
) )
// BulkReassignmentService defines the service interface for bulk // BulkReassignmentService defines the service interface for bulk
@@ -11,8 +11,8 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/service" "github.com/certctl-io/certctl/internal/service"
) )
type mockBulkReassignmentService struct { type mockBulkReassignmentService struct {
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"github.com/shankar0123/certctl/internal/api/middleware" "github.com/certctl-io/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
) )
// BulkRenewalService defines the service interface for bulk certificate // BulkRenewalService defines the service interface for bulk certificate
@@ -10,8 +10,8 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/shankar0123/certctl/internal/api/middleware" "github.com/certctl-io/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
) )
// mockBulkRenewalService is a test implementation of BulkRenewalService. // mockBulkRenewalService is a test implementation of BulkRenewalService.
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"github.com/shankar0123/certctl/internal/api/middleware" "github.com/certctl-io/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
) )
// BulkRevocationService defines the service interface for bulk certificate revocation. // BulkRevocationService defines the service interface for bulk certificate revocation.
@@ -8,7 +8,7 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
) )
// EST RFC 7030 hardening master bundle Phase 11.4 — BulkRevokeEST handler tests. // EST RFC 7030 hardening master bundle Phase 11.4 — BulkRevokeEST handler tests.
@@ -10,8 +10,8 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/shankar0123/certctl/internal/api/middleware" "github.com/certctl-io/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
) )
// mockBulkRevocationService is a test implementation of BulkRevocationService // mockBulkRevocationService is a test implementation of BulkRevocationService
@@ -18,9 +18,9 @@ import (
"golang.org/x/crypto/ocsp" "golang.org/x/crypto/ocsp"
"github.com/shankar0123/certctl/internal/api/middleware" "github.com/certctl-io/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository" "github.com/certctl-io/certctl/internal/repository"
) )
// MockCertificateService is a mock implementation of CertificateService interface. // MockCertificateService is a mock implementation of CertificateService interface.
+5 -5
View File
@@ -16,11 +16,11 @@ import (
"golang.org/x/crypto/ocsp" "golang.org/x/crypto/ocsp"
"github.com/shankar0123/certctl/internal/api/middleware" "github.com/certctl-io/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/ratelimit" "github.com/certctl-io/certctl/internal/ratelimit"
"github.com/shankar0123/certctl/internal/repository" "github.com/certctl-io/certctl/internal/repository"
"github.com/shankar0123/certctl/internal/service" "github.com/certctl-io/certctl/internal/service"
) )
// CertificateService defines the service interface for certificate operations. // CertificateService defines the service interface for certificate operations.
@@ -9,7 +9,7 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
) )
// EST RFC 7030 hardening master bundle Phase 10.3 — Cisco IOS quirk // EST RFC 7030 hardening master bundle Phase 10.3 — Cisco IOS quirk
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
) )
// DiscoveryService defines the interface used by the discovery handler. // DiscoveryService defines the interface used by the discovery handler.
@@ -10,8 +10,8 @@ import (
"testing" "testing"
"time" "time"
"github.com/shankar0123/certctl/internal/api/middleware" "github.com/certctl-io/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
) )
// MockDiscoveryService is a mock implementation of DiscoveryService interface. // MockDiscoveryService is a mock implementation of DiscoveryService interface.
+6 -6
View File
@@ -14,12 +14,12 @@ import (
"net/http" "net/http"
"strings" "strings"
"github.com/shankar0123/certctl/internal/api/middleware" "github.com/certctl-io/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/cms" "github.com/certctl-io/certctl/internal/cms"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/pkcs7" "github.com/certctl-io/certctl/internal/pkcs7"
"github.com/shankar0123/certctl/internal/ratelimit" "github.com/certctl-io/certctl/internal/ratelimit"
"github.com/shankar0123/certctl/internal/trustanchor" "github.com/certctl-io/certctl/internal/trustanchor"
) )
// ESTService defines the service interface for EST enrollment operations. // ESTService defines the service interface for EST enrollment operations.
+2 -2
View File
@@ -18,8 +18,8 @@ import (
"testing" "testing"
"time" "time"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/pkcs7" "github.com/certctl-io/certctl/internal/pkcs7"
) )
// mockESTService implements ESTService for testing. // mockESTService implements ESTService for testing.
+4 -4
View File
@@ -21,10 +21,10 @@ import (
"testing" "testing"
"time" "time"
"github.com/shankar0123/certctl/internal/cms" "github.com/certctl-io/certctl/internal/cms"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/ratelimit" "github.com/certctl-io/certctl/internal/ratelimit"
"github.com/shankar0123/certctl/internal/trustanchor" "github.com/certctl-io/certctl/internal/trustanchor"
) )
// EST RFC 7030 hardening master bundle Phases 2-4 tests. // EST RFC 7030 hardening master bundle Phases 2-4 tests.
@@ -18,8 +18,8 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/pkcs7" "github.com/certctl-io/certctl/internal/pkcs7"
) )
// EST RFC 7030 hardening master bundle Phase 5.3 — serverkeygen tests. // EST RFC 7030 hardening master bundle Phase 5.3 — serverkeygen tests.
+4 -4
View File
@@ -5,15 +5,15 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"github.com/shankar0123/certctl/internal/repository" "github.com/certctl-io/certctl/internal/repository"
"log/slog" "log/slog"
"net/http" "net/http"
"strings" "strings"
"time" "time"
"github.com/shankar0123/certctl/internal/api/middleware" "github.com/certctl-io/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/ratelimit" "github.com/certctl-io/certctl/internal/ratelimit"
"github.com/shankar0123/certctl/internal/service" "github.com/certctl-io/certctl/internal/service"
) )
// ExportService defines the service interface for certificate export operations. // ExportService defines the service interface for certificate export operations.
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/shankar0123/certctl/internal/service" "github.com/certctl-io/certctl/internal/service"
) )
// Add context import was already there — verify import is present above // Add context import was already there — verify import is present above
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"net/http" "net/http"
"time" "time"
"github.com/shankar0123/certctl/internal/api/middleware" "github.com/certctl-io/certctl/internal/api/middleware"
) )
// HealthHandler handles health and readiness check endpoints. // HealthHandler handles health and readiness check endpoints.
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository" "github.com/certctl-io/certctl/internal/repository"
) )
// HealthCheckServicer defines the interface used by the health check handler. // HealthCheckServicer defines the interface used by the health check handler.
@@ -9,8 +9,8 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository" "github.com/certctl-io/certctl/internal/repository"
) )
// mockHealthCheckSvc implements HealthCheckServicer for testing. // mockHealthCheckSvc implements HealthCheckServicer for testing.
+1 -1
View File
@@ -9,8 +9,8 @@ import (
"testing" "testing"
"time" "time"
"github.com/certctl-io/certctl/internal/api/middleware"
_ "github.com/lib/pq" // Bundle-5 / H-006: postgres driver for /ready DB-probe regression test _ "github.com/lib/pq" // Bundle-5 / H-006: postgres driver for /ready DB-probe regression test
"github.com/shankar0123/certctl/internal/api/middleware"
) )
func TestHealth_ReturnsOK(t *testing.T) { func TestHealth_ReturnsOK(t *testing.T) {
+318
View File
@@ -0,0 +1,318 @@
package handler
import (
"context"
"crypto/x509/pkix"
"encoding/json"
"errors"
"net/http"
"time"
"github.com/certctl-io/certctl/internal/api/middleware"
"github.com/certctl-io/certctl/internal/crypto/signer"
"github.com/certctl-io/certctl/internal/domain"
"github.com/certctl-io/certctl/internal/service"
)
// IntermediateCAServicer is the handler-facing surface of
// *service.IntermediateCAService. Defined here (handler-defined service
// interface, dependency inversion) so the handler stays decoupled
// from the concrete service type and tests can mock it without
// pulling the full service-layer dependency graph.
//
// Rank 8 of the 2026-05-03 deep-research deliverable, commit 4 of 5 —
// the API + RBAC layer.
type IntermediateCAServicer interface {
CreateRoot(ctx context.Context, issuerID, name, decidedBy string,
rootCertPEM []byte, keyDriverID string, opts *service.CreateRootOptions) (string, error)
CreateChild(ctx context.Context, parentCAID, name, decidedBy string,
opts *service.CreateChildOptions) (string, error)
Retire(ctx context.Context, caID, decidedBy, note string, confirm bool) error
Get(ctx context.Context, id string) (*domain.IntermediateCA, error)
LoadHierarchy(ctx context.Context, issuerID string) ([]*domain.IntermediateCA, error)
}
// IntermediateCAHandler serves the admin-gated CA hierarchy endpoints.
// All routes are pinned at /api/v1/issuers/{id}/intermediates and
// /api/v1/intermediates/{id}.
//
// Admin gate: every method calls middleware.IsAdmin first and surfaces
// HTTP 403 for non-admin Bearer callers (M-003 admin-gating pattern,
// matches AdminCRLCacheHandler / AdminESTHandler / AdminSCEPIntuneHandler).
// CA hierarchy management is a high-blast-radius surface — adding a
// child CA mints a new sub-CA cert that becomes a trust root for every
// downstream leaf. Operators expect this gated behind admin role.
type IntermediateCAHandler struct {
svc IntermediateCAServicer
}
// NewIntermediateCAHandler constructs the handler.
func NewIntermediateCAHandler(svc IntermediateCAServicer) IntermediateCAHandler {
return IntermediateCAHandler{svc: svc}
}
// createIntermediateBody is the JSON body shape for POST
// /api/v1/issuers/{id}/intermediates. ParentCAID is optional —
// when absent OR empty AND RootCertPEM/KeyDriverID are present, the
// endpoint registers an operator-supplied root CA. Otherwise it
// signs a child under the named parent.
type createIntermediateBody struct {
Name string `json:"name"`
ParentCAID string `json:"parent_ca_id,omitempty"` // empty = create root
RootCertPEM string `json:"root_cert_pem,omitempty"`
KeyDriverID string `json:"key_driver_id,omitempty"`
Subject subjectBody `json:"subject,omitempty"`
Algorithm string `json:"algorithm,omitempty"` // ECDSA-P256, RSA-3072, ...
TTLDays int `json:"ttl_days,omitempty"`
PathLenConstraint *int `json:"path_len_constraint,omitempty"`
NameConstraints []domain.NameConstraint `json:"name_constraints,omitempty"`
OCSPResponderURL string `json:"ocsp_responder_url,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
}
// subjectBody is the wire shape for an X.509 subject. Matches the
// pkix.Name fields exposed via the GUI's hierarchy form.
type subjectBody struct {
CommonName string `json:"common_name"`
Organization []string `json:"organization,omitempty"`
OrganizationalUnit []string `json:"organizational_unit,omitempty"`
Country []string `json:"country,omitempty"`
Locality []string `json:"locality,omitempty"`
Province []string `json:"province,omitempty"`
}
func (s subjectBody) toPKIX() pkix.Name {
return pkix.Name{
CommonName: s.CommonName,
Organization: s.Organization,
OrganizationalUnit: s.OrganizationalUnit,
Country: s.Country,
Locality: s.Locality,
Province: s.Province,
}
}
// retireBody is the JSON body shape for POST
// /api/v1/intermediates/{id}/retire. Two-phase: first call (Confirm
// false) transitions active → retiring; second call (Confirm true)
// transitions retiring → retired and refuses if active children
// remain.
type retireBody struct {
Note string `json:"note,omitempty"`
Confirm bool `json:"confirm,omitempty"`
}
// Create handles POST /api/v1/issuers/{id}/intermediates. Admin-gated.
// Discriminator on body shape: when ParentCAID is empty AND
// RootCertPEM + KeyDriverID are present → CreateRoot; otherwise →
// CreateChild under the named parent.
func (h IntermediateCAHandler) Create(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
if !middleware.IsAdmin(r.Context()) {
Error(w, http.StatusForbidden, "Admin access required")
return
}
requestID := middleware.GetRequestID(r.Context())
issuerID := r.PathValue("id")
if issuerID == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "issuer id required", requestID)
return
}
actor, _ := r.Context().Value(middleware.UserKey{}).(string)
if actor == "" {
ErrorWithRequestID(w, http.StatusUnauthorized,
"authentication required", requestID)
return
}
var body createIntermediateBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, "invalid JSON body", requestID)
return
}
if body.Name == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "name required", requestID)
return
}
var (
newID string
err error
)
if body.ParentCAID == "" {
// Root CA registration path.
if body.RootCertPEM == "" || body.KeyDriverID == "" {
ErrorWithRequestID(w, http.StatusBadRequest,
"root_cert_pem + key_driver_id required when parent_ca_id is empty",
requestID)
return
}
opts := &service.CreateRootOptions{
OCSPResponderURL: body.OCSPResponderURL,
Metadata: body.Metadata,
}
newID, err = h.svc.CreateRoot(r.Context(), issuerID, body.Name, actor,
[]byte(body.RootCertPEM), body.KeyDriverID, opts)
} else {
// Child CA signing path.
alg := signer.Algorithm(body.Algorithm)
if alg == "" {
alg = signer.AlgorithmECDSAP256
}
ttl := time.Duration(body.TTLDays) * 24 * time.Hour
opts := &service.CreateChildOptions{
Subject: body.Subject.toPKIX(),
Algorithm: alg,
TTL: ttl,
PathLenConstraint: body.PathLenConstraint,
NameConstraints: body.NameConstraints,
OCSPResponderURL: body.OCSPResponderURL,
Metadata: body.Metadata,
}
newID, err = h.svc.CreateChild(r.Context(), body.ParentCAID, body.Name, actor, opts)
}
if err != nil {
switch {
case errors.Is(err, service.ErrIntermediateCANotFound):
ErrorWithRequestID(w, http.StatusNotFound, err.Error(), requestID)
case errors.Is(err, service.ErrCANotSelfSigned),
errors.Is(err, service.ErrCAKeyMismatch),
errors.Is(err, service.ErrPathLenExceeded),
errors.Is(err, service.ErrNameConstraintExceeded),
errors.Is(err, service.ErrInvalidCertPEM):
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
case errors.Is(err, service.ErrParentCANotActive):
ErrorWithRequestID(w, http.StatusConflict, err.Error(), requestID)
default:
ErrorWithRequestID(w, http.StatusInternalServerError,
"Failed to create intermediate CA", requestID)
}
return
}
created, err := h.svc.Get(r.Context(), newID)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError,
"created but failed to fetch", requestID)
return
}
JSON(w, http.StatusCreated, created)
}
// List handles GET /api/v1/issuers/{id}/intermediates. Admin-gated.
// Returns the flat list for the issuer; callers render the tree from
// each row's parent_ca_id.
func (h IntermediateCAHandler) List(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
if !middleware.IsAdmin(r.Context()) {
Error(w, http.StatusForbidden, "Admin access required")
return
}
requestID := middleware.GetRequestID(r.Context())
issuerID := r.PathValue("id")
if issuerID == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "issuer id required", requestID)
return
}
rows, err := h.svc.LoadHierarchy(r.Context(), issuerID)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError,
"Failed to list intermediate CAs", requestID)
return
}
JSON(w, http.StatusOK, map[string]interface{}{"data": rows})
}
// Get handles GET /api/v1/intermediates/{id}. Admin-gated.
func (h IntermediateCAHandler) Get(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
if !middleware.IsAdmin(r.Context()) {
Error(w, http.StatusForbidden, "Admin access required")
return
}
requestID := middleware.GetRequestID(r.Context())
id := r.PathValue("id")
if id == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "id required", requestID)
return
}
ca, err := h.svc.Get(r.Context(), id)
if err != nil {
if errors.Is(err, service.ErrIntermediateCANotFound) {
ErrorWithRequestID(w, http.StatusNotFound, err.Error(), requestID)
return
}
ErrorWithRequestID(w, http.StatusInternalServerError,
"Failed to get intermediate CA", requestID)
return
}
JSON(w, http.StatusOK, ca)
}
// Retire handles POST /api/v1/intermediates/{id}/retire. Admin-gated.
// Two-phase: first call (Confirm=false) sets state to retiring;
// second call (Confirm=true) sets to retired. Refuses if the CA has
// active children — drain-first semantics.
func (h IntermediateCAHandler) Retire(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
if !middleware.IsAdmin(r.Context()) {
Error(w, http.StatusForbidden, "Admin access required")
return
}
requestID := middleware.GetRequestID(r.Context())
id := r.PathValue("id")
if id == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "id required", requestID)
return
}
actor, _ := r.Context().Value(middleware.UserKey{}).(string)
if actor == "" {
ErrorWithRequestID(w, http.StatusUnauthorized,
"authentication required", requestID)
return
}
body := retireBody{}
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
}
}
if err := h.svc.Retire(r.Context(), id, actor, body.Note, body.Confirm); err != nil {
switch {
case errors.Is(err, service.ErrIntermediateCANotFound):
ErrorWithRequestID(w, http.StatusNotFound, err.Error(), requestID)
case errors.Is(err, service.ErrCAStillHasActiveChildren):
ErrorWithRequestID(w, http.StatusConflict, err.Error(), requestID)
default:
ErrorWithRequestID(w, http.StatusInternalServerError,
err.Error(), requestID)
}
return
}
JSON(w, http.StatusOK, map[string]interface{}{
"id": id,
"decided_by": actor,
"confirmed": body.Confirm,
})
}
@@ -0,0 +1,430 @@
package handler
import (
"bytes"
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"math/big"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/certctl-io/certctl/internal/api/middleware"
"github.com/certctl-io/certctl/internal/domain"
"github.com/certctl-io/certctl/internal/service"
)
// mockIntermediateCAService is the minimal IntermediateCAServicer for
// handler-layer tests. Captures the arguments each method was called
// with so tests can assert dispatch + RBAC behavior.
type mockIntermediateCAService struct {
createRootCalled bool
createChildCalled bool
retireCalled bool
createRootErr error
createChildErr error
retireErr error
retireConfirm bool
// Get returns this row when nonzero; otherwise the
// IntermediateCANotFound sentinel.
getResult *domain.IntermediateCA
// LoadHierarchy returns this slice if non-nil.
loadHierarchyResult []*domain.IntermediateCA
}
func (m *mockIntermediateCAService) CreateRoot(ctx context.Context, issuerID, name, decidedBy string,
rootCertPEM []byte, keyDriverID string, opts *service.CreateRootOptions) (string, error) {
m.createRootCalled = true
if m.createRootErr != nil {
return "", m.createRootErr
}
return "ica-root-mock", nil
}
func (m *mockIntermediateCAService) CreateChild(ctx context.Context, parentCAID, name, decidedBy string,
opts *service.CreateChildOptions) (string, error) {
m.createChildCalled = true
if m.createChildErr != nil {
return "", m.createChildErr
}
return "ica-child-mock", nil
}
func (m *mockIntermediateCAService) Retire(ctx context.Context, caID, decidedBy, note string, confirm bool) error {
m.retireCalled = true
m.retireConfirm = confirm
return m.retireErr
}
func (m *mockIntermediateCAService) Get(ctx context.Context, id string) (*domain.IntermediateCA, error) {
if m.getResult != nil {
return m.getResult, nil
}
return nil, service.ErrIntermediateCANotFound
}
func (m *mockIntermediateCAService) LoadHierarchy(ctx context.Context, issuerID string) ([]*domain.IntermediateCA, error) {
return m.loadHierarchyResult, nil
}
// withAdmin returns a context with the admin flag set + a non-empty
// authenticated user — the standard "admin caller" shape for these
// tests.
func withAdmin(actor string, admin bool) context.Context {
ctx := context.WithValue(context.Background(), middleware.UserKey{}, actor)
ctx = context.WithValue(ctx, middleware.AdminKey{}, admin)
return ctx
}
// helperRootCertPEM returns a freshly-minted self-signed root cert
// PEM for the body of CreateRoot tests.
func helperRootCertPEM(t *testing.T) []byte {
t.Helper()
priv, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
subj := pkix.Name{CommonName: "Test Root"}
tmpl := &x509.Certificate{
SerialNumber: serial,
Subject: subj,
Issuer: subj,
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageCertSign,
BasicConstraintsValid: true,
IsCA: true,
}
der, _ := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
}
// TestIntermediateCA_Handler_NonAdmin_Returns403 pins the
// admin-gating contract. Any non-admin Bearer caller — even a valid
// authenticated one — must get HTTP 403 from every endpoint. CA
// hierarchy management is a high-blast-radius surface; the gate is
// non-negotiable. M-008 admin-gate triplet test #1.
func TestIntermediateCA_Handler_NonAdmin_Returns403(t *testing.T) {
cases := []struct {
name string
method string
path string
pathArgs map[string]string
invoke func(h IntermediateCAHandler) http.HandlerFunc
}{
{
name: "Create",
method: http.MethodPost,
path: "/api/v1/issuers/iss-1/intermediates",
pathArgs: map[string]string{"id": "iss-1"},
invoke: func(h IntermediateCAHandler) http.HandlerFunc { return h.Create },
},
{
name: "List",
method: http.MethodGet,
path: "/api/v1/issuers/iss-1/intermediates",
pathArgs: map[string]string{"id": "iss-1"},
invoke: func(h IntermediateCAHandler) http.HandlerFunc { return h.List },
},
{
name: "Get",
method: http.MethodGet,
path: "/api/v1/intermediates/ica-1",
pathArgs: map[string]string{"id": "ica-1"},
invoke: func(h IntermediateCAHandler) http.HandlerFunc { return h.Get },
},
{
name: "Retire",
method: http.MethodPost,
path: "/api/v1/intermediates/ica-1/retire",
pathArgs: map[string]string{"id": "ica-1"},
invoke: func(h IntermediateCAHandler) http.HandlerFunc { return h.Retire },
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
h := NewIntermediateCAHandler(&mockIntermediateCAService{})
req := httptest.NewRequest(tc.method, tc.path, bytes.NewReader([]byte("{}")))
for k, v := range tc.pathArgs {
req.SetPathValue(k, v)
}
// Authenticated user but admin=false.
req = req.WithContext(withAdmin("alice", false))
w := httptest.NewRecorder()
tc.invoke(h)(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("%s: expected 403 for non-admin, got %d body=%s", tc.name, w.Code, w.Body.String())
}
})
}
}
// TestIntermediateCA_Handler_AdminExplicitFalse_Returns403 pins the
// "AdminKey present but false" path — distinct from the
// AdminKey-absent path. Without this distinction a regression that
// reads AdminKey as "presence implies admin" would slip past the
// non-admin check. M-008 admin-gate triplet test #2.
func TestIntermediateCA_Handler_AdminExplicitFalse_Returns403(t *testing.T) {
h := NewIntermediateCAHandler(&mockIntermediateCAService{})
req := httptest.NewRequest(http.MethodPost, "/api/v1/issuers/iss-1/intermediates",
bytes.NewReader([]byte(`{"name":"r"}`)))
req.SetPathValue("id", "iss-1")
// AdminKey explicitly set to false — distinct from missing key.
ctx := context.WithValue(context.Background(), middleware.UserKey{}, "alice")
ctx = context.WithValue(ctx, middleware.AdminKey{}, false)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.Create(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403 for AdminKey=false, got %d", w.Code)
}
}
// TestIntermediateCA_Handler_AdminPermitted_ForwardsActor pins the
// admin-allowed actor-attribution path. An admin caller's actor
// (UserKey context value) must be forwarded to the service so the
// audit trail records who registered the CA. M-008 admin-gate
// triplet test #3.
func TestIntermediateCA_Handler_AdminPermitted_ForwardsActor(t *testing.T) {
mock := &mockIntermediateCAService{
getResult: &domain.IntermediateCA{ID: "ica-mock"},
}
h := NewIntermediateCAHandler(mock)
rootPEM := helperRootCertPEM(t)
body := `{"name":"Acme Root","root_cert_pem":` + jsonString(string(rootPEM)) +
`,"key_driver_id":"/etc/certctl/keys/root.pem"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/issuers/iss-1/intermediates",
bytes.NewReader([]byte(body)))
req.SetPathValue("id", "iss-1")
req = req.WithContext(withAdmin("admin-actor", true))
w := httptest.NewRecorder()
h.Create(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d body=%s", w.Code, w.Body.String())
}
if !mock.createRootCalled {
t.Fatalf("expected service dispatch with admin actor")
}
}
// TestIntermediateCA_HandlerCreate_RootDispatch pins the body
// discriminator: empty parent_ca_id + root_cert_pem + key_driver_id
// → CreateRoot (not CreateChild). The mock service captures which
// method was called.
func TestIntermediateCA_HandlerCreate_RootDispatch(t *testing.T) {
mock := &mockIntermediateCAService{
getResult: &domain.IntermediateCA{ID: "ica-root-mock"},
}
h := NewIntermediateCAHandler(mock)
rootPEM := helperRootCertPEM(t)
body := `{
"name": "Acme Root",
"root_cert_pem": ` + jsonString(string(rootPEM)) + `,
"key_driver_id": "/etc/certctl/keys/root.pem"
}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/issuers/iss-1/intermediates",
bytes.NewReader([]byte(body)))
req.SetPathValue("id", "iss-1")
req = req.WithContext(withAdmin("admin-actor", true))
w := httptest.NewRecorder()
h.Create(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d body=%s", w.Code, w.Body.String())
}
if !mock.createRootCalled {
t.Fatalf("expected CreateRoot dispatch, got CreateChild=%v", mock.createChildCalled)
}
if mock.createChildCalled {
t.Fatalf("expected only CreateRoot, but CreateChild was also called")
}
}
// TestIntermediateCA_HandlerCreate_ChildDispatch pins the
// discriminator's other half: parent_ca_id present → CreateChild.
func TestIntermediateCA_HandlerCreate_ChildDispatch(t *testing.T) {
mock := &mockIntermediateCAService{
getResult: &domain.IntermediateCA{ID: "ica-child-mock"},
}
h := NewIntermediateCAHandler(mock)
body := `{
"name": "Acme Policy",
"parent_ca_id": "ica-root-1",
"subject": {"common_name": "Acme Policy CA", "organization": ["Acme"]},
"algorithm": "ECDSA-P256",
"ttl_days": 1825
}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/issuers/iss-1/intermediates",
bytes.NewReader([]byte(body)))
req.SetPathValue("id", "iss-1")
req = req.WithContext(withAdmin("admin-actor", true))
w := httptest.NewRecorder()
h.Create(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d body=%s", w.Code, w.Body.String())
}
if !mock.createChildCalled {
t.Fatalf("expected CreateChild dispatch")
}
if mock.createRootCalled {
t.Fatalf("expected only CreateChild, but CreateRoot was also called")
}
}
// TestIntermediateCA_HandlerCreate_BadRequestOnMissingRootBundle pins
// the validation: empty parent_ca_id + missing root_cert_pem →
// HTTP 400.
func TestIntermediateCA_HandlerCreate_BadRequestOnMissingRootBundle(t *testing.T) {
h := NewIntermediateCAHandler(&mockIntermediateCAService{})
body := `{"name": "Some Name"}` // no parent, no root bundle
req := httptest.NewRequest(http.MethodPost, "/api/v1/issuers/iss-1/intermediates",
bytes.NewReader([]byte(body)))
req.SetPathValue("id", "iss-1")
req = req.WithContext(withAdmin("admin-actor", true))
w := httptest.NewRecorder()
h.Create(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d body=%s", w.Code, w.Body.String())
}
}
// TestIntermediateCA_HandlerCreate_ServiceErrorMappings pins the
// error → HTTP code dispatch table.
func TestIntermediateCA_HandlerCreate_ServiceErrorMappings(t *testing.T) {
cases := []struct {
name string
err error
wantCode int
isRootCmd bool
}{
{"NotSelfSigned->400", service.ErrCANotSelfSigned, http.StatusBadRequest, true},
{"KeyMismatch->400", service.ErrCAKeyMismatch, http.StatusBadRequest, true},
{"PathLenExceeded->400", service.ErrPathLenExceeded, http.StatusBadRequest, false},
{"NameConstraintExceeded->400", service.ErrNameConstraintExceeded, http.StatusBadRequest, false},
{"ParentNotActive->409", service.ErrParentCANotActive, http.StatusConflict, false},
{"NotFound->404", service.ErrIntermediateCANotFound, http.StatusNotFound, false},
{"Other->500", errors.New("unexpected"), http.StatusInternalServerError, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
mock := &mockIntermediateCAService{}
if tc.isRootCmd {
mock.createRootErr = tc.err
} else {
mock.createChildErr = tc.err
}
h := NewIntermediateCAHandler(mock)
var body string
if tc.isRootCmd {
rootPEM := helperRootCertPEM(t)
body = `{"name":"Root","root_cert_pem":` + jsonString(string(rootPEM)) + `,"key_driver_id":"/k"}`
} else {
body = `{"name":"Child","parent_ca_id":"ica-root-1"}`
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/issuers/iss-1/intermediates",
bytes.NewReader([]byte(body)))
req.SetPathValue("id", "iss-1")
req = req.WithContext(withAdmin("admin-actor", true))
w := httptest.NewRecorder()
h.Create(w, req)
if w.Code != tc.wantCode {
t.Fatalf("expected %d, got %d body=%s", tc.wantCode, w.Code, w.Body.String())
}
})
}
}
// TestIntermediateCA_HandlerRetire_TwoPhaseConfirm pins the body's
// confirm flag passes through to the service. First call confirm=false;
// second call confirm=true (the operator explicitly terminalizes).
func TestIntermediateCA_HandlerRetire_TwoPhaseConfirm(t *testing.T) {
mock := &mockIntermediateCAService{}
h := NewIntermediateCAHandler(mock)
// First call — confirm omitted (defaults to false).
body1 := `{"note": "drain start"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/intermediates/ica-1/retire",
bytes.NewReader([]byte(body1)))
req.SetPathValue("id", "ica-1")
req = req.WithContext(withAdmin("admin-actor", true))
w := httptest.NewRecorder()
h.Retire(w, req)
if w.Code != http.StatusOK {
t.Fatalf("first retire: expected 200, got %d body=%s", w.Code, w.Body.String())
}
if mock.retireConfirm {
t.Fatalf("first retire: expected confirm=false, got true")
}
// Second call — confirm=true.
mock.retireCalled = false
body2 := `{"note":"terminalize","confirm":true}`
req = httptest.NewRequest(http.MethodPost, "/api/v1/intermediates/ica-1/retire",
bytes.NewReader([]byte(body2)))
req.SetPathValue("id", "ica-1")
req = req.WithContext(withAdmin("admin-actor", true))
w = httptest.NewRecorder()
h.Retire(w, req)
if w.Code != http.StatusOK {
t.Fatalf("second retire: expected 200, got %d body=%s", w.Code, w.Body.String())
}
if !mock.retireConfirm {
t.Fatalf("second retire: expected confirm=true, got false")
}
}
// TestIntermediateCA_HandlerRetire_StillHasActiveChildren_Returns409
// pins the drain-first contract: ErrCAStillHasActiveChildren maps
// to HTTP 409.
func TestIntermediateCA_HandlerRetire_StillHasActiveChildren_Returns409(t *testing.T) {
mock := &mockIntermediateCAService{retireErr: service.ErrCAStillHasActiveChildren}
h := NewIntermediateCAHandler(mock)
req := httptest.NewRequest(http.MethodPost, "/api/v1/intermediates/ica-1/retire",
bytes.NewReader([]byte(`{"confirm": true}`)))
req.SetPathValue("id", "ica-1")
req = req.WithContext(withAdmin("admin-actor", true))
w := httptest.NewRecorder()
h.Retire(w, req)
if w.Code != http.StatusConflict {
t.Fatalf("expected 409, got %d body=%s", w.Code, w.Body.String())
}
}
// jsonString returns a JSON-quoted Go string suitable for embedding
// in a test JSON body literal. Standard library encoding/json's
// Marshal does the same thing but the test assertions are clearer
// when we control the wrapping.
func jsonString(s string) string {
return string(mustMarshalJSONString(s))
}
func mustMarshalJSONString(s string) []byte {
// Trivial: wrap in quotes and escape \ and " — sufficient for
// PEM bodies (which contain newlines but no quotes).
out := make([]byte, 0, len(s)+2)
out = append(out, '"')
for _, r := range []byte(s) {
switch r {
case '"':
out = append(out, '\\', '"')
case '\\':
out = append(out, '\\', '\\')
case '\n':
out = append(out, '\\', 'n')
case '\r':
out = append(out, '\\', 'r')
case '\t':
out = append(out, '\\', 't')
default:
out = append(out, r)
}
}
out = append(out, '"')
return out
}
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
) )
// MockIssuerService is a mock implementation of IssuerService interface. // MockIssuerService is a mock implementation of IssuerService interface.
+3 -3
View File
@@ -4,14 +4,14 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"github.com/shankar0123/certctl/internal/repository" "github.com/certctl-io/certctl/internal/repository"
"log/slog" "log/slog"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"github.com/shankar0123/certctl/internal/api/middleware" "github.com/certctl-io/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
) )
// IssuerService defines the service interface for issuer operations. // IssuerService defines the service interface for issuer operations.
+2 -2
View File
@@ -10,8 +10,8 @@ import (
"testing" "testing"
"time" "time"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/service" "github.com/certctl-io/certctl/internal/service"
) )
// MockJobService is a mock implementation of JobService interface. // MockJobService is a mock implementation of JobService interface.
+4 -4
View File
@@ -4,15 +4,15 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"github.com/shankar0123/certctl/internal/repository" "github.com/certctl-io/certctl/internal/repository"
"io" "io"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"github.com/shankar0123/certctl/internal/api/middleware" "github.com/certctl-io/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/service" "github.com/certctl-io/certctl/internal/service"
) )
// JobService defines the service interface for job operations. // JobService defines the service interface for job operations.
@@ -39,6 +39,7 @@ var AdminGatedHandlers = map[string]string{
"admin_crl_cache.go": "CRL/OCSP-Responder Phase 5: cache state reveals issuer set + CRL cadence — admin-only", "admin_crl_cache.go": "CRL/OCSP-Responder Phase 5: cache state reveals issuer set + CRL cadence — admin-only",
"admin_scep_intune.go": "SCEP RFC 8894 + Intune master bundle Phase 9.2 + Phase 9 follow-up: profiles + stats endpoints reveal per-profile RA cert expiries + Intune trust anchor expiries + mTLS bundle paths; reload-trust is a privileged action — admin-only", "admin_scep_intune.go": "SCEP RFC 8894 + Intune master bundle Phase 9.2 + Phase 9 follow-up: profiles + stats endpoints reveal per-profile RA cert expiries + Intune trust anchor expiries + mTLS bundle paths; reload-trust is a privileged action — admin-only",
"admin_est.go": "EST RFC 7030 hardening master bundle Phase 7.2: profiles endpoint reveals per-profile counter snapshot + mTLS trust-anchor expiries + auth modes; reload-trust is a privileged action — admin-only", "admin_est.go": "EST RFC 7030 hardening master bundle Phase 7.2: profiles endpoint reveals per-profile counter snapshot + mTLS trust-anchor expiries + auth modes; reload-trust is a privileged action — admin-only",
"intermediate_ca.go": "Rank 8: CA hierarchy management mints sub-CA certs that become trust roots for every downstream leaf — admin-only fleet-scale destructive surface",
} }
// InformationalIsAdminCallers is the documented allowlist of files that // InformationalIsAdminCallers is the documented allowlist of files that
+2 -2
View File
@@ -9,8 +9,8 @@ import (
"strconv" "strconv"
"time" "time"
"github.com/shankar0123/certctl/internal/api/middleware" "github.com/certctl-io/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/service" "github.com/certctl-io/certctl/internal/service"
) )
// MetricsService defines the service interface for metrics collection. // MetricsService defines the service interface for metrics collection.
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
) )
// NetworkScanService defines the interface used by the network scan handler. // NetworkScanService defines the interface used by the network scan handler.
@@ -9,7 +9,7 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
) )
// mockNetworkScanService implements NetworkScanService for testing. // mockNetworkScanService implements NetworkScanService for testing.
@@ -8,7 +8,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
) )
// MockNotificationService is a mock implementation of NotificationService interface. // MockNotificationService is a mock implementation of NotificationService interface.
+3 -3
View File
@@ -3,13 +3,13 @@ package handler
import ( import (
"context" "context"
"errors" "errors"
"github.com/shankar0123/certctl/internal/repository" "github.com/certctl-io/certctl/internal/repository"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"github.com/shankar0123/certctl/internal/api/middleware" "github.com/certctl-io/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
) )
// NotificationService defines the service interface for notification operations. // NotificationService defines the service interface for notification operations.
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
) )
// MockOwnerService is a mock implementation of OwnerService interface. // MockOwnerService is a mock implementation of OwnerService interface.
+3 -3
View File
@@ -4,13 +4,13 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"github.com/shankar0123/certctl/internal/repository" "github.com/certctl-io/certctl/internal/repository"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"github.com/shankar0123/certctl/internal/api/middleware" "github.com/certctl-io/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
) )
// OwnerService defines the service interface for owner operations. // OwnerService defines the service interface for owner operations.
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/shankar0123/certctl/internal/api/middleware" "github.com/certctl-io/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
) )
// PolicyService defines the service interface for policy rule operations. // PolicyService defines the service interface for policy rule operations.
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
) )
// MockPolicyService is a mock implementation of PolicyService interface. // MockPolicyService is a mock implementation of PolicyService interface.
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
) )
// MockProfileService is a mock implementation of ProfileService interface. // MockProfileService is a mock implementation of ProfileService interface.
+3 -3
View File
@@ -4,13 +4,13 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"github.com/shankar0123/certctl/internal/repository" "github.com/certctl-io/certctl/internal/repository"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"github.com/shankar0123/certctl/internal/api/middleware" "github.com/certctl-io/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
) )
// ProfileService defines the service interface for certificate profile operations. // ProfileService defines the service interface for certificate profile operations.
+4 -4
View File
@@ -4,14 +4,14 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"github.com/shankar0123/certctl/internal/repository" "github.com/certctl-io/certctl/internal/repository"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"github.com/shankar0123/certctl/internal/api/middleware" "github.com/certctl-io/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/service" "github.com/certctl-io/certctl/internal/service"
) )
// RenewalPolicyService defines the service interface for renewal policy // RenewalPolicyService defines the service interface for renewal policy
@@ -9,8 +9,8 @@ import (
"testing" "testing"
"time" "time"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/service" "github.com/certctl-io/certctl/internal/service"
) )
// G-1 red tests: lock in the HTTP surface of /api/v1/renewal-policies before // G-1 red tests: lock in the HTTP surface of /api/v1/renewal-policies before
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/shankar0123/certctl/internal/api/middleware" "github.com/certctl-io/certctl/internal/api/middleware"
) )
// resolveActor extracts the authenticated named-key identity from the request // resolveActor extracts the authenticated named-key identity from the request
+17 -8
View File
@@ -12,9 +12,9 @@ import (
"net/http" "net/http"
"strings" "strings"
"github.com/shankar0123/certctl/internal/api/middleware" "github.com/certctl-io/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/pkcs7" "github.com/certctl-io/certctl/internal/pkcs7"
) )
// SCEPService defines the service interface for SCEP enrollment operations. // SCEPService defines the service interface for SCEP enrollment operations.
@@ -577,7 +577,6 @@ func extractCSRFields(csrDER []byte) ([]byte, string, string, error) {
} }
challengePassword := "" challengePassword := ""
transactionID := ""
// OID for challengePassword: 1.2.840.113549.1.9.7 // OID for challengePassword: 1.2.840.113549.1.9.7
oidChallengePassword := asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 7} oidChallengePassword := asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 7}
@@ -608,10 +607,20 @@ func extractCSRFields(csrDER []byte) ([]byte, string, string, error) {
} }
} }
// Use CN as fallback transaction ID if not found in attributes // transactionID falls back to the CSR's CN. The MVP path (this
if transactionID == "" && csr.Subject.CommonName != "" { // function) never extracts the SCEP transaction-ID attribute (OID
transactionID = csr.Subject.CommonName // 2.16.840.1.113733.1.9.7) from CSR.Attributes — that's a known
} // gap; the RFC 8894 path (tryParseRFC8894 above) extracts it
// properly from the PKCS#7 SignedData authenticatedAttributes,
// which is where conformant clients put it anyway. CodeQL #18
// flagged the pre-existing `if transactionID == ""` dead
// conditional (transactionID was initialized to "" three lines
// above and never reassigned); cleaned up here. The MVP path
// stays usable for lightweight legacy clients that send the CSR
// directly with no PKCS#7 wrapping — they get CN-as-transaction-ID
// which is sufficient for matching against pollers in the existing
// test suite.
transactionID := csr.Subject.CommonName
return csrDER, challengePassword, transactionID, nil return csrDER, challengePassword, transactionID, nil
} }
+2 -2
View File
@@ -22,8 +22,8 @@ import (
"testing" "testing"
"time" "time"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/pkcs7" "github.com/certctl-io/certctl/internal/pkcs7"
) )
// SCEP RFC 8894 + Intune master bundle Phase 5.2: ChromeOS-shape integration // SCEP RFC 8894 + Intune master bundle Phase 5.2: ChromeOS-shape integration
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
) )
// mockSCEPService implements SCEPService for testing. // mockSCEPService implements SCEPService for testing.
+5 -5
View File
@@ -23,11 +23,11 @@ import (
"testing" "testing"
"time" "time"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/pkcs7" "github.com/certctl-io/certctl/internal/pkcs7"
"github.com/shankar0123/certctl/internal/repository" "github.com/certctl-io/certctl/internal/repository"
"github.com/shankar0123/certctl/internal/scep/intune" "github.com/certctl-io/certctl/internal/scep/intune"
"github.com/shankar0123/certctl/internal/service" "github.com/certctl-io/certctl/internal/service"
) )
// SCEP RFC 8894 + Intune master bundle Phase 10.2 — hermetic end-to-end // SCEP RFC 8894 + Intune master bundle Phase 10.2 — hermetic end-to-end
@@ -15,10 +15,10 @@ import (
"testing" "testing"
"time" "time"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/pkcs7" "github.com/certctl-io/certctl/internal/pkcs7"
"github.com/shankar0123/certctl/internal/scep/intune" "github.com/certctl-io/certctl/internal/scep/intune"
"github.com/shankar0123/certctl/internal/service" "github.com/certctl-io/certctl/internal/service"
) )
// SCEP RFC 8894 + Intune master prompt §13 line 1851 acceptance — // SCEP RFC 8894 + Intune master prompt §13 line 1851 acceptance —
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"github.com/shankar0123/certctl/internal/api/middleware" "github.com/certctl-io/certctl/internal/api/middleware"
) )
// StatsService defines the service interface for statistics operations. // StatsService defines the service interface for statistics operations.
+2 -2
View File
@@ -9,8 +9,8 @@ import (
"testing" "testing"
"time" "time"
"github.com/shankar0123/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/service" "github.com/certctl-io/certctl/internal/service"
) )
// MockTargetService is a mock implementation of TargetService interface. // MockTargetService is a mock implementation of TargetService interface.

Some files were not shown because too many files have changed in this diff Show More