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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3200e9b549bc6bb3b7f77d1905fb79a9d41b59d4730452300437572b4c7cdfe3
|
|
4
|
+
data.tar.gz: 3653d86878c2395602ac78e48f70266cc89ea42d0f393eb122542e958466e36a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: aa7fbc795c731c4f11cd8e6a002b3178f377ef97caa1669771b8a641a7a2860a9fdf49525f49d25fcb0eaced66901cace0b74bb8acfe05918f456421bbc4d0e6
|
|
7
|
+
data.tar.gz: c828ed91f9bd66f7856d603e33f8e44ce875861be873061e4ae016b9b6b3714bb6daeb0001a8862fef1c9b21134a7c80476ec861f18dee25a015bf67218bcb7c
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,52 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.1.0.pre9 (2026-11-02)
|
|
6
|
+
|
|
7
|
+
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.
|
|
8
|
+
|
|
9
|
+
### Breaking
|
|
10
|
+
|
|
11
|
+
- An audit that passed can fail after upgrading, because it reports more: see the entries under Security and the audit entry under Fixed. Read each new problem instead of declaring it to get the build green; a baseline can hold them while you work through them. (#42, #44)
|
|
12
|
+
- In hard mode, a query whose rate limiter cannot answer, because a `complexity:` lambda or an `on_store_error` hook raised `ExecutionError`, is now refused with `RATE_LIMIT_NOT_CHARGED`. It used to run uncharged. Stop raising from those, or use soft mode while you do. (#46)
|
|
13
|
+
- In strict mode, a `GraphQL::Query` built by your own code, for example in `multiplex`, is now refused unless its context holds `armory_allow_raw_query: true`. It used to run any query, because it skips the plugin's query class. Add the flag where your code builds queries on purpose. Outside strict mode it now records `:raw_query_run`. (#32)
|
|
14
|
+
- A sink that raises no longer takes the request down: the gem's decision stands, the next sink runs, and the new `on_sink_error:` is called, which by default warns with the signal and the error's class. A monitoring failure used to become an API failure. If a record must exist before data is served, raise from `on_sink_error:` to fail closed as before. A sink's `Encoder::InvalidError` is no longer read as a forged ID. (#29)
|
|
15
|
+
- A sink that could never be called, such as `->(a) {}`, now raises `Telemetry::ConfigurationError` at boot. It used to fail on the first request. (#29)
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- Several keys per query, against clients that rotate one identity: `rate_limit_key` or `key:` may answer a list of up to 5 different Strings, for example `["user:1", "ip:1.2.3.4"]`, on `Points`, `Concurrency` and `Time`. A query runs only if every key allows it, and each is charged. It costs about two store round trips per key. Two queries racing between the read and the charge can leave a refused one having charged other keys. A key whose store fails is left out and `on_store_error` is called for it; the others still decide. A single String behaves as before. See [Clients that change identity](docs/rate-limiting.md#clients-that-change-identity). (#40)
|
|
20
|
+
- Observation mode, to learn who a new rule would refuse before enforcing it: `authorize :read, observe: "why"` on a type, or `authorize: :manage, observe: "why"` on a field. On a query, what the policy refuses is served and recorded as an `:observed` signal. It refuses as usual with no `sinks:`, with the null adapter, or in a mutation or subscription, and a type holding a field with a resolver or a mutation raises `ConfigurationError` instead of being observed; lists of object types and lookups still go through the scope, but a list with no scope (an interface, a union, `scope: false`, `nodes(ids:)`) serves its rows and records them. The audit reports each one as `:observed`, and a baseline cannot accept it. See [Observing a rule](docs/authorization.md#observing-a-rule-before-enforcing-it). (#35)
|
|
21
|
+
- Audit baseline, for adopting the gem on a schema with many problems: `rake "graphql_armory:audit_baseline[AppSchema,path]"` records them, then `rake "graphql_armory:audit[AppSchema,path]"` and `be_fully_authorized(baseline: path)` fail only on new problems, on an accepted one that grew (a field added to that type, a type added under that base class), or on an accepted one that is gone. The file only ever shrinks unless edited by hand, every accepted problem stays listed, and `report.ok?` never changes because of it. A baseline protects nothing: those problems are still open. (#34)
|
|
22
|
+
- `PersistedQueries::RedisStore` and `Publisher` publish operations without a server deploy, for frontends that deploy on their own: their CI runs `graphql-armory-publish manifest.json` before the deploy, and the server reads each operation when first asked. An operation it read keeps working when Redis fails; an id it cannot find or check is refused. A published operation never changes. There is no endpoint: whoever can write to those keys decides what strict mode allows, so give the CI its own credential. The app adds the `redis` gem. (#33)
|
|
23
|
+
- `PersistedQueries::Manifests` puts several manifests behind one `store:`, for several frontends and mobile versions still in use: `Manifests.build("config/persisted_queries/*.json")`. It raises at boot when a pattern matches nothing or two manifests disagree on an id. They are one allowlist, and a new manifest still needs a deploy. (#31)
|
|
24
|
+
- `PersistedQueries.operation_id` now reads Relay's `doc_id`, so Relay clients need no change to send their id. (#30)
|
|
25
|
+
- The Pundit and CanCanCan adapters take `user_key:`, for an app whose context holds the user under another key: `Adapters::Pundit.new(user_key: :viewer)`. It used to take a subclass. (#30)
|
|
26
|
+
- In Rails, `rake graphql_armory:audit[YourSchema]` now loads by itself, through a Railtie that loads only when Rails is there. The task now runs the app's `:environment` when it runs, not only when it was defined before the task, so clearing the task's prerequisites no longer skips it. An adapter subclass with its own `initialize` must call `super`: without it, nobody has a user and everyone is denied. (#30)
|
|
27
|
+
- A sink that requires three arguments now receives the query's context: `->(signal, data, context) { ... }`. It tells who was refused without a request-global. It reads through a view that cannot write to the context. Sinks of two arguments, `*arguments` or a defaulted third are unchanged and never receive it. (#29)
|
|
28
|
+
- A persisted queries refusal now carries `extensions.code`: `RAW_QUERY_REFUSED`, `UNKNOWN_OPERATION` or `OPERATION_MISMATCH`, so an app can match on it instead of on the message. (#29)
|
|
29
|
+
- Rate limiters take `sinks:` and record `:rate_limit_store_failed`, with the limiter and the error's class, when the store fails. A failing store used to reach only `on_store_error:`, or stderr. The query still runs. (#29)
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- `Points` and `Time` on one store and one key shared a bucket, as the docs recommend: a query taking 300 ms spent the client's points, and each limiter capped and refilled the balance with its own limit. `Time` now keeps its milliseconds under `time:<key>`, read with `Time.bucket_key(key)`. On upgrading, `Time` balances start full once; `Points` keeps its key, so a balance the mix drained recovers as it refills, within one window. Tools or custom stores that read `Time`'s bucket by the client's key must use the new key. Two limiters of the same class on one store and key still share a bucket: give each its own store or keys. (#43)
|
|
34
|
+
- A payload field declared on a parent mutation with no rule, or on a mutation with `authorize :none` and no `covers_fields`, is denied while the mutation still runs, so the client read null after a write. It stays denied, and the audit now reports it as `:field_without_rule`. (#42)
|
|
35
|
+
- With `Points`, a connection asked for with no `first:` or `last:` on a schema with no page size was a server error any client could cause. It is now refused with `This connection needs first: or last:` (code `PAGE_SIZE_REQUIRED`), and charges nothing. Set `default_max_page_size`. A schema with `max_complexity` still gets graphql-ruby's error. (#41)
|
|
36
|
+
- A schema using `Concurrency` twice kept only the last limiter's slot for a query, so the other leaked until `stale_after`. (#40)
|
|
37
|
+
- When an object's check raised and a `rescue_from` answered true, graphql-ruby served the object. The handler still runs, and the object is denied. (#29)
|
|
38
|
+
- The rate limiters' default warning no longer prints the store error's message, which could hold a password. (#29)
|
|
39
|
+
|
|
40
|
+
### Security
|
|
41
|
+
|
|
42
|
+
- An `ExecutionError` raised during analysis, by another analyzer or by a `complexity:` lambda, made `Points`, `Time` and `Concurrency` skip the query or lose a refusal already given, and a client could choose that by field order. Each limiter now still decides. **Upgrading:** in hard mode, a query whose limiter cannot answer, because a `complexity:` lambda or an `on_store_error` hook raised, is refused with the code `RATE_LIMIT_NOT_CHARGED` instead of running. Soft mode runs it and records `charged: false` in the limiter's context record. Limit: a trace that takes no modules from the schema (`context[:trace]`, a class given to `trace_mode`) is not covered. (#46) Docs: [rate limiting](docs/rate-limiting.md#guarantees-and-limits).
|
|
43
|
+
- A resolver, mutation or subscription with no `Base`, on a type with `covers_fields`, was served to anyone while the audit said ok, because the declaration read as covering it and no rule ran. The audit now reports it as `:unprotected`. To fix it, include `Base` and declare `authorize`, or declare `authorize: :none, reason: "..."` on the field. A schema that had this shape fails the audit after upgrading; a plain read that answers a type with a rule is not reported, because that type refuses. Limit: a plain method field under `covers_fields` stays served and listed by its type. Runtime does not change. (#44)
|
|
44
|
+
- A field returning a mutation's payload, without being that mutation, served the payload's fields with no rule run while the audit said ok. The audit now reports it as `:payload_outside_mutation`, as well as a public mutation inheriting fields from a parent with a rule, so a schema that passed may now fail. The runtime does not change: return a type of its own, or give the parent's field `authorize: :none, reason: "..."`. (#42)
|
|
45
|
+
|
|
46
|
+
### Docs
|
|
47
|
+
|
|
48
|
+
- Limits named for rate limits (`max_depth` lost when an analyzer raises, `validate: false` on `Execution::Next`, `max_page_size: nil`, a query `Time` refuses still charged by `Points`, a multiplex analyzer raising), the audit (`authorize: false` with a resolver with no `Base`, another resolver at a path a baseline accepts) and persisted queries (graphql before 2.6 with json 3.1). (#47)
|
|
49
|
+
|
|
3
50
|
## 0.1.0.pre8 (2026-10-31)
|
|
4
51
|
|
|
5
52
|
This release is about [authorization](docs/authorization.md): the audit now follows every definition of the types it reaches from the schema's roots, `orphan_types` and what they reference, whether or not `visible?` hides them (the limits are in the module page), and Pundit judges a record by both the declared model's policy and its own class's. Read it if you run the audit in CI or rely on Pundit reading `policy_class` from an instance: rules that were silently skipped now show up, so an audit that passed can fail after upgrading, and each new problem names what to do.
|
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.pre9`). 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.pre9" # a prerelease: `gem install graphql-armory --pre`
|
|
15
15
|
gem "pundit" # or "cancancan", for the authorization module
|
|
16
16
|
```
|
|
17
17
|
|
|
@@ -25,7 +25,7 @@ Status: ✅ released, 🚧 in progress, ⬜ not started. Update this table in th
|
|
|
25
25
|
|---|---|---|---|---|
|
|
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
|
-
| 3 | Persisted queries / operation allowlist | ✅ | [docs/persisted-queries.md](docs/persisted-queries.md) | Released in 0.1.0.pre4 (prerelease). Allowlist from a build manifest (Apollo, Relay), `use GraphQL::Armory::PersistedQueries` with strict mode. A runtime store (database, sync endpoint) is planned for later. |
|
|
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
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
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. |
|
|
31
31
|
| 6 | Subscriptions over Pusher / Ably | ⬜ | | Research not started. |
|
|
@@ -55,8 +55,8 @@ GRAPHQL_VERSION="~> 2.4.0" docker compose run --rm test # another graphq
|
|
|
55
55
|
Releases are published from GitHub Actions with RubyGems Trusted Publishing (`.github/workflows/release.yml`): no API key is stored and no OTP is asked. Bump `GraphQL::Armory::VERSION` and the CHANGELOG on `main`, then push the matching tag:
|
|
56
56
|
|
|
57
57
|
```
|
|
58
|
-
git tag -a v0.1.0.
|
|
59
|
-
git push origin v0.1.0.
|
|
58
|
+
git tag -a v0.1.0.pre9 -F <message file> main
|
|
59
|
+
git push origin v0.1.0.pre9
|
|
60
60
|
```
|
|
61
61
|
|
|
62
62
|
The workflow checks that the tag matches the version, runs the specs and pushes the gem. It only publishes a tag on a commit of `main`. The `release` environment can require a maintainer's approval first (on a public repository, or a private one on a paid GitHub plan).
|
data/docs/authorization.md
CHANGED
|
@@ -46,7 +46,16 @@ class AppSchema < GraphQL::Schema
|
|
|
46
46
|
end
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
With graphql-ruby's install template, which includes `ConnectionBehaviors` and `EdgeBehaviors` in classes that inherit from your base object, also declare this in `BaseConnection` and `BaseEdge`, or the audit reports them as `:ignored_rule` ([why](#lists-connections-and-lookups)):
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
class Types::BaseConnection < Types::BaseObject
|
|
53
|
+
authorize :none, reason: "the field that returns it holds the rule; each node has its own", covers_fields: true
|
|
54
|
+
include GraphQL::Types::Relay::ConnectionBehaviors
|
|
55
|
+
end
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The actor is `context[:current_user]`; for another key, pass `adapter: GraphQL::Armory::Authorization::Adapters::Pundit.new(user_key: :viewer)`. A complete, tested version lives in [`examples/invoices.rb`](../examples/invoices.rb).
|
|
50
59
|
|
|
51
60
|
## Rules
|
|
52
61
|
|
|
@@ -115,8 +124,11 @@ A declared check is listed as a bypass. Each method is declared on its own, and
|
|
|
115
124
|
|
|
116
125
|
**What the audit does not see.**
|
|
117
126
|
|
|
127
|
+
- `authorize: false` on a field with a resolver that has no `Base`: the audit says ok, and at runtime the field raises `NoMethodError` and serves nothing. Use `authorize: :none, reason: "..."`.
|
|
118
128
|
- It looks at the classes when it runs, so a method defined later is seen by the next audit.
|
|
119
129
|
- A type no definition leads to. The audit follows the schema's roots, `orphan_types` and what each definition references (fields, arguments, interfaces, union members, a connection's node), not filtering by `visible?`, so types hidden with no request and every definition of a duplicated name are audited. A type that only your own `resolve_type` returns, listed nowhere, is not seen.
|
|
130
|
+
- A resolver with no `Base` in a shared payload type is reported even when every mutation returning it has a rule that refuses first. The audit reads the type and not who returns it. Declare `authorize: :none, reason: "..."` on the field. A resolver with its own refusing `authorized?` is reported too: the audit cannot read that method.
|
|
131
|
+
- A plain field under a type with `covers_fields`, with a method or a `resolver_method:`. The declaration says its fields are public, so the field is served and only the type is listed. A resolver or mutation with no `Base` under that type is reported. A read one that answers a type with a rule, a list or a connection of one, or a union of them, is not, because that type refuses; on a write root it is reported, since it has already run.
|
|
120
132
|
- A missing action on an argument of an input object, when a plain field takes that input object. It raises at runtime instead of serving.
|
|
121
133
|
- A Base resolver on a field class with no `Base`, under a type with a rule: its rule's actions and its arguments are not read. It raises at runtime instead of serving.
|
|
122
134
|
- A missing policy action on a field of a bypassed type. The audit does not ask the policy there, so a misspelled action passes it and raises at runtime instead of serving.
|
|
@@ -125,6 +137,42 @@ A declared check is listed as a bypass. Each method is declared on its own, and
|
|
|
125
137
|
- Code written to get past it. The audit reads the classes, it does not run your methods: a declared alias of a library method, a class of yours named under `GraphQL::`, or a forged `__FILE__` are not caught. The audit protects against mistakes, not against the app's own code.
|
|
126
138
|
- `rescue_from(NotAuthorizedError)`: with `on_unauthorized: :raise`, rescuing is what you are meant to do, so keep that handler's message generic. The interpreter catches field denials there and not object denials, which then raise out of the query; `Execution::Next` catches both.
|
|
127
139
|
|
|
140
|
+
## Observing a rule before enforcing it
|
|
141
|
+
|
|
142
|
+
To learn who a new rule would refuse on a schema already in production, name the rule and say why:
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
class Types::Invoice < Types::BaseObject
|
|
146
|
+
authorize :read, observe: "rollout of invoices, ticket 123"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
field :notes, String, authorize: :manage, observe: "rollout of notes, ticket 124"
|
|
150
|
+
|
|
151
|
+
use GraphQL::Armory::Authorization, adapter: :pundit,
|
|
152
|
+
sinks: [->(signal, data) { Rails.logger.info("armory #{signal} #{data}") if signal == :observed }]
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
When the policy answers `false` or `nil`, **the data is served to a user the rule would refuse**, and an `:observed` signal is recorded in place of `:denied`. Remove `observe:` to enforce the rule. There is no switch for a whole schema: each rule says it, with its reason, and the audit reports each one as `:observed` until it is gone, so `report.ok?` is false and a baseline cannot accept it.
|
|
156
|
+
|
|
157
|
+
| Case | What happens under `observe:` |
|
|
158
|
+
|---|---|
|
|
159
|
+
| A query, and the policy answers `false` or `nil` | Served, `:observed` recorded |
|
|
160
|
+
| No `sinks:` | Refused: observing with nobody listening is only an open door |
|
|
161
|
+
| `Adapters::Null` | Refused |
|
|
162
|
+
| A policy that raises, is missing, or answers anything else | Raises or refuses, as without `observe:` |
|
|
163
|
+
| A type or field with no rule | Denied: there is no rule to observe |
|
|
164
|
+
| A mutation or a subscription, including a read in its response | Refused: only a query runs |
|
|
165
|
+
| A mutation whose response returns a type that cannot be observed | The mutation's own body has already run when that type raises: its write stands |
|
|
166
|
+
| A type under `observe:` with a field that has a `resolver:` or a `mutation:`, its own, inherited or from an interface; a definition shadowed in a subclass does not count | Raises `ConfigurationError` on every query that reaches the type, and its resolvers do not run: observing the type would open the only check before them, and they can write. Remove `observe:`, or put it on the type's plain fields |
|
|
167
|
+
| The same, on the root type | Runs nothing, but the error depends on the engine: on the interpreter with graphql 2.6, graphql-ruby's own rescue reports a `NoMethodError` caused by the `ConfigurationError`. A `rescue_from` handler can turn it into an empty result, so run the audit, which names the type |
|
|
168
|
+
| `authorize: true` with a resolver, where the type's rule is observed | Raises `ConfigurationError`, after graphql-ruby has coerced the arguments: their `prepare:` has run, as it does before any field's rule |
|
|
169
|
+
| A list of an object type, a `loads:` or a `find_by:` | Still goes through the user's scope, and an in-memory lookup still asks the policy: rows are lost, and nothing is recorded about them |
|
|
170
|
+
| A list with no scope: of an interface or a union, `scope: false`, Relay `nodes(ids:)` | **Served row by row and recorded**: the check on each object is all that protects these lists, and it cannot tell it is inside one. A foreign row reaches the user |
|
|
171
|
+
| `visible_when:` | Still hides: the rule it builds is not observed |
|
|
172
|
+
| `observe:` on a mutation, a resolver, a field that holds either, an argument, a bypass, or a field with no action of its own | Raises `ArgumentError` when declared |
|
|
173
|
+
|
|
174
|
+
**Limits.** Only a `resolver:` or a `mutation:` counts as something that can write: a plain field's method that writes, or a `prepare:` on its arguments, runs for a refused user, so keep writes in mutations. A sink that raises loses that signal, reported to `on_sink_error:`, and the data is still served. `Telemetry::Notifications` counts as listening even when nothing subscribes to it. A rule on a type with lists can already lose rows while observed: declare its `scope` only when you trust it, because observing it would mean a second query per list and serving other users' rows. Any clear "no" counts: with CanCanCan that includes the guard that refuses a Symbol a type returns, and an adapter you wrap around `Adapters::Null` is not recognised as null. `load_through_scope: false` opts out of the scope, so a foreign record is served and recorded. While a rule is observed, `audit_baseline` refuses to write: remove `observe:` first.
|
|
175
|
+
|
|
128
176
|
## Lists, connections and lookups
|
|
129
177
|
|
|
130
178
|
Lists of a type are narrowed through `InvoicePolicy::Scope` (relations) or by calling the policy per item (arrays). Return a relation when you can: SQL filters before Ruby sees anything.
|
|
@@ -160,7 +208,8 @@ expect(AppSchema).to be_fully_authorized
|
|
|
160
208
|
```
|
|
161
209
|
|
|
162
210
|
```ruby
|
|
163
|
-
#
|
|
211
|
+
# Exits 1 when anything is uncovered. In Rails the task loads by itself when the gem is required after Rails, as Bundler does.
|
|
212
|
+
# Elsewhere, in the Rakefile:
|
|
164
213
|
load "tasks/graphql_armory.rake"
|
|
165
214
|
# bundle exec rake "graphql_armory:audit[AppSchema]"
|
|
166
215
|
```
|
|
@@ -174,23 +223,54 @@ load "tasks/graphql_armory.rake"
|
|
|
174
223
|
| Kind | What it means | What to do |
|
|
175
224
|
|---|---|---|
|
|
176
225
|
| `:plugin` | The schema never installed the plugin. | Add `use GraphQL::Armory::Authorization, adapter: ...`. |
|
|
177
|
-
| `:unprotected` | A base class, or the field class of an interface's field on a bypassed implementer, does not include `Base`, so nothing checks it. | Include `Base
|
|
226
|
+
| `:unprotected` | A base class, or the field class of an interface's field on a bypassed implementer, does not include `Base`, so nothing checks it. Also a resolver, mutation or subscription with no `Base` under a type with `covers_fields`: the declaration says a rule runs elsewhere, and none does. | Include `Base` and declare `authorize`, or declare `authorize: :none, reason: "..."` on the field, which lists it by its own path. |
|
|
178
227
|
| `:no_rule` | A type, mutation or resolver has no rule, so it is denied to everyone. | Add `authorize` or `default_action`. |
|
|
179
228
|
| `:field_without_rule` | A field of a bypassed type has no rule. The message says what really happens: denied, or served with no check (see [Rules](#rules)). | Give the field `authorize:` where it is defined (on the parent, if inherited), or remove it. A field class without `Base` needs one that includes it. |
|
|
180
229
|
| `:adapter` | The policy a rule needs does not exist, or lacks the method or the `Scope`. | Add what is missing. |
|
|
181
230
|
| `:model` | The model a rule needs cannot be worked out. | Pass `model:`. |
|
|
182
231
|
| `:unscoped_load` | A `loads:` goes through `object_from_id` instead of the user's scope. | Let the load use the scope. |
|
|
183
232
|
| `:unscoped_list` | A list or connection of records skips the user's scope, with `scope: false`, `connection: false` on a connection type, or a list type graphql-ruby does not detect such as `X.to_list_type`. A rule on each record hides its fields, not how many there are. | Remove the option, or write the type as `[Type]`. |
|
|
233
|
+
| `:payload_outside_mutation` | A field returns a mutation's payload without being a mutation with a rule, including a shared `payload_type` with no rule of its own. Its fields are covered by the mutation's rule, so here they are served with no check. | Return a type of its own. A public mutation that inherits fields from a parent with a rule serves them with no check, so give those fields `authorize: :none, reason: "..."` on the parent. |
|
|
184
234
|
| `:hook` | The schema's own `unauthorized_field` or `unauthorized_object` wins over the plugin's. | Remove it, or declare it in `own_denial_hooks:`. |
|
|
185
235
|
| `:own_check` | Your own method can skip the rule: `authorized?`, `wrap`, `visible?` and others, or `reauthorize_scoped_objects` turned off. | Remove it, or declare it in `own_checks:`. |
|
|
186
236
|
| `:ignored_rule` | A rule that does not run, or a field or argument defined more than once. | See [Redefined fields and arguments](#redefined-fields-and-arguments) and [Lists, connections and lookups](#lists-connections-and-lookups). |
|
|
187
237
|
| `:foreign_model` | A field rule is defined on an interface or a parent whose model is not this type's, so the policy of this type's model is never asked for that field. A record of another class or a laxer subclass gets past it. Redefine the field on this type with its `authorize:` and an `override_reason:`, or name the same model on the parent. |
|
|
188
238
|
| `:foreign_policy` | A type or a Base resolver names a `policy:`, but a rule it uses is defined on an interface, a parent, an input object or a resolver, so that rule asks another policy. A user this type's policy refuses can get the data. A field never sees its resolved type, so this cannot be fixed at runtime. | Move the rule to the class that names the policy and remove the original, or name the `policy:` on the class that defines the rule, if it can hold one: an interface or an input object cannot. There is no declaration for it. |
|
|
239
|
+
| `:observed` | A rule under `observe:` serves what it would refuse. A baseline cannot accept it. | Remove `observe:` to enforce the rule. |
|
|
189
240
|
|
|
190
241
|
A declaration (`own_checks:`, `own_denial_hooks:`, `override_reason:`, `authorize :none, reason:`) is your word: the audit stops reporting that problem and lists the reason, so a reviewer can read it. It does not read what your code does. A declaration that names nothing in use is itself a problem.
|
|
191
242
|
|
|
192
243
|
Upgrading can make an audit that used to pass fail, because the audit reports more: read each new problem instead of declaring it to get the build green.
|
|
193
244
|
|
|
245
|
+
### Adopting it on a schema with many problems
|
|
246
|
+
|
|
247
|
+
A first audit of an existing schema can report hundreds of problems. A baseline records them so the build fails only on new ones, while the team works through the list:
|
|
248
|
+
|
|
249
|
+
```sh
|
|
250
|
+
bundle exec rake "graphql_armory:audit_baseline[AppSchema,config/graphql_armory_baseline.json]" # once; commit the file
|
|
251
|
+
bundle exec rake "graphql_armory:audit[AppSchema,config/graphql_armory_baseline.json]" # in CI
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
```ruby
|
|
255
|
+
expect(AppSchema).to be_fully_authorized(baseline: "config/graphql_armory_baseline.json")
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
**A baseline does not protect anything.** The accepted problems are still open, and a refused user may still get that data. `report.ok?` stays false and the output lists every accepted problem under "still open".
|
|
259
|
+
|
|
260
|
+
| What happens | Result |
|
|
261
|
+
|---|---|
|
|
262
|
+
| A problem not in the file | Fails, named under "new" |
|
|
263
|
+
| An accepted problem whose message changed, for example after an upgrade | Fails as new: read it, then edit the file |
|
|
264
|
+
| An accepted problem that grew: a field or a `loads:` added to that type, or a type added under that base class or interface | Fails as new, naming what it now reaches |
|
|
265
|
+
| An accepted problem that now appears more times | The extra ones fail as new |
|
|
266
|
+
| An accepted problem that is gone | Fails until it leaves the file, so it cannot come back unseen |
|
|
267
|
+
| Another resolver put at a path whose problem is accepted | Passes: an entry names the path and the problem, not the code behind them. Review what changes at an accepted path |
|
|
268
|
+
| A file that is missing or invalid | Fails: it never runs as if there were no baseline |
|
|
269
|
+
| A schema without the plugin | Cannot be accepted: install the plugin first |
|
|
270
|
+
| An `:own_check` or a `:hook` problem | Cannot be accepted: whatever uses that method later would pass unseen. Remove it, or declare it in `own_checks:` or `own_denial_hooks:` with a reason, which the audit lists |
|
|
271
|
+
|
|
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
|
+
|
|
194
274
|
### Redefined fields and arguments
|
|
195
275
|
|
|
196
276
|
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:
|
|
@@ -227,7 +307,7 @@ Same schema, different adapter. Your types and fields do not change:
|
|
|
227
307
|
use GraphQL::Armory::Authorization, adapter: :cancan
|
|
228
308
|
```
|
|
229
309
|
|
|
230
|
-
The actor is `context[:current_ability]` (it wins over `current_user`), or `Ability.new(context[:current_user])` (pass `Adapters::CanCan.new(ability_class: MyAbility)` for another class). It is built once per query.
|
|
310
|
+
The actor is `context[:current_ability]` (it wins over `current_user`), or `Ability.new(context[:current_user])` (`user_key:` names another key; pass `Adapters::CanCan.new(ability_class: MyAbility)` for another class). It is built once per query.
|
|
231
311
|
|
|
232
312
|
| Question | CanCanCan call |
|
|
233
313
|
|---|---|
|
|
@@ -326,6 +406,7 @@ end
|
|
|
326
406
|
- **`ready?` runs before authorization** (and, on the interpreter, before a failing `loads:`). Keep it free of side effects.
|
|
327
407
|
- **`loads:` inside an input object** goes through the scope only when your base input object includes `Base` (`class Types::BaseInputObject < GraphQL::Schema::InputObject; include GraphQL::Armory::Authorization::Base; end`); otherwise the audit reports it as `:unscoped_load`.
|
|
328
408
|
- **Do not put `authorize:` on a mutation field or on payload fields**; the mutation's own rule is the one that counts. A subclass that redeclares `authorize :none` is open to everyone, on purpose.
|
|
409
|
+
- **Fields on a parent mutation.** A payload field asks the class that defined it, so a field on a parent with no rule, or on a mutation with `authorize :none` and no `covers_fields`, is denied: the mutation runs and the field answers null. The audit reports it as `:field_without_rule`. Give that field `authorize: :none, reason: "..."`, or use a shared `payload_type`. Moving `authorize` to the parent also works, but every new child then inherits that rule instead of being denied, and the audit does not report it.
|
|
329
410
|
|
|
330
411
|
## Interfaces and unions
|
|
331
412
|
|
|
@@ -341,6 +422,8 @@ What an interface adds is its fields, declared once and resolved on many types:
|
|
|
341
422
|
|
|
342
423
|
## Telemetry
|
|
343
424
|
|
|
425
|
+
**When something raises during an object's check.** If a policy raises and a `rescue_from` handles the error, the handler runs and the object is denied, whatever the handler answers. Without a handler the error reaches the request. This includes a `GraphQL::ExecutionError`: when a `rescue_from` catches it, its message does not reach the client, who gets the ordinary denial.
|
|
426
|
+
|
|
344
427
|
The decisions are visible to you and not to the client. Pass `sinks:` to hear each one:
|
|
345
428
|
|
|
346
429
|
```ruby
|
|
@@ -349,7 +432,7 @@ use GraphQL::Armory::Authorization, adapter: :pundit,
|
|
|
349
432
|
# armory denied {:type=>"Invoice", :action=>:read}
|
|
350
433
|
```
|
|
351
434
|
|
|
352
|
-
Signals are `:allowed`, `:denied`, `:bypassed` (an `authorize :none` rule ran) and `:scoped` (a list went through the user's scope). Data holds only `type`, `action` and `reason`, never record attributes. `GraphQL::Armory::Telemetry::Notifications` reports them to `ActiveSupport::Notifications`. Every signal, what to watch for and how to test it: [Telemetry](telemetry.md). A `sinks:` that is not an Array of callables raises `Telemetry::ConfigurationError` at boot.
|
|
435
|
+
Signals are `:allowed`, `:denied`, `:observed` (a rule under `observe:` would have refused; its data also holds the `reason`), `:bypassed` (an `authorize :none` rule ran) and `:scoped` (a list went through the user's scope). Data holds only `type`, `action` and `reason`, never record attributes. `GraphQL::Armory::Telemetry::Notifications` reports them to `ActiveSupport::Notifications`. Every signal, what to watch for and how to test it: [Telemetry](telemetry.md). A `sinks:` that is not an Array of callables raises `Telemetry::ConfigurationError` at boot.
|
|
353
436
|
|
|
354
437
|
## Guarantees and limits
|
|
355
438
|
|
data/docs/encrypted-ids.md
CHANGED
|
@@ -137,7 +137,7 @@ The signal never holds the ID, the key or the record. A rising `:invalid` is som
|
|
|
137
137
|
| `decode(id)` / `decode(cursor, nonce: true)` | The text, or raises `GraphQL::Armory::Encoder::InvalidError` (a `GraphQL::ExecutionError`, so clients see `Invalid ID or cursor`). |
|
|
138
138
|
| `Encoder.versioned(newest, ..., oldest)` | A chain with the same `encode` and `decode`; encodes with `newest`. |
|
|
139
139
|
| `chain.decode_versioned(text, nonce: false)` | `[data, encoder]`, or `[nil, nil]` when no encoder accepts it. |
|
|
140
|
-
| `use GraphQL::Armory::IDs, encoder: nil, find: nil, sinks: []` | Defines `id_from_object` and `object_from_id`; `encoder` defaults to `Encoder.default`; `find:` loads types without `Base`; `sinks:` hears `:id_resolved` and `:id_refused` ([Telemetry](telemetry.md)). |
|
|
140
|
+
| `use GraphQL::Armory::IDs, encoder: nil, find: nil, sinks: [], on_sink_error: nil` | Defines `id_from_object` and `object_from_id`; `encoder` defaults to `Encoder.default`; `find:` loads types without `Base`; `sinks:` hears `:id_resolved` and `:id_refused` ([Telemetry](telemetry.md)). |
|
|
141
141
|
| `Encoder.default` | An encoder keyed by `GRAPHQL_ARMORY_KEY`, else by a key derived with HKDF from Rails' `secret_key_base`; raises `ConfigurationError` without either. |
|
|
142
142
|
|
|
143
143
|
A subclass inherits `key`, `tag` and `codec`, and can override any of them. Passing `nil` to any of them raises `ConfigurationError`, so an unset environment variable fails at boot instead of being ignored.
|
data/docs/persisted-queries.md
CHANGED
|
@@ -101,7 +101,7 @@ class GraphqlController < ApplicationController
|
|
|
101
101
|
end
|
|
102
102
|
```
|
|
103
103
|
|
|
104
|
-
`PersistedQueries.operation_id(params)` reads the id where clients send it: `operationId`, `extensions.operationId`,
|
|
104
|
+
`PersistedQueries.operation_id(params)` reads the id where clients send it: `operationId`, `extensions.operationId`, Apollo's `extensions.persistedQuery.sha256Hash`, or Relay's `doc_id` (`extensions` may be a JSON string, as in GET requests). The first one present wins, in that order. It answers nil when there is no id, or when it is not a non-empty String. Params must have String keys, as Rack and Rails params do. An empty `query` sent with an id (a GET request with `query=`) counts as no query.
|
|
105
105
|
|
|
106
106
|
What each request does:
|
|
107
107
|
|
|
@@ -126,7 +126,97 @@ GraphQL::Armory::PersistedQueries::Manifest.load("persisted-queries.json", diges
|
|
|
126
126
|
|
|
127
127
|
`digest: false` accepts ids that are not digests (names such as `invoices-v1`). Then nothing proves an id belongs to its query: anyone who can edit the file can change what an id runs.
|
|
128
128
|
|
|
129
|
-
|
|
129
|
+
### Several clients
|
|
130
|
+
|
|
131
|
+
Each frontend's build, and every mobile version still in use, has its own manifest. `Manifests` puts them behind one `store:`:
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
use GraphQL::Armory::PersistedQueries, store: GraphQL::Armory::PersistedQueries::Manifests.build("config/persisted_queries/*.json"), strict: true
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
`Manifests.build` takes a glob pattern, a path or a list of them, and `digest:`. For manifests with different digests, load each one and pass them: `Manifests.new([Manifest.load("web.json"), Manifest.load("relay.json", digest: :md5)])`.
|
|
138
|
+
|
|
139
|
+
A pattern skips dotfiles and drops a missing `{a,b}` alternative, and `[` in a name reads as a pattern, so `a[1].json` loads `a1.json` when that file exists. List exact paths when a pattern could miss something, and avoid `[` in names. It raises at boot when a pattern or a path finds nothing, when a file does not load, or when two manifests hold different queries for one id, which only ids that are not digests can do. Keep a version's manifest for as long as that version is in use: removing it refuses those clients with `:unknown_operation`.
|
|
140
|
+
|
|
141
|
+
They are one allowlist: any client may run any manifest's operation. A new manifest still needs a deploy.
|
|
142
|
+
|
|
143
|
+
### Publishing without a server deploy
|
|
144
|
+
|
|
145
|
+
With a `Manifest`, a new query reaches the server only with a server deploy. When frontends deploy on their own, even minutes apart, their users get `UNKNOWN_OPERATION` until the server catches up, and every query change needs a backend deploy. With `RedisStore`, the frontend's CI publishes its operations before the frontend goes out, and the server reads them as they are asked for, with no deploy and no restart.
|
|
146
|
+
|
|
147
|
+
```mermaid
|
|
148
|
+
sequenceDiagram
|
|
149
|
+
participant CI as Frontend CI
|
|
150
|
+
participant R as Redis
|
|
151
|
+
participant F as Frontend
|
|
152
|
+
participant S as GraphQL server
|
|
153
|
+
|
|
154
|
+
CI->>CI: Build the frontend, generate the manifest
|
|
155
|
+
CI->>R: graphql-armory-publish manifest.json (SET NX per id)
|
|
156
|
+
Note over CI,R: A failure stops the deploy
|
|
157
|
+
CI->>F: Deploy the frontend
|
|
158
|
+
F->>S: Request with an operation id
|
|
159
|
+
alt The server already read this id
|
|
160
|
+
S->>S: Answer from memory
|
|
161
|
+
else First time it sees this id
|
|
162
|
+
S->>R: GET the id
|
|
163
|
+
R-->>S: The query
|
|
164
|
+
S->>S: Check the digest, parse, keep in memory
|
|
165
|
+
end
|
|
166
|
+
S-->>F: The result of the stored query
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
On the server:
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
class AppSchema < GraphQL::Schema
|
|
173
|
+
use GraphQL::Armory::PersistedQueries,
|
|
174
|
+
store: GraphQL::Armory::PersistedQueries::RedisStore.build(
|
|
175
|
+
redis: Redis.new(url: ENV.fetch("PERSISTED_QUERIES_REDIS_URL"), timeout: 0.5, reconnect_attempts: 0),
|
|
176
|
+
on_error: ->(error) { Rails.error.report(error) }
|
|
177
|
+
),
|
|
178
|
+
strict: true
|
|
179
|
+
end
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
In the frontend's CI, before its deploy:
|
|
183
|
+
|
|
184
|
+
```sh
|
|
185
|
+
gem install graphql-armory redis
|
|
186
|
+
REDIS_URL="$PUBLISHER_REDIS_URL" graphql-armory-publish dist/persisted-queries.json
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
It exits 1 on any problem, so the deploy stops. Publishing again is safe, so a failed run can simply run again. It takes several files, `--digest` and `--prefix`, and answers the same as `Publisher.call(manifest, redis:)` in Ruby. A CI with no Ruby can run `SET <prefix><id> <query> NX` itself: the default prefix is `graphql-armory:persisted-queries:` and the value is the query text. Check each answer: a refused `SET` means the id already exists, and it must hold the same query.
|
|
190
|
+
|
|
191
|
+
**Who can publish decides what strict mode allows.** Anyone who can write to these keys can make the server run any query they choose, under its digest. So:
|
|
192
|
+
|
|
193
|
+
- Give the server read access only, and the CI its own write access. With Redis ACLs:
|
|
194
|
+
```
|
|
195
|
+
ACL SETUSER armory-server on >SERVER_PASSWORD ~graphql-armory:persisted-queries:* +get
|
|
196
|
+
ACL SETUSER armory-ci on >CI_PASSWORD ~graphql-armory:persisted-queries:* +get +set
|
|
197
|
+
```
|
|
198
|
+
On a database other than 0, add `+select` to both: the client selects it when it connects.
|
|
199
|
+
- Keep the CI's credential away from pull requests from forks and from the app's own servers.
|
|
200
|
+
- The gem adds no endpoint to your API, so publishing cannot be reached from it.
|
|
201
|
+
|
|
202
|
+
What happens when something fails:
|
|
203
|
+
|
|
204
|
+
| Situation | What the client gets | What you see |
|
|
205
|
+
|---|---|---|
|
|
206
|
+
| Redis is down, the server already read the id | The normal result | Nothing |
|
|
207
|
+
| Redis is down, the server has not read the id | `UNKNOWN_OPERATION`, nothing runs | `on_error` with the error, plus `:operation_refused` |
|
|
208
|
+
| The id was never published | `UNKNOWN_OPERATION`, nothing runs | `:operation_refused` |
|
|
209
|
+
| The stored query is not the digest of its id, or does not parse | `UNKNOWN_OPERATION`, nothing runs | `on_error` with `RedisStore::CorruptOperation`, plus `:operation_refused` |
|
|
210
|
+
| The CI publishes another query under an id that exists | Nothing changes for clients | `PublishError`, exit 1 |
|
|
211
|
+
|
|
212
|
+
By default `on_error` warns with the error's class only: Redis's messages may hold the URL and its password. `warn` prints nothing under `ruby -W0`, so pass your own `on_error` in production. An `on_error` that raises makes the request fail, and nothing runs.
|
|
213
|
+
|
|
214
|
+
Limits:
|
|
215
|
+
|
|
216
|
+
- Set a short Redis timeout, as above: during an outage each id the server does not know waits for it, and a client can send many such ids. Ids are checked for shape first, and one that cannot be an id never reaches Redis.
|
|
217
|
+
- Removing a key does not revoke an operation until every server restarts: a server keeps what it read, with no limit but what was published.
|
|
218
|
+
- They are one allowlist: any client may run any published operation. Use another `prefix:` for each allowlist you want apart. With `digest: false`, do not nest one prefix inside another: an id holding `:` could reach the other allowlist. With a digest, the check refuses it.
|
|
219
|
+
- Keep these keys where they cannot expire or be evicted (`noeviction`, or a Redis of their own). A lost key looks like an id never published.
|
|
130
220
|
|
|
131
221
|
### Rolling it out
|
|
132
222
|
|
|
@@ -153,21 +243,25 @@ use GraphQL::Armory::PersistedQueries, store: PERSISTED, sinks: [GraphQL::Armory
|
|
|
153
243
|
|
|
154
244
|
While you are in `strict: false`, `:raw_query_run` is the traffic still to migrate: turn on `strict: true` when it reaches zero. After a deploy, `:operation_refused` with `:unknown_operation` is a client build whose operations are not in the manifest. Signals never hold the operation id or the query. See [Telemetry](telemetry.md).
|
|
155
245
|
|
|
246
|
+
### Telling a refusal apart
|
|
247
|
+
|
|
248
|
+
Every refusal carries a stable code in `extensions.code`: `RAW_QUERY_REFUSED`, `UNKNOWN_OPERATION` or `OPERATION_MISMATCH`. Match on it instead of on the message.
|
|
249
|
+
|
|
156
250
|
### Counting probes in strict mode
|
|
157
251
|
|
|
158
252
|
In strict mode a raw query is refused before it is validated, so it never produces a validation error. If your abuse detection counts validation errors (`undefinedField`, `argumentNotAccepted`) to spot someone probing your schema, it sees nothing from a strict endpoint. Count the refusal instead: a legitimate client sends operation ids and never a raw query, so `:operation_refused` with `reason: :raw_query` is a clean strike.
|
|
159
253
|
|
|
160
254
|
```ruby
|
|
161
|
-
count_probes = lambda do |signal, data|
|
|
255
|
+
count_probes = lambda do |signal, data, context|
|
|
162
256
|
next unless signal == :operation_refused && data[:reason] == :raw_query
|
|
163
257
|
|
|
164
|
-
AbuseDetection.strike(
|
|
258
|
+
AbuseDetection.strike(context[:client_key]) # your detector, with a client key your controller put in the context
|
|
165
259
|
end
|
|
166
260
|
|
|
167
261
|
use GraphQL::Armory::PersistedQueries, store: PERSISTED, strict: true, sinks: [count_probes]
|
|
168
262
|
```
|
|
169
263
|
|
|
170
|
-
A signal holds only the reason
|
|
264
|
+
A signal holds only the reason. To know who sent the request, a sink that takes three arguments receives the query's context, as in [Telemetry](telemetry.md#who-asked). Do not strike on `:unknown_operation`: after a deploy it is a legitimate client build whose operations are not in the manifest yet. The example has not been run against a live detector.
|
|
171
265
|
|
|
172
266
|
### Testing with persisted queries
|
|
173
267
|
|
|
@@ -182,37 +276,35 @@ A signal holds only the reason, so the sink reads who sent the request from your
|
|
|
182
276
|
- `fetch(id)` answers the operation (`id`, `name`, `body`, `document`, all frozen) or nil. `size` counts them.
|
|
183
277
|
- Raises `PersistedQueries::ConfigurationError` at boot for: invalid JSON, an unreadable file, an unknown format or version, an id that is not the digest of its query, a query that does not parse, a duplicate id, an entry that is not an object, or an empty manifest. Messages never quote the file's queries.
|
|
184
278
|
|
|
185
|
-
`use GraphQL::Armory::PersistedQueries, store:, strict: false, sinks: []`
|
|
279
|
+
`use GraphQL::Armory::PersistedQueries, store:, strict: false, sinks: [], on_sink_error: nil`
|
|
186
280
|
|
|
187
281
|
- `store:` answers `fetch(id)` with an object that has `body` and `document`, or nil. A Manifest, or your own store.
|
|
188
282
|
- `strict:` is `true` or `false`.
|
|
189
|
-
- `sinks:` an Array of callables taking `(signal, data)`; see [Watching the rollout](#watching-the-rollout).
|
|
283
|
+
- `sinks:` an Array of callables taking `(signal, data)` or `(signal, data, context)`; see [Watching the rollout](#watching-the-rollout).
|
|
284
|
+
- `on_sink_error:` a callable `(error, signal)` called when a sink raises; see [Telemetry](telemetry.md#guarantees-and-limits).
|
|
190
285
|
- Works with your own `query_class`, whether it is set before `use`, after it, or in a child schema: the plugin subclasses it.
|
|
191
286
|
- `use` it once per schema hierarchy. A second `use`, on the same schema or a child, raises instead of replacing the first one's settings.
|
|
192
287
|
- A store whose `fetch` raises or answers something without `document` makes the request raise: it fails closed, as an exception.
|
|
193
288
|
|
|
289
|
+
`Manifests.build(paths, digest: :sha256)` loads manifests from a glob pattern, a path or a list. `Manifests.new(manifests = [])` joins `Manifest` objects (subclasses are refused: their `fetch` would not run). `fetch(id)`, `size`, `empty?`. With no manifests it holds nothing, so every id is unknown, and `strict: false` refuses it at boot.
|
|
290
|
+
|
|
291
|
+
`RedisStore.build(redis:, prefix: "graphql-armory:persisted-queries:", digest: :sha256, on_error:)` reads what a publisher stored; `new` with no Redis holds nothing, and `strict: false` refuses it at boot. `Publisher.call(manifest, redis:, prefix:)` answers `published` and `unchanged`, and raises `Publisher::PublishError` rather than change an id. `RedisStore::Client::Substitute` stands in for Redis in tests.
|
|
292
|
+
|
|
194
293
|
Context keys: `operation_id` (a String; anything else is an unknown id), `armory_allow_raw_query` (only `true` allows).
|
|
195
294
|
|
|
196
295
|
## Guarantees and limits
|
|
197
296
|
|
|
297
|
+
- On graphql before 2.6 (checked on 2.4.18 and 2.5.26) with json 3.1, a known id with different text raises instead of answering `OPERATION_MISMATCH` when the stored query holds a string literal, because graphql-ruby's printer passes an option that version of json rejects. Nothing runs.
|
|
198
298
|
- A request with an id runs only the stored document for that id, or nothing.
|
|
199
299
|
- In strict mode a raw query runs only when the app's context says `armory_allow_raw_query: true`. The plugin does not decide who is staff: your controller does.
|
|
200
|
-
- Queries built by your own code as `GraphQL::Query` objects (for example in `multiplex`)
|
|
300
|
+
- Queries built by your own code as `GraphQL::Query` objects (for example in `multiplex`) run their own text, so an operation id means nothing on them. In strict mode they are refused unless the context holds `armory_allow_raw_query: true`, and they are recorded like any raw query. The check is a trace on `execute_query`, so `run_partials` and `validate: false` are covered. Two limits: built text that does not validate gets the ordinary validation errors, with no signal and with field suggestions, because validation runs first (nothing resolves); and your own code can skip the check by choosing another trace: `context[:trace]` on a multiplex (or on a query, on the interpreter), or a `trace_mode` or `default_trace_mode` given a class that does not include `trace_with` modules. A signal is recorded per execution, so each `run_partials` entry records one. On `Execution::Next`, a refused query with `validate: false` and variables that do not coerce, or a validation timeout, returns a nil result: nothing runs, the refusal is recorded and sits in `context.errors`.
|
|
201
301
|
- In strict mode, schema introspection run through the plugin is refused too. `Schema.to_json` and `rake graphql:schema:json` need `context: { armory_allow_raw_query: true }` (the rake task's `load_context`). `Schema.to_definition` and `Schema.validate` are not affected.
|
|
202
|
-
-
|
|
302
|
+
- A `Manifest` lives in the server's code: adding an operation needs a deploy. `RedisStore` does not: see "Publishing without a server deploy".
|
|
203
303
|
- Subscriptions: graphql-ruby re-runs a subscription with its printed query, which the plugin accepts when it matches the stored document. Subscriptions are not tested with persisted queries yet.
|
|
204
304
|
|
|
205
|
-
## Planned:
|
|
206
|
-
|
|
207
|
-
The manifest is the simplest store: one file, no database, deployed with the code. Teams that ship clients independently of the server (mobile apps, several frontends) need operations published without a server deploy. That is planned for later, behind the same `store:` interface:
|
|
208
|
-
|
|
209
|
-
- **A database table** (ActiveRecord) holding operations per client, with the digest checked on write.
|
|
210
|
-
- **A sync endpoint** the client build calls to publish its operations, authenticated per client with an HMAC of the request body and a secret per client.
|
|
211
|
-
- **Several clients**, each with its own operations and secret, so one client cannot publish under another's name.
|
|
212
|
-
- **Archiving** operations that old app versions no longer use, without deleting their history.
|
|
213
|
-
- **Publishing without redeploying**: the server reads new operations as soon as they are synced.
|
|
305
|
+
## Planned: more runtime stores
|
|
214
306
|
|
|
215
|
-
Until then, any object that answers `fetch(id)` with an operation can be passed as `store:`.
|
|
307
|
+
`RedisStore` is one store behind the `store:` interface. Still planned: a database table, an endpoint a client build calls to publish, operations and secrets per client, and archiving what old app versions no longer use. Until then, any object that answers `fetch(id)` with an operation can be passed as `store:`.
|
|
216
308
|
|
|
217
309
|
## References
|
|
218
310
|
|