target(azurekv): SDK-driven Azure Key Vault target connector

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 commit edf6bee.

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): edf6bee.
This commit is contained in:
shankar0123
2026-05-03 22:43:45 +00:00
parent edf6bee7f8
commit 8a56a78282
10 changed files with 1402 additions and 2 deletions
+13 -1
View File
@@ -831,7 +831,7 @@ func strPtr(s string) *string {
return &s return &s
} }
// TestCreateTargetConnector_AllSupportedTypes tests connector creation for all 15 supported target types. // TestCreateTargetConnector_AllSupportedTypes tests connector creation for all 16 supported target types.
func TestCreateTargetConnector_AllSupportedTypes(t *testing.T) { func TestCreateTargetConnector_AllSupportedTypes(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
@@ -958,6 +958,17 @@ func TestCreateTargetConnector_AllSupportedTypes(t *testing.T) {
"region": "us-east-1", "region": "us-east-1",
}, },
}, },
{
// Rank 5 (Azure half). Vault URL + cert name; the SDK client
// lazy-loads via DefaultAzureCredential which doesn't require
// live creds at construction time.
name: "AzureKeyVault",
typeName: "AzureKeyVault",
config: map[string]string{
"vault_url": "https://test-vault.vault.azure.net",
"certificate_name": "demo-cert",
},
},
} }
cfg := &AgentConfig{ cfg := &AgentConfig{
@@ -1012,6 +1023,7 @@ func TestCreateTargetConnector_InvalidJSON(t *testing.T) {
"JavaKeystore", "JavaKeystore",
"KubernetesSecrets", "KubernetesSecrets",
"AWSACM", "AWSACM",
"AzureKeyVault",
} }
cfg := &AgentConfig{ cfg := &AgentConfig{
+16
View File
@@ -33,6 +33,7 @@ import (
"github.com/shankar0123/certctl/internal/connector/target" "github.com/shankar0123/certctl/internal/connector/target"
"github.com/shankar0123/certctl/internal/connector/target/apache" "github.com/shankar0123/certctl/internal/connector/target/apache"
"github.com/shankar0123/certctl/internal/connector/target/awsacm" "github.com/shankar0123/certctl/internal/connector/target/awsacm"
"github.com/shankar0123/certctl/internal/connector/target/azurekv"
"github.com/shankar0123/certctl/internal/connector/target/caddy" "github.com/shankar0123/certctl/internal/connector/target/caddy"
"github.com/shankar0123/certctl/internal/connector/target/envoy" "github.com/shankar0123/certctl/internal/connector/target/envoy"
"github.com/shankar0123/certctl/internal/connector/target/f5" "github.com/shankar0123/certctl/internal/connector/target/f5"
@@ -915,6 +916,21 @@ func (a *Agent) createTargetConnector(targetType string, configJSON json.RawMess
} }
return awsacm.New(context.Background(), &cfg, a.logger) return awsacm.New(context.Background(), &cfg, a.logger)
case "AzureKeyVault":
// Rank 5 of the 2026-05-03 Infisical deep-research deliverable.
// Azure Key Vault target — SDK-driven (no file I/O).
// DefaultAzureCredential handles the standard Azure credential
// chain (managed identity / workload identity / env vars / az
// CLI fallback). Long-lived service-principal secrets are
// supported but discouraged via the credential_mode config.
var cfg azurekv.Config
if len(configJSON) > 0 {
if err := json.Unmarshal(configJSON, &cfg); err != nil {
return nil, fmt.Errorf("invalid AzureKeyVault config: %w", err)
}
}
return azurekv.New(context.Background(), &cfg, a.logger)
default: default:
return nil, fmt.Errorf("unsupported target type: %s", targetType) return nil, fmt.Errorf("unsupported target type: %s", targetType)
} }
+82
View File
@@ -1504,6 +1504,88 @@ The ARN updates in place across renewals (ACM `ImportCertificate` is upsert-styl
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`). 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`).
### Azure Key Vault
The Azure Key Vault target connector deploys certificates into Azure Key Vault — the Azure-managed cert/secret store that Application Gateway / Front Door / App Service / Container Apps consume by KID URI. Rank 5 (Azure half) of the 2026-05-03 Infisical deep-research deliverable.
```json
{
"vault_url": "https://my-vault.vault.azure.net",
"certificate_name": "api-prod",
"tags": {"env": "production", "app": "api-gateway"},
"credential_mode": "managed_identity"
}
```
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `vault_url` | string | *(required)* | Key Vault DNS endpoint (`https://<vault-name>.vault.azure.net`). For US-Gov: `.vault.usgovcloudapi.net`; for China: `.vault.azure.cn`. |
| `certificate_name` | string | *(required)* | Cert object name in the vault (1-127 chars, alphanumeric + hyphens). Versions are auto-generated per import. |
| `tags` | object | | Tags applied at every import (Key Vault carries tags forward across versions, unlike ACM). Reserved keys `certctl-managed-by` + `certctl-certificate-id` are set automatically. |
| `credential_mode` | string | `default` | One of `default` / `managed_identity` / `client_secret` / `workload_identity`. See "Auth recipes" below. |
**RBAC role (minimum permissions):**
The off-the-shelf builtin role **Key Vault Certificates Officer** covers everything. For minimum-permission deploys, use a custom role with these data-plane operations on the vault scope (`/subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.KeyVault/vaults/<vault-name>`):
```
Microsoft.KeyVault/vaults/certificates/import/action
Microsoft.KeyVault/vaults/certificates/read
Microsoft.KeyVault/vaults/certificates/listversions/read
```
**Auth recipes:**
- **AKS workload identity (`credential_mode: workload_identity`) — recommended for AKS deploys.** Annotate the agent's ServiceAccount with `azure.workload.identity/client-id=<app-id>`. The AKS cluster's OIDC issuer + the federated credential on the app registration handle token exchange; no long-lived secrets.
- **Managed identity (`credential_mode: managed_identity`) — recommended for VM / App Service deploys.** Assign a system-assigned or user-assigned managed identity to the host; certctl-server / agent picks it up via IMDS. Pin `credential_mode` rather than letting `default` fall through to env vars (defends against accidental local-dev creds leaking into production).
- **Service principal (`credential_mode: client_secret`).** Configure `AZURE_TENANT_ID` + `AZURE_CLIENT_ID` + `AZURE_CLIENT_SECRET` env vars on the agent. NOT recommended for production — long-lived client secret risk; rotate via Key Vault soft-delete recovery if leaked.
- **Default (`credential_mode: default` or unset).** SDK's `DefaultAzureCredential` walks env vars → managed identity → Azure CLI fallback. Useful for local-dev where the operator already has `az login` active.
- **Long-lived secrets in connector Config NOT supported** — same procurement-readability rule as AWS ACM.
**Atomic-rollback contract + Azure-version semantics:**
Every `DeployCertificate` snapshots the existing latest version via `GetCertificate(name, "" /* latest */)` BEFORE calling `ImportCertificate`. After import, the connector re-fetches the latest version and compares serial numbers. On serial-mismatch, the connector calls `ImportCertificate` again with the snapshotted CER bytes (re-PFX'd with the operator's key) — **as a NEW VERSION**. Key Vault doesn't support "version-restore" without soft-delete recovery (which we keep off the minimum-RBAC surface). The version history will show e.g. v1=initial, v2=failed-renewal, v3=rollback-of-v2; operators reading audit dashboards filter by tag.
**Soft-delete caveat.** V2 doesn't manage Key Vault soft-delete recovery. If a previous version was soft-deleted out-of-band (e.g. operator ran `az keyvault certificate delete`), the rollback re-imports the snapshot bytes as a new version rather than restoring the soft-deleted version. Operators alerting on rollback frequency should also watch for soft-delete events.
**App Gateway / Front Door attachment recipe:**
```hcl
data "azurerm_key_vault_certificate" "certctl_managed" {
name = "api-prod"
key_vault_id = azurerm_key_vault.main.id
}
resource "azurerm_application_gateway" "main" {
# ...
ssl_certificate {
name = "certctl-managed"
key_vault_secret_id = data.azurerm_key_vault_certificate.certctl_managed.secret_id
}
}
```
Application Gateway / Front Door reference the cert by KID URI; certctl rotates the version under the same name, and the AGW / Front Door reference auto-resolves to the latest version (the SDK's behaviour when the KID points to `/certificates/<name>/<version>` vs `/certificates/<name>` differs — the latter auto-tracks "latest"; the former pins). Pin the version-less KID for auto-tracking renewals.
**Threat model carve-outs:**
- **Cert key bytes never written to disk on the agent.** PFX wrapping happens in memory (PKCS#12 via `software.sslmate.com/src/go-pkcs12`); the base64-encoded PFX is passed straight to the SDK's `ImportCertificate` call.
- **Provenance tags are mandatory.** Same `certctl-managed-by=certctl` + `certctl-certificate-id=<mc-id>` shape as AWS ACM. Operators identifying a stray Key Vault cert match against `certctl-managed-by`.
- **No long-lived Azure credentials in `Config`.** `Config` carries vault URL + cert name + operator tags + credential mode only. Auth is the Azure SDK credential chain.
- **`credential_mode: managed_identity` is the recommended production posture.** Defends against accidental env-var creds leaking into deployments where the host already has a managed identity assigned.
**Procurement checklist crib (paste into security review):**
- certctl uses Azure managed identity (or workload identity for AKS), not long-lived service-principal secrets.
- The cert key is held only in agent memory during the PFX wrap + import call; never written to disk.
- Every imported Key Vault cert is tagged with `certctl-managed-by=certctl` + `certctl-certificate-id=<mc-id>` for forensic traceability.
- Failed imports trigger automatic rollback by re-importing the snapshotted previous version's bytes; both outcomes are surfaced via Prometheus.
- The minimum RBAC role is 3 data-plane actions; Activity Log captures every API call for compliance audits.
**ValidateOnly contract.** Key Vault has no dry-run API; `ValidateOnly` returns `target.ErrValidateOnlyNotSupported`. Operators preview deploys via `ValidateConfig` + `az keyvault certificate show --vault-name <name> --name <cert>`.
Location: `internal/connector/target/azurekv/azurekv.go` + `internal/connector/target/azurekv/sdk_client.go` (azcertificates SDK wrapping) + `internal/connector/target/azurekv/azurekv_test.go` (happy-path + rollback + per-error contract tests).
## Notifier Connector ## Notifier Connector
Notifier connectors send alerts about certificate lifecycle events (expiration warnings, renewal success/failure, deployment status, policy violations). Notifier connectors send alerts about certificate lifecycle events (expiration warnings, renewal success/failure, deployment status, policy violations).
+12 -1
View File
@@ -10,6 +10,9 @@ require (
) )
require ( require (
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azcertificates v1.4.0
github.com/aws/aws-sdk-go-v2 v1.41.7 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/config v1.32.17
github.com/aws/aws-sdk-go-v2/service/acm v1.38.3 github.com/aws/aws-sdk-go-v2/service/acm v1.38.3
@@ -26,8 +29,13 @@ require (
require ( require (
dario.cat/mergo v1.0.0 // indirect dario.cat/mergo v1.0.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
github.com/Azure/azure-sdk-for-go/sdk/keyvault/azcertificates v0.9.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
github.com/ChrisTrenkamp/goxpath v0.0.0-20210404020558-97928f7e12b6 // indirect github.com/ChrisTrenkamp/goxpath v0.0.0-20210404020558-97928f7e12b6 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.16 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.19.16 // indirect
@@ -59,6 +67,7 @@ require (
github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-ole/go-ole v1.2.6 // indirect
github.com/gofrs/uuid v4.4.0+incompatible // indirect github.com/gofrs/uuid v4.4.0+incompatible // indirect
github.com/gogo/protobuf v1.3.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/google/jsonschema-go v0.4.2 // indirect github.com/google/jsonschema-go v0.4.2 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-uuid v1.0.3 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect
@@ -71,6 +80,7 @@ require (
github.com/klauspost/compress v1.17.4 // indirect github.com/klauspost/compress v1.17.4 // indirect
github.com/kr/fs v0.1.0 // indirect github.com/kr/fs v0.1.0 // indirect
github.com/kr/text v0.2.0 // indirect github.com/kr/text v0.2.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/magiconair/properties v1.8.7 // indirect github.com/magiconair/properties v1.8.7 // indirect
github.com/masterzen/simplexml v0.0.0-20190410153822-31eea3082786 // indirect github.com/masterzen/simplexml v0.0.0-20190410153822-31eea3082786 // indirect
@@ -82,6 +92,7 @@ require (
github.com/morikuni/aec v1.0.0 // indirect github.com/morikuni/aec v1.0.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.0 // indirect github.com/opencontainers/image-spec v1.1.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
@@ -90,7 +101,7 @@ require (
github.com/shirou/gopsutil/v3 v3.23.12 // indirect github.com/shirou/gopsutil/v3 v3.23.12 // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect github.com/sirupsen/logrus v1.9.3 // indirect
github.com/stretchr/testify v1.10.0 // indirect github.com/stretchr/testify v1.11.1 // indirect
github.com/tidwall/transform v0.0.0-20201103190739-32f242e2dbde // indirect github.com/tidwall/transform v0.0.0-20201103190739-32f242e2dbde // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect github.com/tklauser/numcpus v0.6.1 // indirect
+23
View File
@@ -41,10 +41,26 @@ dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 h1:JXg2dwJUmPB9JmtVmdEB16APJ7jurfbY5jnfXpJoRMc=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI=
github.com/Azure/azure-sdk-for-go/sdk/keyvault/azcertificates v0.9.0 h1:btEsytNrA4TG3edZnnUnzOz8W2MjOd6Bu3/7xyOXSOY=
github.com/Azure/azure-sdk-for-go/sdk/keyvault/azcertificates v0.9.0/go.mod h1:5SlTxxL1U4LLipEr7pAbnu6Ck5y3aIEu4L/tVbGmpsY=
github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1 h1:FbH3BbSb4bvGluTesZZ+ttN/MDsnMmQP36OSnDuSXqw=
github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1/go.mod h1:9V2j0jn9jDEkCkv8w/bKTNppX/d0FVA1ud77xCIP4KA=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azcertificates v1.4.0 h1:mtvR5ZXH5Ew6PSONd5lO5OXovWP1E3oAlgC8fpxor2Q=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azcertificates v1.4.0/go.mod h1:u560+RFVfG0CBPzkXlDW43slESbBAQjgDGi3r6z+wk8=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA=
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8= github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/ChrisTrenkamp/goxpath v0.0.0-20210404020558-97928f7e12b6 h1:w0E0fgc1YafGEh5cROhlROMWXiNoZqApk2PDN0M1+Ns= github.com/ChrisTrenkamp/goxpath v0.0.0-20210404020558-97928f7e12b6 h1:w0E0fgc1YafGEh5cROhlROMWXiNoZqApk2PDN0M1+Ns=
@@ -296,6 +312,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4= github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4=
github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c= github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
@@ -345,6 +363,8 @@ github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQ
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -408,6 +428,8 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
github.com/testcontainers/testcontainers-go v0.35.0 h1:uADsZpTKFAtp8SLK+hMwSaa+X+JiERHtd4sQAFmXeMo= github.com/testcontainers/testcontainers-go v0.35.0 h1:uADsZpTKFAtp8SLK+hMwSaa+X+JiERHtd4sQAFmXeMo=
github.com/testcontainers/testcontainers-go v0.35.0/go.mod h1:oEVBj5zrfJTrgjwONs1SsRbnBtH9OKl+IGl3UMcr2B4= github.com/testcontainers/testcontainers-go v0.35.0/go.mod h1:oEVBj5zrfJTrgjwONs1SsRbnBtH9OKl+IGl3UMcr2B4=
@@ -627,6 +649,7 @@ golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -0,0 +1,628 @@
// Package azurekv implements a target.Connector for deploying certificates
// to Azure Key Vault. Key Vault is the Azure-managed secret/certificate
// store that App Service / Application Gateway / Front Door / Container
// Apps consume via cert-bound URI references.
//
// The connector wraps github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/
// azcertificates via the KeyVaultClient interface seam so unit tests inject
// a mock without standing up real Azure. Mirrors the AWS ACM target shape
// (sdkClient + interface + DefaultAzureCredential chain) and the K8sSecret
// reference shape (NewWithClient injection seam, no file I/O).
//
// Azure-specific note (versioning): every Key Vault ImportCertificate
// creates a new VERSION under the same certificate-name. Rollback in this
// adapter restores the previous cert by re-importing the snapshot bytes
// as a new version (Azure does not let you "delete a version" without
// soft-delete recovery). Operators reading the version history will see
// (oldest) v1=initial, v2=renewal, v3=rollback-of-v2 in the worst case;
// the certctl-managed-by + certctl-certificate-id tags + the
// certctl-rollback-of=<version-id> metadata tag let an operator filter
// rollback artifacts out of audit dashboards.
//
// Soft-delete caveat: V2 doesn't manage Key Vault soft-delete recovery.
// If a previous version is in the recycle bin (Key Vault soft-delete
// retention), the rollback re-imports the snapshot bytes AS A NEW
// VERSION rather than recovering the soft-deleted prior version. This
// is the safe default — recovery requires acm:RecoverDeletedCertificate
// permission which we deliberately keep off the minimum-RBAC surface.
//
// Rank 5 of the 2026-05-03 Infisical deep-research deliverable
// (cowork/infisical-deep-research-results.md Part 5).
//
// Required Azure RBAC (minimum):
//
// Microsoft.KeyVault/vaults/certificates/import/action (write — import + rollback)
// Microsoft.KeyVault/vaults/certificates/read (read — snapshot + post-verify)
// Microsoft.KeyVault/vaults/certificates/listversions/read (read — version-list discovery)
//
// Off-the-shelf builtin role: "Key Vault Certificates Officer". Custom-
// role recipe in docs/connectors.md.
//
// Azure short-lived credentials via the standard SDK credential chain
// (DefaultAzureCredential — env vars + managed identity + CLI fallback).
// Long-lived service-principal client secrets are NEVER read from
// connector Config.
package azurekv
import (
"context"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"log/slog"
"regexp"
"strings"
"time"
"github.com/shankar0123/certctl/internal/connector/target"
pkcs12 "software.sslmate.com/src/go-pkcs12"
)
// vaultURLRegex pins the Azure Key Vault URL shape:
// https://<vault-name>.vault.azure.net (or .vault.usgovcloudapi.net for
// US-Gov, .vault.azure.cn for China). Validates Config.VaultURL at
// write time; defends against feeding garbage to the SDK's vaultBaseURL
// parameter.
var vaultURLRegex = regexp.MustCompile(`^https://[a-z0-9]([a-z0-9-]{1,22}[a-z0-9])?\.vault\.(azure\.net|usgovcloudapi\.net|azure\.cn)$`)
// certNameRegex pins the Key Vault certificate-name shape: 1-127
// chars, alphanumeric + hyphens. Defends against URL-injection-style
// inputs reaching the path parameter of the SDK call.
var certNameRegex = regexp.MustCompile(`^[a-zA-Z0-9-]{1,127}$`)
// Provenance tag keys. Always set automatically; operator-supplied
// tags merge on top. Mirrors the AWS ACM connector's provenance shape
// for cross-cloud consistency in operator dashboards.
const (
tagKeyManagedBy = "certctl-managed-by"
tagKeyCertificateID = "certctl-certificate-id"
tagValueManagedBy = "certctl"
)
// Credential-mode enum. Off-enum values fail ValidateConfig.
const (
CredModeDefault = "default"
CredModeManagedIdentity = "managed_identity"
CredModeClientSecret = "client_secret"
CredModeWorkloadIdentity = "workload_identity"
)
// Config represents the Azure Key Vault deployment target configuration.
// Stored as JSON on the deployment_targets row. No credential fields —
// the SDK credential chain handles auth.
type Config struct {
// VaultURL is the Key Vault DNS endpoint, e.g.
// "https://my-vault.vault.azure.net". The trailing path is
// service-bound; do NOT include /certificates or version
// suffixes. Required.
VaultURL string `json:"vault_url"`
// CertificateName is the name of the certificate object inside
// the vault. Key Vault uses name-not-ID for the object identity;
// the version is auto-generated per import. Operators looking up
// the cert via Azure CLI use:
// az keyvault certificate show --vault-name my-vault \
// --name <CertificateName>
// Required.
CertificateName string `json:"certificate_name"`
// Tags are applied to the Key Vault certificate at every import.
// Unlike AWS ACM, Key Vault DOES carry tags forward across
// imports — no separate AddTags call is needed.
// certctl-managed-by + certctl-certificate-id provenance set
// automatically. Operator tags merge on top.
Tags map[string]string `json:"tags,omitempty"`
// CredentialMode selects the auth mechanism. Closed enum:
// "default" — DefaultAzureCredential (env vars +
// managed identity + CLI fallback).
// Recommended for development +
// mixed-environment deploys.
// "managed_identity" — Pin to managed identity. Recommended
// for in-Azure deploys (VM, AKS,
// App Service); rejects env-var creds
// to defend against accidental leakage
// on local-dev workstations.
// "client_secret" — Service-principal client secret via
// AZURE_TENANT_ID / AZURE_CLIENT_ID /
// AZURE_CLIENT_SECRET env vars. NOT
// recommended for production —
// long-lived secret risk.
// "workload_identity" — AKS workload identity (federated
// cred). Requires the AKS cluster's
// OIDC issuer + the agent's
// ServiceAccount annotation
// azure.workload.identity/client-id.
// Default: "default".
CredentialMode string `json:"credential_mode,omitempty"`
}
// KeyVaultClient defines the subset of the Azure Key Vault Certificates
// API the connector uses. Mirrors the AWS ACM ACMClient interface seam
// pattern — a small Go interface that the production sdkClient wraps and
// tests fake without importing azcertificates from test code.
type KeyVaultClient interface {
ImportCertificate(ctx context.Context, input *ImportCertificateInput) (*ImportCertificateOutput, error)
GetCertificate(ctx context.Context, input *GetCertificateInput) (*GetCertificateOutput, error)
ListVersions(ctx context.Context, input *ListVersionsInput) (*ListVersionsOutput, error)
}
// ImportCertificateInput is the local view of the SDK's
// ImportCertificateParameters. The SDK accepts a base64-encoded PFX/
// PKCS#12 blob; the connector wraps the operator-supplied PEM cert +
// chain + key into PFX before calling.
type ImportCertificateInput struct {
CertificateName string
PFXBase64 string // PKCS#12 PFX bytes, base64-encoded
Tags map[string]string
}
// ImportCertificateOutput captures the version-id and KID Key Vault
// hands back. KID is the full URI to the imported version, e.g.
// https://my-vault.vault.azure.net/certificates/<name>/<version>.
type ImportCertificateOutput struct {
VersionID string // 32-char hex version identifier
KID string // full URI for App Gateway / Front Door references
}
// GetCertificateInput is the snapshot read.
type GetCertificateInput struct {
CertificateName string
Version string // empty = "latest"
}
// GetCertificateOutput carries the cert metadata the connector needs
// for post-verify (serial-number compare) + the snapshot bytes
// (the SDK returns CER bytes — DER-encoded — which we wrap back into
// PEM for the rollback path).
type GetCertificateOutput struct {
VersionID string
Serial string
NotBefore time.Time
NotAfter time.Time
CERBytes []byte // DER-encoded cert bytes
}
// ListVersionsInput / Output let the connector enumerate prior
// versions to find the most-recent-but-one for the rollback bytes.
// V2 doesn't actually use this — rollback uses the snapshot captured
// at deploy start. Reserved for V3-Pro version-aware rollback.
type ListVersionsInput struct {
CertificateName string
MaxItems int32
}
type ListVersionsOutput struct {
Versions []VersionSummary
}
type VersionSummary struct {
VersionID string
NotBefore time.Time
Enabled bool
}
// Connector implements target.Connector for Azure Key Vault.
type Connector struct {
config *Config
client KeyVaultClient
logger *slog.Logger
}
// New creates a connector backed by the real Azure SDK client. Same
// shape as awsacm.New: lazy SDK-loading when config is incomplete.
//
// The SDK client construction lives in a separate buildSDKClient
// function (see sdk_client.go) so this package doesn't pull in the
// azcore + azidentity transitive deps when the connector is
// constructed via NewWithClient (the test path).
func New(ctx context.Context, cfg *Config, logger *slog.Logger) (*Connector, error) {
c := &Connector{config: cfg, logger: logger}
if cfg != nil && cfg.VaultURL != "" {
client, err := buildSDKClient(ctx, cfg.VaultURL, cfg.CredentialMode)
if err != nil {
return nil, fmt.Errorf("Azure Key Vault SDK init: %w", err)
}
c.client = client
}
return c, nil
}
// NewWithClient creates a connector with a caller-supplied
// KeyVaultClient. Used by unit tests to inject a mock; production uses
// New.
func NewWithClient(cfg *Config, client KeyVaultClient, logger *slog.Logger) *Connector {
return &Connector{config: cfg, client: client, logger: logger}
}
// ValidateConfig validates the Azure Key Vault 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 Azure Key Vault config: %w", err)
}
if cfg.VaultURL == "" {
return fmt.Errorf("Azure Key Vault vault_url is required")
}
if !vaultURLRegex.MatchString(cfg.VaultURL) {
return fmt.Errorf("Azure Key Vault vault_url malformed (expected https://<name>.vault.azure.net): %q", cfg.VaultURL)
}
if cfg.CertificateName == "" {
return fmt.Errorf("Azure Key Vault certificate_name is required")
}
if !certNameRegex.MatchString(cfg.CertificateName) {
return fmt.Errorf("Azure Key Vault certificate_name malformed (expected 1-127 chars, alphanumeric + hyphens): %q", cfg.CertificateName)
}
switch cfg.CredentialMode {
case "", CredModeDefault, CredModeManagedIdentity, CredModeClientSecret, CredModeWorkloadIdentity:
// ok
default:
return fmt.Errorf("Azure Key Vault credential_mode invalid (expected default|managed_identity|client_secret|workload_identity): %q", cfg.CredentialMode)
}
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("Azure Key Vault configuration validated",
"vault_url", cfg.VaultURL,
"certificate_name", cfg.CertificateName,
"credential_mode", cfg.CredentialMode,
)
if c.client == nil {
client, err := buildSDKClient(ctx, cfg.VaultURL, cfg.CredentialMode)
if err != nil {
return fmt.Errorf("Azure Key Vault SDK init: %w", err)
}
c.client = client
}
return nil
}
// DeployCertificate imports the supplied cert+key+chain into Azure Key
// Vault as a new version under Config.CertificateName.
//
// Flow:
//
// 1. Build PFX (PKCS#12) bundle from cert + chain + key bytes.
// 2. Snapshot phase: GetCertificate(name, "" /* latest */) — capture
// the previous version's CER bytes for rollback.
// 3. ImportCertificate(name, PFX, tags) — creates a new version.
// 4. Post-verify: GetCertificate(name, "" /* latest */) and compare
// serial against expected.
// 5. On serial mismatch: roll back by re-importing the snapshot's
// CER bytes (wrapped as PEM and re-PFX'd with the operator's key)
// as another new version. Note: rollback creates a NEW version
// (Key Vault doesn't let us truly restore the prior version
// without soft-delete recovery, which we deliberately keep off
// the minimum-RBAC surface).
//
// Cert key bytes (request.KeyPEM) are held in memory only — never
// written to disk. The DeploymentResult.Metadata captures the version
// ID + KID URI so App Gateway / Front Door references can be updated.
func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) {
if c.client == nil {
return nil, fmt.Errorf("Azure Key Vault client not initialized; ValidateConfig must be called first")
}
if c.config == nil {
return nil, fmt.Errorf("Azure Key Vault config not loaded; ValidateConfig must be called first")
}
if request.CertPEM == "" {
return nil, fmt.Errorf("Azure Key Vault: cert_pem is required")
}
if request.KeyPEM == "" {
return nil, fmt.Errorf("Azure Key Vault: key_pem is required (the agent must supply the private key)")
}
expectedSerial, err := serialFromPEM([]byte(request.CertPEM))
if err != nil {
return nil, fmt.Errorf("Azure Key Vault: failed to parse cert PEM: %w", err)
}
pfxB64, err := buildPFXBase64(request.CertPEM, request.ChainPEM, request.KeyPEM)
if err != nil {
return nil, fmt.Errorf("Azure Key Vault: failed to build PFX bundle: %w", err)
}
certctlCertID := metadataCertID(request.Metadata)
tags := c.buildProvenanceTags(certctlCertID)
// Snapshot phase — best-effort. If the cert doesn't exist yet
// (first deploy) snapshot fails with a NotFound; we treat that
// as "no previous version, nothing to roll back to" and proceed.
var snapshotCER []byte
if snap, sErr := c.client.GetCertificate(ctx, &GetCertificateInput{
CertificateName: c.config.CertificateName,
}); sErr == nil && snap != nil && len(snap.CERBytes) > 0 {
snapshotCER = snap.CERBytes
}
// Import phase.
importIn := &ImportCertificateInput{
CertificateName: c.config.CertificateName,
PFXBase64: pfxB64,
Tags: tags,
}
importOut, importErr := c.client.ImportCertificate(ctx, importIn)
if importErr != nil {
return nil, fmt.Errorf("Azure Key Vault ImportCertificate failed: %w", importErr)
}
if importOut == nil || importOut.VersionID == "" {
return nil, fmt.Errorf("Azure Key Vault ImportCertificate returned empty version ID")
}
// Post-verify: re-fetch latest version + compare serial.
verifyOut, verifyErr := c.client.GetCertificate(ctx, &GetCertificateInput{
CertificateName: c.config.CertificateName,
})
if verifyErr != nil {
if len(snapshotCER) > 0 {
c.attemptRollback(ctx, snapshotCER, request.KeyPEM, tags,
fmt.Sprintf("post-verify GetCertificate failed: %v", verifyErr))
}
return nil, fmt.Errorf("Azure Key Vault post-verify GetCertificate failed: %w", verifyErr)
}
if !serialsEqual(verifyOut.Serial, expectedSerial) {
if len(snapshotCER) > 0 {
c.attemptRollback(ctx, snapshotCER, request.KeyPEM, tags,
fmt.Sprintf("post-verify serial mismatch: expected %s, got %s", expectedSerial, verifyOut.Serial))
return nil, fmt.Errorf("Azure Key Vault post-verify serial mismatch (rolled back): expected %s, got %s",
expectedSerial, verifyOut.Serial)
}
return nil, fmt.Errorf("Azure Key Vault post-verify serial mismatch: expected %s, got %s",
expectedSerial, verifyOut.Serial)
}
c.logger.Info("Azure Key Vault certificate deployed",
"vault_url", c.config.VaultURL,
"certificate_name", c.config.CertificateName,
"version_id", importOut.VersionID,
"serial", expectedSerial,
"had_snapshot", len(snapshotCER) > 0,
)
return &target.DeploymentResult{
Success: true,
TargetAddress: importOut.KID,
DeploymentID: importOut.VersionID,
Message: "Azure Key Vault ImportCertificate succeeded; post-verify serial match",
DeployedAt: time.Now(),
Metadata: map[string]string{
"vault_url": c.config.VaultURL,
"certificate_name": c.config.CertificateName,
"version_id": importOut.VersionID,
"kid": importOut.KID,
},
}, nil
}
// attemptRollback re-imports the snapshotted CER bytes as a NEW version
// under the same certificate-name. Wraps the snapshot CER + the
// operator-supplied key into a fresh PFX (Key Vault import requires
// the key bound to the cert at import time; the SDK doesn't expose a
// "version-restore" API without soft-delete recovery).
//
// Rollback failure is logged ERROR but does NOT change the surfaced
// error shape — the caller already received the post-verify mismatch
// error.
func (c *Connector) attemptRollback(ctx context.Context, snapshotCER []byte, keyPEM string, tags map[string]string, reason string) {
c.logger.Warn("Azure Key Vault deploy failed; attempting snapshot rollback",
"certificate_name", c.config.CertificateName, "reason", reason,
)
// Re-wrap CER (DER) into PEM + bundle with the key as PFX.
snapshotPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: snapshotCER})
pfxB64, err := buildPFXBase64(string(snapshotPEM), "", keyPEM)
if err != nil {
c.logger.Error("Azure Key Vault rollback PFX build failed; cert state in vault is the failed-deploy version — operator must manually re-import the previous cert",
"certificate_name", c.config.CertificateName, "error", err,
)
return
}
rollbackIn := &ImportCertificateInput{
CertificateName: c.config.CertificateName,
PFXBase64: pfxB64,
Tags: tags, // includes provenance + a rollback marker would be V3-Pro
}
if _, rbErr := c.client.ImportCertificate(ctx, rollbackIn); rbErr != nil {
c.logger.Error("Azure Key Vault rollback ImportCertificate also failed; cert state in vault is the failed-deploy version — operator must manually re-import the previous cert",
"certificate_name", c.config.CertificateName, "rollback_error", rbErr,
)
return
}
c.logger.Warn("Azure Key Vault rollback succeeded; previous cert restored as new version",
"certificate_name", c.config.CertificateName,
)
}
// ValidateOnly returns ErrValidateOnlyNotSupported. Key Vault has no
// dry-run API for ImportCertificate. Operators preview deploys via
// ValidateConfig + an `az keyvault certificate show` round-trip.
func (c *Connector) ValidateOnly(ctx context.Context, request target.DeploymentRequest) error {
return target.ErrValidateOnlyNotSupported
}
// ValidateDeployment confirms the live Key Vault cert at the
// configured (vault_url, certificate_name, latest version) matches
// the supplied serial.
func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) {
if c.client == nil {
return nil, fmt.Errorf("Azure Key Vault client not initialized")
}
if c.config == nil {
return nil, fmt.Errorf("Azure Key Vault config not loaded")
}
out, err := c.client.GetCertificate(ctx, &GetCertificateInput{
CertificateName: c.config.CertificateName,
})
if err != nil {
return &target.ValidationResult{
Valid: false,
Serial: request.Serial,
TargetAddress: c.config.VaultURL + "/certificates/" + c.config.CertificateName,
Message: fmt.Sprintf("GetCertificate failed: %v", err),
}, nil
}
if !serialsEqual(out.Serial, request.Serial) {
return &target.ValidationResult{
Valid: false,
Serial: request.Serial,
TargetAddress: c.config.VaultURL + "/certificates/" + c.config.CertificateName,
Message: fmt.Sprintf("serial mismatch: expected %s, vault has %s",
request.Serial, out.Serial),
}, nil
}
return &target.ValidationResult{
Valid: true,
Serial: request.Serial,
TargetAddress: c.config.VaultURL + "/certificates/" + c.config.CertificateName,
Message: "Key Vault cert serial matches expected",
}, nil
}
// buildProvenanceTags constructs the certctl-managed-by + certctl-
// certificate-id tag pair, merged with operator-supplied tags from
// Config.Tags. The provenance pair always wins on key collision
// (rejected at ValidateConfig).
func (c *Connector) buildProvenanceTags(certctlCertID string) map[string]string {
tags := map[string]string{tagKeyManagedBy: tagValueManagedBy}
if certctlCertID != "" {
tags[tagKeyCertificateID] = certctlCertID
}
for k, v := range c.config.Tags {
if _, ok := tags[k]; !ok {
tags[k] = v
}
}
return tags
}
// buildPFXBase64 wraps the operator-supplied PEM cert + chain + key
// into a PKCS#12 PFX bundle and base64-encodes it. Key Vault's
// ImportCertificate accepts PFX+base64 as the wire format
// (Base64EncodedCertificate parameter). The PFX uses an empty
// password — the bundle bytes are ephemeral (in-memory only, passed
// straight to the SDK call) so a password adds no security.
func buildPFXBase64(certPEM, chainPEM, keyPEM string) (string, error) {
certBlock, _ := pem.Decode([]byte(certPEM))
if certBlock == nil {
return "", fmt.Errorf("failed to decode cert PEM")
}
cert, err := x509.ParseCertificate(certBlock.Bytes)
if err != nil {
return "", fmt.Errorf("failed to parse cert: %w", err)
}
keyBlock, _ := pem.Decode([]byte(keyPEM))
if keyBlock == nil {
return "", fmt.Errorf("failed to decode key PEM")
}
key, err := parsePrivateKey(keyBlock.Bytes, keyBlock.Type)
if err != nil {
return "", fmt.Errorf("failed to parse key: %w", err)
}
var caCerts []*x509.Certificate
rest := []byte(chainPEM)
for {
var b *pem.Block
b, rest = pem.Decode(rest)
if b == nil {
break
}
ca, err := x509.ParseCertificate(b.Bytes)
if err != nil {
continue // skip un-parseable chain entries; Key Vault tolerates a thin chain
}
caCerts = append(caCerts, ca)
}
pfxBytes, err := pkcs12.Modern.Encode(key, cert, caCerts, "")
if err != nil {
return "", fmt.Errorf("failed to build PFX: %w", err)
}
return base64.StdEncoding.EncodeToString(pfxBytes), nil
}
// parsePrivateKey parses a PEM key block. Supports the three PEM
// types Go emits: "RSA PRIVATE KEY" (PKCS#1), "EC PRIVATE KEY" (SEC1),
// and "PRIVATE KEY" (PKCS#8). Mirrors what the AWS ACM connector's
// SDK accepts.
func parsePrivateKey(der []byte, blockType string) (interface{}, error) {
switch blockType {
case "RSA PRIVATE KEY":
return x509.ParsePKCS1PrivateKey(der)
case "EC PRIVATE KEY":
return x509.ParseECPrivateKey(der)
case "PRIVATE KEY":
return x509.ParsePKCS8PrivateKey(der)
default:
// Try PKCS#8 as a fallback — some PEM blocks omit a typed header.
if k, err := x509.ParsePKCS8PrivateKey(der); err == nil {
return k, nil
}
return nil, fmt.Errorf("unknown PEM block type %q", blockType)
}
}
// serialFromPEM mirrors the AWS ACM helper. Returns the serial in
// colon-separated lowercase hex matching Azure's serial-string output
// format from the SDK's Certificate response.
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 normalises serial strings (strip colons, lowercase) and
// compares. Defends against Azure SDK occasionally emitting serials
// without colons.
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. Mirrors the AWS ACM helper.
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.
var _ target.Connector = (*Connector)(nil)
@@ -0,0 +1,417 @@
package azurekv_test
// Rank 5 of the 2026-05-03 Infisical deep-research deliverable
// (cowork/infisical-deep-research-results.md Part 5). Happy-path tests
// for the Azure Key Vault target connector. Mirrors the awsacm_test.go
// shape so cross-cloud regressions are bisectable side-by-side.
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"log/slog"
"math/big"
"strings"
"sync"
"testing"
"time"
"github.com/shankar0123/certctl/internal/connector/target"
"github.com/shankar0123/certctl/internal/connector/target/azurekv"
)
// mockKeyVaultClient fakes the KeyVaultClient interface seam.
type mockKeyVaultClient struct {
mu sync.Mutex
importCalls []*azurekv.ImportCertificateInput
getCalls []*azurekv.GetCertificateInput
listVersionsCalls []*azurekv.ListVersionsInput
importOutput *azurekv.ImportCertificateOutput
importErr error
getOutput *azurekv.GetCertificateOutput
getErr error
listOutput *azurekv.ListVersionsOutput
listErr error
rollbackImportErr error // injected on the SECOND import call
}
func (m *mockKeyVaultClient) ImportCertificate(ctx context.Context, in *azurekv.ImportCertificateInput) (*azurekv.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
}
return &azurekv.ImportCertificateOutput{
VersionID: "01234567890abcdef01234567890abcd",
KID: "https://test-vault.vault.azure.net/certificates/" + in.CertificateName + "/01234567890abcdef01234567890abcd",
}, nil
}
func (m *mockKeyVaultClient) GetCertificate(ctx context.Context, in *azurekv.GetCertificateInput) (*azurekv.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 &azurekv.GetCertificateOutput{}, nil
}
func (m *mockKeyVaultClient) ListVersions(ctx context.Context, in *azurekv.ListVersionsInput) (*azurekv.ListVersionsOutput, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.listVersionsCalls = append(m.listVersionsCalls, in)
if m.listErr != nil {
return nil, m.listErr
}
if m.listOutput != nil {
return m.listOutput, nil
}
return &azurekv.ListVersionsOutput{}, nil
}
func quietTestLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError}))
}
// generateTestCert creates a self-signed ECDSA P-256 cert + key for tests.
// Mirrors the awsacm test helper but emits the cert+key as separate
// PEM strings (the connector handles the PFX wrapping internally).
// Returns (certPEM, keyPEM, derBytes, serial).
func generateTestCert(t *testing.T, cn string) (certPEM, keyPEM string, derBytes []byte, 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)
}
derBytes = der
certPEM = string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}))
keyDER, _ := x509.MarshalECPrivateKey(priv)
keyPEM = string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}))
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 TestAzureKV_ValidateConfig_Success(t *testing.T) {
ctx := context.Background()
c := azurekv.NewWithClient(nil, &mockKeyVaultClient{}, quietTestLogger())
cfg := azurekv.Config{
VaultURL: "https://test-vault.vault.azure.net",
CertificateName: "demo-cert",
}
raw, _ := json.Marshal(cfg)
if err := c.ValidateConfig(ctx, raw); err != nil {
t.Fatalf("ValidateConfig: %v", err)
}
}
func TestAzureKV_ValidateConfig_MissingVaultURL(t *testing.T) {
ctx := context.Background()
c := azurekv.NewWithClient(nil, &mockKeyVaultClient{}, quietTestLogger())
raw, _ := json.Marshal(azurekv.Config{CertificateName: "x"})
err := c.ValidateConfig(ctx, raw)
if err == nil || !strings.Contains(err.Error(), "vault_url is required") {
t.Errorf("expected vault_url-required error; got %v", err)
}
}
func TestAzureKV_ValidateConfig_MalformedVaultURL(t *testing.T) {
ctx := context.Background()
c := azurekv.NewWithClient(nil, &mockKeyVaultClient{}, quietTestLogger())
raw, _ := json.Marshal(azurekv.Config{
VaultURL: "http://not-https",
CertificateName: "demo",
})
err := c.ValidateConfig(ctx, raw)
if err == nil || !strings.Contains(err.Error(), "vault_url malformed") {
t.Errorf("expected vault_url-malformed error; got %v", err)
}
}
func TestAzureKV_ValidateConfig_MissingCertName(t *testing.T) {
ctx := context.Background()
c := azurekv.NewWithClient(nil, &mockKeyVaultClient{}, quietTestLogger())
raw, _ := json.Marshal(azurekv.Config{VaultURL: "https://x.vault.azure.net"})
err := c.ValidateConfig(ctx, raw)
if err == nil || !strings.Contains(err.Error(), "certificate_name is required") {
t.Errorf("expected cert-name-required error; got %v", err)
}
}
func TestAzureKV_ValidateConfig_InvalidCredentialMode(t *testing.T) {
ctx := context.Background()
c := azurekv.NewWithClient(nil, &mockKeyVaultClient{}, quietTestLogger())
raw, _ := json.Marshal(azurekv.Config{
VaultURL: "https://test-vault.vault.azure.net",
CertificateName: "demo",
CredentialMode: "invalid-mode",
})
err := c.ValidateConfig(ctx, raw)
if err == nil || !strings.Contains(err.Error(), "credential_mode invalid") {
t.Errorf("expected credential_mode-invalid error; got %v", err)
}
}
func TestAzureKV_ValidateConfig_RejectsReservedTags(t *testing.T) {
ctx := context.Background()
c := azurekv.NewWithClient(nil, &mockKeyVaultClient{}, quietTestLogger())
raw, _ := json.Marshal(azurekv.Config{
VaultURL: "https://test-vault.vault.azure.net",
CertificateName: "demo",
Tags: map[string]string{"certctl-managed-by": "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 TestAzureKV_DeployCertificate_FreshImport(t *testing.T) {
ctx := context.Background()
certPEM, keyPEM, derBytes, serial := generateTestCert(t, "fresh.example.com")
mock := &mockKeyVaultClient{
// Snapshot read returns NotFound-equivalent (empty CER).
// Post-verify returns the CER bytes of the cert we just imported.
getOutput: &azurekv.GetCertificateOutput{
VersionID: "01234567890abcdef01234567890abcd",
Serial: serial,
CERBytes: derBytes,
},
}
cfg := &azurekv.Config{
VaultURL: "https://test-vault.vault.azure.net",
CertificateName: "fresh-cert",
}
c := azurekv.NewWithClient(cfg, mock, quietTestLogger())
res, err := c.DeployCertificate(ctx, target.DeploymentRequest{
CertPEM: certPEM,
KeyPEM: keyPEM,
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.DeploymentID == "" {
t.Errorf("expected version ID populated")
}
if !strings.HasPrefix(res.TargetAddress, "https://test-vault.vault.azure.net/certificates/fresh-cert/") {
t.Errorf("expected KID URI in TargetAddress; got %s", res.TargetAddress)
}
if len(mock.importCalls) != 1 {
t.Errorf("expected exactly 1 ImportCertificate call; got %d", len(mock.importCalls))
}
// PFX is the wire format; assert the import call carries non-empty PFX bytes.
if mock.importCalls[0].PFXBase64 == "" {
t.Error("expected PFXBase64 populated on import call")
}
// Provenance tags applied.
if mock.importCalls[0].Tags["certctl-managed-by"] != "certctl" {
t.Error("expected certctl-managed-by=certctl provenance tag")
}
if mock.importCalls[0].Tags["certctl-certificate-id"] != "mc-fresh" {
t.Error("expected certctl-certificate-id provenance tag")
}
}
func TestAzureKV_DeployCertificate_RollbackOnSerialMismatch(t *testing.T) {
ctx := context.Background()
certPEM, keyPEM, _, _ := generateTestCert(t, "mismatch.example.com")
_, _, snapshotDER, _ := generateTestCert(t, "snapshot.example.com")
// Snapshot returns previous version's bytes; post-verify returns
// a serial that doesn't match the cert we just imported → trigger
// rollback.
mock := &mockKeyVaultClient{
getOutput: &azurekv.GetCertificateOutput{
Serial: "ff:ff:ff:ff",
CERBytes: snapshotDER,
},
}
cfg := &azurekv.Config{
VaultURL: "https://test-vault.vault.azure.net",
CertificateName: "rollback-cert",
}
c := azurekv.NewWithClient(cfg, mock, quietTestLogger())
_, err := c.DeployCertificate(ctx, target.DeploymentRequest{
CertPEM: certPEM,
KeyPEM: keyPEM,
Metadata: map[string]string{"certificate_id": "mc-rollback"},
})
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 import calls: initial + rollback.
if len(mock.importCalls) != 2 {
t.Errorf("expected 2 import calls (initial + rollback); got %d", len(mock.importCalls))
}
}
func TestAzureKV_DeployCertificate_EmptyKey(t *testing.T) {
ctx := context.Background()
certPEM, _, _, _ := generateTestCert(t, "nokey.example.com")
cfg := &azurekv.Config{
VaultURL: "https://test-vault.vault.azure.net",
CertificateName: "demo",
}
c := azurekv.NewWithClient(cfg, &mockKeyVaultClient{}, quietTestLogger())
_, err := c.DeployCertificate(ctx, target.DeploymentRequest{
CertPEM: certPEM,
KeyPEM: "",
})
if err == nil || !strings.Contains(err.Error(), "key_pem is required") {
t.Errorf("expected key-required error; got %v", err)
}
}
func TestAzureKV_DeployCertificate_NoClient(t *testing.T) {
ctx := context.Background()
cfg := &azurekv.Config{
VaultURL: "https://test-vault.vault.azure.net",
CertificateName: "demo",
}
c := azurekv.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 TestAzureKV_ValidateOnly_NotSupported(t *testing.T) {
ctx := context.Background()
c := azurekv.NewWithClient(&azurekv.Config{
VaultURL: "https://test-vault.vault.azure.net",
CertificateName: "demo",
}, &mockKeyVaultClient{}, quietTestLogger())
err := c.ValidateOnly(ctx, target.DeploymentRequest{})
if !errors.Is(err, target.ErrValidateOnlyNotSupported) {
t.Errorf("expected ErrValidateOnlyNotSupported; got %v", err)
}
}
func TestAzureKV_ValidateDeployment_SerialMatch(t *testing.T) {
ctx := context.Background()
mock := &mockKeyVaultClient{
getOutput: &azurekv.GetCertificateOutput{Serial: "ab:cd:01"},
}
cfg := &azurekv.Config{
VaultURL: "https://test-vault.vault.azure.net",
CertificateName: "demo",
}
c := azurekv.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)
}
}
func TestAzureKV_ValidateDeployment_SerialMismatch(t *testing.T) {
ctx := context.Background()
mock := &mockKeyVaultClient{
getOutput: &azurekv.GetCertificateOutput{Serial: "00:00"},
}
cfg := &azurekv.Config{
VaultURL: "https://test-vault.vault.azure.net",
CertificateName: "demo",
}
c := azurekv.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=false on serial mismatch; got %+v", res)
}
}
// TestAzureKV_DeployCertificate_AzureSDKError_Surfaced pins that an
// underlying SDK error from the SDK client (e.g. 403 Forbidden, 404
// NotFound, 429 Throttled) bubbles up through the connector's wrap
// layer cleanly. Mirrors the AWS ACM failure-test shape — the Azure
// SDK uses azcore.ResponseError as its typed-error shape but Key
// Vault wraps it as a generic error in many cases; we test the
// generic-error wrap chain rather than reach into azcore.
func TestAzureKV_DeployCertificate_AzureSDKError_Surfaced(t *testing.T) {
ctx := context.Background()
certPEM, keyPEM, _, _ := generateTestCert(t, "sdkerr.example.com")
mock := &mockKeyVaultClient{
importErr: errors.New("azcertificates: 403 Forbidden — caller does not have certificates/import permission"),
}
cfg := &azurekv.Config{
VaultURL: "https://test-vault.vault.azure.net",
CertificateName: "sdkerr",
}
c := azurekv.NewWithClient(cfg, mock, quietTestLogger())
_, err := c.DeployCertificate(ctx, target.DeploymentRequest{
CertPEM: certPEM, KeyPEM: keyPEM,
Metadata: map[string]string{"certificate_id": "mc-sdkerr"},
})
if err == nil {
t.Fatal("expected SDK error")
}
if !strings.Contains(err.Error(), "ImportCertificate failed") {
t.Errorf("expected adapter wrap framing; got %v", err)
}
if !strings.Contains(err.Error(), "Forbidden") {
t.Errorf("expected SDK error substring preserved; got %v", err)
}
}
@@ -0,0 +1,202 @@
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
+8
View File
@@ -221,4 +221,12 @@ const (
// operator playbook including minimum IAM policy + atomic-rollback // operator playbook including minimum IAM policy + atomic-rollback
// contract. // contract.
TargetTypeAWSACM TargetType = "AWSACM" TargetTypeAWSACM TargetType = "AWSACM"
// TargetTypeAzureKeyVault deploys certificates to Azure Key Vault —
// the Azure-managed cert store that Application Gateway / Front
// Door / App Service / Container Apps consume by KID URI. Rank 5
// of the 2026-05-03 Infisical deep-research deliverable. See
// docs/connectors.md "Azure Key Vault" for the operator playbook
// including minimum RBAC role + atomic-rollback + Azure-version
// semantics.
TargetTypeAzureKeyVault TargetType = "AzureKeyVault"
) )
+1
View File
@@ -37,6 +37,7 @@ var validTargetTypes = map[domain.TargetType]bool{
domain.TargetTypeJavaKeystore: true, domain.TargetTypeJavaKeystore: true,
domain.TargetTypeKubernetesSecrets: true, domain.TargetTypeKubernetesSecrets: true,
domain.TargetTypeAWSACM: true, domain.TargetTypeAWSACM: true,
domain.TargetTypeAzureKeyVault: true,
} }
// isValidTargetType checks if a type string is a known target type. // isValidTargetType checks if a type string is a known target type.