mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-10 07:19:00 +00:00
Compare commits
32 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 | |||
| 8b75e0311b |
@@ -1,5 +1,12 @@
|
|||||||
name: Release
|
name: Release
|
||||||
|
|
||||||
|
# Override the auto-generated run name (which would otherwise default to
|
||||||
|
# the most recent commit subject + a #NN run number) so the Actions tab
|
||||||
|
# shows "Release v2.0.69" instead of "chore: rename Go module path... #73".
|
||||||
|
# `github.ref_name` resolves to the tag name (e.g., `v2.0.69`) for tag-triggered
|
||||||
|
# workflows, which is the only trigger we set below.
|
||||||
|
run-name: Release ${{ github.ref_name }}
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
@@ -346,6 +353,11 @@ jobs:
|
|||||||
# noise that gives operators no signal about what actually changed.
|
# noise that gives operators no signal about what actually changed.
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
|
# Pin the release title to the tag name. softprops/action-gh-release@v2
|
||||||
|
# falls back to the most recent commit subject when `name:` is omitted,
|
||||||
|
# which produces ugly titles like "chore: rename Go module path..." on
|
||||||
|
# the Releases page. `github.ref_name` evaluates to the tag (`v2.0.69`).
|
||||||
|
name: ${{ github.ref_name }}
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
body: |
|
body: |
|
||||||
> **Install / upgrade:** see the [Quick Start section in the README](https://github.com/certctl-io/certctl/blob/master/README.md#quick-start) for Docker Compose, agent install, Helm, and binary download instructions.
|
> **Install / upgrade:** see the [Quick Start section in the README](https://github.com/certctl-io/certctl/blob/master/README.md#quick-start) for Docker Compose, agent install, Helm, and binary download instructions.
|
||||||
|
|||||||
@@ -50,6 +50,11 @@ gantt
|
|||||||
| [Architecture](docs/architecture.md) | System design, data flow diagrams, security model |
|
| [Architecture](docs/architecture.md) | System design, data flow diagrams, security model |
|
||||||
| [Feature Inventory](docs/features.md) | Complete reference of all capabilities, API endpoints, and configuration |
|
| [Feature Inventory](docs/features.md) | Complete reference of all capabilities, API endpoints, and configuration |
|
||||||
| [Connector Reference](docs/connectors.md) | Configuration for all issuer, target, and notifier connectors |
|
| [Connector Reference](docs/connectors.md) | Configuration for all issuer, target, and notifier connectors |
|
||||||
|
| [ACME Server](docs/acme-server.md) | Run certctl as a drop-in ACME server — cert-manager / Caddy / Traefik walkthroughs + [threat model](docs/acme-server-threat-model.md) |
|
||||||
|
| [Approval Workflow](docs/approval-workflow.md) | Two-person-integrity gate for certificate issuance — RBAC, audit, bypass mode |
|
||||||
|
| [CA Hierarchy](docs/intermediate-ca-hierarchy.md) | Multi-level intermediate CA management — FedRAMP boundary CA, financial-services policy CA, internal-PKI patterns |
|
||||||
|
| [Cloud Target Runbook](docs/runbook-cloud-targets.md) | AWS ACM + Azure Key Vault deploy connectors — config, debugging, atomic-rollback semantics |
|
||||||
|
| [Expiry Alert Runbook](docs/runbook-expiry-alerts.md) | Per-policy multi-channel routing matrix — severity tiers, fault-isolating dispatch |
|
||||||
| [MCP Server](docs/mcp.md) | AI integration via Model Context Protocol — setup, available tools, examples |
|
| [MCP Server](docs/mcp.md) | AI integration via Model Context Protocol — setup, available tools, examples |
|
||||||
| [OpenAPI 3.1 Spec](docs/openapi.md) | API reference guide with endpoint overview ([raw spec](api/openapi.yaml)) |
|
| [OpenAPI 3.1 Spec](docs/openapi.md) | API reference guide with endpoint overview ([raw spec](api/openapi.yaml)) |
|
||||||
| [Compliance Mapping](docs/compliance.md) | SOC 2 Type II, PCI-DSS 4.0, NIST SP 800-57 alignment guides |
|
| [Compliance Mapping](docs/compliance.md) | SOC 2 Type II, PCI-DSS 4.0, NIST SP 800-57 alignment guides |
|
||||||
@@ -65,18 +70,18 @@ gantt
|
|||||||
|
|
||||||
| Issuer | Type | Notes |
|
| Issuer | Type | Notes |
|
||||||
|--------|------|-------|
|
|--------|------|-------|
|
||||||
| Local CA (self-signed + sub-CA) | `GenericCA` | Sub-CA mode chains to enterprise root (ADCS, etc.) |
|
| Local CA (self-signed + sub-CA + tree mode) | `GenericCA` | Sub-CA mode chains to enterprise root (ADCS, etc.). **Tree mode (Rank 8)** manages multi-level intermediate CAs (`intermediate_cas` table) with RFC 5280 §3.2 / §4.2.1.9 / §4.2.1.10 enforcement — FedRAMP boundary CAs, financial-services policy CAs, internal PKI. See [`docs/intermediate-ca-hierarchy.md`](docs/intermediate-ca-hierarchy.md). |
|
||||||
| ACME v2 (Let's Encrypt, ZeroSSL, etc.) | `ACME` | HTTP-01, DNS-01, DNS-PERSIST-01 challenges. EAB auto-fetch from ZeroSSL. Profile selection (`tlsserver`, `shortlived`). |
|
| ACME v2 (Let's Encrypt, ZeroSSL, etc.) | `ACME` | HTTP-01, DNS-01, DNS-PERSIST-01 challenges. EAB auto-fetch from ZeroSSL. Profile selection (`tlsserver`, `shortlived`). |
|
||||||
| step-ca (Smallstep) | `StepCA` | JWK provisioner auth, issuance + renewal + revocation |
|
| step-ca (Smallstep) | `StepCA` | JWK provisioner auth, issuance + renewal + revocation |
|
||||||
| OpenSSL / Custom CA | `OpenSSL` | Shell script adapter — any CA with a CLI |
|
| OpenSSL / Custom CA | `OpenSSL` | Shell script adapter — any CA with a CLI |
|
||||||
| HashiCorp Vault PKI | `VaultPKI` | Token auth, synchronous issuance, CRL/OCSP delegated to Vault |
|
| HashiCorp Vault PKI | `VaultPKI` | Token auth with **automatic renewal at TTL/2** + Prometheus metric, synchronous issuance, CRL/OCSP delegated to Vault, opaque `*secret.Ref` credential storage |
|
||||||
| DigiCert CertCentral | `DigiCert` | Async order model, OV/EV support, PEM bundle parsing |
|
| DigiCert CertCentral | `DigiCert` | Async order model, OV/EV support, PEM bundle parsing |
|
||||||
| Sectigo SCM | `Sectigo` | 3-header auth, DV/OV/EV, collect-not-ready graceful handling |
|
| Sectigo SCM | `Sectigo` | 3-header auth, DV/OV/EV, collect-not-ready graceful handling |
|
||||||
| Google Cloud CAS | `GoogleCAS` | OAuth2 service account, synchronous issuance, CA pool selection |
|
| Google Cloud CAS | `GoogleCAS` | OAuth2 service account, synchronous issuance, CA pool selection |
|
||||||
| AWS ACM Private CA | `AWSACMPCA` | Synchronous issuance, configurable signing algorithm/template ARN |
|
| AWS ACM Private CA | `AWSACMPCA` | Synchronous issuance, configurable signing algorithm/template ARN |
|
||||||
| Entrust Certificate Services | `Entrust` | mTLS client certificate auth, synchronous/approval-pending issuance |
|
| Entrust Certificate Services | `Entrust` | mTLS client certificate auth, synchronous/approval-pending issuance |
|
||||||
| GlobalSign Atlas HVCA | `GlobalSign` | mTLS + API key/secret dual auth, serial-based tracking |
|
| GlobalSign Atlas HVCA | `GlobalSign` | mTLS + API key/secret dual auth, serial-based tracking |
|
||||||
| EJBCA (Keyfactor) | `EJBCA` | Dual auth (mTLS or OAuth2), self-hosted open-source CA |
|
| EJBCA (Keyfactor) | `EJBCA` | Dual auth (mTLS with auto-reload-on-mtime via `mtlscache`, or OAuth2), self-hosted open-source CA |
|
||||||
|
|
||||||
**Note:** ADCS integration is handled via the Local CA's sub-CA mode — certctl operates as a subordinate CA with its signing certificate issued by ADCS. Any CA with a shell-accessible signing interface can be integrated via the OpenSSL/Custom CA connector.
|
**Note:** ADCS integration is handled via the Local CA's sub-CA mode — certctl operates as a subordinate CA with its signing certificate issued by ADCS. Any CA with a shell-accessible signing interface can be integrated via the OpenSSL/Custom CA connector.
|
||||||
|
|
||||||
@@ -98,6 +103,8 @@ gantt
|
|||||||
| Windows Certificate Store | `WinCertStore` | PowerShell Import-PfxCertificate + Get-ChildItem snapshot for rollback |
|
| Windows Certificate Store | `WinCertStore` | PowerShell Import-PfxCertificate + Get-ChildItem snapshot for rollback |
|
||||||
| Java Keystore | `JavaKeystore` | PEM→PKCS#12→keytool pipeline + keytool snapshot for rollback |
|
| Java Keystore | `JavaKeystore` | PEM→PKCS#12→keytool pipeline + keytool snapshot for rollback |
|
||||||
| Kubernetes Secrets | `KubernetesSecrets` | `kubernetes.io/tls` Secrets, atomic API + SHA-256 verify + kubelet sync poll |
|
| Kubernetes Secrets | `KubernetesSecrets` | `kubernetes.io/tls` Secrets, atomic API + SHA-256 verify + kubelet sync poll |
|
||||||
|
| **AWS Certificate Manager** | `AWSACM` | SDK-driven `ImportCertificate` (fresh ARN or rotate-in-place) + `DescribeCertificate` snapshot for atomic rollback + tag re-application. See [`docs/runbook-cloud-targets.md`](docs/runbook-cloud-targets.md). |
|
||||||
|
| **Azure Key Vault** | `AzureKeyVault` | SDK-driven PEM→PKCS#12 import via `ImportCertificate` (always new version) + snapshot CER bytes for atomic rollback + tag carry-forward. |
|
||||||
|
|
||||||
**Deploy-hardening I** (post-2026-04-30 master bundle): every connector now goes through `internal/deploy.Apply` for atomic-write + ownership-preservation + SHA-256 idempotency + per-target-type Prometheus counters (`certctl_deploy_*_total`). See [`docs/deployment-atomicity.md`](docs/deployment-atomicity.md) for the operator guide.
|
**Deploy-hardening I** (post-2026-04-30 master bundle): every connector now goes through `internal/deploy.Apply` for atomic-write + ownership-preservation + SHA-256 idempotency + per-target-type Prometheus counters (`certctl_deploy_*_total`). See [`docs/deployment-atomicity.md`](docs/deployment-atomicity.md) for the operator guide.
|
||||||
|
|
||||||
@@ -108,8 +115,9 @@ gantt
|
|||||||
| **EST (production-grade)** | RFC 7030 + RFC 9266 channel binding | Native EST server hardened for enterprise WiFi/802.1X, IoT bootstrap, and corporate device enrollment (post-2026-04-29 hardening master bundle). All six RFC 7030 endpoints — `cacerts` / `simpleenroll` / `simplereenroll` / `csrattrs` (profile-driven) / `serverkeygen` (CMS EnvelopedData wire format). Multi-profile dispatch (`/.well-known/est/<pathID>/`). Per-profile auth modes: mTLS sibling route at `/.well-known/est-mtls/<pathID>/`, HTTP Basic enrollment-password (constant-time compare + per-source-IP failed-auth limiter), RFC 9266 `tls-exporter` channel binding (TLS 1.3, opt-in per profile). Per-(CN, sourceIP) sliding-window rate limit. EST-source-scoped bulk revoke (`POST /api/v1/est/certificates/bulk-revoke`, M-008 admin-gated). Tabbed admin GUI at `/est` (Profiles / Recent Activity / Trust Bundle). `SIGHUP`-equivalent trust-bundle reload. libest reference-client interop tested in CI (`deploy/test/libest/Dockerfile` + `deploy/test/est_e2e_test.go`). Typed audit-action codes per failure dimension (`est_simple_enroll_success`/`_failed`, `est_auth_failed_basic`/`_mtls`/`_channel_binding`, `est_rate_limited`, `est_csr_policy_violation`, `est_bulk_revoke`, `est_trust_anchor_reloaded`, etc. — full set in `internal/service/est_audit_actions.go`). CLI + matching MCP tool family (rebuild count via `grep -cE '"est_' internal/mcp/tools_est.go`). See [`docs/est.md`](docs/est.md) for the operator guide — WiFi/802.1X + FreeRADIUS recipe, IoT bootstrap, troubleshooting matrix per audit-action code. |
|
| **EST (production-grade)** | RFC 7030 + RFC 9266 channel binding | Native EST server hardened for enterprise WiFi/802.1X, IoT bootstrap, and corporate device enrollment (post-2026-04-29 hardening master bundle). All six RFC 7030 endpoints — `cacerts` / `simpleenroll` / `simplereenroll` / `csrattrs` (profile-driven) / `serverkeygen` (CMS EnvelopedData wire format). Multi-profile dispatch (`/.well-known/est/<pathID>/`). Per-profile auth modes: mTLS sibling route at `/.well-known/est-mtls/<pathID>/`, HTTP Basic enrollment-password (constant-time compare + per-source-IP failed-auth limiter), RFC 9266 `tls-exporter` channel binding (TLS 1.3, opt-in per profile). Per-(CN, sourceIP) sliding-window rate limit. EST-source-scoped bulk revoke (`POST /api/v1/est/certificates/bulk-revoke`, M-008 admin-gated). Tabbed admin GUI at `/est` (Profiles / Recent Activity / Trust Bundle). `SIGHUP`-equivalent trust-bundle reload. libest reference-client interop tested in CI (`deploy/test/libest/Dockerfile` + `deploy/test/est_e2e_test.go`). Typed audit-action codes per failure dimension (`est_simple_enroll_success`/`_failed`, `est_auth_failed_basic`/`_mtls`/`_channel_binding`, `est_rate_limited`, `est_csr_policy_violation`, `est_bulk_revoke`, `est_trust_anchor_reloaded`, etc. — full set in `internal/service/est_audit_actions.go`). CLI + matching MCP tool family (rebuild count via `grep -cE '"est_' internal/mcp/tools_est.go`). See [`docs/est.md`](docs/est.md) for the operator guide — WiFi/802.1X + FreeRADIUS recipe, IoT bootstrap, troubleshooting matrix per audit-action code. |
|
||||||
| SCEP (Simple Certificate Enrollment Protocol) | RFC 8894 | MDM platforms (Jamf, Intune), network devices, ChromeOS. Full RFC 8894 wire format: EnvelopedData decryption, signerInfo POPO verification, CertRep PKIMessage builder; PKCSReq + RenewalReq + GetCertInitial messageType dispatch; multi-profile dispatch (`/scep/<pathID>`); per-profile RA cert + key. Lightweight raw-CSR clients keep working via the legacy MVP fall-through path. |
|
| SCEP (Simple Certificate Enrollment Protocol) | RFC 8894 | MDM platforms (Jamf, Intune), network devices, ChromeOS. Full RFC 8894 wire format: EnvelopedData decryption, signerInfo POPO verification, CertRep PKIMessage builder; PKCSReq + RenewalReq + GetCertInitial messageType dispatch; multi-profile dispatch (`/scep/<pathID>`); per-profile RA cert + key. Lightweight raw-CSR clients keep working via the legacy MVP fall-through path. |
|
||||||
| **Microsoft Intune SCEP fleet (drop-in NDES replacement)** | RFC 8894 + Intune Connector signed-challenge dispatcher | Per-profile Intune dispatcher validates the Connector's signed challenge against an operator-supplied trust anchor; binds device claim to CSR (set-equality on CN + SAN-DNS/RFC822/UPN); replay cache + per-device rate limit; `SIGHUP`-reloadable trust pool; admin GUI **SCEP Administration** page at `/scep` (Profiles tab with per-profile RA cert expiry + mTLS status, Intune Monitoring tab with per-status counters + reload, Recent Activity tab with full SCEP audit log filter). See [`docs/scep-intune.md`](docs/scep-intune.md) for the migration playbook + Microsoft support statement. |
|
| **Microsoft Intune SCEP fleet (drop-in NDES replacement)** | RFC 8894 + Intune Connector signed-challenge dispatcher | Per-profile Intune dispatcher validates the Connector's signed challenge against an operator-supplied trust anchor; binds device claim to CSR (set-equality on CN + SAN-DNS/RFC822/UPN); replay cache + per-device rate limit; `SIGHUP`-reloadable trust pool; admin GUI **SCEP Administration** page at `/scep` (Profiles tab with per-profile RA cert expiry + mTLS status, Intune Monitoring tab with per-status counters + reload, Recent Activity tab with full SCEP audit log filter). See [`docs/scep-intune.md`](docs/scep-intune.md) for the migration playbook + Microsoft support statement. |
|
||||||
| ACME v2 | RFC 8555 | Public CA automated issuance (Let's Encrypt, ZeroSSL) |
|
| ACME v2 client | RFC 8555 | Public CA automated issuance (Let's Encrypt, ZeroSSL) |
|
||||||
| ACME ARI (Renewal Information) | RFC 9773 | CA-directed renewal timing — the CA tells you when to renew |
|
| **ACME v2 server (drop-in for cert-manager / Caddy / Traefik)** | RFC 8555 + RFC 9773 ARI | Run certctl as your internal ACME CA. Per-profile endpoints at `/acme/profile/{id}/*` (directory, new-nonce, new-account, new-order, finalize, account, order, authz, challenge, key-change, revoke-cert, renewal-info). Per-profile `acme_auth_mode`: `trust_authenticated` for internal PKI; `challenge` for HTTP-01 / DNS-01 / TLS-ALPN-01 validation. Doubly-signed key rollover (§7.3.5), revoke-cert (§7.6, both kid-path and jwk-path auth), per-account rate limiting (orders/hour, key-change/hour, challenge-respond/hour), scheduler-driven nonce/authz/order GC. Three client walkthroughs: [cert-manager](docs/acme-cert-manager-walkthrough.md), [Caddy](docs/acme-caddy-walkthrough.md), [Traefik](docs/acme-traefik-walkthrough.md). Reference: [`docs/acme-server.md`](docs/acme-server.md) + [threat model](docs/acme-server-threat-model.md). |
|
||||||
|
| ACME ARI (Renewal Information) | RFC 9773 | CA-directed renewal timing — the CA tells you when to renew (client-side and server-side) |
|
||||||
|
|
||||||
### Standards & Revocation
|
### Standards & Revocation
|
||||||
|
|
||||||
@@ -161,7 +169,7 @@ Certificate lifecycle tooling falls into two camps: enterprise platforms (Venafi
|
|||||||
|
|
||||||
Built for **platform engineering and DevOps teams** managing 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)
|
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.
|
**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.
|
**Automated lifecycle.** Certificates renew and deploy themselves. The scheduler monitors expiration, issues through your CA, and deploys to targets — zero human intervention. ACME ARI (RFC 9773) lets the CA direct renewal timing. Ready for 47-day (SC-081v3) and 6-day (Let's Encrypt shortlived) certificate lifetimes.
|
||||||
|
|
||||||
**Operational dashboard.** 26-page GUI covers the entire lifecycle: certificate inventory with bulk ops, deployment timeline with rollback, discovery triage, network scan management, agent fleet health, short-lived credential countdown, approval workflows, and observability metrics. Configure issuers and targets from the dashboard — no env var editing, no server restarts.
|
**Operational dashboard.** 30+ page GUI covers the entire lifecycle: certificate inventory with bulk ops, deployment timeline with rollback, discovery triage, network scan management, agent fleet health, short-lived credential countdown, approval workflows, CA-hierarchy management, and observability metrics. Configure issuers and targets from the dashboard — no env var editing, no server restarts.
|
||||||
|
|
||||||
**Private keys stay on your servers.** Agents generate ECDSA P-256 keys locally, submit only the CSR. The control plane never touches private keys. After deployment, agents probe the live TLS endpoint and compare SHA-256 fingerprints to confirm the right certificate is actually being served.
|
**Private keys stay on your servers.** Agents generate ECDSA P-256 keys locally, submit only the CSR. The control plane never touches private keys. After deployment, agents probe the live TLS endpoint and compare SHA-256 fingerprints to confirm the right certificate is actually being served.
|
||||||
|
|
||||||
**Discovery.** Agents scan filesystems for existing PEM/DER certificates. The network scanner probes TLS endpoints across CIDR ranges without agents. Cloud discovery finds certificates in AWS Secrets Manager, Azure Key Vault, and GCP Secret Manager. Continuous TLS health monitoring tracks endpoint status (healthy/degraded/down/cert_mismatch) with configurable thresholds and historical probe data. All discovery modes feed into a unified triage workflow — claim, dismiss, or import what you find.
|
**Discovery.** Agents scan filesystems for existing PEM/DER certificates. The network scanner probes TLS endpoints across CIDR ranges without agents. Cloud discovery finds certificates in AWS Secrets Manager, Azure Key Vault, and GCP Secret Manager. Continuous TLS health monitoring tracks endpoint status (healthy/degraded/down/cert_mismatch) with configurable thresholds and historical probe data. All discovery modes feed into a unified triage workflow — claim, dismiss, or import what you find.
|
||||||
|
|
||||||
**Policy engine.** Certificate profiles constrain key types, max TTL, and EKUs — with crypto policy enforcement that validates every CSR against profile rules before it reaches the issuer. MaxTTL caps are enforced per issuer connector. Approval workflows pause jobs for human review. Ownership tracking routes notifications to the right team. Agent groups match devices by OS, architecture, IP CIDR, and version.
|
**Policy engine.** Certificate profiles constrain key types, max TTL, and EKUs — with crypto policy enforcement that validates every CSR against profile rules before it reaches the issuer. MaxTTL caps are enforced per issuer connector. Ownership tracking routes notifications to the right team. Agent groups match devices by OS, architecture, IP CIDR, and version.
|
||||||
|
|
||||||
|
**Two-person integrity for issuance (compliance-grade).** Set `requires_approval=true` on a `CertificateProfile` and every renewal-loop tick or manual `POST /api/v1/certificates/{id}/renew` blocks at `JobStatusAwaitingApproval` until a different actor approves via `POST /api/v1/approvals/{id}/approve`. Same-actor self-approval is rejected at the service layer with `ErrApproveBySameActor` → HTTP 403. Bypass mode (`CERTCTL_APPROVAL_BYPASS=true`) is auditable — every auto-approve records `actor=system-bypass` so audit-tier review surfaces it. Closes the procurement-checklist question for PCI-DSS Level 1, FedRAMP Moderate / High, SOC 2 Type II, HIPAA. See [`docs/approval-workflow.md`](docs/approval-workflow.md).
|
||||||
|
|
||||||
|
**Multi-level CA hierarchy management.** Set `Issuer.HierarchyMode = "tree"` and certctl manages a real N-level CA tree backed by the `intermediate_cas` table — root → policy → issuing leaves. RFC 5280 §3.2 (self-signed root validation), §4.2.1.9 (path-length tightening), and §4.2.1.10 (NameConstraints subset semantics) are all enforced at the service layer fail-closed. Drain-first retirement (active → retiring → retired) refuses terminal transitions while active children remain. Patterns documented for FedRAMP boundary CAs (4-level), financial-services policy CAs (3-level with per-BU `PermittedDNSDomains`), and internal PKI (2-level). The pre-Rank-8 single-sub-CA flow stays byte-identical for unmigrated deployments — pinned by `TestLocal_HierarchyMode_SingleVsTree_ByteIdentical`. See [`docs/intermediate-ca-hierarchy.md`](docs/intermediate-ca-hierarchy.md).
|
||||||
|
|
||||||
|
**Run certctl as your ACME server.** Beyond consuming public ACME CAs (Let's Encrypt, ZeroSSL), certctl now *serves* RFC 8555 — point cert-manager, Caddy, or Traefik at certctl's per-profile ACME endpoints (`/acme/profile/{id}/*`) and you get internal-PKI cert issuance with the same wire protocol the public CAs use. Full surface: directory, new-nonce, new-account, new-order, finalize, key-change (§7.3.5), revoke-cert (§7.6), renewal-info (RFC 9773 ARI), HTTP-01 / DNS-01 / TLS-ALPN-01 validation, per-account rate limiting, scheduler-driven nonce / authz / order GC. Three client walkthroughs ship — [cert-manager](docs/acme-cert-manager-walkthrough.md), [Caddy](docs/acme-caddy-walkthrough.md), [Traefik](docs/acme-traefik-walkthrough.md) — plus the [operator reference](docs/acme-server.md) and [threat model](docs/acme-server-threat-model.md).
|
||||||
|
|
||||||
**Enrollment protocols.** EST server (RFC 7030) for device and WiFi enrollment. SCEP server (RFC 8894) for MDM platforms and network devices — full wire format (EnvelopedData decrypt + signerInfo POPO verify + CertRep PKIMessage builder), tested against ChromeOS-shape requests; multi-profile dispatch (`/scep/<pathID>`); RenewalReq + GetCertInitial messageType support; lightweight raw-CSR fallback for legacy clients. See [docs/legacy-est-scep.md](docs/legacy-est-scep.md) for the operator + device-integration guide. S/MIME issuance with email protection EKU.
|
**Enrollment protocols.** EST server (RFC 7030) for device and WiFi enrollment. SCEP server (RFC 8894) for MDM platforms and network devices — full wire format (EnvelopedData decrypt + signerInfo POPO verify + CertRep PKIMessage builder), tested against ChromeOS-shape requests; multi-profile dispatch (`/scep/<pathID>`); RenewalReq + GetCertInitial messageType support; lightweight raw-CSR fallback for legacy clients. See [docs/legacy-est-scep.md](docs/legacy-est-scep.md) for the operator + device-integration guide. S/MIME issuance with email protection EKU.
|
||||||
|
|
||||||
@@ -185,9 +199,11 @@ Built for **platform engineering and DevOps teams** managing 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.
|
**Audit and observability.** Immutable append-only audit trail records every lifecycle action, every API call, and every approval decision. Prometheus metrics endpoint. Scheduled certificate digest emails. Continuous endpoint health monitoring with state machine transitions and real-time alerts.
|
||||||
|
|
||||||
**Notifications.** Slack, Teams, PagerDuty, OpsGenie, SMTP, webhooks. Routed by certificate owner. Daily digest emails with stats and expiring certs.
|
**Notifications + per-policy multi-channel routing.** Slack, Teams, PagerDuty, OpsGenie, SMTP, webhooks. Routed by certificate owner. Daily digest emails with stats and expiring certs. Each `RenewalPolicy` carries an `AlertChannels` matrix (per-severity-tier channel set) + `AlertSeverityMap` (per-threshold tier resolution) so production-tier 7-day alerts page PagerDuty *and* Slack while informational 30-day alerts go email-only. Per-channel dispatch is fault-isolating — a PagerDuty failure does NOT skip Slack/Email at the same threshold. Per-channel dedup row + audit row + Prometheus counter (`certctl_expiry_alerts_total{channel,threshold,result}`). See [`docs/runbook-expiry-alerts.md`](docs/runbook-expiry-alerts.md).
|
||||||
|
|
||||||
**Multiple interfaces.** REST API (111 routes), CLI (12 commands), MCP server (80 tools for Claude, Cursor, Windsurf), Helm chart, web dashboard. Certificate export in PEM and PKCS#12.
|
**Cloud-managed targets.** Beyond on-server deploys (NGINX, Apache, IIS, F5, ...), certctl pushes renewed certs directly into AWS Certificate Manager (`ImportCertificate` + `DescribeCertificate` snapshot for atomic rollback + tag re-application) and Azure Key Vault (PEM→PKCS#12 import + snapshot CER bytes for rollback + tag carry-forward). The control plane never touches the cloud credentials — agents own them. See [`docs/runbook-cloud-targets.md`](docs/runbook-cloud-targets.md).
|
||||||
|
|
||||||
|
**Multiple interfaces.** REST API (180+ routes), CLI (`certs` / `agents` / `jobs` / `import` / `est` / `status` / `version` command groups), MCP server (85+ tools for Claude, Cursor, Windsurf), Helm chart, web dashboard. Certificate export in PEM and PKCS#12.
|
||||||
|
|
||||||
**First-run onboarding.** Wizard guides you through connecting a CA, deploying an agent, and issuing your first certificate. Or start with the pre-populated demo — 32 certificates, 10 issuers, 180 days of history.
|
**First-run onboarding.** Wizard guides you through connecting a CA, deploying an agent, and issuing your first certificate. Or start with the pre-populated demo — 32 certificates, 10 issuers, 180 days of history.
|
||||||
|
|
||||||
@@ -398,7 +414,22 @@ CI runs on every push: `go vet`, `go test -race`, `golangci-lint`, `govulncheck`
|
|||||||
Core lifecycle management — Local CA + ACME v2 issuers, NGINX target connector, agent-side key generation, API auth + rate limiting, React dashboard, CI pipeline with coverage gates, Docker images on GHCR.
|
Core lifecycle management — Local CA + ACME v2 issuers, NGINX target connector, agent-side key generation, API auth + rate limiting, React dashboard, CI pipeline with coverage gates, Docker images on GHCR.
|
||||||
|
|
||||||
### V2: Operational Maturity — Shipped
|
### V2: Operational Maturity — Shipped
|
||||||
30+ milestones shipping enterprise-grade features for free. Sub-CA mode, ACME DNS-01/DNS-PERSIST-01/EAB/ARI (RFC 9773)/profile selection, step-ca, Vault PKI, DigiCert CertCentral, Sectigo SCM, Google CAS, AWS ACM PCA, Entrust, GlobalSign, EJBCA, OpenSSL/Custom CA issuers. NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, Postfix, Dovecot, IIS (WinRM), F5 BIG-IP, SSH, Windows Certificate Store, Java Keystore, Kubernetes Secrets targets. EST server (RFC 7030) and SCEP server (RFC 8894) enrollment protocols. RFC 5280 revocation with DER CRL + embedded OCSP responder. Certificate profiles, ownership tracking, team assignment, agent groups, interactive approval workflows. Filesystem, network, and cloud secret manager (AWS SM, Azure KV, GCP SM) certificate discovery with triage GUI. Dynamic issuer/target configuration via GUI with AES-256-GCM encrypted storage. First-run onboarding wizard. Post-deployment TLS verification. Certificate export (PEM/PKCS#12). S/MIME support. Prometheus metrics. Scheduled certificate digest emails. Slack, Teams, PagerDuty, OpsGenie, SMTP notifications. MCP server (80 tools), CLI (12 commands), Helm chart. Compliance mapping (SOC 2, PCI-DSS 4.0, NIST SP 800-57). 5 turnkey deployment examples. Agent install script. Migration guides from certbot, acme.sh, and cert-manager. See the [Feature Inventory](docs/features.md) for details.
|
|
||||||
|
40+ milestones shipping enterprise-grade features for free. Highlights below; the [Feature Inventory](docs/features.md) has the complete reference.
|
||||||
|
|
||||||
|
- **Issuers (12).** Local CA (self-signed + sub-CA + tree-mode N-level hierarchy), ACME (DNS-01 / DNS-PERSIST-01 / EAB / ARI / profile selection), step-ca, Vault PKI (with auto-token-renewal at TTL/2), DigiCert CertCentral, Sectigo SCM, Google CAS, AWS ACM PCA, Entrust (mTLS), GlobalSign Atlas HVCA, EJBCA (mTLS auto-reload via `mtlscache`), OpenSSL/Custom CA shell adapter.
|
||||||
|
- **On-server deploy targets (14).** NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, Postfix, Dovecot, IIS (WinRM), F5 BIG-IP, SSH, Windows Certificate Store, Java Keystore, Kubernetes Secrets — every connector goes through `internal/deploy.Apply` for atomic-write + ownership preservation + SHA-256 idempotency + per-target Prometheus counters + pre-deploy snapshot + on-failure rollback.
|
||||||
|
- **Cloud-managed deploy targets (2).** AWS Certificate Manager + Azure Key Vault — SDK-driven import with snapshot bytes for atomic rollback, tag carry-forward, no cloud creds touch the control plane. ([runbook](docs/runbook-cloud-targets.md))
|
||||||
|
- **certctl as an ACME server.** Full RFC 8555 surface (per-profile endpoints, accounts, orders, finalize, key-change §7.3.5, revoke-cert §7.6) + RFC 9773 ARI + HTTP-01 / DNS-01 / TLS-ALPN-01 validation + per-account rate limiting + scheduler-driven nonce/authz/order GC. Drop in for cert-manager / Caddy / Traefik. ([reference](docs/acme-server.md), [threat model](docs/acme-server-threat-model.md))
|
||||||
|
- **Enrollment protocols.** EST server (RFC 7030 + RFC 9266 channel binding, multi-profile dispatch, libest-tested CI). SCEP server (RFC 8894 full wire format, Microsoft Intune Connector signed-challenge dispatcher with replay cache + per-device rate limit, ChromeOS-shape interop).
|
||||||
|
- **Two-person-integrity approval workflow.** Per-profile `requires_approval=true` gate, `JobStatusAwaitingApproval` scheduler skip, same-actor RBAC reject, auditable bypass mode. Compliance-grade for PCI-DSS Level 1, FedRAMP Moderate / High, SOC 2 Type II, HIPAA. ([playbook](docs/approval-workflow.md))
|
||||||
|
- **First-class CA hierarchy management.** `intermediate_cas` table, RFC 5280 §3.2 / §4.2.1.9 / §4.2.1.10 service-layer enforcement, drain-first retire (active → retiring → retired), 4 admin-gated endpoints, GUI tree view. Patterns documented for FedRAMP / financial-services / internal PKI. ([runbook](docs/intermediate-ca-hierarchy.md))
|
||||||
|
- **Multi-channel expiry alerts.** Per-policy `AlertChannels` matrix + `AlertSeverityMap`, fault-isolating per-channel dispatch (PagerDuty failure does not skip Slack/Email at the same threshold), per-channel dedup + audit + Prometheus counter. ([runbook](docs/runbook-expiry-alerts.md))
|
||||||
|
- **Revocation infrastructure.** RFC 5280 DER CRL per issuer (scheduler-pre-generated + ETag-cached) + embedded RFC 6960 OCSP responder (dedicated per-issuer responder cert per §2.6, `id-pkix-ocsp-nocheck`, RFC §4.4.1 nonce echo, OCSP response cache with revoke-invalidate hot path). Single + bulk revocation. ([guide](docs/crl-ocsp.md))
|
||||||
|
- **Discovery & lifecycle.** Filesystem, network-CIDR, and cloud secret manager (AWS SM / Azure KV / GCP SM) certificate discovery with triage GUI. Continuous endpoint health monitoring. ACME ARI client-driven renewal timing. Approval workflows. Ownership routing. Agent groups (OS / arch / IP CIDR / version match).
|
||||||
|
- **Secrets at rest.** Issuer + target config encrypted with AES-256-GCM (versioned blob format, PBKDF2-SHA256 100K rounds, fail-closed sentinel `ErrEncryptionKeyRequired`). Vault token + DigiCert API key + EJBCA / GlobalSign / Sectigo credentials migrated to opaque `*secret.Ref` references.
|
||||||
|
- **Operator interfaces.** REST API (180+ routes), CLI (`certs` / `agents` / `jobs` / `import` / `est` / `status` / `version` command groups), MCP server (85+ tools for Claude / Cursor / Windsurf), Helm chart, 30+ page web dashboard with first-run onboarding wizard.
|
||||||
|
- **Compliance.** SOC 2 Type II, PCI-DSS 4.0, NIST SP 800-57 mapping ([compliance docs](docs/compliance.md)). Disaster-recovery runbook (8-section operator-grade procedure). Migration guides from [certbot](docs/migrate-from-certbot.md), [acme.sh](docs/migrate-from-acmesh.md), and [cert-manager](docs/certctl-for-cert-manager-users.md).
|
||||||
|
|
||||||
### Forward-looking work — all free, all self-hostable
|
### Forward-looking work — all free, all self-hostable
|
||||||
Everything ships free under BSL 1.1. No paid tier, no V3 / V4 gating, no enterprise edition. Future revenue path is a managed-service hosting offering — operate certctl-server as a hosted service while customers self-install only the agent.
|
Everything ships free under BSL 1.1. No paid tier, no V3 / V4 gating, no enterprise edition. Future revenue path is a managed-service hosting offering — operate certctl-server as a hosted service while customers self-install only the agent.
|
||||||
|
|||||||
@@ -2751,6 +2751,310 @@ paths:
|
|||||||
$ref: "#/components/responses/InternalError"
|
$ref: "#/components/responses/InternalError"
|
||||||
|
|
||||||
# ─── Notifications ──────────────────────────────────────────────────
|
# ─── Notifications ──────────────────────────────────────────────────
|
||||||
|
/api/v1/approvals:
|
||||||
|
get:
|
||||||
|
tags: [Approvals]
|
||||||
|
summary: List approval requests
|
||||||
|
description: |
|
||||||
|
Rank 7 issuance approval-workflow primitive. Returns paginated approval
|
||||||
|
requests, optionally filtered by ?state= (pending/approved/rejected/expired),
|
||||||
|
?certificate_id=, or ?requested_by=. Empty filters return the unfiltered
|
||||||
|
list (default page=1, per_page=50).
|
||||||
|
operationId: listApprovalRequests
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/page"
|
||||||
|
- $ref: "#/components/parameters/per_page"
|
||||||
|
- name: state
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [pending, approved, rejected, expired]
|
||||||
|
- name: certificate_id
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: requested_by
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Paginated list of approval requests
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/ApprovalRequest"
|
||||||
|
page:
|
||||||
|
type: integer
|
||||||
|
per_page:
|
||||||
|
type: integer
|
||||||
|
"500":
|
||||||
|
$ref: "#/components/responses/InternalError"
|
||||||
|
|
||||||
|
/api/v1/approvals/{id}:
|
||||||
|
get:
|
||||||
|
tags: [Approvals]
|
||||||
|
summary: Get approval request
|
||||||
|
description: Returns a single approval request by ID.
|
||||||
|
operationId: getApprovalRequest
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/resourceId"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Approval request details
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ApprovalRequest"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/NotFound"
|
||||||
|
"500":
|
||||||
|
$ref: "#/components/responses/InternalError"
|
||||||
|
|
||||||
|
/api/v1/approvals/{id}/approve:
|
||||||
|
post:
|
||||||
|
tags: [Approvals]
|
||||||
|
summary: Approve a pending approval request
|
||||||
|
description: |
|
||||||
|
Transitions a pending request to approved AND transitions the linked
|
||||||
|
Job from AwaitingApproval to Pending so the scheduler picks it up.
|
||||||
|
RBAC: the authenticated actor extracted via the auth middleware MUST
|
||||||
|
differ from the request's requested_by — a same-actor self-approval
|
||||||
|
returns HTTP 403 with the substring `two-person integrity` in the
|
||||||
|
body. This is the load-bearing two-person integrity contract;
|
||||||
|
compliance auditors (PCI-DSS 6.4.5, NIST 800-53 SA-15, SOC 2 CC6.1)
|
||||||
|
pattern-match against this code path.
|
||||||
|
operationId: approveApprovalRequest
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/resourceId"
|
||||||
|
requestBody:
|
||||||
|
required: false
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
note:
|
||||||
|
type: string
|
||||||
|
description: Optional reason text for the audit trail.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Approval recorded; linked Job transitioned to Pending
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id: { type: string }
|
||||||
|
decided_by: { type: string }
|
||||||
|
action: { type: string, enum: [approved] }
|
||||||
|
"401":
|
||||||
|
description: Authentication required
|
||||||
|
"403":
|
||||||
|
description: Same-actor self-approval blocked by two-person integrity contract
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/NotFound"
|
||||||
|
"409":
|
||||||
|
description: Request already decided (terminal state)
|
||||||
|
"500":
|
||||||
|
$ref: "#/components/responses/InternalError"
|
||||||
|
|
||||||
|
/api/v1/approvals/{id}/reject:
|
||||||
|
post:
|
||||||
|
tags: [Approvals]
|
||||||
|
summary: Reject a pending approval request
|
||||||
|
description: |
|
||||||
|
Transitions a pending request to rejected AND cancels the linked
|
||||||
|
Job. Same-actor RBAC contract as approve. The job's error_message
|
||||||
|
is populated with the supplied note for audit continuity.
|
||||||
|
operationId: rejectApprovalRequest
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/resourceId"
|
||||||
|
requestBody:
|
||||||
|
required: false
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
note:
|
||||||
|
type: string
|
||||||
|
description: Optional reason text for the audit trail.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Rejection recorded; linked Job transitioned to Cancelled
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id: { type: string }
|
||||||
|
decided_by: { type: string }
|
||||||
|
action: { type: string, enum: [rejected] }
|
||||||
|
"401":
|
||||||
|
description: Authentication required
|
||||||
|
"403":
|
||||||
|
description: Same-actor self-rejection blocked by two-person integrity contract
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/NotFound"
|
||||||
|
"409":
|
||||||
|
description: Request already decided (terminal state)
|
||||||
|
"500":
|
||||||
|
$ref: "#/components/responses/InternalError"
|
||||||
|
|
||||||
|
/api/v1/issuers/{id}/intermediates:
|
||||||
|
post:
|
||||||
|
tags: [IntermediateCAs]
|
||||||
|
summary: Create a root or child intermediate CA under the issuer
|
||||||
|
description: |
|
||||||
|
Admin-gated. Discriminator on body shape: when parent_ca_id is
|
||||||
|
empty AND root_cert_pem + key_driver_id are present, the
|
||||||
|
endpoint registers an operator-supplied root CA. Otherwise it
|
||||||
|
signs a child sub-CA cert under the named parent (RFC 5280
|
||||||
|
§4.2.1.9 path-length tightening + §4.2.1.10 NameConstraints
|
||||||
|
subset semantics enforced at the service layer).
|
||||||
|
operationId: createIntermediateCA
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/resourceId"
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [name]
|
||||||
|
properties:
|
||||||
|
name: { type: string }
|
||||||
|
parent_ca_id:
|
||||||
|
type: string
|
||||||
|
description: Empty for root registration; non-empty for child signing
|
||||||
|
root_cert_pem:
|
||||||
|
type: string
|
||||||
|
description: Operator-supplied root cert PEM (root path only)
|
||||||
|
key_driver_id:
|
||||||
|
type: string
|
||||||
|
description: signer.Driver reference for the root key (root path only)
|
||||||
|
subject:
|
||||||
|
type: object
|
||||||
|
description: Distinguished name for child CA (child path only)
|
||||||
|
algorithm:
|
||||||
|
type: string
|
||||||
|
description: Signing algorithm for child key (default ECDSA-P256)
|
||||||
|
ttl_days:
|
||||||
|
type: integer
|
||||||
|
path_len_constraint:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
name_constraints:
|
||||||
|
type: array
|
||||||
|
items: { type: object }
|
||||||
|
ocsp_responder_url:
|
||||||
|
type: string
|
||||||
|
metadata:
|
||||||
|
type: object
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: IntermediateCA row created
|
||||||
|
"400":
|
||||||
|
description: Validation failed (RFC 5280 violations, malformed cert PEM, missing root bundle)
|
||||||
|
"401":
|
||||||
|
description: Authentication required
|
||||||
|
"403":
|
||||||
|
description: Admin role required
|
||||||
|
"409":
|
||||||
|
description: Parent CA not in active state
|
||||||
|
"404":
|
||||||
|
description: Parent CA not found
|
||||||
|
"500":
|
||||||
|
$ref: "#/components/responses/InternalError"
|
||||||
|
get:
|
||||||
|
tags: [IntermediateCAs]
|
||||||
|
summary: List the CA hierarchy for an issuer
|
||||||
|
description: |
|
||||||
|
Admin-gated. Returns the flat list of every IntermediateCA row
|
||||||
|
for the issuer, ordered by created_at. The caller renders the
|
||||||
|
tree from each row's parent_ca_id (nil = root).
|
||||||
|
operationId: listIntermediateCAs
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/resourceId"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Flat list of CA rows
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: array
|
||||||
|
items: { type: object }
|
||||||
|
"401":
|
||||||
|
description: Authentication required
|
||||||
|
"403":
|
||||||
|
description: Admin role required
|
||||||
|
|
||||||
|
/api/v1/intermediates/{id}:
|
||||||
|
get:
|
||||||
|
tags: [IntermediateCAs]
|
||||||
|
summary: Get a single intermediate CA by ID
|
||||||
|
operationId: getIntermediateCA
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/resourceId"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: IntermediateCA row
|
||||||
|
"401":
|
||||||
|
description: Authentication required
|
||||||
|
"403":
|
||||||
|
description: Admin role required
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/NotFound"
|
||||||
|
|
||||||
|
/api/v1/intermediates/{id}/retire:
|
||||||
|
post:
|
||||||
|
tags: [IntermediateCAs]
|
||||||
|
summary: Retire an intermediate CA (two-phase drain)
|
||||||
|
description: |
|
||||||
|
Admin-gated. Two-phase: first call (confirm=false) transitions
|
||||||
|
active to retiring (the CA stops issuing new children but
|
||||||
|
existing children continue). Second call (confirm=true)
|
||||||
|
transitions retiring to retired (terminal). Refuses the
|
||||||
|
terminal transition if the CA still has active children —
|
||||||
|
drain-first semantics.
|
||||||
|
operationId: retireIntermediateCA
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/resourceId"
|
||||||
|
requestBody:
|
||||||
|
required: false
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
note: { type: string }
|
||||||
|
confirm: { type: boolean, default: false }
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Retire transition recorded
|
||||||
|
"401":
|
||||||
|
description: Authentication required
|
||||||
|
"403":
|
||||||
|
description: Admin role required
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/NotFound"
|
||||||
|
"409":
|
||||||
|
description: CA still has active children; drain them first
|
||||||
|
"500":
|
||||||
|
$ref: "#/components/responses/InternalError"
|
||||||
|
|
||||||
/api/v1/notifications:
|
/api/v1/notifications:
|
||||||
get:
|
get:
|
||||||
tags: [Notifications]
|
tags: [Notifications]
|
||||||
@@ -4057,6 +4361,63 @@ components:
|
|||||||
$ref: "#/components/schemas/ErrorResponse"
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
|
||||||
schemas:
|
schemas:
|
||||||
|
# ─── Approvals ───────────────────────────────────────────────────
|
||||||
|
ApprovalRequest:
|
||||||
|
type: object
|
||||||
|
description: |
|
||||||
|
Rank 7 issuance approval-workflow primitive. One row per (CertificateID,
|
||||||
|
JobID) pair; the JobID points at the blocked Job whose Status is
|
||||||
|
AwaitingApproval. Lifecycle: pending → approved | rejected | expired.
|
||||||
|
Once terminal, the row is immutable; the audit_events table is the
|
||||||
|
durable record of who decided + why.
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- certificate_id
|
||||||
|
- job_id
|
||||||
|
- profile_id
|
||||||
|
- requested_by
|
||||||
|
- state
|
||||||
|
- created_at
|
||||||
|
- updated_at
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
description: Approval request ID (ar-<slug>).
|
||||||
|
certificate_id:
|
||||||
|
type: string
|
||||||
|
job_id:
|
||||||
|
type: string
|
||||||
|
profile_id:
|
||||||
|
type: string
|
||||||
|
requested_by:
|
||||||
|
type: string
|
||||||
|
description: Actor that triggered the renewal.
|
||||||
|
state:
|
||||||
|
type: string
|
||||||
|
enum: [pending, approved, rejected, expired]
|
||||||
|
decided_by:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
description: Approver identity; null while state=pending.
|
||||||
|
decided_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
decision_note:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
metadata:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
description: Free-form key/value (common_name, sans, issuer_id, severity_tier).
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
updated_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
|
||||||
# ─── Common ──────────────────────────────────────────────────────
|
# ─── Common ──────────────────────────────────────────────────────
|
||||||
ErrorResponse:
|
ErrorResponse:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
+16
-16
@@ -30,22 +30,22 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/connector/target"
|
"github.com/certctl-io/certctl/internal/connector/target"
|
||||||
"github.com/shankar0123/certctl/internal/connector/target/apache"
|
"github.com/certctl-io/certctl/internal/connector/target/apache"
|
||||||
"github.com/shankar0123/certctl/internal/connector/target/awsacm"
|
"github.com/certctl-io/certctl/internal/connector/target/awsacm"
|
||||||
"github.com/shankar0123/certctl/internal/connector/target/azurekv"
|
"github.com/certctl-io/certctl/internal/connector/target/azurekv"
|
||||||
"github.com/shankar0123/certctl/internal/connector/target/caddy"
|
"github.com/certctl-io/certctl/internal/connector/target/caddy"
|
||||||
"github.com/shankar0123/certctl/internal/connector/target/envoy"
|
"github.com/certctl-io/certctl/internal/connector/target/envoy"
|
||||||
"github.com/shankar0123/certctl/internal/connector/target/f5"
|
"github.com/certctl-io/certctl/internal/connector/target/f5"
|
||||||
"github.com/shankar0123/certctl/internal/connector/target/haproxy"
|
"github.com/certctl-io/certctl/internal/connector/target/haproxy"
|
||||||
"github.com/shankar0123/certctl/internal/connector/target/iis"
|
"github.com/certctl-io/certctl/internal/connector/target/iis"
|
||||||
jks "github.com/shankar0123/certctl/internal/connector/target/javakeystore"
|
jks "github.com/certctl-io/certctl/internal/connector/target/javakeystore"
|
||||||
k8s "github.com/shankar0123/certctl/internal/connector/target/k8ssecret"
|
k8s "github.com/certctl-io/certctl/internal/connector/target/k8ssecret"
|
||||||
"github.com/shankar0123/certctl/internal/connector/target/nginx"
|
"github.com/certctl-io/certctl/internal/connector/target/nginx"
|
||||||
pf "github.com/shankar0123/certctl/internal/connector/target/postfix"
|
pf "github.com/certctl-io/certctl/internal/connector/target/postfix"
|
||||||
sshconn "github.com/shankar0123/certctl/internal/connector/target/ssh"
|
sshconn "github.com/certctl-io/certctl/internal/connector/target/ssh"
|
||||||
"github.com/shankar0123/certctl/internal/connector/target/traefik"
|
"github.com/certctl-io/certctl/internal/connector/target/traefik"
|
||||||
wcs "github.com/shankar0123/certctl/internal/connector/target/wincertstore"
|
wcs "github.com/certctl-io/certctl/internal/connector/target/wincertstore"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AgentConfig represents the agent-side configuration.
|
// AgentConfig represents the agent-side configuration.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/cli"
|
"github.com/certctl-io/certctl/internal/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Bundle Q (L-001 closure): per-subcommand dispatch tests for cmd/cli/main.go.
|
// Bundle Q (L-001 closure): per-subcommand dispatch tests for cmd/cli/main.go.
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/cli"
|
"github.com/certctl-io/certctl/internal/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
|
|
||||||
gomcp "github.com/modelcontextprotocol/go-sdk/mcp"
|
gomcp "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/mcp"
|
"github.com/certctl-io/certctl/internal/mcp"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Version is set at build time via -ldflags.
|
// Version is set at build time via -ldflags.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/api/router"
|
"github.com/certctl-io/certctl/internal/api/router"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Bundle B / Audit M-002 (CWE-862): pin the dispatch-layer auth-exempt
|
// Bundle B / Audit M-002 (CWE-862): pin the dispatch-layer auth-exempt
|
||||||
|
|||||||
+75
-21
@@ -17,27 +17,27 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
acmepkg "github.com/shankar0123/certctl/internal/api/acme"
|
acmepkg "github.com/certctl-io/certctl/internal/api/acme"
|
||||||
"github.com/shankar0123/certctl/internal/api/handler"
|
"github.com/certctl-io/certctl/internal/api/handler"
|
||||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||||
"github.com/shankar0123/certctl/internal/api/router"
|
"github.com/certctl-io/certctl/internal/api/router"
|
||||||
"github.com/shankar0123/certctl/internal/config"
|
"github.com/certctl-io/certctl/internal/config"
|
||||||
discoveryawssm "github.com/shankar0123/certctl/internal/connector/discovery/awssm"
|
discoveryawssm "github.com/certctl-io/certctl/internal/connector/discovery/awssm"
|
||||||
discoveryazurekv "github.com/shankar0123/certctl/internal/connector/discovery/azurekv"
|
discoveryazurekv "github.com/certctl-io/certctl/internal/connector/discovery/azurekv"
|
||||||
discoverygcpsm "github.com/shankar0123/certctl/internal/connector/discovery/gcpsm"
|
discoverygcpsm "github.com/certctl-io/certctl/internal/connector/discovery/gcpsm"
|
||||||
notifyemail "github.com/shankar0123/certctl/internal/connector/notifier/email"
|
notifyemail "github.com/certctl-io/certctl/internal/connector/notifier/email"
|
||||||
notifyopsgenie "github.com/shankar0123/certctl/internal/connector/notifier/opsgenie"
|
notifyopsgenie "github.com/certctl-io/certctl/internal/connector/notifier/opsgenie"
|
||||||
notifypagerduty "github.com/shankar0123/certctl/internal/connector/notifier/pagerduty"
|
notifypagerduty "github.com/certctl-io/certctl/internal/connector/notifier/pagerduty"
|
||||||
notifyslack "github.com/shankar0123/certctl/internal/connector/notifier/slack"
|
notifyslack "github.com/certctl-io/certctl/internal/connector/notifier/slack"
|
||||||
notifyteams "github.com/shankar0123/certctl/internal/connector/notifier/teams"
|
notifyteams "github.com/certctl-io/certctl/internal/connector/notifier/teams"
|
||||||
"github.com/shankar0123/certctl/internal/crypto/signer"
|
"github.com/certctl-io/certctl/internal/crypto/signer"
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
"github.com/shankar0123/certctl/internal/ratelimit"
|
"github.com/certctl-io/certctl/internal/ratelimit"
|
||||||
"github.com/shankar0123/certctl/internal/repository/postgres"
|
"github.com/certctl-io/certctl/internal/repository/postgres"
|
||||||
"github.com/shankar0123/certctl/internal/scep/intune"
|
"github.com/certctl-io/certctl/internal/scep/intune"
|
||||||
"github.com/shankar0123/certctl/internal/scheduler"
|
"github.com/certctl-io/certctl/internal/scheduler"
|
||||||
"github.com/shankar0123/certctl/internal/service"
|
"github.com/certctl-io/certctl/internal/service"
|
||||||
"github.com/shankar0123/certctl/internal/trustanchor"
|
"github.com/certctl-io/certctl/internal/trustanchor"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -267,6 +267,43 @@ func main() {
|
|||||||
// same *sql.DB handle.
|
// same *sql.DB handle.
|
||||||
transactor := postgres.NewTransactor(db)
|
transactor := postgres.NewTransactor(db)
|
||||||
certificateService.SetTransactor(transactor)
|
certificateService.SetTransactor(transactor)
|
||||||
|
|
||||||
|
// Rank 7 of the 2026-05-03 Infisical deep-research deliverable —
|
||||||
|
// issuance approval-workflow primitive. ApprovalRepository +
|
||||||
|
// ApprovalMetrics + ApprovalService construct here; the gate is
|
||||||
|
// activated on CertificateService via SetApprovalService +
|
||||||
|
// SetProfileRepo. Inactive when CertificateProfile.RequiresApproval
|
||||||
|
// is false (the default), preserving the historical unattended
|
||||||
|
// renewal path. See docs/approval-workflow.md.
|
||||||
|
approvalRepo := postgres.NewApprovalRepository(db)
|
||||||
|
approvalMetrics := service.NewApprovalMetrics()
|
||||||
|
approvalService := service.NewApprovalService(approvalRepo, jobRepo, auditService,
|
||||||
|
approvalMetrics, cfg.Approval.BypassEnabled)
|
||||||
|
if cfg.Approval.BypassEnabled {
|
||||||
|
logger.Warn("CERTCTL_APPROVAL_BYPASS=true — every approval auto-approves with actor=system-bypass; production deploys must leave this unset")
|
||||||
|
}
|
||||||
|
certificateService.SetApprovalService(approvalService)
|
||||||
|
certificateService.SetProfileRepo(profileRepo)
|
||||||
|
approvalHandler := handler.NewApprovalHandler(approvalService)
|
||||||
|
|
||||||
|
// Rank 8 of the 2026-05-03 deep-research deliverable — first-class
|
||||||
|
// CA hierarchy management (intermediate_cas table + admin-gated
|
||||||
|
// hierarchy endpoints). The service receives the issuerRepo so
|
||||||
|
// future surface area (issuer-row hierarchy_mode validation) can
|
||||||
|
// query the issuer config; for the commit-4 wiring it carries
|
||||||
|
// only the fields used today. The signer.FileDriver shared with
|
||||||
|
// the OCSP responder bootstrap path is reused here — operators
|
||||||
|
// can plug in PKCS#11 / cloud-KMS drivers via the same Driver
|
||||||
|
// interface without touching the service. See
|
||||||
|
// docs/intermediate-ca-hierarchy.md.
|
||||||
|
intermediateCARepo := postgres.NewIntermediateCARepository(db)
|
||||||
|
intermediateCAMetrics := service.NewIntermediateCAMetrics()
|
||||||
|
// Defer wiring the service + handler — signerDriver is constructed
|
||||||
|
// further down in this function alongside the OCSP responder
|
||||||
|
// bootstrap path. The service holds a reference to issuerRepo for
|
||||||
|
// future hierarchy_mode validation surface area.
|
||||||
|
_ = intermediateCAMetrics // service constructed below alongside signerDriver
|
||||||
|
|
||||||
notifierRegistry := make(map[string]service.Notifier)
|
notifierRegistry := make(map[string]service.Notifier)
|
||||||
|
|
||||||
// Wire notifier connectors from config
|
// Wire notifier connectors from config
|
||||||
@@ -371,6 +408,15 @@ func main() {
|
|||||||
RotationGrace: cfg.OCSPResponder.RotationGrace,
|
RotationGrace: cfg.OCSPResponder.RotationGrace,
|
||||||
Validity: cfg.OCSPResponder.Validity,
|
Validity: cfg.OCSPResponder.Validity,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Rank 8 service + handler — wired here so signerDriver is in
|
||||||
|
// scope. The same FileDriver instance feeds both the OCSP
|
||||||
|
// responder bootstrap path and the intermediate-CA hierarchy.
|
||||||
|
// Operators that swap to PKCS#11 / cloud-KMS drivers reuse the
|
||||||
|
// single Driver instance across both surfaces.
|
||||||
|
intermediateCAService := service.NewIntermediateCAService(
|
||||||
|
intermediateCARepo, issuerRepo, signerDriver, auditService, intermediateCAMetrics)
|
||||||
|
intermediateCAHandler := handler.NewIntermediateCAHandler(intermediateCAService)
|
||||||
crlCacheService := service.NewCRLCacheService(crlCacheRepo, caOperationsSvc, issuerRegistry, logger)
|
crlCacheService := service.NewCRLCacheService(crlCacheRepo, caOperationsSvc, issuerRegistry, logger)
|
||||||
|
|
||||||
// Production hardening II Phase 2: OCSP response cache. Mirrors the
|
// Production hardening II Phase 2: OCSP response cache. Mirrors the
|
||||||
@@ -907,6 +953,14 @@ func main() {
|
|||||||
// new-order, finalize, challenges, revoke, ARI). See
|
// new-order, finalize, challenges, revoke, ARI). See
|
||||||
// docs/acme-server.md for the operator-facing reference.
|
// docs/acme-server.md for the operator-facing reference.
|
||||||
ACME: acmeHandler,
|
ACME: acmeHandler,
|
||||||
|
// Approvals — issuance approval-workflow primitive. Rank 7 of
|
||||||
|
// the 2026-05-03 Infisical deep-research deliverable. See
|
||||||
|
// docs/approval-workflow.md.
|
||||||
|
Approvals: approvalHandler,
|
||||||
|
// IntermediateCAs — first-class CA hierarchy management.
|
||||||
|
// Rank 8 of the 2026-05-03 deep-research deliverable. See
|
||||||
|
// docs/intermediate-ca-hierarchy.md.
|
||||||
|
IntermediateCAs: intermediateCAHandler,
|
||||||
})
|
})
|
||||||
// Register EST (RFC 7030) handlers if enabled.
|
// Register EST (RFC 7030) handlers if enabled.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||||
"github.com/shankar0123/certctl/internal/api/router"
|
"github.com/certctl-io/certctl/internal/api/router"
|
||||||
"github.com/shankar0123/certctl/internal/config"
|
"github.com/certctl-io/certctl/internal/config"
|
||||||
"github.com/shankar0123/certctl/internal/service"
|
"github.com/certctl-io/certctl/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestMain_HealthEndpointBypassesAuth verifies that health check endpoints
|
// TestMain_HealthEndpointBypassesAuth verifies that health check endpoints
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/service"
|
"github.com/certctl-io/certctl/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// fakeIssuerConn implements service.IssuerConnector enough for preflight tests.
|
// fakeIssuerConn implements service.IssuerConnector enough for preflight tests.
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/deploy"
|
"github.com/certctl-io/certctl/internal/deploy"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestDeploy_Atomicity_FileIsAlwaysOldOrNew pins the load-bearing
|
// TestDeploy_Atomicity_FileIsAlwaysOldOrNew pins the load-bearing
|
||||||
|
|||||||
Binary file not shown.
@@ -1,3 +1,3 @@
|
|||||||
module github.com/shankar0123/certctl/deploy/test/f5-mock-icontrol
|
module github.com/certctl-io/certctl/deploy/test/f5-mock-icontrol
|
||||||
|
|
||||||
go 1.25.9
|
go 1.25.9
|
||||||
|
|||||||
@@ -290,7 +290,15 @@ services:
|
|||||||
# /healthz endpoint.
|
# /healthz endpoint.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
f5-mock-target:
|
f5-mock-target:
|
||||||
build: ../f5-mock-icontrol
|
# Long-form build to match docker-compose.test.yml: the Dockerfile
|
||||||
|
# has `COPY deploy/test/f5-mock-icontrol/ ./` which assumes the
|
||||||
|
# build context is the REPO ROOT. The previous shorthand form
|
||||||
|
# `build: ../f5-mock-icontrol` set the context to the
|
||||||
|
# f5-mock-icontrol directory itself, breaking the COPY at CI build
|
||||||
|
# time (run #25305811340: "deploy/test/f5-mock-icontrol: not found").
|
||||||
|
build:
|
||||||
|
context: ../../..
|
||||||
|
dockerfile: deploy/test/f5-mock-icontrol/Dockerfile
|
||||||
container_name: certctl-loadtest-f5-mock
|
container_name: certctl-loadtest-f5-mock
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget -q -O- http://localhost:8080/healthz || exit 1"]
|
test: ["CMD-SHELL", "wget -q -O- http://localhost:8080/healthz || exit 1"]
|
||||||
|
|||||||
@@ -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`.
|
**Sub-CA mode:** Loads a CA certificate and private key from disk (`CERTCTL_CA_CERT_PATH` + `CERTCTL_CA_KEY_PATH`). The CA cert is signed by an upstream CA (e.g., ADCS), so all issued certificates chain to the enterprise root trust hierarchy. Clients that already trust the enterprise root automatically trust certctl-issued certs. Supports RSA, ECDSA, and PKCS#8 key formats. If the paths are not set, falls back to self-signed mode. The loaded certificate must have `IsCA=true` and `KeyUsageCertSign`.
|
||||||
|
|
||||||
|
**Tree mode (Rank 8 — multi-level CA hierarchy):** When `Issuer.HierarchyMode = "tree"` is set on the issuer row, the local connector reads the active CA hierarchy from the `intermediate_cas` table and assembles `IssuanceResult.ChainPEM` by walking the `parent_ca_id` ancestry from the issuing leaf CA up to the root. Tree mode is operator-managed via the admin-gated `/api/v1/issuers/{id}/intermediates` and `/api/v1/intermediates/{id}` endpoints (`POST` to create / sign children, `GET` to list / inspect, `POST .../retire` to two-phase retire). The signing path is shared with single-mode (cert is signed via `c.caCert` + `c.caSigner` from the on-disk issuing CA cert+key); only the chain bytes differ. RFC 5280 §3.2 (self-signed root validation), §4.2.1.9 (path-length tightening), and §4.2.1.10 (NameConstraints subset semantics) are enforced at the service layer fail-closed. The default is `single`, byte-identical to the pre-Rank-8 historical flow. See `docs/intermediate-ca-hierarchy.md` for the operator runbook covering 4-level FedRAMP boundary CA, 3-level financial-services policy CA, 2-level internal-PKI patterns + the migration runbook for flipping a single-mode issuer to tree.
|
||||||
|
|
||||||
**CRL and OCSP support (M15b):** The Local CA supports DER-encoded X.509 CRL generation served unauthenticated at `GET /.well-known/pki/crl/{issuer_id}` (RFC 5280 §5, RFC 8615, `Content-Type: application/pkix-crl`) with 24-hour validity. An embedded OCSP responder at `GET /.well-known/pki/ocsp/{issuer_id}/{serial}` (RFC 6960, `Content-Type: application/ocsp-response`) returns signed OCSP responses for issued certificates (good/revoked/unknown status). Both endpoints are reachable by relying parties with no certctl API credentials, which is how standard TLS clients, browsers, and hardware appliances consume these resources. Certificates with profile TTL < 1 hour automatically skip CRL/OCSP — expiry is treated as sufficient revocation for short-lived credentials.
|
**CRL and OCSP support (M15b):** The Local CA supports DER-encoded X.509 CRL generation served unauthenticated at `GET /.well-known/pki/crl/{issuer_id}` (RFC 5280 §5, RFC 8615, `Content-Type: application/pkix-crl`) with 24-hour validity. An embedded OCSP responder at `GET /.well-known/pki/ocsp/{issuer_id}/{serial}` (RFC 6960, `Content-Type: application/ocsp-response`) returns signed OCSP responses for issued certificates (good/revoked/unknown status). Both endpoints are reachable by relying parties with no certctl API credentials, which is how standard TLS clients, browsers, and hardware appliances consume these resources. Certificates with profile TTL < 1 hour automatically skip CRL/OCSP — expiry is treated as sufficient revocation for short-lived credentials.
|
||||||
|
|
||||||
**Extended Key Usage (EKU) support (M27):** The Local CA respects EKU constraints from certificate profiles and adjusts key usage flags accordingly. For S/MIME certificates (emailProtection EKU), it uses `DigitalSignature | ContentCommitment` instead of the TLS default. For TLS certificates (serverAuth/clientAuth EKU), it uses `DigitalSignature | KeyEncipherment`. This enables support for multiple certificate types — TLS, S/MIME, code signing, timestamping — from a single CA.
|
**Extended Key Usage (EKU) support (M27):** The Local CA respects EKU constraints from certificate profiles and adjusts key usage flags accordingly. For S/MIME certificates (emailProtection EKU), it uses `DigitalSignature | ContentCommitment` instead of the TLS default. For TLS certificates (serverAuth/clientAuth EKU), it uses `DigitalSignature | KeyEncipherment`. This enables support for multiple certificate types — TLS, S/MIME, code signing, timestamping — from a single CA.
|
||||||
|
|||||||
@@ -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)
|
## End-to-end flow (cloud targets)
|
||||||
|
|
||||||
```
|
```mermaid
|
||||||
cert renewed → renewal job created
|
flowchart TD
|
||||||
│
|
Renew["cert renewed → renewal job created"]
|
||||||
▼
|
Pick["agent picks up DeployCertificate work item"]
|
||||||
agent picks up DeployCertificate work item
|
Dispatch["target.Connector.DeployCertificate(ctx, request)"]
|
||||||
│
|
|
||||||
▼
|
Renew --> Pick --> Dispatch
|
||||||
target.Connector.DeployCertificate(ctx, request)
|
Dispatch --> AWS
|
||||||
│
|
Dispatch --> AZ
|
||||||
┌──────────────────┴──────────────────┐
|
|
||||||
│ │
|
subgraph AWS["AWS ACM path"]
|
||||||
▼ ▼
|
A1["1. rotate-in-place only:<br/>DescribeCertificate(arn)"]
|
||||||
AWS ACM path Azure Key Vault path
|
A2["2. GetCertificate(arn) —<br/>capture snapshot bytes for rollback"]
|
||||||
│ │
|
A3["3. ImportCertificate(arn, new_bytes) —<br/>fresh ARN OR rotate-in-place"]
|
||||||
▼ ▼
|
A4["4. AddTagsToCertificate(arn, provenance) —<br/>ACM strips on re-import; we re-apply"]
|
||||||
1. (rotate-in-place only) 1. GetCertificate(name, "" /* latest */)
|
A5["5. DescribeCertificate(arn) —<br/>verify serial matches expected"]
|
||||||
DescribeCertificate(arn) — capture snapshot CER bytes
|
A6["6. ON MISMATCH: rollback<br/>ImportCertificate(arn, snapshot_bytes)"]
|
||||||
2. GetCertificate(arn) — capture 2. Build PFX from cert+chain+key
|
A1 --> A2 --> A3 --> A4 --> A5 --> A6
|
||||||
snapshot bytes for rollback (PKCS#12 via go-pkcs12)
|
end
|
||||||
3. ImportCertificate(arn, new_bytes) 3. ImportCertificate(name, PFX, tags)
|
|
||||||
— fresh ARN OR rotate-in-place — ALWAYS creates a new version
|
subgraph AZ["Azure Key Vault path"]
|
||||||
4. AddTagsToCertificate(arn, 4. (Tags carried forward
|
Z1["1. GetCertificate(name, '' = latest) —<br/>capture snapshot CER bytes"]
|
||||||
provenance) — ACM strips on automatically)
|
Z2["2. Build PFX from cert+chain+key<br/>(PKCS#12 via go-pkcs12)"]
|
||||||
re-import; we re-apply
|
Z3["3. ImportCertificate(name, PFX, tags) —<br/>ALWAYS creates a new version"]
|
||||||
5. DescribeCertificate(arn) — verify 5. GetCertificate(name, "" /* latest */)
|
Z4["4. Tags carried forward automatically"]
|
||||||
serial matches expected — verify serial matches expected
|
Z5["5. GetCertificate(name, '' = latest) —<br/>verify serial matches expected"]
|
||||||
6. ON MISMATCH: rollback ←──── (same shape) ────→ 6. ON MISMATCH: rollback
|
Z6["6. ON MISMATCH: rollback<br/>ImportCertificate(name, snapshot_PFX) —<br/>new version"]
|
||||||
ImportCertificate(arn, ImportCertificate(name,
|
Z1 --> Z2 --> Z3 --> Z4 --> Z5 --> Z6
|
||||||
snapshot_bytes) snapshot_PFX) — new version
|
end
|
||||||
│
|
|
||||||
▼
|
A6 --> Audit
|
||||||
7. Audit row + Prometheus counter
|
Z6 --> Audit
|
||||||
certctl_deploy_attempts_total{target_type="AWSACM"|"AzureKeyVault",
|
Audit["7. Audit row + Prometheus counters<br/>certctl_deploy_attempts_total{target_type, result}<br/>certctl_deploy_rollback_total{target_type, outcome}"]
|
||||||
result="success"|"failure"}
|
|
||||||
certctl_deploy_rollback_total{target_type=...,
|
|
||||||
outcome="restored"|"also_failed"}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -14,36 +14,37 @@ walkthrough of how to install certctl — that lives in the README.
|
|||||||
|
|
||||||
## End-to-end flow
|
## End-to-end flow
|
||||||
|
|
||||||
```
|
```mermaid
|
||||||
daily ticker (renewalCheckLoop)
|
flowchart TD
|
||||||
│
|
Tick["daily ticker (renewalCheckLoop)"]
|
||||||
▼
|
Check["RenewalService.CheckExpiringCertificates"]
|
||||||
RenewalService.CheckExpiringCertificates
|
|
||||||
│
|
Tick --> Check --> Loop
|
||||||
┌────────────────┴────────────────┐
|
|
||||||
│ for cert in expiring (≤30 days):│
|
subgraph Loop["for cert in expiring (≤30 days)"]
|
||||||
│ 1. Resolve RenewalPolicy │
|
L1["1. Resolve RenewalPolicy"]
|
||||||
│ 2. Compute daysUntil │
|
L2["2. Compute daysUntil"]
|
||||||
│ 3. updateCertExpiryStatus │
|
L3["3. updateCertExpiryStatus"]
|
||||||
│ 4. sendThresholdAlerts ──────►│ per threshold:
|
L4["4. sendThresholdAlerts"]
|
||||||
│ 5. Create renewal job (if │ a. resolve severity tier
|
L5["5. Create renewal job<br/>(if issuer registered +<br/>ARI allows)"]
|
||||||
│ issuer registered + ARI │ via AlertSeverityMap
|
L1 --> L2 --> L3 --> L4 --> L5
|
||||||
│ allows) │ b. resolve channel set
|
end
|
||||||
└──────────────────────────────────┘ via AlertChannels[tier]
|
|
||||||
c. for each channel:
|
L4 --> Threshold
|
||||||
i. dedup via
|
|
||||||
notification_events
|
subgraph Threshold["per threshold"]
|
||||||
(cert,threshold,channel)
|
T1["a. resolve severity tier<br/>via AlertSeverityMap"]
|
||||||
ii. SendThresholdAlertOnChannel
|
T2["b. resolve channel set<br/>via AlertChannels[tier]"]
|
||||||
→ notifierRegistry[channel]
|
T1 --> T2 --> Channel
|
||||||
→ Send(recipient,subj,body)
|
end
|
||||||
iii. record audit row
|
|
||||||
(event_type=expiration_alert_sent,
|
subgraph Channel["for each channel (fault-isolating)"]
|
||||||
metadata.channel,
|
C1["i. dedup via notification_events<br/>(cert, threshold, channel)"]
|
||||||
metadata.severity_tier)
|
C2["ii. SendThresholdAlertOnChannel<br/>→ notifierRegistry[channel]<br/>→ Send(recipient, subj, body)"]
|
||||||
iv. bump Prometheus counter
|
C3["iii. record audit row<br/>event_type=expiration_alert_sent<br/>metadata.channel, metadata.severity_tier"]
|
||||||
certctl_expiry_alerts_total
|
C4["iv. bump Prometheus counter<br/>certctl_expiry_alerts_total<br/>{channel, threshold, result}"]
|
||||||
{channel,threshold,result}
|
C1 --> C2 --> C3 --> C4
|
||||||
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
The dispatch loop's per-channel error handling is
|
The dispatch loop's per-channel error handling is
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module github.com/shankar0123/certctl
|
module github.com/certctl-io/certctl
|
||||||
|
|
||||||
go 1.25.9
|
go 1.25.9
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ require (
|
|||||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
|
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
|
||||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect
|
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect
|
||||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
|
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
|
||||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
|
github.com/Azure/go-ntlmssp v0.1.1 // indirect
|
||||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
|
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
|
||||||
github.com/ChrisTrenkamp/goxpath v0.0.0-20210404020558-97928f7e12b6 // indirect
|
github.com/ChrisTrenkamp/goxpath v0.0.0-20210404020558-97928f7e12b6 // indirect
|
||||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfg
|
|||||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA=
|
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA=
|
||||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
|
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
|
||||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
|
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
|
||||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
|
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||||
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM=
|
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM=
|
||||||
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=
|
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=
|
||||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
|
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
package acme
|
package acme
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AccountResponseJSON is the wire shape RFC 8555 §7.1.2 mandates for
|
// AccountResponseJSON is the wire shape RFC 8555 §7.1.2 mandates for
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Phase 4 — RFC 9773 ACME Renewal Information.
|
// Phase 4 — RFC 9773 ACME Renewal Information.
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
|
|
||||||
jose "github.com/go-jose/go-jose/v4"
|
jose "github.com/go-jose/go-jose/v4"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AllowedSignatureAlgorithms is the closed allow-list per RFC 8555 §6.2.
|
// AllowedSignatureAlgorithms is the closed allow-list per RFC 8555 §6.2.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
|
|
||||||
jose "github.com/go-jose/go-jose/v4"
|
jose "github.com/go-jose/go-jose/v4"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- test fixtures + helpers --------------------------------------------
|
// --- test fixtures + helpers --------------------------------------------
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// OrderResponseJSON is the wire shape RFC 8555 §7.1.3 mandates for the
|
// OrderResponseJSON is the wire shape RFC 8555 §7.1.3 mandates for the
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import (
|
|||||||
|
|
||||||
jose "github.com/go-jose/go-jose/v4"
|
jose "github.com/go-jose/go-jose/v4"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- Test fixtures + helpers -------------------------------------------
|
// --- Test fixtures + helpers -------------------------------------------
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import (
|
|||||||
|
|
||||||
"golang.org/x/sync/semaphore"
|
"golang.org/x/sync/semaphore"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/validation"
|
"github.com/certctl-io/certctl/internal/validation"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ChallengeValidator is the surface a challenge-validation worker
|
// ChallengeValidator is the surface a challenge-validation worker
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ import (
|
|||||||
|
|
||||||
jose "github.com/go-jose/go-jose/v4"
|
jose "github.com/go-jose/go-jose/v4"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/api/acme"
|
"github.com/certctl-io/certctl/internal/api/acme"
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
"github.com/shankar0123/certctl/internal/service"
|
"github.com/certctl-io/certctl/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MaxJWSBodyBytes caps the per-request JWS payload at 64 KiB. RFC 8555
|
// MaxJWSBodyBytes caps the per-request JWS payload at 64 KiB. RFC 8555
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ import (
|
|||||||
|
|
||||||
jose "github.com/go-jose/go-jose/v4"
|
jose "github.com/go-jose/go-jose/v4"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/api/acme"
|
"github.com/certctl-io/certctl/internal/api/acme"
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
"github.com/shankar0123/certctl/internal/service"
|
"github.com/certctl-io/certctl/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// mockACMEService implements ACMEService for handler-level tests.
|
// mockACMEService implements ACMEService for handler-level tests.
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
"github.com/shankar0123/certctl/internal/repository"
|
"github.com/certctl-io/certctl/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AdminCRLCacheService is the slice of CRLCacheRepository the admin
|
// AdminCRLCacheService is the slice of CRLCacheRepository the admin
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
// fakeAdminCRLCacheService is the test stub for the
|
// fakeAdminCRLCacheService is the test stub for the
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||||
"github.com/shankar0123/certctl/internal/service"
|
"github.com/certctl-io/certctl/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// EST RFC 7030 hardening master bundle Phase 7.2 — admin observability
|
// EST RFC 7030 hardening master bundle Phase 7.2 — admin observability
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||||
"github.com/shankar0123/certctl/internal/service"
|
"github.com/certctl-io/certctl/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// EST RFC 7030 hardening master bundle Phase 7.4 — admin handler tests.
|
// EST RFC 7030 hardening master bundle Phase 7.4 — admin handler tests.
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||||
"github.com/shankar0123/certctl/internal/service"
|
"github.com/certctl-io/certctl/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AdminSCEPIntuneService is the slice of the per-profile SCEPService set
|
// AdminSCEPIntuneService is the slice of the per-profile SCEPService set
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||||
"github.com/shankar0123/certctl/internal/service"
|
"github.com/certctl-io/certctl/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// fakeAdminSCEPIntuneService is the test stub for AdminSCEPIntuneService.
|
// fakeAdminSCEPIntuneService is the test stub for AdminSCEPIntuneService.
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// adversarialCSRInputs exercises the EST CSR parsing surface. None of these
|
// adversarialCSRInputs exercises the EST CSR parsing surface. None of these
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// adversarialPathInputs is the attack catalog shared by Tier 1A cases. Each
|
// adversarialPathInputs is the attack catalog shared by Tier 1A cases. Each
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
"github.com/shankar0123/certctl/internal/repository"
|
"github.com/certctl-io/certctl/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
// buildListRequest constructs a GET /api/v1/certificates request with the
|
// buildListRequest constructs a GET /api/v1/certificates request with the
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MockAgentGroupService is a mock implementation of AgentGroupService interface.
|
// MockAgentGroupService is a mock implementation of AgentGroupService interface.
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"github.com/shankar0123/certctl/internal/repository"
|
"github.com/certctl-io/certctl/internal/repository"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AgentGroupService defines the service interface for agent group operations.
|
// AgentGroupService defines the service interface for agent group operations.
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
"github.com/shankar0123/certctl/internal/service"
|
"github.com/certctl-io/certctl/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MockAgentService is a mock implementation of AgentService interface.
|
// MockAgentService is a mock implementation of AgentService interface.
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
"github.com/shankar0123/certctl/internal/service"
|
"github.com/certctl-io/certctl/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// agentRetireTestSetup builds an AgentHandler with a mock AgentService whose
|
// agentRetireTestSetup builds an AgentHandler with a mock AgentService whose
|
||||||
|
|||||||
@@ -4,16 +4,16 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"github.com/shankar0123/certctl/internal/repository"
|
"github.com/certctl-io/certctl/internal/repository"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
"github.com/shankar0123/certctl/internal/service"
|
"github.com/certctl-io/certctl/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AgentService defines the service interface for agent operations.
|
// AgentService defines the service interface for agent operations.
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -6,8 +6,8 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AuditService defines the service interface for audit event operations.
|
// AuditService defines the service interface for audit event operations.
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// mockAuditService implements AuditService for testing.
|
// mockAuditService implements AuditService for testing.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Bundle C / Audit M-007 (CWE-754): partial-failure tests for the three
|
// Bundle C / Audit M-007 (CWE-754): partial-failure tests for the three
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
"github.com/shankar0123/certctl/internal/service"
|
"github.com/certctl-io/certctl/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BulkReassignmentService defines the service interface for bulk
|
// BulkReassignmentService defines the service interface for bulk
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
"github.com/shankar0123/certctl/internal/service"
|
"github.com/certctl-io/certctl/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
type mockBulkReassignmentService struct {
|
type mockBulkReassignmentService struct {
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BulkRenewalService defines the service interface for bulk certificate
|
// BulkRenewalService defines the service interface for bulk certificate
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// mockBulkRenewalService is a test implementation of BulkRenewalService.
|
// mockBulkRenewalService is a test implementation of BulkRenewalService.
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BulkRevocationService defines the service interface for bulk certificate revocation.
|
// BulkRevocationService defines the service interface for bulk certificate revocation.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// EST RFC 7030 hardening master bundle Phase 11.4 — BulkRevokeEST handler tests.
|
// EST RFC 7030 hardening master bundle Phase 11.4 — BulkRevokeEST handler tests.
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// mockBulkRevocationService is a test implementation of BulkRevocationService
|
// mockBulkRevocationService is a test implementation of BulkRevocationService
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ import (
|
|||||||
|
|
||||||
"golang.org/x/crypto/ocsp"
|
"golang.org/x/crypto/ocsp"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
"github.com/shankar0123/certctl/internal/repository"
|
"github.com/certctl-io/certctl/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MockCertificateService is a mock implementation of CertificateService interface.
|
// MockCertificateService is a mock implementation of CertificateService interface.
|
||||||
|
|||||||
@@ -16,11 +16,11 @@ import (
|
|||||||
|
|
||||||
"golang.org/x/crypto/ocsp"
|
"golang.org/x/crypto/ocsp"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
"github.com/shankar0123/certctl/internal/ratelimit"
|
"github.com/certctl-io/certctl/internal/ratelimit"
|
||||||
"github.com/shankar0123/certctl/internal/repository"
|
"github.com/certctl-io/certctl/internal/repository"
|
||||||
"github.com/shankar0123/certctl/internal/service"
|
"github.com/certctl-io/certctl/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CertificateService defines the service interface for certificate operations.
|
// CertificateService defines the service interface for certificate operations.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// EST RFC 7030 hardening master bundle Phase 10.3 — Cisco IOS quirk
|
// EST RFC 7030 hardening master bundle Phase 10.3 — Cisco IOS quirk
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DiscoveryService defines the interface used by the discovery handler.
|
// DiscoveryService defines the interface used by the discovery handler.
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MockDiscoveryService is a mock implementation of DiscoveryService interface.
|
// MockDiscoveryService is a mock implementation of DiscoveryService interface.
|
||||||
|
|||||||
@@ -14,12 +14,12 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||||
"github.com/shankar0123/certctl/internal/cms"
|
"github.com/certctl-io/certctl/internal/cms"
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
"github.com/shankar0123/certctl/internal/pkcs7"
|
"github.com/certctl-io/certctl/internal/pkcs7"
|
||||||
"github.com/shankar0123/certctl/internal/ratelimit"
|
"github.com/certctl-io/certctl/internal/ratelimit"
|
||||||
"github.com/shankar0123/certctl/internal/trustanchor"
|
"github.com/certctl-io/certctl/internal/trustanchor"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ESTService defines the service interface for EST enrollment operations.
|
// ESTService defines the service interface for EST enrollment operations.
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
"github.com/shankar0123/certctl/internal/pkcs7"
|
"github.com/certctl-io/certctl/internal/pkcs7"
|
||||||
)
|
)
|
||||||
|
|
||||||
// mockESTService implements ESTService for testing.
|
// mockESTService implements ESTService for testing.
|
||||||
|
|||||||
@@ -21,10 +21,10 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/cms"
|
"github.com/certctl-io/certctl/internal/cms"
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
"github.com/shankar0123/certctl/internal/ratelimit"
|
"github.com/certctl-io/certctl/internal/ratelimit"
|
||||||
"github.com/shankar0123/certctl/internal/trustanchor"
|
"github.com/certctl-io/certctl/internal/trustanchor"
|
||||||
)
|
)
|
||||||
|
|
||||||
// EST RFC 7030 hardening master bundle Phases 2-4 tests.
|
// EST RFC 7030 hardening master bundle Phases 2-4 tests.
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
"github.com/shankar0123/certctl/internal/pkcs7"
|
"github.com/certctl-io/certctl/internal/pkcs7"
|
||||||
)
|
)
|
||||||
|
|
||||||
// EST RFC 7030 hardening master bundle Phase 5.3 — serverkeygen tests.
|
// EST RFC 7030 hardening master bundle Phase 5.3 — serverkeygen tests.
|
||||||
|
|||||||
@@ -5,15 +5,15 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/shankar0123/certctl/internal/repository"
|
"github.com/certctl-io/certctl/internal/repository"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||||
"github.com/shankar0123/certctl/internal/ratelimit"
|
"github.com/certctl-io/certctl/internal/ratelimit"
|
||||||
"github.com/shankar0123/certctl/internal/service"
|
"github.com/certctl-io/certctl/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ExportService defines the service interface for certificate export operations.
|
// ExportService defines the service interface for certificate export operations.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/service"
|
"github.com/certctl-io/certctl/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Add context import was already there — verify import is present above
|
// Add context import was already there — verify import is present above
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HealthHandler handles health and readiness check endpoints.
|
// HealthHandler handles health and readiness check endpoints.
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
"github.com/shankar0123/certctl/internal/repository"
|
"github.com/certctl-io/certctl/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HealthCheckServicer defines the interface used by the health check handler.
|
// HealthCheckServicer defines the interface used by the health check handler.
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
"github.com/shankar0123/certctl/internal/repository"
|
"github.com/certctl-io/certctl/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
// mockHealthCheckSvc implements HealthCheckServicer for testing.
|
// mockHealthCheckSvc implements HealthCheckServicer for testing.
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||||
_ "github.com/lib/pq" // Bundle-5 / H-006: postgres driver for /ready DB-probe regression test
|
_ "github.com/lib/pq" // Bundle-5 / H-006: postgres driver for /ready DB-probe regression test
|
||||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestHealth_ReturnsOK(t *testing.T) {
|
func TestHealth_ReturnsOK(t *testing.T) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MockIssuerService is a mock implementation of IssuerService interface.
|
// MockIssuerService is a mock implementation of IssuerService interface.
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"github.com/shankar0123/certctl/internal/repository"
|
"github.com/certctl-io/certctl/internal/repository"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// IssuerService defines the service interface for issuer operations.
|
// IssuerService defines the service interface for issuer operations.
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
"github.com/shankar0123/certctl/internal/service"
|
"github.com/certctl-io/certctl/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MockJobService is a mock implementation of JobService interface.
|
// MockJobService is a mock implementation of JobService interface.
|
||||||
|
|||||||
@@ -4,15 +4,15 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"github.com/shankar0123/certctl/internal/repository"
|
"github.com/certctl-io/certctl/internal/repository"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
"github.com/shankar0123/certctl/internal/service"
|
"github.com/certctl-io/certctl/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// JobService defines the service interface for job operations.
|
// JobService defines the service interface for job operations.
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ var AdminGatedHandlers = map[string]string{
|
|||||||
"admin_crl_cache.go": "CRL/OCSP-Responder Phase 5: cache state reveals issuer set + CRL cadence — admin-only",
|
"admin_crl_cache.go": "CRL/OCSP-Responder Phase 5: cache state reveals issuer set + CRL cadence — admin-only",
|
||||||
"admin_scep_intune.go": "SCEP RFC 8894 + Intune master bundle Phase 9.2 + Phase 9 follow-up: profiles + stats endpoints reveal per-profile RA cert expiries + Intune trust anchor expiries + mTLS bundle paths; reload-trust is a privileged action — admin-only",
|
"admin_scep_intune.go": "SCEP RFC 8894 + Intune master bundle Phase 9.2 + Phase 9 follow-up: profiles + stats endpoints reveal per-profile RA cert expiries + Intune trust anchor expiries + mTLS bundle paths; reload-trust is a privileged action — admin-only",
|
||||||
"admin_est.go": "EST RFC 7030 hardening master bundle Phase 7.2: profiles endpoint reveals per-profile counter snapshot + mTLS trust-anchor expiries + auth modes; reload-trust is a privileged action — admin-only",
|
"admin_est.go": "EST RFC 7030 hardening master bundle Phase 7.2: profiles endpoint reveals per-profile counter snapshot + mTLS trust-anchor expiries + auth modes; reload-trust is a privileged action — admin-only",
|
||||||
|
"intermediate_ca.go": "Rank 8: CA hierarchy management mints sub-CA certs that become trust roots for every downstream leaf — admin-only fleet-scale destructive surface",
|
||||||
}
|
}
|
||||||
|
|
||||||
// InformationalIsAdminCallers is the documented allowlist of files that
|
// InformationalIsAdminCallers is the documented allowlist of files that
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||||
"github.com/shankar0123/certctl/internal/service"
|
"github.com/certctl-io/certctl/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MetricsService defines the service interface for metrics collection.
|
// MetricsService defines the service interface for metrics collection.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NetworkScanService defines the interface used by the network scan handler.
|
// NetworkScanService defines the interface used by the network scan handler.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// mockNetworkScanService implements NetworkScanService for testing.
|
// mockNetworkScanService implements NetworkScanService for testing.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MockNotificationService is a mock implementation of NotificationService interface.
|
// MockNotificationService is a mock implementation of NotificationService interface.
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"github.com/shankar0123/certctl/internal/repository"
|
"github.com/certctl-io/certctl/internal/repository"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NotificationService defines the service interface for notification operations.
|
// NotificationService defines the service interface for notification operations.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MockOwnerService is a mock implementation of OwnerService interface.
|
// MockOwnerService is a mock implementation of OwnerService interface.
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"github.com/shankar0123/certctl/internal/repository"
|
"github.com/certctl-io/certctl/internal/repository"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// OwnerService defines the service interface for owner operations.
|
// OwnerService defines the service interface for owner operations.
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PolicyService defines the service interface for policy rule operations.
|
// PolicyService defines the service interface for policy rule operations.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MockPolicyService is a mock implementation of PolicyService interface.
|
// MockPolicyService is a mock implementation of PolicyService interface.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MockProfileService is a mock implementation of ProfileService interface.
|
// MockProfileService is a mock implementation of ProfileService interface.
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"github.com/shankar0123/certctl/internal/repository"
|
"github.com/certctl-io/certctl/internal/repository"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ProfileService defines the service interface for certificate profile operations.
|
// ProfileService defines the service interface for certificate profile operations.
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"github.com/shankar0123/certctl/internal/repository"
|
"github.com/certctl-io/certctl/internal/repository"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
"github.com/shankar0123/certctl/internal/service"
|
"github.com/certctl-io/certctl/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RenewalPolicyService defines the service interface for renewal policy
|
// RenewalPolicyService defines the service interface for renewal policy
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
"github.com/shankar0123/certctl/internal/service"
|
"github.com/certctl-io/certctl/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// G-1 red tests: lock in the HTTP surface of /api/v1/renewal-policies before
|
// G-1 red tests: lock in the HTTP surface of /api/v1/renewal-policies before
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
// resolveActor extracts the authenticated named-key identity from the request
|
// resolveActor extracts the authenticated named-key identity from the request
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
"github.com/shankar0123/certctl/internal/pkcs7"
|
"github.com/certctl-io/certctl/internal/pkcs7"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SCEPService defines the service interface for SCEP enrollment operations.
|
// SCEPService defines the service interface for SCEP enrollment operations.
|
||||||
@@ -577,7 +577,6 @@ func extractCSRFields(csrDER []byte) ([]byte, string, string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
challengePassword := ""
|
challengePassword := ""
|
||||||
transactionID := ""
|
|
||||||
|
|
||||||
// OID for challengePassword: 1.2.840.113549.1.9.7
|
// OID for challengePassword: 1.2.840.113549.1.9.7
|
||||||
oidChallengePassword := asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 7}
|
oidChallengePassword := asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 7}
|
||||||
@@ -608,10 +607,20 @@ func extractCSRFields(csrDER []byte) ([]byte, string, string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use CN as fallback transaction ID if not found in attributes
|
// transactionID falls back to the CSR's CN. The MVP path (this
|
||||||
if transactionID == "" && csr.Subject.CommonName != "" {
|
// function) never extracts the SCEP transaction-ID attribute (OID
|
||||||
transactionID = csr.Subject.CommonName
|
// 2.16.840.1.113733.1.9.7) from CSR.Attributes — that's a known
|
||||||
}
|
// gap; the RFC 8894 path (tryParseRFC8894 above) extracts it
|
||||||
|
// properly from the PKCS#7 SignedData authenticatedAttributes,
|
||||||
|
// which is where conformant clients put it anyway. CodeQL #18
|
||||||
|
// flagged the pre-existing `if transactionID == ""` dead
|
||||||
|
// conditional (transactionID was initialized to "" three lines
|
||||||
|
// above and never reassigned); cleaned up here. The MVP path
|
||||||
|
// stays usable for lightweight legacy clients that send the CSR
|
||||||
|
// directly with no PKCS#7 wrapping — they get CN-as-transaction-ID
|
||||||
|
// which is sufficient for matching against pollers in the existing
|
||||||
|
// test suite.
|
||||||
|
transactionID := csr.Subject.CommonName
|
||||||
|
|
||||||
return csrDER, challengePassword, transactionID, nil
|
return csrDER, challengePassword, transactionID, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
"github.com/shankar0123/certctl/internal/pkcs7"
|
"github.com/certctl-io/certctl/internal/pkcs7"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SCEP RFC 8894 + Intune master bundle Phase 5.2: ChromeOS-shape integration
|
// SCEP RFC 8894 + Intune master bundle Phase 5.2: ChromeOS-shape integration
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// mockSCEPService implements SCEPService for testing.
|
// mockSCEPService implements SCEPService for testing.
|
||||||
|
|||||||
@@ -23,11 +23,11 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
"github.com/shankar0123/certctl/internal/pkcs7"
|
"github.com/certctl-io/certctl/internal/pkcs7"
|
||||||
"github.com/shankar0123/certctl/internal/repository"
|
"github.com/certctl-io/certctl/internal/repository"
|
||||||
"github.com/shankar0123/certctl/internal/scep/intune"
|
"github.com/certctl-io/certctl/internal/scep/intune"
|
||||||
"github.com/shankar0123/certctl/internal/service"
|
"github.com/certctl-io/certctl/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SCEP RFC 8894 + Intune master bundle Phase 10.2 — hermetic end-to-end
|
// SCEP RFC 8894 + Intune master bundle Phase 10.2 — hermetic end-to-end
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
"github.com/shankar0123/certctl/internal/pkcs7"
|
"github.com/certctl-io/certctl/internal/pkcs7"
|
||||||
"github.com/shankar0123/certctl/internal/scep/intune"
|
"github.com/certctl-io/certctl/internal/scep/intune"
|
||||||
"github.com/shankar0123/certctl/internal/service"
|
"github.com/certctl-io/certctl/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SCEP RFC 8894 + Intune master prompt §13 line 1851 acceptance —
|
// SCEP RFC 8894 + Intune master prompt §13 line 1851 acceptance —
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
// StatsService defines the service interface for statistics operations.
|
// StatsService defines the service interface for statistics operations.
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
"github.com/shankar0123/certctl/internal/service"
|
"github.com/certctl-io/certctl/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MockTargetService is a mock implementation of TargetService interface.
|
// MockTargetService is a mock implementation of TargetService interface.
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user