Skip to content

Commit 2a70f54

Browse files
committed
Add Azure workload identity support
Signed-off-by: rkthtrifork <rkth@trifork.com>
1 parent 9b92ec9 commit 2a70f54

File tree

3 files changed

+244
-14
lines changed

3 files changed

+244
-14
lines changed

internal/cnpgi/operator/lifecycle.go

Lines changed: 87 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -145,18 +145,25 @@ func (impl LifecycleImplementation) reconcileJob(
145145
return nil, err
146146
}
147147

148+
useAzureWorkloadIdentity, err := impl.collectAzureWorkloadIdentityUsage(ctx, pluginConfiguration)
149+
if err != nil {
150+
return nil, err
151+
}
152+
148153
return reconcileJob(ctx, cluster, request, sidecarConfiguration{
149-
env: env,
150-
certificates: certificates,
151-
resources: resources,
154+
env: env,
155+
certificates: certificates,
156+
resources: resources,
157+
useAzureWorkloadIdentity: useAzureWorkloadIdentity,
152158
})
153159
}
154160

155161
type sidecarConfiguration struct {
156-
env []corev1.EnvVar
157-
certificates []corev1.VolumeProjection
158-
resources corev1.ResourceRequirements
159-
additionalArgs []string
162+
env []corev1.EnvVar
163+
certificates []corev1.VolumeProjection
164+
resources corev1.ResourceRequirements
165+
additionalArgs []string
166+
useAzureWorkloadIdentity bool
160167
}
161168

162169
func reconcileJob(
@@ -243,11 +250,17 @@ func (impl LifecycleImplementation) reconcilePod(
243250
return nil, err
244251
}
245252

253+
useAzureWorkloadIdentity, err := impl.collectAzureWorkloadIdentityUsage(ctx, pluginConfiguration)
254+
if err != nil {
255+
return nil, err
256+
}
257+
246258
return reconcileInstancePod(ctx, cluster, request, pluginConfiguration, sidecarConfiguration{
247-
env: env,
248-
certificates: certificates,
249-
resources: resources,
250-
additionalArgs: additionalArgs,
259+
env: env,
260+
certificates: certificates,
261+
resources: resources,
262+
additionalArgs: additionalArgs,
263+
useAzureWorkloadIdentity: useAzureWorkloadIdentity,
251264
})
252265
}
253266

@@ -301,6 +314,25 @@ func (impl LifecycleImplementation) collectAdditionalInstanceArgs(
301314
return nil, nil
302315
}
303316

317+
func (impl LifecycleImplementation) collectAzureWorkloadIdentityUsage(
318+
ctx context.Context,
319+
pluginConfiguration *config.PluginConfiguration,
320+
) (bool, error) {
321+
for _, objectKey := range pluginConfiguration.GetReferredBarmanObjectsKey() {
322+
var objectStore barmancloudv1.ObjectStore
323+
if err := impl.Client.Get(ctx, objectKey, &objectStore); err != nil {
324+
return false, fmt.Errorf("while getting object store %s: %w", objectKey.String(), err)
325+
}
326+
327+
if objectStore.Spec.Configuration.Azure != nil &&
328+
objectStore.Spec.Configuration.Azure.UseDefaultAzureCredentials {
329+
return true, nil
330+
}
331+
}
332+
333+
return false, nil
334+
}
335+
304336
func reconcileInstancePod(
305337
ctx context.Context,
306338
cluster *cnpgv1.Cluster,
@@ -379,6 +411,13 @@ func reconcilePodSpec(
379411
},
380412
)
381413

414+
if config.useAzureWorkloadIdentity {
415+
envs = append(envs, corev1.EnvVar{
416+
Name: "AZURE_FEDERATED_TOKEN_FILE",
417+
Value: azureFederatedTokenFilePath,
418+
})
419+
}
420+
382421
envs = append(envs, config.env...)
383422

384423
baseProbe := &corev1.Probe{
@@ -466,13 +505,50 @@ func reconcilePodSpec(
466505
spec.Volumes = removeVolume(spec.Volumes, barmanCertificatesVolumeName)
467506
}
468507

508+
if config.useAzureWorkloadIdentity {
509+
sidecarTemplate.VolumeMounts = ensureVolumeMount(
510+
sidecarTemplate.VolumeMounts,
511+
corev1.VolumeMount{
512+
Name: azureFederatedTokenVolumeName,
513+
MountPath: azureFederatedTokenMountPath,
514+
ReadOnly: true,
515+
},
516+
)
517+
518+
spec.Volumes = ensureVolume(spec.Volumes, corev1.Volume{
519+
Name: azureFederatedTokenVolumeName,
520+
VolumeSource: corev1.VolumeSource{
521+
Projected: &corev1.ProjectedVolumeSource{
522+
Sources: []corev1.VolumeProjection{
523+
{
524+
ServiceAccountToken: &corev1.ServiceAccountTokenProjection{
525+
Path: azureFederatedTokenFileName,
526+
Audience: azureFederatedTokenAudience,
527+
ExpirationSeconds: ptr.To[int64](azureFederatedTokenExpirationSeconds),
528+
},
529+
},
530+
},
531+
},
532+
},
533+
})
534+
}
535+
469536
if err := injectPluginSidecarPodSpec(spec, &sidecarTemplate, mainContainerName); err != nil {
470537
return err
471538
}
472539

473540
return nil
474541
}
475542

