graphql-armory 0.1.0.pre
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +26 -0
- data/LICENSE +21 -0
- data/MIGRATING.md +85 -0
- data/README.md +311 -0
- data/lib/graphql/armory/authorization/adapters/cancan.rb +92 -0
- data/lib/graphql/armory/authorization/adapters/null.rb +28 -0
- data/lib/graphql/armory/authorization/adapters/pundit.rb +83 -0
- data/lib/graphql/armory/authorization/adapters/substitute.rb +46 -0
- data/lib/graphql/armory/authorization/audit/command.rb +36 -0
- data/lib/graphql/armory/authorization/audit/report.rb +51 -0
- data/lib/graphql/armory/authorization/audit.rb +213 -0
- data/lib/graphql/armory/authorization/authorize.rb +68 -0
- data/lib/graphql/armory/authorization/configuration.rb +35 -0
- data/lib/graphql/armory/authorization/controls/actor.rb +15 -0
- data/lib/graphql/armory/authorization/controls/context.rb +29 -0
- data/lib/graphql/armory/authorization/controls/rule.rb +15 -0
- data/lib/graphql/armory/authorization/controls/subject.rb +15 -0
- data/lib/graphql/armory/authorization/errors.rb +29 -0
- data/lib/graphql/armory/authorization/integration/argument.rb +74 -0
- data/lib/graphql/armory/authorization/integration/base.rb +34 -0
- data/lib/graphql/armory/authorization/integration/field.rb +102 -0
- data/lib/graphql/armory/authorization/integration/find_by.rb +22 -0
- data/lib/graphql/armory/authorization/integration/object.rb +67 -0
- data/lib/graphql/armory/authorization/integration/resolver.rb +84 -0
- data/lib/graphql/armory/authorization/integration/schema.rb +60 -0
- data/lib/graphql/armory/authorization/integration.rb +104 -0
- data/lib/graphql/armory/authorization/memo.rb +35 -0
- data/lib/graphql/armory/authorization/rspec.rb +21 -0
- data/lib/graphql/armory/authorization/rule.rb +61 -0
- data/lib/graphql/armory/authorization/scope.rb +37 -0
- data/lib/graphql/armory/authorization/telemetry.rb +42 -0
- data/lib/graphql/armory/authorization.rb +41 -0
- data/lib/graphql/armory/version.rb +7 -0
- data/lib/graphql/armory.rb +13 -0
- data/lib/graphql-armory.rb +2 -0
- data/lib/tasks/graphql_armory.rake +11 -0
- metadata +107 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module Armory
|
|
5
|
+
module Authorization
|
|
6
|
+
module Adapters
|
|
7
|
+
# An adapter with canned answers that keeps the questions it was asked, for tests.
|
|
8
|
+
class Substitute
|
|
9
|
+
attr_reader :asked, :scoped_for
|
|
10
|
+
|
|
11
|
+
def initialize(allow: true, actor: nil, scoped: :itself.to_proc, raises: nil, raises_on_scope: nil)
|
|
12
|
+
@allow = allow
|
|
13
|
+
@actor = actor
|
|
14
|
+
@scoped = scoped
|
|
15
|
+
@raises = raises
|
|
16
|
+
@raises_on_scope = raises_on_scope
|
|
17
|
+
@asked = []
|
|
18
|
+
@scoped_for = []
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def actor(_context)
|
|
22
|
+
@actor
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def authorized?(actor, subject, rule)
|
|
26
|
+
raise @raises if @raises
|
|
27
|
+
|
|
28
|
+
@asked << [actor, subject, rule]
|
|
29
|
+
@allow
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def scope(actor, items, rule)
|
|
33
|
+
raise @raises_on_scope if @raises_on_scope
|
|
34
|
+
|
|
35
|
+
@scoped_for << [actor, items, rule]
|
|
36
|
+
@scoped.call(items)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def check(_type, _rule)
|
|
40
|
+
[]
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module Armory
|
|
5
|
+
module Authorization
|
|
6
|
+
class Audit
|
|
7
|
+
# What `rake graphql_armory:audit[YourSchema]` runs: prints the report and answers an exit status.
|
|
8
|
+
class Command
|
|
9
|
+
def initialize(out: $stdout)
|
|
10
|
+
@out = out
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call(schema_name)
|
|
14
|
+
return fail_with("Name the schema: rake graphql_armory:audit[YourSchema]") if schema_name.to_s.empty?
|
|
15
|
+
|
|
16
|
+
schema = Object.const_get(schema_name)
|
|
17
|
+
report = Audit.(schema)
|
|
18
|
+
@out.print(report)
|
|
19
|
+
report.ok? ? 0 : 1
|
|
20
|
+
rescue NameError => error
|
|
21
|
+
raise unless error.name.to_s == schema_name.to_s.split("::").last
|
|
22
|
+
|
|
23
|
+
fail_with("No schema named #{schema_name}")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def fail_with(message)
|
|
29
|
+
@out.puts(message)
|
|
30
|
+
1
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module Armory
|
|
5
|
+
module Authorization
|
|
6
|
+
class Audit
|
|
7
|
+
# What an audit found: what is not covered, and every bypass someone declared.
|
|
8
|
+
class Report
|
|
9
|
+
Problem = Struct.new(:kind, :path, :message) do
|
|
10
|
+
def to_s
|
|
11
|
+
"#{path} (#{kind}): #{message}"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
Bypass = Struct.new(:path, :reason)
|
|
16
|
+
|
|
17
|
+
attr_reader :problems, :bypasses
|
|
18
|
+
|
|
19
|
+
def initialize(problems:, bypasses:)
|
|
20
|
+
@problems = problems.freeze
|
|
21
|
+
@bypasses = bypasses.freeze
|
|
22
|
+
freeze
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def ok?
|
|
26
|
+
problems.empty?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def to_s
|
|
30
|
+
[problems_text, bypasses_text].compact.join("\n\n") << "\n"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def problems_text
|
|
36
|
+
return "No problems." if ok?
|
|
37
|
+
|
|
38
|
+
["#{problems.size} #{problems.size == 1 ? 'problem' : 'problems'}:", *problems.map { |problem| " #{problem}" }].join("\n")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def bypasses_text
|
|
42
|
+
return if bypasses.empty?
|
|
43
|
+
|
|
44
|
+
["#{bypasses.size} declared #{bypasses.size == 1 ? 'bypass' : 'bypasses'}:",
|
|
45
|
+
*bypasses.map { |bypass| " #{bypass.path}: #{bypass.reason}" }].join("\n")
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
require "graphql/armory/authorization/audit/report"
|
|
5
|
+
require "graphql/armory/authorization/audit/command"
|
|
6
|
+
|
|
7
|
+
module GraphQL
|
|
8
|
+
module Armory
|
|
9
|
+
module Authorization
|
|
10
|
+
# Walks a schema, without running a query, and reports everything that is not covered:
|
|
11
|
+
# types nobody protected, types without a rule or a policy, fields with no rule on a bypassed
|
|
12
|
+
# type, and `loads:` that skip the scope. Every declared bypass is listed with its reason.
|
|
13
|
+
class Audit
|
|
14
|
+
RELAY_TYPES = [GraphQL::Types::Relay::BaseConnection, GraphQL::Types::Relay::BaseEdge,
|
|
15
|
+
GraphQL::Types::Relay::PageInfo].freeze
|
|
16
|
+
|
|
17
|
+
attr_reader :adapter
|
|
18
|
+
|
|
19
|
+
# `new` is inert: with no adapter nothing can be verified, so nothing is covered.
|
|
20
|
+
def initialize(adapter: Adapters::Null.new)
|
|
21
|
+
@adapter = adapter
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.build(schema)
|
|
25
|
+
configuration = schema.armory_configuration if schema.respond_to?(:armory_configuration)
|
|
26
|
+
configuration ? new(adapter: configuration.adapter) : new
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.call(schema)
|
|
30
|
+
build(schema).(schema)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def call(schema)
|
|
34
|
+
findings = Findings.new
|
|
35
|
+
unless schema.respond_to?(:armory_configuration) && schema.armory_configuration
|
|
36
|
+
findings.problem(:plugin, "Schema", "never installed the plugin: add `use GraphQL::Armory::Authorization, adapter: ...`")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
audited_types(schema).each { |type| audit_type(type, findings) }
|
|
40
|
+
schema.types.each_value { |type| audit_interface(type, schema, findings) if type.kind.interface? && !type.introspection? }
|
|
41
|
+
findings.report
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# Walks `schema.types`, which a schema that hides types with `visible?` would filter.
|
|
47
|
+
def audited_types(schema)
|
|
48
|
+
schema.types.values.select do |type|
|
|
49
|
+
type.kind.object? && !type.introspection? && RELAY_TYPES.none? { |relay| type <= relay }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def audit_type(type, findings)
|
|
54
|
+
path = type.graphql_name
|
|
55
|
+
unless type.respond_to?(:armory_rule)
|
|
56
|
+
findings.problem(:unprotected, path, "its base class does not include GraphQL::Armory::Authorization::Base, so it is open")
|
|
57
|
+
return
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
rule = type.armory_rule
|
|
61
|
+
if rule.nil?
|
|
62
|
+
findings.problem(:no_rule, path, "has no rule and no default_action, so it is denied")
|
|
63
|
+
elsif rule.none?
|
|
64
|
+
findings.bypass(path, rule.reason)
|
|
65
|
+
audit_fields(type, findings, model: nil, bypassed: !rule.covers_fields?, reported: Set.new)
|
|
66
|
+
else
|
|
67
|
+
model = model_of(type, path, findings)
|
|
68
|
+
reported = Set.new
|
|
69
|
+
check(path, model, rule, findings, reported) if model
|
|
70
|
+
check(path, model, Rule.new(rule.visible_when, policy: rule.policy), findings, reported) if model && rule.visible_when
|
|
71
|
+
audit_fields(type, findings, model: model, bypassed: false, reported: reported)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# An interface field with no rule is denied when any implementer is a bypass; the fix is to declare it.
|
|
76
|
+
def audit_interface(interface, schema, findings)
|
|
77
|
+
return unless Integration.bypassed_implementer?(interface, schema)
|
|
78
|
+
|
|
79
|
+
interface.fields.each_value do |field|
|
|
80
|
+
next unless field.respond_to?(:armory_declared) && field.armory_declared.nil?
|
|
81
|
+
|
|
82
|
+
findings.problem(:field_without_rule, "#{interface.graphql_name}.#{field.original_name}",
|
|
83
|
+
"declares no rule and an implementing type is bypassed, so it is denied")
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def model_of(type, path, findings)
|
|
88
|
+
type.armory_model
|
|
89
|
+
rescue ConfigurationError => error
|
|
90
|
+
findings.problem(:model, path, error.message)
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# An adapter problem shows once per type, however many fields declare an action of their own.
|
|
95
|
+
def check(path, model, rule, findings, reported)
|
|
96
|
+
adapter.check(model, rule).each do |message|
|
|
97
|
+
findings.problem(:adapter, path, message) if reported.add?(message)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def audit_fields(type, findings, model:, bypassed:, reported:)
|
|
102
|
+
type.all_field_definitions.each do |field|
|
|
103
|
+
path = "#{type.graphql_name}.#{field.original_name}"
|
|
104
|
+
next findings.problem(:field_without_rule, path, "its field class does not include Base, so it is denied") if bypassed && !field.respond_to?(:armory_declared)
|
|
105
|
+
next unless field.respond_to?(:armory_declared)
|
|
106
|
+
next audit_resolver(field, path, findings) if field.resolver.respond_to?(:armory_rule)
|
|
107
|
+
|
|
108
|
+
audit_declaration(field, path, findings, model: model, bypassed: bypassed, reported: reported)
|
|
109
|
+
audit_visibility(field, path, findings, model: model, reported: reported)
|
|
110
|
+
audit_loads(field, path, findings)
|
|
111
|
+
audit_arguments(field, path, findings, reported: reported)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# A resolver or mutation carries its own rule: no rule is denied, `:none` is listed, an action is checked.
|
|
116
|
+
def audit_resolver(field, path, findings)
|
|
117
|
+
resolver = field.resolver
|
|
118
|
+
rule = resolver.armory_rule
|
|
119
|
+
return findings.problem(:no_rule, path, "its mutation or resolver declares no `authorize`, so it is denied") if rule.nil?
|
|
120
|
+
return findings.bypass(path, rule.reason) if rule.none?
|
|
121
|
+
|
|
122
|
+
model = begin
|
|
123
|
+
resolver.armory_model
|
|
124
|
+
rescue ConfigurationError => error
|
|
125
|
+
return findings.problem(:model, path, error.message)
|
|
126
|
+
end
|
|
127
|
+
check(path, model, rule, findings, Set.new)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# The action a field is hidden with is asked of the policy like any other, so it must exist.
|
|
131
|
+
def audit_visibility(field, path, findings, model:, reported:)
|
|
132
|
+
return unless model && field.armory_visible_when
|
|
133
|
+
|
|
134
|
+
check(path.split(".").first, model, Rule.new(field.armory_visible_when, attribute: field.original_name), findings, reported)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def audit_declaration(field, path, findings, model:, bypassed:, reported:)
|
|
138
|
+
case (declared = field.armory_declared)
|
|
139
|
+
when :none
|
|
140
|
+
findings.bypass(path, field.armory_reason)
|
|
141
|
+
when nil, true
|
|
142
|
+
if bypassed
|
|
143
|
+
findings.problem(:field_without_rule, path, "declares no rule and its type is bypassed, so it is denied")
|
|
144
|
+
end
|
|
145
|
+
else
|
|
146
|
+
check(path.split(".").first, model, Rule.new(declared), findings, reported) if model
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# An argument that declares a rule is listed (a bypass with its reason) or checked like a field's action.
|
|
151
|
+
def audit_arguments(field, path, findings, reported:)
|
|
152
|
+
field.all_argument_definitions.each do |argument|
|
|
153
|
+
next unless argument.respond_to?(:armory_declared)
|
|
154
|
+
|
|
155
|
+
apath = "#{path}(#{argument.keyword})"
|
|
156
|
+
declared = argument.armory_declared
|
|
157
|
+
findings.bypass(apath, argument.armory_reason) if declared == :none
|
|
158
|
+
[declared, argument.armory_visible_when].compact.reject { |action| action == :none }.each do |action|
|
|
159
|
+
audit_argument_action(argument, apath, action, findings, reported)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def audit_argument_action(argument, path, action, findings, reported)
|
|
165
|
+
check(path.split(".").first, argument.armory_model, Rule.new(action, attribute: argument.keyword), findings, reported)
|
|
166
|
+
rescue ConfigurationError => error
|
|
167
|
+
findings.problem(:model, path, error.message)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def audit_loads(field, path, findings)
|
|
171
|
+
return if field.armory_load_through_scope?
|
|
172
|
+
|
|
173
|
+
field.all_argument_definitions.select(&:loads).each do |argument|
|
|
174
|
+
name = GraphQL::Schema::Member::BuildType.underscore(argument.graphql_name)
|
|
175
|
+
findings.problem(:unscoped_load, "#{path}(#{name})", "loads through the app's object_from_id, not through the user's scope")
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Collects what one audit finds.
|
|
180
|
+
class Findings
|
|
181
|
+
def initialize
|
|
182
|
+
@problems = []
|
|
183
|
+
@bypasses = []
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def problem(kind, path, message)
|
|
187
|
+
@problems << Report::Problem.new(kind, path, message)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def bypass(path, reason)
|
|
191
|
+
@bypasses << Report::Bypass.new(path, reason)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def report
|
|
195
|
+
Report.new(problems: @problems.sort_by { |problem| [problem.path, problem.kind.to_s] },
|
|
196
|
+
bypasses: @bypasses.sort_by(&:path))
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Answers the report it was given, for tests.
|
|
201
|
+
class Substitute
|
|
202
|
+
def initialize(report)
|
|
203
|
+
@report = report
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def call(_schema)
|
|
207
|
+
@report
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module Armory
|
|
5
|
+
module Authorization
|
|
6
|
+
# Can this actor perform this rule's action on this subject? Anything but a clear `true` is a denial.
|
|
7
|
+
class Authorize
|
|
8
|
+
attr_reader :adapter, :memo, :telemetry
|
|
9
|
+
|
|
10
|
+
# `new` is inert: with no configuration it denies everything.
|
|
11
|
+
def initialize(adapter: Adapters::Null.new, memo: Memo.new, telemetry: Telemetry.new)
|
|
12
|
+
@adapter = adapter
|
|
13
|
+
@memo = memo
|
|
14
|
+
@telemetry = telemetry
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.build(context, adapter:, sinks: [])
|
|
18
|
+
new(adapter: adapter, memo: Memo.build(context), telemetry: Telemetry.build(sinks: sinks))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.call(subject, rule, context, adapter:, sinks: [])
|
|
22
|
+
build(context, adapter: adapter, sinks: sinks).(subject, rule, context)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def call(subject, rule, context)
|
|
26
|
+
return bypass(subject, rule) if rule.none?
|
|
27
|
+
|
|
28
|
+
memo.fetch(subject, rule) { decide(subject, rule, context) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def bypass(subject, rule)
|
|
34
|
+
telemetry.record(:bypassed, type: subject.class.name, reason: rule.reason)
|
|
35
|
+
true
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def decide(subject, rule, context)
|
|
39
|
+
answer = adapter.authorized?(adapter.actor(context), subject, rule)
|
|
40
|
+
|
|
41
|
+
case answer
|
|
42
|
+
when true, false, nil
|
|
43
|
+
allowed = answer == true
|
|
44
|
+
telemetry.record(allowed ? :allowed : :denied, type: subject.class.name, action: rule.action)
|
|
45
|
+
allowed
|
|
46
|
+
else
|
|
47
|
+
raise AdapterError, "#{adapter.class} answered #{answer.inspect}; it must answer true, false or nil"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Answers what it was told and keeps the calls, for tests.
|
|
52
|
+
class Substitute
|
|
53
|
+
attr_reader :calls
|
|
54
|
+
|
|
55
|
+
def initialize(answer: true)
|
|
56
|
+
@answer = answer
|
|
57
|
+
@calls = []
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def call(subject, rule, _context)
|
|
61
|
+
@calls << [subject, rule]
|
|
62
|
+
@answer
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module Armory
|
|
5
|
+
module Authorization
|
|
6
|
+
# What `use GraphQL::Armory::Authorization, ...` stores on the schema.
|
|
7
|
+
class Configuration
|
|
8
|
+
MODES = %i[null error raise].freeze
|
|
9
|
+
|
|
10
|
+
attr_reader :adapter, :on_unauthorized, :sinks
|
|
11
|
+
|
|
12
|
+
def initialize(adapter:, on_unauthorized: :null, sinks: [])
|
|
13
|
+
unless MODES.include?(on_unauthorized)
|
|
14
|
+
raise ArgumentError, "on_unauthorized must be one of #{MODES.inspect}, got #{on_unauthorized.inspect}"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
@adapter = resolve(adapter)
|
|
18
|
+
@on_unauthorized = on_unauthorized
|
|
19
|
+
@sinks = sinks
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def resolve(adapter)
|
|
25
|
+
case adapter
|
|
26
|
+
when :pundit then Adapters::Pundit.new
|
|
27
|
+
when :cancan then Adapters::CanCan.new
|
|
28
|
+
when Symbol, String then raise ArgumentError, "unknown adapter #{adapter.inspect}; use :pundit, :cancan or an adapter object"
|
|
29
|
+
else adapter
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module Armory
|
|
5
|
+
module Authorization
|
|
6
|
+
module Controls
|
|
7
|
+
# Stands in for GraphQL::Query::Context: values plus a namespace per library.
|
|
8
|
+
class Context
|
|
9
|
+
def self.example(**values)
|
|
10
|
+
new(values)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(values)
|
|
14
|
+
@values = values
|
|
15
|
+
@namespaces = Hash.new { |namespaces, name| namespaces[name] = {} }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def [](key)
|
|
19
|
+
@values[key]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def namespace(name)
|
|
23
|
+
@namespaces[name]
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module Armory
|
|
5
|
+
module Authorization
|
|
6
|
+
module Controls
|
|
7
|
+
module Rule
|
|
8
|
+
def self.example(action: :read, **options)
|
|
9
|
+
Authorization::Rule.new(action, **options)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module Armory
|
|
5
|
+
module Authorization
|
|
6
|
+
class Error < Armory::Error; end
|
|
7
|
+
|
|
8
|
+
# An adapter answered something other than true, false or nil.
|
|
9
|
+
class AdapterError < Error; end
|
|
10
|
+
|
|
11
|
+
# An adapter cannot narrow a list to what the actor may see.
|
|
12
|
+
class MissingScopeError < Error; end
|
|
13
|
+
|
|
14
|
+
# The schema or a class was not set up the way the integration needs.
|
|
15
|
+
class ConfigurationError < Error; end
|
|
16
|
+
|
|
17
|
+
# Raised on a denial when the schema asks for `on_unauthorized: :raise`.
|
|
18
|
+
# It names the type but never the record.
|
|
19
|
+
class NotAuthorizedError < Error
|
|
20
|
+
attr_reader :type_name
|
|
21
|
+
|
|
22
|
+
def initialize(type_name = nil)
|
|
23
|
+
@type_name = type_name
|
|
24
|
+
super("Not authorized to access #{type_name || 'this object'}")
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module Armory
|
|
5
|
+
module Authorization
|
|
6
|
+
module Integration
|
|
7
|
+
# Included in an argument class by `include GraphQL::Armory::Authorization::Base`.
|
|
8
|
+
#
|
|
9
|
+
# argument :include_internal, Boolean, authorize: :manage # checked when the client sends it
|
|
10
|
+
# argument :tag, String, visible_when: :export # hidden from users who cannot export
|
|
11
|
+
# argument :status, String, authorize: :none, reason: "public" # declared, listed in the audit
|
|
12
|
+
#
|
|
13
|
+
# An argument with no rule is covered by its field: the field already decides whether the user may call it.
|
|
14
|
+
# A declared rule is checked on top, only when the client sends the argument (graphql-ruby skips
|
|
15
|
+
# arguments left at their default), and a denial denies the whole field.
|
|
16
|
+
#
|
|
17
|
+
# The question is asked of the parent object when it is an instance of the model, else of the model's
|
|
18
|
+
# class (a root field has no record). The model is the one the field's type wraps, or `model:`.
|
|
19
|
+
module Argument
|
|
20
|
+
def initialize(*args, authorize: nil, reason: nil, model: nil, visible_when: nil, **kwargs, &block)
|
|
21
|
+
unless [nil, :none].include?(authorize) || authorize.is_a?(Symbol)
|
|
22
|
+
raise ArgumentError, "an argument's `authorize:` is an action or :none, not #{authorize.inspect}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
if authorize && authorize != :none && kwargs.key?(:default_value)
|
|
26
|
+
raise ArgumentError,
|
|
27
|
+
"a restricted argument cannot have a default_value: the interpreter skips the check for a default " \
|
|
28
|
+
"and Execution::Next applies it, so the engines would disagree"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
@armory_declared = authorize
|
|
32
|
+
@armory_reason = reason
|
|
33
|
+
@armory_model = model
|
|
34
|
+
@armory_visible_when = visible_when&.to_sym
|
|
35
|
+
Rule.none(reason: reason) if authorize == :none
|
|
36
|
+
super(*args, **kwargs, &block)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
attr_reader :armory_declared, :armory_reason, :armory_visible_when
|
|
40
|
+
|
|
41
|
+
def armory_model
|
|
42
|
+
@armory_model || armory_field_model
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def authorized?(object, value, context)
|
|
46
|
+
return super if @armory_declared.nil? || @armory_declared == :none
|
|
47
|
+
|
|
48
|
+
model = armory_model
|
|
49
|
+
subject = object.is_a?(model) ? object : model
|
|
50
|
+
Integration.allowed?(subject, Rule.new(@armory_declared, attribute: keyword), context) && super
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def visible?(context)
|
|
54
|
+
return super unless @armory_visible_when
|
|
55
|
+
|
|
56
|
+
super && Integration.visible?(armory_model, Rule.new(@armory_visible_when, attribute: keyword), context)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def armory_field_model
|
|
62
|
+
type = owner.respond_to?(:owner) ? owner.owner : owner
|
|
63
|
+
model = type.armory_model if type.respond_to?(:armory_model)
|
|
64
|
+
return model if model
|
|
65
|
+
|
|
66
|
+
raise ConfigurationError, "#{keyword}: cannot tell which model to ask about, pass `model: YourModel`"
|
|
67
|
+
rescue ConfigurationError
|
|
68
|
+
raise ConfigurationError, "#{keyword}: cannot tell which model to ask about, pass `model: YourModel`"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module Armory
|
|
5
|
+
module Authorization
|
|
6
|
+
# One module for every base class. It detects what it was included in.
|
|
7
|
+
#
|
|
8
|
+
# class Types::BaseObject < GraphQL::Schema::Object
|
|
9
|
+
# include GraphQL::Armory::Authorization::Base
|
|
10
|
+
# default_action :read
|
|
11
|
+
# end
|
|
12
|
+
# class Types::BaseField < GraphQL::Schema::Field
|
|
13
|
+
# include GraphQL::Armory::Authorization::Base
|
|
14
|
+
# end
|
|
15
|
+
module Base
|
|
16
|
+
def self.included(base)
|
|
17
|
+
if base <= GraphQL::Schema::Object
|
|
18
|
+
base.extend(Integration::Object)
|
|
19
|
+
elsif base <= GraphQL::Schema::Field
|
|
20
|
+
base.include(Integration::Field)
|
|
21
|
+
elsif base <= GraphQL::Schema::Resolver
|
|
22
|
+
base.extend(Integration::ResolverClass)
|
|
23
|
+
base.include(Integration::Resolver)
|
|
24
|
+
elsif base <= GraphQL::Schema::Argument
|
|
25
|
+
base.include(Integration::Argument)
|
|
26
|
+
else
|
|
27
|
+
raise ArgumentError,
|
|
28
|
+
"GraphQL::Armory::Authorization::Base can only be included in a GraphQL object, field, argument, resolver or mutation class, not #{base}. Interfaces and unions are not authorized themselves: declare the rule on their possible types"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|