graphql-armory 0.1.0.pre2 → 0.1.0.pre3
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 +10 -0
- data/MIGRATING.md +1 -1
- data/README.md +21 -274
- data/docs/authorization.md +264 -0
- data/docs/encrypted-ids.md +136 -0
- data/lib/graphql/armory/authorization/integration.rb +3 -2
- data/lib/graphql/armory/encoder/default.rb +59 -0
- data/lib/graphql/armory/encoder/legacy.rb +34 -0
- data/lib/graphql/armory/encoder/versioned.rb +67 -0
- data/lib/graphql/armory/encoder.rb +137 -0
- data/lib/graphql/armory/ids.rb +76 -0
- data/lib/graphql/armory/version.rb +1 -1
- data/lib/graphql/armory.rb +2 -0
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3eacfaa8caea3d5963df1d9a12f30efd9b9dc5232bae15b73475fef3f519ad85
|
|
4
|
+
data.tar.gz: 1176b251773ecf02e4a71cacabe1b4ef4c3f4345ca9d88efdf2ba103be4bf84a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0d8e40044eef04927178f8a75bd0394b833686f6ee4e2a04c5734b1b84fa7644d66c683b8456d1b1d2dbc916c3c3714f15a060a6757f04af2632d8e40b7dd469
|
|
7
|
+
data.tar.gz: 548f98bc7e3b373f49172d29dfe89a20ea3cf9a1f70eeba4ee9161e38ce429e46c30c27cfdb4afd1d591d221d10f34d56eaa41f1cffca514b62d7821dd8ce88f
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.1.0.pre3 (2026-10-29)
|
|
6
|
+
|
|
7
|
+
Encrypted, versioned IDs and cursors (roadmap module 2), and a docs page per module.
|
|
8
|
+
|
|
9
|
+
- Encrypted IDs and cursors, part 4: `use GraphQL::Armory::IDs` defines `id_from_object` and `object_from_id` with encrypted IDs (default encoder unless `encoder:`); Base types are looked up inside the user's scope, other types through `find:`; every ID it cannot honour answers `null`.
|
|
10
|
+
- Encrypted IDs and cursors, part 3: `Encoder.default`, keyed by `GRAPHQL_ARMORY_KEY` or, in Rails, by a key derived with HKDF from `secret_key_base` (not from `Rails.application.key_generator`, whose digest changes during boot); it raises when the schema loads without either.
|
|
11
|
+
- Encrypted IDs and cursors, part 2: `Encoder.versioned(newest, ..., oldest)` and `decode_versioned` to rotate keys without breaking clients; `Encoder::Legacy` reads graphql-ruby's default base64 to add encryption to an existing API.
|
|
12
|
+
- Encrypted IDs and cursors, part 1: `GraphQL::Armory::Encoder` (AES-256-GCM, HKDF subkeys, stable IDs, random-nonce cursors, `key`, `tag`, `codec`). Works as `cursor_encoder` and inside `object_from_id`; an ID it refuses in `loads:` is answered like a missing record. See `docs/encrypted-ids.md`.
|
|
13
|
+
- Docs: each module has its own page in `docs/` (the problem, the solution, how to use it, guarantees and limits). The authorization guide moved from the README to `docs/authorization.md`; the README now lists the modules.
|
|
14
|
+
|
|
5
15
|
## 0.1.0.pre2 (2026-10-29)
|
|
6
16
|
|
|
7
17
|
Closes the known gaps of the authorization module.
|
data/MIGRATING.md
CHANGED
|
@@ -10,7 +10,7 @@ class AppSchema < GraphQL::Schema
|
|
|
10
10
|
end
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
Make your base classes include `GraphQL::Armory::Authorization::Base` (see
|
|
13
|
+
Make your base classes include `GraphQL::Armory::Authorization::Base` (see "How to use it" in [docs/authorization.md](docs/authorization.md)). Types that inherit from them are now covered; run the audit to see the rest.
|
|
14
14
|
|
|
15
15
|
## 2. Replace `authorized?` with a rule
|
|
16
16
|
|
data/README.md
CHANGED
|
@@ -4,273 +4,39 @@
|
|
|
4
4
|
|
|
5
5
|
# graphql-armory
|
|
6
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.
|
|
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
|
-
|
|
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.
|
|
9
|
+
> **Status: early development.** Prereleases on RubyGems (`0.1.0.pre3`). The API may change before 1.0.
|
|
17
10
|
|
|
18
11
|
## Install
|
|
19
12
|
|
|
20
13
|
```ruby
|
|
21
|
-
gem "graphql-armory", "~> 0.1.0.
|
|
22
|
-
gem "pundit" # or "cancancan",
|
|
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:` (also inside input objects whose base class includes `Base`) 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 field's `loads:` only on purpose, with `load_through_scope: false`; the audit lists it. On `Execution::Next` the opt-out also reaches `loads:` nested in that field's input objects (the interpreter keeps those scoped), and the audit lists them too (`Query.field(filter.invoice_id)`). An input object that overrides `object_from_id` or defines `load_<argument>` itself is honoured on the interpreter and not seen by the audit.
|
|
101
|
-
|
|
102
|
-
**Global IDs.** When your schema defines `object_from_id` (Relay global IDs), `loads:` decodes the id with it, refuses a record of another model, and looks the record up again inside the scope (one extra query per load). Without `object_from_id`, the id is the primary key. `object_from_id` must return the record itself, not a lazy value: a lazy value is denied. A decoder that raises `ActiveRecord::RecordNotFound` (like `GlobalID.find`) or `ArgumentError` (a malformed id) is answered like a missing record; other errors propagate. `loads:` of an interface or union (for example `Types::Node`) raises `ConfigurationError`: load a concrete type. Composite primary keys are not supported.
|
|
103
|
-
|
|
104
|
-
## The audit
|
|
105
|
-
|
|
106
|
-
```ruby
|
|
107
|
-
GraphQL::Armory::Authorization::Audit.(AppSchema) # => report with problems
|
|
108
|
-
```
|
|
109
|
-
|
|
110
|
-
```ruby
|
|
111
|
-
# spec/schema_spec.rb
|
|
112
|
-
require "graphql/armory/authorization/rspec"
|
|
113
|
-
expect(AppSchema).to be_fully_authorized
|
|
114
|
-
```
|
|
115
|
-
|
|
116
|
-
```ruby
|
|
117
|
-
# Rakefile (or a Rails task file): exits 1 when anything is uncovered
|
|
118
|
-
load "tasks/graphql_armory.rake"
|
|
119
|
-
# bundle exec rake "graphql_armory:audit[AppSchema]"
|
|
120
|
-
```
|
|
121
|
-
|
|
122
|
-
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.
|
|
123
|
-
|
|
124
|
-
## CanCanCan
|
|
125
|
-
|
|
126
|
-
Same schema, different adapter. Your types and fields do not change:
|
|
127
|
-
|
|
128
|
-
```ruby
|
|
129
|
-
use GraphQL::Armory::Authorization, adapter: :cancan
|
|
14
|
+
gem "graphql-armory", "~> 0.1.0.pre3" # a prerelease: `gem install graphql-armory --pre`
|
|
15
|
+
gem "pundit" # or "cancancan", for the authorization module
|
|
130
16
|
```
|
|
131
17
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
| Question | CanCanCan call |
|
|
135
|
-
|---|---|
|
|
136
|
-
| Can this object be seen? | `ability.can?(:read, object)` |
|
|
137
|
-
| 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 |
|
|
138
|
-
| Which items of a list? | `Model.accessible_by(ability, :read)`, still a paginable relation; Arrays are filtered with `can?` |
|
|
139
|
-
|
|
140
|
-
**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`.
|
|
18
|
+
Requires Ruby >= 3.1 and graphql >= 2.4.
|
|
141
19
|
|
|
142
|
-
|
|
20
|
+
## Modules
|
|
143
21
|
|
|
144
|
-
|
|
22
|
+
Status: ✅ released, 🚧 in progress, ⬜ not started. Update this table in the same PR that changes a module.
|
|
145
23
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
Hide a type or a field from users who may not use it, in introspection and in "did you mean" suggestions:
|
|
149
|
-
|
|
150
|
-
```ruby
|
|
151
|
-
class InvoiceExportType < BaseObject
|
|
152
|
-
authorize :read, model: Invoice, visible_when: :export # InvoicePolicy#export? / can?(:export, Invoice)
|
|
153
|
-
end
|
|
154
|
-
|
|
155
|
-
field :internal_notes, String, authorize: :manage, visible_when: :export
|
|
156
|
-
```
|
|
157
|
-
|
|
158
|
-
`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.
|
|
159
|
-
|
|
160
|
-
A hidden member also hides what leads only to it: a root field that returns a hidden type disappears with it.
|
|
161
|
-
|
|
162
|
-
**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:
|
|
163
|
-
|
|
164
|
-
```ruby
|
|
165
|
-
AppSchema.to_definition(context: { armory_full_schema: true })
|
|
166
|
-
```
|
|
167
|
-
|
|
168
|
-
Things to know:
|
|
169
|
-
|
|
170
|
-
- **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`.
|
|
171
|
-
- **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.
|
|
172
|
-
- **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)`.
|
|
173
|
-
- **A subclass that redeclares `authorize` replaces the rule**, `visible_when:` included: repeat it.
|
|
174
|
-
- **`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.
|
|
175
|
-
- **Static `Schema::Visibility` profiles are not supported** and raise `ConfigurationError`; plain `use Schema::Visibility`, `dynamic: true` and the default warden work.
|
|
176
|
-
|
|
177
|
-
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.
|
|
178
|
-
|
|
179
|
-
## Arguments
|
|
180
|
-
|
|
181
|
-
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:
|
|
182
|
-
|
|
183
|
-
```ruby
|
|
184
|
-
class BaseArgument < GraphQL::Schema::Argument
|
|
185
|
-
include GraphQL::Armory::Authorization::Base
|
|
186
|
-
end
|
|
187
|
-
# in your base field: argument_class BaseArgument
|
|
188
|
-
|
|
189
|
-
field :invoices, [InvoiceType], authorize: :none, reason: "narrowed by the scope" do
|
|
190
|
-
argument :include_internal, Boolean, required: false, authorize: :manage, model: Invoice
|
|
191
|
-
argument :tag, String, required: false, visible_when: :export, model: Invoice
|
|
192
|
-
argument :status, String, required: false, authorize: :none, reason: "public filter"
|
|
193
|
-
end
|
|
194
|
-
```
|
|
195
|
-
|
|
196
|
-
- **`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.
|
|
197
|
-
- **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.
|
|
198
|
-
- **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.
|
|
199
|
-
- **`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.
|
|
200
|
-
- **`authorize: :none, reason:`** only declares and documents: the audit lists it with its reason.
|
|
201
|
-
- `authorize: true` is refused on an argument, and an argument with no model to ask about raises `ConfigurationError`.
|
|
202
|
-
|
|
203
|
-
## Mutations and resolvers
|
|
204
|
-
|
|
205
|
-
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**.
|
|
206
|
-
|
|
207
|
-
```ruby
|
|
208
|
-
class Mutations::BaseMutation < GraphQL::Schema::RelayClassicMutation
|
|
209
|
-
include GraphQL::Armory::Authorization::Base
|
|
210
|
-
argument_class Types::BaseArgument
|
|
211
|
-
field_class Types::BaseField
|
|
212
|
-
object_class Types::BaseObject
|
|
213
|
-
end
|
|
214
|
-
|
|
215
|
-
class Mutations::ApproveInvoice < Mutations::BaseMutation
|
|
216
|
-
authorize :approve, model: Invoice # InvoicePolicy#approve?(invoice) / can?(:approve, invoice)
|
|
217
|
-
argument :invoice_id, ID, loads: Types::Invoice
|
|
218
|
-
field :invoice, Types::Invoice, null: true
|
|
219
|
-
end
|
|
220
|
-
```
|
|
221
|
-
|
|
222
|
-
- **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.
|
|
223
|
-
- **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.
|
|
224
|
-
- **Payload types** are ordinary objects: declare `authorize :none, reason: "...", covers_fields: true` so their fields are covered by the mutation instead of denied.
|
|
225
|
-
- **`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`.
|
|
226
|
-
- An unknown `on_denied:` raises `ArgumentError`; a mutation with a rule but no model raises `ConfigurationError`.
|
|
227
|
-
- **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.
|
|
228
|
-
- **`ready?` runs before authorization** (and, on the interpreter, before a failing `loads:`). Keep it free of side effects.
|
|
229
|
-
- **`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`.
|
|
230
|
-
- **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.
|
|
231
|
-
|
|
232
|
-
## Interfaces and unions
|
|
233
|
-
|
|
234
|
-
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.
|
|
235
|
-
|
|
236
|
-
What an interface adds is its fields, declared once and resolved on many types:
|
|
237
|
-
|
|
238
|
-
- 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.
|
|
239
|
-
- `visible_when:` is not supported on an interface field; put it on the concrete types' fields.
|
|
240
|
-
- `authorize: true` is not valid on an interface field: it is always denied, and the audit reports it. Use an action or `:none`.
|
|
241
|
-
- 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. The audit reports it (`:unprotected`, `Node.id`); give your own `Node` interface a Base field class if that matters.
|
|
242
|
-
- The audit reports an interface field once, on the interface that declares it, not again on each implementer.
|
|
243
|
-
|
|
244
|
-
## Telemetry
|
|
245
|
-
|
|
246
|
-
Pass `sinks: [->(signal, data) {}]`. Signals are `:allowed`, `:denied`, `:bypassed`; data holds only `type`, `action` and `reason`, never record attributes.
|
|
247
|
-
|
|
248
|
-
## Guarantees and limits
|
|
249
|
-
|
|
250
|
-
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).
|
|
251
|
-
|
|
252
|
-
Policies must return `true`/`false`/`nil`; anything else raises `AdapterError`. Do not use `dataloader.request` inside policies; use `dataloader.with(...).load`.
|
|
253
|
-
|
|
254
|
-
## Engines
|
|
255
|
-
|
|
256
|
-
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.
|
|
257
|
-
|
|
258
|
-
## Performance
|
|
259
|
-
|
|
260
|
-
`benchmark/authorization.rb`, 1000 invoices, graphql 2.6.2, Ruby 3.2, Docker on Windows:
|
|
261
|
-
|
|
262
|
-
| Engine | Query | plain | armory (array) | armory (relation) |
|
|
24
|
+
| Order | Module | Status | Docs | Current limits |
|
|
263
25
|
|---|---|---|---|---|
|
|
264
|
-
|
|
|
265
|
-
|
|
|
266
|
-
|
|
|
267
|
-
|
|
|
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
|
+
| 2 | Encrypted, versioned IDs and cursors | ✅ | [docs/encrypted-ids.md](docs/encrypted-ids.md) | Released in 0.1.0.pre3 (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 whitelisting | ⬜ | | Research not started. |
|
|
29
|
+
| 4 | Rate limiting | ⬜ | | Research not started. |
|
|
30
|
+
| 5 | Stable (keyset) connections | ⬜ | | Research not started. |
|
|
31
|
+
| 6 | Subscriptions over Pusher / Ably | ⬜ | | Research not started. |
|
|
32
|
+
| 7 | Response caching | ⬜ | | Research not started. |
|
|
33
|
+
| 8 | API versioning (changesets) | ⬜ | | Research not started. |
|
|
34
|
+
| 9 | `@defer` / `@stream` | ⬜ | | Research not started. |
|
|
35
|
+
| 10 | Dashboard | ⬜ | | Research not started. |
|
|
268
36
|
|
|
269
|
-
|
|
37
|
+
Each module starts with its own research before design. A module is ✅ only when it is released and its page documents its limits.
|
|
270
38
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
Coming from hand-written `authorized?` and `scope_items`? See [MIGRATING.md](MIGRATING.md).
|
|
39
|
+
Every module page follows the same outline: **The problem**, **The solution**, **How to use it**, reference sections, **Guarantees and limits**.
|
|
274
40
|
|
|
275
41
|
## Development
|
|
276
42
|
|
|
@@ -287,30 +53,11 @@ GRAPHQL_VERSION="~> 2.4.0" docker compose run --rm test # another graphq
|
|
|
287
53
|
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:
|
|
288
54
|
|
|
289
55
|
```
|
|
290
|
-
git tag v0.1.0.
|
|
56
|
+
git tag v0.1.0.pre3 && git push origin v0.1.0.pre3
|
|
291
57
|
```
|
|
292
58
|
|
|
293
59
|
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).
|
|
294
60
|
|
|
295
|
-
## Roadmap
|
|
296
|
-
|
|
297
|
-
Status: ✅ done, 🚧 in progress, ⬜ not started. Update this table in the same PR that changes a module.
|
|
298
|
-
|
|
299
|
-
| Order | Module | Status | Current limits |
|
|
300
|
-
|---|---|---|---|
|
|
301
|
-
| 1 | Authorization (Pundit, CanCanCan) | ✅ | 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. |
|
|
302
|
-
| 2 | Encrypted, versioned IDs and cursors | ⬜ | Research not started. |
|
|
303
|
-
| 3 | Persisted queries / operation whitelisting | ⬜ | Research not started. |
|
|
304
|
-
| 4 | Rate limiting | ⬜ | Research not started. |
|
|
305
|
-
| 5 | Stable (keyset) connections | ⬜ | Research not started. |
|
|
306
|
-
| 6 | Subscriptions over Pusher / Ably | ⬜ | Research not started. |
|
|
307
|
-
| 7 | Response caching | ⬜ | Research not started. |
|
|
308
|
-
| 8 | API versioning (changesets) | ⬜ | Research not started. |
|
|
309
|
-
| 9 | `@defer` / `@stream` | ⬜ | Research not started. |
|
|
310
|
-
| 10 | Dashboard | ⬜ | Research not started. |
|
|
311
|
-
|
|
312
|
-
Each module starts with its own research before design. A module is ✅ only when it is released and its limits are documented here.
|
|
313
|
-
|
|
314
61
|
## Related gems
|
|
315
62
|
|
|
316
63
|
- [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.
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# Authorization
|
|
2
|
+
|
|
3
|
+
Pundit or CanCanCan policies for graphql-ruby, declared once on your types and enforced on every path.
|
|
4
|
+
|
|
5
|
+
## The problem
|
|
6
|
+
|
|
7
|
+
graphql-ruby leaves authorization to you. Each type needs an `authorized?` method, each list a `scope_items`, each `loads:` its own check, and nothing tells you which ones you forgot. One forgotten type is enough for user A to read user B's invoice by its ID (an IDOR), through a root field, a nested association, a list, or an argument that loads a record. And an error that says "not allowed" instead of "not found" tells an attacker the record exists.
|
|
8
|
+
|
|
9
|
+
## The solution
|
|
10
|
+
|
|
11
|
+
graphql-armory plugs your existing policies into graphql-ruby's public hooks:
|
|
12
|
+
|
|
13
|
+
- **Nothing is open by default.** A type or field no rule covers answers `null` (or an error), never data.
|
|
14
|
+
- **Lists only contain what the user may see**, including connections: the scope runs before pagination and counting.
|
|
15
|
+
- **Lookups by ID cannot leak** (IDOR). A record you may not read and a record that does not exist look identical.
|
|
16
|
+
- **An audit tells you what you forgot**, from a rake task or an RSpec matcher, so CI fails instead of production leaking.
|
|
17
|
+
|
|
18
|
+
## How to use it
|
|
19
|
+
|
|
20
|
+
Your policies stay plain Pundit. The only new thing is where the rules are declared:
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
class BaseField < GraphQL::Schema::Field
|
|
24
|
+
include GraphQL::Armory::Authorization::Base
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class BaseObject < GraphQL::Schema::Object
|
|
28
|
+
include GraphQL::Armory::Authorization::Base
|
|
29
|
+
field_class BaseField
|
|
30
|
+
default_action :read # every type is checked with InvoicePolicy#read?, AccountPolicy#read?...
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class InvoiceType < BaseObject
|
|
34
|
+
field :number, String, null: false
|
|
35
|
+
field :internal_notes, String, authorize: :manage # this field asks InvoicePolicy#manage?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class QueryType < BaseObject
|
|
39
|
+
authorize :none, reason: "root type; each field declares its own rule"
|
|
40
|
+
field :invoices, [InvoiceType], null: false, authorize: :none, reason: "narrowed by InvoicePolicy::Scope"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
class AppSchema < GraphQL::Schema
|
|
44
|
+
query QueryType
|
|
45
|
+
use GraphQL::Armory::Authorization, adapter: :pundit
|
|
46
|
+
end
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The actor is `context[:current_user]`. A complete, tested version lives in [`examples/invoices.rb`](../examples/invoices.rb).
|
|
50
|
+
|
|
51
|
+
## Rules
|
|
52
|
+
|
|
53
|
+
| Declaration | Meaning |
|
|
54
|
+
|---|---|
|
|
55
|
+
| `default_action :read` | Default rule for every type below this base class. |
|
|
56
|
+
| `authorize :manage` | This type is checked with `manage?` instead. |
|
|
57
|
+
| `authorize :read, model: Invoice` | The model when it is not named like the type. |
|
|
58
|
+
| `field ..., authorize: :manage` | The field asks another question of the same policy. |
|
|
59
|
+
| `authorize :none, reason: "..."` | Explicit bypass. A reason is required and appears in the audit. |
|
|
60
|
+
|
|
61
|
+
A bypass does not propagate: a field of a bypassed type still needs its own rule, or it is denied.
|
|
62
|
+
|
|
63
|
+
## What happens on denial
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
use GraphQL::Armory::Authorization, adapter: :pundit, on_unauthorized: :null # default
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
| Mode | Client sees | Use |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| `:null` | `null`, no error | Default. Reveals nothing. |
|
|
72
|
+
| `:error` | `"Not found"`, code `NOT_FOUND`, identical on every path | When clients need to tell. |
|
|
73
|
+
| `:raise` | `NotAuthorizedError` naming only the type | When your app rescues it. |
|
|
74
|
+
|
|
75
|
+
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.
|
|
76
|
+
|
|
77
|
+
## Lists, connections and lookups
|
|
78
|
+
|
|
79
|
+
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.
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
field :invoice, InvoiceType, authorize: :none, reason: "looked up through the scope", find_by: :id do
|
|
83
|
+
argument :id, ID
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
field :invoice_via_ref, InvoiceType, authorize: :none, reason: "loaded through the scope" do
|
|
87
|
+
argument :invoice_id, ID, loads: InvoiceType
|
|
88
|
+
end
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`find_by:` and `loads:` (also inside input objects whose base class includes `Base`) 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 field's `loads:` only on purpose, with `load_through_scope: false`; the audit lists it. On `Execution::Next` the opt-out also reaches `loads:` nested in that field's input objects (the interpreter keeps those scoped), and the audit lists them too (`Query.field(filter.invoice_id)`). An input object that overrides `object_from_id` or defines `load_<argument>` itself is honoured on the interpreter and not seen by the audit.
|
|
92
|
+
|
|
93
|
+
**Global IDs.** When your schema defines `object_from_id` (Relay global IDs), `loads:` decodes the id with it, refuses a record of another model, and looks the record up again inside the scope (one extra query per load). Without `object_from_id`, the id is the primary key. `object_from_id` must return the record itself, not a lazy value: a lazy value is denied. A decoder that raises `ActiveRecord::RecordNotFound` (like `GlobalID.find`), `ArgumentError` (a malformed id) or `GraphQL::Armory::Encoder::InvalidError` (see [encrypted IDs](encrypted-ids.md)) is answered like a missing record; other errors propagate. `loads:` of an interface or union (for example `Types::Node`) raises `ConfigurationError`: load a concrete type. Composite primary keys are not supported.
|
|
94
|
+
|
|
95
|
+
## The audit
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
GraphQL::Armory::Authorization::Audit.(AppSchema) # => report with problems
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
# spec/schema_spec.rb
|
|
103
|
+
require "graphql/armory/authorization/rspec"
|
|
104
|
+
expect(AppSchema).to be_fully_authorized
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
# Rakefile (or a Rails task file): exits 1 when anything is uncovered
|
|
109
|
+
load "tasks/graphql_armory.rake"
|
|
110
|
+
# bundle exec rake "graphql_armory:audit[AppSchema]"
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
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.
|
|
114
|
+
|
|
115
|
+
## CanCanCan
|
|
116
|
+
|
|
117
|
+
Same schema, different adapter. Your types and fields do not change:
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
use GraphQL::Armory::Authorization, adapter: :cancan
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
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.
|
|
124
|
+
|
|
125
|
+
| Question | CanCanCan call |
|
|
126
|
+
|---|---|
|
|
127
|
+
| Can this object be seen? | `ability.can?(:read, object)` |
|
|
128
|
+
| 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 |
|
|
129
|
+
| Which items of a list? | `Model.accessible_by(ability, :read)`, still a paginable relation; Arrays are filtered with `can?` |
|
|
130
|
+
|
|
131
|
+
**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`.
|
|
132
|
+
|
|
133
|
+
**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.
|
|
134
|
+
|
|
135
|
+
**Guests.** `Ability.new(nil)` is your ability class's decision. Make it deny everything if there is no user.
|
|
136
|
+
|
|
137
|
+
## Visibility
|
|
138
|
+
|
|
139
|
+
Hide a type or a field from users who may not use it, in introspection and in "did you mean" suggestions:
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
class InvoiceExportType < BaseObject
|
|
143
|
+
authorize :read, model: Invoice, visible_when: :export # InvoicePolicy#export? / can?(:export, Invoice)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
field :internal_notes, String, authorize: :manage, visible_when: :export
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
`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.
|
|
150
|
+
|
|
151
|
+
A hidden member also hides what leads only to it: a root field that returns a hidden type disappears with it.
|
|
152
|
+
|
|
153
|
+
**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:
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
AppSchema.to_definition(context: { armory_full_schema: true })
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Things to know:
|
|
160
|
+
|
|
161
|
+
- **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`.
|
|
162
|
+
- **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.
|
|
163
|
+
- **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)`.
|
|
164
|
+
- **A subclass that redeclares `authorize` replaces the rule**, `visible_when:` included: repeat it.
|
|
165
|
+
- **`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.
|
|
166
|
+
- **Static `Schema::Visibility` profiles are not supported** and raise `ConfigurationError`; plain `use Schema::Visibility`, `dynamic: true` and the default warden work.
|
|
167
|
+
|
|
168
|
+
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.
|
|
169
|
+
|
|
170
|
+
## Arguments
|
|
171
|
+
|
|
172
|
+
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:
|
|
173
|
+
|
|
174
|
+
```ruby
|
|
175
|
+
class BaseArgument < GraphQL::Schema::Argument
|
|
176
|
+
include GraphQL::Armory::Authorization::Base
|
|
177
|
+
end
|
|
178
|
+
# in your base field: argument_class BaseArgument
|
|
179
|
+
|
|
180
|
+
field :invoices, [InvoiceType], authorize: :none, reason: "narrowed by the scope" do
|
|
181
|
+
argument :include_internal, Boolean, required: false, authorize: :manage, model: Invoice
|
|
182
|
+
argument :tag, String, required: false, visible_when: :export, model: Invoice
|
|
183
|
+
argument :status, String, required: false, authorize: :none, reason: "public filter"
|
|
184
|
+
end
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
- **`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.
|
|
188
|
+
- **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.
|
|
189
|
+
- **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.
|
|
190
|
+
- **`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.
|
|
191
|
+
- **`authorize: :none, reason:`** only declares and documents: the audit lists it with its reason.
|
|
192
|
+
- `authorize: true` is refused on an argument, and an argument with no model to ask about raises `ConfigurationError`.
|
|
193
|
+
|
|
194
|
+
## Mutations and resolvers
|
|
195
|
+
|
|
196
|
+
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**.
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
class Mutations::BaseMutation < GraphQL::Schema::RelayClassicMutation
|
|
200
|
+
include GraphQL::Armory::Authorization::Base
|
|
201
|
+
argument_class Types::BaseArgument
|
|
202
|
+
field_class Types::BaseField
|
|
203
|
+
object_class Types::BaseObject
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
class Mutations::ApproveInvoice < Mutations::BaseMutation
|
|
207
|
+
authorize :approve, model: Invoice # InvoicePolicy#approve?(invoice) / can?(:approve, invoice)
|
|
208
|
+
argument :invoice_id, ID, loads: Types::Invoice
|
|
209
|
+
field :invoice, Types::Invoice, null: true
|
|
210
|
+
end
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
- **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.
|
|
214
|
+
- **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.
|
|
215
|
+
- **Payload types** are ordinary objects: declare `authorize :none, reason: "...", covers_fields: true` so their fields are covered by the mutation instead of denied.
|
|
216
|
+
- **`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`.
|
|
217
|
+
- An unknown `on_denied:` raises `ArgumentError`; a mutation with a rule but no model raises `ConfigurationError`.
|
|
218
|
+
- **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.
|
|
219
|
+
- **`ready?` runs before authorization** (and, on the interpreter, before a failing `loads:`). Keep it free of side effects.
|
|
220
|
+
- **`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`.
|
|
221
|
+
- **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.
|
|
222
|
+
|
|
223
|
+
## Interfaces and unions
|
|
224
|
+
|
|
225
|
+
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.
|
|
226
|
+
|
|
227
|
+
What an interface adds is its fields, declared once and resolved on many types:
|
|
228
|
+
|
|
229
|
+
- 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.
|
|
230
|
+
- `visible_when:` is not supported on an interface field; put it on the concrete types' fields.
|
|
231
|
+
- `authorize: true` is not valid on an interface field: it is always denied, and the audit reports it. Use an action or `:none`.
|
|
232
|
+
- 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. The audit reports it (`:unprotected`, `Node.id`); give your own `Node` interface a Base field class if that matters.
|
|
233
|
+
- The audit reports an interface field once, on the interface that declares it, not again on each implementer.
|
|
234
|
+
|
|
235
|
+
## Telemetry
|
|
236
|
+
|
|
237
|
+
Pass `sinks: [->(signal, data) {}]`. Signals are `:allowed`, `:denied`, `:bypassed`; data holds only `type`, `action` and `reason`, never record attributes.
|
|
238
|
+
|
|
239
|
+
## Guarantees and limits
|
|
240
|
+
|
|
241
|
+
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).
|
|
242
|
+
|
|
243
|
+
Policies must return `true`/`false`/`nil`; anything else raises `AdapterError`. Do not use `dataloader.request` inside policies; use `dataloader.with(...).load`.
|
|
244
|
+
|
|
245
|
+
## Engines
|
|
246
|
+
|
|
247
|
+
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.
|
|
248
|
+
|
|
249
|
+
## Performance
|
|
250
|
+
|
|
251
|
+
`benchmark/authorization.rb`, 1000 invoices, graphql 2.6.2, Ruby 3.2, Docker on Windows:
|
|
252
|
+
|
|
253
|
+
| Engine | Query | plain | armory (array) | armory (relation) |
|
|
254
|
+
|---|---|---|---|---|
|
|
255
|
+
| interpreter | `number` | 117.6 i/s | 45.9 i/s | 44.2 i/s |
|
|
256
|
+
| interpreter | 3 fields | 58.8 i/s | 21.6 i/s | 21.0 i/s |
|
|
257
|
+
| next | `number` | 654.2 i/s | 71.8 i/s | 70.8 i/s |
|
|
258
|
+
| next | 3 fields | 417.5 i/s | 42.1 i/s | 39.5 i/s |
|
|
259
|
+
|
|
260
|
+
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).
|
|
261
|
+
|
|
262
|
+
## Migrating
|
|
263
|
+
|
|
264
|
+
Coming from hand-written `authorized?` and `scope_items`? See [MIGRATING.md](../MIGRATING.md).
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# Encrypted IDs and cursors
|
|
2
|
+
|
|
3
|
+
IDs and pagination cursors that clients can neither read, guess, nor forge.
|
|
4
|
+
|
|
5
|
+
## The problem
|
|
6
|
+
|
|
7
|
+
graphql-ruby's IDs and cursors are base64, which is an encoding, not encryption. A Relay ID such as `SW52b2ljZS80Mg` is `Invoice/42` to anyone who decodes it, and a cursor such as `MTA` is the position `10`. From them a client can:
|
|
8
|
+
|
|
9
|
+
- **Guess other IDs.** `Invoice/43`, `Invoice/44`… or `User/1`, `Admin/1`. Authorization still decides what it may read, but every guess costs you a lookup and probes your API.
|
|
10
|
+
- **Learn about your business.** Sequential IDs tell how many invoices or users you have and how fast they grow.
|
|
11
|
+
- **Edit cursors** to jump anywhere in a list or count its rows.
|
|
12
|
+
|
|
13
|
+
## The solution
|
|
14
|
+
|
|
15
|
+
`GraphQL::Armory::Encoder` encrypts and authenticates them with AES-256-GCM:
|
|
16
|
+
|
|
17
|
+
- **Opaque.** The ID of `Invoice/42` looks like `x0Tn…Qw`; nothing in it can be read.
|
|
18
|
+
- **Tamper-proof.** Change one character, use another key or another tag, and it does not decode. Every failure gives the same error, `Invalid ID or cursor`, without saying why.
|
|
19
|
+
- **Stable IDs.** The same object always has the same ID, so client caches (Apollo, Relay) keep working. Its nonce is derived from the ID, so two different IDs do not share one (a collision needs around 2^48 IDs).
|
|
20
|
+
- **Unpredictable cursors.** The same position gives a different cursor in every response.
|
|
21
|
+
- **IDs and cursors are not interchangeable.** Each uses its own derived key.
|
|
22
|
+
- **One line for the whole schema.** `use GraphQL::Armory::IDs` defines `id_from_object` and `object_from_id`, looking records up inside the user's scope.
|
|
23
|
+
- **Keys can be rotated, and encryption added to an existing API**, without breaking the IDs and cursors clients already hold: a versioned chain reads old values and writes new ones.
|
|
24
|
+
|
|
25
|
+
## How to use it
|
|
26
|
+
|
|
27
|
+
Generate a key once and keep it out of the code: `ruby -rsecurerandom -e 'puts SecureRandom.hex(32)'`, and set it as `GRAPHQL_ARMORY_KEY` (in a Rails app you can skip this: a key is derived from `secret_key_base`, see [the default encoder](#the-default-encoder)).
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
class AppSchema < GraphQL::Schema
|
|
31
|
+
use GraphQL::Armory::Authorization, adapter: :pundit # optional, but recommended
|
|
32
|
+
use GraphQL::Armory::IDs # encrypted global IDs
|
|
33
|
+
cursor_encoder GraphQL::Armory::Encoder.default # encrypted cursors
|
|
34
|
+
end
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`use GraphQL::Armory::IDs` defines `id_from_object` and `object_from_id` for you. An ID holds the type name and the record's id, encrypted. When the schema decodes one:
|
|
38
|
+
|
|
39
|
+
- A type that includes the authorization module's `Base` is looked up **inside the user's scope**, so another tenant's ID answers `null`, like a missing record (in `node(id:)`, `loads:` and anything else that calls `object_from_id`).
|
|
40
|
+
- A tampered ID, an ID made with another key, an unknown type, a type hidden from this user (`visible_when:`), or a type that is not an object type implementing `Node` (root types, payloads, introspection) answer `null` too, with no error.
|
|
41
|
+
- A type without `Base` is loaded only if you say how: `use GraphQL::Armory::IDs, find: ->(type, key, context) { ... }`. Otherwise its IDs answer `null`. `find:` only sees `Node` types; return `nil` for a missing record (`find_by`, not `find`): an error it raises reaches the client.
|
|
42
|
+
- A `Base` type named unlike its model (Rails' `Types::InvoiceType`) needs `authorize :read, model: Invoice`, or every lookup of it raises `ConfigurationError`.
|
|
43
|
+
|
|
44
|
+
Pass your own encoder with `use GraphQL::Armory::IDs, encoder: AppEncoder` (or a versioned chain, below).
|
|
45
|
+
|
|
46
|
+
### Your own encoder
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
class AppEncoder < GraphQL::Armory::Encoder
|
|
50
|
+
key ENV.fetch("GRAPHQL_ARMORY_KEY")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
class AppSchema < GraphQL::Schema
|
|
54
|
+
use GraphQL::Armory::IDs, encoder: AppEncoder
|
|
55
|
+
cursor_encoder AppEncoder # every built-in connection now uses encrypted cursors
|
|
56
|
+
end
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Without the plugin, call the encoder from your own `id_from_object` and `object_from_id` (`AppEncoder.encode("#{type.graphql_name}/#{object.id}")` and `AppEncoder.decode(id)`). With the [authorization module](authorization.md), an ID the encoder refuses in a `loads:` argument is answered exactly like a missing record.
|
|
60
|
+
|
|
61
|
+
### The default encoder
|
|
62
|
+
|
|
63
|
+
If you do not need your own encoder class, use the default one. It never ships a key: it takes `GRAPHQL_ARMORY_KEY` from the environment or, in a Rails app, derives a key of its own from `secret_key_base` with HKDF (purpose `graphql-armory/ids`, so the Rails secret itself is never used to encrypt). Without either, it raises when the schema loads (at boot, with eager loading) instead of serving unencrypted IDs.
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
class AppSchema < GraphQL::Schema
|
|
67
|
+
cursor_encoder GraphQL::Armory::Encoder.default
|
|
68
|
+
end
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`GRAPHQL_ARMORY_KEY` wins over Rails; an empty value counts as unset, and a value with surrounding whitespace is refused (another process might strip it and get other IDs). Changing `secret_key_base` changes every ID: if you rotate it, keep the old encoder in a versioned chain (below). The key does not depend on any other Rails setting: it does not use `Rails.application.key_generator`, whose digest changes during boot. With `SECRET_KEY_BASE_DUMMY` Rails invents a secret per process, so IDs do not survive a restart; set a real secret where IDs matter. `MyEncoder.default` keeps `MyEncoder`'s tag and codec but replaces its key with the default one.
|
|
72
|
+
|
|
73
|
+
## Rotating keys and migrating from base64
|
|
74
|
+
|
|
75
|
+
Put encoders in a chain, newest first. The chain encodes with the first and decodes with whichever accepts the value, so clients keep working while you change keys:
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
class AppEncoder2026 < GraphQL::Armory::Encoder
|
|
79
|
+
key ENV.fetch("GRAPHQL_ARMORY_KEY")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
class AppEncoder2025 < GraphQL::Armory::Encoder
|
|
83
|
+
key ENV.fetch("GRAPHQL_ARMORY_OLD_KEY")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
AppIDs = GraphQL::Armory::Encoder.versioned(AppEncoder2026, AppEncoder2025)
|
|
87
|
+
# cursor_encoder AppIDs, and AppIDs.encode / AppIDs.decode in id_from_object / object_from_id
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Drop the old encoder from the chain once its values have expired (cursors are short-lived; IDs may be bookmarked or cached by clients for longer).
|
|
91
|
+
|
|
92
|
+
**Adding encryption to an existing API.** `GraphQL::Armory::Encoder::Legacy` reads graphql-ruby's default base64 (padded or not, url-safe or standard), so the IDs and cursors clients already hold keep working:
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
AppIDs = GraphQL::Armory::Encoder.versioned(AppEncoder, GraphQL::Armory::Encoder::Legacy)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`Legacy` is not encryption: while it is in the chain, anyone can still forge a base64 value. It only accepts printable text, so a tampered full-length encrypted value does not come back as data (a truncated one may decode to short gibberish, like any forged value). Do not put `GraphQL::Schema::Base64Encoder` in a chain: it would turn tampered values into binary data. To treat old values differently, ask which encoder decoded them:
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
data, encoder = AppIDs.decode_versioned(id)
|
|
102
|
+
return nil if encoder.nil? # nothing in the chain accepted it
|
|
103
|
+
log_legacy_id(id) if encoder == GraphQL::Armory::Encoder::Legacy
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Reference
|
|
107
|
+
|
|
108
|
+
| Declaration | Meaning |
|
|
109
|
+
|---|---|
|
|
110
|
+
| `key "..."` | Required. At least 32 bytes. Subkeys for IDs and cursors are derived from it with HKDF. |
|
|
111
|
+
| `tag "v1"` | Optional authenticated data. An ID made with another tag does not decode: use it to separate apps or environments sharing a key. |
|
|
112
|
+
| `codec MyCodec` | Optional. How the encrypted bytes become text; anything with `encode(bytes)` and `decode(text)`. Default: base64url without padding. Any error it raises while decoding becomes `InvalidError`. |
|
|
113
|
+
|
|
114
|
+
| Call | Returns |
|
|
115
|
+
|---|---|
|
|
116
|
+
| `encode(text)` | A stable, encrypted ID. |
|
|
117
|
+
| `encode(text, nonce: true)` | A cursor, different every time. |
|
|
118
|
+
| `decode(id)` / `decode(cursor, nonce: true)` | The text, or raises `GraphQL::Armory::Encoder::InvalidError` (a `GraphQL::ExecutionError`, so clients see `Invalid ID or cursor`). |
|
|
119
|
+
| `Encoder.versioned(newest, ..., oldest)` | A chain with the same `encode` and `decode`; encodes with `newest`. |
|
|
120
|
+
| `chain.decode_versioned(text, nonce: false)` | `[data, encoder]`, or `[nil, nil]` when no encoder accepts it. |
|
|
121
|
+
| `use GraphQL::Armory::IDs, encoder: nil, find: nil` | Defines `id_from_object` and `object_from_id`; `encoder` defaults to `Encoder.default`; `find:` loads types without `Base`. |
|
|
122
|
+
| `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. |
|
|
123
|
+
|
|
124
|
+
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.
|
|
125
|
+
|
|
126
|
+
## Guarantees and limits
|
|
127
|
+
|
|
128
|
+
Covered by `spec/security/encoder_spec.rb`, `encoder_versioning_spec.rb`, `default_encoder_spec.rb` and `ids_plugin_spec.rb` on both engines: round trips, tampering at several positions, another key, another tag, a cursor used as an ID and the reverse, malformed and truncated input, encrypted pagination and a tampered cursor, rotation and migration from base64 cursors, where the default encoder takes its key, and the plugin answering every ID it cannot honour (foreign, tampered, unknown or non-object type, another model in `loads:`) like a missing one.
|
|
129
|
+
|
|
130
|
+
- **Changing the key invalidates every ID and cursor already handed out**, unless the old encoder stays in a versioned chain.
|
|
131
|
+
- **`Legacy` in a chain can be forged**, and it does not separate IDs from cursors. Keep it only while migrating.
|
|
132
|
+
- **Encryption hides the ID; it does not authorize.** Whoever obtained a valid ID can still send it. What they may read is decided by the authorization module.
|
|
133
|
+
- IDs are longer than base64 ones: 28 extra bytes (nonce and authentication tag) before encoding.
|
|
134
|
+
- graphql-ruby decodes an `after:` cursor once per selection that uses it, so a tampered cursor can produce the same error more than once.
|
|
135
|
+
- Without the plugin, graphql-ruby's own `node(id:)` field does not rescue errors: a refused ID answers `Invalid ID or cursor` while a missing record answers `null`. `use GraphQL::Armory::IDs` answers both with `null`.
|
|
136
|
+
- The plugin builds IDs from `object.id` and looks them up by primary key: composite primary keys are not supported.
|
|
@@ -78,11 +78,12 @@ module GraphQL
|
|
|
78
78
|
end
|
|
79
79
|
|
|
80
80
|
# `GlobalID.find`, graphql-ruby's documented `object_from_id`, raises for a missing record, and a
|
|
81
|
-
# decoder may raise for a malformed id.
|
|
81
|
+
# decoder may raise for a malformed id (ArgumentError, or the Encoder's InvalidError). All must answer like a
|
|
82
|
+
# record out of scope: nil.
|
|
82
83
|
def self.decode(schema, id, context)
|
|
83
84
|
schema.object_from_id(id, context)
|
|
84
85
|
rescue StandardError => error
|
|
85
|
-
raise unless error.is_a?(ArgumentError) || error.class.name == "ActiveRecord::RecordNotFound"
|
|
86
|
+
raise unless error.is_a?(ArgumentError) || error.is_a?(Encoder::InvalidError) || error.class.name == "ActiveRecord::RecordNotFound"
|
|
86
87
|
|
|
87
88
|
nil
|
|
88
89
|
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module Armory
|
|
5
|
+
class Encoder
|
|
6
|
+
ENV_KEY = "GRAPHQL_ARMORY_KEY"
|
|
7
|
+
RAILS_PURPOSE = "graphql-armory/ids"
|
|
8
|
+
|
|
9
|
+
# An encoder whose key comes from the app, never from the gem: `GRAPHQL_ARMORY_KEY`, or else a key derived
|
|
10
|
+
# with HKDF from Rails' secret_key_base for this purpose alone. Without either it raises, so the app fails
|
|
11
|
+
# when the schema loads instead of serving unencrypted IDs.
|
|
12
|
+
#
|
|
13
|
+
# It reads `secret_key_base` instead of `Rails.application.key_generator`: that generator is built once per
|
|
14
|
+
# process with whatever digest Rails has configured at that moment, which changes during boot, so the same
|
|
15
|
+
# secret could give different IDs in a web server and in a rake task (and calling it early would also fix
|
|
16
|
+
# Rails' own cookie keys to the wrong digest).
|
|
17
|
+
def self.default(env: ENV, rails: rails_application)
|
|
18
|
+
secret = env_key(env)
|
|
19
|
+
secret = rails_key(rails) if secret.nil? && rails
|
|
20
|
+
if secret.nil?
|
|
21
|
+
raise ConfigurationError,
|
|
22
|
+
"no key for the default encoder: set #{ENV_KEY} (try `ruby -rsecurerandom -e 'puts SecureRandom.hex(32)'`), " \
|
|
23
|
+
"or run inside a Rails app with a secret_key_base"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
Class.new(self) { key(secret) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.env_key(env)
|
|
30
|
+
value = env[ENV_KEY].to_s
|
|
31
|
+
return nil if value.empty?
|
|
32
|
+
if value.strip != value || value.strip.empty?
|
|
33
|
+
raise ConfigurationError, "#{ENV_KEY} has leading or trailing whitespace, or only whitespace: remove it"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
value
|
|
37
|
+
end
|
|
38
|
+
private_class_method :env_key
|
|
39
|
+
|
|
40
|
+
def self.rails_application
|
|
41
|
+
::Rails.application if defined?(::Rails) && ::Rails.respond_to?(:application)
|
|
42
|
+
end
|
|
43
|
+
private_class_method :rails_application
|
|
44
|
+
|
|
45
|
+
def self.rails_key(rails)
|
|
46
|
+
secret = begin
|
|
47
|
+
rails.secret_key_base
|
|
48
|
+
rescue StandardError => error
|
|
49
|
+
# Rails' message may come from app code; name only its class so nothing secret is echoed.
|
|
50
|
+
raise ConfigurationError, "the default encoder could not read Rails' secret_key_base (#{error.class})"
|
|
51
|
+
end
|
|
52
|
+
raise ConfigurationError, "this Rails app has no secret_key_base for the default encoder" if secret.to_s.empty?
|
|
53
|
+
|
|
54
|
+
OpenSSL::KDF.hkdf(secret.to_s, salt: "graphql-armory", info: RAILS_PURPOSE, length: 32, hash: "SHA256")
|
|
55
|
+
end
|
|
56
|
+
private_class_method :rails_key
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
|
|
5
|
+
module GraphQL
|
|
6
|
+
module Armory
|
|
7
|
+
class Encoder
|
|
8
|
+
# graphql-ruby's default base64, to keep reading the cursors and IDs clients already hold while migrating to
|
|
9
|
+
# encryption: put it last in `Encoder.versioned`. It reads base64 padded or not, url-safe or standard, and
|
|
10
|
+
# only when it holds printable text, so a tampered encrypted value never comes back as garbage. It is not
|
|
11
|
+
# encryption: anyone can forge what it reads. Remove it from the chain once old values have expired.
|
|
12
|
+
module Legacy
|
|
13
|
+
PRINTABLE = /\A[[:print:]]*\z/
|
|
14
|
+
|
|
15
|
+
def self.encode(text, nonce: false)
|
|
16
|
+
Base64.urlsafe_encode64(text.to_s, padding: false)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.decode(text, nonce: false)
|
|
20
|
+
raise InvalidError unless text.is_a?(String) && !text.empty?
|
|
21
|
+
|
|
22
|
+
plain = Base64.urlsafe_decode64(text.tr("+/", "-_").delete_suffix("==").delete_suffix("="))
|
|
23
|
+
raise InvalidError if plain.empty?
|
|
24
|
+
plain.force_encoding(Encoding::UTF_8)
|
|
25
|
+
raise InvalidError unless plain.valid_encoding? && plain.match?(PRINTABLE)
|
|
26
|
+
|
|
27
|
+
plain
|
|
28
|
+
rescue StandardError
|
|
29
|
+
raise InvalidError
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module Armory
|
|
5
|
+
class Encoder
|
|
6
|
+
# A chain of encoders, newest first: encodes with the first, decodes with whichever accepts the value.
|
|
7
|
+
# Build it with `Encoder.versioned(NewEncoder, OldEncoder, Encoder::Legacy)`.
|
|
8
|
+
class Versioned
|
|
9
|
+
attr_reader :encoders
|
|
10
|
+
|
|
11
|
+
def initialize(encoders)
|
|
12
|
+
raise ConfigurationError, "a versioned encoder needs at least one encoder" if encoders.empty?
|
|
13
|
+
|
|
14
|
+
encoders.each { |encoder| check(encoder) }
|
|
15
|
+
@encoders = encoders.freeze
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def encode(text, nonce: false)
|
|
19
|
+
encoders.first.encode(text, nonce: nonce)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def decode(text, nonce: false)
|
|
23
|
+
data, encoder = decode_versioned(text, nonce: nonce)
|
|
24
|
+
raise InvalidError unless encoder
|
|
25
|
+
|
|
26
|
+
data
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The data and the encoder that decoded it, or `[nil, nil]`, so the app can treat old values differently.
|
|
30
|
+
def decode_versioned(text, nonce: false)
|
|
31
|
+
encoders.each do |encoder|
|
|
32
|
+
data = encoder.decode(text, nonce: nonce)
|
|
33
|
+
return [data, encoder] if data.is_a?(String)
|
|
34
|
+
rescue ConfigurationError
|
|
35
|
+
raise
|
|
36
|
+
rescue StandardError
|
|
37
|
+
next
|
|
38
|
+
end
|
|
39
|
+
[nil, nil]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
# Fails at boot for a member that would otherwise refuse every value at request time.
|
|
45
|
+
def check(encoder)
|
|
46
|
+
unless %i[encode decode].all? { |name| encoder.respond_to?(name) }
|
|
47
|
+
raise ConfigurationError, "#{encoder.inspect} cannot be versioned: it must respond to encode and decode"
|
|
48
|
+
end
|
|
49
|
+
unless %i[encode decode].all? { |name| accepts_nonce?(encoder.method(name)) }
|
|
50
|
+
raise ConfigurationError, "#{encoder.inspect} cannot be versioned: encode and decode must accept `nonce:`"
|
|
51
|
+
end
|
|
52
|
+
return unless encoder.respond_to?(:key) && encoder.respond_to?(:tag) && encoder.key.nil?
|
|
53
|
+
|
|
54
|
+
raise ConfigurationError, "#{encoder.inspect} cannot be versioned: it has no key"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def accepts_nonce?(method)
|
|
58
|
+
method.parameters.any? { |kind, name| kind == :keyrest || (%i[key keyreq].include?(kind) && name == :nonce) }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.versioned(*encoders)
|
|
63
|
+
Versioned.new(encoders)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "base64"
|
|
5
|
+
|
|
6
|
+
module GraphQL
|
|
7
|
+
module Armory
|
|
8
|
+
# Encrypts and authenticates IDs and cursors with AES-256-GCM. Subclass it and set a key:
|
|
9
|
+
#
|
|
10
|
+
# class AppEncoder < GraphQL::Armory::Encoder
|
|
11
|
+
# key ENV.fetch("GRAPHQL_ARMORY_KEY")
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# Cursors (`nonce: true`, as graphql-ruby's connections ask) get a random nonce, so the same position
|
|
15
|
+
# never gives the same cursor. IDs must be stable, so their nonce is derived from the ID itself with an
|
|
16
|
+
# HMAC: the same ID always encrypts the same way, and two different IDs never share a nonce.
|
|
17
|
+
# IDs and cursors use separate keys, so one is never accepted as the other.
|
|
18
|
+
class Encoder
|
|
19
|
+
# Raised for anything that does not decode: tampered, foreign, malformed. The message never says which.
|
|
20
|
+
class InvalidError < GraphQL::ExecutionError
|
|
21
|
+
def initialize(message = "Invalid ID or cursor")
|
|
22
|
+
super
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
class ConfigurationError < Armory::Error; end
|
|
27
|
+
|
|
28
|
+
# graphql-ruby's default text form for cursors: base64url without padding.
|
|
29
|
+
module URLSafeBase64
|
|
30
|
+
def self.encode(bytes)
|
|
31
|
+
Base64.urlsafe_encode64(bytes, padding: false)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.decode(text)
|
|
35
|
+
Base64.urlsafe_decode64(text)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
MINIMUM_KEY_BYTES = 32
|
|
40
|
+
NONCE_BYTES = 12
|
|
41
|
+
AUTH_TAG_BYTES = 16
|
|
42
|
+
CIPHER = "aes-256-gcm"
|
|
43
|
+
|
|
44
|
+
class << self
|
|
45
|
+
# The key, at least 32 bytes. With no argument, answers it (inherited from the parent class).
|
|
46
|
+
def key(*value)
|
|
47
|
+
return setting(:@key, nil) if value.empty?
|
|
48
|
+
|
|
49
|
+
secret = value.first
|
|
50
|
+
unless secret.is_a?(String) && secret.bytesize >= MINIMUM_KEY_BYTES
|
|
51
|
+
raise ConfigurationError, "an Encoder key must be a String of at least #{MINIMUM_KEY_BYTES} bytes (try SecureRandom.hex(32))"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
@key = secret
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Authenticated data: an ID made with another tag does not decode. Use it to separate apps or versions.
|
|
58
|
+
def tag(*value)
|
|
59
|
+
return setting(:@tag, "") if value.empty?
|
|
60
|
+
raise ConfigurationError, "an Encoder tag cannot be nil" if value.first.nil?
|
|
61
|
+
|
|
62
|
+
@tag = value.first.to_s
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Turns the encrypted bytes into text and back. Anything with `encode(bytes)` and `decode(text)`.
|
|
66
|
+
def codec(*value)
|
|
67
|
+
return setting(:@codec, URLSafeBase64) if value.empty?
|
|
68
|
+
raise ConfigurationError, "an Encoder codec must respond to encode and decode" unless %i[encode decode].all? { |name| value.first.respond_to?(name) }
|
|
69
|
+
|
|
70
|
+
@codec = value.first
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def encode(text, nonce: false)
|
|
74
|
+
purpose = nonce ? :cursor : :id
|
|
75
|
+
bytes = text.to_s.b
|
|
76
|
+
iv = nonce ? OpenSSL::Random.random_bytes(NONCE_BYTES) : derived_nonce(bytes)
|
|
77
|
+
cipher = OpenSSL::Cipher.new(CIPHER).encrypt
|
|
78
|
+
cipher.key = subkey(purpose, :cipher)
|
|
79
|
+
cipher.iv = iv
|
|
80
|
+
cipher.auth_data = tag
|
|
81
|
+
sealed = cipher.update(bytes) + cipher.final
|
|
82
|
+
codec.encode(iv + sealed + cipher.auth_tag)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def decode(text, nonce: false)
|
|
86
|
+
purpose = nonce ? :cursor : :id
|
|
87
|
+
bytes = codec.decode(text.to_s)
|
|
88
|
+
# One text form only: a base64 variant with other padding bits is not the same ID.
|
|
89
|
+
raise InvalidError unless codec.encode(bytes) == text.to_s
|
|
90
|
+
raise InvalidError if bytes.bytesize < NONCE_BYTES + AUTH_TAG_BYTES
|
|
91
|
+
|
|
92
|
+
iv = bytes[0, NONCE_BYTES]
|
|
93
|
+
cipher = OpenSSL::Cipher.new(CIPHER).decrypt
|
|
94
|
+
cipher.key = subkey(purpose, :cipher)
|
|
95
|
+
cipher.iv = iv
|
|
96
|
+
cipher.auth_tag = bytes[-AUTH_TAG_BYTES..]
|
|
97
|
+
cipher.auth_data = tag
|
|
98
|
+
plain = cipher.update(bytes[NONCE_BYTES...-AUTH_TAG_BYTES]) + cipher.final
|
|
99
|
+
# An ID has one valid form only: its nonce must be the one derived from it.
|
|
100
|
+
raise InvalidError if !nonce && !OpenSSL.fixed_length_secure_compare(iv, derived_nonce(plain))
|
|
101
|
+
|
|
102
|
+
plain.force_encoding(Encoding::UTF_8)
|
|
103
|
+
rescue ConfigurationError
|
|
104
|
+
raise
|
|
105
|
+
rescue StandardError
|
|
106
|
+
# Whatever the codec, OpenSSL or the input raise, the client learns only that it did not decode.
|
|
107
|
+
raise InvalidError
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
def setting(ivar, default)
|
|
113
|
+
return instance_variable_get(ivar) if instance_variable_defined?(ivar)
|
|
114
|
+
|
|
115
|
+
superclass.respond_to?(:key) ? superclass.send(:setting, ivar, default) : default
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def derived_nonce(bytes)
|
|
119
|
+
OpenSSL::HMAC.digest("SHA256", subkey(:id, :nonce), tag.b + "\0".b + bytes)[0, NONCE_BYTES]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Separate keys per purpose and use, derived with HKDF and cached by the secret they come from, so a key
|
|
123
|
+
# changed later (here or in a parent class) is used at once.
|
|
124
|
+
def subkey(purpose, use)
|
|
125
|
+
secret = key or raise ConfigurationError, "#{name || "Encoder"} has no key: call `key` in its class body"
|
|
126
|
+
@subkeys ||= {}
|
|
127
|
+
@subkeys[[secret, purpose, use]] ||= OpenSSL::KDF.hkdf(secret, salt: "graphql-armory", info: "#{purpose}/#{use}",
|
|
128
|
+
length: 32, hash: "SHA256")
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
require "graphql/armory/encoder/versioned"
|
|
136
|
+
require "graphql/armory/encoder/legacy"
|
|
137
|
+
require "graphql/armory/encoder/default"
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module Armory
|
|
5
|
+
# Encrypted global IDs in one line:
|
|
6
|
+
#
|
|
7
|
+
# class AppSchema < GraphQL::Schema
|
|
8
|
+
# use GraphQL::Armory::IDs # the default encoder
|
|
9
|
+
# use GraphQL::Armory::IDs, encoder: AppEncoder # or your own
|
|
10
|
+
# end
|
|
11
|
+
#
|
|
12
|
+
# Defines `id_from_object` (the type name and the record's id, encrypted) and `object_from_id`, which only
|
|
13
|
+
# honours visible object types that implement Node. A type that
|
|
14
|
+
# includes the authorization module's Base is looked up inside the user's scope; any other type only through
|
|
15
|
+
# `find: ->(type, key, context) { ... }`. Every ID that cannot be honoured answers nil, like a missing record.
|
|
16
|
+
module IDs
|
|
17
|
+
SEPARATOR = "/"
|
|
18
|
+
|
|
19
|
+
def self.use(schema, encoder: nil, find: nil)
|
|
20
|
+
lookup = Lookup.new(encoder: encoder || Encoder.default, find: find)
|
|
21
|
+
schema.define_singleton_method(:id_from_object) { |object, type, _context| lookup.id_for(object, type) }
|
|
22
|
+
schema.define_singleton_method(:object_from_id) { |id, context| lookup.object_for(id, context) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Turns records into IDs and IDs back into records.
|
|
26
|
+
class Lookup
|
|
27
|
+
attr_reader :encoder, :find
|
|
28
|
+
|
|
29
|
+
def initialize(encoder:, find: nil)
|
|
30
|
+
@encoder = encoder
|
|
31
|
+
@find = find
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def id_for(object, type)
|
|
35
|
+
key = object.id
|
|
36
|
+
raise Encoder::ConfigurationError, "#{type.graphql_name} object has no id to build a global ID from" if key.nil?
|
|
37
|
+
|
|
38
|
+
encoder.encode("#{type.graphql_name}#{SEPARATOR}#{key}")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def object_for(id, context)
|
|
42
|
+
type_name, key = encoder.decode(id).split(SEPARATOR, 2)
|
|
43
|
+
return nil if key.nil? || key.empty?
|
|
44
|
+
|
|
45
|
+
type = visible_type(type_name, context)
|
|
46
|
+
return nil unless node_type?(type)
|
|
47
|
+
|
|
48
|
+
load(type, key, context)
|
|
49
|
+
rescue Encoder::InvalidError
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
# Through the query's own view of the schema: `Schema.get_type(name, context)` does not apply `visible?`
|
|
56
|
+
# when graphql-ruby runs without `Schema::Visibility`, `context.types` does in both modes.
|
|
57
|
+
def visible_type(type_name, context)
|
|
58
|
+
context.respond_to?(:types) ? context.types.type(type_name) : context.schema.get_type(type_name, context)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Only object types that implement Node can have handed out an ID: root types, payloads and introspection
|
|
62
|
+
# types never did, so an ID naming them is not honoured (and their model is never looked up).
|
|
63
|
+
def node_type?(type)
|
|
64
|
+
type.respond_to?(:kind) && type.kind.object? &&
|
|
65
|
+
type.interfaces.any? { |interface| interface.graphql_name == "Node" }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def load(type, key, context)
|
|
69
|
+
return Authorization::Integration.find(type, nil, key, context) if type.respond_to?(:armory_model)
|
|
70
|
+
|
|
71
|
+
find&.call(type, key, context)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
data/lib/graphql/armory.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: graphql-armory
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.0.
|
|
4
|
+
version: 0.1.0.pre3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Israel
|
|
@@ -41,6 +41,8 @@ files:
|
|
|
41
41
|
- LICENSE
|
|
42
42
|
- MIGRATING.md
|
|
43
43
|
- README.md
|
|
44
|
+
- docs/authorization.md
|
|
45
|
+
- docs/encrypted-ids.md
|
|
44
46
|
- lib/graphql-armory.rb
|
|
45
47
|
- lib/graphql/armory.rb
|
|
46
48
|
- lib/graphql/armory/authorization.rb
|
|
@@ -72,6 +74,11 @@ files:
|
|
|
72
74
|
- lib/graphql/armory/authorization/rule.rb
|
|
73
75
|
- lib/graphql/armory/authorization/scope.rb
|
|
74
76
|
- lib/graphql/armory/authorization/telemetry.rb
|
|
77
|
+
- lib/graphql/armory/encoder.rb
|
|
78
|
+
- lib/graphql/armory/encoder/default.rb
|
|
79
|
+
- lib/graphql/armory/encoder/legacy.rb
|
|
80
|
+
- lib/graphql/armory/encoder/versioned.rb
|
|
81
|
+
- lib/graphql/armory/ids.rb
|
|
75
82
|
- lib/graphql/armory/version.rb
|
|
76
83
|
- lib/tasks/graphql_armory.rake
|
|
77
84
|
homepage: https://github.com/israelb/graphql-armory
|