Skip to content

Commit f022e9a

Browse files
Add support for Entra SecurityGroup (#4768)
* Scaffold bare resource * Rename test file * Scaffold reconciler and registration * Add targets to tidy go.mod files * Update securitygroup * Create Entra client * Updates * Update imports * Checkpoint * Add annotation helpers and test * Update resource shape * Test updates too * Fix status after update * Add recording for test * Export EntraID as a secret * Remove debris * Updates based on personal review * Add sample * Fix lint issue * Improve documentation of CreationMode * Default CreationMode to AdoptOrCreate * Add IsAssignableToRole * Directly support all four kinds of membership * Default CreationMode via WebHook * Update test * Relocate validation * Fixup move of validation * Switch to using configmaps * go mod tidy * Remove duplicate imports * Tidy up test * Tidy up test * Fix test * Address PR feedback * Add additional fields to redact. * Update PR * Use more permissive interface for setting annotations * Remove unused method * Remove unused cloudConfig from EntraClientCache * Remove unused error return * Extend documentation on MailNickname * Use helper method to simplify guard clause * Update sample
1 parent eb49a15 commit f022e9a

31 files changed

+2355
-176
lines changed

Taskfile.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,14 @@ tasks:
106106
- task: generator:format-code
107107
- task: controller:format-code
108108

109+
go-mod-tidy:
110+
desc: Tidy the go.mod files
111+
dir : v2
112+
cmds:
113+
- task: controller:go-mod-tidy
114+
- task: asoctl:go-mod-tidy
115+
- task: generator:go-mod-tidy
116+
109117
build-docs-site:
110118
cmds:
111119
- task: doc:build-site
@@ -171,6 +179,12 @@ tasks:
171179
- cmd: gofumpt -l -w .
172180
ignore_error: true # Just in case the code doesn't build
173181

182+
asoctl:go-mod-tidy:
183+
desc: Tidy the go.mod file
184+
dir : v2/cmd/asoctl/
185+
cmds:
186+
- go mod tidy
187+
174188
asoctl:lint:
175189
desc: Run {{.ASOCTL_APP}} fast lint checks.
176190
dir: '{{.ASOCTL_ROOT}}'
@@ -261,6 +275,12 @@ tasks:
261275
- cmd: gofumpt -l -w .
262276
ignore_error: true # Just in case the code doesn't build
263277

278+
generator:go-mod-tidy:
279+
desc: Tidy the go.mod file
280+
dir : v2/tools/generator
281+
cmds:
282+
- go mod tidy
283+
264284
generator:lint:
265285
desc: Run {{.GENERATOR_APP}} lint checks.
266286
dir: '{{.GENERATOR_ROOT}}'
@@ -319,6 +339,12 @@ tasks:
319339
- cmd: gofumpt -l -w .
320340
ignore_error: true # Just in case the code doesn't build
321341

342+
controller:go-mod-tidy:
343+
desc: Tidy the go.mod file
344+
dir : v2
345+
cmds:
346+
- go mod tidy
347+
322348
controller:lint:
323349
desc: Run lint checks.
324350
deps:

v2/api/entra/v1/creation_mode.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
package v1
5+
6+
// CreationMode specifies how ASO will try to create the Entra resource.
7+
// +kubebuilder:validation:Enum=AdoptOrCreate;AlwaysCreate
8+
type CreationMode string
9+
10+
const (
11+
// AlwaysCreate means that ASO will always attempt to create the resource,
12+
// without first checking to see whether it already exists.
13+
AlwaysCreate CreationMode = "AlwaysCreate"
14+
15+
// AdoptOrCreate means that ASO will try to adopt an existing resource if it exists,
16+
// and can be uniquely identified.
17+
// If multiple matches are found, the resource condition will show an error.
18+
// If it does not exist, ASO will create a new resource.
19+
AdoptOrCreate CreationMode = "AdoptOrCreate"
20+
)
21+
22+
// AllowsCreation checks if the creation mode allows ASO to create a new resource.
23+
// All current modes do, but this could change in the future.
24+
func (cm CreationMode) AllowsCreation() bool {
25+
return cm == AlwaysCreate || cm == AdoptOrCreate
26+
}
27+
28+
// AllowsAdoption checks if the creation mode allows ASO to adopt an existing resource.
29+
func (cm CreationMode) AllowsAdoption() bool {
30+
return cm == AdoptOrCreate
31+
}

v2/api/entra/v1/doc.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/*
2+
Copyright (c) Microsoft Corporation.
3+
Licensed under the MIT license.
4+
*/
5+
6+
// Package v1 contains hand-crafted API Schema definitions for the entra v1 API group
7+
// +groupName=entra.azure.com
8+
package v1
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/*
2+
Copyright (c) Microsoft Corporation.
3+
Licensed under the MIT license.
4+
*/
5+
6+
// Package v1 contains API Schema definitions for entra data plane APIs
7+
// +kubebuilder:object:generate=true
8+
// All object properties are optional by default, this will be overridden when needed:
9+
// +kubebuilder:validation:Optional
10+
// +groupName=entra.azure.com
11+
package v1
12+
13+
import (
14+
"k8s.io/apimachinery/pkg/runtime/schema"
15+
"sigs.k8s.io/controller-runtime/pkg/scheme"
16+
)
17+
18+
var (
19+
// GroupVersion is group version used to register these objects
20+
GroupVersion = schema.GroupVersion{Group: "entra.azure.com", Version: "v1"}
21+
22+
// SchemeBuilder is used to add go types to the GroupVersionKind scheme
23+
SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion}
24+
25+
// AddToScheme adds the types in this group-version to the given scheme.
26+
AddToScheme = SchemeBuilder.AddToScheme
27+
)
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
package v1
4+
5+
import (
6+
"github.com/microsoftgraph/msgraph-sdk-go/models"
7+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
8+
"sigs.k8s.io/controller-runtime/pkg/conversion"
9+
10+
"github.com/Azure/azure-service-operator/v2/internal/util/to"
11+
"github.com/Azure/azure-service-operator/v2/pkg/genruntime"
12+
"github.com/Azure/azure-service-operator/v2/pkg/genruntime/conditions"
13+
)
14+
15+
// +kubebuilder:rbac:groups=entra.azure.com,resources=securitygroups,verbs=get;list;watch;create;update;patch;delete
16+
// +kubebuilder:rbac:groups=entra.azure.com,resources={securitygroups/status,users/finalizers},verbs=get;update;patch
17+
18+
// +kubebuilder:object:root=true
19+
// +kubebuilder:subresource:status
20+
// +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].status"
21+
// +kubebuilder:printcolumn:name="Severity",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].severity"
22+
// +kubebuilder:printcolumn:name="Reason",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].reason"
23+
// +kubebuilder:printcolumn:name="Message",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].message"
24+
// +kubebuilder:storageversion
25+
// SecurityGroup is an Entra Security Group.
26+
type SecurityGroup struct {
27+
metav1.TypeMeta `json:",inline"`
28+
metav1.ObjectMeta `json:"metadata,omitempty"`
29+
Spec SecurityGroupSpec `json:"spec,omitempty"`
30+
Status SecurityGroupStatus `json:"status,omitempty"`
31+
}
32+
33+
var _ conditions.Conditioner = &SecurityGroup{}
34+
35+
// GetConditions returns the conditions of the resource
36+
func (group *SecurityGroup) GetConditions() conditions.Conditions {
37+
return group.Status.Conditions
38+
}
39+
40+
// SetConditions sets the conditions on the resource status
41+
func (group *SecurityGroup) SetConditions(conditions conditions.Conditions) {
42+
group.Status.Conditions = conditions
43+
}
44+
45+
var _ conversion.Hub = &SecurityGroup{}
46+
47+
// Hub marks that this userSpec is the hub type for conversion
48+
func (user *SecurityGroup) Hub() {}
49+
50+
// +kubebuilder:object:root=true
51+
type SecurityGroupList struct {
52+
metav1.TypeMeta `json:",inline"`
53+
metav1.ListMeta `json:"metadata,omitempty"`
54+
Items []SecurityGroup `json:"items"`
55+
}
56+
57+
type SecurityGroupSpec struct {
58+
// DisplayName: The display name of the group.
59+
// +kubebuilder:validation:Required
60+
DisplayName *string `json:"displayName,omitempty"`
61+
62+
// MailNickname: The email address of the group, specified either as a mail nickname (`mygroup`)
63+
// or as a full email address (`[email protected]`).
64+
// +kubebuilder:validation:Required
65+
MailNickname *string `json:"mailNickname,omitempty"`
66+
67+
// Description: The description of the group.
68+
Description *string `json:"description,omitempty"`
69+
70+
// MembershipType: The membership type of the group.
71+
MembershipType *SecurityGroupMembershipType `json:"membershipType,omitempty"`
72+
73+
// OperatorSpec: The operator specific configuration for the resource.
74+
OperatorSpec *SecurityGroupOperatorSpec `json:"operatorSpec,omitempty"`
75+
76+
// IsAssignableToRole: Indicates whether the group can be assigned to a role.
77+
IsAssignableToRole *bool `json:"isAssignableToRole,omitempty"`
78+
}
79+
80+
// OriginalVersion returns the original API version used to create the resource.
81+
func (spec *SecurityGroupSpec) OriginalVersion() string {
82+
return GroupVersion.Version
83+
}
84+
85+
// AssignToGroup configures the provided instance with the details of the group
86+
func (spec *SecurityGroupSpec) AssignToGroup(model models.Groupable) {
87+
model.SetSecurityEnabled(to.Ptr(true))
88+
model.SetDisplayName(spec.DisplayName)
89+
90+
if spec.MailNickname != nil {
91+
model.SetMailNickname(spec.MailNickname)
92+
}
93+
94+
if spec.Description != nil {
95+
model.SetDescription(spec.Description)
96+
}
97+
98+
// Set the membership type
99+
membershipType := SecurityGroupMembershipTypeAssigned
100+
if spec.MembershipType != nil {
101+
membershipType = *spec.MembershipType
102+
}
103+
104+
var groupTypes []string
105+
switch membershipType {
106+
case SecurityGroupMembershipTypeAssigned:
107+
// Empty list means assigned membership
108+
case SecurityGroupMembershipTypeAssignedM365:
109+
groupTypes = []string{"Unified"}
110+
case SecurityGroupMembershipTypeDynamic:
111+
groupTypes = []string{"DynamicMembership"}
112+
case SecurityGroupMembershipTypeDynamicM365:
113+
groupTypes = []string{"Unified", "DynamicMembership"}
114+
}
115+
model.SetGroupTypes(groupTypes)
116+
117+
// Set isAssignableToRole
118+
if spec.IsAssignableToRole != nil {
119+
model.SetIsAssignableToRole(spec.IsAssignableToRole)
120+
}
121+
122+
// This is a security group, not a mail distribution group
123+
model.SetMailEnabled(to.Ptr(false))
124+
}
125+
126+
type SecurityGroupStatus struct {
127+
// EntraID: The GUID identifing the resource in Entra
128+
EntraID *string `json:"entraID,omitempty"`
129+
130+
// DisplayName: The display name of the group.
131+
DisplayName *string `json:"displayName,omitempty"`
132+
133+
// Conditions: The observed state of the resource
134+
Conditions []conditions.Condition `json:"conditions,omitempty"`
135+
136+
// +kubebuilder:validation:Required
137+
// MailNickname: The email address of the group.
138+
MailNickname *string `json:"groupEmailAddress,omitempty"`
139+
140+
// Description: The description of the group.
141+
Description *string `json:"description,omitempty"`
142+
}
143+
144+
func (status *SecurityGroupStatus) AssignFromGroup(model models.Groupable) {
145+
if id := model.GetId(); id != nil {
146+
status.EntraID = id
147+
}
148+
149+
if name := model.GetDisplayName(); name != nil {
150+
status.DisplayName = name
151+
}
152+
153+
if mailNickname := model.GetMailNickname(); mailNickname != nil {
154+
status.MailNickname = mailNickname
155+
}
156+
157+
if description := model.GetDescription(); description != nil {
158+
status.Description = description
159+
}
160+
}
161+
162+
// +kubebuilder:validation:Enum={"assigned","enabled","assignedm365","enabledm365"}
163+
// +kubebuilder:default=AdoptOrCreate
164+
type SecurityGroupMembershipType string
165+
166+
const (
167+
// SecurityGroupMembershipTypeAssigned indicates that the group is a security group with assigned members.
168+
SecurityGroupMembershipTypeAssigned SecurityGroupMembershipType = "assigned"
169+
// SecurityGroupMembershipTypeDynamic indicates that the group is a security group with dynamic membership.
170+
SecurityGroupMembershipTypeDynamic SecurityGroupMembershipType = "dynamic"
171+
// SecurityGroupMembershipTypeAssigned indicates that the group is a Microsoft 365 security group with assigned members.
172+
SecurityGroupMembershipTypeAssignedM365 SecurityGroupMembershipType = "assignedm365"
173+
// SecurityGroupMembershipTypeDynamic indicates that the group is a Microsoft 365 security group with dynamic membership.
174+
SecurityGroupMembershipTypeDynamicM365 SecurityGroupMembershipType = "dynamicm365"
175+
)
176+
177+
type SecurityGroupOperatorSpec struct {
178+
// CreationMode: Specifies how ASO will try to create the resource.
179+
// Specify "AlwaysCreate" to always create a new security group when first reconciled.
180+
// Or specify "AdoptOrCreate" to first try to adopt an existing security group with the same display name.
181+
// If multiple security groups with the same display name are found, the resource condition will show an error.
182+
// If not specified, defaults to "AdoptOrCreate".
183+
CreationMode *CreationMode `json:"creationMode,omitempty"`
184+
185+
// ConfigMaps specifies any config maps that should be created by the operator.
186+
ConfigMaps *SecurityGroupOperatorConfigMaps `json:"configmaps,omitempty"`
187+
}
188+
189+
// CreationAllowed checks if the creation mode allows ASO to create a new security group.
190+
func (spec *SecurityGroupOperatorSpec) CreationAllowed() bool {
191+
if spec.CreationMode == nil {
192+
// Default is AdoptOrCreate
193+
return true
194+
}
195+
196+
return spec.CreationMode.AllowsCreation()
197+
}
198+
199+
// AllowsAdoption checks if the creation mode allows ASO to adopt an existing security group.
200+
func (spec *SecurityGroupOperatorSpec) AdoptionAllowed() bool {
201+
if spec.CreationMode == nil {
202+
// Default is AdoptOrCreate
203+
return true
204+
}
205+
206+
return spec.CreationMode.AllowsAdoption()
207+
}
208+
209+
type SecurityGroupOperatorConfigMaps struct {
210+
// EntraID: The Entra ID of the group.
211+
EntraID *genruntime.ConfigMapDestination `json:"entraID,omitempty"`
212+
}
213+
214+
func init() {
215+
SchemeBuilder.Register(&SecurityGroup{}, &SecurityGroupList{})
216+
}

0 commit comments

Comments
 (0)