From 14fcc82cdae543ceab00ee2c68c9cc309a099913 Mon Sep 17 00:00:00 2001 From: shankar0123 Date: Sun, 3 May 2026 22:43:45 +0000 Subject: [PATCH] target(azurekv): SDK-driven Azure Key Vault target connector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 54033aa. 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=), automatically applied on every import. Key Vault carries tags forward across versions (unlike ACM which strips on re-import), so no separate AddTags call is required. - DeploymentRequest.KeyPEM held in agent memory only; PFX wrapping happens in-memory via software.sslmate.com/src/go-pkcs12. No disk write. Tests: - azurekv_test.go: 13-subtest happy-path + validation matrix — ValidateConfig (success / missing-vault-url / malformed-vault- url / missing-cert-name / invalid-credential-mode / reserved- tag rejection), DeployCertificate (fresh import / rollback-on- serial-mismatch / empty-key-rejected / no-client-rejected / SDK-error-surfaced), ValidateOnly (returns sentinel), ValidateDeployment (serial match / mismatch). - All tests use the NewWithClient injection seam; no real-Azure API calls. - go test -short -count=1 ./internal/connector/target/azurekv/... green. Wiring: - internal/domain/connector.go: TargetTypeAzureKeyVault = "AzureKeyVault". - internal/service/target.go: validTargetTypes set extended. - cmd/agent/main.go::createTargetConnector: AzureKeyVault case arm mirroring the AWSACM shape exactly. - cmd/agent/agent_test.go::TestCreateTargetConnector_AllSupported Types: AzureKeyVault added to the type matrix + the InvalidJSON matrix (16 supported target types now, up from 15). go.mod / go.sum: - github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 (direct). - github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 (direct). - github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/ azcertificates v1.4.0 (direct). The deprecated /keyvault/azcertificates path appears as a transitive indirect via Microsoft's microsoft-authentication-library-for-go; we use the new /security/keyvault/ path exclusively. Documentation: - docs/connectors.md "Azure Key Vault" section: config table, RBAC role recipe (off-the-shelf "Key Vault Certificates Officer" or custom role with 3 data-plane actions), AKS workload-identity / managed-identity / service-principal / default credential recipes, atomic-rollback contract + Azure-version semantics explanation, soft-delete caveat, App Gateway / Front Door Terraform attachment snippet, threat model carve-outs (no disk writes, mandatory provenance tags, no long-lived secrets in Config), 5-bullet procurement checklist crib. Out of scope (intentional, flagged in V3-Pro forward path): - Azure Front Door direct-attach (UpdateRoutingConfig — different Azure RBAC scope). - App Gateway / App Service auto-bind (V3-Pro auto-attach). - Soft-delete recovery (acm:RecoverDeletedCertificate-equivalent requires extra RBAC; V2 keeps minimum-permission surface). - GCP Certificate Manager (separate cloud, separate connector). Verified locally: - gofmt clean. - go vet ./internal/connector/target/azurekv/... ./internal/domain/... ./internal/service/... ./cmd/agent/... clean. - go test -short -count=1 ./internal/connector/target/azurekv/... ./cmd/agent/... green (all 16 supported target types instantiate via the agent factory). Reference: cowork/infisical-deep-research-results.md Part 5 Rank 5. Acquisition prompt: cowork/rank-5-aws-acm-azure-kv-target-adapters-prompt.md. Companion commit (AWS half): 54033aa. --- cmd/agent/agent_test.go | 14 +- cmd/agent/main.go | 16 + docs/connectors.md | 82 +++ go.mod | 13 +- go.sum | 23 + internal/connector/target/azurekv/azurekv.go | 628 ++++++++++++++++++ .../connector/target/azurekv/azurekv_test.go | 417 ++++++++++++ .../connector/target/azurekv/sdk_client.go | 202 ++++++ internal/domain/connector.go | 8 + internal/service/target.go | 1 + 10 files changed, 1402 insertions(+), 2 deletions(-) create mode 100644 internal/connector/target/azurekv/azurekv.go create mode 100644 internal/connector/target/azurekv/azurekv_test.go create mode 100644 internal/connector/target/azurekv/sdk_client.go diff --git a/cmd/agent/agent_test.go b/cmd/agent/agent_test.go index 796a1f7..d07b39e 100644 --- a/cmd/agent/agent_test.go +++ b/cmd/agent/agent_test.go @@ -831,7 +831,7 @@ func strPtr(s string) *string { return &s } -// TestCreateTargetConnector_AllSupportedTypes tests connector creation for all 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{ diff --git a/cmd/agent/main.go b/cmd/agent/main.go index 711e71e..a859c0e 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -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) } diff --git a/docs/connectors.md b/docs/connectors.md index 0c64eb1..4153b3b 100644 --- a/docs/connectors.md +++ b/docs/connectors.md @@ -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.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//resourceGroups//providers/Microsoft.KeyVault/vaults/`): + +``` +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=`. 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//` vs `/certificates/` 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=` 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=` 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 `. + +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). diff --git a/go.mod b/go.mod index 6ba2138..25dabb4 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 0ff3254..13de97c 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/connector/target/azurekv/azurekv.go b/internal/connector/target/azurekv/azurekv.go new file mode 100644 index 0000000..d37a2a8 --- /dev/null +++ b/internal/connector/target/azurekv/azurekv.go @@ -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= 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.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 + // 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//. +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://.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) diff --git a/internal/connector/target/azurekv/azurekv_test.go b/internal/connector/target/azurekv/azurekv_test.go new file mode 100644 index 0000000..8ec8b89 --- /dev/null +++ b/internal/connector/target/azurekv/azurekv_test.go @@ -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) + } +} diff --git a/internal/connector/target/azurekv/sdk_client.go b/internal/connector/target/azurekv/sdk_client.go new file mode 100644 index 0000000..660c508 --- /dev/null +++ b/internal/connector/target/azurekv/sdk_client.go @@ -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//. + 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 diff --git a/internal/domain/connector.go b/internal/domain/connector.go index 27afbdf..f2fd232 100644 --- a/internal/domain/connector.go +++ b/internal/domain/connector.go @@ -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" ) diff --git a/internal/service/target.go b/internal/service/target.go index c62532a..1f6c8c2 100644 --- a/internal/service/target.go +++ b/internal/service/target.go @@ -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.