graphql-armory 0.1.0.pre9 → 0.1.0.pre10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3200e9b549bc6bb3b7f77d1905fb79a9d41b59d4730452300437572b4c7cdfe3
4
- data.tar.gz: 3653d86878c2395602ac78e48f70266cc89ea42d0f393eb122542e958466e36a
3
+ metadata.gz: 306b875ce5c7b3b423266aac6acad20df8cff2fcfb2de8bd2fd617f18f06ee0a
4
+ data.tar.gz: 4850e8b8d61f40d0bfe87a25326b5af43a12415c73dc3fcabdfddb1b861cd393
5
5
  SHA512:
6
- metadata.gz: aa7fbc795c731c4f11cd8e6a002b3178f377ef97caa1669771b8a641a7a2860a9fdf49525f49d25fcb0eaced66901cace0b74bb8acfe05918f456421bbc4d0e6
7
- data.tar.gz: c828ed91f9bd66f7856d603e33f8e44ce875861be873061e4ae016b9b6b3714bb6daeb0001a8862fef1c9b21134a7c80476ec861f18dee25a015bf67218bcb7c
6
+ metadata.gz: 197340f48f89963609828eff4da2250a56c49bd7946546fafccb1aae03a8fa0743c4850ae24b5df7e780f89d29633c42b380c0326838ecdd8389b4693efd8f3a
7
+ data.tar.gz: b3343756c445fb47b688afc126181cefd94d448396324ac310392a3bbda5a5d17ce789979560fa6761f28d6491d62ddcb4d94a8c9befe9da11ac0ce557056f48
data/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.1.0.pre10 (2026-11-03)
6
+
7
+ This release closes the ways a client could make an analysis refusal disappear, and makes clients pay for the invalid queries they send. Anyone using [rate limits](docs/rate-limiting.md) together with `max_depth`, `max_complexity` or their own analyzers should read Breaking before upgrading: a query whose analysis keeps raising is now refused instead of run. [Keyset connections](docs/keyset-connections.md) now check the cursor encoder when the app loads.
8
+
9
+ ### Breaking
10
+
11
+ - On a schema with a rate limiter, an analysis refusal is no longer lost when an analyzer or a `complexity:` lambda raises `ExecutionError`, which a client could choose by asking for that field. A `max_depth` or `max_complexity` refusal, or one another analyzer returned, stands; an `AnalysisError` raised from `result` keeps its message. Any query analyzer that keeps raising when asked again, including `max_complexity` with a raising lambda and your own maximums, refuses the query with `QUERY_LIMIT_UNKNOWN`, in soft mode too. When a raise stops the visit, a query in a multiplex with multiplex analyzers (such as `Schema.multiplex` with `max_complexity`) is refused the same way. To upgrade: if an analyzer or a lambda raises on honest queries, move that check to the field. Limits: a schema with no rate limiter keeps graphql-ruby's behaviour, and an analyzer that remembers in the context that it already refused still loses that refusal. See [Guarantees and limits](docs/rate-limiting.md#guarantees-and-limits). (#49, #53)
12
+ - Invalid queries are now charged. A syntax error, a validation error, no query string or a strict `PersistedQueries` refusal costs `invalid_cost:` points on `Points` (default 1; `nil` turns it off) and, on `Time`, a share of the time of a multiplex where no query ran. Its answer does not change: this penalises the next valid query and does not stop a flood. To upgrade: clients that send many invalid queries spend more, so read `context[:rate_limit_points]` in soft mode first. (#51)
13
+
14
+ ### Added
15
+
16
+ - `use GraphQL::Armory::Keyset` checks the cursor encoder when the schema loads, and any encoder set later on it or on a subclass, each member of an encoder chain included. It refuses an encoder that does not round trip, leaves the text readable, has a fixed key or nonce, or decodes forged text; it cannot prove encryption. `plain_cursors: true` on `use` becomes the default for `build`. Without `use` nothing changes. See [Checking the encoder](docs/keyset-connections.md#checking-the-encoder-when-the-app-loads). (#52)
17
+
18
+ ### Docs
19
+
20
+ - How to audit a schema before adopting the plugin and what to fix first, in [Authorization](docs/authorization.md#measuring-before-you-adopt); a missing rate limit key raises when the query runs, in [Rate limiting](docs/rate-limiting.md). Specs that need Redis skip without `REDIS_URL` outside CI. (#50)
21
+
5
22
  ## 0.1.0.pre9 (2026-11-02)
6
23
 
7
24
  This release is about adopting the gem on a real schema, and about limits that hold when a client tries to get around them. An existing app can now [observe a new rule](docs/authorization.md) before enforcing it and keep the audit green on known problems with a baseline; persisted queries can be published without a server deploy and from several manifests ([persisted queries](docs/persisted-queries.md)); and [rate limits](docs/rate-limiting.md) take several keys per query, against clients that rotate one identity. Anyone running the audit or rate limits in hard mode should read Breaking and Security before upgrading.
data/README.md CHANGED
@@ -6,12 +6,12 @@
6
6
 
7
7
  Open-source extensions for [graphql-ruby](https://github.com/rmosolgo/graphql-ruby), built on its public extension points: no fork, no monkey patching. One gem, one module at a time; each module has its own page in [`docs/`](docs) with the problem it solves, how to use it, and its limits.
8
8
 
9
- > **Status: early development.** Prereleases on RubyGems (`0.1.0.pre9`). The API may change before 1.0.
9
+ > **Status: early development.** Prereleases on RubyGems (`0.1.0.pre10`). The API may change before 1.0.
10
10
 
11
11
  ## Install
12
12
 
13
13
  ```ruby
14
- gem "graphql-armory", "~> 0.1.0.pre9" # a prerelease: `gem install graphql-armory --pre`
14
+ gem "graphql-armory", "~> 0.1.0.pre10" # a prerelease: `gem install graphql-armory --pre`
15
15
  gem "pundit" # or "cancancan", for the authorization module
16
16
  ```
17
17
 
@@ -26,8 +26,8 @@ Status: ✅ released, 🚧 in progress, ⬜ not started. Update this table in th
26
26
  | 1 | Authorization (Pundit, CanCanCan) | ✅ | [docs/authorization.md](docs/authorization.md) | Released in 0.1.0.pre2 (prerelease). Pundit and CanCanCan (the audit checks less with CanCanCan). Global IDs in `loads:` decoded through `object_from_id`; `loads:` inside input objects scoped with `include Base`. `Execution::Next` needs graphql >= 2.6.2. |
27
27
  | 2 | Encrypted, versioned IDs and cursors | ✅ | [docs/encrypted-ids.md](docs/encrypted-ids.md) | Released in 0.1.0.pre4 (prerelease). `Encoder` (AES-256-GCM), versioned chains and `Legacy`, `Encoder.default` (ENV or Rails), `use GraphQL::Armory::IDs`. Composite primary keys not supported. |
28
28
  | 3 | Persisted queries / operation allowlist | ✅ | [docs/persisted-queries.md](docs/persisted-queries.md) | Released in 0.1.0.pre4 (prerelease). Operations can be published without a server deploy (`RedisStore`, unreleased). Allowlist from a build manifest (Apollo, Relay), `use GraphQL::Armory::PersistedQueries` with strict mode. A runtime store (database, sync endpoint) is planned for later. |
29
- | 4 | Rate limiting | ✅ | [docs/rate-limiting.md](docs/rate-limiting.md) | Released in 0.1.0.pre5 (prerelease); `RateLimits::Time` in 0.1.0.pre6. Points per client (query complexity) and operations at the same time, `MemoryStore` and `RedisStore`, the budget in response extensions and headers. `RateLimits::Time` charges the wall-clock milliseconds operations take. |
30
- | 5 | Stable (keyset) connections | ✅ | [docs/keyset-connections.md](docs/keyset-connections.md) | Released in 0.1.0.pre7 (prerelease). `Keyset.build(relation)` per resolver, ActiveRecord only, plain non-null columns of the relation's own table. Base64 cursors refused unless `plain_cursors: true`. No total count. |
29
+ | 4 | Rate limiting | ✅ | [docs/rate-limiting.md](docs/rate-limiting.md) | Released in 0.1.0.pre5 (prerelease); `RateLimits::Time` in 0.1.0.pre6. Points per client (query complexity) and operations at the same time, `MemoryStore` and `RedisStore`, the budget in response extensions and headers. `RateLimits::Time` charges the wall-clock milliseconds operations take. Invalid queries are charged `invalid_cost:` from 0.1.0.pre10. |
30
+ | 5 | Stable (keyset) connections | ✅ | [docs/keyset-connections.md](docs/keyset-connections.md) | Released in 0.1.0.pre7 (prerelease). `Keyset.build(relation)` per resolver, ActiveRecord only, plain non-null columns of the relation's own table. Base64 cursors refused unless `plain_cursors: true`. No total count. `use` checks the cursor encoder when the schema loads, from 0.1.0.pre10. |
31
31
  | 6 | Subscriptions over Pusher / Ably | ⬜ | | Research not started. |
32
32
  | 7 | Response caching | ⬜ | | Research not started. |
33
33
  | 8 | API versioning (changesets) | ⬜ | | Research not started. |
@@ -271,6 +271,41 @@ expect(AppSchema).to be_fully_authorized(baseline: "config/graphql_armory_baseli
271
271
 
272
272
  Run `audit_baseline` again after fixing problems: on a file that exists it only removes what is gone, and it never adds. To accept a new problem, edit the file by hand, so it shows in the pull request: add its kind, path and message as the output prints them; `count` and `covers` may be left out, and then the entry accepts one problem that reaches nothing, so run `audit_baseline` afterwards to fill them in. The file holds each problem's kind, path, message and count, and the names it reaches (`covers`), and no data of the app. Removing what a problem reaches is fine: running `audit_baseline` narrows the entry.
273
273
 
274
+ ### Measuring before you adopt
275
+
276
+ With no plugin, the audit reports little more than `:plugin` and each base class without `Base`. To see the real list, audit the schema as if it had adopted the plugin, without touching what runs in production:
277
+
278
+ ```ruby
279
+ # script/armory_audit.rb. Run it on its own, never in the process of your app or of your other specs.
280
+ require_relative "../config/environment"
281
+ Rails.application.eager_load!
282
+
283
+ z = GraphQL::Armory::Authorization
284
+ [Types::BaseObject, Types::BaseField, Types::BaseArgument, Types::BaseInputObject, Mutations::BaseMutation].each { |base| base.include(z::Base) }
285
+ Types::BaseObject.default_action(:read)
286
+
287
+ simulated = Class.new(AppSchema) do
288
+ use z, adapter: :pundit,
289
+ own_checks: { Types::BaseField => { authorized?: "adds the status check, then calls super" } }
290
+ end
291
+
292
+ path = "config/graphql_armory_baseline.json"
293
+ report = z::Audit.(simulated)
294
+ z::Audit::Baseline.write(report, path) unless File.exist?(path) # once; commit the file
295
+ result = z::Audit::Baseline.load(path).(report)
296
+ puts result.new_problems
297
+ exit(result.passes? ? 0 : 1)
298
+ ```
299
+
300
+ `Class.new(AppSchema)` leaves `AppSchema` without the plugin, but `include(z::Base)` changes your base classes for the whole process, so its queries no longer behave as in production. That is why the script runs alone. Name your own classes and your user key (`z::Adapters::Pundit.new(user_key: :viewer)` if it is not `current_user`). The first run stops with an error if an undeclared own check or hook remains: declare it or remove it, then run it again. A simulation measures; it protects nothing until the plugin is really installed.
301
+
302
+ What to do first with a long list:
303
+
304
+ 1. **`:hook` and `:own_check`.** A baseline refuses them. Remove the method, or declare it with its reason. A declaration is your word: the audit does not read whether your check runs.
305
+ 2. **`:unscoped_list` and `:unscoped_load`.** These reach data of other users once a rule is in place.
306
+ 3. **`:no_rule` on mutations, with `:field_without_rule` on their payloads.** Without a rule, the field of a payload is denied: when the mutation is public and writes, the client gets `null` and cannot tell whether it worked. A rule on the mutation covers its payload, and a public one declares `authorize :none, reason: "...", covers_fields: true`.
307
+ 4. **`:model`.** Usually the longest part of the list, and the slowest: each type needs a model and a policy.
308
+
274
309
  ### Redefined fields and arguments
275
310
 
276
311
  When a field is hidden from a user, by `visible?` or anything else, and the request carries no visibility profile, graphql-ruby does not answer "no such field": it looks for another definition of that name and serves it. So this rule does not run for a user the child's field is hidden from:
@@ -38,6 +38,21 @@ If the ordering values are not secret (a public list ordered by id), you can kee
38
38
  GraphQL::Armory::Keyset.build(Article.order(:id), plain_cursors: true)
39
39
  ```
40
40
 
41
+ ### Checking the encoder when the app loads
42
+
43
+ Without more, a schema that cannot keep cursors private is refused on the first keyset page, in a request. `use` moves that to when the schema class loads, and looks further than the default encoder:
44
+
45
+ ```ruby
46
+ class AppSchema < GraphQL::Schema
47
+ cursor_encoder AppEncoder
48
+ use GraphQL::Armory::Keyset # below cursor_encoder
49
+ end
50
+ ```
51
+
52
+ It probes the encoder, and refuses one that does not give back what it encoded, leaves the text readable (plain, Base64 or hex, also behind a prefix, ROT13, reversed), changes only part of its output when the input changes (a fixed key, or a real cipher with a fixed nonce or IV), or decodes forged text. Any encoder set later on that schema or on a subclass goes through the same check.
53
+
54
+ `use GraphQL::Armory::Keyset, plain_cursors: true` turns the check off and makes readable cursors the schema's default. Then `Keyset.build(relation)` needs no option of its own, and an explicit `plain_cursors:` on `build` still wins.
55
+
41
56
  ### What it can order by
42
57
 
43
58
  The relation must be ordered by plain, non-null columns of its own table. The primary key is added as the last column, to break ties, so a relation with no order pages by primary key. `build` raises `Keyset::ConfigurationError` for:
@@ -64,10 +79,14 @@ If your own type defines `scope_items`, it receives the `Keyset` object and not
64
79
 
65
80
  ## Reference
66
81
 
67
- `GraphQL::Armory::Keyset.build(relation, plain_cursors: false)`
82
+ `use GraphQL::Armory::Keyset, plain_cursors: false`, optional, on a schema.
83
+
84
+ - `plain_cursors:` is `true` or `false`. `true` skips the check and is what `build` follows when it is given none. A subclass inherits it, and its own `use` replaces it.
85
+
86
+ `GraphQL::Armory::Keyset.build(relation, plain_cursors: nil)`
68
87
 
69
88
  - `relation`: an ActiveRecord relation, checked as above.
70
- - `plain_cursors:` is `true` or `false`. `true` allows the schema's default Base64 encoder.
89
+ - `plain_cursors:` is `true`, `false` or `nil`. `true` allows the schema's default Base64 encoder. `nil` follows the schema's `use`, or `false` with no `use`.
71
90
 
72
91
  Errors:
73
92
 
@@ -75,6 +94,7 @@ Errors:
75
94
  |---|---|
76
95
  | Boot or resolver, a relation it cannot page | `Keyset::ConfigurationError` |
77
96
  | First page, the schema has the default encoder and no `plain_cursors: true` | `Keyset::ConfigurationError` |
97
+ | `use` or a later `cursor_encoder`, an encoder that fails the check | `Keyset::ConfigurationError`, naming an error's class and never its message |
78
98
  | A cursor that is tampered, malformed, or made for another ordering | `Invalid ID or cursor`, the same for all |
79
99
  | `first` and `last` together | `first and last cannot be used together` |
80
100
 
@@ -83,8 +103,12 @@ Errors:
83
103
  - Every row present for the whole walk is seen exactly once, whatever is inserted or deleted meanwhile.
84
104
  - A row whose ordering value **changes** between pages can be skipped or seen twice: it really moved. Order by columns that do not change (a creation time, an id) when this matters.
85
105
  - A row inserted behind the cursor is not seen on the way forward, as in any pagination.
86
- - The encoder check runs on the first page, not at boot, because the connection reads the encoder from the schema of the query.
87
- - The check only knows the default Base64 encoder. A custom encoder that does not encrypt passes unnoticed: a keyset cursor is only as private as your `cursor_encoder`.
106
+ - With no `use`, the encoder check runs on the first page and only knows the default Base64 encoder: a custom encoder that does not encrypt passes unnoticed.
107
+ - With `use`, the check runs when the schema class loads, which is not boot if your app autoloads it lazily, and it cannot prove encryption: a keyed cipher that fails none of the probes passes, sound or not. A keyset cursor is only as private as your `cursor_encoder`.
108
+ - The check calls your encoder when the schema loads, so an encoder whose key is not there yet (during `assets:precompile`, or set in a later initializer) is refused. Fix the load order, or leave out `use` for that step: the check on the first page stays.
109
+ - Every member of an `Encoder.versioned` chain is checked on its own, because each one decodes what a client sends. `Encoder.versioned(New, Encoder::Legacy)` is refused: during such a migration a client can read and make its own cursors, so state it with `plain_cursors: true`.
110
+ - `use` checks the encoders set after it. A subclass that already set its own encoder before `use` ran on its parent is not checked: put `use` in the base schema, before any subclass is defined.
111
+ - A `Keyset` subclass that overrides `encode` or `decode` is not checked. Setting the encoder through `instance_variable_set` is not checked.
88
112
  - With `plain_cursors: true` a client can read the ordering values and forge a cursor. It stays inside the scope and its values are bound, never interpolated, but it can probe those columns.
89
113
  - A forged value on a plain cursor must have the form the column's own cursors have (an Integer, a plain decimal, an ISO 8601 date or time) or it is an invalid cursor. A forged value of the right form is honoured: it pages from there.
90
114
  - Encryption keeps length, so the length of an encrypted cursor reveals roughly the length of a string ordering value.
@@ -277,6 +277,8 @@ store.calls # [[:take, "user:1", { cost:
277
277
  - `window:` positive seconds over which `limit` points refill, continuously.
278
278
  - `soft:` `true` (default) charges and records without refusing; `false` refuses.
279
279
  - `key:` a callable `(query)` answering the client key; by default `context[:rate_limit_key]`. It must be a non-empty String, or a list of up to 5 different ones for all three limiters: a query runs only if every key allows it. The record, the headers and the extensions show the key that binds, and the record adds `keys:` with each one. The refusals a client sees never name a key. Without one, the query raises `ConfigurationError`, on every query: a key built from something that can be nil (an IP behind a misconfigured balancer) needs a fallback, such as `context[:rate_limit_key] = request.remote_ip.presence || "unknown"`, where all such clients share one bucket.
280
+ - A query with no key raises `RateLimits::ConfigurationError` when it runs, not at boot, so a controller that forgets the key, or a `request.remote_ip` that comes out nil behind a load balancer, breaks every query that reaches the limiter. Cover each controller with a request spec.
281
+ - `invalid_cost:` on `Points`, a positive Integer or nil: the points an invalid query costs. Default 1; nil charges nothing.
280
282
  - `on_store_error:` a callable `(error, query)` called when the store raises; the query then runs (fails open). By default it warns.
281
283
  - `sinks:` callables that hear `:rate_limit_store_failed` when the store raises; see [Telemetry](telemetry.md#rate-limiting).
282
284
  - `extensions:` `true` adds `"rateLimit" => { cost, limit, remaining, resetIn }` to every response of a limited client; `false` (default) leaves responses unchanged.
@@ -311,11 +313,12 @@ Invalid settings raise `RateLimits::ConfigurationError` at boot.
311
313
 
312
314
  ## Guarantees and limits
313
315
 
314
- - A query this limiter refuses resolves no field and is not charged. A query that fails validation is not charged. A query refused by another analyzer (`max_depth`, `max_complexity`) is still charged: graphql-ruby runs every analyzer.
316
+ - A query this limiter refuses resolves no field and is not charged. A query refused by another analyzer (`max_depth`, `max_complexity`) is still charged: graphql-ruby runs every analyzer.
315
317
  - Each query in a multiplex is charged on its own.
316
318
  - With several keys, `Points` reads every balance and then charges, so a refused query leaves the others as they were. Two queries racing between those steps can leave a refused one having charged other keys. `Concurrency` gives back the slots a refused query took at once. `Time` charges every key the whole time; a store failure on one key does not stop the others.
317
- - An `ExecutionError` raised during analysis, which graphql-ruby would discard before running the query, does not skip a limiter. A refusal already given stands. A limiter not asked yet is asked on its own, so another analyzer's raise leaves the query charged and running as before. A limiter that cannot answer, because a `complexity:` lambda or its `on_store_error` hook raised, refuses the query in hard mode with the code `RATE_LIMIT_NOT_CHARGED`, and in soft mode lets it run and records `charged: false`. A client with no limit still runs, and a query with no key raises `ConfigurationError`. When a query takes this path it is visited once more per limiter not asked yet, so a `complexity:` lambda can run several times, `key:` and `limit:` are asked again, and a validation timeout can take longer. An analysis your app runs itself outside an execution charges nothing here. A trace that takes no modules from the schema (`context[:trace]` or a class given to `trace_mode`) is outside this: `Points` is not protected on such a trace.
318
- - A `max_depth` or `max_complexity` refusal is not a limiter's, so it is lost when an analyzer or a `complexity:` lambda raises `ExecutionError`: graphql-ruby discards every analyzer's answer and runs the query. If a lambda raises for a field a client can pick, the client can choose this. Do not raise `ExecutionError` from a lambda or an analyzer: have the analyzer's `result` return a `GraphQL::AnalysisError`.
319
+ - An invalid query (a syntax error, a validation error including a variable of the wrong type or a missing required one, no query string, or a refusal from `PersistedQueries` in strict mode) is charged `invalid_cost:` points after it answers; its answer does not change. This penalises the client's next valid query: it does not stop a flood of invalid ones, which are parsed and validated all the same, and a bucket stops at zero. A query the analyzers refused is not invalid: it is charged as before, also when a multiplex analyzer refuses the whole multiplex. Keys are charged in list order, as if the queries had come one at a time: a key that cannot pay all of it pays what it holds, and the keys after it pay only that much, so sending invalid queries together never costs a client less than sending them apart. Put the narrowest key first: a client with nothing left cannot keep draining a bucket it shares. Keys must not depend on the document or the variables, which an invalid query may not have. Failures here, including a query with no key, go to `on_store_error` and never replace the answer, so a missing key on an invalid query does not raise. A trace does this after the multiplex, also when an exception ends it early: then queries that never reached validation are validated to find the invalid ones. A query run with its own trace (`context[:trace]`, or a class given to `trace_mode`) is not charged.
320
+ - An `ExecutionError` raised during analysis, which graphql-ruby would discard before running the query, does not skip a limiter. A refusal already given stands. A limiter not asked yet is asked on its own, so another analyzer's raise still charges the query. A limiter that cannot answer, because a `complexity:` lambda or its `on_store_error` hook raised, refuses the query in hard mode with the code `RATE_LIMIT_NOT_CHARGED`, and in soft mode lets it run and records `charged: false`. A client with no limit still runs, and a query with no key raises `ConfigurationError`. When a query takes this path it is visited once more per limiter not asked yet and per other analyzer (see below), so a `complexity:` lambda can run several times, `key:` and `limit:` are asked again, and a validation timeout can take longer. An analysis your app runs itself outside an execution charges nothing here. A trace that takes no modules from the schema (`context[:trace]` or a class given to `trace_mode`) is outside this: `Points` is not protected on such a trace.
321
+ - On the same path every query analyzer that is not a limiter is then asked on its own. A refusal it returns stands, such as a `max_depth` or `max_complexity` refusal, and so does an `AnalysisError` it raises from `result`, with its own message. One that raises anything else again cannot answer, and the query is refused with the code `QUERY_LIMIT_UNKNOWN`, in soft mode and for a client with no limit too, because otherwise a client could get past that analyzer by asking for the field that makes it raise. That covers `max_complexity` with a `complexity:` lambda that raises and your own maximums alike. So do not raise `ExecutionError` from a `complexity:` lambda or an analyzer: an authorization check belongs in the field. Multiplex analyzers, including a `max_complexity` for a whole multiplex (which `Schema.multiplex` takes from the schema by default), cannot be asked again. When a raise stopped the visit before its end, a query in a multiplex with any multiplex analyzer is refused the same way. A lambda raises after the visit, when the multiplex analyzers have seen the whole query, so it is not refused for that. Each analyzer is asked once more at most, each with the whole remaining validation timeout, so a query on this path can take one more visit per analyzer, and an analyzer with side effects repeats them. After a validation timeout no other analyzer is asked again, since the query is already refused; a limiter not asked yet still is. Limits: this needs a rate limiter on the schema, since a schema with none keeps graphql-ruby's behaviour and the refusal is lost. An analyzer's refusal stands only if it answers the same way when asked again: one that remembers in the context that it already refused is lost. A refusal an analyzer raises as `AnalysisError` during the visit, followed by an `ExecutionError` from the same analyzer, becomes `QUERY_LIMIT_UNKNOWN` without its message. The multiplex analyzers keep the part of a refused query they saw before its visit raised, though it does not run.
319
322
  - On `Execution::Next`, `validate: false` ignores every refusal from analysis, the limiters' included. Never pass it for a client's query.
320
323
  - A field with `max_page_size: nil` under a schema with `default_max_page_size` is sized by that default, while at runtime it has no maximum, so it is charged less than it returns. Give such a field a number.
321
324
  - An `ExecutionError` from a multiplex analyzer's `result` raises to your app: nothing runs and no slot stays held.
@@ -340,7 +343,7 @@ Invalid settings raise `RateLimits::ConfigurationError` at boot.
340
343
  - A multiplex has one measured time, split equally among the queries this limiter charges, rounded up to whole milliseconds: fields of different queries interleave, so their time cannot be told apart. A query refused by `Time`, and one whose key has no limit (`nil`), are not part of the split, so the time of a query with no limit is carried by the limited ones.
341
344
  - A schema may `use Time` more than once, with different stores or keys (for example per user and per IP): each limiter charges only its own. `context[:rate_limit_time]`, the response extension and `Time.headers` then hold the record of the last one.
342
345
  - The clock must answer a finite number of seconds: one that does not is refused at boot, and one that breaks later raises instead of letting operations run uncharged. A clock that stands still or goes backwards charges 0.
343
- - Time spent before analysis finishes (parsing, validation) is charged to no one, and inside a multiplex it ends up on the queries that did run. A query that never reaches analysis (a syntax error) costs the client nothing.
346
+ - Time spent before analysis finishes (parsing, validation) is charged to no one, and it is part of what a multiplex splits. Invalid queries pay time only in a multiplex with no query that ran: there they split it, charged like on `Points` (a key that cannot pay all of its share pays what it holds, and the keys after it pay only that much). Next to a query that ran they pay no time, so padding a multiplex with invalid queries never lowers what that query pays.
344
347
  - The time is wall-clock, so it grows when the server is loaded: the same query costs a client more on a busy server. Choose `limit` with that in mind, and start in soft mode.
345
348
  - A query run with its own trace (`context: { trace: ... }`) cannot be charged, so it raises `ConfigurationError`, as with `Concurrency`.
346
349
  - When the store fails, operations run unlimited and `on_store_error` is called; a failed charge never raises into the query (unless your hook does).
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module GraphQL
6
+ module Armory
7
+ class Keyset < GraphQL::Pagination::Connection
8
+ # Asks an encoder whether it can keep a keyset cursor private, raising `ConfigurationError` when it cannot. It catches
9
+ # what is common: no encryption, a reversible form, a fixed key, and a chain that decodes forged text. It cannot prove
10
+ # encryption. It has no dependencies, so `new` is ready at once and there is nothing to `build`.
11
+ class EncoderCheck
12
+ NEEDLE = "graphql-armory-probe"
13
+ # Shaped like a real cursor, because an encoder may parse what it is given. They differ in their first value.
14
+ PROBE = JSON.generate({ "v" => 1, "o" => "0" * 32, "k" => ["#{NEEDLE}-values"] }).freeze
15
+ # Differ from PROBE near the start and at the end: a chained mode with a fixed IV changes every block after a change
16
+ # near the start, and only the last block after a change at the end.
17
+ OTHERS = [PROBE.sub('"v":1', '"v":2'), PROBE.sub('-values"', '-valuez"')].map(&:freeze).freeze
18
+
19
+ def self.call(encoder)
20
+ new.(encoder)
21
+ end
22
+
23
+ def call(encoder)
24
+ # Every member of a chain decodes what a client sends, so each one is checked on its own.
25
+ encoder.encoders.each { |member| call(member) } if encoder.is_a?(Encoder::Versioned)
26
+ first = encoded(encoder, PROBE)
27
+ refuse("does not give back what it encoded") unless decoded(encoder, first) == PROBE
28
+ refuse("leaves the cursor readable: it holds the ordering values") if readable?(first)
29
+ if OTHERS.any? { |other| local?(first, encoded(encoder, other)) }
30
+ refuse("changes only part of its output when the input changes, as a fixed key or a fixed nonce does")
31
+ end
32
+ refuse("decodes forged text, so a client can make its own cursors") if forged?(encoder)
33
+ nil
34
+ rescue ConfigurationError => error
35
+ raise if error.equal?(@refusal)
36
+
37
+ refuse("could not be probed: #{error.class}")
38
+ rescue StandardError, NotImplementedError => error
39
+ # The class only: an encoder's message may hold a key.
40
+ refuse("could not be probed: #{error.class}")
41
+ end
42
+
43
+ private
44
+
45
+ def encoded(encoder, text)
46
+ answer = encoder.encode(text, nonce: true)
47
+ answer.is_a?(String) ? answer.b : refuse("answers #{answer.class}, not a String")
48
+ end
49
+
50
+ def decoded(encoder, text)
51
+ answer = encoder.decode(text, nonce: true)
52
+ answer.is_a?(String) ? answer.b : answer
53
+ end
54
+
55
+ def readable?(output)
56
+ needle = NEEDLE.b
57
+ [output, output.reverse, output.tr("A-Za-z", "N-ZA-Mn-za-m")].any? do |form|
58
+ ([form, form.delete("\0")] + unpacked(form)).any? { |text| text.include?(needle) }
59
+ end
60
+ end
61
+
62
+ # Each long run of Base64 or hex inside the output, decoded from every offset, so a prefix of any length does not hide it.
63
+ def unpacked(form)
64
+ base64 = form.scan(%r{[A-Za-z0-9+/_-]{16,}}).flat_map do |run|
65
+ (0..3).map { |offset| run[offset..].tr("-_", "+/").unpack1("m").b }
66
+ end
67
+ hex = form.scan(/\h{16,}/).flat_map { |run| (0..1).map { |offset| [run[offset..]].pack("H*").b } }
68
+ base64 + hex
69
+ end
70
+
71
+ # A one-byte change near the start changes most of what a sound encoder answers.
72
+ def local?(one, other)
73
+ size = [one.size, other.size].max
74
+ changed = (0...size).count { |index| one.getbyte(index) != other.getbyte(index) }
75
+ changed < size / 2
76
+ end
77
+
78
+ def forged?(encoder)
79
+ [PROBE, [PROBE].pack("m0"), [PROBE].pack("m0").tr("+/", "-_").delete("=")].any? do |forgery|
80
+ decoded(encoder, forgery) == PROBE.b
81
+ rescue StandardError
82
+ false
83
+ end
84
+ end
85
+
86
+ # Remembers its own refusal, so an encoder that raises a ConfigurationError of its own is named by class, like any other.
87
+ def refuse(what)
88
+ @refusal = ConfigurationError.new(
89
+ "this cursor_encoder #{what}. A keyset cursor holds the row's ordering values: use a GraphQL::Armory::Encoder " \
90
+ "subclass, or `use GraphQL::Armory::Keyset, plain_cursors: true` if those values are not secret"
91
+ )
92
+ raise @refusal
93
+ end
94
+ end
95
+
96
+ # What `use GraphQL::Armory::Keyset` adds to a schema. A subclass inherits it, so an encoder set there is checked too.
97
+ module SchemaChecks
98
+ def armory_keyset_plain_cursors
99
+ return @armory_keyset_plain_cursors unless @armory_keyset_plain_cursors.nil?
100
+
101
+ superclass.respond_to?(:armory_keyset_plain_cursors) ? superclass.armory_keyset_plain_cursors : false
102
+ end
103
+
104
+ attr_writer :armory_keyset_plain_cursors
105
+
106
+ def cursor_encoder(new_encoder = nil)
107
+ EncoderCheck.(new_encoder) if new_encoder && !armory_keyset_plain_cursors
108
+ super
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -84,9 +84,26 @@ module GraphQL
84
84
 
85
85
  attr_reader :columns
86
86
 
87
- # Checks the relation and its ordering; resolvers go through here.
88
- def self.build(relation, plain_cursors: false)
87
+ # Checks the encoder when the schema loads, and keeps checking any encoder set later on it or on a subclass. Put it
88
+ # below `cursor_encoder`: graphql-ruby runs `use` at once, with no hook for the end of the class body.
89
+ def self.use(schema, plain_cursors: false)
89
90
  raise ConfigurationError, "plain_cursors: must be true or false, got #{plain_cursors.inspect}" unless [true, false].include?(plain_cursors)
91
+
92
+ unless plain_cursors
93
+ if schema.cursor_encoder == GraphQL::Schema::Base64Encoder
94
+ raise ConfigurationError, "no cursor_encoder is set on this schema: put cursor_encoder above `use GraphQL::Armory::Keyset`, or pass plain_cursors: true"
95
+ end
96
+
97
+ EncoderCheck.(schema.cursor_encoder)
98
+ end
99
+ schema.extend(SchemaChecks)
100
+ schema.armory_keyset_plain_cursors = plain_cursors
101
+ end
102
+
103
+ # Checks the relation and its ordering; resolvers go through here. With no `plain_cursors:`, the schema's setting
104
+ # decides when the page loads.
105
+ def self.build(relation, plain_cursors: nil)
106
+ raise ConfigurationError, "plain_cursors: must be true, false or nil, got #{plain_cursors.inspect}" unless [true, false, nil].include?(plain_cursors)
90
107
  raise ConfigurationError, "a keyset connection needs an ActiveRecord relation, got #{relation.class}" unless relation.respond_to?(:order_values)
91
108
 
92
109
  new(relation, columns: Ordering.(relation), plain_cursors: plain_cursors)
@@ -181,9 +198,11 @@ module GraphQL
181
198
  end
182
199
 
183
200
  def refuse_plain_cursors
201
+ schema = context&.schema
202
+ return if @plain_cursors.nil? && schema.respond_to?(:armory_keyset_plain_cursors) && schema.armory_keyset_plain_cursors
184
203
  return if @plain_cursors
185
204
 
186
- encoder = context&.schema&.cursor_encoder
205
+ encoder = schema&.cursor_encoder
187
206
  return unless encoder.nil? || encoder == GraphQL::Schema::Base64Encoder
188
207
 
189
208
  raise ConfigurationError,
@@ -277,3 +296,4 @@ module GraphQL
277
296
  end
278
297
 
279
298
  require "graphql/armory/keyset/controls"
299
+ require "graphql/armory/keyset/encoder_check"
@@ -6,14 +6,34 @@ module GraphQL
6
6
  # graphql-ruby discards an ExecutionError raised during analysis, drops what every analyzer answered, and runs the query.
7
7
  # A client can choose that by field order when a `complexity:` lambda raises. So on such a raise each limiter still
8
8
  # decides: a refusal already given stands, a limiter not asked yet is asked on its own, and a limiter that cannot answer
9
- # refuses the query in hard mode and records `charged: false` in soft mode. Only the analysis of an execution counts: an
10
- # analysis the app runs itself takes no slot and charges nothing here. It does not cover a trace that takes no modules
11
- # from the schema (`context[:trace]`, or a class given to `trace_mode`).
9
+ # refuses the query in hard mode and records `charged: false` in soft mode. Every other analyzer is then asked on its
10
+ # own: a refusal it returns or raises stands, and one that raises anything else cannot answer, so unless another refusal
11
+ # stands the query is refused with QUERY_LIMIT_UNKNOWN. A multiplex analyzer cannot be asked again, so when the raise
12
+ # stopped the visit before its end, a query of a multiplex with multiplex analyzers is refused the same way.
13
+ # Only the analysis of an execution counts: an analysis the app runs itself takes no slot and charges nothing here. It
14
+ # does not cover a trace that takes no modules from the schema (`context[:trace]`, or a class given to `trace_mode`).
12
15
  module AnalysisGuard
13
16
  ANSWERS = :limiter_answers
14
17
  ASKED = :limiter_asked
15
18
  ARMED = :limiter_guard_armed
19
+ MULTIPLEXED = :limiter_guard_multiplexed
20
+ # Set just before the guard asks one analyzer again, and taken by that analysis only, so a nested one never sees it.
21
+ REASK = :limiter_guard_reask
22
+ RAISED = :limiter_guard_raised
23
+ VISITED = :limiter_guard_visited
16
24
  NOT_CHARGED = "This query could not be charged to its rate limit."
25
+ LIMIT_UNKNOWN = "The limits of this query could not be computed."
26
+
27
+ # Prepended to the limiters' analyzers: records that a visit of the query reached its end. graphql-ruby calls `result`
28
+ # only after the whole visit, every hook of every analyzer (the multiplex ones too) included; a leave hook is not
29
+ # enough, since the query analyzers leave before the multiplex ones. Marked before computing anything, because a
30
+ # `complexity:` lambda raises inside Points' `result`.
31
+ module Visited
32
+ def result
33
+ query.context.namespace(Points::NAMESPACE)[VISITED] = true
34
+ super
35
+ end
36
+ end
17
37
 
18
38
  # Runs a limiter, keeping per query and per limiter that it was asked and what it answered. A limiter that raised partway
19
39
  # may have charged some of its keys, so it is never asked again.
@@ -24,22 +44,83 @@ module GraphQL
24
44
  end
25
45
 
26
46
  def self.decide(query, error)
47
+ state = query.context.namespace(Points::NAMESPACE)
48
+ # Read before the guard's own visits below. A limiter answers from `result`, after the whole visit.
49
+ visited = state[VISITED] || !(state[ASKED] || {}).empty?
50
+ refusals, late = limiter_refusals(query, error)
51
+ # Each new visit gets a whole timeout of its own, and the timeout already refuses the query.
52
+ return [late] if late
53
+ return refusals if timeout?(error)
54
+
55
+ kept, unknown = other_answers(query)
56
+ unknown ||= !visited && state[MULTIPLEXED]
57
+ # A lambda's AnalysisError can come back from a limiter and from a maximum alike: show it once.
58
+ refusals = (refusals + kept).uniq { |refusal| [refusal.message, refusal.extensions] }
59
+ refusals.empty? && unknown ? [GraphQL::AnalysisError.new(LIMIT_UNKNOWN, extensions: { "code" => "QUERY_LIMIT_UNKNOWN" })] : refusals
60
+ end
61
+
62
+ # Asks one analyzer on its own and answers `[results, the error it raised or nil]`.
63
+ def self.ask(query, analyzer)
64
+ state = query.context.namespace(Points::NAMESPACE)
65
+ state[REASK] = true
66
+ [GraphQL::Analysis.analyze_query(query, [analyzer]), state.delete(RAISED)]
67
+ ensure
68
+ state.delete(REASK)
69
+ end
70
+ private_class_method :ask
71
+
72
+ def self.limiter_refusals(query, error)
27
73
  analyzers = query.analyzers.select { |analyzer| analyzer.respond_to?(:armory_limiter) }
28
74
  state = query.context.namespace(Points::NAMESPACE)
29
75
  answers = (state[ANSWERS] ||= {})
30
76
  asked = (state[ASKED] ||= {})
31
- analyzers.reject { |analyzer| asked.key?(analyzer.armory_limiter) }.each { |analyzer| GraphQL::Analysis.analyze_query(query, [analyzer]) }
77
+ analyzers.reject { |analyzer| asked.key?(analyzer.armory_limiter) }.each do |analyzer|
78
+ results, raised = ask(query, analyzer)
79
+ late = timeout?(raised) ? raised : results.flatten.find { |answer| timeout?(answer) }
80
+ return [[], late] if late
81
+ end
32
82
 
33
83
  silent = analyzers.map(&:armory_limiter).reject { |limiter| answers.key?(limiter) || exempt?(limiter, query) }
34
84
  silent.each { |limiter| query.context[limiter.class::CONTEXT_KEY] = { limit: nil, soft: limiter.soft?, limited: !limiter.soft?, charged: false } }
35
85
 
36
86
  refusals = answers.values.compact
37
- return refusals unless refusals.empty?
38
- return [] if silent.all?(&:soft?)
87
+ return [refusals, nil] unless refusals.empty?
88
+ return [[], nil] if silent.all?(&:soft?)
39
89
 
40
- [error.is_a?(GraphQL::AnalysisError) ? error : GraphQL::AnalysisError.new(NOT_CHARGED, extensions: { "code" => "RATE_LIMIT_NOT_CHARGED" })]
90
+ [[error.is_a?(GraphQL::AnalysisError) ? error : GraphQL::AnalysisError.new(NOT_CHARGED, extensions: { "code" => "RATE_LIMIT_NOT_CHARGED" })], nil]
41
91
  end
42
92
 
93
+ private_class_method :limiter_refusals
94
+
95
+ # What the analyzers that are not limiters return or raise, one at a time, and whether one could not answer. Each is
96
+ # asked once more at most, so a raise the client chose costs one more visit per analyzer of the schema.
97
+ def self.other_answers(query)
98
+ kept = []
99
+ unknown = false
100
+ query.analyzers.reject { |analyzer| analyzer.respond_to?(:armory_limiter) }.each do |analyzer|
101
+ results, raised = ask(query, analyzer)
102
+ answers = results.flatten.grep(GraphQL::AnalysisError)
103
+ late = timeout?(raised) ? raised : answers.find { |answer| timeout?(answer) }
104
+ return [[late], false] if late
105
+
106
+ if raised.is_a?(GraphQL::AnalysisError)
107
+ # Raised from `result`: a refusal, with the app's own message.
108
+ kept << raised
109
+ elsif raised
110
+ unknown = true
111
+ else
112
+ kept.concat(answers)
113
+ end
114
+ end
115
+ [kept, unknown]
116
+ end
117
+ private_class_method :other_answers
118
+
119
+ def self.timeout?(error)
120
+ defined?(GraphQL::Analysis::TimeoutError) && error.is_a?(GraphQL::Analysis::TimeoutError)
121
+ end
122
+ private_class_method :timeout?
123
+
43
124
  # A limit that cannot be asked for does not exempt anyone.
44
125
  def self.exempt?(limiter, query)
45
126
  limiter.exempt?(query)
@@ -48,15 +129,23 @@ module GraphQL
48
129
  end
49
130
  private_class_method :exempt?
50
131
 
132
+ # Mirrors how graphql-ruby builds the multiplex analyzers on both engines.
51
133
  def analyze_multiplex(multiplex:)
52
- multiplex.queries.each { |query| query.context.namespace(Points::NAMESPACE)[ARMED] = true }
134
+ multiplexed = !multiplex.max_complexity.nil? || multiplex.schema.multiplex_analyzers.any?
135
+ multiplex.queries.each do |query|
136
+ state = query.context.namespace(Points::NAMESPACE)
137
+ state[ARMED] = true
138
+ state[MULTIPLEXED] = multiplexed
139
+ end
53
140
  super
54
141
  end
55
142
 
56
- # The first analysis of each query in an execution. Any other, nested in it or run by the app, passes through, and
57
- # each analyzer is asked apart, so one that raises leaves the others asked.
143
+ # The first analysis of each query in an execution, and each analyzer the guard asks again. Any other, nested in them or
144
+ # run by the app, passes through. graphql-ruby swallows a raise outside this hook, so the guard records it here.
58
145
  def analyze_query(query:)
59
- return super unless query.context.namespace(Points::NAMESPACE).delete(ARMED)
146
+ state = query.context.namespace(Points::NAMESPACE)
147
+ return reasked(state) { super } if state.delete(REASK)
148
+ return super unless state.delete(ARMED)
60
149
 
61
150
  begin
62
151
  super
@@ -67,6 +156,13 @@ module GraphQL
67
156
 
68
157
  private
69
158
 
159
+ def reasked(state)
160
+ yield
161
+ rescue GraphQL::ExecutionError, GraphQL::UnauthorizedError => error
162
+ state[RAISED] = error
163
+ raise
164
+ end
165
+
70
166
  def decided(query, error)
71
167
  refusals = AnalysisGuard.decide(query, error)
72
168
  raise error if refusals.empty?
@@ -171,6 +171,8 @@ module GraphQL
171
171
 
172
172
  # Takes the slot during analysis, before anything runs.
173
173
  class Analyzer < GraphQL::Analysis::Analyzer
174
+ prepend AnalysisGuard::Visited
175
+
174
176
  def result
175
177
  AnalysisGuard.answer(query, concurrency) { concurrency.(query) }
176
178
  end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Armory
5
+ module RateLimits
6
+ # Finds what a limiter owes for a multiplex's invalid queries, which no analyzer ever sees. It runs after the answers are
7
+ # made, so a failure here is reported and never replaces them.
8
+ module InvalidQueries
9
+ # The invalid queries this limiter did not handle, grouped by what they draw from: `{ { key => limit } => [query] }`.
10
+ def self.grouped(multiplex, limiter, reader, limit, handler)
11
+ groups = {}
12
+ multiplex.queries.each do |query|
13
+ next unless invalid?(query, limiter)
14
+
15
+ key = Settings.key_for(reader, query)
16
+ limits = Settings.limits_for(limit, Array(key), query)
17
+ (groups[limits] ||= []) << query unless limits.empty?
18
+ rescue StandardError => error
19
+ report(handler, error, query)
20
+ end
21
+ groups
22
+ end
23
+
24
+ # Takes `cost` from each key in order, as if the queries had come one at a time. A key that cannot pay all of it pays
25
+ # what it holds, and the keys after it pay only that much: sending invalid queries together never costs a client less
26
+ # than sending them apart, and a client with nothing left cannot keep draining a bucket it shares. Never with `force`,
27
+ # never below zero. A key whose store fails is left out. Answers `[budgets, every key paid in full]`.
28
+ def self.charge(store, limits, cost, window, handler, query, bucket: ->(key) { key })
29
+ budgets = []
30
+ paid = true
31
+ limits.each do |key, limit|
32
+ break if cost.zero?
33
+
34
+ stored = bucket.call(key)
35
+ taken, remaining = take(store, stored, cost, limit, window)
36
+ unless taken
37
+ paid = false
38
+ # A refused take changes nothing and answers what the key holds. Taking that can still fail when another request
39
+ # got there first; then the keys after it pay nothing.
40
+ held = remaining
41
+ cost = 0
42
+ if held.positive?
43
+ again, remaining = take(store, stored, held, limit, window)
44
+ cost = held if again
45
+ end
46
+ end
47
+ budgets << { key: key, limit: limit, remaining: remaining }
48
+ rescue StandardError => error
49
+ report(handler, error, query)
50
+ end
51
+ [budgets, paid]
52
+ end
53
+
54
+ def self.take(store, key, cost, limit, window)
55
+ Settings.checked(store.take(key, cost: cost, limit: limit, window: window, force: false), :take, "the Integer left")
56
+ end
57
+ private_class_method :take
58
+
59
+ def self.report(handler, error, query)
60
+ handler.call(error, query)
61
+ rescue StandardError => raised
62
+ warn("graphql-armory: on_store_error raised while charging an invalid query: #{raised.class}: #{raised.message}")
63
+ end
64
+
65
+ # Only validation decides: `valid?` is also false for a query an analyzer refused, and graphql-ruby copies a multiplex
66
+ # analyzer's errors into every query, invalid ones included. A query that was analyzed is not invalid: what the
67
+ # limiter decided on it stands.
68
+ def self.invalid?(query, limiter)
69
+ !query.validation_pipeline.valid? && !query.context.namespace(Points::NAMESPACE)[AnalysisGuard::ASKED]&.key?(limiter)
70
+ end
71
+ private_class_method :invalid?
72
+ end
73
+ end
74
+ end
75
+ end
@@ -11,18 +11,19 @@ module GraphQL
11
11
  #
12
12
  # A query that costs more than the client has left is refused before any field resolves, and charged
13
13
  # nothing. Soft mode (the default) charges and records but refuses nothing. What happened is in
14
- # `context[:rate_limit_points]`.
14
+ # `context[:rate_limit_points]`. An invalid query, which no analyzer sees, costs `invalid_cost:`, charged by a trace.
15
15
  class Points
16
16
  CONTEXT_KEY = :rate_limit_points
17
17
  NAMESPACE = :graphql_armory
18
18
 
19
- def self.use(schema, store:, limit:, window: 60, soft: true, key: nil, on_store_error: nil, sinks: [], extensions: false)
20
- points = build(store: store, limit: limit, window: window, soft: soft, key: key, on_store_error: on_store_error, sinks: sinks, extensions: extensions)
19
+ def self.use(schema, store:, limit:, window: 60, soft: true, key: nil, on_store_error: nil, sinks: [], extensions: false, invalid_cost: 1)
20
+ points = build(store: store, limit: limit, window: window, soft: soft, key: key, on_store_error: on_store_error, sinks: sinks, extensions: extensions, invalid_cost: invalid_cost)
21
21
  schema.query_analyzer(Class.new(Analyzer) do
22
22
  define_method(:points) { points }
23
23
  define_singleton_method(:armory_limiter) { points }
24
24
  end)
25
25
  schema.trace_with(AnalysisGuard)
26
+ schema.trace_with(points.invalid_trace)
26
27
  end
27
28
 
28
29
  # HTTP headers for the budget a result recorded: `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`
@@ -39,13 +40,13 @@ module GraphQL
39
40
  end
40
41
 
41
42
  # Wires the app's store with `use`'s defaults (soft mode); `use` goes through here.
42
- def self.build(store:, limit:, window: 60, soft: true, key: nil, on_store_error: nil, sinks: [], extensions: false)
43
- new(store: store, limit: limit, window: window, soft: soft, key: key, on_store_error: on_store_error, sinks: sinks, extensions: extensions)
43
+ def self.build(store:, limit:, window: 60, soft: true, key: nil, on_store_error: nil, sinks: [], extensions: false, invalid_cost: 1)
44
+ new(store: store, limit: limit, window: window, soft: soft, key: key, on_store_error: on_store_error, sinks: sinks, extensions: extensions, invalid_cost: invalid_cost)
44
45
  end
45
46
 
46
47
  # `new` is inert: with no store wired it refuses every query. It checks every setting, so no partial
47
48
  # configuration lets a query through either.
48
- def initialize(store: Store::Null.new, limit: 1, window: 60, soft: false, key: nil, on_store_error: nil, sinks: [], extensions: false)
49
+ def initialize(store: Store::Null.new, limit: 1, window: 60, soft: false, key: nil, on_store_error: nil, sinks: [], extensions: false, invalid_cost: 1)
49
50
  unless Settings.store_answers?(store, take: %i[cost limit window force])
50
51
  raise ConfigurationError, "store: must answer take(key, cost:, limit:, window:, force:), like a MemoryStore"
51
52
  end
@@ -58,6 +59,40 @@ module GraphQL
58
59
  @key = Settings.key_reader(key)
59
60
  @on_store_error = Settings.store_error_handler(on_store_error, limiter: :points, sinks: sinks)
60
61
  @extensions = extensions
62
+ unless invalid_cost.nil? || (invalid_cost.is_a?(Integer) && invalid_cost.positive?)
63
+ raise ConfigurationError, "invalid_cost: must be a positive Integer or nil, got #{invalid_cost.inspect}"
64
+ end
65
+
66
+ @invalid_cost = invalid_cost
67
+ end
68
+
69
+ # One module per limiter, so a schema with several charges each. Inside `ensure`: the answers are already made.
70
+ def invalid_trace
71
+ points = self
72
+ Module.new do
73
+ define_method(:execute_multiplex) do |multiplex:, &block|
74
+ super(multiplex: multiplex, &block)
75
+ ensure
76
+ points.charge_invalid(multiplex)
77
+ end
78
+ end
79
+ end
80
+
81
+ # Charges the multiplex's invalid queries, together per client. It never raises over the answers.
82
+ def charge_invalid(multiplex)
83
+ return if @invalid_cost.nil?
84
+
85
+ InvalidQueries.grouped(multiplex, self, @key, @limit, @on_store_error).each do |limits, queries|
86
+ budgets, paid = InvalidQueries.charge(@store, limits, @invalid_cost * queries.size, @window, @on_store_error, queries.first)
87
+ next if budgets.empty?
88
+
89
+ binding = budgets.min_by { |budget| budget[:remaining] }
90
+ queries.each do |query|
91
+ record(query, key: binding[:key], cost: @invalid_cost, limit: binding[:limit], remaining: binding[:remaining], limited: !paid, invalid: true)
92
+ end
93
+ end
94
+ rescue StandardError => error
95
+ InvalidQueries.report(@on_store_error, error, multiplex.queries.first)
61
96
  end
62
97
 
63
98
  def soft?
@@ -154,7 +189,7 @@ module GraphQL
154
189
  # In the context hash for instrumentation, and per query for `headers`: queries of a multiplex may share one
155
190
  # context hash, where the last query's record wins.
156
191
  def record(query, **info)
157
- info = { **info.slice(:key, :cost, :limit, :remaining), window: @window, soft: @soft, limited: info[:limited], **info.slice(:keys) }
192
+ info = { **info.slice(:key, :cost, :limit, :remaining), window: @window, soft: @soft, limited: info[:limited], **info.slice(:keys, :invalid) }
158
193
  query.context.namespace(NAMESPACE)[:points] = info
159
194
  query.context[CONTEXT_KEY] = info
160
195
  end
@@ -176,6 +211,8 @@ module GraphQL
176
211
 
177
212
  # graphql-ruby's own complexity analysis, then the charge.
178
213
  class Analyzer < GraphQL::Analysis::QueryComplexity
214
+ prepend AnalysisGuard::Visited
215
+
179
216
  # graphql-ruby's words when a connection has no `first:`, `last:` or page size. A client can send that, so it answers
180
217
  # an error for the client and not a server error. Nothing is charged, and any other error while scoring still raises.
181
218
  UNSIZED_CONNECTION = "Can't calculate complexity for "
@@ -13,7 +13,8 @@ module GraphQL
13
13
  # field resolves, when none is left. A trace measures the multiplex with a monotonic clock and charges the
14
14
  # elapsed milliseconds when it ends, even when it raises. A running operation is never interrupted: the one
15
15
  # that spends the last milliseconds finishes, and the next is refused. Soft mode (the default) charges and
16
- # records but refuses nothing. What happened is in `context[:rate_limit_time]`.
16
+ # records but refuses nothing. What happened is in `context[:rate_limit_time]`. An invalid query, which no analyzer
17
+ # sees, takes its share of the time too.
17
18
  class Time
18
19
  CONTEXT_KEY = :rate_limit_time
19
20
  NAMESPACE = :graphql_armory
@@ -143,9 +144,14 @@ module GraphQL
143
144
  debt = query.context.namespace(NAMESPACE)[:time_charges]&.delete(self)
144
145
  [query, debt] if debt
145
146
  end
146
- return if owed.empty?
147
-
148
- share = (seconds * 1000 / owed.size.to_f).ceil
147
+ # Invalid queries pay only a multiplex that has nothing else to charge: next to a query that ran, they would lower
148
+ # its share, and that query pays with `force` while they cannot.
149
+ invalid = owed.empty? ? invalid_queries(multiplex) : {}
150
+ payers = owed.size + invalid.values.sum(&:size)
151
+ return if payers.zero?
152
+
153
+ share = (seconds * 1000 / payers.to_f).ceil
154
+ invalid.each { |limits, queries| charge_invalid(limits, queries, share) }
149
155
  # Every query is charged even if one hook raises; the first such error is raised afterwards.
150
156
  failure = nil
151
157
  owed.each do |query, debt|
@@ -158,6 +164,28 @@ module GraphQL
158
164
 
159
165
  private
160
166
 
167
+ def invalid_queries(multiplex)
168
+ InvalidQueries.grouped(multiplex, self, @key, @limit, @on_store_error)
169
+ rescue StandardError => error
170
+ InvalidQueries.report(@on_store_error, error, multiplex.queries.first)
171
+ {}
172
+ end
173
+
174
+ # Never raises over the answers, and never with `force`: a key that cannot pay all of it pays what it holds, and the
175
+ # keys after it pay only that much.
176
+ def charge_invalid(limits, queries, share)
177
+ bucket = self.class.method(:bucket_key)
178
+ budgets, paid = InvalidQueries.charge(@store, limits, share * queries.size, @window, @on_store_error, queries.first, bucket: bucket)
179
+ return if budgets.empty?
180
+
181
+ binding = budgets.min_by { |budget| budget[:remaining] }
182
+ queries.each do |query|
183
+ record(query, key: binding[:key], limit: binding[:limit], remaining: binding[:remaining], limited: !paid, invalid: true, elapsed: share)
184
+ end
185
+ rescue StandardError => error
186
+ InvalidQueries.report(@on_store_error, error, queries.first)
187
+ end
188
+
161
189
  # Each key is charged on its own: a store failure on one, or a hook that raises for it, leaves the others charged. The
162
190
  # first error a hook raises is raised afterwards.
163
191
  def charge_one(query, debt, cost)
@@ -253,8 +281,8 @@ module GraphQL
253
281
  end
254
282
 
255
283
  # `elapsed` is nil until the operation ends and is charged; it stays nil for a refused one.
256
- def base_record(key:, limit:, limited:, remaining: nil, keys: nil)
257
- { key: key, limit: limit, remaining: remaining, elapsed: nil, window: @window, soft: @soft, limited: limited, **(keys ? { keys: keys } : {}) }
284
+ def base_record(key:, limit:, limited:, remaining: nil, keys: nil, invalid: false, elapsed: nil)
285
+ { key: key, limit: limit, remaining: remaining, elapsed: elapsed, window: @window, soft: @soft, limited: limited, **(keys ? { keys: keys } : {}), **(invalid ? { invalid: true } : {}) }
258
286
  end
259
287
 
260
288
  # The budget before the run; the charge completes the same hash, which the response already holds.
@@ -287,6 +315,8 @@ module GraphQL
287
315
 
288
316
  # Asks for the balance during analysis, before anything runs.
289
317
  class Analyzer < GraphQL::Analysis::Analyzer
318
+ prepend AnalysisGuard::Visited
319
+
290
320
  def result
291
321
  AnalysisGuard.answer(query, time) { time.(query) }
292
322
  end
@@ -149,6 +149,7 @@ require "graphql/armory/rate_limits/store"
149
149
  require "graphql/armory/rate_limits/clock"
150
150
  require "graphql/armory/rate_limits/redis_store"
151
151
  require "graphql/armory/rate_limits/analysis_guard"
152
+ require "graphql/armory/rate_limits/invalid_queries"
152
153
  require "graphql/armory/rate_limits/points"
153
154
  require "graphql/armory/rate_limits/concurrency"
154
155
  require "graphql/armory/rate_limits/time"
@@ -2,6 +2,6 @@
2
2
 
3
3
  module GraphQL
4
4
  module Armory
5
- VERSION = "0.1.0.pre9"
5
+ VERSION = "0.1.0.pre10"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: graphql-armory
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0.pre9
4
+ version: 0.1.0.pre10
5
5
  platform: ruby
6
6
  authors:
7
7
  - Israel
@@ -107,6 +107,7 @@ files:
107
107
  - lib/graphql/armory/ids/controls.rb
108
108
  - lib/graphql/armory/keyset.rb
109
109
  - lib/graphql/armory/keyset/controls.rb
110
+ - lib/graphql/armory/keyset/encoder_check.rb
110
111
  - lib/graphql/armory/persisted_queries.rb
111
112
  - lib/graphql/armory/persisted_queries/controls.rb
112
113
  - lib/graphql/armory/persisted_queries/manifest.rb
@@ -120,6 +121,7 @@ files:
120
121
  - lib/graphql/armory/rate_limits/clock.rb
121
122
  - lib/graphql/armory/rate_limits/concurrency.rb
122
123
  - lib/graphql/armory/rate_limits/controls.rb
124
+ - lib/graphql/armory/rate_limits/invalid_queries.rb
123
125
  - lib/graphql/armory/rate_limits/memory_store.rb
124
126
  - lib/graphql/armory/rate_limits/points.rb
125
127
  - lib/graphql/armory/rate_limits/redis_store.rb