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,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ # reeve — per-record authorization and an append-only audit ledger for the MCP tools
4
+ # this application exposes to AI agents.
5
+ #
6
+ # Everything below is a live setting; the only one you must fill in is the first.
7
+
8
+ Reeve.configure do |config|
9
+ # ---------------------------------------------------------------------------
10
+ # REQUIRED. Who is the agent acting for?
11
+ #
12
+ # Receives a Reeve::Context and returns the principal — the human whose
13
+ # permissions govern this call — or nil to deny it. Raising is treated as nil:
14
+ # reeve fails closed rather than guessing.
15
+ #
16
+ # Where the identity comes from is yours to decide. With the fast-mcp adapter
17
+ # the request's headers arrive as context.metadata[:headers]:
18
+ #
19
+ # config.principal_resolver = lambda do |context|
20
+ # token = context.metadata.dig(:headers, "Authorization")
21
+ # ApiToken.find_by(token: token)&.user
22
+ # end
23
+ #
24
+ # TODO: replace this with your own lookup. Until you do, every guarded call
25
+ # denies with the rule `no_principal` — which is the intended behaviour, not a
26
+ # bug: the library is safe before it is configured.
27
+ config.principal_resolver = ->(context) { nil }
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # What happens to a tool with no `guard_with` declaration?
31
+ #
32
+ # :deny — the call returns nothing and is recorded as denied.
33
+ # :allow_with_warning — the call runs UNSCOPED, is logged, and is recorded
34
+ # with guard: "none". For retrofitting an existing
35
+ # server; every such call is a hole until it is
36
+ # declared.
37
+ #
38
+ # Written out rather than defaulted, because it is a decision you should make
39
+ # knowingly.
40
+ config.unguarded_tools = :deny
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Argument names whose values never reach the ledger. Names survive, values do
44
+ # not, recursively through nested hashes. Add per-tool names with `redact` in
45
+ # the tool itself.
46
+ config.redact_arguments = %i[password token secret ssn]
47
+
48
+ # What happens if the ledger write fails?
49
+ #
50
+ # :fail — the invocation fails. A call that cannot be recorded is a call
51
+ # that did not happen.
52
+ # :warn — log it and continue. Opt in knowingly: it trades the audit
53
+ # guarantee for availability.
54
+ config.audit_failure_mode = :fail
55
+
56
+ # Cap on how many record identifiers one entry stores. The true count is
57
+ # always recorded, and the entry says when the list was truncated.
58
+ config.max_recorded_ids = 1000
59
+
60
+ # Where warnings go (unguarded tools, degraded audit mode).
61
+ config.logger = Rails.logger
62
+ end
63
+
64
+ # The ledger itself is opt-in, so the core stays loadable without ActiveRecord.
65
+ require "reeve/audit"
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Audit
5
+ # One ledger row: which agent, acting for which principal, called which tool with
6
+ # which arguments, what came back, and which rule decided (FR-009).
7
+ #
8
+ # Append-only (FR-010). The model enforces that as far as a library can: a persisted
9
+ # row is readonly, so `update` and `save` raise, and `destroy` aborts. It cannot
10
+ # enforce it against raw SQL and does not pretend to — the generated migration
11
+ # documents the INSERT+SELECT grant that closes the rest of the gap.
12
+ class Entry < ActiveRecord::Base
13
+ self.table_name = TABLE_NAME
14
+
15
+ ALLOW = "allow"
16
+ DENY = "deny"
17
+ OUTCOMES = [ALLOW, DENY].freeze
18
+
19
+ REQUIRED = %i[
20
+ invocation_id occurred_at agent_id tool_name outcome rule guard contract_version
21
+ ].freeze
22
+
23
+ validates(*REQUIRED, presence: true)
24
+ validates :invocation_id, uniqueness: true
25
+ validates :outcome, inclusion: { in: OUTCOMES, message: "must be allow or deny" }
26
+
27
+ before_destroy { throw :abort }
28
+
29
+ # The contract version this build of the gem writes (FR-015).
30
+ #
31
+ # Class-level, and deliberately not the same question as `entry.contract_version`:
32
+ # this is what the gem implements *now*, while the column on each row is the shape
33
+ # that row was actually written under. They differ for every row written before an
34
+ # upgrade, which is the whole reason the column exists.
35
+ def self.contract_version
36
+ CONTRACT_VERSION
37
+ end
38
+
39
+ # False while the row is being inserted, true forever after: the insert is the only
40
+ # write the ledger ever performs.
41
+ def readonly?
42
+ persisted?
43
+ end
44
+
45
+ def allowed?
46
+ outcome == ALLOW
47
+ end
48
+
49
+ def denied?
50
+ outcome == DENY
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Audit
5
+ # The documented way to read the ledger (FR-013): one scope per query axis —
6
+ # principal, agent, tool, outcome, time range — each backed by one of the composite
7
+ # indexes the generated migration creates.
8
+ #
9
+ # Q = Reeve::Audit::Query
10
+ # Q.for_principal(user)
11
+ # .for_agent("claude-desktop")
12
+ # .between(1.week.ago, Time.current)
13
+ # .pluck(:tool_name, :record_type, :record_ids, :outcome, :rule)
14
+ #
15
+ # The scopes are also extended onto Entry, so a host that would rather work with the
16
+ # model directly gets the same chain. There is deliberately no scope that writes.
17
+ module Query
18
+ # Defined once and used from two places: on Query, +all+ opens on the whole ledger;
19
+ # on Entry (and on a relation being chained), +all+ is the current scope, which is
20
+ # what makes these compose in any order.
21
+ module Scopes
22
+ def for_principal(principal)
23
+ all.where(Query.principal_key(principal))
24
+ end
25
+
26
+ def for_agent(agent_id)
27
+ all.where(agent_id: agent_id.to_s)
28
+ end
29
+
30
+ def for_tool(tool_name)
31
+ all.where(tool_name: tool_name.to_s)
32
+ end
33
+
34
+ def allowed
35
+ all.where(outcome: Entry::ALLOW)
36
+ end
37
+
38
+ def denied
39
+ all.where(outcome: Entry::DENY)
40
+ end
41
+
42
+ # By occurred_at: the ledger is ordered by when the call happened, not by when the
43
+ # row landed.
44
+ def between(from, to)
45
+ all.where(occurred_at: from..to)
46
+ end
47
+ end
48
+
49
+ extend Scopes
50
+
51
+ class << self
52
+ def all
53
+ Entry.all
54
+ end
55
+
56
+ # How a principal becomes a pair of columns. Mirrors Reeve::Context#principal_type
57
+ # and #principal_id exactly — a query that derived the key differently would
58
+ # quietly fail to find the rows the envelope wrote.
59
+ #
60
+ # Accepts the principal itself, or `type:`/`id:` for when an incident report is
61
+ # all that survives of them.
62
+ def principal_key(principal)
63
+ return explicit_key(principal) if principal.is_a?(Hash)
64
+
65
+ {
66
+ principal_type: principal.class.name,
67
+ principal_id: principal.respond_to?(:id) ? principal.id.to_s : principal.to_s
68
+ }
69
+ end
70
+
71
+ private
72
+
73
+ def explicit_key(attributes)
74
+ {
75
+ principal_type: attributes[:type]&.to_s,
76
+ principal_id: attributes[:id]&.to_s
77
+ }
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
83
+
84
+ Reeve::Audit::Entry.extend(Reeve::Audit::Query::Scopes)
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Audit
5
+ # The one write path into the ledger (FR-008).
6
+ #
7
+ # The envelope calls this from an `ensure` block and expects it to raise on failure —
8
+ # Constitution II makes a failed write a failed call unless the host has opted into a
9
+ # degraded mode. Everything here is therefore synchronous: no queue, no thread, no
10
+ # ActiveJob. An asynchronous ledger cannot be relied on and would make FR-012
11
+ # unenforceable.
12
+ #
13
+ # The insert runs in `requires_new: true`, which is what makes the trace survive a
14
+ # tool body that opens a transaction and rolls it back (R5) — the case this was built
15
+ # for, and the one it does solve.
16
+ #
17
+ # What it does **not** do, corrected after review: `requires_new` is a SAVEPOINT, not
18
+ # an independent transaction. If the host has already opened a transaction *around*
19
+ # the invocation — a controller or middleware that wraps each request, or a test suite
20
+ # using transactional fixtures — the savepoint is released into that transaction, and
21
+ # a later rollback takes the ledger row with it. The write reports success and the
22
+ # envelope has no way to learn otherwise, so the invocation returns records with no
23
+ # surviving trace. The comment here previously claimed independence outright; it did
24
+ # not have it.
25
+ #
26
+ # A genuinely independent write needs a second connection, and that is not portable:
27
+ # on SQLite the enclosing transaction holds the write lock, so a second connection
28
+ # blocks until it times out. Rather than fail every call on the databases where
29
+ # isolation is impossible, the recorder detects the enclosing transaction and warns
30
+ # that the guarantee is suspended for that call. A host that needs durability under a
31
+ # wrapping transaction supplies its own `audit_recorder` — writing to a separate
32
+ # connection, a queue, or an append-only log — which is what that setting is for.
33
+ #
34
+ # The other known limit, unchanged: on a single connection there is a narrow window
35
+ # where the tool's data commits and the ledger write then fails. The caller learns by
36
+ # exception, but the data change has already landed.
37
+ class Recorder
38
+ # Free-form text from a policy or an exception message. Capped rather than trusted:
39
+ # it is the one column whose length the host does not control.
40
+ DETAIL_LIMIT = 1000
41
+
42
+ def self.record(attributes)
43
+ new.record(attributes)
44
+ end
45
+
46
+ def initialize(entry_class: Entry, config: nil)
47
+ @entry_class = entry_class
48
+ @config = config
49
+ end
50
+
51
+ # Returns the entry. Raises if the row could not be written, so the envelope can
52
+ # fail the invocation.
53
+ def record(attributes)
54
+ row = row_for(attributes)
55
+ warn_about_enclosing_transaction(row[:invocation_id])
56
+
57
+ entry_class.transaction(requires_new: true) do
58
+ entry_class.create!(row)
59
+ end
60
+ rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => e
61
+ # A replayed invocation is the same invocation. One row per invocation_id means
62
+ # a retry is a no-op insert, never a second row (contracts/audit-entry.md).
63
+ existing = entry_class.find_by(invocation_id: row[:invocation_id])
64
+ raise e if existing.nil?
65
+
66
+ existing
67
+ end
68
+
69
+ private
70
+
71
+ attr_reader :entry_class
72
+
73
+ def config
74
+ @config || Reeve.config
75
+ end
76
+
77
+ def row_for(attributes)
78
+ ids, count, truncated = identifiers(attributes)
79
+
80
+ identity(attributes).merge(
81
+ arguments: redact(attributes),
82
+ outcome: attributes[:outcome].to_s,
83
+ rule: attributes[:rule],
84
+ detail: detail(attributes),
85
+ record_type: attributes[:record_type],
86
+ record_ids: ids,
87
+ record_count: count,
88
+ truncated: truncated,
89
+ derived: attributes[:derived] ? true : false,
90
+ guard: blank?(attributes[:guard]) ? "policy" : attributes[:guard].to_s,
91
+ duration_ms: attributes[:duration_ms],
92
+ metadata: redact_metadata(attributes),
93
+ # Stamped from the constant rather than from the caller: the row records the
94
+ # shape the gem that wrote it implements, which is not something an adapter or
95
+ # a host is in a position to assert.
96
+ contract_version: CONTRACT_VERSION
97
+ )
98
+ end
99
+
100
+ def identity(attributes)
101
+ {
102
+ invocation_id: attributes[:invocation_id],
103
+ occurred_at: attributes[:occurred_at],
104
+ agent_id: agent_id(attributes),
105
+ agent_name: attributes[:agent_name],
106
+ principal_type: attributes[:principal_type],
107
+ principal_id: attributes[:principal_id]&.to_s,
108
+ tool_name: attributes[:tool_name]
109
+ }
110
+ end
111
+
112
+ # An unidentifiable agent is recorded as unknown, never dropped: attribution is not
113
+ # authorization, and a row that names no agent still answers most of the question.
114
+ def agent_id(attributes)
115
+ blank?(attributes[:agent_id]) ? Context::UNKNOWN_AGENT_ID : attributes[:agent_id]
116
+ end
117
+
118
+ def redact(attributes)
119
+ Redactor.for(attributes[:tool_name], config: config).call(attributes[:arguments])
120
+ end
121
+
122
+ # Metadata is transport detail, and the transport is where the credentials are: the
123
+ # fast-mcp bridge puts the whole header hash in here, `Authorization` included. It
124
+ # goes through the same redactor as the arguments — which recurses into nested
125
+ # hashes and already knows `authorization`, `token` and friends by name — so
126
+ # recording metadata does not turn the ledger into a place bearer tokens accumulate.
127
+ # nil stays nil rather than becoming `{}`: a call that carried no metadata should
128
+ # not be indistinguishable from one whose metadata was emptied.
129
+ def redact_metadata(attributes)
130
+ metadata = attributes[:metadata]
131
+ return nil if metadata.nil? || metadata.empty?
132
+
133
+ Redactor.for(attributes[:tool_name], config: config).call(metadata)
134
+ end
135
+
136
+ # FR-014: identifiers are capped, never silently dropped — the count stays true and
137
+ # the row says it was truncated. The scoper caps first; this is the backstop that
138
+ # makes the guarantee hold at the ledger regardless of who produced the list.
139
+ def identifiers(attributes)
140
+ ids = Array(attributes[:record_ids]).map(&:to_s)
141
+ count = attributes[:record_count] || ids.size
142
+ truncated = attributes[:truncated] ? true : false
143
+ limit = config.max_recorded_ids
144
+
145
+ return [ids, count, truncated] unless ids.size > limit
146
+
147
+ [ids.first(limit), [count, ids.size].max, true]
148
+ end
149
+
150
+ def detail(attributes)
151
+ value = attributes[:detail]
152
+ return nil if blank?(value)
153
+
154
+ value.to_s[0, DETAIL_LIMIT]
155
+ end
156
+
157
+ # The host's transaction, not the tool's: the tool's own transaction is nested
158
+ # inside this write and is exactly what `requires_new` protects against.
159
+ def warn_about_enclosing_transaction(invocation_id)
160
+ return unless enclosing_transaction?
161
+
162
+ message = "reeve: invocation #{invocation_id} was recorded inside a transaction " \
163
+ "the host opened around it, so the ledger row will be rolled back with " \
164
+ "it. Configure Reeve.config.audit_recorder with a recorder that writes " \
165
+ "outside this transaction if the trace must survive."
166
+ logger = config.logger
167
+ logger ? logger.warn(message) : Kernel.warn(message)
168
+ end
169
+
170
+ def enclosing_transaction?
171
+ entry_class.connection.transaction_open?
172
+ rescue StandardError
173
+ false
174
+ end
175
+
176
+ def blank?(value)
177
+ value.nil? || value.to_s.strip.empty?
178
+ end
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Audit
5
+ # Removes declared-sensitive values from the arguments before they are written
6
+ # (FR-011).
7
+ #
8
+ # Names always survive: an entry that cannot say *which* argument was passed answers
9
+ # nothing after an incident. Values of declared names are replaced, recursively,
10
+ # wherever they appear. Matching is on the name, never on the value — pattern-sniffing
11
+ # a compliance artifact both misses and over-matches (R6).
12
+ #
13
+ # The input is never mutated and never stored: the redacted hash is a new structure,
14
+ # so no unredacted copy exists downstream of this object.
15
+ class Redactor
16
+ MARKER = "[REDACTED]"
17
+
18
+ # The names declared globally, plus the ones this tool's own guard declared.
19
+ def self.for(tool_name, config: Reeve.config, registry: Audit.guard_registry)
20
+ new(Array(config.redact_arguments) + tool_names(tool_name, registry))
21
+ end
22
+
23
+ def self.tool_names(tool_name, registry)
24
+ return [] unless registry.respond_to?(:guard_for)
25
+
26
+ guard = registry.guard_for(tool_name)
27
+ return [] unless guard.respond_to?(:redacted_arguments)
28
+
29
+ Array(guard.redacted_arguments)
30
+ end
31
+ private_class_method :tool_names
32
+
33
+ def initialize(names, marker: MARKER)
34
+ @names = Array(names).map { |name| name.to_s.downcase }.uniq.freeze
35
+ @marker = marker
36
+ end
37
+
38
+ def call(arguments)
39
+ return {} if arguments.nil?
40
+
41
+ redact_hash(arguments)
42
+ end
43
+
44
+ private
45
+
46
+ attr_reader :names, :marker
47
+
48
+ def redact_hash(hash)
49
+ hash.each_with_object({}) do |(key, value), result|
50
+ result[key] = sensitive?(key) ? marker : redact(value)
51
+ end
52
+ end
53
+
54
+ def redact(value)
55
+ case value
56
+ when Hash then redact_hash(value)
57
+ when Array then value.map { |element| redact(element) }
58
+ else value
59
+ end
60
+ end
61
+
62
+ def sensitive?(key)
63
+ names.include?(key.to_s.downcase)
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../reeve"
4
+
5
+ begin
6
+ require "active_record"
7
+ rescue LoadError => e
8
+ raise Reeve::ConfigurationError,
9
+ "reeve/audit needs activerecord, which could not be loaded (#{e.message}). " \
10
+ "Add activerecord to your Gemfile, or configure a non-ActiveRecord " \
11
+ "Reeve.config.audit_recorder instead."
12
+ end
13
+
14
+ module Reeve
15
+ # The append-only ledger: one row per guarded invocation, allowed or denied
16
+ # (Constitution II, FR-008).
17
+ #
18
+ # This file is the opt-in boundary. `require "reeve"` must keep working in a bare Ruby
19
+ # process with no ActiveRecord (SC-008), so nothing here is loaded by the core — a host
20
+ # (or the generated initializer) requires "reeve/audit" when it wants the table-backed
21
+ # recorder.
22
+ #
23
+ # require "reeve/audit"
24
+ #
25
+ # Reeve::Audit::Query.for_principal(user)
26
+ # .for_agent("claude-desktop")
27
+ # .between(1.week.ago, Time.current)
28
+ # .pluck(:tool_name, :record_type, :record_ids, :outcome, :rule)
29
+ #
30
+ # == What this module guarantees
31
+ #
32
+ # * exactly one row per invocation, allowed or denied — `invocation_id` is unique, and
33
+ # a replayed invocation is a no-op insert rather than a second row;
34
+ # * `rule` is never null: every row explains itself;
35
+ # * arguments are post-redaction, and no unredacted copy is written anywhere;
36
+ # * `record_count` stays true even when `record_ids` was capped, and `truncated` says so;
37
+ # * `occurred_at` is invocation time, not write time;
38
+ # * no public method updates or deletes an entry.
39
+ #
40
+ # == What it does not
41
+ #
42
+ # * It does not stop a database superuser, a migration, or raw SQL from rewriting the
43
+ # table. Immutability is enforced at the library level; the generated migration
44
+ # documents the `GRANT INSERT, SELECT` that enforces the rest where it can actually be
45
+ # enforced, and the gem makes no stronger claim than that.
46
+ # * No retention, rotation or archival yet — the table is host-owned and the host's
47
+ # existing policies apply.
48
+ # * No cryptographic chaining or tamper-evidence yet. If that lands it arrives as a
49
+ # nullable column, which the audit-entry contract's versioning already permits.
50
+ #
51
+ # ("yet" rather than "in v1": the gem version and the audit-entry contract version are
52
+ # different numbers that move independently, and writing v1 for one of them read as the
53
+ # other.)
54
+ module Audit
55
+ # The version of the audit-entry shape, as documented in
56
+ # specs/001-guardrails-core/contracts/audit-entry.md (FR-015). Adding a nullable
57
+ # column is a MINOR change and leaves this alone; removing or renaming a column, or
58
+ # changing what a value means, is MAJOR and bumps it.
59
+ #
60
+ # 2 — `metadata` carries the transport detail the caller passed. Through version 1 it
61
+ # was written NULL on every row regardless of what was passed, so anything mapping
62
+ # version 1 rows could reasonably have read the column as "always empty". The shape
63
+ # did not change; what a value means did.
64
+ CONTRACT_VERSION = 2
65
+
66
+ TABLE_NAME = "reeve_audit_entries"
67
+
68
+ class << self
69
+ # Where per-tool redaction declarations come from: the authorization registry, if
70
+ # the authorization module is loaded. Duck-typed and optional on purpose — the
71
+ # ledger is useful on its own, and a host running audit without guards should get
72
+ # the global redaction list rather than a NameError.
73
+ def guard_registry
74
+ return nil unless Reeve.respond_to?(:registry)
75
+
76
+ Reeve.registry
77
+ end
78
+
79
+ # contracts/configuration.md documents `audit_recorder` as defaulting to nil, "which
80
+ # resolves to Reeve::Audit::Recorder at invocation time — the default cannot be the
81
+ # constant itself, since the core loads without ActiveRecord". This is that
82
+ # resolution, performed at the first moment the constant is known to exist: the
83
+ # require of this file. A host that named its own recorder keeps it.
84
+ def install!(config = Reeve.config)
85
+ config.audit_recorder ||= Recorder
86
+ config
87
+ end
88
+ end
89
+ end
90
+ end
91
+
92
+ require_relative "audit/redactor"
93
+ require_relative "audit/entry"
94
+ require_relative "audit/recorder"
95
+ require_relative "audit/query"
96
+
97
+ Reeve::Audit.install!
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Authorization
5
+ # Chooses the adapter a policy speaks through, and refuses declarations no adapter
6
+ # can serve — at declaration time, so the developer learns immediately rather than on
7
+ # the first denial in production (Constitution VI).
8
+ module Adapter
9
+ BUILT_IN = { plain: Adapters::Plain, pundit: Adapters::Pundit }.freeze
10
+
11
+ module_function
12
+
13
+ def resolve(policy, setting = Reeve.config.policy_adapter)
14
+ case setting
15
+ when :plain then Adapters::Plain.new
16
+ when :pundit then Adapters::Pundit.new
17
+ when :auto then auto_resolve(policy)
18
+ else setting # a host-supplied adapter object; Configuration validated its protocol
19
+ end
20
+ end
21
+
22
+ def resolve_name(policy, setting = Reeve.config.policy_adapter)
23
+ return setting unless setting == :auto
24
+
25
+ Adapters::Pundit.supports?(policy) ? :pundit : :plain
26
+ end
27
+
28
+ # Raises unless some adapter can serve this policy. Called from `guard_with`.
29
+ def validate!(policy, setting = Reeve.config.policy_adapter)
30
+ return true if supported?(policy, setting)
31
+
32
+ raise ConfigurationError, unsupported_message(policy, setting)
33
+ end
34
+
35
+ def supported?(policy, setting = Reeve.config.policy_adapter)
36
+ case setting
37
+ when :plain then Adapters::Plain.supports?(policy)
38
+ when :pundit then Adapters::Pundit.supports?(policy)
39
+ when :auto then Adapters::Pundit.supports?(policy) || Adapters::Plain.supports?(policy)
40
+ else true # a custom adapter is trusted to know its own policies
41
+ end
42
+ end
43
+
44
+ def auto_resolve(policy)
45
+ Adapters::Pundit.supports?(policy) ? Adapters::Pundit.new : Adapters::Plain.new
46
+ end
47
+
48
+ def unsupported_message(policy, setting)
49
+ described = policy.respond_to?(:name) && policy.name ? policy.name : policy.inspect
50
+ missing = Adapters::Plain.missing_methods(policy).map do |method|
51
+ "##{method}"
52
+ end.join(" and ")
53
+
54
+ "#{described} cannot be used as a reeve policy (policy_adapter is #{setting.inspect}). " \
55
+ "A plain policy must respond to #{missing.empty? ? '#authorize and #scope' : missing}; " \
56
+ "a Pundit policy must define a query method and a Scope class."
57
+ end
58
+ end
59
+ end
60
+
61
+ # Reopened to answer one question the adapter layer owns: which adapter a policy will
62
+ # actually be served by. Kept here rather than in the kernel so configuration.rb stays
63
+ # free of any knowledge of policies.
64
+ class Configuration
65
+ # Which adapter `:auto` actually chose. Documented in contracts/policy-adapter.md so
66
+ # the choice is never a mystery, and so the compliance suite can assert on it.
67
+ def resolved_policy_adapter(policy = nil)
68
+ Authorization::Adapter.resolve_name(policy, policy_adapter)
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Authorization
5
+ module Adapters
6
+ # Plain policy objects — always available, no dependency on anything.
7
+ #
8
+ # class InvoicePolicy
9
+ # def self.authorize(principal, action, record) = ...
10
+ # def self.scope(principal, relation) = relation.where(owner: principal)
11
+ # end
12
+ class Plain
13
+ REQUIRED_METHODS = %i[authorize scope].freeze
14
+
15
+ def self.supports?(policy)
16
+ REQUIRED_METHODS.all? { |method| policy.respond_to?(method) }
17
+ end
18
+
19
+ def self.missing_methods(policy)
20
+ REQUIRED_METHODS.reject { |method| policy.respond_to?(method) }
21
+ end
22
+
23
+ def authorize(principal:, policy:, action:, record: nil)
24
+ rule = rule_for(policy, action)
25
+ allowed = policy.authorize(principal, action, record)
26
+
27
+ allowed ? Decision.allow(rule: rule) : Decision.deny(rule: rule)
28
+ end
29
+
30
+ def scope(principal:, policy:, relation:)
31
+ scoped = policy.scope(principal, relation)
32
+ return scoped unless scoped.nil?
33
+
34
+ # A nil scope is a policy that did not answer. Answering "everything" would be
35
+ # the dangerous reading, so this is an error rather than a fallback.
36
+ raise Error, "#{rule_for(policy, :scope)} returned nil; a scope must return a relation"
37
+ end
38
+
39
+ def scope_rule(policy)
40
+ rule_for(policy, :scope)
41
+ end
42
+
43
+ # A tool may return more than the type its declared policy governs. Rather than
44
+ # denying every mixed result, reeve looks for the conventional `<Model>Policy`
45
+ # for the other types — and denies when there is not one (unknown_record_type).
46
+ def policy_for(record_class)
47
+ name = "#{record_class.name}Policy"
48
+ return nil unless Object.const_defined?(name)
49
+
50
+ policy = Object.const_get(name)
51
+ self.class.supports?(policy) ? policy : nil
52
+ rescue NameError
53
+ nil
54
+ end
55
+
56
+ private
57
+
58
+ def rule_for(policy, action)
59
+ name = policy.respond_to?(:name) && policy.name ? policy.name : policy.class.name
60
+ "#{name}##{action}"
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end