Skip to content

Commit 0fef73e

Browse files
authored
CLOUDP-355710 Fix deletion of external roles (#625)
# Summary This PR fixes an issue where roles added to an OM project outside the operator (through UI or the API) are overwritten by the operator every reconciliation. This is now consistent with the user behaviour, where users can be defined in the UI and will not be removed. To accomplish this, we are now tracking the roles we configure with the operator in an annotation, and state configmap (where applicable). This was necessary due to possible usage of the `ClusterMongoDBRole` resource. If the reference to a role resource is removed at the same time as removing the resource itself, then it is not possible to determine which role needs to be removed from the automation config without knowing the previous set of roles. ## Proof of Work Unit tests have been added to verify that the `ensureRoles` method which is reused across all controllers behaves correctly. Additionally, the unit tests verify that each controller keeps track of the previously configured roles in an annotation. ## Checklist - [x] Have you linked a jira ticket and/or is the ticket in the title? - [x] Have you checked whether your jira ticket required DOCSP changes? - [x] Have you added changelog file? - use `skip-changelog` label if not needed - refer to [Changelog files and Release Notes](https://github.com/mongodb/mongodb-kubernetes/blob/master/CONTRIBUTING.md#changelog-files-and-release-notes) section in CONTRIBUTING.md for more details
1 parent 2d18b52 commit 0fef73e

17 files changed

+528
-28
lines changed

api/v1/mdb/mongodb_types.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1201,6 +1201,20 @@ func (m *MongoDB) GetLastSpec() (*MongoDbSpec, error) {
12011201
return &lastSpec, nil
12021202
}
12031203

1204+
func (m *MongoDB) GetLastConfiguredRoles() ([]string, error) {
1205+
lastRolesStr := annotations.GetAnnotation(m, util.LastConfiguredRoles)
1206+
if lastRolesStr == "" {
1207+
return nil, nil
1208+
}
1209+
1210+
lastRoles := []string{}
1211+
if err := json.Unmarshal([]byte(lastRolesStr), &lastRoles); err != nil {
1212+
return nil, err
1213+
}
1214+
1215+
return lastRoles, nil
1216+
}
1217+
12041218
func (m *MongoDB) ServiceName() string {
12051219
if m.Spec.StatefulSetConfiguration != nil {
12061220
svc := m.Spec.StatefulSetConfiguration.SpecWrapper.Spec.ServiceName

api/v1/mdbmulti/mongodbmultibuilder.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,14 @@ func (m *MultiReplicaSetBuilder) SetSecurity(s *mdbv1.Security) *MultiReplicaSet
7373
return m
7474
}
7575

76+
func (m *MultiReplicaSetBuilder) SetRoles(roles []mdbv1.MongoDBRole) *MultiReplicaSetBuilder {
77+
if m.Spec.Security == nil {
78+
m.Spec.Security = &mdbv1.Security{}
79+
}
80+
m.Spec.Security.Roles = roles
81+
return m
82+
}
83+
7684
func (m *MultiReplicaSetBuilder) SetRoleRefs(roleRefs []mdbv1.MongoDBRoleRef) *MultiReplicaSetBuilder {
7785
if m.Spec.Security == nil {
7886
m.Spec.Security = &mdbv1.Security{}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
kind: fix
3+
date: 2025-12-04
4+
---
5+
6+
* Roles configured via Ops Manager UI or API will no longer be removed by the operator

controllers/om/mockedomclient.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -933,6 +933,16 @@ func (oc *MockedOmConnection) AddPreferredHostname(agentApiKey string, value str
933933
return nil
934934
}
935935

936+
func (oc *MockedOmConnection) AddRole(role mdbv1.MongoDBRole) {
937+
roles := oc.deployment.GetRoles()
938+
roles = append(roles, role)
939+
oc.deployment.SetRoles(roles)
940+
}
941+
942+
func (oc *MockedOmConnection) GetRoles() []mdbv1.MongoDBRole {
943+
return oc.deployment.GetRoles()
944+
}
945+
936946
// updateAutoAuthMechanism simulates the changes made by Ops Manager and the agents in deciding which
937947
// mechanism will be specified as the "autoAuthMechanisms"
938948
func updateAutoAuthMechanism(ac *AutomationConfig) {

controllers/operator/common_controller.go

Lines changed: 79 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -100,44 +100,85 @@ func NewReconcileCommonController(ctx context.Context, client client.Client) *Re
100100
}
101101
}
102102

103-
// ensureRoles will first check if both roles and roleRefs are populated. If both are, it will return an error, which is inline with the webhook validation rules.
104-
// Otherwise, if roles is populated, then it will extract the list of roles and check if they are already set in Ops Manager. If they are not, it will update the roles in Ops Manager.
105-
// If roleRefs is populated, it will extract the list of roles from the referenced resources and check if they are already set in Ops Manager. If they are not, it will update the roles in Ops Manager.
106-
func (r *ReconcileCommonController) ensureRoles(ctx context.Context, db mdbv1.DbCommonSpec, enableClusterMongoDBRoles bool, conn om.Connection, mongodbResourceNsName types.NamespacedName, log *zap.SugaredLogger) workflow.Status {
103+
func (r *ReconcileCommonController) getRoleAnnotation(ctx context.Context, db mdbv1.DbCommonSpec, enableClusterMongoDBRoles bool, mongodbResourceNsName types.NamespacedName) (map[string]string, []string, error) {
104+
previousRoles, err := r.getRoleStrings(ctx, db, enableClusterMongoDBRoles, mongodbResourceNsName)
105+
if err != nil {
106+
return nil, nil, xerrors.Errorf("Error retrieving configured roles: %w", err)
107+
}
108+
109+
annotationToAdd := make(map[string]string)
110+
rolesBytes, err := json.Marshal(previousRoles)
111+
if err != nil {
112+
return nil, nil, err
113+
}
114+
annotationToAdd[util.LastConfiguredRoles] = string(rolesBytes)
115+
116+
return annotationToAdd, previousRoles, nil
117+
}
118+
119+
func (r *ReconcileCommonController) getRoleStrings(ctx context.Context, db mdbv1.DbCommonSpec, enableClusterMongoDBRoles bool, mongodbResourceNsName types.NamespacedName) ([]string, error) {
120+
roles, err := r.getRoles(ctx, db, enableClusterMongoDBRoles, mongodbResourceNsName)
121+
if err != nil {
122+
return []string{}, err
123+
}
124+
125+
roleStrings := make([]string, len(roles))
126+
for i, r := range roles {
127+
roleStrings[i] = fmt.Sprintf("%s@%s", r.Role, r.Db)
128+
}
129+
130+
return roleStrings, nil
131+
}
132+
133+
func (r *ReconcileCommonController) getRoles(ctx context.Context, db mdbv1.DbCommonSpec, enableClusterMongoDBRoles bool, mongodbResourceNsName types.NamespacedName) ([]mdbv1.MongoDBRole, error) {
107134
localRoles := db.GetSecurity().Roles
108135
roleRefs := db.GetSecurity().RoleRefs
109136

110137
if len(localRoles) > 0 && len(roleRefs) > 0 {
111-
return workflow.Failed(xerrors.Errorf("At most one one of roles or roleRefs can be non-empty."))
138+
return nil, xerrors.Errorf("At most one of roles or roleRefs can be non-empty.")
112139
}
113140

114141
var roles []mdbv1.MongoDBRole
115142
if len(roleRefs) > 0 {
116143
if !enableClusterMongoDBRoles {
117-
return workflow.Failed(xerrors.Errorf("RoleRefs are not supported when ClusterMongoDBRoles are disabled. Please enable ClusterMongoDBRoles in the operator configuration. This can be done by setting the operator.enableClusterMongoDBRoles to true in the helm values file, which will automatically installed the necessary RBAC. Alternatively, it can be enabled by adding -watch-resource=clustermongodbroles flag to the operator deployment, and manually creating the necessary RBAC."))
144+
return nil, xerrors.Errorf("RoleRefs are not supported when ClusterMongoDBRoles are disabled. Please enable ClusterMongoDBRoles in the operator configuration. This can be done by setting the operator.enableClusterMongoDBRoles to true in the helm values file, which will automatically installed the necessary RBAC. Alternatively, it can be enabled by adding -watch-resource=clustermongodbroles flag to the operator deployment, and manually creating the necessary RBAC.")
118145
}
119146
var err error
120147
roles, err = r.getRoleRefs(ctx, roleRefs, mongodbResourceNsName, db.Version)
121148
if err != nil {
122-
return workflow.Failed(err)
149+
return nil, err
123150
}
124151
} else {
125152
roles = localRoles
126153
}
127154

155+
return roles, nil
156+
}
157+
158+
// ensureRoles will first check if both roles and roleRefs are populated. If both are, it will return an error, which is inline with the webhook validation rules.
159+
// Otherwise, if roles is populated, then it will extract the list of roles and check if they are already set in Ops Manager. If they are not, it will update the roles in Ops Manager.
160+
// If roleRefs is populated, it will extract the list of roles from the referenced resources and check if they are already set in Ops Manager. If they are not, it will update the roles in Ops Manager.
161+
func (r *ReconcileCommonController) ensureRoles(ctx context.Context, db mdbv1.DbCommonSpec, enableClusterMongoDBRoles bool, conn om.Connection, mongodbResourceNsName types.NamespacedName, previousRoles []string, log *zap.SugaredLogger) workflow.Status {
162+
roles, err := r.getRoles(ctx, db, enableClusterMongoDBRoles, mongodbResourceNsName)
163+
if err != nil {
164+
return workflow.Failed(err)
165+
}
166+
128167
d, err := conn.ReadDeployment()
129168
if err != nil {
130169
return workflow.Failed(err)
131170
}
132171
dRoles := d.GetRoles()
133-
if reflect.DeepEqual(dRoles, roles) {
172+
mergedRoles := mergeRoles(dRoles, roles, previousRoles)
173+
174+
if reflect.DeepEqual(dRoles, mergedRoles) {
134175
return workflow.OK()
135176
}
136177

137178
// clone roles list to avoid mutating the spec in normalizePrivilegeResource
138-
newRoles := make([]mdbv1.MongoDBRole, len(roles))
139-
for i := range roles {
140-
newRoles[i] = *roles[i].DeepCopy()
179+
newRoles := make([]mdbv1.MongoDBRole, len(mergedRoles))
180+
for i := range mergedRoles {
181+
newRoles[i] = *mergedRoles[i].DeepCopy()
141182
}
142183
roles = newRoles
143184

@@ -189,6 +230,33 @@ func normalizePrivilegeResource(resource mdbv1.Resource) mdbv1.Resource {
189230
return resource
190231
}
191232

233+
// mergeRoles merges the deployed roles with the current roles and previously configured roles.
234+
// It ensures that roles configured outside the operator are not removed.
235+
// This is achieved by removing currently configured roles from the deployed roles.
236+
// To ensure that roles removed from the spec are also removed from OM, we also remove the previously configured roles.
237+
// Finally, we add back the currently configured roles.
238+
func mergeRoles(deployed []mdbv1.MongoDBRole, current []mdbv1.MongoDBRole, previous []string) []mdbv1.MongoDBRole {
239+
roleMap := make(map[string]struct{})
240+
for _, r := range current {
241+
roleMap[r.Role+"@"+r.Db] = struct{}{}
242+
}
243+
244+
for _, r := range previous {
245+
roleMap[r] = struct{}{}
246+
}
247+
248+
mergedRoles := make([]mdbv1.MongoDBRole, 0)
249+
for _, r := range deployed {
250+
key := r.Role + "@" + r.Db
251+
if _, ok := roleMap[key]; !ok {
252+
mergedRoles = append(mergedRoles, r)
253+
}
254+
}
255+
256+
mergedRoles = append(mergedRoles, current...)
257+
return mergedRoles
258+
}
259+
192260
// getRoleRefs retrieves the roles from the referenced resources. It will return an error if any of the referenced resources are not found.
193261
// It will also add the referenced resources to the resource watcher, so that they are watched for changes.
194262
// The referenced resources are expected to be of kind ClusterMongoDBRole.

controllers/operator/common_controller_test.go

Lines changed: 149 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,7 @@ func TestFailWhenRoleAndRoleRefsAreConfigured(t *testing.T) {
290290
controller := NewReconcileCommonController(ctx, kubeClient)
291291
mockOm, _ := prepareConnection(ctx, controller, omConnectionFactory.GetConnectionFunc, t)
292292

293-
result := controller.ensureRoles(ctx, rs.Spec.DbCommonSpec, true, mockOm, kube.ObjectKeyFromApiObject(rs), zap.S())
293+
result := controller.ensureRoles(ctx, rs.Spec.DbCommonSpec, true, mockOm, kube.ObjectKeyFromApiObject(rs), nil, zap.S())
294294
assert.False(t, result.IsOK())
295295
assert.Equal(t, status.PhaseFailed, result.Phase())
296296

@@ -318,7 +318,7 @@ func TestRoleRefsAreAdded(t *testing.T) {
318318

319319
_ = kubeClient.Create(ctx, roleResource)
320320

321-
controller.ensureRoles(ctx, rs.Spec.DbCommonSpec, true, mockOm, kube.ObjectKeyFromApiObject(rs), zap.S())
321+
controller.ensureRoles(ctx, rs.Spec.DbCommonSpec, true, mockOm, kube.ObjectKeyFromApiObject(rs), nil, zap.S())
322322

323323
ac, err := mockOm.ReadAutomationConfig()
324324
assert.NoError(t, err)
@@ -345,7 +345,7 @@ func TestErrorWhenRoleRefIsWrong(t *testing.T) {
345345

346346
_ = kubeClient.Create(ctx, roleResource)
347347

348-
result := controller.ensureRoles(ctx, rs.Spec.DbCommonSpec, true, mockOm, kube.ObjectKeyFromApiObject(rs), zap.S())
348+
result := controller.ensureRoles(ctx, rs.Spec.DbCommonSpec, true, mockOm, kube.ObjectKeyFromApiObject(rs), nil, zap.S())
349349
assert.False(t, result.IsOK())
350350
assert.Equal(t, status.PhaseFailed, result.Phase())
351351

@@ -371,7 +371,7 @@ func TestErrorWhenRoleDoesNotExist(t *testing.T) {
371371
controller := NewReconcileCommonController(ctx, kubeClient)
372372
mockOm, _ := prepareConnection(ctx, controller, omConnectionFactory.GetConnectionFunc, t)
373373

374-
result := controller.ensureRoles(ctx, rs.Spec.DbCommonSpec, true, mockOm, kube.ObjectKeyFromApiObject(rs), zap.S())
374+
result := controller.ensureRoles(ctx, rs.Spec.DbCommonSpec, true, mockOm, kube.ObjectKeyFromApiObject(rs), nil, zap.S())
375375
assert.False(t, result.IsOK())
376376
assert.Equal(t, status.PhaseFailed, result.Phase())
377377

@@ -398,7 +398,7 @@ func TestDontSendNilPrivileges(t *testing.T) {
398398
kubeClient, omConnectionFactory := mock.NewDefaultFakeClient()
399399
controller := NewReconcileCommonController(ctx, kubeClient)
400400
mockOm, _ := prepareConnection(ctx, controller, omConnectionFactory.GetConnectionFunc, t)
401-
controller.ensureRoles(ctx, rs.Spec.DbCommonSpec, true, mockOm, kube.ObjectKeyFromApiObject(rs), zap.S())
401+
controller.ensureRoles(ctx, rs.Spec.DbCommonSpec, true, mockOm, kube.ObjectKeyFromApiObject(rs), nil, zap.S())
402402
ac, err := mockOm.ReadAutomationConfig()
403403
assert.NoError(t, err)
404404
roles, ok := ac.Deployment["roles"].([]mdbv1.MongoDBRole)
@@ -498,7 +498,7 @@ func TestCheckEmptyStringsInPrivilegesEquivalentToNotPassingFields(t *testing.T)
498498
controller := NewReconcileCommonController(ctx, kubeClient)
499499
mockOm, _ := prepareConnection(ctx, controller, omConnectionFactory.GetConnectionFunc, t)
500500

501-
controller.ensureRoles(ctx, rs.Spec.DbCommonSpec, true, mockOm, kube.ObjectKeyFromApiObject(rs), zap.S())
501+
controller.ensureRoles(ctx, rs.Spec.DbCommonSpec, true, mockOm, kube.ObjectKeyFromApiObject(rs), nil, zap.S())
502502

503503
ac, err := mockOm.ReadAutomationConfig()
504504
assert.NoError(t, err)
@@ -528,6 +528,149 @@ func TestCheckEmptyStringsInPrivilegesEquivalentToNotPassingFields(t *testing.T)
528528
}
529529
}
530530

531+
func TestMergeRoles(t *testing.T) {
532+
externalRole := mdbv1.MongoDBRole{
533+
Role: "ext_role",
534+
Db: "admin",
535+
Roles: []mdbv1.InheritedRole{{
536+
Db: "admin",
537+
Role: "read",
538+
}},
539+
}
540+
role1 := mdbv1.MongoDBRole{
541+
Role: "role1",
542+
Db: "admin",
543+
Roles: []mdbv1.InheritedRole{{
544+
Db: "admin",
545+
Role: "readWrite",
546+
}},
547+
}
548+
549+
role2 := mdbv1.MongoDBRole{
550+
Role: "role2",
551+
Db: "admin",
552+
Roles: []mdbv1.InheritedRole{{
553+
Db: "admin",
554+
Role: "readWrite",
555+
}},
556+
}
557+
558+
tests := []struct {
559+
name string
560+
deployedRoles []mdbv1.MongoDBRole
561+
currentRoles []mdbv1.MongoDBRole
562+
previousRoles []string
563+
expectedRoles []mdbv1.MongoDBRole
564+
}{
565+
// externalRole was added via UI
566+
// role1 and role2 were defined in the CR
567+
// role2 was removed from the CR
568+
{
569+
name: "Removing role from resource",
570+
deployedRoles: []mdbv1.MongoDBRole{externalRole, role1, role2},
571+
currentRoles: []mdbv1.MongoDBRole{role1},
572+
previousRoles: []string{"role1@admin", "role2@admin"},
573+
expectedRoles: []mdbv1.MongoDBRole{externalRole, role1},
574+
},
575+
// externalRole was added via UI
576+
// role1 was defined in the CR
577+
// role2 was added in the CR
578+
{
579+
name: "Adding role in resource",
580+
deployedRoles: []mdbv1.MongoDBRole{externalRole, role1},
581+
currentRoles: []mdbv1.MongoDBRole{role1, role2},
582+
previousRoles: []string{"role1@admin"},
583+
expectedRoles: []mdbv1.MongoDBRole{externalRole, role1, role2},
584+
},
585+
{
586+
name: "Idempotency",
587+
deployedRoles: []mdbv1.MongoDBRole{externalRole, role1, role2},
588+
currentRoles: []mdbv1.MongoDBRole{role1, role2},
589+
previousRoles: []string{"role1@admin", "role2@admin"},
590+
expectedRoles: []mdbv1.MongoDBRole{externalRole, role1, role2},
591+
},
592+
{
593+
name: "Nil previous roles - adding all defined roles",
594+
deployedRoles: []mdbv1.MongoDBRole{externalRole, role1, role2},
595+
currentRoles: []mdbv1.MongoDBRole{role1, role2},
596+
previousRoles: nil,
597+
expectedRoles: []mdbv1.MongoDBRole{externalRole, role1, role2},
598+
},
599+
{
600+
name: "Nil current roles - removing all defined roles",
601+
deployedRoles: []mdbv1.MongoDBRole{externalRole, role1, role2},
602+
currentRoles: nil,
603+
previousRoles: []string{"role1@admin", "role2@admin"},
604+
expectedRoles: []mdbv1.MongoDBRole{externalRole},
605+
},
606+
}
607+
608+
for _, tc := range tests {
609+
t.Run(tc.name, func(t *testing.T) {
610+
mergedRoles := mergeRoles(tc.deployedRoles, tc.currentRoles, tc.previousRoles)
611+
612+
require.Len(t, mergedRoles, len(tc.expectedRoles))
613+
for _, r := range tc.expectedRoles {
614+
assert.Contains(t, mergedRoles, r)
615+
}
616+
})
617+
}
618+
}
619+
620+
func TestExternalRoleIsNotRemoved(t *testing.T) {
621+
ctx := context.Background()
622+
623+
role := mdbv1.MongoDBRole{
624+
Role: "embedded-role",
625+
Db: "admin",
626+
Roles: []mdbv1.InheritedRole{{
627+
Db: "admin",
628+
Role: "read",
629+
}},
630+
}
631+
632+
rs := DefaultReplicaSetBuilder().SetRoles([]mdbv1.MongoDBRole{role}).Build()
633+
kubeClient, omConnectionFactory := mock.NewDefaultFakeClient()
634+
controller := NewReconcileCommonController(ctx, kubeClient)
635+
mockOm, _ := prepareConnection(ctx, controller, omConnectionFactory.GetConnectionFunc, t)
636+
637+
// Create deployment with one embedded role
638+
controller.ensureRoles(ctx, rs.Spec.DbCommonSpec, true, mockOm, kube.ObjectKeyFromApiObject(rs), nil, zap.S())
639+
640+
roles := mockOm.GetRoles()
641+
require.Len(t, roles, 1)
642+
643+
// Add external role directly to OM (via UI/API)
644+
externalRole := mdbv1.MongoDBRole{
645+
Role: "external-role",
646+
Db: "admin",
647+
Roles: []mdbv1.InheritedRole{{
648+
Db: "admin",
649+
Role: "read",
650+
}},
651+
}
652+
mockOm.AddRole(externalRole)
653+
654+
// Ensure external role is added
655+
roles = mockOm.GetRoles()
656+
require.Len(t, roles, 2)
657+
658+
// Reconcile again - role created from the UI should still be there
659+
roleStrings, _ := controller.getRoleStrings(ctx, rs.Spec.DbCommonSpec, true, kube.ObjectKeyFromApiObject(rs))
660+
controller.ensureRoles(ctx, rs.Spec.DbCommonSpec, true, mockOm, kube.ObjectKeyFromApiObject(rs), roleStrings, zap.S())
661+
662+
roles = mockOm.GetRoles()
663+
require.Len(t, roles, 2)
664+
665+
// Delete embedded role, only the external should remain
666+
rs.Spec.Security.Roles = nil
667+
controller.ensureRoles(ctx, rs.Spec.DbCommonSpec, true, mockOm, kube.ObjectKeyFromApiObject(rs), roleStrings, zap.S())
668+
669+
roles = mockOm.GetRoles()
670+
require.Len(t, roles, 1)
671+
assert.Equal(t, roles[0].Role, "external-role")
672+
}
673+
531674
func TestSecretWatcherWithAllResources(t *testing.T) {
532675
ctx := context.Background()
533676
caName := "custom-ca"

0 commit comments

Comments
 (0)