diff --git a/api/v1alpha1/postgresdatabase_types.go b/api/v1alpha1/postgresdatabase_types.go index be4497c..deb0c50 100644 --- a/api/v1alpha1/postgresdatabase_types.go +++ b/api/v1alpha1/postgresdatabase_types.go @@ -20,6 +20,13 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +// PostgresDatabasePrivilegesSpec defines the desired database privileges to grant to roles +type PostgresDatabasePrivilegesSpec struct { + Create bool `json:"create,omitempty"` + Connect bool `json:"connect,omitempty"` + Temporary bool `json:"temporary,omitempty"` +} + // PostgresDatabaseSpec defines the desired state of PostgresDatabase. type PostgresDatabaseSpec struct { @@ -39,6 +46,9 @@ type PostgresDatabaseSpec struct { // PreserveConnectionsOnDelete will determine if the deletion of the object should drop the existing connections to the remote PostgreSQL database. Default is false. PreserveConnectionsOnDelete bool `json:"preserveConnectionsOnDelete,omitempty"` + + // PrivilegesByRole will grant privileges to roles + PrivilegesByRole map[string]PostgresDatabasePrivilegesSpec `json:"privilegesByRole,omitempty"` } // PostgresDatabaseStatus defines the observed state of PostgresDatabase. diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 3092636..af8c353 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -83,6 +83,21 @@ func (in *PostgresDatabaseList) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PostgresDatabasePrivilegesSpec) DeepCopyInto(out *PostgresDatabasePrivilegesSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresDatabasePrivilegesSpec. +func (in *PostgresDatabasePrivilegesSpec) DeepCopy() *PostgresDatabasePrivilegesSpec { + if in == nil { + return nil + } + out := new(PostgresDatabasePrivilegesSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PostgresDatabaseSpec) DeepCopyInto(out *PostgresDatabaseSpec) { *out = *in @@ -91,6 +106,13 @@ func (in *PostgresDatabaseSpec) DeepCopyInto(out *PostgresDatabaseSpec) { *out = make([]string, len(*in)) copy(*out, *in) } + if in.PrivilegesByRole != nil { + in, out := &in.PrivilegesByRole, &out.PrivilegesByRole + *out = make(map[string]PostgresDatabasePrivilegesSpec, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresDatabaseSpec. diff --git a/config/crd/bases/managed-postgres-operator.hoppscale.com_postgresdatabases.yaml b/config/crd/bases/managed-postgres-operator.hoppscale.com_postgresdatabases.yaml index b17e306..5cf7841 100644 --- a/config/crd/bases/managed-postgres-operator.hoppscale.com_postgresdatabases.yaml +++ b/config/crd/bases/managed-postgres-operator.hoppscale.com_postgresdatabases.yaml @@ -65,6 +65,20 @@ spec: of the object should drop the existing connections to the remote PostgreSQL database. Default is false. type: boolean + privilegesByRole: + additionalProperties: + description: PostgresDatabasePrivilegesSpec defines the desired + state of PostgresDatabase. + properties: + connect: + type: boolean + create: + type: boolean + temporary: + type: boolean + type: object + description: PrivilegesByRole will grant privileges to roles + type: object required: - name type: object diff --git a/internal/controller/postgresdatabase_controller.go b/internal/controller/postgresdatabase_controller.go index c5d63ab..1a1a155 100644 --- a/internal/controller/postgresdatabase_controller.go +++ b/internal/controller/postgresdatabase_controller.go @@ -3,6 +3,7 @@ package controller import ( "context" "fmt" + "slices" "time" "github.com/go-logr/logr" @@ -107,6 +108,17 @@ func (r *PostgresDatabaseReconciler) Reconcile(ctx context.Context, req ctrl.Req return ctrlFailResult, err } + for roleName, rolePrivileges := range resource.Spec.PrivilegesByRole { + err = r.reconcilePrivileges( + desiredDatabase.Name, + roleName, + r.convertPrivilegesSpecToList(rolePrivileges), + ) + if err != nil { + return ctrlFailResult, err + } + } + if !resource.Status.Succeeded { resource.Status.Succeeded = true if err = r.Client.Status().Update(context.Background(), resource); err != nil { @@ -243,3 +255,54 @@ func (r *PostgresDatabaseReconciler) reconcileExtensions(database *postgresql.Da } return err } + +// reconcilePrivileges performs all actions related to the database privileges for a single role +func (r *PostgresDatabaseReconciler) reconcilePrivileges(databaseName, roleName string, desiredPrivileges []string) (err error) { + // We retrieve the existing privileges + existingPrivileges, err := postgresql.GetDatabaseRolePrivileges(r.PGPools.Default, databaseName, roleName) + if err != nil { + r.logging.Error(err, "failed to retrieve privileges of database \"%s\" on role \"%s\": %s", databaseName, roleName, err) + return err + } + + // We grant the missing privileges + for _, desiredPrivilege := range desiredPrivileges { + if !slices.Contains(existingPrivileges, desiredPrivilege) { + err := postgresql.GrantDatabaseRolePrivilege(r.PGPools.Default, databaseName, roleName, desiredPrivilege) + if err != nil { + r.logging.Error(err, "failed to grant \"%s\" privilege on database \"%s\" to role \"%s\"", desiredPrivilege, databaseName, roleName) + return err + } + + r.logging.Info(fmt.Sprintf("Privilege \"%s\" has been granted to \"%s\" on database \"%s\"", desiredPrivilege, roleName, databaseName)) + } + } + + // We revoke the non-declared privileges + for _, existingPrivilege := range existingPrivileges { + if !slices.Contains(desiredPrivileges, existingPrivilege) { + err := postgresql.RevokeDatabaseRolePrivilege(r.PGPools.Default, databaseName, roleName, existingPrivilege) + if err != nil { + r.logging.Error(err, "failed to revoke \"%s\" privilege on database \"%s\" to role \"%s\"", existingPrivilege, databaseName, roleName) + return err + } + + r.logging.Info(fmt.Sprintf("Privilege \"%s\" has been revoked from \"%s\" on database \"%s\"", existingPrivilege, roleName, databaseName)) + } + } + return err +} + +func (r *PostgresDatabaseReconciler) convertPrivilegesSpecToList(privilegesSpec managedpostgresoperatorhoppscalecomv1alpha1.PostgresDatabasePrivilegesSpec) []string { + privileges := []string{} + if privilegesSpec.Create { + privileges = append(privileges, "CREATE") + } + if privilegesSpec.Connect { + privileges = append(privileges, "CONNECT") + } + if privilegesSpec.Temporary { + privileges = append(privileges, "TEMPORARY") + } + return privileges +} diff --git a/internal/controller/postgresdatabase_controller_test.go b/internal/controller/postgresdatabase_controller_test.go index dfd3b60..e4e2278 100644 --- a/internal/controller/postgresdatabase_controller_test.go +++ b/internal/controller/postgresdatabase_controller_test.go @@ -627,6 +627,85 @@ var _ = Describe("PostgresDatabase Controller", func() { } } }) + }) + + When("reconciling privileges", func() { + It("should grant privileges to role 'myrole'", func() { + controllerReconciler := &PostgresDatabaseReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + PGPools: pgpools, + } + + // Loop over all privileges + for _, privilege := range postgresql.ListDatabaseAvailablePrivileges() { + pgpoolsMock["default"].ExpectQuery(fmt.Sprintf("^%s$", regexp.QuoteMeta(`SELECT has_database_privilege($1, $2, $3)`))). + WithArgs("myrole", "mydb", privilege). + WillReturnRows( + pgxmock.NewRows([]string{ + "changeme", + }). + AddRow( + false, + ), + ) + } + pgpoolsMock["default"].ExpectExec(fmt.Sprintf("^%s$", regexp.QuoteMeta(`GRANT CREATE ON DATABASE "mydb" TO "myrole"`))). + WillReturnResult(pgxmock.NewResult("", 1)) + + err := controllerReconciler.reconcilePrivileges( + "mydb", + "myrole", + []string{ + "CREATE", + }, + ) + Expect(err).NotTo(HaveOccurred()) + for _, poolMock := range pgpoolsMock { + if err := poolMock.ExpectationsWereMet(); err != nil { + Fail(err.Error()) + } + } + }) + + It("should revoke privileges to role 'myrole'", func() { + controllerReconciler := &PostgresDatabaseReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + PGPools: pgpools, + } + + // Loop over all privileges + for _, privilege := range postgresql.ListDatabaseAvailablePrivileges() { + pgpoolsMock["default"].ExpectQuery(fmt.Sprintf("^%s$", regexp.QuoteMeta(`SELECT has_database_privilege($1, $2, $3)`))). + WithArgs("myrole", "mydb", privilege). + WillReturnRows( + pgxmock.NewRows([]string{ + "changeme", + }). + AddRow( + true, + ), + ) + } + pgpoolsMock["default"].ExpectExec(fmt.Sprintf("^%s$", regexp.QuoteMeta(`REVOKE CREATE ON DATABASE "mydb" FROM "myrole"`))). + WillReturnResult(pgxmock.NewResult("", 1)) + + err := controllerReconciler.reconcilePrivileges( + "mydb", + "myrole", + []string{ + "CONNECT", + "TEMPORARY", + }, + ) + Expect(err).NotTo(HaveOccurred()) + for _, poolMock := range pgpoolsMock { + if err := poolMock.ExpectationsWereMet(); err != nil { + Fail(err.Error()) + } + } + }) }) }) diff --git a/internal/postgresql/database.go b/internal/postgresql/database.go index 444a1cb..3d4d460 100644 --- a/internal/postgresql/database.go +++ b/internal/postgresql/database.go @@ -116,3 +116,59 @@ func DropDatabaseConnections(pgpool PGPoolInterface, name string) (err error) { } return } + +func ListDatabaseAvailablePrivileges() []string { + return []string{ + "CREATE", + "CONNECT", + "TEMPORARY", + } +} + +func GetDatabaseRolePrivileges(pgpool PGPoolInterface, database, role string) (existingPrivileges []string, err error) { + existingPrivileges = []string{} + var hasPrivilege bool + for _, privilege := range ListDatabaseAvailablePrivileges() { + rows, err := pgpool.Query(context.Background(), "SELECT has_database_privilege($1, $2, $3)", role, database, privilege) + if err != nil { + err = fmt.Errorf("pg query failed: %s", err) + return []string{}, err + } + defer rows.Close() + + hasPrivilege, err = pgx.CollectOneRow(rows, pgx.RowTo[bool]) + if err != nil { + err = fmt.Errorf("failed to collect rows: %s", err) + return []string{}, err + } + + if hasPrivilege { + existingPrivileges = append(existingPrivileges, privilege) + } + } + return +} + +func GrantDatabaseRolePrivilege(pgpool PGPoolInterface, database, role, privilege string) (err error) { + sanitizedDatabase := pgx.Identifier{database}.Sanitize() + sanitizedRole := pgx.Identifier{role}.Sanitize() + + _, err = pgpool.Exec(context.Background(), fmt.Sprintf("GRANT %s ON DATABASE %s TO %s", privilege, sanitizedDatabase, sanitizedRole)) + if err != nil { + return fmt.Errorf("failed to grant privilege \"%s\" on database %s to role %s: %s", privilege, sanitizedDatabase, sanitizedRole, err) + } + + return +} + +func RevokeDatabaseRolePrivilege(pgpool PGPoolInterface, database, role, privilege string) (err error) { + sanitizedDatabase := pgx.Identifier{database}.Sanitize() + sanitizedRole := pgx.Identifier{role}.Sanitize() + + _, err = pgpool.Exec(context.Background(), fmt.Sprintf("REVOKE %s ON DATABASE %s FROM %s", privilege, sanitizedDatabase, sanitizedRole)) + if err != nil { + return fmt.Errorf("failed to revoke privilege \"%s\" on database %s from role %s: %s", privilege, sanitizedDatabase, sanitizedRole, err) + } + + return +} diff --git a/internal/postgresql/database_test.go b/internal/postgresql/database_test.go index 27bc3b3..360bbdf 100644 --- a/internal/postgresql/database_test.go +++ b/internal/postgresql/database_test.go @@ -373,4 +373,104 @@ var _ = Describe("PostgreSQL Database", func() { }) }) + Context("Calling GetDatabaseRolePrivileges", func() { + It("should returns the list of privileges for a role", func() { + // Loop over all privileges + existingPrivileges := map[string]bool{ + "CREATE": false, + "CONNECT": true, + "TEMPORARY": true, + } + for privName, privEnabled := range existingPrivileges { + pgpoolMock.ExpectQuery(fmt.Sprintf("^%s$", regexp.QuoteMeta(`SELECT has_database_privilege($1, $2, $3)`))). + WithArgs("myrole", "mydb", privName). + WillReturnRows( + pgxmock.NewRows([]string{ + "changeme", + }). + AddRow( + privEnabled, + ), + ) + } + + privs, err := GetDatabaseRolePrivileges(pgpool, "mydb", "myrole") + + Expect(privs).To(Equal([]string{"CONNECT", "TEMPORARY"})) + + Expect(err).NotTo(HaveOccurred()) + if err := pgpoolMock.ExpectationsWereMet(); err != nil { + Fail(err.Error()) + } + }) + + It("should return an error if the PostgreSQL request failed", func() { + pgpoolMock.ExpectQuery(fmt.Sprintf("^%s$", regexp.QuoteMeta(`SELECT has_database_privilege($1, $2, $3)`))). + WithArgs("myrole", "mydb", "CREATE"). + WillReturnError(fmt.Errorf("fake error from PostgreSQL")) + + privs, err := GetDatabaseRolePrivileges(pgpool, "mydb", "myrole") + + Expect(privs).To(Equal([]string{})) + + Expect(err).To(HaveOccurred()) + if err := pgpoolMock.ExpectationsWereMet(); err != nil { + Fail(err.Error()) + } + }) + }) + + Context("Calling GrantDatabaseRolePrivilege", func() { + It("should grant a privilege", func() { + pgpoolMock.ExpectExec(regexp.QuoteMeta(`GRANT CREATE ON DATABASE "mydb" TO "myrole"`)). + WillReturnResult(pgxmock.NewResult("foo", 1)) + + err := GrantDatabaseRolePrivilege(pgpool, "mydb", "myrole", "CREATE") + + Expect(err).NotTo(HaveOccurred()) + if err := pgpoolMock.ExpectationsWereMet(); err != nil { + Fail(err.Error()) + } + }) + + It("should return an error if the PostgreSQL request failed", func() { + pgpoolMock.ExpectExec(regexp.QuoteMeta(`GRANT CREATE ON DATABASE "mydb" TO "myrole"`)). + WillReturnError(fmt.Errorf("fake error from PostgreSQL")) + + err := GrantDatabaseRolePrivilege(pgpool, "mydb", "myrole", "CREATE") + + Expect(err).To(HaveOccurred()) + if err := pgpoolMock.ExpectationsWereMet(); err != nil { + Fail(err.Error()) + } + }) + }) + + Context("Calling RevokeDatabaseRolePrivilege", func() { + It("should revoke a privilege", func() { + pgpoolMock.ExpectExec(regexp.QuoteMeta(`REVOKE CREATE ON DATABASE "mydb" FROM "myrole"`)). + WillReturnResult(pgxmock.NewResult("foo", 1)) + + err := RevokeDatabaseRolePrivilege(pgpool, "mydb", "myrole", "CREATE") + + Expect(err).NotTo(HaveOccurred()) + if err := pgpoolMock.ExpectationsWereMet(); err != nil { + Fail(err.Error()) + } + }) + + It("should return an error if the PostgreSQL request failed", func() { + pgpoolMock.ExpectExec(regexp.QuoteMeta(`REVOKE CREATE ON DATABASE "mydb" FROM "myrole"`)). + WillReturnError(fmt.Errorf("fake error from PostgreSQL")) + + err := RevokeDatabaseRolePrivilege(pgpool, "mydb", "myrole", "CREATE") + + Expect(err).To(HaveOccurred()) + if err := pgpoolMock.ExpectationsWereMet(); err != nil { + Fail(err.Error()) + } + }) + + }) + })