reeve 0.0.1 → 0.1.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 +112 -0
  3. data/README.md +191 -17
  4. data/lib/generators/reeve/install/install_generator.rb +46 -0
  5. data/lib/generators/reeve/install/templates/create_audit_entries.rb.tt +69 -0
  6. data/lib/generators/reeve/install/templates/initializer.rb.tt +65 -0
  7. data/lib/reeve/audit/entry.rb +48 -0
  8. data/lib/reeve/audit/query.rb +84 -0
  9. data/lib/reeve/audit/recorder.rb +163 -0
  10. data/lib/reeve/audit/redactor.rb +67 -0
  11. data/lib/reeve/audit.rb +88 -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 +84 -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 +104 -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 +73 -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 +9 -3
  56. metadata +52 -1
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Authorization
5
+ # What `guard_with` records for one tool class: which policy governs it, which action
6
+ # to check, and which of its arguments never reach the ledger in the clear.
7
+ class Declaration
8
+ attr_reader :tool_class, :policy, :action, :redacted_arguments
9
+
10
+ def initialize(tool_class:, policy:, action:, redacted_arguments: [])
11
+ @tool_class = tool_class
12
+ @policy = policy
13
+ @action = action.to_sym
14
+ @redacted_arguments = redacted_arguments.map(&:to_sym).freeze
15
+ end
16
+
17
+ # Resolved on first use rather than at construction: `guard_with` runs inside the
18
+ # class body, and a class defined as `Class.new { ... }` has no name until the
19
+ # constant it is assigned to exists.
20
+ def tool_name
21
+ @tool_name ||= derive_tool_name
22
+ end
23
+
24
+ def with(policy: self.policy, action: self.action,
25
+ redacted_arguments: self.redacted_arguments)
26
+ self.class.new(
27
+ tool_class: tool_class, policy: policy, action: action,
28
+ redacted_arguments: redacted_arguments
29
+ )
30
+ end
31
+
32
+ def for_subclass(subclass)
33
+ self.class.new(
34
+ tool_class: subclass, policy: policy, action: action,
35
+ redacted_arguments: redacted_arguments
36
+ )
37
+ end
38
+
39
+ def policy_name
40
+ policy.respond_to?(:name) && policy.name ? policy.name : policy.class.name
41
+ end
42
+
43
+ private
44
+
45
+ # The name the envelope looks a guard up by. MCP server libraries let a tool name
46
+ # itself; when it does, that name wins, because that is the name the protocol —
47
+ # and therefore the ledger — will use.
48
+ def derive_tool_name
49
+ declared = tool_class.respond_to?(:tool_name) ? tool_class.tool_name : nil
50
+ return declared.to_s unless declared.nil? || declared.to_s.empty?
51
+
52
+ name = tool_class.name
53
+ if name.nil? || name.empty?
54
+ raise ConfigurationError,
55
+ "#{tool_class.inspect} has no name, so reeve cannot identify it in the " \
56
+ "ledger. Assign it to a constant, or define `def self.tool_name`."
57
+ end
58
+
59
+ underscore(name)
60
+ end
61
+
62
+ # ActiveSupport is not a dependency of the core, so this is deliberately small:
63
+ # it handles the CamelCase and Namespaced::CamelCase shapes tool classes actually
64
+ # have, and nothing more.
65
+ def underscore(name)
66
+ name.gsub("::", "_")
67
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
68
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
69
+ .downcase
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ # The tool-side surface: two macros and one helper. `include Reeve::Guard` in a tool
5
+ # class (the fast-mcp adapter does it for you) and declare which policy governs it.
6
+ #
7
+ # class InvoiceSearchTool
8
+ # include Reeve::Guard
9
+ # guard_with InvoicePolicy
10
+ # redact :customer_ssn
11
+ #
12
+ # def call(query:)
13
+ # Invoice.where("number LIKE ?", "%#{query}%")
14
+ # end
15
+ # end
16
+ module Guard
17
+ def self.included(base)
18
+ base.extend(ClassMethods)
19
+ end
20
+
21
+ # Class-level DSL. See contracts/tool-dsl.md.
22
+ module ClassMethods
23
+ # Declares which policy governs this tool. Absence is not neutral: a tool with no
24
+ # declaration is denied by the envelope (FR-002, FR-004).
25
+ def guard_with(policy, action: nil)
26
+ Authorization::Adapter.validate!(policy)
27
+
28
+ @reeve_guard = Reeve.registry.register(
29
+ tool_class: self,
30
+ policy: policy,
31
+ action: action,
32
+ redacted_arguments: pending_redactions + inherited_redactions
33
+ )
34
+ end
35
+
36
+ # Argument names this tool never writes to the ledger in the clear, on top of the
37
+ # process-wide list (FR-011).
38
+ def redact(*names)
39
+ symbols = names.flatten.map(&:to_sym)
40
+ @pending_redactions = pending_redactions | symbols
41
+
42
+ declaration = reeve_guard
43
+ return symbols if declaration.nil?
44
+
45
+ @reeve_guard = Reeve.registry.add(
46
+ declaration.with(redacted_arguments: declaration.redacted_arguments | symbols)
47
+ )
48
+ symbols
49
+ end
50
+
51
+ # This tool's declaration, inherited from a superclass when it has none of its own.
52
+ def reeve_guard
53
+ Reeve.registry.for_class(self)
54
+ end
55
+
56
+ def guarded?
57
+ !reeve_guard.nil?
58
+ end
59
+
60
+ # A subclass of a guarded tool is itself guarded, and is registered under its own
61
+ # name so the envelope — which only ever has a name — can find it.
62
+ def inherited(subclass)
63
+ super
64
+ declaration = reeve_guard
65
+ # An anonymous subclass has no name to be looked up by; it still inherits the
66
+ # declaration through the ancestry walk in Registry#for_class.
67
+ return if declaration.nil? || subclass.name.nil?
68
+
69
+ Reeve.registry.add(declaration.for_subclass(subclass))
70
+ end
71
+
72
+ def pending_redactions
73
+ @pending_redactions ||= []
74
+ end
75
+
76
+ private
77
+
78
+ def inherited_redactions
79
+ return [] unless superclass.respond_to?(:reeve_guard)
80
+
81
+ inherited = superclass.reeve_guard
82
+ inherited ? inherited.redacted_arguments : []
83
+ end
84
+ end
85
+
86
+ # The scoped relation for the invoking principal. This is how a guarded tool returns
87
+ # anything that is not a record: a count, a sum, a rendered summary. Computing from
88
+ # `scoped(...)` means the tool never held unscoped data, so the derived value is safe
89
+ # by construction rather than by promise (R4).
90
+ def scoped(model_or_relation)
91
+ state = Authorization::Current.state
92
+ raise Error, "scoped(...) may only be called inside a guarded invocation" if state.nil?
93
+
94
+ Authorization::Scoper.scoped_relation(state, model_or_relation)
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Reeve — per-record authorization and an append-only audit ledger for MCP tools.
4
+ module Reeve
5
+ module Authorization
6
+ # Every `guard_with` declaration in the process, keyed by tool class.
7
+ #
8
+ # Two lookups matter: by class, which the DSL and inheritance use, and by tool name,
9
+ # which is all the envelope has. Enumeration is what lets the compliance suite ask the
10
+ # question that matters — "is every tool this application exposes actually guarded?"
11
+ class Registry
12
+ include Enumerable
13
+
14
+ def initialize
15
+ @declarations = {}
16
+ @name_index = nil # invalidated on every add; see #name_index
17
+ @mutex = Mutex.new
18
+ end
19
+
20
+ # The DSL's `guard_with`. Declaring twice on one class is a mistake worth naming;
21
+ # `add` is the quiet path used by `redact` and by inheritance, which refine an
22
+ # existing declaration rather than compete with it.
23
+ def register(tool_class:, policy:, action: nil, redacted_arguments: [])
24
+ warn_about_redeclaration_of(tool_class)
25
+
26
+ declaration = Declaration.new(
27
+ tool_class: tool_class,
28
+ policy: policy,
29
+ action: action || Reeve.config.default_action,
30
+ redacted_arguments: redacted_arguments
31
+ )
32
+ add(declaration)
33
+ end
34
+
35
+ def add(declaration)
36
+ @mutex.synchronize do
37
+ @declarations[declaration.tool_class] = declaration
38
+ @name_index = nil
39
+ declaration
40
+ end
41
+ end
42
+
43
+ # Walks the ancestry, so a subclass inherits its parent's guard and may override it
44
+ # simply by declaring its own.
45
+ def for_class(tool_class)
46
+ return nil unless tool_class.respond_to?(:ancestors)
47
+
48
+ tool_class.ancestors.each do |ancestor|
49
+ declaration = @declarations[ancestor]
50
+ return declaration if declaration
51
+ end
52
+ nil
53
+ end
54
+
55
+ # The envelope's lookup. Returns nil for an unknown tool, which is a denial.
56
+ def guard_for(tool_name)
57
+ name_index[tool_name.to_s]
58
+ end
59
+
60
+ def each(&block)
61
+ @declarations.values.each(&block)
62
+ end
63
+
64
+ def size
65
+ @declarations.size
66
+ end
67
+
68
+ def empty?
69
+ @declarations.empty?
70
+ end
71
+
72
+ # Forgets one tool. Test suites build throwaway tools, and the compliance suite
73
+ # walks this registry — a fixture left behind fails a later, unrelated example.
74
+ def remove(tool_class)
75
+ @mutex.synchronize do
76
+ @declarations.delete(tool_class)
77
+ @name_index = nil
78
+ end
79
+ end
80
+
81
+ def reset!
82
+ @mutex.synchronize do
83
+ @declarations = {}
84
+ @name_index = nil
85
+ end
86
+ end
87
+
88
+ private
89
+
90
+ def name_index
91
+ @name_index ||= @declarations.values.to_h do |declaration|
92
+ [declaration.tool_name, declaration]
93
+ end
94
+ end
95
+
96
+ def warn_about_redeclaration_of(tool_class)
97
+ return unless @declarations.key?(tool_class)
98
+
99
+ message = "reeve: #{tool_class} declared guard_with more than once; " \
100
+ "the later declaration replaces the earlier one"
101
+ logger = Reeve.config.logger
102
+ logger ? logger.warn(message) : Kernel.warn(message)
103
+ end
104
+ end
105
+ end
106
+
107
+ class << self
108
+ # The process-wide registry the DSL writes to.
109
+ def registry
110
+ @registry ||= Authorization::Registry.new
111
+ end
112
+
113
+ # Public so host test suites can isolate examples from one another.
114
+ def reset_registry!
115
+ registry.reset!
116
+ end
117
+ end
118
+ end
@@ -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