diff --git a/api/sys_billing_test.go b/api/sys_billing_test.go index db5ccb9dd9..615ccd237f 100644 --- a/api/sys_billing_test.go +++ b/api/sys_billing_test.go @@ -143,7 +143,7 @@ const billingOverviewResponse = `{ { "metric_name": "auto_rotated_roles", "metric_data": { - "total": 10, + "total": 15, "metric_details": [ { "type": "aws_static", @@ -152,6 +152,10 @@ const billingOverviewResponse = `{ { "type": "azure_static", "count": 5 + }, + { + "type": "os_local_account_static", + "count": 5 } ] } @@ -203,10 +207,10 @@ const billingOverviewResponse = `{ "type": "totp", "count": 5 }, - { - "type": "kmse", - "count": 5 - } + { + "type": "kmse", + "count": 5 + } ] } }, diff --git a/changelog/_14467.txt b/changelog/_14467.txt new file mode 100644 index 0000000000..583a2b3e01 --- /dev/null +++ b/changelog/_14467.txt @@ -0,0 +1,3 @@ +```release-note:improvement +consumption-billing: Add billing tracking for OS Local Account static roles to support consumption-based billing metrics and high-water mark (HWM) tracking. +``` \ No newline at end of file diff --git a/helper/pluginconsts/plugin_consts.go b/helper/pluginconsts/plugin_consts.go index be534fafa7..9190b1a10d 100644 --- a/helper/pluginconsts/plugin_consts.go +++ b/helper/pluginconsts/plugin_consts.go @@ -65,6 +65,7 @@ const ( // SecretEngineDatabase is the entry type for all databases, i.e. this is the combined // database type for every database. SecretEngineDatabase = "database" + SecretEngineOS = "vault-plugin-secrets-os" ) // These DB consts match the type returned from database plugin's Type() method. diff --git a/vault/consumption_billing_test.go b/vault/consumption_billing_test.go index 380497cf81..d469659b96 100644 --- a/vault/consumption_billing_test.go +++ b/vault/consumption_billing_test.go @@ -178,6 +178,7 @@ func TestHandleEndOfMonthMetrics(t *testing.T) { GCPRolesets: 3, DatabaseDynamicRoles: 5, DatabaseStaticRoles: 7, + OSLocalAccountRoles: 9, }, localPathPrefix, month) core.storeMaxKvCountsLocked(context.Background(), 10, localPathPrefix, month) @@ -252,9 +253,10 @@ func TestDeleteExpiredBillingMetrics(t *testing.T) { for _, month := range []time.Time{monthToDelete, oldestRetainedMonth, currentMonth} { for _, pathPrefix := range []string{billing.ReplicatedPrefix, billing.LocalPrefix} { core.storeMaxRoleCountsLocked(context.Background(), &RoleCounts{ - AWSDynamicRoles: 5, - AWSStaticRoles: 10, - LDAPDynamicRoles: 3, + AWSDynamicRoles: 5, + AWSStaticRoles: 10, + LDAPDynamicRoles: 3, + OSLocalAccountRoles: 7, }, pathPrefix, month) core.storeMaxKvCountsLocked(context.Background(), 20, pathPrefix, month) core.storeTransitCallCountsLocked(context.Background(), 15, pathPrefix, month) @@ -407,6 +409,7 @@ func TestConsumptionBillingMetricsWorkerWithCustomClock(t *testing.T) { KubernetesDynamicRoles: 5, MongoDBAtlasDynamicRoles: 7, TerraformCloudDynamicRoles: 10, + OSLocalAccountRoles: 11, } verifyMonthlyBillingMetrics := func(month time.Time, localPathPrefix string) { diff --git a/vault/consumption_billing_testing_util.go b/vault/consumption_billing_testing_util.go index 0bdd81ba9c..60f14768e2 100644 --- a/vault/consumption_billing_testing_util.go +++ b/vault/consumption_billing_testing_util.go @@ -3,6 +3,14 @@ package vault +import ( + "context" + "encoding/json" + + "github.com/hashicorp/vault/sdk/framework" + "github.com/hashicorp/vault/sdk/logical" +) + func (c *Core) ResetInMemoryTransitDataProtectionCallCounts() { c.consumptionBillingLock.RLock() cb := c.consumptionBilling @@ -137,3 +145,106 @@ func (c *Core) SetInMemoryOidcCounts(tokenDuration float64) { cb.IdentityTokenUnits.OidcTokenDuration.Store(tokenDuration) } } + +// NewMockOSBackendFactory creates a mock OS backend factory for testing. +// The backend implements LIST operations for hosts and accounts to support +// billing enumeration testing. +func NewMockOSBackendFactory() logical.Factory { + return func(ctx context.Context, conf *logical.BackendConfig) (logical.Backend, error) { + b := &framework.Backend{ + BackendType: logical.TypeLogical, + Paths: []*framework.Path{ + { + Pattern: "hosts/?$", + Operations: map[logical.Operation]framework.OperationHandler{ + logical.ListOperation: &framework.PathOperation{ + Callback: func(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + // List all hosts from storage + hosts, err := req.Storage.List(ctx, "hosts/") + if err != nil { + return nil, err + } + return logical.ListResponse(hosts), nil + }, + }, + }, + }, + { + Pattern: "hosts/" + framework.GenericNameRegex("host") + "/accounts/?$", + Fields: map[string]*framework.FieldSchema{ + "host": { + Type: framework.TypeString, + Description: "Host name", + }, + }, + Operations: map[logical.Operation]framework.OperationHandler{ + logical.ListOperation: &framework.PathOperation{ + Callback: func(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + // Get the host name from the path + hostName := data.Get("host").(string) + + // Read the host entry from storage + entry, err := req.Storage.Get(ctx, "hosts/"+hostName) + if err != nil { + return nil, err + } + if entry == nil { + return logical.ListResponse([]string{}), nil + } + + // Parse the JSON to extract account names + var hostData map[string]interface{} + if err := json.Unmarshal(entry.Value, &hostData); err != nil { + return nil, err + } + + accounts := []string{} + if accountsMap, ok := hostData["accounts"].(map[string]interface{}); ok { + for accountName := range accountsMap { + accounts = append(accounts, accountName) + } + } + + return logical.ListResponse(accounts), nil + }, + }, + }, + }, + }, + } + if err := b.Setup(ctx, conf); err != nil { + return nil, err + } + return b, nil + } +} + +// CreateMockOSHost creates a mock OS host entry in storage with the specified accounts. +// This is a helper function for tests that need to populate OS backend storage. +func CreateMockOSHost(ctx context.Context, storage logical.Storage, hostName string, accountNames []string) error { + // Build the accounts map structure + accountsMap := make(map[string]interface{}) + for _, accountName := range accountNames { + accountsMap[accountName] = map[string]string{"username": "testuser"} + } + + // Create the host entry structure and marshal to JSON + hostMap := map[string]interface{}{"accounts": accountsMap} + value, err := json.Marshal(hostMap) + if err != nil { + return err + } + + // Create a mock host entry with accounts + hostEntry := &logical.StorageEntry{ + Key: "hosts/" + hostName, + Value: value, + } + return storage.Put(ctx, hostEntry) +} + +// DeleteMockOSHost deletes a mock OS host entry from storage. +// This is a helper function for tests that need to clean up OS backend storage. +func DeleteMockOSHost(ctx context.Context, storage logical.Storage, hostName string) error { + return storage.Delete(ctx, "hosts/"+hostName) +} diff --git a/vault/consumption_billing_util.go b/vault/consumption_billing_util.go index 29b1ec4902..9b4a5ab5fc 100644 --- a/vault/consumption_billing_util.go +++ b/vault/consumption_billing_util.go @@ -133,6 +133,7 @@ func combineRoleCounts(a, b *RoleCounts) *RoleCounts { a.KubernetesDynamicRoles + b.KubernetesDynamicRoles, a.MongoDBAtlasDynamicRoles + b.MongoDBAtlasDynamicRoles, a.TerraformCloudDynamicRoles + b.TerraformCloudDynamicRoles, + a.OSLocalAccountRoles + b.OSLocalAccountRoles, } } @@ -321,6 +322,7 @@ func (c *Core) updateMaxRoleCounts(ctx context.Context, currentRoleCounts *RoleC 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") + maxRoleCounts.OSLocalAccountRoles = c.compareCounts(currentRoleCounts.OSLocalAccountRoles, maxRoleCounts.OSLocalAccountRoles, "OS Local Account Static Roles") err = c.storeMaxRoleCountsLocked(ctx, maxRoleCounts, localPathPrefix, currentMonth) if err != nil { diff --git a/vault/core_metrics.go b/vault/core_metrics.go index 048791cf8b..dfd22334ae 100644 --- a/vault/core_metrics.go +++ b/vault/core_metrics.go @@ -865,6 +865,7 @@ type RoleCounts struct { KubernetesDynamicRoles int `json:"kubernetes_dynamic_roles"` MongoDBAtlasDynamicRoles int `json:"mongodb_atlas_dynamic_roles"` TerraformCloudDynamicRoles int `json:"terraformcloud_dynamic_roles"` + OSLocalAccountRoles int `json:"os_local_account_static_roles"` } type ManagedKeyCounts struct { @@ -890,18 +891,33 @@ func (c *Core) getRoleAndManagedKeyCountsInternal(includeLocal bool, includeRepl } resp, err := c.router.Route(ctx, listRequest) - if err != nil || resp == nil { + if err != nil || resp == nil || resp.Data == nil { return nil } + rawKeys, ok := resp.Data["keys"] - if !ok { + if !ok || rawKeys == nil { return nil } - keys, ok := rawKeys.([]string) - if !ok { + + // Type switch handles both the 'official' behavior and the 'generic' behavior + switch kt := rawKeys.(type) { + case []string: + // Existing plugins likely hit this path + return kt + case []interface{}: + // External/RPC plugins likely hit this path + keys := make([]string, 0, len(kt)) + for _, k := range kt { + if s, ok := k.(string); ok { + keys = append(keys, s) + } + } + return keys + default: + // If it's something totally weird, we still fail safely return nil } - return keys } c.mountsLock.RLock() @@ -1009,6 +1025,21 @@ func (c *Core) getRoleAndManagedKeyCountsInternal(includeLocal bool, includeRepl case pluginconsts.SecretEngineKeymgmt: keyCountPerEntry := apiList(entry, "key") keyCounts.KmseKeys += len(keyCountPerEntry) + + case pluginconsts.SecretEngineOS: + // OS plugin stores all accounts within each host entry + // List all hosts, then list accounts for each host + hosts := apiList(entry, "hosts/") + accountCount := 0 + for _, host := range hosts { + if host == "" { + continue + } + hostName := strings.TrimSuffix(host, "/") + accounts := apiList(entry, "hosts/"+hostName+"/accounts/") + accountCount += len(accounts) + } + roles.OSLocalAccountRoles += accountCount } } diff --git a/vault/logical_system_use_case_billing.go b/vault/logical_system_use_case_billing.go index f7d98d312c..d42ccc2f85 100644 --- a/vault/logical_system_use_case_billing.go +++ b/vault/logical_system_use_case_billing.go @@ -436,6 +436,7 @@ func buildAutoRotatedRolesMetric(counts *RoleCounts) map[string]interface{} { gcpImpersonatedCount := 0 ldapCount := 0 openldapCount := 0 + osLocalAccountCount := 0 if counts != nil { awsCount = counts.AWSStaticRoles @@ -445,9 +446,10 @@ func buildAutoRotatedRolesMetric(counts *RoleCounts) map[string]interface{} { gcpImpersonatedCount = counts.GCPImpersonatedAccounts ldapCount = counts.LDAPStaticRoles openldapCount = counts.OpenLDAPStaticRoles + osLocalAccountCount = counts.OSLocalAccountRoles total = awsCount + azureCount + databaseCount + gcpStaticCount + - gcpImpersonatedCount + ldapCount + openldapCount + gcpImpersonatedCount + ldapCount + openldapCount + osLocalAccountCount } details := []map[string]interface{}{ @@ -458,6 +460,7 @@ func buildAutoRotatedRolesMetric(counts *RoleCounts) map[string]interface{} { {"type": "gcp_impersonated", "count": gcpImpersonatedCount}, {"type": "ldap_static", "count": ldapCount}, {"type": "openldap_static", "count": openldapCount}, + {"type": "os_local_account_static", "count": osLocalAccountCount}, } return map[string]interface{}{ diff --git a/vault/logical_system_use_case_billing_test.go b/vault/logical_system_use_case_billing_test.go index 1d7d385214..7b4808fbd5 100644 --- a/vault/logical_system_use_case_billing_test.go +++ b/vault/logical_system_use_case_billing_test.go @@ -1225,16 +1225,16 @@ func TestSystemBackend_BillingOverview_AllMetricTypesPresent(t *testing.T) { require.Equal(t, 0, dynamicRolesDetails[i]["count"], "dynamic role count at index %d should be 0", i) } - // Verify auto_rotated_roles has all 7 types + // Verify auto_rotated_roles has all 8 types autoRotatedMetric, exists := metricsMap["auto_rotated_roles"] require.True(t, exists, "auto_rotated_roles metric should be present") autoRotatedData := autoRotatedMetric["metric_data"].(map[string]interface{}) autoRotatedDetails := autoRotatedData["metric_details"].([]map[string]interface{}) - require.Len(t, autoRotatedDetails, 7, "auto_rotated_roles should have 7 types") + require.Len(t, autoRotatedDetails, 8, "auto_rotated_roles should have 8 types") expectedAutoRotatedTypes := []string{ "aws_static", "azure_static", "database_static", "gcp_static", - "gcp_impersonated", "ldap_static", "openldap_static", + "gcp_impersonated", "ldap_static", "openldap_static", "os_local_account_static", } for i, expectedType := range expectedAutoRotatedTypes { require.Equal(t, expectedType, autoRotatedDetails[i]["type"], "auto-rotated role type at index %d should be %s", i, expectedType)