Read only endpoint support (#10)

* This change introduces ability to set additional endpoint leading to the
instance of the graphql server connected to the read only database.
If regular query is detected and endpoint for `HOST_GRAPHQL_READONLY` value is set,
the query will be proxied to it. Mutations and non-graphql will be sent
to the `HOST_GRAPHQL` endpoint.
This commit is contained in:
2024-03-12 11:16:35 +00:00
committed by GitHub
parent ba1fef9b57
commit e495cf23d9
8 changed files with 249 additions and 48 deletions
+9
View File
@@ -15,6 +15,7 @@ This project is in active use by [telegram-bot.app](https://telegram-bot.app), a
- [Configuration](#configuration) - [Configuration](#configuration)
- [Speed](#speed) - [Speed](#speed)
- [Caching](#caching) - [Caching](#caching)
- [Read-only endpoint](#read-only-endpoint)
- [Security](#security) - [Security](#security)
- [Role-based rate limiting](#role-based-rate-limiting) - [Role-based rate limiting](#role-based-rate-limiting)
- [Read-only mode](#read-only-mode) - [Read-only mode](#read-only-mode)
@@ -93,6 +94,7 @@ In this case, both proxy and websockets will be available under the `/v1/graphql
| monitor | Extracting the query name and type and adding it as a label to metrics| | monitor | Extracting the query name and type and adding it as a label to metrics|
| monitor | Calculating the query duration and adding it to the metrics | | monitor | Calculating the query duration and adding it to the metrics |
| speed | Caching the queries, together with per-query cache and TTL | | speed | Caching the queries, together with per-query cache and TTL |
| speed | Support for READ ONLY graphql endpoint |
| security | Blocking schema introspection | | security | Blocking schema introspection |
| security | Rate limiting queries based on user role | | security | Rate limiting queries based on user role |
| security | Blocking mutations in read-only mode | | security | Blocking mutations in read-only mode |
@@ -111,6 +113,7 @@ You can still use the non-prefixed environment variables in the spirit of the ba
| `MONITORING_PORT` | The port to expose the metrics endpoint | `9393` | | `MONITORING_PORT` | The port to expose the metrics endpoint | `9393` |
| `PORT_GRAPHQL` | The port to expose the graphql endpoint | `8080` | | `PORT_GRAPHQL` | The port to expose the graphql endpoint | `8080` |
| `HOST_GRAPHQL` | The host to proxy the graphql endpoint | `http://localhost/` | | `HOST_GRAPHQL` | The host to proxy the graphql endpoint | `http://localhost/` |
| `HOST_GRAPHQL_READONLY` | The host to proxy the read-only graphql endpoint | `` |
| `HEALTHCHECK_GRAPHQL_URL` | The URL to check the health of the graphql endpoint | `` | | `HEALTHCHECK_GRAPHQL_URL` | The URL to check the health of the graphql endpoint | `` |
| `JWT_USER_CLAIM_PATH` | Path to the user claim in the JWT token | `` | | `JWT_USER_CLAIM_PATH` | Path to the user claim in the JWT token | `` |
| `JWT_ROLE_CLAIM_PATH` | Path to the role claim in the JWT token | `` | | `JWT_ROLE_CLAIM_PATH` | Path to the role claim in the JWT token | `` |
@@ -156,6 +159,12 @@ query MyProducts @cached(refresh: true) {
Since version `0.5.30` the cache is gzipped in the memory, which should optimise the memory usage quite significantly. Since version `0.5.30` the cache is gzipped in the memory, which should optimise the memory usage quite significantly.
#### Read-only endpoint
You can now specify the read-only GraphQL endpoint by setting the `HOST_GRAPHQL_READONLY` environment variable. The default value is empty, preventing the proxy from using the read-only endpoint for the queries and directing all the requests to the main endpoint specified as `HOST_GRAPHQL`. If the `HOST_GRAPHQL_READONLY` is set, the proxy will use the read-only endpoint for the queries with the `query` type and the main endpoint for the `mutation` type queries. Format of the read-only endpoint is the same as `HOST_GRAPHQL` endpoint, for example `http://localhost:8080/`.
You can check out the [example of combined deployment with RW and read-only hasura](static/kubernetes-single-deployment-with-ro.yaml).
### Security ### Security
#### Role-based rate limiting #### Role-based rate limiting
+19 -10
View File
@@ -56,13 +56,14 @@ func prepareQueriesAndExemptions() {
} }
type parseGraphQLQueryResult struct { type parseGraphQLQueryResult struct {
operationType string operationType string
operationName string operationName string
cacheTime int activeEndpoint string
cacheRequest bool cacheTime int
cacheRefresh bool cacheRequest bool
shouldBlock bool cacheRefresh bool
shouldIgnore bool shouldBlock bool
shouldIgnore bool
} }
func parseGraphQLQuery(c *fiber.Ctx) (res *parseGraphQLQueryResult) { func parseGraphQLQuery(c *fiber.Ctx) (res *parseGraphQLQueryResult) {
@@ -70,7 +71,7 @@ func parseGraphQLQuery(c *fiber.Ctx) (res *parseGraphQLQueryResult) {
m := make(map[string]interface{}) m := make(map[string]interface{})
err := json.Unmarshal(c.Body(), &m) err := json.Unmarshal(c.Body(), &m)
if err != nil { if err != nil {
cfg.Logger.Debug("Can't unmarshal the request", map[string]interface{}{"error": err.Error(), "body": string(c.Body())}) cfg.Logger.Error("Can't unmarshal the request", map[string]interface{}{"error": err.Error(), "body": string(c.Body())})
if ifNotInTest() { if ifNotInTest() {
cfg.Monitoring.Increment(libpack_monitoring.MetricsSkipped, nil) cfg.Monitoring.Increment(libpack_monitoring.MetricsSkipped, nil)
} }
@@ -97,15 +98,23 @@ func parseGraphQLQuery(c *fiber.Ctx) (res *parseGraphQLQueryResult) {
res.shouldIgnore = false res.shouldIgnore = false
res.operationName = "undefined" res.operationName = "undefined"
res.activeEndpoint = cfg.Server.HostGraphQL
for _, d := range p.Definitions { for _, d := range p.Definitions {
if oper, ok := d.(*ast.OperationDefinition); ok { if oper, ok := d.(*ast.OperationDefinition); ok {
res.operationType = oper.Operation res.operationType = strings.ToLower(oper.Operation)
if oper.Name != nil { if oper.Name != nil {
res.operationName = oper.Name.Value res.operationName = oper.Name.Value
} }
if strings.ToLower(res.operationType) == "mutation" && cfg.Server.ReadOnlyMode { // If the query is a mutation then direct it to the RW endpoint,
// otherwise direct it to the RO endpoint if it's set.
if cfg.Server.HostGraphQLReadOnly != "" && res.operationType != "mutation" {
res.activeEndpoint = cfg.Server.HostGraphQLReadOnly
}
if res.operationType == "mutation" && cfg.Server.ReadOnlyMode {
cfg.Logger.Warning("Mutation blocked", m) cfg.Logger.Warning("Mutation blocked", m)
if ifNotInTest() { if ifNotInTest() {
cfg.Monitoring.Increment(libpack_monitoring.MetricsSkipped, nil) cfg.Monitoring.Increment(libpack_monitoring.MetricsSkipped, nil)
+1
View File
@@ -39,6 +39,7 @@ func parseConfig() {
c.Server.PortGraphQL = getDetailsFromEnv("PORT_GRAPHQL", 8080) c.Server.PortGraphQL = getDetailsFromEnv("PORT_GRAPHQL", 8080)
c.Server.PortMonitoring = getDetailsFromEnv("MONITORING_PORT", 9393) c.Server.PortMonitoring = getDetailsFromEnv("MONITORING_PORT", 9393)
c.Server.HostGraphQL = getDetailsFromEnv("HOST_GRAPHQL", "http://localhost/") c.Server.HostGraphQL = getDetailsFromEnv("HOST_GRAPHQL", "http://localhost/")
c.Server.HostGraphQLReadOnly = getDetailsFromEnv("HOST_GRAPHQL_READONLY", "")
c.Client.JWTUserClaimPath = getDetailsFromEnv("JWT_USER_CLAIM_PATH", "") c.Client.JWTUserClaimPath = getDetailsFromEnv("JWT_USER_CLAIM_PATH", "")
c.Client.JWTRoleClaimPath = getDetailsFromEnv("JWT_ROLE_CLAIM_PATH", "") c.Client.JWTRoleClaimPath = getDetailsFromEnv("JWT_ROLE_CLAIM_PATH", "")
c.Client.RoleFromHeader = getDetailsFromEnv("ROLE_FROM_HEADER", "") c.Client.RoleFromHeader = getDetailsFromEnv("ROLE_FROM_HEADER", "")
+2 -2
View File
@@ -28,7 +28,7 @@ func createFasthttpClient(timeout int) *fasthttp.Client {
} }
} }
func proxyTheRequest(c *fiber.Ctx) error { func proxyTheRequest(c *fiber.Ctx, currentEndpoint string) error {
if !checkAllowedURLs(c) { if !checkAllowedURLs(c) {
cfg.Logger.Error("Request blocked", map[string]interface{}{"path": c.Path()}) cfg.Logger.Error("Request blocked", map[string]interface{}{"path": c.Path()})
if ifNotInTest() { if ifNotInTest() {
@@ -46,7 +46,7 @@ func proxyTheRequest(c *fiber.Ctx) error {
err := retry.Do( err := retry.Do(
func() error { func() error {
errInt := proxy.DoRedirects(c, cfg.Server.HostGraphQL+c.Path(), 3, cfg.Client.FastProxyClient) errInt := proxy.DoRedirects(c, currentEndpoint+c.Path(), 3, cfg.Client.FastProxyClient)
if errInt != nil { if errInt != nil {
cfg.Logger.Error("Can't proxy the request", map[string]interface{}{"error": errInt.Error()}) cfg.Logger.Error("Can't proxy the request", map[string]interface{}{"error": errInt.Error()})
if ifNotInTest() { if ifNotInTest() {
+34 -19
View File
@@ -12,37 +12,48 @@ func (suite *Tests) Test_proxyTheRequest() {
} }
tests := []struct { tests := []struct {
name string
query string
host string
path string
headers map[string]string headers map[string]string
name string
body string
host string
hostRO string
path string
wantErr bool wantErr bool
}{ }{
{ {
name: "test_empty", name: "test_empty",
query: `query { body: `{"query":"query {\n __type(name: \"Query\") {\n name\n }\n }"}`,
__type(name: "Query") {
name
}
}`,
host: "https://telegram-bot.app/", host: "https://telegram-bot.app/",
path: "/v1/graphql", path: "/v1/graphql",
headers: supplied_headers, headers: supplied_headers,
wantErr: false, wantErr: false,
}, },
{ {
name: "test_wrong_url", name: "test_wrong_url",
query: `query { body: `{"query":"query {\n __type(name: \"Query\") {\n name\n }\n }"}`,
__type(name: "Query") {
name
}
}`,
host: "https://google.com/", host: "https://google.com/",
path: "/v1/wrongURL", path: "/v1/wrongURL",
headers: supplied_headers, headers: supplied_headers,
wantErr: true, wantErr: true,
}, },
{
name: "Test read only mode",
body: `{"query":"query {\n __type(name: \"Query\") {\n name\n }\n }"}`,
host: "https://google.com/",
hostRO: "https://telegram-bot.app/",
path: "/v1/graphql",
headers: supplied_headers,
wantErr: false,
},
{
name: "Test read only mode wrong host",
body: `{"query":"query {\n __type(name: \"Query\") {\n name\n }\n }"}`,
host: "https://telegram-bot.app/",
hostRO: "https://google.com/",
path: "/v1/graphql",
headers: supplied_headers,
wantErr: true,
},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -52,6 +63,10 @@ func (suite *Tests) Test_proxyTheRequest() {
parseConfig() parseConfig()
cfg.Server.HostGraphQL = tt.host cfg.Server.HostGraphQL = tt.host
if tt.hostRO != "" {
cfg.Server.HostGraphQLReadOnly = tt.hostRO
}
ctx_headers := func() *fasthttp.RequestHeader { ctx_headers := func() *fasthttp.RequestHeader {
h := fasthttp.RequestHeader{} h := fasthttp.RequestHeader{}
for k, v := range tt.headers { for k, v := range tt.headers {
@@ -63,15 +78,15 @@ func (suite *Tests) Test_proxyTheRequest() {
ctx_request := fasthttp.Request{ ctx_request := fasthttp.Request{
Header: *ctx_headers, Header: *ctx_headers,
} }
ctx_request.SetBody([]byte(tt.body))
ctx_request.SetRequestURI(tt.path) ctx_request.SetRequestURI(tt.path)
ctx_request.Header.SetMethod("POST") ctx_request.Header.SetMethod("POST")
ctx := suite.app.AcquireCtx(&fasthttp.RequestCtx{ ctx := suite.app.AcquireCtx(&fasthttp.RequestCtx{
Request: ctx_request, Request: ctx_request,
}) })
res := parseGraphQLQuery(ctx)
assert.NotNil(ctx, "Fiber context is nil", tt.name) assert.NotNil(ctx, "Fiber context is nil", tt.name)
err := proxyTheRequest(ctx) err := proxyTheRequest(ctx, res.activeEndpoint)
if tt.wantErr { if tt.wantErr {
assert.NotNil(err, "Error is nil", tt.name) assert.NotNil(err, "Error is nil", tt.name)
} else { } else {
+10 -6
View File
@@ -37,7 +37,7 @@ func StartHTTPProxy() {
server.Get("/livez", healthCheck) server.Get("/livez", healthCheck)
server.Post("/*", processGraphQLRequest) server.Post("/*", processGraphQLRequest)
server.Get("/*", proxyTheRequest) server.Get("/*", proxyTheRequestToDefault)
cfg.Logger.Info("GraphQL query proxy started", map[string]interface{}{"port": cfg.Server.PortGraphQL}) cfg.Logger.Info("GraphQL query proxy started", map[string]interface{}{"port": cfg.Server.PortGraphQL})
err := server.Listen(fmt.Sprintf(":%d", cfg.Server.PortGraphQL)) err := server.Listen(fmt.Sprintf(":%d", cfg.Server.PortGraphQL))
@@ -46,6 +46,10 @@ func StartHTTPProxy() {
} }
} }
func proxyTheRequestToDefault(c *fiber.Ctx) error {
return proxyTheRequest(c, cfg.Server.HostGraphQL)
}
func AddRequestUUID(c *fiber.Ctx) error { func AddRequestUUID(c *fiber.Ctx) error {
c.Locals("request_uuid", uuid.NewString()) c.Locals("request_uuid", uuid.NewString())
return c.Next() return c.Next()
@@ -118,7 +122,7 @@ func processGraphQLRequest(c *fiber.Ctx) error {
if parsedResult.shouldIgnore { if parsedResult.shouldIgnore {
cfg.Logger.Debug("Request passed as-is - probably not a GraphQL") cfg.Logger.Debug("Request passed as-is - probably not a GraphQL")
return proxyTheRequest(c) return proxyTheRequest(c, parsedResult.activeEndpoint)
} }
if parsedResult.cacheTime > 0 { if parsedResult.cacheTime > 0 {
@@ -153,10 +157,10 @@ func processGraphQLRequest(c *fiber.Ctx) error {
wasCached = true wasCached = true
} else { } else {
cfg.Logger.Debug("Cache miss", map[string]interface{}{"hash": queryCacheHash, "user_id": extractedUserID, "request_uuid": c.Locals("request_uuid")}) cfg.Logger.Debug("Cache miss", map[string]interface{}{"hash": queryCacheHash, "user_id": extractedUserID, "request_uuid": c.Locals("request_uuid")})
proxyAndCacheTheRequest(c, queryCacheHash, parsedResult.cacheTime) proxyAndCacheTheRequest(c, queryCacheHash, parsedResult.cacheTime, parsedResult.activeEndpoint)
} }
} else { } else {
proxyTheRequest(c) proxyTheRequest(c, parsedResult.activeEndpoint)
} }
timeTaken := time.Since(startTime) timeTaken := time.Since(startTime)
@@ -168,8 +172,8 @@ func processGraphQLRequest(c *fiber.Ctx) error {
} }
// Additional helper function to avoid code repetition // Additional helper function to avoid code repetition
func proxyAndCacheTheRequest(c *fiber.Ctx, queryCacheHash string, cacheTime int) { func proxyAndCacheTheRequest(c *fiber.Ctx, queryCacheHash string, cacheTime int, currentEndpoint string) {
err := proxyTheRequest(c) err := proxyTheRequest(c, currentEndpoint)
if err != nil { if err != nil {
cfg.Logger.Error("Can't proxy the request", map[string]interface{}{"error": err.Error()}) cfg.Logger.Error("Can't proxy the request", map[string]interface{}{"error": err.Error()})
cfg.Monitoring.Increment(libpack_monitoring.MetricsFailed, nil) cfg.Monitoring.Increment(libpack_monitoring.MetricsFailed, nil)
@@ -0,0 +1,162 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: hasura-w-proxy-internal
labels:
app: hasura-w-proxy-internal
type: support
spec:
replicas: 2
selector:
matchLabels:
app: hasura-w-proxy-internal
type: support
template:
metadata:
labels:
app: hasura-w-proxy-internal
type: support
spec:
securityContext:
runAsUser: 65534 # nobody
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: node-role.kubernetes.io/worker
operator: Exists
containers:
- name: hasura
image: hasura/graphql-engine:v2.33.1-ce
ports:
- name: hasura-internal
containerPort: 8080
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 30
resources:
limits:
cpu: "1"
memory: "640Mi"
requests:
cpu: "0.75"
memory: "512Mi"
env:
- name: HASURA_GRAPHQL_DATABASE_URL
value: postgres://postgres:xxx@yyy:5432/postgres
- name: HASURA_GRAPHQL_ENABLE_CONSOLE
value: "true"
- name: HASURA_GRAPHQL_DEV_MODE
value: "true"
- name: HASURA_GRAPHQL_ENABLE_TELEMETRY
value: "false"
- name: HASURA_GRAPHQL_EXPERIMENTAL_FEATURES
value: "inherited_roles"
- name: HASURA_GRAPHQL_PG_CONNECTIONS
value: "20"
- name: HASURA_GRAPHQL_LOG_LEVEL
value: "error"
- name: hasura-ro
image: hasura/graphql-engine:v2.33.1-ce
ports:
- name: hasura-internal-ro
containerPort: 8088
livenessProbe:
httpGet:
path: /healthz
port: 8088
initialDelaySeconds: 30
resources:
limits:
cpu: "1"
memory: "640Mi"
requests:
cpu: "0.75"
memory: "512Mi"
env:
- name: HASURA_GRAPHQL_DATABASE_URL
value: postgres://postgres:[email protected]:5432/postgres
- name: HASURA_GRAPHQL_ENABLE_CONSOLE
value: "true"
- name: HASURA_GRAPHQL_DEV_MODE
value: "true"
- name: HASURA_GRAPHQL_ENABLE_TELEMETRY
value: "false"
- name: HASURA_GRAPHQL_EXPERIMENTAL_FEATURES
value: "inherited_roles"
- name: HASURA_GRAPHQL_PG_CONNECTIONS
value: "20"
- name: HASURA_GRAPHQL_LOG_LEVEL
value: "error"
- name: HASURA_PORT
value: "8088"
- name: graphql-proxy
image: ghcr.io/lukaszraczylo/graphql-monitoring-proxy:latest
imagePullPolicy: Always
resources:
limits:
cpu: "1"
memory: "640Mi"
requests:
cpu: "0.75"
memory: "128Mi"
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 5
timeoutSeconds: 5
ports:
- name: web
containerPort: 8181
- name: monitoring
containerPort: 9393
env:
- name: PORT_GRAPHQL
value: "8181"
- name: MONITORING_PORT
value: "9393"
- name: HOST_GRAPHQL
value: http://localhost:8080/
- name: HOST_GRAPHQL_READONLY
value: http://localhost:8088/
- name: ENABLE_GLOBAL_CACHE
value: "true"
- name: CACHE_TTL
value: "10"
---
apiVersion: v1
kind: Service
metadata:
name: hasura-w-proxy-internal
labels:
app: hasura-w-proxy-internal
type: support
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "9393"
prometheus.io/path: "/metrics"
spec:
ports:
- name: hasura
port: 8080
targetPort: 8080
- name: hasura-ro
port: 8088
targetPort: 8088
- name: proxy
port: 8181
targetPort: 8181
- name: monitoring
port: 9393
targetPort: 9393
selector:
app: hasura-w-proxy-internal
type: support
type: ClusterIP
+12 -11
View File
@@ -33,16 +33,17 @@ type config struct {
BlockIntrospection bool BlockIntrospection bool
} }
Server struct { Server struct {
HostGraphQL string HostGraphQL string
HealthcheckGraphQL string HostGraphQLReadOnly string
AllowURLs []string HealthcheckGraphQL string
PortGraphQL int AllowURLs []string
PortMonitoring int PortGraphQL int
ApiPort int PortMonitoring int
PurgeEvery int ApiPort int
AccessLog bool PurgeEvery int
ReadOnlyMode bool AccessLog bool
EnableApi bool ReadOnlyMode bool
PurgeOnCrawl bool EnableApi bool
PurgeOnCrawl bool
} }
} }