Combined Database backend: Add Static Account support to MySQL (#6970)

* temp support for mysql+static accounts

* remove create/update database user for static accounts

* update tests after create/delete removed

* small cleanups

* update postgresql setcredentials test

* temp support for mysql+static accounts

* Add Static Account support to MySQL

* add note that MySQL supports static roles

* remove code comment

* tidy up tests

* Update plugins/database/mysql/mysql_test.go

Co-Authored-By: Calvin Leung Huang <cleung2010@gmail.com>

* Update plugins/database/mysql/mysql.go

Co-Authored-By: Calvin Leung Huang <cleung2010@gmail.com>

* update what password we test

* refactor CreateUser and SetCredentials to use a common helper

* add close statements for statements in loops

* remove some redundant checks in the mysql test

* use root rotation statements as default for static accounts

* missed a file save
This commit is contained in:
Clint
2019-07-05 13:52:56 -05:00
committed by Chris Hoffman
parent 7b0f7a4964
commit 27e295ace8
3 changed files with 231 additions and 66 deletions

View File

@@ -22,7 +22,7 @@ const (
DROP USER '{{name}}'@'%' DROP USER '{{name}}'@'%'
` `
defaultMySQLRotateRootCredentialsSQL = ` defaultMySQLRotateCredentialsSQL = `
ALTER USER '{{username}}'@'%' IDENTIFIED BY '{{password}}'; ALTER USER '{{username}}'@'%' IDENTIFIED BY '{{password}}';
` `
@@ -36,7 +36,7 @@ var (
LegacyUsernameLen int = 16 LegacyUsernameLen int = 16
) )
var _ dbplugin.Database = &MySQL{} var _ dbplugin.Database = (*MySQL)(nil)
type MySQL struct { type MySQL struct {
*connutil.SQLConnectionProducer *connutil.SQLConnectionProducer
@@ -112,18 +112,8 @@ func (m *MySQL) getConnection(ctx context.Context) (*sql.DB, error) {
} }
func (m *MySQL) CreateUser(ctx context.Context, statements dbplugin.Statements, usernameConfig dbplugin.UsernameConfig, expiration time.Time) (username string, password string, err error) { func (m *MySQL) CreateUser(ctx context.Context, statements dbplugin.Statements, usernameConfig dbplugin.UsernameConfig, expiration time.Time) (username string, password string, err error) {
// Grab the lock
m.Lock()
defer m.Unlock()
statements = dbutil.StatementCompatibilityHelper(statements) statements = dbutil.StatementCompatibilityHelper(statements)
// Get the connection
db, err := m.getConnection(ctx)
if err != nil {
return "", "", err
}
if len(statements.Creation) == 0 { if len(statements.Creation) == 0 {
return "", "", dbutil.ErrEmptyCreationStatement return "", "", dbutil.ErrEmptyCreationStatement
} }
@@ -143,57 +133,15 @@ func (m *MySQL) CreateUser(ctx context.Context, statements dbplugin.Statements,
return "", "", err return "", "", err
} }
// Start a transaction queryMap := map[string]string{
tx, err := db.BeginTx(ctx, nil) "name": username,
if err != nil { "password": password,
"expiration": expirationStr,
}
if err := m.executePreparedStatmentsWithMap(ctx, statements.Creation, queryMap); err != nil {
return "", "", err return "", "", err
} }
defer tx.Rollback()
// Execute each query
for _, stmt := range statements.Creation {
for _, query := range strutil.ParseArbitraryStringSlice(stmt, ";") {
query = strings.TrimSpace(query)
if len(query) == 0 {
continue
}
query = dbutil.QueryHelper(query, map[string]string{
"name": username,
"password": password,
"expiration": expirationStr,
})
stmt, err := tx.PrepareContext(ctx, query)
if err != nil {
// If the error code we get back is Error 1295: This command is not
// supported in the prepared statement protocol yet, we will execute
// the statement without preparing it. This allows the caller to
// manually prepare statements, as well as run other not yet
// prepare supported commands. If there is no error when running we
// will continue to the next statement.
if e, ok := err.(*stdmysql.MySQLError); ok && e.Number == 1295 {
_, err = tx.ExecContext(ctx, query)
if err != nil {
return "", "", err
}
continue
}
return "", "", err
}
if _, err := stmt.ExecContext(ctx); err != nil {
stmt.Close()
return "", "", err
}
stmt.Close()
}
}
// Commit the transaction
if err := tx.Commit(); err != nil {
return "", "", err
}
return username, password, nil return username, password, nil
} }
@@ -262,9 +210,9 @@ func (m *MySQL) RotateRootCredentials(ctx context.Context, statements []string)
return nil, errors.New("username and password are required to rotate") return nil, errors.New("username and password are required to rotate")
} }
rotateStatents := statements rotateStatements := statements
if len(rotateStatents) == 0 { if len(rotateStatements) == 0 {
rotateStatents = []string{defaultMySQLRotateRootCredentialsSQL} rotateStatements = []string{defaultMySQLRotateCredentialsSQL}
} }
db, err := m.getConnection(ctx) db, err := m.getConnection(ctx)
@@ -285,7 +233,7 @@ func (m *MySQL) RotateRootCredentials(ctx context.Context, statements []string)
return nil, err return nil, err
} }
for _, stmt := range rotateStatents { for _, stmt := range rotateStatements {
for _, query := range strutil.ParseArbitraryStringSlice(stmt, ";") { for _, query := range strutil.ParseArbitraryStringSlice(stmt, ";") {
query = strings.TrimSpace(query) query = strings.TrimSpace(query)
if len(query) == 0 { if len(query) == 0 {
@@ -315,3 +263,97 @@ func (m *MySQL) RotateRootCredentials(ctx context.Context, statements []string)
m.RawConfig["password"] = password m.RawConfig["password"] = password
return m.RawConfig, nil return m.RawConfig, nil
} }
// SetCredentials uses provided information to set the password to a user in the
// database. Unlike CreateUser, this method requires a username be provided and
// uses the name given, instead of generating a name. This is used for setting
// the password of static accounts, as well as rolling back passwords in the
// database in the event an updated database fails to save in Vault's storage.
func (m *MySQL) SetCredentials(ctx context.Context, statements dbplugin.Statements, staticUser dbplugin.StaticUserConfig) (username, password string, err error) {
rotateStatements := statements.Rotation
if len(rotateStatements) == 0 {
rotateStatements = []string{defaultMySQLRotateCredentialsSQL}
}
username = staticUser.Username
password = staticUser.Password
if username == "" || password == "" {
return "", "", errors.New("must provide both username and password")
}
queryMap := map[string]string{
"name": username,
"password": password,
}
if err := m.executePreparedStatmentsWithMap(ctx, statements.Rotation, queryMap); err != nil {
return "", "", err
}
return username, password, nil
}
// executePreparedStatmentsWithMap loops through the given templated SQL statements and
// applies the a map to them, interpolating values into the templates,returning
// tthe resulting username and password
func (m *MySQL) executePreparedStatmentsWithMap(ctx context.Context, statements []string, queryMap map[string]string) error {
// Grab the lock
m.Lock()
defer m.Unlock()
// Get the connection
db, err := m.getConnection(ctx)
if err != nil {
return err
}
// Start a transaction
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
// Execute each query
for _, stmt := range statements {
for _, query := range strutil.ParseArbitraryStringSlice(stmt, ";") {
query = strings.TrimSpace(query)
if len(query) == 0 {
continue
}
query = dbutil.QueryHelper(query, queryMap)
stmt, err := tx.PrepareContext(ctx, query)
if err != nil {
// If the error code we get back is Error 1295: This command is not
// supported in the prepared statement protocol yet, we will execute
// the statement without preparing it. This allows the caller to
// manually prepare statements, as well as run other not yet
// prepare supported commands. If there is no error when running we
// will continue to the next statement.
if e, ok := err.(*stdmysql.MySQLError); ok && e.Number == 1295 {
_, err = tx.ExecContext(ctx, query)
if err != nil {
stmt.Close()
return err
}
continue
}
return err
}
if _, err := stmt.ExecContext(ctx); err != nil {
stmt.Close()
return err
}
stmt.Close()
}
}
// Commit the transaction
if err := tx.Commit(); err != nil {
return err
}
return nil
}

View File

@@ -9,12 +9,17 @@ import (
"testing" "testing"
"time" "time"
stdmysql "github.com/go-sql-driver/mysql"
"github.com/hashicorp/vault/helper/testhelpers/docker" "github.com/hashicorp/vault/helper/testhelpers/docker"
"github.com/hashicorp/vault/sdk/database/dbplugin" "github.com/hashicorp/vault/sdk/database/dbplugin"
"github.com/hashicorp/vault/sdk/database/helper/credsutil" "github.com/hashicorp/vault/sdk/database/helper/credsutil"
"github.com/hashicorp/vault/sdk/database/helper/dbutil"
"github.com/hashicorp/vault/sdk/helper/strutil"
"github.com/ory/dockertest" "github.com/ory/dockertest"
) )
var _ dbplugin.Database = (*MySQL)(nil)
func prepareMySQLTestContainer(t *testing.T, legacy bool) (cleanup func(), retURL string) { func prepareMySQLTestContainer(t *testing.T, legacy bool) (cleanup func(), retURL string) {
if os.Getenv("MYSQL_URL") != "" { if os.Getenv("MYSQL_URL") != "" {
return func() {}, os.Getenv("MYSQL_URL") return func() {}, os.Getenv("MYSQL_URL")
@@ -305,6 +310,64 @@ func TestMySQL_RevokeUser(t *testing.T) {
} }
} }
func TestMySQL_SetCredentials(t *testing.T) {
cleanup, connURL := prepareMySQLTestContainer(t, false)
defer cleanup()
// create the database user and verify we can access
dbUser := "vaultstatictest"
createTestMySQLUser(t, connURL, dbUser, "password", testRoleStaticCreate)
if err := testCredsExist(t, connURL, dbUser, "password"); err != nil {
t.Fatalf("Could not connect with credentials: %s", err)
}
connectionDetails := map[string]interface{}{
"connection_url": connURL,
}
db := new(MetadataLen, MetadataLen, UsernameLen)
_, err := db.Init(context.Background(), connectionDetails, true)
if err != nil {
t.Fatalf("err: %s", err)
}
newPassword, err := db.GenerateCredentials(context.Background())
if err != nil {
t.Fatal(err)
}
userConfig := dbplugin.StaticUserConfig{
Username: dbUser,
Password: newPassword,
}
statements := dbplugin.Statements{
Rotation: []string{testRoleStaticRotate},
}
_, _, err = db.SetCredentials(context.Background(), statements, userConfig)
if err != nil {
t.Fatalf("err: %s", err)
}
// verify new password works
if err := testCredsExist(t, connURL, dbUser, newPassword); err != nil {
t.Fatalf("Could not connect with new credentials: %s", err)
}
// call SetCredentials again, password will change
newPassword, _ = db.GenerateCredentials(context.Background())
userConfig.Password = newPassword
_, _, err = db.SetCredentials(context.Background(), statements, userConfig)
if err != nil {
t.Fatalf("err: %s", err)
}
if err := testCredsExist(t, connURL, dbUser, newPassword); err != nil {
t.Fatalf("Could not connect with new credentials: %s", err)
}
}
func testCredsExist(t testing.TB, connURL, username, password string) error { func testCredsExist(t testing.TB, connURL, username, password string) error {
// Log in with the new creds // Log in with the new creds
connURL = strings.Replace(connURL, "root:secret", fmt.Sprintf("%s:%s", username, password), 1) connURL = strings.Replace(connURL, "root:secret", fmt.Sprintf("%s:%s", username, password), 1)
@@ -316,6 +379,56 @@ func testCredsExist(t testing.TB, connURL, username, password string) error {
return db.Ping() return db.Ping()
} }
func createTestMySQLUser(t *testing.T, connURL, username, password, query string) {
t.Helper()
db, err := sql.Open("mysql", connURL)
defer db.Close()
if err != nil {
t.Fatal(err)
}
// Start a transaction
ctx := context.Background()
tx, err := db.BeginTx(ctx, nil)
if err != nil {
t.Fatal(err)
}
defer func() {
_ = tx.Rollback()
}()
// copied from mysql.go
for _, query := range strutil.ParseArbitraryStringSlice(query, ";") {
query = strings.TrimSpace(query)
if len(query) == 0 {
continue
}
query = dbutil.QueryHelper(query, map[string]string{
"name": username,
"password": password,
})
stmt, err := tx.PrepareContext(ctx, query)
if err != nil {
if e, ok := err.(*stdmysql.MySQLError); ok && e.Number == 1295 {
_, err = tx.ExecContext(ctx, query)
if err != nil {
t.Fatal(err)
}
stmt.Close()
continue
}
t.Fatal(err)
}
if _, err := stmt.ExecContext(ctx); err != nil {
stmt.Close()
t.Fatal(err)
}
stmt.Close()
}
}
const testMySQLRolePreparedStmt = ` const testMySQLRolePreparedStmt = `
CREATE USER '{{name}}'@'%' IDENTIFIED BY '{{password}}'; CREATE USER '{{name}}'@'%' IDENTIFIED BY '{{password}}';
set @grants=CONCAT("GRANT SELECT ON ", "*", ".* TO '{{name}}'@'%'"); set @grants=CONCAT("GRANT SELECT ON ", "*", ".* TO '{{name}}'@'%'");
@@ -331,3 +444,12 @@ const testMySQLRevocationSQL = `
REVOKE ALL PRIVILEGES, GRANT OPTION FROM '{{name}}'@'%'; REVOKE ALL PRIVILEGES, GRANT OPTION FROM '{{name}}'@'%';
DROP USER '{{name}}'@'%'; DROP USER '{{name}}'@'%';
` `
const testRoleStaticCreate = `
CREATE USER '{{name}}'@'%' IDENTIFIED BY '{{password}}';
GRANT SELECT ON *.* TO '{{name}}'@'%';
`
const testRoleStaticRotate = `
ALTER USER '{{name}}'@'%' IDENTIFIED BY '{{password}}';
`

View File

@@ -13,7 +13,8 @@ description: |-
MySQL is one of the supported plugins for the database secrets engine. This MySQL is one of the supported plugins for the database secrets engine. This
plugin generates database credentials dynamically based on configured roles for plugin generates database credentials dynamically based on configured roles for
the MySQL database. the MySQL database, and also supports [Static
Roles](/docs/secrets/databases/index.html#static-roles).
This plugin has a few different instances built into vault, each instance is for This plugin has a few different instances built into vault, each instance is for
a slightly different MySQL driver. The only difference between these plugins is a slightly different MySQL driver. The only difference between these plugins is