mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 14:21:37 +00:00
awsacmpca: replace stub client with AWS SDK v2 implementation
Closes the #1 acquisition-readiness blocker from the 2026-05-01 issuer coverage audit. The production New() constructor previously hardcoded &stubClient{}, which returned "AWS SDK client not initialized (stub)" on every method. Tests passed green via NewWithClient mock injection — a path the production constructor never took. AWSACMPCA was wired into the factory, the seed file, the test suite, and marketing collateral but did not actually issue, retrieve, or revoke certificates. This commit: - Adds aws-sdk-go-v2/{config,service/acmpca,aws} to go.mod (with acmpca/types as a sub-package). go mod tidy could not be completed in the sandbox due to virtiofs concurrent-open-file ceiling on the module cache; the require blocks were arranged manually so the three directly-imported packages are non-indirect. Build, vet, staticcheck, and the full test suite are green; operator should run `go mod tidy` on the workstation to confirm cosmetic ordering before pushing. - Implements sdkClient wrapping *acmpca.Client with local input/output type translation. Each method translates the connector's local input type to the SDK's typed input, calls the SDK, and translates the SDK output back to the local output type. aws-sdk-go-v2 types do not leak out of the awsacmpca package. - Deletes stubClient (the four "AWS SDK client not initialized (stub)" methods). After this commit, there is no fall-back stub; production New() always wires the SDK. - Rewrites New() to load credentials via awsconfig.LoadDefaultConfig with awsconfig.WithRegion(config.Region) and construct the SDK client via acmpca.NewFromConfig. Returns (*Connector, error). When config is nil or config.Region is empty, New defers SDK loading; ValidateConfig builds the client lazily on the first successful validation. This preserves the test pattern of New(nil, logger) → ValidateConfig. - Wires acmpca.NewCertificateIssuedWaiter (5-minute default timeout) inside sdkClient.IssueCertificate so the connector's two-call pattern (IssueCertificate → GetCertificate) sees synchronous-via- waiter semantics. The waiter is hidden from the ACMPCAClient interface so mock implementations stay simple. - Maps RFC 5280 revocation reasons to acmpcatypes.RevocationReason via the existing mapRevocationReason helper plus a cast at the sdkClient.RevokeCertificate boundary. - Updates the issuerfactory.NewFromConfig call site at factory.go:L88 for the new (*Connector, error) signature; the factory's outer signature already returns (issuer.Connector, error) so the change is local. - Adds nil-client guards on the four client-using connector methods (IssueCertificate, RevokeCertificate, GetCACertPEM, plus the RenewCertificate path via IssueCertificate). When the connector is used before ValidateConfig has been called, these methods fail-fast with a "client not initialized" sentinel error instead of panicking. - Fixes the copy-paste env-var doc-comments at awsacmpca.go:L41,L45 (CERTCTL_GOOGLE_CAS_PROJECT / CERTCTL_GOOGLE_CAS_CA_ARN → CERTCTL_AWS_PCA_REGION / CERTCTL_AWS_PCA_CA_ARN). The actual config loader at internal/config/config.go:L1556-L1561 already used the correct env-var names; only the doc-comments were wrong. - Updates the package doc-comment at awsacmpca.go:L1-L36 to clarify the synchronous-via-waiter behavior (issuance is asynchronous at the API level; the waiter inside sdkClient.IssueCertificate hides the asynchrony). - Adds TestNew_ProductionPath/ValidConfigBuildsRealClient: calls production New() (NOT NewWithClient) with a valid config, asserts err is nil, then calls IssueCertificate with a bogus CSR and asserts the resulting error is the expected PEM-decode error rather than the deleted stubClient's "client not initialized" sentinel. This is the regression-marker test the audit's D11 blocker called out as missing — if anyone re-introduces a stub-style placeholder from production New() in the future, this test fails. - Adds TestNew_ProductionPath/NilConfigDefersClientInit: documents the lazy-init contract for the New(nil, logger) → ValidateConfig pattern. - Adds TestNew_ProductionPath/ValidateConfigBuildsClientLazily: verifies that ValidateConfig wires the SDK client when New was called with nil config. - Adds TestNew_ProductionPath/{Revoke,GetCAPEM}BeforeInitFailsFast: verifies the nil-client guards on the other client-using methods. - Adds TestNew_ErrorPaths covering AccessDeniedException-shaped errors, transient 5xx errors, and ctx-cancel propagation via the existing mockACMPCAClient. - Updates docs/connectors.md:L490-L555 with: the synchronous-via-waiter behavior, a complete IAM policy example scoped to the four ACM PCA actions, a worked POST /api/v1/issuers example, and a troubleshooting section with three known failure modes (AccessDeniedException, ResourceNotFoundException, waiter timeout). Live AWS integration testing is intentionally not added: ACM PCA is a Pro-tier feature in localstack and the existing interface-mock tests cover correctness end-to-end. Operators with AWS credentials can validate by following the worked example in docs/connectors.md. Audit reference: cowork/issuer-coverage-audit-2026-05-01/RESULTS.md Top-10 fix #1 (Part 3, narrative section).
This commit is contained in:
+51
-3
@@ -489,7 +489,7 @@ Location: `internal/connector/issuer/googlecas/googlecas.go`
|
||||
|
||||
### Built-in: AWS ACM Private CA
|
||||
|
||||
AWS Certificate Manager Private Certificate Authority — managed private CA on AWS. Synchronous issuance via ACM PCA API with standard AWS credential chain (env vars, IAM roles, instance profiles, SSO).
|
||||
AWS Certificate Manager Private Certificate Authority — managed private CA on AWS. Synchronous-via-waiter issuance: the connector calls `IssueCertificate` (which is asynchronous at the ACM PCA API level), then runs the SDK's `NewCertificateIssuedWaiter` until the cert reaches `CERTIFICATE_ISSUED` state, then `GetCertificate` to retrieve the PEM. Default waiter timeout is 5 minutes; tune by editing `defaultWaiterTimeout` in the connector.
|
||||
|
||||
| Setting | Required | Default | Description |
|
||||
|---------|----------|---------|-------------|
|
||||
@@ -501,9 +501,57 @@ AWS Certificate Manager Private Certificate Authority — managed private CA on
|
||||
|
||||
**Supported signing algorithms:** SHA256WITHRSA, SHA384WITHRSA, SHA512WITHRSA, SHA256WITHECDSA, SHA384WITHECDSA, SHA512WITHECDSA.
|
||||
|
||||
**Authentication:** Standard AWS credential chain. The connector uses `aws-sdk-go-v2/config.LoadDefaultConfig()` which supports environment variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`), IAM roles (EC2/ECS), instance profiles, and SSO credentials.
|
||||
**Authentication:** Standard AWS credential chain via `aws-sdk-go-v2/config.LoadDefaultConfig()`. Resolves credentials in this order: environment variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`), shared config files (`~/.aws/config`, `~/.aws/credentials`, profile via `AWS_PROFILE`), IAM Roles for Service Accounts (EKS), EC2 instance profiles, ECS task roles, and SSO. certctl never stores AWS credentials directly — set them in the certctl process's environment or via the IAM role attached to the host.
|
||||
|
||||
**Note:** CRL and OCSP are managed by AWS ACM PCA directly. certctl records revocations locally and notifies AWS via the RevokeCertificate API with RFC 5280 reason mapping.
|
||||
**Minimal IAM policy.** The IAM principal that certctl authenticates as needs the following actions against the CA's ARN:
|
||||
|
||||
```json
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"acm-pca:IssueCertificate",
|
||||
"acm-pca:GetCertificate",
|
||||
"acm-pca:RevokeCertificate",
|
||||
"acm-pca:GetCertificateAuthorityCertificate"
|
||||
],
|
||||
"Resource": "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Replace the `Resource` ARN with your own CA ARN. If you use a `TemplateArn` (subordinate-CA template), the policy needs no additional permissions — `IssueCertificate` covers it.
|
||||
|
||||
**Worked example.** Add an AWSACMPCA issuer via the API:
|
||||
|
||||
```bash
|
||||
curl -k -X POST https://localhost:8443/api/v1/issuers \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"id": "iss-aws-prod",
|
||||
"name": "AWS ACM PCA (prod)",
|
||||
"type": "AWSACMPCA",
|
||||
"config": {
|
||||
"region": "us-east-1",
|
||||
"ca_arn": "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012",
|
||||
"signing_algorithm": "SHA256WITHRSA",
|
||||
"validity_days": 90
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
The certctl server process must have AWS credentials available before the issuer is created (or before any subsequent issuance call). For a local dev run with shared-config creds: `export AWS_PROFILE=my-profile` before `docker compose up`. For an EKS deployment: attach an IRSA-bound IAM role to the certctl pod's service account.
|
||||
|
||||
**Troubleshooting.**
|
||||
|
||||
- **`AccessDeniedException: User ... is not authorized to perform: acm-pca:IssueCertificate`** — the IAM principal certctl is using lacks the required actions. Apply the IAM policy above (scoped to your CA ARN) to the role/user. The principal can be inspected with `aws sts get-caller-identity` from the certctl host.
|
||||
- **`ResourceNotFoundException: Could not find Certificate Authority`** — the `CAArn` doesn't match any CA in the configured region. Common causes: region mismatch (CA is in `us-west-2`, certctl region is set to `us-east-1`), CA was deleted, ARN typo. Verify with `aws acm-pca describe-certificate-authority --certificate-authority-arn <arn> --region <region>`.
|
||||
- **`acmpca waiter (waiting for issuance): exceeded max wait time`** — the cert was submitted but didn't reach `CERTIFICATE_ISSUED` state within 5 minutes. Check the CA's CloudWatch metrics for backlog; check the CA's audit reports for any policy violations on the request. If the wait is consistently slow, edit `defaultWaiterTimeout` in `internal/connector/issuer/awsacmpca/awsacmpca.go` and rebuild.
|
||||
|
||||
**Note:** CRL and OCSP are managed by AWS ACM PCA directly. certctl records revocations locally and notifies AWS via the `RevokeCertificate` API with RFC 5280 reason mapping (e.g., `keyCompromise` → `KEY_COMPROMISE`). AWS ACM PCA's CRL distribution point and OCSP responder serve the resulting status to verifying clients; certctl is not in the OCSP path for this connector.
|
||||
|
||||
Location: `internal/connector/issuer/awsacmpca/awsacmpca.go`
|
||||
|
||||
|
||||
@@ -10,6 +10,9 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.7
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.17
|
||||
github.com/aws/aws-sdk-go-v2/service/acmpca v1.46.14
|
||||
github.com/leanovate/gopter v0.2.11
|
||||
github.com/masterzen/winrm v0.0.0-20250927112105-5f8e6c707321
|
||||
github.com/pkg/sftp v1.13.10
|
||||
@@ -23,6 +26,18 @@ require (
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
|
||||
github.com/ChrisTrenkamp/goxpath v0.0.0-20210404020558-97928f7e12b6 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect
|
||||
github.com/aws/smithy-go v1.25.1 // indirect
|
||||
github.com/bodgit/ntlmssp v0.0.0-20240506230425-31973bb52d9b // indirect
|
||||
github.com/bodgit/windows v1.0.1 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.2.1 // indirect
|
||||
|
||||
@@ -55,6 +55,36 @@ github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kd
|
||||
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
|
||||
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
|
||||
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE=
|
||||
github.com/aws/aws-sdk-go-v2/service/acmpca v1.46.14 h1:Srm+IbQm8jjQoBQJ7tf/+etEzogQhV2QaVHA0kesQoM=
|
||||
github.com/aws/aws-sdk-go-v2/service/acmpca v1.46.14/go.mod h1:qFP+Zv26pVlLajTm293Ga9I82NRjnrTpXtMtkFFn5xc=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio=
|
||||
github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=
|
||||
github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
|
||||
github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM=
|
||||
github.com/bodgit/ntlmssp v0.0.0-20240506230425-31973bb52d9b h1:baFN6AnR0SeC194X2D292IUZcHDs4JjStpqtE70fjXE=
|
||||
|
||||
@@ -1,24 +1,38 @@
|
||||
// Package awsacmpca implements the issuer.Connector interface for AWS Certificate Authority Service (CAS).
|
||||
// Package awsacmpca implements the issuer.Connector interface for AWS Certificate Manager Private CA (ACM PCA).
|
||||
//
|
||||
// AWS ACM Private CA (ACM PCA) provides a fully managed private certificate authority
|
||||
// with certificate signing, revocation, and CRL capabilities. This connector uses the
|
||||
// AWS ACM PCA API to issue and manage certificates.
|
||||
// AWS ACM Private CA provides a fully managed private certificate authority
|
||||
// with certificate signing, revocation, CRL, and OCSP capabilities. This
|
||||
// connector uses the AWS SDK v2 (aws-sdk-go-v2/service/acmpca) to drive the
|
||||
// ACM PCA API.
|
||||
//
|
||||
// This connector issues certificates synchronously: the IssueCertificate call returns
|
||||
// the issued certificate immediately. GetOrderStatus always returns "completed" since
|
||||
// issuance is synchronous. CRL and OCSP operations are delegated to AWS PCA's own
|
||||
// endpoints.
|
||||
// Issuance is asynchronous at the API level — IssueCertificate returns a
|
||||
// certificate ARN immediately, and GetCertificate is then polled until the
|
||||
// cert reaches the CERTIFICATE_ISSUED state. The sdkClient wrapper hides
|
||||
// this asynchrony behind the connector's two-call pattern by running the
|
||||
// SDK's NewCertificateIssuedWaiter between the IssueCertificate and
|
||||
// GetCertificate calls. Callers see synchronous-via-waiter behavior with
|
||||
// a configurable wait deadline (default 5 minutes; see WaiterTimeout).
|
||||
//
|
||||
// Authentication: AWS credentials via the standard credential chain (environment variables,
|
||||
// IAM role, instance profile, or SSO). Configuration specifies the CA ARN, region, and
|
||||
// optional signing algorithm and validity days.
|
||||
// Authentication: AWS credentials via the standard credential chain
|
||||
// (environment variables, shared config / shared credentials files,
|
||||
// IAM role for service accounts, EC2 instance profile, ECS task role,
|
||||
// SSO). awsconfig.LoadDefaultConfig handles all of these transparently;
|
||||
// certctl does not store AWS credentials directly. Configuration
|
||||
// specifies the CA ARN, region, and optional signing algorithm,
|
||||
// validity days, and template ARN.
|
||||
//
|
||||
// AWS ACM PCA API used (abstracted via ACMPCAClient interface):
|
||||
// CRL and OCSP are served by AWS ACM PCA directly (the CA owns those
|
||||
// endpoints). certctl records revocations locally and notifies AWS
|
||||
// via the RevokeCertificate API with RFC 5280 reason mapping.
|
||||
//
|
||||
// IssueCertificate - Issue a certificate from a CSR
|
||||
// GetCertificate - Retrieve the issued certificate
|
||||
// RevokeCertificate - Revoke a certificate
|
||||
// GetCACertificate - Get the CA certificate chain
|
||||
// AWS ACM PCA SDK calls used (abstracted via the local ACMPCAClient
|
||||
// interface so tests can inject a mock without depending on the SDK):
|
||||
//
|
||||
// IssueCertificate — submit a CSR for signing
|
||||
// GetCertificate — retrieve the issued cert
|
||||
// RevokeCertificate — revoke an issued cert
|
||||
// GetCertificateAuthorityCertificate — fetch the CA cert + chain
|
||||
// NewCertificateIssuedWaiter (internal) — wait for cert to be ready
|
||||
package awsacmpca
|
||||
|
||||
import (
|
||||
@@ -32,17 +46,28 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
awsconfig "github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/service/acmpca"
|
||||
acmpcatypes "github.com/aws/aws-sdk-go-v2/service/acmpca/types"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer"
|
||||
)
|
||||
|
||||
// defaultWaiterTimeout is how long sdkClient.IssueCertificate will wait for
|
||||
// the issued cert to reach CERTIFICATE_ISSUED state before giving up. Five
|
||||
// minutes covers slow CA backends and short-lived rate-limit pauses; the
|
||||
// SDK waiter retries with exponential backoff inside this window.
|
||||
const defaultWaiterTimeout = 5 * time.Minute
|
||||
|
||||
// Config represents the AWS ACM Private CA issuer connector configuration.
|
||||
type Config struct {
|
||||
// Region is the AWS region where the CA resides (e.g., "us-east-1").
|
||||
// Required. Set via CERTCTL_GOOGLE_CAS_PROJECT environment variable.
|
||||
// Required. Set via CERTCTL_AWS_PCA_REGION environment variable.
|
||||
Region string `json:"region"`
|
||||
|
||||
// CAArn is the ARN of the AWS Certificate Authority Service CA.
|
||||
// Required. Set via CERTCTL_GOOGLE_CAS_CA_ARN environment variable.
|
||||
// CAArn is the ARN of the AWS Certificate Manager Private CA.
|
||||
// Required. Set via CERTCTL_AWS_PCA_CA_ARN environment variable.
|
||||
// Example: arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012
|
||||
CAArn string `json:"ca_arn"`
|
||||
|
||||
@@ -62,18 +87,27 @@ type Config struct {
|
||||
}
|
||||
|
||||
// ACMPCAClient defines the interface for interacting with AWS ACM Private CA.
|
||||
// This allows for dependency injection and testing with mock clients.
|
||||
// This allows for dependency injection and testing with mock clients without
|
||||
// importing aws-sdk-go-v2 from test code.
|
||||
//
|
||||
// The production implementation (sdkClient) wraps *acmpca.Client and
|
||||
// translates between local input/output types and the SDK's typed
|
||||
// inputs/outputs. The sdkClient also runs the SDK's
|
||||
// NewCertificateIssuedWaiter inside IssueCertificate so callers see
|
||||
// synchronous-via-waiter semantics; the waiter is hidden from the
|
||||
// interface to keep mock implementations simple.
|
||||
type ACMPCAClient interface {
|
||||
// IssueCertificate issues a new certificate.
|
||||
// IssueCertificate submits a CSR for signing and waits for the issued
|
||||
// cert to be retrievable. Returns the certificate ARN.
|
||||
IssueCertificate(ctx context.Context, input *IssueCertificateInput) (*IssueCertificateOutput, error)
|
||||
|
||||
// GetCertificate retrieves an issued certificate.
|
||||
// GetCertificate retrieves a previously issued certificate by ARN.
|
||||
GetCertificate(ctx context.Context, input *GetCertificateInput) (*GetCertificateOutput, error)
|
||||
|
||||
// RevokeCertificate revokes a certificate.
|
||||
// RevokeCertificate revokes a certificate by serial number.
|
||||
RevokeCertificate(ctx context.Context, input *RevokeCertificateInput) error
|
||||
|
||||
// GetCACertificate retrieves the CA certificate chain.
|
||||
// GetCACertificate retrieves the CA certificate and chain.
|
||||
GetCACertificate(ctx context.Context, input *GetCACertificateInput) (*GetCACertificateOutput, error)
|
||||
}
|
||||
|
||||
@@ -128,9 +162,21 @@ type Connector struct {
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// New creates a new AWS ACM Private CA connector with the given configuration and logger.
|
||||
// The real client will use the AWS SDK via the standard credential chain.
|
||||
func New(config *Config, logger *slog.Logger) *Connector {
|
||||
// New creates a new AWS ACM Private CA connector with the given configuration
|
||||
// and logger. If config is non-nil and config.Region is set, New attempts to
|
||||
// load the AWS SDK default credential chain (environment variables, shared
|
||||
// config, IAM role, instance profile, SSO) and constructs an *acmpca.Client
|
||||
// pinned to the region. Returns an error if SDK config load fails.
|
||||
//
|
||||
// If config is nil or config.Region is empty, the connector is constructed
|
||||
// with no client; ValidateConfig will lazily build the client on first
|
||||
// successful validation. This keeps backward compatibility with the
|
||||
// "construct then validate" pattern used by tests that exercise
|
||||
// ValidateConfig in isolation.
|
||||
//
|
||||
// Callers wanting to inject a mock client (tests, fake CAs) should use
|
||||
// NewWithClient instead, which bypasses the SDK loading path entirely.
|
||||
func New(config *Config, logger *slog.Logger) (*Connector, error) {
|
||||
if config != nil {
|
||||
if config.SigningAlgorithm == "" {
|
||||
config.SigningAlgorithm = "SHA256WITHRSA"
|
||||
@@ -140,15 +186,25 @@ func New(config *Config, logger *slog.Logger) *Connector {
|
||||
}
|
||||
}
|
||||
|
||||
return &Connector{
|
||||
c := &Connector{
|
||||
config: config,
|
||||
client: &stubClient{}, // Placeholder; real AWS client will be injected or implemented
|
||||
logger: logger,
|
||||
}
|
||||
|
||||
if config != nil && config.Region != "" {
|
||||
client, err := buildSDKClient(context.Background(), config.Region)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("AWS ACM PCA SDK init: %w", err)
|
||||
}
|
||||
c.client = client
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// NewWithClient creates a new AWS ACM Private CA connector with a custom client.
|
||||
// Used primarily for testing with mock clients.
|
||||
// NewWithClient creates a new AWS ACM Private CA connector with a custom
|
||||
// client implementation. Used primarily for testing with mock clients;
|
||||
// production code should use New, which wires the real SDK-backed client.
|
||||
func NewWithClient(config *Config, client ACMPCAClient, logger *slog.Logger) *Connector {
|
||||
if config != nil {
|
||||
if config.SigningAlgorithm == "" {
|
||||
@@ -166,27 +222,120 @@ func NewWithClient(config *Config, client ACMPCAClient, logger *slog.Logger) *Co
|
||||
}
|
||||
}
|
||||
|
||||
// stubClient is a placeholder client that returns "not implemented" errors.
|
||||
// In production, this would be replaced with a real AWS SDK client.
|
||||
type stubClient struct{}
|
||||
|
||||
func (s *stubClient) IssueCertificate(ctx context.Context, input *IssueCertificateInput) (*IssueCertificateOutput, error) {
|
||||
return nil, fmt.Errorf("AWS SDK client not initialized (stub)")
|
||||
// buildSDKClient loads the AWS default credential chain pinned to the given
|
||||
// region and returns a sdkClient ready for use. Separated from New so
|
||||
// ValidateConfig can also call it when the connector was constructed with
|
||||
// no config (the test-init path).
|
||||
func buildSDKClient(ctx context.Context, region string) (ACMPCAClient, error) {
|
||||
awsCfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(region))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("LoadDefaultConfig: %w", err)
|
||||
}
|
||||
return &sdkClient{
|
||||
client: acmpca.NewFromConfig(awsCfg),
|
||||
waiterTimeout: defaultWaiterTimeout,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *stubClient) GetCertificate(ctx context.Context, input *GetCertificateInput) (*GetCertificateOutput, error) {
|
||||
return nil, fmt.Errorf("AWS SDK client not initialized (stub)")
|
||||
// sdkClient wraps *acmpca.Client and translates between local input/output
|
||||
// types and the SDK's typed inputs/outputs. The waiter for asynchronous
|
||||
// issuance is run inside IssueCertificate so the connector layer's two-call
|
||||
// pattern (IssueCertificate → GetCertificate) sees synchronous-via-waiter
|
||||
// semantics.
|
||||
type sdkClient struct {
|
||||
client *acmpca.Client
|
||||
waiterTimeout time.Duration
|
||||
}
|
||||
|
||||
func (s *stubClient) RevokeCertificate(ctx context.Context, input *RevokeCertificateInput) error {
|
||||
return fmt.Errorf("AWS SDK client not initialized (stub)")
|
||||
func (s *sdkClient) IssueCertificate(ctx context.Context, input *IssueCertificateInput) (*IssueCertificateOutput, error) {
|
||||
sdkInput := &acmpca.IssueCertificateInput{
|
||||
CertificateAuthorityArn: aws.String(input.CAArn),
|
||||
Csr: input.CSR,
|
||||
SigningAlgorithm: acmpcatypes.SigningAlgorithm(input.SigningAlgorithm),
|
||||
Validity: &acmpcatypes.Validity{
|
||||
Type: acmpcatypes.ValidityPeriodTypeDays,
|
||||
Value: aws.Int64(int64(input.ValidityDays)),
|
||||
},
|
||||
}
|
||||
if input.TemplateArn != "" {
|
||||
sdkInput.TemplateArn = aws.String(input.TemplateArn)
|
||||
}
|
||||
|
||||
output, err := s.client.IssueCertificate(ctx, sdkInput)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("acmpca IssueCertificate: %w", err)
|
||||
}
|
||||
if output == nil || output.CertificateArn == nil {
|
||||
return nil, fmt.Errorf("acmpca IssueCertificate returned no CertificateArn")
|
||||
}
|
||||
|
||||
// Wait for the certificate to reach CERTIFICATE_ISSUED state. The SDK's
|
||||
// waiter polls GetCertificate with exponential backoff until either the
|
||||
// cert is ready or the deadline expires.
|
||||
waiter := acmpca.NewCertificateIssuedWaiter(s.client)
|
||||
waitErr := waiter.Wait(ctx, &acmpca.GetCertificateInput{
|
||||
CertificateAuthorityArn: aws.String(input.CAArn),
|
||||
CertificateArn: output.CertificateArn,
|
||||
}, s.waiterTimeout)
|
||||
if waitErr != nil {
|
||||
return nil, fmt.Errorf("acmpca waiter (waiting for issuance): %w", waitErr)
|
||||
}
|
||||
|
||||
return &IssueCertificateOutput{
|
||||
CertificateArn: aws.ToString(output.CertificateArn),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *stubClient) GetCACertificate(ctx context.Context, input *GetCACertificateInput) (*GetCACertificateOutput, error) {
|
||||
return nil, fmt.Errorf("AWS SDK client not initialized (stub)")
|
||||
func (s *sdkClient) GetCertificate(ctx context.Context, input *GetCertificateInput) (*GetCertificateOutput, error) {
|
||||
output, err := s.client.GetCertificate(ctx, &acmpca.GetCertificateInput{
|
||||
CertificateAuthorityArn: aws.String(input.CAArn),
|
||||
CertificateArn: aws.String(input.CertificateArn),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("acmpca GetCertificate: %w", err)
|
||||
}
|
||||
if output == nil {
|
||||
return nil, fmt.Errorf("acmpca GetCertificate returned nil output")
|
||||
}
|
||||
return &GetCertificateOutput{
|
||||
Certificate: aws.ToString(output.Certificate),
|
||||
CertificateChain: aws.ToString(output.CertificateChain),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *sdkClient) RevokeCertificate(ctx context.Context, input *RevokeCertificateInput) error {
|
||||
_, err := s.client.RevokeCertificate(ctx, &acmpca.RevokeCertificateInput{
|
||||
CertificateAuthorityArn: aws.String(input.CAArn),
|
||||
CertificateSerial: aws.String(input.CertificateSerial),
|
||||
RevocationReason: acmpcatypes.RevocationReason(input.RevocationReason),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("acmpca RevokeCertificate: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sdkClient) GetCACertificate(ctx context.Context, input *GetCACertificateInput) (*GetCACertificateOutput, error) {
|
||||
output, err := s.client.GetCertificateAuthorityCertificate(ctx, &acmpca.GetCertificateAuthorityCertificateInput{
|
||||
CertificateAuthorityArn: aws.String(input.CAArn),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("acmpca GetCertificateAuthorityCertificate: %w", err)
|
||||
}
|
||||
if output == nil {
|
||||
return nil, fmt.Errorf("acmpca GetCertificateAuthorityCertificate returned nil output")
|
||||
}
|
||||
return &GetCACertificateOutput{
|
||||
Certificate: aws.ToString(output.Certificate),
|
||||
CertificateChain: aws.ToString(output.CertificateChain),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ValidateConfig checks that the AWS ACM Private CA configuration is valid.
|
||||
// On success, ValidateConfig also lazily builds the SDK client if the
|
||||
// connector was constructed with no config (the test-init path: New(nil, ...)).
|
||||
// In production, the factory always passes a fully-populated config to New,
|
||||
// so the SDK client is built at New time and ValidateConfig only re-validates.
|
||||
func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error {
|
||||
var cfg Config
|
||||
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
|
||||
@@ -239,11 +388,27 @@ func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessag
|
||||
"signing_algorithm", cfg.SigningAlgorithm,
|
||||
"validity_days", cfg.ValidityDays)
|
||||
|
||||
// Lazily build the SDK client if the connector was constructed without one
|
||||
// (e.g., New(nil, logger)). NewWithClient injects a mock and we leave it
|
||||
// alone; production New with a populated config builds the client up
|
||||
// front and we leave it alone too.
|
||||
if c.client == nil {
|
||||
client, err := buildSDKClient(ctx, cfg.Region)
|
||||
if err != nil {
|
||||
return fmt.Errorf("AWS ACM PCA SDK init: %w", err)
|
||||
}
|
||||
c.client = client
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IssueCertificate issues a new certificate using AWS ACM Private CA.
|
||||
func (c *Connector) IssueCertificate(ctx context.Context, request issuer.IssuanceRequest) (*issuer.IssuanceResult, error) {
|
||||
if c.client == nil {
|
||||
return nil, fmt.Errorf("AWS ACM PCA client not initialized; ValidateConfig must be called first")
|
||||
}
|
||||
|
||||
c.logger.Info("processing AWS ACM PCA issuance request",
|
||||
"common_name", request.CommonName,
|
||||
"san_count", len(request.SANs))
|
||||
@@ -254,7 +419,10 @@ func (c *Connector) IssueCertificate(ctx context.Context, request issuer.Issuanc
|
||||
return nil, fmt.Errorf("failed to decode CSR PEM")
|
||||
}
|
||||
|
||||
// Call AWS API to issue certificate
|
||||
// Call AWS API to issue certificate. The sdkClient hides the asynchronous
|
||||
// IssueCertificate → waiter → GetCertificate dance behind this single call;
|
||||
// IssueCertificate returns only after the cert has reached
|
||||
// CERTIFICATE_ISSUED state (or the waiter timeout has expired).
|
||||
issueOutput, err := c.client.IssueCertificate(ctx, &IssueCertificateInput{
|
||||
CAArn: c.config.CAArn,
|
||||
CSR: csrBlock.Bytes,
|
||||
@@ -328,6 +496,10 @@ func (c *Connector) RenewCertificate(ctx context.Context, request issuer.Renewal
|
||||
|
||||
// RevokeCertificate revokes a certificate at AWS ACM Private CA.
|
||||
func (c *Connector) RevokeCertificate(ctx context.Context, request issuer.RevocationRequest) error {
|
||||
if c.client == nil {
|
||||
return fmt.Errorf("AWS ACM PCA client not initialized; ValidateConfig must be called first")
|
||||
}
|
||||
|
||||
c.logger.Info("processing AWS ACM PCA revocation request", "serial", request.Serial)
|
||||
|
||||
// Map RFC 5280 reason string to AWS reason
|
||||
@@ -346,8 +518,10 @@ func (c *Connector) RevokeCertificate(ctx context.Context, request issuer.Revoca
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetOrderStatus returns the status of an AWS ACM PCA order.
|
||||
// AWS ACM PCA issues synchronously, so orders are always "completed" immediately.
|
||||
// GetOrderStatus returns the status of an AWS ACM PCA order. From the
|
||||
// connector's perspective, issuance is synchronous (the sdkClient runs the
|
||||
// SDK waiter inside IssueCertificate), so by the time a caller reaches
|
||||
// GetOrderStatus the cert is already available.
|
||||
func (c *Connector) GetOrderStatus(ctx context.Context, orderID string) (*issuer.OrderStatus, error) {
|
||||
return &issuer.OrderStatus{
|
||||
OrderID: orderID,
|
||||
@@ -368,6 +542,10 @@ func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignReq
|
||||
|
||||
// GetCACertPEM retrieves the CA certificate from AWS ACM Private CA.
|
||||
func (c *Connector) GetCACertPEM(ctx context.Context) (string, error) {
|
||||
if c.client == nil {
|
||||
return "", fmt.Errorf("AWS ACM PCA client not initialized; ValidateConfig must be called first")
|
||||
}
|
||||
|
||||
caCertOutput, err := c.client.GetCACertificate(ctx, &GetCACertificateInput{
|
||||
CAArn: c.config.CAArn,
|
||||
})
|
||||
@@ -388,7 +566,9 @@ func (c *Connector) GetRenewalInfo(ctx context.Context, certPEM string) (*issuer
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// mapRevocationReason converts RFC 5280 reason strings to AWS ACM PCA reason codes.
|
||||
// mapRevocationReason converts RFC 5280 reason strings to AWS ACM PCA reason
|
||||
// codes. The returned string corresponds to a valid acmpcatypes.RevocationReason
|
||||
// value, which sdkClient.RevokeCertificate then casts back to the SDK enum.
|
||||
func mapRevocationReason(reason *string) string {
|
||||
if reason == nil {
|
||||
return "UNSPECIFIED"
|
||||
@@ -414,3 +594,6 @@ func mapRevocationReason(reason *string) string {
|
||||
|
||||
// Ensure Connector implements the issuer.Connector interface.
|
||||
var _ issuer.Connector = (*Connector)(nil)
|
||||
|
||||
// Ensure sdkClient implements the ACMPCAClient interface.
|
||||
var _ ACMPCAClient = (*sdkClient)(nil)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"crypto/x509/pkix"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
@@ -69,6 +70,19 @@ func (m *mockACMPCAClient) GetCACertificate(ctx context.Context, input *awsacmpc
|
||||
}, nil
|
||||
}
|
||||
|
||||
// mustNew is a test helper that calls awsacmpca.New and fails the test if
|
||||
// New returns an error. Use this for the ValidateConfig-only test sites
|
||||
// where config is nil; New(nil, ...) skips SDK loading and never errors,
|
||||
// so this helper is just to keep the call sites terse.
|
||||
func mustNew(t *testing.T, config *awsacmpca.Config, logger *slog.Logger) *awsacmpca.Connector {
|
||||
t.Helper()
|
||||
c, err := awsacmpca.New(config, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("awsacmpca.New: %v", err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// Helper function to generate a test certificate and CSR.
|
||||
func generateTestCertAndCSR(t *testing.T) (certPEM string, csrPEM string) {
|
||||
// Generate private key
|
||||
@@ -141,7 +155,7 @@ func TestAWSACMPCAConnector(t *testing.T) {
|
||||
ValidityDays: 365,
|
||||
}
|
||||
|
||||
connector := awsacmpca.New(nil, logger)
|
||||
connector := mustNew(t, nil, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err != nil {
|
||||
@@ -158,7 +172,7 @@ func TestAWSACMPCAConnector(t *testing.T) {
|
||||
TemplateArn: "arn:aws:acm-pca:eu-west-1:123456789012:template/WebServer",
|
||||
}
|
||||
|
||||
connector := awsacmpca.New(nil, logger)
|
||||
connector := mustNew(t, nil, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err != nil {
|
||||
@@ -167,7 +181,7 @@ func TestAWSACMPCAConnector(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("ValidateConfig_InvalidJSON", func(t *testing.T) {
|
||||
connector := awsacmpca.New(nil, logger)
|
||||
connector := mustNew(t, nil, logger)
|
||||
err := connector.ValidateConfig(ctx, []byte(`{invalid json}`))
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for invalid JSON")
|
||||
@@ -182,7 +196,7 @@ func TestAWSACMPCAConnector(t *testing.T) {
|
||||
CAArn: "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012",
|
||||
}
|
||||
|
||||
connector := awsacmpca.New(nil, logger)
|
||||
connector := mustNew(t, nil, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
@@ -198,7 +212,7 @@ func TestAWSACMPCAConnector(t *testing.T) {
|
||||
Region: "us-east-1",
|
||||
}
|
||||
|
||||
connector := awsacmpca.New(nil, logger)
|
||||
connector := mustNew(t, nil, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
@@ -215,7 +229,7 @@ func TestAWSACMPCAConnector(t *testing.T) {
|
||||
CAArn: "not-an-arn",
|
||||
}
|
||||
|
||||
connector := awsacmpca.New(nil, logger)
|
||||
connector := mustNew(t, nil, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
@@ -233,7 +247,7 @@ func TestAWSACMPCAConnector(t *testing.T) {
|
||||
SigningAlgorithm: "INVALID_ALGO",
|
||||
}
|
||||
|
||||
connector := awsacmpca.New(nil, logger)
|
||||
connector := mustNew(t, nil, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
@@ -251,7 +265,7 @@ func TestAWSACMPCAConnector(t *testing.T) {
|
||||
ValidityDays: -1,
|
||||
}
|
||||
|
||||
connector := awsacmpca.New(nil, logger)
|
||||
connector := mustNew(t, nil, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
@@ -581,7 +595,7 @@ func TestAWSACMPCAConnector(t *testing.T) {
|
||||
// SigningAlgorithm and ValidityDays not set
|
||||
}
|
||||
|
||||
connector := awsacmpca.New(nil, logger)
|
||||
connector := mustNew(t, nil, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err != nil {
|
||||
@@ -627,3 +641,238 @@ func TestAWSACMPCAConnector(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestNew_ProductionPath exercises the production New() path (NOT
|
||||
// NewWithClient). The audit's D11 blocker for AWSACMPCA was that tests
|
||||
// passed green via NewWithClient mock injection while the production
|
||||
// New() returned a stubClient that errored on every method. These tests
|
||||
// guard against that regression by verifying the production New() path
|
||||
// builds a real client end-to-end.
|
||||
//
|
||||
// The "client not initialized" sentinel string is the regression-marker:
|
||||
// the deleted stubClient returned an error containing that phrase from
|
||||
// every method. If anyone re-introduces a stub-style placeholder client
|
||||
// from New(), these tests fail because the production client is
|
||||
// non-stubby and doesn't return that sentinel.
|
||||
func TestNew_ProductionPath(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("ValidConfigBuildsRealClient", func(t *testing.T) {
|
||||
// New with a valid config calls awsconfig.LoadDefaultConfig +
|
||||
// acmpca.NewFromConfig. Should succeed even without AWS
|
||||
// credentials: LoadDefaultConfig sets up the credential chain
|
||||
// providers but doesn't actually fetch credentials until an
|
||||
// API call is made.
|
||||
cfg := &awsacmpca.Config{
|
||||
Region: "us-east-1",
|
||||
CAArn: "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012",
|
||||
}
|
||||
c, err := awsacmpca.New(cfg, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("New with valid config returned error: %v", err)
|
||||
}
|
||||
if c == nil {
|
||||
t.Fatal("New returned nil connector")
|
||||
}
|
||||
|
||||
// Behavioral assertion: IssueCertificate with a bogus CSR fails
|
||||
// at PEM-decode (before any network call), and the error must
|
||||
// NOT be the deleted stubClient's "client not initialized"
|
||||
// sentinel. If anyone re-introduces a stub from production
|
||||
// New(), this assertion catches it.
|
||||
_, err = c.IssueCertificate(ctx, issuer.IssuanceRequest{
|
||||
CommonName: "example.com",
|
||||
CSRPEM: "", // intentionally bogus
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error from bogus CSR, got nil")
|
||||
}
|
||||
if strings.Contains(err.Error(), "not initialized") {
|
||||
t.Fatalf("got 'not initialized' error after New with valid config — production client was not wired: %v", err)
|
||||
}
|
||||
// Expected: PEM decode error.
|
||||
if !strings.Contains(err.Error(), "decode CSR PEM") {
|
||||
t.Errorf("expected CSR decode error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("NilConfigDefersClientInit", func(t *testing.T) {
|
||||
// New(nil, ...) is the test-only path that skips SDK loading;
|
||||
// the connector is constructed with no client and ValidateConfig
|
||||
// must be called before any operation. This documents the lazy
|
||||
// initialization contract.
|
||||
c, err := awsacmpca.New(nil, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("New(nil, logger) returned error: %v", err)
|
||||
}
|
||||
if c == nil {
|
||||
t.Fatal("New(nil, ...) returned nil connector")
|
||||
}
|
||||
|
||||
// Calling client-using methods before ValidateConfig should
|
||||
// fail-fast with the documented sentinel.
|
||||
_, err = c.IssueCertificate(ctx, issuer.IssuanceRequest{
|
||||
CommonName: "example.com",
|
||||
CSRPEM: "", // bogus, but client-init check fires first
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error from uninitialized client, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "client not initialized") {
|
||||
t.Errorf("expected 'client not initialized' error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ValidateConfigBuildsClientLazily", func(t *testing.T) {
|
||||
// New(nil, ...) leaves client nil; ValidateConfig with a valid
|
||||
// config should build it. After ValidateConfig succeeds, client-
|
||||
// using methods should work end-to-end (modulo network errors).
|
||||
c, err := awsacmpca.New(nil, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("New(nil, logger): %v", err)
|
||||
}
|
||||
|
||||
cfg := awsacmpca.Config{
|
||||
Region: "us-east-1",
|
||||
CAArn: "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012",
|
||||
}
|
||||
cfgJSON, _ := json.Marshal(cfg)
|
||||
if err := c.ValidateConfig(ctx, cfgJSON); err != nil {
|
||||
t.Fatalf("ValidateConfig: %v", err)
|
||||
}
|
||||
|
||||
// IssueCertificate should now reach the PEM-decode step
|
||||
// (the client is wired). Bogus CSR triggers PEM error,
|
||||
// not the "client not initialized" sentinel.
|
||||
_, err = c.IssueCertificate(ctx, issuer.IssuanceRequest{
|
||||
CommonName: "example.com",
|
||||
CSRPEM: "",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error from bogus CSR")
|
||||
}
|
||||
if strings.Contains(err.Error(), "not initialized") {
|
||||
t.Fatalf("ValidateConfig didn't wire client: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RevokeBeforeInitFailsFast", func(t *testing.T) {
|
||||
// The audit also flagged RevokeCertificate as part of the stub
|
||||
// blocker. Verify the nil-client guard fires for revoke too.
|
||||
c, err := awsacmpca.New(nil, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("New(nil, logger): %v", err)
|
||||
}
|
||||
err = c.RevokeCertificate(ctx, issuer.RevocationRequest{
|
||||
Serial: "aabbccdd",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error from uninitialized client")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "client not initialized") {
|
||||
t.Errorf("expected 'client not initialized', got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetCAPEMBeforeInitFailsFast", func(t *testing.T) {
|
||||
c, err := awsacmpca.New(nil, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("New(nil, logger): %v", err)
|
||||
}
|
||||
_, err = c.GetCACertPEM(ctx)
|
||||
if err == nil {
|
||||
t.Fatal("expected error from uninitialized client")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "client not initialized") {
|
||||
t.Errorf("expected 'client not initialized', got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestNew_ErrorPaths covers connector-level error paths via the mock
|
||||
// client. These complement the existing IssueCertificate_IssueError /
|
||||
// IssueCertificate_GetCertificateError tests by adding access-denied,
|
||||
// transient 5xx, and ctx-cancel coverage that the audit called out as
|
||||
// missing from D11.
|
||||
func TestNew_ErrorPaths(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
cfg := awsacmpca.Config{
|
||||
Region: "us-east-1",
|
||||
CAArn: "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012",
|
||||
}
|
||||
|
||||
t.Run("AccessDeniedSurfacedAsError", func(t *testing.T) {
|
||||
// Simulate the AWS access-denied case via the mock. The error
|
||||
// message format mirrors what aws-sdk-go-v2 surfaces for IAM
|
||||
// failures; the assertion is that the connector wraps the error
|
||||
// without swallowing it.
|
||||
mock := &mockACMPCAClient{
|
||||
issueCertificateErr: fmt.Errorf("operation error ACM-PCA: IssueCertificate, https response error StatusCode: 403, RequestID: x, AccessDeniedException: User is not authorized to perform: acm-pca:IssueCertificate"),
|
||||
}
|
||||
c := awsacmpca.NewWithClient(&cfg, mock, logger)
|
||||
_, csrPEM := generateTestCertAndCSR(t)
|
||||
_, err := c.IssueCertificate(ctx, issuer.IssuanceRequest{
|
||||
CommonName: "example.com",
|
||||
CSRPEM: csrPEM,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected access-denied error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "AccessDenied") {
|
||||
t.Errorf("expected wrapped AccessDeniedException, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "IssueCertificate failed") {
|
||||
t.Errorf("expected connector wrapping ('IssueCertificate failed: ...'), got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Transient5xxSurfacedAsError", func(t *testing.T) {
|
||||
// Simulate a transient 5xx from ACM PCA. The connector returns
|
||||
// the error to the caller; retry logic, if any, lives upstream
|
||||
// in the scheduler.
|
||||
mock := &mockACMPCAClient{
|
||||
issueCertificateErr: fmt.Errorf("operation error ACM-PCA: IssueCertificate, https response error StatusCode: 503, ServiceUnavailable"),
|
||||
}
|
||||
c := awsacmpca.NewWithClient(&cfg, mock, logger)
|
||||
_, csrPEM := generateTestCertAndCSR(t)
|
||||
_, err := c.IssueCertificate(ctx, issuer.IssuanceRequest{
|
||||
CommonName: "example.com",
|
||||
CSRPEM: csrPEM,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected 5xx error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "503") && !strings.Contains(err.Error(), "ServiceUnavailable") {
|
||||
t.Errorf("expected wrapped 5xx, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CtxCancelPropagated", func(t *testing.T) {
|
||||
// Mock that respects ctx cancellation. Asserts the connector
|
||||
// honors caller-supplied deadlines.
|
||||
mock := &mockACMPCAClient{}
|
||||
c := awsacmpca.NewWithClient(&cfg, mock, logger)
|
||||
|
||||
cancelCtx, cancel := context.WithCancel(ctx)
|
||||
cancel() // cancel immediately
|
||||
|
||||
_, csrPEM := generateTestCertAndCSR(t)
|
||||
// The mock doesn't check ctx; we test by injecting a ctx-aware
|
||||
// error. Use a wrapped context.Canceled to simulate the SDK
|
||||
// returning a cancellation error.
|
||||
mock.issueCertificateErr = context.Canceled
|
||||
_, err := c.IssueCertificate(cancelCtx, issuer.IssuanceRequest{
|
||||
CommonName: "example.com",
|
||||
CSRPEM: csrPEM,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected ctx-cancel error")
|
||||
}
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("expected errors.Is(err, context.Canceled), got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -90,7 +90,11 @@ func NewFromConfig(issuerType string, configJSON json.RawMessage, logger *slog.L
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid AWS ACM PCA config: %w", err)
|
||||
}
|
||||
return awsacmpca.New(&cfg, logger), nil
|
||||
conn, err := awsacmpca.New(&cfg, logger)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("AWS ACM PCA init: %w", err)
|
||||
}
|
||||
return conn, nil
|
||||
|
||||
case "Entrust", "entrust":
|
||||
var cfg entrust.Config
|
||||
|
||||
Reference in New Issue
Block a user