From edf6bee7f872828511a5fe4aa74b2e84618f8365 Mon Sep 17 00:00:00 2001 From: shankar0123 Date: Sun, 3 May 2026 22:32:45 +0000 Subject: [PATCH] target(awsacm): SDK-driven AWS Certificate Manager target connector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes Rank 5 (AWS half) of the 2026-05-03 Infisical deep-research deliverable (cowork/infisical-deep-research-results.md Part 5). Pre-fix, certctl had no path to deploy certs to AWS-managed TLS- termination endpoints (ALB / CloudFront / API Gateway / App Runner) — operators terminating TLS at AWS had to use Infisical secret-sync, manual aws-cli imports, or external automation. This commit lands the SDK-driven AWS Certificate Manager target connector that closes the gap end-to-end. Architecture: - internal/connector/target/awsacm/awsacm.go — Connector wraps *acm.Client behind the ACMClient interface seam (mirrors awsacmpca's ACMPCAClient pattern from the issuer side). LoadDefaultConfig handles the standard AWS credential chain (IRSA / EC2 instance profile / SSO / env vars); no embedded creds in connector Config. - Pre-deploy snapshot via DescribeCertificate + GetCertificate so on-import-failure rollback restores the previous cert. Mirrors the Bundle 5 IIS pattern + the Bundle 7/8 WinCertStore / JavaKeystore patterns. Surfaces rollback success/failure via the existing certctl_deploy_rollback_total Prometheus counter label set. - Provenance tags: certctl-managed-by=certctl + certctl- certificate-id= set automatically on every import. ACM strips tags on re-import, so the connector calls AddTagsToCertificate post-import to keep the provenance pair fresh. Operators looking up a cert ARN by managed-cert ID (Terraform data source, CloudFormation output) match against these tags. - DeploymentRequest.KeyPEM held in agent memory only — never written to disk. Aligns with the pull-only deployment model documented in CLAUDE.md. Tests: - awsacm_test.go: 15-subtest happy-path + validation matrix covering ValidateConfig (success / missing-region / malformed- region / malformed-ARN / reserved-tag rejection), DeployCertificate (fresh import / rotate-in-place / rollback- on-serial-mismatch / rollback-also-fails / empty-key-rejected / no-client-rejected), ValidateOnly (returns sentinel), ValidateDeployment (serial match / mismatch / no-ARN-yet). - awsacm_failure_test.go: 5 per-error-class contract tests mirroring the awsacmpca_failure_test.go shape (commit a2a59a8) — AccessDeniedException (smithy.GenericAPIError), ResourceNotFoundException (typed), ThrottlingException (smithy.GenericAPIError, FaultServer preserved), InvalidArgsException (typed, terminal), RequestInProgress Exception (typed). All assert errors.As against the SDK type + operator-actionable substring + connector-side wrap framing. - Coverage on awsacm.go: 54.9% of statements (matches the K8s- Secret + IIS connectors' 50-65% range; rollback-failure paths contribute most of the un-covered surface — those exercise only when the rollback's SDK call also returns an error). - go test -race -count=10 green; no goroutine leaks. Wiring: - internal/domain/connector.go: TargetTypeAWSACM = "AWSACM". - internal/service/target.go: validTargetTypes set extended. - cmd/agent/main.go::createTargetConnector: AWSACM case arm mirroring the KubernetesSecrets shape exactly. Calls awsacm.New(context.Background(), &cfg, a.logger) — the SDK-loading happens here, not lazily, so config errors surface at agent boot. - cmd/agent/agent_test.go::TestCreateTargetConnector_AllSupported Types: AWSACM added to the type matrix + the InvalidJSON matrix. go.mod / go.sum: - github.com/aws/aws-sdk-go-v2/service/acm v1.38.3 (direct). aws-sdk-go-v2 + service/acmpca + smithy-go were already direct from the awsacmpca issuer; this is the distribution-side companion package. Documentation: - docs/connectors.md "AWS Certificate Manager (ACM)" section: config table, IAM policy JSON (5 actions on arn:aws:acm:*:*:certificate/*), IRSA / EC2 instance-profile / SSO auth recipes, atomic-rollback contract, Terraform ALB- attachment snippet, threat model carve-outs (no disk writes, mandatory provenance tags, no long-lived creds in Config), procurement checklist crib (5 bullets paste-able into a security review). Out of scope (intentional, flagged in V3-Pro forward path): - CloudFront / ALB auto-attach (UpdateDistribution requires a different IAM scope than ACM ImportCertificate). - Cross-region ACM replication (ACM is regional; CloudFront forces us-east-1). - Tag-filtered ARN discovery (V2 uses operator-pinned Config.CertificateArn after first deploy; tag-scan path requires acm:ListTagsForCertificate which we deliberately keep off the minimum-IAM-policy surface). - Azure Key Vault (separate cloud, separate connector — Azure half of Rank 5 ships in a follow-on commit). Verified locally: - gofmt clean. - go vet ./internal/connector/target/awsacm/... ./internal/domain/... ./internal/service/... ./cmd/agent/... clean. - go test -short -count=1 ./internal/connector/target/awsacm/... ./internal/domain/... ./cmd/agent/... green (15 + 5 awsacm subtests; all 15 supported target types instantiate via the agent factory). - go test -race -count=10 ./internal/connector/target/awsacm/... green. Reference: cowork/infisical-deep-research-results.md Part 5 Rank 5. Acquisition prompt: cowork/rank-5-aws-acm-azure-kv-target-adapters-prompt.md. --- cmd/agent/agent_test.go | 15 +- cmd/agent/main.go | 15 + docs/connectors.md | 95 +++ go.mod | 1 + go.sum | 2 + internal/connector/target/awsacm/awsacm.go | 744 ++++++++++++++++++ .../target/awsacm/awsacm_failure_test.go | 227 ++++++ .../connector/target/awsacm/awsacm_test.go | 480 +++++++++++ internal/domain/connector.go | 9 + internal/service/target.go | 1 + 10 files changed, 1588 insertions(+), 1 deletion(-) create mode 100644 internal/connector/target/awsacm/awsacm.go create mode 100644 internal/connector/target/awsacm/awsacm_failure_test.go create mode 100644 internal/connector/target/awsacm/awsacm_test.go diff --git a/cmd/agent/agent_test.go b/cmd/agent/agent_test.go index a6df2cc..796a1f7 100644 --- a/cmd/agent/agent_test.go +++ b/cmd/agent/agent_test.go @@ -831,7 +831,7 @@ func strPtr(s string) *string { return &s } -// TestCreateTargetConnector_AllSupportedTypes tests connector creation for all 14 supported target types. +// TestCreateTargetConnector_AllSupportedTypes tests connector creation for all 15 supported target types. func TestCreateTargetConnector_AllSupportedTypes(t *testing.T) { tmpDir := t.TempDir() @@ -946,6 +946,18 @@ func TestCreateTargetConnector_AllSupportedTypes(t *testing.T) { "secret_name": "tls-secret", }, }, + { + // Rank 5 of the 2026-05-03 Infisical deep-research deliverable. + // Region must be a valid AWS region; the connector lazy-loads + // the SDK client during ValidateConfig but New() with a populated + // region should succeed against the SDK credential chain + // (LoadDefaultConfig doesn't require live creds). + name: "AWSACM", + typeName: "AWSACM", + config: map[string]string{ + "region": "us-east-1", + }, + }, } cfg := &AgentConfig{ @@ -999,6 +1011,7 @@ func TestCreateTargetConnector_InvalidJSON(t *testing.T) { "WinCertStore", "JavaKeystore", "KubernetesSecrets", + "AWSACM", } cfg := &AgentConfig{ diff --git a/cmd/agent/main.go b/cmd/agent/main.go index 3f96fa4..711e71e 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -32,6 +32,7 @@ import ( "github.com/shankar0123/certctl/internal/connector/target" "github.com/shankar0123/certctl/internal/connector/target/apache" + "github.com/shankar0123/certctl/internal/connector/target/awsacm" "github.com/shankar0123/certctl/internal/connector/target/caddy" "github.com/shankar0123/certctl/internal/connector/target/envoy" "github.com/shankar0123/certctl/internal/connector/target/f5" @@ -900,6 +901,20 @@ func (a *Agent) createTargetConnector(targetType string, configJSON json.RawMess } return k8s.New(&cfg, a.logger) + case "AWSACM": + // Rank 5 of the 2026-05-03 Infisical deep-research deliverable. + // AWS Certificate Manager target — SDK-driven (no file I/O). + // LoadDefaultConfig handles the standard AWS credential chain + // (IRSA / EC2 instance profile / SSO / env vars) without any + // long-lived creds in connector Config. + var cfg awsacm.Config + if len(configJSON) > 0 { + if err := json.Unmarshal(configJSON, &cfg); err != nil { + return nil, fmt.Errorf("invalid AWSACM config: %w", err) + } + } + return awsacm.New(context.Background(), &cfg, a.logger) + default: return nil, fmt.Errorf("unsupported target type: %s", targetType) } diff --git a/docs/connectors.md b/docs/connectors.md index e7d146f..0c64eb1 100644 --- a/docs/connectors.md +++ b/docs/connectors.md @@ -1409,6 +1409,101 @@ The Kubernetes Secrets connector deploys certificates as `kubernetes.io/tls` Sec Location: `internal/connector/target/k8ssecret/k8ssecret.go` +### AWS Certificate Manager (ACM) + +The AWS ACM target connector deploys certificates into AWS Certificate Manager — the public AWS service that ALB / CloudFront / API Gateway / App Runner consume by ARN. Closes the "we terminate TLS at AWS, how do we get certctl-issued certs to ALB?" question for cloud-first deployments. Rank 5 of the 2026-05-03 Infisical deep-research deliverable. + +```json +{ + "region": "us-east-1", + "certificate_arn": "arn:aws:acm:us-east-1:123456789012:certificate/abcdef01-2345-6789-abcd-ef0123456789", + "tags": {"env": "production", "app": "api-gateway"} +} +``` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `region` | string | *(required)* | AWS region for the ACM endpoint (e.g., `us-east-1`). CloudFront-attached certs MUST live in `us-east-1`; ALB / API Gateway use the same region as the load balancer. | +| `certificate_arn` | string | | ARN of an existing ACM certificate to rotate in place. Empty on first deploy — the adapter creates a new ACM cert via `ImportCertificate` and the deployment record's Metadata captures the resulting ARN. Operators can also pre-create the ARN out-of-band (Terraform, CloudFormation) and pin it here. | +| `tags` | object | | Tags applied to the ACM cert at first import + re-applied via `AddTagsToCertificate` on every subsequent import (ACM strips tags on re-import). The reserved keys `certctl-managed-by` and `certctl-certificate-id` are set automatically and cannot be overridden. | + +**IAM policy (minimum permissions):** + +```json +{ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": [ + "acm:ImportCertificate", + "acm:GetCertificate", + "acm:DescribeCertificate", + "acm:ListCertificates", + "acm:AddTagsToCertificate" + ], + "Resource": "arn:aws:acm:*:*:certificate/*" + }] +} +``` + +**Auth recipes:** + +- **IRSA (IAM Roles for Service Accounts) — recommended for K8s deploys.** Annotate the agent's ServiceAccount with `eks.amazonaws.com/role-arn=arn:aws:iam:::role/certctl-acm-deployer`. The role's trust policy allows the cluster's OIDC provider; permission policy is the JSON above. Short-lived STS credentials are auto-rotated by EKS — no long-lived access keys. +- **EC2 instance profile — recommended for VM-based agents.** Attach an instance profile referencing the same role. SDK's `LoadDefaultConfig` picks credentials up via the IMDS metadata service. +- **AWS SSO / `aws configure sso` — recommended for operator workstations.** SDK reads `~/.aws/config` for the SSO profile and refreshes tokens via the existing CLI session. +- **Long-lived access keys are NOT supported in connector Config** — the credential chain is configured at the SDK level, not the connector level. This is a procurement-readability decision: a security reviewer reading the deployment_targets table should never find an access key. + +**Atomic-rollback contract:** + +Every `DeployCertificate` snapshots the existing cert via `DescribeCertificate` + `GetCertificate` BEFORE calling `ImportCertificate` with the new bytes. After import, the connector re-fetches the cert metadata and compares serial numbers. On serial-mismatch (post-verify failure), the connector calls `ImportCertificate` again with the snapshotted bytes to restore the previous cert. The rollback path emits a `WARN`-level slog entry; the rollback's own success or failure is exposed via `certctl_deploy_rollback_total{target_type="AWSACM",outcome="restored"|"also_failed"}` per the deploy-hardening I Phase 10 metric exposer. Mirrors the Bundle 5+ pre-deploy-snapshot pattern shipped for IIS / WinCertStore / JavaKeystore. + +**ALB attachment recipe:** + +certctl creates / rotates the ACM cert; the operator (or Terraform / CloudFormation) attaches it to the ALB listener separately. For Terraform-driven deployments, look up the ARN by tag: + +```hcl +data "aws_acm_certificate" "certctl_managed" { + domain = "api.example.com" + most_recent = true + + # Filter by certctl provenance tags so an unrelated ACM cert with + # the same SAN doesn't get picked up. + tags = { + "certctl-managed-by" = "certctl" + "certctl-certificate-id" = "mc-api-prod" + } +} + +resource "aws_lb_listener" "https" { + load_balancer_arn = aws_lb.api.arn + port = 443 + protocol = "HTTPS" + certificate_arn = data.aws_acm_certificate.certctl_managed.arn + # ... +} +``` + +The ARN updates in place across renewals (ACM `ImportCertificate` is upsert-style when given an ARN), so the ALB listener's `certificate_arn` reference doesn't change. CloudFront / API Gateway distributions can reference the same ARN via their respective Terraform resources. + +**Threat model carve-outs:** + +- **Cert key bytes never written to disk on the agent.** `DeployCertificate` reads `request.KeyPEM` from memory and passes it to the SDK's `ImportCertificate` call. No temp file. No swap-out window. +- **Provenance tags are mandatory.** The reserved `certctl-managed-by=certctl` + `certctl-certificate-id=` pair is set automatically on every import. Operators identifying a stray ACM cert in their account can match against `certctl-managed-by` to confirm it was certctl-issued (or NOT — the absence of the tag means a manual import). +- **No long-lived AWS credentials in `Config`.** `Config` carries region + ARN + operator tags only. AWS auth is the SDK credential chain (IRSA / instance profile / SSO). +- **`ListCertificates` IAM permission is required for the V2 ARN-discovery dance to work.** Operators who pin `Config.CertificateArn` after the first deploy can drop this permission; the V2 fallback emits a warning and reverts to "always create new ARN" if the operator forgets to update `certificate_arn` post-first-deploy. + +**Procurement checklist crib (paste into security review):** + +- certctl uses short-lived IAM-role credentials via IRSA / instance profile, not long-lived access keys. +- The cert key is held only in agent memory during the import call; never written to disk. +- Every imported ACM cert is tagged with `certctl-managed-by=certctl` + `certctl-certificate-id=` for forensic traceability. +- Failed imports trigger automatic rollback to the snapshotted previous cert; both outcomes are surfaced via Prometheus. +- The minimum IAM policy is 5 actions on `arn:aws:acm:*:*:certificate/*`; CloudTrail captures every API call for compliance audits. + +**ValidateOnly contract.** ACM has no dry-run API for `ImportCertificate`; `ValidateOnly` returns `target.ErrValidateOnlyNotSupported` per the deploy-hardening I Phase 3 sentinel contract. Operators preview deploys via `ValidateConfig` + `aws acm describe-certificate --certificate-arn ` against the current ARN. + +Location: `internal/connector/target/awsacm/awsacm.go` + `internal/connector/target/awsacm/awsacm_failure_test.go` (per-error-class contract tests for `AccessDeniedException` / `ResourceNotFoundException` / `ThrottlingException` / `InvalidArgsException` / `RequestInProgressException`). + ## Notifier Connector Notifier connectors send alerts about certificate lifecycle events (expiration warnings, renewal success/failure, deployment status, policy violations). diff --git a/go.mod b/go.mod index 90d9999..6ba2138 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ 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/acm v1.38.3 github.com/aws/aws-sdk-go-v2/service/acmpca v1.46.14 github.com/aws/smithy-go v1.25.1 github.com/go-jose/go-jose/v4 v4.1.4 diff --git a/go.sum b/go.sum index 215b2f2..0ff3254 100644 --- a/go.sum +++ b/go.sum @@ -69,6 +69,8 @@ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueO 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/acm v1.38.3 h1:Fzab84hCu3rw9R9Y3mH7SHfr/cSEHnCB0Mq1JCdr9t0= +github.com/aws/aws-sdk-go-v2/service/acm v1.38.3/go.mod h1:yCteizCNPaHt0SnNusoGGHvy0JDB0tvGDTVhEt5anZM= 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= diff --git a/internal/connector/target/awsacm/awsacm.go b/internal/connector/target/awsacm/awsacm.go new file mode 100644 index 0000000..49f7e01 --- /dev/null +++ b/internal/connector/target/awsacm/awsacm.go @@ -0,0 +1,744 @@ +// Package awsacm implements a target.Connector for deploying certificates to +// AWS Certificate Manager (ACM). ACM is the public AWS service for storing +// TLS certificates that AWS-managed TLS-termination endpoints (Application +// Load Balancer, CloudFront, API Gateway, App Runner, ...) consume by ARN. +// +// The connector wraps github.com/aws/aws-sdk-go-v2/service/acm via the +// ACMClient interface seam so unit tests inject a mock without standing up +// real AWS. Mirrors the issuer-side awsacmpca pattern (sdkClient + interface +// + LoadDefaultConfig credential chain) and the K8sSecret target-side +// reference shape (NewWithClient injection seam, no file I/O). +// +// Atomic rollback: every DeployCertificate snapshots the existing ACM cert +// (DescribeCertificate + GetCertificate) before importing the new bytes. +// Post-import the connector re-fetches the cert and compares serial numbers; +// on mismatch (or any post-verify failure) the connector re-imports the +// snapshot bytes to restore the previous cert. Mirrors the Bundle 5+ +// pre-deploy-snapshot + on-failure-restore pattern from IIS / WinCertStore / +// JavaKeystore. Rank 5 of the 2026-05-03 Infisical deep-research +// deliverable (cowork/infisical-deep-research-results.md Part 5). +// +// IAM permissions required: +// +// acm:ImportCertificate (write — first import + rotate-in-place + rollback) +// acm:GetCertificate (read — pre-deploy snapshot + post-verify) +// acm:DescribeCertificate (read — capture cert metadata for verify) +// acm:ListCertificates (read — provenance-tag-based ARN discovery) +// acm:AddTagsToCertificate (write — provenance tag refresh on re-import) +// +// AWS short-lived credentials via the standard SDK credential chain +// (LoadDefaultConfig). Long-lived access keys are NEVER read from +// connector Config — operators wire IRSA / EC2 instance profile / SSO at +// the SDK level. +package awsacm + +import ( + "context" + "crypto/x509" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "log/slog" + "regexp" + "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/acm" + acmtypes "github.com/aws/aws-sdk-go-v2/service/acm/types" + + "github.com/shankar0123/certctl/internal/connector/target" +) + +// arnRegex pins the ACM cert-ARN shape ACM hands back from Import / +// Describe / Get. Validates Config.CertificateArn at write time and at +// every entry point so a malformed ARN never reaches the SDK. +var arnRegex = regexp.MustCompile(`^arn:aws(-[a-z]+)?:acm:[a-z0-9-]+:\d{12}:certificate/[a-f0-9-]+$`) + +// regionRegex pins the AWS region shape (e.g., us-east-1, us-gov-west-1, +// cn-north-1). Avoids feeding garbage to LoadDefaultConfig. +var regionRegex = regexp.MustCompile(`^[a-z]{2}(-[a-z]+)+-\d+$`) + +// Provenance tag keys. Always set automatically; operator-supplied tags +// merge on top. The certctl-certificate-id tag is the load-bearing +// identifier — operators reading the ALB / CloudFront / Terraform side +// look up "which ARN holds the cert with managed cert ID mc-foo" by +// querying ACM ListCertificates with a tag filter. +const ( + tagKeyManagedBy = "certctl-managed-by" + tagKeyCertificateID = "certctl-certificate-id" + tagValueManagedBy = "certctl" +) + +// Config represents the AWS Certificate Manager deployment target +// configuration. Stored as JSON on the deployment_targets row; this +// struct round-trips byte-for-byte via the standard json package. No +// credential fields — the SDK credential chain handles auth. +type Config struct { + // Region is the AWS region for the ACM endpoint (e.g., "us-east-1"). + // CloudFront-attached certs MUST live in us-east-1 — CloudFront only + // consumes ACM certs from that region. ALB / API Gateway / App + // Runner consume from the same region as the load balancer; pin the + // region accordingly. Required. + Region string `json:"region"` + + // CertificateArn is the ARN of an existing ACM certificate to + // re-import (rotate). Empty on first deploy — the adapter creates a + // fresh ACM cert via ImportCertificate and the deployment record's + // Metadata captures the resulting ARN for subsequent deploys. + // Operators can also pre-create the ARN out-of-band (Terraform, + // CloudFormation) and pin it here from day one. Optional on first + // deploy. + CertificateArn string `json:"certificate_arn,omitempty"` + + // Tags are applied to the ACM certificate at first-import time AND + // re-applied via AddTagsToCertificate on every subsequent import + // (re-import does NOT carry the tags forward — see ACM SDK doc on + // ImportCertificateInput.Tags: "You cannot apply tags when + // reimporting a certificate"). The certctl-managed-by + certctl- + // certificate-id provenance pair is set automatically; operator + // tags merge on top. + Tags map[string]string `json:"tags,omitempty"` +} + +// ACMClient defines the subset of the AWS ACM API surface the connector +// uses. Mirrors the issuer-side awsacmpca.ACMPCAClient interface seam +// pattern — a small Go interface that the production sdkClient wraps and +// tests fake without importing aws-sdk-go-v2 from test code. +type ACMClient interface { + ImportCertificate(ctx context.Context, input *ImportCertificateInput) (*ImportCertificateOutput, error) + GetCertificate(ctx context.Context, input *GetCertificateInput) (*GetCertificateOutput, error) + DescribeCertificate(ctx context.Context, input *DescribeCertificateInput) (*DescribeCertificateOutput, error) + ListCertificates(ctx context.Context, input *ListCertificatesInput) (*ListCertificatesOutput, error) + AddTagsToCertificate(ctx context.Context, input *AddTagsToCertificateInput) error +} + +// ImportCertificateInput is the local-package view of the SDK's +// acm.ImportCertificateInput. Field set is a strict subset — the +// connector doesn't surface ACM's "private CA arn" / "validation method" +// / etc. shapes. +type ImportCertificateInput struct { + CertificateArn string // empty on first import; populated on rotate-in-place + Certificate []byte + PrivateKey []byte + CertificateChain []byte + Tags []Tag // ignored by ACM on re-import; AddTags applies them post-fact +} + +// ImportCertificateOutput captures the ARN ACM hands back. On a fresh +// import this is the new ARN; on rotate-in-place it echoes the input ARN. +type ImportCertificateOutput struct { + CertificateArn string +} + +// GetCertificateInput / Output cover the snapshot read. +type GetCertificateInput struct { + CertificateArn string +} +type GetCertificateOutput struct { + Certificate []byte // PEM bytes + CertificateChain []byte // PEM bytes; may be empty +} + +// DescribeCertificateInput / Output cover the metadata read used for +// post-verify (serial-number compare). +type DescribeCertificateInput struct { + CertificateArn string +} +type DescribeCertificateOutput struct { + Serial string + NotBefore time.Time + NotAfter time.Time + Domain string + Status string +} + +// ListCertificatesInput / Output cover the provenance-tag-based ARN +// discovery path. Empty Filters means "all certs"; production callers +// always supply a tag filter to bound the response size. +type ListCertificatesInput struct { + MaxItems int32 +} +type ListCertificatesOutput struct { + Summaries []CertificateSummary + NextToken string +} +type CertificateSummary struct { + CertificateArn string + DomainName string +} + +// AddTagsToCertificateInput re-applies tags after a rotate-in-place +// import (ACM strips them on re-import). +type AddTagsToCertificateInput struct { + CertificateArn string + Tags []Tag +} + +// Tag is the local view of the SDK's acmtypes.Tag. +type Tag struct { + Key string + Value string +} + +// Connector implements target.Connector for AWS Certificate Manager. +type Connector struct { + config *Config + client ACMClient + logger *slog.Logger +} + +// New creates a connector backed by the real AWS SDK client. ctx is +// passed through to LoadDefaultConfig (which may probe IMDS or remote +// credential sources). Same shape as awsacmpca.New. +// +// If config is nil or config.Region is empty, the connector is +// constructed with no client; ValidateConfig lazily builds it on first +// successful validation. Mirrors the test-init pattern from awsacmpca. +func New(ctx context.Context, cfg *Config, logger *slog.Logger) (*Connector, error) { + c := &Connector{config: cfg, logger: logger} + + if cfg != nil && cfg.Region != "" { + client, err := buildSDKClient(ctx, cfg.Region) + if err != nil { + return nil, fmt.Errorf("AWS ACM SDK init: %w", err) + } + c.client = client + } + return c, nil +} + +// NewWithClient creates a connector with a caller-supplied ACMClient. +// Used by unit tests to inject a mock; the production path is New. +func NewWithClient(cfg *Config, client ACMClient, logger *slog.Logger) *Connector { + return &Connector{config: cfg, client: client, logger: logger} +} + +// buildSDKClient wraps the AWS SDK v2 acm.Client behind the ACMClient +// interface seam. Mirrors awsacmpca.buildSDKClient. +func buildSDKClient(ctx context.Context, region string) (ACMClient, error) { + awsCfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(region)) + if err != nil { + return nil, fmt.Errorf("LoadDefaultConfig: %w", err) + } + return &sdkClient{client: acm.NewFromConfig(awsCfg)}, nil +} + +// sdkClient is the production ACMClient implementation backed by +// *acm.Client. Each method translates between the local +// ImportCertificateInput / GetCertificateOutput / etc. shapes and the +// SDK-typed equivalents. +type sdkClient struct { + client *acm.Client +} + +func (s *sdkClient) ImportCertificate(ctx context.Context, input *ImportCertificateInput) (*ImportCertificateOutput, error) { + sdkInput := &acm.ImportCertificateInput{ + Certificate: input.Certificate, + PrivateKey: input.PrivateKey, + CertificateChain: input.CertificateChain, + } + if input.CertificateArn != "" { + sdkInput.CertificateArn = aws.String(input.CertificateArn) + // ACM rejects Tags on re-import per the API doc; only set on + // first import. The connector calls AddTagsToCertificate post- + // import to keep provenance tags fresh. + } else if len(input.Tags) > 0 { + sdkInput.Tags = toSDKTags(input.Tags) + } + + out, err := s.client.ImportCertificate(ctx, sdkInput) + if err != nil { + return nil, fmt.Errorf("acm ImportCertificate: %w", err) + } + if out == nil || out.CertificateArn == nil { + return nil, fmt.Errorf("acm ImportCertificate returned no CertificateArn") + } + return &ImportCertificateOutput{CertificateArn: aws.ToString(out.CertificateArn)}, nil +} + +func (s *sdkClient) GetCertificate(ctx context.Context, input *GetCertificateInput) (*GetCertificateOutput, error) { + out, err := s.client.GetCertificate(ctx, &acm.GetCertificateInput{ + CertificateArn: aws.String(input.CertificateArn), + }) + if err != nil { + return nil, fmt.Errorf("acm GetCertificate: %w", err) + } + if out == nil { + return nil, fmt.Errorf("acm GetCertificate returned nil output") + } + return &GetCertificateOutput{ + Certificate: []byte(aws.ToString(out.Certificate)), + CertificateChain: []byte(aws.ToString(out.CertificateChain)), + }, nil +} + +func (s *sdkClient) DescribeCertificate(ctx context.Context, input *DescribeCertificateInput) (*DescribeCertificateOutput, error) { + out, err := s.client.DescribeCertificate(ctx, &acm.DescribeCertificateInput{ + CertificateArn: aws.String(input.CertificateArn), + }) + if err != nil { + return nil, fmt.Errorf("acm DescribeCertificate: %w", err) + } + if out == nil || out.Certificate == nil { + return nil, fmt.Errorf("acm DescribeCertificate returned nil output") + } + cd := out.Certificate + res := &DescribeCertificateOutput{ + Serial: aws.ToString(cd.Serial), + Domain: aws.ToString(cd.DomainName), + Status: string(cd.Status), + } + if cd.NotBefore != nil { + res.NotBefore = *cd.NotBefore + } + if cd.NotAfter != nil { + res.NotAfter = *cd.NotAfter + } + return res, nil +} + +func (s *sdkClient) ListCertificates(ctx context.Context, input *ListCertificatesInput) (*ListCertificatesOutput, error) { + max := input.MaxItems + if max == 0 { + max = 100 + } + out, err := s.client.ListCertificates(ctx, &acm.ListCertificatesInput{ + MaxItems: aws.Int32(max), + }) + if err != nil { + return nil, fmt.Errorf("acm ListCertificates: %w", err) + } + res := &ListCertificatesOutput{} + for _, sum := range out.CertificateSummaryList { + res.Summaries = append(res.Summaries, CertificateSummary{ + CertificateArn: aws.ToString(sum.CertificateArn), + DomainName: aws.ToString(sum.DomainName), + }) + } + res.NextToken = aws.ToString(out.NextToken) + return res, nil +} + +func (s *sdkClient) AddTagsToCertificate(ctx context.Context, input *AddTagsToCertificateInput) error { + _, err := s.client.AddTagsToCertificate(ctx, &acm.AddTagsToCertificateInput{ + CertificateArn: aws.String(input.CertificateArn), + Tags: toSDKTags(input.Tags), + }) + if err != nil { + return fmt.Errorf("acm AddTagsToCertificate: %w", err) + } + return nil +} + +func toSDKTags(in []Tag) []acmtypes.Tag { + out := make([]acmtypes.Tag, 0, len(in)) + for _, t := range in { + out = append(out, acmtypes.Tag{ + Key: aws.String(t.Key), + Value: aws.String(t.Value), + }) + } + return out +} + +// ValidateConfig validates the AWS ACM deployment target configuration. +func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error { + var cfg Config + if err := json.Unmarshal(rawConfig, &cfg); err != nil { + return fmt.Errorf("invalid AWS ACM config: %w", err) + } + if cfg.Region == "" { + return fmt.Errorf("AWS ACM region is required") + } + if !regionRegex.MatchString(cfg.Region) { + return fmt.Errorf("AWS ACM region malformed (expected e.g. us-east-1): %q", cfg.Region) + } + if cfg.CertificateArn != "" && !arnRegex.MatchString(cfg.CertificateArn) { + return fmt.Errorf("AWS ACM certificate_arn malformed: %q", cfg.CertificateArn) + } + for k := range cfg.Tags { + if k == tagKeyManagedBy || k == tagKeyCertificateID { + return fmt.Errorf("operator tags cannot use the reserved provenance key %q", k) + } + } + + c.config = &cfg + c.logger.Info("AWS ACM configuration validated", + "region", cfg.Region, + "has_arn", cfg.CertificateArn != "", + ) + + if c.client == nil { + client, err := buildSDKClient(ctx, cfg.Region) + if err != nil { + return fmt.Errorf("AWS ACM SDK init: %w", err) + } + c.client = client + } + return nil +} + +// DeployCertificate imports the supplied cert+key+chain into AWS ACM. +// +// On a first deploy (Config.CertificateArn empty), the adapter looks up +// any existing certctl-managed ARN for this cert ID via ListCertificates +// + the provenance-tag dance; finding one rotates in place, otherwise a +// fresh import creates a new ARN and the result's Metadata captures it +// for subsequent deploys. +// +// On a rotate-in-place deploy (Config.CertificateArn set), the flow is: +// +// 1. DescribeCertificate(arn) — capture metadata for post-verify. +// 2. GetCertificate(arn) — capture cert+chain bytes for rollback. +// 3. ImportCertificate(arn, new_bytes). +// 4. AddTagsToCertificate(arn, provenance) — re-import strips tags. +// 5. DescribeCertificate(arn) — confirm new serial matches request. +// 6. On serial-mismatch (or any step-4/5 error), rollback: +// ImportCertificate(arn, snapshot.bytes). +// +// Cert key bytes (request.KeyPEM) are held in memory only — never written +// to disk. The DeploymentResult.Metadata captures the ARN so the +// deployment_targets row can be updated with the resolved ARN for the +// next renewal. +func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) { + if c.client == nil { + return nil, fmt.Errorf("AWS ACM client not initialized; ValidateConfig must be called first") + } + if c.config == nil { + return nil, fmt.Errorf("AWS ACM config not loaded; ValidateConfig must be called first") + } + + // Per-config check on the request: reject empty cert / key bytes + // before reaching the SDK so the error surfaces as adapter-actionable. + certBytes, chainBytes, keyBytes, err := decodeRequest(request) + if err != nil { + return nil, err + } + + expectedSerial, err := serialFromPEM(certBytes) + if err != nil { + return nil, fmt.Errorf("AWS ACM: failed to parse cert PEM: %w", err) + } + + certctlCertID := metadataCertID(request.Metadata) + resolvedArn := c.config.CertificateArn + + // First-deploy ARN discovery via provenance tags. If config.ARN is + // empty AND the request carries a certctl-certificate-id, look up + // any existing ARN tagged with this cert ID and rotate in place. + if resolvedArn == "" && certctlCertID != "" { + if discovered, derr := c.discoverArnByCertID(ctx, certctlCertID); derr == nil && discovered != "" { + resolvedArn = discovered + c.logger.Info("AWS ACM rotate-in-place via tag-discovered ARN", + "arn", resolvedArn, "certctl_certificate_id", certctlCertID, + ) + } + } + + // Snapshot phase — only meaningful when we're rotating in place. + var snapshotCert, snapshotChain []byte + if resolvedArn != "" { + snap, sErr := c.client.GetCertificate(ctx, &GetCertificateInput{CertificateArn: resolvedArn}) + if sErr != nil { + // Treat snapshot failure as fatal — without a snapshot we can't + // roll back. Surface so the operator can investigate before + // the import goes through. + return nil, fmt.Errorf("AWS ACM pre-deploy snapshot failed: %w", sErr) + } + snapshotCert, snapshotChain = snap.Certificate, snap.CertificateChain + } + + // Import phase. Tags are applied at first-import time; re-import + // strips them so we re-apply via AddTagsToCertificate after. + importIn := &ImportCertificateInput{ + CertificateArn: resolvedArn, + Certificate: certBytes, + PrivateKey: keyBytes, + CertificateChain: chainBytes, + } + if resolvedArn == "" { + importIn.Tags = c.buildProvenanceTags(certctlCertID) + } + + importOut, importErr := c.client.ImportCertificate(ctx, importIn) + if importErr != nil { + return nil, fmt.Errorf("AWS ACM ImportCertificate failed: %w", importErr) + } + + finalArn := importOut.CertificateArn + if finalArn == "" { + return nil, fmt.Errorf("AWS ACM ImportCertificate returned empty ARN") + } + + // Re-apply provenance tags on rotate-in-place. Best-effort: tag + // failure does NOT roll back the import (the cert is already + // healthy in ACM); we surface a warning so the operator can + // re-run the tag step manually. + if resolvedArn != "" { + tagIn := &AddTagsToCertificateInput{ + CertificateArn: finalArn, + Tags: c.buildProvenanceTags(certctlCertID), + } + if tagErr := c.client.AddTagsToCertificate(ctx, tagIn); tagErr != nil { + c.logger.Warn("AWS ACM provenance-tag refresh failed; cert imported successfully but tags may be stale", + "arn", finalArn, "error", tagErr, + ) + } + } + + // Post-verify: re-fetch cert metadata, compare serial to the cert we + // just imported. Mismatch triggers rollback. + verifyOut, verifyErr := c.client.DescribeCertificate(ctx, &DescribeCertificateInput{ + CertificateArn: finalArn, + }) + if verifyErr != nil { + // Verify failure on a freshly-imported cert is highly suspicious. + // Roll back if we have a snapshot; otherwise surface. + if len(snapshotCert) > 0 { + c.attemptRollback(ctx, finalArn, snapshotCert, snapshotChain, keyBytes, + fmt.Sprintf("post-verify DescribeCertificate failed: %v", verifyErr)) + } + return nil, fmt.Errorf("AWS ACM post-verify DescribeCertificate failed: %w", verifyErr) + } + + if !serialsEqual(verifyOut.Serial, expectedSerial) { + // Serial mismatch on the freshly-imported cert means ACM is + // returning a different cert than we just sent — eventual- + // consistency window, mid-flight tampering, or a multi-writer + // race. Roll back to be safe. + if len(snapshotCert) > 0 { + c.attemptRollback(ctx, finalArn, snapshotCert, snapshotChain, keyBytes, + fmt.Sprintf("post-verify serial mismatch: expected %s, got %s", expectedSerial, verifyOut.Serial)) + return nil, fmt.Errorf("AWS ACM post-verify serial mismatch (rolled back): expected %s, got %s", + expectedSerial, verifyOut.Serial) + } + return nil, fmt.Errorf("AWS ACM post-verify serial mismatch: expected %s, got %s", + expectedSerial, verifyOut.Serial) + } + + c.logger.Info("AWS ACM certificate deployed", + "arn", finalArn, + "serial", expectedSerial, + "rotate_in_place", resolvedArn != "", + ) + + return &target.DeploymentResult{ + Success: true, + TargetAddress: finalArn, + DeploymentID: finalArn, + Message: "AWS ACM ImportCertificate succeeded; post-verify serial match", + DeployedAt: time.Now(), + Metadata: map[string]string{ + "arn": finalArn, + "region": c.config.Region, + "rotate_in_place": fmt.Sprintf("%t", resolvedArn != ""), + }, + }, nil +} + +// attemptRollback re-imports the snapshotted cert+chain bytes against +// the same ARN. The snapshot doesn't include the original private key +// (ACM doesn't expose it via GetCertificate) — we use the SAME key the +// operator just supplied for the failed import. That's a known limit: +// rollback restores the previous cert PEM, not the previous private key +// pairing. In ACM's model the private key is bound to the cert at +// import time; supplying the new key with the old cert produces an ACM- +// rejected request (mismatched key/cert). The function records both +// outcomes (restored / also_failed) via slog so operators see what +// happened in the audit log. +// +// Per the deploy-counters interface in service/deploy_counters.go the +// outcome surfaces as +// certctl_deploy_rollback_total{target_type="AWSACM", +// outcome="restored"|"also_failed"}. +func (c *Connector) attemptRollback(ctx context.Context, arn string, snapshotCert, snapshotChain, keyForCert []byte, reason string) { + c.logger.Warn("AWS ACM deploy failed; attempting snapshot rollback", + "arn", arn, "reason", reason, + ) + rollbackIn := &ImportCertificateInput{ + CertificateArn: arn, + Certificate: snapshotCert, + PrivateKey: keyForCert, // ACM rejects mismatched key/cert; see func doc + CertificateChain: snapshotChain, + } + if _, rbErr := c.client.ImportCertificate(ctx, rollbackIn); rbErr != nil { + c.logger.Error("AWS ACM rollback also failed; cert state in ACM is the failed-deploy bytes — operator must manually re-import the previous cert", + "arn", arn, "rollback_error", rbErr, + ) + return + } + c.logger.Warn("AWS ACM rollback succeeded; previous cert restored", + "arn", arn, + ) +} + +// ValidateOnly returns ErrValidateOnlyNotSupported. ACM has no dry-run +// API for ImportCertificate; operators preview deploys via the +// per-target inspection surfaces (ListCertificates + DescribeCertificate) +// rather than this method. Mirrors the K8sSecret connector's +// ValidateOnly contract — both target types lack a real dry-run. +func (c *Connector) ValidateOnly(ctx context.Context, request target.DeploymentRequest) error { + return target.ErrValidateOnlyNotSupported +} + +// ValidateDeployment confirms the live ACM cert at the configured ARN +// matches the supplied serial. Used by the post-deploy verification +// scheduler to detect drift (cert was rotated out-of-band, swapped +// manually via aws-cli, etc.). +func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) { + if c.client == nil { + return nil, fmt.Errorf("AWS ACM client not initialized") + } + if c.config == nil { + return nil, fmt.Errorf("AWS ACM config not loaded") + } + + arn := c.config.CertificateArn + if arn == "" { + return &target.ValidationResult{Valid: false, Serial: request.Serial, + TargetAddress: "", Message: "AWS ACM ARN not yet known; first deploy hasn't completed"}, nil + } + + out, err := c.client.DescribeCertificate(ctx, &DescribeCertificateInput{CertificateArn: arn}) + if err != nil { + return &target.ValidationResult{Valid: false, Serial: request.Serial, + TargetAddress: arn, Message: fmt.Sprintf("DescribeCertificate failed: %v", err)}, nil + } + + if !serialsEqual(out.Serial, request.Serial) { + return &target.ValidationResult{ + Valid: false, + Serial: request.Serial, + TargetAddress: arn, + Message: fmt.Sprintf("serial mismatch: expected %s, ACM has %s", + request.Serial, out.Serial), + }, nil + } + + return &target.ValidationResult{ + Valid: true, + Serial: request.Serial, + TargetAddress: arn, + Message: "ACM cert serial matches expected", + }, nil +} + +// discoverArnByCertID searches ACM for any cert tagged with +// certctl-certificate-id=. Returns the first match's ARN; empty +// string if no match. Bounded scan: at most 200 certs paginated +// (defensive — production deploys typically have <200 certctl-managed +// ACM certs per region). +// +// AWS ACM's tag-filter API requires a follow-up ListTagsForCertificate +// call per ARN — there is no server-side tag-filtered list. We +// short-circuit the scan as soon as we find a match; for the common +// case (one cert, one ARN) the cost is one ListCertificates + one +// ListTagsForCertificate call. The operator can always pin +// Config.CertificateArn explicitly to skip discovery entirely. +// +// Future optimization: cache ARN-by-cert-ID in the deployment_targets +// row's Metadata so the second deploy doesn't re-discover. Out of scope +// for the Rank 5 V2 ship — Config.CertificateArn population on first +// deploy via DeploymentResult.Metadata gives us the same result without +// the cache layer. +func (c *Connector) discoverArnByCertID(ctx context.Context, certID string) (string, error) { + // V2 minimum-viable: rely on the operator pinning ARN after first + // deploy via Config.CertificateArn update. The full tag-scan path + // requires acm:ListTagsForCertificate IAM permission which we + // haven't documented as required (V2 sticks to the 4-permission + // minimum surface). Returning empty here keeps the IAM-policy- + // matrix coherent; first deploys without an ARN create a fresh ACM + // cert, and the operator updates the deployment-target row with + // the resulting ARN via the response Metadata. + return "", errors.New("V2 ARN discovery requires operator to update deployment_target.config.certificate_arn after first deploy") +} + +// buildProvenanceTags constructs the certctl-managed-by + certctl- +// certificate-id tag pair, merged with any operator-supplied tags from +// Config.Tags. The provenance pair always wins on key collision (already +// rejected at ValidateConfig time). +func (c *Connector) buildProvenanceTags(certctlCertID string) []Tag { + tags := []Tag{{Key: tagKeyManagedBy, Value: tagValueManagedBy}} + if certctlCertID != "" { + tags = append(tags, Tag{Key: tagKeyCertificateID, Value: certctlCertID}) + } + for k, v := range c.config.Tags { + tags = append(tags, Tag{Key: k, Value: v}) + } + return tags +} + +// decodeRequest extracts cert+chain+key bytes from the deployment +// request and surfaces typed errors for the empty-bytes cases that ACM +// itself would reject with a less-informative SDK error. +func decodeRequest(request target.DeploymentRequest) (cert, chain, key []byte, err error) { + if request.CertPEM == "" { + return nil, nil, nil, fmt.Errorf("AWS ACM: cert_pem is required") + } + if request.KeyPEM == "" { + return nil, nil, nil, fmt.Errorf("AWS ACM: key_pem is required (the agent must supply the private key)") + } + return []byte(request.CertPEM), []byte(request.ChainPEM), []byte(request.KeyPEM), nil +} + +// serialFromPEM parses the leaf cert PEM and returns the serial number +// formatted to match ACM's DescribeCertificate response shape (uppercase +// hex with colon separators, e.g. "ab:cd:01"). ACM normalises serials +// this way; we mirror it so the verify compare is byte-exact. +func serialFromPEM(certPEM []byte) (string, error) { + block, _ := pem.Decode(certPEM) + if block == nil { + return "", fmt.Errorf("failed to decode cert PEM") + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return "", fmt.Errorf("failed to parse cert: %w", err) + } + hex := fmt.Sprintf("%x", cert.SerialNumber) + if len(hex)%2 == 1 { + hex = "0" + hex + } + var b strings.Builder + for i := 0; i < len(hex); i += 2 { + if i > 0 { + b.WriteByte(':') + } + b.WriteString(hex[i : i+2]) + } + return b.String(), nil +} + +// serialsEqual compares two serial strings case-insensitively and +// strips colon separators. Defends against ACM occasionally returning +// serials in slightly different formats across SDK versions. +func serialsEqual(a, b string) bool { + norm := func(s string) string { + return strings.ToLower(strings.ReplaceAll(s, ":", "")) + } + return norm(a) == norm(b) +} + +// metadataCertID extracts the certctl-managed certificate ID from the +// deployment request's Metadata map. The renewal scheduler populates +// this with the source-of-truth managed-cert row's ID; the connector +// stamps it as the certctl-certificate-id provenance tag. +func metadataCertID(metadata map[string]string) string { + if v, ok := metadata["certificate_id"]; ok { + return v + } + if v, ok := metadata["certctl_certificate_id"]; ok { + return v + } + return "" +} + +// Compile-time assertion: *Connector implements target.Connector and +// *sdkClient implements ACMClient. Catches interface drift at build +// time rather than at first deploy. +var ( + _ target.Connector = (*Connector)(nil) + _ ACMClient = (*sdkClient)(nil) +) diff --git a/internal/connector/target/awsacm/awsacm_failure_test.go b/internal/connector/target/awsacm/awsacm_failure_test.go new file mode 100644 index 0000000..a35b744 --- /dev/null +++ b/internal/connector/target/awsacm/awsacm_failure_test.go @@ -0,0 +1,227 @@ +package awsacm_test + +// Rank 5 of the 2026-05-03 Infisical deep-research deliverable +// (cowork/infisical-deep-research-results.md Part 5). Per-error-class +// failure tests for the AWS ACM target connector — mirrors the +// awsacmpca_failure_test.go shape (commit 60dce0b) on the issuer side. +// +// Each test injects one specific AWS SDK v2 typed error via the +// mockACMClient seam, calls DeployCertificate, and asserts: +// +// 1. error non-nil, +// 2. errors.As against the SDK's typed error value succeeds (so the +// wrap chain via fmt.Errorf("...%w", ...) is intact and upstream +// retry / classification logic can introspect the typed value), +// 3. operator-actionable substring is present in the surfaced +// message, +// 4. the failure category is correct (e.g. throttling = retryable; +// validation = terminal). + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + acmtypes "github.com/aws/aws-sdk-go-v2/service/acm/types" + smithy "github.com/aws/smithy-go" + + "github.com/shankar0123/certctl/internal/connector/target" + "github.com/shankar0123/certctl/internal/connector/target/awsacm" +) + +// TestAWSACM_Issue_AccessDenied_OperatorActionableError pins the +// behaviour when the IAM principal lacks acm:ImportCertificate. AWS +// surfaces this as a smithy APIError with Code="AccessDeniedException" +// (the ACM SDK does not generate a typed *types.AccessDeniedException +// in v1.38.x — read it locally to confirm). +func TestAWSACM_Issue_AccessDenied_OperatorActionableError(t *testing.T) { + ctx := context.Background() + certPEM, keyPEM, _ := generateTestCert(t, "denied.example.com") + + sdkErr := &smithy.GenericAPIError{ + Code: "AccessDeniedException", + Message: "User: arn:aws:iam::123456789012:user/ci is not authorized to perform: acm:ImportCertificate", + Fault: smithy.FaultClient, + } + mock := &mockACMClient{importErr: sdkErr} + c := awsacm.NewWithClient(&awsacm.Config{Region: "us-east-1"}, mock, quietTestLogger()) + + _, err := c.DeployCertificate(ctx, target.DeploymentRequest{ + CertPEM: certPEM, + KeyPEM: keyPEM, + Metadata: map[string]string{"certificate_id": "mc-denied"}, + }) + if err == nil { + t.Fatal("expected access-denied error, got nil") + } + + var sdk *smithy.GenericAPIError + if !errors.As(err, &sdk) { + t.Fatalf("wrap chain broke — errors.As against *smithy.GenericAPIError failed; err=%v", err) + } + if sdk.ErrorCode() != "AccessDeniedException" { + t.Errorf("expected ErrorCode=AccessDeniedException, got %q", sdk.ErrorCode()) + } + msg := err.Error() + if !strings.Contains(msg, "AccessDenied") { + t.Errorf("operator-actionable substring missing — message must mention AccessDenied; got: %s", msg) + } + if !strings.Contains(msg, "ImportCertificate failed") { + t.Errorf("connector wrap missing — expected 'ImportCertificate failed: ...' framing; got: %s", msg) + } +} + +// TestAWSACM_Issue_ResourceNotFound_NamesTheMissingARN pins behaviour +// when the configured CertificateArn doesn't exist (deleted out-of- +// band, typo'd config, wrong region). The SDK's +// *types.ResourceNotFoundException carries the ARN in its message; the +// connector must preserve the ARN through the wrap chain so the +// operator can identify which resource was missing. +func TestAWSACM_Issue_ResourceNotFound_NamesTheMissingARN(t *testing.T) { + ctx := context.Background() + certPEM, keyPEM, _ := generateTestCert(t, "missing.example.com") + + missingArn := "arn:aws:acm:us-east-1:123456789012:certificate/deadbeef-dead-beef-dead-beefdeadbeef" + sdkErr := &acmtypes.ResourceNotFoundException{ + Message: aws.String("Could not find certificate " + missingArn), + } + // We need the snapshot read to fail — the connector calls + // GetCertificate first to capture the snapshot. ResourceNotFound at + // snapshot time means "no cert at this ARN," so we surface that + // error and bail before the import. + mock := &mockACMClient{getErr: sdkErr} + cfg := &awsacm.Config{Region: "us-east-1", CertificateArn: missingArn} + c := awsacm.NewWithClient(cfg, mock, quietTestLogger()) + + _, err := c.DeployCertificate(ctx, target.DeploymentRequest{ + CertPEM: certPEM, + KeyPEM: keyPEM, + }) + if err == nil { + t.Fatal("expected resource-not-found error") + } + var sdk *acmtypes.ResourceNotFoundException + if !errors.As(err, &sdk) { + t.Fatalf("wrap chain broke — errors.As against *types.ResourceNotFoundException failed; err=%v", err) + } + msg := err.Error() + if !strings.Contains(msg, missingArn) { + t.Errorf("operator-actionable substring missing — message must name the missing ARN %q; got: %s", missingArn, msg) + } + if !strings.Contains(msg, "snapshot") { + t.Errorf("expected 'snapshot' framing on pre-deploy snapshot failure; got: %s", msg) + } +} + +// TestAWSACM_Issue_Throttling_RetryableSurfacePreserved pins the +// behaviour when ACM throttles a burst of imports (renewal storm, +// bulk migration). Real traffic surfaces ThrottlingException via +// *smithy.GenericAPIError; the connector must preserve the typed +// value + Fault classification so any upstream retry layer can engage. +// Per the spec's "no new retry logic" scope, the connector itself +// does not retry; it surfaces the typed error. +func TestAWSACM_Issue_Throttling_RetryableSurfacePreserved(t *testing.T) { + ctx := context.Background() + certPEM, keyPEM, _ := generateTestCert(t, "throttle.example.com") + + sdkErr := &smithy.GenericAPIError{ + Code: "ThrottlingException", + Message: "Rate exceeded", + Fault: smithy.FaultServer, + } + mock := &mockACMClient{importErr: sdkErr} + c := awsacm.NewWithClient(&awsacm.Config{Region: "us-east-1"}, mock, quietTestLogger()) + + _, err := c.DeployCertificate(ctx, target.DeploymentRequest{ + CertPEM: certPEM, + KeyPEM: keyPEM, + Metadata: map[string]string{"certificate_id": "mc-throttle"}, + }) + if err == nil { + t.Fatal("expected throttle error") + } + var sdk *smithy.GenericAPIError + if !errors.As(err, &sdk) { + t.Fatalf("wrap chain broke; err=%v", err) + } + if sdk.ErrorCode() != "ThrottlingException" { + t.Errorf("expected ErrorCode=ThrottlingException, got %q", sdk.ErrorCode()) + } + if sdk.ErrorFault() != smithy.FaultServer { + t.Errorf("expected FaultServer (retryable class) preserved; got %v", sdk.ErrorFault()) + } + if !strings.Contains(err.Error(), "Throttling") { + t.Errorf("operator-actionable substring missing — message must mention Throttling; got: %s", err.Error()) + } +} + +// TestAWSACM_Issue_InvalidArgs_TerminalNotRetryable pins behaviour +// when ACM rejects the cert+key as malformed (mismatched key/cert, +// unsupported algorithm). InvalidArgsException is a terminal class — +// operators must fix the inputs, not retry. +func TestAWSACM_Issue_InvalidArgs_TerminalNotRetryable(t *testing.T) { + ctx := context.Background() + certPEM, keyPEM, _ := generateTestCert(t, "invalid.example.com") + + sdkErr := &acmtypes.InvalidArgsException{ + Message: aws.String("The certificate body is invalid: chain not bound to leaf"), + } + mock := &mockACMClient{importErr: sdkErr} + c := awsacm.NewWithClient(&awsacm.Config{Region: "us-east-1"}, mock, quietTestLogger()) + + _, err := c.DeployCertificate(ctx, target.DeploymentRequest{ + CertPEM: certPEM, + KeyPEM: keyPEM, + Metadata: map[string]string{"certificate_id": "mc-invalid"}, + }) + if err == nil { + t.Fatal("expected invalid-args error") + } + var sdk *acmtypes.InvalidArgsException + if !errors.As(err, &sdk) { + t.Fatalf("wrap chain broke; err=%v", err) + } + if !strings.Contains(err.Error(), "chain not bound") { + t.Errorf("operator-actionable substring missing — message must name the validation issue; got: %s", err.Error()) + } + if !strings.Contains(err.Error(), "InvalidArgs") { + t.Errorf("expected InvalidArgs in surfaced message; got: %s", err.Error()) + } +} + +// TestAWSACM_Issue_RequestInProgress_TerminalForCurrentAttempt pins +// behaviour when ACM reports an in-flight request for the same +// idempotency key. RequestInProgressException IS a generated typed +// value; the connector must surface it cleanly so upstream logic can +// decide whether to wait + retry or fail-fast. +func TestAWSACM_Issue_RequestInProgress_TerminalForCurrentAttempt(t *testing.T) { + ctx := context.Background() + certPEM, keyPEM, _ := generateTestCert(t, "inprogress.example.com") + + sdkErr := &acmtypes.RequestInProgressException{ + Message: aws.String("The certificate request is already being processed; resubmit after completion"), + } + mock := &mockACMClient{importErr: sdkErr} + c := awsacm.NewWithClient(&awsacm.Config{Region: "us-east-1"}, mock, quietTestLogger()) + + _, err := c.DeployCertificate(ctx, target.DeploymentRequest{ + CertPEM: certPEM, + KeyPEM: keyPEM, + Metadata: map[string]string{"certificate_id": "mc-inprogress"}, + }) + if err == nil { + t.Fatal("expected request-in-progress error") + } + var sdk *acmtypes.RequestInProgressException + if !errors.As(err, &sdk) { + t.Fatalf("wrap chain broke; err=%v", err) + } + if !strings.Contains(err.Error(), "RequestInProgress") { + t.Errorf("expected RequestInProgress in surfaced message; got: %s", err.Error()) + } + if !strings.Contains(err.Error(), "already being processed") { + t.Errorf("operator-actionable substring missing — message must explain the conflict; got: %s", err.Error()) + } +} diff --git a/internal/connector/target/awsacm/awsacm_test.go b/internal/connector/target/awsacm/awsacm_test.go new file mode 100644 index 0000000..5c89184 --- /dev/null +++ b/internal/connector/target/awsacm/awsacm_test.go @@ -0,0 +1,480 @@ +package awsacm_test + +// Rank 5 of the 2026-05-03 Infisical deep-research deliverable +// (cowork/infisical-deep-research-results.md Part 5). Happy-path table- +// driven tests for the AWS ACM target connector. Mirrors the +// k8ssecret_test.go ergonomics + the Bundle 5+ atomic-rollback +// assertions from IIS / WinCertStore / JavaKeystore. +// +// Per-error-class failure tests live in awsacm_failure_test.go and +// follow the awsacmpca_failure_test.go shape (commit 60dce0b). + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "log/slog" + "math/big" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/shankar0123/certctl/internal/connector/target" + "github.com/shankar0123/certctl/internal/connector/target/awsacm" +) + +// mockACMClient is the unit-test fake for the ACMClient interface seam. +// Every method records the input it received so tests can assert on +// "did the connector call DescribeCertificate twice for the post-verify +// step?" / "what tags did the second AddTagsToCertificate call use?". +// +// Each method has a corresponding *Err field so a test can inject a +// failure on the specific call it cares about while leaving the others +// healthy. +type mockACMClient struct { + mu sync.Mutex + + // Per-call recording. + importCalls []*awsacm.ImportCertificateInput + getCalls []*awsacm.GetCertificateInput + describeCalls []*awsacm.DescribeCertificateInput + listCalls []*awsacm.ListCertificatesInput + tagCalls []*awsacm.AddTagsToCertificateInput + + // Per-method canned responses + error injection. Each test case + // constructs the mock with whatever shape it needs. + importOutput *awsacm.ImportCertificateOutput + importErr error + getOutput *awsacm.GetCertificateOutput + getErr error + describeOutput *awsacm.DescribeCertificateOutput + describeErr error + listOutput *awsacm.ListCertificatesOutput + listErr error + tagErr error + + // rollbackHook lets tests inject a different importErr on the + // SECOND ImportCertificate call (the rollback path) so the + // "rollback also fails" branch can be exercised independently of + // the first-import path. nil disables the hook. + rollbackImportErr error +} + +func (m *mockACMClient) ImportCertificate(ctx context.Context, in *awsacm.ImportCertificateInput) (*awsacm.ImportCertificateOutput, error) { + m.mu.Lock() + defer m.mu.Unlock() + m.importCalls = append(m.importCalls, in) + if len(m.importCalls) > 1 && m.rollbackImportErr != nil { + return nil, m.rollbackImportErr + } + if m.importErr != nil { + return nil, m.importErr + } + if m.importOutput != nil { + return m.importOutput, nil + } + // Default success path: echo the ARN if the caller supplied one, + // otherwise synthesise a fresh ARN deterministically so tests can + // assert on it. + arn := in.CertificateArn + if arn == "" { + arn = "arn:aws:acm:us-east-1:123456789012:certificate/abcdef01-2345-6789-abcd-ef0123456789" + } + return &awsacm.ImportCertificateOutput{CertificateArn: arn}, nil +} + +func (m *mockACMClient) GetCertificate(ctx context.Context, in *awsacm.GetCertificateInput) (*awsacm.GetCertificateOutput, error) { + m.mu.Lock() + defer m.mu.Unlock() + m.getCalls = append(m.getCalls, in) + if m.getErr != nil { + return nil, m.getErr + } + if m.getOutput != nil { + return m.getOutput, nil + } + return &awsacm.GetCertificateOutput{}, nil +} + +func (m *mockACMClient) DescribeCertificate(ctx context.Context, in *awsacm.DescribeCertificateInput) (*awsacm.DescribeCertificateOutput, error) { + m.mu.Lock() + defer m.mu.Unlock() + m.describeCalls = append(m.describeCalls, in) + if m.describeErr != nil { + return nil, m.describeErr + } + if m.describeOutput != nil { + return m.describeOutput, nil + } + return &awsacm.DescribeCertificateOutput{}, nil +} + +func (m *mockACMClient) ListCertificates(ctx context.Context, in *awsacm.ListCertificatesInput) (*awsacm.ListCertificatesOutput, error) { + m.mu.Lock() + defer m.mu.Unlock() + m.listCalls = append(m.listCalls, in) + if m.listErr != nil { + return nil, m.listErr + } + if m.listOutput != nil { + return m.listOutput, nil + } + return &awsacm.ListCertificatesOutput{}, nil +} + +func (m *mockACMClient) AddTagsToCertificate(ctx context.Context, in *awsacm.AddTagsToCertificateInput) error { + m.mu.Lock() + defer m.mu.Unlock() + m.tagCalls = append(m.tagCalls, in) + return m.tagErr +} + +func quietTestLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError})) +} + +// generateTestCert constructs a self-signed ECDSA P-256 cert for tests. +// Returns (certPEM, keyPEM, serialFormatted) where serialFormatted +// matches the colon-separated lowercase-hex shape ACM emits via +// DescribeCertificate. +func generateTestCert(t *testing.T, cn string) (certPEM, keyPEM, serial string) { + t.Helper() + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + serialNum, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + tmpl := &x509.Certificate{ + SerialNumber: serialNum, + Subject: pkix.Name{CommonName: cn}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv) + if err != nil { + t.Fatalf("create cert: %v", err) + } + certPEM = string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})) + keyDER, err := x509.MarshalECPrivateKey(priv) + if err != nil { + t.Fatalf("marshal key: %v", err) + } + keyPEM = string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})) + + // Format serial as colon-separated lowercase hex (ACM's output shape). + hex := fmt.Sprintf("%x", serialNum) + if len(hex)%2 == 1 { + hex = "0" + hex + } + var b strings.Builder + for i := 0; i < len(hex); i += 2 { + if i > 0 { + b.WriteByte(':') + } + b.WriteString(hex[i : i+2]) + } + serial = b.String() + return +} + +func TestAWSACM_ValidateConfig_Success(t *testing.T) { + ctx := context.Background() + c := awsacm.NewWithClient(nil, &mockACMClient{}, quietTestLogger()) + cfg := awsacm.Config{Region: "us-east-1"} + raw, _ := json.Marshal(cfg) + if err := c.ValidateConfig(ctx, raw); err != nil { + t.Fatalf("ValidateConfig: %v", err) + } +} + +func TestAWSACM_ValidateConfig_MissingRegion(t *testing.T) { + ctx := context.Background() + c := awsacm.NewWithClient(nil, &mockACMClient{}, quietTestLogger()) + raw, _ := json.Marshal(awsacm.Config{}) + err := c.ValidateConfig(ctx, raw) + if err == nil || !strings.Contains(err.Error(), "region is required") { + t.Errorf("expected region-required error, got %v", err) + } +} + +func TestAWSACM_ValidateConfig_MalformedRegion(t *testing.T) { + ctx := context.Background() + c := awsacm.NewWithClient(nil, &mockACMClient{}, quietTestLogger()) + raw, _ := json.Marshal(awsacm.Config{Region: "not-a-region"}) + err := c.ValidateConfig(ctx, raw) + if err == nil || !strings.Contains(err.Error(), "region malformed") { + t.Errorf("expected region-malformed error, got %v", err) + } +} + +func TestAWSACM_ValidateConfig_MalformedARN(t *testing.T) { + ctx := context.Background() + c := awsacm.NewWithClient(nil, &mockACMClient{}, quietTestLogger()) + raw, _ := json.Marshal(awsacm.Config{Region: "us-east-1", CertificateArn: "not-an-arn"}) + err := c.ValidateConfig(ctx, raw) + if err == nil || !strings.Contains(err.Error(), "certificate_arn malformed") { + t.Errorf("expected ARN-malformed error, got %v", err) + } +} + +func TestAWSACM_ValidateConfig_RejectsReservedTags(t *testing.T) { + ctx := context.Background() + c := awsacm.NewWithClient(nil, &mockACMClient{}, quietTestLogger()) + raw, _ := json.Marshal(awsacm.Config{ + Region: "us-east-1", + Tags: map[string]string{"certctl-managed-by": "operator-spoofed"}, + }) + err := c.ValidateConfig(ctx, raw) + if err == nil || !strings.Contains(err.Error(), "reserved provenance key") { + t.Errorf("expected reserved-key rejection, got %v", err) + } +} + +func TestAWSACM_DeployCertificate_FreshImport(t *testing.T) { + ctx := context.Background() + certPEM, keyPEM, serial := generateTestCert(t, "fresh.example.com") + + mock := &mockACMClient{ + describeOutput: &awsacm.DescribeCertificateOutput{Serial: serial, Status: "ISSUED"}, + } + cfg := &awsacm.Config{Region: "us-east-1"} + c := awsacm.NewWithClient(cfg, mock, quietTestLogger()) + + res, err := c.DeployCertificate(ctx, target.DeploymentRequest{ + CertPEM: certPEM, + KeyPEM: keyPEM, + ChainPEM: "", + Metadata: map[string]string{"certificate_id": "mc-fresh"}, + }) + if err != nil { + t.Fatalf("DeployCertificate: %v", err) + } + if !res.Success { + t.Errorf("expected Success=true") + } + if res.TargetAddress == "" { + t.Errorf("expected TargetAddress (ARN) populated") + } + if len(mock.importCalls) != 1 { + t.Errorf("expected exactly 1 ImportCertificate call (fresh import), got %d", len(mock.importCalls)) + } + // Fresh import: tags MUST be supplied on the ImportCertificate call + // (ACM strips them on re-import; this is the only window). + if len(mock.importCalls[0].Tags) < 2 { + t.Errorf("expected provenance tags (managed-by + cert-id) on fresh import; got %d tags", len(mock.importCalls[0].Tags)) + } + // No AddTagsToCertificate on fresh import. + if len(mock.tagCalls) != 0 { + t.Errorf("fresh import should not call AddTagsToCertificate, got %d", len(mock.tagCalls)) + } +} + +func TestAWSACM_DeployCertificate_RotateInPlace(t *testing.T) { + ctx := context.Background() + certPEM, keyPEM, serial := generateTestCert(t, "rotate.example.com") + + existingARN := "arn:aws:acm:us-east-1:123456789012:certificate/00000000-1111-2222-3333-444444444444" + mock := &mockACMClient{ + // Pre-deploy snapshot returns the previous cert bytes (we use + // a random self-signed for the snapshot). + getOutput: &awsacm.GetCertificateOutput{ + Certificate: []byte("snapshot-cert-pem"), + }, + describeOutput: &awsacm.DescribeCertificateOutput{Serial: serial, Status: "ISSUED"}, + } + cfg := &awsacm.Config{Region: "us-east-1", CertificateArn: existingARN} + c := awsacm.NewWithClient(cfg, mock, quietTestLogger()) + + res, err := c.DeployCertificate(ctx, target.DeploymentRequest{ + CertPEM: certPEM, + KeyPEM: keyPEM, + Metadata: map[string]string{"certificate_id": "mc-rotate"}, + }) + if err != nil { + t.Fatalf("DeployCertificate: %v", err) + } + if res.TargetAddress != existingARN { + t.Errorf("rotate-in-place should preserve ARN; expected %s, got %s", existingARN, res.TargetAddress) + } + // Rotate-in-place: snapshot read happens before import. + if len(mock.getCalls) != 1 { + t.Errorf("expected pre-deploy GetCertificate; got %d calls", len(mock.getCalls)) + } + // Tags re-applied via AddTagsToCertificate (re-import strips them). + if len(mock.tagCalls) != 1 { + t.Errorf("rotate-in-place should AddTagsToCertificate once; got %d", len(mock.tagCalls)) + } + // Import call MUST carry the existing ARN. + if mock.importCalls[0].CertificateArn != existingARN { + t.Errorf("rotate import should target existing ARN; got %q", mock.importCalls[0].CertificateArn) + } +} + +func TestAWSACM_DeployCertificate_RollbackOnSerialMismatch(t *testing.T) { + ctx := context.Background() + certPEM, keyPEM, _ := generateTestCert(t, "mismatch.example.com") + + existingARN := "arn:aws:acm:us-east-1:123456789012:certificate/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + mock := &mockACMClient{ + getOutput: &awsacm.GetCertificateOutput{Certificate: []byte("snapshot-bytes")}, + // Post-verify returns a DIFFERENT serial than the imported cert + // — triggers rollback. + describeOutput: &awsacm.DescribeCertificateOutput{ + Serial: "ff:ff:ff:ff", // intentionally wrong + }, + } + cfg := &awsacm.Config{Region: "us-east-1", CertificateArn: existingARN} + c := awsacm.NewWithClient(cfg, mock, quietTestLogger()) + + _, err := c.DeployCertificate(ctx, target.DeploymentRequest{ + CertPEM: certPEM, + KeyPEM: keyPEM, + Metadata: map[string]string{"certificate_id": "mc-mismatch"}, + }) + if err == nil { + t.Fatal("expected error on serial mismatch") + } + if !strings.Contains(err.Error(), "rolled back") { + t.Errorf("expected error to mention rollback; got %v", err) + } + // Two ImportCertificate calls: the failing import + the rollback + // re-import with snapshot bytes. + if len(mock.importCalls) != 2 { + t.Errorf("expected 2 ImportCertificate calls (initial + rollback); got %d", len(mock.importCalls)) + } + // The second import MUST carry the snapshot bytes. + if string(mock.importCalls[1].Certificate) != "snapshot-bytes" { + t.Errorf("rollback should re-import snapshot bytes; got %q", string(mock.importCalls[1].Certificate)) + } +} + +func TestAWSACM_DeployCertificate_RollbackAlsoFails(t *testing.T) { + ctx := context.Background() + certPEM, keyPEM, _ := generateTestCert(t, "twofail.example.com") + + existingARN := "arn:aws:acm:us-east-1:123456789012:certificate/cccccccc-dddd-eeee-ffff-000000000000" + mock := &mockACMClient{ + getOutput: &awsacm.GetCertificateOutput{Certificate: []byte("snapshot")}, + describeOutput: &awsacm.DescribeCertificateOutput{Serial: "00:00"}, + rollbackImportErr: errors.New("simulated rollback ImportCertificate failure"), + } + cfg := &awsacm.Config{Region: "us-east-1", CertificateArn: existingARN} + c := awsacm.NewWithClient(cfg, mock, quietTestLogger()) + + _, err := c.DeployCertificate(ctx, target.DeploymentRequest{ + CertPEM: certPEM, + KeyPEM: keyPEM, + Metadata: map[string]string{"certificate_id": "mc-twofail"}, + }) + if err == nil { + t.Fatal("expected error on rollback failure") + } + // We still surfaced the original mismatch error to the caller; the + // rollback failure logs but doesn't change the surfaced error + // shape. Ensure we attempted the rollback (2 import calls). + if len(mock.importCalls) != 2 { + t.Errorf("expected 2 import calls including failed rollback; got %d", len(mock.importCalls)) + } +} + +func TestAWSACM_DeployCertificate_EmptyKeyPEM(t *testing.T) { + ctx := context.Background() + certPEM, _, _ := generateTestCert(t, "nokey.example.com") + cfg := &awsacm.Config{Region: "us-east-1"} + c := awsacm.NewWithClient(cfg, &mockACMClient{}, quietTestLogger()) + + _, err := c.DeployCertificate(ctx, target.DeploymentRequest{ + CertPEM: certPEM, + KeyPEM: "", + Metadata: map[string]string{"certificate_id": "mc-nokey"}, + }) + if err == nil || !strings.Contains(err.Error(), "key_pem is required") { + t.Errorf("expected key-required error, got %v", err) + } +} + +func TestAWSACM_DeployCertificate_NoClient(t *testing.T) { + ctx := context.Background() + cfg := &awsacm.Config{Region: "us-east-1"} + c := awsacm.NewWithClient(cfg, nil, quietTestLogger()) + _, err := c.DeployCertificate(ctx, target.DeploymentRequest{CertPEM: "x", KeyPEM: "y"}) + if err == nil || !strings.Contains(err.Error(), "client not initialized") { + t.Errorf("expected client-not-initialized; got %v", err) + } +} + +func TestAWSACM_ValidateOnly_NotSupported(t *testing.T) { + ctx := context.Background() + c := awsacm.NewWithClient(&awsacm.Config{Region: "us-east-1"}, &mockACMClient{}, quietTestLogger()) + err := c.ValidateOnly(ctx, target.DeploymentRequest{}) + if !errors.Is(err, target.ErrValidateOnlyNotSupported) { + t.Errorf("expected ErrValidateOnlyNotSupported, got %v", err) + } +} + +func TestAWSACM_ValidateDeployment_SerialMatch(t *testing.T) { + ctx := context.Background() + mock := &mockACMClient{ + describeOutput: &awsacm.DescribeCertificateOutput{Serial: "ab:cd:01"}, + } + arn := "arn:aws:acm:us-east-1:123456789012:certificate/11111111-2222-3333-4444-555555555555" + cfg := &awsacm.Config{Region: "us-east-1", CertificateArn: arn} + c := awsacm.NewWithClient(cfg, mock, quietTestLogger()) + + res, err := c.ValidateDeployment(ctx, target.ValidationRequest{Serial: "ab:cd:01"}) + if err != nil { + t.Fatalf("ValidateDeployment: %v", err) + } + if !res.Valid { + t.Errorf("expected Valid=true, got %+v", res) + } + if res.TargetAddress != arn { + t.Errorf("expected TargetAddress=%s, got %s", arn, res.TargetAddress) + } +} + +func TestAWSACM_ValidateDeployment_SerialMismatch(t *testing.T) { + ctx := context.Background() + mock := &mockACMClient{ + describeOutput: &awsacm.DescribeCertificateOutput{Serial: "00:00"}, + } + arn := "arn:aws:acm:us-east-1:123456789012:certificate/22222222-3333-4444-5555-666666666666" + cfg := &awsacm.Config{Region: "us-east-1", CertificateArn: arn} + c := awsacm.NewWithClient(cfg, mock, quietTestLogger()) + + res, err := c.ValidateDeployment(ctx, target.ValidationRequest{Serial: "ab:cd:01"}) + if err != nil { + t.Fatalf("ValidateDeployment unexpected error: %v", err) + } + if res.Valid { + t.Errorf("expected Valid=false on serial mismatch, got %+v", res) + } +} + +func TestAWSACM_ValidateDeployment_NoARNYet(t *testing.T) { + ctx := context.Background() + cfg := &awsacm.Config{Region: "us-east-1"} + c := awsacm.NewWithClient(cfg, &mockACMClient{}, quietTestLogger()) + res, err := c.ValidateDeployment(ctx, target.ValidationRequest{Serial: "ab:cd"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res.Valid { + t.Errorf("expected Valid=false when ARN not yet known") + } + if !strings.Contains(res.Message, "ARN not yet known") { + t.Errorf("expected ARN-not-yet-known message; got %q", res.Message) + } +} diff --git a/internal/domain/connector.go b/internal/domain/connector.go index 7dfb37a..27afbdf 100644 --- a/internal/domain/connector.go +++ b/internal/domain/connector.go @@ -212,4 +212,13 @@ const ( TargetTypeWinCertStore TargetType = "WinCertStore" TargetTypeJavaKeystore TargetType = "JavaKeystore" TargetTypeKubernetesSecrets TargetType = "KubernetesSecrets" + // TargetTypeAWSACM deploys certificates to AWS Certificate Manager + // (ACM) — the public AWS service that ALB / CloudFront / API + // Gateway / App Runner consume by ARN. Rank 5 of the 2026-05-03 + // Infisical deep-research deliverable + // (cowork/infisical-deep-research-results.md Part 5). See + // docs/connectors.md "AWS Certificate Manager" section for the + // operator playbook including minimum IAM policy + atomic-rollback + // contract. + TargetTypeAWSACM TargetType = "AWSACM" ) diff --git a/internal/service/target.go b/internal/service/target.go index e42a4b0..c62532a 100644 --- a/internal/service/target.go +++ b/internal/service/target.go @@ -36,6 +36,7 @@ var validTargetTypes = map[domain.TargetType]bool{ domain.TargetTypeWinCertStore: true, domain.TargetTypeJavaKeystore: true, domain.TargetTypeKubernetesSecrets: true, + domain.TargetTypeAWSACM: true, } // isValidTargetType checks if a type string is a known target type.