Skip to content

Commit 0f9258e

Browse files
authored
Merge pull request #2965 from willie-yao/identity-scope
Allow for configurable scope in system assigned identities
2 parents 0e8ff6b + 23617a1 commit 0f9258e

35 files changed

+1042
-160
lines changed

api/v1alpha3/azuremachine_conversion.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,10 @@ func (src *AzureMachine) ConvertTo(dstRaw conversion.Hub) error {
7878
dst.Spec.NetworkInterfaces = restored.Spec.NetworkInterfaces
7979
}
8080

81+
if restored.Spec.SystemAssignedIdentityRole != nil {
82+
dst.Spec.SystemAssignedIdentityRole = restored.Spec.SystemAssignedIdentityRole
83+
}
84+
8185
//nolint:staticcheck // SubnetName is now deprecated, but the v1beta1 defaulting webhook will migrate it to the networkInterfaces field
8286
dst.Spec.SubnetName = restored.Spec.SubnetName
8387

api/v1alpha3/azuremachinetemplate_conversion.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ func (src *AzureMachineTemplate) ConvertTo(dstRaw conversion.Hub) error {
8282
dst.Spec.Template.Spec.NetworkInterfaces = restored.Spec.Template.Spec.NetworkInterfaces
8383
}
8484

85+
if restored.Spec.Template.Spec.SystemAssignedIdentityRole != nil {
86+
dst.Spec.Template.Spec.SystemAssignedIdentityRole = restored.Spec.Template.Spec.SystemAssignedIdentityRole
87+
}
88+
8589
return nil
8690
}
8791

api/v1alpha3/zz_generated.conversion.go

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

api/v1alpha4/azuremachine_conversion.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ func (src *AzureMachine) ConvertTo(dstRaw conversion.Hub) error {
6464
dst.Spec.NetworkInterfaces = restored.Spec.NetworkInterfaces
6565
}
6666

67+
if restored.Spec.SystemAssignedIdentityRole != nil {
68+
dst.Spec.SystemAssignedIdentityRole = restored.Spec.SystemAssignedIdentityRole
69+
}
70+
6771
return nil
6872
}
6973

api/v1alpha4/azuremachinetemplate_conversion.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@ func (src *AzureMachineTemplate) ConvertTo(dstRaw conversion.Hub) error {
6666
dst.Spec.Template.Spec.NetworkInterfaces = restored.Spec.Template.Spec.NetworkInterfaces
6767
}
6868

69+
if restored.Spec.Template.Spec.SystemAssignedIdentityRole != nil {
70+
dst.Spec.Template.Spec.SystemAssignedIdentityRole = restored.Spec.Template.Spec.SystemAssignedIdentityRole
71+
}
72+
6973
return nil
7074
}
7175

api/v1alpha4/zz_generated.conversion.go

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

api/v1beta1/azuremachine_default.go

Lines changed: 81 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,24 @@ limitations under the License.
1717
package v1beta1
1818

1919
import (
20+
"context"
2021
"encoding/base64"
22+
"fmt"
23+
"time"
2124

2225
"github.com/Azure/azure-sdk-for-go/services/compute/mgmt/2021-11-01/compute"
26+
"github.com/pkg/errors"
2327
"golang.org/x/crypto/ssh"
2428
"k8s.io/apimachinery/pkg/util/uuid"
2529
utilSSH "sigs.k8s.io/cluster-api-provider-azure/util/ssh"
30+
clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1"
2631
ctrl "sigs.k8s.io/controller-runtime"
32+
"sigs.k8s.io/controller-runtime/pkg/client"
2733
)
2834

