Merge remote-tracking branch 'remotes/from/ce/release/2.x.x' into release/2.x.x

This commit is contained in:
hc-github-team-secure-vault-core 2026-05-12 18:32:33 +00:00
commit 2c4853d23b
9 changed files with 175 additions and 17 deletions

View file

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

3
changelog/_14467.txt Normal file
View file

@ -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.
```

View file

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

View file

@ -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) {

View file

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

View file

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

View file

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

View file

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

View file

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