543+
const (
544+
azureFederatedTokenVolumeName = "azure-identity-token"
545+
azureFederatedTokenMountPath = "/var/run/secrets/azure/tokens"
546+
azureFederatedTokenFileName = "azure-identity-token"
547+
azureFederatedTokenFilePath = azureFederatedTokenMountPath + "/" + azureFederatedTokenFileName
548+
azureFederatedTokenAudience = "api://AzureADTokenExchange"
549+
azureFederatedTokenExpirationSeconds = 3600
550+
)
551+
476552
// TODO: move to machinery once the logic is finalized
477553

478554
// InjectPluginVolumePodSpec injects the plugin volume into a CNPG Pod spec.

internal/cnpgi/operator/lifecycle_test.go

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ package operator
2222
import (
2323
"encoding/json"
2424

25+
barmanapi "github.com/cloudnative-pg/barman-cloud/pkg/api"
2526
cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1"
2627
"github.com/cloudnative-pg/cloudnative-pg/pkg/utils"
2728
"github.com/cloudnative-pg/cnpg-i/pkg/lifecycle"
@@ -387,6 +388,134 @@ var _ = Describe("LifecycleImplementation", func() {
387388
Expect(args).To(Equal([]string{"--log-level=info"}))
388389
})
389390
})
391+
392+
Describe("collectAzureWorkloadIdentityUsage", func() {
393+
It("returns true when any referred object store uses default Azure credentials", func(ctx SpecContext) {
394+
ns := "test-ns"
395+
cluster := &cnpgv1.Cluster{ObjectMeta: metav1.ObjectMeta{Name: "c", Namespace: ns}}
396+
pc := &config.PluginConfiguration{
397+
Cluster: cluster,
398+
BarmanObjectName: "primary-store",
399+
RecoveryBarmanObjectName: "recovery-store",
400+
}
401+
primaryStore := &barmancloudv1.ObjectStore{
402+
ObjectMeta: metav1.ObjectMeta{Name: pc.BarmanObjectName, Namespace: ns},
403+
Spec: barmancloudv1.ObjectStoreSpec{
404+
Configuration: barmanapi.BarmanObjectStoreConfiguration{
405+
BarmanCredentials: barmanapi.BarmanCredentials{
406+
Azure: &barmanapi.AzureCredentials{UseDefaultAzureCredentials: true},
407+
},
408+
},
409+
},
410+
}
411+
recoveryStore := &barmancloudv1.ObjectStore{
412+
ObjectMeta: metav1.ObjectMeta{Name: pc.RecoveryBarmanObjectName, Namespace: ns},
413+
}
414+
cli := buildClientFunc(primaryStore, recoveryStore).Build()
415+
416+
impl := LifecycleImplementation{Client: cli}
417+
useWorkloadIdentity, err := impl.collectAzureWorkloadIdentityUsage(ctx, pc)
418+
Expect(err).NotTo(HaveOccurred())
419+
Expect(useWorkloadIdentity).To(BeTrue())
420+
})
421+
422+
It("returns false when none of the referred object stores use default Azure credentials", func(ctx SpecContext) {
423+
ns := "test-ns"
424+
cluster := &cnpgv1.Cluster{ObjectMeta: metav1.ObjectMeta{Name: "c", Namespace: ns}}
425+
pc := &config.PluginConfiguration{
426+
Cluster: cluster,
427+
BarmanObjectName: "primary-store",
428+
}
429+
primaryStore := &barmancloudv1.ObjectStore{
430+
ObjectMeta: metav1.ObjectMeta{Name: pc.BarmanObjectName, Namespace: ns},
431+
Spec: barmancloudv1.ObjectStoreSpec{
432+
Configuration: barmanapi.BarmanObjectStoreConfiguration{
433+
BarmanCredentials: barmanapi.BarmanCredentials{
434+
Azure: &barmanapi.AzureCredentials{InheritFromAzureAD: true},
435+
},
436+
},
437+
},
438+
}
439+
cli := buildClientFunc(primaryStore).Build()
440+
441+
impl := LifecycleImplementation{Client: cli}
442+
useWorkloadIdentity, err := impl.collectAzureWorkloadIdentityUsage(ctx, pc)
443+
Expect(err).NotTo(HaveOccurred())
444+
Expect(useWorkloadIdentity).To(BeFalse())
445+
})
446+
})
447+
})
448+
449+
var _ = Describe("reconcilePodSpec", func() {
450+
It("injects the Azure federated token volume and env when workload identity is enabled", func() {
451+
cluster := &cnpgv1.Cluster{ObjectMeta: metav1.ObjectMeta{Name: "cluster-1", Namespace: "ns-1"}}
452+
spec := &corev1.PodSpec{
453+
Containers: []corev1.Container{
454+
{
455+
Name: "postgres",
456+
Env: []corev1.EnvVar{
457+
{Name: "AZURE_CLIENT_ID", Value: "client-id"},
458+
},
459+
},
460+
},
461+
}
462+
463+
err := reconcilePodSpec(
464+
cluster,
465+
spec,
466+
"postgres",
467+
corev1.Container{Args: []string{"instance"}},
468+
sidecarConfiguration{useAzureWorkloadIdentity: true},
469+
)
470+
Expect(err).NotTo(HaveOccurred())
471+
Expect(spec.Volumes).To(ContainElement(HaveField("Name", azureFederatedTokenVolumeName)))
472+
Expect(spec.InitContainers).To(HaveLen(1))
473+
Expect(spec.InitContainers[0].Env).To(ContainElement(corev1.EnvVar{
474+
Name: "AZURE_FEDERATED_TOKEN_FILE",
475+
Value: azureFederatedTokenFilePath,
476+
}))
477+
Expect(spec.InitContainers[0].Env).To(ContainElement(corev1.EnvVar{
478+
Name: "AZURE_CLIENT_ID",
479+
Value: "client-id",
480+
}))
481+
Expect(spec.InitContainers[0].VolumeMounts).To(ContainElement(corev1.VolumeMount{
482+
Name: azureFederatedTokenVolumeName,
483+
MountPath: azureFederatedTokenMountPath,
484+
ReadOnly: true,
485+
}))
486+
})
487+
488+
It("does not override an existing federated token file env", func() {
489+
cluster := &cnpgv1.Cluster{ObjectMeta: metav1.ObjectMeta{Name: "cluster-1", Namespace: "ns-1"}}
490+
spec := &corev1.PodSpec{
491+
Containers: []corev1.Container{
492+
{
493+
Name: "postgres",
494+
Env: []corev1.EnvVar{
495+
{Name: "AZURE_FEDERATED_TOKEN_FILE", Value: "/custom/token"},
496+
},
497+
},
498+
},
499+
}
500+
501+
err := reconcilePodSpec(
502+
cluster,
503+
spec,
504+
"postgres",
505+
corev1.Container{Args: []string{"instance"}},
506+
sidecarConfiguration{useAzureWorkloadIdentity: true},
507+
)
508+
Expect(err).NotTo(HaveOccurred())
509+
Expect(spec.InitContainers).To(HaveLen(1))
510+
Expect(spec.InitContainers[0].Env).To(ContainElement(corev1.EnvVar{
511+
Name: "AZURE_FEDERATED_TOKEN_FILE",
512+
Value: "/custom/token",
513+
}))
514+
Expect(spec.InitContainers[0].Env).NotTo(ContainElement(corev1.EnvVar{
515+
Name: "AZURE_FEDERATED_TOKEN_FILE",
516+
Value: azureFederatedTokenFilePath,
517+
}))
518+
})
390519
})
391520

