From c0e8de6ed9f25bfb477e946f2fe666c8f8fdf9ee Mon Sep 17 00:00:00 2001 From: Vault Automation Date: Fri, 20 Feb 2026 08:17:23 -0500 Subject: [PATCH] VAULT-41572: hookup billing overview endpoint (#12328) (#12451) * hookup the new path to the system backend * add API client method for the new endpoint * add test for the api method structure * adjust the path implementation to capture all so-far added metrics * add tests * add go docs to the tests * add more tests * feedback: add go doc to test * feedback: use require in tests * fix the api: use parse secret method to properly parse response, add mapstructure definitions to api structs * feedback: fix api method test by using mock api response * Update vault/logical_system_use_case_billing.go * Update vault/logical_system_use_case_billing.go * feedback: refactor build month data method into methods that collect data separately * feedback: make update_counts parameter a new user set field for the endpoint * feedback: remove basic comments * update logic around determining updated at field * fix tests: add actual data and fix some assertions * separate out ent only features from the neutral test file * add a new test file to test ent only features * call one update method to update all metrics * add external tests for the endpoint * add a changelog * feedback: rename update_counts parameter to refresh_data * feedback: fix determination of updated_at field * feedback: convert created methods into core methods from system backend methods * Update changelog/12328.txt * feedback: create a new atomic tracker of last updated time for the metrics update and use that in the endpoint * add unit tests to test updated_at * always build metrics, even when the values are 0 * add test coverage to verify metrics still exist in the response with zero values even when there are no billing resources * feedback: remove manual check of root namespace - rely on system backend to enforce root namespace restriction * remove namespace test from oss test file * properly accomodate new totp metric * add pki cert and totp to endpoint response, add test coverage * rename changelog file * linters * change changelog type to improvement, make the file CE and ENT * test ent changelog * fix some tests after the addition of totp and pki * add test coverage for the new metrics in external and api tests * make changelog CE file --------- Co-authored-by: Amir Aslamov Co-authored-by: Violet Hynes Co-authored-by: divyaac --- api/sys_billing.go | 70 ++ api/sys_billing_test.go | 148 ++++ changelog/12328.txt | 3 + vault/billing/billing_counts.go | 5 + vault/consumption_billing.go | 1 + vault/external_tests/api/sys_billing_test.go | 209 ++++++ vault/logical_system.go | 1 + vault/logical_system_use_case_billing.go | 544 +++++++++++---- ...ogical_system_use_case_billing_pki_test.go | 10 +- vault/logical_system_use_case_billing_test.go | 658 ++++++++++++++++++ 10 files changed, 1522 insertions(+), 127 deletions(-) create mode 100644 api/sys_billing.go create mode 100644 api/sys_billing_test.go create mode 100644 changelog/12328.txt create mode 100644 vault/external_tests/api/sys_billing_test.go create mode 100644 vault/logical_system_use_case_billing_test.go diff --git a/api/sys_billing.go b/api/sys_billing.go new file mode 100644 index 0000000000..e7c137cc80 --- /dev/null +++ b/api/sys_billing.go @@ -0,0 +1,70 @@ +// Copyright IBM Corp. 2016, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package api + +import ( + "context" + "errors" + "net/http" + + "github.com/mitchellh/mapstructure" +) + +// BillingOverview returns billing metrics for the current and previous month. +// If updateCounts is true, the current month's counts will be updated before returning. +// This is an expensive operation that holds locks and should be used sparingly. +func (c *Sys) BillingOverview(updateCounts bool) (*BillingOverviewResponse, error) { + return c.BillingOverviewWithContext(context.Background(), updateCounts) +} + +// BillingOverviewWithContext returns billing metrics for the current and previous month. +func (c *Sys) BillingOverviewWithContext(ctx context.Context, updateCounts bool) (*BillingOverviewResponse, error) { + ctx, cancelFunc := c.c.withConfiguredTimeout(ctx) + defer cancelFunc() + + r := c.c.NewRequest(http.MethodGet, "/v1/sys/billing/overview") + if updateCounts { + r.Params.Set("refresh_data", "true") + } + + resp, err := c.c.rawRequestWithContext(ctx, r) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + secret, err := ParseSecret(resp.Body) + if err != nil { + return nil, err + } + if secret == nil || secret.Data == nil { + return nil, errors.New("data from server response is empty") + } + + var result BillingOverviewResponse + err = mapstructure.Decode(secret.Data, &result) + if err != nil { + return nil, err + } + + return &result, nil +} + +// BillingOverviewResponse represents the response from the billing overview endpoint. +type BillingOverviewResponse struct { + Months []BillingMonth `json:"months" mapstructure:"months"` +} + +// BillingMonth represents billing data for a single month. +type BillingMonth struct { + Month string `json:"month" mapstructure:"month"` + UpdatedAt string `json:"updated_at" mapstructure:"updated_at"` + UsageMetrics []UsageMetric `json:"usage_metrics" mapstructure:"usage_metrics"` +} + +// UsageMetric represents a single usage metric with its data. +type UsageMetric struct { + MetricName string `json:"metric_name" mapstructure:"metric_name"` + MetricData map[string]interface{} `json:"metric_data" mapstructure:"metric_data"` +} diff --git a/api/sys_billing_test.go b/api/sys_billing_test.go new file mode 100644 index 0000000000..04ae7ac4e7 --- /dev/null +++ b/api/sys_billing_test.go @@ -0,0 +1,148 @@ +// Copyright IBM Corp. 2016, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package api + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestSys_BillingOverview tests the BillingOverview API client method and structure of the response +func TestSys_BillingOverview(t *testing.T) { + mockVaultServer := httptest.NewServer(http.HandlerFunc(mockVaultBillingHandler)) + defer mockVaultServer.Close() + + // Create API client pointing to mock server + cfg := DefaultConfig() + cfg.Address = mockVaultServer.URL + client, err := NewClient(cfg) + require.NoError(t, err) + + resp, err := client.Sys().BillingOverview(false) + require.NoError(t, err) + require.NotNil(t, resp) + + // Verify we have 2 months (current and previous) + require.Len(t, resp.Months, 2) + + // Verify current month structure + currentMonth := resp.Months[0] + require.Equal(t, "2026-01", currentMonth.Month) + require.Equal(t, "2026-01-14T10:49:00Z", currentMonth.UpdatedAt) + require.Len(t, currentMonth.UsageMetrics, 4) + + // Verify static_secrets metric + staticSecretsMetric := currentMonth.UsageMetrics[0] + require.Equal(t, "static_secrets", staticSecretsMetric.MetricName) + require.NotNil(t, staticSecretsMetric.MetricData) + require.Contains(t, staticSecretsMetric.MetricData, "total") + require.Contains(t, staticSecretsMetric.MetricData, "metric_details") + + // Verify kmip metric + kmipMetric := currentMonth.UsageMetrics[1] + require.Equal(t, "kmip", kmipMetric.MetricName) + require.NotNil(t, kmipMetric.MetricData) + require.Contains(t, kmipMetric.MetricData, "used_in_month") + require.Equal(t, true, kmipMetric.MetricData["used_in_month"]) + + // Verify pki_units metric + pkiMetric := currentMonth.UsageMetrics[2] + require.Equal(t, "pki_units", pkiMetric.MetricName) + require.NotNil(t, pkiMetric.MetricData) + require.Contains(t, pkiMetric.MetricData, "total") + + // Verify managed_keys metric + managedKeysMetric := currentMonth.UsageMetrics[3] + require.Equal(t, "managed_keys", managedKeysMetric.MetricName) + require.NotNil(t, managedKeysMetric.MetricData) + require.Contains(t, managedKeysMetric.MetricData, "total") + require.Contains(t, managedKeysMetric.MetricData, "metric_details") + + // Verify previous month structure + previousMonth := resp.Months[1] + require.Equal(t, "2025-12", previousMonth.Month) + require.Equal(t, "2025-12-31T23:59:59Z", previousMonth.UpdatedAt) + require.Len(t, previousMonth.UsageMetrics, 1) + + // Verify external_plugins metric in previous month + externalPluginsMetric := previousMonth.UsageMetrics[0] + require.Equal(t, "external_plugins", externalPluginsMetric.MetricName) + require.NotNil(t, externalPluginsMetric.MetricData) + require.Contains(t, externalPluginsMetric.MetricData, "total") +} + +func mockVaultBillingHandler(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(billingOverviewResponse)) +} + +const billingOverviewResponse = `{ + "request_id": "d8d3e6e1-4e5f-6a7b-8c9d-0e1f2a3b4c5d", + "lease_id": "", + "renewable": false, + "lease_duration": 0, + "data": { + "months": [ + { + "month": "2026-01", + "updated_at": "2026-01-14T10:49:00Z", + "usage_metrics": [ + { + "metric_name": "static_secrets", + "metric_data": { + "total": 10, + "metric_details": [ + { + "type": "kv", + "count": 10 + } + ] + } + }, + { + "metric_name": "kmip", + "metric_data": { + "used_in_month": true + } + }, + { + "metric_name": "pki_units", + "metric_data": { + "total": 100.5 + } + }, + { + "metric_name": "managed_keys", + "metric_data": { + "total": 5, + "metric_details": [ + { + "type": "totp", + "count": 5 + } + ] + } + } + ] + }, + { + "month": "2025-12", + "updated_at": "2025-12-31T23:59:59Z", + "usage_metrics": [ + { + "metric_name": "external_plugins", + "metric_data": { + "total": 5 + } + } + ] + } + ] + }, + "wrap_info": null, + "warnings": null, + "auth": null +}` diff --git a/changelog/12328.txt b/changelog/12328.txt new file mode 100644 index 0000000000..012e1bf17c --- /dev/null +++ b/changelog/12328.txt @@ -0,0 +1,3 @@ +```release-note:improvement +consumption-billing: Adds a new `sys/billing/overview` endpoint that returns current and previous month consumption billing metrics. Accessible via API client method `client.Sys().BillingOverview()`. +``` \ No newline at end of file diff --git a/vault/billing/billing_counts.go b/vault/billing/billing_counts.go index a7b3365d8e..66959c674d 100644 --- a/vault/billing/billing_counts.go +++ b/vault/billing/billing_counts.go @@ -48,6 +48,11 @@ type ConsumptionBilling struct { // KmipSeenEnabledThisMonth tracks whether KMIP has been enabled during the current billing month. // This is used to avoid scanning all mounts every 10 minutes for KMIP billing detection. KmipSeenEnabledThisMonth atomic.Bool + + // LastMetricsUpdate tracks when billing metrics were last updated, either by the background worker + // or by the billing endpoint API call. This timestamp is used by the billing overview endpoint to + // indicate data freshness. + LastMetricsUpdate atomic.Value } type BillingConfig struct { diff --git a/vault/consumption_billing.go b/vault/consumption_billing.go index 4a72362bc3..e26417e9bd 100644 --- a/vault/consumption_billing.go +++ b/vault/consumption_billing.go @@ -180,6 +180,7 @@ func (c *Core) updateBillingMetrics(ctx context.Context, currentMonth time.Time) c.logger.Info("updated cluster data protection call counts", "prefix", billing.LocalPrefix, "currentMonth", currentMonth) } + c.consumptionBilling.LastMetricsUpdate.Store(time.Now().UTC()) } return nil } diff --git a/vault/external_tests/api/sys_billing_test.go b/vault/external_tests/api/sys_billing_test.go new file mode 100644 index 0000000000..11078b8db8 --- /dev/null +++ b/vault/external_tests/api/sys_billing_test.go @@ -0,0 +1,209 @@ +// Copyright IBM Corp. 2016, 2025 +// SPDX-License-Identifier: BUSL-1.1 + +package api + +import ( + "context" + "encoding/json" + "testing" + + "github.com/hashicorp/vault/api" + logicalAws "github.com/hashicorp/vault/builtin/logical/aws" + logicalDatabase "github.com/hashicorp/vault/builtin/logical/database" + logicalTransit "github.com/hashicorp/vault/builtin/logical/transit" + "github.com/hashicorp/vault/helper/pluginconsts" + "github.com/hashicorp/vault/helper/testhelpers/minimal" + "github.com/hashicorp/vault/sdk/logical" + "github.com/hashicorp/vault/vault" + "github.com/stretchr/testify/require" +) + +// Test_BillingOverview tests that the BillingOverview API method works correctly +func Test_BillingOverview(t *testing.T) { + t.Parallel() + + coreConfig := &vault.CoreConfig{ + LogicalBackends: map[string]logical.Factory{ + pluginconsts.SecretEngineAWS: logicalAws.Factory, + pluginconsts.SecretEngineDatabase: logicalDatabase.Factory, + pluginconsts.SecretEngineTransit: logicalTransit.Factory, + }, + } + + cluster := minimal.NewTestSoloCluster(t, coreConfig) + client := cluster.Cores[0].Client + + // Mount AWS for dynamic roles + err := client.Sys().Mount("aws", &api.MountInput{ + Type: "aws", + }) + require.NoError(t, err) + + // Create an AWS role + _, err = client.Logical().Write("aws/roles/test-role", map[string]interface{}{ + "credential_type": "iam_user", + "policy_document": `{"Version": "2012-10-17","Statement": [{"Effect": "Allow","Action": "ec2:*","Resource": "*"}]}`, + }) + require.NoError(t, err) + + // Mount Database for dynamic roles + err = client.Sys().Mount("database", &api.MountInput{ + Type: "database", + }) + require.NoError(t, err) + + // Create a database role + _, err = client.Logical().Write("database/roles/test-db-role", map[string]interface{}{ + "db_name": "test-db", + "creation_statements": []string{"CREATE USER '{{name}}'@'%' IDENTIFIED BY '{{password}}';"}, + "default_ttl": "1h", + "max_ttl": "24h", + }) + require.NoError(t, err) + + // Mount KV for static secrets + err = client.Sys().Mount("kv-v2", &api.MountInput{ + Type: "kv-v2", + }) + require.NoError(t, err) + + // Create KV secrets + secretData := map[string]interface{}{ + "foo": "bar", + } + _, err = client.KVv2("kv-v2").Put(context.Background(), "secret1", secretData) + require.NoError(t, err) + + resp, err := client.Sys().BillingOverview(true) + require.NoError(t, err) + require.NotNil(t, resp) + + // Validate response structure + require.NotNil(t, resp.Months) + require.Len(t, resp.Months, 2, "should have current and previous month") + + // Check current month data + currentMonth := resp.Months[0] + require.NotEmpty(t, currentMonth.Month) + require.NotEmpty(t, currentMonth.UpdatedAt) + require.NotNil(t, currentMonth.UsageMetrics) + + // Verify we have some metrics + require.NotEmpty(t, currentMonth.UsageMetrics, "should have usage metrics after creating test data") + + // Validate that metrics have the expected structure + for _, metric := range currentMonth.UsageMetrics { + require.NotEmpty(t, metric.MetricName) + require.NotNil(t, metric.MetricData) + require.NotEmpty(t, metric.MetricData) + + if total, ok := metric.MetricData["total"]; ok { + _, ok := total.(json.Number) + require.True(t, ok, "total should be json.Number for metric %s", metric.MetricName) + } + + if details, ok := metric.MetricData["metric_details"]; ok { + _, ok := details.([]interface{}) + require.True(t, ok, "metric_details should be []interface{} for metric %s", metric.MetricName) + } + } +} + +// Test_BillingOverview_WithoutUpdateCounts tests that BillingOverview works with updateCounts=false +func Test_BillingOverview_WithoutUpdateCounts(t *testing.T) { + t.Parallel() + + coreConfig := &vault.CoreConfig{ + LogicalBackends: map[string]logical.Factory{ + pluginconsts.SecretEngineAWS: logicalAws.Factory, + }, + } + + cluster := minimal.NewTestSoloCluster(t, coreConfig) + client := cluster.Cores[0].Client + + // Call BillingOverview without updating counts + resp, err := client.Sys().BillingOverview(false) + require.NoError(t, err) + require.NotNil(t, resp) + + // Validate basic response structure + require.NotNil(t, resp.Months) + require.Len(t, resp.Months, 2, "should have current and previous month") + + // Check that months are properly formatted + for _, month := range resp.Months { + require.NotEmpty(t, month.Month) + require.NotEmpty(t, month.UpdatedAt) + require.NotNil(t, month.UsageMetrics) + } +} + +// Test_BillingOverview_EmptyCluster tests BillingOverview on a cluster with no mounts. +// Verifies that all metrics are present with zero values +func Test_BillingOverview_EmptyCluster(t *testing.T) { + t.Parallel() + + cluster := minimal.NewTestSoloCluster(t, nil) + client := cluster.Cores[0].Client + + resp, err := client.Sys().BillingOverview(true) + require.NoError(t, err) + require.NotNil(t, resp) + + require.NotNil(t, resp.Months) + require.Len(t, resp.Months, 2) + + currentMonth := resp.Months[0] + require.NotEmpty(t, currentMonth.Month) + require.NotEmpty(t, currentMonth.UpdatedAt) + require.NotNil(t, currentMonth.UsageMetrics) + + // Verify all expected metrics are present even with no usage + require.NotEmpty(t, currentMonth.UsageMetrics, "should have all metrics even with zero values") + + expectedMetrics := map[string]bool{ + "static_secrets": false, + "dynamic_roles": false, + "auto_rotated_roles": false, + "kmip": false, + "external_plugins": false, + "data_protection_calls": false, + "pki_units": false, + "managed_keys": false, + } + + for _, metric := range currentMonth.UsageMetrics { + require.NotEmpty(t, metric.MetricName) + require.Contains(t, expectedMetrics, metric.MetricName, "unexpected metric: %s", metric.MetricName) + expectedMetrics[metric.MetricName] = true + require.NotNil(t, metric.MetricData) + } + + // Verify all expected metrics were found + for metricName, found := range expectedMetrics { + require.True(t, found, "metric %s should be present", metricName) + } +} + +// Test_BillingOverview_MonthFormat tests that month strings are in correct format +func Test_BillingOverview_MonthFormat(t *testing.T) { + t.Parallel() + + cluster := minimal.NewTestSoloCluster(t, nil) + client := cluster.Cores[0].Client + + resp, err := client.Sys().BillingOverview(false) + require.NoError(t, err) + require.NotNil(t, resp) + + // Verify month format (YYYY-MM) + for _, month := range resp.Months { + require.Regexp(t, `^\d{4}-\d{2}$`, month.Month, "month should be in YYYY-MM format") + require.Regexp(t, `^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}`, month.UpdatedAt, "updated_at should be in ISO 8601 format") + } + + // Verify months are in descending order (current, then previous) + require.Greater(t, resp.Months[0].Month, resp.Months[1].Month, "first month should be more recent than second") +} diff --git a/vault/logical_system.go b/vault/logical_system.go index b75e42dc5d..56b290f3f7 100644 --- a/vault/logical_system.go +++ b/vault/logical_system.go @@ -239,6 +239,7 @@ func NewSystemBackend(core *Core, logger log.Logger, config *logical.BackendConf 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()...) + b.Backend.Paths = append(b.Backend.Paths, b.useCaseConsumptionBillingPaths()...) if core.rawEnabled { b.Backend.Paths = append(b.Backend.Paths, b.rawPaths()...) diff --git a/vault/logical_system_use_case_billing.go b/vault/logical_system_use_case_billing.go index 5f15b333af..0036e00e5d 100644 --- a/vault/logical_system_use_case_billing.go +++ b/vault/logical_system_use_case_billing.go @@ -21,21 +21,24 @@ func (b *SystemBackend) useCaseConsumptionBillingPaths() []*framework.Path { return []*framework.Path{ { Pattern: "billing/overview$", + Fields: map[string]*framework.FieldSchema{ + "refresh_data": { + Type: framework.TypeBool, + Description: "If set, updates the billing counts for the current month before returning. This is an expensive operation with potential performance impact and should be used sparingly.", + Query: true, + }, + }, Operations: map[logical.Operation]framework.OperationHandler{ logical.ReadOperation: &framework.PathOperation{ Callback: b.handleUseCaseConsumption, - Summary: "Report the count of secrets and roles for the purposes of use case billing.", + Summary: "Reports consumption billing metrics for the current and previous months.", Responses: map[int][]framework.Response{ http.StatusOK: {{ Description: http.StatusText(http.StatusOK), Fields: map[string]*framework.FieldSchema{ - "high_watermark_role_counts": { - Type: framework.TypeMap, - Description: "High watermark (for this month) role counts for this cluster.", - }, - "data_protection_call_counts": { - Type: framework.TypeMap, - Description: "Count of data protection calls on this cluster.", + "months": { + Type: framework.TypeSlice, + Description: "List of monthly billing data, including the current and previous months.", }, }, }}, @@ -56,127 +59,26 @@ func (b *SystemBackend) useCaseConsumptionBillingPaths() []*framework.Path { } func (b *SystemBackend) handleUseCaseConsumption(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { - // Get HWM role counts - replicatedMaxRoleCounts := &RoleCounts{} - replicatedManagedKeyCounts := &ManagedKeyCounts{} - replicatedKvHWMCounts := 0 - replicatedTotpHWMCounts := 0 - var err error + refreshData := data.Get("refresh_data").(bool) + currentMonth := time.Now() previousMonth := timeutil.StartOfPreviousMonth(currentMonth) - // If we are the primary, then we want to get the replicated max role counts. Else we shouldn't retrieve them. - if b.Core.isPrimary() { - // We use update instead of Get so that the counts are up to date. - replicatedMaxRoleCounts, replicatedManagedKeyCounts, err = b.Core.UpdateMaxRoleAndManagedKeyCounts(ctx, billing.ReplicatedPrefix, currentMonth) - if err != nil { - return nil, fmt.Errorf("error retrieving replicated max role and managed key counts: %w", err) - } - replicatedKvHWMCounts, err = b.Core.UpdateMaxKvCounts(ctx, billing.ReplicatedPrefix, currentMonth) - if err != nil { - return nil, fmt.Errorf("error retrieving replicated max kv counts: %w", err) - } - replicatedTotpHWMCounts = replicatedManagedKeyCounts.TotpKeys + // Refresh data only if explicitly requested and for current month + currentMonthData, err := b.buildMonthBillingData(ctx, currentMonth, refreshData) + if err != nil { + return nil, fmt.Errorf("error building current month billing data: %w", err) } - // We always want to get the local max role counts - // We use update instead of Get so that the counts are up to date. - localMaxRoleCounts, localMaxManagedKeyCounts, err := b.Core.UpdateMaxRoleAndManagedKeyCounts(ctx, billing.LocalPrefix, currentMonth) + previousMonthData, err := b.buildMonthBillingData(ctx, previousMonth, false) if err != nil { - return nil, fmt.Errorf("error retrieving local max role and managed key counts: %w", err) - } - localKvHWMCounts, err := b.Core.UpdateMaxKvCounts(ctx, billing.LocalPrefix, currentMonth) - if err != nil { - return nil, fmt.Errorf("error retrieving local max kv counts: %w", err) - } - localTotpHWMCounts := localMaxManagedKeyCounts.TotpKeys - - // Data protection call counts are stored to local path only - // Each cluster tracks its own total requests to avoid double counting - localTransitCallCounts, err := b.Core.UpdateTransitCallCounts(ctx, currentMonth) - if err != nil { - return nil, fmt.Errorf("error retrieving local transit call counts: %w", err) - } - localTransformCallCounts, err := b.Core.UpdateTransformCallCounts(ctx, currentMonth) - if err != nil { - return nil, fmt.Errorf("error retrieving local transform call counts: %w", err) - } - - // If we are the primary, then combine the replicated and local max role counts. Else just output the local - // max role counts. replicatedMaxRoleCounts will be empty if we are not a primary, so this is taken care of for us. - combinedMaxRoleCounts := combineRoleCounts(replicatedMaxRoleCounts, localMaxRoleCounts) - combinedMaxKvCounts := replicatedKvHWMCounts + localKvHWMCounts - combinedMaxTotpCounts := replicatedTotpHWMCounts + localTotpHWMCounts - // Data protection counts are not combined - each cluster reports its own total - combinedMaxDataProtectionCallCounts := map[string]interface{}{ - "transit": localTransitCallCounts, - "transform": localTransformCallCounts, - } - - var replicatedPreviousMonthRoleCounts *RoleCounts - replicatedPreviousMonthKvHWMCounts := 0 - replicatedPreviousMonthTotpHWMCounts := 0 - if b.Core.isPrimary() { - replicatedPreviousMonthRoleCounts, err = b.Core.GetStoredHWMRoleCounts(ctx, billing.ReplicatedPrefix, previousMonth) - if err != nil { - return nil, fmt.Errorf("error retrieving replicated max role counts for previous month: %w", err) - } - replicatedPreviousMonthKvHWMCounts, err = b.Core.GetStoredHWMKvCounts(ctx, billing.ReplicatedPrefix, previousMonth) - if err != nil { - return nil, fmt.Errorf("error retrieving replicated max kv counts for previous month: %w", err) - } - replicatedPreviousMonthTotpHWMCounts, err = b.Core.GetStoredHWMTotpCounts(ctx, billing.ReplicatedPrefix, previousMonth) - if err != nil { - return nil, fmt.Errorf("error retrieving replicated max totp key for previous month: %w", err) - } - } - localPreviousMonthRoleCounts, err := b.Core.GetStoredHWMRoleCounts(ctx, billing.LocalPrefix, previousMonth) - if err != nil { - return nil, fmt.Errorf("error retrieving local max role counts for previous month: %w", err) - } - localPreviousMonthKvHWMCounts, err := b.Core.GetStoredHWMKvCounts(ctx, billing.LocalPrefix, previousMonth) - if err != nil { - return nil, fmt.Errorf("error retrieving local max kv counts for previous month: %w", err) - } - localPreviousMonthTotpHWMCounts, err := b.Core.GetStoredHWMTotpCounts(ctx, billing.LocalPrefix, previousMonth) - if err != nil { - return nil, fmt.Errorf("error retrieving local max totp key for previous month: %w", err) - } - - // Data protection counts for previous month - localPreviousMonthTransitCallCounts, err := b.Core.GetStoredTransitCallCounts(ctx, previousMonth) - if err != nil { - return nil, fmt.Errorf("error retrieving local transit call counts for previous month: %w", err) - } - localPreviousMonthTransformCallCounts, err := b.Core.GetStoredTransformCallCounts(ctx, previousMonth) - if err != nil { - return nil, fmt.Errorf("error retrieving local transform call counts for previous month: %w", err) - } - - combinedPreviousMonthRoleCounts := combineRoleCounts(replicatedPreviousMonthRoleCounts, localPreviousMonthRoleCounts) - combinedPreviousMonthKvHWMCounts := replicatedPreviousMonthKvHWMCounts + localPreviousMonthKvHWMCounts - combinedPreviousMonthTotpHWMCounts := replicatedPreviousMonthTotpHWMCounts + localPreviousMonthTotpHWMCounts - - // Data protection counts are not combined - each cluster reports its own total - combinedPreviousMonthDataProtectionCallCounts := map[string]interface{}{ - "transit": localPreviousMonthTransitCallCounts, - "transform": localPreviousMonthTransformCallCounts, + return nil, fmt.Errorf("error building previous month billing data: %w", err) } resp := map[string]interface{}{ - "current_month": map[string]interface{}{ - "timestamp": timeutil.StartOfMonth(currentMonth), - "maximum_role_counts": combinedMaxRoleCounts, - "maximum_kv_counts": combinedMaxKvCounts, - "maximum_totp_counts": combinedMaxTotpCounts, - "data_protection_call_counts": combinedMaxDataProtectionCallCounts, - }, - "previous_month": map[string]interface{}{ - "timestamp": previousMonth, - "maximum_role_counts": combinedPreviousMonthRoleCounts, - "maximum_kv_counts": combinedPreviousMonthKvHWMCounts, - "maximum_totp_counts": combinedPreviousMonthTotpHWMCounts, - "data_protection_call_counts": combinedPreviousMonthDataProtectionCallCounts, + "months": []interface{}{ + currentMonthData, + previousMonthData, }, } @@ -185,8 +87,252 @@ func (b *SystemBackend) handleUseCaseConsumption(ctx context.Context, req *logic }, nil } -// generatePkiBillingMetric generates the billing metric for PKI duration-adjusted certificate counts. -func (b *SystemBackend) generatePkiBillingMetric(ctx context.Context, month time.Time) (map[string]interface{}, error) { +// buildMonthBillingData constructs billing data for a specific month +func (b *SystemBackend) buildMonthBillingData(ctx context.Context, month time.Time, refreshData bool) (map[string]interface{}, error) { + // Retrieve all billing metrics + combinedRoleCounts, combinedMaxTotpCounts, err := b.Core.getRoleAndManagedKeyCounts(ctx, month, refreshData) + if err != nil { + return nil, err + } + + combinedKvCounts, err := b.Core.getKvCounts(ctx, month, refreshData) + if err != nil { + return nil, err + } + + transitCounts, transformCounts, err := b.Core.getDataProtectionCounts(ctx, month, refreshData) + if err != nil { + return nil, err + } + + kmipEnabled, err := b.Core.getKmipStatus(ctx, month, refreshData) + if err != nil { + return nil, err + } + + thirdPartyPluginCounts, err := b.Core.getThirdPartyPluginCounts(ctx, month, refreshData) + if err != nil { + return nil, err + } + + // Build the usage metrics + usageMetrics := []map[string]interface{}{} + + kvDetails := []map[string]interface{}{} + if combinedKvCounts > 0 { + kvDetails = append(kvDetails, map[string]interface{}{"type": "kv", "count": combinedKvCounts}) + } + usageMetrics = append(usageMetrics, map[string]interface{}{ + "metric_name": "static_secrets", + "metric_data": map[string]interface{}{ + "total": combinedKvCounts, + "metric_details": kvDetails, + }, + }) + + usageMetrics = append(usageMetrics, buildDynamicRolesMetric(combinedRoleCounts)) + + usageMetrics = append(usageMetrics, buildAutoRotatedRolesMetric(combinedRoleCounts)) + + usageMetrics = append(usageMetrics, map[string]interface{}{ + "metric_name": "kmip", + "metric_data": map[string]interface{}{ + "used_in_month": kmipEnabled, + }, + }) + + usageMetrics = append(usageMetrics, map[string]interface{}{ + "metric_name": "external_plugins", + "metric_data": map[string]interface{}{ + "total": thirdPartyPluginCounts, + }, + }) + + dataProtectionDetails := []map[string]interface{}{} + if transitCounts > 0 { + dataProtectionDetails = append(dataProtectionDetails, map[string]interface{}{"type": "transit", "count": transitCounts}) + } + if transformCounts > 0 { + dataProtectionDetails = append(dataProtectionDetails, map[string]interface{}{"type": "transform", "count": transformCounts}) + } + + usageMetrics = append(usageMetrics, map[string]interface{}{ + "metric_name": "data_protection_calls", + "metric_data": map[string]interface{}{ + "total": transitCounts + transformCounts, + "metric_details": dataProtectionDetails, + }, + }) + + pkiMetric, err := b.buildPkiBillingMetric(ctx, month) + if err != nil { + return nil, err + } + usageMetrics = append(usageMetrics, pkiMetric) + + managedKeysDetails := []map[string]interface{}{} + if combinedMaxTotpCounts > 0 { + managedKeysDetails = append(managedKeysDetails, map[string]interface{}{"type": "totp", "count": combinedMaxTotpCounts}) + } + usageMetrics = append(usageMetrics, map[string]interface{}{ + "metric_name": "managed_keys", + "metric_data": map[string]interface{}{ + "total": combinedMaxTotpCounts, + "metric_details": managedKeysDetails, + }, + }) + + // Determine updated_at timestamp based on whether data was refreshed + var dataUpdatedAt time.Time + if refreshData { + // Data was just refreshed, use current time and update the stored timestamp + dataUpdatedAt = time.Now().UTC() + b.Core.consumptionBilling.LastMetricsUpdate.Store(dataUpdatedAt) + } else { + // Data was not refreshed, use the last time metrics were updated by the background worker + lastUpdate := b.Core.consumptionBilling.LastMetricsUpdate.Load() + if lastUpdate != nil { + if t, ok := lastUpdate.(time.Time); ok && !t.IsZero() { + dataUpdatedAt = t + } else { + // Fallback to end of month if timestamp not available + dataUpdatedAt = timeutil.StartOfMonth(month.AddDate(0, 1, 0)).Add(-time.Second).UTC() + } + } else { + // Fallback to end of month if timestamp not available + dataUpdatedAt = timeutil.StartOfMonth(month.AddDate(0, 1, 0)).Add(-time.Second).UTC() + } + } + + monthStr := month.Format("2006-01") + + return map[string]interface{}{ + "month": monthStr, + "updated_at": dataUpdatedAt.Format(time.RFC3339), + "usage_metrics": usageMetrics, + }, nil +} + +// buildDynamicRolesMetric creates the dynamic_roles metric from role counts. +func buildDynamicRolesMetric(counts *RoleCounts) map[string]interface{} { + total := 0 + if counts != nil { + total = counts.AWSDynamicRoles + + counts.AzureDynamicRoles + + counts.DatabaseDynamicRoles + + counts.GCPRolesets + + counts.LDAPDynamicRoles + + counts.OpenLDAPDynamicRoles + + counts.AlicloudDynamicRoles + + counts.RabbitMQDynamicRoles + + counts.ConsulDynamicRoles + + counts.NomadDynamicRoles + + counts.KubernetesDynamicRoles + + counts.MongoDBAtlasDynamicRoles + + counts.TerraformCloudDynamicRoles + } + + details := []map[string]interface{}{} + if counts != nil { + if counts.AWSDynamicRoles > 0 { + details = append(details, map[string]interface{}{"type": "aws_dynamic", "count": counts.AWSDynamicRoles}) + } + if counts.AzureDynamicRoles > 0 { + details = append(details, map[string]interface{}{"type": "azure_dynamic", "count": counts.AzureDynamicRoles}) + } + if counts.DatabaseDynamicRoles > 0 { + details = append(details, map[string]interface{}{"type": "database_dynamic", "count": counts.DatabaseDynamicRoles}) + } + if counts.GCPRolesets > 0 { + details = append(details, map[string]interface{}{"type": "gcp_dynamic", "count": counts.GCPRolesets}) + } + if counts.LDAPDynamicRoles > 0 { + details = append(details, map[string]interface{}{"type": "ldap_dynamic", "count": counts.LDAPDynamicRoles}) + } + if counts.OpenLDAPDynamicRoles > 0 { + details = append(details, map[string]interface{}{"type": "openldap_dynamic", "count": counts.OpenLDAPDynamicRoles}) + } + if counts.AlicloudDynamicRoles > 0 { + details = append(details, map[string]interface{}{"type": "alicloud_dynamic", "count": counts.AlicloudDynamicRoles}) + } + if counts.RabbitMQDynamicRoles > 0 { + details = append(details, map[string]interface{}{"type": "rabbitmq_dynamic", "count": counts.RabbitMQDynamicRoles}) + } + if counts.ConsulDynamicRoles > 0 { + details = append(details, map[string]interface{}{"type": "consul_dynamic", "count": counts.ConsulDynamicRoles}) + } + if counts.NomadDynamicRoles > 0 { + details = append(details, map[string]interface{}{"type": "nomad_dynamic", "count": counts.NomadDynamicRoles}) + } + if counts.KubernetesDynamicRoles > 0 { + details = append(details, map[string]interface{}{"type": "kubernetes_dynamic", "count": counts.KubernetesDynamicRoles}) + } + if counts.MongoDBAtlasDynamicRoles > 0 { + details = append(details, map[string]interface{}{"type": "mongodbatlas_dynamic", "count": counts.MongoDBAtlasDynamicRoles}) + } + if counts.TerraformCloudDynamicRoles > 0 { + details = append(details, map[string]interface{}{"type": "terraform_dynamic", "count": counts.TerraformCloudDynamicRoles}) + } + } + + return map[string]interface{}{ + "metric_name": "dynamic_roles", + "metric_data": map[string]interface{}{ + "total": total, + "metric_details": details, + }, + } +} + +// buildAutoRotatedRolesMetric creates the auto_rotated_roles metric from role counts. +func buildAutoRotatedRolesMetric(counts *RoleCounts) map[string]interface{} { + total := 0 + if counts != nil { + total = counts.AWSStaticRoles + + counts.AzureStaticRoles + + counts.DatabaseStaticRoles + + counts.GCPStaticAccounts + + counts.GCPImpersonatedAccounts + + counts.LDAPStaticRoles + + counts.OpenLDAPStaticRoles + } + + details := []map[string]interface{}{} + if counts != nil { + if counts.AWSStaticRoles > 0 { + details = append(details, map[string]interface{}{"type": "aws_static", "count": counts.AWSStaticRoles}) + } + if counts.AzureStaticRoles > 0 { + details = append(details, map[string]interface{}{"type": "azure_static", "count": counts.AzureStaticRoles}) + } + if counts.DatabaseStaticRoles > 0 { + details = append(details, map[string]interface{}{"type": "database_static", "count": counts.DatabaseStaticRoles}) + } + if counts.GCPStaticAccounts > 0 { + details = append(details, map[string]interface{}{"type": "gcp_static", "count": counts.GCPStaticAccounts}) + } + if counts.GCPImpersonatedAccounts > 0 { + details = append(details, map[string]interface{}{"type": "gcp_impersonated", "count": counts.GCPImpersonatedAccounts}) + } + if counts.LDAPStaticRoles > 0 { + details = append(details, map[string]interface{}{"type": "ldap_static", "count": counts.LDAPStaticRoles}) + } + if counts.OpenLDAPStaticRoles > 0 { + details = append(details, map[string]interface{}{"type": "openldap_static", "count": counts.OpenLDAPStaticRoles}) + } + } + + return map[string]interface{}{ + "metric_name": "auto_rotated_roles", + "metric_data": map[string]interface{}{ + "total": total, + "metric_details": details, + }, + } +} + +// buildPkiBillingMetric creates the billing metric for PKI duration-adjusted certificate counts. +func (b *SystemBackend) buildPkiBillingMetric(ctx context.Context, month time.Time) (map[string]interface{}, error) { count, err := b.Core.GetStoredPkiDurationAdjustedCount(ctx, month) if err != nil { return nil, fmt.Errorf("error retrieving PKI duration-adjusted count for month: %w", err) @@ -199,3 +345,157 @@ func (b *SystemBackend) generatePkiBillingMetric(ctx context.Context, month time }, }, nil } + +// getRoleCounts retrieves and combines role and managed key counts from replicated and local storage +func (c *Core) getRoleAndManagedKeyCounts(ctx context.Context, month time.Time, updateCounts bool) (*RoleCounts, int, error) { + var replicatedRoleCounts *RoleCounts + var replicatedManagedKeyCounts *ManagedKeyCounts + replicatedTotpHWMValue := 0 + var err error + + if c.isPrimary() { + if updateCounts { + replicatedRoleCounts, replicatedManagedKeyCounts, err = c.UpdateMaxRoleAndManagedKeyCounts(ctx, billing.ReplicatedPrefix, month) + if err != nil { + return nil, 0, fmt.Errorf("error updating replicated max role and managed key counts: %w", err) + } + replicatedTotpHWMValue = replicatedManagedKeyCounts.TotpKeys + } else { + replicatedRoleCounts, err = c.GetStoredHWMRoleCounts(ctx, billing.ReplicatedPrefix, month) + if err != nil { + return nil, 0, fmt.Errorf("error retrieving replicated max role counts: %w", err) + } + replicatedTotpHWMValue, err = c.GetStoredHWMTotpCounts(ctx, billing.ReplicatedPrefix, month) + if err != nil { + return nil, 0, fmt.Errorf("error retrieving replicated max managed key count: %w", err) + } + } + } + + var localRoleCounts *RoleCounts + var localManagedKeyCounts *ManagedKeyCounts + localTotpHWMValue := 0 + if updateCounts { + localRoleCounts, localManagedKeyCounts, err = c.UpdateMaxRoleAndManagedKeyCounts(ctx, billing.LocalPrefix, month) + if err != nil { + return nil, 0, fmt.Errorf("error updating local max role and managed key counts: %w", err) + } + localTotpHWMValue = localManagedKeyCounts.TotpKeys + } else { + localRoleCounts, err = c.GetStoredHWMRoleCounts(ctx, billing.LocalPrefix, month) + if err != nil { + return nil, 0, fmt.Errorf("error retrieving local max role counts: %w", err) + } + localTotpHWMValue, err = c.GetStoredHWMTotpCounts(ctx, billing.LocalPrefix, month) + if err != nil { + return nil, 0, fmt.Errorf("error retrieving local max totp key count: %w", err) + } + } + + return combineRoleCounts(replicatedRoleCounts, localRoleCounts), localTotpHWMValue + replicatedTotpHWMValue, nil +} + +// getKvCounts retrieves and combines KV secret counts from replicated and local storage +func (c *Core) getKvCounts(ctx context.Context, month time.Time, updateCounts bool) (int, error) { + var replicatedKvCounts int + var err error + + if c.isPrimary() { + if updateCounts { + replicatedKvCounts, err = c.UpdateMaxKvCounts(ctx, billing.ReplicatedPrefix, month) + if err != nil { + return 0, fmt.Errorf("error updating replicated max kv counts: %w", err) + } + } else { + replicatedKvCounts, err = c.GetStoredHWMKvCounts(ctx, billing.ReplicatedPrefix, month) + if err != nil { + return 0, fmt.Errorf("error retrieving replicated max kv counts: %w", err) + } + } + } + + var localKvCounts int + if updateCounts { + localKvCounts, err = c.UpdateMaxKvCounts(ctx, billing.LocalPrefix, month) + if err != nil { + return 0, fmt.Errorf("error updating local max kv counts: %w", err) + } + } else { + localKvCounts, err = c.GetStoredHWMKvCounts(ctx, billing.LocalPrefix, month) + if err != nil { + return 0, fmt.Errorf("error retrieving local max kv counts: %w", err) + } + } + + return replicatedKvCounts + localKvCounts, nil +} + +// getDataProtectionCounts retrieves Transit and Transform call counts +// Data protection call counts are stored at local path only +// Each cluster tracks its own total requests to avoid double counting +func (c *Core) getDataProtectionCounts(ctx context.Context, month time.Time, updateCounts bool) (uint64, uint64, error) { + var transitCounts, transformCounts uint64 + var err error + + if updateCounts { + transitCounts, err = c.UpdateTransitCallCounts(ctx, month) + if err != nil { + return 0, 0, fmt.Errorf("error updating local transit call counts: %w", err) + } + transformCounts, err = c.UpdateTransformCallCounts(ctx, month) + if err != nil { + return 0, 0, fmt.Errorf("error updating local transform call counts: %w", err) + } + } else { + transitCounts, err = c.GetStoredTransitCallCounts(ctx, month) + if err != nil { + return 0, 0, fmt.Errorf("error retrieving local transit call counts: %w", err) + } + transformCounts, err = c.GetStoredTransformCallCounts(ctx, month) + if err != nil { + return 0, 0, fmt.Errorf("error retrieving local transform call counts: %w", err) + } + } + + return transitCounts, transformCounts, nil +} + +// getKmipStatus retrieves KMIP enabled status (always stored at local path) +func (c *Core) getKmipStatus(ctx context.Context, month time.Time, updateCounts bool) (bool, error) { + var kmipEnabled bool + var err error + + if updateCounts { + kmipEnabled, err = c.UpdateKmipEnabled(ctx, month) + if err != nil { + return false, fmt.Errorf("error updating KMIP enabled status: %w", err) + } + } else { + kmipEnabled, err = c.GetStoredKmipEnabled(ctx, month) + if err != nil { + return false, fmt.Errorf("error retrieving KMIP enabled status: %w", err) + } + } + + return kmipEnabled, nil +} + +// getThirdPartyPluginCounts retrieves third-party plugin counts (always stored at local path) +func (c *Core) getThirdPartyPluginCounts(ctx context.Context, month time.Time, updateCounts bool) (int, error) { + var thirdPartyPluginCounts int + var err error + + if updateCounts { + thirdPartyPluginCounts, err = c.UpdateMaxThirdPartyPluginCounts(ctx, month) + if err != nil { + return 0, fmt.Errorf("error updating third-party plugin counts: %w", err) + } + } else { + thirdPartyPluginCounts, err = c.GetStoredThirdPartyPluginCounts(ctx, month) + if err != nil { + return 0, fmt.Errorf("error retrieving third-party plugin counts: %w", err) + } + } + + return thirdPartyPluginCounts, nil +} diff --git a/vault/logical_system_use_case_billing_pki_test.go b/vault/logical_system_use_case_billing_pki_test.go index 5318047860..81748ed2ea 100644 --- a/vault/logical_system_use_case_billing_pki_test.go +++ b/vault/logical_system_use_case_billing_pki_test.go @@ -20,7 +20,7 @@ func TestGeneratePkiBillingMetric(t *testing.T) { currentMonth := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC) t.Run("returns zero count when no data exists", func(t *testing.T) { - overview, err := backend.generatePkiBillingMetric(ctx, currentMonth) + overview, err := backend.buildPkiBillingMetric(ctx, currentMonth) require.NoError(t, err) require.NotNil(t, overview) @@ -43,7 +43,7 @@ func TestGeneratePkiBillingMetric(t *testing.T) { require.NoError(t, err) // Generate overview - overview, err := backend.generatePkiBillingMetric(ctx, month) + overview, err := backend.buildPkiBillingMetric(ctx, month) require.NoError(t, err) require.NotNil(t, overview) @@ -72,13 +72,13 @@ func TestGeneratePkiBillingMetric(t *testing.T) { require.NoError(t, err) // Generate overview for month1 - overview1, err := backend.generatePkiBillingMetric(ctx, month1) + overview1, err := backend.buildPkiBillingMetric(ctx, month1) require.NoError(t, err) metricData1 := overview1["metric_data"].(map[string]interface{}) require.Equal(t, count1, metricData1["total"]) // Generate overview for month2 - overview2, err := backend.generatePkiBillingMetric(ctx, month2) + overview2, err := backend.buildPkiBillingMetric(ctx, month2) require.NoError(t, err) metricData2 := overview2["metric_data"].(map[string]interface{}) require.Equal(t, count2, metricData2["total"]) @@ -87,7 +87,7 @@ func TestGeneratePkiBillingMetric(t *testing.T) { t.Run("uses constant for metric name", func(t *testing.T) { month := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) - overview, err := backend.generatePkiBillingMetric(ctx, month) + overview, err := backend.buildPkiBillingMetric(ctx, month) require.NoError(t, err) // Verify it uses the constant pkiDurationAjustedCountMetricName diff --git a/vault/logical_system_use_case_billing_test.go b/vault/logical_system_use_case_billing_test.go new file mode 100644 index 0000000000..7e0eee44a4 --- /dev/null +++ b/vault/logical_system_use_case_billing_test.go @@ -0,0 +1,658 @@ +// Copyright IBM Corp. 2016, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package vault + +import ( + "context" + "testing" + "time" + + logicalKv "github.com/hashicorp/vault-plugin-secrets-kv" + logicalAws "github.com/hashicorp/vault/builtin/logical/aws" + logicalDatabase "github.com/hashicorp/vault/builtin/logical/database" + logicalTransit "github.com/hashicorp/vault/builtin/logical/transit" + "github.com/hashicorp/vault/helper/namespace" + "github.com/hashicorp/vault/helper/pluginconsts" + "github.com/hashicorp/vault/helper/timeutil" + "github.com/hashicorp/vault/sdk/logical" + "github.com/hashicorp/vault/vault/billing" + "github.com/stretchr/testify/require" +) + +// TestSystemBackend_BillingOverview verifies that the billing overview endpoint +// returns the correct response structure with current and previous month data. +// It validates the response format, month strings (YYYY-MM), RFC3339 timestamps, +// and ensures both months are present in the response. +func TestSystemBackend_BillingOverview(t *testing.T) { + _, b, _ := testCoreSystemBackend(t) + ctx := namespace.RootContext(nil) + + // Make a request to the billing overview endpoint + req := logical.TestRequest(t, logical.ReadOperation, "billing/overview") + resp, err := b.HandleRequest(ctx, req) + require.NoError(t, err) + require.NotNil(t, resp) + require.NotNil(t, resp.Data) + + // Verify the response structure + months, ok := resp.Data["months"].([]interface{}) + require.True(t, ok, "months should be a slice") + require.Len(t, months, 2, "should have current and previous month") + + // Verify current month structure + currentMonth, ok := months[0].(map[string]interface{}) + require.True(t, ok, "current month should be a map") + require.Contains(t, currentMonth, "month") + require.Contains(t, currentMonth, "updated_at") + require.Contains(t, currentMonth, "usage_metrics") + + // Verify month format (YYYY-MM) + monthStr, ok := currentMonth["month"].(string) + require.True(t, ok) + require.Regexp(t, `^\d{4}-\d{2}$`, monthStr) + + // Verify updated_at format (RFC3339) + updatedAt, ok := currentMonth["updated_at"].(string) + require.True(t, ok) + _, err = time.Parse(time.RFC3339, updatedAt) + require.NoError(t, err, "updated_at should be valid RFC3339 timestamp") + + // Verify usage_metrics is a slice + _, ok = currentMonth["usage_metrics"].([]map[string]interface{}) + require.True(t, ok, "usage_metrics should be a slice of maps") + + // Verify previous month structure + previousMonth, ok := months[1].(map[string]interface{}) + require.True(t, ok, "previous month should be a map") + require.Contains(t, previousMonth, "month") + require.Contains(t, previousMonth, "updated_at") + require.Contains(t, previousMonth, "usage_metrics") + + // Verify that current month is actually current + now := time.Now() + expectedCurrentMonth := now.Format("2006-01") + require.Equal(t, expectedCurrentMonth, monthStr) + + // Verify that previous month is actually previous + prevMonthStr, ok := previousMonth["month"].(string) + require.True(t, ok) + expectedPreviousMonth := timeutil.StartOfPreviousMonth(now).Format("2006-01") + require.Equal(t, expectedPreviousMonth, prevMonthStr) +} + +// TestSystemBackend_BillingOverview_WithMetrics tests the billing overview endpoint +// with actual KV secrets created to generate billing metrics. It verifies that KV v2 +// secrets are properly counted in billing, the static_secrets metric appears in the +// response, the metric_data structure contains total and metric_details and the +// metric_details include the correct type and count. +func TestSystemBackend_BillingOverview_WithMetrics(t *testing.T) { + c, b, root := testCoreSystemBackend(t) + ctx := namespace.RootContext(nil) + + // Create some KV secrets to generate metrics + req := logical.TestRequest(t, logical.UpdateOperation, "mounts/testkv") + req.Data = map[string]interface{}{ + "type": "kv-v2", + } + resp, err := b.HandleRequest(ctx, req) + require.NoError(t, err) + require.Nil(t, resp) + + kvReq := logical.TestRequest(t, logical.CreateOperation, "testkv/data/test") + kvReq.Data["data"] = map[string]interface{}{ + "foo": "bar", + } + kvReq.ClientToken = root + kvResp, err := c.HandleRequest(ctx, kvReq) + require.NoError(t, err) + require.NotNil(t, kvResp) + + currentMonth := time.Now() + _, err = c.UpdateMaxKvCounts(ctx, billing.LocalPrefix, currentMonth) + require.NoError(t, err) + + req = logical.TestRequest(t, logical.ReadOperation, "billing/overview") + req.Data["refresh_data"] = true + resp, err = b.HandleRequest(ctx, req) + require.NoError(t, err) + require.NotNil(t, resp) + + // Verify the response contains metrics + months, ok := resp.Data["months"].([]interface{}) + require.True(t, ok) + require.Len(t, months, 2) + + currentMonthData, ok := months[0].(map[string]interface{}) + require.True(t, ok) + + usageMetrics, ok := currentMonthData["usage_metrics"].([]map[string]interface{}) + require.True(t, ok) + + // Check if static_secrets metric exists + foundStaticSecrets := false + for _, metric := range usageMetrics { + if metricName, ok := metric["metric_name"].(string); ok && metricName == "static_secrets" { + foundStaticSecrets = true + + // Verify metric_data structure + metricData, ok := metric["metric_data"].(map[string]interface{}) + require.True(t, ok) + require.Contains(t, metricData, "total") + require.Contains(t, metricData, "metric_details") + + // Verify total is greater than 0 + total, ok := metricData["total"].(int) + require.True(t, ok) + require.Greater(t, total, 0) + + // Verify metric_details structure + metricDetails, ok := metricData["metric_details"].([]map[string]interface{}) + require.True(t, ok) + require.NotEmpty(t, metricDetails) + + // Verify first detail has type and count + firstDetail := metricDetails[0] + require.Contains(t, firstDetail, "type") + require.Contains(t, firstDetail, "count") + require.Equal(t, "kv", firstDetail["type"]) + + break + } + } + require.True(t, foundStaticSecrets, "static_secrets metric should be present") +} + +// TestSystemBackend_BillingOverview_MetricFormats validates that different metric types +// in the billing overview response have the correct data structure. +func TestSystemBackend_BillingOverview_MetricFormats(t *testing.T) { + c, _, root, _ := TestCoreUnsealedWithMetricsAndConfig(t, &CoreConfig{ + LogicalBackends: map[string]logical.Factory{ + pluginconsts.SecretEngineKV: logicalKv.Factory, + pluginconsts.SecretEngineAWS: logicalAws.Factory, + pluginconsts.SecretEngineDatabase: logicalDatabase.Factory, + pluginconsts.SecretEngineTransit: logicalTransit.Factory, + }, + }) + b := c.systemBackend + ctx := namespace.RootContext(context.Background()) + + // Create KV secrets for static_secrets metric + req := logical.TestRequest(t, logical.UpdateOperation, "mounts/kv-v1") + req.Data = map[string]interface{}{"type": "kv-v1"} + resp, err := b.HandleRequest(ctx, req) + require.NoError(t, err) + require.Nil(t, resp) + + req = logical.TestRequest(t, logical.UpdateOperation, "mounts/kv-v2") + req.Data = map[string]interface{}{"type": "kv-v2"} + resp, err = b.HandleRequest(ctx, req) + require.NoError(t, err) + require.Nil(t, resp) + + addKvSecretToStorage(t, ctx, c, "kv-v1", root, "secret1", "kv-v1") + addKvSecretToStorage(t, ctx, c, "kv-v2", root, "secret2", "kv-v2") + + // Create roles for dynamic_roles and auto_rotated_roles metrics + req = logical.TestRequest(t, logical.UpdateOperation, "mounts/aws") + req.Data = map[string]interface{}{"type": "aws"} + resp, err = b.HandleRequest(ctx, req) + require.NoError(t, err) + require.Nil(t, resp) + + addRoleToStorage(t, c, "aws", "role/", 2) + addRoleToStorage(t, c, "aws", "static-roles/", 1) + + req = logical.TestRequest(t, logical.UpdateOperation, "mounts/database") + req.Data = map[string]interface{}{"type": "database"} + resp, err = b.HandleRequest(ctx, req) + require.NoError(t, err) + require.Nil(t, resp) + + addRoleToStorage(t, c, "database", "role/", 1) + addRoleToStorage(t, c, "database", "static-role/", 1) + + // Mount transit backend + req = logical.TestRequest(t, logical.CreateOperation, "sys/mounts/transit") + req.Data["type"] = "transit" + req.ClientToken = root + _, err = c.HandleRequest(ctx, req) + require.NoError(t, err) + + // Create an encryption key + req = logical.TestRequest(t, logical.CreateOperation, "transit/keys/foo") + req.Data["type"] = "aes256-gcm96" + req.ClientToken = root + _, err = c.HandleRequest(ctx, req) + require.NoError(t, err) + + // Perform encryption on the key + req = logical.TestRequest(t, logical.UpdateOperation, "transit/encrypt/foo") + req.Data["plaintext"] = "dGhlIHF1aWNrIGJyb3duIGZveA==" + req.ClientToken = root + _, err = c.HandleRequest(ctx, req) + require.NoError(t, err) + + // Update all metrics + currentMonth := time.Now() + _, err = c.UpdateMaxKvCounts(ctx, billing.LocalPrefix, currentMonth) + require.NoError(t, err) + + _, _, err = c.UpdateMaxRoleAndManagedKeyCounts(ctx, billing.LocalPrefix, currentMonth) + require.NoError(t, err) + + _, err = c.UpdateTransitCallCounts(ctx, currentMonth) + require.NoError(t, err) + + // Make a request to the billing overview endpoint + req = logical.TestRequest(t, logical.ReadOperation, "billing/overview") + req.Data["refresh_data"] = true + resp, err = b.HandleRequest(ctx, req) + require.NoError(t, err) + require.NotNil(t, resp) + + months, ok := resp.Data["months"].([]interface{}) + require.True(t, ok) + require.Len(t, months, 2) + + currentMonthData, ok := months[0].(map[string]interface{}) + require.True(t, ok) + + usageMetrics, ok := currentMonthData["usage_metrics"].([]map[string]interface{}) + require.True(t, ok) + require.NotEmpty(t, usageMetrics, "usage_metrics should not be empty after creating test data") + + metricsFound := make(map[string]bool) + + // Verify each metric has the correct structure + for _, metric := range usageMetrics { + metricName, ok := metric["metric_name"].(string) + require.True(t, ok, "metric_name should be a string") + require.NotEmpty(t, metricName) + + metricsFound[metricName] = true + + metricData, ok := metric["metric_data"].(map[string]interface{}) + require.True(t, ok, "metric_data should be a map") + require.NotEmpty(t, metricData) + + // Different metrics have different structures + switch metricName { + case "static_secrets": + // Should have total and metric_details + require.Contains(t, metricData, "total") + require.Contains(t, metricData, "metric_details") + + total, ok := metricData["total"].(int) + require.True(t, ok) + require.Equal(t, total, 2) + + metricDetails, ok := metricData["metric_details"].([]map[string]interface{}) + require.True(t, ok) + require.NotEmpty(t, metricDetails) + for _, detail := range metricDetails { + require.Contains(t, detail, "type") + require.Contains(t, detail, "count") + count, ok := detail["count"].(int) + require.True(t, ok) + require.Equal(t, count, 2) + } + + case "dynamic_roles": + // Should have total and metric_details + require.Contains(t, metricData, "total") + require.Contains(t, metricData, "metric_details") + + total, ok := metricData["total"].(int) + require.True(t, ok) + require.Equal(t, total, 3) + + metricDetails, ok := metricData["metric_details"].([]map[string]interface{}) + require.True(t, ok) + require.NotEmpty(t, metricDetails) + for _, detail := range metricDetails { + require.Contains(t, detail, "type") + require.Contains(t, detail, "count") + } + + case "auto_rotated_roles": + // Should have total and metric_details + require.Contains(t, metricData, "total") + require.Contains(t, metricData, "metric_details") + + total, ok := metricData["total"].(int) + require.True(t, ok) + require.Equal(t, total, 2) + + metricDetails, ok := metricData["metric_details"].([]map[string]interface{}) + require.True(t, ok) + require.NotEmpty(t, metricDetails) + for _, detail := range metricDetails { + require.Contains(t, detail, "type") + require.Contains(t, detail, "count") + } + + case "data_protection_calls": + require.Contains(t, metricData, "total") + total, ok := metricData["total"].(uint64) + require.True(t, ok) + require.Equal(t, total, uint64(1)) + + require.Contains(t, metricData, "metric_details") + metricDetails, ok := metricData["metric_details"].([]map[string]interface{}) + require.True(t, ok, "metric_details should be []map[string]interface{}") + require.NotEmpty(t, metricDetails) + + foundTransit := false + for _, detail := range metricDetails { + if detail["type"] == "transit" { + foundTransit = true + count, ok := detail["count"].(uint64) + require.True(t, ok) + require.Equal(t, count, uint64(1)) + } + } + require.True(t, foundTransit, "should have transit type in metric_details") + + case "pki_units": + require.Contains(t, metricData, "total") + total, ok := metricData["total"].(float64) + require.True(t, ok, "pki_units total should be float64") + require.GreaterOrEqual(t, total, float64(0)) + + case "managed_keys": + require.Contains(t, metricData, "total") + total, ok := metricData["total"].(int) + require.True(t, ok, "pki_units total should be float64") + require.GreaterOrEqual(t, total, 0) + require.Contains(t, metricData, "metric_details") + } + } + + // Verify we found the expected metrics + require.True(t, metricsFound["static_secrets"], "should have static_secrets metric") + require.True(t, metricsFound["dynamic_roles"], "should have dynamic_roles metric") + require.True(t, metricsFound["auto_rotated_roles"], "should have auto_rotated_roles metric") + require.True(t, metricsFound["data_protection_calls"], "should have data_protection_calls metric") + require.True(t, metricsFound["pki_units"], "should have pki_units metric") + require.True(t, metricsFound["managed_keys"], "should have managed_keys metric") +} + +// TestSystemBackend_BillingOverview_PreviousMonth verifies that the billing overview +// endpoint correctly retrieves and formats data for the previous month. It stores +// billing data for the previous month, validates the previous month string format, +// enures the updated_at timestamp is set to the end of the previous month, and confirms +// the previous month data is included in the response. +func TestSystemBackend_BillingOverview_PreviousMonth(t *testing.T) { + c, b, _ := testCoreSystemBackend(t) + ctx := namespace.RootContext(nil) + + // Store some data for previous month + previousMonth := timeutil.StartOfPreviousMonth(time.Now()) + + // Manually store some counts for previous month + c.consumptionBilling.BillingStorageLock.Lock() + err := c.storeMaxKvCountsLocked(ctx, 5, "local/", previousMonth) + c.consumptionBilling.BillingStorageLock.Unlock() + require.NoError(t, err) + + // Make a request to the billing overview endpoint + req := logical.TestRequest(t, logical.ReadOperation, "billing/overview") + resp, err := b.HandleRequest(ctx, req) + require.NoError(t, err) + require.NotNil(t, resp) + + months, ok := resp.Data["months"].([]interface{}) + require.True(t, ok) + require.Len(t, months, 2) + + // Check previous month data + previousMonthData, ok := months[1].(map[string]interface{}) + require.True(t, ok) + + monthStr, ok := previousMonthData["month"].(string) + require.True(t, ok) + expectedMonth := previousMonth.Format("2006-01") + require.Equal(t, expectedMonth, monthStr) + + // Verify updated_at is end of previous month + updatedAt, ok := previousMonthData["updated_at"].(string) + require.True(t, ok) + parsedTime, err := time.Parse(time.RFC3339, updatedAt) + require.NoError(t, err) + + // The updated_at for previous month should be at the end of that month + expectedEndOfMonth := timeutil.StartOfMonth(previousMonth.AddDate(0, 1, 0)).Add(-time.Second) + require.WithinDuration(t, expectedEndOfMonth, parsedTime, time.Minute) +} + +// TestSystemBackend_BillingOverview_EmptyMetrics verifies that the billing overview +// endpoint returns all metrics with zero values when no billing data exists. +func TestSystemBackend_BillingOverview_EmptyMetrics(t *testing.T) { + _, b, _ := testCoreSystemBackend(t) + ctx := namespace.RootContext(nil) + + // Make a request without creating any billable resources + req := logical.TestRequest(t, logical.ReadOperation, "billing/overview") + resp, err := b.HandleRequest(ctx, req) + require.NoError(t, err) + require.NotNil(t, resp) + require.NotNil(t, resp.Data) + + // Verify the response structure exists + months, ok := resp.Data["months"].([]interface{}) + require.True(t, ok) + require.Len(t, months, 2) + + // Check current month has all metrics with zero values + currentMonth, ok := months[0].(map[string]interface{}) + require.True(t, ok) + require.Contains(t, currentMonth, "usage_metrics") + + usageMetrics, ok := currentMonth["usage_metrics"].([]map[string]interface{}) + require.True(t, ok) + require.NotNil(t, usageMetrics) + require.NotEmpty(t, usageMetrics, "usage_metrics should contain all metrics even with zero values") + + // Verify all expected metrics are present with zero/false values + expectedMetrics := map[string]bool{ + "static_secrets": false, + "dynamic_roles": false, + "auto_rotated_roles": false, + "kmip": false, + "external_plugins": false, + "data_protection_calls": false, + "pki_units": false, + "managed_keys": false, + } + + for _, metric := range usageMetrics { + metricName, ok := metric["metric_name"].(string) + require.True(t, ok, "metric_name should be a string") + require.Contains(t, expectedMetrics, metricName, "unexpected metric: %s", metricName) + expectedMetrics[metricName] = true + + metricData, ok := metric["metric_data"].(map[string]interface{}) + require.True(t, ok, "metric_data should be a map") + + // Verify each metric has appropriate zero value + switch metricName { + case "static_secrets", "dynamic_roles", "auto_rotated_roles": + total, ok := metricData["total"].(int) + require.True(t, ok, "%s total should be int", metricName) + require.Equal(t, 0, total, "%s total should be 0", metricName) + + details, ok := metricData["metric_details"].([]map[string]interface{}) + require.True(t, ok, "%s metric_details should be array", metricName) + require.Empty(t, details, "%s metric_details should be empty when total is 0", metricName) + + case "kmip": + used, ok := metricData["used_in_month"].(bool) + require.True(t, ok, "kmip used_in_month should be bool") + require.False(t, used, "kmip should be false when not used") + + case "external_plugins": + total, ok := metricData["total"].(int) + require.True(t, ok, "external_plugins total should be int") + require.Equal(t, 0, total, "external_plugins total should be 0") + + case "data_protection_calls": + total, ok := metricData["total"].(uint64) + require.True(t, ok, "data_protection_calls total should be uint64") + require.Equal(t, uint64(0), total, "data_protection_calls total should be 0") + + details, ok := metricData["metric_details"].([]map[string]interface{}) + require.True(t, ok, "data_protection_calls metric_details should be array") + require.Empty(t, details, "data_protection_calls metric_details should be empty when total is 0") + + case "pki_units": + total, ok := metricData["total"].(float64) + require.True(t, ok, "pki_units total should be float64") + require.Equal(t, float64(0), total, "data_protection_calls total should be 0") + + case "managed_keys": + total, ok := metricData["total"].(int) + require.True(t, ok, "managed_keys total should be float64") + require.Equal(t, int(0), total, "data_protection_calls total should be 0") + details, ok := metricData["metric_details"].([]map[string]interface{}) + require.True(t, ok, "%s metric_details should be array", metricName) + require.Empty(t, details, "%s metric_details should be empty when total is 0", metricName) + } + } + + // Verify all expected metrics were found + for metricName, found := range expectedMetrics { + require.True(t, found, "metric %s should be present", metricName) + } +} + +// TestSystemBackend_BillingOverview_MultipleMetricTypes tests the billing overview +// endpoint with multiple different metric types to ensure they all appear correctly +// in the response with their respective data structures. +func TestSystemBackend_BillingOverview_MultipleMetricTypes(t *testing.T) { + c, b, root := testCoreSystemBackend(t) + ctx := namespace.RootContext(nil) + currentMonth := time.Now() + + // Create KV secrets + req := logical.TestRequest(t, logical.UpdateOperation, "mounts/testkv") + req.Data = map[string]interface{}{ + "type": "kv-v2", + } + resp, err := b.HandleRequest(ctx, req) + require.NoError(t, err) + require.Nil(t, resp) + + kvReq := logical.TestRequest(t, logical.CreateOperation, "testkv/data/test") + kvReq.Data["data"] = map[string]interface{}{"foo": "bar"} + kvReq.ClientToken = root + _, err = c.HandleRequest(ctx, kvReq) + require.NoError(t, err) + + _, err = c.UpdateMaxKvCounts(ctx, billing.LocalPrefix, currentMonth) + require.NoError(t, err) + + // Make request to billing overview + req = logical.TestRequest(t, logical.ReadOperation, "billing/overview") + req.Data["refresh_data"] = true + resp, err = b.HandleRequest(ctx, req) + require.NoError(t, err) + require.NotNil(t, resp) + + months, ok := resp.Data["months"].([]interface{}) + require.True(t, ok) + require.Len(t, months, 2) + + currentMonthData, ok := months[0].(map[string]interface{}) + require.True(t, ok) + + usageMetrics, ok := currentMonthData["usage_metrics"].([]map[string]interface{}) + require.True(t, ok) + require.NotEmpty(t, usageMetrics) + + // Verify each metric has proper structure + for _, metric := range usageMetrics { + metricName, ok := metric["metric_name"].(string) + require.True(t, ok, "metric_name should be a string") + require.NotEmpty(t, metricName, "metric_name should not be empty") + + metricData, ok := metric["metric_data"].(map[string]interface{}) + require.True(t, ok, "metric_data should be a map") + require.NotEmpty(t, metricData, "metric_data should not be empty") + } + + // Verify we have at least the static_secrets metric from our KV secret + foundStaticSecrets := false + for _, metric := range usageMetrics { + if metricName, ok := metric["metric_name"].(string); ok && metricName == "static_secrets" { + foundStaticSecrets = true + break + } + } + require.True(t, foundStaticSecrets, "should have static_secrets metric from KV secret") +} + +// TestSystemBackend_BillingOverview_UpdatedAtTimestamp verifies that the updated_at +// timestamp behaves correctly based on whether data was refreshed. +func TestSystemBackend_BillingOverview_UpdatedAtTimestamp(t *testing.T) { + c, b, _ := testCoreSystemBackend(t) + ctx := namespace.RootContext(nil) + + // First, call with refresh_data set to set the LastMetricsUpdate timestamp + req := logical.TestRequest(t, logical.ReadOperation, "billing/overview") + req.Data["refresh_data"] = true + resp, err := b.HandleRequest(ctx, req) + require.NoError(t, err) + require.NotNil(t, resp) + + months, ok := resp.Data["months"].([]interface{}) + require.True(t, ok) + require.Len(t, months, 2) + + currentMonth, ok := months[0].(map[string]interface{}) + require.True(t, ok) + + // Get the updated_at timestamp from the first call + firstUpdatedAt, ok := currentMonth["updated_at"].(string) + require.True(t, ok) + firstTime, err := time.Parse(time.RFC3339, firstUpdatedAt) + require.NoError(t, err) + + // Verify LastMetricsUpdate was set + lastUpdate := c.consumptionBilling.LastMetricsUpdate.Load() + require.NotNil(t, lastUpdate, "LastMetricsUpdate should be set after refresh") + storedTime, ok := lastUpdate.(time.Time) + require.True(t, ok) + require.WithinDuration(t, firstTime, storedTime, time.Second, "stored timestamp should match response timestamp") + + // Wait a moment to ensure time difference + time.Sleep(100 * time.Millisecond) + + // Now call without refresh_data + req = logical.TestRequest(t, logical.ReadOperation, "billing/overview") + req.Data["refresh_data"] = false + resp, err = b.HandleRequest(ctx, req) + require.NoError(t, err) + require.NotNil(t, resp) + + months, ok = resp.Data["months"].([]interface{}) + require.True(t, ok) + require.Len(t, months, 2) + + currentMonth, ok = months[0].(map[string]interface{}) + require.True(t, ok) + + // Get the updated_at timestamp from the second call + secondUpdatedAt, ok := currentMonth["updated_at"].(string) + require.True(t, ok) + secondTime, err := time.Parse(time.RFC3339, secondUpdatedAt) + require.NoError(t, err) + + // The timestamp should be the same as the first call because we didn't refresh the data + require.WithinDuration(t, firstTime, secondTime, time.Second, + "updated_at without refresh should use stored LastMetricsUpdate timestamp") + + // Verify the timestamps are equal + require.Equal(t, firstUpdatedAt, secondUpdatedAt, + "updated_at without refresh should be identical to the stored timestamp") +}