diff --git a/internal/cnpgi/operator/lifecycle.go b/internal/cnpgi/operator/lifecycle.go index 8a0bbb50..1149a16b 100644 --- a/internal/cnpgi/operator/lifecycle.go +++ b/internal/cnpgi/operator/lifecycle.go @@ -145,18 +145,25 @@ func (impl LifecycleImplementation) reconcileJob( return nil, err } + useAzureWorkloadIdentity, err := impl.collectAzureWorkloadIdentityUsage(ctx, pluginConfiguration) + if err != nil { + return nil, err + } + return reconcileJob(ctx, cluster, request, sidecarConfiguration{ - env: env, - certificates: certificates, - resources: resources, + env: env, + certificates: certificates, + resources: resources, + useAzureWorkloadIdentity: useAzureWorkloadIdentity, }) } type sidecarConfiguration struct { - env []corev1.EnvVar - certificates []corev1.VolumeProjection - resources corev1.ResourceRequirements - additionalArgs []string + env []corev1.EnvVar + certificates []corev1.VolumeProjection + resources corev1.ResourceRequirements + additionalArgs []string + useAzureWorkloadIdentity bool } func reconcileJob( @@ -243,11 +250,17 @@ func (impl LifecycleImplementation) reconcilePod( return nil, err } + useAzureWorkloadIdentity, err := impl.collectAzureWorkloadIdentityUsage(ctx, pluginConfiguration) + if err != nil { + return nil, err + } + return reconcileInstancePod(ctx, cluster, request, pluginConfiguration, sidecarConfiguration{ - env: env, - certificates: certificates, - resources: resources, - additionalArgs: additionalArgs, + env: env, + certificates: certificates, + resources: resources, + additionalArgs: additionalArgs, + useAzureWorkloadIdentity: useAzureWorkloadIdentity, }) } @@ -301,6 +314,25 @@ func (impl LifecycleImplementation) collectAdditionalInstanceArgs( return nil, nil } +func (impl LifecycleImplementation) collectAzureWorkloadIdentityUsage( + ctx context.Context, + pluginConfiguration *config.PluginConfiguration, +) (bool, error) { + for _, objectKey := range pluginConfiguration.GetReferredBarmanObjectsKey() { + var objectStore barmancloudv1.ObjectStore + if err := impl.Client.Get(ctx, objectKey, &objectStore); err != nil { + return false, fmt.Errorf("while getting object store %s: %w", objectKey.String(), err) + } + + if objectStore.Spec.Configuration.Azure != nil && + objectStore.Spec.Configuration.Azure.UseDefaultAzureCredentials { + return true, nil + } + } + + return false, nil +} + func reconcileInstancePod( ctx context.Context, cluster *cnpgv1.Cluster, @@ -379,6 +411,13 @@ func reconcilePodSpec( }, ) + if config.useAzureWorkloadIdentity { + envs = append(envs, corev1.EnvVar{ + Name: "AZURE_FEDERATED_TOKEN_FILE", + Value: azureFederatedTokenFilePath, + }) + } + envs = append(envs, config.env...) baseProbe := &corev1.Probe{ @@ -466,6 +505,34 @@ func reconcilePodSpec( spec.Volumes = removeVolume(spec.Volumes, barmanCertificatesVolumeName) } + if config.useAzureWorkloadIdentity { + sidecarTemplate.VolumeMounts = ensureVolumeMount( + sidecarTemplate.VolumeMounts, + corev1.VolumeMount{ + Name: azureFederatedTokenVolumeName, + MountPath: azureFederatedTokenMountPath, + ReadOnly: true, + }, + ) + + spec.Volumes = ensureVolume(spec.Volumes, corev1.Volume{ + Name: azureFederatedTokenVolumeName, + VolumeSource: corev1.VolumeSource{ + Projected: &corev1.ProjectedVolumeSource{ + Sources: []corev1.VolumeProjection{ + { + ServiceAccountToken: &corev1.ServiceAccountTokenProjection{ + Path: azureFederatedTokenFileName, + Audience: azureFederatedTokenAudience, + ExpirationSeconds: ptr.To[int64](azureFederatedTokenExpirationSeconds), + }, + }, + }, + }, + }, + }) + } + if err := injectPluginSidecarPodSpec(spec, &sidecarTemplate, mainContainerName); err != nil { return err } @@ -473,6 +540,15 @@ func reconcilePodSpec( return nil } +const ( + azureFederatedTokenVolumeName = "azure-identity-token" + azureFederatedTokenMountPath = "/var/run/secrets/azure/tokens" + azureFederatedTokenFileName = "azure-identity-token" + azureFederatedTokenFilePath = azureFederatedTokenMountPath + "/" + azureFederatedTokenFileName + azureFederatedTokenAudience = "api://AzureADTokenExchange" + azureFederatedTokenExpirationSeconds = 3600 +) + // TODO: move to machinery once the logic is finalized // InjectPluginVolumePodSpec injects the plugin volume into a CNPG Pod spec. diff --git a/internal/cnpgi/operator/lifecycle_test.go b/internal/cnpgi/operator/lifecycle_test.go index 9d70a84c..a8c16285 100644 --- a/internal/cnpgi/operator/lifecycle_test.go +++ b/internal/cnpgi/operator/lifecycle_test.go @@ -22,6 +22,7 @@ package operator import ( "encoding/json" + barmanapi "github.com/cloudnative-pg/barman-cloud/pkg/api" cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1" "github.com/cloudnative-pg/cloudnative-pg/pkg/utils" "github.com/cloudnative-pg/cnpg-i/pkg/lifecycle" @@ -387,6 +388,134 @@ var _ = Describe("LifecycleImplementation", func() { Expect(args).To(Equal([]string{"--log-level=info"})) }) }) + + Describe("collectAzureWorkloadIdentityUsage", func() { + It("returns true when any referred object store uses default Azure credentials", func(ctx SpecContext) { + ns := "test-ns" + cluster := &cnpgv1.Cluster{ObjectMeta: metav1.ObjectMeta{Name: "c", Namespace: ns}} + pc := &config.PluginConfiguration{ + Cluster: cluster, + BarmanObjectName: "primary-store", + RecoveryBarmanObjectName: "recovery-store", + } + primaryStore := &barmancloudv1.ObjectStore{ + ObjectMeta: metav1.ObjectMeta{Name: pc.BarmanObjectName, Namespace: ns}, + Spec: barmancloudv1.ObjectStoreSpec{ + Configuration: barmanapi.BarmanObjectStoreConfiguration{ + BarmanCredentials: barmanapi.BarmanCredentials{ + Azure: &barmanapi.AzureCredentials{UseDefaultAzureCredentials: true}, + }, + }, + }, + } + recoveryStore := &barmancloudv1.ObjectStore{ + ObjectMeta: metav1.ObjectMeta{Name: pc.RecoveryBarmanObjectName, Namespace: ns}, + } + cli := buildClientFunc(primaryStore, recoveryStore).Build() + + impl := LifecycleImplementation{Client: cli} + useWorkloadIdentity, err := impl.collectAzureWorkloadIdentityUsage(ctx, pc) + Expect(err).NotTo(HaveOccurred()) + Expect(useWorkloadIdentity).To(BeTrue()) + }) + + It("returns false when none of the referred object stores use default Azure credentials", func(ctx SpecContext) { + ns := "test-ns" + cluster := &cnpgv1.Cluster{ObjectMeta: metav1.ObjectMeta{Name: "c", Namespace: ns}} + pc := &config.PluginConfiguration{ + Cluster: cluster, + BarmanObjectName: "primary-store", + } + primaryStore := &barmancloudv1.ObjectStore{ + ObjectMeta: metav1.ObjectMeta{Name: pc.BarmanObjectName, Namespace: ns}, + Spec: barmancloudv1.ObjectStoreSpec{ + Configuration: barmanapi.BarmanObjectStoreConfiguration{ + BarmanCredentials: barmanapi.BarmanCredentials{ + Azure: &barmanapi.AzureCredentials{InheritFromAzureAD: true}, + }, + }, + }, + } + cli := buildClientFunc(primaryStore).Build() + + impl := LifecycleImplementation{Client: cli} + useWorkloadIdentity, err := impl.collectAzureWorkloadIdentityUsage(ctx, pc) + Expect(err).NotTo(HaveOccurred()) + Expect(useWorkloadIdentity).To(BeFalse()) + }) + }) +}) + +var _ = Describe("reconcilePodSpec", func() { + It("injects the Azure federated token volume and env when workload identity is enabled", func() { + cluster := &cnpgv1.Cluster{ObjectMeta: metav1.ObjectMeta{Name: "cluster-1", Namespace: "ns-1"}} + spec := &corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "postgres", + Env: []corev1.EnvVar{ + {Name: "AZURE_CLIENT_ID", Value: "client-id"}, + }, + }, + }, + } + + err := reconcilePodSpec( + cluster, + spec, + "postgres", + corev1.Container{Args: []string{"instance"}}, + sidecarConfiguration{useAzureWorkloadIdentity: true}, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(spec.Volumes).To(ContainElement(HaveField("Name", azureFederatedTokenVolumeName))) + Expect(spec.InitContainers).To(HaveLen(1)) + Expect(spec.InitContainers[0].Env).To(ContainElement(corev1.EnvVar{ + Name: "AZURE_FEDERATED_TOKEN_FILE", + Value: azureFederatedTokenFilePath, + })) + Expect(spec.InitContainers[0].Env).To(ContainElement(corev1.EnvVar{ + Name: "AZURE_CLIENT_ID", + Value: "client-id", + })) + Expect(spec.InitContainers[0].VolumeMounts).To(ContainElement(corev1.VolumeMount{ + Name: azureFederatedTokenVolumeName, + MountPath: azureFederatedTokenMountPath, + ReadOnly: true, + })) + }) + + It("does not override an existing federated token file env", func() { + cluster := &cnpgv1.Cluster{ObjectMeta: metav1.ObjectMeta{Name: "cluster-1", Namespace: "ns-1"}} + spec := &corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "postgres", + Env: []corev1.EnvVar{ + {Name: "AZURE_FEDERATED_TOKEN_FILE", Value: "/custom/token"}, + }, + }, + }, + } + + err := reconcilePodSpec( + cluster, + spec, + "postgres", + corev1.Container{Args: []string{"instance"}}, + sidecarConfiguration{useAzureWorkloadIdentity: true}, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(spec.InitContainers).To(HaveLen(1)) + Expect(spec.InitContainers[0].Env).To(ContainElement(corev1.EnvVar{ + Name: "AZURE_FEDERATED_TOKEN_FILE", + Value: "/custom/token", + })) + Expect(spec.InitContainers[0].Env).NotTo(ContainElement(corev1.EnvVar{ + Name: "AZURE_FEDERATED_TOKEN_FILE", + Value: azureFederatedTokenFilePath, + })) + }) }) var _ = Describe("Volume utilities", func() { diff --git a/web/docs/object_stores.md b/web/docs/object_stores.md index 11b1ff8c..12dabea4 100644 --- a/web/docs/object_stores.md +++ b/web/docs/object_stores.md @@ -272,9 +272,10 @@ flow, which uses [`DefaultAzureCredential`](https://learn.microsoft.com/en-us/py to automatically discover and use available credentials in the following order: 1. **Environment Variables** — `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, and `AZURE_TENANT_ID` for Service Principal authentication -2. **Managed Identity** — Uses the managed identity assigned to the pod -3. **Azure CLI** — Uses credentials from the Azure CLI if available -4. **Azure PowerShell** — Uses credentials from Azure PowerShell if available +2. **Workload Identity** — Uses `AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, and a federated service account token +3. **Managed Identity** — Uses the managed identity assigned to the pod +4. **Azure CLI** — Uses credentials from the Azure CLI if available +5. **Azure PowerShell** — Uses credentials from Azure PowerShell if available This approach is particularly useful for getting started with development and testing; it allows the SDK to attempt multiple authentication mechanisms seamlessly across different environments. @@ -295,6 +296,30 @@ spec: [...] ``` +When `useDefaultAzureCredentials: true` is set, the plugin sidecar projects a +service account token with the Azure workload identity audience and exposes it +as `AZURE_FEDERATED_TOKEN_FILE`. If your platform does not already inject +`AZURE_CLIENT_ID` and `AZURE_TENANT_ID`, you can provide them through +`.spec.instanceSidecarConfiguration.env`: + +```yaml +apiVersion: barmancloud.cnpg.io/v1 +kind: ObjectStore +metadata: + name: azure-store +spec: + configuration: + destinationPath: "" + azureCredentials: + useDefaultAzureCredentials: true + instanceSidecarConfiguration: + env: + - name: AZURE_CLIENT_ID + value: "" + - name: AZURE_TENANT_ID + value: "" +``` + ### Access Key, SAS Token, or Connection String Store credentials in a Kubernetes secret: