diff --git a/README.md b/README.md index 8f5d8bd39..c3356b744 100644 --- a/README.md +++ b/README.md @@ -173,7 +173,7 @@ metadata: postgres.db.movetokube.com/instance: POSTGRES_INSTANCE spec: role: username - database: my-db # This references the Postgres CR + database: my-db # This references the Postgres CR (deprecated; use `databases`) secretName: my-secret privileges: OWNER # Can be OWNER/READ/WRITE annotations: # Annotations to be propagated to the secrets metadata section (optional) @@ -235,6 +235,53 @@ Available context: | `.Hostname` | Database host (without port) | | `.Port` | Database port | +### Multi-Database PostgresUser + +You can grant a single login role access to multiple databases. Use `spec.databases[]` to reference one or more +`Postgres` CRs in the same namespace, with per-database privileges. + +Example: + +```yaml +--- +apiVersion: db.movetokube.com/v1alpha1 +kind: Postgres +metadata: + name: foo-db + namespace: app +spec: + database: foo +--- +apiVersion: db.movetokube.com/v1alpha1 +kind: Postgres +metadata: + name: bar-db + namespace: app +spec: + database: bar +--- +apiVersion: db.movetokube.com/v1alpha1 +kind: PostgresUser +metadata: + name: app-user + namespace: app +spec: + role: app + databases: + - name: foo-db + privileges: OWNER + - name: bar-db + privileges: OWNER + secretName: my-secret +``` + +Notes: +- The operator creates a single login role (with random suffix) and grants it the proper group roles for each database. +- `status.grants[]` records the database-to-group mapping. Fields `status.databaseName` and `status.postgresGroup` + remain for backward compatibility and reflect the first database. +- Secrets still include a single `DATABASE_NAME` and URL values; for multi-DB users these point to the first referenced + database. Customize via `spec.secretTemplate` if needed. + ### Compatibility Postgres operator uses Operator SDK, which uses kubernetes client. Kubernetes client compatibility with Kubernetes cluster diff --git a/api/v1alpha1/postgresuser_types.go b/api/v1alpha1/postgresuser_types.go index 14988760b..cfcc7660b 100644 --- a/api/v1alpha1/postgresuser_types.go +++ b/api/v1alpha1/postgresuser_types.go @@ -9,17 +9,31 @@ import ( // PostgresUserSpec defines the desired state of PostgresUser type PostgresUserSpec struct { - Role string `json:"role"` - Database string `json:"database"` + Role string `json:"role"` + // Deprecated: use Databases instead + Database string `json:"database,omitempty"` SecretName string `json:"secretName"` // +optional SecretTemplate map[string]string `json:"secretTemplate,omitempty"` // key-value, where key is secret field, value is go template // +optional - Privileges string `json:"privileges"` + // Deprecated: use Databases[].privileges instead + Privileges string `json:"privileges,omitempty"` // +optional Annotations map[string]string `json:"annotations,omitempty"` // +optional Labels map[string]string `json:"labels,omitempty"` + // +optional + // +listType=map + // +listMapKey=name + Databases []PostgresUserDatabaseRef `json:"databases,omitempty"` +} + +// PostgresUserDatabaseRef references a Postgres CR and desired privileges +type PostgresUserDatabaseRef struct { + // name of the Postgres CR in the same namespace + Name string `json:"name"` + // Privileges: one of OWNER, WRITE, READ + Privileges string `json:"privileges"` } // PostgresUserStatus defines the observed state of PostgresUser @@ -27,8 +41,20 @@ type PostgresUserStatus struct { Succeeded bool `json:"succeeded"` PostgresRole string `json:"postgresRole"` PostgresLogin string `json:"postgresLogin"` - PostgresGroup string `json:"postgresGroup"` + // Deprecated: for multi-db, use Grants + PostgresGroup string `json:"postgresGroup,omitempty"` + // Deprecated: for multi-db, use Grants + DatabaseName string `json:"databaseName,omitempty"` + // +optional + // +listType=map + // +listMapKey=databaseName + Grants []PostgresUserDatabaseGrant `json:"grants,omitempty"` +} + +// PostgresUserDatabaseGrant stores the granted group per database +type PostgresUserDatabaseGrant struct { DatabaseName string `json:"databaseName"` + PostgresGroup string `json:"postgresGroup"` } // +kubebuilder:object:root=true diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 9d8d57b6a..af9299dbe 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -139,7 +139,7 @@ func (in *PostgresUser) DeepCopyInto(out *PostgresUser) { out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) in.Spec.DeepCopyInto(&out.Spec) - out.Status = in.Status + in.Status.DeepCopyInto(&out.Status) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresUser. @@ -160,6 +160,36 @@ func (in *PostgresUser) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PostgresUserDatabaseGrant) DeepCopyInto(out *PostgresUserDatabaseGrant) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresUserDatabaseGrant. +func (in *PostgresUserDatabaseGrant) DeepCopy() *PostgresUserDatabaseGrant { + if in == nil { + return nil + } + out := new(PostgresUserDatabaseGrant) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PostgresUserDatabaseRef) DeepCopyInto(out *PostgresUserDatabaseRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresUserDatabaseRef. +func (in *PostgresUserDatabaseRef) DeepCopy() *PostgresUserDatabaseRef { + if in == nil { + return nil + } + out := new(PostgresUserDatabaseRef) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PostgresUserList) DeepCopyInto(out *PostgresUserList) { *out = *in @@ -216,6 +246,11 @@ func (in *PostgresUserSpec) DeepCopyInto(out *PostgresUserSpec) { (*out)[key] = val } } + if in.Databases != nil { + in, out := &in.Databases, &out.Databases + *out = make([]PostgresUserDatabaseRef, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresUserSpec. @@ -231,6 +266,11 @@ func (in *PostgresUserSpec) DeepCopy() *PostgresUserSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PostgresUserStatus) DeepCopyInto(out *PostgresUserStatus) { *out = *in + if in.Grants != nil { + in, out := &in.Grants, &out.Grants + *out = make([]PostgresUserDatabaseGrant, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresUserStatus. 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 3b45527c7..c88dc7cd7 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 @@ -41,6 +41,18 @@ spec: additionalProperties: type: string type: object + databases: + items: + properties: + name: + type: string + privileges: + type: string + required: + - name + - privileges + type: object + type: array privileges: type: string role: @@ -52,7 +64,6 @@ spec: type: string type: object required: - - database - role - secretName type: object @@ -61,6 +72,18 @@ spec: properties: databaseName: type: string + grants: + items: + properties: + databaseName: + type: string + postgresGroup: + type: string + required: + - databaseName + - postgresGroup + type: object + type: array postgresGroup: type: string postgresLogin: @@ -70,8 +93,6 @@ spec: succeeded: type: boolean required: - - databaseName - - postgresGroup - postgresLogin - postgresRole - succeeded diff --git a/config/crd/bases/db.movetokube.com_postgresusers.yaml b/config/crd/bases/db.movetokube.com_postgresusers.yaml index 478eb378c..9e6621502 100644 --- a/config/crd/bases/db.movetokube.com_postgresusers.yaml +++ b/config/crd/bases/db.movetokube.com_postgresusers.yaml @@ -44,12 +44,33 @@ spec: type: string type: object database: + description: 'Deprecated: use Databases instead' type: string + databases: + items: + description: PostgresUserDatabaseRef references a Postgres CR and + desired privileges + properties: + name: + description: name of the Postgres CR in the same namespace + type: string + privileges: + description: 'Privileges: one of OWNER, WRITE, READ' + type: string + required: + - name + - privileges + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map labels: additionalProperties: type: string type: object privileges: + description: 'Deprecated: use Databases[].privileges instead' type: string role: type: string @@ -60,7 +81,6 @@ spec: type: string type: object required: - - database - role - secretName type: object @@ -68,8 +88,27 @@ spec: description: PostgresUserStatus defines the observed state of PostgresUser properties: databaseName: + description: 'Deprecated: for multi-db, use Grants' type: string + grants: + items: + description: PostgresUserDatabaseGrant stores the granted group + per database + properties: + databaseName: + type: string + postgresGroup: + type: string + required: + - databaseName + - postgresGroup + type: object + type: array + x-kubernetes-list-map-keys: + - databaseName + x-kubernetes-list-type: map postgresGroup: + description: 'Deprecated: for multi-db, use Grants' type: string postgresLogin: type: string @@ -78,8 +117,6 @@ spec: succeeded: type: boolean required: - - databaseName - - postgresGroup - postgresLogin - postgresRole - succeeded diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index f803d4b21..3ba15263f 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -1,26 +1,35 @@ +--- apiVersion: rbac.authorization.k8s.io/v1 -kind: Role +kind: ClusterRole metadata: - name: ext-postgres-operator + name: manager-role rules: - - apiGroups: - - "" - resources: - - configmaps - - secrets - - services - verbs: - - "*" - - apiGroups: - - "" - resources: - - pods - verbs: - - "get" - - apiGroups: - - "apps" - resources: - - replicasets - - deployments - verbs: - - "get" +- apiGroups: + - db.movetokube.com + resources: + - postgres + - postgresusers + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - db.movetokube.com + resources: + - postgres/finalizers + - postgresusers/finalizers + verbs: + - update +- apiGroups: + - db.movetokube.com + resources: + - postgres/status + - postgresusers/status + verbs: + - get + - patch + - update diff --git a/internal/controller/postgresuser_controller.go b/internal/controller/postgresuser_controller.go index da94df087..3d4e7ac26 100644 --- a/internal/controller/postgresuser_controller.go +++ b/internal/controller/postgresuser_controller.go @@ -84,23 +84,40 @@ func (r *PostgresUserReconciler) Reconcile(ctx context.Context, req ctrl.Request // Deletion logic if instance.GetDeletionTimestamp() != nil { if instance.Status.Succeeded && instance.Status.PostgresRole != "" { - // Initialize database name for connection with default database - // in case postgres cr isn't here anymore - db := r.pg.GetDefaultDatabase() - // Search Postgres CR - postgres, err := r.getPostgresCR(ctx, instance) - // Check if error exists and not a not found error - if err != nil && !errors.IsNotFound(err) { - return ctrl.Result{}, err + // Build database -> groupRole mapping from status (multi-db aware) + ownerByDB := map[string]string{} + if len(instance.Status.Grants) > 0 { + for _, g := range instance.Status.Grants { + // Skip empty values just in case + if g.DatabaseName != "" && g.PostgresGroup != "" { + ownerByDB[g.DatabaseName] = g.PostgresGroup + } + } } - // Check if postgres cr is found and not in deletion state - if postgres != nil && postgres.GetDeletionTimestamp().IsZero() { - db = instance.Status.DatabaseName + // Backward compatibility: single DB fields + if len(ownerByDB) == 0 && instance.Status.DatabaseName != "" && instance.Status.PostgresGroup != "" { + ownerByDB[instance.Status.DatabaseName] = instance.Status.PostgresGroup } - err = r.pg.DropRole(instance.Status.PostgresRole, instance.Status.PostgresGroup, - db, reqLogger) - if err != nil { - return ctrl.Result{}, err + // If still empty, fallback to default database to allow DropRole to proceed + if len(ownerByDB) == 0 { + ownerByDB[r.pg.GetDefaultDatabase()] = instance.Status.PostgresGroup + } + + type dropper interface { + DropRoleMulti(role string, ownerByDB map[string]string, logger logr.Logger) error + } + if dr, ok := r.pg.(dropper); ok { + if err := dr.DropRoleMulti(instance.Status.PostgresRole, ownerByDB, reqLogger); err != nil { + return ctrl.Result{}, err + } + } else { + // Fallback: try single-db drop using the first entry + for dbName, group := range ownerByDB { + if err := r.pg.DropRole(instance.Status.PostgresRole, group, dbName, reqLogger); err != nil { + return ctrl.Result{}, err + } + break + } } } controllerutil.RemoveFinalizer(instance, "finalizer.db.movetokube.com") @@ -122,11 +139,39 @@ func (r *PostgresUserReconciler) Reconcile(ctx context.Context, req ctrl.Request } if instance.Status.PostgresRole == "" { - // We need to get the Postgres CR to get the group role name - database, err := r.getPostgresCR(ctx, instance) - if err != nil { - return r.requeue(ctx, instance, errors.NewInternalError(err)) + // Resolve desired databases and privileges (supports both legacy and new spec) + var desired []dbv1alpha1.PostgresUserDatabaseRef + if len(instance.Spec.Databases) > 0 { + desired = instance.Spec.Databases + } else if instance.Spec.Database != "" { + desired = []dbv1alpha1.PostgresUserDatabaseRef{{ + Name: instance.Spec.Database, + Privileges: instance.Spec.Privileges, + }} + } else { + return r.requeue(ctx, instance, fmt.Errorf("no databases specified in spec")) } + + // Fetch all Postgres CRs and compute group roles + type dbGrant struct{ dbName, groupRole, dbActual string } + grants := make([]dbGrant, 0, len(desired)) + for _, ref := range desired { + pgcr, err := r.getPostgresByName(ctx, instance.Namespace, ref.Name) + if err != nil { + return r.requeue(ctx, instance, errors.NewInternalError(err)) + } + var group string + switch ref.Privileges { + case "READ": + group = pgcr.Status.Roles.Reader + case "WRITE": + group = pgcr.Status.Roles.Writer + default: + group = pgcr.Status.Roles.Owner + } + grants = append(grants, dbGrant{dbName: pgcr.Spec.Database, groupRole: group, dbActual: pgcr.Spec.Database}) + } + // Create user role suffix := utils.GetRandomString(6) role = fmt.Sprintf("%s-%s", instance.Spec.Role, suffix) @@ -135,33 +180,37 @@ func (r *PostgresUserReconciler) Reconcile(ctx context.Context, req ctrl.Request return r.requeue(ctx, instance, errors.NewInternalError(err)) } - // Grant group role to user role - var groupRole string - switch instance.Spec.Privileges { - case "READ": - groupRole = database.Status.Roles.Reader - case "WRITE": - groupRole = database.Status.Roles.Writer - default: - groupRole = database.Status.Roles.Owner - } - - err = r.pg.GrantRole(groupRole, role) - if err != nil { - return r.requeue(ctx, instance, errors.NewInternalError(err)) + // Grant group roles to user role for each database + for _, g := range grants { + if err := r.pg.GrantRole(g.groupRole, role); err != nil { + return r.requeue(ctx, instance, errors.NewInternalError(err)) + } } - // Alter default set role to group role - // This is so that objects created by user gets owned by group role - err = r.pg.AlterDefaultLoginRole(role, groupRole) - if err != nil { - return r.requeue(ctx, instance, errors.NewInternalError(err)) + // Set default login role only when exactly one database is granted. + // For multi-database users, leaving the default role unset ensures the login role + // inherits privileges from all granted group roles simultaneously. + if len(grants) == 1 { + if err := r.pg.AlterDefaultLoginRole(role, grants[0].groupRole); err != nil { + return r.requeue(ctx, instance, errors.NewInternalError(err)) + } } + // Update status (store first db fields for backwards compatibility) instance.Status.PostgresRole = role - instance.Status.PostgresGroup = groupRole instance.Status.PostgresLogin = login - instance.Status.DatabaseName = database.Spec.Database + if len(grants) > 0 { + instance.Status.PostgresGroup = grants[0].groupRole + instance.Status.DatabaseName = grants[0].dbActual + } + // Fill detailed grants + instance.Status.Grants = nil + for _, g := range grants { + instance.Status.Grants = append(instance.Status.Grants, dbv1alpha1.PostgresUserDatabaseGrant{ + DatabaseName: g.dbName, + PostgresGroup: g.groupRole, + }) + } err = r.Status().Update(ctx, instance) if err != nil { return r.requeue(ctx, instance, err) @@ -235,6 +284,20 @@ func (r *PostgresUserReconciler) getPostgresCR(ctx context.Context, instance *db return &database, nil } +func (r *PostgresUserReconciler) getPostgresByName(ctx context.Context, namespace, name string) (*dbv1alpha1.Postgres, error) { + database := dbv1alpha1.Postgres{} + if err := r.Get(ctx, types.NamespacedName{Namespace: namespace, Name: name}, &database); err != nil { + return nil, err + } + if !utils.MatchesInstanceAnnotation(database.Annotations, r.instanceFilter) { + return nil, fmt.Errorf("database \"%s\" is not managed by this operator", database.Name) + } + if !database.Status.Succeeded { + return nil, fmt.Errorf("database \"%s\" is not ready", database.Name) + } + return &database, nil +} + func (r *PostgresUserReconciler) newSecretForCR(reqLogger logr.Logger, cr *dbv1alpha1.PostgresUser, role, password, login string) (*corev1.Secret, error) { hostname, port, err := net.SplitHostPort(r.pgHost) if err != nil { @@ -316,8 +379,14 @@ func (r *PostgresUserReconciler) addFinalizer(ctx context.Context, reqLogger log } func (r *PostgresUserReconciler) addOwnerRef(ctx context.Context, _ logr.Logger, instance *dbv1alpha1.PostgresUser) error { - // Search postgres database CR - pg, err := r.getPostgresCR(ctx, instance) + // Search postgres database CR (use first referenced DB) + var pg *dbv1alpha1.Postgres + var err error + if len(instance.Spec.Databases) > 0 { + pg, err = r.getPostgresByName(ctx, instance.Namespace, instance.Spec.Databases[0].Name) + } else { + pg, err = r.getPostgresCR(ctx, instance) + } if err != nil { return err } diff --git a/internal/controller/postgresuser_controller_test.go b/internal/controller/postgresuser_controller_test.go index f54789e3d..82e41bf70 100644 --- a/internal/controller/postgresuser_controller_test.go +++ b/internal/controller/postgresuser_controller_test.go @@ -184,10 +184,9 @@ var _ = Describe("PostgresUser Controller", func() { }) It("should drop the role and remove finalizer", func() { - // Expect DropRole to be called - pg.EXPECT().GetDefaultDatabase().Return("postgres") - pg.EXPECT().DropRole(postgresUser.Status.PostgresRole, postgresUser.Status.PostgresGroup, - databaseName, gomock.Any()).Return(nil) + // Expect DropRoleMulti to be called with the DB->group mapping + ownerByDB := map[string]string{databaseName: postgresUser.Status.PostgresGroup} + pg.EXPECT().DropRoleMulti(postgresUser.Status.PostgresRole, ownerByDB, gomock.Any()).Return(nil) // Call Reconcile err := runReconcile(rp, ctx, req) @@ -204,10 +203,9 @@ var _ = Describe("PostgresUser Controller", func() { }) It("should return an error if role dropping fails", func() { - // Expect DropRole to fail - pg.EXPECT().GetDefaultDatabase().Return("postgres") - pg.EXPECT().DropRole(postgresUser.Status.PostgresRole, postgresUser.Status.PostgresGroup, - databaseName, gomock.Any()).Return(fmt.Errorf("failed to drop role")) + // Expect DropRoleMulti to fail + ownerByDB := map[string]string{databaseName: postgresUser.Status.PostgresGroup} + pg.EXPECT().DropRoleMulti(postgresUser.Status.PostgresRole, ownerByDB, gomock.Any()).Return(fmt.Errorf("failed to drop role")) // Call Reconcile err := runReconcile(rp, ctx, req) Expect(err).To(HaveOccurred()) diff --git a/pkg/postgres/aws.go b/pkg/postgres/aws.go index 61e732357..a26523986 100644 --- a/pkg/postgres/aws.go +++ b/pkg/postgres/aws.go @@ -78,3 +78,39 @@ func (c *awspg) DropRole(role, newOwner, database string, logger logr.Logger) er return c.pg.DropRole(role, newOwner, database, logger) } + +func (c *awspg) DropRoleMulti(role string, ownerByDB map[string]string, logger logr.Logger) error { + // On AWS RDS the postgres user isn't really superuser so he doesn't have permissions + // to REASSIGN OWNED BY unless he belongs to both roles + if err := c.GrantRole(role, c.user); err != nil { + if e, ok := err.(*pq.Error); ok { + switch e.Code { + case "42704": + // role does not exist + return nil + case "0LP01": + // insufficient privilege, ignore + default: + return err + } + } else { + return err + } + } + // Grant all target owners as well + for _, owner := range ownerByDB { + if err := c.GrantRole(owner, c.user); err != nil { + if e, ok := err.(*pq.Error); ok { + switch e.Code { + case "42704", "0LP01": + // ignore + default: + return err + } + } else { + return err + } + } + } + return c.pg.DropRoleMulti(role, ownerByDB, logger) +} diff --git a/pkg/postgres/azure.go b/pkg/postgres/azure.go index 99628bcb7..8d9231ba4 100644 --- a/pkg/postgres/azure.go +++ b/pkg/postgres/azure.go @@ -48,3 +48,16 @@ func (azpg *azurepg) DropRole(role, newOwner, database string, logger logr.Logge // Delegate to parent implementation to perform the actual drop return azpg.pg.DropRole(role, newOwner, database, logger) } + +func (azpg *azurepg) DropRoleMulti(role string, ownerByDB map[string]string, logger logr.Logger) error { + // Grant the role to the user first + if err := azpg.GrantRole(role, azpg.user); err != nil { + if pqErr, ok := err.(*pq.Error); !ok || pqErr.Code != "0LP01" { + if ok && pqErr.Code == "42704" { + return nil + } + return err + } + } + return azpg.pg.DropRoleMulti(role, ownerByDB, logger) +} diff --git a/pkg/postgres/gcp.go b/pkg/postgres/gcp.go index 1531ffb84..fd9649470 100644 --- a/pkg/postgres/gcp.go +++ b/pkg/postgres/gcp.go @@ -83,3 +83,17 @@ func (c *gcppg) DropRole(role, newOwner, database string, logger logr.Logger) er } return nil } + +func (c *gcppg) DropRoleMulti(role string, ownerByDB map[string]string, logger logr.Logger) error { + // GCP behavior: do not attempt REASSIGN OWNED; just drop role if not master + // Pick an arbitrary database to check master role + var anyDB string + for db := range ownerByDB { + anyDB = db + break + } + if anyDB == "" { + anyDB = c.default_database + } + return c.DropRole(role, "", anyDB, logger) +} diff --git a/pkg/postgres/mock/postgres.go b/pkg/postgres/mock/postgres.go index 4f53e59ec..b9d824326 100644 --- a/pkg/postgres/mock/postgres.go +++ b/pkg/postgres/mock/postgres.go @@ -154,6 +154,20 @@ func (mr *MockPGMockRecorder) DropRole(role, newOwner, database, logger any) *go return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DropRole", reflect.TypeOf((*MockPG)(nil).DropRole), role, newOwner, database, logger) } +// DropRoleMulti mocks base method. +func (m *MockPG) DropRoleMulti(role string, ownerByDB map[string]string, logger logr.Logger) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DropRoleMulti", role, ownerByDB, logger) + ret0, _ := ret[0].(error) + return ret0 +} + +// DropRoleMulti indicates an expected call of DropRoleMulti. +func (mr *MockPGMockRecorder) DropRoleMulti(role, ownerByDB, logger any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DropRoleMulti", reflect.TypeOf((*MockPG)(nil).DropRoleMulti), role, ownerByDB, logger) +} + // GetDefaultDatabase mocks base method. func (m *MockPG) GetDefaultDatabase() string { m.ctrl.T.Helper() diff --git a/pkg/postgres/postgres.go b/pkg/postgres/postgres.go index 033640abe..33407d0ac 100644 --- a/pkg/postgres/postgres.go +++ b/pkg/postgres/postgres.go @@ -22,6 +22,8 @@ type PG interface { AlterDefaultLoginRole(role, setRole string) error DropDatabase(db string, logger logr.Logger) error DropRole(role, newOwner, database string, logger logr.Logger) error + // DropRoleMulti reassigns and drops owned objects across multiple databases then drops role + DropRoleMulti(role string, ownerByDB map[string]string, logger logr.Logger) error GetUser() string GetDefaultDatabase() string } diff --git a/pkg/postgres/role.go b/pkg/postgres/role.go index 8bf4f4b71..eed19e795 100644 --- a/pkg/postgres/role.go +++ b/pkg/postgres/role.go @@ -92,6 +92,44 @@ func (c *pg) DropRole(role, newOwner, database string, logger logr.Logger) error return nil } +// DropRoleMulti reassigns/drops owned across multiple databases then drops the role +func (c *pg) DropRoleMulti(role string, ownerByDB map[string]string, logger logr.Logger) error { + // For each database, reassign and drop owned by role + for db, owner := range ownerByDB { + tmpDb, err := GetConnection(c.user, c.pass, c.host, db, c.args, logger) + if err != nil { + // If database does not exist, skip + if pqErr, ok := err.(*pq.Error); ok && pqErr.Code == "3D000" { + continue + } + return err + } + // reassign owned objects + if _, err = tmpDb.Exec(fmt.Sprintf(REASIGN_OBJECTS, role, owner)); err != nil { + // Ignore role not found + if pqErr, ok := err.(*pq.Error); !ok || pqErr.Code != "42704" { + tmpDb.Close() + return err + } + } + // drop owned by + if _, err = tmpDb.Exec(fmt.Sprintf(DROP_OWNED_BY, role)); err != nil { + if pqErr, ok := err.(*pq.Error); !ok || pqErr.Code != "42704" { + tmpDb.Close() + return err + } + } + tmpDb.Close() + } + // Finally, drop the role globally + if _, err := c.db.Exec(fmt.Sprintf(DROP_ROLE, role)); err != nil { + if pqErr, ok := err.(*pq.Error); !ok || pqErr.Code != "42704" { + return err + } + } + return nil +} + func (c *pg) UpdatePassword(role, password string) error { _, err := c.db.Exec(fmt.Sprintf(UPDATE_PASSWORD, role, password)) if err != nil { diff --git a/tests/e2e/multi-db-user/01-postgres.yaml b/tests/e2e/multi-db-user/01-postgres.yaml new file mode 100644 index 000000000..80fe9e95b --- /dev/null +++ b/tests/e2e/multi-db-user/01-postgres.yaml @@ -0,0 +1,13 @@ +apiVersion: db.movetokube.com/v1alpha1 +kind: Postgres +metadata: + name: md-test-db1 +spec: + database: mdtestdb1 +--- +apiVersion: db.movetokube.com/v1alpha1 +kind: Postgres +metadata: + name: md-test-db2 +spec: + database: mdtestdb2 diff --git a/tests/e2e/multi-db-user/02-postgresuser.yaml b/tests/e2e/multi-db-user/02-postgresuser.yaml new file mode 100644 index 000000000..95da0ddcb --- /dev/null +++ b/tests/e2e/multi-db-user/02-postgresuser.yaml @@ -0,0 +1,12 @@ +apiVersion: db.movetokube.com/v1alpha1 +kind: PostgresUser +metadata: + name: md-test-user +spec: + role: mdtest + databases: + - name: md-test-db1 + privileges: OWNER + - name: md-test-db2 + privileges: READ + secretName: md-secret diff --git a/tests/e2e/multi-db-user/03-assert.yaml b/tests/e2e/multi-db-user/03-assert.yaml new file mode 100644 index 000000000..ff07aa86e --- /dev/null +++ b/tests/e2e/multi-db-user/03-assert.yaml @@ -0,0 +1,26 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +collectors: + - type: pod + selector: app.kubernetes.io/name=ext-postgres-operator + tail: 100 +--- +apiVersion: db.movetokube.com/v1alpha1 +kind: PostgresUser +metadata: + name: md-test-user +status: + succeeded: true + grants: + - databaseName: mdtestdb1 + postgresGroup: mdtestdb1-group + - databaseName: mdtestdb2 + postgresGroup: mdtestdb2-reader +--- +apiVersion: v1 +kind: Secret +metadata: + name: md-secret-md-test-user + labels: + app: md-test-user +type: Opaque