diff --git a/config/crd/bases/pxc.percona.com_perconaxtradbclusters.yaml b/config/crd/bases/pxc.percona.com_perconaxtradbclusters.yaml index ff5d94855..532d436b5 100644 --- a/config/crd/bases/pxc.percona.com_perconaxtradbclusters.yaml +++ b/config/crd/bases/pxc.percona.com_perconaxtradbclusters.yaml @@ -10233,6 +10233,34 @@ spec: versionServiceEndpoint: type: string type: object + users: + items: + properties: + dbs: + items: + type: string + type: array + grants: + items: + type: string + type: array + hosts: + items: + type: string + type: array + name: + type: string + passwordSecretRef: + properties: + key: + type: string + name: + type: string + type: object + withGrantOption: + type: boolean + type: object + type: array vaultSecretName: type: string type: object diff --git a/deploy/bundle.yaml b/deploy/bundle.yaml index 1fe59c9a1..4a84c33ad 100644 --- a/deploy/bundle.yaml +++ b/deploy/bundle.yaml @@ -11138,6 +11138,34 @@ spec: versionServiceEndpoint: type: string type: object + users: + items: + properties: + dbs: + items: + type: string + type: array + grants: + items: + type: string + type: array + hosts: + items: + type: string + type: array + name: + type: string + passwordSecretRef: + properties: + key: + type: string + name: + type: string + type: object + withGrantOption: + type: boolean + type: object + type: array vaultSecretName: type: string type: object diff --git a/deploy/cr.yaml b/deploy/cr.yaml index a93f7415b..aba1eb78d 100644 --- a/deploy/cr.yaml +++ b/deploy/cr.yaml @@ -556,6 +556,24 @@ spec: requests: memory: 100M cpu: 200m + +# users: +# - name: my-user +# dbs: +# - db1 +# - db2 +# hosts: +# - localhost +# grants: +# - SELECT +# - DELETE +# - INSERT +# withGrantOption: true +# passwordSecretRef: +# name: my-user-pwd +# key: my-user-pwd-key +# - name: my-user-two + pmm: enabled: false image: perconalab/pmm-client:dev-latest diff --git a/deploy/crd.yaml b/deploy/crd.yaml index da65fed3b..4b379a3ab 100644 --- a/deploy/crd.yaml +++ b/deploy/crd.yaml @@ -11138,6 +11138,34 @@ spec: versionServiceEndpoint: type: string type: object + users: + items: + properties: + dbs: + items: + type: string + type: array + grants: + items: + type: string + type: array + hosts: + items: + type: string + type: array + name: + type: string + passwordSecretRef: + properties: + key: + type: string + name: + type: string + type: object + withGrantOption: + type: boolean + type: object + type: array vaultSecretName: type: string type: object diff --git a/deploy/cw-bundle.yaml b/deploy/cw-bundle.yaml index 160e86930..787991ffd 100644 --- a/deploy/cw-bundle.yaml +++ b/deploy/cw-bundle.yaml @@ -11138,6 +11138,34 @@ spec: versionServiceEndpoint: type: string type: object + users: + items: + properties: + dbs: + items: + type: string + type: array + grants: + items: + type: string + type: array + hosts: + items: + type: string + type: array + name: + type: string + passwordSecretRef: + properties: + key: + type: string + name: + type: string + type: object + withGrantOption: + type: boolean + type: object + type: array vaultSecretName: type: string type: object diff --git a/e2e-tests/custom-users/compare/select-1.sql b/e2e-tests/custom-users/compare/select-1.sql new file mode 100644 index 000000000..8e738f4cf --- /dev/null +++ b/e2e-tests/custom-users/compare/select-1.sql @@ -0,0 +1 @@ +100500 diff --git a/e2e-tests/custom-users/compare/user-five-1.sql b/e2e-tests/custom-users/compare/user-five-1.sql new file mode 100644 index 000000000..e996e88a7 --- /dev/null +++ b/e2e-tests/custom-users/compare/user-five-1.sql @@ -0,0 +1 @@ +user-five % diff --git a/e2e-tests/custom-users/compare/user-five.sql b/e2e-tests/custom-users/compare/user-five.sql new file mode 100644 index 000000000..b0c4a8714 --- /dev/null +++ b/e2e-tests/custom-users/compare/user-five.sql @@ -0,0 +1,4 @@ +GRANT USAGE ON *.* TO `user-five`@`%` +GRANT SELECT, UPDATE, DELETE ON `db1`.* TO `user-five`@`%` +GRANT SELECT, UPDATE, DELETE ON `db2`.* TO `user-five`@`%` +GRANT SELECT, UPDATE, DELETE ON `db3`.* TO `user-five`@`%` diff --git a/e2e-tests/custom-users/compare/user-four-1.sql b/e2e-tests/custom-users/compare/user-four-1.sql new file mode 100644 index 000000000..98d569a4a --- /dev/null +++ b/e2e-tests/custom-users/compare/user-four-1.sql @@ -0,0 +1 @@ +user-four % diff --git a/e2e-tests/custom-users/compare/user-four-2.sql b/e2e-tests/custom-users/compare/user-four-2.sql new file mode 100644 index 000000000..1b2f3084c --- /dev/null +++ b/e2e-tests/custom-users/compare/user-four-2.sql @@ -0,0 +1,4 @@ +GRANT USAGE ON *.* TO `user-four`@`%` +GRANT SELECT, UPDATE ON `db1`.* TO `user-four`@`%` +GRANT SELECT, UPDATE ON `db2`.* TO `user-four`@`%` +GRANT SELECT, UPDATE ON `db3`.* TO `user-four`@`%` diff --git a/e2e-tests/custom-users/compare/user-four-3.sql b/e2e-tests/custom-users/compare/user-four-3.sql new file mode 100644 index 000000000..0e19ed7a0 --- /dev/null +++ b/e2e-tests/custom-users/compare/user-four-3.sql @@ -0,0 +1,4 @@ +GRANT USAGE ON *.* TO `user-four`@`%` +GRANT SELECT, UPDATE, DELETE ON `db1`.* TO `user-four`@`%` +GRANT SELECT, UPDATE, DELETE ON `db2`.* TO `user-four`@`%` +GRANT SELECT, UPDATE, DELETE ON `db3`.* TO `user-four`@`%` diff --git a/e2e-tests/custom-users/compare/user-four-4.sql b/e2e-tests/custom-users/compare/user-four-4.sql new file mode 100644 index 000000000..ce0f1720e --- /dev/null +++ b/e2e-tests/custom-users/compare/user-four-4.sql @@ -0,0 +1,4 @@ +GRANT USAGE ON *.* TO `user-four`@`%` +GRANT SELECT, INSERT, UPDATE, DELETE ON `db1`.* TO `user-four`@`%` +GRANT SELECT, UPDATE, DELETE ON `db2`.* TO `user-four`@`%` +GRANT SELECT, UPDATE, DELETE ON `db3`.* TO `user-four`@`%` diff --git a/e2e-tests/custom-users/compare/user-four.sql b/e2e-tests/custom-users/compare/user-four.sql new file mode 100644 index 000000000..471dde3d5 --- /dev/null +++ b/e2e-tests/custom-users/compare/user-four.sql @@ -0,0 +1,3 @@ +GRANT USAGE ON *.* TO `user-four`@`%` +GRANT SELECT, UPDATE ON `db1`.* TO `user-four`@`%` +GRANT SELECT, UPDATE ON `db2`.* TO `user-four`@`%` diff --git a/e2e-tests/custom-users/compare/user-one-1.sql b/e2e-tests/custom-users/compare/user-one-1.sql new file mode 100644 index 000000000..c64747019 --- /dev/null +++ b/e2e-tests/custom-users/compare/user-one-1.sql @@ -0,0 +1,2 @@ +user-one % +user-one 127.0.0.1 diff --git a/e2e-tests/custom-users/compare/user-one-2.sql b/e2e-tests/custom-users/compare/user-one-2.sql new file mode 100644 index 000000000..084d33df6 --- /dev/null +++ b/e2e-tests/custom-users/compare/user-one-2.sql @@ -0,0 +1,3 @@ +GRANT USAGE ON *.* TO `user-one`@`127.0.0.1` +GRANT SELECT, INSERT ON `db1`.* TO `user-one`@`127.0.0.1` +GRANT SELECT, INSERT ON `db2`.* TO `user-one`@`127.0.0.1` diff --git a/e2e-tests/custom-users/compare/user-one.sql b/e2e-tests/custom-users/compare/user-one.sql new file mode 100644 index 000000000..e189d3386 --- /dev/null +++ b/e2e-tests/custom-users/compare/user-one.sql @@ -0,0 +1,3 @@ +GRANT USAGE ON *.* TO `user-one`@`%` +GRANT SELECT, INSERT ON `db1`.* TO `user-one`@`%` +GRANT SELECT, INSERT ON `db2`.* TO `user-one`@`%` diff --git a/e2e-tests/custom-users/compare/user-three-1.sql b/e2e-tests/custom-users/compare/user-three-1.sql new file mode 100644 index 000000000..dad3b815b --- /dev/null +++ b/e2e-tests/custom-users/compare/user-three-1.sql @@ -0,0 +1 @@ +user-three % diff --git a/e2e-tests/custom-users/compare/user-three.sql b/e2e-tests/custom-users/compare/user-three.sql new file mode 100644 index 000000000..23fc85260 --- /dev/null +++ b/e2e-tests/custom-users/compare/user-three.sql @@ -0,0 +1 @@ +GRANT USAGE ON *.* TO `user-three`@`%` diff --git a/e2e-tests/custom-users/compare/user-two-1.sql b/e2e-tests/custom-users/compare/user-two-1.sql new file mode 100644 index 000000000..a2d17f308 --- /dev/null +++ b/e2e-tests/custom-users/compare/user-two-1.sql @@ -0,0 +1 @@ +user-two % diff --git a/e2e-tests/custom-users/compare/user-two.sql b/e2e-tests/custom-users/compare/user-two.sql new file mode 100644 index 000000000..7413ebde3 --- /dev/null +++ b/e2e-tests/custom-users/compare/user-two.sql @@ -0,0 +1 @@ +GRANT INSERT, UPDATE ON *.* TO `user-two`@`%` diff --git a/e2e-tests/custom-users/conf/some-name.yml b/e2e-tests/custom-users/conf/some-name.yml new file mode 100644 index 000000000..1fdfaff13 --- /dev/null +++ b/e2e-tests/custom-users/conf/some-name.yml @@ -0,0 +1,124 @@ +apiVersion: pxc.percona.com/v1-6-0 +kind: PerconaXtraDBCluster +metadata: + name: some-name + finalizers: + - percona.com/delete-pxc-pods-in-order +spec: + secretsName: my-cluster-secrets + vaultSecretName: some-name-vault + pause: false + + users: + - name: user-one + dbs: + - db1 + - db2 + hosts: + - '%' + - '127.0.0.1' + grants: + - SELECT + - INSERT + passwordSecretRef: + name: user-secrets + key: pwd-key-one + - name: user-two + hosts: + - '%' + grants: + - INSERT + - UPDATE + passwordSecretRef: + name: user-secrets # will use default user password key + - name: user-three # will use generated password + + pxc: + size: 3 + image: -pxc + resources: + requests: + memory: 0.1G + cpu: 100m + limits: + memory: "1G" + cpu: "1" + volumeSpec: + persistentVolumeClaim: + resources: + requests: + storage: 2Gi + affinity: + antiAffinityTopologyKey: "kubernetes.io/hostname" + podDisruptionBudget: + maxUnavailable: 1 + haproxy: + enabled: true + size: 3 + image: -haproxy + affinity: + antiAffinityTopologyKey: "kubernetes.io/hostname" + tolerations: + - key: "node.alpha.kubernetes.io/unreachable" + operator: "Exists" + effect: "NoExecute" + tolerationSeconds: 6000 + podDisruptionBudget: + maxUnavailable: 2 + proxysql: + enabled: false + size: 2 + image: -proxysql + resources: + requests: + memory: 0.1G + cpu: 100m + limits: + memory: 1G + cpu: 700m + volumeSpec: + persistentVolumeClaim: + resources: + requests: + storage: 2Gi + affinity: + antiAffinityTopologyKey: "kubernetes.io/hostname" + podDisruptionBudget: + maxUnavailable: 1 + pmm: + enabled: false + image: perconalab/pmm-client:1.17.1 + serverHost: monitoring-service + serverUser: pmm + backup: + image: -backup + serviceAccountName: default + storages: + pvc: + type: filesystem + volume: + persistentVolumeClaim: + accessModes: [ "ReadWriteOnce" ] + resources: + requests: + storage: 1Gi + aws-s3: + type: s3 + s3: + region: us-east-1 + bucket: operator-testing + credentialsSecret: aws-s3-secret + minio: + type: s3 + s3: + credentialsSecret: minio-secret + region: us-east-1 + bucket: operator-testing + endpointUrl: http://minio-service:9000/ + gcp-cs: + type: s3 + s3: + credentialsSecret: gcp-cs-secret + region: us-east-1 + bucket: operator-testing + endpointUrl: https://storage.googleapis.com diff --git a/e2e-tests/custom-users/conf/user-secrets.yml b/e2e-tests/custom-users/conf/user-secrets.yml new file mode 100644 index 000000000..75609eb9f --- /dev/null +++ b/e2e-tests/custom-users/conf/user-secrets.yml @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: Secret +metadata: + name: user-secrets +type: Opaque +stringData: + pwd-key-one: testpass + pwd-key-two: testpass2 + password: testpass3 +# --- +# apiVersion: v1 +# kind: Secret +# metadata: +# name: user-secrets-two +# type: Opaque +# stringData: +# pwd-key: testpass +# password: testpass diff --git a/e2e-tests/custom-users/run b/e2e-tests/custom-users/run new file mode 100755 index 000000000..3de89a325 --- /dev/null +++ b/e2e-tests/custom-users/run @@ -0,0 +1,120 @@ +#!/bin/bash + +set -o errexit + +test_dir=$(realpath $(dirname $0)) +. ${test_dir}/../functions + +set_debug + +create_infra $namespace + +desc 'create PXC cluster' + +cluster="some-name" +kubectl_bin apply -f "$test_dir/conf/user-secrets.yml" +spinup_pxc "$cluster" "$test_dir/conf/$cluster.yml" + +desc 'check users created on cluster creation' + +compare_mysql_user "-h $cluster-haproxy -uuser-one -ptestpass" +compare_mysql_cmd "user-one-1" "SELECT User, Host from mysql.user WHERE User = 'user-one';" "-h $cluster-haproxy -uroot -proot_password" +compare_mysql_cmd "user-one-2" "SHOW GRANTS FOR 'user-one'@'127.0.0.1';" "-h $cluster-haproxy -uroot -proot_password" + +compare_mysql_user "-h $cluster-haproxy -uuser-two -ptestpass3" +compare_mysql_cmd "user-two-1" "SELECT User, Host from mysql.user WHERE User = 'user-two';" "-h $cluster-haproxy -uroot -proot_password" + +generatedUserSecret="$cluster-custom-user-secret" +userThreePass=$(kubectl_bin get secret $generatedUserSecret -o jsonpath="{.data.user-three}" | base64 -d) +compare_mysql_user "-h $cluster-haproxy -uuser-three -p'$userThreePass'" +compare_mysql_cmd "user-three-1" "SELECT User, Host from mysql.user WHERE User = 'user-three';" "-h $cluster-haproxy -uroot -proot_password" + +desc 'check password change' +kubectl_bin patch secret user-secrets -p='{"stringData":{"pwd-key-one": "new-password"}}' +sleep 15 + +compare_mysql_user "-h $cluster-haproxy -uuser-one -pnew-password" +compare_mysql_cmd "user-one-1" "SELECT User, Host from mysql.user WHERE User = 'user-one';" "-h $cluster-haproxy -uroot -proot_password" +compare_mysql_cmd "user-one-2" "SHOW GRANTS FOR 'user-one'@'127.0.0.1';" "-h $cluster-haproxy -uroot -proot_password" + +desc 'delete initial users from CR and create a new one' +kubectl_bin patch pxc some-name --type=merge -p='{ + "spec": {"users":[ + { + "name":"user-four", + "dbs": ["db1", "db2"], + "grants":["SELECT, UPDATE"], + "hosts": ["%"] + } + ]} + }' +wait_cluster_consistency "$cluster" 3 3 + +userFourPass=$(kubectl_bin get secret $generatedUserSecret -o jsonpath="{.data.user-four}" | base64 -d) + +compare_mysql_user "-h $cluster-haproxy -uuser-four -p'$userFourPass'" +compare_mysql_cmd "user-four-1" "SELECT User, Host from mysql.user WHERE User = 'user-four';" "-h $cluster-haproxy -uroot -proot_password" + +# user-one, user-two and three should not be deleted +compare_mysql_user "-h $cluster-haproxy -uuser-one -pnew-password" +compare_mysql_user "-h $cluster-haproxy -uuser-two -ptestpass3" + +desc 'check user DBs updated' +kubectl_bin patch pxc some-name --type=merge -p='{ + "spec": {"users":[ + { + "name":"user-four", + "dbs": ["db1", "db2", "db3"], + "grants":["SELECT, UPDATE"], + "hosts": ["%"] + } + ]} + }' +wait_cluster_consistency "$cluster" 3 3 +compare_mysql_cmd "user-four-2" "SHOW GRANTS FOR 'user-four'@'%';" "-h $cluster-haproxy -uroot -proot_password" + +desc 'check user grants updated' +kubectl_bin patch pxc some-name --type=merge -p='{ + "spec": {"users":[ + { + "name":"user-four", + "dbs": ["db1", "db2", "db3"], + "grants":["SELECT, UPDATE, DELETE"], + "hosts": ["%"] + } + ]} + }' +wait_cluster_consistency "$cluster" 3 3 +compare_mysql_cmd "user-four-3" "SHOW GRANTS FOR 'user-four'@'%';" "-h $cluster-haproxy -uroot -proot_password" + +desc 'check user recreated after deleted from DB' +run_mysql "DROP USER 'user-four'@'%';" "-h $cluster-haproxy -uroot -proot_password" +sleep 15 +compare_mysql_cmd "user-four-1" "SELECT User, Host from mysql.user WHERE User = 'user-four';" "-h $cluster-haproxy -uroot -proot_password" +compare_mysql_cmd "user-four-3" "SHOW GRANTS FOR 'user-four'@'%';" "-h $cluster-haproxy -uroot -proot_password" + +desc 'check user update from DB' +run_mysql "GRANT INSERT ON db1.* TO 'user-four'@'%'" "-h $cluster-haproxy -uroot -proot_password" +sleep 15 +compare_mysql_cmd "user-four-1" "SELECT User, Host from mysql.user WHERE User = 'user-four';" "-h $cluster-haproxy -uroot -proot_password" +compare_mysql_cmd "user-four-4" "SHOW GRANTS FOR 'user-four'@'%';" "-h $cluster-haproxy -uroot -proot_password" + +desc 'check new user created after updated user name via CR' +kubectl_bin patch pxc some-name --type=merge -p='{ + "spec": {"users":[ + { + "name":"user-five", + "dbs": ["db1", "db2", "db3"], + "grants":["SELECT, UPDATE, DELETE"], + "hosts": ["%"] + } + ]} + }' +wait_cluster_consistency "$cluster" 3 3 + +userFivePass=$(kubectl_bin get secret $generatedUserSecret -o jsonpath="{.data.user-five}" | base64 -d) +compare_mysql_user "-h $cluster-haproxy -uuser-five -p'$userFivePass'" +compare_mysql_cmd "user-five-1" "SELECT User, Host from mysql.user WHERE User = 'user-five';" "-h $cluster-haproxy -uroot -proot_password" + +destroy "${namespace}" +desc "test passed" diff --git a/e2e-tests/run-distro.csv b/e2e-tests/run-distro.csv index d0b1bbb9d..74eb10b5b 100644 --- a/e2e-tests/run-distro.csv +++ b/e2e-tests/run-distro.csv @@ -1,6 +1,7 @@ auto-tuning default-cr demand-backup-encrypted-with-tls +custom-users haproxy init-deploy one-pod diff --git a/e2e-tests/run-pr.csv b/e2e-tests/run-pr.csv index 2e69c9a98..8a8bdfc34 100644 --- a/e2e-tests/run-pr.csv +++ b/e2e-tests/run-pr.csv @@ -1,6 +1,7 @@ affinity,8.0 auto-tuning,8.0 cross-site,8.0 +custom-users,8.0 demand-backup-cloud,8.0 demand-backup-encrypted-with-tls,8.0 demand-backup,8.0 diff --git a/e2e-tests/run-release.csv b/e2e-tests/run-release.csv index 95b44cb69..29456f42f 100644 --- a/e2e-tests/run-release.csv +++ b/e2e-tests/run-release.csv @@ -2,6 +2,7 @@ affinity auto-tuning big-data cross-site +custom-users default-cr demand-backup demand-backup-cloud diff --git a/pkg/apis/pxc/v1/pxc_types.go b/pkg/apis/pxc/v1/pxc_types.go index f4c9704ee..81b9c3d79 100644 --- a/pkg/apis/pxc/v1/pxc_types.go +++ b/pkg/apis/pxc/v1/pxc_types.go @@ -56,6 +56,22 @@ type PerconaXtraDBClusterSpec struct { EnableCRValidationWebhook *bool `json:"enableCRValidationWebhook,omitempty"` IgnoreAnnotations []string `json:"ignoreAnnotations,omitempty"` IgnoreLabels []string `json:"ignoreLabels,omitempty"` + + Users []User `json:"users,omitempty"` +} + +type SecretKeySelector struct { + Name string `json:"name"` + Key string `json:"key,omitempty"` +} + +type User struct { + Name string `json:"name"` + PasswordSecretRef *SecretKeySelector `json:"passwordSecretRef"` + DBs []string `json:"dbs,omitempty"` + Hosts []string `json:"hosts,omitempty"` + Grants []string `json:"grants,omitempty"` + WithGrantOption bool `json:"withGrantOption,omitempty"` } type UnsafeFlags struct { diff --git a/pkg/apis/pxc/v1/zz_generated.deepcopy.go b/pkg/apis/pxc/v1/zz_generated.deepcopy.go index 832ca6fe7..d5bac9b67 100644 --- a/pkg/apis/pxc/v1/zz_generated.deepcopy.go +++ b/pkg/apis/pxc/v1/zz_generated.deepcopy.go @@ -856,6 +856,13 @@ func (in *PerconaXtraDBClusterSpec) DeepCopyInto(out *PerconaXtraDBClusterSpec) *out = make([]string, len(*in)) copy(*out, *in) } + if in.Users != nil { + in, out := &in.Users, &out.Users + *out = make([]User, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PerconaXtraDBClusterSpec. @@ -1239,6 +1246,21 @@ func (in *ReplicationStatus) DeepCopy() *ReplicationStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SecretKeySelector) DeepCopyInto(out *SecretKeySelector) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretKeySelector. +func (in *SecretKeySelector) DeepCopy() *SecretKeySelector { + if in == nil { + return nil + } + out := new(SecretKeySelector) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ServiceExpose) DeepCopyInto(out *ServiceExpose) { *out = *in @@ -1333,6 +1355,41 @@ func (in *UpgradeOptions) DeepCopy() *UpgradeOptions { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *User) DeepCopyInto(out *User) { + *out = *in + if in.PasswordSecretRef != nil { + in, out := &in.PasswordSecretRef, &out.PasswordSecretRef + *out = new(SecretKeySelector) + **out = **in + } + if in.DBs != nil { + in, out := &in.DBs, &out.DBs + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Hosts != nil { + in, out := &in.Hosts, &out.Hosts + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Grants != nil { + in, out := &in.Grants, &out.Grants + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new User. +func (in *User) DeepCopy() *User { + if in == nil { + return nil + } + out := new(User) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Volume) DeepCopyInto(out *Volume) { *out = *in diff --git a/pkg/controller/pxc/controller.go b/pkg/controller/pxc/controller.go index 60ea5206b..a139ca277 100644 --- a/pkg/controller/pxc/controller.go +++ b/pkg/controller/pxc/controller.go @@ -314,6 +314,11 @@ func (r *ReconcilePerconaXtraDBCluster) Reconcile(ctx context.Context, request r userReconcileResult = urr } + err = r.reconcileCustomUsers(ctx, o) + if err != nil { + return reconcile.Result{}, errors.Wrap(err, "reconcile custom users") + } + r.resyncPXCUsersWithProxySQL(ctx, o) if o.Status.PXC.Version == "" || strings.HasSuffix(o.Status.PXC.Version, "intermediate") { diff --git a/pkg/controller/pxc/users_custom.go b/pkg/controller/pxc/users_custom.go new file mode 100644 index 000000000..85efb9f88 --- /dev/null +++ b/pkg/controller/pxc/users_custom.go @@ -0,0 +1,373 @@ +package pxc + +import ( + "context" + "fmt" + "strings" + + "github.com/go-logr/logr" + "github.com/pkg/errors" + corev1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + logf "sigs.k8s.io/controller-runtime/pkg/log" + + api "github.com/percona/percona-xtradb-cluster-operator/pkg/apis/pxc/v1" + "github.com/percona/percona-xtradb-cluster-operator/pkg/k8s" + "github.com/percona/percona-xtradb-cluster-operator/pkg/pxc/users" +) + +func (r *ReconcilePerconaXtraDBCluster) reconcileCustomUsers(ctx context.Context, cr *api.PerconaXtraDBCluster) error { + if cr.Spec.Users == nil && len(cr.Spec.Users) == 0 { + return nil + } + + if cr.Status.Status != api.AppStateReady { + return nil + } + + log := logf.FromContext(ctx) + + internalSecrets := corev1.Secret{} + err := r.client.Get(ctx, + types.NamespacedName{ + Namespace: cr.Namespace, + Name: internalSecretsPrefix + cr.Name, + }, + &internalSecrets, + ) + if err != nil && !k8serrors.IsNotFound(err) { + return errors.Wrap(err, "get internal sys users secret") + } + + um, err := getUserManager(cr, &internalSecrets) + if err != nil { + return err + } + defer um.Close() + + sysUserNames := sysUserNames() + + for _, user := range cr.Spec.Users { + if _, ok := sysUserNames[user.Name]; ok { + log.Error(nil, "creating user with reserved user name is forbidden", "user", user.Name) + continue + } + + if len(user.Grants) == 0 && user.WithGrantOption { + log.Error(nil, "withGrantOption is set but no grants are provided", "user", user.Name) + continue + } + + if user.PasswordSecretRef != nil && user.PasswordSecretRef.Name == "" { + log.Error(nil, "passwordSecretRef name is not set", "user", user.Name) + continue + } + + if user.PasswordSecretRef != nil && user.PasswordSecretRef.Key == "" { + user.PasswordSecretRef.Key = "password" + } + + if len(user.Hosts) == 0 { + user.Hosts = []string{"%"} + } + + defaultUserSecretName := fmt.Sprintf("%s-custom-user-secret", cr.Name) + + userSecretName := defaultUserSecretName + userSecretPassKey := user.Name + if user.PasswordSecretRef != nil { + userSecretName = user.PasswordSecretRef.Name + userSecretPassKey = user.PasswordSecretRef.Key + } + + userSecret, err := getUserSecret(ctx, r.client, cr, userSecretName, defaultUserSecretName, userSecretPassKey) + if err != nil { + log.Error(err, "failed to get user secret", "user", user) + continue + } + + annotationKey := fmt.Sprintf("percona.com/%s-%s-hash", cr.Name, user.Name) + + if userPasswordChanged(userSecret, annotationKey, userSecretPassKey) { + log.Info("User password changed", "user", user.Name) + + err := um.UpsertUser(ctx, alterUserQuery(&user), string(userSecret.Data[userSecretPassKey])) + if err != nil { + log.Error(err, "failed to update user", "user", user) + continue + } + + err = k8s.AnnotateObject(ctx, r.client, userSecret, + map[string]string{annotationKey: sha256Hash(userSecret.Data[userSecretPassKey])}) + if err != nil { + return errors.Wrap(err, "update user secret") + } + + log.Info("User password updated", "user", user.Name) + } + + u, err := um.GetUser(ctx, user.Name) + if err != nil { + log.Error(err, "failed to get user", "user", user) + continue + } + + if userChanged(u, &user, log) { + log.Info("Creating/updating user", "user", user.Name) + + err := um.UpsertUser(ctx, upsertUserQuery(&user), string(userSecret.Data[userSecretPassKey])) + if err != nil { + log.Error(err, "failed to update user", "user", user) + continue + } + + err = k8s.AnnotateObject(ctx, r.client, userSecret, + map[string]string{annotationKey: sha256Hash(userSecret.Data[userSecretPassKey])}) + if err != nil { + return errors.Wrap(err, "update user secret") + } + + log.Info("User created/updated", "user", user.Name) + } + } + + return nil +} + +func generateUserPass( + ctx context.Context, + cl client.Client, + cr *api.PerconaXtraDBCluster, + secret *corev1.Secret, + passKey string) error { + + log := logf.FromContext(ctx) + + pass, err := generatePass() + if err != nil { + return errors.Wrap(err, "generate custom user password") + } + + if secret.Data == nil { + secret.Data = make(map[string][]byte) + } + secret.Data[passKey] = pass + + err = cl.Create(ctx, secret) + if err != nil { + return fmt.Errorf("create custom users secret: %v", err) + } + + log.Info("Created custom user secrets", "secrets", cr.Spec.SecretsName) + return nil +} + +func userPasswordChanged(secret *corev1.Secret, key, passKey string) bool { + if secret.Annotations == nil { + return false + } + + hash, ok := secret.Annotations[key] + if !ok { + return false + } + + newHash := sha256Hash(secret.Data[passKey]) + + return hash != newHash +} + +func userChanged(current *users.User, desired *api.User, log logr.Logger) bool { + userName := desired.Name + + if current == nil { + log.Info("User not created", "user", userName) + return true + } + + if len(current.Hosts) != len(desired.Hosts) { + log.Info("Hosts changed", "current", current.Hosts, "desired", desired.Hosts, "user", userName) + return true + } + + if len(current.DBs) != len(desired.DBs) { + log.Info("DBs changed", "current", current.DBs, "desired", desired.DBs) + return true + } + + for _, u := range desired.Hosts { + if !current.Hosts.Has(u) { + log.Info("Hosts changed", "current", current.Hosts, "desired", desired.Hosts, "user", userName) + return true + } + } + + for _, db := range desired.DBs { + if !current.DBs.Has(db) { + log.Info("DBs changed", "current", current.DBs, "desired", desired.DBs, "user", userName) + return true + } + } + + for _, host := range desired.Hosts { + if _, ok := current.Grants[host]; !ok && len(desired.Grants) > 0 { + log.Info("Grants for user host not present", "host", host, "user", userName) + return true + } + + for _, grant := range desired.Grants { + for _, currGrant := range current.Grants[host] { + if currGrant == fmt.Sprintf("GRANT USAGE ON *.* TO `%s`@`%s`", desired.Name, host) { + continue + } + + if !strings.Contains(currGrant, strings.ToUpper(grant)) { + log.Info("Grant not present in current grants", "grant", grant, "user", userName) + return true + } + + if desired.WithGrantOption && !strings.Contains(currGrant, "WITH GRANT OPTION") { + log.Info("Grant with grant option not present", "user", userName) + return true + } + } + } + + for _, db := range desired.DBs { + dbPresent := false + + for _, currGrant := range current.Grants[host] { + if strings.Contains(currGrant, fmt.Sprintf("ON `%s`.*", db)) { + dbPresent = true + break + } + } + + if !dbPresent { + log.Info("DB not present in current grants", "db", db, "user", userName) + return true + } + } + } + + return false +} + +// getUserSecret gets secret by name defined by `user.PasswordSecretRef.Name` or returns a secret +// with newly generated password if name matches defaultName +func getUserSecret(ctx context.Context, cl client.Client, cr *api.PerconaXtraDBCluster, name, defaultName, passKey string) (*corev1.Secret, error) { + secret := &corev1.Secret{} + err := cl.Get(ctx, types.NamespacedName{Name: name, Namespace: cr.Namespace}, secret) + + if err != nil && name != defaultName { + return nil, errors.Wrap(err, "failed to get user secret") + } + + if err != nil && !k8serrors.IsNotFound(err) && name == defaultName { + return nil, errors.Wrap(err, "failed to get default user secret") + } + + if err != nil && k8serrors.IsNotFound(err) { + secret = &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: cr.Namespace, + }, + } + err := generateUserPass(ctx, cl, cr, secret, passKey) + if err != nil { + return nil, errors.Wrap(err, "failed to generate user password secrets") + } + + return secret, nil + } + + _, hasPass := secret.Data[passKey] + if !hasPass && name == defaultName { + pass, err := generatePass() + if err != nil { + return nil, errors.Wrap(err, "generate custom user password") + } + + if secret.Data == nil { + secret.Data = make(map[string][]byte) + } + + secret.Data[passKey] = pass + + err = cl.Update(ctx, secret) + if err != nil { + return nil, errors.Wrap(err, "failed to update user secret") + } + + return secret, nil + } + + // pass key should be present in the user provided secret + if !hasPass { + return nil, errors.New("password key not found in secret") + } + + return secret, nil +} + +func sysUserNames() map[string]struct{} { + sysUserNames := make(map[string]struct{}, len(users.UserNames)) + for _, v := range users.UserNames { + sysUserNames[string(v)] = struct{}{} + } + return sysUserNames +} + +func escapeIdentifier(identifier string) string { + return strings.ReplaceAll(identifier, "'", "''") +} + +func alterUserQuery(user *api.User) []string { + query := make([]string, 0) + + if len(user.Hosts) > 0 { + for _, host := range user.Hosts { + query = append(query, fmt.Sprintf("ALTER USER '%s'@'%s' IDENTIFIED BY ?", escapeIdentifier(user.Name), escapeIdentifier(host))) + } + } else { + query = append(query, fmt.Sprintf("ALTER USER '%s'@'%%' IDENTIFIED BY ?", escapeIdentifier(user.Name))) + } + + return query +} + +func upsertUserQuery(user *api.User) []string { + query := make([]string, 0) + + for _, db := range user.DBs { + query = append(query, (fmt.Sprintf("CREATE DATABASE IF NOT EXISTS %s", db))) + } + + withGrantOption := "" + if user.WithGrantOption { + withGrantOption = "WITH GRANT OPTION" + } + + for _, host := range user.Hosts { + query = append(query, (fmt.Sprintf("CREATE USER IF NOT EXISTS '%s'@'%s' IDENTIFIED BY ?", escapeIdentifier(user.Name), escapeIdentifier(host)))) + + if len(user.Grants) > 0 { + grants := strings.Join(user.Grants, ",") + if len(user.DBs) > 0 { + for _, db := range user.DBs { + q := fmt.Sprintf("GRANT %s ON %s.* TO '%s'@'%s' %s", grants, db, escapeIdentifier(user.Name), escapeIdentifier(host), withGrantOption) + query = append(query, q) + } + } else { + q := fmt.Sprintf("GRANT %s ON *.* TO '%s'@'%s' %s", grants, escapeIdentifier(user.Name), escapeIdentifier(host), withGrantOption) + query = append(query, q) + } + } + } + + return query +} diff --git a/pkg/controller/pxc/users_custom_test.go b/pkg/controller/pxc/users_custom_test.go new file mode 100644 index 000000000..a5488413f --- /dev/null +++ b/pkg/controller/pxc/users_custom_test.go @@ -0,0 +1,424 @@ +package pxc + +import ( + "reflect" + "testing" + + "github.com/go-logr/logr" + "k8s.io/apimachinery/pkg/util/sets" + + api "github.com/percona/percona-xtradb-cluster-operator/pkg/apis/pxc/v1" + "github.com/percona/percona-xtradb-cluster-operator/pkg/pxc/users" +) + +func TestUpsertUserQuery(t *testing.T) { + var tests = []struct { + name string + user *api.User + pass string + expected []string + }{ + { + name: "Hosts set but no DBs", + user: &api.User{ + Name: "test", + Hosts: []string{"host1", "host2"}, + Grants: []string{"SELECT, INSERT"}, + }, + expected: []string{ + "CREATE USER IF NOT EXISTS 'test'@'host1' IDENTIFIED BY ?", + "GRANT SELECT, INSERT ON *.* TO 'test'@'host1' ", + "CREATE USER IF NOT EXISTS 'test'@'host2' IDENTIFIED BY ?", + "GRANT SELECT, INSERT ON *.* TO 'test'@'host2' ", + }, + }, + { + name: "DBs and hosts set", + user: &api.User{ + Name: "test", + Hosts: []string{"host1", "host2"}, + DBs: []string{"db1", "db2"}, + Grants: []string{"SELECT, INSERT"}, + }, + pass: "pass1", + expected: []string{ + "CREATE DATABASE IF NOT EXISTS db1", + "CREATE DATABASE IF NOT EXISTS db2", + "CREATE USER IF NOT EXISTS 'test'@'host1' IDENTIFIED BY ?", + "GRANT SELECT, INSERT ON db1.* TO 'test'@'host1' ", + "GRANT SELECT, INSERT ON db2.* TO 'test'@'host1' ", + "CREATE USER IF NOT EXISTS 'test'@'host2' IDENTIFIED BY ?", + "GRANT SELECT, INSERT ON db1.* TO 'test'@'host2' ", + "GRANT SELECT, INSERT ON db2.* TO 'test'@'host2' ", + }, + }, + { + name: "DBs and hosts set with grants and grant option", + user: &api.User{ + Name: "test", + Hosts: []string{"host1", "host2"}, + DBs: []string{"db1", "db2"}, + Grants: []string{"SELECT, INSERT"}, + WithGrantOption: true, + }, + pass: "pass1", + expected: []string{ + "CREATE DATABASE IF NOT EXISTS db1", + "CREATE DATABASE IF NOT EXISTS db2", + "CREATE USER IF NOT EXISTS 'test'@'host1' IDENTIFIED BY ?", + "GRANT SELECT, INSERT ON db1.* TO 'test'@'host1' WITH GRANT OPTION", + "GRANT SELECT, INSERT ON db2.* TO 'test'@'host1' WITH GRANT OPTION", + "CREATE USER IF NOT EXISTS 'test'@'host2' IDENTIFIED BY ?", + "GRANT SELECT, INSERT ON db1.* TO 'test'@'host2' WITH GRANT OPTION", + "GRANT SELECT, INSERT ON db2.* TO 'test'@'host2' WITH GRANT OPTION", + }, + }, + { + name: "DBs and hosts set with no grants", + user: &api.User{ + Name: "test", + Hosts: []string{"host1", "host2"}, + DBs: []string{"db1", "db2"}, + }, + pass: "pass1", + expected: []string{ + "CREATE DATABASE IF NOT EXISTS db1", + "CREATE DATABASE IF NOT EXISTS db2", + "CREATE USER IF NOT EXISTS 'test'@'host1' IDENTIFIED BY ?", + "CREATE USER IF NOT EXISTS 'test'@'host2' IDENTIFIED BY ?", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := upsertUserQuery(tt.user) + if !reflect.DeepEqual(actual, tt.expected) { + t.Fatalf("expected %s, got %s", tt.expected, actual) + } + }) + } +} + +func TestAlterUserQuery(t *testing.T) { + var tests = []struct { + name string + user *api.User + expected []string + }{ + { + name: "no hosts set", + user: &api.User{ + Name: "test", + }, + expected: []string{"ALTER USER 'test'@'%' IDENTIFIED BY ?"}, + }, + { + name: "hosts set", + user: &api.User{ + Name: "test", + Hosts: []string{"host1", "host2"}, + }, + expected: []string{ + "ALTER USER 'test'@'host1' IDENTIFIED BY ?", + "ALTER USER 'test'@'host2' IDENTIFIED BY ?", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := alterUserQuery(tt.user) + if !reflect.DeepEqual(actual, tt.expected) { + t.Fatalf("expected %s, got %s", tt.expected, actual) + } + }) + } +} + +func TestUserChanged(t *testing.T) { + var tests = []struct { + name string + desiredUser *api.User + currentUser *users.User + expected bool + }{ + { + name: "no users in DB", + desiredUser: &api.User{ + Name: "test", + Hosts: []string{"host1", "host2"}, + }, + currentUser: nil, + expected: true, + }, + { + name: "host the same", + desiredUser: &api.User{ + Name: "test", + Hosts: []string{"host1", "host2"}, + }, + currentUser: &users.User{ + Name: "test", + Hosts: sets.New("host1", "host2"), + }, + expected: false, + }, + { + name: "host number not the same", + desiredUser: &api.User{ + Name: "test", + Hosts: []string{"host1", "host2"}, + }, + currentUser: &users.User{ + Name: "test", + Hosts: sets.New("host1"), + }, + expected: true, + }, + { + name: "hosts don't match by content", + desiredUser: &api.User{ + Name: "test", + Hosts: []string{"host1", "host2"}, + }, + currentUser: &users.User{ + Name: "test", + Hosts: sets.New("host1", "host2222"), + }, + expected: true, + }, + { + name: "dbs the same", + desiredUser: &api.User{ + Name: "test", + DBs: []string{"db1", "db2"}, + }, + currentUser: &users.User{ + Name: "test", + DBs: sets.New("db1", "db2"), + }, + expected: false, + }, + { + name: "db number not the same", + desiredUser: &api.User{ + Name: "test", + DBs: []string{"db1", "db2"}, + }, + currentUser: &users.User{ + Name: "test", + DBs: sets.New("db1"), + }, + expected: true, + }, + { + name: "dbs don't match by content", + desiredUser: &api.User{ + Name: "test", + DBs: []string{"db1", "db2"}, + }, + currentUser: &users.User{ + Name: "test", + DBs: sets.New("db1", "db2222"), + }, + expected: true, + }, + { + name: "grants the same with same number of hosts and DBs specified", + desiredUser: &api.User{ + Name: "test", + Hosts: []string{"host1", "host2"}, + DBs: []string{"db1", "db2"}, + Grants: []string{"SELECT", "INSERT"}, + WithGrantOption: true, + }, + currentUser: &users.User{ + Name: "test", + DBs: sets.New("db1", "db2"), + Hosts: sets.New("host1", "host2"), + Grants: map[string][]string{ + "host1": { + "GRANT USAGE ON *.* TO `test`@`host1`", + "GRANT SELECT, INSERT ON `db1`.* TO `test`@`host1` WITH GRANT OPTION", + "GRANT SELECT, INSERT ON `db2`.* TO `test`@`host1` WITH GRANT OPTION", + }, + "host2": { + "GRANT USAGE ON *.* TO `test`@`host2`", + "GRANT SELECT, INSERT ON `db1`.* TO `test`@`host2` WITH GRANT OPTION", + "GRANT SELECT, INSERT ON `db2`.* TO `test`@`host2` WITH GRANT OPTION", + }, + }, + }, + expected: false, + }, + { + name: "grants the same with more DBs then hosts specified", + desiredUser: &api.User{ + Name: "test", + Hosts: []string{"host1", "host2"}, + DBs: []string{"db1", "db2", "db3"}, + Grants: []string{"SELECT", "INSERT"}, + WithGrantOption: true, + }, + currentUser: &users.User{ + Name: "test", + DBs: sets.New("db1", "db2", "db3"), + Hosts: sets.New("host1", "host2"), + Grants: map[string][]string{ + "host1": { + "GRANT USAGE ON *.* TO `test`@`host1`", + "GRANT SELECT, INSERT ON `db1`.* TO `test`@`host1` WITH GRANT OPTION", + "GRANT SELECT, INSERT ON `db2`.* TO `test`@`host1` WITH GRANT OPTION", + "GRANT SELECT, INSERT ON `db3`.* TO `test`@`host1` WITH GRANT OPTION", + }, + "host2": { + "GRANT USAGE ON *.* TO `test`@`host2`", + "GRANT SELECT, INSERT ON `db1`.* TO `test`@`host2` WITH GRANT OPTION", + "GRANT SELECT, INSERT ON `db2`.* TO `test`@`host2` WITH GRANT OPTION", + "GRANT SELECT, INSERT ON `db3`.* TO `test`@`host2` WITH GRANT OPTION", + }, + }, + }, + expected: false, + }, + { + name: "grants the same with more hosts then DBs specified", + desiredUser: &api.User{ + Name: "test", + Hosts: []string{"host1", "host2", "host3"}, + DBs: []string{"db1", "db2"}, + Grants: []string{"SELECT", "INSERT"}, + WithGrantOption: true, + }, + currentUser: &users.User{ + Name: "test", + DBs: sets.New("db1", "db2"), + Hosts: sets.New("host1", "host2", "host3"), + Grants: map[string][]string{ + "host1": { + "GRANT USAGE ON *.* TO `test`@`host1`", + "GRANT SELECT, INSERT ON `db1`.* TO `test`@`host1` WITH GRANT OPTION", + "GRANT SELECT, INSERT ON `db2`.* TO `test`@`host1` WITH GRANT OPTION", + "GRANT SELECT, INSERT ON `db3`.* TO `test`@`host1` WITH GRANT OPTION", + }, + "host2": { + "GRANT USAGE ON *.* TO `test`@`host2`", + "GRANT SELECT, INSERT ON `db1`.* TO `test`@`host2` WITH GRANT OPTION", + "GRANT SELECT, INSERT ON `db2`.* TO `test`@`host2` WITH GRANT OPTION", + "GRANT SELECT, INSERT ON `db3`.* TO `test`@`host2` WITH GRANT OPTION", + }, + "host3": { + "GRANT USAGE ON *.* TO `test`@`host3`", + "GRANT SELECT, INSERT ON `db1`.* TO `test`@`host3` WITH GRANT OPTION", + "GRANT SELECT, INSERT ON `db2`.* TO `test`@`host3` WITH GRANT OPTION", + "GRANT SELECT, INSERT ON `db3`.* TO `test`@`host3` WITH GRANT OPTION", + }, + }, + }, + expected: false, + }, + { + name: "grants the same with more privileges then specified", + desiredUser: &api.User{ + Name: "test", + Hosts: []string{"host1"}, + DBs: []string{"db1"}, + Grants: []string{"SELECT", "INSERT"}, + WithGrantOption: true, + }, + currentUser: &users.User{ + Name: "test", + DBs: sets.New("db1"), + Hosts: sets.New("host1"), + Grants: map[string][]string{ + "host1": { + "GRANT USAGE ON *.* TO `test`@`host1`", + "GRANT SELECT, INSERT, UPDATE ON `db1`.* TO `test`@`host1` WITH GRANT OPTION", + }, + }, + }, + expected: false, + }, + { + name: "grants for user host missing", + desiredUser: &api.User{ + Name: "test", + Hosts: []string{"host1", "host2"}, + DBs: []string{"db1", "db2"}, + Grants: []string{"SELECT", "INSERT"}, + WithGrantOption: true, + }, + currentUser: &users.User{ + Name: "test", + DBs: sets.New("db1", "db2", "db3"), + Hosts: sets.New("host1", "host2"), + Grants: map[string][]string{ + "host2": { + "GRANT USAGE ON *.* TO `test`@`host2`", + "GRANT SELECT, INSERT ON `db1`.* TO `test`@`host2` WITH GRANT OPTION", + "GRANT SELECT, INSERT ON `db2`.* TO `test`@`host2` WITH GRANT OPTION", + }, + }, + }, + expected: true, + }, + { + name: "grants for DB missing", + desiredUser: &api.User{ + Name: "test", + Hosts: []string{"host1", "host2"}, + DBs: []string{"db1", "db2"}, + Grants: []string{"SELECT", "INSERT"}, + WithGrantOption: true, + }, + currentUser: &users.User{ + Name: "test", + DBs: sets.New("db1", "db2", "db3"), + Hosts: sets.New("host1", "host2"), + Grants: map[string][]string{ + "host2": { + "GRANT USAGE ON *.* TO `test`@`host2`", + "GRANT SELECT, INSERT ON `db1`.* TO `test`@`host2` WITH GRANT OPTION", + "GRANT SELECT, INSERT ON `db88`.* TO `test`@`host2` WITH GRANT OPTION", + }, + }, + }, + expected: true, + }, + { + name: "grants for privileges missing", + desiredUser: &api.User{ + Name: "test", + Hosts: []string{"host1", "host2"}, + DBs: []string{"db1", "db2"}, + Grants: []string{"SELECT", "INSERT"}, + WithGrantOption: true, + }, + currentUser: &users.User{ + Name: "test", + DBs: sets.New("db1", "db2", "db3"), + Hosts: sets.New("host1", "host2"), + Grants: map[string][]string{ + "host2": { + "GRANT USAGE ON *.* TO `test`@`host2`", + "GRANT SELECT, INSERT ON `db1`.* TO `test`@`host2` WITH GRANT OPTION", + "GRANT SELECT ON `db2`.* TO `test`@`host2` WITH GRANT OPTION", + }, + }, + }, + expected: true, + }, + } + + log := logr.Discard() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := userChanged(tt.currentUser, tt.desiredUser, log) + if actual != tt.expected { + t.Fatalf("expected %v, got %v", tt.expected, actual) + } + }) + } +} diff --git a/pkg/pxc/users/users.go b/pkg/pxc/users/users.go index 0f79573af..4bf221883 100644 --- a/pkg/pxc/users/users.go +++ b/pkg/pxc/users/users.go @@ -1,11 +1,14 @@ package users import ( + "context" "database/sql" "fmt" + "strings" "github.com/go-sql-driver/mysql" "github.com/pkg/errors" + "k8s.io/apimachinery/pkg/util/sets" ) const ( @@ -32,6 +35,15 @@ type SysUser struct { Hosts []string `yaml:"hosts"` } +type User struct { + Name string + Hosts sets.Set[string] + DBs sets.Set[string] + + // Grants holds the grants for each user@host + Grants map[string][]string +} + func NewManager(addr string, user, pass string, timeout int32) (Manager, error) { var um Manager @@ -293,3 +305,67 @@ func (u *Manager) UpdatePassExpirationPolicy(user *SysUser) error { } return nil } + +func (u *Manager) UpsertUser(ctx context.Context, query []string, pass string) error { + for _, q := range query { + var err error + if strings.Contains(q, "?") { + _, err = u.db.ExecContext(ctx, q, pass) + } else { + _, err = u.db.ExecContext(ctx, q) + } + if err != nil { + return errors.Wrap(err, "exec") + } + } + + return nil +} + +// GetUsers returns a user stored in the database +func (p *Manager) GetUser(ctx context.Context, user string) (*User, error) { + u := &User{ + Name: user, + Hosts: sets.New[string](), + DBs: sets.New[string](), + Grants: make(map[string][]string), + } + + rows, err := p.db.QueryContext(ctx, "SELECT DISTINCT u.Host, d.Db FROM mysql.user u LEFT JOIN mysql.db d ON u.User = d.User WHERE u.User = ?", user) + if err != nil { + return nil, err + } + for rows.Next() { + var host string + var db sql.NullString + err = rows.Scan(&host, &db) + if err != nil { + return nil, err + } + + if db.Valid { + u.DBs.Insert(db.String) + } + u.Hosts.Insert(host) + } + + for host := range u.Hosts { + rows, err := p.db.QueryContext(ctx, "SHOW GRANTS FOR ?@?", user, host) + if err != nil { + return nil, err + } + grants := make([]string, 0, len(u.DBs)+1) + for rows.Next() { + var grant string + err = rows.Scan(&grant) + if err != nil { + return nil, err + } + grants = append(grants, grant) + } + + u.Grants[host] = grants + } + + return u, nil +}