mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 15:01:32 +00:00
14fcc82cda
Closes Rank 5 (Azure 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 Azure-managed TLS- termination endpoints (Application Gateway / Front Door / App Service / Container Apps) — operators terminating TLS at Azure had to use manual `az keyvault certificate import` invocations or external automation. This commit lands the SDK-driven Azure Key Vault target connector that closes the gap, mirroring the AWS ACM target shape shipped in commit54033aa. Architecture: - internal/connector/target/azurekv/azurekv.go — Connector wraps *azcertificates.Client behind the KeyVaultClient interface seam (mirrors awsacm's ACMClient + awsacmpca's ACMPCAClient). Lives in azurekv.go alongside the PFX (PKCS#12) wrapping helper that bundles the operator-supplied PEM cert + chain + key into the base64-PFX wire format azcertificates.ImportCertificate accepts. - internal/connector/target/azurekv/sdk_client.go — SDK-loading code isolated so the test path (NewWithClient) compiles without pulling azcore + azidentity transitive deps into the test binary. DefaultAzureCredential / ManagedIdentityCredential / EnvironmentCredential / WorkloadIdentityCredential selected via Config.CredentialMode (closed enum). - Pre-deploy snapshot via GetCertificate(name, "" /* latest */) so on-import-failure rollback restores the previous cert. Mirrors Bundle 5+. The Azure-specific quirk: rollback creates a NEW VERSION (Key Vault doesn't support version-restore without soft-delete recovery, which we keep off the minimum-RBAC surface). Operators reading audit dashboards see e.g. v1=initial, v2=failed-renewal, v3=rollback-of-v2; the certctl-managed-by + certctl-certificate-id provenance tags + future certctl-rollback-of metadata tag let an operator filter rollback artifacts. - Provenance tags identical to AWS ACM (certctl-managed-by=certctl + certctl-certificate-id=<mc-id>), automatically applied on every import. Key Vault carries tags forward across versions (unlike ACM which strips on re-import), so no separate AddTags call is required. - DeploymentRequest.KeyPEM held in agent memory only; PFX wrapping happens in-memory via software.sslmate.com/src/go-pkcs12. No disk write. Tests: - azurekv_test.go: 13-subtest happy-path + validation matrix — ValidateConfig (success / missing-vault-url / malformed-vault- url / missing-cert-name / invalid-credential-mode / reserved- tag rejection), DeployCertificate (fresh import / rollback-on- serial-mismatch / empty-key-rejected / no-client-rejected / SDK-error-surfaced), ValidateOnly (returns sentinel), ValidateDeployment (serial match / mismatch). - All tests use the NewWithClient injection seam; no real-Azure API calls. - go test -short -count=1 ./internal/connector/target/azurekv/... green. Wiring: - internal/domain/connector.go: TargetTypeAzureKeyVault = "AzureKeyVault". - internal/service/target.go: validTargetTypes set extended. - cmd/agent/main.go::createTargetConnector: AzureKeyVault case arm mirroring the AWSACM shape exactly. - cmd/agent/agent_test.go::TestCreateTargetConnector_AllSupported Types: AzureKeyVault added to the type matrix + the InvalidJSON matrix (16 supported target types now, up from 15). go.mod / go.sum: - github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 (direct). - github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 (direct). - github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/ azcertificates v1.4.0 (direct). The deprecated /keyvault/azcertificates path appears as a transitive indirect via Microsoft's microsoft-authentication-library-for-go; we use the new /security/keyvault/ path exclusively. Documentation: - docs/connectors.md "Azure Key Vault" section: config table, RBAC role recipe (off-the-shelf "Key Vault Certificates Officer" or custom role with 3 data-plane actions), AKS workload-identity / managed-identity / service-principal / default credential recipes, atomic-rollback contract + Azure-version semantics explanation, soft-delete caveat, App Gateway / Front Door Terraform attachment snippet, threat model carve-outs (no disk writes, mandatory provenance tags, no long-lived secrets in Config), 5-bullet procurement checklist crib. Out of scope (intentional, flagged in V3-Pro forward path): - Azure Front Door direct-attach (UpdateRoutingConfig — different Azure RBAC scope). - App Gateway / App Service auto-bind (V3-Pro auto-attach). - Soft-delete recovery (acm:RecoverDeletedCertificate-equivalent requires extra RBAC; V2 keeps minimum-permission surface). - GCP Certificate Manager (separate cloud, separate connector). Verified locally: - gofmt clean. - go vet ./internal/connector/target/azurekv/... ./internal/domain/... ./internal/service/... ./cmd/agent/... clean. - go test -short -count=1 ./internal/connector/target/azurekv/... ./cmd/agent/... green (all 16 supported target types instantiate via the agent factory). Reference: cowork/infisical-deep-research-results.md Part 5 Rank 5. Acquisition prompt: cowork/rank-5-aws-acm-azure-kv-target-adapters-prompt.md. Companion commit (AWS half):54033aa.
203 lines
6.2 KiB
Go
203 lines
6.2 KiB
Go
package azurekv
|
|
|
|
// sdk_client.go isolates the imports of github.com/Azure/azure-sdk-for-go/
|
|
// sdk/azidentity + sdk/security/keyvault/azcertificates so that
|
|
// NewWithClient (the test path) compiles without dragging the SDK
|
|
// transitive deps into test binaries.
|
|
//
|
|
// The production New() path is the only caller of buildSDKClient.
|
|
|
|
import (
|
|
"context"
|
|
"crypto/x509"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
|
|
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
|
|
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
|
|
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azcertificates"
|
|
)
|
|
|
|
// sdkClient is the production KeyVaultClient implementation backed by
|
|
// *azcertificates.Client. Each method translates between the local
|
|
// ImportCertificateInput / GetCertificateOutput / etc. shapes and the
|
|
// SDK-typed equivalents.
|
|
type sdkClient struct {
|
|
client *azcertificates.Client
|
|
}
|
|
|
|
// buildSDKClient constructs an *azcertificates.Client wrapped in
|
|
// sdkClient. The credential chain is selected by credMode:
|
|
//
|
|
// "" / "default" — DefaultAzureCredential
|
|
// "managed_identity" — ManagedIdentityCredential
|
|
// "client_secret" — ClientSecretCredential (env vars only)
|
|
// "workload_identity" — WorkloadIdentityCredential
|
|
//
|
|
// Any error from credential construction or client init bubbles up
|
|
// to the caller (typically ValidateConfig or New).
|
|
func buildSDKClient(ctx context.Context, vaultURL, credMode string) (KeyVaultClient, error) {
|
|
cred, err := buildCredential(credMode)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Azure credential init: %w", err)
|
|
}
|
|
|
|
clientOpts := &azcertificates.ClientOptions{
|
|
ClientOptions: azcore.ClientOptions{
|
|
Transport: &http.Client{Timeout: 30 * time.Second},
|
|
Retry: policy.RetryOptions{
|
|
MaxRetries: 3,
|
|
},
|
|
},
|
|
}
|
|
client, err := azcertificates.NewClient(vaultURL, cred, clientOpts)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("azcertificates.NewClient: %w", err)
|
|
}
|
|
return &sdkClient{client: client}, nil
|
|
}
|
|
|
|
func buildCredential(credMode string) (azcore.TokenCredential, error) {
|
|
switch credMode {
|
|
case "", CredModeDefault:
|
|
return azidentity.NewDefaultAzureCredential(nil)
|
|
case CredModeManagedIdentity:
|
|
return azidentity.NewManagedIdentityCredential(nil)
|
|
case CredModeClientSecret:
|
|
return azidentity.NewEnvironmentCredential(nil)
|
|
case CredModeWorkloadIdentity:
|
|
return azidentity.NewWorkloadIdentityCredential(nil)
|
|
default:
|
|
return nil, fmt.Errorf("unsupported credential_mode %q", credMode)
|
|
}
|
|
}
|
|
|
|
func (s *sdkClient) ImportCertificate(ctx context.Context, in *ImportCertificateInput) (*ImportCertificateOutput, error) {
|
|
tagsPtr := make(map[string]*string, len(in.Tags))
|
|
for k, v := range in.Tags {
|
|
v := v // capture
|
|
tagsPtr[k] = &v
|
|
}
|
|
resp, err := s.client.ImportCertificate(ctx, in.CertificateName, azcertificates.ImportCertificateParameters{
|
|
Base64EncodedCertificate: ptrTo(in.PFXBase64),
|
|
Tags: tagsPtr,
|
|
}, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("azcertificates ImportCertificate: %w", err)
|
|
}
|
|
out := &ImportCertificateOutput{}
|
|
if resp.ID != nil {
|
|
out.KID = string(*resp.ID)
|
|
// Version ID is the last path segment: .../certificates/<name>/<version>.
|
|
out.VersionID = lastPathSegment(out.KID)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *sdkClient) GetCertificate(ctx context.Context, in *GetCertificateInput) (*GetCertificateOutput, error) {
|
|
resp, err := s.client.GetCertificate(ctx, in.CertificateName, in.Version, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("azcertificates GetCertificate: %w", err)
|
|
}
|
|
out := &GetCertificateOutput{
|
|
CERBytes: resp.CER,
|
|
}
|
|
if resp.ID != nil {
|
|
out.VersionID = lastPathSegment(string(*resp.ID))
|
|
}
|
|
if resp.Attributes != nil {
|
|
if resp.Attributes.NotBefore != nil {
|
|
out.NotBefore = *resp.Attributes.NotBefore
|
|
}
|
|
if resp.Attributes.Expires != nil {
|
|
out.NotAfter = *resp.Attributes.Expires
|
|
}
|
|
}
|
|
// Parse serial from the CER bytes; Key Vault doesn't expose it
|
|
// directly on the response struct.
|
|
if len(resp.CER) > 0 {
|
|
if cert, parseErr := x509.ParseCertificate(resp.CER); parseErr == nil {
|
|
out.Serial = serialFromX509(cert)
|
|
}
|
|
}
|
|
// X509Thumbprint is also available; we use Serial for parity with
|
|
// the AWS ACM connector's verify path.
|
|
return out, nil
|
|
}
|
|
|
|
func (s *sdkClient) ListVersions(ctx context.Context, in *ListVersionsInput) (*ListVersionsOutput, error) {
|
|
out := &ListVersionsOutput{}
|
|
pager := s.client.NewListCertificatePropertiesVersionsPager(in.CertificateName, nil)
|
|
max := in.MaxItems
|
|
if max == 0 {
|
|
max = 100
|
|
}
|
|
for pager.More() && int32(len(out.Versions)) < max {
|
|
page, err := pager.NextPage(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("azcertificates ListVersions: %w", err)
|
|
}
|
|
for _, v := range page.Value {
|
|
vs := VersionSummary{}
|
|
if v.ID != nil {
|
|
vs.VersionID = lastPathSegment(string(*v.ID))
|
|
}
|
|
if v.Attributes != nil {
|
|
if v.Attributes.NotBefore != nil {
|
|
vs.NotBefore = *v.Attributes.NotBefore
|
|
}
|
|
if v.Attributes.Enabled != nil {
|
|
vs.Enabled = *v.Attributes.Enabled
|
|
}
|
|
}
|
|
out.Versions = append(out.Versions, vs)
|
|
if int32(len(out.Versions)) >= max {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ptrTo is a helper for the SDK's heavy use of *T parameters.
|
|
func ptrTo[T any](v T) *T { return &v }
|
|
|
|
// lastPathSegment returns everything after the final '/' in a URI.
|
|
// Used to extract the Key Vault version ID from a cert KID.
|
|
func lastPathSegment(uri string) string {
|
|
for i := len(uri) - 1; i >= 0; i-- {
|
|
if uri[i] == '/' {
|
|
return uri[i+1:]
|
|
}
|
|
}
|
|
return uri
|
|
}
|
|
|
|
// serialFromX509 formats an x509.Certificate's SerialNumber to match
|
|
// the colon-separated lowercase-hex shape the Azure SDK emits + the
|
|
// AWS ACM connector uses for cross-cloud parity.
|
|
func serialFromX509(cert *x509.Certificate) string {
|
|
hex := fmt.Sprintf("%x", cert.SerialNumber)
|
|
if len(hex)%2 == 1 {
|
|
hex = "0" + hex
|
|
}
|
|
out := make([]byte, 0, len(hex)+(len(hex)/2)-1)
|
|
for i := 0; i < len(hex); i += 2 {
|
|
if i > 0 {
|
|
out = append(out, ':')
|
|
}
|
|
out = append(out, hex[i], hex[i+1])
|
|
}
|
|
return string(out)
|
|
}
|
|
|
|
// Compile-time assertion: *sdkClient implements KeyVaultClient.
|
|
var _ KeyVaultClient = (*sdkClient)(nil)
|
|
|
|
// _ = pem keeps the import stable across refactors that drop and
|
|
// re-add PEM-handling code paths.
|
|
var _ = pem.Decode
|