graphql-armory 0.1.0.pre → 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 +29 -0
- data/LICENSE +0 -0
- data/MIGRATING.md +1 -1
- data/README.md +26 -266
- data/docs/authorization.md +264 -0
- data/docs/encrypted-ids.md +136 -0
- data/lib/graphql/armory/authorization/adapters/cancan.rb +0 -0
- data/lib/graphql/armory/authorization/adapters/null.rb +0 -0
- data/lib/graphql/armory/authorization/adapters/pundit.rb +0 -0
- data/lib/graphql/armory/authorization/adapters/substitute.rb +0 -0
- data/lib/graphql/armory/authorization/audit/command.rb +0 -0
- data/lib/graphql/armory/authorization/audit/report.rb +0 -0
- data/lib/graphql/armory/authorization/audit.rb +53 -11
- data/lib/graphql/armory/authorization/authorize.rb +0 -0
- data/lib/graphql/armory/authorization/configuration.rb +0 -0
- data/lib/graphql/armory/authorization/controls/actor.rb +0 -0
- data/lib/graphql/armory/authorization/controls/context.rb +0 -0
- data/lib/graphql/armory/authorization/controls/rule.rb +0 -0
- data/lib/graphql/armory/authorization/controls/subject.rb +0 -0
- data/lib/graphql/armory/authorization/errors.rb +0 -0
- data/lib/graphql/armory/authorization/integration/argument.rb +0 -0
- data/lib/graphql/armory/authorization/integration/base.rb +3 -1
- data/lib/graphql/armory/authorization/integration/field.rb +1 -1
- data/lib/graphql/armory/authorization/integration/find_by.rb +0 -0
- data/lib/graphql/armory/authorization/integration/input_object.rb +31 -0
- data/lib/graphql/armory/authorization/integration/object.rb +0 -0
- data/lib/graphql/armory/authorization/integration/resolver.rb +1 -1
- data/lib/graphql/armory/authorization/integration/schema.rb +0 -0
- data/lib/graphql/armory/authorization/integration.rb +32 -0
- data/lib/graphql/armory/authorization/memo.rb +0 -0
- data/lib/graphql/armory/authorization/rspec.rb +0 -0
- data/lib/graphql/armory/authorization/rule.rb +0 -0
- data/lib/graphql/armory/authorization/scope.rb +0 -0
- data/lib/graphql/armory/authorization/telemetry.rb +0 -0
- data/lib/graphql/armory/authorization.rb +0 -0
- 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
- data/lib/graphql-armory.rb +0 -0
- data/lib/tasks/graphql_armory.rake +0 -0
- metadata +13 -9
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,35 @@
|
|
|
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
|
+
|
|
15
|
+
## 0.1.0.pre2 (2026-10-29)
|
|
16
|
+
|
|
17
|
+
Closes the known gaps of the authorization module.
|
|
18
|
+
|
|
19
|
+
- `loads:` inside input objects: `include GraphQL::Armory::Authorization::Base` in the base input object loads them through
|
|
20
|
+
the user's scope; the audit reports input objects whose `loads:` skip it.
|
|
21
|
+
|
|
22
|
+
- Audit of interfaces: an interface field is reported once, on the interface that declares it; `authorize: true` on an
|
|
23
|
+
interface field and interface fields whose field class lacks `Base` (such as Relay `Node.id`) on a bypassed implementer
|
|
24
|
+
are reported.
|
|
25
|
+
|
|
26
|
+
- Global IDs in `loads:`: when the schema defines `object_from_id`, the id is decoded with it, a record of another model is
|
|
27
|
+
refused, and the record is looked up again inside the user's scope.
|
|
28
|
+
|
|
29
|
+
## 0.1.0.pre (2026-10-29)
|
|
30
|
+
|
|
31
|
+
First prerelease on RubyGems: Pundit and CanCanCan adapters, deny-by-default coverage, scoped lists and lookups, schema audit,
|
|
32
|
+
visibility, argument rules, mutations and resolvers, interfaces and unions. The entries below describe it.
|
|
33
|
+
|
|
5
34
|
- Interfaces and unions: an interface field with no rule is denied when an implementing type is a bypass, and the audit
|
|
6
35
|
reports it; `include Base` in an interface or union is refused with a pointer to the possible types.
|
|
7
36
|
|
data/LICENSE
CHANGED
|
File without changes
|
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,270 +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"
|
|
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:` 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
|
|
14
|
+
gem "graphql-armory", "~> 0.1.0.pre3" # a prerelease: `gem install graphql-armory --pre`
|
|
15
|
+
gem "pundit" # or "cancancan", for the authorization module
|
|
218
16
|
```
|
|
219
17
|
|
|
220
|
-
|
|
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:
|
|
18
|
+
Requires Ruby >= 3.1 and graphql >= 2.4.
|
|
235
19
|
|
|
236
|
-
|
|
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.
|
|
20
|
+
## Modules
|
|
240
21
|
|
|
241
|
-
|
|
22
|
+
Status: ✅ released, 🚧 in progress, ⬜ not started. Update this table in the same PR that changes a module.
|
|
242
23
|
|
|
243
|
-
|
|
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) |
|
|
24
|
+
| Order | Module | Status | Docs | Current limits |
|
|
260
25
|
|---|---|---|---|---|
|
|
261
|
-
|
|
|
262
|
-
|
|
|
263
|
-
|
|
|
264
|
-
|
|
|
265
|
-
|
|
266
|
-
|
|
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. |
|
|
267
36
|
|
|
268
|
-
|
|
37
|
+
Each module starts with its own research before design. A module is ✅ only when it is released and its page documents its limits.
|
|
269
38
|
|
|
270
|
-
|
|
39
|
+
Every module page follows the same outline: **The problem**, **The solution**, **How to use it**, reference sections, **Guarantees and limits**.
|
|
271
40
|
|
|
272
41
|
## Development
|
|
273
42
|
|
|
@@ -279,24 +48,15 @@ GRAPHQL_ARMORY_ENGINE=next docker compose run --rm test # full suite, Ex
|
|
|
279
48
|
GRAPHQL_VERSION="~> 2.4.0" docker compose run --rm test # another graphql version
|
|
280
49
|
```
|
|
281
50
|
|
|
282
|
-
|
|
51
|
+
### Releasing
|
|
283
52
|
|
|
284
|
-
|
|
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:
|
|
285
54
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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. |
|
|
55
|
+
```
|
|
56
|
+
git tag v0.1.0.pre3 && git push origin v0.1.0.pre3
|
|
57
|
+
```
|
|
298
58
|
|
|
299
|
-
|
|
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).
|
|
300
60
|
|
|
301
61
|
## Related gems
|
|
302
62
|
|