diff --git a/apis/cluster/mysql/v1alpha1/user_types.go b/apis/cluster/mysql/v1alpha1/user_types.go index c59d4bae..cae393f8 100644 --- a/apis/cluster/mysql/v1alpha1/user_types.go +++ b/apis/cluster/mysql/v1alpha1/user_types.go @@ -35,12 +35,31 @@ type UserStatus struct { } // UserParameters define the desired state of a MySQL user instance. +// +kubebuilder:validation:XValidation:rule="!(has(self.authenticationPlugin) && has(self.passwordSecretRef))",message="authenticationPlugin and passwordSecretRef are mutually exclusive" type UserParameters struct { // PasswordSecretRef references the secret that contains the password used // for this user. If no reference is given, a password will be auto-generated. + // Cannot be set together with AuthenticationPlugin. // +optional PasswordSecretRef *xpv1.SecretKeySelector `json:"passwordSecretRef,omitempty"` + // AuthenticationPlugin selects a non-default MySQL authentication plugin + // for the user. When set, the user is created with + // CREATE USER ... IDENTIFIED WITH [AS ''] and no + // password is generated or required. + // + // Most commonly used for AWS RDS IAM authentication, where the DB user + // has no static password and clients authenticate via short-lived IAM + // auth tokens. Example: + // authenticationPlugin: + // name: AWSAuthenticationPlugin + // authString: RDS + // + // When AuthenticationPlugin is set, PasswordSecretRef must not be set + // and the connection secret will not contain a password. + // +optional + AuthenticationPlugin *AuthenticationPlugin `json:"authenticationPlugin,omitempty"` + // ResourceOptions sets account specific resource limits. // See https://dev.mysql.com/doc/refman/8.0/en/user-resources.html // +optional @@ -51,6 +70,20 @@ type UserParameters struct { BinLog *bool `json:"binlog,omitempty"` } +// AuthenticationPlugin selects the auth plugin used when creating the user. +type AuthenticationPlugin struct { + // Name is the auth plugin name. Examples: "AWSAuthenticationPlugin", + // "mysql_native_password", "caching_sha2_password", "auth_socket". + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` + + // AuthString is the optional auth string passed to the plugin via the + // AS clause. For AWSAuthenticationPlugin set this to "RDS". For native + // password plugins, leave empty and use PasswordSecretRef instead. + // +optional + AuthString *string `json:"authString,omitempty"` +} + // ResourceOptions define the account specific resource limits. type ResourceOptions struct { // MaxQueriesPerHour sets the number of queries an account can issue per hour @@ -74,6 +107,14 @@ type ResourceOptions struct { type UserObservation struct { // ResourceOptionsAsClauses represents the applied resource options ResourceOptionsAsClauses []string `json:"resourceOptionsAsClauses,omitempty"` + + // AuthenticationPlugin reflects the user's actual authentication plugin + // as read from mysql.user. Populated only when the observed plugin is a + // non-default-password plugin (e.g., AWSAuthenticationPlugin). Used by + // the reconciler to detect drift between the desired + // spec.forProvider.authenticationPlugin and what's actually configured + // on the DB so plugin changes are not silently ignored. + AuthenticationPlugin *AuthenticationPlugin `json:"authenticationPlugin,omitempty"` } // +kubebuilder:object:root=true diff --git a/apis/cluster/mysql/v1alpha1/zz_generated.deepcopy.go b/apis/cluster/mysql/v1alpha1/zz_generated.deepcopy.go index fff8a6cc..078183e1 100644 --- a/apis/cluster/mysql/v1alpha1/zz_generated.deepcopy.go +++ b/apis/cluster/mysql/v1alpha1/zz_generated.deepcopy.go @@ -13,6 +13,26 @@ import ( runtime "k8s.io/apimachinery/pkg/runtime" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AuthenticationPlugin) DeepCopyInto(out *AuthenticationPlugin) { + *out = *in + if in.AuthString != nil { + in, out := &in.AuthString, &out.AuthString + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AuthenticationPlugin. +func (in *AuthenticationPlugin) DeepCopy() *AuthenticationPlugin { + if in == nil { + return nil + } + out := new(AuthenticationPlugin) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Database) DeepCopyInto(out *Database) { *out = *in @@ -662,6 +682,11 @@ func (in *UserObservation) DeepCopyInto(out *UserObservation) { *out = make([]string, len(*in)) copy(*out, *in) } + if in.AuthenticationPlugin != nil { + in, out := &in.AuthenticationPlugin, &out.AuthenticationPlugin + *out = new(AuthenticationPlugin) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UserObservation. @@ -682,6 +707,11 @@ func (in *UserParameters) DeepCopyInto(out *UserParameters) { *out = new(v1.SecretKeySelector) **out = **in } + if in.AuthenticationPlugin != nil { + in, out := &in.AuthenticationPlugin, &out.AuthenticationPlugin + *out = new(AuthenticationPlugin) + (*in).DeepCopyInto(*out) + } if in.ResourceOptions != nil { in, out := &in.ResourceOptions, &out.ResourceOptions *out = new(ResourceOptions) diff --git a/apis/namespaced/mysql/v1alpha1/user_types.go b/apis/namespaced/mysql/v1alpha1/user_types.go index d8a8a1c8..fec67c90 100644 --- a/apis/namespaced/mysql/v1alpha1/user_types.go +++ b/apis/namespaced/mysql/v1alpha1/user_types.go @@ -36,12 +36,31 @@ type UserStatus struct { } // UserParameters define the desired state of a MySQL user instance. +// +kubebuilder:validation:XValidation:rule="!(has(self.authenticationPlugin) && has(self.passwordSecretRef))",message="authenticationPlugin and passwordSecretRef are mutually exclusive" type UserParameters struct { // PasswordSecretRef references the secret that contains the password used // for this user. If no reference is given, a password will be auto-generated. + // Cannot be set together with AuthenticationPlugin. // +optional PasswordSecretRef *xpv1.LocalSecretKeySelector `json:"passwordSecretRef,omitempty"` + // AuthenticationPlugin selects a non-default MySQL authentication plugin + // for the user. When set, the user is created with + // CREATE USER ... IDENTIFIED WITH [AS ''] and no + // password is generated or required. + // + // Most commonly used for AWS RDS IAM authentication, where the DB user + // has no static password and clients authenticate via short-lived IAM + // auth tokens. Example: + // authenticationPlugin: + // name: AWSAuthenticationPlugin + // authString: RDS + // + // When AuthenticationPlugin is set, PasswordSecretRef must not be set + // and the connection secret will not contain a password. + // +optional + AuthenticationPlugin *AuthenticationPlugin `json:"authenticationPlugin,omitempty"` + // ResourceOptions sets account specific resource limits. // See https://dev.mysql.com/doc/refman/8.0/en/user-resources.html // +optional @@ -52,6 +71,20 @@ type UserParameters struct { BinLog *bool `json:"binlog,omitempty"` } +// AuthenticationPlugin selects the auth plugin used when creating the user. +type AuthenticationPlugin struct { + // Name is the auth plugin name. Examples: "AWSAuthenticationPlugin", + // "mysql_native_password", "caching_sha2_password", "auth_socket". + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` + + // AuthString is the optional auth string passed to the plugin via the + // AS clause. For AWSAuthenticationPlugin set this to "RDS". For native + // password plugins, leave empty and use PasswordSecretRef instead. + // +optional + AuthString *string `json:"authString,omitempty"` +} + // ResourceOptions define the account specific resource limits. type ResourceOptions struct { // MaxQueriesPerHour sets the number of queries an account can issue per hour @@ -75,6 +108,14 @@ type ResourceOptions struct { type UserObservation struct { // ResourceOptionsAsClauses represents the applied resource options ResourceOptionsAsClauses []string `json:"resourceOptionsAsClauses,omitempty"` + + // AuthenticationPlugin reflects the user's actual authentication plugin + // as read from mysql.user. Populated only when the observed plugin is a + // non-default-password plugin (e.g., AWSAuthenticationPlugin). Used by + // the reconciler to detect drift between the desired + // spec.forProvider.authenticationPlugin and what's actually configured + // on the DB so plugin changes are not silently ignored. + AuthenticationPlugin *AuthenticationPlugin `json:"authenticationPlugin,omitempty"` } // +kubebuilder:object:root=true diff --git a/apis/namespaced/mysql/v1alpha1/zz_generated.deepcopy.go b/apis/namespaced/mysql/v1alpha1/zz_generated.deepcopy.go index fae51bbd..6c6c349d 100644 --- a/apis/namespaced/mysql/v1alpha1/zz_generated.deepcopy.go +++ b/apis/namespaced/mysql/v1alpha1/zz_generated.deepcopy.go @@ -13,6 +13,26 @@ import ( runtime "k8s.io/apimachinery/pkg/runtime" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AuthenticationPlugin) DeepCopyInto(out *AuthenticationPlugin) { + *out = *in + if in.AuthString != nil { + in, out := &in.AuthString, &out.AuthString + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AuthenticationPlugin. +func (in *AuthenticationPlugin) DeepCopy() *AuthenticationPlugin { + if in == nil { + return nil + } + out := new(AuthenticationPlugin) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ClusterProviderConfig) DeepCopyInto(out *ClusterProviderConfig) { *out = *in @@ -780,6 +800,11 @@ func (in *UserObservation) DeepCopyInto(out *UserObservation) { *out = make([]string, len(*in)) copy(*out, *in) } + if in.AuthenticationPlugin != nil { + in, out := &in.AuthenticationPlugin, &out.AuthenticationPlugin + *out = new(AuthenticationPlugin) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UserObservation. @@ -800,6 +825,11 @@ func (in *UserParameters) DeepCopyInto(out *UserParameters) { *out = new(v1.LocalSecretKeySelector) **out = **in } + if in.AuthenticationPlugin != nil { + in, out := &in.AuthenticationPlugin, &out.AuthenticationPlugin + *out = new(AuthenticationPlugin) + (*in).DeepCopyInto(*out) + } if in.ResourceOptions != nil { in, out := &in.ResourceOptions, &out.ResourceOptions *out = new(ResourceOptions) diff --git a/examples/cluster/mysql/user_iam_auth.yaml b/examples/cluster/mysql/user_iam_auth.yaml new file mode 100644 index 00000000..8ce97997 --- /dev/null +++ b/examples/cluster/mysql/user_iam_auth.yaml @@ -0,0 +1,22 @@ +--- +# AWS RDS IAM authentication: the DB user has no static password and the +# application authenticates with a short-lived auth token generated from its +# IAM role (see https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.IAMDBAuth.html). +# +# Crossplane runs: +# CREATE USER 'example-iam-user'@'%' IDENTIFIED WITH `AWSAuthenticationPlugin` AS 'RDS' +# +# The connection secret will not contain a password (only username, endpoint, +# port). Clients fetch a temporary auth token from AWS RDS at connect time. +apiVersion: mysql.sql.crossplane.io/v1alpha1 +kind: User +metadata: + name: example-iam-user +spec: + forProvider: + authenticationPlugin: + name: AWSAuthenticationPlugin + authString: RDS + writeConnectionSecretToRef: + name: example-iam-user-connection + namespace: default diff --git a/examples/namespaced/mysql/user_iam_auth.yaml b/examples/namespaced/mysql/user_iam_auth.yaml new file mode 100644 index 00000000..b34c8e7a --- /dev/null +++ b/examples/namespaced/mysql/user_iam_auth.yaml @@ -0,0 +1,22 @@ +--- +# AWS RDS IAM authentication: the DB user has no static password and the +# application authenticates with a short-lived auth token generated from its +# IAM role (see https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.IAMDBAuth.html). +# +# Crossplane runs: +# CREATE USER 'example-iam-user'@'%' IDENTIFIED WITH `AWSAuthenticationPlugin` AS 'RDS' +# +# The connection secret will not contain a password (only username, endpoint, +# port). Clients fetch a temporary auth token from AWS RDS at connect time. +apiVersion: mysql.sql.m.crossplane.io/v1alpha1 +kind: User +metadata: + name: example-iam-user + namespace: default +spec: + forProvider: + authenticationPlugin: + name: AWSAuthenticationPlugin + authString: RDS + writeConnectionSecretToRef: + name: example-iam-user-connection diff --git a/package/crds/mysql.sql.crossplane.io_users.yaml b/package/crds/mysql.sql.crossplane.io_users.yaml index fa1c6574..a14e6c68 100644 --- a/package/crds/mysql.sql.crossplane.io_users.yaml +++ b/package/crds/mysql.sql.crossplane.io_users.yaml @@ -71,6 +71,38 @@ spec: description: UserParameters define the desired state of a MySQL user instance. properties: + authenticationPlugin: + description: |- + AuthenticationPlugin selects a non-default MySQL authentication plugin + for the user. When set, the user is created with + CREATE USER ... IDENTIFIED WITH [AS ''] and no + password is generated or required. + + Most commonly used for AWS RDS IAM authentication, where the DB user + has no static password and clients authenticate via short-lived IAM + auth tokens. Example: + authenticationPlugin: + name: AWSAuthenticationPlugin + authString: RDS + + When AuthenticationPlugin is set, PasswordSecretRef must not be set + and the connection secret will not contain a password. + properties: + authString: + description: |- + AuthString is the optional auth string passed to the plugin via the + AS clause. For AWSAuthenticationPlugin set this to "RDS". For native + password plugins, leave empty and use PasswordSecretRef instead. + type: string + name: + description: |- + Name is the auth plugin name. Examples: "AWSAuthenticationPlugin", + "mysql_native_password", "caching_sha2_password", "auth_socket". + minLength: 1 + type: string + required: + - name + type: object binlog: description: BinLog defines whether the create, delete, update operations of this user are propagated to replicas. Defaults @@ -80,6 +112,7 @@ spec: description: |- PasswordSecretRef references the secret that contains the password used for this user. If no reference is given, a password will be auto-generated. + Cannot be set together with AuthenticationPlugin. properties: key: description: The key to select. @@ -118,6 +151,10 @@ spec: type: integer type: object type: object + x-kubernetes-validations: + - message: authenticationPlugin and passwordSecretRef are mutually + exclusive + rule: '!(has(self.authenticationPlugin) && has(self.passwordSecretRef))' managementPolicies: default: - '*' @@ -211,6 +248,30 @@ spec: description: A UserObservation represents the observed state of a MySQL user. properties: + authenticationPlugin: + description: |- + AuthenticationPlugin reflects the user's actual authentication plugin + as read from mysql.user. Populated only when the observed plugin is a + non-default-password plugin (e.g., AWSAuthenticationPlugin). Used by + the reconciler to detect drift between the desired + spec.forProvider.authenticationPlugin and what's actually configured + on the DB so plugin changes are not silently ignored. + properties: + authString: + description: |- + AuthString is the optional auth string passed to the plugin via the + AS clause. For AWSAuthenticationPlugin set this to "RDS". For native + password plugins, leave empty and use PasswordSecretRef instead. + type: string + name: + description: |- + Name is the auth plugin name. Examples: "AWSAuthenticationPlugin", + "mysql_native_password", "caching_sha2_password", "auth_socket". + minLength: 1 + type: string + required: + - name + type: object resourceOptionsAsClauses: description: ResourceOptionsAsClauses represents the applied resource options diff --git a/package/crds/mysql.sql.m.crossplane.io_users.yaml b/package/crds/mysql.sql.m.crossplane.io_users.yaml index 75edcbdf..8ff1932e 100644 --- a/package/crds/mysql.sql.m.crossplane.io_users.yaml +++ b/package/crds/mysql.sql.m.crossplane.io_users.yaml @@ -57,6 +57,38 @@ spec: description: UserParameters define the desired state of a MySQL user instance. properties: + authenticationPlugin: + description: |- + AuthenticationPlugin selects a non-default MySQL authentication plugin + for the user. When set, the user is created with + CREATE USER ... IDENTIFIED WITH [AS ''] and no + password is generated or required. + + Most commonly used for AWS RDS IAM authentication, where the DB user + has no static password and clients authenticate via short-lived IAM + auth tokens. Example: + authenticationPlugin: + name: AWSAuthenticationPlugin + authString: RDS + + When AuthenticationPlugin is set, PasswordSecretRef must not be set + and the connection secret will not contain a password. + properties: + authString: + description: |- + AuthString is the optional auth string passed to the plugin via the + AS clause. For AWSAuthenticationPlugin set this to "RDS". For native + password plugins, leave empty and use PasswordSecretRef instead. + type: string + name: + description: |- + Name is the auth plugin name. Examples: "AWSAuthenticationPlugin", + "mysql_native_password", "caching_sha2_password", "auth_socket". + minLength: 1 + type: string + required: + - name + type: object binlog: description: BinLog defines whether the create, delete, update operations of this user are propagated to replicas. Defaults @@ -66,6 +98,7 @@ spec: description: |- PasswordSecretRef references the secret that contains the password used for this user. If no reference is given, a password will be auto-generated. + Cannot be set together with AuthenticationPlugin. properties: key: type: string @@ -99,6 +132,10 @@ spec: type: integer type: object type: object + x-kubernetes-validations: + - message: authenticationPlugin and passwordSecretRef are mutually + exclusive + rule: '!(has(self.authenticationPlugin) && has(self.passwordSecretRef))' managementPolicies: default: - '*' @@ -164,6 +201,30 @@ spec: description: A UserObservation represents the observed state of a MySQL user. properties: + authenticationPlugin: + description: |- + AuthenticationPlugin reflects the user's actual authentication plugin + as read from mysql.user. Populated only when the observed plugin is a + non-default-password plugin (e.g., AWSAuthenticationPlugin). Used by + the reconciler to detect drift between the desired + spec.forProvider.authenticationPlugin and what's actually configured + on the DB so plugin changes are not silently ignored. + properties: + authString: + description: |- + AuthString is the optional auth string passed to the plugin via the + AS clause. For AWSAuthenticationPlugin set this to "RDS". For native + password plugins, leave empty and use PasswordSecretRef instead. + type: string + name: + description: |- + Name is the auth plugin name. Examples: "AWSAuthenticationPlugin", + "mysql_native_password", "caching_sha2_password", "auth_socket". + minLength: 1 + type: string + required: + - name + type: object resourceOptionsAsClauses: description: ResourceOptionsAsClauses represents the applied resource options diff --git a/pkg/controller/cluster/mysql/user/reconciler.go b/pkg/controller/cluster/mysql/user/reconciler.go index 3eba36a6..3a7e21b9 100644 --- a/pkg/controller/cluster/mysql/user/reconciler.go +++ b/pkg/controller/cluster/mysql/user/reconciler.go @@ -204,11 +204,14 @@ func (c *external) Observe(ctx context.Context, mg *v1alpha1.User) (managed.Exte ResourceOptions: &v1alpha1.ResourceOptions{}, } + var pluginName, authString string query := "SELECT " + "max_questions, " + "max_updates, " + "max_connections, " + - "max_user_connections " + + "max_user_connections, " + + "plugin, " + + "authentication_string " + "FROM mysql.user WHERE User = ? AND Host = ?" err := c.db.Scan(ctx, xsql.Query{ @@ -222,6 +225,8 @@ func (c *external) Observe(ctx context.Context, mg *v1alpha1.User) (managed.Exte &observed.ResourceOptions.MaxUpdatesPerHour, &observed.ResourceOptions.MaxConnectionsPerHour, &observed.ResourceOptions.MaxUserConnections, + &pluginName, + &authString, ) if xsql.IsNoRows(err) { return managed.ExternalObservation{ResourceExists: false}, nil @@ -230,12 +235,34 @@ func (c *external) Observe(ctx context.Context, mg *v1alpha1.User) (managed.Exte return managed.ExternalObservation{}, errors.Wrap(err, errSelectUser) } - _, pwdChanged, err := c.getPassword(ctx, mg) - if err != nil { - return managed.ExternalObservation{}, err + // Hydrate observed.AuthenticationPlugin only when the user is configured + // with a non-default-password plugin. Password plugins (caching_sha2_password, + // mysql_native_password, sha256_password) store an opaque hash in + // authentication_string and are the implicit default when the user was + // created with IDENTIFIED BY '' — we leave AuthenticationPlugin nil in + // that case so the comparison against a spec without AuthenticationPlugin + // reports up-to-date. + if pluginName != "" && !isPasswordPlugin(pluginName) { + observed.AuthenticationPlugin = &v1alpha1.AuthenticationPlugin{Name: pluginName} + if authString != "" { + as := authString + observed.AuthenticationPlugin.AuthString = &as + } + } + + // When the user is configured with a non-default auth plugin (e.g., AWS + // IAM auth), there is no password to compare. Skip the password drift + // check so the resource doesn't churn on every reconcile. + pwdChanged := false + if mg.Spec.ForProvider.AuthenticationPlugin == nil { + _, pwdChanged, err = c.getPassword(ctx, mg) + if err != nil { + return managed.ExternalObservation{}, err + } } mg.Status.AtProvider.ResourceOptionsAsClauses = resourceOptionsToClauses(observed.ResourceOptions) + mg.Status.AtProvider.AuthenticationPlugin = observed.AuthenticationPlugin mg.SetConditions(xpv1.Available()) @@ -245,10 +272,64 @@ func (c *external) Observe(ctx context.Context, mg *v1alpha1.User) (managed.Exte }, nil } +// isPasswordPlugin reports whether the given MySQL plugin name is one of the +// password-based authentication plugins. Users created with IDENTIFIED BY +// '' are assigned a password plugin (caching_sha2_password is the MySQL +// 8.0 default; mysql_native_password and sha256_password are common). For +// these we treat AuthenticationPlugin as nil in the observed state because +// they represent "use a password" rather than an opt-in plugin choice. +func isPasswordPlugin(name string) bool { + switch name { + case "caching_sha2_password", "mysql_native_password", "sha256_password": + return true + } + return false +} + +// authPluginEqual reports whether observed and desired AuthenticationPlugin +// configurations match. nil/nil = match. Mismatched nil-ness = drift. Same +// nil-ness with non-nil values: names must match, and authStrings must both +// be nil OR both equal. +func authPluginEqual(observed, desired *v1alpha1.AuthenticationPlugin) bool { + if desired == nil { + return observed == nil + } + if observed == nil { + return false + } + if observed.Name != desired.Name { + return false + } + if (observed.AuthString == nil) != (desired.AuthString == nil) { + return false + } + if observed.AuthString != nil && *observed.AuthString != *desired.AuthString { + return false + } + return true +} + func (c *external) Create(ctx context.Context, mg *v1alpha1.User) (managed.ExternalCreation, error) { mg.SetConditions(xpv1.Creating()) username, host := mysql.SplitUserHost(meta.GetExternalName(mg)) + ro := resourceOptionsToClauses(mg.Spec.ForProvider.ResourceOptions) + + // When AuthenticationPlugin is set, the user has no password — skip + // password generation, emit IDENTIFIED WITH ..., and return connection + // details without a password. + if ap := mg.Spec.ForProvider.AuthenticationPlugin; ap != nil { + if err := c.executeCreateUserWithPluginQuery(ctx, username, host, ro, ap); err != nil { + return managed.ExternalCreation{}, err + } + if len(ro) != 0 { + mg.Status.AtProvider.ResourceOptionsAsClauses = ro + } + return managed.ExternalCreation{ + ConnectionDetails: c.db.GetConnectionDetails(username, ""), + }, nil + } + pw, _, err := c.getPassword(ctx, mg) if err != nil { return managed.ExternalCreation{}, err @@ -261,7 +342,6 @@ func (c *external) Create(ctx context.Context, mg *v1alpha1.User) (managed.Exter } } - ro := resourceOptionsToClauses(mg.Spec.ForProvider.ResourceOptions) if err := c.executeCreateUserQuery(ctx, username, host, ro, pw); err != nil { return managed.ExternalCreation{}, err } @@ -296,29 +376,67 @@ func (c *external) executeCreateUserQuery(ctx context.Context, username string, return nil } +// executeCreateUserWithPluginQuery emits CREATE USER ... IDENTIFIED WITH +// [AS ''] [WITH ]. The plugin name is +// not value-quoted because MySQL parses it as an identifier; QuoteIdentifier +// (backticks) is used to guard against injection in the plugin name. The +// auth string is value-quoted as a regular string. +func (c *external) executeCreateUserWithPluginQuery(ctx context.Context, username string, host string, resourceOptionsClauses []string, ap *v1alpha1.AuthenticationPlugin) error { + resourceOptions := "" + if len(resourceOptionsClauses) != 0 { + resourceOptions = fmt.Sprintf(" WITH %s", strings.Join(resourceOptionsClauses, " ")) + } + + authString := "" + if ap.AuthString != nil && *ap.AuthString != "" { + authString = fmt.Sprintf(" AS %s", mysql.QuoteValue(*ap.AuthString)) + } + + query := fmt.Sprintf( + "CREATE USER %s@%s IDENTIFIED WITH %s%s%s", + mysql.QuoteValue(username), + mysql.QuoteValue(host), + mysql.QuoteIdentifier(ap.Name), + authString, + resourceOptions, + ) + + if err := mysql.ExecWrapper(ctx, c.db, mysql.ExecQuery{Query: query, ErrorValue: errCreateUser}); err != nil { + return err + } + + return nil +} + func (c *external) Update(ctx context.Context, mg *v1alpha1.User) (managed.ExternalUpdate, error) { username, host := mysql.SplitUserHost(meta.GetExternalName(mg)) - ro := resourceOptionsToClauses(mg.Spec.ForProvider.ResourceOptions) - rochanged, err := changedResourceOptions(mg.Status.AtProvider.ResourceOptionsAsClauses, ro) - if err != nil { - return managed.ExternalUpdate{}, errors.Wrap(err, errUpdateUser) + if err := c.updateResourceOptions(ctx, mg, username, host); err != nil { + return managed.ExternalUpdate{}, err } - if len(rochanged) > 0 { - resourceOptions := fmt.Sprintf("WITH %s", strings.Join(ro, " ")) - - query := fmt.Sprintf( - "ALTER USER %s@%s %s", - mysql.QuoteValue(username), - mysql.QuoteValue(host), - resourceOptions, - ) - if err := mysql.ExecWrapper(ctx, c.db, mysql.ExecQuery{Query: query, ErrorValue: errUpdateUser}); err != nil { + // Detect AuthenticationPlugin drift using the last-observed value cached + // in Status.AtProvider by Observe. Three transitions are handled: + // 1. password → plugin: ALTER USER ... IDENTIFIED WITH [AS ''] + // 2. plugin → password: fall through to UpdatePassword below; it emits + // ALTER USER ... IDENTIFIED BY '' which both sets the password + // AND restores the default password plugin. + // 3. plugin → different plugin or same plugin with different authString: + // ALTER USER ... IDENTIFIED WITH [AS ''] + // The API-level XValidation rule enforces mutual exclusivity of + // PasswordSecretRef and AuthenticationPlugin, so the spec always + // represents one mode or the other. + desiredPlugin := mg.Spec.ForProvider.AuthenticationPlugin + observedPlugin := mg.Status.AtProvider.AuthenticationPlugin + if !authPluginEqual(observedPlugin, desiredPlugin) && desiredPlugin != nil { + if err := c.executeAlterUserWithPluginQuery(ctx, username, host, desiredPlugin); err != nil { return managed.ExternalUpdate{}, err } - - mg.Status.AtProvider.ResourceOptionsAsClauses = ro + mg.Status.AtProvider.AuthenticationPlugin = desiredPlugin + // Connection details for a plugin-auth user carry no password. + return managed.ExternalUpdate{ + ConnectionDetails: c.db.GetConnectionDetails(username, ""), + }, nil } connectionDetails, err := c.UpdatePassword(ctx, mg, username, host) @@ -326,6 +444,12 @@ func (c *external) Update(ctx context.Context, mg *v1alpha1.User) (managed.Exter return managed.ExternalUpdate{}, err } + // If transitioning from plugin → password, clear the cached observed + // plugin so the next reconcile starts clean. + if desiredPlugin == nil && observedPlugin != nil { + mg.Status.AtProvider.AuthenticationPlugin = nil + } + if len(connectionDetails) > 0 { return managed.ExternalUpdate{ConnectionDetails: connectionDetails}, nil } @@ -333,7 +457,65 @@ func (c *external) Update(ctx context.Context, mg *v1alpha1.User) (managed.Exter return managed.ExternalUpdate{}, nil } +// updateResourceOptions emits ALTER USER ... WITH when the +// resource-options clauses observed by Status differ from the desired spec. +// Extracted from Update to keep that function below the gocyclo threshold. +func (c *external) updateResourceOptions(ctx context.Context, mg *v1alpha1.User, username, host string) error { + ro := resourceOptionsToClauses(mg.Spec.ForProvider.ResourceOptions) + rochanged, err := changedResourceOptions(mg.Status.AtProvider.ResourceOptionsAsClauses, ro) + if err != nil { + return errors.Wrap(err, errUpdateUser) + } + if len(rochanged) == 0 { + return nil + } + + query := fmt.Sprintf( + "ALTER USER %s@%s WITH %s", + mysql.QuoteValue(username), + mysql.QuoteValue(host), + strings.Join(ro, " "), + ) + if err := mysql.ExecWrapper(ctx, c.db, mysql.ExecQuery{Query: query, ErrorValue: errUpdateUser}); err != nil { + return err + } + mg.Status.AtProvider.ResourceOptionsAsClauses = ro + return nil +} + +// executeAlterUserWithPluginQuery emits ALTER USER ... IDENTIFIED WITH +// [AS '']. Same identifier-vs-value quoting rules as +// the Create-time helper: plugin name is QuoteIdentifier (backticked) to +// guard against injection while still parsing as an identifier, authString +// is value-quoted as a regular string. +func (c *external) executeAlterUserWithPluginQuery(ctx context.Context, username string, host string, ap *v1alpha1.AuthenticationPlugin) error { + authString := "" + if ap.AuthString != nil && *ap.AuthString != "" { + authString = fmt.Sprintf(" AS %s", mysql.QuoteValue(*ap.AuthString)) + } + + query := fmt.Sprintf( + "ALTER USER %s@%s IDENTIFIED WITH %s%s", + mysql.QuoteValue(username), + mysql.QuoteValue(host), + mysql.QuoteIdentifier(ap.Name), + authString, + ) + + if err := mysql.ExecWrapper(ctx, c.db, mysql.ExecQuery{Query: query, ErrorValue: errUpdateUser}); err != nil { + return err + } + return nil +} + func (c *external) UpdatePassword(ctx context.Context, cr *v1alpha1.User, username, host string) (managed.ConnectionDetails, error) { + // Users authenticated via a non-default plugin (e.g., AWS IAM auth) + // have no static password to manage. Skip the ALTER USER call so the + // provider doesn't downgrade the user back to native password auth. + if cr.Spec.ForProvider.AuthenticationPlugin != nil { + return managed.ConnectionDetails{}, nil + } + pw, pwchanged, err := c.getPassword(ctx, cr) if err != nil { return managed.ConnectionDetails{}, err @@ -369,6 +551,11 @@ func (c *external) Delete(ctx context.Context, mg *v1alpha1.User) (managed.Exter } func upToDate(observed *v1alpha1.UserParameters, desired *v1alpha1.UserParameters) bool { + // AuthenticationPlugin drift — checked first so a plugin change is detected + // even when ResourceOptions are nil. + if !authPluginEqual(observed.AuthenticationPlugin, desired.AuthenticationPlugin) { + return false + } if desired.ResourceOptions == nil { // Return true if there are no desired ResourceOptions return true diff --git a/pkg/controller/cluster/mysql/user/reconciler_test.go b/pkg/controller/cluster/mysql/user/reconciler_test.go index 8d88abd0..c70ad225 100644 --- a/pkg/controller/cluster/mysql/user/reconciler_test.go +++ b/pkg/controller/cluster/mysql/user/reconciler_test.go @@ -70,6 +70,8 @@ func (m mockDB) GetServerVersion(ctx context.Context) (int, error) { return m.MockGetServerVersion(ctx) } +func ptrString(s string) *string { return &s } + func TestConnect(t *testing.T) { errBoom := errors.New("boom") nopUsage := func(ctx context.Context, mg resource.LegacyManaged) error { return nil } @@ -481,6 +483,101 @@ func TestCreate(t *testing.T) { }, }, }, + "AuthenticationPluginWithAuthString": { + reason: "When AuthenticationPlugin is set the user must be created via IDENTIFIED WITH ... AS, with no password", + comparePw: true, + fields: fields{ + db: &mockDB{ + MockExec: func(ctx context.Context, q xsql.Query) error { + if !strings.HasPrefix(q.String, "CREATE USER ") { + return errors.New("unexpected query: " + q.String) + } + if !strings.Contains(q.String, "IDENTIFIED WITH `AWSAuthenticationPlugin` AS 'RDS'") { + return errors.New("expected IDENTIFIED WITH clause, got: " + q.String) + } + if strings.Contains(q.String, "IDENTIFIED BY") { + return errors.New("password clause should not be emitted, got: " + q.String) + } + return nil + }, + }, + }, + args: args{ + mg: &v1alpha1.User{ + ObjectMeta: v1.ObjectMeta{ + Annotations: map[string]string{ + meta.AnnotationKeyExternalName: "example", + }, + }, + Spec: v1alpha1.UserSpec{ + ForProvider: v1alpha1.UserParameters{ + AuthenticationPlugin: &v1alpha1.AuthenticationPlugin{ + Name: "AWSAuthenticationPlugin", + AuthString: ptrString("RDS"), + }, + }, + }, + }, + }, + want: want{ + err: nil, + c: managed.ExternalCreation{ + ConnectionDetails: managed.ConnectionDetails{ + xpv1.ResourceCredentialsSecretUserKey: []byte("example"), + xpv1.ResourceCredentialsSecretPasswordKey: []byte(""), + xpv1.ResourceCredentialsSecretEndpointKey: []byte("localhost"), + xpv1.ResourceCredentialsSecretPortKey: []byte("3306"), + }, + }, + }, + }, + "AuthenticationPluginNoAuthString": { + reason: "When AuthenticationPlugin has no AuthString the AS clause must be omitted", + comparePw: true, + fields: fields{ + db: &mockDB{ + MockExec: func(ctx context.Context, q xsql.Query) error { + if !strings.HasPrefix(q.String, "CREATE USER ") { + return errors.New("unexpected query: " + q.String) + } + if !strings.Contains(q.String, "IDENTIFIED WITH `auth_socket`") { + return errors.New("expected IDENTIFIED WITH clause, got: " + q.String) + } + if strings.Contains(q.String, " AS ") { + return errors.New("AS clause should not be emitted, got: " + q.String) + } + return nil + }, + }, + }, + args: args{ + mg: &v1alpha1.User{ + ObjectMeta: v1.ObjectMeta{ + Annotations: map[string]string{ + meta.AnnotationKeyExternalName: "example", + }, + }, + Spec: v1alpha1.UserSpec{ + ForProvider: v1alpha1.UserParameters{ + AuthenticationPlugin: &v1alpha1.AuthenticationPlugin{ + Name: "auth_socket", + }, + }, + }, + }, + }, + want: want{ + err: nil, + c: managed.ExternalCreation{ + ConnectionDetails: managed.ConnectionDetails{ + xpv1.ResourceCredentialsSecretUserKey: []byte("example"), + xpv1.ResourceCredentialsSecretPasswordKey: []byte(""), + xpv1.ResourceCredentialsSecretEndpointKey: []byte("localhost"), + xpv1.ResourceCredentialsSecretPortKey: []byte("3306"), + }, + }, + }, + }, } for name, tc := range cases { @@ -631,6 +728,139 @@ func TestUpdate(t *testing.T) { }, want: want{}, }, + "AuthenticationPluginSkipsAlterPassword": { + reason: "When AuthenticationPlugin is already applied (observed == desired), Update must be a no-op for the password path — no ALTER USER ... IDENTIFIED BY", + fields: fields{ + db: &mockDB{ + MockExec: func(ctx context.Context, q xsql.Query) error { + if strings.Contains(q.String, "IDENTIFIED BY") { + return errors.New("ALTER USER ... IDENTIFIED BY must not be run for plugin-auth users, got: " + q.String) + } + if strings.Contains(q.String, "IDENTIFIED WITH") { + return errors.New("ALTER USER ... IDENTIFIED WITH must not run when observed plugin matches desired, got: " + q.String) + } + return nil + }, + }, + }, + args: args{ + mg: &v1alpha1.User{ + ObjectMeta: v1.ObjectMeta{ + Annotations: map[string]string{ + meta.AnnotationKeyExternalName: "example", + }, + }, + Spec: v1alpha1.UserSpec{ + ForProvider: v1alpha1.UserParameters{ + AuthenticationPlugin: &v1alpha1.AuthenticationPlugin{ + Name: "AWSAuthenticationPlugin", + AuthString: ptrString("RDS"), + }, + }, + }, + Status: v1alpha1.UserStatus{ + AtProvider: v1alpha1.UserObservation{ + AuthenticationPlugin: &v1alpha1.AuthenticationPlugin{ + Name: "AWSAuthenticationPlugin", + AuthString: ptrString("RDS"), + }, + }, + }, + }, + }, + want: want{}, + }, + "AuthenticationPluginDriftPasswordToPlugin": { + reason: "When observed has no plugin (password user) and desired has AuthenticationPlugin, Update must emit ALTER USER ... IDENTIFIED WITH", + fields: fields{ + db: &mockDB{ + MockExec: func(ctx context.Context, q xsql.Query) error { + if !strings.Contains(q.String, "IDENTIFIED WITH `AWSAuthenticationPlugin` AS 'RDS'") { + return errors.New("expected ALTER USER ... IDENTIFIED WITH `AWSAuthenticationPlugin` AS 'RDS', got: " + q.String) + } + return nil + }, + }, + }, + args: args{ + mg: &v1alpha1.User{ + ObjectMeta: v1.ObjectMeta{ + Annotations: map[string]string{ + meta.AnnotationKeyExternalName: "example", + }, + }, + Spec: v1alpha1.UserSpec{ + ForProvider: v1alpha1.UserParameters{ + AuthenticationPlugin: &v1alpha1.AuthenticationPlugin{ + Name: "AWSAuthenticationPlugin", + AuthString: ptrString("RDS"), + }, + }, + }, + // Status.AtProvider.AuthenticationPlugin intentionally nil — + // represents an existing password-auth user that's now being + // switched to plugin auth. + }, + }, + want: want{ + c: managed.ExternalUpdate{ + ConnectionDetails: managed.ConnectionDetails{ + xpv1.ResourceCredentialsSecretUserKey: []byte("example"), + xpv1.ResourceCredentialsSecretPasswordKey: []byte(""), + xpv1.ResourceCredentialsSecretEndpointKey: []byte("localhost"), + xpv1.ResourceCredentialsSecretPortKey: []byte("3306"), + }, + }, + }, + }, + "AuthenticationPluginDriftAuthStringChange": { + reason: "When observed plugin matches desired name but authString differs, Update must emit ALTER USER ... IDENTIFIED WITH for the new authString", + fields: fields{ + db: &mockDB{ + MockExec: func(ctx context.Context, q xsql.Query) error { + if !strings.Contains(q.String, "IDENTIFIED WITH `AWSAuthenticationPlugin` AS 'RDS2'") { + return errors.New("expected ALTER USER with new authString RDS2, got: " + q.String) + } + return nil + }, + }, + }, + args: args{ + mg: &v1alpha1.User{ + ObjectMeta: v1.ObjectMeta{ + Annotations: map[string]string{ + meta.AnnotationKeyExternalName: "example", + }, + }, + Spec: v1alpha1.UserSpec{ + ForProvider: v1alpha1.UserParameters{ + AuthenticationPlugin: &v1alpha1.AuthenticationPlugin{ + Name: "AWSAuthenticationPlugin", + AuthString: ptrString("RDS2"), + }, + }, + }, + Status: v1alpha1.UserStatus{ + AtProvider: v1alpha1.UserObservation{ + AuthenticationPlugin: &v1alpha1.AuthenticationPlugin{ + Name: "AWSAuthenticationPlugin", + AuthString: ptrString("RDS"), + }, + }, + }, + }, + }, + want: want{ + c: managed.ExternalUpdate{ + ConnectionDetails: managed.ConnectionDetails{ + xpv1.ResourceCredentialsSecretUserKey: []byte("example"), + xpv1.ResourceCredentialsSecretPasswordKey: []byte(""), + xpv1.ResourceCredentialsSecretEndpointKey: []byte("localhost"), + xpv1.ResourceCredentialsSecretPortKey: []byte("3306"), + }, + }, + }, + }, "UpdatePassword": { reason: "The password must be updated", fields: fields{ diff --git a/pkg/controller/namespaced/mysql/user/reconciler.go b/pkg/controller/namespaced/mysql/user/reconciler.go index de6f2189..59f33753 100644 --- a/pkg/controller/namespaced/mysql/user/reconciler.go +++ b/pkg/controller/namespaced/mysql/user/reconciler.go @@ -186,11 +186,14 @@ func (c *external) Observe(ctx context.Context, mg *namespacedv1alpha1.User) (ma ResourceOptions: &namespacedv1alpha1.ResourceOptions{}, } + var pluginName, authString string query := "SELECT " + "max_questions, " + "max_updates, " + "max_connections, " + - "max_user_connections " + + "max_user_connections, " + + "plugin, " + + "authentication_string " + "FROM mysql.user WHERE User = ? AND Host = ?" err := c.db.Scan(ctx, xsql.Query{ @@ -204,6 +207,8 @@ func (c *external) Observe(ctx context.Context, mg *namespacedv1alpha1.User) (ma &observed.ResourceOptions.MaxUpdatesPerHour, &observed.ResourceOptions.MaxConnectionsPerHour, &observed.ResourceOptions.MaxUserConnections, + &pluginName, + &authString, ) if xsql.IsNoRows(err) { return managed.ExternalObservation{ResourceExists: false}, nil @@ -212,12 +217,34 @@ func (c *external) Observe(ctx context.Context, mg *namespacedv1alpha1.User) (ma return managed.ExternalObservation{}, errors.Wrap(err, errSelectUser) } - _, pwdChanged, err := c.getPassword(ctx, mg) - if err != nil { - return managed.ExternalObservation{}, err + // Hydrate observed.AuthenticationPlugin only when the user is configured + // with a non-default-password plugin. Password plugins (caching_sha2_password, + // mysql_native_password, sha256_password) store an opaque hash in + // authentication_string and are the implicit default when the user was + // created with IDENTIFIED BY '' — we leave AuthenticationPlugin nil in + // that case so the comparison against a spec without AuthenticationPlugin + // reports up-to-date. + if pluginName != "" && !isPasswordPlugin(pluginName) { + observed.AuthenticationPlugin = &namespacedv1alpha1.AuthenticationPlugin{Name: pluginName} + if authString != "" { + as := authString + observed.AuthenticationPlugin.AuthString = &as + } + } + + // When the user is configured with a non-default auth plugin (e.g., AWS + // IAM auth), there is no password to compare. Skip the password drift + // check so the resource doesn't churn on every reconcile. + pwdChanged := false + if mg.Spec.ForProvider.AuthenticationPlugin == nil { + _, pwdChanged, err = c.getPassword(ctx, mg) + if err != nil { + return managed.ExternalObservation{}, err + } } mg.Status.AtProvider.ResourceOptionsAsClauses = resourceOptionsToClauses(observed.ResourceOptions) + mg.Status.AtProvider.AuthenticationPlugin = observed.AuthenticationPlugin mg.SetConditions(xpv1.Available()) @@ -227,10 +254,64 @@ func (c *external) Observe(ctx context.Context, mg *namespacedv1alpha1.User) (ma }, nil } +// isPasswordPlugin reports whether the given MySQL plugin name is one of the +// password-based authentication plugins. Users created with IDENTIFIED BY +// '' are assigned a password plugin (caching_sha2_password is the MySQL +// 8.0 default; mysql_native_password and sha256_password are common). For +// these we treat AuthenticationPlugin as nil in the observed state because +// they represent "use a password" rather than an opt-in plugin choice. +func isPasswordPlugin(name string) bool { + switch name { + case "caching_sha2_password", "mysql_native_password", "sha256_password": + return true + } + return false +} + +// authPluginEqual reports whether observed and desired AuthenticationPlugin +// configurations match. nil/nil = match. Mismatched nil-ness = drift. Same +// nil-ness with non-nil values: names must match, and authStrings must both +// be nil OR both equal. +func authPluginEqual(observed, desired *namespacedv1alpha1.AuthenticationPlugin) bool { + if desired == nil { + return observed == nil + } + if observed == nil { + return false + } + if observed.Name != desired.Name { + return false + } + if (observed.AuthString == nil) != (desired.AuthString == nil) { + return false + } + if observed.AuthString != nil && *observed.AuthString != *desired.AuthString { + return false + } + return true +} + func (c *external) Create(ctx context.Context, mg *namespacedv1alpha1.User) (managed.ExternalCreation, error) { mg.SetConditions(xpv1.Creating()) username, host := mysql.SplitUserHost(meta.GetExternalName(mg)) + ro := resourceOptionsToClauses(mg.Spec.ForProvider.ResourceOptions) + + // When AuthenticationPlugin is set, the user has no password — skip + // password generation, emit IDENTIFIED WITH ..., and return connection + // details without a password. + if ap := mg.Spec.ForProvider.AuthenticationPlugin; ap != nil { + if err := c.executeCreateUserWithPluginQuery(ctx, username, host, ro, ap); err != nil { + return managed.ExternalCreation{}, err + } + if len(ro) != 0 { + mg.Status.AtProvider.ResourceOptionsAsClauses = ro + } + return managed.ExternalCreation{ + ConnectionDetails: c.db.GetConnectionDetails(username, ""), + }, nil + } + pw, _, err := c.getPassword(ctx, mg) if err != nil { return managed.ExternalCreation{}, err @@ -243,7 +324,6 @@ func (c *external) Create(ctx context.Context, mg *namespacedv1alpha1.User) (man } } - ro := resourceOptionsToClauses(mg.Spec.ForProvider.ResourceOptions) if err := c.executeCreateUserQuery(ctx, username, host, ro, pw); err != nil { return managed.ExternalCreation{}, err } @@ -278,29 +358,66 @@ func (c *external) executeCreateUserQuery(ctx context.Context, username string, return nil } +// executeCreateUserWithPluginQuery emits CREATE USER ... IDENTIFIED WITH +// [AS ''] [WITH ]. The plugin name is +// wrapped in QuoteIdentifier (backticks) since MySQL parses it as an +// identifier; the auth string is value-quoted as a regular string. +func (c *external) executeCreateUserWithPluginQuery(ctx context.Context, username string, host string, resourceOptionsClauses []string, ap *namespacedv1alpha1.AuthenticationPlugin) error { + resourceOptions := "" + if len(resourceOptionsClauses) != 0 { + resourceOptions = fmt.Sprintf(" WITH %s", strings.Join(resourceOptionsClauses, " ")) + } + + authString := "" + if ap.AuthString != nil && *ap.AuthString != "" { + authString = fmt.Sprintf(" AS %s", mysql.QuoteValue(*ap.AuthString)) + } + + query := fmt.Sprintf( + "CREATE USER %s@%s IDENTIFIED WITH %s%s%s", + mysql.QuoteValue(username), + mysql.QuoteValue(host), + mysql.QuoteIdentifier(ap.Name), + authString, + resourceOptions, + ) + + if err := mysql.ExecWrapper(ctx, c.db, mysql.ExecQuery{Query: query, ErrorValue: errCreateUser}); err != nil { + return err + } + + return nil +} + func (c *external) Update(ctx context.Context, mg *namespacedv1alpha1.User) (managed.ExternalUpdate, error) { username, host := mysql.SplitUserHost(meta.GetExternalName(mg)) - ro := resourceOptionsToClauses(mg.Spec.ForProvider.ResourceOptions) - rochanged, err := changedResourceOptions(mg.Status.AtProvider.ResourceOptionsAsClauses, ro) - if err != nil { - return managed.ExternalUpdate{}, errors.Wrap(err, errUpdateUser) + if err := c.updateResourceOptions(ctx, mg, username, host); err != nil { + return managed.ExternalUpdate{}, err } - if len(rochanged) > 0 { - resourceOptions := fmt.Sprintf("WITH %s", strings.Join(ro, " ")) - - query := fmt.Sprintf( - "ALTER USER %s@%s %s", - mysql.QuoteValue(username), - mysql.QuoteValue(host), - resourceOptions, - ) - if err := mysql.ExecWrapper(ctx, c.db, mysql.ExecQuery{Query: query, ErrorValue: errUpdateUser}); err != nil { + // Detect AuthenticationPlugin drift using the last-observed value cached + // in Status.AtProvider by Observe. Three transitions are handled: + // 1. password → plugin: ALTER USER ... IDENTIFIED WITH [AS ''] + // 2. plugin → password: fall through to UpdatePassword below; it emits + // ALTER USER ... IDENTIFIED BY '' which both sets the password + // AND restores the default password plugin. + // 3. plugin → different plugin or same plugin with different authString: + // ALTER USER ... IDENTIFIED WITH [AS ''] + // The API-level XValidation rule enforces mutual exclusivity of + // PasswordSecretRef and AuthenticationPlugin, so the spec always + // represents one mode or the other. + desiredPlugin := mg.Spec.ForProvider.AuthenticationPlugin + observedPlugin := mg.Status.AtProvider.AuthenticationPlugin + if !authPluginEqual(observedPlugin, desiredPlugin) && desiredPlugin != nil { + if err := c.executeAlterUserWithPluginQuery(ctx, username, host, desiredPlugin); err != nil { return managed.ExternalUpdate{}, err } - - mg.Status.AtProvider.ResourceOptionsAsClauses = ro + mg.Status.AtProvider.AuthenticationPlugin = desiredPlugin + // Connection details for a plugin-auth user carry no password. + return managed.ExternalUpdate{ + ConnectionDetails: c.db.GetConnectionDetails(username, ""), + }, nil } connectionDetails, err := c.UpdatePassword(ctx, mg, username, host) @@ -308,6 +425,12 @@ func (c *external) Update(ctx context.Context, mg *namespacedv1alpha1.User) (man return managed.ExternalUpdate{}, err } + // If transitioning from plugin → password, clear the cached observed + // plugin so the next reconcile starts clean. + if desiredPlugin == nil && observedPlugin != nil { + mg.Status.AtProvider.AuthenticationPlugin = nil + } + if len(connectionDetails) > 0 { return managed.ExternalUpdate{ConnectionDetails: connectionDetails}, nil } @@ -315,7 +438,65 @@ func (c *external) Update(ctx context.Context, mg *namespacedv1alpha1.User) (man return managed.ExternalUpdate{}, nil } +// updateResourceOptions emits ALTER USER ... WITH when the +// resource-options clauses observed by Status differ from the desired spec. +// Extracted from Update to keep that function below the gocyclo threshold. +func (c *external) updateResourceOptions(ctx context.Context, mg *namespacedv1alpha1.User, username, host string) error { + ro := resourceOptionsToClauses(mg.Spec.ForProvider.ResourceOptions) + rochanged, err := changedResourceOptions(mg.Status.AtProvider.ResourceOptionsAsClauses, ro) + if err != nil { + return errors.Wrap(err, errUpdateUser) + } + if len(rochanged) == 0 { + return nil + } + + query := fmt.Sprintf( + "ALTER USER %s@%s WITH %s", + mysql.QuoteValue(username), + mysql.QuoteValue(host), + strings.Join(ro, " "), + ) + if err := mysql.ExecWrapper(ctx, c.db, mysql.ExecQuery{Query: query, ErrorValue: errUpdateUser}); err != nil { + return err + } + mg.Status.AtProvider.ResourceOptionsAsClauses = ro + return nil +} + +// executeAlterUserWithPluginQuery emits ALTER USER ... IDENTIFIED WITH +// [AS '']. Same identifier-vs-value quoting rules as +// the Create-time helper: plugin name is QuoteIdentifier (backticked) to +// guard against injection while still parsing as an identifier, authString +// is value-quoted as a regular string. +func (c *external) executeAlterUserWithPluginQuery(ctx context.Context, username string, host string, ap *namespacedv1alpha1.AuthenticationPlugin) error { + authString := "" + if ap.AuthString != nil && *ap.AuthString != "" { + authString = fmt.Sprintf(" AS %s", mysql.QuoteValue(*ap.AuthString)) + } + + query := fmt.Sprintf( + "ALTER USER %s@%s IDENTIFIED WITH %s%s", + mysql.QuoteValue(username), + mysql.QuoteValue(host), + mysql.QuoteIdentifier(ap.Name), + authString, + ) + + if err := mysql.ExecWrapper(ctx, c.db, mysql.ExecQuery{Query: query, ErrorValue: errUpdateUser}); err != nil { + return err + } + return nil +} + func (c *external) UpdatePassword(ctx context.Context, cr *namespacedv1alpha1.User, username, host string) (managed.ConnectionDetails, error) { + // Users authenticated via a non-default plugin (e.g., AWS IAM auth) + // have no static password to manage. Skip the ALTER USER call so the + // provider doesn't downgrade the user back to native password auth. + if cr.Spec.ForProvider.AuthenticationPlugin != nil { + return managed.ConnectionDetails{}, nil + } + pw, pwchanged, err := c.getPassword(ctx, cr) if err != nil { return managed.ConnectionDetails{}, err @@ -351,6 +532,11 @@ func (c *external) Delete(ctx context.Context, mg *namespacedv1alpha1.User) (man } func upToDate(observed *namespacedv1alpha1.UserParameters, desired *namespacedv1alpha1.UserParameters) bool { + // AuthenticationPlugin drift — checked first so a plugin change is detected + // even when ResourceOptions are nil. + if !authPluginEqual(observed.AuthenticationPlugin, desired.AuthenticationPlugin) { + return false + } if desired.ResourceOptions == nil { // Return true if there are no desired ResourceOptions return true diff --git a/pkg/controller/namespaced/mysql/user/reconciler_test.go b/pkg/controller/namespaced/mysql/user/reconciler_test.go index 23e9cb56..ad3f89c2 100644 --- a/pkg/controller/namespaced/mysql/user/reconciler_test.go +++ b/pkg/controller/namespaced/mysql/user/reconciler_test.go @@ -73,6 +73,8 @@ func (m mockDB) GetServerVersion(ctx context.Context) (int, error) { return m.MockGetServerVersion(ctx) } +func ptrString(s string) *string { return &s } + func TestConnect(t *testing.T) { errBoom := errors.New("boom") @@ -534,6 +536,101 @@ func TestCreate(t *testing.T) { }, }, }, + "AuthenticationPluginWithAuthString": { + reason: "When AuthenticationPlugin is set the user must be created via IDENTIFIED WITH ... AS, with no password", + comparePw: true, + fields: fields{ + db: &mockDB{ + MockExec: func(ctx context.Context, q xsql.Query) error { + if !strings.HasPrefix(q.String, "CREATE USER ") { + return errors.New("unexpected query: " + q.String) + } + if !strings.Contains(q.String, "IDENTIFIED WITH `AWSAuthenticationPlugin` AS 'RDS'") { + return errors.New("expected IDENTIFIED WITH clause, got: " + q.String) + } + if strings.Contains(q.String, "IDENTIFIED BY") { + return errors.New("password clause should not be emitted, got: " + q.String) + } + return nil + }, + }, + }, + args: args{ + mg: &v1alpha1.User{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + meta.AnnotationKeyExternalName: "example", + }, + }, + Spec: v1alpha1.UserSpec{ + ForProvider: v1alpha1.UserParameters{ + AuthenticationPlugin: &v1alpha1.AuthenticationPlugin{ + Name: "AWSAuthenticationPlugin", + AuthString: ptrString("RDS"), + }, + }, + }, + }, + }, + want: want{ + err: nil, + c: managed.ExternalCreation{ + ConnectionDetails: managed.ConnectionDetails{ + xpv1.ResourceCredentialsSecretUserKey: []byte("example"), + xpv1.ResourceCredentialsSecretPasswordKey: []byte(""), + xpv1.ResourceCredentialsSecretEndpointKey: []byte("localhost"), + xpv1.ResourceCredentialsSecretPortKey: []byte("3306"), + }, + }, + }, + }, + "AuthenticationPluginNoAuthString": { + reason: "When AuthenticationPlugin has no AuthString the AS clause must be omitted", + comparePw: true, + fields: fields{ + db: &mockDB{ + MockExec: func(ctx context.Context, q xsql.Query) error { + if !strings.HasPrefix(q.String, "CREATE USER ") { + return errors.New("unexpected query: " + q.String) + } + if !strings.Contains(q.String, "IDENTIFIED WITH `auth_socket`") { + return errors.New("expected IDENTIFIED WITH clause, got: " + q.String) + } + if strings.Contains(q.String, " AS ") { + return errors.New("AS clause should not be emitted, got: " + q.String) + } + return nil + }, + }, + }, + args: args{ + mg: &v1alpha1.User{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + meta.AnnotationKeyExternalName: "example", + }, + }, + Spec: v1alpha1.UserSpec{ + ForProvider: v1alpha1.UserParameters{ + AuthenticationPlugin: &v1alpha1.AuthenticationPlugin{ + Name: "auth_socket", + }, + }, + }, + }, + }, + want: want{ + err: nil, + c: managed.ExternalCreation{ + ConnectionDetails: managed.ConnectionDetails{ + xpv1.ResourceCredentialsSecretUserKey: []byte("example"), + xpv1.ResourceCredentialsSecretPasswordKey: []byte(""), + xpv1.ResourceCredentialsSecretEndpointKey: []byte("localhost"), + xpv1.ResourceCredentialsSecretPortKey: []byte("3306"), + }, + }, + }, + }, } for name, tc := range cases { @@ -684,6 +781,136 @@ func TestUpdate(t *testing.T) { }, want: want{}, }, + "AuthenticationPluginSkipsAlterPassword": { + reason: "When AuthenticationPlugin is already applied (observed == desired), Update must be a no-op for the password path — no ALTER USER ... IDENTIFIED BY", + fields: fields{ + db: &mockDB{ + MockExec: func(ctx context.Context, q xsql.Query) error { + if strings.Contains(q.String, "IDENTIFIED BY") { + return errors.New("ALTER USER ... IDENTIFIED BY must not be run for plugin-auth users, got: " + q.String) + } + if strings.Contains(q.String, "IDENTIFIED WITH") { + return errors.New("ALTER USER ... IDENTIFIED WITH must not run when observed plugin matches desired, got: " + q.String) + } + return nil + }, + }, + }, + args: args{ + mg: &v1alpha1.User{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + meta.AnnotationKeyExternalName: "example", + }, + }, + Spec: v1alpha1.UserSpec{ + ForProvider: v1alpha1.UserParameters{ + AuthenticationPlugin: &v1alpha1.AuthenticationPlugin{ + Name: "AWSAuthenticationPlugin", + AuthString: ptrString("RDS"), + }, + }, + }, + Status: v1alpha1.UserStatus{ + AtProvider: v1alpha1.UserObservation{ + AuthenticationPlugin: &v1alpha1.AuthenticationPlugin{ + Name: "AWSAuthenticationPlugin", + AuthString: ptrString("RDS"), + }, + }, + }, + }, + }, + want: want{}, + }, + "AuthenticationPluginDriftPasswordToPlugin": { + reason: "When observed has no plugin (password user) and desired has AuthenticationPlugin, Update must emit ALTER USER ... IDENTIFIED WITH", + fields: fields{ + db: &mockDB{ + MockExec: func(ctx context.Context, q xsql.Query) error { + if !strings.Contains(q.String, "IDENTIFIED WITH `AWSAuthenticationPlugin` AS 'RDS'") { + return errors.New("expected ALTER USER ... IDENTIFIED WITH `AWSAuthenticationPlugin` AS 'RDS', got: " + q.String) + } + return nil + }, + }, + }, + args: args{ + mg: &v1alpha1.User{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + meta.AnnotationKeyExternalName: "example", + }, + }, + Spec: v1alpha1.UserSpec{ + ForProvider: v1alpha1.UserParameters{ + AuthenticationPlugin: &v1alpha1.AuthenticationPlugin{ + Name: "AWSAuthenticationPlugin", + AuthString: ptrString("RDS"), + }, + }, + }, + }, + }, + want: want{ + c: managed.ExternalUpdate{ + ConnectionDetails: managed.ConnectionDetails{ + xpv1.ResourceCredentialsSecretUserKey: []byte("example"), + xpv1.ResourceCredentialsSecretPasswordKey: []byte(""), + xpv1.ResourceCredentialsSecretEndpointKey: []byte("localhost"), + xpv1.ResourceCredentialsSecretPortKey: []byte("3306"), + }, + }, + }, + }, + "AuthenticationPluginDriftAuthStringChange": { + reason: "When observed plugin matches desired name but authString differs, Update must emit ALTER USER ... IDENTIFIED WITH for the new authString", + fields: fields{ + db: &mockDB{ + MockExec: func(ctx context.Context, q xsql.Query) error { + if !strings.Contains(q.String, "IDENTIFIED WITH `AWSAuthenticationPlugin` AS 'RDS2'") { + return errors.New("expected ALTER USER with new authString RDS2, got: " + q.String) + } + return nil + }, + }, + }, + args: args{ + mg: &v1alpha1.User{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + meta.AnnotationKeyExternalName: "example", + }, + }, + Spec: v1alpha1.UserSpec{ + ForProvider: v1alpha1.UserParameters{ + AuthenticationPlugin: &v1alpha1.AuthenticationPlugin{ + Name: "AWSAuthenticationPlugin", + AuthString: ptrString("RDS2"), + }, + }, + }, + Status: v1alpha1.UserStatus{ + AtProvider: v1alpha1.UserObservation{ + AuthenticationPlugin: &v1alpha1.AuthenticationPlugin{ + Name: "AWSAuthenticationPlugin", + AuthString: ptrString("RDS"), + }, + }, + }, + }, + }, + want: want{ + c: managed.ExternalUpdate{ + ConnectionDetails: managed.ConnectionDetails{ + xpv1.ResourceCredentialsSecretUserKey: []byte("example"), + xpv1.ResourceCredentialsSecretPasswordKey: []byte(""), + xpv1.ResourceCredentialsSecretEndpointKey: []byte("localhost"), + xpv1.ResourceCredentialsSecretPortKey: []byte("3306"), + }, + }, + }, + }, "UpdatePassword": { reason: "The password must be updated", fields: fields{