VAULT-35079: Scaffolding for recover operations on backends (#30556)

* allow snapshot read, recover operation, snapshot ID in request

* lint and add comment
This commit is contained in:
miagilepner 2025-05-09 12:48:20 +02:00 committed by GitHub
parent 23ab4d924c
commit 1c37b94d65
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 737 additions and 552 deletions

View file

@ -174,6 +174,14 @@ type Paths struct {
//
// For more details, consult limits/registry.go.
Limited []string
// AllowSnapshotRead paths are API paths that are allowed to be read from
// a loaded snapshot. These can't be regular expressions, it is either an
// exact match, a prefix match and/or a wildcard match.
// For prefix match, append '*' as a suffix.
// For a wildcard match, use '+' in the segment to match any identifier
// (e.g. 'foo/+/bar'). Note that '+' can't be adjacent to a non-slash.
AllowSnapshotRead []string
}
type Auditor interface {

View file

@ -263,6 +263,10 @@ type Request struct {
// RequestLimiterDisabled tells whether the request context has Request Limiter applied.
RequestLimiterDisabled bool `json:"request_limiter_disabled,omitempty"`
// RequiresSnapshotID holds a loaded snapshot ID that the request will use,
// for either a read, list, or recover operation
RequiresSnapshotID string `json:"snapshot_id,omitempty"`
}
// Clone returns a deep copy (almost) of the request.
@ -400,6 +404,13 @@ func (r *Request) SetTokenEntry(te *TokenEntry) {
r.tokenEntry = te
}
// IsSnapshotReadOrList checks whether the request reads or lists from a
// snapshot. When this method returns true, handling the request should not
// modify any internal caches or state
func (r *Request) IsSnapshotReadOrList() bool {
return (r.Operation == ReadOperation || r.Operation == ListOperation) && r.RequiresSnapshotID != ""
}
// RenewRequest creates the structure of the renew request.
func RenewRequest(path string, secret *Secret, data map[string]interface{}) *Request {
return &Request{
@ -455,6 +466,7 @@ const (
AliasLookaheadOperation = "alias-lookahead"
ResolveRoleOperation = "resolve-role"
HeaderOperation = "header"
RecoverOperation = "recover"
// The operations below are called globally, the path is less relevant.
RevokeOperation Operation = "revoke"
@ -611,3 +623,24 @@ func CtxRedactionSettingsValue(ctx context.Context) (redactVersion, redactAddres
func CreateContextRedactionSettings(parent context.Context, redactVersion, redactAddresses, redactClusterName bool) context.Context {
return context.WithValue(parent, ctxKeyRedactionSettings{}, []bool{redactVersion, redactAddresses, redactClusterName})
}
type ctxKeySnapshotID struct{}
func (c ctxKeySnapshotID) String() string {
return "snapshot-id"
}
// CreateContextWithSnapshotID creates a new context indicating that any storage
// operations should be done using the given snapshot ID. If the value is empty,
// it means that the request should use the normal storage.
func CreateContextWithSnapshotID(parent context.Context, value string) context.Context {
return context.WithValue(parent, ctxKeySnapshotID{}, value)
}
// ContextSnapshotIDValue retrieves the snapshot ID value stored in the context.
// This value can be empty, indicating that the request should use the normal
// storage.
func ContextSnapshotIDValue(ctx context.Context) (value string, ok bool) {
value, ok = ctx.Value(ctxKeySnapshotID{}).(string)
return
}

View file

@ -132,6 +132,8 @@ func (b *backendGRPCPluginClient) SpecialPaths() *logical.Paths {
LocalStorage: reply.Paths.LocalStorage,
SealWrapStorage: reply.Paths.SealWrapStorage,
WriteForwardedStorage: reply.Paths.WriteForwardedStorage,
Limited: reply.Paths.Limited,
AllowSnapshotRead: reply.Paths.AllowSnapshotRead,
}
}

File diff suppressed because it is too large Load diff

View file

@ -68,6 +68,11 @@ message Paths {
//
// See note in /sdk/logical/logical.go.
repeated string limited = 7;
// AllowSnapshotRead paths are allowed to be read from a loaded snapshot
//
// See note in /sdk/logical/logical.go
repeated string allow_snapshot_read = 8;
}
message Request {
@ -156,6 +161,10 @@ message Request {
// inspect the connection information and potentially use it for
// authentication/protection.
Connection connection = 20;
// RequiresSnapshotID will be present when the request is a list, read, or
// recover from snapshot operation
string requires_snapshot_id = 21;
}
message Auth {

View file

@ -257,6 +257,7 @@ func LogicalRequestToProtoRequest(r *logical.Request) (*Request, error) {
EntityID: r.EntityID,
PolicyOverride: r.PolicyOverride,
Unauthenticated: r.Unauthenticated,
RequiresSnapshotID: r.RequiresSnapshotID,
}, nil
}
@ -315,6 +316,7 @@ func ProtoRequestToLogicalRequest(r *Request) (*logical.Request, error) {
EntityID: r.EntityID,
PolicyOverride: r.PolicyOverride,
Unauthenticated: r.Unauthenticated,
RequiresSnapshotID: r.RequiresSnapshotID,
}, nil
}

View file

@ -91,6 +91,7 @@ func TestTranslation_Request(t *testing.T) {
PeerCertificates: certs,
},
},
RequiresSnapshotID: "abcd",
},
{
ID: "ID",
@ -170,6 +171,7 @@ func TestTranslation_Request(t *testing.T) {
EntityID: "tester",
PolicyOverride: true,
Unauthenticated: true,
RequiresSnapshotID: "abcd",
},
}

View file

@ -63,14 +63,15 @@ type routeEntry struct {
tainted atomic.Bool
// backend is the actual backend instance for this route entry; lock l must
// be held to access this field.
backend logical.Backend
mountEntry *MountEntry
storageView logical.Storage
storagePrefix string
rootPaths atomic.Value
loginPaths atomic.Value
binaryPaths atomic.Value
limitedPaths atomic.Value
backend logical.Backend
mountEntry *MountEntry
storageView logical.Storage
storagePrefix string
rootPaths atomic.Value
loginPaths atomic.Value
binaryPaths atomic.Value
limitedPaths atomic.Value
allowSnapshotReadPaths atomic.Value
// l is the lock used to protect access to backend during reloads
l sync.RWMutex
}
@ -89,7 +90,8 @@ type specialPathsEntry struct {
}
// specialPathsLookupFunc is used by (*Router).specialPath to look up a
// specialPathsEntry corresponding to loginPath, binaryPath, or limitedPath.
// specialPathsEntry corresponding to loginPath, binaryPath, limitedPath, or
// allowSnapshotReadPath.
type specialPathsLookupFunc func(re *routeEntry) *specialPathsEntry
type ValidateMountResponse struct {
@ -225,6 +227,12 @@ func (r *Router) Mount(backend logical.Backend, prefix string, mountEntry *Mount
}
re.limitedPaths.Store(limitedPathsEntry)
allowSnapshotReadPathsEntry, err := parseUnauthenticatedPaths(paths.AllowSnapshotRead)
if err != nil {
return err
}
re.allowSnapshotReadPaths.Store(allowSnapshotReadPathsEntry)
switch {
case prefix == "":
return fmt.Errorf("missing prefix to be used for router entry; mount_path: %q, mount_type: %q", re.mountEntry.Path, re.mountEntry.Type)
@ -915,9 +923,18 @@ func (r *Router) LimitedPath(ctx context.Context, path string) bool {
})
}
// AllowSnapshotReadPath checks if the given path is allowed to be used for a
// snapshot read
func (r *Router) AllowSnapshotReadPath(ctx context.Context, path string) bool {
return r.specialPath(ctx, path,
func(re *routeEntry) *specialPathsEntry {
return re.allowSnapshotReadPaths.Load().(*specialPathsEntry)
})
}
// specialPath is a common method for checking if the given path has a matching
// PathsSpecial entry. This is used for Login, Binary, and Limited PathsSpecial
// fields.
// PathsSpecial entry. This is used for Login, Binary, Limited, and
// AllowSnapshotRead PathsSpecial fields.
// Matching Priority
// 1. prefix
// 2. exact

View file

@ -13,6 +13,7 @@ import (
"github.com/hashicorp/vault/helper/namespace"
"github.com/hashicorp/vault/sdk/logical"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRouter_Mount(t *testing.T) {
@ -683,3 +684,84 @@ func TestWellKnownRedirectMatching(t *testing.T) {
t.Fail()
}
}
// TestRouter_AllowSnapshotReadPaths mounts a backend with a set of paths that
// are allowed to be read from a snapshot. The test verifies that paths that
// match the allowed paths return true, while paths that do not match return
// false from the router.
func TestRouter_AllowSnapshotReadPaths(t *testing.T) {
r := NewRouter()
_, barrier, _ := mockBarrier(t)
view := NewBarrierView(barrier, "logical/")
meUUID, err := uuid.GenerateUUID()
if err != nil {
t.Fatal(err)
}
ctx := namespace.RootContext(context.Background())
n := &NoopBackend{
AllowSnapshotRead: []string{
"config/root",
"roles/+",
"roles/+/status",
"static-creds/+",
"static-creds/+/sub",
},
BackendType: logical.TypeCredential,
}
err = r.Mount(n, "foo/", &MountEntry{UUID: meUUID, Accessor: "fooaccessor", NamespaceID: namespace.RootNamespaceID, namespace: namespace.RootNamespace}, view)
if err != nil {
t.Fatalf("err: %v", err)
}
testCases := []struct {
queryPath string
expect bool
}{
{
queryPath: "config",
expect: false,
},
{
queryPath: "config/root",
expect: true,
},
{
queryPath: "config/root/key",
expect: false,
},
{
queryPath: "roles/name",
expect: true,
},
{
queryPath: "roles/name/status",
expect: true,
},
{
queryPath: "roles/name/status/sub",
expect: false,
},
{
queryPath: "static-creds/name",
expect: true,
},
{
queryPath: "static-creds/name/sub",
expect: true,
},
{
queryPath: "static-creds/name/more/sub",
expect: false,
},
{
queryPath: "creds/name",
expect: false,
},
}
for _, tc := range testCases {
t.Run(strings.ReplaceAll(tc.queryPath, "/", "_"), func(t *testing.T) {
require.Equal(t, tc.expect, r.AllowSnapshotReadPath(ctx, "foo/"+tc.queryPath))
})
}
}

View file

@ -19,16 +19,17 @@ type RouterTestHandlerFunc func(context.Context, *logical.Request) (*logical.Res
type NoopBackend struct {
sync.Mutex
Root []string
Login []string
Paths []string
Requests []*logical.Request
Response *logical.Response
RequestHandler RouterTestHandlerFunc
Invalidations []string
DefaultLeaseTTL time.Duration
MaxLeaseTTL time.Duration
BackendType logical.BackendType
Root []string
Login []string
Paths []string
AllowSnapshotRead []string
Requests []*logical.Request
Response *logical.Response
RequestHandler RouterTestHandlerFunc
Invalidations []string
DefaultLeaseTTL time.Duration
MaxLeaseTTL time.Duration
BackendType logical.BackendType
RollbackErrs bool
}
@ -79,8 +80,9 @@ func (n *NoopBackend) HandleExistenceCheck(ctx context.Context, req *logical.Req
func (n *NoopBackend) SpecialPaths() *logical.Paths {
return &logical.Paths{
Root: n.Root,
Unauthenticated: n.Login,
Root: n.Root,
Unauthenticated: n.Login,
AllowSnapshotRead: n.AllowSnapshotRead,
}
}