VAULT-37633: Database static role recover operations (#8922) (#8982)

* initial implementation

* fix

* tests

* changelog

* fix vet errors

* pr comments

Co-authored-by: miagilepner <mia.epner@hashicorp.com>
This commit is contained in:
Vault Automation 2025-08-29 08:48:18 -06:00 committed by GitHub
parent 3c459f7dca
commit eaf949cb1f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 147 additions and 6 deletions

View file

@ -108,6 +108,7 @@ func Backend(conf *logical.BackendConfig) *databaseBackend {
"config/*",
"static-role/*",
},
AllowSnapshotRead: []string{"static-roles/*", "static-roles", "static-creds/*"},
},
Paths: framework.PathAppend(
[]*framework.Path{

View file

@ -92,10 +92,11 @@ func pathRoles(b *databaseBackend) []*framework.Path {
Fields: fieldsForType(databaseStaticRolePath),
ExistenceCheck: b.pathStaticRoleExistenceCheck,
Callbacks: map[logical.Operation]framework.OperationFunc{
logical.ReadOperation: b.pathStaticRoleRead,
logical.CreateOperation: b.pathStaticRoleCreateUpdate,
logical.UpdateOperation: b.pathStaticRoleCreateUpdate,
logical.DeleteOperation: b.pathStaticRoleDelete,
logical.ReadOperation: b.pathStaticRoleRead,
logical.CreateOperation: b.pathStaticRoleCreateUpdate,
logical.UpdateOperation: b.pathStaticRoleCreateUpdate,
logical.DeleteOperation: b.pathStaticRoleDelete,
logical.RecoverOperation: b.pathStaticRoleRecover,
},
HelpSynopsis: pathStaticRoleHelpSyn,
@ -546,6 +547,45 @@ func (b *databaseBackend) pathRoleCreateUpdate(ctx context.Context, req *logical
return nil, nil
}
func (b *databaseBackend) pathStaticRoleRecover(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
exists, err := b.pathStaticRoleExistenceCheck(ctx, req, data)
if err != nil {
return nil, err
}
if exists {
return logical.ErrorResponse("cannot recover a static role that already exists"), nil
}
snapStorage, err := logical.NewSnapshotStorageView(req)
if err != nil {
return nil, fmt.Errorf("failed to create snapshot storage: %s", err)
}
name := data.Get("name").(string)
if req.RecoverSourcePath != "" {
fd, err := b.RecoverSourcePathFieldData(req)
if err != nil {
return nil, fmt.Errorf("failed to parse the recover source path: %w", err)
}
name = fd.Get("name").(string)
}
role, err := b.StaticRole(ctx, snapStorage, name)
if err != nil {
return nil, err
}
if role.StaticAccount.Password != "" {
data.Raw["password"] = role.StaticAccount.Password
}
req.Operation = logical.CreateOperation
defer func() {
req.Operation = logical.RecoverOperation
}()
return b.pathStaticRoleCreateUpdate(ctx, req, data)
}
// ignore-nil-nil-function-check
func (b *databaseBackend) pathStaticRoleCreateUpdate(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
response := &logical.Response{}
name := data.Get("name").(string)

View file

@ -15,6 +15,7 @@ import (
"github.com/hashicorp/vault/helper/namespace"
postgreshelper "github.com/hashicorp/vault/helper/testhelpers/postgresql"
v5 "github.com/hashicorp/vault/sdk/database/dbplugin/v5"
"github.com/hashicorp/vault/sdk/helper/testhelpers/snapshots"
"github.com/hashicorp/vault/sdk/logical"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
@ -1494,3 +1495,90 @@ ALTER USER "{{name}}" WITH PASSWORD '{{password}}';
const testRoleStaticUpdateRotation = `
ALTER USER "{{name}}" WITH PASSWORD '{{password}}';GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO "{{name}}";
`
// TestStaticRole_Recover verifies that a role that exists in a snapshot can be
// read, listed, and recovered
func TestStaticRole_Recover(t *testing.T) {
b, storage, mockDB := getBackend(t)
b2, snapStorage, mockDBSnap := getBackend(t)
ctx := context.Background()
defer b.Cleanup(ctx)
defer b2.Cleanup(ctx)
tc := snapshots.NewSnapshotTestCaseWithStorages(t, b, storage, snapStorage)
configureDBMount(t, storage)
configureDBMount(t, snapStorage)
createRole(t, b2, snapStorage, mockDBSnap, "hashicorp")
tc.RunRead(t, "static-roles/hashicorp")
tc.RunList(t, "static-roles")
mockDB.On("UpdateUser", mock.Anything, mock.Anything).
Return(v5.UpdateUserResponse{}, nil).
Once()
_, err := tc.DoRecover(t, "static-roles/hashicorp")
require.NoError(t, err)
readStaticCred(t, b, storage, mockDB, "hashicorp")
mockDB.On("UpdateUser", mock.Anything, mock.Anything).
Return(v5.UpdateUserResponse{}, nil).
Once()
_, err = tc.DoRecover(t, "static-roles/hashicorp-copy", snapshots.WithRecoverSourcePath("static-roles/hashicorp"))
require.NoError(t, err)
readStaticCred(t, b, storage, mockDB, "hashicorp-copy")
}
// TestStaticRole_RecoverExists verifies that a static role cannot be updated
// via a recover operation, but can be copied to a new role
func TestStaticRole_RecoverExists(t *testing.T) {
b, storage, mockDB := getBackend(t)
b2, snapStorage, mockDBSnap := getBackend(t)
ctx := context.Background()
defer b.Cleanup(ctx)
defer b2.Cleanup(ctx)
tc := snapshots.NewSnapshotTestCaseWithStorages(t, b, storage, snapStorage)
configureDBMount(t, storage)
configureDBMount(t, snapStorage)
createRole(t, b2, snapStorage, mockDBSnap, "hashicorp")
createRole(t, b, storage, mockDB, "hashicorp")
resp, err := tc.DoRecover(t, "static-roles/hashicorp")
require.NoError(t, err)
require.True(t, resp.IsError())
require.ErrorContains(t, resp.Error(), "cannot recover a static role that already exists")
mockDB.On("UpdateUser", mock.Anything, mock.Anything).
Return(v5.UpdateUserResponse{}, nil).
Once()
resp, err = tc.DoRecover(t, "static-roles/hashicorp-copy", snapshots.WithRecoverSourcePath("static-roles/hashicorp"))
require.NoError(t, err)
require.False(t, resp.IsError())
readStaticCred(t, b, storage, mockDB, "hashicorp-copy")
}
// TestStaticCreds_Recover verifies that static credentials can be read from the
// snapshot without side effects, but they cannot be recovered
func TestStaticCreds_Recover(t *testing.T) {
b, storage, mockDB := getBackend(t)
b2, snapStorage, mockDBSnap := getBackend(t)
ctx := context.Background()
defer b.Cleanup(ctx)
defer b2.Cleanup(ctx)
tc := snapshots.NewSnapshotTestCaseWithStorages(t, b, storage, snapStorage)
configureDBMount(t, storage)
configureDBMount(t, snapStorage)
createRole(t, b2, snapStorage, mockDBSnap, "hashicorp")
createRole(t, b, storage, mockDB, "hashicorp")
rotateRole(t, b, snapStorage, mockDB, "hashicorp")
tc.RunRead(t, "static-creds/hashicorp")
_, err := tc.DoRecover(t, "static-creds/hashicorp")
require.Error(t, err)
}

3
changelog/_8922.txt Normal file
View file

@ -0,0 +1,3 @@
```release-note:improvement
secrets/database (enterprise): Add support for reading, listing, and recovering static roles from a loaded snapshot. Also add support for reading static credentials from a loaded snapshot.
```

View file

@ -33,10 +33,19 @@ var _ logical.SnapshotStorageProvider = (*storageProvider)(nil)
// when it receives snapshot operations, without having to do the end-to-end
// setup of creating a raft cluster, taking a snapshot, and loading it.
func NewSnapshotTestCase(t testing.TB, backend logical.Backend) *SnapshotTestCase {
return NewSnapshotTestCaseWithStorages(t, backend, &logical.InmemStorage{}, &logical.InmemStorage{})
}
// NewSnapshotTestCaseWithStorages is used to create a snapshot test case for a
// particular backend, using the provided storage instances. The test case is
// used to ensure that the backend behaves correctly when it receives snapshot
// operations, without having to do the end-to-end setup of creating a raft
// cluster, taking a snapshot, and loading it.
func NewSnapshotTestCaseWithStorages(t testing.TB, backend logical.Backend, regularStorage, snapshotStorage logical.Storage) *SnapshotTestCase {
s := &SnapshotTestCase{
backend: backend,
regularStorage: &logical.InmemStorage{},
snapshotStorage: &logical.InmemStorage{},
regularStorage: regularStorage,
snapshotStorage: snapshotStorage,
}
s.storageRouter = logical.NewSnapshotStorageRouter(s.regularStorage, &storageProvider{s.snapshotStorage})