diff --git a/plugins/database/hana/hana.go b/plugins/database/hana/hana.go index a2af57526785..e9d156a6d2f2 100644 --- a/plugins/database/hana/hana.go +++ b/plugins/database/hana/hana.go @@ -3,38 +3,37 @@ package hana import ( "context" "database/sql" - "errors" "fmt" "strings" - "time" - _ "github.com/SAP/go-hdb/driver" "github.com/hashicorp/vault/api" - "github.com/hashicorp/vault/sdk/database/dbplugin" + "github.com/hashicorp/vault/sdk/database/dbplugin/v5" "github.com/hashicorp/vault/sdk/database/helper/connutil" "github.com/hashicorp/vault/sdk/database/helper/credsutil" "github.com/hashicorp/vault/sdk/database/helper/dbutil" "github.com/hashicorp/vault/sdk/helper/dbtxn" "github.com/hashicorp/vault/sdk/helper/strutil" + + _ "github.com/SAP/go-hdb/driver" ) const ( - hanaTypeName = "hdb" + hanaTypeName = "hdb" + maxIdentifierLength = 127 ) // HANA is an implementation of Database interface type HANA struct { *connutil.SQLConnectionProducer - credsutil.CredentialsProducer } -var _ dbplugin.Database = &HANA{} +var _ dbplugin.Database = (*HANA)(nil) // New implements builtinplugins.BuiltinFactory func New() (interface{}, error) { db := new() // Wrap the plugin with middleware to sanitize errors - dbType := dbplugin.NewDatabaseErrorSanitizerMiddleware(db, db.SecretValues) + dbType := dbplugin.NewDatabaseErrorSanitizerMiddleware(db, db.secretValues) return dbType, nil } @@ -43,19 +42,28 @@ func new() *HANA { connProducer := &connutil.SQLConnectionProducer{} connProducer.Type = hanaTypeName - credsProducer := &credsutil.SQLCredentialsProducer{ - DisplayNameLen: 32, - RoleNameLen: 20, - UsernameLen: 128, - Separator: "_", - } - return &HANA{ SQLConnectionProducer: connProducer, - CredentialsProducer: credsProducer, } } +func (h *HANA) secretValues() map[string]string { + return map[string]string{ + h.Password: "[password]", + } +} + +func (h *HANA) Initialize(ctx context.Context, req dbplugin.InitializeRequest) (dbplugin.InitializeResponse, error) { + conf, err := h.Init(ctx, req.Config, req.VerifyConnection) + if err != nil { + return dbplugin.InitializeResponse{}, fmt.Errorf("error initializing db: %w", err) + } + + return dbplugin.InitializeResponse{ + Config: conf, + }, nil +} + // Run instantiates a HANA object, and runs the RPC server for the plugin func Run(apiTLSConfig *api.TLSConfig) error { dbType, err := New() @@ -82,61 +90,53 @@ func (h *HANA) getConnection(ctx context.Context) (*sql.DB, error) { return db.(*sql.DB), nil } -// CreateUser generates the username/password on the underlying HANA secret backend +// NewUser generates the username/password on the underlying HANA secret backend // as instructed by the CreationStatement provided. -func (h *HANA) CreateUser(ctx context.Context, statements dbplugin.Statements, usernameConfig dbplugin.UsernameConfig, expiration time.Time) (username string, password string, err error) { +func (h *HANA) NewUser(ctx context.Context, req dbplugin.NewUserRequest) (response dbplugin.NewUserResponse, err error) { // Grab the lock h.Lock() defer h.Unlock() - statements = dbutil.StatementCompatibilityHelper(statements) - // Get the connection db, err := h.getConnection(ctx) if err != nil { - return "", "", err + return dbplugin.NewUserResponse{}, err } - if len(statements.Creation) == 0 { - return "", "", dbutil.ErrEmptyCreationStatement + if len(req.Statements.Commands) == 0 { + return dbplugin.NewUserResponse{}, dbutil.ErrEmptyCreationStatement } // Generate username - username, err = h.GenerateUsername(usernameConfig) + username, err := credsutil.GenerateUsername( + credsutil.DisplayName(req.UsernameConfig.DisplayName, 32), + credsutil.RoleName(req.UsernameConfig.RoleName, 20), + credsutil.MaxLength(maxIdentifierLength), + credsutil.Separator("_"), + credsutil.ToUpper(), + ) + if err != nil { - return "", "", err + return dbplugin.NewUserResponse{}, err } // HANA does not allow hyphens in usernames, and highly prefers capital letters username = strings.Replace(username, "-", "_", -1) username = strings.ToUpper(username) - // Generate password - password, err = h.GeneratePassword() - if err != nil { - return "", "", err - } - // Most HANA configurations have password constraints - // Prefix with A1a to satisfy these constraints. User will be forced to change upon login - password = strings.Replace(password, "-", "_", -1) - password = "A1a" + password - // If expiration is in the role SQL, HANA will deactivate the user when time is up, // regardless of whether vault is alive to revoke lease - expirationStr, err := h.GenerateExpiration(expiration) - if err != nil { - return "", "", err - } + expirationStr := req.Expiration.UTC().Format("2006-01-02 15:04:05") // Start a transaction tx, err := db.BeginTx(ctx, nil) if err != nil { - return "", "", err + return dbplugin.NewUserResponse{}, err } defer tx.Rollback() // Execute each query - for _, stmt := range statements.Creation { + for _, stmt := range req.Statements.Commands { for _, query := range strutil.ParseArbitraryStringSlice(stmt, ";") { query = strings.TrimSpace(query) if len(query) == 0 { @@ -145,89 +145,169 @@ func (h *HANA) CreateUser(ctx context.Context, statements dbplugin.Statements, u m := map[string]string{ "name": username, - "password": password, + "password": req.Password, "expiration": expirationStr, } + if err := dbtxn.ExecuteTxQuery(ctx, tx, m, query); err != nil { - return "", "", err + return dbplugin.NewUserResponse{}, err } } } // Commit the transaction if err := tx.Commit(); err != nil { - return "", "", err + return dbplugin.NewUserResponse{}, err + } + + resp := dbplugin.NewUserResponse{ + Username: username, } - return username, password, nil + return resp, nil } -// Renewing hana user just means altering user's valid until property -func (h *HANA) RenewUser(ctx context.Context, statements dbplugin.Statements, username string, expiration time.Time) error { - statements = dbutil.StatementCompatibilityHelper(statements) +// UpdateUser allows for updating the expiration or password of the user mentioned in +// the UpdateUserRequest +func (h *HANA) UpdateUser(ctx context.Context, req dbplugin.UpdateUserRequest) (dbplugin.UpdateUserResponse, error) { + h.Lock() + defer h.Unlock() + + // No change requested + if req.Password == nil && req.Expiration == nil { + return dbplugin.UpdateUserResponse{}, nil + } // Get connection db, err := h.getConnection(ctx) if err != nil { - return err + return dbplugin.UpdateUserResponse{}, err } // Start a transaction tx, err := db.BeginTx(ctx, nil) if err != nil { - return err + return dbplugin.UpdateUserResponse{}, err } defer tx.Rollback() - // If expiration is in the role SQL, HANA will deactivate the user when time is up, - // regardless of whether vault is alive to revoke lease - expirationStr, err := h.GenerateExpiration(expiration) - if err != nil { - return err + if req.Password != nil { + err = h.updateUserPassword(ctx, tx, req.Username, req.Password) + if err != nil { + return dbplugin.UpdateUserResponse{}, err + } } - // Renew user's valid until property field - stmt, err := tx.PrepareContext(ctx, "ALTER USER "+username+" VALID UNTIL "+"'"+expirationStr+"'") - if err != nil { - return err - } - defer stmt.Close() - if _, err := stmt.ExecContext(ctx); err != nil { - return err + if req.Expiration != nil { + err = h.updateUserExpiration(ctx, tx, req.Username, req.Expiration) + if err != nil { + return dbplugin.UpdateUserResponse{}, err + } } // Commit the transaction if err := tx.Commit(); err != nil { - return err + return dbplugin.UpdateUserResponse{}, err + } + + return dbplugin.UpdateUserResponse{}, nil +} + +func (h *HANA) updateUserPassword(ctx context.Context, tx *sql.Tx, username string, req *dbplugin.ChangePassword) error { + password := req.NewPassword + + if username == "" || password == "" { + return fmt.Errorf("must provide both username and password") + } + + stmts := req.Statements.Commands + if len(stmts) == 0 { + stmts = []string{"ALTER USER {{username}} PASSWORD \"{{password}}\""} + } + + for _, stmt := range stmts { + for _, query := range strutil.ParseArbitraryStringSlice(stmt, ";") { + query = strings.TrimSpace(query) + if len(query) == 0 { + continue + } + + m := map[string]string{ + "name": username, + "username": username, + "password": password, + } + + if err := dbtxn.ExecuteTxQuery(ctx, tx, m, query); err != nil { + return fmt.Errorf("failed to execute query: %w", err) + } + } + } + + return nil +} + +func (h *HANA) updateUserExpiration(ctx context.Context, tx *sql.Tx, username string, req *dbplugin.ChangeExpiration) error { + // If expiration is in the role SQL, HANA will deactivate the user when time is up, + // regardless of whether vault is alive to revoke lease + expirationStr := req.NewExpiration.String() + + if username == "" || expirationStr == "" { + return fmt.Errorf("must provide both username and expiration") + } + + stmts := req.Statements.Commands + if len(stmts) == 0 { + stmts = []string{"ALTER USER {{username}} VALID UNTIL '{{expiration}}'"} + } + + for _, stmt := range stmts { + for _, query := range strutil.ParseArbitraryStringSlice(stmt, ";") { + query = strings.TrimSpace(query) + if len(query) == 0 { + continue + } + + m := map[string]string{ + "name": username, + "username": username, + "expiration": expirationStr, + } + + if err := dbtxn.ExecuteTxQuery(ctx, tx, m, query); err != nil { + return fmt.Errorf("failed to execute query: %w", err) + } + } } return nil } // Revoking hana user will deactivate user and try to perform a soft drop -func (h *HANA) RevokeUser(ctx context.Context, statements dbplugin.Statements, username string) error { - statements = dbutil.StatementCompatibilityHelper(statements) +func (h *HANA) DeleteUser(ctx context.Context, req dbplugin.DeleteUserRequest) (dbplugin.DeleteUserResponse, error) { + h.Lock() + defer h.Unlock() // default revoke will be a soft drop on user - if len(statements.Revocation) == 0 { - return h.revokeUserDefault(ctx, username) + if len(req.Statements.Commands) == 0 { + return h.revokeUserDefault(ctx, req) } // Get connection db, err := h.getConnection(ctx) if err != nil { - return err + return dbplugin.DeleteUserResponse{}, err } // Start a transaction tx, err := db.BeginTx(ctx, nil) if err != nil { - return err + return dbplugin.DeleteUserResponse{}, err } defer tx.Rollback() // Execute each query - for _, stmt := range statements.Revocation { + for _, stmt := range req.Statements.Commands { for _, query := range strutil.ParseArbitraryStringSlice(stmt, ";") { query = strings.TrimSpace(query) if len(query) == 0 { @@ -235,61 +315,56 @@ func (h *HANA) RevokeUser(ctx context.Context, statements dbplugin.Statements, u } m := map[string]string{ - "name": username, + "name": req.Username, } if err := dbtxn.ExecuteTxQuery(ctx, tx, m, query); err != nil { - return err + return dbplugin.DeleteUserResponse{}, err } } } - return tx.Commit() + return dbplugin.DeleteUserResponse{}, tx.Commit() } -func (h *HANA) revokeUserDefault(ctx context.Context, username string) error { +func (h *HANA) revokeUserDefault(ctx context.Context, req dbplugin.DeleteUserRequest) (dbplugin.DeleteUserResponse, error) { // Get connection db, err := h.getConnection(ctx) if err != nil { - return err + return dbplugin.DeleteUserResponse{}, err } // Start a transaction tx, err := db.BeginTx(ctx, nil) if err != nil { - return err + return dbplugin.DeleteUserResponse{}, err } defer tx.Rollback() // Disable server login for user - disableStmt, err := tx.PrepareContext(ctx, fmt.Sprintf("ALTER USER %s DEACTIVATE USER NOW", username)) + disableStmt, err := tx.PrepareContext(ctx, fmt.Sprintf("ALTER USER %s DEACTIVATE USER NOW", req.Username)) if err != nil { - return err + return dbplugin.DeleteUserResponse{}, err } defer disableStmt.Close() if _, err := disableStmt.ExecContext(ctx); err != nil { - return err + return dbplugin.DeleteUserResponse{}, err } // Invalidates current sessions and performs soft drop (drop if no dependencies) // if hard drop is desired, custom revoke statements should be written for role - dropStmt, err := tx.PrepareContext(ctx, fmt.Sprintf("DROP USER %s RESTRICT", username)) + dropStmt, err := tx.PrepareContext(ctx, fmt.Sprintf("DROP USER %s RESTRICT", req.Username)) if err != nil { - return err + return dbplugin.DeleteUserResponse{}, err } defer dropStmt.Close() if _, err := dropStmt.ExecContext(ctx); err != nil { - return err + return dbplugin.DeleteUserResponse{}, err } // Commit transaction if err := tx.Commit(); err != nil { - return err + return dbplugin.DeleteUserResponse{}, err } - return nil -} - -// RotateRootCredentials is not currently supported on HANA -func (h *HANA) RotateRootCredentials(ctx context.Context, statements []string) (map[string]interface{}, error) { - return nil, errors.New("root credentaion rotation is not currently implemented in this database secrets engine") + return dbplugin.DeleteUserResponse{}, nil } diff --git a/plugins/database/hana/hana_test.go b/plugins/database/hana/hana_test.go index bcb7a9e19950..b38e93b459cb 100644 --- a/plugins/database/hana/hana_test.go +++ b/plugins/database/hana/hana_test.go @@ -5,11 +5,13 @@ import ( "database/sql" "fmt" "os" + "reflect" "strings" "testing" "time" - "github.com/hashicorp/vault/sdk/database/dbplugin" + "github.com/hashicorp/vault/sdk/database/dbplugin/v5" + dbtesting "github.com/hashicorp/vault/sdk/database/dbplugin/v5/testing" ) func TestHANA_Initialize(t *testing.T) { @@ -22,122 +24,231 @@ func TestHANA_Initialize(t *testing.T) { "connection_url": connURL, } - db := new() - _, err := db.Init(context.Background(), connectionDetails, true) - if err != nil { - t.Fatalf("err: %s", err) - } + expectedConfig := copyConfig(connectionDetails) - if !db.Initialized { - t.Fatal("Database should be initialized") + initReq := dbplugin.InitializeRequest{ + Config: connectionDetails, + VerifyConnection: true, } - err = db.Close() - if err != nil { - t.Fatalf("err: %s", err) + db := new() + initResp := dbtesting.AssertInitialize(t, db, initReq) + defer dbtesting.AssertClose(t, db) + + if !reflect.DeepEqual(initResp.Config, expectedConfig) { + t.Fatalf("Actual config: %#v\nExpected config: %#v", initResp.Config, expectedConfig) } } // this test will leave a lingering user on the system -func TestHANA_CreateUser(t *testing.T) { +func TestHANA_NewUser(t *testing.T) { if os.Getenv("HANA_URL") == "" || os.Getenv("VAULT_ACC") != "1" { t.SkipNow() } + connURL := os.Getenv("HANA_URL") - connectionDetails := map[string]interface{}{ - "connection_url": connURL, + type testCase struct { + commands []string + expectErr bool + assertUser func(t testing.TB, connURL, username, password string) } - db := new() - _, err := db.Init(context.Background(), connectionDetails, true) - if err != nil { - t.Fatalf("err: %s", err) + tests := map[string]testCase{ + "no creation statements": { + commands: []string{}, + expectErr: true, + assertUser: assertCredsDoNotExist, + }, + "with creation statements": { + commands: []string{testHANARole}, + expectErr: false, + assertUser: assertCredsExist, + }, } - usernameConfig := dbplugin.UsernameConfig{ - DisplayName: "test-test", - RoleName: "test-test", - } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + connectionDetails := map[string]interface{}{ + "connection_url": connURL, + } - // Test with no configured Creation Statement - _, _, err = db.CreateUser(context.Background(), dbplugin.Statements{}, usernameConfig, time.Now().Add(time.Hour)) - if err == nil { - t.Fatal("Expected error when no creation statement is provided") - } + initReq := dbplugin.InitializeRequest{ + Config: connectionDetails, + VerifyConnection: true, + } - statements := dbplugin.Statements{ - Creation: []string{testHANARole}, - } + db := new() + dbtesting.AssertInitialize(t, db, initReq) + defer dbtesting.AssertClose(t, db) - username, password, err := db.CreateUser(context.Background(), statements, usernameConfig, time.Now().Add(time.Hour)) - if err != nil { - t.Fatalf("err: %s", err) - } + req := dbplugin.NewUserRequest{ + UsernameConfig: dbplugin.UsernameMetadata{ + DisplayName: "test-test", + RoleName: "test-test", + }, + Statements: dbplugin.Statements{ + Commands: test.commands, + }, + Password: "AG4qagho_dsvZ", + Expiration: time.Now().Add(1 * time.Second), + } - if err = testCredsExist(t, connURL, username, password); err != nil { - t.Fatalf("Could not connect with new credentials: %s", err) + createResp, err := db.NewUser(context.Background(), req) + if test.expectErr && err == nil { + t.Fatalf("err expected, received nil") + } + if !test.expectErr && err != nil { + t.Fatalf("no error expected, got: %s", err) + } + + test.assertUser(t, connURL, createResp.Username, req.Password) + }) } } -func TestHANA_RevokeUser(t *testing.T) { +func TestHANA_UpdateUser(t *testing.T) { if os.Getenv("HANA_URL") == "" || os.Getenv("VAULT_ACC") != "1" { t.SkipNow() } connURL := os.Getenv("HANA_URL") - connectionDetails := map[string]interface{}{ - "connection_url": connURL, + type testCase struct { + commands []string + expectErrOnLogin bool + expectedErrMsg string } - db := new() - _, err := db.Init(context.Background(), connectionDetails, true) - if err != nil { - t.Fatalf("err: %s", err) + tests := map[string]testCase{ + "no update statements": { + commands: []string{}, + expectErrOnLogin: true, + expectedErrMsg: "user is forced to change password", + }, + "with custom update statements": { + commands: []string{testHANAUpdate}, + expectErrOnLogin: false, + }, } - statements := dbplugin.Statements{ - Creation: []string{testHANARole}, - } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + connectionDetails := map[string]interface{}{ + "connection_url": connURL, + } - usernameConfig := dbplugin.UsernameConfig{ - DisplayName: "test-test", - RoleName: "test-test", - } + initReq := dbplugin.InitializeRequest{ + Config: connectionDetails, + VerifyConnection: true, + } - // Test default revoke statements - username, password, err := db.CreateUser(context.Background(), statements, usernameConfig, time.Now().Add(time.Hour)) - if err != nil { - t.Fatalf("err: %s", err) - } - if err = testCredsExist(t, connURL, username, password); err != nil { - t.Fatalf("Could not connect with new credentials: %s", err) - } + db := new() + dbtesting.AssertInitialize(t, db, initReq) + defer dbtesting.AssertClose(t, db) - err = db.RevokeUser(context.Background(), statements, username) - if err != nil { - t.Fatalf("err: %s", err) - } - if err := testCredsExist(t, connURL, username, password); err == nil { - t.Fatal("Credentials were not revoked") + password := "this_is_Thirty_2_characters_wow_" + newReq := dbplugin.NewUserRequest{ + UsernameConfig: dbplugin.UsernameMetadata{ + DisplayName: "test-test", + RoleName: "test-test", + }, + Password: password, + Statements: dbplugin.Statements{ + Commands: []string{testHANARole}, + }, + Expiration: time.Now().Add(time.Hour), + } + + userResp := dbtesting.AssertNewUser(t, db, newReq) + assertCredsExist(t, connURL, userResp.Username, password) + + req := dbplugin.UpdateUserRequest{ + Username: userResp.Username, + Password: &dbplugin.ChangePassword{ + NewPassword: "this_is_ALSO_Thirty_2_characters_", + Statements: dbplugin.Statements{ + Commands: test.commands, + }, + }, + } + + dbtesting.AssertUpdateUser(t, db, req) + err := testCredsExist(t, connURL, userResp.Username, req.Password.NewPassword) + if test.expectErrOnLogin { + if err == nil { + t.Fatalf("Able to login with new creds when expecting an issue") + } else if test.expectedErrMsg != "" && !strings.Contains(err.Error(), test.expectedErrMsg) { + t.Fatalf("Expected error message to contain \"%s\", received: %s", test.expectedErrMsg, err) + } + } + if !test.expectErrOnLogin && err != nil { + t.Fatalf("Unable to login: %s", err) + } + }) } +} - // Test custom revoke statement - username, password, err = db.CreateUser(context.Background(), statements, usernameConfig, time.Now().Add(time.Hour)) - if err != nil { - t.Fatalf("err: %s", err) +func TestHANA_DeleteUser(t *testing.T) { + if os.Getenv("HANA_URL") == "" || os.Getenv("VAULT_ACC") != "1" { + t.SkipNow() } - if err = testCredsExist(t, connURL, username, password); err != nil { - t.Fatalf("Could not connect with new credentials: %s", err) + connURL := os.Getenv("HANA_URL") + + type testCase struct { + commands []string } - statements.Revocation = []string{testHANADrop} - err = db.RevokeUser(context.Background(), statements, username) - if err != nil { - t.Fatalf("err: %s", err) + tests := map[string]testCase{ + "no update statements": { + commands: []string{}, + }, + "with custom update statements": { + commands: []string{testHANADrop}, + }, } - if err := testCredsExist(t, connURL, username, password); err == nil { - t.Fatal("Credentials were not revoked") + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + connectionDetails := map[string]interface{}{ + "connection_url": connURL, + } + + initReq := dbplugin.InitializeRequest{ + Config: connectionDetails, + VerifyConnection: true, + } + + db := new() + dbtesting.AssertInitialize(t, db, initReq) + defer dbtesting.AssertClose(t, db) + + password := "this_is_Thirty_2_characters_wow_" + + newReq := dbplugin.NewUserRequest{ + UsernameConfig: dbplugin.UsernameMetadata{ + DisplayName: "test-test", + RoleName: "test-test", + }, + Password: password, + Statements: dbplugin.Statements{ + Commands: []string{testHANARole}, + }, + Expiration: time.Now().Add(time.Hour), + } + + userResp := dbtesting.AssertNewUser(t, db, newReq) + assertCredsExist(t, connURL, userResp.Username, password) + + req := dbplugin.DeleteUserRequest{ + Username: userResp.Username, + Statements: dbplugin.Statements{ + Commands: test.commands, + }, + } + + dbtesting.AssertDeleteUser(t, db, req) + assertCredsDoNotExist(t, connURL, userResp.Username, password) + }) } } @@ -153,8 +264,35 @@ func testCredsExist(t testing.TB, connURL, username, password string) error { return db.Ping() } +func assertCredsExist(t testing.TB, connURL, username, password string) { + t.Helper() + err := testCredsExist(t, connURL, username, password) + if err != nil { + t.Fatalf("Unable to log in as %q: %s", username, err) + } +} + +func assertCredsDoNotExist(t testing.TB, connURL, username, password string) { + t.Helper() + err := testCredsExist(t, connURL, username, password) + if err == nil { + t.Fatalf("Able to log in when we should not be able to") + } +} + +func copyConfig(config map[string]interface{}) map[string]interface{} { + newConfig := map[string]interface{}{} + for k, v := range config { + newConfig[k] = v + } + return newConfig +} + const testHANARole = ` -CREATE USER {{name}} PASSWORD {{password}} VALID UNTIL '{{expiration}}';` +CREATE USER {{name}} PASSWORD "{{password}}" NO FORCE_FIRST_PASSWORD_CHANGE VALID UNTIL '{{expiration}}';` const testHANADrop = ` DROP USER {{name}} CASCADE;` + +const testHANAUpdate = ` +ALTER USER {{name}} PASSWORD "{{password}}" NO FORCE_FIRST_PASSWORD_CHANGE;`