1
0
vault-redux/vault/logical_system_quotas.go
2024-01-02 10:36:20 -08:00

462 lines
14 KiB
Go

// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package vault
import (
"context"
"net/http"
"strings"
"time"
"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/logical"
"github.com/hashicorp/vault/helper/namespace"
"github.com/hashicorp/vault/vault/quotas"
)
// quotasPaths returns paths that enable quota management
func (b *SystemBackend) quotasPaths() []*framework.Path {
return []*framework.Path{
{
Pattern: "quotas/config$",
DisplayAttrs: &framework.DisplayAttributes{
OperationPrefix: "rate-limit-quotas",
},
Fields: map[string]*framework.FieldSchema{
"rate_limit_exempt_paths": {
Type: framework.TypeStringSlice,
Description: "Specifies the list of exempt paths from all rate limit quotas. If empty no paths will be exempt.",
},
"enable_rate_limit_audit_logging": {
Type: framework.TypeBool,
Description: "If set, starts audit logging of requests that get rejected due to rate limit quota rule violations.",
},
"enable_rate_limit_response_headers": {
Type: framework.TypeBool,
Description: "If set, additional rate limit quota HTTP headers will be added to responses.",
},
},
Operations: map[logical.Operation]framework.OperationHandler{
logical.UpdateOperation: &framework.PathOperation{
Callback: b.handleQuotasConfigUpdate(),
DisplayAttrs: &framework.DisplayAttributes{
OperationVerb: "configure",
},
Responses: map[int][]framework.Response{
http.StatusNoContent: {{
Description: "OK",
}},
},
},
logical.ReadOperation: &framework.PathOperation{
Callback: b.handleQuotasConfigRead(),
DisplayAttrs: &framework.DisplayAttributes{
OperationSuffix: "configuration",
},
Responses: map[int][]framework.Response{
http.StatusOK: {{
Description: "OK",
Fields: map[string]*framework.FieldSchema{
"enable_rate_limit_audit_logging": {
Type: framework.TypeBool,
Required: true,
},
"enable_rate_limit_response_headers": {
Type: framework.TypeBool,
Required: true,
},
"rate_limit_exempt_paths": {
Type: framework.TypeStringSlice,
Required: true,
},
},
}},
},
},
},
HelpSynopsis: strings.TrimSpace(quotasHelp["quotas-config"][0]),
HelpDescription: strings.TrimSpace(quotasHelp["quotas-config"][1]),
},
{
Pattern: "quotas/rate-limit/?$",
DisplayAttrs: &framework.DisplayAttributes{
OperationPrefix: "rate-limit-quotas",
OperationVerb: "list",
},
Operations: map[logical.Operation]framework.OperationHandler{
logical.ListOperation: &framework.PathOperation{
Callback: b.handleRateLimitQuotasList(),
Responses: map[int][]framework.Response{
http.StatusOK: {{
Description: "OK",
Fields: map[string]*framework.FieldSchema{
"keys": {
Type: framework.TypeStringSlice,
Required: true,
},
},
}},
},
},
},
HelpSynopsis: strings.TrimSpace(quotasHelp["rate-limit-list"][0]),
HelpDescription: strings.TrimSpace(quotasHelp["rate-limit-list"][1]),
},
{
Pattern: "quotas/rate-limit/" + framework.GenericNameRegex("name"),
DisplayAttrs: &framework.DisplayAttributes{
OperationPrefix: "rate-limit-quotas",
},
Fields: map[string]*framework.FieldSchema{
"type": {
Type: framework.TypeString,
Description: "Type of the quota rule.",
},
"name": {
Type: framework.TypeString,
Description: "Name of the quota rule.",
},
"path": {
Type: framework.TypeString,
Description: `Path of the mount or namespace to apply the quota. A blank path configures a
global quota. For example namespace1/ adds a quota to a full namespace,
namespace1/auth/userpass adds a quota to userpass in namespace1.`,
},
"role": {
Type: framework.TypeString,
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.`,
},
"rate": {
Type: framework.TypeFloat,
Description: `The maximum number of requests in a given interval to be allowed by the quota rule.
The 'rate' must be positive.`,
},
"interval": {
Type: framework.TypeDurationSecond,
Description: "The duration to enforce rate limiting for (default '1s').",
},
"block_interval": {
Type: framework.TypeDurationSecond,
Description: `If set, when a client reaches a rate limit threshold, the client will be prohibited
from any further requests until after the 'block_interval' has elapsed.`,
},
},
Operations: map[logical.Operation]framework.OperationHandler{
logical.UpdateOperation: &framework.PathOperation{
Callback: b.handleRateLimitQuotasUpdate(),
DisplayAttrs: &framework.DisplayAttributes{
OperationVerb: "write",
},
Responses: map[int][]framework.Response{
http.StatusNoContent: {{
Description: http.StatusText(http.StatusNoContent),
}},
},
},
logical.ReadOperation: &framework.PathOperation{
Callback: b.handleRateLimitQuotasRead(),
DisplayAttrs: &framework.DisplayAttributes{
OperationVerb: "read",
},
Responses: map[int][]framework.Response{
http.StatusOK: {{
Description: "OK",
Fields: map[string]*framework.FieldSchema{
"type": {
Type: framework.TypeString,
Required: true,
},
"name": {
Type: framework.TypeString,
Required: true,
},
"path": {
Type: framework.TypeString,
Required: true,
},
"role": {
Type: framework.TypeString,
Required: true,
},
"rate": {
Type: framework.TypeFloat,
Required: true,
},
"interval": {
Type: framework.TypeInt,
Required: true,
},
"block_interval": {
Type: framework.TypeInt,
Required: true,
},
},
}},
},
},
logical.DeleteOperation: &framework.PathOperation{
Callback: b.handleRateLimitQuotasDelete(),
DisplayAttrs: &framework.DisplayAttributes{
OperationVerb: "delete",
},
Responses: map[int][]framework.Response{
http.StatusNoContent: {{
Description: "OK",
}},
},
},
},
HelpSynopsis: strings.TrimSpace(quotasHelp["rate-limit"][0]),
HelpDescription: strings.TrimSpace(quotasHelp["rate-limit"][1]),
},
}
}
func (b *SystemBackend) handleQuotasConfigUpdate() framework.OperationFunc {
return func(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
config, err := quotas.LoadConfig(ctx, b.Core.systemBarrierView)
if err != nil {
return nil, err
}
config.EnableRateLimitAuditLogging = d.Get("enable_rate_limit_audit_logging").(bool)
config.EnableRateLimitResponseHeaders = d.Get("enable_rate_limit_response_headers").(bool)
config.RateLimitExemptPaths = d.Get("rate_limit_exempt_paths").([]string)
entry, err := logical.StorageEntryJSON(quotas.ConfigPath, config)
if err != nil {
return nil, err
}
if err := req.Storage.Put(ctx, entry); err != nil {
return nil, err
}
entry, err = logical.StorageEntryJSON(quotas.DefaultRateLimitExemptPathsToggle, true)
if err != nil {
return nil, err
}
if err := req.Storage.Put(ctx, entry); err != nil {
return nil, err
}
b.Core.quotaManager.SetEnableRateLimitAuditLogging(config.EnableRateLimitAuditLogging)
b.Core.quotaManager.SetEnableRateLimitResponseHeaders(config.EnableRateLimitResponseHeaders)
b.Core.quotaManager.SetRateLimitExemptPaths(config.RateLimitExemptPaths)
return nil, nil
}
}
func (b *SystemBackend) handleQuotasConfigRead() framework.OperationFunc {
return func(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
config := b.Core.quotaManager.Config()
return &logical.Response{
Data: map[string]interface{}{
"enable_rate_limit_audit_logging": config.EnableRateLimitAuditLogging,
"enable_rate_limit_response_headers": config.EnableRateLimitResponseHeaders,
"rate_limit_exempt_paths": config.RateLimitExemptPaths,
},
}, nil
}
}
func (b *SystemBackend) handleRateLimitQuotasList() framework.OperationFunc {
return func(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
names, err := b.Core.quotaManager.QuotaNames(quotas.TypeRateLimit)
if err != nil {
return nil, err
}
return logical.ListResponse(names), nil
}
}
func (b *SystemBackend) handleRateLimitQuotasUpdate() framework.OperationFunc {
return func(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
name := d.Get("name").(string)
qType := quotas.TypeRateLimit.String()
rate := d.Get("rate").(float64)
if rate <= 0 {
return logical.ErrorResponse("'rate' is invalid"), nil
}
interval := time.Second * time.Duration(d.Get("interval").(int))
if interval == 0 {
interval = time.Second
}
blockInterval := time.Second * time.Duration(d.Get("block_interval").(int))
if blockInterval < 0 {
return logical.ErrorResponse("'block' is invalid"), nil
}
mountPath := sanitizePath(d.Get("path").(string))
ns := b.Core.namespaceByPath(mountPath)
if ns.ID != namespace.RootNamespaceID {
mountPath = strings.TrimPrefix(mountPath, ns.Path)
}
var pathSuffix string
if mountPath != "" {
me := b.Core.router.MatchingMountEntry(namespace.ContextWithNamespace(ctx, ns), mountPath)
if me == nil {
return logical.ErrorResponse("invalid mount path %q", mountPath), nil
}
mountAPIPath := me.APIPathNoNamespace()
pathSuffix = strings.TrimSuffix(strings.TrimPrefix(mountPath, mountAPIPath), "/")
mountPath = mountAPIPath
}
role := d.Get("role").(string)
// If this is a quota with a role, ensure the backend supports role resolution
if role != "" {
if pathSuffix != "" {
return logical.ErrorResponse("Quotas cannot contain both a path suffix and a role. If a role is provided, path must be a valid auth mount with a concept of roles"), nil
}
authBackend := b.Core.router.MatchingBackend(namespace.ContextWithNamespace(ctx, ns), mountPath)
if authBackend == nil || authBackend.Type() != logical.TypeCredential {
return logical.ErrorResponse("Mount path %q is not a valid auth method and therefore unsuitable for use with role-based quotas", mountPath), nil
}
// We will always error as we aren't supplying real data, but we're looking for "unsupported operation" in particular
_, err := authBackend.HandleRequest(ctx, &logical.Request{
Path: "login",
Operation: logical.ResolveRoleOperation,
})
if err != nil && (err == logical.ErrUnsupportedOperation || err == logical.ErrUnsupportedPath) {
return logical.ErrorResponse("Mount path %q does not support use with role-based quotas", mountPath), 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)
if err != nil {
return nil, err
}
if quotaByFactors != nil && quotaByFactors.QuotaName() != name {
return logical.ErrorResponse("quota rule with similar properties exists under the name %q", quotaByFactors.QuotaName()), nil
}
// If a quota already exists, fetch and update it.
quota, err := b.Core.quotaManager.QuotaByName(qType, name)
if err != nil {
return nil, err
}
switch {
case quota == nil:
quota = quotas.NewRateLimitQuota(name, ns.Path, mountPath, pathSuffix, role, rate, interval, blockInterval)
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.
clonedQuota := quota.Clone()
rlq := clonedQuota.(*quotas.RateLimitQuota)
rlq.NamespacePath = ns.Path
rlq.MountPath = mountPath
rlq.PathSuffix = pathSuffix
rlq.Rate = rate
rlq.Interval = interval
rlq.BlockInterval = blockInterval
quota = rlq
}
entry, err := logical.StorageEntryJSON(quotas.QuotaStoragePath(qType, name), quota)
if err != nil {
return nil, err
}
if err := req.Storage.Put(ctx, entry); err != nil {
return nil, err
}
if err := b.Core.quotaManager.SetQuota(ctx, qType, quota, false); err != nil {
return nil, err
}
return nil, nil
}
}
func (b *SystemBackend) handleRateLimitQuotasRead() framework.OperationFunc {
return func(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
name := d.Get("name").(string)
qType := quotas.TypeRateLimit.String()
quota, err := b.Core.quotaManager.QuotaByName(qType, name)
if err != nil {
return nil, err
}
if quota == nil {
return nil, nil
}
rlq := quota.(*quotas.RateLimitQuota)
nsPath := rlq.NamespacePath
if rlq.NamespacePath == "root" {
nsPath = ""
}
data := map[string]interface{}{
"type": qType,
"name": rlq.Name,
"path": nsPath + rlq.MountPath + rlq.PathSuffix,
"role": rlq.Role,
"rate": rlq.Rate,
"interval": int(rlq.Interval.Seconds()),
"block_interval": int(rlq.BlockInterval.Seconds()),
}
return &logical.Response{
Data: data,
}, nil
}
}
func (b *SystemBackend) handleRateLimitQuotasDelete() framework.OperationFunc {
return func(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
name := d.Get("name").(string)
qType := quotas.TypeRateLimit.String()
if err := req.Storage.Delete(ctx, quotas.QuotaStoragePath(qType, name)); err != nil {
return nil, err
}
if err := b.Core.quotaManager.DeleteQuota(ctx, qType, name); err != nil {
return nil, err
}
return nil, nil
}
}
var quotasHelp = map[string][2]string{
"quotas-config": {
"Create, update and read the quota configuration.",
"",
},
"rate-limit": {
`Get, create or update rate limit resource quota for an optional namespace or
mount.`,
`A rate limit quota will enforce API rate limiting in a specified interval. A
rate limit quota can be created at the root level or defined on a namespace or
mount by specifying a 'path'. The rate limiter is applied to each unique client
IP address.`,
},
"rate-limit-list": {
"Lists the names of all the rate limit quotas.",
"This list contains quota definitions from all the namespaces.",
},
}