Moved common endpointer packages to platform dir. (#37780)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #37192 - Move /server/service/middleware/endpoint_utils to /server/platform/endpointer - Move /server/service/middleware/authzcheck to /server/platform/middleware/authzcheck - Move /server/service/middleware/ratelimit to /server/platform/middleware/ratelimit # Checklist for submitter - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Release Notes * **Refactor** * Reorganized internal endpoint utilities to a centralized platform location for improved code organization and maintainability. No functional changes to existing features or APIs. <sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
// Package authzcheck implements a middleware that ensures that an authorization
|
||||
// check was performed. This does not ensure that the correct authorization
|
||||
// check was performed, but offers a backstop in case that a developer misses a
|
||||
// check.
|
||||
package authzcheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz"
|
||||
platform_http "github.com/fleetdm/fleet/v4/server/platform/http"
|
||||
"github.com/go-kit/kit/endpoint"
|
||||
)
|
||||
|
||||
// Middleware is the authzcheck middleware type.
|
||||
type Middleware struct{}
|
||||
|
||||
// NewMiddleware returns a new authzcheck middleware.
|
||||
func NewMiddleware() *Middleware {
|
||||
return &Middleware{}
|
||||
}
|
||||
|
||||
func (m *Middleware) AuthzCheck() endpoint.Middleware {
|
||||
return func(next endpoint.Endpoint) endpoint.Endpoint {
|
||||
return func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
authzctx := &authz_ctx.AuthorizationContext{}
|
||||
ctx = authz_ctx.NewContext(ctx, authzctx)
|
||||
|
||||
response, err := next(ctx, req)
|
||||
|
||||
// If authentication check failed, return that error (so that we log
|
||||
// appropriately).
|
||||
var authFailedError *platform_http.AuthFailedError
|
||||
var authRequiredError *platform_http.AuthRequiredError
|
||||
var authHeaderRequiredError *platform_http.AuthHeaderRequiredError
|
||||
if errors.As(err, &authFailedError) ||
|
||||
errors.As(err, &authRequiredError) ||
|
||||
errors.As(err, &authHeaderRequiredError) ||
|
||||
errors.Is(err, platform_http.ErrPasswordResetRequired) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// TODO(mna): currently, any error detected before an authorization check gets
|
||||
// lost and the response is always Unauthorized because of the following condition.
|
||||
// I _think_ it would be safe to check here of response.error() returns a non-nil
|
||||
// error and if so, leave that error go through instead of returning a check missing
|
||||
// authorization error. To look into when addressing #4406.
|
||||
|
||||
// If authorization was not checked, return a response that will
|
||||
// marshal to a generic error and log that the check was missed.
|
||||
if !authzctx.Checked() {
|
||||
// Getting to here means there is an authorization-related bug in our code.
|
||||
return nil, platform_http.CheckMissingWithResponse(response)
|
||||
}
|
||||
|
||||
return response, err
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package authzcheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/authz"
|
||||
platform_http "github.com/fleetdm/fleet/v4/server/platform/http"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAuthzCheck(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
checker := NewMiddleware()
|
||||
|
||||
check := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
authCtx, ok := authz.FromContext(ctx)
|
||||
require.True(t, ok)
|
||||
authCtx.SetChecked()
|
||||
return struct{}{}, nil
|
||||
}
|
||||
check = checker.AuthzCheck()(check)
|
||||
|
||||
_, err := check(context.Background(), struct{}{})
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestAuthzCheckAuthFailed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
checker := NewMiddleware()
|
||||
|
||||
check := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return nil, platform_http.NewAuthFailedError("failed")
|
||||
}
|
||||
check = checker.AuthzCheck()(check)
|
||||
|
||||
_, err := check(context.Background(), struct{}{})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed")
|
||||
}
|
||||
|
||||
func TestAuthzCheckAuthRequired(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
checker := NewMiddleware()
|
||||
|
||||
check := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return nil, platform_http.NewAuthRequiredError("required")
|
||||
}
|
||||
check = checker.AuthzCheck()(check)
|
||||
|
||||
_, err := check(context.Background(), struct{}{})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "required")
|
||||
}
|
||||
|
||||
func TestAuthzCheckMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
checker := NewMiddleware()
|
||||
|
||||
nocheck := func(context.Context, interface{}) (interface{}, error) { return struct{}{}, nil }
|
||||
nocheck = checker.AuthzCheck()(nocheck)
|
||||
|
||||
_, err := nocheck(context.Background(), struct{}{})
|
||||
assert.Error(t, err)
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz"
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/publicip"
|
||||
"github.com/go-kit/kit/endpoint"
|
||||
kitlog "github.com/go-kit/log"
|
||||
"github.com/go-kit/log/level"
|
||||
"github.com/throttled/throttled/v2"
|
||||
)
|
||||
|
||||
// Middleware is a rate limiting middleware using the provided store. Each
|
||||
// function wrapped by the rate limiter receives a separate quota.
|
||||
type Middleware struct {
|
||||
store throttled.GCRAStore
|
||||
}
|
||||
|
||||
// NewMiddleware initializes the middleware with the provided store.
|
||||
func NewMiddleware(store throttled.GCRAStore) *Middleware {
|
||||
if store == nil {
|
||||
panic("nil store")
|
||||
}
|
||||
|
||||
return &Middleware{store: store}
|
||||
}
|
||||
|
||||
// Limit returns a new middleware function enforcing the provided quota.
|
||||
func (m *Middleware) Limit(keyName string, quota throttled.RateQuota) endpoint.Middleware {
|
||||
return func(next endpoint.Endpoint) endpoint.Endpoint {
|
||||
limiter, err := throttled.NewGCRARateLimiter(m.store, quota)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return func(ctx context.Context, req interface{}) (response interface{}, err error) {
|
||||
limited, result, err := limiter.RateLimit(keyName, 1)
|
||||
if err != nil {
|
||||
// This can happen if the limit store (e.g. Redis) is unavailable.
|
||||
//
|
||||
// We need to set authentication as checked, otherwise we end up returning HTTP 500
|
||||
// errors.
|
||||
if az, ok := authz_ctx.FromContext(ctx); ok {
|
||||
az.SetChecked()
|
||||
}
|
||||
return nil, ctxerr.Wrap(ctx, err, "rate limit Middleware: failed to increase rate limit")
|
||||
}
|
||||
|
||||
if limited {
|
||||
// We need to set authentication as checked, otherwise we end up returning HTTP 500
|
||||
// errors.
|
||||
if az, ok := authz_ctx.FromContext(ctx); ok {
|
||||
az.SetChecked()
|
||||
}
|
||||
return nil, ctxerr.Wrap(ctx, &rateLimitError{result: result})
|
||||
}
|
||||
|
||||
return next(ctx, req)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ErrorMiddleware is a rate limiter that performs limits only when there is an error in the request
|
||||
type ErrorMiddleware struct {
|
||||
ipBanner IPBanner
|
||||
}
|
||||
|
||||
// IPBanner is an interface to perform rate limiting based on the request's IP.
|
||||
type IPBanner interface {
|
||||
// CheckBanned returns true if the IP is currently banned.
|
||||
CheckBanned(ip string) (bool, error)
|
||||
// RunRequest will update the status of the given IP with the result of a request.
|
||||
RunRequest(ip string, success bool) error
|
||||
}
|
||||
|
||||
// NewErrorMiddleware creates a new instance of ErrorMiddleware
|
||||
func NewErrorMiddleware(ipBanner IPBanner) *ErrorMiddleware {
|
||||
if ipBanner == nil {
|
||||
panic("internal error: nil IP banner")
|
||||
}
|
||||
|
||||
return &ErrorMiddleware{ipBanner: ipBanner}
|
||||
}
|
||||
|
||||
func (m *ErrorMiddleware) Limit(logger kitlog.Logger) endpoint.Middleware {
|
||||
return func(next endpoint.Endpoint) endpoint.Endpoint {
|
||||
return func(ctx context.Context, req interface{}) (response interface{}, err error) {
|
||||
publicIP := publicip.FromContext(ctx)
|
||||
|
||||
//
|
||||
// Requests with empty public IP will fall under the same bucket.
|
||||
//
|
||||
|
||||
banned, err := m.ipBanner.CheckBanned(publicIP)
|
||||
if err != nil {
|
||||
// This can happen if the limit store (e.g. Redis) is unavailable.
|
||||
//
|
||||
// We need to set authentication as checked, otherwise we end up returning HTTP 500 errors.
|
||||
if az, ok := authz_ctx.FromContext(ctx); ok {
|
||||
az.SetChecked()
|
||||
}
|
||||
return nil, ctxerr.Wrap(ctx, err, "rate limit ErrorMiddleware: failed to check rate limit")
|
||||
}
|
||||
|
||||
if banned {
|
||||
// We need to set authentication as checked, otherwise we end up returning HTTP 500 errors.
|
||||
if az, ok := authz_ctx.FromContext(ctx); ok {
|
||||
az.SetChecked()
|
||||
}
|
||||
level.Warn(logger).Log(
|
||||
"ip", publicIP,
|
||||
"msg", "limit exceeded",
|
||||
)
|
||||
return nil, ctxerr.Wrap(ctx, &rateLimitError{})
|
||||
|
||||
}
|
||||
|
||||
resp, err := next(ctx, req)
|
||||
|
||||
if rateErr := m.ipBanner.RunRequest(publicIP, err == nil); rateErr != nil {
|
||||
level.Warn(logger).Log(
|
||||
"ip", publicIP,
|
||||
"msg", "fail to run request on IP banner",
|
||||
"err", rateErr,
|
||||
)
|
||||
}
|
||||
|
||||
return resp, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Error is the interface for rate limiting errors.
|
||||
type Error interface {
|
||||
error
|
||||
Result() throttled.RateLimitResult
|
||||
}
|
||||
|
||||
type rateLimitError struct {
|
||||
result throttled.RateLimitResult
|
||||
}
|
||||
|
||||
func (r rateLimitError) Error() string {
|
||||
ra := int(r.result.RetryAfter.Seconds())
|
||||
if ra > 0 {
|
||||
return fmt.Sprintf("limit exceeded, retry after: %ds", ra)
|
||||
}
|
||||
return "limit exceeded"
|
||||
}
|
||||
|
||||
func (r rateLimitError) StatusCode() int {
|
||||
return http.StatusTooManyRequests
|
||||
}
|
||||
|
||||
func (r rateLimitError) RetryAfter() int {
|
||||
return int(r.result.RetryAfter.Seconds())
|
||||
}
|
||||
|
||||
func (r rateLimitError) Result() throttled.RateLimitResult {
|
||||
return r.result
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/throttled/throttled/v2"
|
||||
"github.com/throttled/throttled/v2/store/memstore"
|
||||
)
|
||||
|
||||
// Intent is to test the middleware functionality. We rely on the tests within
|
||||
// Throttled to verify that the rate limiting algorithm works properly.
|
||||
|
||||
func TestLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store, _ := memstore.New(0)
|
||||
limiter := NewMiddleware(store)
|
||||
var endpointCallCount uint
|
||||
|
||||
endpoint := func(context.Context, interface{}) (interface{}, error) {
|
||||
endpointCallCount++
|
||||
return struct{}{}, nil
|
||||
}
|
||||
wrapped := limiter.Limit(
|
||||
"test_limit",
|
||||
throttled.RateQuota{MaxRate: throttled.PerHour(1), MaxBurst: 0},
|
||||
)(endpoint)
|
||||
|
||||
wrapped2 := limiter.Limit(
|
||||
"test_limit2",
|
||||
throttled.RateQuota{MaxRate: throttled.PerHour(1), MaxBurst: 0},
|
||||
)(endpoint)
|
||||
|
||||
sameWrapped := limiter.Limit(
|
||||
"test_limit",
|
||||
throttled.RateQuota{MaxRate: throttled.PerHour(1), MaxBurst: 0},
|
||||
)(endpoint)
|
||||
|
||||
authzCtx := &authz_ctx.AuthorizationContext{}
|
||||
ctx := authz_ctx.NewContext(context.Background(), authzCtx)
|
||||
|
||||
_, err := wrapped(ctx, struct{}{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Hits rate limit
|
||||
_, err = wrapped(ctx, struct{}{})
|
||||
assert.Error(t, err)
|
||||
var rle Error
|
||||
assert.True(t, errors.As(err, &rle))
|
||||
assert.True(t, authzCtx.Checked())
|
||||
require.Contains(t, rle.Error(), "limit exceeded, retry after: ")
|
||||
rle_, ok := rle.(*rateLimitError)
|
||||
require.True(t, ok)
|
||||
require.NotZero(t, rle_.RetryAfter())
|
||||
require.Equal(t, http.StatusTooManyRequests, rle_.StatusCode())
|
||||
|
||||
// ensure that the same endpoint wrapped with a different limiter doesn't hit the error
|
||||
_, err = wrapped2(ctx, struct{}{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Same underlying key, so hits same limit
|
||||
_, err = sameWrapped(ctx, struct{}{})
|
||||
assert.Error(t, err)
|
||||
|
||||
assert.True(t, errors.As(err, &rle))
|
||||
assert.True(t, authzCtx.Checked())
|
||||
|
||||
assert.Equal(t, uint(2), endpointCallCount) // when rate limit is exceeded, shouldn't call endpoint
|
||||
}
|
||||
|
||||
func TestNewErrorMiddlewarePanics(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Errorf("The code did not panic")
|
||||
}
|
||||
}()
|
||||
|
||||
NewErrorMiddleware(nil)
|
||||
}
|
||||
Reference in New Issue
Block a user