diff --git a/api/v1alpha1/postgresuser_types.go b/api/v1alpha1/postgresuser_types.go index a2d49d1a..bc8dbbcb 100644 --- a/api/v1alpha1/postgresuser_types.go +++ b/api/v1alpha1/postgresuser_types.go @@ -26,6 +26,12 @@ type PostgresUserSpec struct { Annotations map[string]string `json:"annotations,omitempty"` // +optional Labels map[string]string `json:"labels,omitempty"` + // +optional + // +kubebuilder:validation:Enum=0;4;5;6;7;8;9;10;11;12 + // Length of the random suffix appended to the role name. + // Default is 6. Set to 0 to disable the suffix entirely. + // Values 1-3 are not allowed as they provide insufficient uniqueness. + RoleSuffixLength *int `json:"roleSuffixLength,omitempty"` } // PostgresUserAWSSpec encapsulates AWS specific configuration toggles. diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index c2112808..44f74be8 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -236,6 +236,11 @@ func (in *PostgresUserSpec) DeepCopyInto(out *PostgresUserSpec) { (*out)[key] = val } } + if in.RoleSuffixLength != nil { + in, out := &in.RoleSuffixLength, &out.RoleSuffixLength + *out = new(int) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresUserSpec. diff --git a/charts/ext-postgres-operator/crds/db.movetokube.com_postgresusers_crd.yaml b/charts/ext-postgres-operator/crds/db.movetokube.com_postgresusers_crd.yaml index 07a5304c..c88535b5 100644 --- a/charts/ext-postgres-operator/crds/db.movetokube.com_postgresusers_crd.yaml +++ b/charts/ext-postgres-operator/crds/db.movetokube.com_postgresusers_crd.yaml @@ -56,6 +56,23 @@ spec: role: description: Name of the PostgresRole this user will be associated with type: string + roleSuffixLength: + description: |- + Length of the random suffix appended to the role name. + Default is 6. Set to 0 to disable the suffix entirely. + Values 1-3 are not allowed as they provide insufficient uniqueness. + enum: + - 0 + - 4 + - 5 + - 6 + - 7 + - 8 + - 9 + - 10 + - 11 + - 12 + type: integer secretName: description: Name of the secret to create with user credentials type: string diff --git a/config/crd/bases/db.movetokube.com_postgresusers.yaml b/config/crd/bases/db.movetokube.com_postgresusers.yaml index 14b8d6e0..0caccd1a 100644 --- a/config/crd/bases/db.movetokube.com_postgresusers.yaml +++ b/config/crd/bases/db.movetokube.com_postgresusers.yaml @@ -68,6 +68,23 @@ spec: description: Name of the PostgresRole this user will be associated with type: string + roleSuffixLength: + description: |- + Length of the random suffix appended to the role name. + Default is 6. Set to 0 to disable the suffix entirely. + Values 1-3 are not allowed as they provide insufficient uniqueness. + enum: + - 0 + - 4 + - 5 + - 6 + - 7 + - 8 + - 9 + - 10 + - 11 + - 12 + type: integer secretName: description: Name of the secret to create with user credentials type: string diff --git a/internal/controller/postgresuser_controller.go b/internal/controller/postgresuser_controller.go index aeb0dc8b..a01ac1f1 100644 --- a/internal/controller/postgresuser_controller.go +++ b/internal/controller/postgresuser_controller.go @@ -129,9 +129,17 @@ func (r *PostgresUserReconciler) Reconcile(ctx context.Context, req ctrl.Request if err != nil { return r.requeue(ctx, instance, errors.NewInternalError(err)) } - // Create user role - suffix := utils.GetRandomString(6) - role = fmt.Sprintf("%s-%s", instance.Spec.Role, suffix) + // Create user role with configurable suffix length (default: 6) + suffixLength := 6 + if instance.Spec.RoleSuffixLength != nil { + suffixLength = *instance.Spec.RoleSuffixLength + } + if suffixLength > 0 { + suffix := utils.GetRandomString(suffixLength) + role = fmt.Sprintf("%s-%s", instance.Spec.Role, suffix) + } else { + role = instance.Spec.Role + } login, err = r.pg.CreateUserRole(role, password) if err != nil { return r.requeue(ctx, instance, errors.NewInternalError(err)) diff --git a/internal/controller/postgresuser_controller_test.go b/internal/controller/postgresuser_controller_test.go index 87072f6e..cc9aa69a 100644 --- a/internal/controller/postgresuser_controller_test.go +++ b/internal/controller/postgresuser_controller_test.go @@ -532,6 +532,103 @@ var _ = Describe("PostgresUser Controller", func() { }) }) + + Context("Role suffix length configuration", func() { + BeforeEach(func() { + initClient(postgresDB, nil, false) + }) + + AfterEach(func() { + // Clean up any created secrets + secretList := &corev1.SecretList{} + Expect(cl.List(ctx, secretList, client.InNamespace(namespace))).To(Succeed()) + for _, secret := range secretList.Items { + Expect(cl.Delete(ctx, &secret)).To(Succeed()) + } + }) + + It("should use default suffix length of 6 when not specified", func() { + // Create user without roleSuffixLength specified + user := postgresUser.DeepCopy() + Expect(cl.Create(ctx, user)).To(Succeed()) + + var capturedRole string + pg.EXPECT().GetDefaultDatabase().Return("postgres").AnyTimes() + pg.EXPECT().CreateUserRole(gomock.Any(), gomock.Any()).DoAndReturn( + func(role, password string) (string, error) { + capturedRole = role + // Should have format: roleName-XXXXXX (6 char suffix) + Expect(role).To(HavePrefix(roleName + "-")) + Expect(len(role)).To(Equal(len(roleName) + 1 + 6)) // role + "-" + 6 chars + return role, nil + }) + pg.EXPECT().GrantRole(gomock.Any(), gomock.Any()).Return(nil) + pg.EXPECT().AlterDefaultLoginRole(gomock.Any(), gomock.Any()).Return(nil) + + err := runReconcile(rp, ctx, req) + Expect(err).NotTo(HaveOccurred()) + + foundUser := &dbv1alpha1.PostgresUser{} + err = cl.Get(ctx, types.NamespacedName{Name: name, Namespace: namespace}, foundUser) + Expect(err).NotTo(HaveOccurred()) + Expect(foundUser.Status.PostgresRole).To(Equal(capturedRole)) + }) + + It("should use custom suffix length when specified", func() { + // Create user with custom roleSuffixLength of 4 + user := postgresUser.DeepCopy() + suffixLen := 4 + user.Spec.RoleSuffixLength = &suffixLen + Expect(cl.Create(ctx, user)).To(Succeed()) + + var capturedRole string + pg.EXPECT().GetDefaultDatabase().Return("postgres").AnyTimes() + pg.EXPECT().CreateUserRole(gomock.Any(), gomock.Any()).DoAndReturn( + func(role, password string) (string, error) { + capturedRole = role + // Should have format: roleName-XXXX (4 char suffix) + Expect(role).To(HavePrefix(roleName + "-")) + Expect(len(role)).To(Equal(len(roleName) + 1 + 4)) // role + "-" + 4 chars + return role, nil + }) + pg.EXPECT().GrantRole(gomock.Any(), gomock.Any()).Return(nil) + pg.EXPECT().AlterDefaultLoginRole(gomock.Any(), gomock.Any()).Return(nil) + + err := runReconcile(rp, ctx, req) + Expect(err).NotTo(HaveOccurred()) + + foundUser := &dbv1alpha1.PostgresUser{} + err = cl.Get(ctx, types.NamespacedName{Name: name, Namespace: namespace}, foundUser) + Expect(err).NotTo(HaveOccurred()) + Expect(foundUser.Status.PostgresRole).To(Equal(capturedRole)) + }) + + It("should use exact role name when suffix length is 0", func() { + // Create user with roleSuffixLength of 0 (disable suffix) + user := postgresUser.DeepCopy() + suffixLen := 0 + user.Spec.RoleSuffixLength = &suffixLen + Expect(cl.Create(ctx, user)).To(Succeed()) + + pg.EXPECT().GetDefaultDatabase().Return("postgres").AnyTimes() + pg.EXPECT().CreateUserRole(gomock.Any(), gomock.Any()).DoAndReturn( + func(role, password string) (string, error) { + // Should be exactly the role name, no suffix + Expect(role).To(Equal(roleName)) + return role, nil + }) + pg.EXPECT().GrantRole(gomock.Any(), gomock.Any()).Return(nil) + pg.EXPECT().AlterDefaultLoginRole(gomock.Any(), gomock.Any()).Return(nil) + + err := runReconcile(rp, ctx, req) + Expect(err).NotTo(HaveOccurred()) + + foundUser := &dbv1alpha1.PostgresUser{} + err = cl.Get(ctx, types.NamespacedName{Name: name, Namespace: namespace}, foundUser) + Expect(err).NotTo(HaveOccurred()) + Expect(foundUser.Status.PostgresRole).To(Equal(roleName)) + }) + }) }) Context("IAM authentication", func() {