graphql-armory 0.1.0.pre8 → 0.1.0.pre9
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 +47 -0
- data/README.md +5 -5
- data/docs/authorization.md +88 -5
- data/docs/encrypted-ids.md +1 -1
- data/docs/persisted-queries.md +111 -19
- data/docs/rate-limiting.md +32 -11
- data/docs/telemetry.md +31 -8
- data/exe/graphql-armory-publish +6 -0
- data/lib/graphql/armory/authorization/adapters/cancan.rb +7 -3
- data/lib/graphql/armory/authorization/adapters/pundit.rb +8 -1
- data/lib/graphql/armory/authorization/audit/baseline.rb +191 -0
- data/lib/graphql/armory/authorization/audit/command.rb +57 -8
- data/lib/graphql/armory/authorization/audit/findings.rb +3 -2
- data/lib/graphql/armory/authorization/audit/interface_rules.rb +4 -1
- data/lib/graphql/armory/authorization/audit/observed_rules.rb +58 -0
- data/lib/graphql/armory/authorization/audit/payload_reuse.rb +95 -0
- data/lib/graphql/armory/authorization/audit/plain_resolvers.rb +85 -0
- data/lib/graphql/armory/authorization/audit/reach.rb +75 -0
- data/lib/graphql/armory/authorization/audit/report.rb +7 -1
- data/lib/graphql/armory/authorization/audit/type_rules.rb +19 -2
- data/lib/graphql/armory/authorization/audit.rb +12 -4
- data/lib/graphql/armory/authorization/authorize.rb +28 -8
- data/lib/graphql/armory/authorization/configuration.rb +4 -3
- data/lib/graphql/armory/authorization/definitions.rb +7 -0
- data/lib/graphql/armory/authorization/integration/field.rb +26 -4
- data/lib/graphql/armory/authorization/integration/object.rb +8 -0
- data/lib/graphql/armory/authorization/integration/resolver.rb +2 -0
- data/lib/graphql/armory/authorization/integration.rb +14 -3
- data/lib/graphql/armory/authorization/rspec.rb +10 -4
- data/lib/graphql/armory/authorization/rule.rb +19 -3
- data/lib/graphql/armory/authorization/scope.rb +6 -6
- data/lib/graphql/armory/authorization.rb +1 -1
- data/lib/graphql/armory/ids.rb +11 -11
- data/lib/graphql/armory/persisted_queries/manifest.rb +2 -0
- data/lib/graphql/armory/persisted_queries/manifests.rb +69 -0
- data/lib/graphql/armory/persisted_queries/publisher.rb +127 -0
- data/lib/graphql/armory/persisted_queries/redis_store.rb +137 -0
- data/lib/graphql/armory/persisted_queries.rb +55 -16
- data/lib/graphql/armory/railtie.rb +10 -0
- data/lib/graphql/armory/rate_limits/analysis_guard.rb +79 -0
- data/lib/graphql/armory/rate_limits/concurrency.rb +91 -14
- data/lib/graphql/armory/rate_limits/points.rb +91 -14
- data/lib/graphql/armory/rate_limits/time.rb +107 -19
- data/lib/graphql/armory/rate_limits.rb +29 -5
- data/lib/graphql/armory/telemetry.rb +71 -9
- data/lib/graphql/armory/version.rb +1 -1
- data/lib/graphql/armory.rb +1 -0
- data/lib/tasks/graphql_armory.rake +11 -4
- metadata +15 -3
data/docs/rate-limiting.md
CHANGED
|
@@ -107,7 +107,7 @@ field :create_invoice, mutation: Mutations::CreateInvoice, complexity: 10 # 10
|
|
|
107
107
|
|
|
108
108
|
Three settings make the scores fair. Large public APIs (GitHub, Shopify, see [References](#references)) do the same:
|
|
109
109
|
|
|
110
|
-
- **Paginate every list.** A plain list field (`[Invoice]`) costs its fields **once**, however many items it returns, so 10,000 invoices cost like one. Use connections for anything that can grow. A connection asked for without `first:` or `last:` is sized by the schema's `default_page_size`, or else by `default_max_page_size`. If neither is set, graphql-ruby
|
|
110
|
+
- **Paginate every list.** A plain list field (`[Invoice]`) costs its fields **once**, however many items it returns, so 10,000 invoices cost like one. Use connections for anything that can grow. A connection asked for without `first:` or `last:` is sized by the schema's `default_page_size`, or else by `default_max_page_size`. If neither is set, `Points` refuses that query with `This connection needs first: or last:` (code `PAGE_SIZE_REQUIRED`), in soft mode too, and charges nothing. A raise by another analyzer does not skip that refusal. graphql-ruby's install template sets neither, so set one: without it, a client that does send `first:` chooses its own page size. A schema with `max_complexity` still gets graphql-ruby's server error on that query, because its own analyzer raises it. This refusal leaves no record and no `rateLimit` extension, so it does not appear in your rate limit metrics. GitHub goes further and requires `first:` or `last:` on every connection.
|
|
111
111
|
- **Cap a single query.** graphql-ruby's `max_complexity 1_000` refuses any query above that score, whatever the client's budget. Shopify caps single queries at 1,000 points.
|
|
112
112
|
- **Make writes cost more.** Give mutations a higher `complexity:`: `complexity: 10` charges 10 points for the mutation field itself, plus the fields it selects. Shopify charges 10 points per mutation by default.
|
|
113
113
|
|
|
@@ -229,7 +229,7 @@ To test it, inject a clock: `Time` takes `clock:` (a callable answering seconds)
|
|
|
229
229
|
2. Send what it recorded to your metrics: `result.context[:rate_limit_points]` is `{ key:, cost:, limit:, remaining:, window:, soft:, limited: }`, and `limited: true` marks the queries that would have been refused.
|
|
230
230
|
3. When the limits fit your traffic, set `soft: false`.
|
|
231
231
|
|
|
232
|
-
Rate limiting reports through the query context rather than through `sinks:`, because the context is also where the budget for the response comes from. To see its decisions next to the other modules' signals, send those records to the same place: [Telemetry](telemetry.md#rate-limiting).
|
|
232
|
+
Rate limiting reports its charges through the query context rather than through `sinks:`, because the context is also where the budget for the response comes from. To see its decisions next to the other modules' signals, send those records to the same place: [Telemetry](telemetry.md#rate-limiting).
|
|
233
233
|
|
|
234
234
|
### Sharing limits between processes
|
|
235
235
|
|
|
@@ -247,8 +247,8 @@ end
|
|
|
247
247
|
|
|
248
248
|
- **Atomic.** Each charge, slot taken and slot released is one Lua script, so two requests of the same client never both spend the last points.
|
|
249
249
|
- **One clock.** The time comes from Redis (`TIME`), not from each server, so servers whose clocks drift still agree on when a bucket refills.
|
|
250
|
-
- **Small.** A client costs
|
|
251
|
-
- **Fails open.** When Redis is down or slow, the client raises, the query runs, and `on_store_error` is called: make that hook alert. Set the client's timeouts (for example `Redis.new(url: ..., timeout: 0.2)`) so a slow Redis cannot slow every request.
|
|
250
|
+
- **Small.** A client costs up to three keys, `graphql-armory:rate-limits:points:<key>`, `...:points:time:<key>` for `Time`, and `...:slots:<key>`; all expire once they no longer matter (a window, or `stale_after`, after the last use). Pass `prefix:` to share one Redis with other apps.
|
|
251
|
+
- **Fails open.** When Redis is down or slow, the client raises, the query runs, and `on_store_error` is called: make that hook alert. With `sinks:`, the failure is also recorded as `:rate_limit_store_failed`. Set the client's timeouts (for example `Redis.new(url: ..., timeout: 0.2)`) so a slow Redis cannot slow every request.
|
|
252
252
|
|
|
253
253
|
The gem does not depend on the redis gem: add it to your Gemfile. A single `Redis` client is safe to share between threads.
|
|
254
254
|
|
|
@@ -270,31 +270,32 @@ store.calls # [[:take, "user:1", { cost:
|
|
|
270
270
|
|
|
271
271
|
## Reference
|
|
272
272
|
|
|
273
|
-
`use GraphQL::Armory::RateLimits::Points, store:, limit:, window: 60, soft: true, key: nil, on_store_error: nil, extensions: false`
|
|
273
|
+
`use GraphQL::Armory::RateLimits::Points, store:, limit:, window: 60, soft: true, key: nil, on_store_error: nil, sinks: [], extensions: false`
|
|
274
274
|
|
|
275
275
|
- `store:` answers `take(key, cost:, limit:, window:, force:)` with `[taken, remaining]`. `MemoryStore.new(clock: ...)` keeps buckets in the process; `RedisStore` shares them (below).
|
|
276
276
|
- `limit:` a positive Integer, or a callable `(key, query)` answering a positive Integer or nil.
|
|
277
277
|
- `window:` positive seconds over which `limit` points refill, continuously.
|
|
278
278
|
- `soft:` `true` (default) charges and records without refusing; `false` refuses.
|
|
279
|
-
- `key:` a callable `(query)` answering the client key; by default `context[:rate_limit_key]`. It must be a non-empty String.
|
|
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
280
|
- `on_store_error:` a callable `(error, query)` called when the store raises; the query then runs (fails open). By default it warns.
|
|
281
|
+
- `sinks:` callables that hear `:rate_limit_store_failed` when the store raises; see [Telemetry](telemetry.md#rate-limiting).
|
|
281
282
|
- `extensions:` `true` adds `"rateLimit" => { cost, limit, remaining, resetIn }` to every response of a limited client; `false` (default) leaves responses unchanged.
|
|
282
283
|
- `Points.headers(result)` answers the `RateLimit-*` headers for a result, or `{}`.
|
|
283
284
|
- Instrumentation: `context[:rate_limit_points]`.
|
|
284
285
|
|
|
285
|
-
`use GraphQL::Armory::RateLimits::Concurrency, store:, limit:, stale_after: 60, soft: true, key: nil, on_store_error: nil`
|
|
286
|
+
`use GraphQL::Armory::RateLimits::Concurrency, store:, limit:, stale_after: 60, soft: true, key: nil, on_store_error: nil, sinks: []`
|
|
286
287
|
|
|
287
288
|
- `store:` answers `acquire(key, token:, limit:, ttl:, force:)` with `[acquired, active]` and `release(key, token:)`. `MemoryStore` does both.
|
|
288
289
|
- `limit:` operations at once per client: a positive Integer, or a callable `(key, query)` answering a positive Integer or nil.
|
|
289
290
|
- `stale_after:` positive seconds after which a slot that was never released expires.
|
|
290
|
-
- `soft:`, `key:` and `
|
|
291
|
+
- `soft:`, `key:`, `on_store_error:` and `sinks:` as for `Points`. When the store raises on release, `on_store_error` is called and the slot expires after `stale_after`.
|
|
291
292
|
- Instrumentation: `context[:rate_limit_concurrency]`.
|
|
292
293
|
|
|
293
|
-
`use GraphQL::Armory::RateLimits::Time, store:, limit:, window: 60, soft: true, key: nil, on_store_error: nil, clock: Clock.monotonic, extensions: false`
|
|
294
|
+
`use GraphQL::Armory::RateLimits::Time, store:, limit:, window: 60, soft: true, key: nil, on_store_error: nil, sinks: [], clock: Clock.monotonic, extensions: false`
|
|
294
295
|
|
|
295
296
|
- `store:` as for `Points`: it is asked for the balance with `take(key, cost: 0, ...)` and charged with `take(key, cost: milliseconds, ..., force: true)`. No store changes are needed.
|
|
296
297
|
- `limit:` milliseconds per `window` per client: a positive Integer, or a callable `(key, query)` answering a positive Integer or nil.
|
|
297
|
-
- `window:`, `soft:`, `key:` and `
|
|
298
|
+
- `window:`, `soft:`, `key:`, `on_store_error:` and `sinks:` as for `Points`.
|
|
298
299
|
- `clock:` a callable answering seconds, read when a multiplex starts and when it ends. By default a monotonic clock.
|
|
299
300
|
- `extensions:` `true` adds `"rateLimitTime" => { limit, remaining, elapsed, resetIn }` to the response of a limited client.
|
|
300
301
|
- `Time.headers(result)` answers the `RateLimit-Time-*` headers for a result, or `{}`.
|
|
@@ -312,9 +313,18 @@ Invalid settings raise `RateLimits::ConfigurationError` at boot.
|
|
|
312
313
|
|
|
313
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.
|
|
314
315
|
- Each query in a multiplex is charged on its own.
|
|
316
|
+
- 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
|
+
- On `Execution::Next`, `validate: false` ignores every refusal from analysis, the limiters' included. Never pass it for a client's query.
|
|
320
|
+
- 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
|
+
- An `ExecutionError` from a multiplex analyzer's `result` raises to your app: nothing runs and no slot stays held.
|
|
322
|
+
- With several keys, a key whose store fails is left out and `on_store_error` is called for it; the others still decide, so a client cannot cancel a refusal by choosing a key on a dead shard. Only when every key's store fails does the query run unlimited. A list whose members differ only in encoding counts as a duplicate and raises.
|
|
315
323
|
- `Concurrency` takes one slot per query of a multiplex, and releases them all when the multiplex ends, even when the queries share one context hash. If a release fails, that slot expires after `stale_after`; the others are still released.
|
|
316
324
|
- A query run with its own trace (`context: { trace: ... }`) cannot be released by `Concurrency`, so it raises `ConfigurationError` instead of taking a slot.
|
|
317
325
|
- A resolver that runs another query for the same client (a nested `execute`) takes a second slot while the first is held.
|
|
326
|
+
- `Points` and `Time` keep separate budgets on one store and key: `Time` stores its milliseconds under `time:<key>` (`Time.bucket_key(key)`; a custom store sees that key). So do not give `Points` a key starting with `time:` that matches a `Time` key.
|
|
327
|
+
- Two limiters of the same class on one store and key share one bucket: both charge it, and each caps and refills it with its own limit. Give each `use Points` or `use Time` its own store (`RedisStore.new(redis:, prefix:)`) or its own keys.
|
|
318
328
|
- A subscription holds a slot only while its first result runs, not while it stays subscribed.
|
|
319
329
|
- When queries of a multiplex share one context hash, `context[:rate_limit_points]`, `context[:rate_limit_concurrency]` and `context[:rate_limit_time]` hold the last query's record: give each query its own context hash to read every record.
|
|
320
330
|
- The cost is graphql-ruby's estimate before running, not the time a query really takes: a field that is slow but scored 1 costs 1. Score expensive fields, and add `Time` to charge what queries really take.
|
|
@@ -324,10 +334,11 @@ Invalid settings raise `RateLimits::ConfigurationError` at boot.
|
|
|
324
334
|
|
|
325
335
|
### Limits of `Time`
|
|
326
336
|
|
|
337
|
+
- A query `Time` refuses is still charged by `Points` on the same schema, because graphql-ruby runs every analyzer.
|
|
327
338
|
- A running operation is never interrupted: the one that exhausts the budget finishes, and only the next one is refused. Operations that start while a long one is still running all see the balance before it is charged, so together they can spend more than the client has; the balance then stops at zero (there is no debt).
|
|
328
339
|
- `retryAfter` is the time the client needs to regain one millisecond (`window / limit` seconds, at least 1). It lets the client in again, not necessarily for an operation that takes long.
|
|
329
340
|
- 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.
|
|
330
|
-
- A schema may `use Time` more than once (for example per user and per IP
|
|
341
|
+
- 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.
|
|
331
342
|
- 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.
|
|
332
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.
|
|
333
344
|
- 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.
|
|
@@ -340,6 +351,16 @@ Every key has its own bucket. A client that gets a new key for each request (a n
|
|
|
340
351
|
|
|
341
352
|
What you can do with the gem:
|
|
342
353
|
|
|
354
|
+
- **Draw from several buckets at once.** A key may be a list of up to 5 different Strings, so rotating one identity leaves the others:
|
|
355
|
+
|
|
356
|
+
```ruby
|
|
357
|
+
context[:rate_limit_key] = ["user:#{user.id}", "ip:#{request.remote_ip}", ("new-accounts" if user.created_at > 1.day.ago)].compact
|
|
358
|
+
|
|
359
|
+
use GraphQL::Armory::RateLimits::Points, store: store, window: 60,
|
|
360
|
+
limit: ->(key, _query) { key.start_with?("user:") ? 1_000 : key.start_with?("ip:") ? 3_000 : 500 }
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
A query runs only if every key has the budget, and each key is charged. `limit:` is asked once per key, and a key it answers `nil` for is left out. The prefixes are yours: they keep kinds of key apart in the store. It costs store round trips on every query, about two per key, so use two or three. Do not build a key from the document or its variables: a client could then spend someone else's budget.
|
|
343
364
|
- **Share one bucket among the clients you do not trust yet.** Build the key from something that is not free to rotate, such as the age of the account, so every new account draws from the same small budget:
|
|
344
365
|
|
|
345
366
|
```ruby
|
data/docs/telemetry.md
CHANGED
|
@@ -100,6 +100,20 @@ end
|
|
|
100
100
|
|
|
101
101
|
The gem does not depend on Datadog or on any other backend. The same shape works for StatsD, Prometheus, OpenTelemetry or whatever your team uses: write the callable that turns `(signal, data)` into one call of that client. The example above follows Datadog's documented `increment(name, tags:)`; it has not been run against a live agent.
|
|
102
102
|
|
|
103
|
+
### Who asked
|
|
104
|
+
|
|
105
|
+
A sink that requires three arguments also receives the query's context, so it can read who was refused:
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
count_probes = lambda do |signal, data, context|
|
|
109
|
+
next unless signal == :operation_refused && data[:reason] == :raw_query
|
|
110
|
+
|
|
111
|
+
Strikes.add(context[:client_ip])
|
|
112
|
+
end
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The sink reads the context through a view that answers `[]`, `fetch`, `dig` and `key?`, and cannot write to it, so a sink cannot change who the query runs as. An object held in the context is your own, and a sink can still change that. The view is empty when the query has none. A sink that takes two arguments, `*arguments` or a defaulted third never receives it, so a sink that logs what it gets does not start logging tokens. What you read from the context is yours to keep out of logs.
|
|
116
|
+
|
|
103
117
|
### What to watch for
|
|
104
118
|
|
|
105
119
|
| You see | It usually means | What to do |
|
|
@@ -119,6 +133,7 @@ The gem does not depend on Datadog or on any other backend. The same shape works
|
|
|
119
133
|
|---|---|---|
|
|
120
134
|
| `:allowed` | The policy or ability said yes | `type`, `action` |
|
|
121
135
|
| `:denied` | It said no, nil or anything that is not a clear yes | `type`, `action` |
|
|
136
|
+
| `:observed` | It said no to a rule under `observe:`, so the data was served | `type`, `action`, `reason` |
|
|
122
137
|
| `:bypassed` | The rule is `authorize :none, reason: "..."` | `type` (not on lists), `reason` |
|
|
123
138
|
| `:scoped` | A list or lookup went through the user's scope | `action` |
|
|
124
139
|
|
|
@@ -153,15 +168,21 @@ The reasons of `:id_refused`:
|
|
|
153
168
|
|
|
154
169
|
### Rate limiting
|
|
155
170
|
|
|
156
|
-
Rate limiting
|
|
171
|
+
Rate limiting takes `sinks:` for one signal, when its store fails:
|
|
172
|
+
|
|
173
|
+
| Signal | When | Data |
|
|
174
|
+
|---|---|---|
|
|
175
|
+
| `:rate_limit_store_failed` | The store raised, so the query ran without a limit | `limiter`: `:points`, `:concurrency` or `:time`; `error`: the error's class name |
|
|
176
|
+
|
|
177
|
+
Everything else it records on every query, in the query's context. It records on every query, in the query's context, what it charged and what remains: `context[:rate_limit_points]`, `context[:rate_limit_concurrency]` and `context[:rate_limit_time]`, which carry `limited: true` for the queries it refused or, in soft mode, would have refused. See [Rolling it out](rate-limiting.md#rolling-it-out) and [Showing clients their budget](rate-limiting.md#showing-clients-their-budget). Send those to the same place as the signals above.
|
|
157
178
|
|
|
158
179
|
## What telemetry does not cover
|
|
159
180
|
|
|
160
181
|
Signals are counters of the gem's decisions. They are not a replacement for these:
|
|
161
182
|
|
|
162
183
|
- **Traces.** A signal is a point event, not a span. In Datadog APM (or any tracer) a refused ID does not appear as a span of its own; it shows in the trace of the request that carried it, as the normal "not found" answer. If you want it on the trace, have your sink add a tag to the active span with your tracer's API.
|
|
163
|
-
- **Who asked.** A signal says `id_refused`, not which user or IP.
|
|
164
|
-
- **Rate limiting.**
|
|
184
|
+
- **Who asked, in the signal.** A signal says `id_refused`, not which user or IP. A sink that takes three arguments receives the query's context and can read them from it: see [Who asked](#who-asked).
|
|
185
|
+
- **Rate limiting's decisions.** Its sinks hear only about a failing store: what it charged is in the query's context. Send `context[:rate_limit_points]`, `context[:rate_limit_concurrency]` and `context[:rate_limit_time]` from your controller, to the same client. They hold the rate limit `key:` (a user id or an IP): send them somewhere that may hold personal data, or leave the key out.
|
|
165
186
|
- **Slow or failing requests.** The gem records its decisions. Latency, errors and throughput of your GraphQL endpoint come from your APM or from graphql-ruby's own tracing.
|
|
166
187
|
- **Anything that did not reach the gem.** A request rejected before GraphQL runs (a web application firewall, a body size limit, a parse error) never produces a signal.
|
|
167
188
|
- **A guarantee of delivery.** Sinks run in the request and report nothing if the process dies first. Counters sent to StatsD are best effort by design.
|
|
@@ -203,22 +224,24 @@ use GraphQL::Armory::PersistedQueries, store: store, sinks: [->(signal, data) {
|
|
|
203
224
|
|
|
204
225
|
## Reference
|
|
205
226
|
|
|
206
|
-
- `sinks:` is an option of `use GraphQL::Armory::Authorization`, `use GraphQL::Armory::IDs
|
|
207
|
-
- `
|
|
227
|
+
- `sinks:` is an option of `use GraphQL::Armory::Authorization`, `use GraphQL::Armory::IDs`, `use GraphQL::Armory::PersistedQueries` and the rate limiters. An Array of callables taking `(signal, data)` or `(signal, data, context)`, called in order; default `[]`.
|
|
228
|
+
- `on_sink_error:` is an option of the same modules except the rate limiters. A callable `(error, signal)` called when a sink raises; by default it warns with the signal and the error's class. Raise from it to fail the request closed.
|
|
229
|
+
- A sink that could never be called (`->(a) {}`, four required arguments, a required keyword) raises `Telemetry::ConfigurationError` at boot.
|
|
230
|
+
- `Telemetry.new(sinks: [], on_sink_error: nil)`, `Telemetry.build(sinks: [], on_sink_error: nil)`, `telemetry.record(signal, context: nil, **data)` (answers nil; `data` reaches the sinks frozen).
|
|
208
231
|
- `Telemetry::Notifications`: a sink that calls `ActiveSupport::Notifications.instrument("<signal>.graphql_armory", data)`. `Notifications.available?` tells whether ActiveSupport::Notifications is loaded.
|
|
209
|
-
- `Telemetry::Substitute`: `record`, `recorded` (an Array of `[signal, data]`), `recorded?(signal)`, and `fail_with(error)` / `recover` to make `record` raise,
|
|
232
|
+
- `Telemetry::Substitute`: `record`, `recorded` (an Array of `[signal, data]`), `contexts`, `recorded?(signal)`, and `fail_with(error)` / `recover` to make `record` raise, for code that calls `record` directly: a real `record` does not raise when a sink does.
|
|
210
233
|
- `Telemetry::ConfigurationError`, raised at boot when `sinks:` is not an Array of callables, or a sink reports `available?` as false. Its message names the class of the offending value and never prints the value: a metrics client holds its API key.
|
|
211
234
|
- `Authorization::Telemetry` is the same class as `Telemetry`.
|
|
212
235
|
|
|
213
236
|
## Guarantees and limits
|
|
214
237
|
|
|
215
238
|
- **Sinks run inline**, in the request's thread, in the order given. A slow sink slows every request: increment a counter or enqueue, do not call a remote service.
|
|
216
|
-
- **A sink that raises
|
|
239
|
+
- **A sink that raises does not touch the request.** The gem's decision stands, the next sink runs, and `on_sink_error:` is called. By default that prints the signal and the error's class, never its message, which may hold a credential, once per failure: one request can print many. If the record must exist before the data is served (an audit log), raise from `on_sink_error:` to fail the request closed. A `rescue_from` for that error still runs, and the object is denied whatever it answers. In encrypted IDs, an `Encoder::InvalidError` or `ArgumentError` raised from `on_sink_error:` reads as an invalid ID: the client gets `nil`, closed, and an extra `:id_refused` signal is recorded. On a rate limiter a sink always only warns: use `on_store_error:` to act on a failing store.
|
|
217
240
|
- **Every sink gets the same frozen data.** The Hash and its String values are frozen, so a sink cannot change what the next one sees: trying raises `FrozenError`. The signals carry only Symbols and Strings today. `Notifications` makes one unfrozen copy per signal, which ActiveSupport passes to every subscriber: a subscriber that changes its payload changes it for the next subscriber.
|
|
218
241
|
- **One request can record many signals**, one per decision (a list of invoices is several), and each query of a batch records its own.
|
|
219
242
|
- **Signals are not a security log by themselves.** They tell you what the gem decided; who asked comes from your request context.
|
|
220
243
|
- Names and reasons are part of the public API, but the gem is in prerelease: they may change before 1.0, and the CHANGELOG says so when they do.
|
|
221
|
-
- Rate limiting reports through the query context
|
|
244
|
+
- Rate limiting reports its charges through the query context; its sinks hear only about a failing store.
|
|
222
245
|
|
|
223
246
|
## References
|
|
224
247
|
|
|
@@ -9,15 +9,18 @@ module GraphQL
|
|
|
9
9
|
# Answers the core's four questions with CanCanCan's public API.
|
|
10
10
|
#
|
|
11
11
|
# The actor is an Ability: `context[:current_ability]`, else `Ability.new(context[:current_user])`
|
|
12
|
-
# (or the class given as `ability_class:`). It is built once per query.
|
|
12
|
+
# (or the class given as `ability_class:`, and the key given as `user_key:`). It is built once per query.
|
|
13
13
|
#
|
|
14
14
|
# A field that declares its own action (`authorize: :manage`) passes its attribute to `can?`: with
|
|
15
15
|
# `can :read, User, [:username]`, `can?(:read, user)`
|
|
16
16
|
# is true even though only one attribute is readable. Fields covered by their type's rule are not asked
|
|
17
17
|
# about their attribute: declare an action on the ones an attribute rule must hide.
|
|
18
18
|
class CanCan
|
|
19
|
-
def initialize(ability_class: nil)
|
|
19
|
+
def initialize(ability_class: nil, user_key: :current_user)
|
|
20
|
+
raise ArgumentError, "user_key must be a Symbol or a String, got #{user_key.class}" unless user_key.is_a?(Symbol) || user_key.is_a?(String)
|
|
21
|
+
|
|
20
22
|
@ability_class = ability_class
|
|
23
|
+
@user_key = user_key
|
|
21
24
|
end
|
|
22
25
|
|
|
23
26
|
def actor(context)
|
|
@@ -86,7 +89,8 @@ module GraphQL
|
|
|
86
89
|
|
|
87
90
|
def ability_for(context)
|
|
88
91
|
store = context.namespace(:armory)
|
|
89
|
-
store[:
|
|
92
|
+
abilities = store[:cancan_abilities] ||= {}
|
|
93
|
+
abilities[self] ||= ability_class.new(context[@user_key])
|
|
90
94
|
end
|
|
91
95
|
|
|
92
96
|
def ability_class
|
|
@@ -12,8 +12,15 @@ module GraphQL
|
|
|
12
12
|
# overrides and namespaced models work. A policy must answer `true`, `false` or `nil`:
|
|
13
13
|
# anything else (a lazy, a record) is handed back as is and the core refuses it.
|
|
14
14
|
class Pundit
|
|
15
|
+
# `user_key:` is the context key that holds the user.
|
|
16
|
+
def initialize(user_key: :current_user)
|
|
17
|
+
raise ArgumentError, "user_key must be a Symbol or a String, got #{user_key.class}" unless user_key.is_a?(Symbol) || user_key.is_a?(String)
|
|
18
|
+
|
|
19
|
+
@user_key = user_key
|
|
20
|
+
end
|
|
21
|
+
|
|
15
22
|
def actor(context)
|
|
16
|
-
context[
|
|
23
|
+
context[@user_key]
|
|
17
24
|
end
|
|
18
25
|
|
|
19
26
|
def authorized?(user, subject, rule)
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module GraphQL
|
|
6
|
+
module Armory
|
|
7
|
+
module Authorization
|
|
8
|
+
class Audit
|
|
9
|
+
# The problems a team accepted for now, so adopting the gem on a large schema fails the build only on new ones.
|
|
10
|
+
# A report's `ok?` never changes because of it: a schema with problems is not ok, with or without a baseline.
|
|
11
|
+
#
|
|
12
|
+
# Baseline.load("config/graphql_armory_baseline.json").(report).passes?
|
|
13
|
+
#
|
|
14
|
+
# An entry accepts one problem as it was: how many times it appeared, and what it reached (see Reach). A problem that
|
|
15
|
+
# grew, because a field or a subclass was added to what it covers, is not the problem someone accepted.
|
|
16
|
+
class Baseline
|
|
17
|
+
VERSION = 1
|
|
18
|
+
Entry = Struct.new(:kind, :path, :message, :count, :covers) do
|
|
19
|
+
def key = [kind, path, message]
|
|
20
|
+
def to_h = { "kind" => kind, "path" => path, "message" => message, "count" => count, "covers" => covers }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# What a report comes to against a baseline. It has no `ok?`, so nobody reads a pass as a clean schema.
|
|
24
|
+
class Result
|
|
25
|
+
attr_reader :report, :new_problems, :accepted, :gone, :grown
|
|
26
|
+
|
|
27
|
+
def initialize(report:, new_problems:, accepted:, gone:, grown: {})
|
|
28
|
+
@report = report
|
|
29
|
+
@new_problems = new_problems.freeze
|
|
30
|
+
@accepted = accepted.freeze
|
|
31
|
+
@gone = gone.freeze
|
|
32
|
+
@grown = grown.freeze
|
|
33
|
+
freeze
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# An accepted problem that is gone fails too: left in the file, it could come back unseen.
|
|
37
|
+
def passes?
|
|
38
|
+
new_problems.empty? && gone.empty?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def to_s
|
|
42
|
+
sections = [
|
|
43
|
+
section("#{new_problems.size} new #{plural('problem', new_problems.size)}:", new_problems.map { |problem| new_line(problem) }),
|
|
44
|
+
section("#{gone.size} accepted #{plural('problem', gone.size)} no longer found (remove them from the baseline):", gone.map { |entry| "#{entry.path} (#{entry.kind}): #{entry.message}" }),
|
|
45
|
+
section("#{accepted.size} accepted in the baseline, still open:", accepted),
|
|
46
|
+
bypasses_text
|
|
47
|
+
].compact
|
|
48
|
+
sections.unshift("Passes only because of its baseline.") if passes? && !accepted.empty?
|
|
49
|
+
sections.unshift("No problems.") if passes? && accepted.empty?
|
|
50
|
+
"#{sections.join("\n\n")}\n"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def new_line(problem)
|
|
56
|
+
extra = grown[problem]
|
|
57
|
+
extra ? "#{problem} (it now also reaches #{extra.join(', ')})" : problem.to_s
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def section(title, lines)
|
|
61
|
+
return if lines.empty?
|
|
62
|
+
|
|
63
|
+
[title, *lines.map { |line| " #{line}" }].join("\n")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def bypasses_text
|
|
67
|
+
section("#{report.bypasses.size} declared #{report.bypasses.size == 1 ? 'bypass' : 'bypasses'}:", report.bypasses.map { |bypass| "#{bypass.path}: #{bypass.reason}" })
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def plural(word, count)
|
|
71
|
+
count == 1 ? word : "#{word}s"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Every problem of a report, accepted as it is now.
|
|
76
|
+
def self.from(report)
|
|
77
|
+
new(report.problems.group_by { |problem| [problem.kind.to_s, problem.path, problem.message] }.map do |(kind, path, message), problems|
|
|
78
|
+
Entry.new(kind, path, message, problems.size, report.reach_of(problems.first))
|
|
79
|
+
end)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.load(path)
|
|
83
|
+
data = begin
|
|
84
|
+
JSON.parse(File.read(path, mode: "r:bom|utf-8"))
|
|
85
|
+
rescue JSON::ParserError, ArgumentError, EncodingError
|
|
86
|
+
# Not the parser's message: it quotes the file's content.
|
|
87
|
+
raise ConfigurationError, "the baseline #{path} is not valid JSON"
|
|
88
|
+
rescue SystemCallError => error
|
|
89
|
+
raise ConfigurationError, "the baseline #{path} cannot be read (#{error.class})"
|
|
90
|
+
end
|
|
91
|
+
unless data.is_a?(Hash) && data["version"].eql?(VERSION) && data["problems"].is_a?(Array)
|
|
92
|
+
raise ConfigurationError, "the baseline #{path} needs version #{VERSION} and a list of problems"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
new(data["problems"].map { |entry| entry_from(entry, path) })
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Creates the file with every problem. A file that exists only ever loses what is gone: accepting a new problem is
|
|
99
|
+
# a manual edit, so it shows in review. Answers what was written.
|
|
100
|
+
def self.write(report, path)
|
|
101
|
+
baseline = from(report)
|
|
102
|
+
kept = File.exist?(path) ? load(path).kept_from(baseline) : baseline
|
|
103
|
+
File.write(path, "#{JSON.pretty_generate('version' => VERSION, 'problems' => kept.entries.map(&:to_h))}\n")
|
|
104
|
+
kept
|
|
105
|
+
rescue SystemCallError => error
|
|
106
|
+
raise ConfigurationError, "the baseline #{path} cannot be written (#{error.class})"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# With no count or covers an entry accepts the narrowest thing: one problem that reaches nothing.
|
|
110
|
+
def self.entry_from(entry, path)
|
|
111
|
+
unless entry.is_a?(Hash) && %w[kind path message].all? { |key| entry[key].is_a?(String) }
|
|
112
|
+
raise ConfigurationError, "the baseline #{path} needs a kind, a path and a message in each problem"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
count = entry.fetch("count", 1)
|
|
116
|
+
covers = entry.fetch("covers", [])
|
|
117
|
+
unless count.is_a?(Integer) && count.positive? && covers.is_a?(Array) && covers.all?(String)
|
|
118
|
+
raise ConfigurationError, "the baseline #{path} needs a positive count and a list of Strings in covers"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
Entry.new(*entry.values_at("kind", "path", "message"), count, covers)
|
|
122
|
+
end
|
|
123
|
+
private_class_method :entry_from
|
|
124
|
+
|
|
125
|
+
attr_reader :entries
|
|
126
|
+
|
|
127
|
+
# What a baseline never accepts: with no plugin, no rule runs at all, so there is nothing to adopt gradually; an own
|
|
128
|
+
# check or a denial hook reaches whatever uses it later, which no entry can pin down, and each already has its
|
|
129
|
+
# declaration with a reason (`own_checks:`, `own_denial_hooks:`).
|
|
130
|
+
REFUSED = { "plugin" => "a baseline cannot accept a schema that never installed the plugin: install it first",
|
|
131
|
+
"own_check" => "a baseline cannot accept an own check: remove it, or declare it in own_checks: with a reason",
|
|
132
|
+
"hook" => "a baseline cannot accept a denial hook: remove it, or declare it in own_denial_hooks: with a reason",
|
|
133
|
+
"observed" => "a baseline cannot accept observe: it serves what the rule would refuse, and it already says why" }.freeze
|
|
134
|
+
|
|
135
|
+
# `new` is inert: it accepts nothing, so every problem is new.
|
|
136
|
+
def initialize(entries = [])
|
|
137
|
+
refused = entries.find { |entry| REFUSED.key?(entry.kind) }
|
|
138
|
+
raise ConfigurationError, "#{REFUSED.fetch(refused.kind)} (#{refused.path})" if refused
|
|
139
|
+
|
|
140
|
+
@entries = entries.map { |entry| Entry.new(entry.kind, entry.path, entry.message, entry.count || 1, entry.covers || []) }.uniq(&:key).sort_by(&:to_a).freeze
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def call(report)
|
|
144
|
+
by_key = entries.to_h { |entry| [entry.key, entry] }
|
|
145
|
+
seen = Hash.new(0)
|
|
146
|
+
grown = {}
|
|
147
|
+
accepted, new_problems = report.problems.partition do |problem|
|
|
148
|
+
entry = by_key[key_of(problem)]
|
|
149
|
+
next false unless entry
|
|
150
|
+
|
|
151
|
+
extra = beyond(report.reach_of(problem), entry.covers)
|
|
152
|
+
grown[problem] = extra unless extra.empty?
|
|
153
|
+
extra.empty? && (seen[entry.key] += 1) <= entry.count
|
|
154
|
+
end
|
|
155
|
+
found = report.problems.map { |problem| key_of(problem) }
|
|
156
|
+
Result.new(report: report, new_problems: new_problems, accepted: accepted, grown: grown,
|
|
157
|
+
gone: entries.reject { |entry| found.include?(entry.key) })
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# This baseline without what `other` no longer holds: nothing grows.
|
|
161
|
+
def kept_from(other)
|
|
162
|
+
current = other.entries.to_h { |entry| [entry.key, entry] }
|
|
163
|
+
self.class.new(entries.filter_map do |entry|
|
|
164
|
+
now = current[entry.key]
|
|
165
|
+
next unless now
|
|
166
|
+
|
|
167
|
+
Entry.new(entry.kind, entry.path, entry.message, [entry.count, now.count].min, common(entry.covers, now.covers))
|
|
168
|
+
end)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
private
|
|
172
|
+
|
|
173
|
+
def key_of(problem)
|
|
174
|
+
[problem.kind.to_s, problem.path, problem.message]
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# What `now` holds beyond `accepted`, counting repeats: two classes may share a name.
|
|
178
|
+
def beyond(now, accepted)
|
|
179
|
+
left = accepted.tally
|
|
180
|
+
now.reject { |item| left[item].to_i.positive? && (left[item] -= 1) }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def common(one, other)
|
|
184
|
+
left = other.tally
|
|
185
|
+
one.select { |item| left[item].to_i.positive? && (left[item] -= 1) }
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
@@ -6,25 +6,74 @@ module GraphQL
|
|
|
6
6
|
class Audit
|
|
7
7
|
# What `rake graphql_armory:audit[YourSchema]` runs: prints the report and answers an exit status.
|
|
8
8
|
class Command
|
|
9
|
+
# The rake task's action. The app's Rake file may name the task first, to add a prerequisite, so the task existing does
|
|
10
|
+
# not mean this ran: the file looks for this object among its actions.
|
|
11
|
+
RAKE_ACTION = proc do |_task, args|
|
|
12
|
+
Rake::Task[:environment].invoke if Rake::Task.task_defined?(:environment)
|
|
13
|
+
# By position: an app that declares the task again renames its arguments.
|
|
14
|
+
status = new.(args[:schema], args.to_a[1])
|
|
15
|
+
exit(status) unless status.zero?
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# The baseline task's action: writes the file, or removes from it what is gone.
|
|
19
|
+
BASELINE_ACTION = proc do |_task, args|
|
|
20
|
+
Rake::Task[:environment].invoke if Rake::Task.task_defined?(:environment)
|
|
21
|
+
status = new.write_baseline(args[:schema], args.to_a[1])
|
|
22
|
+
exit(status) unless status.zero?
|
|
23
|
+
end
|
|
24
|
+
|
|
9
25
|
def initialize(out: $stdout)
|
|
10
26
|
@out = out
|
|
11
27
|
end
|
|
12
28
|
|
|
13
|
-
def call(schema_name)
|
|
14
|
-
|
|
29
|
+
def call(schema_name, baseline = nil)
|
|
30
|
+
with_schema(schema_name, "audit") do |schema|
|
|
31
|
+
report = Audit.(schema)
|
|
32
|
+
next judged(report, baseline) unless baseline.to_s.empty?
|
|
33
|
+
|
|
34
|
+
@out.print(report)
|
|
35
|
+
report.ok? ? 0 : 1
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def write_baseline(schema_name, path)
|
|
40
|
+
return fail_with("Name the file: rake graphql_armory:audit_baseline[YourSchema,path]") if path.to_s.empty?
|
|
41
|
+
|
|
42
|
+
with_schema(schema_name, "audit_baseline") do |schema|
|
|
43
|
+
wrote(Audit.(schema), path)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
15
48
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
@out.print(
|
|
19
|
-
|
|
49
|
+
def judged(report, baseline)
|
|
50
|
+
result = Baseline.load(baseline).(report)
|
|
51
|
+
@out.print(result)
|
|
52
|
+
result.passes? ? 0 : 1
|
|
53
|
+
rescue ConfigurationError => error
|
|
54
|
+
fail_with(error.message)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def wrote(report, path)
|
|
58
|
+
written = Baseline.write(report, path)
|
|
59
|
+
not_accepted = Baseline.new(written.entries).(report).new_problems.size
|
|
60
|
+
@out.puts("#{path}: #{written.entries.size} accepted. Fix them: this list only ever shrinks.")
|
|
61
|
+
@out.puts("#{not_accepted} not accepted: they still fail the audit.") if not_accepted.positive?
|
|
62
|
+
0
|
|
63
|
+
rescue ConfigurationError => error
|
|
64
|
+
fail_with(error.message)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def with_schema(schema_name, task)
|
|
68
|
+
return fail_with("Name the schema: rake graphql_armory:#{task}[YourSchema]") if schema_name.to_s.empty?
|
|
69
|
+
|
|
70
|
+
yield Object.const_get(schema_name)
|
|
20
71
|
rescue NameError => error
|
|
21
72
|
raise unless error.name.to_s == schema_name.to_s.split("::").last
|
|
22
73
|
|
|
23
74
|
fail_with("No schema named #{schema_name}")
|
|
24
75
|
end
|
|
25
76
|
|
|
26
|
-
private
|
|
27
|
-
|
|
28
77
|
def fail_with(message)
|
|
29
78
|
@out.puts(message)
|
|
30
79
|
1
|
|
@@ -23,9 +23,10 @@ module GraphQL
|
|
|
23
23
|
@bypasses << Report::Bypass.new(path, reason)
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
def report
|
|
26
|
+
def report(reach = nil)
|
|
27
27
|
Report.new(problems: @problems.sort_by { |problem| [problem.path, problem.kind.to_s] },
|
|
28
|
-
bypasses: @bypasses.sort_by(&:path)
|
|
28
|
+
bypasses: @bypasses.sort_by(&:path),
|
|
29
|
+
reach: reach)
|
|
29
30
|
end
|
|
30
31
|
end
|
|
31
32
|
end
|
|
@@ -15,9 +15,12 @@ module GraphQL
|
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def self.audit(interface, definitions, findings)
|
|
18
|
-
|
|
18
|
+
implementers = definitions.select { |type| type.kind.object? && type <= interface }
|
|
19
|
+
bypassed = Integration.any_bypass?(implementers)
|
|
20
|
+
covered = implementers.any? { |type| type.respond_to?(:armory_rule) && PlainResolvers.covering?(type.armory_rule) }
|
|
19
21
|
interface.own_fields.values.flatten.each do |field|
|
|
20
22
|
path = "#{interface.graphql_name}.#{field.original_name}"
|
|
23
|
+
PlainResolvers.report(findings, path, field) if covered && !bypassed && PlainResolvers.open?(field)
|
|
21
24
|
unless field.respond_to?(:armory_declared)
|
|
22
25
|
findings.problem(:unprotected, path, "its field class does not include Base, so it is not checked on a bypassed implementer") if bypassed
|
|
23
26
|
next
|