Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions api/hypershift/v1beta1/hostedcluster_conditions.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,13 @@ const (
// A failure here indicates that the input is invalid, or permissions are missing to use the encryption key.
ValidAzureKMSConfig ConditionType = "ValidAzureKMSConfig"

// ValidAzureWorkloadIdentity indicates whether the required Azure Workload Identity
// configuration is present and valid in the HostedCluster spec. This validates that
// all mandatory managed identity client IDs are specified for self-managed Azure clusters
// using WorkloadIdentities authentication. A failure here indicates the cluster was
// created without running "hypershift create iam azure" or the spec is incomplete.
ValidAzureWorkloadIdentity ConditionType = "ValidAzureWorkloadIdentity"

// ValidGCPCredentials indicates if GCP credentials are valid and operational
// for the HostedCluster. This includes service account authentication and
// proper IAM permissions for CAPG controllers.
Expand Down Expand Up @@ -293,8 +300,11 @@ const (

InvalidIAMRoleReason = "InvalidIAMRole"

InvalidAzureCredentialsReason = "InvalidAzureCredentials"
AzureErrorReason = "AzureError"
InvalidAzureCredentialsReason = "InvalidAzureCredentials"
AzureWorkloadIdentityNotConfiguredReason = "WorkloadIdentityNotConfigured"
AzureWorkloadIdentityIncompleteReason = "WorkloadIdentityIncomplete"
AzureWorkloadIdentityValidReason = "ValidWorkloadIdentityConfiguration"
AzureErrorReason = "AzureError"

ExternalDNSHostNotReachableReason = "ExternalDNSHostNotReachable"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ func (a Azure) CAPIProviderDeploymentSpec(hcluster *hyperv1.HostedCluster, _ *hy

// For self-managed Azure with workload identity, instruct Azure SDK to use the minted SA token
if azureutil.IsSelfManagedAzureWithWorkloadIdentity(hcluster.Spec.Platform.Type, hcluster.Spec.Platform.Azure) {

deploymentSpec.Template.Spec.Containers[0].Env = append(
deploymentSpec.Template.Spec.Containers[0].Env,
corev1.EnvVar{
Expand Down Expand Up @@ -281,6 +282,15 @@ func (a Azure) ReconcileCredentials(ctx context.Context, c client.Client, create

// For self-managed Azure, use workload identity credentials; for managed Azure, use the existing approach
if azureutil.IsSelfManagedAzureWithWorkloadIdentity(hcluster.Spec.Platform.Type, hcluster.Spec.Platform.Azure) {
// Validate workload identity configuration before proceeding.
// TODO(RFE-9351 Phase 2): Set ValidAzureWorkloadIdentity condition directly on
// hcluster.Status.Conditions instead of relying on error propagation through
// PlatformCredentialsFound. This requires either changing ReconcileCredentials to
// return []metav1.Condition or moving validation to the controller level.
if condition := validateWorkloadIdentityConfig(hcluster); condition != nil && condition.Status == metav1.ConditionFalse {
return fmt.Errorf("azure workload identity validation failed: %s", condition.Message)
}

// Add federated token file for workload identity authentication
baseSecretData["azure_federated_token_file"] = []byte(path.Join(config.CloudTokenMountPath, "token"))

Expand Down Expand Up @@ -325,6 +335,7 @@ func (a Azure) ReconcileCredentials(ctx context.Context, c client.Client, create
secretData := maps.Clone(baseSecretData)
// For self-managed Azure with workload identities, add the network client ID
if azureutil.IsSelfManagedAzureWithWorkloadIdentity(hcluster.Spec.Platform.Type, hcluster.Spec.Platform.Azure) {

secretData["azure_client_id"] = []byte(hcluster.Spec.Platform.Azure.AzureAuthenticationConfig.WorkloadIdentities.Network.ClientID)
}
cloudNetworkConfigCreds.Data = secretData
Expand Down Expand Up @@ -486,3 +497,72 @@ func reconcileKMSConfigSecret(secret *corev1.Secret, hc *hyperv1.HostedCluster)

return nil
}

// validateWorkloadIdentityConfig validates that all required Azure Workload Identity
// client IDs are present in the HostedCluster spec. Returns a metav1.Condition
// reflecting the validation result. Currently the caller uses only the error path
// (condition.Status == ConditionFalse); the condition struct is retained so Phase 2
// can wire it directly onto HostedCluster.Status.Conditions.
func validateWorkloadIdentityConfig(hcluster *hyperv1.HostedCluster) *metav1.Condition {
azureSpec := hcluster.Spec.Platform.Azure
if azureSpec == nil {
return nil
}

authConfig := azureSpec.AzureAuthenticationConfig
if authConfig.AzureAuthenticationConfigType != hyperv1.AzureAuthenticationTypeWorkloadIdentities {
return nil
}

wi := authConfig.WorkloadIdentities
if wi == nil {
return &metav1.Condition{
Type: string(hyperv1.ValidAzureWorkloadIdentity),
Status: metav1.ConditionFalse,
Reason: hyperv1.AzureWorkloadIdentityNotConfiguredReason,
ObservedGeneration: hcluster.Generation,
Message: "workloadIdentities is not configured; run 'hypershift create iam azure' to create the required managed identities and federated credentials",
}
}

var missing []string
if wi.CloudProvider.ClientID == "" {
missing = append(missing, "cloudProvider")
}
if wi.NodePoolManagement.ClientID == "" {
missing = append(missing, "nodePoolManagement")
}
if wi.Ingress.ClientID == "" {
missing = append(missing, "ingress")
}
if wi.ImageRegistry.ClientID == "" {
missing = append(missing, "imageRegistry")
}
if wi.Disk.ClientID == "" {
missing = append(missing, "disk")
}
if wi.File.ClientID == "" {
missing = append(missing, "file")
}
if wi.Network.ClientID == "" {
missing = append(missing, "network")
}
Comment on lines +529 to +549

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Reject whitespace-only client IDs during WI validation.

clientID == "" allows values like " ", so invalid config can pass this gate and fail later with less actionable errors.

Suggested fix
-	if wi.CloudProvider.ClientID == "" {
+	if strings.TrimSpace(string(wi.CloudProvider.ClientID)) == "" {
 		missing = append(missing, "cloudProvider")
 	}
-	if wi.NodePoolManagement.ClientID == "" {
+	if strings.TrimSpace(string(wi.NodePoolManagement.ClientID)) == "" {
 		missing = append(missing, "nodePoolManagement")
 	}
-	if wi.Ingress.ClientID == "" {
+	if strings.TrimSpace(string(wi.Ingress.ClientID)) == "" {
 		missing = append(missing, "ingress")
 	}
-	if wi.ImageRegistry.ClientID == "" {
+	if strings.TrimSpace(string(wi.ImageRegistry.ClientID)) == "" {
 		missing = append(missing, "imageRegistry")
 	}
-	if wi.Disk.ClientID == "" {
+	if strings.TrimSpace(string(wi.Disk.ClientID)) == "" {
 		missing = append(missing, "disk")
 	}
-	if wi.File.ClientID == "" {
+	if strings.TrimSpace(string(wi.File.ClientID)) == "" {
 		missing = append(missing, "file")
 	}
-	if wi.Network.ClientID == "" {
+	if strings.TrimSpace(string(wi.Network.ClientID)) == "" {
 		missing = append(missing, "network")
 	}
As per coding guidelines, "`**/*.{py,js,ts,go,rs,java,rb,php,kt,swift,cs}`: Validate at trust boundaries with allow-lists, not deny-lists".
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if wi.CloudProvider.ClientID == "" {
missing = append(missing, "cloudProvider")
}
if wi.NodePoolManagement.ClientID == "" {
missing = append(missing, "nodePoolManagement")
}
if wi.Ingress.ClientID == "" {
missing = append(missing, "ingress")
}
if wi.ImageRegistry.ClientID == "" {
missing = append(missing, "imageRegistry")
}
if wi.Disk.ClientID == "" {
missing = append(missing, "disk")
}
if wi.File.ClientID == "" {
missing = append(missing, "file")
}
if wi.Network.ClientID == "" {
missing = append(missing, "network")
}
if strings.TrimSpace(string(wi.CloudProvider.ClientID)) == "" {
missing = append(missing, "cloudProvider")
}
if strings.TrimSpace(string(wi.NodePoolManagement.ClientID)) == "" {
missing = append(missing, "nodePoolManagement")
}
if strings.TrimSpace(string(wi.Ingress.ClientID)) == "" {
missing = append(missing, "ingress")
}
if strings.TrimSpace(string(wi.ImageRegistry.ClientID)) == "" {
missing = append(missing, "imageRegistry")
}
if strings.TrimSpace(string(wi.Disk.ClientID)) == "" {
missing = append(missing, "disk")
}
if strings.TrimSpace(string(wi.File.ClientID)) == "" {
missing = append(missing, "file")
}
if strings.TrimSpace(string(wi.Network.ClientID)) == "" {
missing = append(missing, "network")
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@hypershift-operator/controllers/hostedcluster/internal/platform/azure/azure.go`
around lines 529 - 549, The validation currently checks equality to empty string
which permits whitespace-only client IDs; update each check (e.g.,
wi.CloudProvider.ClientID, wi.NodePoolManagement.ClientID, wi.Ingress.ClientID,
wi.ImageRegistry.ClientID, wi.Disk.ClientID, wi.File.ClientID,
wi.Network.ClientID) to reject values that are empty after trimming whitespace
(use strings.TrimSpace or equivalent) so that purely whitespace client IDs are
treated as missing and appended to the missing slice.


if len(missing) > 0 {
return &metav1.Condition{
Type: string(hyperv1.ValidAzureWorkloadIdentity),
Status: metav1.ConditionFalse,
Reason: hyperv1.AzureWorkloadIdentityIncompleteReason,
ObservedGeneration: hcluster.Generation,
Message: fmt.Sprintf("workloadIdentities is missing required clientID for: %s", strings.Join(missing, ", ")),
}
}

return &metav1.Condition{
Type: string(hyperv1.ValidAzureWorkloadIdentity),
Status: metav1.ConditionTrue,
Reason: hyperv1.AzureWorkloadIdentityValidReason,
ObservedGeneration: hcluster.Generation,
Message: "All required workload identity client IDs are configured",
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,12 @@ func TestReconcileCredentials(t *testing.T) {
name: "self-managed Azure with workload identities creates all credential secrets",
managedService: "",
hcluster: createTestHostedCluster(true, &hyperv1.AzureWorkloadIdentities{
CloudProvider: hyperv1.WorkloadIdentity{
ClientID: "cloud-provider-client-id",
},
NodePoolManagement: hyperv1.WorkloadIdentity{
ClientID: "nodepool-mgmt-client-id",
},
Ingress: hyperv1.WorkloadIdentity{
ClientID: "ingress-client-id",
},
Expand Down Expand Up @@ -315,6 +321,12 @@ func TestReconcileCredentials(t *testing.T) {
managedService: "",
hcluster: func() *hyperv1.HostedCluster {
hc := createTestHostedCluster(true, &hyperv1.AzureWorkloadIdentities{
CloudProvider: hyperv1.WorkloadIdentity{
ClientID: "cloud-provider-client-id",
},
NodePoolManagement: hyperv1.WorkloadIdentity{
ClientID: "nodepool-mgmt-client-id",
},
Ingress: hyperv1.WorkloadIdentity{
ClientID: "ingress-client-id",
},
Expand Down Expand Up @@ -527,3 +539,139 @@ func TestReconcileKMSConfigSecret(t *testing.T) {
})
}
}

func TestValidateWorkloadIdentityConfig(t *testing.T) {
tests := []struct {
name string
hcluster *hyperv1.HostedCluster
expectNil bool
expectStatus metav1.ConditionStatus
expectReason string
expectContains string
}{
{
name: "non-Azure platform returns nil",
hcluster: &hyperv1.HostedCluster{
Spec: hyperv1.HostedClusterSpec{
Platform: hyperv1.PlatformSpec{
Type: hyperv1.AWSPlatform,
},
},
},
expectNil: true,
},
{
name: "Azure with ManagedIdentities auth returns nil",
hcluster: &hyperv1.HostedCluster{
Spec: hyperv1.HostedClusterSpec{
Platform: hyperv1.PlatformSpec{
Type: hyperv1.AzurePlatform,
Azure: &hyperv1.AzurePlatformSpec{
AzureAuthenticationConfig: hyperv1.AzureAuthenticationConfiguration{
AzureAuthenticationConfigType: hyperv1.AzureAuthenticationTypeManagedIdentities,
},
},
},
},
},
expectNil: true,
},
{
name: "WorkloadIdentities auth with nil WorkloadIdentities returns not configured",
hcluster: &hyperv1.HostedCluster{
ObjectMeta: metav1.ObjectMeta{Generation: 3},
Spec: hyperv1.HostedClusterSpec{
Platform: hyperv1.PlatformSpec{
Type: hyperv1.AzurePlatform,
Azure: &hyperv1.AzurePlatformSpec{
AzureAuthenticationConfig: hyperv1.AzureAuthenticationConfiguration{
AzureAuthenticationConfigType: hyperv1.AzureAuthenticationTypeWorkloadIdentities,
},
},
},
},
},
expectNil: false,
expectStatus: metav1.ConditionFalse,
expectReason: hyperv1.AzureWorkloadIdentityNotConfiguredReason,
expectContains: "not configured",
},
{
name: "WorkloadIdentities with missing clientIDs returns incomplete",
hcluster: &hyperv1.HostedCluster{
ObjectMeta: metav1.ObjectMeta{Generation: 5},
Spec: hyperv1.HostedClusterSpec{
Platform: hyperv1.PlatformSpec{
Type: hyperv1.AzurePlatform,
Azure: &hyperv1.AzurePlatformSpec{
AzureAuthenticationConfig: hyperv1.AzureAuthenticationConfiguration{
AzureAuthenticationConfigType: hyperv1.AzureAuthenticationTypeWorkloadIdentities,
WorkloadIdentities: &hyperv1.AzureWorkloadIdentities{
CloudProvider: hyperv1.WorkloadIdentity{ClientID: "client-1"},
NodePoolManagement: hyperv1.WorkloadIdentity{ClientID: "client-2"},
Ingress: hyperv1.WorkloadIdentity{ClientID: ""},
ImageRegistry: hyperv1.WorkloadIdentity{ClientID: "client-4"},
Disk: hyperv1.WorkloadIdentity{ClientID: "client-5"},
File: hyperv1.WorkloadIdentity{ClientID: ""},
Network: hyperv1.WorkloadIdentity{ClientID: "client-7"},
},
},
},
},
},
},
expectNil: false,
expectStatus: metav1.ConditionFalse,
expectReason: hyperv1.AzureWorkloadIdentityIncompleteReason,
expectContains: "ingress, file",
},
{
name: "WorkloadIdentities with all clientIDs returns valid",
hcluster: &hyperv1.HostedCluster{
ObjectMeta: metav1.ObjectMeta{Generation: 7},
Spec: hyperv1.HostedClusterSpec{
Platform: hyperv1.PlatformSpec{
Type: hyperv1.AzurePlatform,
Azure: &hyperv1.AzurePlatformSpec{
AzureAuthenticationConfig: hyperv1.AzureAuthenticationConfiguration{
AzureAuthenticationConfigType: hyperv1.AzureAuthenticationTypeWorkloadIdentities,
WorkloadIdentities: &hyperv1.AzureWorkloadIdentities{
CloudProvider: hyperv1.WorkloadIdentity{ClientID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"},
NodePoolManagement: hyperv1.WorkloadIdentity{ClientID: "aaaaaaaa-bbbb-cccc-dddd-ffffffffffff"},
Ingress: hyperv1.WorkloadIdentity{ClientID: "aaaaaaaa-bbbb-cccc-dddd-111111111111"},
ImageRegistry: hyperv1.WorkloadIdentity{ClientID: "aaaaaaaa-bbbb-cccc-dddd-222222222222"},
Disk: hyperv1.WorkloadIdentity{ClientID: "aaaaaaaa-bbbb-cccc-dddd-333333333333"},
File: hyperv1.WorkloadIdentity{ClientID: "aaaaaaaa-bbbb-cccc-dddd-444444444444"},
Network: hyperv1.WorkloadIdentity{ClientID: "aaaaaaaa-bbbb-cccc-dddd-555555555555"},
},
},
},
},
},
},
expectNil: false,
expectStatus: metav1.ConditionTrue,
expectReason: hyperv1.AzureWorkloadIdentityValidReason,
expectContains: "All required",
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
g := NewWithT(t)
condition := validateWorkloadIdentityConfig(tc.hcluster)

if tc.expectNil {
g.Expect(condition).To(BeNil())
return
}

g.Expect(condition).NotTo(BeNil())
g.Expect(condition.Type).To(Equal(string(hyperv1.ValidAzureWorkloadIdentity)))
g.Expect(condition.Status).To(Equal(tc.expectStatus))
g.Expect(condition.Reason).To(Equal(tc.expectReason))
g.Expect(condition.Message).To(ContainSubstring(tc.expectContains))
g.Expect(condition.ObservedGeneration).To(Equal(tc.hcluster.Generation))
})
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.