diff --git a/api/postgresql/v1alpha1/postgresqluserrole_types.go b/api/postgresql/v1alpha1/postgresqluserrole_types.go index 8dff70c..484eb24 100644 --- a/api/postgresql/v1alpha1/postgresqluserrole_types.go +++ b/api/postgresql/v1alpha1/postgresqluserrole_types.go @@ -58,6 +58,19 @@ type PostgresqlUserRolePrivilege struct { GeneratedSecretName string `json:"generatedSecretName"` } +type PostgresqlUserRoleAttributes struct { + // REPLICATION attribute + // Note: This can be either true, false or null (to ignore this parameter) + Replication *bool `json:"replication,omitempty"` + // BYPASSRLS attribute + // Note: This can be either true, false or null (to ignore this parameter) + BypassRLS *bool `json:"bypassRLS,omitempty"` //nolint:tagliatelle + // CONNECTION LIMIT connlimit attribute + // Note: This can be either -1, a number or null (to ignore this parameter) + // Note: Increase your number by one because operator is using the created user to perform some operations. + ConnectionLimit *int `json:"connectionLimit,omitempty"` +} + type ModeEnum string const ProvidedMode ModeEnum = "PROVIDED" @@ -90,6 +103,9 @@ type PostgresqlUserRoleSpec struct { // Import secret name // +optional ImportSecretName string `json:"importSecretName,omitempty"` + // Role attributes + // Note: Only attributes that aren't conflicting with operator are supported. + RoleAttributes *PostgresqlUserRoleAttributes `json:"roleAttributes,omitempty"` } type UserRoleStatusPhase string diff --git a/api/postgresql/v1alpha1/zz_generated.deepcopy.go b/api/postgresql/v1alpha1/zz_generated.deepcopy.go index 004b47c..72478d7 100644 --- a/api/postgresql/v1alpha1/zz_generated.deepcopy.go +++ b/api/postgresql/v1alpha1/zz_generated.deepcopy.go @@ -457,6 +457,36 @@ func (in *PostgresqlUserRole) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PostgresqlUserRoleAttributes) DeepCopyInto(out *PostgresqlUserRoleAttributes) { + *out = *in + if in.Replication != nil { + in, out := &in.Replication, &out.Replication + *out = new(bool) + **out = **in + } + if in.BypassRLS != nil { + in, out := &in.BypassRLS, &out.BypassRLS + *out = new(bool) + **out = **in + } + if in.ConnectionLimit != nil { + in, out := &in.ConnectionLimit, &out.ConnectionLimit + *out = new(int) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresqlUserRoleAttributes. +func (in *PostgresqlUserRoleAttributes) DeepCopy() *PostgresqlUserRoleAttributes { + if in == nil { + return nil + } + out := new(PostgresqlUserRoleAttributes) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PostgresqlUserRoleList) DeepCopyInto(out *PostgresqlUserRoleList) { *out = *in @@ -523,6 +553,11 @@ func (in *PostgresqlUserRoleSpec) DeepCopyInto(out *PostgresqlUserRoleSpec) { } } } + if in.RoleAttributes != nil { + in, out := &in.RoleAttributes, &out.RoleAttributes + *out = new(PostgresqlUserRoleAttributes) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresqlUserRoleSpec. diff --git a/config/crd/bases/postgresql.easymile.com_postgresqluserroles.yaml b/config/crd/bases/postgresql.easymile.com_postgresqluserroles.yaml index b547360..a39e2b1 100644 --- a/config/crd/bases/postgresql.easymile.com_postgresqluserroles.yaml +++ b/config/crd/bases/postgresql.easymile.com_postgresqluserroles.yaml @@ -110,6 +110,28 @@ spec: - privilege type: object type: array + roleAttributes: + description: |- + Role attributes + Note: Only attributes that aren't conflicting with operator are supported. + properties: + bypassRLS: + description: |- + BYPASSRLS attribute + Note: This can be either true, false or null (to ignore this parameter) + type: boolean + connectionLimit: + description: |- + CONNECTION LIMIT connlimit attribute + Note: This can be either -1, a number or null (to ignore this parameter) + Note: Increase your number by one because operator is using the created user to perform some operations. + type: integer + replication: + description: |- + REPLICATION attribute + Note: This can be either true, false or null (to ignore this parameter) + type: boolean + type: object rolePrefix: description: User role prefix type: string diff --git a/internal/controller/postgresql/postgres/azure.go b/internal/controller/postgresql/postgres/azure.go index 96303f5..ff8a4f4 100644 --- a/internal/controller/postgresql/postgres/azure.go +++ b/internal/controller/postgresql/postgres/azure.go @@ -26,8 +26,8 @@ func newAzurePG(postgres *pg) PG { } } -func (azpg *azurepg) CreateUserRole(role, password string) (string, error) { - returnedRole, err := azpg.pg.CreateUserRole(role, password) +func (azpg *azurepg) CreateUserRole(role, password string, attributes *RoleAttributes) (string, error) { + returnedRole, err := azpg.pg.CreateUserRole(role, password, attributes) if err != nil { return "", err } diff --git a/internal/controller/postgresql/postgres/postgres.go b/internal/controller/postgresql/postgres/postgres.go index 034ee75..d6e5c53 100644 --- a/internal/controller/postgresql/postgres/postgres.go +++ b/internal/controller/postgresql/postgres/postgres.go @@ -23,7 +23,9 @@ type PG interface { //nolint:interfacebloat // This is needed CreateSchema(db, role, schema string) error CreateExtension(db, extension string) error CreateGroupRole(role string) error - CreateUserRole(role, password string) (string, error) + CreateUserRole(role, password string, attributes *RoleAttributes) (string, error) + AlterRoleAttributes(role string, attributes *RoleAttributes) error + GetRoleAttributes(role string) (*RoleAttributes, error) IsRoleExist(role string) (bool, error) RenameRole(oldname, newname string) error UpdatePassword(role, password string) error diff --git a/internal/controller/postgresql/postgres/role.go b/internal/controller/postgresql/postgres/role.go index fea476d..cb5c311 100644 --- a/internal/controller/postgresql/postgres/role.go +++ b/internal/controller/postgresql/postgres/role.go @@ -2,13 +2,14 @@ package postgres import ( "fmt" + "strings" "github.com/lib/pq" ) const ( CreateGroupRoleSQLTemplate = `CREATE ROLE "%s"` - CreateUserRoleSQLTemplate = `CREATE ROLE "%s" WITH LOGIN PASSWORD '%s'` + CreateUserRoleSQLTemplate = `CREATE ROLE "%s" WITH LOGIN PASSWORD '%s' %s` GrantRoleSQLTemplate = `GRANT "%s" TO "%s"` GrantRoleWithAdminOptionSQLTemplate = `GRANT "%s" TO "%s" WITH ADMIN OPTION` AlterUserSetRoleSQLTemplate = `ALTER USER "%s" SET ROLE "%s"` @@ -21,8 +22,10 @@ const ( ReassignObjectsSQLTemplate = `REASSIGN OWNED BY "%s" TO "%s"` IsRoleExistSQLTemplate = `SELECT 1 FROM pg_roles WHERE rolname='%s'` RenameRoleSQLTemplate = `ALTER ROLE "%s" RENAME TO "%s"` + AlterRoleWithOptionSQLTemplate = `ALTER ROLE "%s" WITH %s` // Source: https://dba.stackexchange.com/questions/136858/postgresql-display-role-members GetRoleMembershipSQLTemplate = `SELECT r1.rolname as "role" FROM pg_catalog.pg_roles r JOIN pg_catalog.pg_auth_members m ON (m.member = r.oid) JOIN pg_roles r1 ON (m.roleid=r1.oid) WHERE r.rolcanlogin AND r.rolname='%s'` + GetRoleAttributesSQLTemplate = `select rolconnlimit, rolreplication, rolbypassrls FROM pg_roles WHERE rolname = '%s'` // DO NOT TOUCH THIS // Cannot filter on compute value so... cf line before. GetRoleSettingsSQLTemplate = `SELECT pg_catalog.split_part(pg_catalog.unnest(setconfig), '=', 1) as parameter_type, pg_catalog.split_part(pg_catalog.unnest(setconfig), '=', 2) as parameter_value, d.datname as database FROM pg_catalog.pg_roles r JOIN pg_catalog.pg_db_role_setting c ON (c.setrole = r.oid) JOIN pg_catalog.pg_database d ON (d.oid = c.setdatabase) WHERE r.rolcanlogin AND r.rolname='%s'` //nolint:lll//Because @@ -32,6 +35,111 @@ const ( InvalidGrantOperationErrorCode = "0LP01" ) +var ( + DefaultAttributeConnectionLimit = -1 + DefaultAttributeReplication = false + DefaultAttributeBypassRLS = false +) + +type RoleAttributes struct { + ConnectionLimit *int + Replication *bool + BypassRLS *bool +} + +func (*pg) buildAttributesString(attributes *RoleAttributes) string { + // Check nil + if attributes == nil { + return "" + } + + res := make([]string, 0) + + // Connection limit case + if attributes.ConnectionLimit != nil { + res = append(res, fmt.Sprintf("CONNECTION LIMIT %d", *attributes.ConnectionLimit)) + } + + // Replication case + if attributes.Replication != nil { + if *attributes.Replication { + res = append(res, "REPLICATION") + } else { + res = append(res, "NOREPLICATION") + } + } + + // BypassRLS case + if attributes.BypassRLS != nil { + if *attributes.BypassRLS { + res = append(res, "BYPASSRLS") + } else { + res = append(res, "NOBYPASSRLS") + } + } + + return strings.Join(res, " ") +} + +func (c *pg) AlterRoleAttributes(role string, attributes *RoleAttributes) error { + // Build attributes str + attributesSQLStr := c.buildAttributesString(attributes) + // Check if it is empty + if attributesSQLStr == "" { + return nil + } + + err := c.connect(c.defaultDatabase) + if err != nil { + return err + } + + _, err = c.db.Exec(fmt.Sprintf(AlterRoleWithOptionSQLTemplate, role, attributesSQLStr)) + if err != nil { + return err + } + + return nil +} + +func (c *pg) GetRoleAttributes(role string) (*RoleAttributes, error) { + res := &RoleAttributes{ + ConnectionLimit: new(int), + Replication: new(bool), + BypassRLS: new(bool), + } + + err := c.connect(c.defaultDatabase) + if err != nil { + return res, err + } + + rows, err := c.db.Query(fmt.Sprintf(GetRoleAttributesSQLTemplate, role)) + if err != nil { + return res, err + } + + defer rows.Close() + + for rows.Next() { + // Scan + err = rows.Scan(res.ConnectionLimit, res.Replication, res.BypassRLS) + // Check error + if err != nil { + return res, err + } + } + + // Rows error + err = rows.Err() + // Check error + if err != nil { + return res, err + } + + return res, nil +} + func (c *pg) GetRoleMembership(role string) ([]string, error) { res := make([]string, 0) @@ -91,13 +199,16 @@ func (c *pg) CreateGroupRole(role string) error { return nil } -func (c *pg) CreateUserRole(role, password string) (string, error) { +func (c *pg) CreateUserRole(role, password string, attributes *RoleAttributes) (string, error) { err := c.connect(c.defaultDatabase) if err != nil { return "", err } - _, err = c.db.Exec(fmt.Sprintf(CreateUserRoleSQLTemplate, role, password)) + // Build attributes sql + attributesSQLStr := c.buildAttributesString(attributes) + + _, err = c.db.Exec(fmt.Sprintf(CreateUserRoleSQLTemplate, role, password, attributesSQLStr)) if err != nil { return "", err } diff --git a/internal/controller/postgresql/postgresqluserrole_controller.go b/internal/controller/postgresql/postgresqluserrole_controller.go index a977ae1..7e6086d 100644 --- a/internal/controller/postgresql/postgresqluserrole_controller.go +++ b/internal/controller/postgresql/postgresqluserrole_controller.go @@ -712,6 +712,91 @@ func (*PostgresqlUserRoleReconciler) getDBRoleFromPrivilege( } } +func convertPostgresqlUserRoleAttributesToRoleAttributes(item *v1alpha1.PostgresqlUserRoleAttributes) *postgres.RoleAttributes { + // Check nil + if item == nil { + return nil + } + + return &postgres.RoleAttributes{ + ConnectionLimit: item.ConnectionLimit, + Replication: item.Replication, + BypassRLS: item.BypassRLS, + } +} + +func diffAttributes(sqlAttributes, wantedAttributes *postgres.RoleAttributes) *postgres.RoleAttributes { + // Init result & vars + attributes := &postgres.RoleAttributes{} + + // Check if we are in the case of wanted have been flushed and database have different configuration + // Need to reset to default + if wantedAttributes == nil { + // Check connection limit + if sqlAttributes.ConnectionLimit != nil && *sqlAttributes.ConnectionLimit != postgres.DefaultAttributeConnectionLimit { + // Change value needed => Reset to default + attributes.ConnectionLimit = &postgres.DefaultAttributeConnectionLimit + } + + // Check replication + if sqlAttributes.Replication != nil && *sqlAttributes.Replication != postgres.DefaultAttributeReplication { + // Change value needed => Reset to default + attributes.Replication = &postgres.DefaultAttributeReplication + } + + // Check BypassRLS + if sqlAttributes.BypassRLS != nil && *sqlAttributes.BypassRLS != postgres.DefaultAttributeBypassRLS { + // Change value needed => Reset to default + attributes.BypassRLS = &postgres.DefaultAttributeBypassRLS + } + + // Stop here + return attributes + } + + // + // Now we are in the case of an update is needed + // + + // Check differences for ConnectionLimit + if !reflect.DeepEqual(sqlAttributes.ConnectionLimit, wantedAttributes.ConnectionLimit) { + // Check if we are in a reset case + if wantedAttributes.ConnectionLimit == nil && sqlAttributes.ConnectionLimit != nil && *sqlAttributes.ConnectionLimit != postgres.DefaultAttributeConnectionLimit { + // Change value needed => Reset to default + attributes.ConnectionLimit = &postgres.DefaultAttributeConnectionLimit + } else { + // New value asked + attributes.ConnectionLimit = wantedAttributes.ConnectionLimit + } + } + + // Check differences for Replication + if !reflect.DeepEqual(sqlAttributes.Replication, wantedAttributes.Replication) { + // Check if we are in a reset case + if wantedAttributes.Replication == nil && sqlAttributes.Replication != nil && *sqlAttributes.Replication != postgres.DefaultAttributeReplication { + // Change value needed => Reset to default + attributes.Replication = &postgres.DefaultAttributeReplication + } else { + // New value asked + attributes.Replication = wantedAttributes.Replication + } + } + + // Check differences for BypassRLS + if !reflect.DeepEqual(sqlAttributes.BypassRLS, wantedAttributes.BypassRLS) { + // Check if we are in a reset case + if wantedAttributes.BypassRLS == nil && sqlAttributes.BypassRLS != nil && *sqlAttributes.BypassRLS != postgres.DefaultAttributeBypassRLS { + // Change value needed => Reset to default + attributes.BypassRLS = &postgres.DefaultAttributeBypassRLS + } else { + // New value asked + attributes.BypassRLS = wantedAttributes.BypassRLS + } + } + + return attributes +} + func (r *PostgresqlUserRoleReconciler) managePGUserRoles( _ context.Context, logger logr.Logger, @@ -721,6 +806,9 @@ func (r *PostgresqlUserRoleReconciler) managePGUserRoles( username, password string, passwordChanged bool, ) error { + // Build wantedAttributes + wantedAttributes := convertPostgresqlUserRoleAttributesToRoleAttributes(instance.Spec.RoleAttributes) + // Loop over all pg instances for key, pgInstance := range pgInstanceCache { // Check if user exists in database @@ -732,7 +820,7 @@ func (r *PostgresqlUserRoleReconciler) managePGUserRoles( // Check if role doesn't exist to create it if !exists { // Create role - _, err = pgInstance.CreateUserRole(username, password) + _, err = pgInstance.CreateUserRole(username, password, wantedAttributes) // Check error if err != nil { return err @@ -744,6 +832,30 @@ func (r *PostgresqlUserRoleReconciler) managePGUserRoles( continue } + // Get role attributes + sqlAttributes, err := pgInstance.GetRoleAttributes(username) + // Check error + if err != nil { + return err + } + // Check if results haven't been found + if sqlAttributes == nil { + return errors.NewBadRequest("seems that role attributes cannot be found (maybe role has been removed)") + } + + // Diff attributes + newAttributes := diffAttributes(sqlAttributes, wantedAttributes) + + // Check if new attributes are defined + if newAttributes != nil { + // Alter + err = pgInstance.AlterRoleAttributes(username, newAttributes) + // Check error + if err != nil { + return err + } + } + // Check if it is the first time this instance is managed // If yes and if the user exist, the password must be ensured // Or if the password have changed, change password diff --git a/internal/controller/postgresql/postgresqluserrole_controller_test.go b/internal/controller/postgresql/postgresqluserrole_controller_test.go index b2525d9..17c4f8d 100644 --- a/internal/controller/postgresql/postgresqluserrole_controller_test.go +++ b/internal/controller/postgresql/postgresqluserrole_controller_test.go @@ -718,6 +718,14 @@ var _ = Describe("PostgresqlUserRole tests", func() { usernameWithAdminOption, err := getSQLRoleMembershipWithAdminOption(pgurImportUsername) Expect(err).ToNot(HaveOccurred()) Expect(usernameWithAdminOption).To(Equal(map[string]bool{postgresUser: false})) + + attr, err := getRoleAttributes(item.Status.PostgresRole) + Expect(err).ToNot(HaveOccurred()) + Expect(attr).To(Equal(&RoleAttributes{ + ConnectionLimit: starAny(-1), + Replication: starAny(false), + BypassRLS: starAny(false), + })) }) It("should be ok without work secret name and with a pgec with allow grant admin option", func() { @@ -828,6 +836,14 @@ var _ = Describe("PostgresqlUserRole tests", func() { usernameWithAdminOption, err := getSQLRoleMembershipWithAdminOption(pgurImportUsername) Expect(err).ToNot(HaveOccurred()) Expect(usernameWithAdminOption).To(Equal(map[string]bool{postgresUser: true})) + + attr, err := getRoleAttributes(item.Status.PostgresRole) + Expect(err).ToNot(HaveOccurred()) + Expect(attr).To(Equal(&RoleAttributes{ + ConnectionLimit: starAny(-1), + Replication: starAny(false), + BypassRLS: starAny(false), + })) }) It("should be ok with work secret name", func() { @@ -893,6 +909,87 @@ var _ = Describe("PostgresqlUserRole tests", func() { usernameWithAdminOption, err := getSQLRoleMembershipWithAdminOption(pgurImportUsername) Expect(err).ToNot(HaveOccurred()) Expect(usernameWithAdminOption).To(Equal(map[string]bool{postgresUser: false})) + + attr, err := getRoleAttributes(item.Status.PostgresRole) + Expect(err).ToNot(HaveOccurred()) + Expect(attr).To(Equal(&RoleAttributes{ + ConnectionLimit: starAny(-1), + Replication: starAny(false), + BypassRLS: starAny(false), + })) + }) + + It("should be ok with custom attributes", func() { + // Setup pgec + pgec, _ := setupPGEC("30s", false) + // Create pgdb + pgdb := setupPGDB(false) + + // Create secret + setupPGURImportSecret() + + preDate := time.Now().Add(-time.Second) + + item := setupProvidedPGURAndPartialCustomAttributes() + + // Checks + Expect(item.Status.Ready).To(BeTrue()) + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.UserRoleCreatedPhase)) + Expect(item.Status.Message).To(Equal("")) + Expect(item.Status.RolePrefix).To(Equal("")) + Expect(item.Status.PostgresRole).To(Equal(pgurImportUsername)) + Expect(item.Spec.WorkGeneratedSecretName).To(Equal(pgurWorkSecretName)) + Expect(item.Spec.Privileges[0].ConnectionType).To(Equal(postgresqlv1alpha1.PrimaryConnectionType)) + d, err := time.Parse(time.RFC3339, item.Status.LastPasswordChangedTime) + Expect(err).To(Succeed()) + Expect(d.After(preDate)).To(BeTrue()) + + // Get work secret + sec := &corev1.Secret{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: item.Spec.WorkGeneratedSecretName, + Namespace: pgurNamespace, + }, sec)).Should(Succeed()) + + Expect(string(sec.Data[UsernameSecretKey])).To(Equal(pgurImportUsername)) + Expect(string(sec.Data[PasswordSecretKey])).To(Equal(pgurImportPassword)) + + // Get db secret + sec = &corev1.Secret{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: pgurDBSecretName, + Namespace: pgurNamespace, + }, sec)).Should(Succeed()) + + // Validate + checkPGURSecretValues(pgurDBSecretName, pgurNamespace, pgdbDBName, pgurImportUsername, pgurImportPassword, pgec, v1alpha1.PrimaryConnectionType) + + // Connect to check user + _, err = connectAs(pgurImportUsername, pgurImportPassword) + Expect(err).To(Succeed()) + + exists, err := isSQLRoleExists(pgurImportUsername) + Expect(err).To(Succeed()) + Expect(exists).To(BeTrue()) + + sett, err := isSetRoleOnDatabasesRoleSettingsExists(pgurImportUsername, pgdbDBName, pgdb.Status.Roles.Owner) + Expect(err).To(Succeed()) + Expect(sett).To(BeTrue()) + + ownerMemberWithAdminOption, err := getSQLRoleMembershipWithAdminOption(pgdb.Status.Roles.Owner) + Expect(err).ToNot(HaveOccurred()) + Expect(ownerMemberWithAdminOption).To(Equal(map[string]bool{postgresUser: false, pgurImportUsername: false})) + usernameWithAdminOption, err := getSQLRoleMembershipWithAdminOption(pgurImportUsername) + Expect(err).ToNot(HaveOccurred()) + Expect(usernameWithAdminOption).To(Equal(map[string]bool{postgresUser: false})) + + attr, err := getRoleAttributes(item.Status.PostgresRole) + Expect(err).ToNot(HaveOccurred()) + Expect(attr).To(Equal(&RoleAttributes{ + ConnectionLimit: starAny(5), + Replication: starAny(true), + BypassRLS: starAny(false), + })) }) It("should be ok with 2 databases", func() { @@ -972,6 +1069,14 @@ var _ = Describe("PostgresqlUserRole tests", func() { usernameWithAdminOption, err := getSQLRoleMembershipWithAdminOption(pgurImportUsername) Expect(err).ToNot(HaveOccurred()) Expect(usernameWithAdminOption).To(Equal(map[string]bool{postgresUser: false})) + + attr, err := getRoleAttributes(item.Status.PostgresRole) + Expect(err).ToNot(HaveOccurred()) + Expect(attr).To(Equal(&RoleAttributes{ + ConnectionLimit: starAny(-1), + Replication: starAny(false), + BypassRLS: starAny(false), + })) }) It("should be ok with 2 databases and with a pgec with allow grand admin option", func() { @@ -1051,6 +1156,14 @@ var _ = Describe("PostgresqlUserRole tests", func() { usernameWithAdminOption, err := getSQLRoleMembershipWithAdminOption(pgurImportUsername) Expect(err).ToNot(HaveOccurred()) Expect(usernameWithAdminOption).To(Equal(map[string]bool{postgresUser: true})) + + attr, err := getRoleAttributes(item.Status.PostgresRole) + Expect(err).ToNot(HaveOccurred()) + Expect(attr).To(Equal(&RoleAttributes{ + ConnectionLimit: starAny(-1), + Replication: starAny(false), + BypassRLS: starAny(false), + })) }) It("should be ok to edit work secret", func() { @@ -2150,6 +2263,192 @@ var _ = Describe("PostgresqlUserRole tests", func() { Expect(worksecOri.Data[UsernameSecretKey]).To(Equal(worksec.Data[UsernameSecretKey])) }) + It("should be ok to change custom attributes", func() { + // Setup pgec + setupPGEC("30s", false) + // Create pgdb + setupPGDB(false) + + // Create secret + setupPGURImportSecret() + + item := setupProvidedPGURAndPartialCustomAttributes() + + // Checks + Expect(item.Status.Ready).To(BeTrue()) + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.UserRoleCreatedPhase)) + + // Edit + item.Spec.RoleAttributes.Replication = starAny(false) + item.Spec.RoleAttributes.ConnectionLimit = starAny(10) + + Expect(k8sClient.Update(ctx, item)).To(Succeed()) + + Eventually( + func() error { + attr, err := getRoleAttributes(item.Status.PostgresRole) + if err != nil { + return err + } + + if attr == nil || attr.ConnectionLimit == nil || attr.Replication == nil || *attr.Replication || *attr.ConnectionLimit != 10 { + return errors.New("not updated by operator") + } + + return nil + }, + generalEventuallyTimeout, + generalEventuallyInterval, + ). + Should(Succeed()) + + item2 := &postgresqlv1alpha1.PostgresqlUserRole{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: pgurName, + Namespace: pgurNamespace, + }, item2)).To(Succeed()) + + Expect(item2.Status.Ready).To(BeTrue()) + + attr, err := getRoleAttributes(item.Status.PostgresRole) + Expect(err).ToNot(HaveOccurred()) + Expect(attr).To(Equal(&RoleAttributes{ + ConnectionLimit: starAny(10), + Replication: starAny(false), + BypassRLS: starAny(false), + })) + }) + + It("should be ok to remove a custom attribute", func() { + // Setup pgec + setupPGEC("30s", false) + // Create pgdb + setupPGDB(false) + + // Create secret + setupPGURImportSecret() + + item := setupProvidedPGURAndPartialCustomAttributes() + + // Checks + Expect(item.Status.Ready).To(BeTrue()) + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.UserRoleCreatedPhase)) + + // Edit + item.Spec.RoleAttributes.ConnectionLimit = nil + + Expect(k8sClient.Update(ctx, item)).To(Succeed()) + + item3 := &postgresqlv1alpha1.PostgresqlUserRole{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: pgurName, + Namespace: pgurNamespace, + }, item3)).To(Succeed()) + + Expect(item3.Spec.RoleAttributes).To(Equal(&postgresqlv1alpha1.PostgresqlUserRoleAttributes{ + Replication: starAny(true), + BypassRLS: nil, + ConnectionLimit: nil, + })) + + Eventually( + func() error { + attr, err := getRoleAttributes(item.Status.PostgresRole) + if err != nil { + return err + } + + if attr == nil || attr.ConnectionLimit == nil || *attr.ConnectionLimit != -1 { + return errors.New("not updated by operator") + } + + return nil + }, + generalEventuallyTimeout, + generalEventuallyInterval, + ). + Should(Succeed()) + + item2 := &postgresqlv1alpha1.PostgresqlUserRole{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: pgurName, + Namespace: pgurNamespace, + }, item2)).To(Succeed()) + + Expect(item2.Status.Ready).To(BeTrue()) + + attr, err := getRoleAttributes(item.Status.PostgresRole) + Expect(err).ToNot(HaveOccurred()) + Expect(attr).To(Equal(&RoleAttributes{ + ConnectionLimit: starAny(-1), + Replication: starAny(true), + BypassRLS: starAny(false), + })) + }) + + It("should be ok to add a custom attribute", func() { + // Setup pgec + setupPGEC("30s", false) + // Create pgdb + setupPGDB(false) + + // Create secret + setupPGURImportSecret() + + item := setupProvidedPGUR() + + // Checks + Expect(item.Status.Ready).To(BeTrue()) + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.UserRoleCreatedPhase)) + + worksecOri := &corev1.Secret{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: pgurWorkSecretName, + Namespace: pgurNamespace, + }, worksecOri)).To(Succeed()) + + // Edit + item.Spec.RoleAttributes = &postgresqlv1alpha1.PostgresqlUserRoleAttributes{ + ConnectionLimit: starAny(50), + } + + Expect(k8sClient.Update(ctx, item)).To(Succeed()) + + Eventually( + func() error { + attr, err := getRoleAttributes(item.Status.PostgresRole) + if err != nil { + return err + } + + if attr == nil || attr.ConnectionLimit == nil || *attr.ConnectionLimit == -1 { + return errors.New("not updated by operator") + } + + return nil + }, + generalEventuallyTimeout, + generalEventuallyInterval, + ). + Should(Succeed()) + + item2 := &postgresqlv1alpha1.PostgresqlUserRole{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: pgurName, + Namespace: pgurNamespace, + }, item2)).To(Succeed()) + + Expect(item2.Status.Ready).To(BeTrue()) + + attr, err := getRoleAttributes(item.Status.PostgresRole) + Expect(err).ToNot(HaveOccurred()) + Expect(attr).To(Equal(&RoleAttributes{ + ConnectionLimit: starAny(50), + Replication: starAny(false), + BypassRLS: starAny(false), + })) + }) + It("should be ok to generate a primary user role with a bouncer enabled pgec", func() { // Setup pgec pgec, _ := setupPGECWithBouncer("30s", false) @@ -2926,6 +3225,14 @@ var _ = Describe("PostgresqlUserRole tests", func() { usernameWithAdminOption, err := getSQLRoleMembershipWithAdminOption(username) Expect(err).ToNot(HaveOccurred()) Expect(usernameWithAdminOption).To(Equal(map[string]bool{postgresUser: false})) + + attr, err := getRoleAttributes(item.Status.PostgresRole) + Expect(err).ToNot(HaveOccurred()) + Expect(attr).To(Equal(&RoleAttributes{ + ConnectionLimit: starAny(-1), + Replication: starAny(false), + BypassRLS: starAny(false), + })) }) It("should be ok without work secret name and with a pgec with allow grant admin option", func() { @@ -3034,6 +3341,14 @@ var _ = Describe("PostgresqlUserRole tests", func() { usernameWithAdminOption, err := getSQLRoleMembershipWithAdminOption(username) Expect(err).ToNot(HaveOccurred()) Expect(usernameWithAdminOption).To(Equal(map[string]bool{postgresUser: true})) + + attr, err := getRoleAttributes(item.Status.PostgresRole) + Expect(err).ToNot(HaveOccurred()) + Expect(attr).To(Equal(&RoleAttributes{ + ConnectionLimit: starAny(-1), + Replication: starAny(false), + BypassRLS: starAny(false), + })) }) It("should be ok with work secret name", func() { @@ -3098,6 +3413,86 @@ var _ = Describe("PostgresqlUserRole tests", func() { usernameWithAdminOption, err := getSQLRoleMembershipWithAdminOption(username) Expect(err).ToNot(HaveOccurred()) Expect(usernameWithAdminOption).To(Equal(map[string]bool{postgresUser: false})) + + attr, err := getRoleAttributes(item.Status.PostgresRole) + Expect(err).ToNot(HaveOccurred()) + Expect(attr).To(Equal(&RoleAttributes{ + ConnectionLimit: starAny(-1), + Replication: starAny(false), + BypassRLS: starAny(false), + })) + }) + + It("should be ok with custom attributes", func() { + // Setup pgec + pgec, _ := setupPGEC("30s", false) + // Create pgdb + pgdb := setupPGDB(false) + + preDate := time.Now().Add(-time.Second) + + item := setupManagedPGURWithPartialCustomAttributes() + + username := pgurRolePrefix + Login0Suffix + // Checks + Expect(item.Status.Ready).To(BeTrue()) + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.UserRoleCreatedPhase)) + Expect(item.Status.Message).To(Equal("")) + Expect(item.Status.RolePrefix).To(Equal(pgurRolePrefix)) + Expect(item.Status.PostgresRole).To(Equal(username)) + Expect(item.Spec.WorkGeneratedSecretName).To(Equal(pgurWorkSecretName)) + Expect(item.Spec.Privileges[0].ConnectionType).To(Equal(postgresqlv1alpha1.PrimaryConnectionType)) + d, err := time.Parse(time.RFC3339, item.Status.LastPasswordChangedTime) + Expect(err).To(Succeed()) + Expect(d.After(preDate)).To(BeTrue()) + + // Get work secret + sec := &corev1.Secret{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: item.Spec.WorkGeneratedSecretName, + Namespace: pgurNamespace, + }, sec)).Should(Succeed()) + + Expect(string(sec.Data[UsernameSecretKey])).To(Equal(username)) + Expect(string(sec.Data[PasswordSecretKey])).ToNot(Equal("")) + Expect(string(sec.Data[PasswordSecretKey])).To(HaveLen(ManagedPasswordSize)) + + // Get db secret + dbsec := &corev1.Secret{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: pgurDBSecretName, + Namespace: pgurNamespace, + }, dbsec)).Should(Succeed()) + + // Validate + checkPGURSecretValues(pgurDBSecretName, pgurNamespace, pgdbDBName, username, string(sec.Data[PasswordSecretKey]), pgec, v1alpha1.PrimaryConnectionType) + + // Connect to check user + _, err = connectAs(username, string(sec.Data[PasswordSecretKey])) + Expect(err).To(Succeed()) + + exists, err := isSQLRoleExists(username) + Expect(err).To(Succeed()) + Expect(exists).To(BeTrue()) + + sett, err := isSetRoleOnDatabasesRoleSettingsExists(username, pgdbDBName, pgdb.Status.Roles.Owner) + Expect(err).To(Succeed()) + Expect(sett).To(BeTrue()) + + ownerMemberWithAdminOption, err := getSQLRoleMembershipWithAdminOption(pgdb.Status.Roles.Owner) + Expect(err).ToNot(HaveOccurred()) + Expect(ownerMemberWithAdminOption).To(Equal(map[string]bool{postgresUser: false, username: false})) + usernameWithAdminOption, err := getSQLRoleMembershipWithAdminOption(username) + Expect(err).ToNot(HaveOccurred()) + Expect(usernameWithAdminOption).To(Equal(map[string]bool{postgresUser: false})) + + attr, err := getRoleAttributes(item.Status.PostgresRole) + Expect(err).ToNot(HaveOccurred()) + Expect(attr).To(Equal(&RoleAttributes{ + ConnectionLimit: starAny(5), + Replication: starAny(true), + BypassRLS: starAny(false), + })) }) It("should be ok with 2 databases", func() { @@ -3177,6 +3572,14 @@ var _ = Describe("PostgresqlUserRole tests", func() { usernameWithAdminOption, err := getSQLRoleMembershipWithAdminOption(username) Expect(err).ToNot(HaveOccurred()) Expect(usernameWithAdminOption).To(Equal(map[string]bool{postgresUser: false})) + + attr, err := getRoleAttributes(item.Status.PostgresRole) + Expect(err).ToNot(HaveOccurred()) + Expect(attr).To(Equal(&RoleAttributes{ + ConnectionLimit: starAny(-1), + Replication: starAny(false), + BypassRLS: starAny(false), + })) }) It("should be ok with 2 databases with a pgec with allow grant admin option", func() { @@ -3256,6 +3659,218 @@ var _ = Describe("PostgresqlUserRole tests", func() { usernameWithAdminOption, err := getSQLRoleMembershipWithAdminOption(username) Expect(err).ToNot(HaveOccurred()) Expect(usernameWithAdminOption).To(Equal(map[string]bool{postgresUser: true})) + + attr, err := getRoleAttributes(item.Status.PostgresRole) + Expect(err).ToNot(HaveOccurred()) + Expect(attr).To(Equal(&RoleAttributes{ + ConnectionLimit: starAny(-1), + Replication: starAny(false), + BypassRLS: starAny(false), + })) + }) + + It("should be ok to change custom attributes", func() { + // Setup pgec + setupPGEC("30s", false) + // Create pgdb + setupPGDB(false) + + preDate := time.Now().Add(-time.Second) + + item := setupManagedPGURWithPartialCustomAttributes() + + username := pgurRolePrefix + Login0Suffix + + // Checks + Expect(item.Status.Ready).To(BeTrue()) + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.UserRoleCreatedPhase)) + Expect(item.Status.Message).To(Equal("")) + Expect(item.Status.RolePrefix).To(Equal(pgurRolePrefix)) + Expect(item.Status.PostgresRole).To(Equal(username)) + Expect(item.Spec.WorkGeneratedSecretName).To(Equal(pgurWorkSecretName)) + Expect(item.Spec.Privileges[0].ConnectionType).To(Equal(postgresqlv1alpha1.PrimaryConnectionType)) + d, err := time.Parse(time.RFC3339, item.Status.LastPasswordChangedTime) + Expect(err).To(Succeed()) + Expect(d.After(preDate)).To(BeTrue()) + + item.Spec.RoleAttributes.Replication = starAny(false) + item.Spec.RoleAttributes.ConnectionLimit = starAny(10) + // Save + Expect(k8sClient.Update(ctx, item)).To(Succeed()) + + Eventually( + func() error { + attr, err := getRoleAttributes(item.Status.PostgresRole) + if err != nil { + return err + } + + if attr == nil || attr.ConnectionLimit == nil || attr.Replication == nil || *attr.Replication || *attr.ConnectionLimit != 10 { + return errors.New("not updated by operator") + } + + return nil + }, + generalEventuallyTimeout, + generalEventuallyInterval, + ). + Should(Succeed()) + + item2 := &postgresqlv1alpha1.PostgresqlUserRole{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: pgurName, + Namespace: pgurNamespace, + }, item2)).To(Succeed()) + + Expect(item2.Status.Ready).To(BeTrue()) + + attr, err := getRoleAttributes(item.Status.PostgresRole) + Expect(err).ToNot(HaveOccurred()) + Expect(attr).To(Equal(&RoleAttributes{ + ConnectionLimit: starAny(10), + Replication: starAny(false), + BypassRLS: starAny(false), + })) + }) + + It("should be ok to remove a custom attributes", func() { + // Setup pgec + setupPGEC("30s", false) + // Create pgdb + setupPGDB(false) + + preDate := time.Now().Add(-time.Second) + + item := setupManagedPGURWithPartialCustomAttributes() + + username := pgurRolePrefix + Login0Suffix + + // Checks + Expect(item.Status.Ready).To(BeTrue()) + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.UserRoleCreatedPhase)) + Expect(item.Status.Message).To(Equal("")) + Expect(item.Status.RolePrefix).To(Equal(pgurRolePrefix)) + Expect(item.Status.PostgresRole).To(Equal(username)) + Expect(item.Spec.WorkGeneratedSecretName).To(Equal(pgurWorkSecretName)) + Expect(item.Spec.Privileges[0].ConnectionType).To(Equal(postgresqlv1alpha1.PrimaryConnectionType)) + d, err := time.Parse(time.RFC3339, item.Status.LastPasswordChangedTime) + Expect(err).To(Succeed()) + Expect(d.After(preDate)).To(BeTrue()) + + item.Spec.RoleAttributes.ConnectionLimit = nil + // Save + Expect(k8sClient.Update(ctx, item)).To(Succeed()) + + item3 := &postgresqlv1alpha1.PostgresqlUserRole{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: pgurName, + Namespace: pgurNamespace, + }, item3)).To(Succeed()) + + Expect(item3.Spec.RoleAttributes).To(Equal(&postgresqlv1alpha1.PostgresqlUserRoleAttributes{ + Replication: starAny(true), + BypassRLS: nil, + ConnectionLimit: nil, + })) + + Eventually( + func() error { + attr, err := getRoleAttributes(item.Status.PostgresRole) + if err != nil { + return err + } + + if attr == nil || attr.ConnectionLimit == nil || *attr.ConnectionLimit != -1 { + return errors.New("not updated by operator") + } + + return nil + }, + generalEventuallyTimeout, + generalEventuallyInterval, + ). + Should(Succeed()) + + item2 := &postgresqlv1alpha1.PostgresqlUserRole{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: pgurName, + Namespace: pgurNamespace, + }, item2)).To(Succeed()) + + Expect(item2.Status.Ready).To(BeTrue()) + + attr, err := getRoleAttributes(item.Status.PostgresRole) + Expect(err).ToNot(HaveOccurred()) + Expect(attr).To(Equal(&RoleAttributes{ + ConnectionLimit: starAny(-1), + Replication: starAny(true), + BypassRLS: starAny(false), + })) + }) + + It("should be ok to add a custom attributes", func() { + // Setup pgec + setupPGEC("30s", false) + // Create pgdb + setupPGDB(false) + + preDate := time.Now().Add(-time.Second) + + item := setupManagedPGUR("") + + username := pgurRolePrefix + Login0Suffix + + // Checks + Expect(item.Status.Ready).To(BeTrue()) + Expect(item.Status.Phase).To(Equal(postgresqlv1alpha1.UserRoleCreatedPhase)) + Expect(item.Status.Message).To(Equal("")) + Expect(item.Status.RolePrefix).To(Equal(pgurRolePrefix)) + Expect(item.Status.PostgresRole).To(Equal(username)) + Expect(item.Spec.WorkGeneratedSecretName).To(Equal(pgurWorkSecretName)) + Expect(item.Spec.Privileges[0].ConnectionType).To(Equal(postgresqlv1alpha1.PrimaryConnectionType)) + d, err := time.Parse(time.RFC3339, item.Status.LastPasswordChangedTime) + Expect(err).To(Succeed()) + Expect(d.After(preDate)).To(BeTrue()) + + item.Spec.RoleAttributes = &postgresqlv1alpha1.PostgresqlUserRoleAttributes{ + ConnectionLimit: starAny(50), + } + // Save + Expect(k8sClient.Update(ctx, item)).To(Succeed()) + + Eventually( + func() error { + attr, err := getRoleAttributes(item.Status.PostgresRole) + if err != nil { + return err + } + + if attr == nil || attr.ConnectionLimit == nil || *attr.ConnectionLimit != -1 { + return errors.New("not updated by operator") + } + + return nil + }, + generalEventuallyTimeout, + generalEventuallyInterval, + ). + Should(Succeed()) + + item2 := &postgresqlv1alpha1.PostgresqlUserRole{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: pgurName, + Namespace: pgurNamespace, + }, item2)).To(Succeed()) + + Expect(item2.Status.Ready).To(BeTrue()) + + attr, err := getRoleAttributes(item.Status.PostgresRole) + Expect(err).ToNot(HaveOccurred()) + Expect(attr).To(Equal(&RoleAttributes{ + ConnectionLimit: starAny(50), + Replication: starAny(false), + BypassRLS: starAny(false), + })) }) It("should be ok to edit work secret", func() { diff --git a/internal/controller/postgresql/suite_test.go b/internal/controller/postgresql/suite_test.go index 1221ce0..0d9a856 100644 --- a/internal/controller/postgresql/suite_test.go +++ b/internal/controller/postgresql/suite_test.go @@ -428,6 +428,32 @@ func setupProvidedPGUR() *postgresqlv1alpha1.PostgresqlUserRole { return setupSavePGURInternal(it) } +func setupProvidedPGURAndPartialCustomAttributes() *postgresqlv1alpha1.PostgresqlUserRole { + it := &postgresqlv1alpha1.PostgresqlUserRole{ + ObjectMeta: v1.ObjectMeta{ + Name: pgurName, + Namespace: pgurNamespace, + }, + Spec: postgresqlv1alpha1.PostgresqlUserRoleSpec{ + Mode: postgresqlv1alpha1.ProvidedMode, + ImportSecretName: pgurImportSecretName, + WorkGeneratedSecretName: pgurWorkSecretName, + Privileges: []*postgresqlv1alpha1.PostgresqlUserRolePrivilege{ + { + Privilege: postgresqlv1alpha1.OwnerPrivilege, + Database: &common.CRLink{Name: pgdbName, Namespace: pgdbNamespace}, + GeneratedSecretName: pgurDBSecretName, + }, + }, + RoleAttributes: &postgresqlv1alpha1.PostgresqlUserRoleAttributes{ + Replication: starAny(true), + ConnectionLimit: starAny(5), + }, + }, + } + + return setupSavePGURInternal(it) +} func setupProvidedPGURWithBouncer() *postgresqlv1alpha1.PostgresqlUserRole { it := &postgresqlv1alpha1.PostgresqlUserRole{ @@ -560,6 +586,33 @@ func setupManagedPGUR(userPasswordRotationDuration string) *postgresqlv1alpha1.P return setupSavePGURInternal(it) } +func setupManagedPGURWithPartialCustomAttributes() *postgresqlv1alpha1.PostgresqlUserRole { + it := &postgresqlv1alpha1.PostgresqlUserRole{ + ObjectMeta: v1.ObjectMeta{ + Name: pgurName, + Namespace: pgurNamespace, + }, + Spec: postgresqlv1alpha1.PostgresqlUserRoleSpec{ + Mode: postgresqlv1alpha1.ManagedMode, + RolePrefix: pgurRolePrefix, + WorkGeneratedSecretName: pgurWorkSecretName, + Privileges: []*postgresqlv1alpha1.PostgresqlUserRolePrivilege{ + { + Privilege: postgresqlv1alpha1.OwnerPrivilege, + Database: &common.CRLink{Name: pgdbName, Namespace: pgdbNamespace}, + GeneratedSecretName: pgurDBSecretName, + }, + }, + RoleAttributes: &postgresqlv1alpha1.PostgresqlUserRoleAttributes{ + Replication: starAny(true), + ConnectionLimit: starAny(5), + }, + }, + } + + return setupSavePGURInternal(it) +} + func setupManagedPGURWith2Databases() *postgresqlv1alpha1.PostgresqlUserRole { it := &postgresqlv1alpha1.PostgresqlUserRole{ ObjectMeta: v1.ObjectMeta{ @@ -1610,6 +1663,57 @@ func getReplicationSlotInternal(db *sql.DB, name string) (*replicationSlotResult return &res, nil } +type RoleAttributes struct { + ConnectionLimit *int + Replication *bool + BypassRLS *bool +} + +func getRoleAttributes(role string) (*RoleAttributes, error) { + res := &RoleAttributes{ + ConnectionLimit: new(int), + Replication: new(bool), + BypassRLS: new(bool), + } + + // Connect + db, err := sql.Open("postgres", postgresUrl) + // Check error + if err != nil { + return nil, err + } + + defer func() error { + return db.Close() + }() + + GetRoleAttributesSQLTemplate := `select rolconnlimit, rolreplication, rolbypassrls FROM pg_roles WHERE rolname = '%s'` + rows, err := db.Query(fmt.Sprintf(GetRoleAttributesSQLTemplate, role)) + if err != nil { + return res, err + } + + defer rows.Close() + + for rows.Next() { + // Scan + err = rows.Scan(res.ConnectionLimit, res.Replication, res.BypassRLS) + // Check error + if err != nil { + return res, err + } + } + + // Rows error + err = rows.Err() + // Check error + if err != nil { + return res, err + } + + return res, nil +} + func checkRoleInSQLDb(role string) { roleExists, roleErr := isSQLRoleExists(role) Expect(roleErr).ToNot(HaveOccurred())