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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +112 -0
- data/README.md +191 -17
- data/lib/generators/reeve/install/install_generator.rb +46 -0
- data/lib/generators/reeve/install/templates/create_audit_entries.rb.tt +69 -0
- data/lib/generators/reeve/install/templates/initializer.rb.tt +65 -0
- data/lib/reeve/audit/entry.rb +48 -0
- data/lib/reeve/audit/query.rb +84 -0
- data/lib/reeve/audit/recorder.rb +163 -0
- data/lib/reeve/audit/redactor.rb +67 -0
- data/lib/reeve/audit.rb +88 -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 +84 -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 +104 -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 +73 -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 +9 -3
- metadata +52 -1
|
@@ -0,0 +1,163 @@
|
|
|
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: attributes[:metadata]
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def identity(attributes)
|
|
97
|
+
{
|
|
98
|
+
invocation_id: attributes[:invocation_id],
|
|
99
|
+
occurred_at: attributes[:occurred_at],
|
|
100
|
+
agent_id: agent_id(attributes),
|
|
101
|
+
agent_name: attributes[:agent_name],
|
|
102
|
+
principal_type: attributes[:principal_type],
|
|
103
|
+
principal_id: attributes[:principal_id]&.to_s,
|
|
104
|
+
tool_name: attributes[:tool_name]
|
|
105
|
+
}
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# An unidentifiable agent is recorded as unknown, never dropped: attribution is not
|
|
109
|
+
# authorization, and a row that names no agent still answers most of the question.
|
|
110
|
+
def agent_id(attributes)
|
|
111
|
+
blank?(attributes[:agent_id]) ? Context::UNKNOWN_AGENT_ID : attributes[:agent_id]
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def redact(attributes)
|
|
115
|
+
Redactor.for(attributes[:tool_name], config: config).call(attributes[:arguments])
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# FR-014: identifiers are capped, never silently dropped — the count stays true and
|
|
119
|
+
# the row says it was truncated. The scoper caps first; this is the backstop that
|
|
120
|
+
# makes the guarantee hold at the ledger regardless of who produced the list.
|
|
121
|
+
def identifiers(attributes)
|
|
122
|
+
ids = Array(attributes[:record_ids]).map(&:to_s)
|
|
123
|
+
count = attributes[:record_count] || ids.size
|
|
124
|
+
truncated = attributes[:truncated] ? true : false
|
|
125
|
+
limit = config.max_recorded_ids
|
|
126
|
+
|
|
127
|
+
return [ids, count, truncated] unless ids.size > limit
|
|
128
|
+
|
|
129
|
+
[ids.first(limit), [count, ids.size].max, true]
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def detail(attributes)
|
|
133
|
+
value = attributes[:detail]
|
|
134
|
+
return nil if blank?(value)
|
|
135
|
+
|
|
136
|
+
value.to_s[0, DETAIL_LIMIT]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# The host's transaction, not the tool's: the tool's own transaction is nested
|
|
140
|
+
# inside this write and is exactly what `requires_new` protects against.
|
|
141
|
+
def warn_about_enclosing_transaction(invocation_id)
|
|
142
|
+
return unless enclosing_transaction?
|
|
143
|
+
|
|
144
|
+
message = "reeve: invocation #{invocation_id} was recorded inside a transaction " \
|
|
145
|
+
"the host opened around it, so the ledger row will be rolled back with " \
|
|
146
|
+
"it. Configure Reeve.config.audit_recorder with a recorder that writes " \
|
|
147
|
+
"outside this transaction if the trace must survive."
|
|
148
|
+
logger = config.logger
|
|
149
|
+
logger ? logger.warn(message) : Kernel.warn(message)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def enclosing_transaction?
|
|
153
|
+
entry_class.connection.transaction_open?
|
|
154
|
+
rescue StandardError
|
|
155
|
+
false
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def blank?(value)
|
|
159
|
+
value.nil? || value.to_s.strip.empty?
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
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
|
data/lib/reeve/audit.rb
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
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 in v1 — the table is host-owned and the host's
|
|
47
|
+
# existing policies apply.
|
|
48
|
+
# * No cryptographic chaining or tamper-evidence in v1. If that lands it arrives as a
|
|
49
|
+
# nullable column, which the audit-entry contract's versioning already permits.
|
|
50
|
+
module Audit
|
|
51
|
+
# The version of the audit-entry shape, as documented in
|
|
52
|
+
# specs/001-guardrails-core/contracts/audit-entry.md (FR-015). Adding a nullable
|
|
53
|
+
# column is a MINOR change and leaves this alone; removing or renaming a column, or
|
|
54
|
+
# changing what a value means, is MAJOR and bumps it.
|
|
55
|
+
CONTRACT_VERSION = 1
|
|
56
|
+
|
|
57
|
+
TABLE_NAME = "reeve_audit_entries"
|
|
58
|
+
|
|
59
|
+
class << self
|
|
60
|
+
# Where per-tool redaction declarations come from: the authorization registry, if
|
|
61
|
+
# the authorization module is loaded. Duck-typed and optional on purpose — the
|
|
62
|
+
# ledger is useful on its own, and a host running audit without guards should get
|
|
63
|
+
# the global redaction list rather than a NameError.
|
|
64
|
+
def guard_registry
|
|
65
|
+
return nil unless Reeve.respond_to?(:registry)
|
|
66
|
+
|
|
67
|
+
Reeve.registry
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# contracts/configuration.md documents `audit_recorder` as defaulting to nil, "which
|
|
71
|
+
# resolves to Reeve::Audit::Recorder at invocation time — the default cannot be the
|
|
72
|
+
# constant itself, since the core loads without ActiveRecord". This is that
|
|
73
|
+
# resolution, performed at the first moment the constant is known to exist: the
|
|
74
|
+
# require of this file. A host that named its own recorder keeps it.
|
|
75
|
+
def install!(config = Reeve.config)
|
|
76
|
+
config.audit_recorder ||= Recorder
|
|
77
|
+
config
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
require_relative "audit/redactor"
|
|
84
|
+
require_relative "audit/entry"
|
|
85
|
+
require_relative "audit/recorder"
|
|
86
|
+
require_relative "audit/query"
|
|
87
|
+
|
|
88
|
+
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
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reeve
|
|
4
|
+
module Authorization
|
|
5
|
+
module Adapters
|
|
6
|
+
# Pundit policies, bridged rather than depended on. Pundit itself is never required
|
|
7
|
+
# by this gem (Constitution IV); this file only speaks its conventions, and reports
|
|
8
|
+
# that it supports nothing when Pundit is absent.
|
|
9
|
+
#
|
|
10
|
+
# class InvoicePolicy
|
|
11
|
+
# def index? = ...
|
|
12
|
+
# class Scope < ApplicationPolicy::Scope
|
|
13
|
+
# def resolve = scope.where(owner: user)
|
|
14
|
+
# end
|
|
15
|
+
# end
|
|
16
|
+
class Pundit
|
|
17
|
+
def self.pundit_loaded?
|
|
18
|
+
defined?(::Pundit) ? true : false
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.supports?(policy)
|
|
22
|
+
return false unless pundit_loaded?
|
|
23
|
+
return false unless policy.is_a?(Class)
|
|
24
|
+
|
|
25
|
+
# `false` matters: const_defined? otherwise walks up to Object, where any
|
|
26
|
+
# top-level Scope constant would make an unrelated policy look Pundit-shaped.
|
|
27
|
+
policy.const_defined?(:Scope, false) &&
|
|
28
|
+
policy.public_instance_methods.any? { |method| method.to_s.end_with?("?") }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.missing_methods(policy)
|
|
32
|
+
return [] if supports?(policy)
|
|
33
|
+
return [:Scope] if policy.is_a?(Class) && !policy.const_defined?(:Scope, false)
|
|
34
|
+
|
|
35
|
+
%i[query_method Scope]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def authorize(principal:, policy:, action:, record: nil)
|
|
39
|
+
query = "#{action}?"
|
|
40
|
+
rule = "#{policy.name}##{query}"
|
|
41
|
+
subject = record || inferred_subject(policy)
|
|
42
|
+
|
|
43
|
+
if policy.new(principal,
|
|
44
|
+
subject).public_send(query)
|
|
45
|
+
Decision.allow(rule: rule)
|
|
46
|
+
else
|
|
47
|
+
Decision.deny(rule: rule)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def scope(principal:, policy:, relation:)
|
|
52
|
+
scoped = policy::Scope.new(principal, relation).resolve
|
|
53
|
+
return scoped unless scoped.nil?
|
|
54
|
+
|
|
55
|
+
raise Error, "#{scope_rule(policy)} returned nil; a scope must return a relation"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def scope_rule(policy)
|
|
59
|
+
"#{policy.name}::Scope"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Pundit's own convention, which is what makes per-type scoping of a mixed result
|
|
63
|
+
# possible at all: Invoice => InvoicePolicy.
|
|
64
|
+
def policy_for(record_class)
|
|
65
|
+
name = "#{record_class.name}Policy"
|
|
66
|
+
Object.const_defined?(name) ? Object.const_get(name) : nil
|
|
67
|
+
rescue NameError
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# Pundit policies are constructed with a record; for an index-style check there is
|
|
74
|
+
# no record yet, so the class stands in — the same thing `authorize Invoice` does.
|
|
75
|
+
def inferred_subject(policy)
|
|
76
|
+
name = policy.name.to_s.sub(/Policy\z/, "")
|
|
77
|
+
Object.const_defined?(name) ? Object.const_get(name) : nil
|
|
78
|
+
rescue NameError
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reeve
|
|
4
|
+
module Authorization
|
|
5
|
+
# The envelope's `authorizer` collaborator: turns a guard declaration plus a principal
|
|
6
|
+
# into a Decision, through whichever adapter the policy speaks.
|
|
7
|
+
#
|
|
8
|
+
# It deliberately does not rescue. The envelope converts a raising policy into
|
|
9
|
+
# `policy_error` and fails closed; swallowing it here would hide which policy broke.
|
|
10
|
+
class Authorizer
|
|
11
|
+
def authorize(context:, guard:)
|
|
12
|
+
adapter = Adapter.resolve(guard.policy)
|
|
13
|
+
|
|
14
|
+
adapter.authorize(
|
|
15
|
+
principal: context.principal,
|
|
16
|
+
policy: guard.policy,
|
|
17
|
+
action: guard.action,
|
|
18
|
+
record: nil
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reeve
|
|
4
|
+
module Authorization
|
|
5
|
+
# The invocation in progress on this thread.
|
|
6
|
+
#
|
|
7
|
+
# `scoped(Model)` is called from inside a tool body, which has no reference to the
|
|
8
|
+
# envelope, so the envelope's caller publishes the state here and clears it in an
|
|
9
|
+
# ensure. Fiber-local storage (Thread#[]) rather than thread-global, so concurrent
|
|
10
|
+
# invocations — the normal case for an MCP server — cannot see each other's principal.
|
|
11
|
+
module Current
|
|
12
|
+
KEY = :reeve_current_invocation
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def state
|
|
17
|
+
Thread.current[KEY]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def start(context:, declaration:, adapter:)
|
|
21
|
+
previous = state
|
|
22
|
+
Thread.current[KEY] =
|
|
23
|
+
State.new(context: context, declaration: declaration, adapter: adapter)
|
|
24
|
+
previous
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def finish(previous = nil)
|
|
28
|
+
Thread.current[KEY] = previous
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def with(context:, declaration:, adapter:)
|
|
32
|
+
previous = start(context: context, declaration: declaration, adapter: adapter)
|
|
33
|
+
yield(state)
|
|
34
|
+
ensure
|
|
35
|
+
finish(previous)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def active?
|
|
39
|
+
!state.nil?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Mutable for exactly two reasons: recording that `scoped` was used, and the size of
|
|
43
|
+
# what it scoped. Everything else about an invocation is fixed when it starts.
|
|
44
|
+
class State
|
|
45
|
+
attr_reader :context, :declaration, :adapter
|
|
46
|
+
attr_accessor :scoped_source_count
|
|
47
|
+
|
|
48
|
+
def initialize(context:, declaration:, adapter:)
|
|
49
|
+
@context = context
|
|
50
|
+
@declaration = declaration
|
|
51
|
+
@adapter = adapter
|
|
52
|
+
@scoped_used = false
|
|
53
|
+
@scoped_source_count = nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def scoped_used!
|
|
57
|
+
@scoped_used = true
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def scoped_used?
|
|
61
|
+
@scoped_used
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|