database: Emit event notifications (#24718)

Including for failures to write credentials and failure to rotate.
This commit is contained in:
Christopher Swenson
2024-02-05 10:30:00 -08:00
committed by GitHub
parent 47024f060c
commit 55d2dfb3d0
11 changed files with 227 additions and 24 deletions

View File

@@ -5,8 +5,10 @@ package database
import (
"context"
"errors"
"fmt"
"net/rpc"
"strconv"
"strings"
"sync"
"time"
@@ -398,6 +400,28 @@ func (b *databaseBackend) clean(_ context.Context) {
})
}
func (b *databaseBackend) dbEvent(ctx context.Context,
operation string,
path string,
name string,
modified bool,
additionalMetadataPairs ...string,
) {
metadata := []string{
logical.EventMetadataModified, strconv.FormatBool(modified),
logical.EventMetadataOperation, operation,
"path", path,
}
if name != "" {
metadata = append(metadata, "name", name)
}
metadata = append(metadata, additionalMetadataPairs...)
err := logical.SendEvent(ctx, b, fmt.Sprintf("database/%s", operation), metadata...)
if err != nil && !errors.Is(err, framework.ErrNoEvents) {
b.Logger().Error("Error sending event", "error", err)
}
}
const backendHelp = `
The database backend supports using many different databases
as secret backends, including but not limited to:

View File

@@ -13,6 +13,7 @@ import (
"net/url"
"os"
"reflect"
"slices"
"strings"
"sync"
"testing"
@@ -36,6 +37,7 @@ import (
"github.com/hashicorp/vault/vault"
_ "github.com/jackc/pgx/v4"
"github.com/mitchellh/mapstructure"
"github.com/stretchr/testify/assert"
)
func getClusterPostgresDBWithFactory(t *testing.T, factory logical.Factory) (*vault.TestCluster, logical.SystemView) {
@@ -151,6 +153,8 @@ func TestBackend_config_connection(t *testing.T) {
config := logical.TestBackendConfig()
config.StorageView = &logical.InmemStorage{}
config.System = sys
eventSender := logical.NewMockEventSender()
config.EventsSender = eventSender
lb, err := Factory(context.Background(), config)
if err != nil {
t.Fatal(err)
@@ -329,6 +333,16 @@ func TestBackend_config_connection(t *testing.T) {
if key != "plugin-test" {
t.Fatalf("bad key: %q", key)
}
assert.Equal(t, 3, len(eventSender.Events))
assert.Equal(t, "database/config-write", string(eventSender.Events[0].Type))
assert.Equal(t, "config/plugin-test", eventSender.Events[0].Event.Metadata.AsMap()["path"])
assert.Equal(t, "plugin-test", eventSender.Events[0].Event.Metadata.AsMap()["name"])
assert.Equal(t, "database/config-write", string(eventSender.Events[1].Type))
assert.Equal(t, "config/plugin-test", eventSender.Events[1].Event.Metadata.AsMap()["path"])
assert.Equal(t, "plugin-test", eventSender.Events[1].Event.Metadata.AsMap()["name"])
assert.Equal(t, "database/config-write", string(eventSender.Events[2].Type))
assert.Equal(t, "config/plugin-test", eventSender.Events[2].Event.Metadata.AsMap()["path"])
assert.Equal(t, "plugin-test", eventSender.Events[2].Event.Metadata.AsMap()["name"])
}
func TestBackend_BadConnectionString(t *testing.T) {
@@ -387,6 +401,8 @@ func TestBackend_basic(t *testing.T) {
config := logical.TestBackendConfig()
config.StorageView = &logical.InmemStorage{}
config.System = sys
eventSender := logical.NewMockEventSender()
config.EventsSender = eventSender
b, err := Factory(context.Background(), config)
if err != nil {
@@ -585,6 +601,23 @@ func TestBackend_basic(t *testing.T) {
t.Fatalf("Creds should not exist")
}
}
assert.Equal(t, 9, len(eventSender.Events))
assertEvent := func(t *testing.T, typ, name, path string) {
t.Helper()
assert.Equal(t, typ, string(eventSender.Events[0].Type))
assert.Equal(t, name, eventSender.Events[0].Event.Metadata.AsMap()["name"])
assert.Equal(t, path, eventSender.Events[0].Event.Metadata.AsMap()["path"])
eventSender.Events = slices.Delete(eventSender.Events, 0, 1)
}
assertEvent(t, "database/config-write", "plugin-test", "config/plugin-test")
for i := 0; i < 3; i++ {
assertEvent(t, "database/role-update", "plugin-role-test", "roles/plugin-role-test")
assertEvent(t, "database/creds-create", "plugin-role-test", "creds/plugin-role-test")
}
assertEvent(t, "database/creds-create", "plugin-role-test", "creds/plugin-role-test")
assertEvent(t, "database/role-delete", "plugin-role-test", "roles/plugin-role-test")
}
// singletonDBFactory allows us to reach into the internals of a databaseBackend

View File

@@ -100,6 +100,7 @@ func (b *databaseBackend) pathConnectionReset() framework.OperationFunc {
return nil, err
}
b.dbEvent(ctx, "reset", req.Path, name, false)
return nil, nil
}
}
@@ -196,7 +197,7 @@ func (b *databaseBackend) reloadPlugin() framework.OperationFunc {
if len(reloaded) == 0 {
resp.AddWarning(fmt.Sprintf("no connections were found with plugin_name %q", pluginName))
}
b.dbEvent(ctx, "reload", req.Path, "", true, "plugin_name", pluginName)
return resp, nil
}
}
@@ -413,6 +414,7 @@ func (b *databaseBackend) connectionDeleteHandler() framework.OperationFunc {
return nil, err
}
b.dbEvent(ctx, "config-delete", req.Path, name, true)
return nil, nil
}
}
@@ -559,6 +561,7 @@ func (b *databaseBackend) connectionWriteHandler() framework.OperationFunc {
"Vault (or the sdk if using a custom plugin) to gain password policy support", config.PluginName))
}
b.dbEvent(ctx, "config-write", req.Path, name, true)
if len(resp.Warnings) == 0 {
return nil, nil
}

View File

@@ -67,8 +67,16 @@ func pathCredsCreate(b *databaseBackend) []*framework.Path {
}
func (b *databaseBackend) pathCredsCreateRead() framework.OperationFunc {
return func(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
return func(ctx context.Context, req *logical.Request, data *framework.FieldData) (resp *logical.Response, err error) {
name := data.Get("name").(string)
modified := false
defer func() {
if err == nil && (resp == nil || !resp.IsError()) {
b.dbEvent(ctx, "creds-create", req.Path, name, modified)
} else {
b.dbEvent(ctx, "creds-create-fail", req.Path, name, modified)
}
}()
// Get the role
role, err := b.Role(ctx, req.Storage, name)
@@ -202,6 +210,7 @@ func (b *databaseBackend) pathCredsCreateRead() framework.OperationFunc {
b.CloseIfShutdown(dbi, err)
return nil, err
}
modified = true
respData["username"] = newUserResp.Username
// Database plugins using the v4 interface generate and return the password.
@@ -216,7 +225,7 @@ func (b *databaseBackend) pathCredsCreateRead() framework.OperationFunc {
"db_name": role.DBName,
"revocation_statements": role.Statements.Revocation,
}
resp := b.Secret(SecretCredsType).Response(respData, internal)
resp = b.Secret(SecretCredsType).Response(respData, internal)
resp.Secret.TTL = role.DefaultTTL
resp.Secret.MaxTTL = role.MaxTTL
return resp, nil

View File

@@ -238,11 +238,12 @@ func (b *databaseBackend) pathStaticRoleExistenceCheck(ctx context.Context, req
}
func (b *databaseBackend) pathRoleDelete(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
err := req.Storage.Delete(ctx, databaseRolePath+data.Get("name").(string))
name := data.Get("name").(string)
err := req.Storage.Delete(ctx, databaseRolePath+name)
if err != nil {
return nil, err
}
b.dbEvent(ctx, "role-delete", req.Path, name, true)
return nil, nil
}
@@ -283,6 +284,7 @@ func (b *databaseBackend) pathStaticRoleDelete(ctx context.Context, req *logical
}
}
b.dbEvent(ctx, "static-role-delete", req.Path, name, true)
return nil, merr.ErrorOrNil()
}
@@ -498,6 +500,7 @@ func (b *databaseBackend) pathRoleCreateUpdate(ctx context.Context, req *logical
return nil, err
}
b.dbEvent(ctx, fmt.Sprintf("role-%s", req.Operation), req.Path, name, true)
return nil, nil
}
@@ -696,7 +699,7 @@ func (b *databaseBackend) pathStaticRoleCreateUpdate(ctx context.Context, req *l
if err := b.pushItem(item); err != nil {
return nil, err
}
b.dbEvent(ctx, fmt.Sprintf("static-role-%s", req.Operation), req.Path, name, true)
return response, nil
}

View File

@@ -75,8 +75,17 @@ func pathRotateRootCredentials(b *databaseBackend) []*framework.Path {
}
func (b *databaseBackend) pathRotateRootCredentialsUpdate() framework.OperationFunc {
return func(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
return func(ctx context.Context, req *logical.Request, data *framework.FieldData) (resp *logical.Response, err error) {
name := data.Get("name").(string)
modified := false
defer func() {
if err == nil {
b.dbEvent(ctx, "rotate-root", req.Path, name, modified)
} else {
b.dbEvent(ctx, "rotate-root-fail", req.Path, name, modified)
}
}()
if name == "" {
return logical.ErrorResponse(respErrEmptyName), nil
}
@@ -159,6 +168,7 @@ func (b *databaseBackend) pathRotateRootCredentialsUpdate() framework.OperationF
if newConfigDetails != nil {
config.ConnectionDetails = newConfigDetails
}
modified = true
// 1.12.0 and 1.12.1 stored builtin plugins in storage, but 1.12.2 reverted
// that, so clean up any pre-existing stored builtin versions on write.
@@ -179,8 +189,16 @@ func (b *databaseBackend) pathRotateRootCredentialsUpdate() framework.OperationF
}
func (b *databaseBackend) pathRotateRoleCredentialsUpdate() framework.OperationFunc {
return func(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
return func(ctx context.Context, req *logical.Request, data *framework.FieldData) (_ *logical.Response, err error) {
name := data.Get("name").(string)
modified := false
defer func() {
if err == nil {
b.dbEvent(ctx, "rotate", req.Path, name, modified)
} else {
b.dbEvent(ctx, "rotate-fail", req.Path, name, modified)
}
}()
if name == "" {
return logical.ErrorResponse("empty role name attribute given"), nil
}
@@ -227,6 +245,7 @@ func (b *databaseBackend) pathRotateRoleCredentialsUpdate() framework.OperationF
item.Priority = role.StaticAccount.NextRotationTimeFromInput(resp.RotationTime).Unix()
// Clear any stored WAL ID as we must have successfully deleted our WAL to get here.
item.Value = ""
modified = true
}
// Add their rotation to the queue

View File

@@ -252,6 +252,16 @@ func (b *databaseBackend) rotateCredential(ctx context.Context, s logical.Storag
return false
}
// send an event indicating if the rotation was a success or failure
rotated := false
defer func() {
if rotated {
b.dbEvent(ctx, "rotate", "", roleName, true)
} else {
b.dbEvent(ctx, "rotate-fail", "", roleName, false)
}
}()
// If there is a WAL entry related to this Role, the corresponding WAL ID
// should be stored in the Item's Value field.
if walID, ok := item.Value.(string); ok {
@@ -291,6 +301,7 @@ func (b *databaseBackend) rotateCredential(ctx context.Context, s logical.Storag
if err := b.pushItem(item); err != nil {
logger.Warn("unable to push item on to queue", "error", err)
}
rotated = true
return true
}
@@ -350,10 +361,19 @@ type setStaticAccountOutput struct {
//
// This method does not perform any operations on the priority queue. Those
// tasks must be handled outside of this method.
func (b *databaseBackend) setStaticAccount(ctx context.Context, s logical.Storage, input *setStaticAccountInput) (*setStaticAccountOutput, error) {
func (b *databaseBackend) setStaticAccount(ctx context.Context, s logical.Storage, input *setStaticAccountInput) (_ *setStaticAccountOutput, err error) {
if input == nil || input.Role == nil || input.RoleName == "" {
return nil, errors.New("input was empty when attempting to set credentials for static account")
}
modified := false
defer func() {
if err == nil {
b.dbEvent(ctx, "static-creds-create", "", input.RoleName, modified)
} else {
b.dbEvent(ctx, "static-creds-create-fail", "", input.RoleName, modified)
}
}()
// Re-use WAL ID if present, otherwise PUT a new WAL
output := &setStaticAccountOutput{WALID: input.WALID}
@@ -507,6 +527,7 @@ func (b *databaseBackend) setStaticAccount(ctx context.Context, s logical.Storag
b.CloseIfShutdown(dbi, err)
return output, fmt.Errorf("error setting credentials: %w", err)
}
modified = true
// Store updated role information
// lvr is the known LastVaultRotation

View File

@@ -27,6 +27,7 @@ import (
"github.com/hashicorp/vault/sdk/queue"
_ "github.com/jackc/pgx/v4/stdlib"
"github.com/robfig/cron/v3"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
mongodbatlasapi "go.mongodb.org/atlas/mongodbatlas"
@@ -257,6 +258,8 @@ func TestBackend_StaticRole_Rotation_Schedule_ErrorRecover(t *testing.T) {
config := logical.TestBackendConfig()
config.StorageView = &logical.InmemStorage{}
config.System = sys
eventSender := logical.NewMockEventSender()
config.EventsSender = eventSender
lb, err := Factory(context.Background(), config)
if err != nil {
@@ -382,7 +385,6 @@ func TestBackend_StaticRole_Rotation_Schedule_ErrorRecover(t *testing.T) {
// should match because rotations should not occur outside the rotation window
t.Fatalf("expected passwords to match, got (%s)", checkPassword)
}
// Verify new username/password
verifyPgConn(t, username, checkPassword, connURL)
@@ -409,6 +411,29 @@ func TestBackend_StaticRole_Rotation_Schedule_ErrorRecover(t *testing.T) {
// Verify new username/password
verifyPgConn(t, username, checkPassword, connURL)
eventSender.Stop() // avoid race detector
// check that we got a successful rotation event
if len(eventSender.Events) == 0 {
t.Fatal("Expected to have some events but got none")
}
// check that we got a rotate-fail event
found := false
for _, event := range eventSender.Events {
if string(event.Type) == "database/rotate-fail" {
found = true
break
}
}
assert.True(t, found)
found = false
for _, event := range eventSender.Events {
if string(event.Type) == "database/rotate" {
found = true
break
}
}
assert.True(t, found)
}
// Sanity check to make sure we don't allow an attempt of rotating credentials

3
changelog/24718.txt Normal file
View File

@@ -0,0 +1,3 @@
```release-note:feature
database: Emit event notifications
```

View File

@@ -0,0 +1,46 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package logical
import (
"context"
"sync"
)
// MockEventSender is a simple implementation of logical.EventSender that simply stores whatever events it receives,
// meant to be used in testing. It is thread-safe.
type MockEventSender struct {
sync.Mutex
Events []MockEvent
Stopped bool
}
// MockEvent is a container for an event type + event pair.
type MockEvent struct {
Type EventType
Event *EventData
}
// SendEvent implements the logical.EventSender interface.
func (m *MockEventSender) SendEvent(_ context.Context, eventType EventType, event *EventData) error {
m.Lock()
defer m.Unlock()
if !m.Stopped {
m.Events = append(m.Events, MockEvent{Type: eventType, Event: event})
}
return nil
}
func (m *MockEventSender) Stop() {
m.Lock()
defer m.Unlock()
m.Stopped = true
}
var _ EventSender = (*MockEventSender)(nil)
// NewMockEventSender returns a new MockEventSender ready to be used.
func NewMockEventSender() *MockEventSender {
return &MockEventSender{}
}

View File

@@ -24,20 +24,37 @@ additional `metadata` field.
The following events are currently generated by Vault and its builtin plugins automatically:
| Plugin | Event Type | Metadata | Vault version |
| ------ | ----------------------- | -------------------------------------------- | ------------- |
| kv | `kv-v1/delete` | `modified`, `operation`, `path` | 1.13 |
| kv | `kv-v1/write` | `data_path`, `modified`, `operation`, `path` | 1.13 |
| kv | `kv-v2/config-write` | `data_path`, `modified`, `operation`, `path` | 1.13 |
| kv | `kv-v2/data-delete` | `modified`, `operation`, `path` | 1.13 |
| kv | `kv-v2/data-patch` | `data_path`, `modified`, `operation`, `path` | 1.13 |
| kv | `kv-v2/data-write` | `data_path`, `modified`, `operation`, `path` | 1.13 |
| kv | `kv-v2/delete` | `modified`, `operation`, `path` | 1.13 |
| kv | `kv-v2/destroy` | `modified`, `operation`, `path` | 1.13 |
| kv | `kv-v2/metadata-delete` | `modified`, `operation`, `path` | 1.13 |
| kv | `kv-v2/metadata-patch` | `data_path`, `modified`, `operation`, `path` | 1.13 |
| kv | `kv-v2/metadata-write` | `data_path`, `modified`, `operation`, `path` | 1.13 |
| kv | `kv-v2/undelete` | `data_path`, `modified`, `operation`, `path` | 1.13 |
| Plugin | Event Type | Metadata | Vault version |
| -------- | ------------------------------------ | ---------------------------------------------- | ------------- |
| database | `database/config-delete` | `modified`, `operation`, `path`, `name` | 1.16 |
| database | `database/config-write` | `modified`, `operation`, `path`, `name` | 1.16 |
| database | `database/creds-create` | `modified`, `operation`, `path`, `name` | 1.16 |
| database | `database/reload` | `modified`, `operation`, `path`, `plugin_name` | 1.16 |
| database | `database/reset` | `modified`, `operation`, `path`, `name` | 1.16 |
| database | `database/role-create` | `modified`, `operation`, `path`, `name` | 1.16 |
| database | `database/role-delete` | `modified`, `operation`, `path`, `name` | 1.16 |
| database | `database/role-update` | `modified`, `operation`, `path`, `name` | 1.16 |
| database | `database/root-rotate-fail` | `modified`, `operation`, `path`, `name` | 1.16 |
| database | `database/root-rotate` | `modified`, `operation`, `path`, `name` | 1.16 |
| database | `database/rotate-fail` | `modified`, `operation`, `path`, `name` | 1.16 |
| database | `database/rotate` | `modified`, `operation`, `path`, `name` | 1.16 |
| database | `database/static-creds-create-fail` | `modified`, `operation`, `path`, `name` | 1.16 |
| database | `database/static-creds-create` | `modified`, `operation`, `path`, `name` | 1.16 |
| database | `database/static-role-create` | `modified`, `operation`, `path`, `name` | 1.16 |
| database | `database/static-role-delete` | `modified`, `operation`, `path`, `name` | 1.16 |
| database | `database/static-role-update` | `modified`, `operation`, `path`, `name` | 1.16 |
| kv | `kv-v1/delete` | `modified`, `operation`, `path` | 1.13 |
| kv | `kv-v1/write` | `data_path`, `modified`, `operation`, `path` | 1.13 |
| kv | `kv-v2/config-write` | `data_path`, `modified`, `operation`, `path` | 1.13 |
| kv | `kv-v2/data-delete` | `modified`, `operation`, `path` | 1.13 |
| kv | `kv-v2/data-patch` | `data_path`, `modified`, `operation`, `path` | 1.13 |
| kv | `kv-v2/data-write` | `data_path`, `modified`, `operation`, `path` | 1.13 |
| kv | `kv-v2/delete` | `modified`, `operation`, `path` | 1.13 |
| kv | `kv-v2/destroy` | `modified`, `operation`, `path` | 1.13 |
| kv | `kv-v2/metadata-delete` | `modified`, `operation`, `path` | 1.13 |
| kv | `kv-v2/metadata-patch` | `data_path`, `modified`, `operation`, `path` | 1.13 |
| kv | `kv-v2/metadata-write` | `data_path`, `modified`, `operation`, `path` | 1.13 |
| kv | `kv-v2/undelete` | `data_path`, `modified`, `operation`, `path` | 1.13 |
## Event format