GraphQL Gateway
GraphQL Gateway
Section titled “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.
Key Facts
Section titled “Key Facts”- Port: 4000
- Tech: Fastify 5, Apollo Server 5 (via
@as-integrations/fastify), GraphQL 16, TypeScript - Feature flag:
GRAPHQL_ENABLEDmust betrue, otherwise/graphqlreturns503 - Auth: Keycloak Bearer JWT is required on every request — validated against the KC JWKS endpoint
- Introspection: disabled when
NODE_ENV=production
Schema
Section titled “Schema”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.
Example query
Section titled “Example query”query OrgOverview($orgId: ID!) { cloudInstances(orgId: $orgId) { id name provider status region } incidents(orgId: $orgId, limit: 5) { id title severity status created_at }}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"}}'Auth and org-scoping
Section titled “Auth and org-scoping”Every request must carry a valid Keycloak-issued JWT (Authorization: Bearer <token>), validated in src/auth.ts:
- Signature + issuer — verified against the Keycloak JWKS endpoint (RS256 only).
- Audience/
azpcheck — the token must carry anazporaudclaim matchingKEYCLOAK_CLIENT_ID(defaultsencai-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.
Abuse limits
Section titled “Abuse limits”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:
| Limit | Env var | Default | Mechanism |
|---|---|---|---|
| Query depth | QUERY_DEPTH_LIMIT | 10 | graphql-depth-limit |
| Query complexity | QUERY_COMPLEXITY_LIMIT | 1000 | graphql-query-complexity |
| Aliased root fields | QUERY_ALIAS_LIMIT | 15 | custom 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 stringorg_id— theorgIdargument from the query
Endpoints
Section titled “Endpoints”| Method | Path | Auth | Description |
|---|---|---|---|
| GET/POST | /graphql | Bearer KC JWT | GraphQL endpoint (requires GRAPHQL_ENABLED=true) |
| GET | /health | None | Health check |
Configuration
Section titled “Configuration”Full template in .env.example. Key variables:
PORT=4000GRAPHQL_ENABLED=false # must be true to enable the endpoint
QUERY_DEPTH_LIMIT=10QUERY_COMPLEXITY_LIMIT=1000QUERY_ALIAS_LIMIT=15
STRAPI_URL=http://sencai-backend:1337STRAPI_API_TOKEN= # read access to cloud-instances, incidents, audit-logs, organisation-members
KEYCLOAK_URL=http://keycloak:8080/kcKEYCLOAK_REALM=sencaiKEYCLOAK_CLIENT_ID=sencai-frontendADMIN_KC_REALM_ROLE=sencai-admin
RABBITMQ_URL=amqp://admin:admin@sencai-mq:5672NODE_ENV=developmentTroubleshooting
Section titled “Troubleshooting”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.