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 +4 -4
- data/CHANGELOG.md +17 -0
- data/README.md +4 -4
- data/docs/authorization.md +35 -0
- data/docs/keyset-connections.md +28 -4
- data/docs/rate-limiting.md +7 -4
- data/lib/graphql/armory/keyset/encoder_check.rb +113 -0
- data/lib/graphql/armory/keyset.rb +23 -3
- data/lib/graphql/armory/rate_limits/analysis_guard.rb +107 -11
- data/lib/graphql/armory/rate_limits/concurrency.rb +2 -0
- data/lib/graphql/armory/rate_limits/invalid_queries.rb +75 -0
- data/lib/graphql/armory/rate_limits/points.rb +44 -7
- data/lib/graphql/armory/rate_limits/time.rb +36 -6
- data/lib/graphql/armory/rate_limits.rb +1 -0
- data/lib/graphql/armory/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 306b875ce5c7b3b423266aac6acad20df8cff2fcfb2de8bd2fd617f18f06ee0a
|
|
4
|
+
data.tar.gz: 4850e8b8d61f40d0bfe87a25326b5af43a12415c73dc3fcabdfddb1b861cd393
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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.
|
|
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. |
|
data/docs/authorization.md
CHANGED
|
@@ -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:
|
data/docs/keyset-connections.md
CHANGED
|
@@ -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
|
|
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 `
|
|
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
|
-
-
|
|
87
|
-
-
|
|
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.
|
data/docs/rate-limiting.md
CHANGED
|
@@ -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
|
|
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
|
|
318
|
-
-
|
|
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
|
|
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
|
|
88
|
-
|
|
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 =
|
|
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.
|
|
10
|
-
#
|
|
11
|
-
#
|
|
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
|
|
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.
|
|
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
|
|
57
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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:
|
|
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"
|
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.
|
|
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
|