Vault-40972 Third Party Plugin Counts (#12010) (#12038)

* temp

* temp

* Modified the tests a bit

* One more modification

* Added two tests for official and third party plugins

* TEmp

* Added external test with primary and secondary

* Made some fixes based on comments

* Fixing a linter error

* One more fix

Co-authored-by: divyaac <divya.chandrasekaran@hashicorp.com>
This commit is contained in:
Vault Automation 2026-01-27 18:27:49 -05:00 committed by GitHub
parent 2e32e679d0
commit ab5ff72603
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 255 additions and 33 deletions

View file

@ -10,12 +10,13 @@ import (
)
const (
BillingSubPath = "billing/"
ReplicatedPrefix = "replicated/"
RoleHWMCountsHWM = "maxRoleCounts/"
KvHWMCountsHWM = "maxKvCounts/"
LocalPrefix = "local/"
BillingWriteInterval = 10 * time.Minute
BillingSubPath = "billing/"
ReplicatedPrefix = "replicated/"
RoleHWMCountsHWM = "maxRoleCounts/"
KvHWMCountsHWM = "maxKvCounts/"
LocalPrefix = "local/"
ThirdPartyPluginsPrefix = "thirdPartyPluginCounts/"
BillingWriteInterval = 10 * time.Minute
)
var BillingMonthStorageFormat = "%s%d/%02d/%s" // e.g replicated/2026/01/maxKvCounts/

View file

@ -104,5 +104,13 @@ func (c *Core) UpdateLocalHWMMetrics(ctx context.Context, currentMonth time.Time
} else {
c.logger.Info("updated local max kv counts", "prefix", billing.LocalPrefix, "currentMonth", currentMonth)
}
// The count of external plugins is per cluster, and we do not de-duplicate across clusters.
// For that reason, we will always store the count at the "local" prefix, so that the count does not
// get replicated.
if _, err := c.UpdateMaxThirdPartyPluginCounts(ctx, currentMonth); err != nil {
c.logger.Error("error updating local max external plugin counts", "error", err)
} else {
c.logger.Info("updated local max external plugin counts", "prefix", billing.LocalPrefix, "currentMonth", currentMonth)
}
return nil
}

View file

