diff --git a/changelog/29237.txt b/changelog/29237.txt new file mode 100644 index 000000000000..796a8a5bc4a0 --- /dev/null +++ b/changelog/29237.txt @@ -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. +``` diff --git a/helper/activationflags/activation_flags.go b/helper/activationflags/activation_flags.go new file mode 100644 index 000000000000..fb031cae8399 --- /dev/null +++ b/helper/activationflags/activation_flags.go @@ -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 +} diff --git a/vault/core.go b/vault/core.go index bdae94d8238f..56c267ea259d 100644 --- a/vault/core.go +++ b/vault/core.go @@ -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 } diff --git a/vault/core_util.go b/vault/core_util.go index aba50d415b91..549971b9e49e 100644 --- a/vault/core_util.go +++ b/vault/core_util.go @@ -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 } diff --git a/vault/logical_system.go b/vault/logical_system.go index 3e328fd1dc26..49dfc0c6864d 100644 --- a/vault/logical_system.go +++ b/vault/logical_system.go @@ -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()...) diff --git a/vault/logical_system_activation_flags.go b/vault/logical_system_activation_flags.go new file mode 100644 index 000000000000..596f9e6f128c --- /dev/null +++ b/vault/logical_system_activation_flags.go @@ -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 //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//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, + }, + } +} diff --git a/vault/logical_system_activation_flags_test.go b/vault/logical_system_activation_flags_test.go new file mode 100644 index 000000000000..aa2e17293f9d --- /dev/null +++ b/vault/logical_system_activation_flags_test.go @@ -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) + }) +} diff --git a/vault/logical_system_helpers.go b/vault/logical_system_helpers.go index 9deea02b5a02..79c30bad7f3e 100644 --- a/vault/logical_system_helpers.go +++ b/vault/logical_system_helpers.go @@ -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'