diff --git a/enginetest/queries/priv_auth_queries.go b/enginetest/queries/priv_auth_queries.go index 2614383390..42b0a0f1c6 100644 --- a/enginetest/queries/priv_auth_queries.go +++ b/enginetest/queries/priv_auth_queries.go @@ -2147,6 +2147,166 @@ func (p *NoopPlaintextPlugin) Authenticate(db *mysql_db.MySQLDb, user string, us // ServerAuthTests test the server authentication system. These tests always have the root account available, and the // root account is used with any queries in the SetUpScript, along as being set to the context passed to SetUpFunc. var ServerAuthTests = []ServerAuthenticationTest{ + { + Name: "ALTER USER can change passwords", + Assertions: []ServerAuthenticationTestAssertion{ + // Create test users, privileges, etc + { + Username: "root", + Password: "", + Query: "CREATE TABLE mydb.test (pk BIGINT PRIMARY KEY);", + ExpectedErr: false, + }, { + // Create a user with CREATE USER privileges + Username: "root", + Password: "", + Query: "CREATE USER `createUserUser`@`localhost` IDENTIFIED BY '';", + ExpectedErr: false, + }, { + Username: "root", + Password: "", + Query: "GRANT CREATE USER ON *.* TO `createUserUser`@`localhost`;", + ExpectedErr: false, + }, { + // Create a user with UPDATE privileges on the mysql database + Username: "root", + Password: "", + Query: "CREATE USER `updateUser`@`localhost` IDENTIFIED BY '';", + ExpectedErr: false, + }, { + Username: "root", + Password: "", + Query: "GRANT UPDATE ON mysql.* TO `updateUser`@`localhost`;", + ExpectedErr: false, + }, { + // Create a regular user named user1 with SELECT privileges + Username: "root", + Password: "", + Query: "CREATE USER `user1`@`localhost` IDENTIFIED BY '';", + ExpectedErr: false, + }, { + Username: "root", + Password: "", + Query: "GRANT SELECT ON *.* TO `user1`@`localhost`;", + ExpectedErr: false, + }, { + // Create a regular user named user2 with SELECT privileges + Username: "root", + Password: "", + Query: "CREATE USER `user2`@`localhost` IDENTIFIED BY '';", + ExpectedErr: false, + }, { + Username: "root", + Password: "", + Query: "GRANT SELECT ON *.* TO `user2`@`localhost`;", + ExpectedErr: false, + }, + + // When IF EXISTS is specified, an error isn't returned if the user doesn't exist + { + Username: "root", + Password: "", + Query: "ALTER USER IF EXISTS nobody@localhost IDENTIFIED BY 'password';", + ExpectedErr: false, + }, { + Username: "root", + Password: "", + Query: "ALTER USER nobody@localhost IDENTIFIED BY 'password';", + ExpectedErr: true, + ExpectedErrStr: "Error 1105 (HY000): Operation ALTER USER failed for 'nobody'@'localhost'", + }, + + // RANDOM PASSWORD is not supported yet, so an error should be returned + { + Username: "root", + Password: "", + Query: "ALTER USER user2@localhost IDENTIFIED BY RANDOM PASSWORD;", + ExpectedErr: true, + ExpectedErrStr: "Error 1105 (HY000): random password generation is not currently supported; " + + "you can request support at https://github.com/dolthub/dolt/issues/new", + }, + + // root super user can change other account passwords + { + Username: "root", + Password: "", + Query: "ALTER USER `user1`@`localhost` IDENTIFIED BY 'password1';", + ExpectedErr: false, + }, { + Username: "user1", + Password: "", + Query: "SELECT * FROM mydb.test;", + ExpectedErr: true, + }, { + Username: "user1", + Password: "password1", + Query: "SELECT * FROM mydb.test;", + ExpectedErr: false, + }, + + // Accounts with the CREATE USER privilege can change other account passwords + { + Username: "createUserUser", + Password: "", + Query: "ALTER USER `user1`@`localhost` IDENTIFIED BY 'password2';", + ExpectedErr: false, + }, { + Username: "user1", + Password: "", + Query: "SELECT * FROM mydb.test;", + ExpectedErr: true, + }, { + Username: "user1", + Password: "password2", + Query: "SELECT * FROM mydb.test;", + ExpectedErr: false, + }, + + // Accounts with the UPDATE privilege on the mysql db can change other account passwords + { + Username: "updateUser", + Password: "", + Query: "ALTER USER `user2`@`localhost` IDENTIFIED BY 'password3';", + ExpectedErr: false, + }, { + Username: "user2", + Password: "", + Query: "SELECT * FROM mydb.test;", + ExpectedErr: true, + }, { + Username: "user2", + Password: "password3", + Query: "SELECT * FROM mydb.test;", + ExpectedErr: false, + }, + + // Accounts can change their own password + { + Username: "user1", + Password: "password2", + Query: "ALTER USER `user1`@`localhost` IDENTIFIED BY 'password4';", + ExpectedErr: false, + }, { + Username: "user1", + Password: "", + Query: "SELECT * FROM mydb.test;", + ExpectedErr: true, + }, { + Username: "user1", + Password: "password4", + Query: "SELECT * FROM mydb.test;", + ExpectedErr: false, + }, + + // Accounts CANNOT change another account's password (without the CREATE USER or UPDATE privilege) + { + Username: "user1", + Password: "password2", + Query: "ALTER USER `user2`@`localhost` IDENTIFIED BY 'password5';", + ExpectedErr: true, + }, + }, + }, { Name: "DROP USER reports correct string for missing address", Assertions: []ServerAuthenticationTestAssertion{ @@ -2158,6 +2318,19 @@ var ServerAuthTests = []ServerAuthenticationTest{ }, }, }, + { + Name: "CREATE USER with a random password is not supported", + Assertions: []ServerAuthenticationTestAssertion{ + { + Username: "root", + Password: "", + Query: "CREATE USER foo1@localhost IDENTIFIED BY RANDOM PASSWORD;", + ExpectedErr: true, + ExpectedErrStr: "Error 1105 (HY000): random password generation is not currently supported; " + + "you can request support at https://github.com/dolthub/dolt/issues/new", + }, + }, + }, { Name: "CREATE USER with an empty password", Assertions: []ServerAuthenticationTestAssertion{ diff --git a/sql/errors.go b/sql/errors.go index c6dfa1bcc3..e54881b807 100644 --- a/sql/errors.go +++ b/sql/errors.go @@ -644,6 +644,9 @@ var ( // ErrUserCreationFailure is returned when attempting to create a user and it fails for any reason. ErrUserCreationFailure = errors.NewKind("Operation CREATE USER failed for %s") + // ErrUserAlterFailure is returned when attempting to alter a user and it fails for any reason. + ErrUserAlterFailure = errors.NewKind("Operation ALTER USER failed for %s") + // ErrRoleCreationFailure is returned when attempting to create a role and it fails for any reason. ErrRoleCreationFailure = errors.NewKind("Operation CREATE ROLE failed for %s") diff --git a/sql/plan/alter_user.go b/sql/plan/alter_user.go new file mode 100644 index 0000000000..7ffa2ef15b --- /dev/null +++ b/sql/plan/alter_user.go @@ -0,0 +1,112 @@ +// Copyright 2024 Dolthub, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package plan + +import ( + "fmt" + + "github.com/dolthub/go-mysql-server/sql" + "github.com/dolthub/go-mysql-server/sql/types" +) + +// AlterUser represents the statement ALTER USER. +type AlterUser struct { + IfExists bool + User AuthenticatedUser + MySQLDb sql.Database +} + +var _ sql.Node = (*AlterUser)(nil) +var _ sql.Databaser = (*AlterUser)(nil) +var _ sql.CollationCoercible = (*AlterUser)(nil) + +// Schema implements the interface sql.Node. +func (a *AlterUser) Schema() sql.Schema { + return types.OkResultSchema +} + +// String implements the interface sql.Node. +func (a *AlterUser) String() string { + ifExists := "" + if a.IfExists { + ifExists = "IfExists: " + } + return fmt.Sprintf("AlterUser(%s%s)", ifExists, a.User.String("")) +} + +// Database implements the interface sql.Databaser. +func (a *AlterUser) Database() sql.Database { + return a.MySQLDb +} + +// WithDatabase implements the interface sql.Databaser. +func (a *AlterUser) WithDatabase(db sql.Database) (sql.Node, error) { + aa := *a + aa.MySQLDb = db + return &aa, nil +} + +// Resolved implements the interface sql.Node. +func (a *AlterUser) Resolved() bool { + _, ok := a.MySQLDb.(sql.UnresolvedDatabase) + return !ok +} + +// IsReadOnly implements the interface sql.Node. +func (a *AlterUser) IsReadOnly() bool { + return false +} + +// Children implements the interface sql.Node. +func (a *AlterUser) Children() []sql.Node { + return nil +} + +// WithChildren implements the interface sql.Node. +func (a *AlterUser) WithChildren(children ...sql.Node) (sql.Node, error) { + if len(children) != 0 { + return nil, sql.ErrInvalidChildrenNumber.New(a, len(children), 0) + } + return a, nil +} + +// CheckPrivileges implements the interface sql.Node. +func (a *AlterUser) CheckPrivileges(ctx *sql.Context, opChecker sql.PrivilegedOperationChecker) bool { + // From the MySQL reference on ALTER USER: + // https://dev.mysql.com/doc/refman/8.0/en/alter-user.html + // ALTER USER generally requires either the global `CREATE USER` privilege, or the `UPDATE` privilege + // for the `mysql` system schema. + if opChecker.UserHasPrivileges(ctx, sql.NewPrivilegedOperation( + sql.PrivilegeCheckSubject{Database: "mysql"}, sql.PrivilegeType_Update)) { + return true + } else if opChecker.UserHasPrivileges(ctx, sql.NewPrivilegedOperation( + sql.PrivilegeCheckSubject{}, sql.PrivilegeType_CreateUser)) { + return true + } + + // There are several exceptions to the general privilege requirements. Currently, the only relevant one is + // that any client who connects to the server using a non-anonymous account can change the password for that account. + authenticatedUser := ctx.Session.Client() + if a.User.Name == authenticatedUser.User { + return true + } + + return false +} + +// CollationCoercibility implements the interface sql.CollationCoercible. +func (a *AlterUser) CollationCoercibility(_ *sql.Context) (collation sql.CollationID, coercibility byte) { + return sql.Collation_binary, 7 +} diff --git a/sql/plan/create_user.go b/sql/plan/create_user.go index 48cff61daf..ff5e80945f 100644 --- a/sql/plan/create_user.go +++ b/sql/plan/create_user.go @@ -17,10 +17,8 @@ package plan import ( "fmt" "strings" - "time" "github.com/dolthub/go-mysql-server/sql" - "github.com/dolthub/go-mysql-server/sql/mysql_db" "github.com/dolthub/go-mysql-server/sql/types" ) @@ -104,62 +102,3 @@ func (n *CreateUser) CheckPrivileges(ctx *sql.Context, opChecker sql.PrivilegedO func (*CreateUser) CollationCoercibility(ctx *sql.Context) (collation sql.CollationID, coercibility byte) { return sql.Collation_binary, 7 } - -// RowIter implements the interface sql.Node. -func (n *CreateUser) RowIter(ctx *sql.Context, row sql.Row) (sql.RowIter, error) { - mysqlDb, ok := n.MySQLDb.(*mysql_db.MySQLDb) - if !ok { - return nil, sql.ErrDatabaseNotFound.New("mysql") - } - editor := mysqlDb.Editor() - defer editor.Close() - - for _, user := range n.Users { - // replace empty host with any host - if user.UserName.Host == "" { - user.UserName.Host = "%" - } - - userPk := mysql_db.UserPrimaryKey{ - Host: user.UserName.Host, - User: user.UserName.Name, - } - _, ok := editor.GetUser(userPk) - if ok { - if n.IfNotExists { - continue - } - return nil, sql.ErrUserCreationFailure.New(user.UserName.String("'")) - } - - plugin := "mysql_native_password" - password := "" - if user.Auth1 != nil { - plugin = user.Auth1.Plugin() - password = user.Auth1.Password() - } - if plugin != "mysql_native_password" { - if err := mysqlDb.VerifyPlugin(plugin); err != nil { - return nil, sql.ErrUserCreationFailure.New(err) - } - } - // TODO: attributes should probably not be nil, but setting it to &n.Attribute causes unexpected behavior - // TODO:validate all of the data - editor.PutUser(&mysql_db.User{ - User: user.UserName.Name, - Host: user.UserName.Host, - PrivilegeSet: mysql_db.NewPrivilegeSet(), - Plugin: plugin, - Password: password, - PasswordLastChanged: time.Now().UTC(), - Locked: false, - Attributes: nil, - IsRole: false, - Identity: user.Identity, - }) - } - if err := mysqlDb.Persist(ctx, editor); err != nil { - return nil, err - } - return sql.RowsToRowIter(sql.Row{types.NewOkResult(0)}), nil -} diff --git a/sql/planbuilder/create_ddl.go b/sql/planbuilder/create_ddl.go index 7e7f454ff0..69299b9b6f 100644 --- a/sql/planbuilder/create_ddl.go +++ b/sql/planbuilder/create_ddl.go @@ -304,6 +304,25 @@ func (b *Builder) buildEventScheduleTimeSpec(inScope *scope, spec *ast.EventSche return ts, intervals } +func (b *Builder) buildAlterUser(inScope *scope, _ string, c *ast.DDL) (outScope *scope) { + database := b.resolveDb("mysql") + accountWithAuth := ast.AccountWithAuth{AccountName: c.User, Auth1: c.Authentication} + user := b.buildAuthenticatedUser(accountWithAuth) + + if c.Authentication.RandomPassword { + b.handleErr(fmt.Errorf("random password generation is not currently supported; " + + "you can request support at https://github.com/dolthub/dolt/issues/new")) + } + + outScope = inScope.push() + outScope.node = &plan.AlterUser{ + IfExists: c.IfExists, + User: user, + MySQLDb: database, + } + return outScope +} + func (b *Builder) buildAlterEvent(inScope *scope, query string, c *ast.DDL) (outScope *scope) { eventSpec := c.EventSpec diff --git a/sql/planbuilder/ddl.go b/sql/planbuilder/ddl.go index 8dcccc948c..bc37c267c1 100644 --- a/sql/planbuilder/ddl.go +++ b/sql/planbuilder/ddl.go @@ -137,6 +137,8 @@ func (b *Builder) buildDDL(inScope *scope, query string, c *ast.DDL) (outScope * case ast.AlterStr: if c.EventSpec != nil { return b.buildAlterEvent(inScope, query, c) + } else if !c.User.IsEmpty() { + return b.buildAlterUser(inScope, query, c) } b.handleErr(sql.ErrUnsupportedFeature.New(ast.String(c))) case ast.RenameStr: diff --git a/sql/planbuilder/priv.go b/sql/planbuilder/priv.go index 8a653f21bc..aa43ed4cc0 100644 --- a/sql/planbuilder/priv.go +++ b/sql/planbuilder/priv.go @@ -147,30 +147,40 @@ func convertPrivilegeLevel(privLevel ast.PrivilegeLevel) plan.PrivilegeLevel { } } +func (b *Builder) buildAuthenticatedUser(user ast.AccountWithAuth) plan.AuthenticatedUser { + authUser := plan.AuthenticatedUser{ + UserName: convertAccountName(user.AccountName)[0], + } + if user.Auth1 != nil { + authUser.Identity = user.Auth1.Identity + if user.Auth1.Plugin == "mysql_native_password" && len(user.Auth1.Password) > 0 { + authUser.Auth1 = plan.AuthenticationMysqlNativePassword(user.Auth1.Password) + } else if len(user.Auth1.Plugin) > 0 { + authUser.Auth1 = plan.NewOtherAuthentication(user.Auth1.Password, user.Auth1.Plugin) + } else { + // We default to using the password, even if it's empty + authUser.Auth1 = plan.NewDefaultAuthentication(user.Auth1.Password) + } + } + // We do not support Auth2, Auth3, or AuthInitial, so error out if they are set, since nothing reads them + if user.Auth2 != nil || user.Auth3 != nil || user.AuthInitial != nil { + err := fmt.Errorf(`multi-factor authentication is not yet supported`) + b.handleErr(err) + } + //TODO: figure out how to represent the remaining authentication methods and multi-factor auth + + return authUser +} + func (b *Builder) buildCreateUser(inScope *scope, n *ast.CreateUser) (outScope *scope) { outScope = inScope.push() authUsers := make([]plan.AuthenticatedUser, len(n.Users)) for i, user := range n.Users { - authUser := plan.AuthenticatedUser{ - UserName: convertAccountName(user.AccountName)[0], - } - if user.Auth1 != nil { - authUser.Identity = user.Auth1.Identity - if user.Auth1.Plugin == "mysql_native_password" && len(user.Auth1.Password) > 0 { - authUser.Auth1 = plan.AuthenticationMysqlNativePassword(user.Auth1.Password) - } else if len(user.Auth1.Plugin) > 0 { - authUser.Auth1 = plan.NewOtherAuthentication(user.Auth1.Password, user.Auth1.Plugin) - } else { - // We default to using the password, even if it's empty - authUser.Auth1 = plan.NewDefaultAuthentication(user.Auth1.Password) - } - } - if user.Auth2 != nil || user.Auth3 != nil || user.AuthInitial != nil { - err := fmt.Errorf(`multi-factor authentication is not yet supported`) - b.handleErr(err) + if user.Auth1 != nil && user.Auth1.RandomPassword { + b.handleErr(fmt.Errorf("random password generation is not currently supported; " + + "you can request support at https://github.com/dolthub/dolt/issues/new")) } - //TODO: figure out how to represent the remaining authentication methods and multi-factor auth - authUsers[i] = authUser + authUsers[i] = b.buildAuthenticatedUser(user) } var tlsOptions *plan.TLSOptions if n.TLSOptions != nil { diff --git a/sql/rowexec/ddl.go b/sql/rowexec/ddl.go index 0cc0f159f4..e21bbe429a 100644 --- a/sql/rowexec/ddl.go +++ b/sql/rowexec/ddl.go @@ -452,7 +452,57 @@ func (b *BaseBuilder) buildDropView(ctx *sql.Context, n *plan.DropView, row sql. return sql.RowsToRowIter(), nil } -func (b *BaseBuilder) buildCreateUser(ctx *sql.Context, n *plan.CreateUser, row sql.Row) (sql.RowIter, error) { +func (b *BaseBuilder) buildAlterUser(ctx *sql.Context, a *plan.AlterUser, _ sql.Row) (sql.RowIter, error) { + mysqlDb, ok := a.MySQLDb.(*mysql_db.MySQLDb) + if !ok { + return nil, sql.ErrDatabaseNotFound.New("mysql") + } + editor := mysqlDb.Editor() + defer editor.Close() + + user := a.User + // replace empty host with any host + if user.UserName.Host == "" { + user.UserName.Host = "%" + } + + userPk := mysql_db.UserPrimaryKey{ + Host: user.UserName.Host, + User: user.UserName.Name, + } + previousUserEntry, ok := editor.GetUser(userPk) + if !ok { + if a.IfExists { + return sql.RowsToRowIter(sql.Row{types.NewOkResult(0)}), nil + } + return nil, sql.ErrUserAlterFailure.New(user.UserName.String("'")) + } + + plugin := "mysql_native_password" + password := "" + if user.Auth1 != nil { + plugin = user.Auth1.Plugin() + password = user.Auth1.Password() + } + if plugin != "mysql_native_password" { + if err := mysqlDb.VerifyPlugin(plugin); err != nil { + return nil, sql.ErrUserAlterFailure.New(err) + } + } + + previousUserEntry.Plugin = plugin + previousUserEntry.Password = password + previousUserEntry.PasswordLastChanged = time.Now().UTC() + editor.PutUser(previousUserEntry) + + if err := mysqlDb.Persist(ctx, editor); err != nil { + return nil, err + } + + return sql.RowsToRowIter(sql.Row{types.NewOkResult(0)}), nil +} + +func (b *BaseBuilder) buildCreateUser(ctx *sql.Context, n *plan.CreateUser, _ sql.Row) (sql.RowIter, error) { mysqlDb, ok := n.MySQLDb.(*mysql_db.MySQLDb) if !ok { return nil, sql.ErrDatabaseNotFound.New("mysql") diff --git a/sql/rowexec/node_builder.gen.go b/sql/rowexec/node_builder.gen.go index f6a87c800f..ef1d8573b7 100644 --- a/sql/rowexec/node_builder.gen.go +++ b/sql/rowexec/node_builder.gen.go @@ -193,6 +193,8 @@ func (b *BaseBuilder) buildNodeExecNoAnalyze(ctx *sql.Context, n sql.Node, row s return b.buildDeferredAsOfTable(ctx, n, row) case *plan.CreateUser: return b.buildCreateUser(ctx, n, row) + case *plan.AlterUser: + return b.buildAlterUser(ctx, n, row) case *plan.DropView: return b.buildDropView(ctx, n, row) case *plan.GroupBy: