Port activation flags with dynamic registration (#29237)

This commit is contained in:
Bianca
2025-01-09 10:27:58 -03:00
committed by GitHub
parent 357b2949e3
commit ab4e8da697
8 changed files with 394 additions and 3 deletions

3
changelog/29237.txt Normal file
View 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.
```

View 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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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()...)

View 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,
},
}
}

View 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)
})
}

View File

@@ -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'