392521
var _ = Describe("Volume utilities", func() {

web/docs/object_stores.md

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -272,9 +272,10 @@ flow, which uses [`DefaultAzureCredential`](https://learn.microsoft.com/en-us/py
272272
to automatically discover and use available credentials in the following order:
273273

274274
1. **Environment Variables**`AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, and `AZURE_TENANT_ID` for Service Principal authentication
275-
2. **Managed Identity** — Uses the managed identity assigned to the pod
276-
3. **Azure CLI** — Uses credentials from the Azure CLI if available
277-
4. **Azure PowerShell** — Uses credentials from Azure PowerShell if available
275+
2. **Workload Identity** — Uses `AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, and a federated service account token
276+
3. **Managed Identity** — Uses the managed identity assigned to the pod
277+
4. **Azure CLI** — Uses credentials from the Azure CLI if available
278+
5. **Azure PowerShell** — Uses credentials from Azure PowerShell if available
278279

279280
This approach is particularly useful for getting started with development and testing; it allows
280281
the SDK to attempt multiple authentication mechanisms seamlessly across different environments.
@@ -295,6 +296,30 @@ spec:
295296
[...]
296297
```
297298

299+
When `useDefaultAzureCredentials: true` is set, the plugin sidecar projects a
300+
service account token with the Azure workload identity audience and exposes it
301+
as `AZURE_FEDERATED_TOKEN_FILE`. If your platform does not already inject
302+
`AZURE_CLIENT_ID` and `AZURE_TENANT_ID`, you can provide them through
303+
`.spec.instanceSidecarConfiguration.env`:
304+
305+
```yaml
306+
apiVersion: barmancloud.cnpg.io/v1
307+
kind: ObjectStore
308+
metadata:
309+
name: azure-store
310+
spec:
311+
configuration:
312+
destinationPath: "<destination path here>"
313+
azureCredentials:
314+
useDefaultAzureCredentials: true
315+
instanceSidecarConfiguration:
316+
env:
317+
- name: AZURE_CLIENT_ID
318+
value: "<managed-identity-client-id>"
319+
- name: AZURE_TENANT_ID
320+
value: "<tenant-id>"
321+
```
322+
298323
### Access Key, SAS Token, or Connection String
299324
300325
Store credentials in a Kubernetes secret:

0 commit comments

Comments
 (0)