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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +191 -0
  3. data/README.md +280 -17
  4. data/lib/generators/reeve/install/install_generator.rb +46 -0
  5. data/lib/generators/reeve/install/templates/create_audit_entries.rb.tt +77 -0
  6. data/lib/generators/reeve/install/templates/initializer.rb.tt +65 -0
  7. data/lib/reeve/audit/entry.rb +54 -0
  8. data/lib/reeve/audit/query.rb +84 -0
  9. data/lib/reeve/audit/recorder.rb +181 -0
  10. data/lib/reeve/audit/redactor.rb +67 -0
  11. data/lib/reeve/audit.rb +97 -0
  12. data/lib/reeve/authorization/adapter.rb +71 -0
  13. data/lib/reeve/authorization/adapters/plain.rb +65 -0
  14. data/lib/reeve/authorization/adapters/pundit.rb +97 -0
  15. data/lib/reeve/authorization/authorizer.rb +23 -0
  16. data/lib/reeve/authorization/current.rb +66 -0
  17. data/lib/reeve/authorization/declaration.rb +73 -0
  18. data/lib/reeve/authorization/guard.rb +97 -0
  19. data/lib/reeve/authorization/registry.rb +118 -0
  20. data/lib/reeve/authorization/scoper.rb +399 -0
  21. data/lib/reeve/authorization.rb +76 -0
  22. data/lib/reeve/configuration.rb +158 -0
  23. data/lib/reeve/context.rb +111 -0
  24. data/lib/reeve/decision.rb +111 -0
  25. data/lib/reeve/errors.rb +89 -0
  26. data/lib/reeve/fast_mcp.rb +24 -0
  27. data/lib/reeve/integrations/fast_mcp/context_builder.rb +48 -0
  28. data/lib/reeve/integrations/fast_mcp/tool_extension.rb +55 -0
  29. data/lib/reeve/invocation.rb +260 -0
  30. data/lib/reeve/minitest.rb +19 -0
  31. data/lib/reeve/rspec.rb +18 -0
  32. data/lib/reeve/scope_result.rb +104 -0
  33. data/lib/reeve/testing/assertions.rb +76 -0
  34. data/lib/reeve/testing/checks/audit_coverage.rb +46 -0
  35. data/lib/reeve/testing/checks/base.rb +155 -0
  36. data/lib/reeve/testing/checks/contract_version.rb +97 -0
  37. data/lib/reeve/testing/checks/cross_principal_leak.rb +132 -0
  38. data/lib/reeve/testing/checks/guard_declared.rb +33 -0
  39. data/lib/reeve/testing/checks/principal_required.rb +60 -0
  40. data/lib/reeve/testing/checks/redaction_holds.rb +142 -0
  41. data/lib/reeve/testing/checks/rule_present.rb +55 -0
  42. data/lib/reeve/testing/checks.rb +95 -0
  43. data/lib/reeve/testing/compliance_assertions.rb +34 -0
  44. data/lib/reeve/testing/compliance_suite.rb +23 -0
  45. data/lib/reeve/testing/ledger.rb +82 -0
  46. data/lib/reeve/testing/matchers/audit_every_call.rb +39 -0
  47. data/lib/reeve/testing/matchers/base.rb +84 -0
  48. data/lib/reeve/testing/matchers/deny_access_for.rb +38 -0
  49. data/lib/reeve/testing/matchers/pass_reeve_check.rb +32 -0
  50. data/lib/reeve/testing/matchers.rb +33 -0
  51. data/lib/reeve/testing/report.rb +58 -0
  52. data/lib/reeve/testing/result.rb +49 -0
  53. data/lib/reeve/testing.rb +60 -0
  54. data/lib/reeve/version.rb +1 -1
  55. data/lib/reeve.rb +10 -3
  56. 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