graphql-armory 0.1.0.pre

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.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +26 -0
  3. data/LICENSE +21 -0
  4. data/MIGRATING.md +85 -0
  5. data/README.md +311 -0
  6. data/lib/graphql/armory/authorization/adapters/cancan.rb +92 -0
  7. data/lib/graphql/armory/authorization/adapters/null.rb +28 -0
  8. data/lib/graphql/armory/authorization/adapters/pundit.rb +83 -0
  9. data/lib/graphql/armory/authorization/adapters/substitute.rb +46 -0
  10. data/lib/graphql/armory/authorization/audit/command.rb +36 -0
  11. data/lib/graphql/armory/authorization/audit/report.rb +51 -0
  12. data/lib/graphql/armory/authorization/audit.rb +213 -0
  13. data/lib/graphql/armory/authorization/authorize.rb +68 -0
  14. data/lib/graphql/armory/authorization/configuration.rb +35 -0
  15. data/lib/graphql/armory/authorization/controls/actor.rb +15 -0
  16. data/lib/graphql/armory/authorization/controls/context.rb +29 -0
  17. data/lib/graphql/armory/authorization/controls/rule.rb +15 -0
  18. data/lib/graphql/armory/authorization/controls/subject.rb +15 -0
  19. data/lib/graphql/armory/authorization/errors.rb +29 -0
  20. data/lib/graphql/armory/authorization/integration/argument.rb +74 -0
  21. data/lib/graphql/armory/authorization/integration/base.rb +34 -0
  22. data/lib/graphql/armory/authorization/integration/field.rb +102 -0
  23. data/lib/graphql/armory/authorization/integration/find_by.rb +22 -0
  24. data/lib/graphql/armory/authorization/integration/object.rb +67 -0
  25. data/lib/graphql/armory/authorization/integration/resolver.rb +84 -0
  26. data/lib/graphql/armory/authorization/integration/schema.rb +60 -0
  27. data/lib/graphql/armory/authorization/integration.rb +104 -0
  28. data/lib/graphql/armory/authorization/memo.rb +35 -0
  29. data/lib/graphql/armory/authorization/rspec.rb +21 -0
  30. data/lib/graphql/armory/authorization/rule.rb +61 -0
  31. data/lib/graphql/armory/authorization/scope.rb +37 -0
  32. data/lib/graphql/armory/authorization/telemetry.rb +42 -0
  33. data/lib/graphql/armory/authorization.rb +41 -0
  34. data/lib/graphql/armory/version.rb +7 -0
  35. data/lib/graphql/armory.rb +13 -0
  36. data/lib/graphql-armory.rb +2 -0
  37. data/lib/tasks/graphql_armory.rake +11 -0
  38. metadata +107 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9bb288f28bfb93054aa3ff328b791ff7936dfb546d289671650bd19182b56177
