Add automated root rotation support to DB Secrets (#29557)

This commit is contained in:
vinay-gopalan
2025-02-11 12:09:26 -08:00
committed by GitHub
parent b9ee65e302
commit 9e38a88883
11 changed files with 258 additions and 124 deletions

View File

@@ -6,6 +6,7 @@ package awsauth
import (
"context"
"errors"
"fmt"
"net/http"
"net/textproto"
"net/url"
@@ -394,7 +395,7 @@ func (b *backend) pathConfigClientCreateUpdate(ctx context.Context, req *logical
var performedRotationManagerOpern string
if configEntry.ShouldDeregisterRotationJob() {
performedRotationManagerOpern = "deregistration"
performedRotationManagerOpern = rotation.PerformedDeregistration
// Disable Automated Rotation and Deregister credentials if required
deregisterReq := &rotation.RotationJobDeregisterRequest{
MountPoint: req.MountPoint,
@@ -406,7 +407,7 @@ func (b *backend) pathConfigClientCreateUpdate(ctx context.Context, req *logical
return logical.ErrorResponse("error deregistering rotation job: %s", err), nil
}
} else if configEntry.ShouldRegisterRotationJob() {
performedRotationManagerOpern = "registration"
performedRotationManagerOpern = rotation.PerformedRegistration
// Register the rotation job if it's required.
cfgReq := &rotation.RotationJobConfigureRequest{
MountPoint: req.MountPoint,
@@ -434,11 +435,15 @@ func (b *backend) pathConfigClientCreateUpdate(ctx context.Context, req *logical
if changedCreds || changedOtherConfig || req.Operation == logical.CreateOperation {
if err := req.Storage.Put(ctx, entry); err != nil {
b.Logger().Error("write to storage failed but the rotation manager still succeeded.",
"operation", performedRotationManagerOpern, "mount", req.MountPoint, "path", req.Path)
wrappedError := err
if performedRotationManagerOpern != "" {
b.Logger().Error("write to storage failed but the rotation manager still succeeded.",
"operation", performedRotationManagerOpern, "mount", req.MountPoint, "path", req.Path)
wrappedError = fmt.Errorf("write to storage failed but the rotation manager still succeeded; "+
"operation=%s, mount=%s, path=%s, storageError=%s", performedRotationManagerOpern, req.MountPoint, req.Path, err)
}
return nil, err
return nil, wrappedError
}
}

View File

@@ -6,6 +6,7 @@ package aws
import (
"context"
"errors"
"fmt"
"github.com/aws/aws-sdk-go/aws"
"github.com/hashicorp/vault/sdk/framework"
@@ -252,7 +253,7 @@ func (b *backend) pathConfigRootWrite(ctx context.Context, req *logical.Request,
var performedRotationManagerOpern string
if rc.ShouldDeregisterRotationJob() {
performedRotationManagerOpern = "deregistration"
performedRotationManagerOpern = rotation.PerformedDeregistration
// Disable Automated Rotation and Deregister credentials if required
deregisterReq := &rotation.RotationJobDeregisterRequest{
MountPoint: req.MountPoint,
@@ -264,7 +265,7 @@ func (b *backend) pathConfigRootWrite(ctx context.Context, req *logical.Request,
return logical.ErrorResponse("error deregistering rotation job: %s", err), nil
}
} else if rc.ShouldRegisterRotationJob() {
performedRotationManagerOpern = "registration"
performedRotationManagerOpern = rotation.PerformedRegistration
// Register the rotation job if it's required.
cfgReq := &rotation.RotationJobConfigureRequest{
MountPoint: req.MountPoint,
@@ -282,11 +283,15 @@ func (b *backend) pathConfigRootWrite(ctx context.Context, req *logical.Request,
// Save the config
if err := putConfigToStorage(ctx, req, rc); err != nil {
b.Logger().Error("write to storage failed but the rotation manager still succeeded.",
"operation", performedRotationManagerOpern, "mount", req.MountPoint, "path", req.Path)
wrappedError := err
if performedRotationManagerOpern != "" {
b.Logger().Error("write to storage failed but the rotation manager still succeeded.",
"operation", performedRotationManagerOpern, "mount", req.MountPoint, "path", req.Path)
wrappedError = fmt.Errorf("write to storage failed but the rotation manager still succeeded; "+
"operation=%s, mount=%s, path=%s, storageError=%s", performedRotationManagerOpern, req.MountPoint, req.Path, err)
}
return nil, err
return nil, wrappedError
}
// clear possible cached IAM / STS clients after successfully updating

View File

@@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"net/rpc"
"regexp"
"strconv"
"strings"
"sync"
@@ -39,6 +40,8 @@ const (
minRootCredRollbackAge = 1 * time.Minute
)
var databaseConfigNameFromRotationIDRegex = regexp.MustCompile("^.+/config/(.+$)")
type dbPluginInstance struct {
sync.RWMutex
database databaseVersionWrapper
@@ -127,6 +130,14 @@ func Backend(conf *logical.BackendConfig) *databaseBackend {
WALRollback: b.walRollback,
WALRollbackMinAge: minRootCredRollbackAge,
BackendType: logical.TypeLogical,
RotateCredential: func(ctx context.Context, request *logical.Request) error {
name, err := b.getDatabaseConfigNameFromRotationID(request.RotationID)
if err != nil {
return err
}
_, err = b.rotateRootCredentials(ctx, request, name)
return err
},
}
b.logger = conf.Logger
@@ -483,6 +494,17 @@ func (b *databaseBackend) dbEvent(ctx context.Context,
}
}
func (b *databaseBackend) getDatabaseConfigNameFromRotationID(path string) (string, error) {
if !databaseConfigNameFromRotationIDRegex.MatchString(path) {
return "", fmt.Errorf("no name found from rotation ID")
}
res := databaseConfigNameFromRotationIDRegex.FindStringSubmatch(path)
if len(res) != 2 {
return "", fmt.Errorf("unexpected number of matches (%d) for name in rotation ID", len(res))
}
return res[1], nil
}
const backendHelp = `
The database backend supports using many different databases
as secret backends, including but not limited to:

View File

@@ -213,6 +213,10 @@ func TestBackend_config_connection(t *testing.T) {
"plugin_version": "",
"verify_connection": false,
"skip_static_role_import_rotation": false,
"rotation_schedule": "",
"rotation_period": 0,
"rotation_window": 0,
"disable_automated_rotation": false,
}
configReq.Operation = logical.ReadOperation
resp, err = b.HandleRequest(namespace.RootContext(nil), configReq)
@@ -221,6 +225,7 @@ func TestBackend_config_connection(t *testing.T) {
}
delete(resp.Data["connection_details"].(map[string]interface{}), "name")
delete(resp.Data, "AutomatedRotationParams")
if !reflect.DeepEqual(expected, resp.Data) {
t.Fatalf("bad: expected:%#v\nactual:%#v\n", expected, resp.Data)
}
@@ -269,6 +274,10 @@ func TestBackend_config_connection(t *testing.T) {
"plugin_version": "",
"verify_connection": false,
"skip_static_role_import_rotation": false,
"rotation_schedule": "",
"rotation_period": 0,
"rotation_window": 0,
"disable_automated_rotation": false,
}
configReq.Operation = logical.ReadOperation
resp, err = b.HandleRequest(namespace.RootContext(nil), configReq)
@@ -277,6 +286,7 @@ func TestBackend_config_connection(t *testing.T) {
}
delete(resp.Data["connection_details"].(map[string]interface{}), "name")
delete(resp.Data, "AutomatedRotationParams")
if !reflect.DeepEqual(expected, resp.Data) {
t.Fatalf("bad: expected:%#v\nactual:%#v\n", expected, resp.Data)
}
@@ -314,6 +324,10 @@ func TestBackend_config_connection(t *testing.T) {
"plugin_version": "",
"verify_connection": false,
"skip_static_role_import_rotation": false,
"rotation_schedule": "",
"rotation_period": 0,
"rotation_window": 0,
"disable_automated_rotation": false,
}
configReq.Operation = logical.ReadOperation
resp, err = b.HandleRequest(namespace.RootContext(nil), configReq)
@@ -322,6 +336,7 @@ func TestBackend_config_connection(t *testing.T) {
}
delete(resp.Data["connection_details"].(map[string]interface{}), "name")
delete(resp.Data, "AutomatedRotationParams")
if !reflect.DeepEqual(expected, resp.Data) {
t.Fatalf("bad: expected:%#v\nactual:%#v\n", expected, resp.Data)
}
@@ -773,6 +788,10 @@ func TestBackend_connectionCrud(t *testing.T) {
"plugin_version": "",
"verify_connection": false,
"skip_static_role_import_rotation": false,
"rotation_schedule": "",
"rotation_period": json.Number("0"),
"rotation_window": json.Number("0"),
"disable_automated_rotation": false,
}
resp, err = client.Read("database/config/plugin-test")
if err != nil {
@@ -780,6 +799,7 @@ func TestBackend_connectionCrud(t *testing.T) {
}
delete(resp.Data["connection_details"].(map[string]interface{}), "name")
delete(resp.Data, "AutomatedRotationParams")
if diff := deep.Equal(resp.Data, expected); diff != nil {
t.Fatal(strings.Join(diff, "\n"))
}

View File

@@ -17,9 +17,11 @@ import (
"github.com/hashicorp/vault/helper/versions"
v5 "github.com/hashicorp/vault/sdk/database/dbplugin/v5"
"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/helper/automatedrotationutil"
"github.com/hashicorp/vault/sdk/helper/consts"
"github.com/hashicorp/vault/sdk/helper/pluginutil"
"github.com/hashicorp/vault/sdk/logical"
"github.com/hashicorp/vault/sdk/rotation"
)
var (
@@ -49,6 +51,8 @@ type DatabaseConfig struct {
// role-level by the role's skip_import_rotation field. The default is
// false. Enterprise only.
SkipStaticRoleImportRotation bool `json:"skip_static_role_import_rotation" structs:"skip_static_role_import_rotation" mapstructure:"skip_static_role_import_rotation"`
automatedrotationutil.AutomatedRotationParams
}
// ConnectionDetails represents the DatabaseConfig.ConnectionDetails map as a
@@ -263,6 +267,7 @@ func pathConfigurePluginConnection(b *databaseBackend) *framework.Path {
},
}
AddConnectionFieldsEnt(fields)
automatedrotationutil.AddAutomatedRotationFields(fields)
return &framework.Path{
Pattern: fmt.Sprintf("config/%s", framework.GenericNameRegex("name")),
@@ -409,6 +414,7 @@ func (b *databaseBackend) connectionReadHandler() framework.OperationFunc {
}
resp.Data = structs.New(config).Map()
config.PopulateAutomatedRotationData(resp.Data)
return resp, nil
}
}
@@ -500,6 +506,10 @@ func (b *databaseBackend) connectionWriteHandler() framework.OperationFunc {
config.SkipStaticRoleImportRotation = skipImportRotationRaw.(bool)
}
if err := config.ParseAutomatedRotationFields(data); err != nil {
return logical.ErrorResponse(err.Error()), nil
}
// Remove these entries from the data before we store it keyed under
// ConnectionDetails.
delete(data.Raw, "name")
@@ -510,6 +520,10 @@ func (b *databaseBackend) connectionWriteHandler() framework.OperationFunc {
delete(data.Raw, "root_rotation_statements")
delete(data.Raw, "password_policy")
delete(data.Raw, "skip_static_role_import_rotation")
delete(data.Raw, "rotation_schedule")
delete(data.Raw, "rotation_window")
delete(data.Raw, "rotation_ttl")
delete(data.Raw, "disable_automated_rotation")
id, err := uuid.GenerateUUID()
if err != nil {
@@ -560,6 +574,36 @@ func (b *databaseBackend) connectionWriteHandler() framework.OperationFunc {
oldConn.Close()
}
var performedRotationManagerOpern string
if config.ShouldDeregisterRotationJob() {
performedRotationManagerOpern = rotation.PerformedDeregistration
// Disable Automated Rotation and Deregister credentials if required
deregisterReq := &rotation.RotationJobDeregisterRequest{
MountPoint: req.MountPoint,
ReqPath: req.Path,
}
b.Logger().Debug("Deregistering rotation job", "mount", req.MountPoint+req.Path)
if err := b.System().DeregisterRotationJob(ctx, deregisterReq); err != nil {
return logical.ErrorResponse("error deregistering rotation job: %s", err), nil
}
} else if config.ShouldRegisterRotationJob() {
performedRotationManagerOpern = rotation.PerformedRegistration
// Register the rotation job if it's required.
cfgReq := &rotation.RotationJobConfigureRequest{
MountPoint: req.MountPoint,
ReqPath: req.Path,
RotationSchedule: config.RotationSchedule,
RotationWindow: config.RotationWindow,
RotationPeriod: config.RotationPeriod,
}
b.Logger().Debug("Registering rotation job", "mount", req.MountPoint+req.Path)
if _, err = b.System().RegisterRotationJob(ctx, cfgReq); err != nil {
return logical.ErrorResponse("error registering rotation job: %s", err), nil
}
}
// 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.
if versions.IsBuiltinVersion(config.PluginVersion) {
@@ -567,7 +611,15 @@ func (b *databaseBackend) connectionWriteHandler() framework.OperationFunc {
}
err = storeConfig(ctx, req.Storage, name, config)
if err != nil {
return nil, err
b.Logger().Error("write to storage failed but the rotation manager still succeeded.",
"operation", performedRotationManagerOpern, "mount", req.MountPoint, "path", req.Path)
wrappedError := err
if performedRotationManagerOpern != "" {
wrappedError = fmt.Errorf("write to storage failed but the rotation manager still succeeded; "+
"operation=%s, mount=%s, path=%s, storageError=%s", performedRotationManagerOpern, req.MountPoint, req.Path, err)
}
return nil, wrappedError
}
resp := &logical.Response{}

View File

@@ -77,117 +77,121 @@ func pathRotateRootCredentials(b *databaseBackend) []*framework.Path {
func (b *databaseBackend) pathRotateRootCredentialsUpdate() framework.OperationFunc {
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
}
config, err := b.DatabaseConfig(ctx, req.Storage, name)
if err != nil {
return nil, err
}
rootUsername, ok := config.ConnectionDetails["username"].(string)
if !ok || rootUsername == "" {
return nil, fmt.Errorf("unable to rotate root credentials: no username in configuration")
}
rootPassword, ok := config.ConnectionDetails["password"].(string)
if !ok || rootPassword == "" {
return nil, fmt.Errorf("unable to rotate root credentials: no password in configuration")
}
dbi, err := b.GetConnection(ctx, req.Storage, name)
if err != nil {
return nil, err
}
// Take the write lock on the instance
dbi.Lock()
defer func() {
dbi.Unlock()
// Even on error, still remove the connection
b.ClearConnectionId(name, dbi.id)
}()
defer func() {
// Close the plugin
dbi.closed = true
if err := dbi.database.Close(); err != nil {
b.Logger().Error("error closing the database plugin connection", "err", err)
}
}()
generator, err := newPasswordGenerator(nil)
if err != nil {
return nil, fmt.Errorf("failed to construct credential generator: %s", err)
}
generator.PasswordPolicy = config.PasswordPolicy
// Generate new credentials
oldPassword := config.ConnectionDetails["password"].(string)
newPassword, err := generator.generate(ctx, b, dbi.database)
if err != nil {
b.CloseIfShutdown(dbi, err)
return nil, fmt.Errorf("failed to generate password: %s", err)
}
config.ConnectionDetails["password"] = newPassword
// Write a WAL entry
walID, err := framework.PutWAL(ctx, req.Storage, rotateRootWALKey, &rotateRootCredentialsWAL{
ConnectionName: name,
UserName: rootUsername,
OldPassword: oldPassword,
NewPassword: newPassword,
})
if err != nil {
return nil, err
}
updateReq := v5.UpdateUserRequest{
Username: rootUsername,
CredentialType: v5.CredentialTypePassword,
Password: &v5.ChangePassword{
NewPassword: newPassword,
Statements: v5.Statements{
Commands: config.RootCredentialsRotateStatements,
},
},
}
newConfigDetails, err := dbi.database.UpdateUser(ctx, updateReq, true)
if err != nil {
return nil, fmt.Errorf("failed to update user: %w", err)
}
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.
if versions.IsBuiltinVersion(config.PluginVersion) {
config.PluginVersion = ""
}
err = storeConfig(ctx, req.Storage, name, config)
if err != nil {
return nil, err
}
err = framework.DeleteWAL(ctx, req.Storage, walID)
if err != nil {
b.Logger().Warn("unable to delete WAL", "error", err, "WAL ID", walID)
}
return nil, nil
return b.rotateRootCredentials(ctx, req, name)
}
}
func (b *databaseBackend) rotateRootCredentials(ctx context.Context, req *logical.Request, name string) (resp *logical.Response, err error) {
if name == "" {
return logical.ErrorResponse(respErrEmptyName), nil
}
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)
}
}()
config, err := b.DatabaseConfig(ctx, req.Storage, name)
if err != nil {
return nil, err
}
rootUsername, ok := config.ConnectionDetails["username"].(string)
if !ok || rootUsername == "" {
return nil, fmt.Errorf("unable to rotate root credentials: no username in configuration")
}
rootPassword, ok := config.ConnectionDetails["password"].(string)
if !ok || rootPassword == "" {
return nil, fmt.Errorf("unable to rotate root credentials: no password in configuration")
}
dbi, err := b.GetConnection(ctx, req.Storage, name)
if err != nil {
return nil, err
}
// Take the write lock on the instance
dbi.Lock()
defer func() {
dbi.Unlock()
// Even on error, still remove the connection
b.ClearConnectionId(name, dbi.id)
}()
defer func() {
// Close the plugin
dbi.closed = true
if err := dbi.database.Close(); err != nil {
b.Logger().Error("error closing the database plugin connection", "err", err)
}
}()
generator, err := newPasswordGenerator(nil)
if err != nil {
return nil, fmt.Errorf("failed to construct credential generator: %s", err)
}
generator.PasswordPolicy = config.PasswordPolicy
// Generate new credentials
oldPassword := config.ConnectionDetails["password"].(string)
newPassword, err := generator.generate(ctx, b, dbi.database)
if err != nil {
b.CloseIfShutdown(dbi, err)
return nil, fmt.Errorf("failed to generate password: %s", err)
}
config.ConnectionDetails["password"] = newPassword
// Write a WAL entry
walID, err := framework.PutWAL(ctx, req.Storage, rotateRootWALKey, &rotateRootCredentialsWAL{
ConnectionName: name,
UserName: rootUsername,
OldPassword: oldPassword,
NewPassword: newPassword,
})
if err != nil {
return nil, err
}
updateReq := v5.UpdateUserRequest{
Username: rootUsername,
CredentialType: v5.CredentialTypePassword,
Password: &v5.ChangePassword{
NewPassword: newPassword,
Statements: v5.Statements{
Commands: config.RootCredentialsRotateStatements,
},
},
}
newConfigDetails, err := dbi.database.UpdateUser(ctx, updateReq, true)
if err != nil {
return nil, fmt.Errorf("failed to update user: %w", err)
}
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.
if versions.IsBuiltinVersion(config.PluginVersion) {
config.PluginVersion = ""
}
err = storeConfig(ctx, req.Storage, name, config)
if err != nil {
return nil, err
}
err = framework.DeleteWAL(ctx, req.Storage, walID)
if err != nil {
b.Logger().Warn("unable to delete WAL", "error", err, "WAL ID", walID)
}
return nil, nil
}
func (b *databaseBackend) pathRotateRoleCredentialsUpdate() framework.OperationFunc {
return func(ctx context.Context, req *logical.Request, data *framework.FieldData) (_ *logical.Response, err error) {
name := data.Get("name").(string)

View File

@@ -1,5 +1,11 @@
```release-note:feature
**Automated Root Rotation**: Adds Automated Root Rotation capabilities to the AWS Auth, AWS Secrets
and DB Secrets plugins. This allows plugin users to automate their root credential rotations based
on configurable schedules/periods via the Rotation Manager. Note: Enterprise only.
**Automated Root Rotation**: Adds Automated Root Rotation capabilities to the AWS Auth and AWS Secrets
plugins. This allows plugin users to automate their root credential rotations based on configurable
schedules/periods via the Rotation Manager. Note: Enterprise only.
```
```release-note:change
secrets/aws: The AWS Secrets engine now persists entries to storage between writes. This enables users
to not have to pass every required field on each write and to make individual updates as necessary.
Note: in order to zero out a value that is previously configured, users must now explicitly set the
field to its zero value on an update.
```

5
changelog/29557.txt Normal file
View File

@@ -0,0 +1,5 @@
```release-note:feature
**Automated Root Rotation**: Adds Automated Root Rotation capabilities to the DB Secrets plugin.
This allows plugin users to automate their root credential rotations based on configurable
schedules/periods via the Rotation Manager. Note: Enterprise only.
```

View File

@@ -63,8 +63,8 @@ func (p *AutomatedRotationParams) ParseAutomatedRotationFields(d *framework.Fiel
p.RotationPeriod = rotationPeriodRaw.(int)
}
if (scheduleOk && !windowOk) || (windowOk && !scheduleOk) {
return fmt.Errorf("must include both schedule and window")
if windowOk && !scheduleOk {
return fmt.Errorf("cannot use rotation_window without rotation_schedule")
}
p.DisableAutomatedRotation = d.Get("disable_automated_rotation").(bool)

View File

@@ -86,6 +86,16 @@ func TestParseAutomatedRotationFields(t *testing.T) {
},
expectedError: "rotation_window does not apply to period",
},
{
name: "window-without-schedule",
data: &framework.FieldData{
Raw: map[string]interface{}{
"rotation_window": 60,
},
Schema: schemaMap,
},
expectedError: "cannot use rotation_window without rotation_schedule",
},
}
for _, tt := range tests {

View File

@@ -10,6 +10,11 @@ import (
"github.com/robfig/cron/v3"
)
const (
PerformedRegistration = "registration"
PerformedDeregistration = "deregistration"
)
// RotationOptions is an embeddable struct to capture common rotation
// settings between a Secret and Auth
type RotationOptions struct {