Sync Azure KeyVault secrets into Kubernetes
If, for some reasone, you want some secrets to be stored in KeyValut and side by side you want them in Kubernetes you have few options
- Secrets CSI - powerfull but is like a portal to the hell
- External Secrets Operator - deploys quadrillion of CRD, deplyoemtns and so on aka overcomplicated (but still chosen)
Here, I'm noting for my self how similar thing may be achieved without relying on 3rd party dependencies
Here is wanted pieces described via Terraform
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "4.8.0"
}
}
}
provider "azurerm" {
tenant_id = "00000000-0000-0000-0000-000000000000"
subscription_id = "00000000-0000-0000-0000-000000000000"
features {}
}
resource "azurerm_resource_group" "example" {
name = "example"
location = "northeurope"
}
resource "azurerm_key_vault" "example" {
name = "example"
resource_group_name = azurerm_resource_group.example.name
location = azurerm_resource_group.example.location
tenant_id = "00000000-0000-0000-0000-000000000000"
sku_name = "standard"
enable_rbac_authorization = true
}
# # let's pretend we will put here secret for other resources
# resource "azurerm_key_vault_secret" "example" {
# name = "example"
# value = "helloworld"
# key_vault_id = azurerm_key_vault.example.id
# }
# let's create service account, which we will use inside kubernetes to access keyvault
resource "azurerm_user_assigned_identity" "example" {
name = "example"
location = azurerm_resource_group.example.location
resource_group_name = azurerm_resource_group.example.name
}
data "azurerm_kubernetes_cluster" "kube" {
name = "my-awesome-kubernetes-cluster"
resource_group_name = "example"
}
# federated credentials will allow azure-workload-identity to impersonate service account
resource "azurerm_federated_identity_credential" "example" {
name = "example"
resource_group_name = azurerm_resource_group.example.name
issuer = data.azurerm_kubernetes_cluster.kube.oidc_issuer_url
parent_id = azurerm_user_assigned_identity.example.id
audience = ["api://AzureADTokenExchange"]
subject = "system:serviceaccount:default:example"
}
# this one is needed if we want reactive updates
resource "azurerm_eventgrid_system_topic" "example" {
name = "example"
resource_group_name = azurerm_resource_group.example.name
location = azurerm_resource_group.example.location
source_arm_resource_id = azurerm_key_vault.example.id
topic_type = "Microsoft.KeyVault.vaults"
}
# allow access for our service account to keyvault
resource "azurerm_role_assignment" "example-managed-identity" {
principal_id = azurerm_user_assigned_identity.example.principal_id
role_definition_name = "Key Vault Administrator"
scope = azurerm_key_vault.example.id
}
# allow access for ourselves
resource "azurerm_role_assignment" "example-alexandrm" {
principal_id = "00000000-0000-0000-0000-000000000000"
role_definition_name = "Key Vault Administrator"
scope = azurerm_key_vault.example.id
}
# # just as an example, instead, here we will add endpoint that will create kubernetes job
# resource "azurerm_eventgrid_system_topic_event_subscription" "example" {
# name = "example"
# system_topic = azurerm_eventgrid_system_topic.example.name
# resource_group_name = azurerm_resource_group.example.name
# webhook_endpoint {
# url = "https://webhook.site/1b1b1b1b-1b1b-1b1b-1b1b-1b1b1b1b1b1b"
# }
# }
Nothing special here, we are creating key vault, and service account and adjusting few related settings
From a Kubernetes side we gonna need:
apiVersion: kustomize.config.k8s.io/v1beta1
resources:
- serviceaccount.yml
- clusterrolebinding.yml
- cronjob.yml
configMapGenerator:
- name: example
files:
- sync.ps1
generatorOptions:
disableNameSuffixHash: true
I am ommiting service account and cluster role binding - nothing interesting there (but do not forget about azure.workload.identity/use
label)
Cron job is as simple as
apiVersion: batch/v1
kind: CronJob
metadata:
name: example
spec:
suspend: true
schedule: "0 0 * * *"
successfulJobsHistoryLimit: 1
failedJobsHistoryLimit: 1
jobTemplate:
spec:
template:
metadata:
labels:
azure.workload.identity/use: "true"
spec:
serviceAccountName: example
restartPolicy: Never
containers:
- name: example
image: mcr.microsoft.com/powershell
command:
- /bin/pwsh
- /example/sync.ps1
volumeMounts:
- name: example
mountPath: /example
volumes:
- name: example
configMap:
name: example
As you may guess, nothing special here as well, once a while we are running sync script
$secretName = 'example'
$namespace = (Get-Content /var/run/secrets/kubernetes.io/serviceaccount/namespace)
$body = @{
apiVersion = 'v1'
kind = 'Secret'
metadata = @{
name = $secretName
namespace = $namespace
}
data = @{
# foo = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes('bar'))
}
}
$secret = Invoke-RestMethod "https://$($env:KUBERNETES_SERVICE_HOST)/api/v1/namespaces/$namespace/secrets/$secretName" -Headers @{ authorization = "Bearer $(Get-Content /var/run/secrets/kubernetes.io/serviceaccount/token)" } -SkipCertificateCheck -SkipHttpErrorCheck -StatusCodeVariable status
if ($status -ne 200) {
$secret = Invoke-RestMethod -Method Post "https://$($env:KUBERNETES_SERVICE_HOST)/api/v1/namespaces/$namespace/secrets" -Headers @{ authorization = "Bearer $(Get-Content /var/run/secrets/kubernetes.io/serviceaccount/token)" } -SkipCertificateCheck -ContentType "application/json" -Body (ConvertTo-Json -Depth 100 -InputObject $body)
}
else {
$secret.data.PSObject.Properties | ForEach-Object {
$body.data[$_.Name] = $_.Value
}
}
$changed = $false
$token = $(Invoke-RestMethod -Method Post "https://login.microsoftonline.com/$($env:AZURE_TENANT_ID)/oauth2/v2.0/token" -ContentType "application/x-www-form-urlencoded" -Body "scope=https%3A%2F%2Fmactemp3.vault.azure.net%2F.default&client_id=$($env:AZURE_CLIENT_ID)&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer&client_assertion=$(Get-Content $env:AZURE_FEDERATED_TOKEN_FILE)&grant_type=client_credentials" | Select-Object -ExpandProperty access_token)
$urls = Invoke-RestMethod "https://example.vault.azure.net/secrets?api-version=7.4" -Headers @{ authorization = "Bearer $token" } | Select-Object -ExpandProperty value | Select-Object -ExpandProperty id
foreach ($url in $urls) {
$name = $url.Split("/")[-1]
$value = Invoke-RestMethod "$($url)?api-version=7.4" -Headers @{ authorization = "Bearer $token" } | Select-Object -ExpandProperty value
$encoded = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($value))
if ($encoded -ne $body.data[$name]) {
$body.data.$name = $encoded
$changed = $true
}
}
if ($changed) {
Invoke-RestMethod -Method Put "https://$($env:KUBERNETES_SERVICE_HOST)/api/v1/namespaces/$namespace/secrets/$secretName" -Headers @{ authorization = "Bearer $(Get-Content /var/run/secrets/kubernetes.io/serviceaccount/token)" } -SkipCertificateCheck -ContentType "application/json" -Body (ConvertTo-Json -Depth 100 -InputObject $body)
}
else {
Write-Host "No changes"
}
here we are exchanging kubernetes token provided by azure workload identity to access token
retrieving list of secrets from key vault
and updating kubernetes secret if needed
profit
Why I do like this approach - it is 40 LoC of plain script that we may run any time we want, compare this to number of thigs being deployed by default out of the box by