diff --git a/vault/billing/billing_counts.go b/vault/billing/billing_counts.go index c206b4cd23..c81702076a 100644 --- a/vault/billing/billing_counts.go +++ b/vault/billing/billing_counts.go @@ -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/ diff --git a/vault/consumption_billing.go b/vault/consumption_billing.go index b2955f645f..bccf164beb 100644 --- a/vault/consumption_billing.go +++ b/vault/consumption_billing.go @@ -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 } diff --git a/vault/consumption_billing_util.go b/vault/consumption_billing_util.go index 6e4b1c6fb1..41398e7d85 100644 --- a/vault/consumption_billing_util.go +++ b/vault/consumption_billing_util.go @@ -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 -} diff --git a/vault/core_metrics.go b/vault/core_metrics.go index d2899deb64..40b6302d77 100644 --- a/vault/core_metrics.go +++ b/vault/core_metrics.go @@ -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 +} diff --git a/vault/core_metrics_test.go b/vault/core_metrics_test.go index 8bf0e59253..3a23e66820 100644 --- a/vault/core_metrics_test.go +++ b/vault/core_metrics_test.go @@ -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) +}