Skip to content

Commit 7272a43

Browse files
authored
CLOUDP-350197: allow to create a custom role with cluster: true (#553)
# Summary This PR fixes a problem which was preventing creating cluster-wide mongodb custom roles. **Context** As described in [MongoDB docs](https://www.mongodb.com/docs/manual/reference/system-roles-collection/#mongodb-data-admin.system.roles.privileges-n-.resource) the resource object is either: ``` { db: <database>, collection: <collection> } ``` or ``` { cluster : true } ``` So that means we cannot provide empty strings for `db` and `collection` fields as empty string means "any db/collection". At the same time our previous serialization rules were always sending empty strings even if not set. This was the source of the problem - it wasn't possible to specify `cluster: true` because the operator was also sending empty db and collections. **Backwards compatibility** Making `db` and `collection` fields just as omitempty `*string` is not sufficient, because that would change the semantics of the resource and would be potentially a breaking change. In order to preserve backwards compatibility we need additional logic (see [normalizePrivilegeResource](https://github.com/mongodb/mongodb-kubernetes/pull/553/files#diff-457d90107d5cbfbfb07a276cc6d58bd1f53c710f58ce19d6ec54daa7ab8e08aaR174) that will maintain the same behavior for non-cluster-wide resources (sending empty strings even if the field is not set in yaml). ## Proof of Work Tests passing. <!-- start git-machete generated --> # Based on PR #551 ## Chain of upstream PRs as of 2025-10-25 * PR #551: `master` ← `lsierant/custom-roles-regression-tests` * **PR #553 (THIS ONE)**: `lsierant/custom-roles-regression-tests` ← `lsierant/custom-roles` <!-- end git-machete generated -->
1 parent 2c8dc84 commit 7272a43

File tree

10 files changed

+121
-30
lines changed

10 files changed

+121
-30
lines changed

api/v1/mdb/mongodb_roles_validation.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ func validatePrivilege(privilege Privilege, mdbVersion string) v1.ValidationResu
246246
if !*privilege.Resource.Cluster {
247247
return v1.ValidationError("The only valid value for privilege.cluster, if set, is true")
248248
}
249-
if privilege.Resource.Collection != "" || privilege.Resource.Db != "" {
249+
if privilege.Resource.Collection != nil || privilege.Resource.Db != nil {
250250
return v1.ValidationError("Cluster: true is not compatible with setting db/collection")
251251
}
252252
if res := validateClusterPrivilegeActions(privilege.Actions, mdbVersion); res.Level == v1.ErrorLevel {

api/v1/mdb/mongodb_types.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -965,10 +965,10 @@ type AuthenticationRestriction struct {
965965

966966
type Resource struct {
967967
// +optional
968-
Db string `json:"db"`
968+
Db *string `json:"db,omitempty"`
969969
// +optional
970-
Collection string `json:"collection"`
971-
Cluster *bool `json:"cluster,omitempty"`
970+
Collection *string `json:"collection,omitempty"`
971+
Cluster *bool `json:"cluster,omitempty"`
972972
}
973973

974974
type Privilege struct {
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-10-27
4+
---
5+
6+
* Fixed inability to specify cluster-wide privileges in custom roles.

controllers/operator/common_controller.go

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"k8s.io/apimachinery/pkg/types"
1616
"k8s.io/client-go/kubernetes"
1717
"k8s.io/client-go/rest"
18+
"k8s.io/utils/ptr"
1819
"sigs.k8s.io/controller-runtime/pkg/client"
1920
"sigs.k8s.io/controller-runtime/pkg/reconcile"
2021

@@ -132,12 +133,24 @@ func (r *ReconcileCommonController) ensureRoles(ctx context.Context, db mdbv1.Db
132133
if reflect.DeepEqual(dRoles, roles) {
133134
return workflow.OK()
134135
}
135-
// HELP-20798: the agent deals correctly with a null value for
136-
// privileges only when creating a role, not when updating
137-
// we work around it by explicitly passing empty array
138-
for i, role := range roles {
139-
if role.Privileges == nil {
140-
roles[i].Privileges = []mdbv1.Privilege{}
136+
137+
// 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()
141+
}
142+
roles = newRoles
143+
144+
for roleIdx := range roles {
145+
// HELP-20798: the agent deals correctly with a null value for
146+
// privileges only when creating a role, not when updating
147+
// we work around it by explicitly passing empty array
148+
if roles[roleIdx].Privileges == nil {
149+
roles[roleIdx].Privileges = []mdbv1.Privilege{}
150+
}
151+
152+
for privilegeIdx := range roles[roleIdx].Privileges {
153+
roles[roleIdx].Privileges[privilegeIdx].Resource = normalizePrivilegeResource(roles[roleIdx].Privileges[privilegeIdx].Resource)
141154
}
142155
}
143156

@@ -155,6 +168,27 @@ func (r *ReconcileCommonController) ensureRoles(ctx context.Context, db mdbv1.Db
155168
return workflow.OK()
156169
}
157170

171+
// normalizePrivilegeResource ensures that mutually exclusive fields are not passed at the same time and ensures backwards compatibility by
172+
// preserving empty strings for db and collection.
173+
// This function was introduced after we've changed db and collection fields to *string allowing to omit the field from serialization (CLOUDP-349078).
174+
func normalizePrivilegeResource(resource mdbv1.Resource) mdbv1.Resource {
175+
if resource.Cluster != nil && *resource.Cluster {
176+
// for cluster-wide privilege mongod is not accepting even empty strings in db and collection
177+
resource.Db = nil
178+
resource.Collection = nil
179+
} else {
180+
// for backwards compatibility we must convert "not specified" fields as empty strings
181+
if resource.Db == nil {
182+
resource.Db = ptr.To("")
183+
}
184+
if resource.Collection == nil {
185+
resource.Collection = ptr.To("")
186+
}
187+
}
188+
189+
return resource
190+
}
191+
158192
// getRoleRefs retrieves the roles from the referenced resources. It will return an error if any of the referenced resources are not found.
159193
// It will also add the referenced resources to the resource watcher, so that they are watched for changes.
160194
// The referenced resources are expected to be of kind ClusterMongoDBRole.

controllers/operator/common_controller_test.go

Lines changed: 49 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -419,22 +419,36 @@ func TestCheckEmptyStringsInPrivilegesEquivalentToNotPassingFields(t *testing.T)
419419
Privileges: []mdbv1.Privilege{
420420
{
421421
Resource: mdbv1.Resource{
422-
Db: "config",
423-
Collection: "", // Explicit empty string
422+
Db: ptr.To("config"),
423+
Collection: ptr.To(""), // Explicit empty string
424424
},
425425
Actions: []string{"find", "update", "insert", "remove"},
426426
},
427427
{
428428
Resource: mdbv1.Resource{
429-
Db: "users",
430-
Collection: "usersCollection",
429+
Db: ptr.To("users"),
430+
Collection: ptr.To("usersCollection"),
431431
},
432432
Actions: []string{"update", "insert", "remove"},
433433
},
434434
{
435435
Resource: mdbv1.Resource{
436-
Db: "", // Explicit empty string
437-
Collection: "", // Explicit empty string
436+
Db: ptr.To(""), // Explicit empty string
437+
Collection: ptr.To(""), // Explicit empty string
438+
},
439+
Actions: []string{"find"},
440+
},
441+
{
442+
Resource: mdbv1.Resource{
443+
Cluster: ptr.To(true),
444+
},
445+
Actions: []string{"find"},
446+
},
447+
{
448+
Resource: mdbv1.Resource{
449+
Cluster: ptr.To(true),
450+
Db: ptr.To(""),
451+
Collection: ptr.To(""),
438452
},
439453
Actions: []string{"find"},
440454
},
@@ -452,15 +466,15 @@ func TestCheckEmptyStringsInPrivilegesEquivalentToNotPassingFields(t *testing.T)
452466
Privileges: []mdbv1.Privilege{
453467
{
454468
Resource: mdbv1.Resource{
455-
Db: "config",
469+
Db: ptr.To("config"),
456470
// field not set, should pass ""
457471
},
458472
Actions: []string{"find", "update", "insert", "remove"},
459473
},
460474
{
461475
Resource: mdbv1.Resource{
462-
Db: "users",
463-
Collection: "usersCollection",
476+
Db: ptr.To("users"),
477+
Collection: ptr.To("usersCollection"),
464478
},
465479
Actions: []string{"update", "insert", "remove"},
466480
},
@@ -470,6 +484,12 @@ func TestCheckEmptyStringsInPrivilegesEquivalentToNotPassingFields(t *testing.T)
470484
},
471485
Actions: []string{"find"},
472486
},
487+
{
488+
Resource: mdbv1.Resource{
489+
Cluster: ptr.To(true),
490+
},
491+
Actions: []string{"find"},
492+
},
473493
},
474494
}
475495

@@ -486,16 +506,26 @@ func TestCheckEmptyStringsInPrivilegesEquivalentToNotPassingFields(t *testing.T)
486506
assert.True(t, ok)
487507
require.Len(t, roles, 2)
488508

489-
assert.Equal(t, "config", roles[0].Privileges[0].Resource.Db)
490-
assert.Equal(t, "", roles[0].Privileges[0].Resource.Collection)
491-
492-
assert.Equal(t, "users", roles[0].Privileges[1].Resource.Db)
493-
assert.Equal(t, "usersCollection", roles[0].Privileges[1].Resource.Collection)
494-
495-
assert.Equal(t, "", roles[0].Privileges[2].Resource.Db)
496-
assert.Equal(t, "", roles[0].Privileges[2].Resource.Collection)
497-
498-
assert.True(t, reflect.DeepEqual(roles[0].Privileges, roles[1].Privileges))
509+
// we iterate over two created privileges because both should end with the same result
510+
for i := range 2 {
511+
assert.Nil(t, roles[i].Privileges[0].Resource.Cluster)
512+
assert.Equal(t, ptr.To("config"), roles[i].Privileges[0].Resource.Db)
513+
// even if the db or collection field is not passed it must result in empty string
514+
assert.Equal(t, ptr.To(""), roles[i].Privileges[0].Resource.Collection)
515+
516+
assert.Nil(t, roles[i].Privileges[1].Resource.Cluster)
517+
assert.Equal(t, ptr.To("users"), roles[i].Privileges[1].Resource.Db)
518+
assert.Equal(t, ptr.To("usersCollection"), roles[i].Privileges[1].Resource.Collection)
519+
520+
assert.Nil(t, roles[i].Privileges[2].Resource.Cluster)
521+
assert.Equal(t, ptr.To(""), roles[i].Privileges[2].Resource.Db)
522+
assert.Equal(t, ptr.To(""), roles[i].Privileges[2].Resource.Collection)
523+
524+
require.NotNil(t, roles[i].Privileges[3].Resource.Cluster)
525+
assert.True(t, *roles[i].Privileges[3].Resource.Cluster)
526+
assert.Nil(t, roles[i].Privileges[3].Resource.Db)
527+
assert.Nil(t, roles[i].Privileges[3].Resource.Collection)
528+
}
499529
}
500530

501531
func TestSecretWatcherWithAllResources(t *testing.T) {

docker/mongodb-kubernetes-tests/kubetester/mongotester.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -645,7 +645,7 @@ def run(self):
645645
self.health_function(**self.health_function_params)
646646
consecutive_failure = 0
647647
except Exception as e:
648-
print(f"Error in {self.__class__.__name__}: {e})")
648+
logging.error(f"Error in {self.__class__.__name__}: {e})")
649649
self.last_exception = e
650650
consecutive_failure = consecutive_failure + 1
651651
self.max_consecutive_failure = max(self.max_consecutive_failure, consecutive_failure)

docker/mongodb-kubernetes-tests/tests/authentication/fixtures/cluster-mongodb-role-with-empty-strings.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ spec:
2929
collection: ""
3030
actions:
3131
- "find"
32+
- resource:
33+
cluster: true
34+
actions:
35+
- "bypassWriteBlockingMode"
3236
authenticationRestrictions:
3337
- clientSource:
3438
- "127.0.0.0/8"

docker/mongodb-kubernetes-tests/tests/authentication/fixtures/cluster-mongodb-role-without-empty-strings.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ spec:
2626
- resource: {}
2727
actions:
2828
- "find"
29+
- resource:
30+
cluster: true
31+
actions:
32+
- "bypassWriteBlockingMode"
2933
authenticationRestrictions:
3034
- clientSource:
3135
- "127.0.0.0/8"

docker/mongodb-kubernetes-tests/tests/authentication/mongodb_custom_roles.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,14 @@ def get_expected_role(role_name: str) -> dict:
5757
"find"
5858
]
5959
},
60+
{
61+
"resource": {
62+
"cluster": True
63+
},
64+
"actions": [
65+
"bypassWriteBlockingMode"
66+
]
67+
}
6068
],
6169
"authenticationRestrictions": [
6270
{

scripts/evergreen/e2e/dump_diagnostic_information.sh

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,11 @@ dump_pod_readiness_state() {
206206
kubectl --context="${context}" cp -c "mongodb-agent" "${namespace}/${pod}:/var/log/mongodb-mms-automation/agent-health-status.json" "${agent_health_status}" &> /dev/null
207207
([[ -f "${agent_health_status}" ]] && jq . < "${agent_health_status}" > tmpfile && mv tmpfile "${agent_health_status}")
208208

209+
if [[ ! -f "${agent_health_status}" ]]; then
210+
kubectl --context="${context}" cp -c "mongodb-enterprise-database" "${namespace}/${pod}:/var/log/mongodb-mms-automation/agent-health-status.json" "${agent_health_status}" &> /dev/null
211+
([[ -f "${agent_health_status}" ]] && jq . < "${agent_health_status}" > tmpfile && mv tmpfile "${agent_health_status}")
212+
fi
213+
209214
if [[ ! -f "${agent_health_status}" ]]; then
210215
echo "Agent health status not found; trying community health status: "
211216
kubectl --context="${context}" cp -c "mongodb-agent" "${namespace}/${pod}:/var/log/mongodb-mms-automation/healthstatus/agent-health-status.json" "${agent_health_status}" &> /dev/null

0 commit comments

Comments
 (0)