vault/plugins/database/mysql/mysql_test.go
hashicorp-copywrite[bot] 0b12cdcfd1
[COMPLIANCE] License changes (#22290)
* Adding explicit MPL license for sub-package.

This directory and its subdirectories (packages) contain files licensed with the MPLv2 `LICENSE` file in this directory and are intentionally licensed separately from the BSL `LICENSE` file at the root of this repository.

* Adding explicit MPL license for sub-package.

This directory and its subdirectories (packages) contain files licensed with the MPLv2 `LICENSE` file in this directory and are intentionally licensed separately from the BSL `LICENSE` file at the root of this repository.

* Updating the license from MPL to Business Source License.

Going forward, this project will be licensed under the Business Source License v1.1. Please see our blog post for more details at https://hashi.co/bsl-blog, FAQ at www.hashicorp.com/licensing-faq, and details of the license at www.hashicorp.com/bsl.

* add missing license headers

* Update copyright file headers to BUS-1.1

* Fix test that expected exact offset on hcl file

---------

Co-authored-by: hashicorp-copywrite[bot] <110428419+hashicorp-copywrite[bot]@users.noreply.github.com>
Co-authored-by: Sarah Thompson <sthompson@hashicorp.com>
Co-authored-by: Brian Kassouf <bkassouf@hashicorp.com>
2023-08-10 18:14:03 -07:00

797 lines
21 KiB
Go

// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package mysql
import (
"context"
"database/sql"
"fmt"
"strings"
"testing"
"time"
stdmysql "github.com/go-sql-driver/mysql"
"github.com/hashicorp/go-secure-stdlib/strutil"
mysqlhelper "github.com/hashicorp/vault/helper/testhelpers/mysql"
dbplugin "github.com/hashicorp/vault/sdk/database/dbplugin/v5"
dbtesting "github.com/hashicorp/vault/sdk/database/dbplugin/v5/testing"
"github.com/hashicorp/vault/sdk/database/helper/credsutil"
"github.com/hashicorp/vault/sdk/database/helper/dbutil"
"github.com/stretchr/testify/require"
)
var _ dbplugin.Database = (*MySQL)(nil)
func TestMySQL_Initialize(t *testing.T) {
type testCase struct {
rootPassword string
}
tests := map[string]testCase{
"non-special characters in root password": {
rootPassword: "B44a30c4C04D0aAaE140",
},
"special characters in root password": {
rootPassword: "#secret!%25#{@}",
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
testInitialize(t, test.rootPassword)
})
}
}
func testInitialize(t *testing.T, rootPassword string) {
cleanup, connURL := mysqlhelper.PrepareTestContainer(t, false, rootPassword)
defer cleanup()
mySQLConfig, err := stdmysql.ParseDSN(connURL)
if err != nil {
panic(fmt.Sprintf("Test failure: connection URL is invalid: %s", err))
}
rootUser := mySQLConfig.User
mySQLConfig.User = "{{username}}"
mySQLConfig.Passwd = "{{password}}"
tmplConnURL := mySQLConfig.FormatDSN()
type testCase struct {
initRequest dbplugin.InitializeRequest
expectedResp dbplugin.InitializeResponse
expectErr bool
expectInitialized bool
}
tests := map[string]testCase{
"missing connection_url": {
initRequest: dbplugin.InitializeRequest{
Config: map[string]interface{}{},
VerifyConnection: true,
},
expectedResp: dbplugin.InitializeResponse{},
expectErr: true,
expectInitialized: false,
},
"basic config": {
initRequest: dbplugin.InitializeRequest{
Config: map[string]interface{}{
"connection_url": connURL,
},
VerifyConnection: true,
},
expectedResp: dbplugin.InitializeResponse{
Config: map[string]interface{}{
"connection_url": connURL,
},
},
expectErr: false,
expectInitialized: true,
},
"username and password replacement in connection_url": {
initRequest: dbplugin.InitializeRequest{
Config: map[string]interface{}{
"connection_url": tmplConnURL,
"username": rootUser,
"password": rootPassword,
},
VerifyConnection: true,
},
expectedResp: dbplugin.InitializeResponse{
Config: map[string]interface{}{
"connection_url": tmplConnURL,
"username": rootUser,
"password": rootPassword,
},
},
expectErr: false,
expectInitialized: true,
},
"invalid username template": {
initRequest: dbplugin.InitializeRequest{
Config: map[string]interface{}{
"connection_url": connURL,
"username_template": "{{.FieldThatDoesNotExist}}",
},
VerifyConnection: true,
},
expectedResp: dbplugin.InitializeResponse{},
expectErr: true,
expectInitialized: false,
},
"bad username template": {
initRequest: dbplugin.InitializeRequest{
Config: map[string]interface{}{
"connection_url": connURL,
"username_template": "{{ .DisplayName", // Explicitly bad template
},
VerifyConnection: true,
},
expectedResp: dbplugin.InitializeResponse{},
expectErr: true,
expectInitialized: false,
},
"custom username template": {
initRequest: dbplugin.InitializeRequest{
Config: map[string]interface{}{
"connection_url": connURL,
"username_template": "foo-{{random 10}}-{{.DisplayName}}",
},
VerifyConnection: true,
},
expectedResp: dbplugin.InitializeResponse{
Config: map[string]interface{}{
"connection_url": connURL,
"username_template": "foo-{{random 10}}-{{.DisplayName}}",
},
},
expectErr: false,
expectInitialized: true,
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
db := newMySQL(DefaultUserNameTemplate)
defer dbtesting.AssertClose(t, db)
initResp, err := db.Initialize(context.Background(), test.initRequest)
if test.expectErr && err == nil {
t.Fatalf("err expected, got nil")
}
if !test.expectErr && err != nil {
t.Fatalf("no error expected, got: %s", err)
}
require.Equal(t, test.expectedResp, initResp)
require.Equal(t, test.expectInitialized, db.Initialized, "Initialized variable not set correctly")
})
}
}
func TestMySQL_NewUser_nonLegacy(t *testing.T) {
displayName := "token"
roleName := "testrole"
type testCase struct {
usernameTemplate string
newUserReq dbplugin.NewUserRequest
expectedUsernameRegex string
expectErr bool
}
tests := map[string]testCase{
"name statements": {
newUserReq: dbplugin.NewUserRequest{
UsernameConfig: dbplugin.UsernameMetadata{
DisplayName: displayName,
RoleName: roleName,
},
Statements: dbplugin.Statements{
Commands: []string{
`CREATE USER '{{name}}'@'%' IDENTIFIED BY '{{password}}';
GRANT SELECT ON *.* TO '{{name}}'@'%';`,
},
},
Password: "09g8hanbdfkVSM",
Expiration: time.Now().Add(time.Minute),
},
expectedUsernameRegex: `^v-token-testrole-[a-zA-Z0-9]{15}$`,
expectErr: false,
},
"username statements": {
newUserReq: dbplugin.NewUserRequest{
UsernameConfig: dbplugin.UsernameMetadata{
DisplayName: displayName,
RoleName: roleName,
},
Statements: dbplugin.Statements{
Commands: []string{
`CREATE USER '{{username}}'@'%' IDENTIFIED BY '{{password}}';
GRANT SELECT ON *.* TO '{{username}}'@'%';`,
},
},
Password: "09g8hanbdfkVSM",
Expiration: time.Now().Add(time.Minute),
},
expectedUsernameRegex: `^v-token-testrole-[a-zA-Z0-9]{15}$`,
expectErr: false,
},
"prepared name statements": {
newUserReq: dbplugin.NewUserRequest{
UsernameConfig: dbplugin.UsernameMetadata{
DisplayName: displayName,
RoleName: roleName,
},
Statements: dbplugin.Statements{
Commands: []string{
`CREATE USER '{{name}}'@'%' IDENTIFIED BY '{{password}}';
set @grants=CONCAT("GRANT SELECT ON ", "*", ".* TO '{{name}}'@'%'");
PREPARE grantStmt from @grants;
EXECUTE grantStmt;
DEALLOCATE PREPARE grantStmt;`,
},
},
Password: "09g8hanbdfkVSM",
Expiration: time.Now().Add(time.Minute),
},
expectedUsernameRegex: `^v-token-testrole-[a-zA-Z0-9]{15}$`,
expectErr: false,
},
"prepared username statements": {
newUserReq: dbplugin.NewUserRequest{
UsernameConfig: dbplugin.UsernameMetadata{
DisplayName: displayName,
RoleName: roleName,
},
Statements: dbplugin.Statements{
Commands: []string{
`CREATE USER '{{username}}'@'%' IDENTIFIED BY '{{password}}';
set @grants=CONCAT("GRANT SELECT ON ", "*", ".* TO '{{username}}'@'%'");
PREPARE grantStmt from @grants;
EXECUTE grantStmt;
DEALLOCATE PREPARE grantStmt;`,
},
},
Password: "09g8hanbdfkVSM",
Expiration: time.Now().Add(time.Minute),
},
expectedUsernameRegex: `^v-token-testrole-[a-zA-Z0-9]{15}$`,
expectErr: false,
},
"custom username template": {
usernameTemplate: "foo-{{random 10}}-{{.RoleName | uppercase}}",
newUserReq: dbplugin.NewUserRequest{
UsernameConfig: dbplugin.UsernameMetadata{
DisplayName: displayName,
RoleName: roleName,
},
Statements: dbplugin.Statements{
Commands: []string{
`CREATE USER '{{username}}'@'%' IDENTIFIED BY '{{password}}';
GRANT SELECT ON *.* TO '{{username}}'@'%';`,
},
},
Password: "09g8hanbdfkVSM",
Expiration: time.Now().Add(time.Minute),
},
expectedUsernameRegex: `^foo-[a-zA-Z0-9]{10}-TESTROLE$`,
expectErr: false,
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
cleanup, connURL := mysqlhelper.PrepareTestContainer(t, false, "secret")
defer cleanup()
connectionDetails := map[string]interface{}{
"connection_url": connURL,
"username_template": test.usernameTemplate,
}
initReq := dbplugin.InitializeRequest{
Config: connectionDetails,
VerifyConnection: true,
}
db := newMySQL(DefaultUserNameTemplate)
defer db.Close()
_, err := db.Initialize(context.Background(), initReq)
require.NoError(t, err)
userResp, err := db.NewUser(context.Background(), test.newUserReq)
if test.expectErr && err == nil {
t.Fatalf("err expected, got nil")
}
if !test.expectErr && err != nil {
t.Fatalf("no error expected, got: %s", err)
}
require.Regexp(t, test.expectedUsernameRegex, userResp.Username)
err = mysqlhelper.TestCredsExist(t, connURL, userResp.Username, test.newUserReq.Password)
require.NoError(t, err, "Failed to connect with credentials")
})
}
}
func TestMySQL_NewUser_legacy(t *testing.T) {
displayName := "token"
roleName := "testrole"
type testCase struct {
usernameTemplate string
newUserReq dbplugin.NewUserRequest
expectedUsernameRegex string
expectErr bool
}
tests := map[string]testCase{
"name statements": {
newUserReq: dbplugin.NewUserRequest{
UsernameConfig: dbplugin.UsernameMetadata{
DisplayName: displayName,
RoleName: roleName,
},
Statements: dbplugin.Statements{
Commands: []string{
`CREATE USER '{{name}}'@'%' IDENTIFIED BY '{{password}}';
GRANT SELECT ON *.* TO '{{name}}'@'%';`,
},
},
Password: "09g8hanbdfkVSM",
Expiration: time.Now().Add(time.Minute),
},
expectedUsernameRegex: `^v-test-[a-zA-Z0-9]{9}$`,
expectErr: false,
},
"username statements": {
newUserReq: dbplugin.NewUserRequest{
UsernameConfig: dbplugin.UsernameMetadata{
DisplayName: displayName,
RoleName: roleName,
},
Statements: dbplugin.Statements{
Commands: []string{
`CREATE USER '{{username}}'@'%' IDENTIFIED BY '{{password}}';
GRANT SELECT ON *.* TO '{{username}}'@'%';`,
},
},
Password: "09g8hanbdfkVSM",
Expiration: time.Now().Add(time.Minute),
},
expectedUsernameRegex: `^v-test-[a-zA-Z0-9]{9}$`,
expectErr: false,
},
"prepared name statements": {
newUserReq: dbplugin.NewUserRequest{
UsernameConfig: dbplugin.UsernameMetadata{
DisplayName: displayName,
RoleName: roleName,
},
Statements: dbplugin.Statements{
Commands: []string{
`CREATE USER '{{name}}'@'%' IDENTIFIED BY '{{password}}';
set @grants=CONCAT("GRANT SELECT ON ", "*", ".* TO '{{name}}'@'%'");
PREPARE grantStmt from @grants;
EXECUTE grantStmt;
DEALLOCATE PREPARE grantStmt;`,
},
},
Password: "09g8hanbdfkVSM",
Expiration: time.Now().Add(time.Minute),
},
expectedUsernameRegex: `^v-test-[a-zA-Z0-9]{9}$`,
expectErr: false,
},
"prepared username statements": {
newUserReq: dbplugin.NewUserRequest{
UsernameConfig: dbplugin.UsernameMetadata{
DisplayName: displayName,
RoleName: roleName,
},
Statements: dbplugin.Statements{
Commands: []string{
`CREATE USER '{{username}}'@'%' IDENTIFIED BY '{{password}}';
set @grants=CONCAT("GRANT SELECT ON ", "*", ".* TO '{{username}}'@'%'");
PREPARE grantStmt from @grants;
EXECUTE grantStmt;
DEALLOCATE PREPARE grantStmt;`,
},
},
Password: "09g8hanbdfkVSM",
Expiration: time.Now().Add(time.Minute),
},
expectedUsernameRegex: `^v-test-[a-zA-Z0-9]{9}$`,
expectErr: false,
},
"custom username template": {
usernameTemplate: `{{printf "foo-%s-%s" (random 5) (.RoleName | uppercase) | truncate 16}}`,
newUserReq: dbplugin.NewUserRequest{
UsernameConfig: dbplugin.UsernameMetadata{
DisplayName: displayName,
RoleName: roleName,
},
Statements: dbplugin.Statements{
Commands: []string{
`CREATE USER '{{username}}'@'%' IDENTIFIED BY '{{password}}';
GRANT SELECT ON *.* TO '{{username}}'@'%';`,
},
},
Password: "09g8hanbdfkVSM",
Expiration: time.Now().Add(time.Minute),
},
expectedUsernameRegex: `^foo-[a-zA-Z0-9]{5}-TESTRO$`,
expectErr: false,
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
cleanup, connURL := mysqlhelper.PrepareTestContainer(t, false, "secret")
defer cleanup()
connectionDetails := map[string]interface{}{
"connection_url": connURL,
"username_template": test.usernameTemplate,
}
initReq := dbplugin.InitializeRequest{
Config: connectionDetails,
VerifyConnection: true,
}
db := newMySQL(DefaultLegacyUserNameTemplate)
defer db.Close()
_, err := db.Initialize(context.Background(), initReq)
require.NoError(t, err)
userResp, err := db.NewUser(context.Background(), test.newUserReq)
if test.expectErr && err == nil {
t.Fatalf("err expected, got nil")
}
if !test.expectErr && err != nil {
t.Fatalf("no error expected, got: %s", err)
}
require.Regexp(t, test.expectedUsernameRegex, userResp.Username)
err = mysqlhelper.TestCredsExist(t, connURL, userResp.Username, test.newUserReq.Password)
require.NoError(t, err, "Failed to connect with credentials")
})
}
}
func TestMySQL_RotateRootCredentials(t *testing.T) {
type testCase struct {
statements []string
}
tests := map[string]testCase{
"empty statements": {
statements: nil,
},
"default username": {
statements: []string{defaultMySQLRotateCredentialsSQL},
},
"default name": {
statements: []string{
`
ALTER USER '{{username}}'@'%' IDENTIFIED BY '{{password}}';`,
},
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
cleanup, connURL := mysqlhelper.PrepareTestContainer(t, false, "secret")
defer cleanup()
connectionDetails := map[string]interface{}{
"connection_url": connURL,
"username": "root",
"password": "secret",
}
// Give a timeout just in case the test decides to be problematic
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
initReq := dbplugin.InitializeRequest{
Config: connectionDetails,
VerifyConnection: true,
}
db := newMySQL(DefaultUserNameTemplate)
defer db.Close()
_, err := db.Initialize(context.Background(), initReq)
if err != nil {
t.Fatalf("err: %s", err)
}
if !db.Initialized {
t.Fatal("Database should be initialized")
}
updateReq := dbplugin.UpdateUserRequest{
Username: "root",
Password: &dbplugin.ChangePassword{
NewPassword: "different_sercret",
Statements: dbplugin.Statements{
Commands: test.statements,
},
},
}
_, err = db.UpdateUser(ctx, updateReq)
if err != nil {
t.Fatalf("err: %v", err)
}
err = mysqlhelper.TestCredsExist(t, connURL, updateReq.Username, updateReq.Password.NewPassword)
if err != nil {
t.Fatalf("Could not connect with new credentials: %s", err)
}
// verify old password doesn't work
if err := mysqlhelper.TestCredsExist(t, connURL, updateReq.Username, "secret"); err == nil {
t.Fatalf("Should not be able to connect with initial credentials")
}
err = db.Close()
if err != nil {
t.Fatalf("err: %s", err)
}
})
}
}
func TestMySQL_DeleteUser(t *testing.T) {
type testCase struct {
revokeStmts []string
}
tests := map[string]testCase{
"empty statements": {
revokeStmts: nil,
},
"default name": {
revokeStmts: []string{defaultMysqlRevocationStmts},
},
"default username": {
revokeStmts: []string{
`
REVOKE ALL PRIVILEGES, GRANT OPTION FROM '{{username}}'@'%';
DROP USER '{{username}}'@'%'`,
},
},
}
// Shared test container for speed - there should not be any overlap between the tests
cleanup, connURL := mysqlhelper.PrepareTestContainer(t, false, "secret")
defer cleanup()
connectionDetails := map[string]interface{}{
"connection_url": connURL,
}
initReq := dbplugin.InitializeRequest{
Config: connectionDetails,
VerifyConnection: true,
}
db := newMySQL(DefaultUserNameTemplate)
defer db.Close()
_, err := db.Initialize(context.Background(), initReq)
if err != nil {
t.Fatalf("err: %s", err)
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
password, err := credsutil.RandomAlphaNumeric(32, false)
if err != nil {
t.Fatalf("unable to generate password: %s", err)
}
createReq := dbplugin.NewUserRequest{
UsernameConfig: dbplugin.UsernameMetadata{
DisplayName: "test",
RoleName: "test",
},
Statements: dbplugin.Statements{
Commands: []string{
`
CREATE USER '{{name}}'@'%' IDENTIFIED BY '{{password}}';
GRANT SELECT ON *.* TO '{{name}}'@'%';`,
},
},
Password: password,
Expiration: time.Now().Add(time.Minute),
}
// Give a timeout just in case the test decides to be problematic
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
userResp, err := db.NewUser(ctx, createReq)
if err != nil {
t.Fatalf("err: %s", err)
}
if err := mysqlhelper.TestCredsExist(t, connURL, userResp.Username, password); err != nil {
t.Fatalf("Could not connect with new credentials: %s", err)
}
deleteReq := dbplugin.DeleteUserRequest{
Username: userResp.Username,
Statements: dbplugin.Statements{
Commands: test.revokeStmts,
},
}
_, err = db.DeleteUser(context.Background(), deleteReq)
if err != nil {
t.Fatalf("err: %s", err)
}
if err := mysqlhelper.TestCredsExist(t, connURL, userResp.Username, password); err == nil {
t.Fatalf("Credentials were not revoked!")
}
})
}
}
func TestMySQL_UpdateUser(t *testing.T) {
type testCase struct {
rotateStmts []string
}
tests := map[string]testCase{
"empty statements": {
rotateStmts: nil,
},
"custom statement name": {
rotateStmts: []string{`
ALTER USER '{{name}}'@'%' IDENTIFIED BY '{{password}}';`},
},
"custom statement username": {
rotateStmts: []string{`
ALTER USER '{{username}}'@'%' IDENTIFIED BY '{{password}}';`},
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
cleanup, connURL := mysqlhelper.PrepareTestContainer(t, false, "secret")
defer cleanup()
// create the database user and verify we can access
dbUser := "vaultstatictest"
initPassword := "password"
createStatements := `
CREATE USER '{{name}}'@'%' IDENTIFIED BY '{{password}}';
GRANT SELECT ON *.* TO '{{name}}'@'%';`
createTestMySQLUser(t, connURL, dbUser, initPassword, createStatements)
if err := mysqlhelper.TestCredsExist(t, connURL, dbUser, initPassword); err != nil {
t.Fatalf("Could not connect with credentials: %s", err)
}
connectionDetails := map[string]interface{}{
"connection_url": connURL,
}
initReq := dbplugin.InitializeRequest{
Config: connectionDetails,
VerifyConnection: true,
}
// Give a timeout just in case the test decides to be problematic
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
db := newMySQL(DefaultUserNameTemplate)
defer db.Close()
_, err := db.Initialize(context.Background(), initReq)
if err != nil {
t.Fatalf("err: %s", err)
}
newPassword, err := credsutil.RandomAlphaNumeric(32, false)
if err != nil {
t.Fatalf("unable to generate password: %s", err)
}
updateReq := dbplugin.UpdateUserRequest{
Username: dbUser,
Password: &dbplugin.ChangePassword{
NewPassword: newPassword,
Statements: dbplugin.Statements{
Commands: test.rotateStmts,
},
},
}
_, err = db.UpdateUser(ctx, updateReq)
if err != nil {
t.Fatalf("err: %s", err)
}
// verify new password works
if err := mysqlhelper.TestCredsExist(t, connURL, dbUser, newPassword); err != nil {
t.Fatalf("Could not connect with new credentials: %s", err)
}
// verify old password doesn't work
if err := mysqlhelper.TestCredsExist(t, connURL, dbUser, initPassword); err == nil {
t.Fatalf("Should not be able to connect with initial credentials")
}
})
}
}
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()
}
}