mirror of
				https://github.com/optim-enterprises-bv/vault.git
				synced 2025-10-31 02:28:09 +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
	 Bianca
					Bianca