mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 13:51:36 +00:00
edf6bee7f8
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=<mc-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.
225 lines
10 KiB
Go
225 lines
10 KiB
Go
package domain
|
|
|
|
import (
|
|
"encoding/json"
|
|
"time"
|
|
)
|
|
|
|
// Issuer represents a certificate authority or ACME provider.
|
|
type Issuer struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Type IssuerType `json:"type"`
|
|
Config json.RawMessage `json:"config"`
|
|
EncryptedConfig []byte `json:"-"` // AES-GCM encrypted full config (never exposed via API)
|
|
Enabled bool `json:"enabled"`
|
|
LastTestedAt *time.Time `json:"last_tested_at,omitempty"`
|
|
TestStatus string `json:"test_status,omitempty"`
|
|
Source string `json:"source,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// DeploymentTarget represents a target system where certificates are deployed.
|
|
type DeploymentTarget struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Type TargetType `json:"type"`
|
|
AgentID string `json:"agent_id"`
|
|
Config json.RawMessage `json:"config"`
|
|
EncryptedConfig []byte `json:"-"` // AES-GCM encrypted full config (never exposed via API)
|
|
Enabled bool `json:"enabled"`
|
|
LastTestedAt *time.Time `json:"last_tested_at,omitempty"`
|
|
TestStatus string `json:"test_status,omitempty"`
|
|
Source string `json:"source,omitempty"`
|
|
RetiredAt *time.Time `json:"retired_at,omitempty"` // I-004: soft-retirement timestamp (nil = active)
|
|
RetiredReason *string `json:"retired_reason,omitempty"` // I-004: reason captured at cascade retirement
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// Agent represents an agent running on a target system.
|
|
type Agent struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Hostname string `json:"hostname"`
|
|
Status AgentStatus `json:"status"`
|
|
LastHeartbeatAt *time.Time `json:"last_heartbeat_at,omitempty"`
|
|
RegisteredAt time.Time `json:"registered_at"`
|
|
// APIKeyHash is the SHA-256 of the agent's plaintext API key,
|
|
// populated by service.RegisterAgent (`hashAPIKey(apiKey)`) and
|
|
// consumed by repository.AgentRepository::GetByAPIKey at auth time.
|
|
// It is server-internal: never serialized to clients, never echoed
|
|
// via CLI / MCP / agent registration response, never logged.
|
|
//
|
|
// G-2 (P1): pre-G-2 the field was tagged `json:"api_key_hash"` and
|
|
// shipped on every /api/v1/agents response (cat-s5-apikey_leak). Even
|
|
// SHA-256 should not be shipped to clients — it gives an offline
|
|
// brute-force target if API-key entropy is low (certctl doesn't enforce
|
|
// a minimum on operator-supplied keys), and there is no business reason
|
|
// for any client to ever receive it. Post-G-2 the JSON tag is "-" and
|
|
// Agent.MarshalJSON below zeroes the field on a copy before delegating
|
|
// to the default marshal — defense in depth so a future tag-revert by
|
|
// refactor cannot reopen the leak. The DB column, repo SELECT/INSERT/
|
|
// UPDATE paths, and service-side hashing are unchanged. See
|
|
// docs/architecture.md ER diagram (which documents DB shape, not API
|
|
// shape) and coverage-gap-audit-2026-04-24-v5/unified-audit.md
|
|
// cat-s5-apikey_leak for the full closure rationale.
|
|
APIKeyHash string `json:"-"`
|
|
OS string `json:"os"`
|
|
Architecture string `json:"architecture"`
|
|
IPAddress string `json:"ip_address"`
|
|
Version string `json:"version"`
|
|
// I-004: soft-retirement fields. An agent with RetiredAt != nil is the
|
|
// canonical "retired" state. The Status column remains as before (Online
|
|
// / Offline / Degraded) and is preserved at retirement time as the
|
|
// last-seen operational status; RetiredAt is the source of truth for
|
|
// "should we filter this row from active listings?".
|
|
RetiredAt *time.Time `json:"retired_at,omitempty"`
|
|
RetiredReason *string `json:"retired_reason,omitempty"`
|
|
}
|
|
|
|
// MarshalJSON implements json.Marshaler. It explicitly zeros APIKeyHash
|
|
// before serialization to defense-in-depth the `json:"-"` tag above.
|
|
//
|
|
// G-2 (P1): pre-G-2 the field was tagged `json:"api_key_hash"` and
|
|
// shipped on every /api/v1/agents response (cat-s5-apikey_leak). Post-G-2
|
|
// the tag is "-" and this method enforces redaction even if the tag is
|
|
// reverted by a future refactor — the receiver is by-value so the
|
|
// APIKeyHash = "" assignment mutates only the marshal-time copy, never
|
|
// the caller's original. The type-alias trick (`type alias Agent`)
|
|
// breaks the recursive MarshalJSON call that would otherwise stack-
|
|
// overflow. Both *Agent and Agent receivers route through here because
|
|
// the json package looks the method up via reflect.Value, and a value
|
|
// receiver satisfies both kinds of pointer.
|
|
//
|
|
// Auditor's note for the next reader: do NOT remove this method even if
|
|
// the json:"-" tag stays. The CI guardrail at .github/workflows/ci.yml
|
|
// also blocks reintroduction at the tag site, but this method is the
|
|
// last line of defense for serialization paths that bypass struct tags
|
|
// (e.g., a future MarshalJSON on a parent struct that embeds Agent).
|
|
func (a Agent) MarshalJSON() ([]byte, error) {
|
|
type alias Agent // breaks recursion: alias has no MarshalJSON method
|
|
a.APIKeyHash = ""
|
|
return json.Marshal(alias(a))
|
|
}
|
|
|
|
// IsRetired returns true when this agent has been soft-retired.
|
|
// I-004: callers that iterate active agents (stats dashboard, stale-offline
|
|
// sweeper, handler-facing list) must skip retired rows by default.
|
|
func (a *Agent) IsRetired() bool { return a != nil && a.RetiredAt != nil }
|
|
|
|
// AgentDependencyCounts captures the active downstream rows that would be
|
|
// affected by retiring an agent. Returned by the preflight pass on
|
|
// DELETE /api/v1/agents/{id}. Zero counts mean a clean soft-retire is safe;
|
|
// any non-zero count blocks a default retire with HTTP 409 and requires an
|
|
// explicit ?force=true&reason=... escape hatch from the operator.
|
|
type AgentDependencyCounts struct {
|
|
ActiveTargets int `json:"active_targets"` // deployment_targets.agent_id=id AND retired_at IS NULL
|
|
ActiveCertificates int `json:"active_certificates"` // certificates currently deployed via one of this agent's active targets
|
|
PendingJobs int `json:"pending_jobs"` // jobs.agent_id=id AND status IN (Pending, AwaitingCSR, AwaitingApproval, Running)
|
|
}
|
|
|
|
// HasDependencies reports whether any preflight counter is non-zero.
|
|
func (d AgentDependencyCounts) HasDependencies() bool {
|
|
return d.ActiveTargets > 0 || d.ActiveCertificates > 0 || d.PendingJobs > 0
|
|
}
|
|
|
|
// SentinelAgentIDs enumerates the four reserved agent identities that back
|
|
// non-agent discovery subsystems. These rows are created by cmd/server on
|
|
// startup and retiring them would orphan their subsystem — the network
|
|
// scanner and the three cloud secret-manager sources all key writes to
|
|
// these IDs via service.SentinelAgentID / service.SentinelAWSSecretsMgr /
|
|
// service.SentinelAzureKeyVault / service.SentinelGCPSecretMgr. The four
|
|
// literal IDs below MUST stay in lockstep with those service-package
|
|
// constants (see internal/service/network_scan.go line 23 and
|
|
// internal/service/cloud_discovery.go lines 14-16).
|
|
//
|
|
// The retirement service refuses them unconditionally — even with
|
|
// ?force=true — via ErrAgentIsSentinel. Living here (and not in the
|
|
// service package) lets handler, repository, and scheduler code filter
|
|
// them without importing service and creating a cycle.
|
|
var SentinelAgentIDs = []string{
|
|
"server-scanner",
|
|
"cloud-aws-sm",
|
|
"cloud-azure-kv",
|
|
"cloud-gcp-sm",
|
|
}
|
|
|
|
// IsSentinelAgent reports whether id matches one of the four reserved
|
|
// sentinel agent IDs. A linear scan is fine — the slice is length 4 and
|
|
// the check is rare (only on retirement attempts and sweeper filters).
|
|
func IsSentinelAgent(id string) bool {
|
|
for _, s := range SentinelAgentIDs {
|
|
if s == id {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// AgentMetadata contains runtime metadata reported by agents via heartbeat.
|
|
type AgentMetadata struct {
|
|
OS string `json:"os"`
|
|
Architecture string `json:"architecture"`
|
|
Hostname string `json:"hostname"`
|
|
IPAddress string `json:"ip_address"`
|
|
Version string `json:"version"`
|
|
}
|
|
|
|
// AgentStatus represents the operational status of an agent.
|
|
type AgentStatus string
|
|
|
|
const (
|
|
AgentStatusOnline AgentStatus = "Online"
|
|
AgentStatusOffline AgentStatus = "Offline"
|
|
AgentStatusDegraded AgentStatus = "Degraded"
|
|
)
|
|
|
|
// IssuerType represents the type of certificate authority.
|
|
type IssuerType string
|
|
|
|
const (
|
|
IssuerTypeACME IssuerType = "ACME"
|
|
IssuerTypeGenericCA IssuerType = "GenericCA"
|
|
IssuerTypeStepCA IssuerType = "StepCA"
|
|
IssuerTypeOpenSSL IssuerType = "OpenSSL"
|
|
IssuerTypeVault IssuerType = "VaultPKI"
|
|
IssuerTypeDigiCert IssuerType = "DigiCert"
|
|
IssuerTypeSectigo IssuerType = "Sectigo"
|
|
IssuerTypeGoogleCAS IssuerType = "GoogleCAS"
|
|
IssuerTypeAWSACMPCA IssuerType = "AWSACMPCA"
|
|
IssuerTypeEntrust IssuerType = "Entrust"
|
|
IssuerTypeGlobalSign IssuerType = "GlobalSign"
|
|
IssuerTypeEJBCA IssuerType = "EJBCA"
|
|
)
|
|
|
|
// TargetType represents the type of deployment target.
|
|
type TargetType string
|
|
|
|
const (
|
|
TargetTypeNGINX TargetType = "NGINX"
|
|
TargetTypeApache TargetType = "Apache"
|
|
TargetTypeHAProxy TargetType = "HAProxy"
|
|
TargetTypeF5 TargetType = "F5"
|
|
TargetTypeIIS TargetType = "IIS"
|
|
TargetTypeTraefik TargetType = "Traefik"
|
|
TargetTypeCaddy TargetType = "Caddy"
|
|
TargetTypeEnvoy TargetType = "Envoy"
|
|
TargetTypePostfix TargetType = "Postfix"
|
|
TargetTypeDovecot TargetType = "Dovecot"
|
|
TargetTypeSSH TargetType = "SSH"
|
|
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"
|
|
)
|