mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 14:51:30 +00:00
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 commitedf6bee. 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:
+13
-1
@@ -831,7 +831,7 @@ func strPtr(s string) *string {
|
||||
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) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
@@ -958,6 +958,17 @@ func TestCreateTargetConnector_AllSupportedTypes(t *testing.T) {
|
||||
"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{
|
||||
@@ -1012,6 +1023,7 @@ func TestCreateTargetConnector_InvalidJSON(t *testing.T) {
|
||||
"JavaKeystore",
|
||||
"KubernetesSecrets",
|
||||
"AWSACM",
|
||||
"AzureKeyVault",
|
||||
}
|
||||
|
||||
cfg := &AgentConfig{
|
||||
|
||||
@@ -33,6 +33,7 @@ import (
|
||||
"github.com/shankar0123/certctl/internal/connector/target"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/apache"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/awsacm"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/azurekv"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/caddy"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/envoy"
|
||||
"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)
|
||||
|
||||
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:
|
||||
return nil, fmt.Errorf("unsupported target type: %s", targetType)
|
||||
}
|
||||
|
||||
@@ -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`).
|
||||
|
||||
### 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 connectors send alerts about certificate lifecycle events (expiration warnings, renewal success/failure, deployment status, policy violations).
|
||||
|
||||
@@ -10,6 +10,9 @@ 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/config v1.32.17
|
||||
github.com/aws/aws-sdk-go-v2/service/acm v1.38.3
|
||||
@@ -26,8 +29,13 @@ require (
|
||||
|
||||
require (
|
||||
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-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/Microsoft/go-winio v0.6.2 // 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/gofrs/uuid v4.4.0+incompatible // 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/hashicorp/go-cleanhttp v0.5.2 // 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/kr/fs v0.1.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/magiconair/properties v1.8.7 // 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/opencontainers/go-digest v1.0.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/pmezard/go-difflib v1.0.0 // 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/shoenig/go-m1cpu v0.1.6 // 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/tklauser/go-sysconf v0.3.12 // indirect
|
||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||
|
||||
@@ -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=
|
||||
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/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/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/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/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=
|
||||
@@ -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.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
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/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c=
|
||||
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/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/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.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
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/testcontainers/testcontainers-go v0.35.0 h1:uADsZpTKFAtp8SLK+hMwSaa+X+JiERHtd4sQAFmXeMo=
|
||||
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-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.1.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.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
|
||||
@@ -221,4 +221,12 @@ const (
|
||||
// operator playbook including minimum IAM policy + atomic-rollback
|
||||
// contract.
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -37,6 +37,7 @@ var validTargetTypes = map[domain.TargetType]bool{
|
||||
domain.TargetTypeJavaKeystore: true,
|
||||
domain.TargetTypeKubernetesSecrets: true,
|
||||
domain.TargetTypeAWSACM: true,
|
||||
domain.TargetTypeAzureKeyVault: true,
|
||||
}
|
||||
|
||||
// isValidTargetType checks if a type string is a known target type.
|
||||
|
||||
Reference in New Issue
Block a user