reeve 0.0.1 → 0.2.0
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 +191 -0
- data/README.md +280 -17
- data/lib/generators/reeve/install/install_generator.rb +46 -0
- data/lib/generators/reeve/install/templates/create_audit_entries.rb.tt +77 -0
- data/lib/generators/reeve/install/templates/initializer.rb.tt +65 -0
- data/lib/reeve/audit/entry.rb +54 -0
- data/lib/reeve/audit/query.rb +84 -0
- data/lib/reeve/audit/recorder.rb +181 -0
- data/lib/reeve/audit/redactor.rb +67 -0
- data/lib/reeve/audit.rb +97 -0
- data/lib/reeve/authorization/adapter.rb +71 -0
- data/lib/reeve/authorization/adapters/plain.rb +65 -0
- data/lib/reeve/authorization/adapters/pundit.rb +97 -0
- data/lib/reeve/authorization/authorizer.rb +23 -0
- data/lib/reeve/authorization/current.rb +66 -0
- data/lib/reeve/authorization/declaration.rb +73 -0
- data/lib/reeve/authorization/guard.rb +97 -0
- data/lib/reeve/authorization/registry.rb +118 -0
- data/lib/reeve/authorization/scoper.rb +399 -0
- data/lib/reeve/authorization.rb +76 -0
- data/lib/reeve/configuration.rb +158 -0
- data/lib/reeve/context.rb +111 -0
- data/lib/reeve/decision.rb +111 -0
- data/lib/reeve/errors.rb +89 -0
- data/lib/reeve/fast_mcp.rb +24 -0
- data/lib/reeve/integrations/fast_mcp/context_builder.rb +48 -0
- data/lib/reeve/integrations/fast_mcp/tool_extension.rb +55 -0
- data/lib/reeve/invocation.rb +260 -0
- data/lib/reeve/minitest.rb +19 -0
- data/lib/reeve/rspec.rb +18 -0
- data/lib/reeve/scope_result.rb +104 -0
- data/lib/reeve/testing/assertions.rb +76 -0
- data/lib/reeve/testing/checks/audit_coverage.rb +46 -0
- data/lib/reeve/testing/checks/base.rb +155 -0
- data/lib/reeve/testing/checks/contract_version.rb +97 -0
- data/lib/reeve/testing/checks/cross_principal_leak.rb +132 -0
- data/lib/reeve/testing/checks/guard_declared.rb +33 -0
- data/lib/reeve/testing/checks/principal_required.rb +60 -0
- data/lib/reeve/testing/checks/redaction_holds.rb +142 -0
- data/lib/reeve/testing/checks/rule_present.rb +55 -0
- data/lib/reeve/testing/checks.rb +95 -0
- data/lib/reeve/testing/compliance_assertions.rb +34 -0
- data/lib/reeve/testing/compliance_suite.rb +23 -0
- data/lib/reeve/testing/ledger.rb +82 -0
- data/lib/reeve/testing/matchers/audit_every_call.rb +39 -0
- data/lib/reeve/testing/matchers/base.rb +84 -0
- data/lib/reeve/testing/matchers/deny_access_for.rb +38 -0
- data/lib/reeve/testing/matchers/pass_reeve_check.rb +32 -0
- data/lib/reeve/testing/matchers.rb +33 -0
- data/lib/reeve/testing/report.rb +58 -0
- data/lib/reeve/testing/result.rb +49 -0
- data/lib/reeve/testing.rb +60 -0
- data/lib/reeve/version.rb +1 -1
- data/lib/reeve.rb +10 -3
- metadata +53 -2
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reeve
|
|
4
|
+
module Authorization
|
|
5
|
+
# Narrows whatever a tool returned to what the principal may actually see, and reports
|
|
6
|
+
# what it narrowed for the ledger. Every row of the return-value table in
|
|
7
|
+
# contracts/tool-dsl.md is one branch of `scope`.
|
|
8
|
+
#
|
|
9
|
+
# The envelope reads records out of this result rather than out of the tool's return
|
|
10
|
+
# value, so "nothing is returned without being scoped" is structural.
|
|
11
|
+
class Scoper
|
|
12
|
+
# Called by `Reeve::Guard#scoped` from inside a tool body. Marks the invocation as
|
|
13
|
+
# having asked for a scoped relation, which is what makes a derived return value
|
|
14
|
+
# (a count, a sum) safe to allow.
|
|
15
|
+
def self.scoped_relation(state, model_or_relation)
|
|
16
|
+
adapter = state.adapter
|
|
17
|
+
declaration = state.declaration
|
|
18
|
+
policy = policy_for(adapter, declaration, model_class(model_or_relation))
|
|
19
|
+
raise Error, "no policy for #{model_or_relation.inspect}" if policy.nil?
|
|
20
|
+
|
|
21
|
+
relation = model_or_relation.respond_to?(:all) ? model_or_relation.all : model_or_relation
|
|
22
|
+
scoped = adapter.scope(principal: state.context.principal, policy: policy,
|
|
23
|
+
relation: relation)
|
|
24
|
+
|
|
25
|
+
state.scoped_used!
|
|
26
|
+
state.scoped_source_count = countable_size(scoped)
|
|
27
|
+
scoped
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The declared policy governs, full stop — a declaration is an instruction, not a
|
|
31
|
+
# hint. Only a result mixing several types needs more than one policy, and only the
|
|
32
|
+
# types the declaration is *not* named for are resolved by convention; a type with
|
|
33
|
+
# no policy denies the call (unknown_record_type).
|
|
34
|
+
#
|
|
35
|
+
# Inferring a policy from the record's class in the single-type case would mean a
|
|
36
|
+
# tool declaring `guard_with LeakyPolicy` was silently enforced by `InvoicePolicy`:
|
|
37
|
+
# the guard the developer declared would not be the guard that ran.
|
|
38
|
+
def self.policy_for(adapter, declaration, record_class)
|
|
39
|
+
return declaration.policy if record_class.nil?
|
|
40
|
+
return declaration.policy unless declared_for_other_type?(declaration, record_class)
|
|
41
|
+
|
|
42
|
+
adapter.policy_for(record_class) || nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# True only when the declaration names a *different* model that actually exists —
|
|
46
|
+
# `InvoicePolicy` on a tool that also returned `Memo`s. A policy whose name matches
|
|
47
|
+
# no model (`LeakyPolicy`, `ApplicationPolicy`) is generic, and governs whatever the
|
|
48
|
+
# tool it was declared on returns.
|
|
49
|
+
#
|
|
50
|
+
# "Exists" means *is a model*, not merely "is a defined constant". Asking
|
|
51
|
+
# `Object.const_defined?` alone handed `DataPolicy`, `SetPolicy`, `FilePolicy` and
|
|
52
|
+
# every anonymous policy class (whose name falls back to "Class") to a
|
|
53
|
+
# convention-named policy instead, because `Data`, `Set`, `File` and `Class` are all
|
|
54
|
+
# defined in Ruby — the same silent substitution this method exists to prevent.
|
|
55
|
+
def self.declared_for_other_type?(declaration, record_class)
|
|
56
|
+
named = declaration.policy_name.to_s.sub(/Policy\z/, "")
|
|
57
|
+
return false if named.empty? || named == base_class_name(record_class)
|
|
58
|
+
|
|
59
|
+
target = resolve_constant(named)
|
|
60
|
+
return false unless model_like?(target)
|
|
61
|
+
|
|
62
|
+
target != base_class(record_class)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.resolve_constant(name)
|
|
66
|
+
Object.const_defined?(name) ? Object.const_get(name) : nil
|
|
67
|
+
rescue NameError
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# A model, not just any class: something records are actually fetched from.
|
|
72
|
+
def self.model_like?(target)
|
|
73
|
+
return false unless target.is_a?(Class)
|
|
74
|
+
return true if defined?(::ActiveRecord::Base) && target <= ::ActiveRecord::Base
|
|
75
|
+
|
|
76
|
+
target.respond_to?(:all)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Single-table inheritance: `TextComment` is governed by `CommentPolicy`, because
|
|
80
|
+
# the policy is written for the table, not for each subclass. Comparing subclasses
|
|
81
|
+
# directly denied every STI result with `unknown_record_type`.
|
|
82
|
+
def self.base_class(record_class)
|
|
83
|
+
return record_class unless record_class.is_a?(Class)
|
|
84
|
+
return record_class unless record_class.respond_to?(:base_class)
|
|
85
|
+
|
|
86
|
+
record_class.base_class
|
|
87
|
+
rescue StandardError
|
|
88
|
+
record_class
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# `InvoicePolicy` is named for `Invoice`.
|
|
92
|
+
def self.policy_named_for?(policy, record_class)
|
|
93
|
+
name = policy.respond_to?(:name) && policy.name ? policy.name : policy.class.name
|
|
94
|
+
name.to_s.sub(/Policy\z/, "") == base_class_name(record_class)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def self.base_class_name(record_class)
|
|
98
|
+
base_class(record_class).name.to_s
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def self.model_class(model_or_relation)
|
|
102
|
+
return model_or_relation if model_or_relation.is_a?(Class)
|
|
103
|
+
return model_or_relation.klass if model_or_relation.respond_to?(:klass)
|
|
104
|
+
|
|
105
|
+
model_or_relation.class
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def self.countable_size(scoped)
|
|
109
|
+
scoped.respond_to?(:count) ? scoped.count : nil
|
|
110
|
+
rescue StandardError
|
|
111
|
+
nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def scope(context:, guard:, result:)
|
|
115
|
+
state = Current.state
|
|
116
|
+
adapter = state ? state.adapter : Adapter.resolve(guard.policy)
|
|
117
|
+
|
|
118
|
+
case result
|
|
119
|
+
when nil then ScopeResult.allow(records: result, record_count: 0)
|
|
120
|
+
else dispatch(context: context, guard: guard, result: result, adapter: adapter,
|
|
121
|
+
state: state)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
def dispatch(context:, guard:, result:, adapter:, state:)
|
|
128
|
+
if relation?(result)
|
|
129
|
+
scope_relation(context, guard, result, adapter)
|
|
130
|
+
elsif result.is_a?(Array)
|
|
131
|
+
scope_array(context, guard, result, adapter)
|
|
132
|
+
elsif record?(result)
|
|
133
|
+
scope_single_record(context, guard, result, adapter)
|
|
134
|
+
else
|
|
135
|
+
scope_derived(result, state)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def scope_relation(context, guard, relation, adapter)
|
|
140
|
+
policy = policy_for!(adapter, guard, relation_class(relation))
|
|
141
|
+
return unpoliced(relation_class(relation)) if policy.nil?
|
|
142
|
+
|
|
143
|
+
scoped = adapter.scope(principal: context.principal, policy: policy, relation: relation)
|
|
144
|
+
allow_records(scoped, relation_class(relation).name, record_ids(scoped))
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# An array can hold records, non-records, or both. Each part is judged on its own
|
|
148
|
+
# terms: records are scoped, and anything else is a derived value, allowed only if
|
|
149
|
+
# the tool asked for a scoped relation.
|
|
150
|
+
#
|
|
151
|
+
# This used to hand the whole array to the derived path the moment one element was
|
|
152
|
+
# not a record, so a tool returning `[invoice, invoice, { total: 2 }]` had its
|
|
153
|
+
# invoices returned entirely unscoped as soon as it had called `scoped(...)`
|
|
154
|
+
# anywhere in its body — with no identifiers in the ledger to show for it.
|
|
155
|
+
def scope_array(context, guard, items, adapter)
|
|
156
|
+
return ScopeResult.allow(records: [], record_count: 0) if items.empty?
|
|
157
|
+
|
|
158
|
+
records, others = items.partition { |item| record?(item) }
|
|
159
|
+
return scope_derived(items, Current.state) if records.empty?
|
|
160
|
+
return unscoped_derived(others.first) unless derivation_established?(others)
|
|
161
|
+
|
|
162
|
+
kept = keep_in_scope(context, guard, records, adapter)
|
|
163
|
+
kept.is_a?(ScopeResult) ? kept : allow_survivors(items, kept)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def derivation_established?(others)
|
|
167
|
+
others.empty? || Current.state&.scoped_used? || false
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Keeps the tool's own ordering: the caller asked for a list, not a set.
|
|
171
|
+
def allow_survivors(items, kept)
|
|
172
|
+
surviving = items.select { |item| record?(item) ? kept.include?(item) : true }
|
|
173
|
+
|
|
174
|
+
allow_records(surviving, dominant_type(kept), kept.map { |record| identifier(record) })
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# The records the principal may see, or a denial when some type has no policy.
|
|
178
|
+
def keep_in_scope(context, guard, records, adapter)
|
|
179
|
+
kept = []
|
|
180
|
+
groups = records.group_by { |record| self.class.base_class(record.class) }
|
|
181
|
+
|
|
182
|
+
groups.each do |record_class, group|
|
|
183
|
+
policy = policy_for!(adapter, guard, record_class)
|
|
184
|
+
return unpoliced(record_class) if policy.nil?
|
|
185
|
+
return ungoverned(record_class) unless governed?(record_class, policy, adapter)
|
|
186
|
+
|
|
187
|
+
kept.concat(in_scope(context, policy, adapter, record_class, group))
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
kept
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# A type with no relation cannot be checked against a scope, only against the
|
|
194
|
+
# policy's own `authorize` — and a policy whose real protection lives in `scope`
|
|
195
|
+
# commonly answers `true` to everything else. So a scope-less type is trusted only
|
|
196
|
+
# when something ties it to the policy: a policy named for it, or a `scoped(...)`
|
|
197
|
+
# call establishing where the values came from.
|
|
198
|
+
#
|
|
199
|
+
# Without this, a tool returning `Invoice.all.map { Row.new(...) }` had every row
|
|
200
|
+
# allowed by a catch-all `authorize`, which is what a Struct used to be protected
|
|
201
|
+
# from by being classed as a derived value.
|
|
202
|
+
def governed?(record_class, policy, adapter)
|
|
203
|
+
return true if relation_source?(record_class)
|
|
204
|
+
return true if Current.state&.scoped_used?
|
|
205
|
+
return true if self.class.policy_named_for?(policy, record_class)
|
|
206
|
+
|
|
207
|
+
!adapter.policy_for(self.class.base_class(record_class)).nil?
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def ungoverned(record_class)
|
|
211
|
+
ScopeResult.deny(
|
|
212
|
+
rule: Decision::UNKNOWN_RECORD_TYPE,
|
|
213
|
+
detail: "#{record_class} has no relation to scope and no policy named for it, " \
|
|
214
|
+
"so what the principal may see cannot be established — derive it " \
|
|
215
|
+
"through scoped(...) or give the type its own policy"
|
|
216
|
+
)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def unscoped_derived(example)
|
|
220
|
+
ScopeResult.deny(
|
|
221
|
+
rule: Decision::UNSCOPED_DERIVED_RESULT,
|
|
222
|
+
detail: "the tool returned a #{example.class} alongside records without calling " \
|
|
223
|
+
"scoped(...), so what it was derived from cannot be established"
|
|
224
|
+
)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# FR-006: a record outside the scope is never returned and never distinguished from
|
|
228
|
+
# one that does not exist. The denial says nothing about the record.
|
|
229
|
+
def scope_single_record(context, guard, record, adapter)
|
|
230
|
+
policy = policy_for!(adapter, guard, record.class)
|
|
231
|
+
return unpoliced(record.class) if policy.nil?
|
|
232
|
+
return ungoverned(record.class) unless governed?(record.class, policy, adapter)
|
|
233
|
+
|
|
234
|
+
return ScopeResult.deny(rule: Decision::OUT_OF_SCOPE_RECORD) if
|
|
235
|
+
in_scope(context, policy, adapter, record.class, [record]).empty?
|
|
236
|
+
|
|
237
|
+
allow_records(record, record.class.name, [identifier(record)])
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Anything that is not a record: a count, a sum, a string, a hash. Safe only if the
|
|
241
|
+
# tool asked for a scoped relation rather than reaching for the model directly (R4).
|
|
242
|
+
def scope_derived(result, state)
|
|
243
|
+
unless state&.scoped_used?
|
|
244
|
+
return ScopeResult.deny(
|
|
245
|
+
rule: Decision::UNSCOPED_DERIVED_RESULT,
|
|
246
|
+
detail: "the tool returned a #{result.class} without calling scoped(...), " \
|
|
247
|
+
"so what it was derived from cannot be established"
|
|
248
|
+
)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
ScopeResult.allow(records: result, derived: true,
|
|
252
|
+
record_count: state.scoped_source_count || 0)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# "May this principal see these records?" has two honest answers and one dishonest
|
|
256
|
+
# one. For a model backed by a relation, ask the policy scope which of *these*
|
|
257
|
+
# identifiers it admits. For a type with no relation to narrow, authorize each
|
|
258
|
+
# record on its own — that is what makes the core usable with no database.
|
|
259
|
+
#
|
|
260
|
+
# The dishonest answer, which this used to give: if the scope could not be read,
|
|
261
|
+
# fall back to a per-record `authorize` and keep whatever it permits. A policy whose
|
|
262
|
+
# `Scope#resolve` ends in `.to_a`, or a model whose primary key is not `id`, would
|
|
263
|
+
# silently downgrade set-based scoping to a `show?` check that is commonly
|
|
264
|
+
# `user.present?` — every record kept, recorded as properly scoped. A scope we
|
|
265
|
+
# cannot read is now a denial (Constitution I: unclear means no).
|
|
266
|
+
def in_scope(context, policy, adapter, record_class, records)
|
|
267
|
+
unless relation_source?(record_class)
|
|
268
|
+
return authorize_each(adapter, context, policy,
|
|
269
|
+
records)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
visible = visible_ids_among(context, policy, adapter, record_class, records)
|
|
273
|
+
records.select { |record| visible.include?(identifier(record)) }
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def authorize_each(adapter, context, policy, records)
|
|
277
|
+
records.select { |record| allowed?(adapter, context, policy, record) }
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Bounded by the records actually being checked, never by the table: a single-record
|
|
281
|
+
# lookup against a scope of two million rows must not pluck two million ids.
|
|
282
|
+
def visible_ids_among(context, policy, adapter, record_class, records)
|
|
283
|
+
scoped = adapter.scope(
|
|
284
|
+
principal: context.principal, policy: policy, relation: record_class.all
|
|
285
|
+
)
|
|
286
|
+
raise Error, "#{policy} returned no scope for #{record_class}" if scoped.nil?
|
|
287
|
+
|
|
288
|
+
ids = records.map { |record| record_id(record) }.compact
|
|
289
|
+
return [] if ids.empty?
|
|
290
|
+
|
|
291
|
+
narrowed = scoped.respond_to?(:where) ? scoped.where(id: ids) : scoped
|
|
292
|
+
identifiers_of(narrowed)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Raises rather than returning nil: the caller cannot tell "nothing is visible" from
|
|
296
|
+
# "I could not find out", and only one of those is safe to act on.
|
|
297
|
+
def identifiers_of(scoped)
|
|
298
|
+
return scoped.pluck(:id).map(&:to_s) if scoped.respond_to?(:pluck)
|
|
299
|
+
|
|
300
|
+
Array(scoped).map { |record| identifier(record) }
|
|
301
|
+
rescue StandardError => e
|
|
302
|
+
raise Error,
|
|
303
|
+
"could not read the policy scope to establish record visibility " \
|
|
304
|
+
"(#{e.class}: #{e.message})"
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def relation_source?(record_class)
|
|
308
|
+
active_record_class?(record_class)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def active_record_class?(record_class)
|
|
312
|
+
defined?(::ActiveRecord::Base) &&
|
|
313
|
+
record_class.is_a?(Class) &&
|
|
314
|
+
record_class <= ::ActiveRecord::Base
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# The action the guard declared, not a hardcoded one: a host whose policies speak
|
|
318
|
+
# `:read` would otherwise fall through to a catch-all `else` and permit everything.
|
|
319
|
+
def allowed?(adapter, context, policy, record)
|
|
320
|
+
adapter.authorize(
|
|
321
|
+
principal: context.principal, policy: policy, action: current_action, record: record
|
|
322
|
+
).allowed?
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def current_action
|
|
326
|
+
Current.state&.declaration&.action || Reeve.config.default_action
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def visible_ids(scoped)
|
|
330
|
+
identifiers_of(scoped)
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def policy_for!(adapter, guard, record_class)
|
|
334
|
+
self.class.policy_for(adapter, guard, self.class.base_class(record_class))
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def unpoliced(record_class)
|
|
338
|
+
ScopeResult.deny(
|
|
339
|
+
rule: Decision::UNKNOWN_RECORD_TYPE,
|
|
340
|
+
detail: "no policy governs #{record_class}, so the result cannot be scoped"
|
|
341
|
+
)
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def allow_records(records, record_type, ids)
|
|
345
|
+
limit = Reeve.config.max_recorded_ids
|
|
346
|
+
total = ids.size
|
|
347
|
+
|
|
348
|
+
ScopeResult.allow(
|
|
349
|
+
records: records,
|
|
350
|
+
record_type: record_type,
|
|
351
|
+
record_ids: ids.first(limit),
|
|
352
|
+
record_count: total,
|
|
353
|
+
truncated: total > limit
|
|
354
|
+
)
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def record_ids(scoped)
|
|
358
|
+
ids = visible_ids(scoped)
|
|
359
|
+
return ids if ids
|
|
360
|
+
|
|
361
|
+
Array(scoped).map { |record| identifier(record) }
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def record_id(record)
|
|
365
|
+
record.respond_to?(:id) ? record.id : nil
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def identifier(record)
|
|
369
|
+
record.respond_to?(:id) ? record.id.to_s : record.to_s
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def dominant_type(records)
|
|
373
|
+
return nil if records.empty?
|
|
374
|
+
|
|
375
|
+
records.group_by { |record| record.class.name }.max_by { |_, group| group.size }.first
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def relation_class(relation)
|
|
379
|
+
relation.respond_to?(:klass) ? relation.klass : relation.class
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def relation?(value)
|
|
383
|
+
defined?(::ActiveRecord::Relation) && value.is_a?(::ActiveRecord::Relation)
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# Without ActiveRecord loaded there is no authoritative answer, so this asks the
|
|
387
|
+
# only question that generalises: does it have an identity of its own?
|
|
388
|
+
def record?(value)
|
|
389
|
+
return true if defined?(::ActiveRecord::Base) && value.is_a?(::ActiveRecord::Base)
|
|
390
|
+
# Hashes and arrays are shapes, not records. A Struct with an identity is a
|
|
391
|
+
# record like any other — excluding it was arbitrary, and it is exactly what a
|
|
392
|
+
# host without ActiveRecord reaches for.
|
|
393
|
+
return false if value.is_a?(Hash) || value.is_a?(Array)
|
|
394
|
+
|
|
395
|
+
value.respond_to?(:id) && value.class.respond_to?(:name) && !value.class.name.nil?
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "authorization/declaration"
|
|
4
|
+
require_relative "authorization/registry"
|
|
5
|
+
require_relative "authorization/adapters/plain"
|
|
6
|
+
require_relative "authorization/adapters/pundit"
|
|
7
|
+
require_relative "authorization/adapter"
|
|
8
|
+
require_relative "authorization/current"
|
|
9
|
+
require_relative "authorization/authorizer"
|
|
10
|
+
require_relative "authorization/scoper"
|
|
11
|
+
require_relative "authorization/guard"
|
|
12
|
+
|
|
13
|
+
# Reeve — per-record authorization and an append-only audit ledger for MCP tools.
|
|
14
|
+
module Reeve
|
|
15
|
+
# Per-record authorization: the registry of guard declarations, the policy adapters,
|
|
16
|
+
# and the scoper that narrows a tool's return value to what the principal may see.
|
|
17
|
+
module Authorization
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
# The plain interface, and the composition root for every other one. An MCP server
|
|
22
|
+
# adapter builds the Context and calls this; there is no second path into the
|
|
23
|
+
# envelope, which is what makes "was this authorized and recorded?" answerable in
|
|
24
|
+
# one place.
|
|
25
|
+
#
|
|
26
|
+
# Reeve.invoke(tool: InvoiceSearchTool, arguments: { query: "AC" },
|
|
27
|
+
# principal: current_user, agent: { id: "claude-desktop" })
|
|
28
|
+
def invoke(tool:, arguments: {}, principal: :unset, agent: nil, metadata: {}, &body)
|
|
29
|
+
context = Context.new(
|
|
30
|
+
tool_name: tool_name_for(tool),
|
|
31
|
+
agent: agent,
|
|
32
|
+
arguments: arguments,
|
|
33
|
+
metadata: metadata
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
declaration = registry.guard_for(context.tool_name)
|
|
37
|
+
adapter = declaration ? Authorization::Adapter.resolve(declaration.policy) : nil
|
|
38
|
+
|
|
39
|
+
Authorization::Current.with(context: context, declaration: declaration, adapter: adapter) do
|
|
40
|
+
Invocation.call(
|
|
41
|
+
context,
|
|
42
|
+
registry: registry,
|
|
43
|
+
authorizer: Authorization::Authorizer.new,
|
|
44
|
+
scoper: Authorization::Scoper.new,
|
|
45
|
+
config: configuration_for(principal)
|
|
46
|
+
) { body ? body.call : run(tool, arguments) }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def run(tool, arguments)
|
|
53
|
+
instance = tool.is_a?(Class) ? tool.new : tool
|
|
54
|
+
arguments.empty? ? instance.call : instance.call(**arguments)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def tool_name_for(tool)
|
|
58
|
+
klass = tool.is_a?(Class) ? tool : tool.class
|
|
59
|
+
declaration = registry.for_class(klass)
|
|
60
|
+
return declaration.tool_name if declaration
|
|
61
|
+
return klass.tool_name.to_s if klass.respond_to?(:tool_name) && klass.tool_name
|
|
62
|
+
|
|
63
|
+
Authorization::Declaration.new(tool_class: klass, policy: nil, action: :index).tool_name
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# An explicitly supplied principal is just a resolver that returns it. Routing it
|
|
67
|
+
# through the same resolution step rather than around it keeps one answer to "where
|
|
68
|
+
# did this principal come from" — the envelope still resolves, records and denies
|
|
69
|
+
# identically, and a nil passed in still denies with `no_principal`.
|
|
70
|
+
def configuration_for(principal)
|
|
71
|
+
return config if principal == :unset
|
|
72
|
+
|
|
73
|
+
config.dup.tap { |scoped| scoped.principal_resolver = ->(_context) { principal } }
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Reeve — per-record authorization and an append-only audit ledger for MCP tools.
|
|
4
|
+
module Reeve
|
|
5
|
+
# Process-wide settings. See contracts/configuration.md.
|
|
6
|
+
#
|
|
7
|
+
# Every setting validates at assignment rather than at use: a typo in an initializer
|
|
8
|
+
# should fail on the line that caused it, not three weeks later inside a denial path.
|
|
9
|
+
# The one deliberate exception is +principal_resolver+, which may be nil — the library
|
|
10
|
+
# stays loadable in contexts that never invoke a tool, and denies every call until the
|
|
11
|
+
# host sets one (SC-008).
|
|
12
|
+
class Configuration
|
|
13
|
+
UNGUARDED_TOOL_MODES = %i[deny allow_with_warning].freeze
|
|
14
|
+
AUDIT_FAILURE_MODES = %i[fail warn].freeze
|
|
15
|
+
POLICY_ADAPTERS = %i[auto pundit plain].freeze
|
|
16
|
+
|
|
17
|
+
DEFAULT_REDACTED_ARGUMENTS = %i[
|
|
18
|
+
password password_confirmation passwd secret token access_token refresh_token
|
|
19
|
+
api_key private_key authorization ssn credit_card card_number cvv pin
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
DEFAULT_MAX_RECORDED_IDS = 1000
|
|
23
|
+
|
|
24
|
+
SETTINGS = %i[
|
|
25
|
+
principal_resolver unguarded_tools audit_failure_mode redact_arguments
|
|
26
|
+
max_recorded_ids policy_adapter default_action audit_recorder logger
|
|
27
|
+
compliance_principals
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
30
|
+
attr_reader(*SETTINGS)
|
|
31
|
+
|
|
32
|
+
def initialize
|
|
33
|
+
@principal_resolver = nil
|
|
34
|
+
@unguarded_tools = :deny
|
|
35
|
+
@audit_failure_mode = :fail
|
|
36
|
+
@redact_arguments = DEFAULT_REDACTED_ARGUMENTS.dup
|
|
37
|
+
@max_recorded_ids = DEFAULT_MAX_RECORDED_IDS
|
|
38
|
+
@policy_adapter = :auto
|
|
39
|
+
@default_action = :index
|
|
40
|
+
@audit_recorder = nil
|
|
41
|
+
@logger = nil
|
|
42
|
+
@compliance_principals = nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Two fixture principals with disjoint records — the only host setup the compliance
|
|
46
|
+
# suite needs (contracts/testing-kit.md). A callable rather than a value, because in a
|
|
47
|
+
# Rails test suite the fixtures do not exist yet when the helper is loaded.
|
|
48
|
+
def compliance_principals=(principals)
|
|
49
|
+
unless principals.nil? || principals.respond_to?(:call) || principals.is_a?(Array)
|
|
50
|
+
raise ArgumentError,
|
|
51
|
+
"compliance_principals must be an Array or a callable returning one, " \
|
|
52
|
+
"got #{principals.inspect}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
@compliance_principals = principals
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def unguarded_tools=(mode)
|
|
59
|
+
@unguarded_tools = require_one_of!(:unguarded_tools, mode, UNGUARDED_TOOL_MODES)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def audit_failure_mode=(mode)
|
|
63
|
+
@audit_failure_mode = require_one_of!(:audit_failure_mode, mode, AUDIT_FAILURE_MODES)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def max_recorded_ids=(limit)
|
|
67
|
+
unless limit.is_a?(Integer) && limit.positive?
|
|
68
|
+
raise ArgumentError, "max_recorded_ids must be a positive Integer, got #{limit.inspect}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
@max_recorded_ids = limit
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def principal_resolver=(resolver)
|
|
75
|
+
unless resolver.nil? || resolver.respond_to?(:call)
|
|
76
|
+
raise ArgumentError,
|
|
77
|
+
"principal_resolver must respond to #call (it receives a Reeve::Context), " \
|
|
78
|
+
"got #{resolver.inspect}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
@principal_resolver = resolver
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def policy_adapter=(adapter)
|
|
85
|
+
@policy_adapter =
|
|
86
|
+
if adapter.is_a?(Symbol) || adapter.is_a?(String)
|
|
87
|
+
require_one_of!(:policy_adapter, adapter, POLICY_ADAPTERS)
|
|
88
|
+
else
|
|
89
|
+
require_protocol!(:policy_adapter, adapter, %i[authorize scope])
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def redact_arguments=(names)
|
|
94
|
+
unless names.is_a?(Array)
|
|
95
|
+
raise ArgumentError, "redact_arguments must be an Array of names, got #{names.inspect}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
@redact_arguments = names.map(&:to_sym)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def default_action=(action)
|
|
102
|
+
if action.nil? || action.to_s.strip.empty?
|
|
103
|
+
raise ArgumentError,
|
|
104
|
+
"default_action must be a non-blank policy action, got #{action.inspect}"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
@default_action = action.to_sym
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def audit_recorder=(recorder)
|
|
111
|
+
@audit_recorder =
|
|
112
|
+
recorder.nil? ? nil : require_protocol!(:audit_recorder, recorder, [:record])
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def logger=(logger)
|
|
116
|
+
@logger = logger.nil? ? nil : require_protocol!(:logger, logger, [:warn])
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def to_h
|
|
120
|
+
SETTINGS.to_h { |setting| [setting, public_send(setting)] }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
def require_one_of!(setting, value, allowed)
|
|
126
|
+
symbol = value.is_a?(Symbol) ? value : nil
|
|
127
|
+
return symbol if allowed.include?(symbol)
|
|
128
|
+
|
|
129
|
+
raise ArgumentError,
|
|
130
|
+
"#{setting} must be one of #{allowed.map(&:inspect).join(', ')}, got #{value.inspect}"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def require_protocol!(setting, object, methods)
|
|
134
|
+
missing = methods.reject { |method| object.respond_to?(method) }
|
|
135
|
+
return object if missing.empty?
|
|
136
|
+
|
|
137
|
+
raise ArgumentError,
|
|
138
|
+
"#{setting} must respond to #{missing.map { |m| "##{m}" }.join(', ')} " \
|
|
139
|
+
"(got #{object.inspect})"
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
class << self
|
|
144
|
+
def config
|
|
145
|
+
@config ||= Configuration.new
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def configure
|
|
149
|
+
yield(config)
|
|
150
|
+
config
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Public so host test suites can isolate examples from one another.
|
|
154
|
+
def reset_configuration!
|
|
155
|
+
@config = Configuration.new
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|