Skip to content

GraphQL Gateway

GraphQL Gateway exposes a single unified GraphQL endpoint over a handful of core Strapi entities. Every resolver delegates to the underlying Strapi REST API with a service-level Authorization: Bearer ${STRAPI_API_TOKEN} — the gateway itself persists nothing.

  • Port: 4000
  • Tech: Fastify 5, Apollo Server 5 (via @as-integrations/fastify), GraphQL 16, TypeScript
  • Feature flag: GRAPHQL_ENABLED must be true, otherwise /graphql returns 503
  • Auth: Keycloak Bearer JWT is required on every request — validated against the KC JWKS endpoint
  • Introspection: disabled when NODE_ENV=production
type CloudInstance {
id: ID!
name: String!
provider: String!
status: String!
region: String!
org_id: ID!
}
type Incident {
id: ID!
title: String!
severity: String!
status: String!
org_id: ID!
created_at: String!
}
type AuditLog {
id: ID!
action: String!
actor_email: String
resource_type: String!
created_at: String!
}
type OrgMember {
id: ID!
email: String!
role: String!
org_id: ID!
}
type Query {
cloudInstances(orgId: ID!): [CloudInstance!]!
incidents(orgId: ID!, limit: Int): [Incident!]!
auditLogs(orgId: ID!, limit: Int): [AuditLog!]!
orgMembers(orgId: ID!): [OrgMember!]!
}

The schema is deliberately flat — there are no nested/relational fields between the four types. incidents and auditLogs default to a limit of 20 and 50 respectively when the argument is omitted.

query OrgOverview($orgId: ID!) {
cloudInstances(orgId: $orgId) {
id
name
provider
status
region
}
incidents(orgId: $orgId, limit: 5) {
id
title
severity
status
created_at
}
}
Terminal window
curl -X POST http://localhost:4000/graphql \
-H "Authorization: Bearer $KC_JWT" \
-H "Content-Type: application/json" \
-d '{"query":"query($orgId: ID!){ orgMembers(orgId:$orgId){ id email role } }","variables":{"orgId":"1"}}'

Every request must carry a valid Keycloak-issued JWT (Authorization: Bearer <token>), validated in src/auth.ts:

  1. Signature + issuer — verified against the Keycloak JWKS endpoint (RS256 only).
  2. Audience/azp check — the token must carry an azp or aud claim matching KEYCLOAK_CLIENT_ID (default sencai-frontend). A token minted for a different client in the same realm is rejected, even though it is validly signed.

On top of authentication, all four resolvers (cloudInstances, incidents, auditLogs, orgMembers) call assertOrgAccess() before querying Strapi: the caller’s verified Keycloak sub must be a real member of the orgId passed as a query argument, or the resolver throws a GraphQLError with extensions.code = "FORBIDDEN". Without this check, orgId would be a trivial cross-tenant IDOR — any authenticated caller could pass another organisation’s id and read its data. Platform admins (KC realm role ADMIN_KC_REALM_ROLE, default sencai-admin) bypass the membership check.

Because the schema has no nesting, a plain depth limit does not protect against alias-based batching (e.g. issuing the same query dozens of times under different aliases in one request). The gateway layers three independent limits:

LimitEnv varDefaultMechanism
Query depthQUERY_DEPTH_LIMIT10graphql-depth-limit
Query complexityQUERY_COMPLEXITY_LIMIT1000graphql-query-complexity
Aliased root fieldsQUERY_ALIAS_LIMIT15custom validation rule, src/validation-rules.ts

Requests exceeding any of these limits are rejected before they reach a resolver.

Additional protections: @fastify/rate-limit (100 req/min per IP), @fastify/helmet, @fastify/cors.

Every query — successful or not — emits a gateway.graphql.query audit event via @sencai/audit before the org-access check runs. The event payload includes:

  • query_hash — first 16 hex characters of the SHA-256 hash of the raw query string
  • org_id — the orgId argument from the query
MethodPathAuthDescription
GET/POST/graphqlBearer KC JWTGraphQL endpoint (requires GRAPHQL_ENABLED=true)
GET/healthNoneHealth check

Full template in .env.example. Key variables:

Terminal window
PORT=4000
GRAPHQL_ENABLED=false # must be true to enable the endpoint
QUERY_DEPTH_LIMIT=10
QUERY_COMPLEXITY_LIMIT=1000
QUERY_ALIAS_LIMIT=15
STRAPI_URL=http://sencai-backend:1337
STRAPI_API_TOKEN= # read access to cloud-instances, incidents, audit-logs, organisation-members
KEYCLOAK_URL=http://keycloak:8080/kc
KEYCLOAK_REALM=sencai
KEYCLOAK_CLIENT_ID=sencai-frontend
ADMIN_KC_REALM_ROLE=sencai-admin
RABBITMQ_URL=amqp://admin:admin@sencai-mq:5672
NODE_ENV=development

Q: 503 on /graphql

A: GRAPHQL_ENABLED is not true. Set it and restart the service.

Q: 401 on /graphql

A: The Bearer token is missing, expired, or fails JWKS/audience validation. Confirm KEYCLOAK_CLIENT_ID matches the token’s azp/aud.

Q: FORBIDDEN (403-equivalent GraphQL error) on a query

A: The caller is authenticated but is not a member of the requested orgId. Either use an account that is actually a member of that organisation, or one with the ADMIN_KC_REALM_ROLE realm role.

Q: “Query exceeds maximum allowed aliases” / depth / complexity errors

A: The query hit QUERY_ALIAS_LIMIT, QUERY_DEPTH_LIMIT, or QUERY_COMPLEXITY_LIMIT. Reduce the query shape or raise the relevant env var if the limit is genuinely too tight for a legitimate use case.

Q: Audit events not showing up

A: RABBITMQ_URL is unset or unreachable — the gateway logs a warning and drops the event rather than failing the request.