@ -12,6 +12,60 @@ import (
"github.com/hashicorp/vault/vault/billing"
)
func (c *Core) storeThirdPartyPluginCountsLocked(ctx context.Context, localPathPrefix string, currentMonth time.Time, thirdPartyPluginCounts int) error {
billingPath := billing.GetMonthlyBillingPath(localPathPrefix, currentMonth, billing.ThirdPartyPluginsPrefix)
entry := &logical.StorageEntry{
Key: billingPath,
Value: []byte(strconv.Itoa(thirdPartyPluginCounts)),
}
return c.GetBillingSubView().Put(ctx, entry)
}
func (c *Core) getStoredThirdPartyPluginCountsLocked(ctx context.Context, localPathPrefix string, currentMonth time.Time) (int, error) {
billingPath := billing.GetMonthlyBillingPath(localPathPrefix, currentMonth, billing.ThirdPartyPluginsPrefix)
entry, err := c.GetBillingSubView().Get(ctx, billingPath)
if err != nil {
return 0, err
}
if entry == nil {
return 0, nil
}
thirdPartyPluginCounts, err := strconv.Atoi(string(entry.Value))
if err != nil {
return 0, err
}
return thirdPartyPluginCounts, nil
}
// UpdateMaxThirdPartyPlugins updates the max number of third-party plugins for the given month.
// Note that this count is per cluster. It does NOT de-duplicate across clusters. For that reason,
// we will always store the count at the "local" prefix.
func (c *Core) UpdateMaxThirdPartyPluginCounts(ctx context.Context, currentMonth time.Time) (int, error) {
c.consumptionBilling.BillingStorageLock.Lock()
defer c.consumptionBilling.BillingStorageLock.Unlock()
previousThirdPartyPluginCounts, err := c.getStoredThirdPartyPluginCountsLocked(ctx, billing.LocalPrefix, currentMonth)
if err != nil {
return 0, err
}
currentThirdPartyPluginCounts, err := c.ListExternalSecretPlugins(ctx)
if err != nil {
return 0, err
}
maxCount := c.compareCounts(previousThirdPartyPluginCounts, len(currentThirdPartyPluginCounts), "Third-Party Plugins")
err = c.storeThirdPartyPluginCountsLocked(ctx, billing.LocalPrefix, currentMonth, maxCount)
if err != nil {
return 0, err
}
return maxCount, nil
}
func (c *Core) GetStoredThirdPartyPluginCounts(ctx context.Context, month time.Time) (int, error) {
c.consumptionBilling.BillingStorageLock.RLock()
defer c.consumptionBilling.BillingStorageLock.RUnlock()
return c.getStoredThirdPartyPluginCountsLocked(ctx, billing.LocalPrefix, month)
}
func combineRoleCounts(ctx context.Context, a, b *RoleCounts) *RoleCounts {
if a == nil && b == nil {
return &RoleCounts{}
@ -147,26 +201,26 @@ func (c *Core) UpdateMaxRoleCounts(ctx context.Context, localPathPrefix string,
if currentRoleCounts == nil {
currentRoleCounts = &RoleCounts{}
}
maxRoleCounts.AWSDynamicRoles = adjustCounts(currentRoleCounts.AWSDynamicRoles, maxRoleCounts.AWSDynamicRoles)
maxRoleCounts.AzureDynamicRoles = adjustCounts(currentRoleCounts.AzureDynamicRoles, maxRoleCounts.AzureDynamicRoles)
maxRoleCounts.AzureStaticRoles = adjustCounts(currentRoleCounts.AzureStaticRoles, maxRoleCounts.AzureStaticRoles)
maxRoleCounts.GCPRolesets = adjustCounts(currentRoleCounts.GCPRolesets, maxRoleCounts.GCPRolesets)
maxRoleCounts.AWSStaticRoles = adjustCounts(currentRoleCounts.AWSStaticRoles, maxRoleCounts.AWSStaticRoles)
maxRoleCounts.DatabaseDynamicRoles = adjustCounts(currentRoleCounts.DatabaseDynamicRoles, maxRoleCounts.DatabaseDynamicRoles)
maxRoleCounts.OpenLDAPStaticRoles = adjustCounts(currentRoleCounts.OpenLDAPStaticRoles, maxRoleCounts.OpenLDAPStaticRoles)
maxRoleCounts.OpenLDAPDynamicRoles = adjustCounts(currentRoleCounts.OpenLDAPDynamicRoles, maxRoleCounts.OpenLDAPDynamicRoles)
maxRoleCounts.LDAPDynamicRoles = adjustCounts(currentRoleCounts.LDAPDynamicRoles, maxRoleCounts.LDAPDynamicRoles)
maxRoleCounts.LDAPStaticRoles = adjustCounts(currentRoleCounts.LDAPStaticRoles, maxRoleCounts.LDAPStaticRoles)
maxRoleCounts.DatabaseStaticRoles = adjustCounts(currentRoleCounts.DatabaseStaticRoles, maxRoleCounts.DatabaseStaticRoles)
maxRoleCounts.GCPImpersonatedAccounts = adjustCounts(currentRoleCounts.GCPImpersonatedAccounts, maxRoleCounts.GCPImpersonatedAccounts)
maxRoleCounts.GCPStaticAccounts = adjustCounts(currentRoleCounts.GCPStaticAccounts, maxRoleCounts.GCPStaticAccounts)
maxRoleCounts.AlicloudDynamicRoles = adjustCounts(currentRoleCounts.AlicloudDynamicRoles, maxRoleCounts.AlicloudDynamicRoles)
maxRoleCounts.RabbitMQDynamicRoles = adjustCounts(currentRoleCounts.RabbitMQDynamicRoles, maxRoleCounts.RabbitMQDynamicRoles)
maxRoleCounts.ConsulDynamicRoles = adjustCounts(currentRoleCounts.ConsulDynamicRoles, maxRoleCounts.ConsulDynamicRoles)
maxRoleCounts.NomadDynamicRoles = adjustCounts(currentRoleCounts.NomadDynamicRoles, maxRoleCounts.NomadDynamicRoles)
maxRoleCounts.KubernetesDynamicRoles = adjustCounts(currentRoleCounts.KubernetesDynamicRoles, maxRoleCounts.KubernetesDynamicRoles)
maxRoleCounts.MongoDBAtlasDynamicRoles = adjustCounts(currentRoleCounts.MongoDBAtlasDynamicRoles, maxRoleCounts.MongoDBAtlasDynamicRoles)
maxRoleCounts.TerraformCloudDynamicRoles = adjustCounts(currentRoleCounts.TerraformCloudDynamicRoles, maxRoleCounts.TerraformCloudDynamicRoles)
maxRoleCounts.AWSDynamicRoles = c.compareCounts(currentRoleCounts.AWSDynamicRoles, maxRoleCounts.AWSDynamicRoles, "AWS Dynamic Roles")
maxRoleCounts.AzureDynamicRoles = c.compareCounts(currentRoleCounts.AzureDynamicRoles, maxRoleCounts.AzureDynamicRoles, "Azure Dynamic Roles")
maxRoleCounts.AzureStaticRoles = c.compareCounts(currentRoleCounts.AzureStaticRoles, maxRoleCounts.AzureStaticRoles, "Azure Static Roles")
maxRoleCounts.GCPRolesets = c.compareCounts(currentRoleCounts.GCPRolesets, maxRoleCounts.GCPRolesets, "GCP Rolesets")
maxRoleCounts.AWSStaticRoles = c.compareCounts(currentRoleCounts.AWSStaticRoles, maxRoleCounts.AWSStaticRoles, "AWS Static Roles")
maxRoleCounts.DatabaseDynamicRoles = c.compareCounts(currentRoleCounts.DatabaseDynamicRoles, maxRoleCounts.DatabaseDynamicRoles, "Database Dynamic Roles")
maxRoleCounts.OpenLDAPStaticRoles = c.compareCounts(currentRoleCounts.OpenLDAPStaticRoles, maxRoleCounts.OpenLDAPStaticRoles, "OpenLDAP Static Roles")
maxRoleCounts.OpenLDAPDynamicRoles = c.compareCounts(currentRoleCounts.OpenLDAPDynamicRoles, maxRoleCounts.OpenLDAPDynamicRoles, "OpenLDAP Dynamic Roles")
maxRoleCounts.LDAPDynamicRoles = c.compareCounts(currentRoleCounts.LDAPDynamicRoles, maxRoleCounts.LDAPDynamicRoles, "LDAP Dynamic Roles")
maxRoleCounts.LDAPStaticRoles = c.compareCounts(currentRoleCounts.LDAPStaticRoles, maxRoleCounts.LDAPStaticRoles, "LDAP Static Roles")
maxRoleCounts.DatabaseStaticRoles = c.compareCounts(currentRoleCounts.DatabaseStaticRoles, maxRoleCounts.DatabaseStaticRoles, "Database Static Roles")
maxRoleCounts.GCPImpersonatedAccounts = c.compareCounts(currentRoleCounts.GCPImpersonatedAccounts, maxRoleCounts.GCPImpersonatedAccounts, "GCPImpersonated Accounts")
maxRoleCounts.GCPStaticAccounts = c.compareCounts(currentRoleCounts.GCPStaticAccounts, maxRoleCounts.GCPStaticAccounts, "GCP Static Accounts")
maxRoleCounts.AlicloudDynamicRoles = c.compareCounts(currentRoleCounts.AlicloudDynamicRoles, maxRoleCounts.AlicloudDynamicRoles, "Alicloud Dynamic Roles")
maxRoleCounts.RabbitMQDynamicRoles = c.compareCounts(currentRoleCounts.RabbitMQDynamicRoles, maxRoleCounts.RabbitMQDynamicRoles, "RabbitMQ Dynamic Roles")
maxRoleCounts.ConsulDynamicRoles = c.compareCounts(currentRoleCounts.ConsulDynamicRoles, maxRoleCounts.ConsulDynamicRoles, "Consul Dynamic Roles")
maxRoleCounts.NomadDynamicRoles = c.compareCounts(currentRoleCounts.NomadDynamicRoles, maxRoleCounts.NomadDynamicRoles, "Nomad Dynamic Roles")
maxRoleCounts.KubernetesDynamicRoles = c.compareCounts(currentRoleCounts.KubernetesDynamicRoles, maxRoleCounts.KubernetesDynamicRoles, "Kubernetes Dynamic Roles")
maxRoleCounts.MongoDBAtlasDynamicRoles = c.compareCounts(currentRoleCounts.MongoDBAtlasDynamicRoles, maxRoleCounts.MongoDBAtlasDynamicRoles, "MongoDB Atlas Dynamic Roles")
maxRoleCounts.TerraformCloudDynamicRoles = c.compareCounts(currentRoleCounts.TerraformCloudDynamicRoles, maxRoleCounts.TerraformCloudDynamicRoles, "Terraform Cloud Dynamic Roles")
err = c.storeMaxRoleCountsLocked(ctx, maxRoleCounts, localPathPrefix, currentMonth)
if err != nil {
@ -198,13 +252,14 @@ func (c *Core) getStoredRoleCountsLocked(ctx context.Context, localPathPrefix st
return maxRoleCounts, nil
}
func (c *Core) compareCounts(current, previous int, metricName string) int {
if previous > current {
return previous
}
c.logger.Debug("updating max counts", "metricName", metricName, "previous", previous, "current", current)
return current
}
func (c *Core) GetBillingSubView() *BarrierView {
return c.systemBarrierView.SubView(billing.BillingSubPath)
}
func adjustCounts(currentCount int, maxCount int) int {
if currentCount > maxCount {
return currentCount
}
return maxCount
}

View file

@ -963,3 +963,70 @@ func (c *Core) GetKvUsageMetricsByNamespace(ctx context.Context, kvVersion strin
return results, nil
}
// ListExternalSecretPlugins returns the enabled secret engines
// that are not builtin and not official-tier.
//
// This is useful for identifying "third-party" secrets mounts (e.g. community or
// partner tier external plugins) while excluding builtins and official HashiCorp
// plugins.
// Note: This will include all mounts that have been built externally (even if they are
// Hashicorp owned). This will happen if the plugin was built from a Github repo or from an
// artifact.
func (c *Core) ListExternalSecretPlugins(ctx context.Context) ([]*MountEntry, error) {
if c == nil || c.pluginCatalog == nil {
return nil, fmt.Errorf("core or plugin catalog is nil")
}
mounts, err := c.ListMounts()
if err != nil {
return nil, fmt.Errorf("error listing mounts: %w", err)
}
seen := make(map[string]struct{})
var result []*MountEntry
for _, entry := range mounts {
if entry == nil {
continue
}
// Only secrets-engine mounts live in the mounts table. Exclude the known
// non-secrets mounts and database mounts (PluginTypeDatabase).
if entry.Table != mountTableType {
continue
}
pluginName := entry.Type
if pluginName == mountTypePlugin && entry.Config.PluginName != "" {
pluginName = entry.Config.PluginName
}
if pluginName == "" {
continue
}
pluginVersion := entry.RunningVersion
// De-dupe: multiple mounts can point at the same underlying plugin+version.
// We want to charge for each unique plugin+version pair.
key := pluginName + "\x00" + pluginVersion
if _, ok := seen[key]; ok {
continue
}
runner, err := c.pluginCatalog.Get(ctx, pluginName, consts.PluginTypeSecrets, pluginVersion)
if err != nil || runner == nil {
// If we can't resolve the plugin runner (e.g. missing catalog entry),
// conservatively skip it rather than risk misclassifying it.
continue
}
if runner.Builtin || runner.Tier == consts.PluginTierOfficial {
continue
}
result = append(result, entry)
seen[key] = struct{}{}
}
return result, nil
}

View file

@ -6,7 +6,9 @@ package vault
import (
"context"
"encoding/base64"
"encoding/hex"
"errors"
"path/filepath"
"sort"
"strings"
"testing"
@ -14,7 +16,12 @@ import (
"github.com/armon/go-metrics"
logicalKv "github.com/hashicorp/vault-plugin-secrets-kv"
"github.com/hashicorp/vault/builtin/credential/userpass"
"github.com/hashicorp/vault/helper/namespace"
"github.com/hashicorp/vault/helper/pluginconsts"
"github.com/hashicorp/vault/helper/testhelpers/pluginhelpers"
sdkconsts "github.com/hashicorp/vault/sdk/helper/consts"
sdkpluginutil "github.com/hashicorp/vault/sdk/helper/pluginutil"
"github.com/hashicorp/vault/sdk/logical"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -467,3 +474,87 @@ func TestCoreMetrics_AvailablePolicies(t *testing.T) {
})
}
}
// TestCore_ListExternalSecretPlugins tests that we correctly list the external secret plugins enabled
// on Vault. We will register a few builtin plugins, a few external plugins with different versions
// and verify that we return a count of the unique plugin name+version pairs. We should exclude any builtin
// plugins.
func TestCore_ListExternalSecretPlugins(t *testing.T) {
pluginDir, err := filepath.EvalSymlinks(t.TempDir())
require.NoError(t, err)
coreConfig := &CoreConfig{
PluginDirectory: pluginDir,
CredentialBackends: map[string]logical.Factory{
pluginconsts.AuthTypeUserpass: userpass.Factory,
},
}
core, _, root := TestCoreUnsealedWithConfig(t, coreConfig)
// Register a few plugins in the plugin catalog.
ctx := namespace.RootContext(context.Background())
// Enable a builtin credential plugin and an additional builtin kv engine
// mount. Neither should affect the results from ListNonOfficialExternalSecretsMounts.
req := logical.TestRequest(t, logical.CreateOperation, "sys/auth/userpass")
req.Data["type"] = pluginconsts.AuthTypeUserpass
req.ClientToken = root
_, err = core.HandleRequest(ctx, req)
require.NoError(t, err)
req = logical.TestRequest(t, logical.CreateOperation, "sys/mounts/kv2")
req.Data["type"] = "kv"
req.ClientToken = root
_, err = core.HandleRequest(ctx, req)
require.NoError(t, err)
// Creates a secret plugin of the same name, with different versions
secretPluginV1 := pluginhelpers.CompilePlugin(t, sdkconsts.PluginTypeSecrets, "v1.0.0", pluginDir)
secretPluginV2 := pluginhelpers.CompilePlugin(t, sdkconsts.PluginTypeSecrets, "v2.0.0", pluginDir)
secretPluginV3 := pluginhelpers.CompilePlugin(t, sdkconsts.PluginTypeSecrets, "v3.0.0", pluginDir)
dbPlugin := pluginhelpers.CompilePlugin(t, sdkconsts.PluginTypeDatabase, "v1.0.0", pluginDir)
registerPlugin := func(p pluginhelpers.TestPlugin) {
t.Helper()
shaBytes, err := hex.DecodeString(p.Sha256)
require.NoError(t, err)
require.NoError(t, core.pluginCatalog.Set(ctx, sdkpluginutil.SetPluginInput{
Name: p.Name,
Type: p.Typ,
Version: p.Version,
Command: p.FileName,
Sha256: shaBytes,
}))
}
registerPlugin(secretPluginV1)
registerPlugin(secretPluginV2)
registerPlugin(secretPluginV3)
registerPlugin(dbPlugin)
// Add mounts: include duplicates, official-tier, builtin, and non-secrets.
core.mountsLock.Lock()
core.mounts.Entries = append(core.mounts.Entries,
// Duplicate mounts: same plugin+version should only be counted once.
&MountEntry{Table: mountTableType, Path: "dup-a/", Type: secretPluginV2.Name, RunningVersion: secretPluginV2.Version},
&MountEntry{Table: mountTableType, Path: "dup-b/", Type: secretPluginV2.Name, RunningVersion: secretPluginV2.Version},
&MountEntry{Table: mountTableType, Path: "different-version/", Type: secretPluginV3.Name, RunningVersion: secretPluginV3.Version},
&MountEntry{Table: mountTableType, Path: "another-version/", Type: secretPluginV1.Name, RunningVersion: secretPluginV1.Version},
// Non-secrets plugin mounted in the mounts table: should be excluded.
&MountEntry{Table: mountTableType, Path: "db/", Type: dbPlugin.Name, RunningVersion: dbPlugin.Version},
)
core.mountsLock.Unlock()
got, err := core.ListExternalSecretPlugins(ctx)
require.NoError(t, err)
// Expect only the non-builtin external secrets plugin v2, v3 and v1.
require.Len(t, got, 3)
require.Equal(t, secretPluginV2.Name, got[0].Type)
require.Equal(t, secretPluginV2.Version, got[0].RunningVersion)
require.Equal(t, secretPluginV3.Name, got[1].Type)
require.Equal(t, secretPluginV3.Version, got[1].RunningVersion)
require.Equal(t, secretPluginV1.Name, got[2].Type)
require.Equal(t, secretPluginV1.Version, got[2].RunningVersion)
}