mirror of
https://github.com/lukaszraczylo/graphql-monitoring-proxy.git
synced 2026-06-05 23:03:48 +00:00
Add role ratelimit (#1)
* Add ratelimit configuration. * Add rate limiting :party:
This commit is contained in:
@@ -15,5 +15,6 @@ jobs:
|
||||
uses: telegram-bot-app/ci-scripts/.github/workflows/build-test-publish-inject.yaml@main
|
||||
with:
|
||||
enable-code-scans: false
|
||||
should-deploy: false
|
||||
secrets:
|
||||
ghcr-token: ${{ secrets.GHCR_TOKEN }}
|
||||
|
||||
@@ -4,5 +4,6 @@ WORKDIR /go/src/app
|
||||
ARG TARGETARCH
|
||||
ARG TARGETOS
|
||||
ADD dist/bot-$TARGETOS-$TARGETARCH /go/src/app/graphql-proxy
|
||||
ADD static/default-ratelimit.json /app/ratelimit.json
|
||||
RUN chmod +x /go/src/app/graphql-proxy
|
||||
ENTRYPOINT ["/go/src/app/graphql-proxy"]
|
||||
|
||||
@@ -11,7 +11,7 @@ help: ## display this help
|
||||
|
||||
.PHONY: run
|
||||
run: ## run application
|
||||
@LOG_LEVEL=debug BLOCK_SCHEMA_INTROSPECTION=true JWT_USER_CLAIM_PATH="Hasura.x-hasura-user-id" HOST_GRAPHQL=https://hasura8.lan/v1/graphql go run *.go
|
||||
@LOG_LEVEL=warn BLOCK_SCHEMA_INTROSPECTION=false JWT_ROLE_RATE_LIMIT=false JWT_ROLE_CLAIM_PATH="Hasura.x-hasura-default-role" JWT_USER_CLAIM_PATH="Hasura.x-hasura-user-id" HOST_GRAPHQL=https://hasura8.lan/v1/graphql go run *.go
|
||||
|
||||
.PHONY: build
|
||||
build: ## build the binary
|
||||
|
||||
@@ -26,6 +26,7 @@ I wanted to monitor the queries and responses of our graphql endpoint, but we di
|
||||
* MONITORING: Calculating the query duration and adding it to the metrics
|
||||
* SPEED: Caching the queries
|
||||
* SECURITY: Blocking schema introspection
|
||||
* SECURITY: Rate limiting queries based on user role
|
||||
|
||||
### Configuration
|
||||
|
||||
@@ -33,6 +34,8 @@ I wanted to monitor the queries and responses of our graphql endpoint, but we di
|
||||
* `PORT_GRAPHQL` - the port to expose the graphql endpoint on (default: 8080)
|
||||
* `HOST_GRAPHQL` - the host to proxy the graphql endpoint to (default: `http://localhost/v1/graphql`)
|
||||
* `JWT_USER_CLAIM_PATH` - the path to the user claim in the JWT token (default: ``)
|
||||
* `JWT_ROLE_CLAIM_PATH` - the path to the role claim in the JWT token (default: ``)
|
||||
* `JWT_ROLE_RATE_LIMITING` - enable request rate limiting based on the role (default: `false`)
|
||||
* `ENABLE_GLOBAL_CACHE` - enable the cache (default: `false`)
|
||||
* `CACHE_TTL` - the cache TTL (default: `60s`)
|
||||
* `LOG_LEVEL` - the log level (default: `info`)
|
||||
@@ -44,6 +47,41 @@ I wanted to monitor the queries and responses of our graphql endpoint, but we di
|
||||
Cache engine is enabled in background as it does not use any additional resources.
|
||||
You can then start using the cache by setting the `ENABLE_GLOBAL_CACHE` environment variable to `true` - which will enable the cache for all queries, without introspection of the query. You can leave the global cache disabled and enable the cache for specific queries by adding the `@cache` directive to the query.
|
||||
|
||||
### Role based rate limiting
|
||||
|
||||
You are able to rate limit requests using the `JWT_ROLE_RATE_LIMITING` environment variable. If enabled, the proxy will rate limit the requests based on the role claim in the JWT token. You can then provide the json file in following format to specify the limits.
|
||||
Default interval is `second`, but you can use other values as well. If you want to disable the rate limiting for specific role, you can set the `req` to `0`.
|
||||
|
||||
Available values:
|
||||
`nano`, `micro`, `milli`, `second`, `minute`, `hour`, `day`
|
||||
|
||||
To define path in JWT token where current user role is present use the `JWT_ROLE_CLAIM_PATH` environment variable.
|
||||
|
||||
*Default / sample configuration:*
|
||||
|
||||
```json
|
||||
{
|
||||
"ratelimit": {
|
||||
"admin": {
|
||||
"req": 100,
|
||||
"interval": "second"
|
||||
},
|
||||
"guest": {
|
||||
"req": 50,
|
||||
"interval": "minute"
|
||||
},
|
||||
"-": {
|
||||
"req": 100,
|
||||
"interval": "day"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If you'd like to change it - mount your configmap as `/app/ratelimit.json` file.
|
||||
Remember to include the `-` role, which is used for unauthenticated users or when claim can't be found for any reason.
|
||||
If rate limit has been reached - the proxy will return `429 Too Many Requests` error.
|
||||
|
||||
### Monitoring endpoint
|
||||
|
||||
Example metrics produced by the proxy:
|
||||
|
||||
+22
-7
@@ -8,7 +8,7 @@ import (
|
||||
libpack_monitoring "github.com/telegram-bot-app/libpack/monitoring"
|
||||
)
|
||||
|
||||
func extractClaimsFromJWTHeader(authorization string) (usr string) {
|
||||
func extractClaimsFromJWTHeader(authorization string) (usr string, role string) {
|
||||
tokenParts := strings.Split(authorization, ".")
|
||||
if len(tokenParts) != 3 {
|
||||
cfg.Monitoring.Increment(libpack_monitoring.MetricsFailed, nil)
|
||||
@@ -28,11 +28,26 @@ func extractClaimsFromJWTHeader(authorization string) (usr string) {
|
||||
cfg.Logger.Error("Can't unmarshal the claim", map[string]interface{}{"token": authorization})
|
||||
return
|
||||
}
|
||||
usr, ok := ask.For(claimMap, cfg.Client.JWTUserClaimPath).String("-")
|
||||
if !ok {
|
||||
cfg.Monitoring.Increment(libpack_monitoring.MetricsFailed, nil)
|
||||
cfg.Logger.Error("Can't find the user id", map[string]interface{}{"claim_map": claimMap, "path": cfg.Client.JWTUserClaimPath})
|
||||
return
|
||||
|
||||
if len(cfg.Client.JWTUserClaimPath) > 0 {
|
||||
var ok bool
|
||||
usr, ok = ask.For(claimMap, cfg.Client.JWTUserClaimPath).String("-")
|
||||
if !ok {
|
||||
cfg.Monitoring.Increment(libpack_monitoring.MetricsFailed, nil)
|
||||
cfg.Logger.Error("Can't find the user id", map[string]interface{}{"claim_map": claimMap, "path": cfg.Client.JWTUserClaimPath})
|
||||
return
|
||||
}
|
||||
}
|
||||
return usr
|
||||
|
||||
if len(cfg.Client.JWTRoleClaimPath) > 0 {
|
||||
var ok bool
|
||||
role, ok = ask.For(claimMap, cfg.Client.JWTRoleClaimPath).String("-")
|
||||
if !ok {
|
||||
cfg.Monitoring.Increment(libpack_monitoring.MetricsFailed, nil)
|
||||
cfg.Logger.Error("Can't find the role", map[string]interface{}{"claim_map": claimMap, "path": cfg.Client.JWTRoleClaimPath})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ require (
|
||||
github.com/gookit/goutil v0.6.12
|
||||
github.com/graphql-go/graphql v0.8.1
|
||||
github.com/json-iterator/go v1.1.12
|
||||
github.com/k0kubun/pp v3.0.1+incompatible
|
||||
github.com/lukaszraczylo/ask v0.0.0-20230927103145-2ff1123b4415
|
||||
github.com/lukaszraczylo/go-ratecounter v0.1.8
|
||||
github.com/lukaszraczylo/go-simple-graphql v1.1.31
|
||||
github.com/telegram-bot-app/libpack v0.0.0-20231008100411-9f7f8bf94315
|
||||
)
|
||||
@@ -21,7 +21,6 @@ require (
|
||||
github.com/avast/retry-go/v4 v4.5.0 // indirect
|
||||
github.com/google/uuid v1.3.1 // indirect
|
||||
github.com/gookit/color v1.5.4 // indirect
|
||||
github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88 // indirect
|
||||
github.com/klauspost/compress v1.17.0 // indirect
|
||||
github.com/lukaszraczylo/pandati v0.0.29 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
|
||||
@@ -28,14 +28,12 @@ github.com/graphql-go/graphql v0.8.1 h1:p7/Ou/WpmulocJeEx7wjQy611rtXGQaAcXGqanuM
|
||||
github.com/graphql-go/graphql v0.8.1/go.mod h1:nKiHzRM0qopJEwCITUuIsxk9PlVlwIiiI8pnJEhordQ=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88 h1:uC1QfSlInpQF+M0ao65imhwqKnz3Q2z/d8PWZRMQvDM=
|
||||
github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k=
|
||||
github.com/k0kubun/pp v3.0.1+incompatible h1:3tqvf7QgUnZ5tXO6pNAZlrvHgl6DvifjDrd9g2S9Z40=
|
||||
github.com/k0kubun/pp v3.0.1+incompatible/go.mod h1:GWse8YhT0p8pT4ir3ZgBbfZild3tgzSScAn6HmfYukg=
|
||||
github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM=
|
||||
github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||
github.com/lukaszraczylo/ask v0.0.0-20230927103145-2ff1123b4415 h1:lvI8Wlbg4PxkRcg2f10wgoaRpfN19v+YdRek3+dLtlM=
|
||||
github.com/lukaszraczylo/ask v0.0.0-20230927103145-2ff1123b4415/go.mod h1:M+UVdyqZs++xtEPrascaVmZdOMhCnxjZ2SgH+xHpR0c=
|
||||
github.com/lukaszraczylo/go-ratecounter v0.1.8 h1:ZYm6Wkn58ZAlFWRmC7PaD4oAYHWcu8/0MUDWGe3PnJQ=
|
||||
github.com/lukaszraczylo/go-ratecounter v0.1.8/go.mod h1:TqXEOCtFJStk1i0tkipprv1kiDHGon1MVUisjSTBSKM=
|
||||
github.com/lukaszraczylo/go-simple-graphql v1.1.31 h1:UA3f8M1cV+XnO8UZlAqveW0qF/2NN512eB/gRqe+BHs=
|
||||
github.com/lukaszraczylo/go-simple-graphql v1.1.31/go.mod h1:MyftQ8jTdtkYImPXJpHoxz6+E53Ydv+7q9+Jr+eT8WU=
|
||||
github.com/lukaszraczylo/pandati v0.0.29 h1:WUEWm1+hWjE5KJbIL8OctG00x2dk4XKGJSlrjhxZ55k=
|
||||
|
||||
@@ -22,6 +22,8 @@ func parseConfig() {
|
||||
c.Server.PortMonitoring = envutil.GetInt("MONITORING_PORT", 9393)
|
||||
c.Server.HostGraphQL = envutil.Getenv("HOST_GRAPHQL", "http://localhost/v1/graphql")
|
||||
c.Client.JWTUserClaimPath = envutil.Getenv("JWT_USER_CLAIM_PATH", "")
|
||||
c.Client.JWTRoleClaimPath = envutil.Getenv("JWT_ROLE_CLAIM_PATH", "")
|
||||
c.Client.JWTRoleRateLimit = envutil.GetBool("JWT_ROLE_RATE_LIMIT", false)
|
||||
c.Cache.CacheEnable = envutil.GetBool("ENABLE_GLOBAL_CACHE", false)
|
||||
c.Cache.CacheTTL = envutil.GetInt("CACHE_TTL", 60)
|
||||
c.Security.BlockIntrospection = envutil.GetBool("BLOCK_SCHEMA_INTROSPECTION", false)
|
||||
@@ -31,6 +33,7 @@ func parseConfig() {
|
||||
c.Server.AccessLog = envutil.GetBool("ENABLE_ACCESS_LOG", false)
|
||||
cfg = &c
|
||||
enableCache() // takes close to no resources, but can be used with dynamic query cache
|
||||
loadRatelimitConfig()
|
||||
}
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
goratecounter "github.com/lukaszraczylo/go-ratecounter"
|
||||
)
|
||||
|
||||
type RateLimitConfig struct {
|
||||
Req int `json:"req"`
|
||||
Interval string `json:"interval"`
|
||||
RateCounterTicker *goratecounter.RateCounter
|
||||
}
|
||||
|
||||
var rateLimits map[string]RateLimitConfig
|
||||
var ratelimit_intervals = map[string]time.Duration{
|
||||
"milli": time.Millisecond,
|
||||
"micro": time.Microsecond,
|
||||
"nano": time.Nanosecond,
|
||||
"second": time.Second,
|
||||
"minute": time.Minute,
|
||||
"hour": time.Hour,
|
||||
"day": time.Hour * 24,
|
||||
}
|
||||
|
||||
func loadRatelimitConfig() error {
|
||||
paths := [3]string{"/app/ratelimit.json", "./ratelimit.json", "./static/default-ratelimit.json"}
|
||||
for _, path := range paths {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
defer file.Close()
|
||||
decoder := json.NewDecoder(file)
|
||||
config := struct {
|
||||
RateLimit map[string]RateLimitConfig `json:"ratelimit"`
|
||||
}{}
|
||||
err = decoder.Decode(&config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for key, value := range config.RateLimit {
|
||||
value.RateCounterTicker = goratecounter.NewRateCounter().WithConfig(goratecounter.RateCounterConfig{
|
||||
Interval: time.Duration(value.Req) * ratelimit_intervals[value.Interval],
|
||||
})
|
||||
cfg.Logger.Debug("Setting ratelimit config for role", map[string]interface{}{"role": key, "interval_provided": value.Interval, "interval_used": ratelimit_intervals[value.Interval], "ratelimit": value.Req})
|
||||
config.RateLimit[key] = value
|
||||
}
|
||||
|
||||
rateLimits = config.RateLimit
|
||||
cfg.Logger.Debug("Rate limit config loaded", map[string]interface{}{"ratelimit": rateLimits})
|
||||
return nil
|
||||
}
|
||||
cfg.Logger.Debug("Rate limit config not found")
|
||||
return os.ErrNotExist
|
||||
}
|
||||
|
||||
func rateLimitedRequest(userId string, userRole string) (shouldAllow bool) {
|
||||
if rateLimits == nil {
|
||||
cfg.Logger.Debug("Rate limit config not found", map[string]interface{}{"user_role": userRole})
|
||||
return true
|
||||
}
|
||||
// check if userRole is in rateLimits
|
||||
if _, ok := rateLimits[userRole]; !ok {
|
||||
cfg.Logger.Warning("Rate limit role not found", map[string]interface{}{"user_role": userRole})
|
||||
return true
|
||||
}
|
||||
|
||||
if rateLimits[userRole].RateCounterTicker == nil {
|
||||
cfg.Logger.Warning("Rate limit ticker not found", map[string]interface{}{"user_role": userRole})
|
||||
return true
|
||||
}
|
||||
|
||||
rateLimits[userRole].RateCounterTicker.Incr(1)
|
||||
ticker_rate := rateLimits[userRole].RateCounterTicker.GetRate()
|
||||
cfg.Logger.Debug("Rate limit ticker", map[string]interface{}{"user_role": userRole, "user_id": userId, "rate": ticker_rate, "config_rate": rateLimits[userRole].Req, "interval": rateLimits[userRole].Interval, "interval_duration": rateLimits[userRole].Interval})
|
||||
if ticker_rate > float64(rateLimits[userRole].Req) {
|
||||
cfg.Logger.Debug("Rate limit exceeded", map[string]interface{}{"user_role": userRole, "user_id": userId, "rate": ticker_rate, "config_rate": rateLimits[userRole].Req, "interval": rateLimits[userRole].Interval, "interval_duration": rateLimits[userRole].Interval})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
fiber "github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||
|
||||
jsoniter "github.com/json-iterator/go"
|
||||
libpack_monitoring "github.com/telegram-bot-app/libpack/monitoring"
|
||||
)
|
||||
@@ -44,12 +45,22 @@ func processGraphQLRequest(c *fiber.Ctx) error {
|
||||
t := time.Now()
|
||||
|
||||
var extracted_user_id string = "-"
|
||||
var query_cache_hash string = ""
|
||||
var extracted_role_name string = "-"
|
||||
var query_cache_hash string
|
||||
|
||||
authorization := c.Request().Header.Peek("Authorization")
|
||||
if authorization != nil && len(cfg.Client.JWTUserClaimPath) > 0 {
|
||||
extracted_user_id = extractClaimsFromJWTHeader(string(authorization))
|
||||
if authorization != nil && (len(cfg.Client.JWTUserClaimPath) > 0 || len(cfg.Client.JWTRoleClaimPath) > 0) {
|
||||
extracted_user_id, extracted_role_name = extractClaimsFromJWTHeader(string(authorization))
|
||||
}
|
||||
|
||||
if cfg.Client.JWTRoleRateLimit {
|
||||
cfg.Logger.Debug("Rate limiting enabled", map[string]interface{}{"user_id": extracted_user_id, "role_name": extracted_role_name})
|
||||
if !rateLimitedRequest(extracted_user_id, extracted_role_name) {
|
||||
c.Status(429).SendString("Rate limit exceeded, try again later")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
opType, opName, cache_from_query, should_block := parseGraphQLQuery(c)
|
||||
|
||||
if should_block {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"ratelimit": {
|
||||
"admin": {
|
||||
"req": 100,
|
||||
"interval": "second"
|
||||
},
|
||||
"guest": {
|
||||
"req": 3,
|
||||
"interval": "second"
|
||||
},
|
||||
"-": {
|
||||
"req": 100,
|
||||
"interval": "hour"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,8 @@ type config struct {
|
||||
|
||||
Client struct {
|
||||
JWTUserClaimPath string
|
||||
JWTRoleClaimPath string
|
||||
JWTRoleRateLimit bool
|
||||
GQLClient *graphql.BaseClient
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user