Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions api/v1alpha1/postgresdatabase_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand All @@ -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.
Expand Down
22 changes: 22 additions & 0 deletions api/v1alpha1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 63 additions & 0 deletions internal/controller/postgresdatabase_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package controller
import (
"context"
"fmt"
"slices"
"time"

"github.com/go-logr/logr"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
79 changes: 79 additions & 0 deletions internal/controller/postgresdatabase_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
}
})

})
})
Expand Down
56 changes: 56 additions & 0 deletions internal/postgresql/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading
Loading