4
+ data.tar.gz: 44d9b064622b91a9691499d48df611f4b544950fbbbdf8a49d470c7563c41a03
5
+ SHA512:
6
+ metadata.gz: 4bc8f032f4f5305cfe298542ab63a7f0fac65118abb9011ee4af5fc48f7ddf66886b8cb11ac2ae300a296d0facd3a033e20e06daf23462b667bc9017d3dbea51
7
+ data.tar.gz: 87c9adeafec6dd55dcb8f3cc597e941afff6bab620d6e38ebcc4a4a9880bdfc8200c9fd926c36f67badfa1520dd61f416f86563a68fc164217e19c218105729e
data/CHANGELOG.md ADDED
@@ -0,0 +1,26 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ - Interfaces and unions: an interface field with no rule is denied when an implementing type is a bypass, and the audit
6
+ reports it; `include Base` in an interface or union is refused with a pointer to the possible types.
7
+
8
+ - Mutations and resolvers: `authorize :action, model:, on_denied:` on a mutation or resolver that includes `Base`. Denied by
9
+ default when it declares no rule; the rule runs on the loaded records; `on_denied: :errors` answers as data;
10
+ `covers_fields: true` lets a payload type be covered by its mutation. The audit lists mutations with no rule.
11
+
12
+ - Argument rules: `authorize: :action`, `visible_when:` and `authorize: :none, reason:` on arguments. An argument with no rule
13
+ is covered by its field; a declared rule is checked when the client sends it and a denial denies the field.
14
+
15
+ - Visibility driven by the policy: `authorize :read, visible_when: :export` on a type and `visible_when:` on a field hide
16
+ them from introspection and error suggestions. A schema dump needs `context: { armory_full_schema: true }`.
17
+
18
+ - CanCanCan adapter (`adapter: :cancan`): `can?` with the field attribute, `accessible_by` for relations, and
19
+ `MissingScopeError` for block-only rules unless the type declares `authorize :read, scope: :in_memory`.
20
+
21
+ - Black-box security suite (`spec/security`), runnable example (`examples/invoices.rb`), benchmark,
22
+ README and MIGRATING guide.
23
+ - Execution::Next is supported from graphql 2.6.2: in 2.5.26 to 2.6.1 a denied field crashes the engine.
24
+ The integration refuses to run there.
25
+ - Gem skeleton: gemspec, Docker test image, RSpec with an execution-engine switch
26
+ (`GRAPHQL_ARMORY_ENGINE=interpreter|next`) and a CI matrix.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Israel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/MIGRATING.md ADDED
@@ -0,0 +1,85 @@
1
+ # Migrating from manual `authorized?`
2
+
3
+ The usual hand-written setup has three weak spots: every type needs its own `authorized?`, a forgotten type is open, and lookups by ID skip the check. This guide moves them to graphql-armory one step at a time. Each step leaves the app working.
4
+
5
+ ## 1. Install the plugin and the bases
6
+
7
+ ```ruby
8
+ class AppSchema < GraphQL::Schema
9
+ use GraphQL::Armory::Authorization, adapter: :pundit
10
+ end
11
+ ```
12
+
13
+ Make your base classes include `GraphQL::Armory::Authorization::Base` (see the README quick start). Types that inherit from them are now covered; run the audit to see the rest.
14
+
15
+ ## 2. Replace `authorized?` with a rule
16
+
17
+ Before:
18
+
19
+ ```ruby
20
+ class InvoiceType < BaseObject
21
+ def self.authorized?(object, context)
22
+ super && InvoicePolicy.new(context[:current_user], object).read?
23
+ end
24
+ end
25
+ ```
26
+
27
+ After: delete the method. `default_action :read` on the base (or `authorize :read` on the type) asks the same `InvoicePolicy#read?`.
28
+
29
+ ## 3. Replace `scope_items` and list resolvers
30
+
31
+ Before:
32
+
33
+ ```ruby
34
+ def self.scope_items(items, context)
35
+ InvoicePolicy::Scope.new(context[:current_user], items).resolve
36
+ end
37
+ ```
38
+
39
+ After: delete it. Lists of the type go through `InvoicePolicy::Scope` automatically, before pagination.
40
+
41
+ ## 4. Replace lookups by ID
42
+
43
+ Before:
44
+
45
+ ```ruby
46
+ field :invoice, InvoiceType do
47
+ argument :id, ID
48
+ end
49
+ def invoice(id:) = Invoice.find(id) # IDOR: any ID works
50
+ ```
51
+
52
+ After:
53
+
54
+ ```ruby
55
+ field :invoice, InvoiceType, authorize: :none, reason: "looked up through the scope", find_by: :id do
56
+ argument :id, ID
57
+ end
58
+ ```
59
+
60
+ For `loads:` arguments nothing changes in your code: they now resolve inside the scope. Another tenant's ID answers like a missing record.
61
+
62
+ ## 5. Declare field-level rules
63
+
64
+ ```ruby
65
+ field :internal_notes, String, authorize: :manage
66
+ ```
67
+
68
+ A field with no rule on a bypassed type is denied until it declares one.
69
+
70
+ ## 6. Make CI watch
71
+
72
+ ```ruby
73
+ expect(AppSchema).to be_fully_authorized
74
+ ```
75
+
76
+ ## Check your migration
77
+
78
+ - The audit reports no problems, and every bypass has a reason you can defend.
79
+ - A test per sensitive type: user A asks for user B's record by root field, association and `loads:`; the answer matches a missing record.
80
+ - Policies return `true`/`false`/`nil` only.
81
+
82
+ ## Known differences
83
+
84
+ - Arguments, mutations, interfaces and unions are not covered yet; keep your manual checks there.
85
+ - Policies without a `Scope` class are flagged by the audit for every type that uses them.
data/README.md ADDED
@@ -0,0 +1,311 @@
1
+ <p align="center">
2
+ <img src="assets/logo.jpeg" alt="graphql-armory" width="560">
3
+ </p>
4
+
5
+ # graphql-armory
6
+
7
+ Open-source extensions for [graphql-ruby](https://github.com/rmosolgo/graphql-ruby), built on its public extension points: no fork, no monkey patching.
8
+
9
+ The first module is **authorization**. It plugs [Pundit](https://github.com/varvet/pundit) policies into graphql-ruby so that:
10
+
11
+ - **Nothing is open by default.** A type or field no rule covers answers `null` (or an error), never data.
12
+ - **Lists only contain what the user may see**, including connections: the scope runs before pagination and counting.
13
+ - **Lookups by ID cannot leak** (IDOR). A record you may not read and a record that does not exist look identical.
14
+ - **An audit tells you what you forgot**, from a rake task or an RSpec matcher, so CI fails instead of production leaking.
15
+
16
+ > **Status: early development (0.1, unreleased).** Pundit and CanCanCan adapters. Not published yet.
17
+
18
+ ## Install
19
+
20
+ ```ruby
21
+ gem "graphql-armory"
22
+ gem "pundit" # or "cancancan", whichever you use
23
+ ```
24
+
25
+ Requires Ruby >= 3.1 and graphql >= 2.4. `GraphQL::Execution::Next` is supported from graphql 2.6.2 (see [Engines](#engines)).
26
+
27
+ ## Quick start
28
+
29
+ Your policies stay plain Pundit. The only new thing is where the rules are declared:
30
+
31
+ ```ruby
32
+ class BaseField < GraphQL::Schema::Field
33
+ include GraphQL::Armory::Authorization::Base
34
+ end
35
+
36
+ class BaseObject < GraphQL::Schema::Object
37
+ include GraphQL::Armory::Authorization::Base
38
+ field_class BaseField
39
+ default_action :read # every type is checked with InvoicePolicy#read?, AccountPolicy#read?...
40
+ end
41
+
42
+ class InvoiceType < BaseObject
43
+ field :number, String, null: false
44
+ field :internal_notes, String, authorize: :manage # this field asks InvoicePolicy#manage?
45
+ end
46
+
47
+ class QueryType < BaseObject
48
+ authorize :none, reason: "root type; each field declares its own rule"
49
+ field :invoices, [InvoiceType], null: false, authorize: :none, reason: "narrowed by InvoicePolicy::Scope"
50
+ end
51
+
52
+ class AppSchema < GraphQL::Schema
53
+ query QueryType
54
+ use GraphQL::Armory::Authorization, adapter: :pundit
55
+ end
56
+ ```
57
+
58
+ The actor is `context[:current_user]`. A complete, tested version lives in [`examples/invoices.rb`](examples/invoices.rb).
59
+
60
+ ### Rules
61
+
62
+ | Declaration | Meaning |
63
+ |---|---|
64
+ | `default_action :read` | Default rule for every type below this base class. |
65
+ | `authorize :manage` | This type is checked with `manage?` instead. |
66
+ | `authorize :read, model: Invoice` | The model when it is not named like the type. |
67
+ | `field ..., authorize: :manage` | The field asks another question of the same policy. |
68
+ | `authorize :none, reason: "..."` | Explicit bypass. A reason is required and appears in the audit. |
69
+
70
+ A bypass does not propagate: a field of a bypassed type still needs its own rule, or it is denied.
71
+
72
+ ### What happens on denial
73
+
74
+ ```ruby
75
+ use GraphQL::Armory::Authorization, adapter: :pundit, on_unauthorized: :null # default
76
+ ```
77
+
78
+ | Mode | Client sees | Use |
79
+ |---|---|---|
80
+ | `:null` | `null`, no error | Default. Reveals nothing. |
81
+ | `:error` | `"Not found"`, code `NOT_FOUND`, identical on every path | When clients need to tell. |
82
+ | `:raise` | `NotAuthorizedError` naming only the type | When your app rescues it. |
83
+
84
+ Denied non-null fields raise the generic "Not found" error, because GraphQL cannot return `null` there (on Execution::Next a denied object in a non-null position answers graphql-ruby's own "Cannot return null for non-nullable field", and `:raise` messages are prefixed with the field path). `find_by:` lookups always answer `null` in every mode: a missing ID cannot raise, and a foreign ID must look the same.
85
+
86
+ ### Lists, connections and lookups
87
+
88
+ 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.
89
+
90
+ ```ruby
91
+ field :invoice, InvoiceType, authorize: :none, reason: "looked up through the scope", find_by: :id do
92
+ argument :id, ID
93
+ end
94
+
95
+ field :invoice_via_ref, InvoiceType, authorize: :none, reason: "loaded through the scope" do
96
+ argument :invoice_id, ID, loads: InvoiceType
97
+ end
98
+ ```
99
+
100
+ `find_by:` and `loads:` look the record up **inside the user's scope**, so another tenant's ID answers exactly like a missing one. Opt out of the scope for a `loads:` only on purpose, with `load_through_scope: false`; the audit lists it.
101
+
102
+ ## The audit
103
+
104
+ ```ruby
105
+ GraphQL::Armory::Authorization::Audit.(AppSchema) # => report with problems
106
+ ```
107
+
108
+ ```ruby
109
+ # spec/schema_spec.rb
110
+ require "graphql/armory/authorization/rspec"
111
+ expect(AppSchema).to be_fully_authorized
112
+ ```
113
+
114
+ ```ruby
115
+ # Rakefile (or a Rails task file): exits 1 when anything is uncovered
116
+ load "tasks/graphql_armory.rake"
117
+ # bundle exec rake "graphql_armory:audit[AppSchema]"
118
+ ```
119
+
120
+ It reports types nobody protects (a base class that never included `Base`), types and fields with no rule, policies missing the method or `Scope`, `loads:` that skip the scope, and lists every declared bypass with its reason.
121
+
122
+ ## CanCanCan
123
+
124
+ Same schema, different adapter. Your types and fields do not change:
125
+
126
+ ```ruby
127
+ use GraphQL::Armory::Authorization, adapter: :cancan
128
+ ```
129
+
130
+ 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.
131
+
132
+ | Question | CanCanCan call |
133
+ |---|---|
134
+ | Can this object be seen? | `ability.can?(:read, object)` |
135
+ | Can this field be seen? | `ability.can?(:manage, object, :internal_notes)` for a field that declares `authorize: :manage`: the attribute is passed, because `can :read, User, [:username]` makes `can?(:read, user)` true for the whole object |
136
+ | Which items of a list? | `Model.accessible_by(ability, :read)`, still a paginable relation; Arrays are filtered with `can?` |
137
+
138
+ **Block-only rules.** `can(:read, Invoice) { |i| ... }` cannot be turned into SQL, so `accessible_by` fails. The adapter raises `MissingScopeError` instead of guessing. To accept the cost, declare it on the type: `authorize :read, scope: :in_memory`. Lists are then filtered with `can?` one by one and efficient pagination is lost; `find_by:` and `loads:` find the record and then ask `can?`. Fields covered by the type's rule are not asked about their attribute: give the ones an attribute rule must hide their own `authorize: :action`. Options the adapter does not understand (`policy:`, another `scope:`) raise `ConfigurationError`.
139
+
140
+ **The audit covers less.** It still reports missing rules, bypasses and unscoped `loads:`, but not whether the `Ability` grants the action: abilities depend on the user.
141
+
142
+ **Guests.** `Ability.new(nil)` is your ability class's decision. Make it deny everything if there is no user.
143
+
144
+ ## Visibility
145
+
146
+ Hide a type or a field from users who may not use it, in introspection and in "did you mean" suggestions:
147
+
148
+ ```ruby
149
+ class InvoiceExportType < BaseObject
150
+ authorize :read, model: Invoice, visible_when: :export # InvoicePolicy#export? / can?(:export, Invoice)
151
+ end
152
+
153
+ field :internal_notes, String, authorize: :manage, visible_when: :export
154
+ ```
155
+
156
+ `visible_when:` is an action like any other, but it is asked **of the model's class, with no record**: `InvoicePolicy.new(user, Invoice).export?` with Pundit, `can?(:export, Invoice)` with CanCanCan. Write a method that decides from the user (`user.admin?`), not from the record. Nothing is hidden unless you declare it, and the audit reports a `visible_when:` action the policy does not define.
157
+
158
+ A hidden member also hides what leads only to it: a root field that returns a hidden type disappears with it.
159
+
160
+ **Schema dumps.** graphql 2.6 builds an ordinary context for `to_definition`, which cannot be told apart from an anonymous request, and an anonymous request must never see hidden members. Ask for the full schema explicitly:
161
+
162
+ ```ruby
163
+ AppSchema.to_definition(context: { armory_full_schema: true })
164
+ ```
165
+
166
+ Things to know:
167
+
168
+ - **Hiding is not authorization.** A hidden field is absent from the schema, so a query for it fails like a field that never existed. What the user may *read* is still decided by `authorize`.
169
+ - **The policy receives the class.** A policy that reads the record (`record.account_id`) raises `NoMethodError` for a `visible_when:` action; write that action from the user. The error propagates out of `execute`, so make sure your error handler does not echo it.
170
+ - **CanCanCan conditions are not applied to a class.** `can :export, Invoice, account_id: 1` makes the member visible to anyone who holds the rule. For a field, the attribute is passed: `can?(:export, Invoice, :internal_notes)`.
171
+ - **A subclass that redeclares `authorize` replaces the rule**, `visible_when:` included: repeat it.
172
+ - **`visible_when:` on a field needs a type that includes `Base`** (not an interface or a plain type); otherwise it raises `ConfigurationError` when a query selects it.
173
+ - **Static `Schema::Visibility` profiles are not supported** and raise `ConfigurationError`; plain `use Schema::Visibility`, `dynamic: true` and the default warden work.
174
+
175
+ Visibility is computed per query (and memoized inside it). Role-based profile caching across requests is graphql-ruby's `Schema::Visibility` feature and is not wrapped here.
176
+
177
+ ## Arguments
178
+
179
+ An argument with no rule is **covered by its field**: the field already decides whether the user may call it. Declare a rule on the arguments that must be stricter, and they are checked when the client sends them:
180
+
181
+ ```ruby
182
+ class BaseArgument < GraphQL::Schema::Argument
183
+ include GraphQL::Armory::Authorization::Base
184
+ end
185
+ # in your base field: argument_class BaseArgument
186
+
187
+ field :invoices, [InvoiceType], authorize: :none, reason: "narrowed by the scope" do
188
+ argument :include_internal, Boolean, required: false, authorize: :manage, model: Invoice
189
+ argument :tag, String, required: false, visible_when: :export, model: Invoice
190
+ argument :status, String, required: false, authorize: :none, reason: "public filter"
191
+ end
192
+ ```
193
+
194
+ - **`authorize: :manage`** asks the policy about the parent object when it is an instance of the model, otherwise about the model's class (a root field has no record, so write the action from the user). The model is the one the field's type wraps, or `model:` when it wraps none, as on a root field. A denial denies the whole field, with the usual null or error.
195
+ - **Defaults.** graphql-ruby does not check an argument the client left at its default, and the two engines disagree about it (the interpreter skips the check, `Execution::Next` applies the default), so a restricted argument with a `default_value:` is refused with an `ArgumentError`. Sending `false` explicitly counts as using the argument.
196
+ - **Every argument class needs `Base`.** `argument_class` is not inherited: set it on your base field, and also on resolvers, input objects (for restricted arguments nested in a filter) and directives that need it.
197
+ - **`visible_when:`** hides the argument like it hides a field: it is absent from introspection and from suggestions, and a query that uses it fails like an argument that never existed.
198
+ - **`authorize: :none, reason:`** only declares and documents: the audit lists it with its reason.
199
+ - `authorize: true` is refused on an argument, and an argument with no model to ask about raises `ConfigurationError`.
200
+
201
+ ## Mutations and resolvers
202
+
203
+ Include `Base` in your base mutation (or resolver) and declare what each one needs. A mutation is an action, not a read, so `default_action` does not apply and **a mutation with no `authorize` is denied**.
204
+
205
+ ```ruby
206
+ class Mutations::BaseMutation < GraphQL::Schema::RelayClassicMutation
207
+ include GraphQL::Armory::Authorization::Base
208
+ argument_class Types::BaseArgument
209
+ field_class Types::BaseField
210
+ object_class Types::BaseObject
211
+ end
212
+
213
+ class Mutations::ApproveInvoice < Mutations::BaseMutation
214
+ authorize :approve, model: Invoice # InvoicePolicy#approve?(invoice) / can?(:approve, invoice)
215
+ argument :invoice_id, ID, loads: Types::Invoice
216
+ field :invoice, Types::Invoice, null: true
217
+ end
218
+ ```
219
+
220
+ - **The record is the loaded one.** The rule runs after `loads:`, once per loaded record of the model (with none loaded, the model's class is asked). `loads:` goes through the user's scope, so a foreign id answers like a missing one.
221
+ - **Denial** follows the schema's `on_unauthorized` mode, like a field (`null`, `"Not found"`, or raise). With `on_denied: :errors` the mutation does not run and answers `{ errors: [{ message: "Not allowed", code: "FORBIDDEN" }] }` as data, so the payload needs an `errors` field.
222
+ - **Payload types** are ordinary objects: declare `authorize :none, reason: "...", covers_fields: true` so their fields are covered by the mutation instead of denied.
223
+ - **`authorize :none, reason:`** documents a mutation anyone may call; the audit lists it with its reason. A mutation with no rule appears as `:no_rule` (for example `Mutation.forgotten`), and a rule the policy does not define as `:adapter`.
224
+ - An unknown `on_denied:` raises `ArgumentError`; a mutation with a rule but no model raises `ConfigurationError`.
225
+ - **Class-level answers are weak.** With no loaded record of the model (no `loads:`, an empty `loads: [ID]`, or a `loads:` of another type) the rule is asked about the model's class: CanCan answers true for anyone holding the right, and a Pundit policy that reads `record` attributes raises. Load the record you are authorizing.
226
+ - **`ready?` runs before authorization** (and, on the interpreter, before a failing `loads:`). Keep it free of side effects.
227
+ - **`loads:` works on top-level arguments only.** Inside an input object it is not scoped, and the engines disagree (the interpreter raises, `Execution::Next` yields nil).
228
+ - **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.
229
+
230
+ ## Interfaces and unions
231
+
232
+ They are never authorized themselves: graphql-ruby only runs `authorized?` on the concrete type, and every path ends there (root field, association, each list item, `loads:`, union and interface resolution). A foreign object in a list of a union or an interface comes back `null`, like in any other list. So `include Base` in an interface or a union is refused with a message pointing to the possible types; declare the rule on each concrete type.
233
+
234
+ What an interface adds is its fields, declared once and resolved on many types:
235
+
236
+ - A field with no rule is **covered** by the rule of the Base types that implement the interface (a plain implementer runs no check at all), unless one of them is a bypass (`authorize :none`). Then the field is **denied for every implementer**, because it cannot tell which type it runs on, and the audit reports it (`:field_without_rule`, for example `Document.number`). Declare it (`authorize: :none, reason:` or an action) to fix it.
237
+ - `visible_when:` is not supported on an interface field; put it on the concrete types' fields.
238
+ - `authorize: true` is not valid on an interface field: it is always denied, and the audit does not flag it. Use an action or `:none`.
239
+ - An interface whose field class does not include `Base`, such as graphql-ruby's built-in Relay `Node`, runs no field check, so its `id` stays readable on a bypassed implementer. Give your own `Node` interface a Base field class if that matters.
240
+
241
+ ## Telemetry
242
+
243
+ Pass `sinks: [->(signal, data) {}]`. Signals are `:allowed`, `:denied`, `:bypassed`; data holds only `type`, `action` and `reason`, never record attributes.
244
+
245
+ ## Guarantees and limits
246
+
247
+ Covered by the black-box suite in `spec/security`, on both engines: user A cannot read user B's objects by root field, nested association or `loads:`; lists never contain foreign objects; uncovered types fail closed and show in the audit; a field hidden with `visible?` is absent from introspection and error suggestions (that is graphql-ruby's behaviour, pinned by a test).
248
+
249
+ Not covered yet: decoding global IDs in `loads:`. Policies must return `true`/`false`/`nil`; anything else raises `AdapterError`. Do not use `dataloader.request` inside policies; use `dataloader.with(...).load`.
250
+
251
+ ## Engines
252
+
253
+ Both execution engines are tested: the default interpreter and `Execution::Next`. In graphql 2.5.26 to 2.6.1, Next crashes on denied fields, so the gem refuses to run on Next there (`ConfigurationError`). Everything else works from graphql 2.4.
254
+
255
+ ## Performance
256
+
257
+ `benchmark/authorization.rb`, 1000 invoices, graphql 2.6.2, Ruby 3.2, Docker on Windows:
258
+
259
+ | Engine | Query | plain | armory (array) | armory (relation) |
260
+ |---|---|---|---|---|
261
+ | interpreter | `number` | 117.6 i/s | 45.9 i/s | 44.2 i/s |
262
+ | interpreter | 3 fields | 58.8 i/s | 21.6 i/s | 21.0 i/s |
263
+ | next | `number` | 654.2 i/s | 71.8 i/s | 70.8 i/s |
264
+ | next | 3 fields | 417.5 i/s | 42.1 i/s | 39.5 i/s |
265
+
266
+ The absolute cost is about 13 to 15 ms per 1000 items on both engines; most of it is probably Pundit's policy lookup, which is not cached so code reloading cannot serve stale classes. Run it yourself: `docker compose run --rm test bundle exec ruby benchmark/authorization.rb` (add `GRAPHQL_ARMORY_ENGINE=next` for Next).
267
+
268
+ ## Migrating
269
+
270
+ Coming from hand-written `authorized?` and `scope_items`? See [MIGRATING.md](MIGRATING.md).
271
+
272
+ ## Development
273
+
274
+ Ruby is not required on the host:
275
+
276
+ ```
277
+ docker compose run --rm test # full suite, interpreter
278
+ GRAPHQL_ARMORY_ENGINE=next docker compose run --rm test # full suite, Execution::Next
279
+ GRAPHQL_VERSION="~> 2.4.0" docker compose run --rm test # another graphql version
280
+ ```
281
+
282
+ ## Roadmap
283
+
284
+ Status: ✅ done, 🚧 in progress, ⬜ not started. Update this table in the same PR that changes a module.
285
+
286
+ | Order | Module | Status | Current limits |
287
+ |---|---|---|---|
288
+ | 1 | Authorization (Pundit, CanCanCan) | 🚧 | Pundit and CanCanCan done (the audit checks less with CanCanCan). Not covered yet: decoding global IDs in `loads:`. `Execution::Next` needs graphql >= 2.6.2. Unreleased. |
289
+ | 2 | Encrypted, versioned IDs and cursors | ⬜ | Research not started. |
290
+ | 3 | Persisted queries / operation whitelisting | ⬜ | Research not started. |
291
+ | 4 | Rate limiting | ⬜ | Research not started. |
292
+ | 5 | Stable (keyset) connections | ⬜ | Research not started. |
293
+ | 6 | Subscriptions over Pusher / Ably | ⬜ | Research not started. |
294
+ | 7 | Response caching | ⬜ | Research not started. |
295
+ | 8 | API versioning (changesets) | ⬜ | Research not started. |
296
+ | 9 | `@defer` / `@stream` | ⬜ | Research not started. |
297
+ | 10 | Dashboard | ⬜ | Research not started. |
298
+
299
+ Each module starts with its own research before design. A module is ✅ only when it is released and its limits are documented here.
300
+
301
+ ## Related gems
302
+
303
+ - [graphql-armory-rust_parser](https://github.com/israelb/graphql-armory-rust_parser): a GraphQL parser written in Rust, installable as graphql-ruby's default parser.
304
+
305
+ ## Clean-room policy
306
+
307
+ This project is written independently. It uses only graphql-ruby's open-source code, its public guides, and the public documentation of Pundit and CanCanCan. It does not use, copy or derive from GraphQL-Pro or GraphQL-Enterprise code.
308
+
309
+ ## License
310
+
311
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cancancan"
4
+
5
+ module GraphQL
6
+ module Armory
7
+ module Authorization
8
+ module Adapters
9
+ # Answers the core's four questions with CanCanCan's public API.
10
+ #
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.
13
+ #
14
+ # A field that declares its own action (`authorize: :manage`) passes its attribute to `can?`: with
15
+ # `can :read, User, [:username]`, `can?(:read, user)`
16
+ # is true even though only one attribute is readable. Fields covered by their type's rule are not asked
17
+ # about their attribute: declare an action on the ones an attribute rule must hide.
18
+ class CanCan
19
+ def initialize(ability_class: nil)
20
+ @ability_class = ability_class
21
+ end
22
+
23
+ def actor(context)
24
+ context[:current_ability] || ability_for(context)
25
+ end
26
+
27
+ def authorized?(ability, subject, rule)
28
+ options!(rule)
29
+ return ability.can?(rule.action, subject, rule.attribute) if rule.attribute
30
+
31
+ ability.can?(rule.action, subject)
32
+ end
33
+
34
+ # A relation is narrowed in SQL with `accessible_by`, so it stays paginable. A rule defined only
35
+ # with a block cannot be turned into SQL: it fails, unless the type declared `scope: :in_memory`,
36
+ # which filters with `can?` and gives up efficient pagination.
37
+ def scope(ability, items, rule)
38
+ options!(rule)
39
+ return in_memory(ability, items, rule) if items.is_a?(Array) || rule.scope == :in_memory
40
+
41
+ accessible(ability, items, rule)
42
+ end
43
+
44
+ # Abilities depend on the user, so only what the declaration itself gets wrong can be reported.
45
+ def check(_type, rule)
46
+ return [] if rule.none?
47
+
48
+ problems(rule)
49
+ end
50
+
51
+ private
52
+
53
+ # What this adapter does not understand is refused, never ignored.
54
+ def problems(rule)
55
+ found = []
56
+ found << "policy: is a Pundit option; CanCanCan has no policy classes" if rule.policy
57
+ found << "scope: #{rule.scope.inspect} is not supported; use :in_memory" unless [nil, :in_memory].include?(rule.scope)
58
+ found
59
+ end
60
+
61
+ def options!(rule)
62
+ found = problems(rule)
63
+ raise ConfigurationError, found.join("; ") unless found.empty?
64
+ end
65
+
66
+ def accessible(ability, items, rule)
67
+ items.accessible_by(ability, rule.action)
68
+ rescue ::CanCan::Error
69
+ raise MissingScopeError,
70
+ "a rule defined with a block cannot be turned into SQL: add an SQL or scope argument " \
71
+ "to the rule, or declare `scope: :in_memory` on the type"
72
+ end
73
+
74
+ def in_memory(ability, items, rule)
75
+ items.select { |item| authorized?(ability, item, rule) == true }
76
+ end
77
+
78
+ def ability_for(context)
79
+ store = context.namespace(:armory)
80
+ store[:cancan_ability] ||= ability_class.new(context[:current_user])
81
+ end
82
+
83
+ def ability_class
84
+ @ability_class || (Object.const_get(:Ability) if Object.const_defined?(:Ability)) ||
85
+ raise(ConfigurationError, "no Ability class found: define ::Ability, pass `ability_class:` " \
86
+ "to the adapter or put an ability in context[:current_ability]")
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Armory
5
+ module Authorization
6
+ module Adapters
7
+ # The adapter of an object nobody configured: it denies everything.
8
+ class Null
9
+ def actor(_context)
10
+ nil
11
+ end
12
+
13
+ def authorized?(_actor, _subject, _rule)
14
+ false
15
+ end
16
+
17
+ def scope(_actor, items, _rule)
18
+ items.respond_to?(:none) ? items.none : []
19
+ end
20
+
21
+ def check(_type, _rule)
22
+ ["no adapter is configured: everything is denied"]
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pundit"
4
+
5
+ module GraphQL
6
+ module Armory
7
+ module Authorization
8
+ module Adapters
9
+ # Answers the core's four questions with Pundit's public API.
10
+ #
11
+ # The policy is found from the model through Pundit::PolicyFinder, so `policy_class`
12
+ # overrides and namespaced models work. A policy must answer `true`, `false` or `nil`:
13
+ # anything else (a lazy, a record) is handed back as is and the core refuses it.
14
+ class Pundit
15
+ def actor(context)
16
+ context[:current_user]
17
+ end
18
+
19
+ def authorized?(user, subject, rule)
20
+ policy = policy_class(rule, subject).new(user, subject)
21
+ query = query_for(rule)
22
+ unless policy.respond_to?(query)
23
+ raise AdapterError, "#{policy.class} does not define #{query}"
24
+ end
25
+
26
+ policy.public_send(query)
27
+ end
28
+
29
+ def scope(user, items, rule)
30
+ return items.select { |item| authorized?(user, item, rule) == true } if items.is_a?(Array)
31
+
32
+ policy = policy_class(rule, rule.model || items)
33
+ scope_class = scope_constant(policy)
34
+ raise MissingScopeError, "#{policy} has no Scope class" if scope_class.nil?
35
+
36
+ scope_class.new(user, items).resolve
37
+ end
38
+
39
+ def check(type, rule)
40
+ return [] if rule.none?
41
+
42
+ model = normalize(rule.model || type)
43
+ policy = rule.policy || finder(model).policy
44
+ return ["#{model.is_a?(Symbol) ? model.to_s.camelize : model}Policy not found"] if policy.nil?
45
+
46
+ problems = []
47
+ query = query_for(rule)
48
+ problems << "#{policy}##{query} is not defined" unless policy.public_method_defined?(query)
49
+ problems << "#{policy}::Scope is not defined" if scope_constant(policy).nil?
50
+ problems
51
+ end
52
+
53
+ private
54
+
55
+ def policy_class(rule, subject)
56
+ rule.policy || finder(subject).policy!
57
+ end
58
+
59
+ def query_for(rule)
60
+ :"#{rule.action}?"
61
+ end
62
+
63
+ def finder(subject)
64
+ ::Pundit::PolicyFinder.new(normalize(subject))
65
+ end
66
+
67
+ # Pundit reads a Symbol as a model name but a String as a plain object.
68
+ def normalize(subject)
69
+ subject.is_a?(String) ? subject.to_sym : subject
70
+ end
71
+
72
+ # Looks the Scope up through the policy's ancestors but never in Object, so a
73
+ # top-level constant called Scope cannot be mistaken for a policy scope.
74
+ def scope_constant(policy)
75
+ owner = policy.ancestors.take_while { |ancestor| ancestor != Object }
76
+ .find { |ancestor| ancestor.const_defined?(:Scope, false) }
77
+ owner&.const_get(:Scope, false)
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end