35+
// ContributorRoleID is the ID of the built-in "Contributor" role.
36+
const ContributorRoleID = "b24988ac-6180-42a0-ab88-20f7382dd24c"
37+
2938
// SetDefaultSSHPublicKey sets the default SSHPublicKey for an AzureMachine.
3039
func (s *AzureMachineSpec) SetDefaultSSHPublicKey() error {
3140
if sshKeyData := s.SSHPublicKey; sshKeyData == "" {
@@ -79,10 +88,32 @@ func (s *AzureMachineSpec) SetDataDisksDefaults() {
7988
}
8089

8190
// SetIdentityDefaults sets the defaults for VM Identity.
82-
func (s *AzureMachineSpec) SetIdentityDefaults() {
91+
func (s *AzureMachineSpec) SetIdentityDefaults(subscriptionID string) {
92+
// Ensure the deprecated fields and new fields are not populated simultaneously
93+
if s.RoleAssignmentName != "" && s.SystemAssignedIdentityRole != nil && s.SystemAssignedIdentityRole.Name != "" {
94+
// Both the deprecated and the new fields are both set, return without changes
95+
// and reject the request in the validating webhook which runs later.
96+
return
97+
}
8398
if s.Identity == VMIdentitySystemAssigned {
84-
if s.RoleAssignmentName == "" {
85-
s.RoleAssignmentName = string(uuid.NewUUID())
99+
if s.SystemAssignedIdentityRole == nil {
100+
s.SystemAssignedIdentityRole = &SystemAssignedIdentityRole{}
101+
}
102+
if s.RoleAssignmentName != "" {
103+
// Move the existing value from the deprecated RoleAssignmentName field.
104+
s.SystemAssignedIdentityRole.Name = s.RoleAssignmentName
105+
s.RoleAssignmentName = ""
106+
} else if s.SystemAssignedIdentityRole.Name == "" {
107+
// Default role name to a generated UUID.
108+
s.SystemAssignedIdentityRole.Name = string(uuid.NewUUID())
109+
}
110+
if s.SystemAssignedIdentityRole.Scope == "" && subscriptionID != "" {
111+
// Default scope to the subscription.
112+
s.SystemAssignedIdentityRole.Scope = fmt.Sprintf("/subscriptions/%s/", subscriptionID)
113+
}
114+
if s.SystemAssignedIdentityRole.DefinitionID == "" && subscriptionID != "" {
115+
// Default role definition ID to Contributor role.
116+
s.SystemAssignedIdentityRole.DefinitionID = fmt.Sprintf("/subscriptions/%s/providers/Microsoft.Authorization/roleDefinitions/%s", subscriptionID, ContributorRoleID)
86117
}
87118
}
88119
}
@@ -143,15 +174,52 @@ func (s *AzureMachineSpec) SetNetworkInterfacesDefaults() {
143174
}
144175
}
145176

177+
// GetSubscriptionID returns the subscription ID for the given cluster name and namespace.
178+
func GetSubscriptionID(cli client.Client, clusterName string, namespace string, maxAttempts int) (string, error) {
179+
ctx := context.Background()
180+
181+
ownerCluster := &AzureCluster{}
182+
key := client.ObjectKey{
183+
Namespace: namespace,
184+
Name: clusterName,
185+
}
186+
187+
for i := 1; ; i++ {
188+
err := cli.Get(ctx, key, ownerCluster)
189+
if err != nil {
190+
if i > maxAttempts {
191+
return "", errors.Wrapf(err, "failed to find owner cluster for %s/%s", namespace, clusterName)
192+
}
193+
time.Sleep(1 * time.Second)
194+
continue
195+
}
196+
break
197+
}
198+
199+
return ownerCluster.Spec.SubscriptionID, nil
200+
}
201+
146202
// SetDefaults sets to the defaults for the AzureMachineSpec.
147-
func (s *AzureMachineSpec) SetDefaults() {
148-
if err := s.SetDefaultSSHPublicKey(); err != nil {
149-
ctrl.Log.WithName("SetDefault").Error(err, "SetDefaultSshPublicKey failed")
150-
}
151-
s.SetDefaultCachingType()
152-
s.SetDataDisksDefaults()
153-
s.SetIdentityDefaults()
154-
s.SetSpotEvictionPolicyDefaults()
155-
s.SetDiagnosticsDefaults()
156-
s.SetNetworkInterfacesDefaults()
203+
func (m *AzureMachine) SetDefaults(client client.Client) {
204+
if err := m.Spec.SetDefaultSSHPublicKey(); err != nil {
205+
ctrl.Log.WithName("SetDefault").Error(err, "failed to set default SSH public key")
206+
}
207+
208+
// Fetch the Cluster.
209+
clusterName, ok := m.Labels[clusterv1.ClusterLabelName]
210+
if !ok {
211+
ctrl.Log.WithName("SetDefault").Error(errors.Errorf("failed to fetch owner ClusterName for AzureMachine %s/%s", m.Namespace, m.Name), "failed to fetch ClusterName")
212+
}
213+
214+
subscriptionID, err := GetSubscriptionID(client, clusterName, m.Namespace, 5)
215+
if err != nil {
216+
ctrl.Log.WithName("SetDefault").Error(err, "failed to fetch subscription ID for AzureMachine %s/%s", m.Namespace, m.Name)
217+
}
218+
219+
m.Spec.SetDefaultCachingType()
220+
m.Spec.SetDataDisksDefaults()
221+
m.Spec.SetIdentityDefaults(subscriptionID)
222+
m.Spec.SetSpotEvictionPolicyDefaults()
223+
m.Spec.SetDiagnosticsDefaults()
224+
m.Spec.SetNetworkInterfacesDefaults()
157225
}

api/v1beta1/azuremachine_default_test.go

Lines changed: 99 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,17 @@ limitations under the License.
1717
package v1beta1
1818

1919
import (
20+
"context"
2021
"encoding/json"
22+
"fmt"
2123
"reflect"
2224
"testing"
2325

2426
"github.com/google/uuid"
2527
. "github.com/onsi/gomega"
28+
"github.com/pkg/errors"
2629
"k8s.io/utils/pointer"
30+
"sigs.k8s.io/controller-runtime/pkg/client"
2731
)
2832

2933
func TestAzureMachineSpec_SetDefaultSSHPublicKey(t *testing.T) {
@@ -53,29 +57,50 @@ func TestAzureMachineSpec_SetIdentityDefaults(t *testing.T) {
5357
machine *AzureMachine
5458
}
5559

60+
fakeSubscriptionID := uuid.New().String()
61+
fakeClusterName := "testcluster"
62+
fakeRoleDefinitionID := "testroledefinitionid"
63+
fakeScope := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s", fakeSubscriptionID, fakeClusterName)
5664
existingRoleAssignmentName := "42862306-e485-4319-9bf0-35dbc6f6fe9c"
5765
roleAssignmentExistTest := test{machine: &AzureMachine{Spec: AzureMachineSpec{
58-
Identity: VMIdentitySystemAssigned,
59-
RoleAssignmentName: existingRoleAssignmentName,
60-
}}}
61-
roleAssignmentEmptyTest := test{machine: &AzureMachine{Spec: AzureMachineSpec{
62-
Identity: VMIdentitySystemAssigned,
63-
RoleAssignmentName: "",
66+
Identity: VMIdentitySystemAssigned,
67+
SystemAssignedIdentityRole: &SystemAssignedIdentityRole{
68+
Name: existingRoleAssignmentName,
69+
},
6470
}}}
6571
notSystemAssignedTest := test{machine: &AzureMachine{Spec: AzureMachineSpec{
66-
Identity: VMIdentityUserAssigned,
72+
Identity: VMIdentityUserAssigned,
73+
SystemAssignedIdentityRole: &SystemAssignedIdentityRole{},
74+
}}}
75+
systemAssignedIdentityRoleExistTest := test{machine: &AzureMachine{Spec: AzureMachineSpec{
76+
Identity: VMIdentitySystemAssigned,
77+
SystemAssignedIdentityRole: &SystemAssignedIdentityRole{
78+
Scope: fakeScope,
79+
DefinitionID: fakeRoleDefinitionID,
80+
},
6781
}}}
6882

69-
roleAssignmentExistTest.machine.Spec.SetIdentityDefaults()
70-
g.Expect(roleAssignmentExistTest.machine.Spec.RoleAssignmentName).To(Equal(existingRoleAssignmentName))
83+
emptyTest := test{machine: &AzureMachine{Spec: AzureMachineSpec{
84+
Identity: VMIdentitySystemAssigned,
85+
SystemAssignedIdentityRole: &SystemAssignedIdentityRole{},
86+
}}}
7187

72-
roleAssignmentEmptyTest.machine.Spec.SetIdentityDefaults()
73-
g.Expect(roleAssignmentEmptyTest.machine.Spec.RoleAssignmentName).To(Not(BeEmpty()))
74-
_, err := uuid.Parse(roleAssignmentEmptyTest.machine.Spec.RoleAssignmentName)
75-
g.Expect(err).To(Not(HaveOccurred()))
88+
roleAssignmentExistTest.machine.Spec.SetIdentityDefaults(fakeSubscriptionID)
89+
g.Expect(roleAssignmentExistTest.machine.Spec.SystemAssignedIdentityRole.Name).To(Equal(existingRoleAssignmentName))
90+
91+
notSystemAssignedTest.machine.Spec.SetIdentityDefaults(fakeSubscriptionID)
92+
g.Expect(notSystemAssignedTest.machine.Spec.SystemAssignedIdentityRole.Name).To(BeEmpty())
7693

77-
notSystemAssignedTest.machine.Spec.SetIdentityDefaults()
78-
g.Expect(notSystemAssignedTest.machine.Spec.RoleAssignmentName).To(BeEmpty())
94+
systemAssignedIdentityRoleExistTest.machine.Spec.SetIdentityDefaults(fakeSubscriptionID)
95+
g.Expect(systemAssignedIdentityRoleExistTest.machine.Spec.SystemAssignedIdentityRole.Scope).To(Equal(fakeScope))
96+
g.Expect(systemAssignedIdentityRoleExistTest.machine.Spec.SystemAssignedIdentityRole.DefinitionID).To(Equal(fakeRoleDefinitionID))
97+
98+
emptyTest.machine.Spec.SetIdentityDefaults(fakeSubscriptionID)
99+
g.Expect(emptyTest.machine.Spec.SystemAssignedIdentityRole.Name).To(Not(BeEmpty()))
100+
_, err := uuid.Parse(emptyTest.machine.Spec.SystemAssignedIdentityRole.Name)
101+
g.Expect(err).To(Not(HaveOccurred()))
102+
g.Expect(emptyTest.machine.Spec.SystemAssignedIdentityRole.Scope).To(Equal(fmt.Sprintf("/subscriptions/%s/", fakeSubscriptionID)))
103+
g.Expect(emptyTest.machine.Spec.SystemAssignedIdentityRole.DefinitionID).To(Equal(fmt.Sprintf("/subscriptions/%s/providers/Microsoft.Authorization/roleDefinitions/%s", fakeSubscriptionID, ContributorRoleID)))
79104
}
80105

81106
func TestAzureMachineSpec_SetDataDisksDefaults(t *testing.T) {
@@ -367,6 +392,65 @@ func TestAzureMachineSpec_SetNetworkInterfacesDefaults(t *testing.T) {
367392
}
368393
}
369394

395+
func TestAzureMachineSpec_GetSubscriptionID(t *testing.T) {
396+
g := NewWithT(t)
397+
398+
tests := []struct {
399+
name string
400+
maxAttempts int
401+
want string
402+
wantErr bool
403+
}{
404+
{
405+
name: "subscription ID is returned",
406+
maxAttempts: 1,
407+
want: "test-subscription-id",
408+
wantErr: false,
409+
},
410+
{
411+
name: "subscription ID is returned after 2 attempts",
412+
maxAttempts: 2,
413+
want: "test-subscription-id",
414+
wantErr: false,
415+
},
416+
{
417+
name: "subscription ID is not returned after 5 attempts",
418+
maxAttempts: 5,
419+
want: "",
420+
wantErr: true,
421+
},
422+
}
423+
424+
for _, tc := range tests {
425+
t.Run(tc.name, func(t *testing.T) {
426+
client := mockClient{ReturnError: tc.wantErr}
427+
result, err := GetSubscriptionID(client, "test-cluster", "default", tc.maxAttempts)
428+
if tc.wantErr {
429+
g.Expect(err).To(HaveOccurred())
430+
} else {
431+
g.Expect(err).NotTo(HaveOccurred())
432+
g.Expect(result).To(Equal(tc.want))
433+
}
434+
})
435+
}
436+
}
437+
438+
type mockClient struct {
439+
client.Client
440+
ReturnError bool
441+
}
442+
443+
func (m mockClient) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error {
444+
if m.ReturnError {
445+
return errors.New("AzureCluster not found: failed to find owner cluster for test-cluster")
446+
}
447+
ac := &AzureCluster{}
448+
ac.Spec.SubscriptionID = "test-subscription-id"
449+
obj.(*AzureCluster).Spec.SubscriptionID = ac.Spec.SubscriptionID
450+
451+
return nil
452+
}
453+
370454
func createMachineWithSSHPublicKey(sshPublicKey string) *AzureMachine {
371455
machine := hardcodedAzureMachineWithSSHKey(sshPublicKey)
372456
return machine

api/v1beta1/azuremachine_types.go

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,11 @@ type AzureMachineSpec struct {
6666
// +optional
6767
UserAssignedIdentities []UserAssignedIdentity `json:"userAssignedIdentities,omitempty"`
6868

69-
// RoleAssignmentName is the name of the role assignment to create for a system assigned identity. It can be any valid GUID.
70-
// If not specified, a random GUID will be generated.
69+
// SystemAssignedIdentityRole defines the role and scope to assign to the system-assigned identity.
70+
// +optional
71+
SystemAssignedIdentityRole *SystemAssignedIdentityRole `json:"systemAssignedIdentityRole,omitempty"`
72+
73+
// Deprecated: RoleAssignmentName should be set in the systemAssignedIdentityRole field.
7174
// +optional
7275
RoleAssignmentName string `json:"roleAssignmentName,omitempty"`
7376

@@ -149,6 +152,24 @@ type SpotVMOptions struct {
149152
EvictionPolicy *SpotEvictionPolicy `json:"evictionPolicy,omitempty"`
150153
}
151154

155+
// SystemAssignedIdentityRole defines the role and scope to assign to the system assigned identity.
156+
type SystemAssignedIdentityRole struct {
157+
// Name is the name of the role assignment to create for a system assigned identity. It can be any valid UUID.
158+
// If not specified, a random UUID will be generated.
159+
// +optional
160+
Name string `json:"name,omitempty"`
161+
162+
// DefinitionID is the ID of the role definition to create for a system assigned identity. It can be an Azure built-in role or a custom role.
163+
// Refer to built-in roles: https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles
164+
// +optional
165+
DefinitionID string `json:"definitionID,omitempty"`
166+
167+
// Scope is the scope that the role assignment or definition applies to. The scope can be any REST resource instance.
168+
// If not specified, the scope will be the subscription.
169+
// +optional
170+
Scope string `json:"scope,omitempty"`
171+
}
172+
152173
// AzureMachineStatus defines the observed state of AzureMachine.
153174
type AzureMachineStatus struct {
154175
// Ready is true when the provider resource is ready.

0 commit comments

Comments
 (0)