Quota Inheritance for Rate Limit (OSS) (#22452)

* Quota Inheritance for Rate Limit (OSS)

* Added changelog

* Fix lease quota inheritance issue

* Edit formatting
This commit is contained in:
divyaac 2023-08-18 15:33:46 -07:00 committed by GitHub
parent 8250fba0e6
commit 98523a6874
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 104 additions and 20 deletions

3
changelog/22452.txt Normal file
View file

@ -0,0 +1,3 @@
```release-note:improvement
core : Add field that allows rate-limit namespace quotas to be inherited by child namespaces.
```

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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