diff --git a/changelog/22452.txt b/changelog/22452.txt new file mode 100644 index 0000000000..88657b284d --- /dev/null +++ b/changelog/22452.txt @@ -0,0 +1,3 @@ +```release-note:improvement +core : Add field that allows rate-limit namespace quotas to be inherited by child namespaces. +``` diff --git a/vault/logical_system_quotas.go b/vault/logical_system_quotas.go index 41dcda1f2e..1cc5be3a5e 100644 --- a/vault/logical_system_quotas.go +++ b/vault/logical_system_quotas.go @@ -124,6 +124,10 @@ namespace1/auth/userpass adds a quota to userpass in namespace1.`, Description: `Login role to apply this quota to. Note that when set, path must be configured to a valid auth method with a concept of roles.`, }, + "inheritable": { + Type: framework.TypeBool, + Description: `Whether all child namespaces can inherit this namespace quota.`, + }, "rate": { Type: framework.TypeFloat, Description: `The maximum number of requests in a given interval to be allowed by the quota rule. @@ -188,6 +192,10 @@ from any further requests until after the 'block_interval' has elapsed.`, Type: framework.TypeInt, Required: true, }, + "inheritable": { + Type: framework.TypeBool, + Required: true, + }, }, }}, }, @@ -327,6 +335,29 @@ func (b *SystemBackend) handleRateLimitQuotasUpdate() framework.OperationFunc { } } + var inheritable bool + // All global quotas should be inherited by default + if ns.Path == "" { + inheritable = true + } + + if inheritableRaw, ok := d.GetOk("inheritable"); ok { + inheritable = inheritableRaw.(bool) + if inheritable { + if pathSuffix != "" || role != "" || mountPath != "" { + return logical.ErrorResponse("only namespace quotas can be configured as inheritable"), nil + } + } else if ns.Path == "" { + // User should not try to configure a global quota that cannot be inherited + return logical.ErrorResponse("all global quotas must be inheritable"), nil + } + } + + // User should not try to configure a global quota to be uninheritable + if ns.Path == "" && !inheritable { + return logical.ErrorResponse("all global quotas must be inheritable"), nil + } + // Disallow creation of new quota that has properties similar to an // existing quota. quotaByFactors, err := b.Core.quotaManager.QuotaByFactors(ctx, qType, ns.Path, mountPath, pathSuffix, role) @@ -345,7 +376,7 @@ func (b *SystemBackend) handleRateLimitQuotasUpdate() framework.OperationFunc { switch { case quota == nil: - quota = quotas.NewRateLimitQuota(name, ns.Path, mountPath, pathSuffix, role, rate, interval, blockInterval) + quota = quotas.NewRateLimitQuota(name, ns.Path, mountPath, pathSuffix, role, inheritable, interval, blockInterval, rate) default: // Re-inserting the already indexed object in memdb might cause problems. // So, clone the object. See https://github.com/hashicorp/go-memdb/issues/76. @@ -355,6 +386,7 @@ func (b *SystemBackend) handleRateLimitQuotasUpdate() framework.OperationFunc { rlq.MountPath = mountPath rlq.PathSuffix = pathSuffix rlq.Rate = rate + rlq.Inheritable = inheritable rlq.Interval = interval rlq.BlockInterval = blockInterval quota = rlq @@ -403,6 +435,7 @@ func (b *SystemBackend) handleRateLimitQuotasRead() framework.OperationFunc { "path": nsPath + rlq.MountPath + rlq.PathSuffix, "role": rlq.Role, "rate": rlq.Rate, + "inheritable": rlq.Inheritable, "interval": int(rlq.Interval.Seconds()), "block_interval": int(rlq.BlockInterval.Seconds()), } diff --git a/vault/quotas/quotas.go b/vault/quotas/quotas.go index cde9e1911f..369c0ad5c0 100644 --- a/vault/quotas/quotas.go +++ b/vault/quotas/quotas.go @@ -211,6 +211,9 @@ type Quota interface { // Clone creates a clone of the calling quota Clone() Quota + // Inheritable indicates if this quota can be applied to child namespaces + IsInheritable() bool + // handleRemount updates the mount and namesapce paths of the quota handleRemount(string, string) } @@ -580,10 +583,24 @@ func (m *Manager) queryQuota(txn *memdb.Txn, req *Request) (Quota, error) { return quota, nil } + // Fetch parent ns quotas + curNsSplitPath := strings.SplitAfter(namespace.Canonicalize(req.NamespacePath), "/") + for len(curNsSplitPath) > 2 { + parentNs := strings.Join(curNsSplitPath[0:len(curNsSplitPath)-2], "") + parentQuota, err := quotaFetchFunc(indexNamespace, parentNs, false, false, false) + if err != nil { + return nil, err + } + if parentQuota != nil && parentQuota.IsInheritable() { + return parentQuota, nil + } + curNsSplitPath = strings.SplitAfter(parentNs, "/") + } + // If the request belongs to "root" namespace, then we have already looked at // global quotas when fetching namespace specific quota rule. When the request // belongs to a non-root namespace, and when there are no namespace specific - // quota rules present, we fallback on the global quotas. + // quota rules present, we fall back on the global quotas. if req.NamespacePath == "root" { return nil, nil } diff --git a/vault/quotas/quotas_rate_limit.go b/vault/quotas/quotas_rate_limit.go index 2f8ee0cd39..7a66926ff5 100644 --- a/vault/quotas/quotas_rate_limit.go +++ b/vault/quotas/quotas_rate_limit.go @@ -64,6 +64,9 @@ type RateLimitQuota struct { // PathSuffix is the path suffix to which this quota is applicable PathSuffix string `json:"path_suffix"` + // Inheritable indicates whether the quota will be inherited by child namespaces + Inheritable bool `json:"inheritable"` + // Rate defines the number of requests allowed per Interval. Rate float64 `json:"rate"` @@ -91,7 +94,7 @@ type RateLimitQuota struct { // provided, which will default to 1s when initialized. An optional block // duration may be provided, where if set, when a client reaches the rate limit, // subsequent requests will fail until the block duration has passed. -func NewRateLimitQuota(name, nsPath, mountPath, pathSuffix, role string, rate float64, interval, block time.Duration) *RateLimitQuota { +func NewRateLimitQuota(name, nsPath, mountPath, pathSuffix, role string, inheritable bool, interval, block time.Duration, rate float64) *RateLimitQuota { id, err := uuid.GenerateUUID() if err != nil { // Fall back to generating with a hash of the name, later in initialize @@ -105,6 +108,7 @@ func NewRateLimitQuota(name, nsPath, mountPath, pathSuffix, role string, rate fl MountPath: mountPath, Role: role, PathSuffix: pathSuffix, + Inheritable: inheritable, Rate: rate, Interval: interval, BlockInterval: block, @@ -119,6 +123,7 @@ func (q *RateLimitQuota) Clone() Quota { Name: q.Name, MountPath: q.MountPath, Role: q.Role, + Inheritable: q.Inheritable, Type: q.Type, NamespacePath: q.NamespacePath, PathSuffix: q.PathSuffix, @@ -129,6 +134,10 @@ func (q *RateLimitQuota) Clone() Quota { return rlq } +func (q *RateLimitQuota) IsInheritable() bool { + return q.Inheritable +} + // initialize ensures the namespace and max requests are initialized, sets the ID // if it's currently empty, sets the purge interval and stale age to default // values, and finally starts the client purge go routine if it has been started diff --git a/vault/quotas/quotas_rate_limit_test.go b/vault/quotas/quotas_rate_limit_test.go index f0e2a19c75..940a92c973 100644 --- a/vault/quotas/quotas_rate_limit_test.go +++ b/vault/quotas/quotas_rate_limit_test.go @@ -30,7 +30,7 @@ func TestNewRateLimitQuota(t *testing.T) { rlq *RateLimitQuota expectErr bool }{ - {"valid rate", NewRateLimitQuota("test-rate-limiter", "qa", "/foo/bar", "", "", 16.7, time.Second, 0), false}, + {"valid rate", NewRateLimitQuota("test-rate-limiter", "qa", "/foo/bar", "", "", false, time.Second, 0, 16.7), false}, } for _, tc := range testCases { @@ -47,7 +47,7 @@ func TestNewRateLimitQuota(t *testing.T) { } func TestRateLimitQuota_Close(t *testing.T) { - rlq := NewRateLimitQuota("test-rate-limiter", "qa", "/foo/bar", "", "", 16.7, time.Second, time.Minute) + rlq := NewRateLimitQuota("test-rate-limiter", "qa", "/foo/bar", "", "", false, time.Second, time.Minute, 16.7) require.NoError(t, rlq.initialize(logging.NewVaultLogger(log.Trace), metricsutil.BlackholeSink())) require.NoError(t, rlq.close(context.Background())) @@ -221,7 +221,7 @@ func TestRateLimitQuota_Update(t *testing.T) { qm, err := NewManager(logging.NewVaultLogger(log.Trace), nil, metricsutil.BlackholeSink()) require.NoError(t, err) - quota := NewRateLimitQuota("quota1", "", "", "", "", 10, time.Second, 0) + quota := NewRateLimitQuota("quota1", "", "", "", "", false, time.Second, 0, 10) require.NoError(t, qm.SetQuota(context.Background(), TypeRateLimit.String(), quota, true)) require.NoError(t, qm.SetQuota(context.Background(), TypeRateLimit.String(), quota, true)) diff --git a/vault/quotas/quotas_test.go b/vault/quotas/quotas_test.go index 3276d88990..296c273ea4 100644 --- a/vault/quotas/quotas_test.go +++ b/vault/quotas/quotas_test.go @@ -19,7 +19,7 @@ func TestQuotas_MountPathOverwrite(t *testing.T) { qm, err := NewManager(logging.NewVaultLogger(log.Trace), nil, metricsutil.BlackholeSink()) require.NoError(t, err) - quota := NewRateLimitQuota("tq", "", "kv1/", "", "", 10, time.Second, 0) + quota := NewRateLimitQuota("tq", "", "kv1/", "", "", false, time.Second, 0, 10) require.NoError(t, qm.SetQuota(context.Background(), TypeRateLimit.String(), quota, false)) quota = quota.Clone().(*RateLimitQuota) quota.MountPath = "kv2/" @@ -46,9 +46,9 @@ func TestQuotas_Precedence(t *testing.T) { qm, err := NewManager(logging.NewVaultLogger(log.Trace), nil, metricsutil.BlackholeSink()) require.NoError(t, err) - setQuotaFunc := func(t *testing.T, name, nsPath, mountPath, pathSuffix, role string) Quota { + setQuotaFunc := func(t *testing.T, name, nsPath, mountPath, pathSuffix, role string, inheritable bool) Quota { t.Helper() - quota := NewRateLimitQuota(name, nsPath, mountPath, pathSuffix, role, 10, time.Second, 0) + quota := NewRateLimitQuota(name, nsPath, mountPath, pathSuffix, role, inheritable, time.Second, 0, 10) require.NoError(t, qm.SetQuota(context.Background(), TypeRateLimit.String(), quota, true)) return quota } @@ -73,47 +73,65 @@ func TestQuotas_Precedence(t *testing.T) { checkQuotaFunc(t, "", "", "", "", nil) // Define global quota and expect that to be returned. - rateLimitGlobalQuota := setQuotaFunc(t, "rateLimitGlobalQuota", "", "", "", "") + rateLimitGlobalQuota := setQuotaFunc(t, "rateLimitGlobalQuota", "", "", "", "", true) checkQuotaFunc(t, "", "", "", "", rateLimitGlobalQuota) // Define a global mount specific quota and expect that to be returned. - rateLimitGlobalMountQuota := setQuotaFunc(t, "rateLimitGlobalMountQuota", "", "testmount/", "", "") + rateLimitGlobalMountQuota := setQuotaFunc(t, "rateLimitGlobalMountQuota", "", "testmount/", "", "", false) checkQuotaFunc(t, "", "testmount/", "", "", rateLimitGlobalMountQuota) // Define a global mount + path specific quota and expect that to be returned. - rateLimitGlobalMountPathQuota := setQuotaFunc(t, "rateLimitGlobalMountPathQuota", "", "testmount/", "testpath", "") + rateLimitGlobalMountPathQuota := setQuotaFunc(t, "rateLimitGlobalMountPathQuota", "", "testmount/", "testpath", "", false) checkQuotaFunc(t, "", "testmount/", "testpath", "", rateLimitGlobalMountPathQuota) // Define a namespace quota and expect that to be returned. - rateLimitNSQuota := setQuotaFunc(t, "rateLimitNSQuota", "testns/", "", "", "") + rateLimitNSQuota := setQuotaFunc(t, "rateLimitNSQuota", "testns/", "", "", "", false) checkQuotaFunc(t, "testns/", "", "", "", rateLimitNSQuota) // Define a namespace mount specific quota and expect that to be returned. - rateLimitNSMountQuota := setQuotaFunc(t, "rateLimitNSMountQuota", "testns/", "testmount/", "", "") + rateLimitNSMountQuota := setQuotaFunc(t, "rateLimitNSMountQuota", "testns/", "testmount/", "", "", false) checkQuotaFunc(t, "testns/", "testmount/", "testpath", "", rateLimitNSMountQuota) // Define a namespace mount + glob and expect that to be returned. - rateLimitNSMountGlob := setQuotaFunc(t, "rateLimitNSMountGlob", "testns/", "testmount/", "*", "") + rateLimitNSMountGlob := setQuotaFunc(t, "rateLimitNSMountGlob", "testns/", "testmount/", "*", "", false) checkQuotaFunc(t, "testns/", "testmount/", "testpath", "", rateLimitNSMountGlob) // Define a namespace mount + path specific quota with a glob and expect that to be returned. - rateLimitNSMountPathSuffixGlob := setQuotaFunc(t, "rateLimitNSMountPathSuffixGlob", "testns/", "testmount/", "test*", "") + rateLimitNSMountPathSuffixGlob := setQuotaFunc(t, "rateLimitNSMountPathSuffixGlob", "testns/", "testmount/", "test*", "", false) checkQuotaFunc(t, "testns/", "testmount/", "testpath", "", rateLimitNSMountPathSuffixGlob) // Define a namespace mount + path specific quota with a glob at the end of the path and expect that to be returned. - rateLimitNSMountPathSuffixGlobAfterPath := setQuotaFunc(t, "rateLimitNSMountPathSuffixGlobAfterPath", "testns/", "testmount/", "testpath*", "") + rateLimitNSMountPathSuffixGlobAfterPath := setQuotaFunc(t, "rateLimitNSMountPathSuffixGlobAfterPath", "testns/", "testmount/", "testpath*", "", false) checkQuotaFunc(t, "testns/", "testmount/", "testpath", "", rateLimitNSMountPathSuffixGlobAfterPath) // Define a namespace mount + path specific quota and expect that to be returned. - rateLimitNSMountPathQuota := setQuotaFunc(t, "rateLimitNSMountPathQuota", "testns/", "testmount/", "testpath", "") + rateLimitNSMountPathQuota := setQuotaFunc(t, "rateLimitNSMountPathQuota", "testns/", "testmount/", "testpath", "", false) checkQuotaFunc(t, "testns/", "testmount/", "testpath", "", rateLimitNSMountPathQuota) // Define a namespace mount + role specific quota and expect that to be returned. - rateLimitNSMountRoleQuota := setQuotaFunc(t, "rateLimitNSMountPathQuota", "testns/", "testmount/", "", "role") + rateLimitNSMountRoleQuota := setQuotaFunc(t, "rateLimitNSMountPathQuota", "testns/", "testmount/", "", "role", false) checkQuotaFunc(t, "testns/", "testmount/", "", "role", rateLimitNSMountRoleQuota) + // Create an inheritable namespace quota and expect that to be returned on a child namespace + rateLimitNSInheritableQuota := setQuotaFunc(t, "rateLimitNSInheritableNSQuota", "testns/nested2/", "", "", "", true) + checkQuotaFunc(t, "testns/nested2/nested3/", "testmount/", "", "", rateLimitNSInheritableQuota) + checkQuotaFunc(t, "testns/nested2/nested3/nested4/", "testmount/", "", "", rateLimitNSInheritableQuota) + + // Create a non-namespace quota on a nested namespace and make sure it takes precedence over the inherited quota + rateLimitNonNSNestedQuota := setQuotaFunc(t, "rateLimitNonNSNestedQuota", "testns/nested2/nested3/", "testmount/", "", "", false) + checkQuotaFunc(t, "testns/nested2/nested3/", "testmount/", "", "", rateLimitNonNSNestedQuota) + + // Create a non-namespace quota on a nested namespace and make sure it takes precedence over the inherited quota + rateLimitMultiNestedNsInheritableQuota := setQuotaFunc(t, "rateLimitNSInheritableNSQuota", "testns/nested2/nested3/", "", "", "", true) + checkQuotaFunc(t, "testns/nested2/nested3/nested4/", "testmount/", "", "", rateLimitMultiNestedNsInheritableQuota) + // Now that many quota types are defined, verify that the most specific // matches are returned per namespace. checkQuotaFunc(t, "", "", "", "", rateLimitGlobalQuota) checkQuotaFunc(t, "testns/", "", "", "", rateLimitNSQuota) + checkQuotaFunc(t, "testns/nested1/", "", "", "", rateLimitGlobalQuota) + checkQuotaFunc(t, "testns/nested2/", "", "", "", rateLimitNSInheritableQuota) + checkQuotaFunc(t, "testns/nested2/nested6/", "", "", "", rateLimitNSInheritableQuota) + checkQuotaFunc(t, "testns/nested2/nested3/", "", "", "", rateLimitMultiNestedNsInheritableQuota) + checkQuotaFunc(t, "testns/nested2/nested3/nested4/nested5", "", "", "", rateLimitMultiNestedNsInheritableQuota) } diff --git a/vault/quotas/quotas_util.go b/vault/quotas/quotas_util.go index 8391393c1a..e758a148bb 100644 --- a/vault/quotas/quotas_util.go +++ b/vault/quotas/quotas_util.go @@ -11,7 +11,7 @@ import ( log "github.com/hashicorp/go-hclog" "github.com/hashicorp/vault/helper/metricsutil" - memdb "github.com/hashicorp/go-memdb" + "github.com/hashicorp/go-memdb" ) func quotaTypes() []string { @@ -43,6 +43,10 @@ func (*entManager) Reset() error { type LeaseCountQuota struct{} +func (l LeaseCountQuota) IsInheritable() bool { + panic("implement me") +} + func (l LeaseCountQuota) allow(_ context.Context, _ *Request) (Response, error) { panic("implement me") }