Skip to content

Commit a42d776

Browse files
feat(keystone): INFRA-931 add ApolloQueryBlockingPlugin (#5914)
* refactor(keystone): INFRA-931 reorganize plugins * feat(keystone): INFRA-931 add ApolloQueryBlockingPlugin * docs(keystone): INFRA-931 added docs to ApolloQueryBlockingPlugin * refactor(keystone): INFRA-931 inline error type
1 parent fa72724 commit a42d776

25 files changed

+226
-53
lines changed

apps/condo/domains/user/schema/ResetUserLimitAction.test.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
*/
44
const { faker } = require('@faker-js/faker')
55

6+
const { ApolloRateLimitingPlugin } = require('@open-condo/keystone/apolloServerPlugins')
67
const { getKVClient } = require('@open-condo/keystone/kv')
7-
const { ApolloRateLimitingPlugin } = require('@open-condo/keystone/rateLimiting')
88
const { makeLoggedInAdminClient, makeClient, expectToThrowGQLError } = require('@open-condo/keystone/test.utils')
99
const {
1010
expectToThrowAuthenticationErrorToObj,

apps/condo/domains/user/utils/limits/resetters/RateLimitResetter.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1+
const { ApolloRateLimitingPlugin } = require('@open-condo/keystone/apolloServerPlugins')
12
const { getKVClient } = require('@open-condo/keystone/kv')
2-
const { ApolloRateLimitingPlugin } = require('@open-condo/keystone/rateLimiting')
33

44
const { UUID_TYPE, IPv4_TYPE } = require('@condo/domains/user/constants/identifiers')
55

apps/condo/lang/en/en.json

+1
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,7 @@
349349
"api.common.WRONG_PHONE_FORMAT": "Wrong phone number format",
350350
"api.contact.contact.CONTACT_DUPLICATE_ERROR": "Contact with this phone number is already registered in this room",
351351
"api.document.WRONG_PROPERTY_ORGANIZATION": "Property has different organization",
352+
"api.global.queryBlocking.FORBIDDEN_REQUEST": "This request is denied by the server because it contains blocked operations: {blockedOperations}",
352353
"api.global.rateLimit.RATE_LIMIT_EXCEEDED": "You've made too many requests recently, try again later",
353354
"api.marketplace.invoice.ALREADY_CANCELED": "Changing of canceled invoice is forbidden",
354355
"api.marketplace.invoice.ALREADY_PAID": "Changing of paid invoice is forbidden",

apps/condo/lang/es/es.json

+1
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,7 @@
349349
"api.common.WRONG_PHONE_FORMAT": "Formato del número de teléfono incorrecto",
350350
"api.contact.contact.CONTACT_DUPLICATE_ERROR": "Un contacto con este número de teléfono ya está registrado en esta propiedad",
351351
"api.document.WRONG_PROPERTY_ORGANIZATION": "La propiedad tiene una organización diferente",
352+
"api.global.queryBlocking.FORBIDDEN_REQUEST": "Esta petición ha sido denegada por el servidor porque contiene operaciones bloqueadas: {blockedOperations}",
352353
"api.global.rateLimit.RATE_LIMIT_EXCEEDED": "Has creado demasiadas incidencias. Por favor, inténtalo más tarde.",
353354
"api.marketplace.invoice.ALREADY_CANCELED": "No se puede modificar una factura cancelada",
354355
"api.marketplace.invoice.ALREADY_PAID": "No se puede modificar una factura pagada",

apps/condo/lang/ru/ru.json

+1
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,7 @@
349349
"api.common.WRONG_PHONE_FORMAT": "Неверный формат номера телефона",
350350
"api.contact.contact.CONTACT_DUPLICATE_ERROR": "Контакт с таким номером телефона уже зарегистрирован в этом помещении",
351351
"api.document.WRONG_PROPERTY_ORGANIZATION": "Дом находится в другой организации",
352+
"api.global.queryBlocking.FORBIDDEN_REQUEST": "Данный запрос запрещен сервером, так как содержит заблокированные операции: {blockedOperations}",
352353
"api.global.rateLimit.RATE_LIMIT_EXCEEDED": "Вы сделали слишком много запросов за последнее время, повторите попытку позднее",
353354
"api.marketplace.invoice.ALREADY_CANCELED": "Нельзя изменять отмененный счет",
354355
"api.marketplace.invoice.ALREADY_PAID": "Нельзя изменять оплаченный счет",

packages/keystone/KSv5v6/v5/prepareKeystone.js

+25-12
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ const { v4 } = require('uuid')
1212

1313
const conf = require('@open-condo/config')
1414
const { safeApolloErrorFormatter } = require('@open-condo/keystone/apolloErrorFormatter')
15+
const { ApolloRateLimitingPlugin, ApolloQueryBlockingPlugin } = require('@open-condo/keystone/apolloServerPlugins')
16+
const { ApolloSentryPlugin } = require('@open-condo/keystone/apolloServerPlugins')
1517
const { ExtendedPasswordAuthStrategy } = require('@open-condo/keystone/authStrategy/passwordAuth')
1618
const { parseCorsSettings } = require('@open-condo/keystone/cors.utils')
1719
const { _internalGetExecutionContextAsyncLocalStorage } = require('@open-condo/keystone/executionContext')
@@ -23,8 +25,6 @@ const { expressErrorHandler } = require('@open-condo/keystone/logging/expressErr
2325
const metrics = require('@open-condo/keystone/metrics')
2426
const { composeNonResolveInputHook, composeResolveInputHook } = require('@open-condo/keystone/plugins/utils')
2527
const { schemaDocPreprocessor, adminDocPreprocessor, escapeSearchPreprocessor, customAccessPostProcessor } = require('@open-condo/keystone/preprocessors')
26-
const { ApolloRateLimitingPlugin } = require('@open-condo/keystone/rateLimiting')
27-
const { ApolloSentryPlugin } = require('@open-condo/keystone/sentry')
2828
const { prepareDefaultKeystoneConfig } = require('@open-condo/keystone/setup.utils')
2929
const { registerTasks, registerTaskQueues, taskQueues } = require('@open-condo/keystone/tasks')
3030
const { KeystoneTracingApp } = require('@open-condo/keystone/tracing')
@@ -49,6 +49,7 @@ const INFINITY_MAX_AGE_COOKIE = 1707195600
4949
const SERVICE_USER_SESSION_TTL_IN_SEC = 7 * 24 * 60 * 60 // 7 days in sec
5050
const RATE_LIMIT_CONFIG = JSON.parse(conf['RATE_LIMIT_CONFIG'] || '{}')
5151
const IS_RATE_LIMIT_DISABLED = conf['DISABLE_RATE_LIMIT'] === 'true'
52+
const BLOCKED_OPERATIONS = JSON.parse(conf['BLOCKED_OPERATIONS'] || '{}')
5253

5354
const logger = getLogger('uncaughtError')
5455

@@ -92,6 +93,26 @@ class DataVersionChecker {
9293
}
9394
}
9495

96+
function _getApolloServerPlugins (keystone) {
97+
/** @type {Array<import('apollo-server-plugin-base').ApolloServerPlugin>} */
98+
const apolloServerPlugins = [
99+
new ApolloQueryBlockingPlugin(BLOCKED_OPERATIONS),
100+
]
101+
102+
if (!IS_RATE_LIMIT_DISABLED) {
103+
apolloServerPlugins.push(new ApolloRateLimitingPlugin(keystone, RATE_LIMIT_CONFIG))
104+
}
105+
106+
// NOTE: Must be after all req.context filling plugins
107+
apolloServerPlugins.push(new GraphQLLoggerPlugin())
108+
109+
if (IS_SENTRY_ENABLED) {
110+
apolloServerPlugins.unshift(new ApolloSentryPlugin())
111+
}
112+
113+
return apolloServerPlugins
114+
}
115+
95116
function prepareKeystone ({ onConnect, extendKeystoneConfig, extendExpressApp, schemas, schemasPreprocessors, tasks, queues, apps, lastApp, graphql, ui, authStrategyOpts }) {
96117
// trying to be compatible with keystone-6 and keystone-5
97118
// TODO(pahaz): add storage like https://keystonejs.com/docs/config/config#storage-images-and-files
@@ -178,15 +199,7 @@ function prepareKeystone ({ onConnect, extendKeystoneConfig, extendExpressApp, s
178199
setInterval(sendAppMetrics, 2000)
179200
}
180201

181-
const apolloPlugins = []
182-
if (!IS_RATE_LIMIT_DISABLED) {
183-
apolloPlugins.push(new ApolloRateLimitingPlugin(keystone, RATE_LIMIT_CONFIG))
184-
}
185-
apolloPlugins.push(new GraphQLLoggerPlugin())
186-
187-
if (IS_SENTRY_ENABLED) {
188-
apolloPlugins.unshift(new ApolloSentryPlugin())
189-
}
202+
const apolloServerPlugins = _getApolloServerPlugins(keystone)
190203

191204
return {
192205
keystone,
@@ -211,7 +224,7 @@ function prepareKeystone ({ onConnect, extendKeystoneConfig, extendExpressApp, s
211224
debug: IS_ENABLE_APOLLO_DEBUG,
212225
introspection: IS_ENABLE_DANGEROUS_GRAPHQL_PLAYGROUND,
213226
playground: false,
214-
plugins: apolloPlugins,
227+
plugins: apolloServerPlugins,
215228
},
216229
...(graphql || {}),
217230
}),
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
const { ApolloQueryBlockingPlugin } = require('./queryBlocking')
2+
const { ApolloRateLimitingPlugin } = require('./rateLimiting')
3+
const { ApolloSentryPlugin } = require('./sentry')
4+
5+
module.exports = {
6+
ApolloQueryBlockingPlugin,
7+
ApolloRateLimitingPlugin,
8+
ApolloSentryPlugin,
9+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# ApolloQueryBlockingPlugin
2+
3+
> With this plugin you can block a particular request / mutation in the API in case of an incident
4+
> without re-deploying the application.
5+
6+
## Working principle
7+
1. Queries and mutations are extracted from each request.
8+
2. If some query / mutation from the request is in the block list, the entire request will be rejected
9+
10+
## Configuring
11+
12+
You can find full config spec in [config.utils.specs.js](./config.utils.spec.js), but here's some brief example:
13+
```dotenv
14+
BLOCKED_OPERATIONS='{"queries": ["allResidentBillingReceipts"], "mutations": ["registerMultiPayment"]}'
15+
```
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
const Ajv = require('ajv')
2+
3+
const ajv = new Ajv()
4+
5+
const PLUGIN_OPTIONS_SCHEMA = {
6+
type: 'object',
7+
properties: {
8+
queries: {
9+
items: { type: 'string' },
10+
type: 'array',
11+
},
12+
mutations: {
13+
items: { type: 'string' },
14+
type: 'array',
15+
},
16+
},
17+
additionalProperties: false,
18+
}
19+
20+
const _validate = ajv.compile(PLUGIN_OPTIONS_SCHEMA)
21+
22+
function validatePluginConfig (options) {
23+
if (!_validate(options)) {
24+
throw new TypeError(`Invalid ApolloQueryBlockingPlugin options provided: ${ajv.errorsText(_validate.errors)}`)
25+
}
26+
27+
return options
28+
}
29+
30+
module.exports = {
31+
validatePluginConfig,
32+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
const { validatePluginConfig } = require('./config.utils')
2+
3+
describe('Plugin config utils', () => {
4+
describe('validatePluginOptions', () => {
5+
describe('Must bypass valid options', () => {
6+
const validCases = [
7+
['empty config', {}],
8+
['queries config', { queries: ['allTickets', '_allTicketsMeta'] }],
9+
['mutations config', { mutations: ['createUser', 'updateProperty'] }],
10+
['combined config', { queries: ['allTickets', '_allTicketsMeta'], mutations: ['createUser', 'updateProperty'] }],
11+
]
12+
13+
test.each(validCases)('%p', (_, options) => {
14+
expect(() => validatePluginConfig(options)).not.toThrow()
15+
const validated = validatePluginConfig(options)
16+
expect(validated).toEqual(options)
17+
})
18+
})
19+
describe('Must throw error on invalid options', () => {
20+
const invalidCases = [
21+
['config is not object', 123],
22+
['config is JSON.stringify object', '{}'],
23+
['config with unknown properties', { fields: ['myField'] }],
24+
['config with invalid queries #1', { queries: [123] }],
25+
['config with invalid queries #2', { queries: 'allTickets' }],
26+
['config with invalid mutations #1', { mutations: [123] }],
27+
['config with invalid mutations #2', { mutations: 'allTickets' }],
28+
]
29+
30+
test.each(invalidCases)('%p', (_, options) => {
31+
expect(() => validatePluginConfig(options)).toThrow(/Invalid ApolloQueryBlockingPlugin options provided/)
32+
})
33+
})
34+
})
35+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
const { ApolloQueryBlockingPlugin } = require('./plugin')
2+
3+
module.exports = {
4+
ApolloQueryBlockingPlugin,
5+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
const { GQLError, GQLErrorCode: { FORBIDDEN } } = require('@open-condo/keystone/errors')
2+
3+
const { validatePluginConfig } = require('./config.utils')
4+
5+
const { extractQueriesAndMutationsFromRequest } = require('../utils/requests')
6+
7+
/** @implements {import('apollo-server-plugin-base').ApolloServerPlugin} */
8+
class ApolloQueryBlockingPlugin {
9+
/** @type {Set<string>} */
10+
#blockedQueries = new Set()
11+
/** @type {Set<string>} */
12+
#blockedMutations = new Set()
13+
14+
constructor (config) {
15+
config = validatePluginConfig(config || {})
16+
17+
if (config.queries) {
18+
this.#blockedQueries = new Set(config.queries)
19+
}
20+
21+
if (config.mutations) {
22+
this.#blockedMutations = new Set(config.mutations)
23+
}
24+
}
25+
26+
requestDidStart () {
27+
return {
28+
didResolveOperation: async (requestContext) => {
29+
if (!this.#blockedQueries.size && !this.#blockedMutations.size) return
30+
31+
const { mutations, queries } = extractQueriesAndMutationsFromRequest(requestContext)
32+
33+
const blockedMutations = mutations
34+
.map(mutation => mutation.name)
35+
.filter(name => this.#blockedMutations.has(name))
36+
const blockedQueries = queries
37+
.map(query => query.name)
38+
.filter(name => this.#blockedQueries.has(name))
39+
40+
if (blockedMutations.length || blockedQueries.length) {
41+
throw new GQLError({
42+
code: FORBIDDEN,
43+
type: 'FORBIDDEN_REQUEST',
44+
message: 'Request is rejected because it contains blocked queries / mutations: {blockedOperations}',
45+
messageForUser: 'api.global.queryBlocking.FORBIDDEN_REQUEST',
46+
messageInterpolation: {
47+
blockedOperations: [...blockedMutations, ...blockedQueries].join(', '),
48+
},
49+
}, requestContext.context)
50+
}
51+
},
52+
}
53+
}
54+
}
55+
56+
module.exports = {
57+
ApolloQueryBlockingPlugin,
58+
}

packages/keystone/rateLimiting/README.md packages/keystone/apolloServerPlugins/rateLimiting/README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
2. For each query it's complexity calculated
66
3. Mutations and queries complexities are summed to form total request complexity
77
4. Complexity is extracted from request in logger plugin.
8-
5. (TODO) If request is too complex or there's no quota left it throws 429 GQLError
8+
5. If request is too complex or there's no quota left it throws 429 GQLError
99

1010
## Calculating query complexity
1111
### List query complexity

packages/keystone/rateLimiting/plugin.js packages/keystone/apolloServerPlugins/rateLimiting/plugin.js

+3-1
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@ const {
1616
ERROR_TYPE,
1717
} = require('./constants')
1818
const { extractWhereComplexityFactor, extractRelationsComplexityFactor } = require('./query.utils')
19-
const { extractQueriesAndMutationsFromRequest, extractQuotaKeyFromRequest, addComplexity, buildQuotaKey } = require('./request.utils')
19+
const { extractQuotaKeyFromRequest, addComplexity, buildQuotaKey } = require('./request.utils')
2020
const { extractPossibleArgsFromSchemaQueries, extractKeystoneListsData } = require('./schema.utils')
2121

22+
const { extractQueriesAndMutationsFromRequest } = require('../utils/requests')
23+
2224
/** @implements {import('apollo-server-plugin-base').ApolloServerPlugin} */
2325
class ApolloRateLimitingPlugin {
2426
/** @type {import('@keystonejs/keystone').Keystone} */
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
const { PLUGIN_KEY_PREFIX } = require('./constants')
2+
3+
function buildQuotaKey (identityPrefix, identity) {
4+
return [PLUGIN_KEY_PREFIX, identityPrefix, identity].join(':')
5+
}
6+
7+
function extractQuotaKeyFromRequest (requestContext) {
8+
const isAuthed = Boolean(requestContext.context.authedItem)
9+
const identifier = isAuthed ? requestContext.context.authedItem.id : requestContext.context.req.ip
10+
const identityPrefix = isAuthed ? 'user' : 'ip'
11+
const key = buildQuotaKey(identityPrefix, identifier)
12+
13+
return { isAuthed, key, identifier }
14+
}
15+
16+
function addComplexity (existingComplexity, newComplexity) {
17+
if (!existingComplexity) {
18+
return newComplexity
19+
}
20+
21+
return {
22+
...existingComplexity,
23+
details: {
24+
queries: [...existingComplexity.details.queries, ...newComplexity.details.queries],
25+
mutations: [...existingComplexity.details.mutations, ...newComplexity.details.mutations],
26+
},
27+
queries: existingComplexity.queries + newComplexity.queries,
28+
mutations: existingComplexity.mutations + newComplexity.mutations,
29+
total: existingComplexity.total + newComplexity.total,
30+
}
31+
}
32+
33+
module.exports = {
34+
extractQuotaKeyFromRequest,
35+
addComplexity,
36+
buildQuotaKey,
37+
}

packages/keystone/rateLimiting/request.utils.js packages/keystone/apolloServerPlugins/utils/requests.js

-35
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
const { PLUGIN_KEY_PREFIX } = require('./constants')
2-
31
function extractArgValue (valueNode, variables) {
42
switch (valueNode.kind) {
53
case 'Variable':
@@ -75,39 +73,6 @@ function extractQueriesAndMutationsFromRequest (requestContext) {
7573
return { queries, mutations }
7674
}
7775

78-
function buildQuotaKey (identityPrefix, identity) {
79-
return [PLUGIN_KEY_PREFIX, identityPrefix, identity].join(':')
80-
}
81-
82-
function extractQuotaKeyFromRequest (requestContext) {
83-
const isAuthed = Boolean(requestContext.context.authedItem)
84-
const identifier = isAuthed ? requestContext.context.authedItem.id : requestContext.context.req.ip
85-
const identityPrefix = isAuthed ? 'user' : 'ip'
86-
const key = buildQuotaKey(identityPrefix, identifier)
87-
88-
return { isAuthed, key, identifier }
89-
}
90-
91-
function addComplexity (existingComplexity, newComplexity) {
92-
if (!existingComplexity) {
93-
return newComplexity
94-
}
95-
96-
return {
97-
...existingComplexity,
98-
details: {
99-
queries: [...existingComplexity.details.queries, ...newComplexity.details.queries],
100-
mutations: [...existingComplexity.details.mutations, ...newComplexity.details.mutations],
101-
},
102-
queries: existingComplexity.queries + newComplexity.queries,
103-
mutations: existingComplexity.mutations + newComplexity.mutations,
104-
total: existingComplexity.total + newComplexity.total,
105-
}
106-
}
107-
10876
module.exports = {
10977
extractQueriesAndMutationsFromRequest,
110-
extractQuotaKeyFromRequest,
111-
addComplexity,
112-
buildQuotaKey,
11378
}

0 commit comments

Comments
 (0)