diff --git a/changelog/21010.txt b/changelog/21010.txt new file mode 100644 index 000000000000..bcd218794df9 --- /dev/null +++ b/changelog/21010.txt @@ -0,0 +1,3 @@ +```release-note:improvement +core: Add a new periodic metric to track the number of available policies, `vault.policy.configured.count`. +``` \ No newline at end of file diff --git a/vault/core_metrics.go b/vault/core_metrics.go index 1b695c4f1d82..18086bc3b254 100644 --- a/vault/core_metrics.go +++ b/vault/core_metrics.go @@ -301,6 +301,12 @@ func (c *Core) emitMetricsActiveNode(stopCh chan struct{}) { c.activeEntityGaugeCollector, "", }, + { + []string{"policy", "configured", "count"}, + []metrics.Label{{"gauge", "number_policies_by_type"}}, + c.configuredPoliciesGaugeCollector, + "", + }, } // Disable collection if configured, or if we're a performance standby @@ -565,3 +571,41 @@ func (c *Core) inFlightReqGaugeMetric() { // Adding a gauge metric to capture total number of inflight requests c.metricSink.SetGaugeWithLabels([]string{"core", "in_flight_requests"}, float32(totalInFlightReq), nil) } + +// configuredPoliciesGaugeCollector is used to collect gauge label values for the `vault.policy.configured.count` metric +func (c *Core) configuredPoliciesGaugeCollector(ctx context.Context) ([]metricsutil.GaugeLabelValues, error) { + if c.policyStore == nil { + return []metricsutil.GaugeLabelValues{}, nil + } + + c.stateLock.RLock() + policyStore := c.policyStore + c.stateLock.RUnlock() + + ctx = namespace.RootContext(ctx) + namespaces := c.collectNamespaces() + + policyTypes := []PolicyType{ + PolicyTypeACL, + PolicyTypeRGP, + PolicyTypeEGP, + } + var values []metricsutil.GaugeLabelValues + + for _, pt := range policyTypes { + policies, err := policyStore.policiesByNamespaces(ctx, pt, namespaces) + if err != nil { + return []metricsutil.GaugeLabelValues{}, err + } + + v := metricsutil.GaugeLabelValues{} + v.Labels = []metricsutil.Label{{ + "policy_type", + pt.String(), + }} + v.Value = float32(len(policies)) + values = append(values, v) + } + + return values, nil +} diff --git a/vault/core_metrics_test.go b/vault/core_metrics_test.go index 07147ad3f471..7e31ab9d0baf 100644 --- a/vault/core_metrics_test.go +++ b/vault/core_metrics_test.go @@ -4,12 +4,16 @@ package vault import ( + "context" + "encoding/base64" "errors" "sort" "strings" "testing" "time" + "github.com/stretchr/testify/assert" + "github.com/armon/go-metrics" logicalKv "github.com/hashicorp/vault-plugin-secrets-kv" "github.com/hashicorp/vault/helper/namespace" @@ -350,3 +354,90 @@ func TestCoreMetrics_EntityGauges(t *testing.T) { "mount_point": "auth/userpass/", }) } + +// TestCoreMetrics_AvailablePolicies tests the that available metrics are getting correctly collected when the availablePoliciesGaugeCollector function is invoked +func TestCoreMetrics_AvailablePolicies(t *testing.T) { + aclPolicy := map[string]interface{}{ + "policy": base64.StdEncoding.EncodeToString([]byte(`path "ns1/secret/foo/*" { + capabilities = ["create", "read", "update", "delete", "list"] +}`)), + "name": "secret", + } + + type pathPolicy struct { + Path string + Policy map[string]interface{} + } + + tests := map[string]struct { + Policies []pathPolicy + ExpectedValues map[string]float32 + }{ + "single acl": { + Policies: []pathPolicy{ + { + "sys/policy/secret", aclPolicy, + }, + }, + ExpectedValues: map[string]float32{ + // The "default" policy will always be included + "acl": 2, + "egp": 0, + "rgp": 0, + }, + }, + "multiple acl": { + Policies: []pathPolicy{ + { + "sys/policy/secret", aclPolicy, + }, + { + "sys/policy/secret2", aclPolicy, + }, + }, + ExpectedValues: map[string]float32{ + // The "default" policy will always be included + "acl": 3, + "egp": 0, + "rgp": 0, + }, + }, + } + + for name, tst := range tests { + t.Run(name, func(t *testing.T) { + core, _, root := TestCoreUnsealed(t) + + ctxRoot := namespace.RootContext(context.Background()) + + // Create policies + for _, p := range tst.Policies { + req := logical.TestRequest(t, logical.UpdateOperation, p.Path) + req.Data = p.Policy + req.ClientToken = root + + resp, err := core.HandleRequest(ctxRoot, req) + if err != nil { + t.Fatalf("err: %v", err) + } + if resp != nil { + logger.Info("expected nil response", resp) + t.Fatalf("expected nil response") + } + } + + gValues, err := core.configuredPoliciesGaugeCollector(ctxRoot) + if err != nil { + t.Fatalf("err: %v", err) + } + + // Check the metrics values match the expected values + mgValues := make(map[string]float32, len(gValues)) + for _, v := range gValues { + mgValues[v.Labels[0].Value] = v.Value + } + + assert.EqualValues(t, tst.ExpectedValues, mgValues) + }) + } +} diff --git a/vault/policy_store.go b/vault/policy_store.go index edde91dcc2bf..e4c12e7b0f36 100644 --- a/vault/policy_store.go +++ b/vault/policy_store.go @@ -649,6 +649,60 @@ func (ps *PolicyStore) ListPolicies(ctx context.Context, policyType PolicyType) return keys, err } +// policiesByNamespace is used to list the available policies for the given namespace +func (ps *PolicyStore) policiesByNamespace(ctx context.Context, policyType PolicyType, ns *namespace.Namespace) ([]string, error) { + var err error + var keys []string + var view *BarrierView + + // Scan the view, since the policy names are the same as the + // key names. + switch policyType { + case PolicyTypeACL: + view = ps.getACLView(ns) + case PolicyTypeRGP: + view = ps.getRGPView(ns) + case PolicyTypeEGP: + view = ps.getEGPView(ns) + default: + return nil, fmt.Errorf("unknown policy type %q", policyType) + } + + if view == nil { + return nil, fmt.Errorf("unable to get the barrier subview for policy type %q", policyType) + } + + // Get the appropriate view based on policy type and namespace + ctx = namespace.ContextWithNamespace(ctx, ns) + keys, err = logical.CollectKeys(ctx, view) + if err != nil { + return nil, err + } + + if policyType == PolicyTypeACL { + // We only have non-assignable ACL policies at the moment + keys = strutil.Difference(keys, nonAssignablePolicies, false) + } + + return keys, err +} + +// policiesByNamespaces is used to list the available policies for the given namespaces +func (ps *PolicyStore) policiesByNamespaces(ctx context.Context, policyType PolicyType, ns []*namespace.Namespace) ([]string, error) { + var err error + var keys []string + + for _, nspace := range ns { + ks, err := ps.policiesByNamespace(ctx, policyType, nspace) + if err != nil { + return nil, err + } + keys = append(keys, ks...) + } + + return keys, err +} + // DeletePolicy is used to delete the named policy func (ps *PolicyStore) DeletePolicy(ctx context.Context, name string, policyType PolicyType) error { return ps.switchedDeletePolicy(ctx, name, policyType, true, false) diff --git a/vault/policy_store_test.go b/vault/policy_store_test.go index 624f2806783f..59e005d61270 100644 --- a/vault/policy_store_test.go +++ b/vault/policy_store_test.go @@ -319,3 +319,37 @@ func TestDefaultPolicy(t *testing.T) { }) } } + +// TestPolicyStore_PoliciesByNamespaces tests the policiesByNamespaces function, which should return a slice of policy names for a given slice of namespaces. +func TestPolicyStore_PoliciesByNamespaces(t *testing.T) { + _, ps := mockPolicyWithCore(t, false) + + ctxRoot := namespace.RootContext(context.Background()) + rootNs := namespace.RootNamespace + + parsedPolicy, _ := ParseACLPolicy(rootNs, aclPolicy) + + err := ps.SetPolicy(ctxRoot, parsedPolicy) + if err != nil { + t.Fatalf("err: %v", err) + } + + // Get should work + pResult, err := ps.GetPolicy(ctxRoot, "dev", PolicyTypeACL) + if err != nil { + t.Fatalf("err: %v", err) + } + if !reflect.DeepEqual(pResult, parsedPolicy) { + t.Fatalf("bad: %v", pResult) + } + + out, err := ps.policiesByNamespaces(ctxRoot, PolicyTypeACL, []*namespace.Namespace{rootNs}) + if err != nil { + t.Fatalf("err: %v", err) + } + + expectedResult := []string{"default", "dev"} + if !reflect.DeepEqual(expectedResult, out) { + t.Fatalf("expected: %v\ngot: %v", expectedResult, out) + } +}