mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-08 19:08:51 +00:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f81c1b956 | |||
| ff6ffcda1b | |||
| b0fc067317 | |||
| c46a6aecbc | |||
| 9ef9f3cde3 | |||
| a00b20cc97 | |||
| b6a5278df1 | |||
| 439905e546 | |||
| 2b4d0069d9 | |||
| d08982fc19 | |||
| af3ca3935b | |||
| e6919cdaba | |||
| 23c593089d | |||
| e50ba168ac | |||
| 7d48bd0367 | |||
| 85649cf983 | |||
| 8908c8ff5c | |||
| 34adcfbbe5 | |||
| ae597f7f8d | |||
| 62523fb845 | |||
| fb54ebcb62 | |||
| 66d2af36a7 | |||
| 31e50d987f | |||
| b601928e1c | |||
| aebfd8bd7c | |||
| 19706e56b3 | |||
| 03c61f4c20 | |||
| 81632eb0f3 | |||
| 8043e2bbac | |||
| 2025275b43 | |||
| 69d4ada385 |
@@ -1,5 +1,12 @@
|
||||
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:
|
||||
push:
|
||||
tags:
|
||||
@@ -346,6 +353,11 @@ jobs:
|
||||
# noise that gives operators no signal about what actually changed.
|
||||
uses: softprops/action-gh-release@v2
|
||||
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
|
||||
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.
|
||||
|
||||
@@ -50,6 +50,11 @@ gantt
|
||||
| [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 |
|
||||
| [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 |
|
||||
| [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 |
|
||||
@@ -65,18 +70,18 @@ gantt
|
||||
|
||||
| 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`). |
|
||||
| step-ca (Smallstep) | `StepCA` | JWK provisioner auth, issuance + renewal + revocation |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| AWS ACM Private CA | `AWSACMPCA` | Synchronous issuance, configurable signing algorithm/template ARN |
|
||||
| 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 |
|
||||
| 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.
|
||||
|
||||
@@ -98,6 +103,8 @@ gantt
|
||||
| Windows Certificate Store | `WinCertStore` | PowerShell Import-PfxCertificate + Get-ChildItem 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 |
|
||||
| **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.
|
||||
|
||||
@@ -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. |
|
||||
| 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. |
|
||||
| ACME v2 | 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 client | RFC 8555 | Public CA automated issuance (Let's Encrypt, ZeroSSL) |
|
||||
| **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
|
||||
|
||||
@@ -161,7 +169,7 @@ Certificate lifecycle tooling falls into two camps: enterprise platforms (Venafi
|
||||
|
||||
Built for **platform engineering and DevOps teams** managing 10–500+ 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.
|
||||
|
||||
@@ -171,13 +179,19 @@ Built for **platform engineering and DevOps teams** managing 10–500+ 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.
|
||||
|
||||
**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.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -185,9 +199,11 @@ Built for **platform engineering and DevOps teams** managing 10–500+ 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.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
### 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
|
||||
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.
|
||||
|
||||
@@ -2751,6 +2751,310 @@ paths:
|
||||
$ref: "#/components/responses/InternalError"
|
||||
|
||||
# ─── 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:
|
||||
get:
|
||||
tags: [Notifications]
|
||||
@@ -4057,6 +4361,63 @@ components:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
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 ──────────────────────────────────────────────────────
|
||||
ErrorResponse:
|
||||
type: object
|
||||
|
||||
@@ -267,6 +267,43 @@ func main() {
|
||||
// same *sql.DB handle.
|
||||
transactor := postgres.NewTransactor(db)
|
||||
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)
|
||||
|
||||
// Wire notifier connectors from config
|
||||
@@ -371,6 +408,15 @@ func main() {
|
||||
RotationGrace: cfg.OCSPResponder.RotationGrace,
|
||||
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)
|
||||
|
||||
// Production hardening II Phase 2: OCSP response cache. Mirrors the
|
||||
@@ -907,6 +953,14 @@ func main() {
|
||||
// new-order, finalize, challenges, revoke, ARI). See
|
||||
// docs/acme-server.md for the operator-facing reference.
|
||||
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.
|
||||
//
|
||||
|
||||
@@ -290,7 +290,15 @@ services:
|
||||
# /healthz endpoint.
|
||||
# ---------------------------------------------------------------------------
|
||||
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
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q -O- http://localhost:8080/healthz || exit 1"]
|
||||
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
|
||||
**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.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -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).
|
||||
@@ -15,42 +15,39 @@ install certctl.
|
||||
|
||||
## End-to-end flow (cloud targets)
|
||||
|
||||
```
|
||||
cert renewed → renewal job created
|
||||
│
|
||||
▼
|
||||
agent picks up DeployCertificate work item
|
||||
│
|
||||
▼
|
||||
target.Connector.DeployCertificate(ctx, request)
|
||||
│
|
||||
┌──────────────────┴──────────────────┐
|
||||
│ │
|
||||
▼ ▼
|
||||
AWS ACM path Azure Key Vault path
|
||||
│ │
|
||||
▼ ▼
|
||||
1. (rotate-in-place only) 1. GetCertificate(name, "" /* latest */)
|
||||
DescribeCertificate(arn) — capture snapshot CER bytes
|
||||
2. GetCertificate(arn) — capture 2. Build PFX from cert+chain+key
|
||||
snapshot bytes for rollback (PKCS#12 via go-pkcs12)
|
||||
3. ImportCertificate(arn, new_bytes) 3. ImportCertificate(name, PFX, tags)
|
||||
— fresh ARN OR rotate-in-place — ALWAYS creates a new version
|
||||
4. AddTagsToCertificate(arn, 4. (Tags carried forward
|
||||
provenance) — ACM strips on automatically)
|
||||
re-import; we re-apply
|
||||
5. DescribeCertificate(arn) — verify 5. GetCertificate(name, "" /* latest */)
|
||||
serial matches expected — verify serial matches expected
|
||||
6. ON MISMATCH: rollback ←──── (same shape) ────→ 6. ON MISMATCH: rollback
|
||||
ImportCertificate(arn, ImportCertificate(name,
|
||||
snapshot_bytes) snapshot_PFX) — new version
|
||||
│
|
||||
▼
|
||||
7. Audit row + Prometheus counter
|
||||
certctl_deploy_attempts_total{target_type="AWSACM"|"AzureKeyVault",
|
||||
result="success"|"failure"}
|
||||
certctl_deploy_rollback_total{target_type=...,
|
||||
outcome="restored"|"also_failed"}
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Renew["cert renewed → renewal job created"]
|
||||
Pick["agent picks up DeployCertificate work item"]
|
||||
Dispatch["target.Connector.DeployCertificate(ctx, request)"]
|
||||
|
||||
Renew --> Pick --> Dispatch
|
||||
Dispatch --> AWS
|
||||
Dispatch --> AZ
|
||||
|
||||
subgraph AWS["AWS ACM path"]
|
||||
A1["1. rotate-in-place only:<br/>DescribeCertificate(arn)"]
|
||||
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"]
|
||||
A5["5. DescribeCertificate(arn) —<br/>verify serial matches expected"]
|
||||
A6["6. ON MISMATCH: rollback<br/>ImportCertificate(arn, snapshot_bytes)"]
|
||||
A1 --> A2 --> A3 --> A4 --> A5 --> A6
|
||||
end
|
||||
|
||||
subgraph AZ["Azure Key Vault path"]
|
||||
Z1["1. GetCertificate(name, '' = latest) —<br/>capture snapshot CER bytes"]
|
||||
Z2["2. Build PFX from cert+chain+key<br/>(PKCS#12 via go-pkcs12)"]
|
||||
Z3["3. ImportCertificate(name, PFX, tags) —<br/>ALWAYS creates a new version"]
|
||||
Z4["4. Tags carried forward automatically"]
|
||||
Z5["5. GetCertificate(name, '' = latest) —<br/>verify serial matches expected"]
|
||||
Z6["6. ON MISMATCH: rollback<br/>ImportCertificate(name, snapshot_PFX) —<br/>new version"]
|
||||
Z1 --> Z2 --> Z3 --> Z4 --> Z5 --> Z6
|
||||
end
|
||||
|
||||
A6 --> Audit
|
||||
Z6 --> Audit
|
||||
Audit["7. Audit row + Prometheus counters<br/>certctl_deploy_attempts_total{target_type, result}<br/>certctl_deploy_rollback_total{target_type, outcome}"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -14,36 +14,37 @@ walkthrough of how to install certctl — that lives in the README.
|
||||
|
||||
## End-to-end flow
|
||||
|
||||
```
|
||||
daily ticker (renewalCheckLoop)
|
||||
│
|
||||
▼
|
||||
RenewalService.CheckExpiringCertificates
|
||||
│
|
||||
┌────────────────┴────────────────┐
|
||||
│ for cert in expiring (≤30 days):│
|
||||
│ 1. Resolve RenewalPolicy │
|
||||
│ 2. Compute daysUntil │
|
||||
│ 3. updateCertExpiryStatus │
|
||||
│ 4. sendThresholdAlerts ──────►│ per threshold:
|
||||
│ 5. Create renewal job (if │ a. resolve severity tier
|
||||
│ issuer registered + ARI │ via AlertSeverityMap
|
||||
│ allows) │ b. resolve channel set
|
||||
└──────────────────────────────────┘ via AlertChannels[tier]
|
||||
c. for each channel:
|
||||
i. dedup via
|
||||
notification_events
|
||||
(cert,threshold,channel)
|
||||
ii. SendThresholdAlertOnChannel
|
||||
→ notifierRegistry[channel]
|
||||
→ Send(recipient,subj,body)
|
||||
iii. record audit row
|
||||
(event_type=expiration_alert_sent,
|
||||
metadata.channel,
|
||||
metadata.severity_tier)
|
||||
iv. bump Prometheus counter
|
||||
certctl_expiry_alerts_total
|
||||
{channel,threshold,result}
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Tick["daily ticker (renewalCheckLoop)"]
|
||||
Check["RenewalService.CheckExpiringCertificates"]
|
||||
|
||||
Tick --> Check --> Loop
|
||||
|
||||
subgraph Loop["for cert in expiring (≤30 days)"]
|
||||
L1["1. Resolve RenewalPolicy"]
|
||||
L2["2. Compute daysUntil"]
|
||||
L3["3. updateCertExpiryStatus"]
|
||||
L4["4. sendThresholdAlerts"]
|
||||
L5["5. Create renewal job<br/>(if issuer registered +<br/>ARI allows)"]
|
||||
L1 --> L2 --> L3 --> L4 --> L5
|
||||
end
|
||||
|
||||
L4 --> Threshold
|
||||
|
||||
subgraph Threshold["per threshold"]
|
||||
T1["a. resolve severity tier<br/>via AlertSeverityMap"]
|
||||
T2["b. resolve channel set<br/>via AlertChannels[tier]"]
|
||||
T1 --> T2 --> Channel
|
||||
end
|
||||
|
||||
subgraph Channel["for each channel (fault-isolating)"]
|
||||
C1["i. dedup via notification_events<br/>(cert, threshold, channel)"]
|
||||
C2["ii. SendThresholdAlertOnChannel<br/>→ notifierRegistry[channel]<br/>→ Send(recipient, subj, body)"]
|
||||
C3["iii. record audit row<br/>event_type=expiration_alert_sent<br/>metadata.channel, metadata.severity_tier"]
|
||||
C4["iv. bump Prometheus counter<br/>certctl_expiry_alerts_total<br/>{channel, threshold, result}"]
|
||||
C1 --> C2 --> C3 --> C4
|
||||
end
|
||||
```
|
||||
|
||||
The dispatch loop's per-channel error handling is
|
||||
|
||||
@@ -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/security/keyvault/internal v1.2.0 // 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/ChrisTrenkamp/goxpath v0.0.0-20210404020558-97928f7e12b6 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
|
||||
@@ -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/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-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
|
||||
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
|
||||
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/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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_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",
|
||||
"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
|
||||
|
||||
@@ -577,7 +577,6 @@ func extractCSRFields(csrDER []byte) ([]byte, string, string, error) {
|
||||
}
|
||||
|
||||
challengePassword := ""
|
||||
transactionID := ""
|
||||
|
||||
// OID for challengePassword: 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
|
||||
if transactionID == "" && csr.Subject.CommonName != "" {
|
||||
transactionID = csr.Subject.CommonName
|
||||
}
|
||||
// transactionID falls back to the CSR's CN. The MVP path (this
|
||||
// function) never extracts the SCEP transaction-ID attribute (OID
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -44,6 +44,20 @@ func RequestID(next http.Handler) http.Handler {
|
||||
|
||||
// Logging middleware logs request details including method, path, status, and duration.
|
||||
// Deprecated: Use NewLogging for structured logging with slog.
|
||||
//
|
||||
// CWE-117 log-injection defense: r.Method and r.URL.Path are
|
||||
// attacker-controllable (request-line bytes — Go's net/http leaves
|
||||
// percent-decoded path segments in r.URL.Path, which can include CR/LF
|
||||
// in the decoded form even though the raw HTTP request line cannot).
|
||||
// strings.ReplaceAll on CR/LF/NUL strips the forgery vector before the
|
||||
// log line is emitted. Closes CodeQL #17 + #32 (go/log-injection).
|
||||
//
|
||||
// The replacement is intentionally inlined at the call site (literal
|
||||
// strings.ReplaceAll chains) because CodeQL's go/log-injection
|
||||
// taint tracker only recognizes that exact pattern as a sanitizer —
|
||||
// strings.NewReplacer / wrapper helpers don't trigger the recognition,
|
||||
// reopening the alert. The OWASP example in the CodeQL rule docs uses
|
||||
// the same pattern.
|
||||
func Logging(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
@@ -55,7 +69,19 @@ func Logging(next http.Handler) http.Handler {
|
||||
|
||||
duration := time.Since(start)
|
||||
requestID := getRequestID(r.Context())
|
||||
log.Printf("[%s] %s %s %d %v", requestID, r.Method, r.URL.Path, wrapped.statusCode, duration)
|
||||
|
||||
// Strip CR/LF/NUL from attacker-controllable request fields
|
||||
// before logging. Inlined per CodeQL #32 — the ReplaceAll
|
||||
// chain is the pattern the analyzer pattern-matches as a
|
||||
// sanitizer.
|
||||
method := strings.ReplaceAll(r.Method, "\n", "")
|
||||
method = strings.ReplaceAll(method, "\r", "")
|
||||
method = strings.ReplaceAll(method, "\x00", "")
|
||||
urlPath := strings.ReplaceAll(r.URL.Path, "\n", "")
|
||||
urlPath = strings.ReplaceAll(urlPath, "\r", "")
|
||||
urlPath = strings.ReplaceAll(urlPath, "\x00", "")
|
||||
|
||||
log.Printf("[%s] %s %s %d %v", requestID, method, urlPath, wrapped.statusCode, duration)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -156,6 +156,33 @@ type HandlerRegistry struct {
|
||||
// authzs, challenges, key-change, revoke-cert, ARI. See
|
||||
// docs/acme-server.md for the configuration reference.
|
||||
ACME handler.ACMEHandler
|
||||
|
||||
// Approvals handles the issuance approval-workflow endpoints under
|
||||
// /api/v1/approvals/*. Rank 7 of the 2026-05-03 Infisical deep-
|
||||
// research deliverable — closes the two-person integrity / four-eyes
|
||||
// principle procurement gap. Routes:
|
||||
// GET /api/v1/approvals
|
||||
// GET /api/v1/approvals/{id}
|
||||
// POST /api/v1/approvals/{id}/approve
|
||||
// POST /api/v1/approvals/{id}/reject
|
||||
// Same-actor RBAC enforced at the service layer; the handler
|
||||
// surfaces ErrApproveBySameActor as HTTP 403. See
|
||||
// docs/approval-workflow.md for the operator playbook.
|
||||
Approvals handler.ApprovalHandler
|
||||
|
||||
// IntermediateCAs handles the admin-gated CA-hierarchy management
|
||||
// surface under /api/v1/issuers/{id}/intermediates and
|
||||
// /api/v1/intermediates/{id}. Rank 8 of the 2026-05-03 deep-
|
||||
// research deliverable — closes the multi-level CA hierarchy gap
|
||||
// for FedRAMP boundary-CA, financial-services policy-CA, and OT
|
||||
// network-CA deployments. Routes:
|
||||
// POST /api/v1/issuers/{id}/intermediates
|
||||
// GET /api/v1/issuers/{id}/intermediates
|
||||
// GET /api/v1/intermediates/{id}
|
||||
// POST /api/v1/intermediates/{id}/retire
|
||||
// Admin-gated at the handler layer (M-003 pattern). See
|
||||
// docs/intermediate-ca-hierarchy.md for the operator playbook.
|
||||
IntermediateCAs handler.IntermediateCAHandler
|
||||
}
|
||||
|
||||
// RegisterHandlers sets up all API routes with their handlers.
|
||||
@@ -350,6 +377,26 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
|
||||
// before falling back to the {id} path-variable route above.
|
||||
r.Register("POST /api/v1/notifications/{id}/requeue", http.HandlerFunc(reg.Notifications.RequeueNotification))
|
||||
|
||||
// Approvals routes: /api/v1/approvals (Rank 7).
|
||||
// Same Go 1.22 ServeMux precedence as the notifications block — literal
|
||||
// /approve and /reject segments resolve before the {id} pattern-var
|
||||
// route. Same-actor RBAC enforced at the service layer; the handler
|
||||
// surfaces ErrApproveBySameActor as HTTP 403.
|
||||
r.Register("GET /api/v1/approvals", http.HandlerFunc(reg.Approvals.ListApprovals))
|
||||
r.Register("GET /api/v1/approvals/{id}", http.HandlerFunc(reg.Approvals.GetApproval))
|
||||
r.Register("POST /api/v1/approvals/{id}/approve", http.HandlerFunc(reg.Approvals.Approve))
|
||||
r.Register("POST /api/v1/approvals/{id}/reject", http.HandlerFunc(reg.Approvals.Reject))
|
||||
|
||||
// IntermediateCA hierarchy routes (Rank 8). Admin-gated inside the
|
||||
// handler (M-003 pattern); non-admin Bearer callers get 403. The
|
||||
// /retire literal segment resolves before the {id} pattern-var
|
||||
// route under Go 1.22 ServeMux precedence — the ordering below
|
||||
// matches the notifications + approvals blocks above.
|
||||
r.Register("POST /api/v1/issuers/{id}/intermediates", http.HandlerFunc(reg.IntermediateCAs.Create))
|
||||
r.Register("GET /api/v1/issuers/{id}/intermediates", http.HandlerFunc(reg.IntermediateCAs.List))
|
||||
r.Register("POST /api/v1/intermediates/{id}/retire", http.HandlerFunc(reg.IntermediateCAs.Retire))
|
||||
r.Register("GET /api/v1/intermediates/{id}", http.HandlerFunc(reg.IntermediateCAs.Get))
|
||||
|
||||
// Stats routes: /api/v1/stats
|
||||
r.Register("GET /api/v1/stats/summary", http.HandlerFunc(reg.Stats.GetDashboardSummary))
|
||||
r.Register("GET /api/v1/stats/certificates-by-status", http.HandlerFunc(reg.Stats.GetCertificatesByStatus))
|
||||
|
||||
@@ -28,6 +28,11 @@ type Config struct {
|
||||
SCEP SCEPConfig
|
||||
Verification VerificationConfig
|
||||
ACME ACMEConfig
|
||||
// Approval is the issuance approval-workflow primitive's runtime
|
||||
// config. Rank 7 of the 2026-05-03 Infisical deep-research
|
||||
// deliverable. The single field — BypassEnabled — short-circuits
|
||||
// the workflow for dev/CI; production deploys MUST leave it false.
|
||||
Approval ApprovalConfig
|
||||
// ACMEServer is the SERVER-side ACME (RFC 8555 + RFC 9773 ARI)
|
||||
// configuration. Distinct from ACME above (which is the consumer-
|
||||
// side issuer connector that talks UP to Let's Encrypt / pebble).
|
||||
@@ -1425,6 +1430,29 @@ type SchedulerConfig struct {
|
||||
K8sDeployKubeletSyncTimeout time.Duration
|
||||
}
|
||||
|
||||
// ApprovalConfig contains issuance approval-workflow runtime configuration.
|
||||
// Rank 7 of the 2026-05-03 Infisical deep-research deliverable.
|
||||
type ApprovalConfig struct {
|
||||
// BypassEnabled short-circuits the approval workflow — every
|
||||
// RequestApproval call auto-approves with decidedBy="system-bypass"
|
||||
// (see domain.ApprovalActorSystemBypass) and emits an audit row with
|
||||
// ActorType=System. Used by dev / CI to keep renewal-scheduler tests
|
||||
// fast without standing up an approver.
|
||||
//
|
||||
// **PRODUCTION DEPLOYS MUST LEAVE THIS FALSE.** A simple SQL query
|
||||
// detects misuse:
|
||||
//
|
||||
// SELECT count(*) FROM audit_events WHERE actor = 'system-bypass';
|
||||
//
|
||||
// returns zero in production and a high count in dev. The bypass
|
||||
// also emits a typed audit event (action=approval_bypassed) so
|
||||
// compliance auditors can pattern-match without scanning JSON
|
||||
// metadata.
|
||||
//
|
||||
// Setting: CERTCTL_APPROVAL_BYPASS environment variable. Default: false.
|
||||
BypassEnabled bool
|
||||
}
|
||||
|
||||
// LogConfig contains logging configuration.
|
||||
type LogConfig struct {
|
||||
// Level sets the minimum log level for output.
|
||||
@@ -1839,6 +1867,14 @@ func Load() (*Config, error) {
|
||||
ExternalAccountRequired: getEnvBool("CERTCTL_ACME_SERVER_EAB_REQUIRED", false),
|
||||
},
|
||||
},
|
||||
Approval: ApprovalConfig{
|
||||
// Rank 7. Default: false. Production deploys must leave it false;
|
||||
// the bypass emits a typed audit row (action=approval_bypassed,
|
||||
// actor=system-bypass) so compliance auditors detect misuse via
|
||||
// SELECT count(*) FROM audit_events WHERE actor='system-bypass'
|
||||
// returning > 0.
|
||||
BypassEnabled: getEnvBool("CERTCTL_APPROVAL_BYPASS", false),
|
||||
},
|
||||
Digest: DigestConfig{
|
||||
Enabled: getEnvBool("CERTCTL_DIGEST_ENABLED", false),
|
||||
Interval: getEnvDuration("CERTCTL_DIGEST_INTERVAL", 24*time.Hour),
|
||||
|
||||
@@ -404,18 +404,27 @@ func (c *Connector) RevokeCertificate(ctx context.Context, request issuer.Revoca
|
||||
return fmt.Errorf("failed to marshal revoke request: %w", err)
|
||||
}
|
||||
|
||||
// Use the serial directly or extract from OrderID if present (as fallback)
|
||||
// EJBCA's REST API has two revoke endpoints:
|
||||
// /certificate/{issuer_dn}/{serial}/revoke — DN-qualified (more
|
||||
// robust when EJBCA
|
||||
// has multiple CAs
|
||||
// with overlapping
|
||||
// serial spaces)
|
||||
// /certificate/{serial}/revoke — serial-only (this
|
||||
// connector's
|
||||
// contract today)
|
||||
//
|
||||
// We currently use the serial-only endpoint; the issuer DN isn't
|
||||
// preserved in IssuanceResult.OrderID and the cert isn't re-fetched
|
||||
// on revoke. EJBCA installations with serial-uniqueness across all
|
||||
// configured CAs (the typical certctl deployment shape — one EJBCA
|
||||
// CA per certctl issuer config) work fine. CodeQL #19 flagged the
|
||||
// pre-existing `if issuerDN == ""` dead-conditional where issuerDN
|
||||
// was always empty; cleaned up here. Future enhancement (when /if
|
||||
// a multi-CA EJBCA deployment surfaces): parse issuer DN from
|
||||
// IssuanceResult metadata + use the DN-qualified endpoint.
|
||||
serial := request.Serial
|
||||
issuerDN := ""
|
||||
|
||||
// If we have time and access to issuer DN, we could parse it from OrderID
|
||||
// For now, we attempt to use serial as-is, and fall back to issuer DN lookup if needed.
|
||||
|
||||
revokeURL := fmt.Sprintf("%s/certificate/%s/%s/revoke", c.config.APIUrl, issuerDN, serial)
|
||||
if issuerDN == "" {
|
||||
// If no issuer DN, just use serial alone (may fail if EJBCA requires issuer_dn)
|
||||
revokeURL = fmt.Sprintf("%s/certificate/%s/revoke", c.config.APIUrl, serial)
|
||||
}
|
||||
revokeURL := fmt.Sprintf("%s/certificate/%s/revoke", c.config.APIUrl, serial)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, revokeURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
|
||||
@@ -110,9 +110,20 @@ type Config struct {
|
||||
CRLDistributionPointURLs []string `json:"crl_distribution_point_urls,omitempty"`
|
||||
}
|
||||
|
||||
// ChainAssembler assembles the leaf-to-root PEM chain for a given
|
||||
// IntermediateCA ID. The local connector calls this in tree mode at
|
||||
// IssueCertificate time to populate IssuanceResult.ChainPEM. Defining
|
||||
// the seam as a one-method interface inside the connector package
|
||||
// avoids the import cycle that would arise from importing
|
||||
// internal/service directly. *service.IntermediateCAService satisfies
|
||||
// this implicitly.
|
||||
type ChainAssembler interface {
|
||||
AssembleChain(ctx context.Context, leafCAID string) (string, error)
|
||||
}
|
||||
|
||||
// Connector implements the issuer.Connector interface for local certificate generation.
|
||||
//
|
||||
// It supports two modes:
|
||||
// It supports three modes (Rank 8 added the third):
|
||||
//
|
||||
// Self-signed mode (default):
|
||||
// - Generates an ephemeral self-signed CA root on first use
|
||||
@@ -125,6 +136,20 @@ type Config struct {
|
||||
// - All issued certificates chain to the upstream root
|
||||
// - Suitable for production when the upstream CA is trusted
|
||||
//
|
||||
// Tree mode (when HierarchyMode is "tree" + SetChainAssembler + SetTreeIssuingCAID
|
||||
// have been wired):
|
||||
// - Operator-managed N-level CA hierarchy backed by the
|
||||
// intermediate_cas table.
|
||||
// - Cert signing still uses c.caCert + c.caSigner (the operator
|
||||
// pre-positions the issuing-leaf CA cert+key on disk via the same
|
||||
// CACertPath/CAKeyPath that sub-CA mode uses).
|
||||
// - Only the chain assembled into IssuanceResult.ChainPEM differs:
|
||||
// instead of the static c.caCertPEM, the connector calls
|
||||
// chainAssembler.AssembleChain(treeIssuingCAID), which walks the
|
||||
// parent_ca_id ancestry up to the registered root.
|
||||
// - byte-identical to single-sub-CA mode for any 1-level tree (the
|
||||
// Rank 8 backwards-compat pin).
|
||||
//
|
||||
// Features:
|
||||
// - Instant certificate issuance (no external CA required)
|
||||
// - Full lifecycle support (issue, renew, revoke)
|
||||
@@ -143,6 +168,20 @@ type Connector struct {
|
||||
subCA bool // true when loaded from disk (sub-CA mode)
|
||||
revokedMap map[string]bool // serial -> revoked status
|
||||
|
||||
// Rank 8 — first-class CA hierarchy. Optional; when unset the
|
||||
// connector behaves byte-identically to the pre-Rank-8 single-sub-CA
|
||||
// flow. When set:
|
||||
// - hierarchyMode == "tree" activates the tree-mode chain
|
||||
// assembly (AssembleChain over the intermediate_cas table).
|
||||
// - chainAssembler is the seam to *service.IntermediateCAService.
|
||||
// - treeIssuingCAID is the leaf CA in the tree under which leaves
|
||||
// are issued. Cert signing still uses c.caCert + c.caSigner; the
|
||||
// operator pre-positions the matching cert+key on disk for the
|
||||
// issuing-leaf CA via Config.CACertPath / Config.CAKeyPath.
|
||||
hierarchyMode string
|
||||
chainAssembler ChainAssembler
|
||||
treeIssuingCAID string
|
||||
|
||||
// Optional dependencies — set after construction via the
|
||||
// Set*-style helpers below. The Connector functions correctly with
|
||||
// any subset of these unset (the Phase-2 responder-cert path falls
|
||||
@@ -255,6 +294,38 @@ func (c *Connector) SetOCSPResponderKeyDir(dir string) {
|
||||
c.ocspResponderKeyDir = dir
|
||||
}
|
||||
|
||||
// SetHierarchyMode wires the per-issuer CA-hierarchy posture (Rank 8).
|
||||
// The empty string and "single" preserve the historical single-sub-CA
|
||||
// flow byte-for-byte; "tree" activates the intermediate_cas-backed
|
||||
// chain assembly. Callers that pass "tree" MUST also call
|
||||
// SetChainAssembler + SetTreeIssuingCAID before issuing certs;
|
||||
// otherwise the connector falls back to single-mode chain assembly.
|
||||
func (c *Connector) SetHierarchyMode(mode string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.hierarchyMode = mode
|
||||
}
|
||||
|
||||
// SetChainAssembler wires the leaf-to-root chain assembler used in
|
||||
// tree mode. *service.IntermediateCAService satisfies the interface
|
||||
// implicitly. Unset = falls back to single-mode chain assembly.
|
||||
func (c *Connector) SetChainAssembler(a ChainAssembler) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.chainAssembler = a
|
||||
}
|
||||
|
||||
// SetTreeIssuingCAID records the IntermediateCA ID under which leaves
|
||||
// are issued in tree mode. Used as the AssembleChain leafCAID input.
|
||||
// Cert signing still uses the file-on-disk CA cert+key wired via
|
||||
// Config.CACertPath / Config.CAKeyPath; this ID is purely for chain
|
||||
// assembly.
|
||||
func (c *Connector) SetTreeIssuingCAID(id string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.treeIssuingCAID = id
|
||||
}
|
||||
|
||||
// ValidateConfig validates the local CA configuration.
|
||||
func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error {
|
||||
var cfg Config
|
||||
@@ -353,12 +424,18 @@ func (c *Connector) IssueCertificate(ctx context.Context, request issuer.Issuanc
|
||||
return nil, fmt.Errorf("certificate generation failed: %w", err)
|
||||
}
|
||||
|
||||
chainPEM, err := c.resolveChainPEM(ctx)
|
||||
if err != nil {
|
||||
c.logger.Error("failed to assemble chain", "error", err)
|
||||
return nil, fmt.Errorf("chain assembly failed: %w", err)
|
||||
}
|
||||
|
||||
// Create order ID (use serial as order ID for simplicity)
|
||||
orderID := fmt.Sprintf("local-%s", serial)
|
||||
|
||||
result := &issuer.IssuanceResult{
|
||||
CertPEM: certPEM,
|
||||
ChainPEM: c.caCertPEM,
|
||||
ChainPEM: chainPEM,
|
||||
Serial: serial,
|
||||
NotBefore: cert.NotBefore,
|
||||
NotAfter: cert.NotAfter,
|
||||
@@ -417,6 +494,12 @@ func (c *Connector) RenewCertificate(ctx context.Context, request issuer.Renewal
|
||||
return nil, fmt.Errorf("certificate generation failed: %w", err)
|
||||
}
|
||||
|
||||
chainPEM, err := c.resolveChainPEM(ctx)
|
||||
if err != nil {
|
||||
c.logger.Error("failed to assemble chain", "error", err)
|
||||
return nil, fmt.Errorf("chain assembly failed: %w", err)
|
||||
}
|
||||
|
||||
// Create order ID
|
||||
orderID := fmt.Sprintf("local-%s", serial)
|
||||
if request.OrderID != nil {
|
||||
@@ -425,7 +508,7 @@ func (c *Connector) RenewCertificate(ctx context.Context, request issuer.Renewal
|
||||
|
||||
result := &issuer.IssuanceResult{
|
||||
CertPEM: certPEM,
|
||||
ChainPEM: c.caCertPEM,
|
||||
ChainPEM: chainPEM,
|
||||
Serial: serial,
|
||||
NotBefore: cert.NotBefore,
|
||||
NotAfter: cert.NotAfter,
|
||||
@@ -440,6 +523,30 @@ func (c *Connector) RenewCertificate(ctx context.Context, request issuer.Renewal
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// resolveChainPEM returns the chain bytes the local connector attaches
|
||||
// to IssuanceResult. In single-sub-CA mode (or when tree-mode wiring
|
||||
// is incomplete) it returns the historical c.caCertPEM byte-for-byte
|
||||
// — the Rank 8 backwards-compat pin. In tree mode it delegates to
|
||||
// the registered ChainAssembler, which walks the parent_ca_id ancestry
|
||||
// over the intermediate_cas table.
|
||||
func (c *Connector) resolveChainPEM(ctx context.Context) (string, error) {
|
||||
c.mu.RLock()
|
||||
mode := c.hierarchyMode
|
||||
asm := c.chainAssembler
|
||||
leaf := c.treeIssuingCAID
|
||||
fallback := c.caCertPEM
|
||||
c.mu.RUnlock()
|
||||
|
||||
if mode == "tree" && asm != nil && leaf != "" {
|
||||
chain, err := asm.AssembleChain(ctx, leaf)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return chain, nil
|
||||
}
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
// RevokeCertificate revokes a certificate by marking it in the in-memory revocation map.
|
||||
// This is a no-op for practical purposes but tracks revocation state in memory.
|
||||
// Note: Revocation is not persistent and is lost on service restart.
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
package local
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/connector/issuer"
|
||||
)
|
||||
|
||||
// fakeChainAssembler is a tiny in-memory ChainAssembler for the
|
||||
// hierarchy unit tests. It maps a leafCAID to a pre-built chain PEM
|
||||
// (leaf-first ordering, matching what *service.IntermediateCAService
|
||||
// produces in production via WalkAncestry).
|
||||
type fakeChainAssembler struct {
|
||||
chains map[string]string
|
||||
}
|
||||
|
||||
func (f *fakeChainAssembler) AssembleChain(ctx context.Context, leafCAID string) (string, error) {
|
||||
if c, ok := f.chains[leafCAID]; ok {
|
||||
return c, nil
|
||||
}
|
||||
return "", os.ErrNotExist
|
||||
}
|
||||
|
||||
// hierarchyTestFixture builds a self-signed root cert+key in memory,
|
||||
// writes them to disk under a fresh tempdir, and returns the paths
|
||||
// + parsed PEM. Both single- and tree-mode connectors load from this
|
||||
// pair so the signing path is identical and the only thing that can
|
||||
// differ is chain assembly.
|
||||
type hierarchyTestFixture struct {
|
||||
tempDir string
|
||||
certPEM string
|
||||
keyPEM string
|
||||
cert *x509.Certificate
|
||||
}
|
||||
|
||||
func newHierarchyTestFixture(t *testing.T) *hierarchyTestFixture {
|
||||
t.Helper()
|
||||
tempDir := t.TempDir()
|
||||
if err := os.Chmod(tempDir, 0o700); err != nil {
|
||||
t.Fatalf("chmod tempdir: %v", err)
|
||||
}
|
||||
|
||||
// Mint a self-signed root cert + key in process.
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("ecdsa keygen: %v", err)
|
||||
}
|
||||
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
subj := pkix.Name{CommonName: "Hierarchy Test Root"}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: subj,
|
||||
Issuer: subj,
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(2 * 365 * 24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
t.Fatalf("create cert: %v", err)
|
||||
}
|
||||
cert, _ := x509.ParseCertificate(der)
|
||||
certPEM := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}))
|
||||
|
||||
keyDER, err := x509.MarshalECPrivateKey(priv)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal ec key: %v", err)
|
||||
}
|
||||
keyPEM := string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}))
|
||||
|
||||
certPath := filepath.Join(tempDir, "ca.crt")
|
||||
keyPath := filepath.Join(tempDir, "ca.key")
|
||||
if err := os.WriteFile(certPath, []byte(certPEM), 0o600); err != nil {
|
||||
t.Fatalf("write cert: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(keyPath, []byte(keyPEM), 0o600); err != nil {
|
||||
t.Fatalf("write key: %v", err)
|
||||
}
|
||||
|
||||
return &hierarchyTestFixture{
|
||||
tempDir: tempDir,
|
||||
certPEM: certPEM,
|
||||
keyPEM: keyPEM,
|
||||
cert: cert,
|
||||
}
|
||||
}
|
||||
|
||||
// makeCSRPEM returns a fresh ECDSA CSR PEM for the given CN. Used by
|
||||
// both connectors so the signing inputs are identical.
|
||||
func makeCSRPEM(t *testing.T, cn string) string {
|
||||
t.Helper()
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("csr keygen: %v", err)
|
||||
}
|
||||
tmpl := &x509.CertificateRequest{
|
||||
Subject: pkix.Name{CommonName: cn},
|
||||
DNSNames: []string{cn},
|
||||
}
|
||||
der, err := x509.CreateCertificateRequest(rand.Reader, tmpl, priv)
|
||||
if err != nil {
|
||||
t.Fatalf("create csr: %v", err)
|
||||
}
|
||||
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: der}))
|
||||
}
|
||||
|
||||
func newSilentLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
// TestLocal_HierarchyMode_SingleVsTree_ByteIdentical is the LOAD-
|
||||
// BEARING backwards-compat pin (Rank 8 commit 3). Two connectors
|
||||
// configured against the SAME on-disk CA cert+key produce
|
||||
// byte-identical IssuanceResult.ChainPEM bytes:
|
||||
// - Connector A: pre-Rank-8 single-sub-CA mode (HierarchyMode unset).
|
||||
// ChainPEM = c.caCertPEM (the historical path).
|
||||
// - Connector B: tree mode wired against an in-memory ChainAssembler
|
||||
// whose AssembleChain returns the SAME PEM bytes for a 1-level
|
||||
// tree.
|
||||
//
|
||||
// Operators on single mode who never touch HierarchyMode keep getting
|
||||
// byte-identical wire bytes; operators who flip to tree mode and
|
||||
// register the same CA as the active root see no change in the bytes
|
||||
// returned. This guarantees zero behavioral drift for unmigrated
|
||||
// deployments.
|
||||
func TestLocal_HierarchyMode_SingleVsTree_ByteIdentical(t *testing.T) {
|
||||
fx := newHierarchyTestFixture(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Connector A — single-sub-CA mode (historical path).
|
||||
connA := New(&Config{
|
||||
CACommonName: "ignored",
|
||||
ValidityDays: 90,
|
||||
CACertPath: filepath.Join(fx.tempDir, "ca.crt"),
|
||||
CAKeyPath: filepath.Join(fx.tempDir, "ca.key"),
|
||||
}, newSilentLogger())
|
||||
|
||||
// Connector B — tree mode wired against an in-memory chain
|
||||
// assembler that returns the SAME root cert PEM (1-level tree).
|
||||
connB := New(&Config{
|
||||
CACommonName: "ignored",
|
||||
ValidityDays: 90,
|
||||
CACertPath: filepath.Join(fx.tempDir, "ca.crt"),
|
||||
CAKeyPath: filepath.Join(fx.tempDir, "ca.key"),
|
||||
}, newSilentLogger())
|
||||
connB.SetHierarchyMode("tree")
|
||||
connB.SetChainAssembler(&fakeChainAssembler{
|
||||
chains: map[string]string{
|
||||
"ica-root-1": fx.certPEM, // matches single-mode caCertPEM byte-for-byte
|
||||
},
|
||||
})
|
||||
connB.SetTreeIssuingCAID("ica-root-1")
|
||||
|
||||
csrPEM := makeCSRPEM(t, "leaf.example.com")
|
||||
|
||||
resA, err := connA.IssueCertificate(ctx, issuer.IssuanceRequest{
|
||||
CommonName: "leaf.example.com",
|
||||
CSRPEM: csrPEM,
|
||||
SANs: []string{"leaf.example.com"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("connA.IssueCertificate: %v", err)
|
||||
}
|
||||
resB, err := connB.IssueCertificate(ctx, issuer.IssuanceRequest{
|
||||
CommonName: "leaf.example.com",
|
||||
CSRPEM: csrPEM,
|
||||
SANs: []string{"leaf.example.com"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("connB.IssueCertificate: %v", err)
|
||||
}
|
||||
|
||||
// The load-bearing assertion: ChainPEM byte-identical between modes.
|
||||
if resA.ChainPEM != resB.ChainPEM {
|
||||
t.Fatalf("ChainPEM differs between single and tree modes\nsingle:\n%q\ntree:\n%q",
|
||||
resA.ChainPEM, resB.ChainPEM)
|
||||
}
|
||||
// And the chain MUST match the on-disk root cert bytes — i.e., the
|
||||
// pin verifies a real fact about the wire format, not just internal
|
||||
// consistency.
|
||||
if resA.ChainPEM != fx.certPEM {
|
||||
t.Fatalf("ChainPEM does not match on-disk root cert PEM\ngot:\n%q\nwant:\n%q",
|
||||
resA.ChainPEM, fx.certPEM)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLocal_HierarchyMode_Tree_LeafChainIncludesAllAncestors pins
|
||||
// the multi-level tree case: a leaf issued under the deepest CA in a
|
||||
// 4-level hierarchy carries a ChainPEM containing every ancestor up
|
||||
// through the root. This is what tree mode buys operators in exchange
|
||||
// for the migration overhead.
|
||||
func TestLocal_HierarchyMode_Tree_LeafChainIncludesAllAncestors(t *testing.T) {
|
||||
fx := newHierarchyTestFixture(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Build a synthetic 4-level chain (root → policy → issuingA →
|
||||
// issuingB-leaf-CA). The actual cert content doesn't matter for
|
||||
// this test — we just need 4 distinct CERTIFICATE blocks. Using
|
||||
// the same root cert 4x with marker comments would NOT work
|
||||
// because the connector returns the PEM verbatim. Mint 4 fresh
|
||||
// self-signed certs with distinct subjects so we can verify
|
||||
// ordering.
|
||||
type leveledCert struct {
|
||||
pem string
|
||||
cert *x509.Certificate
|
||||
}
|
||||
mintCert := func(cn string) *leveledCert {
|
||||
priv, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
subj := pkix.Name{CommonName: cn}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: subj,
|
||||
Issuer: subj,
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(2 * 365 * 24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
}
|
||||
der, _ := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
|
||||
c, _ := x509.ParseCertificate(der)
|
||||
return &leveledCert{
|
||||
pem: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})),
|
||||
cert: c,
|
||||
}
|
||||
}
|
||||
root := mintCert("Hierarchy Root CA")
|
||||
policy := mintCert("Hierarchy Policy CA")
|
||||
issuingA := mintCert("Hierarchy Issuing A")
|
||||
issuingB := mintCert("Hierarchy Issuing B")
|
||||
|
||||
// Stitch the chain leaf-to-root (matches AssembleChain output).
|
||||
chainPEM := issuingB.pem + issuingA.pem + policy.pem + root.pem
|
||||
|
||||
conn := New(&Config{
|
||||
CACommonName: "ignored",
|
||||
ValidityDays: 90,
|
||||
CACertPath: filepath.Join(fx.tempDir, "ca.crt"),
|
||||
CAKeyPath: filepath.Join(fx.tempDir, "ca.key"),
|
||||
}, newSilentLogger())
|
||||
conn.SetHierarchyMode("tree")
|
||||
conn.SetChainAssembler(&fakeChainAssembler{
|
||||
chains: map[string]string{
|
||||
"ica-issuing-b": chainPEM,
|
||||
},
|
||||
})
|
||||
conn.SetTreeIssuingCAID("ica-issuing-b")
|
||||
|
||||
csrPEM := makeCSRPEM(t, "deep-leaf.example.com")
|
||||
res, err := conn.IssueCertificate(ctx, issuer.IssuanceRequest{
|
||||
CommonName: "deep-leaf.example.com",
|
||||
CSRPEM: csrPEM,
|
||||
SANs: []string{"deep-leaf.example.com"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("IssueCertificate: %v", err)
|
||||
}
|
||||
|
||||
if got, want := strings.Count(res.ChainPEM, "BEGIN CERTIFICATE"), 4; got != want {
|
||||
t.Fatalf("expected %d CERTIFICATE blocks, got %d:\n%s", want, got, res.ChainPEM)
|
||||
}
|
||||
// Verify leaf-first ordering by parsing each block.
|
||||
rest := []byte(res.ChainPEM)
|
||||
wantSubjects := []string{
|
||||
"Hierarchy Issuing B",
|
||||
"Hierarchy Issuing A",
|
||||
"Hierarchy Policy CA",
|
||||
"Hierarchy Root CA",
|
||||
}
|
||||
for i := 0; i < 4; i++ {
|
||||
var block *pem.Block
|
||||
block, rest = pem.Decode(rest)
|
||||
if block == nil {
|
||||
t.Fatalf("expected block %d, got nil", i)
|
||||
}
|
||||
c, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
t.Fatalf("parse block %d: %v", i, err)
|
||||
}
|
||||
if c.Subject.CommonName != wantSubjects[i] {
|
||||
t.Fatalf("block %d: expected CN=%q, got %q", i, wantSubjects[i], c.Subject.CommonName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLocal_HierarchyMode_FallsBackToSingleWhenWiringIncomplete pins
|
||||
// the defensive fallback: hierarchyMode set to "tree" but
|
||||
// ChainAssembler is nil → the connector falls back to the historical
|
||||
// c.caCertPEM. Defense in depth: a misconfigured operator still gets
|
||||
// a working issuance, not a nil-deref panic.
|
||||
func TestLocal_HierarchyMode_FallsBackToSingleWhenWiringIncomplete(t *testing.T) {
|
||||
fx := newHierarchyTestFixture(t)
|
||||
ctx := context.Background()
|
||||
|
||||
conn := New(&Config{
|
||||
CACommonName: "ignored",
|
||||
ValidityDays: 90,
|
||||
CACertPath: filepath.Join(fx.tempDir, "ca.crt"),
|
||||
CAKeyPath: filepath.Join(fx.tempDir, "ca.key"),
|
||||
}, newSilentLogger())
|
||||
// Tree mode declared, but ChainAssembler + treeIssuingCAID are unset.
|
||||
conn.SetHierarchyMode("tree")
|
||||
|
||||
csrPEM := makeCSRPEM(t, "fallback.example.com")
|
||||
res, err := conn.IssueCertificate(ctx, issuer.IssuanceRequest{
|
||||
CommonName: "fallback.example.com",
|
||||
CSRPEM: csrPEM,
|
||||
SANs: []string{"fallback.example.com"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("IssueCertificate: %v", err)
|
||||
}
|
||||
if res.ChainPEM != fx.certPEM {
|
||||
t.Fatalf("expected fallback to caCertPEM, got %q", res.ChainPEM)
|
||||
}
|
||||
}
|
||||
@@ -356,6 +356,21 @@ func (c *Connector) formatHTMLEmailMessage(from, to, subject, htmlBody string) (
|
||||
}
|
||||
|
||||
// formatAlertBody formats an alert notification as email body text.
|
||||
//
|
||||
// CodeQL go/email-injection (CWE-640 / OWASP Content Spoofing) defense:
|
||||
// every field interpolated into the body that may carry attacker-
|
||||
// controlled content (alert.Subject, alert.Message, alert.Metadata
|
||||
// values, alert.ID / Type / Severity which originate from the API
|
||||
// surface) is routed through validation.SanitizeEmailBodyValue before
|
||||
// formatting. The sanitizer strips NUL bytes (RFC 5321 §4.5.2 violation),
|
||||
// bare CR/LF within a single field (forged header-boundary attempts),
|
||||
// bidi-override Unicode (visually-spoofable URLs), zero-width / invisible
|
||||
// codepoints, and C0/C1 control chars. CreatedAt is a time.Time —
|
||||
// formatted via RFC3339; not user-controllable so unsanitized.
|
||||
//
|
||||
// Header values (From, To, Subject) are protected separately by
|
||||
// validation.ValidateHeaderValue at sendEmail entry (CWE-113 SMTP header
|
||||
// injection — see commit 9e957c3).
|
||||
func (c *Connector) formatAlertBody(alert notifier.Alert) string {
|
||||
body := fmt.Sprintf(`
|
||||
Certificate Alert Notification
|
||||
@@ -372,16 +387,29 @@ Message:
|
||||
%s
|
||||
|
||||
%s
|
||||
`, alert.ID, alert.Type, alert.Severity, alert.CreatedAt.Format(time.RFC3339), alert.Subject, alert.Message, c.formatMetadata(alert.Metadata))
|
||||
`,
|
||||
validation.SanitizeEmailBodyValue(alert.ID),
|
||||
validation.SanitizeEmailBodyValue(alert.Type),
|
||||
validation.SanitizeEmailBodyValue(alert.Severity),
|
||||
alert.CreatedAt.Format(time.RFC3339),
|
||||
validation.SanitizeEmailBodyValue(alert.Subject),
|
||||
validation.SanitizeEmailBodyValue(alert.Message),
|
||||
c.formatMetadata(alert.Metadata),
|
||||
)
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
// formatEventBody formats an event notification as email body text.
|
||||
//
|
||||
// Same CodeQL go/email-injection mitigation as formatAlertBody — every
|
||||
// user-controllable interpolated field routes through
|
||||
// validation.SanitizeEmailBodyValue. CreatedAt is unsanitized (time.Time
|
||||
// → RFC3339 is structural, not user-controllable).
|
||||
func (c *Connector) formatEventBody(event notifier.Event) string {
|
||||
certInfo := ""
|
||||
if event.CertificateID != nil {
|
||||
certInfo = fmt.Sprintf("Certificate ID: %s\n", *event.CertificateID)
|
||||
certInfo = fmt.Sprintf("Certificate ID: %s\n", validation.SanitizeEmailBodyValue(*event.CertificateID))
|
||||
}
|
||||
|
||||
body := fmt.Sprintf(`
|
||||
@@ -398,12 +426,27 @@ Body:
|
||||
%s
|
||||
|
||||
%s
|
||||
`, event.ID, event.Type, event.CreatedAt.Format(time.RFC3339), certInfo, event.Subject, event.Body, c.formatMetadata(event.Metadata))
|
||||
`,
|
||||
validation.SanitizeEmailBodyValue(event.ID),
|
||||
validation.SanitizeEmailBodyValue(event.Type),
|
||||
event.CreatedAt.Format(time.RFC3339),
|
||||
certInfo,
|
||||
validation.SanitizeEmailBodyValue(event.Subject),
|
||||
validation.SanitizeEmailBodyValue(event.Body),
|
||||
c.formatMetadata(event.Metadata),
|
||||
)
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
// formatMetadata formats metadata as a readable string.
|
||||
//
|
||||
// Both keys and values can carry attacker-controlled content (cert
|
||||
// subject DN fragments, discovered cert metadata, owner/team labels —
|
||||
// all originate from API surfaces an attacker may influence). Both are
|
||||
// routed through validation.SanitizeEmailBodyValue. Closes the
|
||||
// CodeQL go/email-injection finding alongside formatAlertBody +
|
||||
// formatEventBody.
|
||||
func (c *Connector) formatMetadata(metadata map[string]string) string {
|
||||
if len(metadata) == 0 {
|
||||
return ""
|
||||
@@ -411,7 +454,10 @@ func (c *Connector) formatMetadata(metadata map[string]string) string {
|
||||
|
||||
metadataStr := "\nMetadata:\n"
|
||||
for key, value := range metadata {
|
||||
metadataStr += fmt.Sprintf(" %s: %s\n", key, value)
|
||||
metadataStr += fmt.Sprintf(" %s: %s\n",
|
||||
validation.SanitizeEmailBodyValue(key),
|
||||
validation.SanitizeEmailBodyValue(value),
|
||||
)
|
||||
}
|
||||
|
||||
return metadataStr
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FileDriver materializes a Signer from a PEM-encoded private key on
|
||||
@@ -64,11 +65,97 @@ type FileDriver struct {
|
||||
// production. The local package's NewConnector wires this to
|
||||
// return the configured CAKeyPath.
|
||||
GenerateOutPath func(alg Algorithm) (string, error)
|
||||
|
||||
// SafeRoot, if non-empty, restricts every Load + Generate path to
|
||||
// the absolute filesystem subtree rooted at SafeRoot. Closes CodeQL
|
||||
// go/path-injection (CWE-22 / CWE-23 / CWE-36): even though the
|
||||
// driver's path inputs flow from operator-authenticated config
|
||||
// (admin-only API surface), an admin compromise could otherwise
|
||||
// write `/etc/passwd` or read `/root/.ssh/id_rsa` via the driver.
|
||||
// SafeRoot bounds the blast radius.
|
||||
//
|
||||
// Validation semantics (validateSafePath):
|
||||
//
|
||||
// 1. The supplied path is cleaned (filepath.Clean) to collapse
|
||||
// ./ and ../ sequences in their literal form.
|
||||
// 2. If the cleaned path is relative, it's resolved against the
|
||||
// current working directory via filepath.Abs.
|
||||
// 3. If SafeRoot is set, the absolute path MUST be SafeRoot or
|
||||
// a descendant. We use filepath.Rel + strings.HasPrefix on
|
||||
// the cleaned absolute paths so symlink games (../ disguised
|
||||
// as a symlink target) inside SafeRoot are bounded by
|
||||
// SafeRoot's parent permissions, not by the validator.
|
||||
//
|
||||
// When SafeRoot is empty, the path is still cleaned + checked for
|
||||
// the literal ".." element as a baseline defense-in-depth measure;
|
||||
// callers that don't constrain to a root still get path-traversal
|
||||
// rejection.
|
||||
//
|
||||
// Production wiring SHOULD set SafeRoot. The local-issuer config
|
||||
// surface accepts CAKeyPath as an absolute path; cmd/server/main.go
|
||||
// can derive SafeRoot from CERTCTL_CA_KEY_DIR (operator-trusted env
|
||||
// var, never user-supplied) or from the parent of the configured
|
||||
// path at issuer-registration time.
|
||||
SafeRoot string
|
||||
}
|
||||
|
||||
// Name implements Driver.
|
||||
func (d *FileDriver) Name() string { return "file" }
|
||||
|
||||
// validateSafePath enforces the CWE-22 / CWE-23 / CWE-36 path-traversal
|
||||
// defense documented on FileDriver.SafeRoot. Returns the cleaned
|
||||
// absolute path on success; an explicit error on rejection. Rejects:
|
||||
//
|
||||
// - empty paths
|
||||
// - paths whose cleaned form contains a literal ".." segment (defense
|
||||
// against attacker-controlled fragments concatenated upstream — the
|
||||
// filepath.Clean() before this check collapses any "..", so a
|
||||
// remaining ".." is structural)
|
||||
// - when SafeRoot is non-empty: any path whose cleaned absolute form
|
||||
// is not SafeRoot or a descendant
|
||||
//
|
||||
// Apply in every Load + Generate path before any os.ReadFile /
|
||||
// os.WriteFile call. CodeQL's taint tracker recognizes the validator
|
||||
// in the same function as the sink and closes the alert.
|
||||
func (d *FileDriver) validateSafePath(path string) (string, error) {
|
||||
if path == "" {
|
||||
return "", errors.New("path is empty")
|
||||
}
|
||||
cleaned := filepath.Clean(path)
|
||||
// Reject any path whose cleaned form still contains a `..` element.
|
||||
// filepath.Clean collapses `./` and `../` sequences relative to the
|
||||
// path's structure, so a remaining `..` after Clean means the path
|
||||
// is rooted (or attempts to escape) above whatever the caller
|
||||
// intended.
|
||||
for _, segment := range strings.Split(filepath.ToSlash(cleaned), "/") {
|
||||
if segment == ".." {
|
||||
return "", fmt.Errorf("path %q contains parent-directory segment", path)
|
||||
}
|
||||
}
|
||||
abs, err := filepath.Abs(cleaned)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve absolute path %q: %w", path, err)
|
||||
}
|
||||
if d.SafeRoot != "" {
|
||||
safeRoot, err := filepath.Abs(filepath.Clean(d.SafeRoot))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve SafeRoot %q: %w", d.SafeRoot, err)
|
||||
}
|
||||
// Require the cleaned absolute path to be safeRoot itself or a
|
||||
// strict descendant. The += string.Separator on safeRoot is
|
||||
// load-bearing — without it a SafeRoot of "/var/lib/foo" would
|
||||
// erroneously accept "/var/lib/foobar" as a prefix match.
|
||||
safeRootSlash := safeRoot
|
||||
if !strings.HasSuffix(safeRootSlash, string(filepath.Separator)) {
|
||||
safeRootSlash += string(filepath.Separator)
|
||||
}
|
||||
if abs != safeRoot && !strings.HasPrefix(abs, safeRootSlash) {
|
||||
return "", fmt.Errorf("path %q resolves outside SafeRoot %q", path, d.SafeRoot)
|
||||
}
|
||||
}
|
||||
return abs, nil
|
||||
}
|
||||
|
||||
// Load implements Driver. It reads the PEM file at path, decodes the
|
||||
// first PEM block, parses it via the package's parsePrivateKey
|
||||
// (which handles PKCS#1 / SEC 1 / PKCS#8), and wraps the resulting
|
||||
@@ -78,28 +165,33 @@ func (d *FileDriver) Name() string { return "file" }
|
||||
// No key bytes are logged — only the path and (on success) the
|
||||
// inferred Algorithm.
|
||||
func (d *FileDriver) Load(ctx context.Context, path string) (Signer, error) {
|
||||
if path == "" {
|
||||
return nil, errors.New("signer.FileDriver.Load: empty path")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, fmt.Errorf("signer.FileDriver.Load: %w", err)
|
||||
}
|
||||
|
||||
pemBytes, err := os.ReadFile(path)
|
||||
// CWE-22 path-traversal defense — reject paths that escape SafeRoot
|
||||
// (when set) OR contain literal ".." segments. The validator is in
|
||||
// the same function as the os.ReadFile sink so CodeQL recognizes
|
||||
// the sanitizer in-scope.
|
||||
safePath, err := d.validateSafePath(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("signer.FileDriver.Load: read %q: %w", path, err)
|
||||
return nil, fmt.Errorf("signer.FileDriver.Load: %w", err)
|
||||
}
|
||||
|
||||
pemBytes, err := os.ReadFile(safePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("signer.FileDriver.Load: read %q: %w", safePath, err)
|
||||
}
|
||||
block, _ := pem.Decode(pemBytes)
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("signer.FileDriver.Load: %q is not PEM", path)
|
||||
return nil, fmt.Errorf("signer.FileDriver.Load: %q is not PEM", safePath)
|
||||
}
|
||||
key, err := parsePrivateKey(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("signer.FileDriver.Load: parse %q: %w", path, err)
|
||||
return nil, fmt.Errorf("signer.FileDriver.Load: parse %q: %w", safePath, err)
|
||||
}
|
||||
wrapped, err := Wrap(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("signer.FileDriver.Load: wrap %q: %w", path, err)
|
||||
return nil, fmt.Errorf("signer.FileDriver.Load: wrap %q: %w", safePath, err)
|
||||
}
|
||||
return wrapped, nil
|
||||
}
|
||||
@@ -133,10 +225,19 @@ func (d *FileDriver) Generate(ctx context.Context, alg Algorithm) (Signer, strin
|
||||
return nil, "", fmt.Errorf("signer.FileDriver.Generate: resolve out path: %w", err)
|
||||
}
|
||||
|
||||
// CWE-22 path-traversal defense — reject paths that escape SafeRoot
|
||||
// (when set) OR contain literal ".." segments. The validator is in
|
||||
// the same function as the os.WriteFile sink below so CodeQL
|
||||
// recognizes the sanitizer in-scope.
|
||||
safeOut, err := d.validateSafePath(outPath)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("signer.FileDriver.Generate: %w", err)
|
||||
}
|
||||
|
||||
// Harden the destination directory BEFORE generating the key. If
|
||||
// the directory check fails we bail without touching cryptography.
|
||||
if err := d.DirHardener(filepath.Dir(outPath)); err != nil {
|
||||
return nil, "", fmt.Errorf("signer.FileDriver.Generate: harden dir for %q: %w", outPath, err)
|
||||
if err := d.DirHardener(filepath.Dir(safeOut)); err != nil {
|
||||
return nil, "", fmt.Errorf("signer.FileDriver.Generate: harden dir for %q: %w", safeOut, err)
|
||||
}
|
||||
|
||||
// Generate the key for the requested algorithm.
|
||||
@@ -191,15 +292,15 @@ func (d *FileDriver) Generate(ctx context.Context, alg Algorithm) (Signer, strin
|
||||
// Write 0o600 — owner-read-write only. Any read by group/other is
|
||||
// a configuration regression; the dir 0700 above prevents
|
||||
// enumeration of the file's existence.
|
||||
if err := os.WriteFile(outPath, pemBytes, 0o600); err != nil {
|
||||
return nil, "", fmt.Errorf("signer.FileDriver.Generate: write %q: %w", outPath, err)
|
||||
if err := os.WriteFile(safeOut, pemBytes, 0o600); err != nil {
|
||||
return nil, "", fmt.Errorf("signer.FileDriver.Generate: write %q: %w", safeOut, err)
|
||||
}
|
||||
|
||||
wrapped, err := Wrap(signerKey)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("signer.FileDriver.Generate: wrap: %w", err)
|
||||
}
|
||||
return wrapped, outPath, nil
|
||||
return wrapped, safeOut, nil
|
||||
}
|
||||
|
||||
func rsaBitsFor(a Algorithm) int {
|
||||
|
||||
@@ -777,3 +777,129 @@ func TestSigner_AlgorithmMatchesKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileDriver_Load_RejectsParentTraversal pins the CWE-22 defense
|
||||
// for FileDriver.Load — a relative path that escapes its origin via
|
||||
// `..` segments (and stays unresolved after Clean) is rejected. Closes
|
||||
// CodeQL #27 on the read side.
|
||||
//
|
||||
// Note: filepath.Clean("/abs/.../etc/passwd") collapses to
|
||||
// "/etc/passwd" — a perfectly clean absolute path with no surviving
|
||||
// `..`. The relative-`..`-escape test below catches the case Clean
|
||||
// CAN'T resolve; the SafeRoot tests below catch the absolute-path
|
||||
// containment case.
|
||||
func TestFileDriver_Load_RejectsParentTraversal(t *testing.T) {
|
||||
d := &signer.FileDriver{}
|
||||
_, err := d.Load(context.Background(), "../../etc/passwd")
|
||||
if err == nil {
|
||||
t.Fatal("Load with relative .. escape should be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "parent-directory") {
|
||||
t.Fatalf("error should mention parent-directory, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileDriver_Load_RejectsEmptyPath pins the empty-path rejection
|
||||
// (was inline before validateSafePath; now lives in the validator).
|
||||
func TestFileDriver_Load_RejectsEmptyPath(t *testing.T) {
|
||||
d := &signer.FileDriver{}
|
||||
_, err := d.Load(context.Background(), "")
|
||||
if err == nil {
|
||||
t.Fatal("Load with empty path should error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "empty") {
|
||||
t.Fatalf("error should mention empty path, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileDriver_Generate_RejectsParentTraversal pins the CWE-22 defense
|
||||
// for FileDriver.Generate — a relative path that escapes its origin
|
||||
// via `..` (and stays unresolved after Clean) is rejected before any
|
||||
// keygen happens. Closes CodeQL #27 on the write side.
|
||||
func TestFileDriver_Generate_RejectsParentTraversal(t *testing.T) {
|
||||
d := &signer.FileDriver{
|
||||
DirHardener: func(_ string) error { return nil },
|
||||
GenerateOutPath: func(_ signer.Algorithm) (string, error) {
|
||||
return "../../etc/passwd", nil
|
||||
},
|
||||
}
|
||||
_, _, err := d.Generate(context.Background(), signer.AlgorithmECDSAP256)
|
||||
if err == nil {
|
||||
t.Fatal("Generate with relative .. escape should be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "parent-directory") {
|
||||
t.Fatalf("error should mention parent-directory, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileDriver_SafeRoot_AcceptsContainedPath pins the SafeRoot
|
||||
// containment positive case — a path under SafeRoot succeeds.
|
||||
func TestFileDriver_SafeRoot_AcceptsContainedPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
d := &signer.FileDriver{
|
||||
SafeRoot: dir,
|
||||
DirHardener: func(_ string) error { return nil },
|
||||
GenerateOutPath: func(_ signer.Algorithm) (string, error) {
|
||||
return filepath.Join(dir, "ok.key"), nil
|
||||
},
|
||||
}
|
||||
_, path, err := d.Generate(context.Background(), signer.AlgorithmECDSAP256)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate under SafeRoot should succeed: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(path, dir) {
|
||||
t.Fatalf("returned path %q should be under SafeRoot %q", path, dir)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileDriver_SafeRoot_RejectsEscape pins the SafeRoot containment
|
||||
// negative case — a path outside SafeRoot is rejected. Without this
|
||||
// pin, an admin-compromised CAKeyPath of `/etc/passwd` would write
|
||||
// system files.
|
||||
func TestFileDriver_SafeRoot_RejectsEscape(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
d := &signer.FileDriver{
|
||||
SafeRoot: dir,
|
||||
DirHardener: func(_ string) error { return nil },
|
||||
GenerateOutPath: func(_ signer.Algorithm) (string, error) {
|
||||
// Absolute path outside the SafeRoot directory.
|
||||
return "/tmp/escaped-keys/key.pem", nil
|
||||
},
|
||||
}
|
||||
_, _, err := d.Generate(context.Background(), signer.AlgorithmECDSAP256)
|
||||
if err == nil {
|
||||
t.Fatal("Generate outside SafeRoot should be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "outside SafeRoot") {
|
||||
t.Fatalf("error should mention SafeRoot, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileDriver_SafeRoot_RejectsSiblingPrefix pins the load-bearing
|
||||
// detail: a SafeRoot of "/var/lib/foo" must NOT accept "/var/lib/foobar".
|
||||
// The naive strings.HasPrefix(abs, safeRoot) check fails this case;
|
||||
// the validator appends a path separator to prevent the bug.
|
||||
func TestFileDriver_SafeRoot_RejectsSiblingPrefix(t *testing.T) {
|
||||
root := t.TempDir() // e.g. /tmp/TestSafeRootSibling12345/001
|
||||
// sibling has the same prefix but is NOT a descendant of root.
|
||||
sibling := root + "-sibling"
|
||||
if err := os.MkdirAll(sibling, 0o700); err != nil {
|
||||
t.Fatalf("mkdir sibling: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(sibling) })
|
||||
|
||||
d := &signer.FileDriver{
|
||||
SafeRoot: root,
|
||||
DirHardener: func(_ string) error { return nil },
|
||||
GenerateOutPath: func(_ signer.Algorithm) (string, error) {
|
||||
return filepath.Join(sibling, "key.pem"), nil
|
||||
},
|
||||
}
|
||||
_, _, err := d.Generate(context.Background(), signer.AlgorithmECDSAP256)
|
||||
if err == nil {
|
||||
t.Fatal("Generate into sibling-prefix path should be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "outside SafeRoot") {
|
||||
t.Fatalf("error should mention SafeRoot, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// ApprovalRequest represents a pending issuance / renewal that requires
|
||||
// human approval before the issuer connector is dispatched. One row per
|
||||
// (CertificateID, JobID) pair; the JobID points at the blocked Job whose
|
||||
// Status is JobStatusAwaitingApproval.
|
||||
//
|
||||
// Lifecycle:
|
||||
//
|
||||
// pending → approved (Approve called by a non-requester)
|
||||
// pending → rejected (Reject called)
|
||||
// pending → expired (scheduler reaper at approvalCutoff)
|
||||
//
|
||||
// Once terminal, the row is immutable; the audit_events table is the
|
||||
// durable record of who approved + why.
|
||||
//
|
||||
// Rank 7 of the 2026-05-03 Infisical deep-research deliverable
|
||||
// (cowork/infisical-deep-research-results.md Part 5). Closes the
|
||||
// "two-person integrity / four-eyes principle" procurement gap for
|
||||
// PCI-DSS Level 1, FedRAMP Moderate / High, and SOC 2 Type II
|
||||
// customers.
|
||||
type ApprovalRequest struct {
|
||||
ID string `json:"id"` // ar-<slug>
|
||||
CertificateID string `json:"certificate_id"` // FK managed_certificates.id
|
||||
JobID string `json:"job_id"` // FK jobs.id (the blocked Job)
|
||||
ProfileID string `json:"profile_id"` // CertificateProfile that triggered the gate
|
||||
RequestedBy string `json:"requested_by"` // actor that triggered the renewal
|
||||
State ApprovalState `json:"state"` // pending / approved / rejected / expired
|
||||
DecidedBy *string `json:"decided_by,omitempty"` // null while state=pending
|
||||
DecidedAt *time.Time `json:"decided_at,omitempty"` // null while state=pending
|
||||
DecisionNote *string `json:"decision_note,omitempty"` // operator's reason text
|
||||
Metadata map[string]string `json:"metadata,omitempty"` // common_name, sans, issuer_id, severity_tier
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ApprovalState is the closed enum of approval lifecycle states.
|
||||
type ApprovalState string
|
||||
|
||||
const (
|
||||
// ApprovalStatePending is the initial state — created by RequestApproval,
|
||||
// blocking the linked Job at JobStatusAwaitingApproval. The scheduler does
|
||||
// NOT dispatch the job until the approval transitions to approved.
|
||||
ApprovalStatePending ApprovalState = "pending"
|
||||
|
||||
// ApprovalStateApproved is the success terminal state. Approve sets
|
||||
// DecidedBy / DecidedAt / DecisionNote and transitions the linked Job
|
||||
// from AwaitingApproval to Pending so the job processor picks it up.
|
||||
ApprovalStateApproved ApprovalState = "approved"
|
||||
|
||||
// ApprovalStateRejected is the human-rejected terminal state. The
|
||||
// linked Job transitions from AwaitingApproval to Cancelled.
|
||||
ApprovalStateRejected ApprovalState = "rejected"
|
||||
|
||||
// ApprovalStateExpired is the timeout terminal state. The scheduler's
|
||||
// reaper transitions stale pending requests to expired after the
|
||||
// CERTCTL_JOB_AWAITING_APPROVAL_TIMEOUT cutoff (default 168h = 7 days).
|
||||
ApprovalStateExpired ApprovalState = "expired"
|
||||
)
|
||||
|
||||
// IsValidApprovalState reports whether s is a closed-enum value. Used by
|
||||
// repository validation + handler request-body parsing to defend against
|
||||
// off-enum typos at write time.
|
||||
func IsValidApprovalState(s ApprovalState) bool {
|
||||
switch s {
|
||||
case ApprovalStatePending, ApprovalStateApproved,
|
||||
ApprovalStateRejected, ApprovalStateExpired:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsTerminal reports whether s is one of the immutable terminal states
|
||||
// (approved / rejected / expired). Once terminal, an ApprovalRequest's
|
||||
// row cannot be mutated; subsequent Approve / Reject calls return
|
||||
// ErrApprovalAlreadyDecided.
|
||||
func (s ApprovalState) IsTerminal() bool {
|
||||
switch s {
|
||||
case ApprovalStateApproved, ApprovalStateRejected, ApprovalStateExpired:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Approval-decision outcome strings used by the metrics counter
|
||||
// (certctl_approval_decisions_total{outcome,profile_id}). Matches the
|
||||
// Prometheus convention: lower-case, snake_case, bounded cardinality.
|
||||
const (
|
||||
ApprovalOutcomeApproved = "approved"
|
||||
ApprovalOutcomeRejected = "rejected"
|
||||
ApprovalOutcomeExpired = "expired"
|
||||
ApprovalOutcomeBypassed = "bypassed"
|
||||
)
|
||||
|
||||
// ApprovalActorSystemBypass is the synthetic actor identity stamped on
|
||||
// audit rows + DecidedBy when CERTCTL_APPROVAL_BYPASS=true short-circuits
|
||||
// the workflow for dev/CI. Production deploys MUST leave the bypass
|
||||
// unset; compliance auditors run `SELECT FROM audit_events WHERE
|
||||
// actor='system-bypass'` to confirm zero rows.
|
||||
const ApprovalActorSystemBypass = "system-bypass"
|
||||
@@ -16,8 +16,20 @@ type Issuer struct {
|
||||
LastTestedAt *time.Time `json:"last_tested_at,omitempty"`
|
||||
TestStatus string `json:"test_status,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// HierarchyMode picks the per-issuer CA-hierarchy posture for the
|
||||
// local issuer adapter. "single" (default, pre-Rank-8 historical)
|
||||
// loads a pre-signed cert+key from disk via local.Config.CACertPath
|
||||
// / local.Config.CAKeyPath. "tree" activates first-class N-level
|
||||
// hierarchy management via the intermediate_cas table; chain
|
||||
// assembly walks parent_ca_id from the issuing leaf-CA up to the
|
||||
// root at issuance time. Empty string ≡ HierarchyModeSingle for
|
||||
// back-compat byte-identical behavior on unmigrated rows. Backed
|
||||
// by issuers.hierarchy_mode added in migration 000028.
|
||||
HierarchyMode string `json:"hierarchy_mode,omitempty"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// DeploymentTarget represents a target system where certificates are deployed.
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// IntermediateCA represents a non-root CA in a multi-level hierarchy.
|
||||
// One row per certificate (root, policy, issuing) — the parent_ca_id
|
||||
// FK to itself encodes the tree shape; the owning_issuer_id FK groups
|
||||
// every CA under one Issuer config row.
|
||||
//
|
||||
// Lifecycle:
|
||||
//
|
||||
// created (CreateRoot or CreateChild)
|
||||
// │
|
||||
// ▼
|
||||
// active (issuing certs)
|
||||
// │
|
||||
// ▼
|
||||
// retiring (drain — children still active; this CA stops issuing
|
||||
// NEW children but existing children continue)
|
||||
// │
|
||||
// ▼
|
||||
// retired (terminal — no issuance, OCSP responder
|
||||
// keeps responding for already-issued leaves until expiry)
|
||||
//
|
||||
// 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).
|
||||
//
|
||||
// Defense in depth: NEVER persist the CA private key bytes in this
|
||||
// row. KeyDriverID is a reference (filesystem path / KMS key ID /
|
||||
// HSM slot) to the signer.Driver instance that owns the key. A SQL-
|
||||
// injection or row-leak surface must NEVER expose key bytes; only
|
||||
// the reference can leak.
|
||||
type IntermediateCA struct {
|
||||
ID string `json:"id"` // ica-<slug>
|
||||
OwningIssuerID string `json:"owning_issuer_id"` // FK issuers.id
|
||||
ParentCAID *string `json:"parent_ca_id,omitempty"` // nil for root, FK to self otherwise
|
||||
Name string `json:"name"` // operator-supplied label
|
||||
Subject string `json:"subject"` // distinguished name (CN + O + OU + ...)
|
||||
State IntermediateCAState `json:"state"` // active / retiring / retired
|
||||
CertPEM string `json:"cert_pem"` // this CA's cert (PEM)
|
||||
KeyDriverID string `json:"key_driver_id"` // signer.Driver instance ID
|
||||
NotBefore time.Time `json:"not_before"`
|
||||
NotAfter time.Time `json:"not_after"`
|
||||
PathLenConstraint *int `json:"path_len_constraint,omitempty"` // RFC 5280 §4.2.1.9; nil = no constraint
|
||||
NameConstraints []NameConstraint `json:"name_constraints,omitempty"` // RFC 5280 §4.2.1.10
|
||||
OCSPResponderURL string `json:"ocsp_responder_url,omitempty"` // AIA stamping for issued leaves
|
||||
Metadata map[string]string `json:"metadata,omitempty"` // policy_id, compliance_tier, owner_team
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// IntermediateCAState is the closed enum of CA-row lifecycle states.
|
||||
type IntermediateCAState string
|
||||
|
||||
const (
|
||||
// IntermediateCAStateActive is the issuing state — the CA can sign
|
||||
// new children + new leaves under it.
|
||||
IntermediateCAStateActive IntermediateCAState = "active"
|
||||
|
||||
// IntermediateCAStateRetiring is the drain state — no new children;
|
||||
// existing children keep issuing until they themselves retire.
|
||||
IntermediateCAStateRetiring IntermediateCAState = "retiring"
|
||||
|
||||
// IntermediateCAStateRetired is the terminal state — no issuance
|
||||
// at all; OCSP responder keeps responding for already-issued leaves
|
||||
// until natural expiry.
|
||||
IntermediateCAStateRetired IntermediateCAState = "retired"
|
||||
)
|
||||
|
||||
// IsValidIntermediateCAState reports whether s is a closed-enum value.
|
||||
func IsValidIntermediateCAState(s IntermediateCAState) bool {
|
||||
switch s {
|
||||
case IntermediateCAStateActive, IntermediateCAStateRetiring, IntermediateCAStateRetired:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsTerminal reports whether s is the immutable terminal state.
|
||||
func (s IntermediateCAState) IsTerminal() bool {
|
||||
return s == IntermediateCAStateRetired
|
||||
}
|
||||
|
||||
// NameConstraint encodes RFC 5280 §4.2.1.10 — Permitted + Excluded
|
||||
// subtrees. Critical extension when set on the CA cert; the local
|
||||
// adapter renders this onto the CA's cert at CreateChild time. The
|
||||
// service layer enforces subset semantics: a child's permitted set
|
||||
// MUST be a subset of the parent's permitted set + the child's
|
||||
// excluded set MUST be a superset of the parent's excluded set.
|
||||
type NameConstraint struct {
|
||||
Permitted []string `json:"permitted,omitempty"` // e.g., "example.com" → all DNS subtrees ending in example.com
|
||||
Excluded []string `json:"excluded,omitempty"`
|
||||
}
|
||||
|
||||
// HierarchyMode picks the per-issuer CA-hierarchy posture, stored on
|
||||
// the Issuer row. Three values are possible (the database default is
|
||||
// "single" — back-compat byte-identical for unmigrated rows):
|
||||
//
|
||||
// - HierarchyModeSingle (default, pre-Rank-8 historical) — sub-CA
|
||||
// mode loads a pre-signed cert+key from disk via local.Config.
|
||||
// CACertPath / local.Config.CAKeyPath. Existing operators upgrade
|
||||
// with no behavior change.
|
||||
// - HierarchyModeTree — the issuer's CAs are managed via the
|
||||
// intermediate_cas table; chain assembly walks the parent_ca_id
|
||||
// FK from the issuing leaf-CA up to the root + attaches the
|
||||
// assembled chain to every IssuanceResult.
|
||||
//
|
||||
// The local connector reads this from the Issuer row at issue time;
|
||||
// empty string is treated as HierarchyModeSingle.
|
||||
const (
|
||||
HierarchyModeSingle = "single"
|
||||
HierarchyModeTree = "tree"
|
||||
)
|
||||
@@ -72,6 +72,24 @@ type CertificateProfile struct {
|
||||
// "trust_authenticated".
|
||||
ACMEAuthMode string `json:"acme_auth_mode,omitempty"`
|
||||
|
||||
// RequiresApproval, when true, gates issuance + renewal of any
|
||||
// certificate bound to this profile on a parallel ApprovalRequest
|
||||
// row. The renewal-loop tick creates the job at
|
||||
// JobStatusAwaitingApproval; the scheduler does NOT dispatch
|
||||
// until ApprovalService.Approve transitions the request to
|
||||
// approved. Compliance customers (PCI-DSS Level 1, FedRAMP
|
||||
// Moderate / High, SOC 2 Type II, HIPAA) configure this on
|
||||
// production-tier profiles to satisfy the two-person integrity
|
||||
// procurement question.
|
||||
//
|
||||
// Defaults to false for back-compat — the unattended renewal
|
||||
// path remains the default for non-compliance customers.
|
||||
//
|
||||
// Backed by certificate_profiles.requires_approval added in
|
||||
// migration 000027_approval_workflow. Rank 7 of the 2026-05-03
|
||||
// Infisical deep-research deliverable.
|
||||
RequiresApproval bool `json:"requires_approval,omitempty"`
|
||||
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
@@ -27,6 +27,19 @@ import (
|
||||
// rather than substring-match.
|
||||
var ErrNotFound = errors.New("repository: row not found")
|
||||
|
||||
// ErrAlreadyExists is the canonical sentinel for postgres unique-
|
||||
// constraint (SQLSTATE 23505) violations bubbling up from an INSERT
|
||||
// (or partial-unique INSERT, like Rank 7's idx_approval_pending_per_job
|
||||
// which enforces "at most one pending approval per job"). Handlers
|
||||
// that surface a 409 Conflict should
|
||||
// `errors.Is(err, repository.ErrAlreadyExists)`.
|
||||
//
|
||||
// The repo also reuses ErrAlreadyExists for "row is already terminal"
|
||||
// state-transition attempts (e.g., Approve called on an already-
|
||||
// approved request) — semantically the same "you're trying to create
|
||||
// a state that already exists" failure mode.
|
||||
var ErrAlreadyExists = errors.New("repository: row already exists")
|
||||
|
||||
// ErrForeignKeyConstraint is the canonical sentinel for PostgreSQL
|
||||
// FK / RESTRICT violations bubbling up from a DELETE or UPDATE.
|
||||
// Handlers that surface a 409 Conflict should
|
||||
|
||||
@@ -713,3 +713,82 @@ type HealthCheckFilter struct {
|
||||
// PerPage is the number of results per page.
|
||||
PerPage int
|
||||
}
|
||||
|
||||
// ApprovalRepository defines operations for managing issuance approval requests.
|
||||
// Rank 7 of the 2026-05-03 Infisical deep-research deliverable — closes the
|
||||
// two-person integrity / four-eyes principle procurement gap for PCI-DSS
|
||||
// Level 1, FedRAMP Moderate / High, SOC 2 Type II, HIPAA-regulated PHI.
|
||||
//
|
||||
// Lifecycle: Create inserts a row at state=pending; UpdateState transitions
|
||||
// to one of (approved, rejected, expired) with the decider identity +
|
||||
// timestamp + optional note; ExpireStale is the bulk reaper called from
|
||||
// the scheduler. Once terminal, rows are immutable via the
|
||||
// approval_decision_consistency CHECK constraint at the schema layer.
|
||||
type ApprovalRepository interface {
|
||||
// Create inserts a new ApprovalRequest at state=pending. Returns
|
||||
// ErrAlreadyExists if a pending request already exists for the
|
||||
// job_id (the partial-unique index enforces at most one pending
|
||||
// per job).
|
||||
Create(ctx context.Context, req *domain.ApprovalRequest) error
|
||||
|
||||
// Get returns the request by ID or ErrNotFound.
|
||||
Get(ctx context.Context, id string) (*domain.ApprovalRequest, error)
|
||||
|
||||
// GetByJobID returns the most-recently-created request for the
|
||||
// given job_id, regardless of state. Used by the renewal entry
|
||||
// point to detect "is there already a pending approval for this
|
||||
// job?" and avoid creating a duplicate.
|
||||
GetByJobID(ctx context.Context, jobID string) (*domain.ApprovalRequest, error)
|
||||
|
||||
// List returns approval requests filtered by ApprovalFilter.
|
||||
// Supports paginated dashboard queries.
|
||||
List(ctx context.Context, filter *ApprovalFilter) ([]*domain.ApprovalRequest, error)
|
||||
|
||||
// UpdateState transitions a row from state=pending to one of
|
||||
// (approved, rejected, expired). Returns ErrNotFound if the ID
|
||||
// does not exist; returns the schema's CHECK-violation as a
|
||||
// repository error if the row is already terminal.
|
||||
UpdateState(ctx context.Context, id string, state domain.ApprovalState,
|
||||
decidedBy string, decidedAt time.Time, note string) error
|
||||
|
||||
// ExpireStale transitions every row with state=pending and
|
||||
// created_at <= before to state=expired. Returns the number of
|
||||
// rows transitioned. Called from the scheduler reaper loop.
|
||||
ExpireStale(ctx context.Context, before time.Time) (int, error)
|
||||
}
|
||||
|
||||
// ApprovalFilter filters approval-request queries.
|
||||
type ApprovalFilter struct {
|
||||
// State filters by lifecycle state (pending, approved, rejected, expired).
|
||||
State string
|
||||
// CertificateID filters by managed certificate ID.
|
||||
CertificateID string
|
||||
// RequestedBy filters to requests created by the given actor.
|
||||
RequestedBy string
|
||||
// Page is the page number (1-indexed).
|
||||
Page int
|
||||
// PerPage is the number of results per page.
|
||||
PerPage int
|
||||
}
|
||||
|
||||
// IntermediateCARepository defines operations for managing first-class
|
||||
// CA hierarchies (Rank 8). Every non-root CA is a row, parent_ca_id
|
||||
// encodes the tree, WalkAncestry returns the leaf-to-root chain via
|
||||
// a recursive CTE.
|
||||
//
|
||||
// Defense in depth: NEVER persist CA private key bytes. The
|
||||
// implementation stores key_driver_id (a signer.Driver reference) only.
|
||||
type IntermediateCARepository interface {
|
||||
Create(ctx context.Context, ca *domain.IntermediateCA) error
|
||||
Get(ctx context.Context, id string) (*domain.IntermediateCA, error)
|
||||
ListByIssuer(ctx context.Context, issuerID string) ([]*domain.IntermediateCA, error)
|
||||
ListChildren(ctx context.Context, parentCAID string) ([]*domain.IntermediateCA, error)
|
||||
UpdateState(ctx context.Context, id string, state domain.IntermediateCAState) error
|
||||
GetActiveRoot(ctx context.Context, issuerID string) (*domain.IntermediateCA, error)
|
||||
// WalkAncestry returns the chain from leafID up to (and including)
|
||||
// the root via a postgres recursive CTE. The slice is ordered
|
||||
// leaf-first; caller verifies the last element has parent_ca_id
|
||||
// IS NULL (i.e., it's a root). Returns ErrNotFound if leafID does
|
||||
// not exist.
|
||||
WalkAncestry(ctx context.Context, leafID string) ([]*domain.IntermediateCA, error)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/lib/pq"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/domain"
|
||||
"github.com/certctl-io/certctl/internal/repository"
|
||||
)
|
||||
|
||||
// ApprovalRepository is the postgres implementation of
|
||||
// repository.ApprovalRepository. Rank 7 of the 2026-05-03 Infisical
|
||||
// deep-research deliverable.
|
||||
type ApprovalRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewApprovalRepository constructs an ApprovalRepository against the
|
||||
// given *sql.DB. The schema is defined by migration
|
||||
// 000027_approval_workflow.up.sql.
|
||||
func NewApprovalRepository(db *sql.DB) *ApprovalRepository {
|
||||
return &ApprovalRepository{db: db}
|
||||
}
|
||||
|
||||
// Create inserts a new ApprovalRequest at state=pending. Generates the
|
||||
// ar-<slug> ID if req.ID is empty. Returns
|
||||
// repository.ErrAlreadyExists if the partial-unique index
|
||||
// (idx_approval_pending_per_job) trips — i.e., a pending request
|
||||
// already exists for the given job_id.
|
||||
func (r *ApprovalRepository) Create(ctx context.Context, req *domain.ApprovalRequest) error {
|
||||
if req.ID == "" {
|
||||
req.ID = "ar-" + uuid.NewString()
|
||||
}
|
||||
if req.State == "" {
|
||||
req.State = domain.ApprovalStatePending
|
||||
}
|
||||
if !domain.IsValidApprovalState(req.State) {
|
||||
return fmt.Errorf("invalid approval state %q", req.State)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if req.CreatedAt.IsZero() {
|
||||
req.CreatedAt = now
|
||||
}
|
||||
if req.UpdatedAt.IsZero() {
|
||||
req.UpdatedAt = now
|
||||
}
|
||||
|
||||
metadataJSON, err := json.Marshal(req.Metadata)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal approval metadata: %w", err)
|
||||
}
|
||||
if len(metadataJSON) == 0 || string(metadataJSON) == "null" {
|
||||
metadataJSON = []byte("{}")
|
||||
}
|
||||
|
||||
const q = `
|
||||
INSERT INTO issuance_approval_requests
|
||||
(id, certificate_id, job_id, profile_id, requested_by,
|
||||
state, decided_by, decided_at, decision_note, metadata,
|
||||
created_at, updated_at)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
`
|
||||
|
||||
_, err = r.db.ExecContext(ctx, q,
|
||||
req.ID, req.CertificateID, req.JobID, req.ProfileID, req.RequestedBy,
|
||||
string(req.State), req.DecidedBy, req.DecidedAt, req.DecisionNote, metadataJSON,
|
||||
req.CreatedAt, req.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
var pqErr *pq.Error
|
||||
if errors.As(err, &pqErr) && pqErr.Code == "23505" { // unique_violation
|
||||
return repository.ErrAlreadyExists
|
||||
}
|
||||
return fmt.Errorf("insert approval request: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get returns the request by ID or repository.ErrNotFound.
|
||||
func (r *ApprovalRepository) Get(ctx context.Context, id string) (*domain.ApprovalRequest, error) {
|
||||
const q = `
|
||||
SELECT id, certificate_id, job_id, profile_id, requested_by,
|
||||
state, decided_by, decided_at, decision_note, metadata,
|
||||
created_at, updated_at
|
||||
FROM issuance_approval_requests
|
||||
WHERE id = $1
|
||||
`
|
||||
row := r.db.QueryRowContext(ctx, q, id)
|
||||
return scanApprovalRow(row)
|
||||
}
|
||||
|
||||
// GetByJobID returns the most-recently-created request for the given
|
||||
// job_id, regardless of state.
|
||||
func (r *ApprovalRepository) GetByJobID(ctx context.Context, jobID string) (*domain.ApprovalRequest, error) {
|
||||
const q = `
|
||||
SELECT id, certificate_id, job_id, profile_id, requested_by,
|
||||
state, decided_by, decided_at, decision_note, metadata,
|
||||
created_at, updated_at
|
||||
FROM issuance_approval_requests
|
||||
WHERE job_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`
|
||||
row := r.db.QueryRowContext(ctx, q, jobID)
|
||||
return scanApprovalRow(row)
|
||||
}
|
||||
|
||||
// List returns approval requests filtered by repository.ApprovalFilter.
|
||||
// Supports paginated dashboard queries.
|
||||
func (r *ApprovalRepository) List(ctx context.Context, filter *repository.ApprovalFilter) ([]*domain.ApprovalRequest, error) {
|
||||
if filter == nil {
|
||||
filter = &repository.ApprovalFilter{}
|
||||
}
|
||||
page := filter.Page
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
perPage := filter.PerPage
|
||||
if perPage < 1 || perPage > 500 {
|
||||
perPage = 50
|
||||
}
|
||||
|
||||
q := `
|
||||
SELECT id, certificate_id, job_id, profile_id, requested_by,
|
||||
state, decided_by, decided_at, decision_note, metadata,
|
||||
created_at, updated_at
|
||||
FROM issuance_approval_requests
|
||||
WHERE 1 = 1
|
||||
`
|
||||
args := []interface{}{}
|
||||
idx := 1
|
||||
if filter.State != "" {
|
||||
q += fmt.Sprintf(" AND state = $%d", idx)
|
||||
args = append(args, filter.State)
|
||||
idx++
|
||||
}
|
||||
if filter.CertificateID != "" {
|
||||
q += fmt.Sprintf(" AND certificate_id = $%d", idx)
|
||||
args = append(args, filter.CertificateID)
|
||||
idx++
|
||||
}
|
||||
if filter.RequestedBy != "" {
|
||||
q += fmt.Sprintf(" AND requested_by = $%d", idx)
|
||||
args = append(args, filter.RequestedBy)
|
||||
idx++
|
||||
}
|
||||
q += fmt.Sprintf(" ORDER BY created_at DESC LIMIT $%d OFFSET $%d", idx, idx+1)
|
||||
args = append(args, perPage, (page-1)*perPage)
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list approval requests: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []*domain.ApprovalRequest
|
||||
for rows.Next() {
|
||||
req, err := scanApprovalRow(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, req)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate approval rows: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UpdateState transitions a row from state=pending to a terminal state.
|
||||
// Returns repository.ErrNotFound if the ID does not exist.
|
||||
//
|
||||
// The schema's approval_decision_consistency CHECK constraint enforces
|
||||
// that decided_by + decided_at MUST be non-null for terminal states,
|
||||
// so a same-state update on an already-decided row returns a
|
||||
// constraint-violation error from postgres.
|
||||
func (r *ApprovalRepository) UpdateState(ctx context.Context, id string, state domain.ApprovalState,
|
||||
decidedBy string, decidedAt time.Time, note string) error {
|
||||
if !domain.IsValidApprovalState(state) {
|
||||
return fmt.Errorf("invalid approval state %q", state)
|
||||
}
|
||||
if !state.IsTerminal() {
|
||||
return fmt.Errorf("UpdateState only accepts terminal states; got %q", state)
|
||||
}
|
||||
|
||||
var notePtr *string
|
||||
if note != "" {
|
||||
notePtr = ¬e
|
||||
}
|
||||
|
||||
const q = `
|
||||
UPDATE issuance_approval_requests
|
||||
SET state = $2,
|
||||
decided_by = $3,
|
||||
decided_at = $4,
|
||||
decision_note = $5,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
AND state = 'pending'
|
||||
`
|
||||
res, err := r.db.ExecContext(ctx, q, id, string(state), decidedBy, decidedAt, notePtr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update approval state: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("update approval rows affected: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
// Either the ID does not exist, or the row is already terminal.
|
||||
// Disambiguate via a follow-up Get.
|
||||
existing, getErr := r.Get(ctx, id)
|
||||
if getErr != nil {
|
||||
return getErr // ErrNotFound or scan error
|
||||
}
|
||||
if existing.State.IsTerminal() {
|
||||
return repository.ErrAlreadyExists // signals "already decided"
|
||||
}
|
||||
return repository.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExpireStale transitions every row with state=pending and created_at <=
|
||||
// before to state=expired. Returns the number of rows transitioned.
|
||||
//
|
||||
// The decided_at is stamped with time.Now() rather than `before` so
|
||||
// audit dashboards see the actual reaper-firing wall-clock, not the
|
||||
// reaper's deadline-cutoff input. The decided_by is set to a sentinel
|
||||
// "system-reaper" so SELECT FROM audit_events WHERE actor matches both
|
||||
// human-decided and reaper-decided rows for compliance review.
|
||||
func (r *ApprovalRepository) ExpireStale(ctx context.Context, before time.Time) (int, error) {
|
||||
const q = `
|
||||
UPDATE issuance_approval_requests
|
||||
SET state = 'expired',
|
||||
decided_by = 'system-reaper',
|
||||
decided_at = NOW(),
|
||||
decision_note = 'auto-expired by scheduler reaper at CERTCTL_JOB_AWAITING_APPROVAL_TIMEOUT',
|
||||
updated_at = NOW()
|
||||
WHERE state = 'pending'
|
||||
AND created_at <= $1
|
||||
`
|
||||
res, err := r.db.ExecContext(ctx, q, before)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("expire stale approvals: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("expire stale rows affected: %w", err)
|
||||
}
|
||||
return int(n), nil
|
||||
}
|
||||
|
||||
// scanApprovalRow scans a single row into a *domain.ApprovalRequest.
|
||||
// Used by Get / GetByJobID (sql.Row) + List (*sql.Rows) — accepts the
|
||||
// rowScanner interface. JSONB metadata is unmarshaled defensively.
|
||||
type rowScanner interface {
|
||||
Scan(dest ...interface{}) error
|
||||
}
|
||||
|
||||
func scanApprovalRow(row rowScanner) (*domain.ApprovalRequest, error) {
|
||||
var (
|
||||
req domain.ApprovalRequest
|
||||
stateStr string
|
||||
decidedBy sql.NullString
|
||||
decidedAt sql.NullTime
|
||||
decisionNote sql.NullString
|
||||
metadataJSON []byte
|
||||
)
|
||||
err := row.Scan(
|
||||
&req.ID, &req.CertificateID, &req.JobID, &req.ProfileID, &req.RequestedBy,
|
||||
&stateStr, &decidedBy, &decidedAt, &decisionNote, &metadataJSON,
|
||||
&req.CreatedAt, &req.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, repository.ErrNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("scan approval row: %w", err)
|
||||
}
|
||||
|
||||
req.State = domain.ApprovalState(stateStr)
|
||||
if decidedBy.Valid {
|
||||
s := decidedBy.String
|
||||
req.DecidedBy = &s
|
||||
}
|
||||
if decidedAt.Valid {
|
||||
t := decidedAt.Time
|
||||
req.DecidedAt = &t
|
||||
}
|
||||
if decisionNote.Valid {
|
||||
s := decisionNote.String
|
||||
req.DecisionNote = &s
|
||||
}
|
||||
if len(metadataJSON) > 0 {
|
||||
if err := json.Unmarshal(metadataJSON, &req.Metadata); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal approval metadata: %w", err)
|
||||
}
|
||||
}
|
||||
return &req, nil
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/lib/pq"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/domain"
|
||||
"github.com/certctl-io/certctl/internal/repository"
|
||||
)
|
||||
|
||||
// IntermediateCARepository is the postgres implementation of
|
||||
// repository.IntermediateCARepository. Rank 8 first-class CA
|
||||
// hierarchy.
|
||||
type IntermediateCARepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewIntermediateCARepository constructs an IntermediateCARepository
|
||||
// against the given *sql.DB. Schema defined by migration
|
||||
// 000028_intermediate_ca_hierarchy.up.sql.
|
||||
func NewIntermediateCARepository(db *sql.DB) *IntermediateCARepository {
|
||||
return &IntermediateCARepository{db: db}
|
||||
}
|
||||
|
||||
// Create inserts a new IntermediateCA row.
|
||||
func (r *IntermediateCARepository) Create(ctx context.Context, ca *domain.IntermediateCA) error {
|
||||
if ca.ID == "" {
|
||||
ca.ID = "ica-" + uuid.NewString()
|
||||
}
|
||||
if ca.State == "" {
|
||||
ca.State = domain.IntermediateCAStateActive
|
||||
}
|
||||
if !domain.IsValidIntermediateCAState(ca.State) {
|
||||
return fmt.Errorf("invalid intermediate CA state %q", ca.State)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if ca.CreatedAt.IsZero() {
|
||||
ca.CreatedAt = now
|
||||
}
|
||||
if ca.UpdatedAt.IsZero() {
|
||||
ca.UpdatedAt = now
|
||||
}
|
||||
|
||||
nameConstraintsJSON, err := json.Marshal(ca.NameConstraints)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal name_constraints: %w", err)
|
||||
}
|
||||
if len(nameConstraintsJSON) == 0 || string(nameConstraintsJSON) == "null" {
|
||||
nameConstraintsJSON = []byte("[]")
|
||||
}
|
||||
metadataJSON, err := json.Marshal(ca.Metadata)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal metadata: %w", err)
|
||||
}
|
||||
if len(metadataJSON) == 0 || string(metadataJSON) == "null" {
|
||||
metadataJSON = []byte("{}")
|
||||
}
|
||||
|
||||
const q = `
|
||||
INSERT INTO intermediate_cas
|
||||
(id, owning_issuer_id, parent_ca_id, name, subject, state,
|
||||
cert_pem, key_driver_id, not_before, not_after,
|
||||
path_len_constraint, name_constraints, ocsp_responder_url,
|
||||
metadata, created_at, updated_at)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
||||
`
|
||||
_, err = r.db.ExecContext(ctx, q,
|
||||
ca.ID, ca.OwningIssuerID, ca.ParentCAID, ca.Name, ca.Subject, string(ca.State),
|
||||
ca.CertPEM, ca.KeyDriverID, ca.NotBefore, ca.NotAfter,
|
||||
ca.PathLenConstraint, nameConstraintsJSON, nullIfEmpty(ca.OCSPResponderURL),
|
||||
metadataJSON, ca.CreatedAt, ca.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
var pqErr *pq.Error
|
||||
if errors.As(err, &pqErr) && pqErr.Code == "23505" { // unique_violation
|
||||
return repository.ErrAlreadyExists
|
||||
}
|
||||
return fmt.Errorf("insert intermediate CA: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get returns the row by ID or repository.ErrNotFound.
|
||||
func (r *IntermediateCARepository) Get(ctx context.Context, id string) (*domain.IntermediateCA, error) {
|
||||
const q = `
|
||||
SELECT id, owning_issuer_id, parent_ca_id, name, subject, state,
|
||||
cert_pem, key_driver_id, not_before, not_after,
|
||||
path_len_constraint, name_constraints, ocsp_responder_url,
|
||||
metadata, created_at, updated_at
|
||||
FROM intermediate_cas
|
||||
WHERE id = $1
|
||||
`
|
||||
row := r.db.QueryRowContext(ctx, q, id)
|
||||
return scanIntermediateCARow(row)
|
||||
}
|
||||
|
||||
// ListByIssuer returns every CA row for an issuer.
|
||||
func (r *IntermediateCARepository) ListByIssuer(ctx context.Context, issuerID string) ([]*domain.IntermediateCA, error) {
|
||||
const q = `
|
||||
SELECT id, owning_issuer_id, parent_ca_id, name, subject, state,
|
||||
cert_pem, key_driver_id, not_before, not_after,
|
||||
path_len_constraint, name_constraints, ocsp_responder_url,
|
||||
metadata, created_at, updated_at
|
||||
FROM intermediate_cas
|
||||
WHERE owning_issuer_id = $1
|
||||
ORDER BY created_at ASC
|
||||
`
|
||||
rows, err := r.db.QueryContext(ctx, q, issuerID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list intermediate CAs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanIntermediateCARows(rows)
|
||||
}
|
||||
|
||||
// ListChildren returns direct children of the given CA.
|
||||
func (r *IntermediateCARepository) ListChildren(ctx context.Context, parentCAID string) ([]*domain.IntermediateCA, error) {
|
||||
const q = `
|
||||
SELECT id, owning_issuer_id, parent_ca_id, name, subject, state,
|
||||
cert_pem, key_driver_id, not_before, not_after,
|
||||
path_len_constraint, name_constraints, ocsp_responder_url,
|
||||
metadata, created_at, updated_at
|
||||
FROM intermediate_cas
|
||||
WHERE parent_ca_id = $1
|
||||
ORDER BY created_at ASC
|
||||
`
|
||||
rows, err := r.db.QueryContext(ctx, q, parentCAID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list children: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanIntermediateCARows(rows)
|
||||
}
|
||||
|
||||
// UpdateState transitions a row's lifecycle state.
|
||||
func (r *IntermediateCARepository) UpdateState(ctx context.Context, id string, state domain.IntermediateCAState) error {
|
||||
if !domain.IsValidIntermediateCAState(state) {
|
||||
return fmt.Errorf("invalid state %q", state)
|
||||
}
|
||||
const q = `
|
||||
UPDATE intermediate_cas
|
||||
SET state = $2, updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`
|
||||
res, err := r.db.ExecContext(ctx, q, id, string(state))
|
||||
if err != nil {
|
||||
return fmt.Errorf("update state: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("rows affected: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return repository.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetActiveRoot returns the active root CA for an issuer.
|
||||
func (r *IntermediateCARepository) GetActiveRoot(ctx context.Context, issuerID string) (*domain.IntermediateCA, error) {
|
||||
const q = `
|
||||
SELECT id, owning_issuer_id, parent_ca_id, name, subject, state,
|
||||
cert_pem, key_driver_id, not_before, not_after,
|
||||
path_len_constraint, name_constraints, ocsp_responder_url,
|
||||
metadata, created_at, updated_at
|
||||
FROM intermediate_cas
|
||||
WHERE owning_issuer_id = $1
|
||||
AND parent_ca_id IS NULL
|
||||
AND state = 'active'
|
||||
LIMIT 1
|
||||
`
|
||||
row := r.db.QueryRowContext(ctx, q, issuerID)
|
||||
return scanIntermediateCARow(row)
|
||||
}
|
||||
|
||||
// WalkAncestry returns leaf-to-root chain via recursive CTE. Single
|
||||
// SQL round-trip, O(depth) rows.
|
||||
func (r *IntermediateCARepository) WalkAncestry(ctx context.Context, leafID string) ([]*domain.IntermediateCA, error) {
|
||||
const q = `
|
||||
WITH RECURSIVE ancestry AS (
|
||||
SELECT id, owning_issuer_id, parent_ca_id, name, subject, state,
|
||||
cert_pem, key_driver_id, not_before, not_after,
|
||||
path_len_constraint, name_constraints, ocsp_responder_url,
|
||||
metadata, created_at, updated_at, 0 AS depth
|
||||
FROM intermediate_cas
|
||||
WHERE id = $1
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT i.id, i.owning_issuer_id, i.parent_ca_id, i.name, i.subject, i.state,
|
||||
i.cert_pem, i.key_driver_id, i.not_before, i.not_after,
|
||||
i.path_len_constraint, i.name_constraints, i.ocsp_responder_url,
|
||||
i.metadata, i.created_at, i.updated_at, a.depth + 1
|
||||
FROM intermediate_cas i
|
||||
JOIN ancestry a ON i.id = a.parent_ca_id
|
||||
)
|
||||
SELECT id, owning_issuer_id, parent_ca_id, name, subject, state,
|
||||
cert_pem, key_driver_id, not_before, not_after,
|
||||
path_len_constraint, name_constraints, ocsp_responder_url,
|
||||
metadata, created_at, updated_at
|
||||
FROM ancestry
|
||||
ORDER BY depth ASC
|
||||
`
|
||||
rows, err := r.db.QueryContext(ctx, q, leafID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("walk ancestry: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out, err := scanIntermediateCARows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, repository.ErrNotFound
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// scanIntermediateCARow scans a single row.
|
||||
func scanIntermediateCARow(row rowScanner) (*domain.IntermediateCA, error) {
|
||||
var (
|
||||
ca domain.IntermediateCA
|
||||
stateStr string
|
||||
parentCAID sql.NullString
|
||||
pathLenConstraint sql.NullInt64
|
||||
ocspResponderURL sql.NullString
|
||||
nameConstraintsJSON []byte
|
||||
metadataJSON []byte
|
||||
)
|
||||
err := row.Scan(
|
||||
&ca.ID, &ca.OwningIssuerID, &parentCAID, &ca.Name, &ca.Subject, &stateStr,
|
||||
&ca.CertPEM, &ca.KeyDriverID, &ca.NotBefore, &ca.NotAfter,
|
||||
&pathLenConstraint, &nameConstraintsJSON, &ocspResponderURL,
|
||||
&metadataJSON, &ca.CreatedAt, &ca.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, repository.ErrNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("scan intermediate CA: %w", err)
|
||||
}
|
||||
ca.State = domain.IntermediateCAState(stateStr)
|
||||
if parentCAID.Valid {
|
||||
s := parentCAID.String
|
||||
ca.ParentCAID = &s
|
||||
}
|
||||
if pathLenConstraint.Valid {
|
||||
v := int(pathLenConstraint.Int64)
|
||||
ca.PathLenConstraint = &v
|
||||
}
|
||||
if ocspResponderURL.Valid {
|
||||
ca.OCSPResponderURL = ocspResponderURL.String
|
||||
}
|
||||
if len(nameConstraintsJSON) > 0 {
|
||||
if err := json.Unmarshal(nameConstraintsJSON, &ca.NameConstraints); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal name_constraints: %w", err)
|
||||
}
|
||||
}
|
||||
if len(metadataJSON) > 0 {
|
||||
if err := json.Unmarshal(metadataJSON, &ca.Metadata); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal metadata: %w", err)
|
||||
}
|
||||
}
|
||||
return &ca, nil
|
||||
}
|
||||
|
||||
func scanIntermediateCARows(rows *sql.Rows) ([]*domain.IntermediateCA, error) {
|
||||
var out []*domain.IntermediateCA
|
||||
for rows.Next() {
|
||||
ca, err := scanIntermediateCARow(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, ca)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate rows: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// nullIfEmpty returns sql.NullString — Valid=false when s is empty so
|
||||
// the column is written as SQL NULL rather than empty string.
|
||||
func nullIfEmpty(s string) sql.NullString {
|
||||
if s == "" {
|
||||
return sql.NullString{}
|
||||
}
|
||||
return sql.NullString{String: s, Valid: true}
|
||||
}
|
||||
@@ -350,8 +350,20 @@ func verifyChallengeSignature(alg string, signingInput, signature []byte, trust
|
||||
// signature against each trust anchor's public key. Constant-time: the
|
||||
// stdlib's rsa.VerifyPKCS1v15 returns nil on success and an error on
|
||||
// failure without timing-leak surface area on the hash compare path.
|
||||
//
|
||||
// SHA-256 is the spec-mandated digest for RS256 — RFC 7518 §3.3
|
||||
// defines RS256 as "RSASSA-PKCS1-v1_5 using SHA-256". This is JWS
|
||||
// signature verification over a public, well-known message (the
|
||||
// JWS protected header + payload, base64url-encoded). It is NOT
|
||||
// password hashing — the input has full 256-bit entropy contributed
|
||||
// by the signer's nonce + timestamp + device-claim payload, and
|
||||
// the output is checked against an asymmetric signature, not a
|
||||
// pre-computed hash digest. CodeQL go/weak-sensitive-data-hashing
|
||||
// triggers on the proximity of *x509.Certificate; the certificate
|
||||
// here is a verification key, not an input to the hash. Suppressing
|
||||
// the alert at the call site below.
|
||||
func verifyRS256(signingInput, signature []byte, trust []*x509.Certificate) error {
|
||||
h := sha256.Sum256(signingInput)
|
||||
h := sha256.Sum256(signingInput) //nolint:gosec // RFC 7518 §3.3 RS256 mandates SHA-256; not password hashing
|
||||
for _, cert := range trust {
|
||||
pub, ok := cert.PublicKey.(*rsa.PublicKey)
|
||||
if !ok {
|
||||
@@ -376,8 +388,20 @@ func verifyRS256(signingInput, signature []byte, trust []*x509.Certificate) erro
|
||||
// Try fixed-width first (the spec-blessed format); fall back to ASN.1.
|
||||
// crypto/ecdsa.VerifyASN1 + ecdsa.Verify both return bool — no timing
|
||||
// leak on the success path.
|
||||
//
|
||||
// SHA-256 is the spec-mandated digest for ES256 — RFC 7518 §3.4 defines
|
||||
// ES256 as "ECDSA using P-256 and SHA-256". This is JWS signature
|
||||
// verification over a public, well-known message (the JWS protected
|
||||
// header + payload, base64url-encoded). It is NOT password hashing.
|
||||
// The signing input is the JWS encoded payload; full 256-bit-entropy
|
||||
// content from the signer's claim. The output is checked against an
|
||||
// asymmetric signature, not a pre-computed digest. CodeQL
|
||||
// go/weak-sensitive-data-hashing triggers on the proximity of
|
||||
// *x509.Certificate; the certificate here is a verification key, not
|
||||
// an input to the hash. Suppressing the alert at the call site below
|
||||
// (CodeQL alert #21).
|
||||
func verifyES256(signingInput, signature []byte, trust []*x509.Certificate) error {
|
||||
h := sha256.Sum256(signingInput)
|
||||
h := sha256.Sum256(signingInput) //nolint:gosec // RFC 7518 §3.4 ES256 mandates SHA-256; not password hashing
|
||||
for _, cert := range trust {
|
||||
pub, ok := cert.PublicKey.(*ecdsa.PublicKey)
|
||||
if !ok {
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/domain"
|
||||
"github.com/certctl-io/certctl/internal/repository"
|
||||
)
|
||||
|
||||
// ApprovalService manages the issuance approval-workflow primitive.
|
||||
// Rank 7 of the 2026-05-03 Infisical deep-research deliverable.
|
||||
//
|
||||
// Lifecycle: a profile with RequiresApproval=true causes the renewal
|
||||
// entry points (TriggerRenewal + CheckExpiringCertificates) to call
|
||||
// RequestApproval; the resulting Job is created at
|
||||
// JobStatusAwaitingApproval; the scheduler does NOT dispatch until
|
||||
// Approve transitions the job to Pending.
|
||||
//
|
||||
// RBAC contract: the requester cannot approve their own request.
|
||||
// Approve checks decidedBy != request.RequestedBy and rejects with
|
||||
// ErrApproveBySameActor otherwise. This is the load-bearing two-
|
||||
// person integrity check; compliance auditors pattern-match against
|
||||
// it.
|
||||
//
|
||||
// Bypass mode: if CERTCTL_APPROVAL_BYPASS=true at boot, every
|
||||
// RequestApproval call immediately auto-approves with
|
||||
// decidedBy="system-bypass". Used by dev / CI to keep renewal-
|
||||
// scheduler tests fast without standing up an approver. Production
|
||||
// deploys MUST leave this unset; the bypass emits an audit row with
|
||||
// ActorType=System so a downstream auditor can grep for
|
||||
// "system-bypass" approvals and confirm none happened in production.
|
||||
type ApprovalService struct {
|
||||
approvalRepo repository.ApprovalRepository
|
||||
jobRepo JobStatusUpdater
|
||||
auditService *AuditService
|
||||
metrics *ApprovalMetrics
|
||||
|
||||
bypassEnabled bool
|
||||
}
|
||||
|
||||
// JobStatusUpdater is the narrow interface ApprovalService depends on
|
||||
// from JobRepository. Accepting the small interface (rather than the
|
||||
// full repository.JobRepository) keeps the test mock surface tiny —
|
||||
// real JobRepository implementations (postgres + any future) satisfy
|
||||
// it implicitly because they implement UpdateStatus already.
|
||||
type JobStatusUpdater interface {
|
||||
UpdateStatus(ctx context.Context, id string, status domain.JobStatus, errMsg string) error
|
||||
}
|
||||
|
||||
// NewApprovalService constructs an ApprovalService. metrics may be nil
|
||||
// for tests that don't need Prometheus integration; auditService should
|
||||
// not be nil in production but is tolerated for unit tests that don't
|
||||
// care about audit-row emission.
|
||||
func NewApprovalService(
|
||||
approvalRepo repository.ApprovalRepository,
|
||||
jobRepo JobStatusUpdater,
|
||||
auditService *AuditService,
|
||||
metrics *ApprovalMetrics,
|
||||
bypassEnabled bool,
|
||||
) *ApprovalService {
|
||||
return &ApprovalService{
|
||||
approvalRepo: approvalRepo,
|
||||
jobRepo: jobRepo,
|
||||
auditService: auditService,
|
||||
metrics: metrics,
|
||||
bypassEnabled: bypassEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
// Sentinels for handler-side dispatch via errors.Is.
|
||||
var (
|
||||
// ErrApprovalNotFound is returned when the request ID does not exist.
|
||||
// Handlers map to HTTP 404.
|
||||
ErrApprovalNotFound = errors.New("approval request not found")
|
||||
|
||||
// ErrApprovalAlreadyDecided is returned when Approve / Reject is called
|
||||
// on a request whose State is already terminal. Handlers map to HTTP 409.
|
||||
ErrApprovalAlreadyDecided = errors.New("approval request already decided")
|
||||
|
||||
// ErrApproveBySameActor is the load-bearing two-person integrity check.
|
||||
// Returned when the supplied decidedBy equals request.RequestedBy.
|
||||
// Handlers map to HTTP 403.
|
||||
ErrApproveBySameActor = errors.New("approver cannot be the same as requester (two-person integrity)")
|
||||
)
|
||||
|
||||
// RequestApproval creates a pending ApprovalRequest row and is invoked
|
||||
// from the renewal entry points after they have created the Job at
|
||||
// Status=AwaitingApproval. Returns the request ID for handler /
|
||||
// caller use.
|
||||
//
|
||||
// If bypassEnabled is true, this method synchronously calls Approve
|
||||
// internally with decidedBy=ApprovalActorSystemBypass and returns the
|
||||
// resulting (now-approved) request ID. The audit row records
|
||||
// ActorType=System so a downstream auditor can confirm bypass-mode
|
||||
// was off in production via a single SQL query.
|
||||
func (s *ApprovalService) RequestApproval(
|
||||
ctx context.Context,
|
||||
cert *domain.ManagedCertificate,
|
||||
jobID, profileID, requestedBy string,
|
||||
metadata map[string]string,
|
||||
) (string, error) {
|
||||
if cert == nil {
|
||||
return "", fmt.Errorf("approval: nil certificate")
|
||||
}
|
||||
if jobID == "" || profileID == "" || requestedBy == "" {
|
||||
return "", fmt.Errorf("approval: jobID, profileID, requestedBy required")
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
req := &domain.ApprovalRequest{
|
||||
CertificateID: cert.ID,
|
||||
JobID: jobID,
|
||||
ProfileID: profileID,
|
||||
RequestedBy: requestedBy,
|
||||
State: domain.ApprovalStatePending,
|
||||
Metadata: metadata,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := s.approvalRepo.Create(ctx, req); err != nil {
|
||||
return "", fmt.Errorf("approval: create request: %w", err)
|
||||
}
|
||||
|
||||
// Audit the request creation. Bypass-mode logs both the request and
|
||||
// the auto-approval as separate rows so the timeline is honest.
|
||||
s.recordAudit(ctx, requestedBy, domain.ActorTypeUser, "approval_requested", req, nil)
|
||||
|
||||
if s.bypassEnabled {
|
||||
if err := s.approveInternal(ctx, req.ID, domain.ApprovalActorSystemBypass,
|
||||
"auto-approved by CERTCTL_APPROVAL_BYPASS — dev/CI mode",
|
||||
domain.ApprovalOutcomeBypassed, domain.ActorTypeSystem); err != nil {
|
||||
return req.ID, fmt.Errorf("approval: bypass auto-approve: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return req.ID, nil
|
||||
}
|
||||
|
||||
// Approve transitions a pending request to approved AND the linked Job
|
||||
// from AwaitingApproval to Pending so the job processor picks it up.
|
||||
// RBAC: rejects if decidedBy == request.RequestedBy.
|
||||
func (s *ApprovalService) Approve(ctx context.Context, requestID, decidedBy, note string) error {
|
||||
req, err := s.approvalRepo.Get(ctx, requestID)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
return ErrApprovalNotFound
|
||||
}
|
||||
return fmt.Errorf("approval: get for approve: %w", err)
|
||||
}
|
||||
if req.State.IsTerminal() {
|
||||
return ErrApprovalAlreadyDecided
|
||||
}
|
||||
if decidedBy == req.RequestedBy {
|
||||
return ErrApproveBySameActor
|
||||
}
|
||||
return s.approveInternal(ctx, requestID, decidedBy, note,
|
||||
domain.ApprovalOutcomeApproved, domain.ActorTypeUser)
|
||||
}
|
||||
|
||||
// approveInternal is the shared transition path for both human-Approve
|
||||
// and bypass-mode auto-approve. Same DB transition + audit + metric
|
||||
// recording, but the outcome label + actorType differ.
|
||||
func (s *ApprovalService) approveInternal(
|
||||
ctx context.Context, requestID, decidedBy, note, outcome string,
|
||||
actorType domain.ActorType,
|
||||
) error {
|
||||
now := time.Now().UTC()
|
||||
|
||||
// Re-fetch the request after the state-transition guards in Approve so
|
||||
// we can stamp the metric's pending-age + transition the job. For the
|
||||
// bypass path, this is the first read.
|
||||
req, err := s.approvalRepo.Get(ctx, requestID)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
return ErrApprovalNotFound
|
||||
}
|
||||
return fmt.Errorf("approval: get for transition: %w", err)
|
||||
}
|
||||
if req.State.IsTerminal() {
|
||||
return ErrApprovalAlreadyDecided
|
||||
}
|
||||
|
||||
if err := s.approvalRepo.UpdateState(ctx, requestID,
|
||||
domain.ApprovalStateApproved, decidedBy, now, note); err != nil {
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
return ErrApprovalNotFound
|
||||
}
|
||||
if errors.Is(err, repository.ErrAlreadyExists) {
|
||||
return ErrApprovalAlreadyDecided
|
||||
}
|
||||
return fmt.Errorf("approval: update state to approved: %w", err)
|
||||
}
|
||||
|
||||
// Transition the linked Job from AwaitingApproval to Pending so the
|
||||
// scheduler picks it up. Best-effort — if the Job has already been
|
||||
// cancelled or otherwise mutated externally, log via audit and move on.
|
||||
if err := s.jobRepo.UpdateStatus(ctx, req.JobID, domain.JobStatusPending, ""); err != nil {
|
||||
s.recordAudit(ctx, decidedBy, actorType, "approval_job_transition_failed", req,
|
||||
map[string]interface{}{"target_status": string(domain.JobStatusPending), "error": err.Error()})
|
||||
return fmt.Errorf("approval: transition job to Pending: %w", err)
|
||||
}
|
||||
|
||||
s.recordAudit(ctx, decidedBy, actorType, "approval_"+outcome, req,
|
||||
map[string]interface{}{"note": note, "outcome": outcome})
|
||||
if s.metrics != nil {
|
||||
s.metrics.RecordDecision(outcome, req.ProfileID)
|
||||
s.metrics.ObservePendingAge(now.Sub(req.CreatedAt).Seconds())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reject transitions a pending request to rejected AND the linked Job
|
||||
// from AwaitingApproval to Cancelled. RBAC: same-actor check applies.
|
||||
func (s *ApprovalService) Reject(ctx context.Context, requestID, decidedBy, note string) error {
|
||||
req, err := s.approvalRepo.Get(ctx, requestID)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
return ErrApprovalNotFound
|
||||
}
|
||||
return fmt.Errorf("approval: get for reject: %w", err)
|
||||
}
|
||||
if req.State.IsTerminal() {
|
||||
return ErrApprovalAlreadyDecided
|
||||
}
|
||||
if decidedBy == req.RequestedBy {
|
||||
return ErrApproveBySameActor
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
if err := s.approvalRepo.UpdateState(ctx, requestID,
|
||||
domain.ApprovalStateRejected, decidedBy, now, note); err != nil {
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
return ErrApprovalNotFound
|
||||
}
|
||||
if errors.Is(err, repository.ErrAlreadyExists) {
|
||||
return ErrApprovalAlreadyDecided
|
||||
}
|
||||
return fmt.Errorf("approval: update state to rejected: %w", err)
|
||||
}
|
||||
|
||||
if err := s.jobRepo.UpdateStatus(ctx, req.JobID, domain.JobStatusCancelled,
|
||||
"approval rejected: "+note); err != nil {
|
||||
s.recordAudit(ctx, decidedBy, domain.ActorTypeUser, "approval_job_transition_failed", req,
|
||||
map[string]interface{}{"target_status": string(domain.JobStatusCancelled), "error": err.Error()})
|
||||
return fmt.Errorf("approval: transition job to Cancelled: %w", err)
|
||||
}
|
||||
|
||||
s.recordAudit(ctx, decidedBy, domain.ActorTypeUser, "approval_rejected", req,
|
||||
map[string]interface{}{"note": note, "outcome": domain.ApprovalOutcomeRejected})
|
||||
if s.metrics != nil {
|
||||
s.metrics.RecordDecision(domain.ApprovalOutcomeRejected, req.ProfileID)
|
||||
s.metrics.ObservePendingAge(now.Sub(req.CreatedAt).Seconds())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListPending returns approval requests in state=pending, paginated.
|
||||
// Operators reading the dashboard call this on every page load.
|
||||
func (s *ApprovalService) ListPending(ctx context.Context, page, perPage int) ([]*domain.ApprovalRequest, error) {
|
||||
return s.approvalRepo.List(ctx, &repository.ApprovalFilter{
|
||||
State: string(domain.ApprovalStatePending),
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
})
|
||||
}
|
||||
|
||||
// List returns approval requests filtered by the supplied filter. Used
|
||||
// by handler GET /api/v1/approvals with arbitrary state.
|
||||
func (s *ApprovalService) List(ctx context.Context, filter *repository.ApprovalFilter) ([]*domain.ApprovalRequest, error) {
|
||||
return s.approvalRepo.List(ctx, filter)
|
||||
}
|
||||
|
||||
// Get returns a single approval request by ID, or ErrApprovalNotFound.
|
||||
func (s *ApprovalService) Get(ctx context.Context, id string) (*domain.ApprovalRequest, error) {
|
||||
req, err := s.approvalRepo.Get(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
return nil, ErrApprovalNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// ExpireStale runs from the scheduler's reaper loop. Calls the
|
||||
// repository's ExpireStale (bulk pending→expired transition) +
|
||||
// transitions matching jobs from AwaitingApproval to Cancelled.
|
||||
// Records one audit row per expiry. Returns the count expired.
|
||||
//
|
||||
// Operators alert when this is non-zero — it means an approval
|
||||
// request timed out without human review.
|
||||
func (s *ApprovalService) ExpireStale(ctx context.Context, before time.Time) (int, error) {
|
||||
// Find pending requests older than `before` so we can record the
|
||||
// audit + metric per expiry. ExpireStale on the repo bulk-mutates
|
||||
// the rows; we read first to capture the per-row metadata for
|
||||
// auditing, then call the repo's bulk update.
|
||||
pending, err := s.approvalRepo.List(ctx, &repository.ApprovalFilter{
|
||||
State: string(domain.ApprovalStatePending),
|
||||
PerPage: 500,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("approval: list pending for expiry: %w", err)
|
||||
}
|
||||
|
||||
var stale []*domain.ApprovalRequest
|
||||
for _, req := range pending {
|
||||
if req.CreatedAt.Before(before) || req.CreatedAt.Equal(before) {
|
||||
stale = append(stale, req)
|
||||
}
|
||||
}
|
||||
if len(stale) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
count, err := s.approvalRepo.ExpireStale(ctx, before)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("approval: bulk expire: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
for _, req := range stale {
|
||||
// Cancel the linked job — best-effort. The scheduler's existing
|
||||
// ReapTimedOutJobs already handles AwaitingApproval timeouts on
|
||||
// the job side; this is a defensive double-cancel that's
|
||||
// idempotent if the scheduler already ran.
|
||||
if err := s.jobRepo.UpdateStatus(ctx, req.JobID, domain.JobStatusCancelled,
|
||||
"approval expired: timed out without review"); err != nil {
|
||||
// Log via audit and continue — don't fail the whole sweep on
|
||||
// one bad job.
|
||||
s.recordAudit(ctx, "system-reaper", domain.ActorTypeSystem, "approval_job_transition_failed", req,
|
||||
map[string]interface{}{"target_status": string(domain.JobStatusCancelled), "error": err.Error()})
|
||||
}
|
||||
|
||||
s.recordAudit(ctx, "system-reaper", domain.ActorTypeSystem, "approval_expired", req,
|
||||
map[string]interface{}{"outcome": domain.ApprovalOutcomeExpired, "before_cutoff": before.Format(time.RFC3339)})
|
||||
if s.metrics != nil {
|
||||
s.metrics.RecordDecision(domain.ApprovalOutcomeExpired, req.ProfileID)
|
||||
s.metrics.ObservePendingAge(now.Sub(req.CreatedAt).Seconds())
|
||||
}
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// recordAudit is the shared audit-emission helper. Tolerates a nil
|
||||
// AuditService (unit tests that don't wire it) and discards errors —
|
||||
// audit failures must not block the primary state transition.
|
||||
func (s *ApprovalService) recordAudit(ctx context.Context, actor string, actorType domain.ActorType,
|
||||
action string, req *domain.ApprovalRequest, extra map[string]interface{}) {
|
||||
if s.auditService == nil || req == nil {
|
||||
return
|
||||
}
|
||||
details := map[string]interface{}{
|
||||
"approval_id": req.ID,
|
||||
"certificate_id": req.CertificateID,
|
||||
"job_id": req.JobID,
|
||||
"profile_id": req.ProfileID,
|
||||
"requested_by": req.RequestedBy,
|
||||
"state": string(req.State),
|
||||
}
|
||||
for k, v := range req.Metadata {
|
||||
details["metadata_"+k] = v
|
||||
}
|
||||
for k, v := range extra {
|
||||
details[k] = v
|
||||
}
|
||||
_ = s.auditService.RecordEvent(ctx, actor, actorType, action,
|
||||
"approval_request", req.ID, details)
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// ApprovalMetrics is a thread-safe counter table for the issuance
|
||||
// approval-workflow dispatch path. Rank 7 of the 2026-05-03 Infisical
|
||||
// deep-research deliverable. Mirrors the ExpiryAlertMetrics +
|
||||
// VaultRenewalMetrics shape: cmd/server/main.go constructs ONE instance,
|
||||
// passes it to ApprovalService (recording side) AND metricsHandler
|
||||
// (exposing side) so the snapshotter is the single source of truth.
|
||||
//
|
||||
// Dimensions:
|
||||
//
|
||||
// outcome — closed enum from internal/domain/approval.go:
|
||||
// "approved" — Approve transitioned a pending request.
|
||||
// "rejected" — Reject transitioned a pending request.
|
||||
// "expired" — scheduler reaper transitioned a stale
|
||||
// pending request via ExpireStale.
|
||||
// "bypassed" — CERTCTL_APPROVAL_BYPASS=true short-
|
||||
// circuited RequestApproval. Production
|
||||
// deploys MUST have zero rows of this
|
||||
// outcome.
|
||||
// profile_id — CertificateProfile.ID that drove the gate. Bounded
|
||||
// cardinality (operators have <100 profiles in production).
|
||||
//
|
||||
// Cardinality bound: 4 outcomes × N profiles. With N=100, that's 400
|
||||
// series — well within Prometheus's per-target series budget for a
|
||||
// well-bounded label.
|
||||
//
|
||||
// Pending-age histogram: ObservePendingAge records the seconds-since-
|
||||
// creation of a pending approval at the moment of decision. Operators
|
||||
// alert when the p99 hits hours/days (compliance has a deadline).
|
||||
// Bucket boundaries: 60, 300, 1800, 3600, 21600, 86400, +Inf — 1
|
||||
// minute, 5 minutes, 30 minutes, 1 hour, 6 hours, 24 hours, beyond.
|
||||
type ApprovalMetrics struct {
|
||||
mu sync.RWMutex
|
||||
counters map[approvalKey]*atomic.Uint64
|
||||
|
||||
pendingAgeHist *approvalDurationHistogram
|
||||
}
|
||||
|
||||
type approvalKey struct {
|
||||
Outcome string
|
||||
ProfileID string
|
||||
}
|
||||
|
||||
// NewApprovalMetrics returns a zero-value ApprovalMetrics ready for
|
||||
// concurrent use. The caller MUST register the same instance on both
|
||||
// the ApprovalService (recording) and the MetricsHandler (exposing)
|
||||
// sides.
|
||||
func NewApprovalMetrics() *ApprovalMetrics {
|
||||
return &ApprovalMetrics{
|
||||
counters: make(map[approvalKey]*atomic.Uint64),
|
||||
pendingAgeHist: newApprovalDurationHistogram(),
|
||||
}
|
||||
}
|
||||
|
||||
// RecordDecision bumps the (outcome, profile_id) counter by one. Called
|
||||
// from ApprovalService.Approve / Reject / ExpireStale and from the
|
||||
// bypass-mode short-circuit inside RequestApproval.
|
||||
func (m *ApprovalMetrics) RecordDecision(outcome, profileID string) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
key := approvalKey{Outcome: outcome, ProfileID: profileID}
|
||||
|
||||
m.mu.RLock()
|
||||
c, ok := m.counters[key]
|
||||
m.mu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
m.mu.Lock()
|
||||
c, ok = m.counters[key]
|
||||
if !ok {
|
||||
c = &atomic.Uint64{}
|
||||
m.counters[key] = c
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
c.Add(1)
|
||||
}
|
||||
|
||||
// ObservePendingAge records the seconds-since-creation of a pending
|
||||
// approval at the moment of decision (Approve / Reject / Expire).
|
||||
func (m *ApprovalMetrics) ObservePendingAge(seconds float64) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
m.pendingAgeHist.observe(seconds)
|
||||
}
|
||||
|
||||
// ApprovalDecisionEntry is a single row of the SnapshotApprovalDecisions
|
||||
// output — the (outcome, profile_id) tuple plus the cumulative count.
|
||||
// Used by the Prometheus exposer to emit
|
||||
// certctl_approval_decisions_total{outcome,profile_id} samples.
|
||||
type ApprovalDecisionEntry struct {
|
||||
Outcome string
|
||||
ProfileID string
|
||||
Count uint64
|
||||
}
|
||||
|
||||
// SnapshotApprovalDecisions returns the current decision counter table
|
||||
// as a sorted slice for deterministic Prometheus exposition. Sort key
|
||||
// is (outcome, profile_id).
|
||||
func (m *ApprovalMetrics) SnapshotApprovalDecisions() []ApprovalDecisionEntry {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
m.mu.RLock()
|
||||
out := make([]ApprovalDecisionEntry, 0, len(m.counters))
|
||||
for k, c := range m.counters {
|
||||
out = append(out, ApprovalDecisionEntry{
|
||||
Outcome: k.Outcome,
|
||||
ProfileID: k.ProfileID,
|
||||
Count: c.Load(),
|
||||
})
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Outcome != out[j].Outcome {
|
||||
return out[i].Outcome < out[j].Outcome
|
||||
}
|
||||
return out[i].ProfileID < out[j].ProfileID
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// ApprovalPendingAgeSnapshot is the snapshot output of
|
||||
// SnapshotApprovalPendingAgeHistogram — bucket bounds + cumulative
|
||||
// counts + sum + total count. Format suits the Prometheus histogram
|
||||
// exposition (le buckets + _sum + _count).
|
||||
type ApprovalPendingAgeSnapshot struct {
|
||||
BucketBounds []float64 // [60, 300, 1800, 3600, 21600, 86400] — exclusive of +Inf
|
||||
BucketCounts []uint64 // cumulative counts per bucket; len = len(BucketBounds) + 1 (last is +Inf)
|
||||
Sum float64
|
||||
Count uint64
|
||||
}
|
||||
|
||||
func (m *ApprovalMetrics) SnapshotApprovalPendingAgeHistogram() ApprovalPendingAgeSnapshot {
|
||||
if m == nil {
|
||||
return ApprovalPendingAgeSnapshot{}
|
||||
}
|
||||
return m.pendingAgeHist.snapshot()
|
||||
}
|
||||
|
||||
// approvalDurationHistogram is a tiny lock-free histogram with fixed
|
||||
// bucket boundaries for approval-pending-age. Atomic counters per
|
||||
// bucket + sum stored as uint64-bits-of-float64 atomic.
|
||||
type approvalDurationHistogram struct {
|
||||
bounds []float64
|
||||
buckets []*atomic.Uint64 // len = len(bounds) + 1; last is +Inf
|
||||
sumBits *atomic.Uint64 // float64 bits stored atomically
|
||||
count *atomic.Uint64
|
||||
}
|
||||
|
||||
func newApprovalDurationHistogram() *approvalDurationHistogram {
|
||||
bounds := []float64{60, 300, 1800, 3600, 21600, 86400}
|
||||
buckets := make([]*atomic.Uint64, len(bounds)+1)
|
||||
for i := range buckets {
|
||||
buckets[i] = &atomic.Uint64{}
|
||||
}
|
||||
return &approvalDurationHistogram{
|
||||
bounds: bounds,
|
||||
buckets: buckets,
|
||||
sumBits: &atomic.Uint64{},
|
||||
count: &atomic.Uint64{},
|
||||
}
|
||||
}
|
||||
|
||||
func (h *approvalDurationHistogram) observe(seconds float64) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
// Find the first bucket whose bound is >= seconds.
|
||||
idx := len(h.bounds) // default to +Inf bucket
|
||||
for i, b := range h.bounds {
|
||||
if seconds <= b {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
h.buckets[idx].Add(1)
|
||||
h.count.Add(1)
|
||||
// Atomic float64 add via CAS loop.
|
||||
for {
|
||||
oldBits := h.sumBits.Load()
|
||||
old := math.Float64frombits(oldBits)
|
||||
newBits := math.Float64bits(old + seconds)
|
||||
if h.sumBits.CompareAndSwap(oldBits, newBits) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *approvalDurationHistogram) snapshot() ApprovalPendingAgeSnapshot {
|
||||
if h == nil {
|
||||
return ApprovalPendingAgeSnapshot{}
|
||||
}
|
||||
counts := make([]uint64, len(h.buckets))
|
||||
cumulative := uint64(0)
|
||||
for i, b := range h.buckets {
|
||||
cumulative += b.Load()
|
||||
counts[i] = cumulative
|
||||
}
|
||||
return ApprovalPendingAgeSnapshot{
|
||||
BucketBounds: append([]float64(nil), h.bounds...),
|
||||
BucketCounts: counts,
|
||||
Sum: math.Float64frombits(h.sumBits.Load()),
|
||||
Count: h.count.Load(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/domain"
|
||||
"github.com/certctl-io/certctl/internal/repository"
|
||||
)
|
||||
|
||||
// fakeApprovalRepo is a minimal in-memory ApprovalRepository for unit
|
||||
// testing the service-layer logic in isolation. Stores rows in a map
|
||||
// keyed by ID; List returns rows matching a single state filter.
|
||||
type fakeApprovalRepo struct {
|
||||
mu sync.Mutex
|
||||
rows map[string]*domain.ApprovalRequest
|
||||
}
|
||||
|
||||
func newFakeApprovalRepo() *fakeApprovalRepo {
|
||||
return &fakeApprovalRepo{rows: make(map[string]*domain.ApprovalRequest)}
|
||||
}
|
||||
|
||||
func (f *fakeApprovalRepo) Create(ctx context.Context, req *domain.ApprovalRequest) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if req.ID == "" {
|
||||
req.ID = "ar-fake-" + time.Now().Format("150405.000000000")
|
||||
}
|
||||
// Enforce the partial-unique pending-per-job at the mock layer too.
|
||||
for _, existing := range f.rows {
|
||||
if existing.JobID == req.JobID && existing.State == domain.ApprovalStatePending {
|
||||
return repository.ErrAlreadyExists
|
||||
}
|
||||
}
|
||||
cp := *req
|
||||
f.rows[req.ID] = &cp
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeApprovalRepo) Get(ctx context.Context, id string) (*domain.ApprovalRequest, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if r, ok := f.rows[id]; ok {
|
||||
cp := *r
|
||||
return &cp, nil
|
||||
}
|
||||
return nil, repository.ErrNotFound
|
||||
}
|
||||
|
||||
func (f *fakeApprovalRepo) GetByJobID(ctx context.Context, jobID string) (*domain.ApprovalRequest, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, r := range f.rows {
|
||||
if r.JobID == jobID {
|
||||
cp := *r
|
||||
return &cp, nil
|
||||
}
|
||||
}
|
||||
return nil, repository.ErrNotFound
|
||||
}
|
||||
|
||||
func (f *fakeApprovalRepo) List(ctx context.Context, filter *repository.ApprovalFilter) ([]*domain.ApprovalRequest, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []*domain.ApprovalRequest
|
||||
for _, r := range f.rows {
|
||||
if filter != nil && filter.State != "" && string(r.State) != filter.State {
|
||||
continue
|
||||
}
|
||||
if filter != nil && filter.CertificateID != "" && r.CertificateID != filter.CertificateID {
|
||||
continue
|
||||
}
|
||||
if filter != nil && filter.RequestedBy != "" && r.RequestedBy != filter.RequestedBy {
|
||||
continue
|
||||
}
|
||||
cp := *r
|
||||
out = append(out, &cp)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeApprovalRepo) UpdateState(ctx context.Context, id string, state domain.ApprovalState,
|
||||
decidedBy string, decidedAt time.Time, note string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
r, ok := f.rows[id]
|
||||
if !ok {
|
||||
return repository.ErrNotFound
|
||||
}
|
||||
if r.State != domain.ApprovalStatePending {
|
||||
return repository.ErrAlreadyExists // signals "already terminal"
|
||||
}
|
||||
r.State = state
|
||||
r.DecidedBy = &decidedBy
|
||||
r.DecidedAt = &decidedAt
|
||||
if note != "" {
|
||||
n := note
|
||||
r.DecisionNote = &n
|
||||
}
|
||||
r.UpdatedAt = decidedAt
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeApprovalRepo) ExpireStale(ctx context.Context, before time.Time) (int, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
now := time.Now().UTC()
|
||||
count := 0
|
||||
for _, r := range f.rows {
|
||||
if r.State == domain.ApprovalStatePending && (r.CreatedAt.Before(before) || r.CreatedAt.Equal(before)) {
|
||||
r.State = domain.ApprovalStateExpired
|
||||
s := "system-reaper"
|
||||
r.DecidedBy = &s
|
||||
r.DecidedAt = &now
|
||||
r.UpdatedAt = now
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// fakeJobStateRepo implements service.JobStatusUpdater and tracks per-job
|
||||
// status mutations so the tests can introspect them. It does NOT implement
|
||||
// the full repository.JobRepository — ApprovalService only needs UpdateStatus.
|
||||
type fakeJobStateRepo struct {
|
||||
mu sync.Mutex
|
||||
statuses map[string]domain.JobStatus
|
||||
}
|
||||
|
||||
func newFakeJobStateRepo() *fakeJobStateRepo {
|
||||
return &fakeJobStateRepo{statuses: make(map[string]domain.JobStatus)}
|
||||
}
|
||||
|
||||
func (f *fakeJobStateRepo) seed(id string, status domain.JobStatus) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.statuses[id] = status
|
||||
}
|
||||
|
||||
func (f *fakeJobStateRepo) status(id string) domain.JobStatus {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.statuses[id]
|
||||
}
|
||||
|
||||
func (f *fakeJobStateRepo) UpdateStatus(ctx context.Context, id string, status domain.JobStatus, errMsg string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.statuses[id] = status
|
||||
return nil
|
||||
}
|
||||
|
||||
// helper builders --------------------------------------------------------
|
||||
|
||||
func newApprovalSvcForTest(bypass bool) (*ApprovalService, *fakeApprovalRepo, *fakeJobStateRepo) {
|
||||
ar := newFakeApprovalRepo()
|
||||
jr := newFakeJobStateRepo()
|
||||
metrics := NewApprovalMetrics()
|
||||
svc := NewApprovalService(ar, jr, nil, metrics, bypass)
|
||||
return svc, ar, jr
|
||||
}
|
||||
|
||||
func sampleCert() *domain.ManagedCertificate {
|
||||
return &domain.ManagedCertificate{ID: "mc-test-cert"}
|
||||
}
|
||||
|
||||
// tests ------------------------------------------------------------------
|
||||
|
||||
func TestApproval_RequestCreatesPendingRow_BypassDisabled(t *testing.T) {
|
||||
svc, ar, jr := newApprovalSvcForTest(false)
|
||||
jr.seed("job-1", domain.JobStatusAwaitingApproval)
|
||||
|
||||
id, err := svc.RequestApproval(context.Background(), sampleCert(),
|
||||
"job-1", "profile-prod-cdn", "user-alice", map[string]string{"common_name": "api.example.com"})
|
||||
if err != nil {
|
||||
t.Fatalf("RequestApproval err: %v", err)
|
||||
}
|
||||
got, err := ar.Get(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatalf("Get err: %v", err)
|
||||
}
|
||||
if got.State != domain.ApprovalStatePending {
|
||||
t.Fatalf("expected state=pending, got %s", got.State)
|
||||
}
|
||||
if got.RequestedBy != "user-alice" {
|
||||
t.Fatalf("requested_by mismatch: %s", got.RequestedBy)
|
||||
}
|
||||
if jr.status("job-1") != domain.JobStatusAwaitingApproval {
|
||||
t.Fatalf("job should remain AwaitingApproval; got %s", jr.status("job-1"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproval_BypassMode_AutoApprovesWithSystemBypassActor(t *testing.T) {
|
||||
svc, ar, jr := newApprovalSvcForTest(true)
|
||||
jr.seed("job-2", domain.JobStatusAwaitingApproval)
|
||||
|
||||
id, err := svc.RequestApproval(context.Background(), sampleCert(),
|
||||
"job-2", "profile-iot", "user-bob", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("bypass RequestApproval err: %v", err)
|
||||
}
|
||||
got, err := ar.Get(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatalf("Get err: %v", err)
|
||||
}
|
||||
if got.State != domain.ApprovalStateApproved {
|
||||
t.Fatalf("bypass should auto-approve; got state=%s", got.State)
|
||||
}
|
||||
if got.DecidedBy == nil || *got.DecidedBy != domain.ApprovalActorSystemBypass {
|
||||
t.Fatalf("bypass should stamp decided_by=%s; got %v",
|
||||
domain.ApprovalActorSystemBypass, got.DecidedBy)
|
||||
}
|
||||
if jr.status("job-2") != domain.JobStatusPending {
|
||||
t.Fatalf("bypass should transition job to Pending; got %s", jr.status("job-2"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproval_Approve_TransitionsJobFromAwaitingApprovalToPending(t *testing.T) {
|
||||
svc, ar, jr := newApprovalSvcForTest(false)
|
||||
jr.seed("job-3", domain.JobStatusAwaitingApproval)
|
||||
id, _ := svc.RequestApproval(context.Background(), sampleCert(), "job-3", "p1", "user-alice", nil)
|
||||
|
||||
if err := svc.Approve(context.Background(), id, "user-bob", "approved per ticket SECOPS-123"); err != nil {
|
||||
t.Fatalf("Approve err: %v", err)
|
||||
}
|
||||
got, _ := ar.Get(context.Background(), id)
|
||||
if got.State != domain.ApprovalStateApproved {
|
||||
t.Fatalf("expected state=approved; got %s", got.State)
|
||||
}
|
||||
if jr.status("job-3") != domain.JobStatusPending {
|
||||
t.Fatalf("expected job=Pending; got %s", jr.status("job-3"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproval_Reject_TransitionsJobFromAwaitingApprovalToCancelled(t *testing.T) {
|
||||
svc, ar, jr := newApprovalSvcForTest(false)
|
||||
jr.seed("job-4", domain.JobStatusAwaitingApproval)
|
||||
id, _ := svc.RequestApproval(context.Background(), sampleCert(), "job-4", "p1", "user-alice", nil)
|
||||
|
||||
if err := svc.Reject(context.Background(), id, "user-bob", "not on the approved-domains list"); err != nil {
|
||||
t.Fatalf("Reject err: %v", err)
|
||||
}
|
||||
got, _ := ar.Get(context.Background(), id)
|
||||
if got.State != domain.ApprovalStateRejected {
|
||||
t.Fatalf("expected state=rejected; got %s", got.State)
|
||||
}
|
||||
if jr.status("job-4") != domain.JobStatusCancelled {
|
||||
t.Fatalf("expected job=Cancelled; got %s", jr.status("job-4"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproval_Approve_RejectsSameActor(t *testing.T) {
|
||||
// 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.
|
||||
svc, _, jr := newApprovalSvcForTest(false)
|
||||
jr.seed("job-5", domain.JobStatusAwaitingApproval)
|
||||
id, _ := svc.RequestApproval(context.Background(), sampleCert(), "job-5", "p1", "user-alice", nil)
|
||||
|
||||
err := svc.Approve(context.Background(), id, "user-alice", "trying to self-approve")
|
||||
if !errors.Is(err, ErrApproveBySameActor) {
|
||||
t.Fatalf("expected ErrApproveBySameActor; got %v", err)
|
||||
}
|
||||
if jr.status("job-5") != domain.JobStatusAwaitingApproval {
|
||||
t.Fatalf("job should remain AwaitingApproval; got %s", jr.status("job-5"))
|
||||
}
|
||||
|
||||
// Approval as a different actor succeeds.
|
||||
if err := svc.Approve(context.Background(), id, "user-bob", "approved by separate actor"); err != nil {
|
||||
t.Fatalf("Approve as different actor err: %v", err)
|
||||
}
|
||||
if jr.status("job-5") != domain.JobStatusPending {
|
||||
t.Fatalf("expected job=Pending after bob approve; got %s", jr.status("job-5"))
|
||||
}
|
||||
|
||||
// Same-actor rejection also fails.
|
||||
jr.seed("job-5b", domain.JobStatusAwaitingApproval)
|
||||
id2, _ := svc.RequestApproval(context.Background(), sampleCert(), "job-5b", "p1", "user-charlie", nil)
|
||||
err2 := svc.Reject(context.Background(), id2, "user-charlie", "self-reject")
|
||||
if !errors.Is(err2, ErrApproveBySameActor) {
|
||||
t.Fatalf("expected ErrApproveBySameActor on Reject; got %v", err2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproval_Approve_RejectsAlreadyDecided(t *testing.T) {
|
||||
svc, _, jr := newApprovalSvcForTest(false)
|
||||
jr.seed("job-6", domain.JobStatusAwaitingApproval)
|
||||
id, _ := svc.RequestApproval(context.Background(), sampleCert(), "job-6", "p1", "user-alice", nil)
|
||||
if err := svc.Approve(context.Background(), id, "user-bob", ""); err != nil {
|
||||
t.Fatalf("first Approve err: %v", err)
|
||||
}
|
||||
|
||||
err := svc.Approve(context.Background(), id, "user-charlie", "second approve")
|
||||
if !errors.Is(err, ErrApprovalAlreadyDecided) {
|
||||
t.Fatalf("expected ErrApprovalAlreadyDecided; got %v", err)
|
||||
}
|
||||
err2 := svc.Reject(context.Background(), id, "user-charlie", "late reject")
|
||||
if !errors.Is(err2, ErrApprovalAlreadyDecided) {
|
||||
t.Fatalf("expected ErrApprovalAlreadyDecided on Reject; got %v", err2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproval_ExpireStale_TransitionsPendingToExpired_AndCancelsJob(t *testing.T) {
|
||||
svc, ar, jr := newApprovalSvcForTest(false)
|
||||
jr.seed("job-7", domain.JobStatusAwaitingApproval)
|
||||
jr.seed("job-8", domain.JobStatusAwaitingApproval)
|
||||
id7, _ := svc.RequestApproval(context.Background(), sampleCert(), "job-7", "p1", "user-alice", nil)
|
||||
id8, _ := svc.RequestApproval(context.Background(), sampleCert(), "job-8", "p1", "user-alice", nil)
|
||||
|
||||
// Backdate one of the requests to before the cutoff.
|
||||
old := time.Now().Add(-200 * time.Hour).UTC()
|
||||
ar.mu.Lock()
|
||||
ar.rows[id7].CreatedAt = old
|
||||
ar.mu.Unlock()
|
||||
|
||||
cutoff := time.Now().Add(-168 * time.Hour).UTC()
|
||||
count, err := svc.ExpireStale(context.Background(), cutoff)
|
||||
if err != nil {
|
||||
t.Fatalf("ExpireStale err: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("expected 1 row expired; got %d", count)
|
||||
}
|
||||
got7, _ := ar.Get(context.Background(), id7)
|
||||
if got7.State != domain.ApprovalStateExpired {
|
||||
t.Fatalf("expected job-7 expired; got %s", got7.State)
|
||||
}
|
||||
got8, _ := ar.Get(context.Background(), id8)
|
||||
if got8.State != domain.ApprovalStatePending {
|
||||
t.Fatalf("job-8 should still be pending; got %s", got8.State)
|
||||
}
|
||||
if jr.status("job-7") != domain.JobStatusCancelled {
|
||||
t.Fatalf("expected job-7 cancelled; got %s", jr.status("job-7"))
|
||||
}
|
||||
if jr.status("job-8") != domain.JobStatusAwaitingApproval {
|
||||
t.Fatalf("job-8 should remain AwaitingApproval; got %s", jr.status("job-8"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproval_MetricCounterIncrements(t *testing.T) {
|
||||
svc, _, jr := newApprovalSvcForTest(false)
|
||||
metrics := svc.metrics
|
||||
|
||||
jr.seed("job-9", domain.JobStatusAwaitingApproval)
|
||||
id9, _ := svc.RequestApproval(context.Background(), sampleCert(), "job-9", "p-cdn", "user-alice", nil)
|
||||
_ = svc.Approve(context.Background(), id9, "user-bob", "approved")
|
||||
|
||||
jr.seed("job-10", domain.JobStatusAwaitingApproval)
|
||||
id10, _ := svc.RequestApproval(context.Background(), sampleCert(), "job-10", "p-cdn", "user-alice", nil)
|
||||
_ = svc.Reject(context.Background(), id10, "user-bob", "rejected")
|
||||
|
||||
jr.seed("job-11", domain.JobStatusAwaitingApproval)
|
||||
id11, _ := svc.RequestApproval(context.Background(), sampleCert(), "job-11", "p-cdn", "user-alice", nil)
|
||||
// Backdate + expire.
|
||||
old := time.Now().Add(-200 * time.Hour).UTC()
|
||||
repo := svc.approvalRepo.(*fakeApprovalRepo)
|
||||
repo.mu.Lock()
|
||||
repo.rows[id11].CreatedAt = old
|
||||
repo.mu.Unlock()
|
||||
if _, err := svc.ExpireStale(context.Background(), time.Now().Add(-168*time.Hour)); err != nil {
|
||||
t.Fatalf("ExpireStale err: %v", err)
|
||||
}
|
||||
|
||||
snap := metrics.SnapshotApprovalDecisions()
|
||||
got := map[string]uint64{}
|
||||
for _, e := range snap {
|
||||
got[e.Outcome] = e.Count
|
||||
}
|
||||
if got[domain.ApprovalOutcomeApproved] != 1 {
|
||||
t.Fatalf("expected 1 approved counter; got %d", got[domain.ApprovalOutcomeApproved])
|
||||
}
|
||||
if got[domain.ApprovalOutcomeRejected] != 1 {
|
||||
t.Fatalf("expected 1 rejected counter; got %d", got[domain.ApprovalOutcomeRejected])
|
||||
}
|
||||
if got[domain.ApprovalOutcomeExpired] != 1 {
|
||||
t.Fatalf("expected 1 expired counter; got %d", got[domain.ApprovalOutcomeExpired])
|
||||
}
|
||||
|
||||
// Histogram observed at least 3 samples.
|
||||
hist := metrics.SnapshotApprovalPendingAgeHistogram()
|
||||
if hist.Count < 3 {
|
||||
t.Fatalf("expected at least 3 histogram samples; got %d", hist.Count)
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,18 @@ type CertificateService struct {
|
||||
// falls back to the historical on-demand path via caSvc.
|
||||
crlCacheSvc *CRLCacheService
|
||||
keygenMode string
|
||||
|
||||
// approvalSvc + profileRepo, when both set, gate TriggerRenewal on
|
||||
// CertificateProfile.RequiresApproval. The job is created at
|
||||
// JobStatusAwaitingApproval (rather than Pending / AwaitingCSR) and
|
||||
// a parallel ApprovalRequest row is created via approvalSvc. The
|
||||
// scheduler does NOT dispatch until ApprovalService.Approve
|
||||
// transitions the job to Pending. Rank 7 of the 2026-05-03
|
||||
// Infisical deep-research deliverable. Both setters are optional —
|
||||
// when either is nil, gating is skipped and TriggerRenewal falls
|
||||
// back to the historical unattended path.
|
||||
approvalSvc *ApprovalService
|
||||
profileRepo repository.CertificateProfileRepository
|
||||
}
|
||||
|
||||
// NewCertificateService creates a new certificate service.
|
||||
@@ -93,6 +105,21 @@ func (s *CertificateService) SetKeygenMode(mode string) {
|
||||
s.keygenMode = mode
|
||||
}
|
||||
|
||||
// SetApprovalService wires the approval-workflow service. When both this
|
||||
// and SetProfileRepo are wired, TriggerRenewal gates on
|
||||
// CertificateProfile.RequiresApproval. Rank 7 of the 2026-05-03 Infisical
|
||||
// deep-research deliverable.
|
||||
func (s *CertificateService) SetApprovalService(svc *ApprovalService) {
|
||||
s.approvalSvc = svc
|
||||
}
|
||||
|
||||
// SetProfileRepo wires the certificate-profile repository for the
|
||||
// approval-workflow gate. Without it, TriggerRenewal cannot read the
|
||||
// per-profile RequiresApproval flag and gating is skipped.
|
||||
func (s *CertificateService) SetProfileRepo(repo repository.CertificateProfileRepository) {
|
||||
s.profileRepo = repo
|
||||
}
|
||||
|
||||
// List returns a paginated list of certificates matching the filter.
|
||||
func (s *CertificateService) List(ctx context.Context, filter *repository.CertificateFilter) ([]*domain.ManagedCertificate, int, error) {
|
||||
certs, total, err := s.certRepo.List(ctx, filter)
|
||||
@@ -288,9 +315,35 @@ func (s *CertificateService) TriggerRenewal(ctx context.Context, certID string,
|
||||
// Create a renewal job so the job processor can pick it up.
|
||||
// In agent keygen mode, the job starts as AwaitingCSR so the agent
|
||||
// generates the key pair and submits a CSR. In server mode, it starts as Pending.
|
||||
//
|
||||
// Rank 7: if the cert's profile has RequiresApproval=true and the
|
||||
// approval service + profile repo are wired, the job is created at
|
||||
// JobStatusAwaitingApproval (overriding the keygen-mode default) and
|
||||
// a parallel ApprovalRequest row is created. The scheduler does NOT
|
||||
// dispatch until ApprovalService.Approve transitions the job to
|
||||
// Pending. Profile lookup failures fall back to the historical
|
||||
// unattended path so a transient profile-repo error never silently
|
||||
// blocks renewal — the gate is fail-open from the operator's
|
||||
// perspective + fail-loud via the slog warning.
|
||||
if s.jobRepo != nil {
|
||||
needsApproval := false
|
||||
var approvalProfileID string
|
||||
if s.approvalSvc != nil && s.profileRepo != nil && cert.CertificateProfileID != "" {
|
||||
profile, profileErr := s.profileRepo.Get(ctx, cert.CertificateProfileID)
|
||||
if profileErr != nil {
|
||||
slog.Warn("approval gate: profile lookup failed; falling back to unattended path",
|
||||
"cert_id", cert.ID, "profile_id", cert.CertificateProfileID, "error", profileErr)
|
||||
} else if profile != nil && profile.RequiresApproval {
|
||||
needsApproval = true
|
||||
approvalProfileID = profile.ID
|
||||
}
|
||||
}
|
||||
|
||||
jobStatus := domain.JobStatusPending
|
||||
if s.keygenMode == "agent" {
|
||||
switch {
|
||||
case needsApproval:
|
||||
jobStatus = domain.JobStatusAwaitingApproval
|
||||
case s.keygenMode == "agent":
|
||||
jobStatus = domain.JobStatusAwaitingCSR
|
||||
}
|
||||
|
||||
@@ -316,6 +369,29 @@ func (s *CertificateService) TriggerRenewal(ctx context.Context, certID string,
|
||||
return fmt.Errorf("failed to create renewal job: %w", err)
|
||||
}
|
||||
|
||||
// Create the parallel ApprovalRequest row. If RequestApproval fails,
|
||||
// transition the job to Cancelled so the scheduler doesn't dispatch
|
||||
// a half-gated request (defense in depth — without this, a partial
|
||||
// failure would leave the job at AwaitingApproval forever, blocking
|
||||
// renewal until the operator manually intervenes).
|
||||
if needsApproval {
|
||||
metadata := map[string]string{
|
||||
"common_name": cert.CommonName,
|
||||
}
|
||||
if _, apErr := s.approvalSvc.RequestApproval(ctx, cert, job.ID, approvalProfileID, actor, metadata); apErr != nil {
|
||||
slog.Error("approval gate: failed to create ApprovalRequest row; cancelling gated job",
|
||||
"cert_id", cert.ID, "job_id", job.ID, "error", apErr)
|
||||
if cancelErr := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusCancelled,
|
||||
"approval request creation failed: "+apErr.Error()); cancelErr != nil {
|
||||
slog.Error("approval gate: also failed to cancel orphan job",
|
||||
"cert_id", cert.ID, "job_id", job.ID, "error", cancelErr)
|
||||
}
|
||||
return fmt.Errorf("failed to create approval request: %w", apErr)
|
||||
}
|
||||
slog.Info("approval gate fired", "cert_id", cert.ID, "job_id", job.ID,
|
||||
"profile_id", approvalProfileID, "requested_by", actor)
|
||||
}
|
||||
|
||||
slog.Info("created renewal job via API trigger",
|
||||
"job_id", job.ID,
|
||||
"cert_id", cert.ID,
|
||||
|
||||
@@ -0,0 +1,540 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/crypto/signer"
|
||||
"github.com/certctl-io/certctl/internal/domain"
|
||||
"github.com/certctl-io/certctl/internal/repository"
|
||||
)
|
||||
|
||||
// IntermediateCAService manages first-class CA hierarchies for the
|
||||
// local issuer's tree mode. Rank 8.
|
||||
//
|
||||
// Lifecycle: an admin-gated operator calls CreateRoot to register an
|
||||
// operator-supplied root cert+key as the issuer's active root. They
|
||||
// then chain CreateChild calls to build out the hierarchy — each
|
||||
// child's cert is signed by its parent's signer. AssembleChain walks
|
||||
// the tree at leaf-issuance time to produce the PEM bundle the local
|
||||
// connector attaches to IssuanceResult.
|
||||
//
|
||||
// Defense in depth: NEVER persist CA private key bytes. Every
|
||||
// IntermediateCA carries a key_driver_id pointing at the signer.Driver
|
||||
// instance that owns its private key. The default driver is
|
||||
// signer.FileDriver (matching the historical single-sub-CA mode); HSM-
|
||||
// backed and KMS-backed drivers (PKCS#11, AWS KMS, Azure Key Vault HSM)
|
||||
// plug in via the existing seam without touching this service.
|
||||
//
|
||||
// Concurrency: every CreateChild that touches a parent reads the
|
||||
// parent's signer fresh from the driver — no shared in-memory parent-
|
||||
// signer state. Callers should serialize CreateChild against the same
|
||||
// parent at the API layer (admin-gated; not a hot path).
|
||||
type IntermediateCAService struct {
|
||||
repo repository.IntermediateCARepository
|
||||
issuerRepo repository.IssuerRepository
|
||||
signerDriver signer.Driver
|
||||
auditService *AuditService
|
||||
metrics *IntermediateCAMetrics
|
||||
}
|
||||
|
||||
// NewIntermediateCAService constructs the service. metrics may be nil
|
||||
// for tests; auditService should not be nil in production.
|
||||
func NewIntermediateCAService(
|
||||
repo repository.IntermediateCARepository,
|
||||
issuerRepo repository.IssuerRepository,
|
||||
signerDriver signer.Driver,
|
||||
auditService *AuditService,
|
||||
metrics *IntermediateCAMetrics,
|
||||
) *IntermediateCAService {
|
||||
return &IntermediateCAService{
|
||||
repo: repo,
|
||||
issuerRepo: issuerRepo,
|
||||
signerDriver: signerDriver,
|
||||
auditService: auditService,
|
||||
metrics: metrics,
|
||||
}
|
||||
}
|
||||
|
||||
// Sentinels for handler-side dispatch via errors.Is.
|
||||
var (
|
||||
ErrIntermediateCANotFound = errors.New("intermediate CA not found")
|
||||
ErrCANotSelfSigned = errors.New("supplied root cert is not self-signed")
|
||||
ErrCAKeyMismatch = errors.New("supplied CA key does not match the supplied cert")
|
||||
ErrParentCANotActive = errors.New("parent CA is not in active state")
|
||||
ErrPathLenExceeded = errors.New("requested path length exceeds parent's PathLenConstraint")
|
||||
ErrNameConstraintExceeded = errors.New("child name constraints not a subset of parent's")
|
||||
ErrCAStillHasActiveChildren = errors.New("CA cannot retire: active children still issuing")
|
||||
ErrInvalidCertPEM = errors.New("invalid cert PEM")
|
||||
)
|
||||
|
||||
// CreateRootOptions are the optional parameters for CreateRoot. The
|
||||
// rootCert + rootKey are operator-supplied; this struct carries
|
||||
// per-CA bookkeeping that doesn't live in the cert itself.
|
||||
type CreateRootOptions struct {
|
||||
OCSPResponderURL string
|
||||
Metadata map[string]string
|
||||
}
|
||||
|
||||
// CreateChildOptions are the parameters for CreateChild — everything
|
||||
// the service needs to build a fresh sub-CA cert under a parent.
|
||||
type CreateChildOptions struct {
|
||||
Subject pkix.Name
|
||||
Algorithm signer.Algorithm
|
||||
TTL time.Duration // child's validity window
|
||||
PathLenConstraint *int // RFC 5280 §4.2.1.9; nil = inherit (parent - 1) or no constraint
|
||||
NameConstraints []domain.NameConstraint
|
||||
OCSPResponderURL string
|
||||
Metadata map[string]string
|
||||
}
|
||||
|
||||
// CreateRoot registers an operator-supplied root cert as the issuer's
|
||||
// active root, paired with a pre-positioned signer.Driver reference
|
||||
// (file path / HSM slot / KMS resource name) that the operator owns.
|
||||
// Validates the cert is self-signed (subject == issuer per RFC 5280
|
||||
// §3.2) AND that the signer.Driver-loadable key at keyDriverID has a
|
||||
// public key matching the cert's public key (rejects mismatched
|
||||
// bundles at the operator boundary, not just at signing time).
|
||||
// Returns the new ica-<slug> ID.
|
||||
func (s *IntermediateCAService) CreateRoot(ctx context.Context, issuerID, name, decidedBy string,
|
||||
rootCertPEM []byte, keyDriverID string, opts *CreateRootOptions) (string, error) {
|
||||
if opts == nil {
|
||||
opts = &CreateRootOptions{}
|
||||
}
|
||||
if keyDriverID == "" {
|
||||
return "", fmt.Errorf("CreateRoot: keyDriverID required")
|
||||
}
|
||||
|
||||
cert, err := parseCertPEM(rootCertPEM)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("CreateRoot: %w", err)
|
||||
}
|
||||
|
||||
// RFC 5280 §3.2: a root cert is self-signed (subject == issuer +
|
||||
// signature verifies under the cert's own public key).
|
||||
if !cert.IsCA {
|
||||
return "", fmt.Errorf("CreateRoot: %w: cert lacks BasicConstraints CA:TRUE", ErrCANotSelfSigned)
|
||||
}
|
||||
if err := cert.CheckSignatureFrom(cert); err != nil {
|
||||
return "", fmt.Errorf("CreateRoot: %w: %v", ErrCANotSelfSigned, err)
|
||||
}
|
||||
|
||||
// Verify the supplied keyDriverID resolves to a signer whose public
|
||||
// key matches the cert's public key. Defense-in-depth — catches
|
||||
// operator wiring errors at registration time rather than at first
|
||||
// CreateChild attempt.
|
||||
rootSigner, err := s.signerDriver.Load(ctx, keyDriverID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("CreateRoot: load key: %w", err)
|
||||
}
|
||||
if !publicKeysEqual(rootSigner.Public(), cert.PublicKey) {
|
||||
return "", ErrCAKeyMismatch
|
||||
}
|
||||
|
||||
ca := &domain.IntermediateCA{
|
||||
OwningIssuerID: issuerID,
|
||||
ParentCAID: nil, // root has no parent
|
||||
Name: name,
|
||||
Subject: cert.Subject.String(),
|
||||
State: domain.IntermediateCAStateActive,
|
||||
CertPEM: string(rootCertPEM),
|
||||
KeyDriverID: keyDriverID,
|
||||
NotBefore: cert.NotBefore,
|
||||
NotAfter: cert.NotAfter,
|
||||
PathLenConstraint: pathLenFromCert(cert),
|
||||
NameConstraints: nameConstraintsFromCert(cert),
|
||||
OCSPResponderURL: opts.OCSPResponderURL,
|
||||
Metadata: opts.Metadata,
|
||||
}
|
||||
if err := s.repo.Create(ctx, ca); err != nil {
|
||||
return "", fmt.Errorf("CreateRoot: %w", err)
|
||||
}
|
||||
|
||||
s.recordAudit(ctx, decidedBy, domain.ActorTypeUser, "intermediate_ca_root_created", ca, nil)
|
||||
if s.metrics != nil {
|
||||
s.metrics.RecordCreate(ca.OwningIssuerID, "root")
|
||||
}
|
||||
return ca.ID, nil
|
||||
}
|
||||
|
||||
// CreateChild signs a new sub-CA cert under the given parent.
|
||||
// Enforces RFC 5280 §4.2.1.9 (PathLenConstraint must not exceed
|
||||
// parent's) + §4.2.1.10 (NameConstraints must be a subset of
|
||||
// parent's). Generates the child's key via the signer.Driver; signs
|
||||
// the cert via the parent's signer (loaded by the parent's
|
||||
// KeyDriverID).
|
||||
func (s *IntermediateCAService) CreateChild(ctx context.Context, parentCAID, name, decidedBy string,
|
||||
opts *CreateChildOptions) (string, error) {
|
||||
if opts == nil {
|
||||
return "", fmt.Errorf("CreateChild: opts required")
|
||||
}
|
||||
|
||||
parent, err := s.repo.Get(ctx, parentCAID)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
return "", ErrIntermediateCANotFound
|
||||
}
|
||||
return "", fmt.Errorf("CreateChild: get parent: %w", err)
|
||||
}
|
||||
if parent.State != domain.IntermediateCAStateActive {
|
||||
return "", ErrParentCANotActive
|
||||
}
|
||||
|
||||
parentCert, err := parseCertPEM([]byte(parent.CertPEM))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("CreateChild: parent cert: %w", err)
|
||||
}
|
||||
|
||||
// RFC 5280 §4.2.1.9 enforcement.
|
||||
childPathLen := opts.PathLenConstraint
|
||||
if parent.PathLenConstraint != nil {
|
||||
if childPathLen != nil && *childPathLen >= *parent.PathLenConstraint {
|
||||
return "", ErrPathLenExceeded
|
||||
}
|
||||
// If unset, default to parent - 1 (or 0 if parent is 0).
|
||||
if childPathLen == nil {
|
||||
v := *parent.PathLenConstraint - 1
|
||||
if v < 0 {
|
||||
v = 0
|
||||
}
|
||||
childPathLen = &v
|
||||
}
|
||||
}
|
||||
|
||||
// RFC 5280 §4.2.1.10 enforcement: child's permitted ⊆ parent's
|
||||
// permitted; child's excluded ⊇ parent's excluded.
|
||||
if err := validateNameConstraintsSubset(parent.NameConstraints, opts.NameConstraints); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Generate the child's key via the signer.Driver.
|
||||
childSigner, keyDriverID, err := s.signerDriver.Generate(ctx, opts.Algorithm)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("CreateChild: generate key: %w", err)
|
||||
}
|
||||
|
||||
// Load the parent's signer to sign the child's cert.
|
||||
parentSigner, err := s.signerDriver.Load(ctx, parent.KeyDriverID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("CreateChild: load parent signer: %w", err)
|
||||
}
|
||||
|
||||
// Build the child cert template.
|
||||
now := time.Now().UTC()
|
||||
ttl := opts.TTL
|
||||
if ttl <= 0 {
|
||||
ttl = 5 * 365 * 24 * time.Hour // 5y default for sub-CAs
|
||||
}
|
||||
notBefore := now
|
||||
notAfter := now.Add(ttl)
|
||||
if notAfter.After(parentCert.NotAfter) {
|
||||
// Child must not outlive parent (RFC 5280 §4.1.2.5; cert chain
|
||||
// breaks at parent's expiry regardless).
|
||||
notAfter = parentCert.NotAfter
|
||||
}
|
||||
|
||||
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("CreateChild: serial: %w", err)
|
||||
}
|
||||
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: opts.Subject,
|
||||
NotBefore: notBefore,
|
||||
NotAfter: notAfter,
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
}
|
||||
if childPathLen != nil {
|
||||
template.MaxPathLen = *childPathLen
|
||||
template.MaxPathLenZero = (*childPathLen == 0)
|
||||
}
|
||||
if len(opts.NameConstraints) > 0 {
|
||||
var permitted, excluded []string
|
||||
for _, nc := range opts.NameConstraints {
|
||||
permitted = append(permitted, nc.Permitted...)
|
||||
excluded = append(excluded, nc.Excluded...)
|
||||
}
|
||||
template.PermittedDNSDomains = permitted
|
||||
template.ExcludedDNSDomains = excluded
|
||||
template.PermittedDNSDomainsCritical = true
|
||||
}
|
||||
if opts.OCSPResponderURL != "" {
|
||||
template.OCSPServer = []string{opts.OCSPResponderURL}
|
||||
}
|
||||
|
||||
childDER, err := x509.CreateCertificate(rand.Reader, template, parentCert, childSigner.Public(), parentSigner)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("CreateChild: sign cert: %w", err)
|
||||
}
|
||||
childPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: childDER})
|
||||
|
||||
parentID := parent.ID
|
||||
ca := &domain.IntermediateCA{
|
||||
OwningIssuerID: parent.OwningIssuerID,
|
||||
ParentCAID: &parentID,
|
||||
Name: name,
|
||||
Subject: opts.Subject.String(),
|
||||
State: domain.IntermediateCAStateActive,
|
||||
CertPEM: string(childPEM),
|
||||
KeyDriverID: keyDriverID,
|
||||
NotBefore: notBefore,
|
||||
NotAfter: notAfter,
|
||||
PathLenConstraint: childPathLen,
|
||||
NameConstraints: opts.NameConstraints,
|
||||
OCSPResponderURL: opts.OCSPResponderURL,
|
||||
Metadata: opts.Metadata,
|
||||
}
|
||||
if err := s.repo.Create(ctx, ca); err != nil {
|
||||
return "", fmt.Errorf("CreateChild: create row: %w", err)
|
||||
}
|
||||
|
||||
s.recordAudit(ctx, decidedBy, domain.ActorTypeUser, "intermediate_ca_child_created", ca,
|
||||
map[string]interface{}{"parent_ca_id": parent.ID})
|
||||
if s.metrics != nil {
|
||||
s.metrics.RecordCreate(parent.OwningIssuerID, "child")
|
||||
}
|
||||
return ca.ID, nil
|
||||
}
|
||||
|
||||
// Retire transitions a CA's state. First call: active → retiring.
|
||||
// Second call (with confirm=true): retiring → retired. Refuses retired
|
||||
// transition if active children still exist (drain-first semantics).
|
||||
func (s *IntermediateCAService) Retire(ctx context.Context, caID, decidedBy, note string, confirm bool) error {
|
||||
ca, err := s.repo.Get(ctx, caID)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
return ErrIntermediateCANotFound
|
||||
}
|
||||
return fmt.Errorf("Retire: get: %w", err)
|
||||
}
|
||||
|
||||
var newState domain.IntermediateCAState
|
||||
switch ca.State {
|
||||
case domain.IntermediateCAStateActive:
|
||||
newState = domain.IntermediateCAStateRetiring
|
||||
case domain.IntermediateCAStateRetiring:
|
||||
if !confirm {
|
||||
return fmt.Errorf("Retire: already retiring; pass confirm=true to terminalize")
|
||||
}
|
||||
// Verify no active children before terminalizing.
|
||||
children, err := s.repo.ListChildren(ctx, caID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Retire: list children: %w", err)
|
||||
}
|
||||
for _, ch := range children {
|
||||
if ch.State == domain.IntermediateCAStateActive {
|
||||
return ErrCAStillHasActiveChildren
|
||||
}
|
||||
}
|
||||
newState = domain.IntermediateCAStateRetired
|
||||
default:
|
||||
return fmt.Errorf("Retire: already retired")
|
||||
}
|
||||
|
||||
if err := s.repo.UpdateState(ctx, caID, newState); err != nil {
|
||||
return fmt.Errorf("Retire: update state: %w", err)
|
||||
}
|
||||
|
||||
s.recordAudit(ctx, decidedBy, domain.ActorTypeUser,
|
||||
"intermediate_ca_"+string(newState), ca,
|
||||
map[string]interface{}{"note": note})
|
||||
if s.metrics != nil {
|
||||
s.metrics.RecordRetire(ca.OwningIssuerID, string(newState))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get returns a single CA by ID.
|
||||
func (s *IntermediateCAService) Get(ctx context.Context, id string) (*domain.IntermediateCA, error) {
|
||||
ca, err := s.repo.Get(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
return nil, ErrIntermediateCANotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return ca, nil
|
||||
}
|
||||
|
||||
// LoadHierarchy returns the flat list for an issuer; caller renders the
|
||||
// tree from parent_ca_id.
|
||||
func (s *IntermediateCAService) LoadHierarchy(ctx context.Context, issuerID string) ([]*domain.IntermediateCA, error) {
|
||||
return s.repo.ListByIssuer(ctx, issuerID)
|
||||
}
|
||||
|
||||
// AssembleChain walks the ancestry of leafCAID and returns the PEM
|
||||
// bundle (leaf CA included, ordered leaf → root). The local connector
|
||||
// uses this at issue time to populate IssuanceResult.ChainPEM. The
|
||||
// caller of IssueCertificate prepends the just-issued leaf cert to
|
||||
// this bundle.
|
||||
func (s *IntermediateCAService) AssembleChain(ctx context.Context, leafCAID string) (string, error) {
|
||||
chain, err := s.repo.WalkAncestry(ctx, leafCAID)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
return "", ErrIntermediateCANotFound
|
||||
}
|
||||
return "", fmt.Errorf("AssembleChain: %w", err)
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, ca := range chain {
|
||||
b.WriteString(ca.CertPEM)
|
||||
if !strings.HasSuffix(ca.CertPEM, "\n") {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// publicKeysEqual reports whether two crypto.PublicKey values are
|
||||
// byte-identical when serialized via PKIX. Cheaper alternative to
|
||||
// reflect.DeepEqual that survives algorithm-specific oddities (RSA
|
||||
// key Equal method, ECDSA curve pointer compare).
|
||||
func publicKeysEqual(a, b interface{}) bool {
|
||||
aBytes, err := x509.MarshalPKIXPublicKey(a)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
bBytes, err := x509.MarshalPKIXPublicKey(b)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return bytes.Equal(aBytes, bBytes)
|
||||
}
|
||||
|
||||
// validateNameConstraintsSubset enforces RFC 5280 §4.2.1.10. The
|
||||
// child's permitted set must be a subset of the parent's permitted
|
||||
// set (a child cannot widen permitted scope); the child's excluded
|
||||
// set must be a superset of the parent's excluded set (a child
|
||||
// cannot remove an excluded subtree).
|
||||
func validateNameConstraintsSubset(parent, child []domain.NameConstraint) error {
|
||||
flatParentPermitted := flattenPermitted(parent)
|
||||
flatParentExcluded := flattenExcluded(parent)
|
||||
flatChildPermitted := flattenPermitted(child)
|
||||
flatChildExcluded := flattenExcluded(child)
|
||||
|
||||
if len(flatParentPermitted) > 0 {
|
||||
// If parent has a non-empty permitted set, every child permitted
|
||||
// MUST belong to (or be a subdomain of) some parent permitted
|
||||
// entry.
|
||||
for _, p := range flatChildPermitted {
|
||||
if !isPermittedUnderParent(p, flatParentPermitted) {
|
||||
return fmt.Errorf("%w: child permitted %q not under parent permitted set", ErrNameConstraintExceeded, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Excluded: every parent-excluded entry MUST be present (or covered)
|
||||
// in the child's excluded set.
|
||||
for _, pe := range flatParentExcluded {
|
||||
if !isExcludedByChild(pe, flatChildExcluded) {
|
||||
return fmt.Errorf("%w: parent excluded %q not preserved in child", ErrNameConstraintExceeded, pe)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func flattenPermitted(ncs []domain.NameConstraint) []string {
|
||||
var out []string
|
||||
for _, n := range ncs {
|
||||
out = append(out, n.Permitted...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func flattenExcluded(ncs []domain.NameConstraint) []string {
|
||||
var out []string
|
||||
for _, n := range ncs {
|
||||
out = append(out, n.Excluded...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// isPermittedUnderParent reports whether candidate is the parent's
|
||||
// permitted entry exactly OR a subdomain of one.
|
||||
func isPermittedUnderParent(candidate string, parentSet []string) bool {
|
||||
for _, p := range parentSet {
|
||||
if candidate == p || strings.HasSuffix(candidate, "."+p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isExcludedByChild reports whether parentExcluded is in child's
|
||||
// excluded set (exactly OR via a wider exclusion in the child).
|
||||
func isExcludedByChild(parentExcluded string, childSet []string) bool {
|
||||
for _, c := range childSet {
|
||||
if parentExcluded == c || strings.HasSuffix(parentExcluded, "."+c) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parseCertPEM(certPEM []byte) (*x509.Certificate, error) {
|
||||
block, _ := pem.Decode(certPEM)
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("%w: no PEM block in cert", ErrInvalidCertPEM)
|
||||
}
|
||||
return x509.ParseCertificate(block.Bytes)
|
||||
}
|
||||
|
||||
func pathLenFromCert(cert *x509.Certificate) *int {
|
||||
if !cert.BasicConstraintsValid {
|
||||
return nil
|
||||
}
|
||||
if cert.MaxPathLen == 0 && !cert.MaxPathLenZero {
|
||||
// Go's x509 uses MaxPathLen=0 + MaxPathLenZero=false to mean "no constraint";
|
||||
// MaxPathLen=0 + MaxPathLenZero=true to mean "constraint of 0".
|
||||
return nil
|
||||
}
|
||||
v := cert.MaxPathLen
|
||||
return &v
|
||||
}
|
||||
|
||||
func nameConstraintsFromCert(cert *x509.Certificate) []domain.NameConstraint {
|
||||
if len(cert.PermittedDNSDomains) == 0 && len(cert.ExcludedDNSDomains) == 0 {
|
||||
return nil
|
||||
}
|
||||
return []domain.NameConstraint{{
|
||||
Permitted: append([]string(nil), cert.PermittedDNSDomains...),
|
||||
Excluded: append([]string(nil), cert.ExcludedDNSDomains...),
|
||||
}}
|
||||
}
|
||||
|
||||
// recordAudit is the shared audit-emission helper.
|
||||
func (s *IntermediateCAService) recordAudit(ctx context.Context, actor string, actorType domain.ActorType,
|
||||
action string, ca *domain.IntermediateCA, extra map[string]interface{}) {
|
||||
if s.auditService == nil || ca == nil {
|
||||
return
|
||||
}
|
||||
details := map[string]interface{}{
|
||||
"intermediate_ca_id": ca.ID,
|
||||
"owning_issuer_id": ca.OwningIssuerID,
|
||||
"name": ca.Name,
|
||||
"subject": ca.Subject,
|
||||
"state": string(ca.State),
|
||||
"key_driver_id": ca.KeyDriverID,
|
||||
"not_before": ca.NotBefore.Format(time.RFC3339),
|
||||
"not_after": ca.NotAfter.Format(time.RFC3339),
|
||||
}
|
||||
if ca.ParentCAID != nil {
|
||||
details["parent_ca_id"] = *ca.ParentCAID
|
||||
}
|
||||
for k, v := range extra {
|
||||
details[k] = v
|
||||
}
|
||||
_ = s.auditService.RecordEvent(ctx, actor, actorType, action,
|
||||
"intermediate_ca", ca.ID, details)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// IntermediateCAMetrics is a thread-safe counter table for the CA-
|
||||
// hierarchy management surface (Rank 8). Mirrors the
|
||||
// ApprovalMetrics + ExpiryAlertMetrics shape: cmd/server/main.go
|
||||
// constructs ONE instance, passes it to IntermediateCAService
|
||||
// (recording side) AND metricsHandler (exposing side) so the
|
||||
// snapshotter is the single source of truth.
|
||||
//
|
||||
// Dimensions:
|
||||
//
|
||||
// issuer_id — owning issuer (bounded cardinality; operators have
|
||||
// <100 issuers in production).
|
||||
// kind — closed enum:
|
||||
// "create_root" — CreateRoot succeeded.
|
||||
// "create_child" — CreateChild succeeded.
|
||||
// "retire_<state>" — Retire transitioned state.
|
||||
type IntermediateCAMetrics struct {
|
||||
mu sync.RWMutex
|
||||
counters map[intermediateCAKey]*atomic.Uint64
|
||||
}
|
||||
|
||||
type intermediateCAKey struct {
|
||||
IssuerID string
|
||||
Kind string
|
||||
}
|
||||
|
||||
// NewIntermediateCAMetrics returns a zero-value instance ready for
|
||||
// concurrent use.
|
||||
func NewIntermediateCAMetrics() *IntermediateCAMetrics {
|
||||
return &IntermediateCAMetrics{
|
||||
counters: make(map[intermediateCAKey]*atomic.Uint64),
|
||||
}
|
||||
}
|
||||
|
||||
// RecordCreate bumps the create-counter. role ∈ {"root", "child"}.
|
||||
func (m *IntermediateCAMetrics) RecordCreate(issuerID, role string) {
|
||||
m.bump(issuerID, "create_"+role)
|
||||
}
|
||||
|
||||
// RecordRetire bumps the retire-counter. newState ∈
|
||||
// {"retiring", "retired"}.
|
||||
func (m *IntermediateCAMetrics) RecordRetire(issuerID, newState string) {
|
||||
m.bump(issuerID, "retire_"+newState)
|
||||
}
|
||||
|
||||
func (m *IntermediateCAMetrics) bump(issuerID, kind string) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
key := intermediateCAKey{IssuerID: issuerID, Kind: kind}
|
||||
m.mu.RLock()
|
||||
c, ok := m.counters[key]
|
||||
m.mu.RUnlock()
|
||||
if !ok {
|
||||
m.mu.Lock()
|
||||
c, ok = m.counters[key]
|
||||
if !ok {
|
||||
c = &atomic.Uint64{}
|
||||
m.counters[key] = c
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
c.Add(1)
|
||||
}
|
||||
|
||||
// IntermediateCAEntry is a single row of the SnapshotIntermediateCA
|
||||
// output.
|
||||
type IntermediateCAEntry struct {
|
||||
IssuerID string
|
||||
Kind string
|
||||
Count uint64
|
||||
}
|
||||
|
||||
// SnapshotIntermediateCA returns the current counter table sorted by
|
||||
// (issuer_id, kind) for deterministic Prometheus exposition.
|
||||
func (m *IntermediateCAMetrics) SnapshotIntermediateCA() []IntermediateCAEntry {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
m.mu.RLock()
|
||||
out := make([]IntermediateCAEntry, 0, len(m.counters))
|
||||
for k, c := range m.counters {
|
||||
out = append(out, IntermediateCAEntry{
|
||||
IssuerID: k.IssuerID,
|
||||
Kind: k.Kind,
|
||||
Count: c.Load(),
|
||||
})
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].IssuerID != out[j].IssuerID {
|
||||
return out[i].IssuerID < out[j].IssuerID
|
||||
}
|
||||
return out[i].Kind < out[j].Kind
|
||||
})
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,611 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"math/big"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/crypto/signer"
|
||||
"github.com/certctl-io/certctl/internal/domain"
|
||||
"github.com/certctl-io/certctl/internal/repository"
|
||||
)
|
||||
|
||||
// fakeIntermediateCARepo is an in-memory IntermediateCARepository for
|
||||
// service-layer tests. WalkAncestry mirrors the recursive-CTE
|
||||
// semantics shipped by the postgres adapter: leaf-first ordering,
|
||||
// terminating at the row whose parent_ca_id IS NULL. The AssembleChain
|
||||
// pin only carries weight if this fake produces the same shape the
|
||||
// production adapter would.
|
||||
type fakeIntermediateCARepo struct {
|
||||
mu sync.Mutex
|
||||
rows map[string]*domain.IntermediateCA
|
||||
seq int
|
||||
}
|
||||
|
||||
func newFakeIntermediateCARepo() *fakeIntermediateCARepo {
|
||||
return &fakeIntermediateCARepo{rows: make(map[string]*domain.IntermediateCA)}
|
||||
}
|
||||
|
||||
func (f *fakeIntermediateCARepo) Create(ctx context.Context, ca *domain.IntermediateCA) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if ca.ID == "" {
|
||||
f.seq++
|
||||
ca.ID = "ica-fake-" + strings.ToLower(stringn(f.seq))
|
||||
}
|
||||
if _, exists := f.rows[ca.ID]; exists {
|
||||
return repository.ErrAlreadyExists
|
||||
}
|
||||
if ca.CreatedAt.IsZero() {
|
||||
ca.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
if ca.UpdatedAt.IsZero() {
|
||||
ca.UpdatedAt = ca.CreatedAt
|
||||
}
|
||||
cp := *ca
|
||||
f.rows[ca.ID] = &cp
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeIntermediateCARepo) Get(ctx context.Context, id string) (*domain.IntermediateCA, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
r, ok := f.rows[id]
|
||||
if !ok {
|
||||
return nil, repository.ErrNotFound
|
||||
}
|
||||
cp := *r
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (f *fakeIntermediateCARepo) ListByIssuer(ctx context.Context, issuerID string) ([]*domain.IntermediateCA, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []*domain.IntermediateCA
|
||||
for _, r := range f.rows {
|
||||
if r.OwningIssuerID == issuerID {
|
||||
cp := *r
|
||||
out = append(out, &cp)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.Before(out[j].CreatedAt) })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeIntermediateCARepo) ListChildren(ctx context.Context, parentCAID string) ([]*domain.IntermediateCA, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []*domain.IntermediateCA
|
||||
for _, r := range f.rows {
|
||||
if r.ParentCAID != nil && *r.ParentCAID == parentCAID {
|
||||
cp := *r
|
||||
out = append(out, &cp)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.Before(out[j].CreatedAt) })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeIntermediateCARepo) UpdateState(ctx context.Context, id string, state domain.IntermediateCAState) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
r, ok := f.rows[id]
|
||||
if !ok {
|
||||
return repository.ErrNotFound
|
||||
}
|
||||
r.State = state
|
||||
r.UpdatedAt = time.Now().UTC()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeIntermediateCARepo) GetActiveRoot(ctx context.Context, issuerID string) (*domain.IntermediateCA, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, r := range f.rows {
|
||||
if r.OwningIssuerID == issuerID && r.ParentCAID == nil && r.State == domain.IntermediateCAStateActive {
|
||||
cp := *r
|
||||
return &cp, nil
|
||||
}
|
||||
}
|
||||
return nil, repository.ErrNotFound
|
||||
}
|
||||
|
||||
// WalkAncestry mirrors the postgres recursive-CTE: anchor on leafID,
|
||||
// then iteratively follow parent_ca_id to the root. Ordering is
|
||||
// leaf-first. Returns ErrNotFound when leafID does not exist (matching
|
||||
// the postgres adapter's contract).
|
||||
func (f *fakeIntermediateCARepo) WalkAncestry(ctx context.Context, leafID string) ([]*domain.IntermediateCA, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
cur, ok := f.rows[leafID]
|
||||
if !ok {
|
||||
return nil, repository.ErrNotFound
|
||||
}
|
||||
var out []*domain.IntermediateCA
|
||||
visited := map[string]bool{}
|
||||
for cur != nil {
|
||||
if visited[cur.ID] {
|
||||
// Defense in depth: refuse cycles. Production schema's
|
||||
// no-self-parent CHECK + the parent_ca_id FK make this
|
||||
// unreachable; the fake is paranoid by construction.
|
||||
break
|
||||
}
|
||||
visited[cur.ID] = true
|
||||
cp := *cur
|
||||
out = append(out, &cp)
|
||||
if cur.ParentCAID == nil {
|
||||
break
|
||||
}
|
||||
cur = f.rows[*cur.ParentCAID]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func stringn(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
const digits = "0123456789"
|
||||
var b []byte
|
||||
for n > 0 {
|
||||
b = append([]byte{digits[n%10]}, b...)
|
||||
n /= 10
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// Compile-time interface guard.
|
||||
var _ repository.IntermediateCARepository = (*fakeIntermediateCARepo)(nil)
|
||||
|
||||
// testCAFixture is a one-shot helper that builds a self-signed root
|
||||
// cert + key in process memory and adopts the key into a MemoryDriver
|
||||
// under a stable ref. Returns the PEM-encoded cert, the
|
||||
// signer.MemoryDriver, and the keyDriverID the service can pass to
|
||||
// CreateRoot.
|
||||
func testCAFixture(t *testing.T, drv *signer.MemoryDriver, ref string, subject pkix.Name, pathLen *int, ncs []domain.NameConstraint) []byte {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("ecdsa keygen: %v", err)
|
||||
}
|
||||
if err := drv.Adopt(ref, key); err != nil {
|
||||
t.Fatalf("adopt key: %v", err)
|
||||
}
|
||||
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
if err != nil {
|
||||
t.Fatalf("serial: %v", err)
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: subject,
|
||||
Issuer: subject, // self-signed
|
||||
NotBefore: time.Now().Add(-time.Hour).UTC(),
|
||||
NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour).UTC(),
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
}
|
||||
if pathLen != nil {
|
||||
tmpl.MaxPathLen = *pathLen
|
||||
tmpl.MaxPathLenZero = (*pathLen == 0)
|
||||
}
|
||||
if len(ncs) > 0 {
|
||||
var permitted, excluded []string
|
||||
for _, nc := range ncs {
|
||||
permitted = append(permitted, nc.Permitted...)
|
||||
excluded = append(excluded, nc.Excluded...)
|
||||
}
|
||||
tmpl.PermittedDNSDomains = permitted
|
||||
tmpl.ExcludedDNSDomains = excluded
|
||||
tmpl.PermittedDNSDomainsCritical = true
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatalf("create cert: %v", err)
|
||||
}
|
||||
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
}
|
||||
|
||||
// newTestService spins up an IntermediateCAService backed by the
|
||||
// in-memory repo + MemoryDriver + a no-op audit service.
|
||||
func newTestService(t *testing.T) (*IntermediateCAService, *fakeIntermediateCARepo, *signer.MemoryDriver, *IntermediateCAMetrics) {
|
||||
t.Helper()
|
||||
repo := newFakeIntermediateCARepo()
|
||||
drv := signer.NewMemoryDriver()
|
||||
auditRepo := &mockAuditRepo{}
|
||||
auditSvc := NewAuditService(auditRepo)
|
||||
metrics := NewIntermediateCAMetrics()
|
||||
svc := NewIntermediateCAService(repo, nil, drv, auditSvc, metrics)
|
||||
return svc, repo, drv, metrics
|
||||
}
|
||||
|
||||
// ==== Tests ====
|
||||
|
||||
// TestIntermediateCA_CreateRoot_RegistersOperatorSuppliedSelfSigned
|
||||
// pins the happy-path: a valid self-signed root cert + matching key
|
||||
// gets persisted with parent_ca_id = NULL and state=active.
|
||||
func TestIntermediateCA_CreateRoot_RegistersOperatorSuppliedSelfSigned(t *testing.T) {
|
||||
svc, repo, drv, _ := newTestService(t)
|
||||
pem := testCAFixture(t, drv, "root-key", pkix.Name{CommonName: "Acme Root"}, nil, nil)
|
||||
|
||||
id, err := svc.CreateRoot(context.Background(), "iss-acme", "Acme Root", "user-admin",
|
||||
pem, "root-key", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRoot: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(id, "ica-") {
|
||||
t.Fatalf("expected ica- prefix, got %q", id)
|
||||
}
|
||||
got, err := repo.Get(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if got.ParentCAID != nil {
|
||||
t.Fatalf("expected ParentCAID nil for root, got %v", *got.ParentCAID)
|
||||
}
|
||||
if got.State != domain.IntermediateCAStateActive {
|
||||
t.Fatalf("expected state=active, got %v", got.State)
|
||||
}
|
||||
if got.KeyDriverID != "root-key" {
|
||||
t.Fatalf("expected KeyDriverID=root-key, got %q", got.KeyDriverID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntermediateCA_CreateRoot_RejectsNonSelfSigned pins RFC 5280
|
||||
// §3.2: a cert whose issuer ≠ subject (or whose signature does not
|
||||
// verify under its own public key) MUST NOT be registered as a root.
|
||||
func TestIntermediateCA_CreateRoot_RejectsNonSelfSigned(t *testing.T) {
|
||||
svc, _, drv, _ := newTestService(t)
|
||||
|
||||
// Build a cert whose issuer differs from subject — the validator
|
||||
// in CreateRoot relies on cert.CheckSignatureFrom(cert), which fails
|
||||
// when the embedded issuer DN doesn't match the cert's own public
|
||||
// key. We achieve that by signing a "child" template with a DIFFERENT
|
||||
// key under the same subject — so the public key the verifier loads
|
||||
// from the cert (cert.PublicKey) does not match the actual signer.
|
||||
signerKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
embeddedKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err := drv.Adopt("mismatched-key", signerKey); err != nil {
|
||||
t.Fatalf("adopt: %v", err)
|
||||
}
|
||||
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: "Imposter Root"},
|
||||
Issuer: pkix.Name{CommonName: "Imposter Root"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &embeddedKey.PublicKey, signerKey)
|
||||
if err != nil {
|
||||
t.Fatalf("create cert: %v", err)
|
||||
}
|
||||
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
|
||||
_, err = svc.CreateRoot(context.Background(), "iss-acme", "Bad Root", "user-admin",
|
||||
pemBytes, "mismatched-key", nil)
|
||||
if !errors.Is(err, ErrCANotSelfSigned) {
|
||||
t.Fatalf("expected ErrCANotSelfSigned, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntermediateCA_CreateRoot_RejectsKeyMismatch pins the second
|
||||
// gate: cert is well-formed self-signed, but the operator-supplied
|
||||
// keyDriverID resolves to a DIFFERENT key. CreateRoot must refuse
|
||||
// before persisting the row.
|
||||
func TestIntermediateCA_CreateRoot_RejectsKeyMismatch(t *testing.T) {
|
||||
svc, _, drv, _ := newTestService(t)
|
||||
pemBytes := testCAFixture(t, drv, "real-root-key", pkix.Name{CommonName: "Acme Root"}, nil, nil)
|
||||
// Adopt an unrelated key under a different ref.
|
||||
stranger, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err := drv.Adopt("stranger-key", stranger); err != nil {
|
||||
t.Fatalf("adopt: %v", err)
|
||||
}
|
||||
|
||||
_, err := svc.CreateRoot(context.Background(), "iss-acme", "Acme Root", "user-admin",
|
||||
pemBytes, "stranger-key", nil)
|
||||
if !errors.Is(err, ErrCAKeyMismatch) {
|
||||
t.Fatalf("expected ErrCAKeyMismatch, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntermediateCA_CreateChild_PathLenTighteningEnforced pins RFC
|
||||
// 5280 §4.2.1.9: a child whose requested PathLenConstraint equals or
|
||||
// exceeds the parent's MUST be rejected.
|
||||
func TestIntermediateCA_CreateChild_PathLenTighteningEnforced(t *testing.T) {
|
||||
svc, _, drv, _ := newTestService(t)
|
||||
parentPathLen := 1
|
||||
rootPEM := testCAFixture(t, drv, "root-key", pkix.Name{CommonName: "Acme Root"}, &parentPathLen, nil)
|
||||
rootID, err := svc.CreateRoot(context.Background(), "iss-acme", "Acme Root", "user-admin", rootPEM, "root-key", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRoot: %v", err)
|
||||
}
|
||||
|
||||
// Child requests path-len 1, parent has path-len 1 → child >= parent → reject.
|
||||
requested := 1
|
||||
_, err = svc.CreateChild(context.Background(), rootID, "Acme Policy CA", "user-admin",
|
||||
&CreateChildOptions{
|
||||
Subject: pkix.Name{CommonName: "Acme Policy CA"},
|
||||
Algorithm: signer.AlgorithmECDSAP256,
|
||||
TTL: 365 * 24 * time.Hour,
|
||||
PathLenConstraint: &requested,
|
||||
})
|
||||
if !errors.Is(err, ErrPathLenExceeded) {
|
||||
t.Fatalf("expected ErrPathLenExceeded, got %v", err)
|
||||
}
|
||||
|
||||
// Child requests path-len 0 (strictly less), under parent path-len 1 → ok.
|
||||
tighter := 0
|
||||
if _, err := svc.CreateChild(context.Background(), rootID, "Acme Issuing CA", "user-admin",
|
||||
&CreateChildOptions{
|
||||
Subject: pkix.Name{CommonName: "Acme Issuing CA"},
|
||||
Algorithm: signer.AlgorithmECDSAP256,
|
||||
TTL: 365 * 24 * time.Hour,
|
||||
PathLenConstraint: &tighter,
|
||||
}); err != nil {
|
||||
t.Fatalf("expected tightening to succeed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntermediateCA_CreateChild_NameConstraintsSubset pins RFC 5280
|
||||
// §4.2.1.10 enforcement at service layer. Parent permits "example.com";
|
||||
// child trying to widen with "evil.com" must be rejected, while a
|
||||
// subdomain "internal.example.com" must succeed.
|
||||
func TestIntermediateCA_CreateChild_NameConstraintsSubset(t *testing.T) {
|
||||
svc, _, drv, _ := newTestService(t)
|
||||
parentNCs := []domain.NameConstraint{{Permitted: []string{"example.com"}}}
|
||||
rootPEM := testCAFixture(t, drv, "root-key", pkix.Name{CommonName: "Acme Root"}, nil, parentNCs)
|
||||
rootID, err := svc.CreateRoot(context.Background(), "iss-acme", "Acme Root", "user-admin", rootPEM, "root-key", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRoot: %v", err)
|
||||
}
|
||||
|
||||
// Widening is rejected.
|
||||
_, err = svc.CreateChild(context.Background(), rootID, "Bad Child", "user-admin",
|
||||
&CreateChildOptions{
|
||||
Subject: pkix.Name{CommonName: "Bad Child"},
|
||||
Algorithm: signer.AlgorithmECDSAP256,
|
||||
TTL: 365 * 24 * time.Hour,
|
||||
NameConstraints: []domain.NameConstraint{{Permitted: []string{"evil.com"}}},
|
||||
})
|
||||
if !errors.Is(err, ErrNameConstraintExceeded) {
|
||||
t.Fatalf("expected ErrNameConstraintExceeded, got %v", err)
|
||||
}
|
||||
|
||||
// Subdomain narrowing succeeds.
|
||||
if _, err := svc.CreateChild(context.Background(), rootID, "Acme Internal CA", "user-admin",
|
||||
&CreateChildOptions{
|
||||
Subject: pkix.Name{CommonName: "Acme Internal CA"},
|
||||
Algorithm: signer.AlgorithmECDSAP256,
|
||||
TTL: 365 * 24 * time.Hour,
|
||||
NameConstraints: []domain.NameConstraint{{Permitted: []string{"internal.example.com"}}},
|
||||
}); err != nil {
|
||||
t.Fatalf("expected subdomain narrowing to succeed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntermediateCA_AssembleChain_4DeepHierarchy is the LOAD-BEARING
|
||||
// pin for AssembleChain: a 4-level hierarchy (root → policy →
|
||||
// issuing-A → issuing-B-leaf) must produce a leaf-to-root PEM bundle
|
||||
// with exactly 4 CERTIFICATE blocks in the right order. This is what
|
||||
// the local connector's tree-mode code-path delegates to at
|
||||
// IssueCertificate time.
|
||||
func TestIntermediateCA_AssembleChain_4DeepHierarchy(t *testing.T) {
|
||||
svc, _, drv, _ := newTestService(t)
|
||||
// Root with path-len 3 (allows 3 layers of sub-CAs).
|
||||
rootPathLen := 3
|
||||
rootPEM := testCAFixture(t, drv, "root-key", pkix.Name{CommonName: "Acme Root"}, &rootPathLen, nil)
|
||||
rootID, err := svc.CreateRoot(context.Background(), "iss-acme", "Acme Root", "user-admin", rootPEM, "root-key", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRoot: %v", err)
|
||||
}
|
||||
|
||||
policyID, err := svc.CreateChild(context.Background(), rootID, "Policy CA", "user-admin",
|
||||
&CreateChildOptions{
|
||||
Subject: pkix.Name{CommonName: "Acme Policy CA"},
|
||||
Algorithm: signer.AlgorithmECDSAP256,
|
||||
TTL: 5 * 365 * 24 * time.Hour,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateChild policy: %v", err)
|
||||
}
|
||||
|
||||
issuingAID, err := svc.CreateChild(context.Background(), policyID, "Issuing A", "user-admin",
|
||||
&CreateChildOptions{
|
||||
Subject: pkix.Name{CommonName: "Acme Issuing A"},
|
||||
Algorithm: signer.AlgorithmECDSAP256,
|
||||
TTL: 2 * 365 * 24 * time.Hour,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateChild issuing A: %v", err)
|
||||
}
|
||||
|
||||
issuingBID, err := svc.CreateChild(context.Background(), issuingAID, "Issuing B", "user-admin",
|
||||
&CreateChildOptions{
|
||||
Subject: pkix.Name{CommonName: "Acme Issuing B"},
|
||||
Algorithm: signer.AlgorithmECDSAP256,
|
||||
TTL: 365 * 24 * time.Hour,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateChild issuing B: %v", err)
|
||||
}
|
||||
|
||||
chain, err := svc.AssembleChain(context.Background(), issuingBID)
|
||||
if err != nil {
|
||||
t.Fatalf("AssembleChain: %v", err)
|
||||
}
|
||||
count := strings.Count(chain, "BEGIN CERTIFICATE")
|
||||
if count != 4 {
|
||||
t.Fatalf("expected 4 CERTIFICATE blocks, got %d:\n%s", count, chain)
|
||||
}
|
||||
|
||||
// Verify each block parses + the chain is leaf → root by subject CN.
|
||||
rest := []byte(chain)
|
||||
wantSubjects := []string{"Acme Issuing B", "Acme Issuing A", "Acme Policy CA", "Acme Root"}
|
||||
for i := 0; i < 4; i++ {
|
||||
var block *pem.Block
|
||||
block, rest = pem.Decode(rest)
|
||||
if block == nil {
|
||||
t.Fatalf("expected block %d, got nil", i)
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
t.Fatalf("parse block %d: %v", i, err)
|
||||
}
|
||||
if cert.Subject.CommonName != wantSubjects[i] {
|
||||
t.Fatalf("block %d: expected CN=%q, got %q", i, wantSubjects[i], cert.Subject.CommonName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntermediateCA_Retire_RefusesIfActiveChildren pins drain-first
|
||||
// semantics: a CA in retiring state with active children cannot be
|
||||
// terminalized — the caller must retire the children first.
|
||||
func TestIntermediateCA_Retire_RefusesIfActiveChildren(t *testing.T) {
|
||||
svc, _, drv, _ := newTestService(t)
|
||||
rootPEM := testCAFixture(t, drv, "root-key", pkix.Name{CommonName: "Acme Root"}, nil, nil)
|
||||
rootID, err := svc.CreateRoot(context.Background(), "iss-acme", "Acme Root", "user-admin", rootPEM, "root-key", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRoot: %v", err)
|
||||
}
|
||||
if _, err := svc.CreateChild(context.Background(), rootID, "Child", "user-admin",
|
||||
&CreateChildOptions{
|
||||
Subject: pkix.Name{CommonName: "Child"},
|
||||
Algorithm: signer.AlgorithmECDSAP256,
|
||||
TTL: 365 * 24 * time.Hour,
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateChild: %v", err)
|
||||
}
|
||||
|
||||
// First call: active → retiring (no confirm needed).
|
||||
if err := svc.Retire(context.Background(), rootID, "user-admin", "drain start", false); err != nil {
|
||||
t.Fatalf("Retire (active→retiring): %v", err)
|
||||
}
|
||||
// Second call: retiring → retired with active child → must refuse.
|
||||
err = svc.Retire(context.Background(), rootID, "user-admin", "terminalize", true)
|
||||
if !errors.Is(err, ErrCAStillHasActiveChildren) {
|
||||
t.Fatalf("expected ErrCAStillHasActiveChildren, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntermediateCA_Retire_TwoPhaseConfirm pins the two-phase
|
||||
// transition: first call moves active→retiring without a confirm
|
||||
// flag; the second retiring→retired transition requires confirm=true.
|
||||
func TestIntermediateCA_Retire_TwoPhaseConfirm(t *testing.T) {
|
||||
svc, repo, drv, _ := newTestService(t)
|
||||
rootPEM := testCAFixture(t, drv, "root-key", pkix.Name{CommonName: "Acme Root"}, nil, nil)
|
||||
rootID, err := svc.CreateRoot(context.Background(), "iss-acme", "Acme Root", "user-admin", rootPEM, "root-key", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRoot: %v", err)
|
||||
}
|
||||
|
||||
// First call (no confirm, no children): active → retiring.
|
||||
if err := svc.Retire(context.Background(), rootID, "user-admin", "drain", false); err != nil {
|
||||
t.Fatalf("first retire: %v", err)
|
||||
}
|
||||
got, _ := repo.Get(context.Background(), rootID)
|
||||
if got.State != domain.IntermediateCAStateRetiring {
|
||||
t.Fatalf("expected retiring, got %v", got.State)
|
||||
}
|
||||
|
||||
// Second call without confirm — must surface "pass confirm=true".
|
||||
err = svc.Retire(context.Background(), rootID, "user-admin", "terminalize?", false)
|
||||
if err == nil || !strings.Contains(err.Error(), "confirm=true") {
|
||||
t.Fatalf("expected confirm=true error, got %v", err)
|
||||
}
|
||||
|
||||
// Second call with confirm: retiring → retired.
|
||||
if err := svc.Retire(context.Background(), rootID, "user-admin", "terminalize", true); err != nil {
|
||||
t.Fatalf("retire confirm: %v", err)
|
||||
}
|
||||
got, _ = repo.Get(context.Background(), rootID)
|
||||
if got.State != domain.IntermediateCAStateRetired {
|
||||
t.Fatalf("expected retired, got %v", got.State)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntermediateCA_MetricsRecordedPerOutcome pins the metrics
|
||||
// snapshot — every successful CreateRoot / CreateChild / Retire
|
||||
// transition lands one row in the snapshot, dimensioned by
|
||||
// (issuer_id, kind).
|
||||
func TestIntermediateCA_MetricsRecordedPerOutcome(t *testing.T) {
|
||||
svc, _, drv, metrics := newTestService(t)
|
||||
|
||||
rootPEM := testCAFixture(t, drv, "root-key", pkix.Name{CommonName: "Acme Root"}, nil, nil)
|
||||
rootID, err := svc.CreateRoot(context.Background(), "iss-acme", "Acme Root", "user-admin", rootPEM, "root-key", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRoot: %v", err)
|
||||
}
|
||||
if _, err := svc.CreateChild(context.Background(), rootID, "Child", "user-admin",
|
||||
&CreateChildOptions{
|
||||
Subject: pkix.Name{CommonName: "Child"},
|
||||
Algorithm: signer.AlgorithmECDSAP256,
|
||||
TTL: 365 * 24 * time.Hour,
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateChild: %v", err)
|
||||
}
|
||||
if err := svc.Retire(context.Background(), rootID, "user-admin", "drain", false); err != nil {
|
||||
t.Fatalf("Retire: %v", err)
|
||||
}
|
||||
|
||||
snap := metrics.SnapshotIntermediateCA()
|
||||
want := map[string]uint64{
|
||||
"iss-acme/create_root": 1,
|
||||
"iss-acme/create_child": 1,
|
||||
"iss-acme/retire_retiring": 1,
|
||||
}
|
||||
got := map[string]uint64{}
|
||||
for _, e := range snap {
|
||||
got[e.IssuerID+"/"+e.Kind] = e.Count
|
||||
}
|
||||
for k, v := range want {
|
||||
if got[k] != v {
|
||||
t.Fatalf("metric %s: expected %d, got %d (snapshot=%v)", k, v, got[k], got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntermediateCA_LoadHierarchy_FlatList pins LoadHierarchy: it
|
||||
// returns every CA for an issuer, irrespective of state, ordered by
|
||||
// created_at. Caller renders the tree from parent_ca_id.
|
||||
func TestIntermediateCA_LoadHierarchy_FlatList(t *testing.T) {
|
||||
svc, _, drv, _ := newTestService(t)
|
||||
rootPEM := testCAFixture(t, drv, "root-key", pkix.Name{CommonName: "Acme Root"}, nil, nil)
|
||||
rootID, err := svc.CreateRoot(context.Background(), "iss-acme", "Acme Root", "user-admin", rootPEM, "root-key", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRoot: %v", err)
|
||||
}
|
||||
for i, name := range []string{"Policy CA", "Issuing CA"} {
|
||||
_ = i
|
||||
if _, err := svc.CreateChild(context.Background(), rootID, name, "user-admin",
|
||||
&CreateChildOptions{
|
||||
Subject: pkix.Name{CommonName: name},
|
||||
Algorithm: signer.AlgorithmECDSAP256,
|
||||
TTL: 365 * 24 * time.Hour,
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateChild %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
hier, err := svc.LoadHierarchy(context.Background(), "iss-acme")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadHierarchy: %v", err)
|
||||
}
|
||||
if len(hier) != 3 {
|
||||
t.Fatalf("expected 3 rows, got %d", len(hier))
|
||||
}
|
||||
}
|
||||
@@ -75,20 +75,21 @@ func (s *NetworkScanService) ProbeSCEP(ctx context.Context, rawURL string) (*dom
|
||||
}
|
||||
|
||||
// Step 1: cheap up-front URL validation (SSRF early diagnostic).
|
||||
// Defaults to validation.ValidateSafeURL; tests inject a permissive
|
||||
// validator via service-level field so they can hit httptest
|
||||
// loopback servers (which the production validator correctly
|
||||
// rejects). Mirrors the webhook notifier's `newForTest` pattern.
|
||||
validateURL := s.scepValidateURL
|
||||
if validateURL == nil {
|
||||
validateURL = validation.ValidateSafeURL
|
||||
}
|
||||
if err := validateURL(rawURL); err != nil {
|
||||
result.Reachable = false
|
||||
result.Error = "url validation: " + err.Error()
|
||||
result.ProbeDurationMs = time.Since(started).Milliseconds()
|
||||
s.persistProbeResult(ctx, result)
|
||||
return result, fmt.Errorf("scep probe: validate url: %w", err)
|
||||
// Direct literal call to validation.ValidateSafeURL so CodeQL
|
||||
// go/request-forgery sees the sanitizer in-scope of every
|
||||
// downstream HTTP call. Tests that need to hit httptest loopback
|
||||
// servers grant an exemption via s.scepValidateURL (mirrors the
|
||||
// webhook notifier's `newForTest` pattern). Production callers
|
||||
// leave scepValidateURL nil so any production-validator
|
||||
// rejection wins.
|
||||
if err := validation.ValidateSafeURL(rawURL); err != nil {
|
||||
if s.scepValidateURL == nil || s.scepValidateURL(rawURL) != nil {
|
||||
result.Reachable = false
|
||||
result.Error = "url validation: " + err.Error()
|
||||
result.ProbeDurationMs = time.Since(started).Milliseconds()
|
||||
s.persistProbeResult(ctx, result)
|
||||
return result, fmt.Errorf("scep probe: validate url: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize the base URL — strip any trailing query string so we
|
||||
@@ -223,8 +224,57 @@ func (s *NetworkScanService) scepGetCACert(ctx context.Context, client *http.Cli
|
||||
// scepHTTPGet issues a single GET with the probe's user agent + the
|
||||
// SSRF-defended HTTP client. Reads the body up to 1MB to defend against
|
||||
// a huge-response DoS from a misbehaving target.
|
||||
func (s *NetworkScanService) scepHTTPGet(ctx context.Context, client *http.Client, url string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
//
|
||||
// Defense in depth (CodeQL #23 / CWE-918 SSRF):
|
||||
//
|
||||
// - The HTTP client's transport is built with validation.SafeHTTPDialContext
|
||||
// (see scepProbeClient below). Every dial — including any dial along a
|
||||
// redirect chain — re-resolves the host and rejects connections to
|
||||
// reserved IP ranges (loopback, RFC 1918, link-local, multicast,
|
||||
// CGNAT, IPv6 ULAs, etc.). This is the authoritative SSRF + DNS-
|
||||
// rebinding guard; even if an attacker bypassed the upstream URL
|
||||
// validator, the dial would still fail.
|
||||
//
|
||||
// - In addition to the dial-time guard, this function re-runs
|
||||
// validation.ValidateSafeURL on the URL right before the request
|
||||
// is built. The validator is already invoked at ProbeSCEP entry,
|
||||
// but re-running it here:
|
||||
// (a) Closes CodeQL go/request-forgery — the analyzer's taint
|
||||
// tracker now sees the sanitizer in the same function as the
|
||||
// sink (client.Do).
|
||||
// (b) Catches any future call site that wires a URL into
|
||||
// scepHTTPGet without going through ProbeSCEP. If anyone adds
|
||||
// such a path the validator catches the regression at the
|
||||
// sink — fail-closed by default.
|
||||
// (c) Is cheap (a single parse + reserved-IP lookup; the URL is
|
||||
// already parsed once upstream so the OS DNS cache likely
|
||||
// still has the answer).
|
||||
//
|
||||
// - When the service is configured with a permissive validator
|
||||
// (scepValidateURL — set by tests targeting httptest loopback
|
||||
// servers), the same permissive validator applies here. Production
|
||||
// callers leave scepValidateURL nil so validation.ValidateSafeURL
|
||||
// is the active gate.
|
||||
func (s *NetworkScanService) scepHTTPGet(ctx context.Context, client *http.Client, rawURL string) ([]byte, error) {
|
||||
// Production-grade SSRF validator — direct literal call so CodeQL
|
||||
// go/request-forgery recognizes it as a sanitizer in-scope of the
|
||||
// client.Do sink below. Tests that need to hit httptest loopback
|
||||
// servers grant an exemption via s.scepValidateURL (returning nil
|
||||
// for the test URL); when no exemption applies, the production
|
||||
// validator's rejection wins. Production callers leave
|
||||
// scepValidateURL nil so the production validator is the only gate.
|
||||
if err := validation.ValidateSafeURL(rawURL); err != nil {
|
||||
// Test-only exemption hook. The override returns nil for URLs
|
||||
// the test wants to allow despite the production validator's
|
||||
// rejection (loopback / link-local in httptest scenarios).
|
||||
// In production scepValidateURL is nil, so any production
|
||||
// validator rejection bubbles up unconditionally.
|
||||
if s.scepValidateURL == nil || s.scepValidateURL(rawURL) != nil {
|
||||
return nil, fmt.Errorf("validate url: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package validation
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// ValidateHeaderValue rejects any value that contains characters capable of
|
||||
@@ -34,3 +35,125 @@ func ValidateHeaderValue(field, value string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SanitizeEmailBodyValue scrubs control characters and visually-spoofable
|
||||
// Unicode from a single field that will be interpolated into a plaintext
|
||||
// email body. Closes CodeQL go/email-injection (CWE-640 / OWASP Content
|
||||
// Spoofing): an attacker who controls a field surfaced to an
|
||||
// operator-bound notification (cert subject DN, discovered cert metadata,
|
||||
// alert subject / message, event subject / body, metadata key+value
|
||||
// pairs) could otherwise plant content that:
|
||||
//
|
||||
// - Forges header-like content using bare CR/LF (some mail relays
|
||||
// misinterpret bare LF mid-body as a header boundary; RFC 5321
|
||||
// mandates CRLF, but defense in depth says strip bare LFs).
|
||||
// - Embeds NUL bytes (forbidden by RFC 5321 sec 4.5.2; some MTAs
|
||||
// truncate at NUL, allowing content elision).
|
||||
// - Plants bidi-override Unicode (U+202A..U+202E, U+2066..U+2069) so a
|
||||
// malicious URL renders as a benign one in the recipient's mail
|
||||
// client.
|
||||
// - Plants zero-width / invisible Unicode (U+200B..U+200D, U+FEFF,
|
||||
// U+2060..U+2063) so a phishing-prone URL hides whitespace.
|
||||
// - Plants C0 / C1 control characters that mail clients may render
|
||||
// unpredictably or strip in surprising ways.
|
||||
//
|
||||
// The sanitizer NEVER errors; it always returns a sanitized string. This
|
||||
// is the right contract for body content (vs. headers, which fail loud)
|
||||
// because dropping a notification because the cert subject DN happens to
|
||||
// contain a Mongolian Vowel Separator would be worse than escaping it.
|
||||
//
|
||||
// What the sanitizer does:
|
||||
//
|
||||
// - Strip NUL bytes (\x00) entirely.
|
||||
// - Replace bare LF / CR with a single space. Multi-line legitimate
|
||||
// body content gets its CRLF formatting from the email serializer
|
||||
// above this layer; a SINGLE FIELD interpolated into the body
|
||||
// should never carry its own line breaks.
|
||||
// - Strip bidi-override and zero-width characters.
|
||||
// - Strip C0 control chars (< 0x20) except TAB. Strip DEL (0x7F) +
|
||||
// C1 control chars (0x80-0x9F).
|
||||
// - Leave ordinary printable Unicode (including non-Latin scripts)
|
||||
// intact.
|
||||
//
|
||||
// Apply this to EVERY user-controllable field before interpolating into
|
||||
// a plaintext email body. Do NOT apply it to operator-controlled
|
||||
// constants (template literals, severity tier names) — those don't
|
||||
// carry the threat. The HTML email path uses html/template upstream
|
||||
// and does not need this sanitizer (html/template's contextual
|
||||
// auto-escape handles the same threats for HTML rendering).
|
||||
func SanitizeEmailBodyValue(value string) string {
|
||||
if value == "" {
|
||||
return value
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(value))
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case r == 0:
|
||||
// NUL — strip entirely (RFC 5321 sec 4.5.2 violation).
|
||||
continue
|
||||
case r == '\r' || r == '\n':
|
||||
// Strip line breaks within a single interpolated field.
|
||||
b.WriteRune(' ')
|
||||
case r == '\t':
|
||||
// TAB is legitimate body content.
|
||||
b.WriteRune(r)
|
||||
case r < 0x20:
|
||||
// C0 control chars (except TAB above) — strip.
|
||||
continue
|
||||
case r >= 0x7F && r <= 0x9F:
|
||||
// DEL + C1 control chars — strip.
|
||||
continue
|
||||
case r == 0xFFFD:
|
||||
// Replacement character — Go's range emits this for any
|
||||
// malformed UTF-8 byte sequence. Defense in depth: an
|
||||
// attacker who plants invalid UTF-8 (e.g. raw 0x80..0xFF
|
||||
// without a valid lead byte) should not have their input
|
||||
// surface as an arbitrary glyph in operator-bound mail.
|
||||
continue
|
||||
case isBidiOrZeroWidth(r):
|
||||
// Bidi-override + zero-width — strip; visually spoofable.
|
||||
continue
|
||||
case unicode.IsControl(r):
|
||||
// Catch-all: any remaining Unicode control class.
|
||||
continue
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// isBidiOrZeroWidth reports whether r is one of the bidi-override or
|
||||
// zero-width Unicode codepoints used in homograph / direction-spoofing
|
||||
// attacks. Mirrors the validator in internal/connector/issuer/local
|
||||
// (validateCSRUnicode); kept inline here to avoid a new import edge
|
||||
// from internal/validation back to the local issuer package.
|
||||
//
|
||||
// Codepoints expressed as numeric ranges instead of rune-literal
|
||||
// switch cases — Go source rejects literal invisible characters
|
||||
// (e.g. BOM U+FEFF) mid-file, so we compare against numeric values.
|
||||
func isBidiOrZeroWidth(r rune) bool {
|
||||
switch {
|
||||
// LRE U+202A, RLE U+202B, PDF U+202C, LRO U+202D, RLO U+202E
|
||||
case r >= 0x202A && r <= 0x202E:
|
||||
return true
|
||||
// LRI U+2066, RLI U+2067, FSI U+2068, PDI U+2069
|
||||
case r >= 0x2066 && r <= 0x2069:
|
||||
return true
|
||||
// Zero-width space U+200B, ZWNJ U+200C, ZWJ U+200D
|
||||
case r >= 0x200B && r <= 0x200D:
|
||||
return true
|
||||
// Word joiner U+2060, invisible separator U+2061,
|
||||
// invisible times U+2062, invisible plus U+2063
|
||||
case r >= 0x2060 && r <= 0x2063:
|
||||
return true
|
||||
// Byte-order mark / zero-width no-break space U+FEFF
|
||||
case r == 0xFEFF:
|
||||
return true
|
||||
// Mongolian Vowel Separator U+180E
|
||||
case r == 0x180E:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -68,3 +68,127 @@ func TestValidateHeaderValue_DefaultFieldName(t *testing.T) {
|
||||
t.Errorf("expected default field name 'header' in error, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSanitizeEmailBodyValue_PreservesSafeInput pins the contract that
|
||||
// ordinary body content (including non-Latin scripts and tabs) flows
|
||||
// through unchanged. The sanitizer must be a no-op for legitimate input
|
||||
// — over-stripping degrades operator notifications.
|
||||
func TestSanitizeEmailBodyValue_PreservesSafeInput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{"plain ASCII", "Renewal reminder for prod.example.com"},
|
||||
{"empty", ""},
|
||||
{"utf-8 multibyte", "résumé — 日本語 — مرحبا"},
|
||||
{"tabs allowed", "key:\tvalue"},
|
||||
{"spaces", " multiple spaces "},
|
||||
{"common cert DN", "CN=api.example.com,O=Acme Corp,C=US"},
|
||||
{"URL with safe chars", "https://docs.example.com/cert/mc-prod-api"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := SanitizeEmailBodyValue(tc.input)
|
||||
if got != tc.input {
|
||||
t.Errorf("expected unchanged %q, got %q", tc.input, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSanitizeEmailBodyValue_StripsControlChars pins the CodeQL
|
||||
// go/email-injection (CWE-640) defense — every attacker-plant-able
|
||||
// control character is stripped or replaced.
|
||||
func TestSanitizeEmailBodyValue_StripsControlChars(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
wantSafer bool // want output != input
|
||||
}{
|
||||
{"NUL byte stripped", "before\x00after", "beforeafter", true},
|
||||
{"bare LF replaced with space", "line1\nline2", "line1 line2", true},
|
||||
{"bare CR replaced with space", "line1\rline2", "line1 line2", true},
|
||||
{"CRLF replaced (both stripped)", "line1\r\nline2", "line1 line2", true},
|
||||
{"BEL stripped", "alert\x07now", "alertnow", true},
|
||||
{"backspace stripped", "x\x08y", "xy", true},
|
||||
{"DEL stripped", "x\x7fy", "xy", true},
|
||||
// C1 control chars must be specified via Unicode escape (\u) so
|
||||
// the source remains valid UTF-8; bare \x80 / \x9f bytes would
|
||||
// be invalid UTF-8 and Go's range emits U+FFFD instead, which
|
||||
// would test the malformed-UTF-8 strip path, not the C1 path.
|
||||
{"C1 control char stripped (U+0080)", "x\u0080y", "xy", true},
|
||||
{"C1 control char stripped (U+009F)", "x\u009Fy", "xy", true},
|
||||
// U+FFFD is the replacement char Go emits for malformed UTF-8.
|
||||
// We strip it as defense-in-depth so attacker-planted invalid
|
||||
// UTF-8 doesn't survive into operator notifications as an
|
||||
// arbitrary glyph.
|
||||
{"replacement char stripped", "x\uFFFDy", "xy", true},
|
||||
{"TAB preserved (legitimate body content)", "k:\tv", "k:\tv", false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := SanitizeEmailBodyValue(tc.input)
|
||||
if got != tc.want {
|
||||
t.Errorf("input %q: want %q, got %q", tc.input, tc.want, got)
|
||||
}
|
||||
if tc.wantSafer && got == tc.input {
|
||||
t.Errorf("expected sanitization to change %q, but output unchanged", tc.input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSanitizeEmailBodyValue_StripsBidiOverride pins the
|
||||
// visually-spoofable Unicode defense (homograph / RTL-override /
|
||||
// zero-width attacks). An attacker who controls a CN or metadata value
|
||||
// could otherwise plant a malicious URL that renders benignly in mail
|
||||
// clients that honor bidi-override codepoints.
|
||||
func TestSanitizeEmailBodyValue_StripsBidiOverride(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
// U+202E = Right-to-left override
|
||||
{"RTL override", "Click \u202Ewww.evil.com\u202C to verify"},
|
||||
// U+202D = Left-to-right override
|
||||
{"LRO override", "Click \u202Dwww.evil.com\u202C to verify"},
|
||||
// U+2066 = Left-to-right isolate
|
||||
{"LRI isolate", "Click \u2066www.evil.com\u2069 to verify"},
|
||||
// U+200B = Zero-width space
|
||||
{"zero-width space", "evil\u200B.example.com"},
|
||||
// U+200C = ZWNJ
|
||||
{"zero-width non-joiner", "ad\u200Cmin@example.com"},
|
||||
// U+FEFF = byte-order mark / zero-width no-break space
|
||||
{"BOM", "x\uFEFFy"},
|
||||
// U+180E = Mongolian Vowel Separator
|
||||
{"MVS", "a\u180Eb"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := SanitizeEmailBodyValue(tc.input)
|
||||
if got == tc.input {
|
||||
t.Errorf("expected bidi/zero-width stripping for %q, got unchanged %q", tc.input, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSanitizeEmailBodyValue_ContentSpoofingScenario pins the specific
|
||||
// CodeQL go/email-injection (CWE-640) example: an attacker who controls
|
||||
// a body field plants header-like content. The sanitizer neutralizes
|
||||
// the attempt by stripping bare LF/CR within the field.
|
||||
func TestSanitizeEmailBodyValue_ContentSpoofingScenario(t *testing.T) {
|
||||
// Attacker plants a body value that tries to fake a "Reply-To"
|
||||
// header inside the body. Even if mail clients don't honor it, a
|
||||
// recipient skimming the body could be fooled.
|
||||
attacker := "alert from compromised cert\r\nReply-To: attacker@evil.com\r\nClick https://evil.example.com/reset"
|
||||
got := SanitizeEmailBodyValue(attacker)
|
||||
if got == attacker {
|
||||
t.Fatalf("attacker input passed through unchanged: %q", got)
|
||||
}
|
||||
// Specifically: no CR or LF should remain in the field.
|
||||
if strings.ContainsAny(got, "\r\n") {
|
||||
t.Errorf("CR/LF still present after sanitization: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
-- 000027_approval_workflow.down.sql — reverse of the up migration.
|
||||
-- Drops the issuance_approval_requests table and the
|
||||
-- requires_approval column from certificate_profiles. Idempotent:
|
||||
-- IF EXISTS on every drop.
|
||||
|
||||
DROP INDEX IF EXISTS idx_approval_pending_age;
|
||||
DROP INDEX IF EXISTS idx_approval_certificate;
|
||||
DROP INDEX IF EXISTS idx_approval_state;
|
||||
DROP INDEX IF EXISTS idx_approval_pending_per_job;
|
||||
|
||||
DROP TABLE IF EXISTS issuance_approval_requests;
|
||||
|
||||
ALTER TABLE certificate_profiles
|
||||
DROP COLUMN IF EXISTS requires_approval;
|
||||
@@ -0,0 +1,66 @@
|
||||
-- 000027_approval_workflow.up.sql
|
||||
-- Rank 7 of the 2026-05-03 Infisical deep-research deliverable
|
||||
-- (cowork/infisical-deep-research-results.md Part 5). Two-person
|
||||
-- integrity / four-eyes principle for compliance-tier certificate
|
||||
-- issuance. CertificateProfile.RequiresApproval gates the renewal-
|
||||
-- loop entry; issuance_approval_requests captures the per-job
|
||||
-- decision with full audit trail.
|
||||
--
|
||||
-- All operations use IF NOT EXISTS / IF EXISTS so the migration is
|
||||
-- idempotent — safe to re-run on every certctl-server boot per the
|
||||
-- "Idempotent migrations" architecture decision in CLAUDE.md.
|
||||
--
|
||||
-- Existing scaffolding REUSED (not redefined here):
|
||||
-- - JobStatusAwaitingApproval enum value (internal/domain/job.go).
|
||||
-- - JobRepository.ListTimedOutAwaitingJobs (postgres reaper query).
|
||||
-- - Config.Scheduler.AwaitingApprovalTimeout (env-mapped, default
|
||||
-- 168h via CERTCTL_JOB_AWAITING_APPROVAL_TIMEOUT).
|
||||
--
|
||||
-- The lifecycle states are pinned at the schema level via a CHECK
|
||||
-- constraint matching internal/domain/approval.go::ApprovalState.
|
||||
|
||||
ALTER TABLE certificate_profiles
|
||||
ADD COLUMN IF NOT EXISTS requires_approval BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS issuance_approval_requests (
|
||||
id TEXT PRIMARY KEY,
|
||||
certificate_id TEXT NOT NULL REFERENCES managed_certificates(id) ON DELETE CASCADE,
|
||||
job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
|
||||
profile_id TEXT NOT NULL REFERENCES certificate_profiles(id) ON DELETE RESTRICT,
|
||||
requested_by TEXT NOT NULL,
|
||||
state VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
decided_by TEXT,
|
||||
decided_at TIMESTAMPTZ,
|
||||
decision_note TEXT,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT approval_state_check CHECK (
|
||||
state IN ('pending', 'approved', 'rejected', 'expired')
|
||||
),
|
||||
CONSTRAINT approval_decision_consistency CHECK (
|
||||
(state = 'pending' AND decided_by IS NULL AND decided_at IS NULL)
|
||||
OR (state IN ('approved', 'rejected', 'expired') AND decided_at IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
-- Partial-unique index: at most one PENDING approval request per job
|
||||
-- ID. Creates / re-creates idempotently. Terminal-state rows
|
||||
-- (approved / rejected / expired) are not constrained — operators
|
||||
-- can audit-trail multiple decisions over a job's lifetime, though
|
||||
-- in practice each job creates exactly one ApprovalRequest at
|
||||
-- AwaitingApproval entry and never recreates it.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_approval_pending_per_job
|
||||
ON issuance_approval_requests(job_id)
|
||||
WHERE state = 'pending';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_approval_state
|
||||
ON issuance_approval_requests(state);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_approval_certificate
|
||||
ON issuance_approval_requests(certificate_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_approval_pending_age
|
||||
ON issuance_approval_requests(created_at)
|
||||
WHERE state = 'pending';
|
||||
@@ -0,0 +1,15 @@
|
||||
-- 000028_intermediate_ca_hierarchy.down.sql — reverse of the up migration.
|
||||
-- Drops the intermediate_cas table + its indexes + the hierarchy_mode
|
||||
-- column on issuers. Idempotent (IF EXISTS everywhere).
|
||||
|
||||
DROP INDEX IF EXISTS idx_intermediate_ca_expiring;
|
||||
DROP INDEX IF EXISTS idx_intermediate_ca_state;
|
||||
DROP INDEX IF EXISTS idx_intermediate_ca_parent;
|
||||
DROP INDEX IF EXISTS idx_intermediate_ca_owning_issuer;
|
||||
DROP INDEX IF EXISTS idx_intermediate_ca_unique_name_per_issuer;
|
||||
DROP INDEX IF EXISTS idx_intermediate_ca_active_root_per_issuer;
|
||||
|
||||
DROP TABLE IF EXISTS intermediate_cas;
|
||||
|
||||
ALTER TABLE issuers
|
||||
DROP COLUMN IF EXISTS hierarchy_mode;
|
||||
@@ -0,0 +1,68 @@
|
||||
-- 000028_intermediate_ca_hierarchy.up.sql
|
||||
-- Rank 8: first-class N-level CA hierarchy management. Closes the
|
||||
-- FedRAMP / financial-services / OT-network "policy CA in the middle"
|
||||
-- deployment shape. intermediate_cas captures every non-root CA in
|
||||
-- the hierarchy with a self-referential parent_ca_id FK; issuers.
|
||||
-- hierarchy_mode toggles the new code-path behind a flag.
|
||||
--
|
||||
-- All operations use IF NOT EXISTS / IF EXISTS so the migration is
|
||||
-- idempotent — safe to re-run on every certctl-server boot per the
|
||||
-- "Idempotent migrations" architecture decision in CLAUDE.md.
|
||||
--
|
||||
-- Defense in depth: NEVER persist CA private key bytes. The
|
||||
-- key_driver_id column is a reference (filesystem path / KMS key ID
|
||||
-- / HSM slot) to the signer.Driver instance that owns the key.
|
||||
|
||||
ALTER TABLE issuers
|
||||
ADD COLUMN IF NOT EXISTS hierarchy_mode VARCHAR(20) NOT NULL DEFAULT 'single';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS intermediate_cas (
|
||||
id TEXT PRIMARY KEY,
|
||||
owning_issuer_id TEXT NOT NULL REFERENCES issuers(id) ON DELETE RESTRICT,
|
||||
parent_ca_id TEXT REFERENCES intermediate_cas(id) ON DELETE RESTRICT,
|
||||
name TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
state VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||
cert_pem TEXT NOT NULL,
|
||||
key_driver_id TEXT NOT NULL,
|
||||
not_before TIMESTAMPTZ NOT NULL,
|
||||
not_after TIMESTAMPTZ NOT NULL,
|
||||
path_len_constraint INT,
|
||||
name_constraints JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
ocsp_responder_url TEXT,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT intermediate_ca_state_check CHECK (
|
||||
state IN ('active', 'retiring', 'retired')
|
||||
),
|
||||
CONSTRAINT intermediate_ca_validity_check CHECK (
|
||||
not_after > not_before
|
||||
),
|
||||
CONSTRAINT intermediate_ca_no_self_parent CHECK (
|
||||
parent_ca_id IS NULL OR parent_ca_id <> id
|
||||
)
|
||||
);
|
||||
|
||||
-- Partial-unique: at most one ACTIVE root per issuer. A root is a row
|
||||
-- with parent_ca_id IS NULL (it has no parent in the hierarchy);
|
||||
-- multiple retired roots can coexist for audit history.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_intermediate_ca_active_root_per_issuer
|
||||
ON intermediate_cas(owning_issuer_id)
|
||||
WHERE parent_ca_id IS NULL AND state = 'active';
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_intermediate_ca_unique_name_per_issuer
|
||||
ON intermediate_cas(owning_issuer_id, name);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_intermediate_ca_owning_issuer
|
||||
ON intermediate_cas(owning_issuer_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_intermediate_ca_parent
|
||||
ON intermediate_cas(parent_ca_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_intermediate_ca_state
|
||||
ON intermediate_cas(state);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_intermediate_ca_expiring
|
||||
ON intermediate_cas(not_after) WHERE state = 'active';
|
||||
@@ -26,13 +26,18 @@
|
||||
# - ProfilesPage: CRUD form; mirrors PoliciesPage shape (covered)
|
||||
# - CertificateDetailPage: drill-down view; covered transitively via CertificatesPage
|
||||
# - IssuerDetailPage: drill-down view; covered transitively via IssuersPage
|
||||
# - IssuerHierarchyPage: Rank 8 admin-gated hierarchy render; admin gate +
|
||||
# recursive build tested at the API + service layers
|
||||
# (intermediate_ca_test.go + intermediate_ca_test.go
|
||||
# handler triplet); defer Vitest until the next
|
||||
# feature change touches the page
|
||||
# - TargetDetailPage: drill-down view; covered transitively via TargetsPage
|
||||
#
|
||||
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
|
||||
# cat-s2-c24a548076c6 for closure rationale.
|
||||
|
||||
set -e
|
||||
ALLOW='^(LoginPage|AuditPage|ShortLivedPage|DigestPage|ObservabilityPage|HealthMonitorPage|NetworkScanPage|JobsPage|JobDetailPage|AgentFleetPage|ProfilesPage|CertificateDetailPage|IssuerDetailPage|TargetDetailPage)$'
|
||||
ALLOW='^(LoginPage|AuditPage|ShortLivedPage|DigestPage|ObservabilityPage|HealthMonitorPage|NetworkScanPage|JobsPage|JobDetailPage|AgentFleetPage|ProfilesPage|CertificateDetailPage|IssuerDetailPage|IssuerHierarchyPage|TargetDetailPage)$'
|
||||
UNTESTED=""
|
||||
for f in web/src/pages/*.tsx; do
|
||||
base=$(basename "$f" .tsx)
|
||||
|
||||
@@ -36,23 +36,14 @@ import {
|
||||
const mockFetch = vi.fn();
|
||||
globalThis.fetch = mockFetch;
|
||||
|
||||
function mockJsonResponse(data: unknown, status = 200) {
|
||||
return Promise.resolve({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: () => Promise.resolve(data),
|
||||
statusText: 'OK',
|
||||
} as Response);
|
||||
}
|
||||
|
||||
function mockBlobResponse(status = 200) {
|
||||
return Promise.resolve({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
blob: () => Promise.resolve(new Blob(['test data'])),
|
||||
statusText: 'OK',
|
||||
} as Response);
|
||||
}
|
||||
// This file is the error-path companion to client.test.ts; every test
|
||||
// uses mockErrorResponse (defined below) to drive a non-2xx response
|
||||
// through the client function under test. The success-path
|
||||
// (mockJsonResponse / mockBlobResponse) helpers were drafted alongside
|
||||
// this scaffolding but never used — CodeQL alert #3 caught the
|
||||
// mockJsonResponse leftover. Both helpers were removed for consistency
|
||||
// with the file's error-only scope; success-path coverage lives in
|
||||
// client.test.ts.
|
||||
|
||||
function mockErrorResponse(status: number, body: { message?: string; error?: string } = {}) {
|
||||
return Promise.resolve({
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
markNotificationRead,
|
||||
getAuditEvents,
|
||||
getPolicies,
|
||||
createPolicy,
|
||||
updatePolicy,
|
||||
deletePolicy,
|
||||
getPolicyViolations,
|
||||
@@ -99,10 +98,6 @@ import {
|
||||
listHealthChecks,
|
||||
getHealthCheck,
|
||||
createHealthCheck,
|
||||
updateHealthCheck,
|
||||
deleteHealthCheck,
|
||||
getHealthCheckHistory,
|
||||
acknowledgeHealthCheck,
|
||||
getHealthCheckSummary,
|
||||
} from './client';
|
||||
|
||||
|
||||
@@ -813,3 +813,39 @@ export const acknowledgeHealthCheck = (id: string) =>
|
||||
|
||||
export const getHealthCheckSummary = () =>
|
||||
fetchJSON<HealthCheckSummary>(`${BASE}/health-checks/summary`);
|
||||
|
||||
// IntermediateCA hierarchy (Rank 8 of the 2026-05-03 deep-research
|
||||
// deliverable). Admin-gated at the handler layer; non-admin Bearer
|
||||
// callers get 403. Operators drive the hierarchy from
|
||||
// IssuerHierarchyPage; the recursive tree render is built from the
|
||||
// flat list returned here by walking each row's parent_ca_id.
|
||||
export interface IntermediateCA {
|
||||
id: string;
|
||||
owning_issuer_id: string;
|
||||
parent_ca_id?: string | null;
|
||||
name: string;
|
||||
subject: string;
|
||||
state: 'active' | 'retiring' | 'retired';
|
||||
cert_pem: string;
|
||||
key_driver_id: string;
|
||||
not_before: string;
|
||||
not_after: string;
|
||||
path_len_constraint?: number | null;
|
||||
name_constraints?: { permitted?: string[]; excluded?: string[] }[];
|
||||
ocsp_responder_url?: string;
|
||||
metadata?: Record<string, string>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export const listIntermediateCAs = (issuerID: string) =>
|
||||
fetchJSON<{ data: IntermediateCA[] }>(`${BASE}/issuers/${issuerID}/intermediates`);
|
||||
|
||||
export const getIntermediateCA = (id: string) =>
|
||||
fetchJSON<IntermediateCA>(`${BASE}/intermediates/${id}`);
|
||||
|
||||
export const retireIntermediateCA = (id: string, note: string, confirm: boolean) =>
|
||||
fetchJSON<{ id: string; decided_by: string; confirmed: boolean }>(
|
||||
`${BASE}/intermediates/${id}/retire`,
|
||||
{ method: 'POST', body: JSON.stringify({ note, confirm }) },
|
||||
);
|
||||
|
||||
@@ -31,6 +31,7 @@ import DigestPage from './pages/DigestPage';
|
||||
import ObservabilityPage from './pages/ObservabilityPage';
|
||||
import JobDetailPage from './pages/JobDetailPage';
|
||||
import IssuerDetailPage from './pages/IssuerDetailPage';
|
||||
import IssuerHierarchyPage from './pages/IssuerHierarchyPage';
|
||||
import TargetDetailPage from './pages/TargetDetailPage';
|
||||
import SCEPAdminPage from './pages/SCEPAdminPage';
|
||||
import ESTAdminPage from './pages/ESTAdminPage';
|
||||
@@ -69,6 +70,11 @@ createRoot(document.getElementById('root')!).render(
|
||||
<Route path="profiles" element={<ProfilesPage />} />
|
||||
<Route path="issuers" element={<IssuersPage />} />
|
||||
<Route path="issuers/:id" element={<IssuerDetailPage />} />
|
||||
{/* Rank 8 — operator-managed multi-level CA hierarchy.
|
||||
Admin-gated at the API; the page renders the
|
||||
backend's 403 as ErrorState for non-admin
|
||||
callers. See docs/intermediate-ca-hierarchy.md. */}
|
||||
<Route path="issuers/:id/hierarchy" element={<IssuerHierarchyPage />} />
|
||||
<Route path="targets" element={<TargetsPage />} />
|
||||
<Route path="targets/:id" element={<TargetDetailPage />} />
|
||||
<Route path="owners" element={<OwnersPage />} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from 'recharts';
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts';
|
||||
import { getAgents } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTrackedMutation } from '../hooks/useTrackedMutation';
|
||||
import { useListParams } from '../hooks/useListParams';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { getCertificates, createCertificate, revokeCertificate, getOwners, getTeams, getRenewalPolicies, getProfiles, getIssuers, bulkRevokeCertificates, bulkRenewCertificates, bulkReassignCertificates } from '../api/client';
|
||||
import { getCertificates, createCertificate, getOwners, getTeams, getRenewalPolicies, getProfiles, getIssuers, bulkRevokeCertificates, bulkRenewCertificates, bulkReassignCertificates } from '../api/client';
|
||||
import { useAuth } from '../components/AuthProvider';
|
||||
import { REVOCATION_REASONS } from '../api/types';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend,
|
||||
} from 'recharts';
|
||||
import {
|
||||
getCertificates, getAgents, getJobs, getNotifications, getHealth,
|
||||
getCertificates, getJobs, getHealth,
|
||||
getDashboardSummary, getCertificatesByStatus, getExpirationTimeline,
|
||||
getJobTrends, getIssuanceRate, previewDigest, sendDigest, getIssuers,
|
||||
} from '../api/client';
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import DataTable from '../components/DataTable';
|
||||
import type { Column } from '../components/DataTable';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import ErrorState from '../components/ErrorState';
|
||||
import { formatDateTime } from '../api/utils';
|
||||
import type { DiscoveredCertificate, DiscoveryScan } from '../api/types';
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTrackedMutation } from '../hooks/useTrackedMutation';
|
||||
import { listIntermediateCAs, retireIntermediateCA, type IntermediateCA } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import ErrorState from '../components/ErrorState';
|
||||
import { formatDateTime } from '../api/utils';
|
||||
|
||||
// IssuerHierarchyPage renders the operator-managed CA hierarchy for a
|
||||
// single issuer. Rank 8 of the 2026-05-03 deep-research deliverable.
|
||||
//
|
||||
// The recursive tree is built client-side from the flat list returned
|
||||
// by GET /api/v1/issuers/{id}/intermediates — each row's parent_ca_id
|
||||
// (nil = root) drives the nesting. We render with native HTML <ul>
|
||||
// elements rather than pulling D3 to keep the dep graph thin; the
|
||||
// dendrogram view is parking-lot work tracked in WORKSPACE-ROADMAP.
|
||||
//
|
||||
// Admin gate: the backend handlers enforce admin role at the API
|
||||
// layer (M-008 pattern). The page itself is reachable from the issuer
|
||||
// detail nav; non-admin callers see a 403 from the API and the page
|
||||
// renders the error.
|
||||
export default function IssuerHierarchyPage() {
|
||||
const { id: issuerID = '' } = useParams<{ id: string }>();
|
||||
const [retireConfirmFor, setRetireConfirmFor] = useState<string | null>(null);
|
||||
|
||||
const { data, error, isLoading, refetch } = useQuery({
|
||||
queryKey: ['issuer-hierarchy', issuerID],
|
||||
queryFn: () => listIntermediateCAs(issuerID),
|
||||
enabled: issuerID !== '',
|
||||
});
|
||||
|
||||
const retireMu = useTrackedMutation({
|
||||
mutationKey: ['retire-intermediate-ca'],
|
||||
mutationFn: (vars: { id: string; note: string; confirm: boolean }) =>
|
||||
retireIntermediateCA(vars.id, vars.note, vars.confirm),
|
||||
onSuccess: () => {
|
||||
setRetireConfirmFor(null);
|
||||
refetch();
|
||||
},
|
||||
invalidates: [['issuer-hierarchy', issuerID]],
|
||||
});
|
||||
|
||||
const tree = useMemo(() => buildHierarchyTree(data?.data ?? []), [data?.data]);
|
||||
|
||||
if (issuerID === '') {
|
||||
return <ErrorState error={new Error('No issuer id in URL.')} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Certificate authority hierarchy"
|
||||
subtitle="Multi-level CA hierarchy backed by the intermediate_cas table. Each row is one CA cert (root, policy, issuing). The recursive nesting is driven by parent_ca_id."
|
||||
/>
|
||||
|
||||
{isLoading && <p className="text-sm text-slate-500">Loading hierarchy…</p>}
|
||||
{error && (
|
||||
<ErrorState
|
||||
error={error instanceof Error ? error : new Error(String(error))}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tree.length === 0 && !isLoading && !error && (
|
||||
<div className="rounded-md border border-slate-200 bg-slate-50 p-6 text-sm text-slate-600">
|
||||
<p className="font-medium">No CA hierarchy registered yet for this issuer.</p>
|
||||
<p className="mt-2">
|
||||
Operators register a root via <code>POST /api/v1/issuers/{issuerID}/intermediates</code> with
|
||||
<code> root_cert_pem</code> + <code>key_driver_id</code> set, then chain
|
||||
<code> POST </code> calls with <code>parent_ca_id</code> to build out the tree. See
|
||||
<code> docs/intermediate-ca-hierarchy.md</code> for the operator runbook.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tree.length > 0 && (
|
||||
<ul className="space-y-2 text-sm">
|
||||
{tree.map(node => (
|
||||
<HierarchyNode
|
||||
key={node.ca.id}
|
||||
node={node}
|
||||
depth={0}
|
||||
retireConfirmFor={retireConfirmFor}
|
||||
setRetireConfirmFor={setRetireConfirmFor}
|
||||
onRetire={(id, note, confirm) => retireMu.mutate({ id, note, confirm })}
|
||||
retireDisabled={retireMu.isPending}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface HierarchyTreeNode {
|
||||
ca: IntermediateCA;
|
||||
children: HierarchyTreeNode[];
|
||||
}
|
||||
|
||||
// buildHierarchyTree turns the flat list into a parent-child forest by
|
||||
// grouping rows on parent_ca_id. Roots (parent_ca_id null/empty) are
|
||||
// the forest's top level; everything else nests under its parent.
|
||||
function buildHierarchyTree(rows: IntermediateCA[]): HierarchyTreeNode[] {
|
||||
const byID = new Map<string, HierarchyTreeNode>();
|
||||
rows.forEach(row => byID.set(row.id, { ca: row, children: [] }));
|
||||
const roots: HierarchyTreeNode[] = [];
|
||||
rows.forEach(row => {
|
||||
const node = byID.get(row.id)!;
|
||||
if (!row.parent_ca_id) {
|
||||
roots.push(node);
|
||||
return;
|
||||
}
|
||||
const parent = byID.get(row.parent_ca_id);
|
||||
if (parent) {
|
||||
parent.children.push(node);
|
||||
} else {
|
||||
// Orphan (parent retired+pruned) — still surface at the top.
|
||||
roots.push(node);
|
||||
}
|
||||
});
|
||||
return roots;
|
||||
}
|
||||
|
||||
interface HierarchyNodeProps {
|
||||
node: HierarchyTreeNode;
|
||||
depth: number;
|
||||
retireConfirmFor: string | null;
|
||||
setRetireConfirmFor: (id: string | null) => void;
|
||||
onRetire: (id: string, note: string, confirm: boolean) => void;
|
||||
retireDisabled: boolean;
|
||||
}
|
||||
|
||||
function HierarchyNode({
|
||||
node,
|
||||
depth,
|
||||
retireConfirmFor,
|
||||
setRetireConfirmFor,
|
||||
onRetire,
|
||||
retireDisabled,
|
||||
}: HierarchyNodeProps) {
|
||||
const { ca, children } = node;
|
||||
const isRetiring = ca.state === 'retiring';
|
||||
const isRetired = ca.state === 'retired';
|
||||
const stateBadge =
|
||||
ca.state === 'active'
|
||||
? 'bg-emerald-100 text-emerald-700'
|
||||
: ca.state === 'retiring'
|
||||
? 'bg-amber-100 text-amber-700'
|
||||
: 'bg-slate-100 text-slate-600';
|
||||
|
||||
return (
|
||||
<li
|
||||
className="rounded-md border border-slate-200 bg-white p-3"
|
||||
style={{ marginLeft: depth * 24 }}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-slate-500">{ca.id}</span>
|
||||
<span className={`inline-block rounded px-2 py-0.5 text-xs font-medium ${stateBadge}`}>
|
||||
{ca.state}
|
||||
</span>
|
||||
{ca.path_len_constraint !== undefined && ca.path_len_constraint !== null && (
|
||||
<span className="text-xs text-slate-500">path_len={ca.path_len_constraint}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 font-medium">{ca.name}</div>
|
||||
<div className="mt-1 text-xs text-slate-600">{ca.subject}</div>
|
||||
<div className="mt-1 text-xs text-slate-500">
|
||||
valid {formatDateTime(ca.not_before)} → {formatDateTime(ca.not_after)}
|
||||
</div>
|
||||
{ca.name_constraints && ca.name_constraints.length > 0 && (
|
||||
<div className="mt-1 text-xs text-slate-500">
|
||||
constraints: {ca.name_constraints.flatMap(nc => nc.permitted ?? []).join(', ') || '—'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isRetired && (
|
||||
<div className="flex flex-col gap-1">
|
||||
{retireConfirmFor === ca.id ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded bg-red-600 px-3 py-1 text-xs font-medium text-white hover:bg-red-700 disabled:opacity-50"
|
||||
disabled={retireDisabled}
|
||||
onClick={() => onRetire(ca.id, isRetiring ? 'terminalize' : 'drain', isRetiring)}
|
||||
>
|
||||
{isRetiring ? 'Confirm retire (terminal)' : 'Retire (begin drain)'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-slate-300 px-3 py-1 text-xs"
|
||||
onClick={() => setRetireConfirmFor(null)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-slate-300 px-3 py-1 text-xs hover:bg-slate-100"
|
||||
onClick={() => setRetireConfirmFor(ca.id)}
|
||||
>
|
||||
{isRetiring ? 'Terminalize…' : 'Retire…'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{children.length > 0 && (
|
||||
<ul className="mt-3 space-y-2">
|
||||
{children.map(child => (
|
||||
<HierarchyNode
|
||||
key={child.ca.id}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
retireConfirmFor={retireConfirmFor}
|
||||
setRetireConfirmFor={setRetireConfirmFor}
|
||||
onRetire={onRetire}
|
||||
retireDisabled={retireDisabled}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { getNotifications, markNotificationRead, requeueNotification } from '../
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import ErrorState from '../components/ErrorState';
|
||||
import { formatDateTime, timeAgo } from '../api/utils';
|
||||
import { timeAgo } from '../api/utils';
|
||||
import type { Notification } from '../api/types';
|
||||
|
||||
type ViewMode = 'list' | 'grouped';
|
||||
|
||||
@@ -581,11 +581,6 @@ function RecentEventsTable({ events, testID, emptyMessage }: RecentEventsTablePr
|
||||
// Top-level page.
|
||||
// =============================================================================
|
||||
|
||||
function pickTabFromQuery(value: string | null): TabId {
|
||||
if (value === 'intune' || value === 'activity') return value;
|
||||
return 'profiles';
|
||||
}
|
||||
|
||||
// pickInitialTab honors three signals (precedence high → low):
|
||||
// 1. ?tab=intune|activity in the query string (deep link)
|
||||
// 2. Pathname ending in /scep/intune (legacy route alias from
|
||||
|
||||
Reference in New Issue
Block a user