mirror of
https://github.com/optim-enterprises-bv/vault.git
synced 2025-10-29 09:42:25 +00:00
Port activation flags with dynamic registration (#29237)
This commit is contained in:
3
changelog/29237.txt
Normal file
3
changelog/29237.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
```release-note:improvement
|
||||
core: Add activation flags. A mechanism for users to opt in to new functionality at a convenient time. Previously used only in Enterprise for SecretSync, activation flags are now available in CE for future features to use.
|
||||
```
|
||||
142
helper/activationflags/activation_flags.go
Normal file
142
helper/activationflags/activation_flags.go
Normal file
@@ -0,0 +1,142 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package activationflags
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"sync"
|
||||
|
||||
"github.com/hashicorp/vault/sdk/logical"
|
||||
)
|
||||
|
||||
const (
|
||||
storagePathActivationFlags = "activation-flags"
|
||||
)
|
||||
|
||||
type FeatureActivationFlags struct {
|
||||
activationFlagsLock sync.RWMutex
|
||||
storage logical.Storage
|
||||
activationFlags map[string]bool
|
||||
}
|
||||
|
||||
func NewFeatureActivationFlags() *FeatureActivationFlags {
|
||||
return &FeatureActivationFlags{
|
||||
activationFlags: map[string]bool{},
|
||||
}
|
||||
}
|
||||
|
||||
func (f *FeatureActivationFlags) Initialize(ctx context.Context, storage logical.Storage) error {
|
||||
f.activationFlagsLock.Lock()
|
||||
defer f.activationFlagsLock.Unlock()
|
||||
|
||||
if storage == nil {
|
||||
return fmt.Errorf("unable to access storage")
|
||||
}
|
||||
|
||||
f.storage = storage
|
||||
|
||||
entry, err := f.storage.Get(ctx, storagePathActivationFlags)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get activation flags from storage: %w", err)
|
||||
}
|
||||
if entry == nil {
|
||||
f.activationFlags = map[string]bool{}
|
||||
return nil
|
||||
}
|
||||
|
||||
var activationFlags map[string]bool
|
||||
if err := entry.DecodeJSON(&activationFlags); err != nil {
|
||||
return fmt.Errorf("failed to decode activation flags from storage: %w", err)
|
||||
}
|
||||
|
||||
f.activationFlags = activationFlags
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get is the helper function called by the activation-flags API read endpoint. This reads the
|
||||
// actual values from storage, then updates the in-memory cache of the activation-flags. It
|
||||
// returns a slice of the feature names which have already been activated.
|
||||
func (f *FeatureActivationFlags) Get(ctx context.Context) ([]string, error) {
|
||||
f.activationFlagsLock.Lock()
|
||||
defer f.activationFlagsLock.Unlock()
|
||||
|
||||
// Don't use nil slice declaration, we want the JSON to show "[]" instead of null
|
||||
activated := []string{}
|
||||
|
||||
if f.storage == nil {
|
||||
return activated, nil
|
||||
}
|
||||
|
||||
entry, err := f.storage.Get(ctx, storagePathActivationFlags)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get activation flags from storage: %w", err)
|
||||
}
|
||||
if entry == nil {
|
||||
return activated, nil
|
||||
}
|
||||
|
||||
var activationFlags map[string]bool
|
||||
if err := entry.DecodeJSON(&activationFlags); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode activation flags from storage: %w", err)
|
||||
}
|
||||
|
||||
// Update the in-memory flags after loading the latest values from storage
|
||||
f.activationFlags = activationFlags
|
||||
|
||||
for flag, set := range activationFlags {
|
||||
if set {
|
||||
activated = append(activated, flag)
|
||||
}
|
||||
}
|
||||
|
||||
return activated, nil
|
||||
}
|
||||
|
||||
// Write is the helper function called by the activation-flags API write endpoint. This stores
|
||||
// the boolean value for the activation-flag feature name into Vault storage across the cluster
|
||||
// and updates the in-memory cache upon success.
|
||||
func (f *FeatureActivationFlags) Write(ctx context.Context, featureName string, activate bool) (err error) {
|
||||
f.activationFlagsLock.Lock()
|
||||
defer f.activationFlagsLock.Unlock()
|
||||
|
||||
if f.storage == nil {
|
||||
return fmt.Errorf("unable to access storage")
|
||||
}
|
||||
|
||||
activationFlags := f.activationFlags
|
||||
|
||||
clonedFlags := maps.Clone(f.activationFlags)
|
||||
clonedFlags[featureName] = activate
|
||||
// The cloned flags are updated but the in-memory state is only updated on success of the storage update.
|
||||
defer func() {
|
||||
if err == nil {
|
||||
activationFlags[featureName] = activate
|
||||
}
|
||||
}()
|
||||
|
||||
entry, err := logical.StorageEntryJSON(storagePathActivationFlags, clonedFlags)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal object to JSON: %w", err)
|
||||
}
|
||||
|
||||
err = f.storage.Put(ctx, entry)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save object in storage: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsActivationFlagEnabled is true if the specified flag is enabled in the core.
|
||||
func (f *FeatureActivationFlags) IsActivationFlagEnabled(featureName string) bool {
|
||||
f.activationFlagsLock.RLock()
|
||||
defer f.activationFlagsLock.RUnlock()
|
||||
|
||||
activated, ok := f.activationFlags[featureName]
|
||||
|
||||
return ok && activated
|
||||
}
|
||||
@@ -45,6 +45,7 @@ import (
|
||||
"github.com/hashicorp/vault/api"
|
||||
"github.com/hashicorp/vault/audit"
|
||||
"github.com/hashicorp/vault/command/server"
|
||||
"github.com/hashicorp/vault/helper/activationflags"
|
||||
"github.com/hashicorp/vault/helper/identity/mfa"
|
||||
"github.com/hashicorp/vault/helper/locking"
|
||||
"github.com/hashicorp/vault/helper/metricsutil"
|
||||
@@ -739,6 +740,9 @@ type Core struct {
|
||||
clusterAddrBridge *raft.ClusterAddrBridge
|
||||
|
||||
censusManager *CensusManager
|
||||
|
||||
// Activation flags for enterprise features that require a one-time activation
|
||||
FeatureActivationFlags *activationflags.FeatureActivationFlags
|
||||
}
|
||||
|
||||
func (c *Core) ActiveNodeClockSkewMillis() int64 {
|
||||
@@ -1448,11 +1452,14 @@ func (c *Core) configureLogicalBackends(backends map[string]logical.Factory, log
|
||||
// System
|
||||
logicalBackends[mountTypeSystem] = func(ctx context.Context, config *logical.BackendConfig) (logical.Backend, error) {
|
||||
sysBackendLogger := logger.Named("system")
|
||||
|
||||
c.AddLogger(sysBackendLogger)
|
||||
b := NewSystemBackend(c, sysBackendLogger, config)
|
||||
|
||||
if err := b.Setup(ctx, config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return b, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/hashicorp/go-hclog"
|
||||
"github.com/hashicorp/vault/helper/activationflags"
|
||||
"github.com/hashicorp/vault/helper/namespace"
|
||||
"github.com/hashicorp/vault/limits"
|
||||
"github.com/hashicorp/vault/sdk/helper/license"
|
||||
@@ -59,6 +60,8 @@ func coreInit(c *Core, conf *CoreConfig) error {
|
||||
c.physical = physical.NewStorageEncoding(c.physical)
|
||||
}
|
||||
|
||||
c.FeatureActivationFlags = activationflags.NewFeatureActivationFlags()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -231,6 +231,7 @@ func NewSystemBackend(core *Core, logger log.Logger, config *logical.BackendConf
|
||||
b.Backend.Paths = append(b.Backend.Paths, b.experimentPaths()...)
|
||||
b.Backend.Paths = append(b.Backend.Paths, b.introspectionPaths()...)
|
||||
b.Backend.Paths = append(b.Backend.Paths, b.wellKnownPaths()...)
|
||||
b.Backend.Paths = append(b.Backend.Paths, b.activationFlagsPaths()...)
|
||||
|
||||
if core.rawEnabled {
|
||||
b.Backend.Paths = append(b.Backend.Paths, b.rawPaths()...)
|
||||
|
||||
140
vault/logical_system_activation_flags.go
Normal file
140
vault/logical_system_activation_flags.go
Normal file
@@ -0,0 +1,140 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package vault
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/hashicorp/vault/sdk/framework"
|
||||
"github.com/hashicorp/vault/sdk/logical"
|
||||
)
|
||||
|
||||
const (
|
||||
paramFeatureName = "feature_name"
|
||||
descFeatureName = "The name of the feature to be activated."
|
||||
summaryList = "Returns the available and activated activation-flagged features."
|
||||
summaryUpdate = "Activate a flagged feature."
|
||||
|
||||
prefixActivationFlags = "activation-flags"
|
||||
verbActivationFlagsActivate = "activate"
|
||||
verbActivationFlagsDeactivate = "deactivate"
|
||||
|
||||
fieldActivated = "activated"
|
||||
fieldUnactivated = "unactivated"
|
||||
|
||||
helpSynopsis = "Returns information about Vault's features that require a one-time activation step."
|
||||
helpDescription = `
|
||||
This path responds to the following HTTP methods.
|
||||
GET /
|
||||
Returns the available and activated activation-flags.
|
||||
|
||||
PUT|POST /<feature-name>/activate
|
||||
Activates the specified feature. Cannot be undone.`
|
||||
)
|
||||
|
||||
// Register CRUD functions dynamically.
|
||||
// These variables should only be mutated during initialization or server construction.
|
||||
// It is unsafe to modify them once the Vault core is running.
|
||||
var (
|
||||
readActivationFlag = func(ctx context.Context, b *SystemBackend, req *logical.Request, fd *framework.FieldData) (*logical.Response, error) {
|
||||
return b.readActivationFlag(ctx, req, fd)
|
||||
}
|
||||
|
||||
writeActivationFlag = func(ctx context.Context, b *SystemBackend, req *logical.Request, fd *framework.FieldData, isActivate bool) (*logical.Response, error) {
|
||||
return b.writeActivationFlagWrite(ctx, req, fd, isActivate)
|
||||
}
|
||||
)
|
||||
|
||||
func (b *SystemBackend) activationFlagsPaths() []*framework.Path {
|
||||
return []*framework.Path{
|
||||
{
|
||||
Pattern: fmt.Sprintf("%s$", prefixActivationFlags),
|
||||
DisplayAttrs: &framework.DisplayAttributes{
|
||||
OperationVerb: "read",
|
||||
OperationSuffix: prefixActivationFlags,
|
||||
},
|
||||
Operations: map[logical.Operation]framework.OperationHandler{
|
||||
logical.ReadOperation: &framework.PathOperation{
|
||||
Callback: b.handleActivationFlagRead,
|
||||
Summary: summaryList,
|
||||
},
|
||||
},
|
||||
HelpSynopsis: helpSynopsis,
|
||||
HelpDescription: helpDescription,
|
||||
},
|
||||
{
|
||||
Pattern: fmt.Sprintf("%s/%s/%s", prefixActivationFlags, "activation-test", verbActivationFlagsActivate),
|
||||
DisplayAttrs: &framework.DisplayAttributes{
|
||||
OperationPrefix: prefixActivationFlags,
|
||||
OperationVerb: verbActivationFlagsActivate,
|
||||
},
|
||||
Operations: map[logical.Operation]framework.OperationHandler{
|
||||
logical.UpdateOperation: &framework.PathOperation{
|
||||
Callback: b.handleActivationFlagsActivate,
|
||||
ForwardPerformanceSecondary: true,
|
||||
ForwardPerformanceStandby: true,
|
||||
Summary: summaryUpdate,
|
||||
},
|
||||
},
|
||||
HelpSynopsis: helpSynopsis,
|
||||
HelpDescription: helpDescription,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (b *SystemBackend) handleActivationFlagRead(ctx context.Context, req *logical.Request, fd *framework.FieldData) (*logical.Response, error) {
|
||||
return readActivationFlag(ctx, b, req, fd)
|
||||
}
|
||||
|
||||
func (b *SystemBackend) handleActivationFlagsActivate(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
|
||||
return writeActivationFlag(ctx, b, req, data, true)
|
||||
}
|
||||
|
||||
func (b *SystemBackend) readActivationFlag(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) {
|
||||
activationFlags, err := b.Core.FeatureActivationFlags.Get(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return b.activationFlagsToResponse(activationFlags), nil
|
||||
}
|
||||
|
||||
func (b *SystemBackend) writeActivationFlagWrite(ctx context.Context, req *logical.Request, _ *framework.FieldData, isActivate bool) (*logical.Response, error) {
|
||||
// We need to manually parse out the feature_name from the path because we can't use FieldSchema parameters
|
||||
// in the path to make generic endpoints. We need each activation-flag path to be a separate endpoint.
|
||||
// Path starts out as activation-flags/<feature_name>/verb
|
||||
// Removes activation-flags/ from the path
|
||||
trimPrefix := strings.TrimPrefix(req.Path, prefixActivationFlags+"/")
|
||||
// Removes /verb from the path
|
||||
featureName := trimPrefix[:strings.LastIndex(trimPrefix, "/")]
|
||||
|
||||
err := b.Core.FeatureActivationFlags.Write(ctx, featureName, isActivate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to write new activation flags: %w", err)
|
||||
}
|
||||
|
||||
// We read back the value after writing it to storage so that we can try forcing a cache update right away.
|
||||
// If this fails, it's still okay to proceed as the write has been successful and the cache will get updated
|
||||
// at the time of an endpoint getting called. However, we can only return the one feature name we just activated
|
||||
// in the response since the read to retrieve any others did not succeed.
|
||||
activationFlags, err := b.Core.FeatureActivationFlags.Get(ctx)
|
||||
if err != nil {
|
||||
resp := b.activationFlagsToResponse([]string{featureName})
|
||||
return resp, fmt.Errorf("failed to read activation-flags back after write: %w", err)
|
||||
}
|
||||
|
||||
return b.activationFlagsToResponse(activationFlags), nil
|
||||
}
|
||||
|
||||
func (b *SystemBackend) activationFlagsToResponse(activationFlags []string) *logical.Response {
|
||||
slices.Sort(activationFlags)
|
||||
return &logical.Response{
|
||||
Data: map[string]interface{}{
|
||||
fieldActivated: activationFlags,
|
||||
},
|
||||
}
|
||||
}
|
||||
87
vault/logical_system_activation_flags_test.go
Normal file
87
vault/logical_system_activation_flags_test.go
Normal file
@@ -0,0 +1,87 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package vault
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/vault/helper/namespace"
|
||||
"github.com/hashicorp/vault/sdk/logical"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestActivationFlags_Read tests the read operation for the activation flags.
|
||||
func TestActivationFlags_Read(t *testing.T) {
|
||||
t.Run("given an initial state then read flags and expect all to be unactivated", func(t *testing.T) {
|
||||
core, _, _ := TestCoreUnsealedWithConfig(t, &CoreConfig{})
|
||||
|
||||
resp, err := core.systemBackend.HandleRequest(
|
||||
context.Background(),
|
||||
&logical.Request{
|
||||
Operation: logical.ReadOperation,
|
||||
Path: prefixActivationFlags,
|
||||
Storage: core.systemBarrierView,
|
||||
},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, resp.Data, map[string]interface{}{
|
||||
"activated": []string{},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// TestActivationFlags_BadFeatureName tests a nonexistent feature name or a missing feature name
|
||||
// in the activation-flags path API call.
|
||||
func TestActivationFlags_BadFeatureName(t *testing.T) {
|
||||
core, _, _ := TestCoreUnsealedWithConfig(t, &CoreConfig{})
|
||||
|
||||
tests := map[string]struct {
|
||||
featureName string
|
||||
}{
|
||||
"if no feature name is provided then expect unsupported path": {
|
||||
featureName: "",
|
||||
},
|
||||
"if an invalid feature name is provided then expect unsupported path": {
|
||||
featureName: "fake-feature",
|
||||
},
|
||||
}
|
||||
|
||||
for name, tt := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
resp, err := core.router.Route(
|
||||
namespace.ContextWithNamespace(context.Background(), namespace.RootNamespace),
|
||||
&logical.Request{
|
||||
Operation: logical.UpdateOperation,
|
||||
Path: fmt.Sprintf("sys/%s/%s/%s", prefixActivationFlags, tt.featureName, verbActivationFlagsActivate),
|
||||
Storage: core.systemBarrierView,
|
||||
},
|
||||
)
|
||||
|
||||
require.Error(t, err)
|
||||
require.Nil(t, resp)
|
||||
require.Equal(t, err, logical.ErrUnsupportedPath)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestActivationFlags_Write tests the write operations for the activation flags
|
||||
func TestActivationFlags_Write(t *testing.T) {
|
||||
t.Run("given an initial state then read flags and expect all to be unactivated", func(t *testing.T) {
|
||||
core, _, _ := TestCoreUnsealedWithConfig(t, &CoreConfig{})
|
||||
|
||||
_, err := core.systemBackend.HandleRequest(
|
||||
context.Background(),
|
||||
&logical.Request{
|
||||
Operation: logical.UpdateOperation,
|
||||
Path: fmt.Sprintf("%s/%s/%s", prefixActivationFlags, "activation-test", verbActivationFlagsActivate),
|
||||
Storage: core.systemBarrierView,
|
||||
},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
@@ -31,9 +31,7 @@ var (
|
||||
return nil
|
||||
}
|
||||
|
||||
sysInitialize = func(b *SystemBackend) func(context.Context, *logical.InitializationRequest) error {
|
||||
return nil
|
||||
}
|
||||
sysInitialize = ceSysInitialize
|
||||
|
||||
sysClean = func(b *SystemBackend) func(context.Context) {
|
||||
return nil
|
||||
@@ -280,6 +278,16 @@ var (
|
||||
checkRaw = func(b *SystemBackend, path string) error { return nil }
|
||||
)
|
||||
|
||||
func ceSysInitialize(b *SystemBackend) func(context.Context, *logical.InitializationRequest) error {
|
||||
return func(ctx context.Context, req *logical.InitializationRequest) error {
|
||||
err := b.Core.FeatureActivationFlags.Initialize(ctx, b.Core.systemBarrierView)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize activation flags: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Contains the config for a global plugin reload
|
||||
type pluginReloadRequest struct {
|
||||
Type string `json:"type"` // Either 'plugins' or 'mounts'
|
||||
|
||||
Reference in New Issue
Block a user