ree_lib 1.3.10 → 1.3.11

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 (26) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +12 -0
  3. data/lib/ree_lib/Packages.schema.json +4 -0
  4. data/lib/ree_lib/packages/ree_audit/.rspec +2 -0
  5. data/lib/ree_lib/packages/ree_audit/package/ree_audit/beans/audit.rb +83 -0
  6. data/lib/ree_lib/packages/ree_audit/package/ree_audit/config.rb +62 -0
  7. data/lib/ree_lib/packages/ree_audit/package/ree_audit/context.rb +44 -0
  8. data/lib/ree_lib/packages/ree_audit/package/ree_audit/event.rb +78 -0
  9. data/lib/ree_lib/packages/ree_audit/package/ree_audit/functions/filter_params.rb +75 -0
  10. data/lib/ree_lib/packages/ree_audit/package/ree_audit/sinks/logger_sink.rb +15 -0
  11. data/lib/ree_lib/packages/ree_audit/package/ree_audit/sinks/null_sink.rb +11 -0
  12. data/lib/ree_lib/packages/ree_audit/package/ree_audit/sinks/sink.rb +10 -0
  13. data/lib/ree_lib/packages/ree_audit/package/ree_audit.rb +37 -0
  14. data/lib/ree_lib/packages/ree_audit/spec/ree_audit/beans/audit_spec.rb +161 -0
  15. data/lib/ree_lib/packages/ree_audit/spec/ree_audit/context_spec.rb +47 -0
  16. data/lib/ree_lib/packages/ree_audit/spec/ree_audit/functions/filter_params_spec.rb +58 -0
  17. data/lib/ree_lib/packages/ree_audit/spec/spec_helper.rb +13 -0
  18. data/lib/ree_lib/packages/ree_roda/package/ree_roda/plugins/ree_routes.rb +29 -1
  19. data/lib/ree_lib/packages/ree_roda/package/ree_roda.rb +1 -0
  20. data/lib/ree_lib/packages/ree_roda/spec/ree_roda/plugins/ree_routes_audit_spec.rb +281 -0
  21. data/lib/ree_lib/packages/ree_roda/spec/spec_helper.rb +3 -0
  22. data/lib/ree_lib/packages/ree_routes/package/ree_routes/route.rb +11 -1
  23. data/lib/ree_lib/packages/ree_routes/package/ree_routes/route_builder.rb +7 -0
  24. data/lib/ree_lib/packages/ree_routes/spec/ree_routes/dsl_spec.rb +43 -0
  25. data/lib/ree_lib/version.rb +1 -1
  26. metadata +16 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9602630839840f13c5b0be8226aea07ddabc2846b22ee90be4d2a2ba64e43aba
4
- data.tar.gz: 3bb721b4ccecb67d709d850ca7e56357fe52350a96a1b817d33654a2eed6e759
3
+ metadata.gz: 49fa98abf408507fb7c59dea94efc94717156aa50e76b3015aee6dcada1e0f17
4
+ data.tar.gz: 9a5927b28c56d6b8982e2565340d7816600f894becd05493bfcb9178196e85c6
5
5
  SHA512:
6
- metadata.gz: 8afde36de03a451291b092d3449438de32ccd1f3bd5ea1356d1b487e0a91373e3ce9f7a1bd149913c26953274009244ca343c7e1a508aaab2f219b3391a3f190
7
- data.tar.gz: b97d9425262c5c99d56c7d23c7069901676594060c40d93c57a9e07a846486bff0cf176c0d39fcff6f77edbdfa239af46261800a7eff74ccb964d2363bc2262d
6
+ metadata.gz: ccdc45ab27ff92239fa01fa4bdd678c29d45a102af203192991acbf12caf447b77a6a8849d6a6f23d1891453b29074e1ed1a1503f239102d0e15948d40f4b364
7
+ data.tar.gz: e6af57d77e1eb6c41cf5b678e216741ec505ceec40eb74abc07fbebe134d43b6e82e33e82aed2ce5fc4a1085ff730324be4678cc32d0de920b89607947902764
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.3.11] - 2026-09-02
4
+
5
+ - New `ree_audit` package: an opt-in trail of audited action calls (event,
6
+ per-fiber context with `ReeAudit.annotate`, recursive params filter, sinks).
7
+ Disabled by default via `AUDIT_ENABLED`.
8
+ - `ree_routes`: `Route#audited?` and the `audit true/false` route DSL. Internal
9
+ routes are audited by default; public ones are not.
10
+ - `ree_routes`: `Route` got the missing `before` and `redirect` accessors —
11
+ `RouteBuilder#before` and `#redirect` wrote to setters that did not exist.
12
+ - `ree_roda`: the `ree_routes` plugin accepts `audit:`. Without the option the
13
+ plugin behaves exactly as before.
14
+
3
15
  ## [1.3.0] - 2025-05-14
4
16
 
5
17
  - Forward compatibility fixes for ruby 3.4
@@ -10,6 +10,10 @@
10
10
  "name": "ree_array",
11
11
  "entry_path": "packages/ree_array/package/ree_array.rb"
12
12
  },
13
+ {
14
+ "name": "ree_audit",
15
+ "entry_path": "packages/ree_audit/package/ree_audit.rb"
16
+ },
13
17
  {
14
18
  "name": "ree_dao",
15
19
  "entry_path": "packages/ree_dao/package/ree_dao.rb"
@@ -0,0 +1,2 @@
1
+ --color
2
+ --require spec_helper
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ReeAudit::Audit
4
+ include Ree::BeanDSL
5
+
6
+ bean :audit do
7
+ singleton
8
+
9
+ # The sink is injected once, at application boot: the package ships a null
10
+ # sink and the host application swaps in its own. That single assignment is
11
+ # the only mutation this object ever sees, so it must not be frozen.
12
+ freeze false
13
+
14
+ link :config
15
+ link :filter_params
16
+ link :logger, from: :ree_logger
17
+ link 'ree_audit/sinks/null_sink', -> { NullSink }
18
+ end
19
+
20
+ MAX_ERROR_MESSAGE_LENGTH = 512
21
+
22
+ def sink
23
+ @sink ||= NullSink.new
24
+ end
25
+
26
+ def sink=(value)
27
+ @sink = value
28
+ end
29
+
30
+ # Wraps one action call. Everything the trail needs that is only knowable
31
+ # here — how it ended, how long it took, what the code annotated on the way —
32
+ # is filled in around the yield.
33
+ #
34
+ # The exception itself is never swallowed: an audited route must behave
35
+ # exactly like an unaudited one.
36
+ def around(event)
37
+ return yield if !config.enabled
38
+
39
+ event.params = filter_params(event.params || {})
40
+ event.started_at = Time.now
41
+ started_monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
42
+
43
+ ReeAudit::Context.push(event)
44
+
45
+ begin
46
+ result = yield
47
+ event.status = :ok
48
+ result
49
+ rescue StandardError => e
50
+ event.status = denied?(e) ? :denied : :error
51
+ event.error_type = e.class.name
52
+ event.error_message = e.message.to_s[0, MAX_ERROR_MESSAGE_LENGTH]
53
+ raise
54
+ ensure
55
+ event.duration_ms = (
56
+ (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_monotonic) * 1000
57
+ ).round
58
+ ReeAudit::Context.pop
59
+ write_safely(event)
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ # A refusal to show client data is as much an audit fact as showing it.
66
+ def denied?(e)
67
+ e.is_a?(ReeErrors::Error) && e.type == :permission
68
+ rescue StandardError
69
+ false
70
+ end
71
+
72
+ # The trail must never be the reason a request fails.
73
+ def write_safely(event)
74
+ sink.write(event)
75
+ rescue StandardError => e
76
+ logger.error(
77
+ "audit sink failed for #{event.action_name}",
78
+ {action_name: event.action_name, status: event.status},
79
+ e
80
+ )
81
+ nil
82
+ end
83
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ReeAudit::Config
4
+ include Ree::BeanDSL
5
+
6
+ bean :config do
7
+ singleton
8
+ factory :build
9
+
10
+ link :to_obj, from: :ree_object
11
+ link :is_blank, from: :ree_object
12
+ end
13
+
14
+ MAX_PARAMS_BYTES = 4096
15
+ MAX_PARAMS_DEPTH = 4
16
+
17
+ # Blacklist of the framework layer: it protects any host application from
18
+ # leaking a credential into the audit trail. Applications are expected to add
19
+ # their own whitelist on top of it inside their sink.
20
+ DEFAULT_FILTER_WORDS = %w[
21
+ password token credential bearer authorization secret key code email phone
22
+ ].freeze
23
+
24
+ def build
25
+ to_obj({
26
+ enabled: parse_bool_string(ENV['AUDIT_ENABLED']),
27
+ max_params_bytes: get_int_value('AUDIT_MAX_PARAMS_BYTES', MAX_PARAMS_BYTES),
28
+ max_params_depth: get_int_value('AUDIT_MAX_PARAMS_DEPTH', MAX_PARAMS_DEPTH),
29
+ filter_words: parse_filter_words(ENV['AUDIT_FILTER_WORDS'])
30
+ })
31
+ end
32
+
33
+ private
34
+
35
+ def get_int_value(name, default)
36
+ value = ENV[name]
37
+
38
+ v = if is_blank(value)
39
+ default
40
+ else
41
+ Integer(value)
42
+ end
43
+
44
+ if v <= 0
45
+ raise ArgumentError, "ENV['#{name}'] should be > 0"
46
+ end
47
+
48
+ v
49
+ end
50
+
51
+ # Audit is opt-in: a host application that knows nothing about this package
52
+ # must not start writing an audit trail after a gem bump.
53
+ def parse_bool_string(bool)
54
+ return false if is_blank(bool)
55
+ bool.to_s.downcase == "true"
56
+ end
57
+
58
+ def parse_filter_words(value)
59
+ return DEFAULT_FILTER_WORDS if is_blank(value)
60
+ value.to_s.split(",").map { _1.strip.downcase }.reject(&:empty?)
61
+ end
62
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Per-fiber stack of audited events.
4
+ #
5
+ # A stack rather than a single slot: an action is allowed to call another
6
+ # audited action, and the inner one must not steal the outer one's annotations.
7
+ #
8
+ # Thread.current[] is fiber-local in Ruby, so this is safe under Falcon as well;
9
+ # ree_datetime/functions/set_default_time_zone.rb relies on the same property.
10
+ module ReeAudit
11
+ module Context
12
+ KEY = :ree_audit_events
13
+
14
+ class << self
15
+ def push(event)
16
+ stack << event
17
+ event
18
+ end
19
+
20
+ def pop
21
+ stack.pop
22
+ end
23
+
24
+ def current
25
+ stack.last
26
+ end
27
+
28
+ def stack
29
+ Thread.current[KEY] ||= []
30
+ end
31
+
32
+ # Leaving a stale event behind leaks one client's context into the next
33
+ # request served by the same fiber, so the stack is always cleaned up.
34
+ def clear
35
+ Thread.current[KEY] = []
36
+ nil
37
+ end
38
+
39
+ def empty?
40
+ stack.empty?
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ # One audited action call. Built by the transport layer (the ree_roda plugin),
4
+ # enriched by the application through ReeAudit.annotate, and handed to a sink
5
+ # once the call is over.
6
+ class ReeAudit::Event
7
+ STATUSES = [:ok, :denied, :error].freeze
8
+
9
+ attr_accessor :action_name, :package_name, :summary, :sections,
10
+ :request_method, :path, :request_path, :params, :accessor,
11
+ :status, :error_type, :error_message, :started_at,
12
+ :duration_ms, :annotations
13
+
14
+ class << self
15
+ # Keyword-only on purpose: callers live in another package and must not
16
+ # depend on the order of the fields here.
17
+ def build(
18
+ action_name:,
19
+ package_name: nil,
20
+ summary: nil,
21
+ sections: [],
22
+ request_method: nil,
23
+ path: nil,
24
+ request_path: nil,
25
+ params: {},
26
+ accessor: nil
27
+ )
28
+ event = new
29
+
30
+ event.action_name = action_name
31
+ event.package_name = package_name
32
+ event.summary = summary
33
+ event.sections = sections || []
34
+ event.request_method = request_method
35
+ event.path = path
36
+ event.request_path = request_path
37
+ event.params = params || {}
38
+ event.accessor = accessor
39
+ event.annotations = {}
40
+
41
+ event
42
+ end
43
+ end
44
+
45
+ def initialize
46
+ @sections = []
47
+ @params = {}
48
+ @annotations = {}
49
+ @status = :ok
50
+ end
51
+
52
+ def ok?
53
+ status == :ok
54
+ end
55
+
56
+ def denied?
57
+ status == :denied
58
+ end
59
+
60
+ def to_h
61
+ {
62
+ action_name: action_name,
63
+ package_name: package_name,
64
+ summary: summary,
65
+ sections: sections,
66
+ request_method: request_method,
67
+ path: path,
68
+ request_path: request_path,
69
+ params: params,
70
+ status: status,
71
+ error_type: error_type,
72
+ error_message: error_message,
73
+ started_at: started_at,
74
+ duration_ms: duration_ms,
75
+ annotations: annotations
76
+ }
77
+ end
78
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ class ReeAudit::FilterParams
6
+ include Ree::FnDSL
7
+
8
+ fn :filter_params do
9
+ link :config
10
+ end
11
+
12
+ FILTERED = 'FILTERED'
13
+ TRUNCATED_DEPTH = '[TRUNCATED_DEPTH]'
14
+
15
+ doc(<<~DOC)
16
+ Strips secrets out of request params before they reach an audit sink.
17
+
18
+ Unlike the logger filter, which only looks at top level keys, this one walks
19
+ the whole structure: a token nested three levels deep is still a token.
20
+ Two more limits keep a single event from becoming a payload dump — maximum
21
+ nesting depth and maximum serialized size.
22
+ DOC
23
+ contract(Hash => Hash)
24
+ def call(params)
25
+ filtered = filter_value(
26
+ params, 1, config.filter_words, config.max_params_depth
27
+ )
28
+
29
+ limit_size(filtered, config.max_params_bytes)
30
+ end
31
+
32
+ private
33
+
34
+ def filter_value(value, depth, filter_words, max_depth)
35
+ case value
36
+ when Hash
37
+ return TRUNCATED_DEPTH if depth > max_depth
38
+
39
+ value.each_with_object({}) do |(key, nested), result|
40
+ result[key] = if filtered_key?(key, filter_words)
41
+ FILTERED
42
+ else
43
+ filter_value(nested, depth + 1, filter_words, max_depth)
44
+ end
45
+ end
46
+ when Array
47
+ return TRUNCATED_DEPTH if depth > max_depth
48
+
49
+ value.map { filter_value(_1, depth + 1, filter_words, max_depth) }
50
+ else
51
+ value
52
+ end
53
+ end
54
+
55
+ def filtered_key?(key, filter_words)
56
+ name = key.to_s.downcase
57
+ filter_words.any? { name.include?(_1) }
58
+ end
59
+
60
+ def limit_size(params, max_bytes)
61
+ size = json_bytesize(params)
62
+
63
+ return params if size.nil? || size <= max_bytes
64
+
65
+ { truncated: true, size: size }
66
+ end
67
+
68
+ # A payload we cannot serialize is a payload we cannot measure; dropping the
69
+ # size limit is better than dropping the whole request.
70
+ def json_bytesize(params)
71
+ JSON.generate(params).bytesize
72
+ rescue StandardError
73
+ nil
74
+ end
75
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "sink"
4
+
5
+ # Development sink: dumps the event into the application log. Debug level on
6
+ # purpose — an audited call is written on every admin request.
7
+ class ReeAudit::LoggerSink < ReeAudit::Sink
8
+ def initialize(logger)
9
+ @logger = logger
10
+ end
11
+
12
+ def write(event)
13
+ @logger.debug("audit: #{event.action_name}", event.to_h)
14
+ end
15
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "sink"
4
+
5
+ # Default sink: an application that never configured one gets no audit trail
6
+ # rather than an exception.
7
+ class ReeAudit::NullSink < ReeAudit::Sink
8
+ def write(event)
9
+ nil
10
+ end
11
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Destination of finished audit events. A host application implements one and
4
+ # hands it to the ree_roda plugin; the package itself never knows where the
5
+ # trail is stored.
6
+ class ReeAudit::Sink
7
+ def write(event)
8
+ raise NotImplementedError, "should be implemented in derived class"
9
+ end
10
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReeAudit
4
+ include Ree::PackageDSL
5
+
6
+ package do
7
+ depends_on :ree_object
8
+ depends_on :ree_hash
9
+ depends_on :ree_datetime
10
+ depends_on :ree_logger
11
+ depends_on :ree_errors
12
+
13
+ env_var 'AUDIT_ENABLED'
14
+ env_var 'AUDIT_MAX_PARAMS_BYTES'
15
+ env_var 'AUDIT_MAX_PARAMS_DEPTH'
16
+ env_var 'AUDIT_FILTER_WORDS'
17
+ end
18
+
19
+ class << self
20
+ # Attaches application-level facts to the call currently being audited:
21
+ # which permission was checked, which object was opened, whose data it is.
22
+ #
23
+ # Returns nil when there is nothing to annotate — audit is disabled, or the
24
+ # code was reached from outside an audited route. Product code calls this
25
+ # through the `annotate_audit` fn, never through the constant directly, so
26
+ # that removing `depends_on :ree_audit` fails loudly instead of in
27
+ # production.
28
+ def annotate(**attrs)
29
+ event = Context.current
30
+ return nil if event.nil?
31
+
32
+ event.annotations.merge!(attrs)
33
+ end
34
+ end
35
+ end
36
+
37
+ require_relative "ree_audit/context"
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ package_require('ree_audit/beans/audit')
4
+ package_require('ree_audit/event')
5
+ package_require('ree_errors/permission_error')
6
+
7
+ RSpec.describe :audit do
8
+ link :audit, from: :ree_audit
9
+ link :config, from: :ree_audit
10
+ link :permission_error, from: :ree_errors
11
+ link :logger, from: :ree_logger
12
+
13
+ # Captures what the application would have persisted.
14
+ let(:test_sink) {
15
+ Class.new(ReeAudit::Sink) do
16
+ attr_reader :events
17
+
18
+ def initialize
19
+ @events = []
20
+ end
21
+
22
+ def write(event)
23
+ @events << event
24
+ end
25
+ end.new
26
+ }
27
+
28
+ let(:broken_sink) {
29
+ Class.new(ReeAudit::Sink) do
30
+ def write(event)
31
+ raise StandardError.new("sink is down")
32
+ end
33
+ end.new
34
+ }
35
+
36
+ let(:event) {
37
+ ReeAudit::Event.build(
38
+ action_name: :admin_users_query,
39
+ package_name: :accounts,
40
+ summary: "List users",
41
+ sections: ["Admin"],
42
+ request_method: :get,
43
+ path: "api/v1/accounts/admin/users",
44
+ request_path: "/api/v1/accounts/admin/users",
45
+ params: {page: 1, token: "secret"},
46
+ accessor: Object.new
47
+ )
48
+ }
49
+
50
+ before do
51
+ ReeAudit::Context.clear
52
+ audit.sink = test_sink
53
+ end
54
+
55
+ after do
56
+ audit.sink = ReeAudit::NullSink.new
57
+ ReeAudit::Context.clear
58
+ end
59
+
60
+ it "writes a successful call and returns the action result" do
61
+ result = audit.around(event) { :action_result }
62
+
63
+ expect(result).to eq(:action_result)
64
+ expect(test_sink.events.size).to eq(1)
65
+
66
+ written = test_sink.events.first
67
+ expect(written.status).to eq(:ok)
68
+ expect(written.action_name).to eq(:admin_users_query)
69
+ expect(written.error_type).to be_nil
70
+ expect(written.started_at).to be_a(Time)
71
+ expect(written.duration_ms).to be >= 0
72
+ end
73
+
74
+ it "filters secrets out of the params before they reach the sink" do
75
+ audit.around(event) { :ok }
76
+
77
+ expect(test_sink.events.first.params).to eq({page: 1, token: 'FILTERED'})
78
+ end
79
+
80
+ it "records a permission refusal and lets the error through" do
81
+ klass = permission_error(:no_access)
82
+
83
+ expect {
84
+ audit.around(event) { raise klass.new("not allowed") }
85
+ }.to raise_error(klass, "not allowed")
86
+
87
+ written = test_sink.events.first
88
+ expect(written.status).to eq(:denied)
89
+ expect(written.error_message).to eq("not allowed")
90
+ end
91
+
92
+ it "records an arbitrary failure as an error and lets it through" do
93
+ expect {
94
+ audit.around(event) { raise ArgumentError.new("boom") }
95
+ }.to raise_error(ArgumentError, "boom")
96
+
97
+ written = test_sink.events.first
98
+ expect(written.status).to eq(:error)
99
+ expect(written.error_type).to eq("ArgumentError")
100
+ expect(written.error_message).to eq("boom")
101
+ end
102
+
103
+ it "survives a broken sink: the request completes, the failure is logged" do
104
+ audit.sink = broken_sink
105
+
106
+ expect(logger).to receive(:error).at_least(:once)
107
+
108
+ expect(audit.around(event) { :action_result }).to eq(:action_result)
109
+ end
110
+
111
+ it "keeps nested calls on a stack" do
112
+ inner_event = ReeAudit::Event.build(action_name: :inner_query)
113
+ seen = []
114
+
115
+ audit.around(event) do
116
+ seen << ReeAudit::Context.current.action_name
117
+
118
+ audit.around(inner_event) do
119
+ seen << ReeAudit::Context.current.action_name
120
+ :inner
121
+ end
122
+
123
+ seen << ReeAudit::Context.current.action_name
124
+ :outer
125
+ end
126
+
127
+ expect(seen).to eq([:admin_users_query, :inner_query, :admin_users_query])
128
+ expect(ReeAudit::Context.current).to be_nil
129
+ expect(test_sink.events.map(&:action_name)).to eq([:inner_query, :admin_users_query])
130
+ end
131
+
132
+ it "costs nothing when audit is disabled" do
133
+ allow(config).to receive(:enabled).and_return(false)
134
+
135
+ result = audit.around(event) {
136
+ expect(ReeAudit::Context.current).to be_nil
137
+ :action_result
138
+ }
139
+
140
+ expect(result).to eq(:action_result)
141
+ expect(test_sink.events).to be_empty
142
+ end
143
+
144
+ it "annotates the event from inside the call" do
145
+ audit.around(event) do
146
+ ReeAudit.annotate(permission: :access_uploads, organization_id: 42)
147
+ ReeAudit.annotate(subject: {type: :upload, id: "abc"})
148
+ :ok
149
+ end
150
+
151
+ expect(test_sink.events.first.annotations).to eq({
152
+ permission: :access_uploads,
153
+ organization_id: 42,
154
+ subject: {type: :upload, id: "abc"}
155
+ })
156
+ end
157
+
158
+ it "ignores an annotation made outside an audited call" do
159
+ expect(ReeAudit.annotate(permission: :access_uploads)).to be_nil
160
+ end
161
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ package_require('ree_audit/event')
4
+
5
+ RSpec.describe ReeAudit::Context do
6
+ let(:event) { ReeAudit::Event.build(action_name: :some_query) }
7
+
8
+ before { ReeAudit::Context.clear }
9
+ after { ReeAudit::Context.clear }
10
+
11
+ it "is empty by default" do
12
+ expect(ReeAudit::Context.current).to be_nil
13
+ expect(ReeAudit::Context).to be_empty
14
+ end
15
+
16
+ it "pushes and pops" do
17
+ ReeAudit::Context.push(event)
18
+ expect(ReeAudit::Context.current).to eq(event)
19
+
20
+ expect(ReeAudit::Context.pop).to eq(event)
21
+ expect(ReeAudit::Context.current).to be_nil
22
+ end
23
+
24
+ # A leftover event would be picked up by the next request served by the same
25
+ # fiber — one client's annotations landing on another client's trail.
26
+ it "is unwound even when the wrapped call raises" do
27
+ expect {
28
+ begin
29
+ ReeAudit::Context.push(event)
30
+ raise ArgumentError.new("boom")
31
+ ensure
32
+ ReeAudit::Context.pop
33
+ end
34
+ }.to raise_error(ArgumentError)
35
+
36
+ expect(ReeAudit::Context.current).to be_nil
37
+ end
38
+
39
+ it "keeps stacks of different threads apart" do
40
+ ReeAudit::Context.push(event)
41
+
42
+ other = Thread.new { ReeAudit::Context.current }.value
43
+
44
+ expect(other).to be_nil
45
+ expect(ReeAudit::Context.current).to eq(event)
46
+ end
47
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ package_require('ree_audit/functions/filter_params')
4
+
5
+ RSpec.describe :filter_params do
6
+ link :filter_params, from: :ree_audit
7
+
8
+ it "leaves a plain payload untouched" do
9
+ expect(filter_params({page: 1, per_page: 25})).to eq({page: 1, per_page: 25})
10
+ end
11
+
12
+ it "filters a secret sitting at the top level" do
13
+ expect(filter_params({password: "hunter2", page: 1})).to eq(
14
+ {password: 'FILTERED', page: 1}
15
+ )
16
+ end
17
+
18
+ it "filters a secret nested three levels deep" do
19
+ result = filter_params({user: {profile: {user_password: "hunter2", name: "Ann"}}})
20
+
21
+ expect(result).to eq(
22
+ {user: {profile: {user_password: 'FILTERED', name: "Ann"}}}
23
+ )
24
+ end
25
+
26
+ it "filters inside an array of hashes" do
27
+ result = filter_params({items: [{auth_token: "t"}, {name: "ok"}]})
28
+
29
+ expect(result).to eq({items: [{auth_token: 'FILTERED'}, {name: "ok"}]})
30
+ end
31
+
32
+ it "matches filter words on string keys as well" do
33
+ expect(filter_params({"Authorization" => "Bearer x"})).to eq(
34
+ {"Authorization" => 'FILTERED'}
35
+ )
36
+ end
37
+
38
+ it "cuts everything deeper than the configured depth" do
39
+ result = filter_params({a: {b: {c: {d: {e: 1}}}}})
40
+
41
+ expect(result).to eq({a: {b: {c: {d: '[TRUNCATED_DEPTH]'}}}})
42
+ end
43
+
44
+ it "replaces an oversized payload with its size" do
45
+ result = filter_params({blob: "x" * 5000})
46
+
47
+ expect(result[:truncated]).to eq(true)
48
+ expect(result[:size]).to be > 4096
49
+ expect(result[:blob]).to be_nil
50
+ end
51
+
52
+ it "keeps a payload that fits the size limit" do
53
+ result = filter_params({blob: "x" * 100})
54
+
55
+ expect(result[:truncated]).to be_nil
56
+ expect(result[:blob]).to eq("x" * 100)
57
+ end
58
+ end
@@ -0,0 +1,13 @@
1
+ RSpec.configure do |config|
2
+ config.extend Ree::RSpecLinkDSL
3
+ end
4
+
5
+ ENV['AUDIT_ENABLED'] = 'true'
6
+ ENV['AUDIT_MAX_PARAMS_BYTES'] = '4096'
7
+ ENV['AUDIT_MAX_PARAMS_DEPTH'] = '4'
8
+
9
+ # The audit bean links a logger; keep it silent — the specs assert on the
10
+ # logger object itself, not on its output.
11
+ ENV['LOG_FILE_PATH'] = '/tmp/ree_audit.log'
12
+ ENV['LOG_LEVEL_FILE'] = ''
13
+ ENV['LOG_LEVEL_STDOUT'] = ''
@@ -7,12 +7,16 @@ class Roda
7
7
  package_require("ree_json/functions/to_json")
8
8
  package_require("ree_hash/functions/transform_values")
9
9
  package_require("ree_object/functions/not_blank")
10
+ package_require("ree_audit/beans/audit")
11
+ package_require("ree_audit/event")
10
12
 
11
13
  app.plugin :all_verbs
12
14
  end
13
15
 
14
16
  def self.configure(app, opts = {})
15
17
  app.opts[:ree_routes_before] = opts[:before] if opts[:before]
18
+ # An application that passes no `audit:` behaves exactly as before.
19
+ app.opts[:ree_routes_audit] = opts[:audit] if opts[:audit]
16
20
  end
17
21
 
18
22
  module ClassMethods
@@ -126,7 +130,31 @@ class Roda
126
130
  end
127
131
 
128
132
  accessor = authenticated_user
129
- action_result = get_cached_action(route).call(accessor, filtered_params)
133
+ audit = context.opts[:ree_routes_audit]
134
+
135
+ action_result = if audit && route.audited?
136
+ # The event carries the params exactly as the action
137
+ # received them. IP, user agent and session come from the
138
+ # accessor rather than from the request, so that the audit
139
+ # package stays free of any Roda dependency.
140
+ audit_event = ReeAudit::Event.build(
141
+ action_name: route.action.name,
142
+ package_name: route.action.package_name,
143
+ summary: route.summary,
144
+ sections: route.sections,
145
+ request_method: route.request_method,
146
+ path: route.path,
147
+ request_path: r.path,
148
+ params: filtered_params,
149
+ accessor: accessor
150
+ )
151
+
152
+ audit.around(audit_event) do
153
+ get_cached_action(route).call(accessor, filtered_params)
154
+ end
155
+ else
156
+ get_cached_action(route).call(accessor, filtered_params)
157
+ end
130
158
 
131
159
  if route.serializer
132
160
  serialized_result = get_cached_serializer(route).serialize(action_result)
@@ -11,6 +11,7 @@ module ReeRoda
11
11
  depends_on :ree_object
12
12
  depends_on :ree_swagger
13
13
  depends_on :ree_errors
14
+ depends_on :ree_audit
14
15
  end
15
16
  end
16
17
 
@@ -0,0 +1,281 @@
1
+ require "rack/test"
2
+
3
+ package_require("ree_roda/app")
4
+ package_require("ree_actions/dsl")
5
+ package_require("ree_routes/dsl")
6
+ package_require("ree_roda/plugins/ree_routes")
7
+ package_require("ree_audit/beans/audit")
8
+
9
+ require "warden"
10
+
11
+ RSpec.describe "ree_routes audit" do
12
+ include Rack::Test::Methods
13
+
14
+ link :audit, from: :ree_audit
15
+
16
+ before :all do
17
+ Ree.enable_irb_mode
18
+
19
+ module ReeRodaAuditTest
20
+ include Ree::PackageDSL
21
+
22
+ package do
23
+ depends_on :ree_actions
24
+ depends_on :ree_errors
25
+ end
26
+ end
27
+
28
+ class ReeRodaAuditTest::OkCmd
29
+ include ReeActions::DSL
30
+
31
+ action :ok_cmd
32
+
33
+ def call(access, attrs)
34
+ {result: "ok"}
35
+ end
36
+ end
37
+
38
+ class ReeRodaAuditTest::DeniedCmd
39
+ include ReeActions::DSL
40
+
41
+ action :denied_cmd do
42
+ link :permission_error, from: :ree_errors
43
+ end
44
+
45
+ PermissionErr = permission_error(:no_access, msg: "not allowed")
46
+
47
+ def call(access, attrs)
48
+ raise PermissionErr
49
+ end
50
+ end
51
+
52
+ class ReeRodaAuditTest::BoomCmd
53
+ include ReeActions::DSL
54
+
55
+ action :boom_cmd
56
+
57
+ def call(access, attrs)
58
+ raise ArgumentError.new("boom")
59
+ end
60
+ end
61
+
62
+ class ReeRodaAuditTest::Routes
63
+ include ReeRoutes::DSL
64
+
65
+ opts = {from: :ree_roda_audit_test}
66
+
67
+ routes :routes do
68
+ default_warden_scope :audit_visitor
69
+
70
+ get "api/internal_default" do
71
+ summary "Internal route"
72
+ sections "Admin"
73
+ visibility :internal
74
+ action :ok_cmd, **opts
75
+ end
76
+
77
+ get "api/internal_silenced" do
78
+ summary "Internal route we do not want in the trail"
79
+ sections "Admin"
80
+ visibility :internal
81
+ audit false
82
+ action :ok_cmd, **opts
83
+ end
84
+
85
+ get "api/public_default" do
86
+ summary "Public route"
87
+ sections "Public"
88
+ action :ok_cmd, **opts
89
+ end
90
+
91
+ get "api/public_audited" do
92
+ summary "Public route touching someone else's data"
93
+ sections "Public"
94
+ audit true
95
+ action :ok_cmd, **opts
96
+ end
97
+
98
+ get "api/internal_denied" do
99
+ summary "Internal route refusing access"
100
+ sections "Admin"
101
+ visibility :internal
102
+ action :denied_cmd, **opts
103
+ end
104
+
105
+ get "api/internal_boom" do
106
+ summary "Internal route blowing up"
107
+ sections "Admin"
108
+ visibility :internal
109
+ action :boom_cmd, **opts
110
+ end
111
+ end
112
+ end
113
+
114
+ class AuditVisitorStrategy < Warden::Strategies::Base
115
+ include Ree::LinkDSL
116
+
117
+ def valid?
118
+ true
119
+ end
120
+
121
+ def authenticate!
122
+ success!({user: "visitor"})
123
+ end
124
+ end
125
+
126
+ Warden::Strategies.add(:audit_visitor, AuditVisitorStrategy)
127
+
128
+ audit_bean = audit
129
+
130
+ # Class.new keeps `audit_bean` in scope; a `class` keyword would open a new
131
+ # lexical scope and lose it.
132
+ audited_app = Class.new(ReeRoda::App) do
133
+ use Warden::Manager do |config|
134
+ config.default_strategies :audit_visitor
135
+ config.default_scope = :audit_visitor
136
+ config.scope_defaults :audit_visitor, strategies: [:audit_visitor], store: false
137
+
138
+ config.failure_app = -> (env) {
139
+ [401, {"Content-Type" => "text/plain"}, ["requires authentication"]]
140
+ }
141
+ end
142
+
143
+ plugin :ree_routes, audit: audit_bean
144
+
145
+ ree_routes ReeRodaAuditTest::Routes.new, api_url: "http://some.api.url:1337"
146
+
147
+ route do |r|
148
+ r.ree_routes
149
+ end
150
+ end
151
+
152
+ Object.const_set(:AuditedTestApp, audited_app)
153
+
154
+ # Same routes, no `audit:` option — the regression guard for every other
155
+ # project running on ree_lib.
156
+ plain_app = Class.new(ReeRoda::App) do
157
+ use Warden::Manager do |config|
158
+ config.default_strategies :audit_visitor
159
+ config.default_scope = :audit_visitor
160
+ config.scope_defaults :audit_visitor, strategies: [:audit_visitor], store: false
161
+
162
+ config.failure_app = -> (env) {
163
+ [401, {"Content-Type" => "text/plain"}, ["requires authentication"]]
164
+ }
165
+ end
166
+
167
+ plugin :ree_routes
168
+
169
+ ree_routes ReeRodaAuditTest::Routes.new, api_url: "http://some.api.url:1337"
170
+
171
+ route do |r|
172
+ r.ree_routes
173
+ end
174
+ end
175
+
176
+ Object.const_set(:PlainTestApp, plain_app)
177
+ end
178
+
179
+ after :all do
180
+ Ree.disable_irb_mode
181
+ end
182
+
183
+ let(:test_sink) {
184
+ Class.new(ReeAudit::Sink) do
185
+ attr_reader :events
186
+
187
+ def initialize
188
+ @events = []
189
+ end
190
+
191
+ def write(event)
192
+ @events << event
193
+ end
194
+ end.new
195
+ }
196
+
197
+ let(:app) { AuditedTestApp.app }
198
+
199
+ before {
200
+ audit.sink = test_sink
201
+ ReeAudit::Context.clear
202
+ }
203
+
204
+ after {
205
+ audit.sink = ReeAudit::NullSink.new
206
+ ReeAudit::Context.clear
207
+ }
208
+
209
+ it "writes an internal route to the trail" do
210
+ get "api/internal_default"
211
+
212
+ expect(last_response.status).to eq(200)
213
+ expect(last_response.body).to eq("{}")
214
+
215
+ expect(test_sink.events.size).to eq(1)
216
+
217
+ event = test_sink.events.first
218
+ expect(event.action_name).to eq(:ok_cmd)
219
+ expect(event.package_name).to eq(:ree_roda_audit_test)
220
+ expect(event.status).to eq(:ok)
221
+ expect(event.sections).to eq(["Admin"])
222
+ expect(event.request_method).to eq(:get)
223
+ expect(event.path).to eq("api/internal_default")
224
+ expect(event.request_path).to eq("/api/internal_default")
225
+ expect(event.accessor).to eq({user: "visitor"})
226
+ end
227
+
228
+ it "leaves a silenced internal route out of the trail" do
229
+ get "api/internal_silenced"
230
+
231
+ expect(last_response.status).to eq(200)
232
+ expect(test_sink.events).to be_empty
233
+ end
234
+
235
+ it "leaves a public route out of the trail" do
236
+ get "api/public_default"
237
+
238
+ expect(last_response.status).to eq(200)
239
+ expect(test_sink.events).to be_empty
240
+ end
241
+
242
+ it "writes a public route that asked to be audited" do
243
+ get "api/public_audited"
244
+
245
+ expect(last_response.status).to eq(200)
246
+ expect(test_sink.events.size).to eq(1)
247
+ expect(test_sink.events.first.status).to eq(:ok)
248
+ end
249
+
250
+ it "records a refusal and still answers 403" do
251
+ get "api/internal_denied"
252
+
253
+ expect(last_response.status).to eq(403)
254
+
255
+ event = test_sink.events.first
256
+ expect(event.status).to eq(:denied)
257
+ expect(event.error_message).to eq("not allowed")
258
+ end
259
+
260
+ it "records a failure and still lets the error handler answer 500" do
261
+ get "api/internal_boom"
262
+
263
+ expect(last_response.status).to eq(500)
264
+
265
+ event = test_sink.events.first
266
+ expect(event.status).to eq(:error)
267
+ expect(event.error_type).to eq("ArgumentError")
268
+ end
269
+
270
+ context "an application that never asked for audit" do
271
+ let(:app) { PlainTestApp.app }
272
+
273
+ it "behaves exactly as before" do
274
+ get "api/internal_default"
275
+
276
+ expect(last_response.status).to eq(200)
277
+ expect(last_response.body).to eq("{}")
278
+ expect(test_sink.events).to be_empty
279
+ end
280
+ end
281
+ end
@@ -1,3 +1,6 @@
1
1
  RSpec.configure do |config|
2
2
  config.extend Ree::RSpecLinkDSL
3
3
  end
4
+
5
+ # The audit plugin option is inert unless the package itself is enabled.
6
+ ENV['AUDIT_ENABLED'] = 'true'
@@ -3,7 +3,9 @@
3
3
  class ReeRoutes::Route
4
4
  attr_accessor :summary, :request_method, :serializer, :respond_to,
5
5
  :sections, :action, :route, :warden_scopes, :path, :override,
6
- :visibility
6
+ :visibility, :before, :redirect
7
+
8
+ attr_writer :audit
7
9
 
8
10
  # Routing always includes every route; visibility only affects generated docs.
9
11
  def public?
@@ -14,6 +16,14 @@ class ReeRoutes::Route
14
16
  !public?
15
17
  end
16
18
 
19
+ # Internal routes are audited unless told otherwise. `visibility` is
20
+ # mandatory for admin paths (see DSL#define_route), so this list can never
21
+ # drift away from the set of routes that actually touch client data — which a
22
+ # hand-maintained list of audited routes inevitably would.
23
+ def audited?
24
+ @audit.nil? ? internal? : @audit
25
+ end
26
+
17
27
  def valid?
18
28
  !action.nil? && !summary.nil? && !warden_scopes.nil? && !warden_scopes.empty?
19
29
  end
@@ -44,6 +44,13 @@ class ReeRoutes::RouteBuilder
44
44
  @route.summary = str
45
45
  end
46
46
 
47
+ # Needed in exactly two cases: silencing a noisy internal route, and turning
48
+ # the trail on for a public route that reaches into someone else's data.
49
+ contract Bool => Bool
50
+ def audit(value)
51
+ @route.audit = value
52
+ end
53
+
47
54
  contract Symbol => Symbol
48
55
  def visibility(value)
49
56
  if !VISIBILITIES.include?(value)
@@ -68,6 +68,38 @@ RSpec.describe ReeRoutes::DSL, type: [:autoclean] do
68
68
  end
69
69
  end
70
70
  end
71
+
72
+ class ReeRoutesTest::AuditRoutes
73
+ include ReeRoutes::DSL
74
+
75
+ routes :audit_routes do
76
+ default_warden_scope :user
77
+
78
+ get "internal_default" do
79
+ summary "Internal route, audited by default"
80
+ action :cmd, from: :ree_routes_test
81
+ visibility :internal
82
+ end
83
+
84
+ get "internal_silenced" do
85
+ summary "Internal route, deliberately not audited"
86
+ action :cmd, from: :ree_routes_test
87
+ visibility :internal
88
+ audit false
89
+ end
90
+
91
+ get "public_default" do
92
+ summary "Public route, not audited"
93
+ action :cmd, from: :ree_routes_test
94
+ end
95
+
96
+ get "public_audited" do
97
+ summary "Public route reaching into someone else's data"
98
+ action :cmd, from: :ree_routes_test
99
+ audit true
100
+ end
101
+ end
102
+ end
71
103
  end
72
104
 
73
105
  after :all do
@@ -92,4 +124,15 @@ RSpec.describe ReeRoutes::DSL, type: [:autoclean] do
92
124
 
93
125
  expect(csv_route.respond_to).to eq(:csv)
94
126
  }
127
+
128
+ it {
129
+ routes = ReeRoutesTest::AuditRoutes.new.each_with_object({}) { |r, acc|
130
+ acc[r.path] = r
131
+ }
132
+
133
+ expect(routes["internal_default"].audited?).to eq(true)
134
+ expect(routes["internal_silenced"].audited?).to eq(false)
135
+ expect(routes["public_default"].audited?).to eq(false)
136
+ expect(routes["public_audited"].audited?).to eq(true)
137
+ }
95
138
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ReeLib
4
- VERSION = "1.3.10"
4
+ VERSION = "1.3.11"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ree_lib
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.10
4
+ version: 1.3.11
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ruslan Gatiyatov
@@ -276,6 +276,20 @@ files:
276
276
  - lib/ree_lib/packages/ree_array/spec/ree_array/functions/split_by_spec.rb
277
277
  - lib/ree_lib/packages/ree_array/spec/ree_array/functions/wrap_spec.rb
278
278
  - lib/ree_lib/packages/ree_array/spec/spec_helper.rb
279
+ - lib/ree_lib/packages/ree_audit/.rspec
280
+ - lib/ree_lib/packages/ree_audit/package/ree_audit.rb
281
+ - lib/ree_lib/packages/ree_audit/package/ree_audit/beans/audit.rb
282
+ - lib/ree_lib/packages/ree_audit/package/ree_audit/config.rb
283
+ - lib/ree_lib/packages/ree_audit/package/ree_audit/context.rb
284
+ - lib/ree_lib/packages/ree_audit/package/ree_audit/event.rb
285
+ - lib/ree_lib/packages/ree_audit/package/ree_audit/functions/filter_params.rb
286
+ - lib/ree_lib/packages/ree_audit/package/ree_audit/sinks/logger_sink.rb
287
+ - lib/ree_lib/packages/ree_audit/package/ree_audit/sinks/null_sink.rb
288
+ - lib/ree_lib/packages/ree_audit/package/ree_audit/sinks/sink.rb
289
+ - lib/ree_lib/packages/ree_audit/spec/ree_audit/beans/audit_spec.rb
290
+ - lib/ree_lib/packages/ree_audit/spec/ree_audit/context_spec.rb
291
+ - lib/ree_lib/packages/ree_audit/spec/ree_audit/functions/filter_params_spec.rb
292
+ - lib/ree_lib/packages/ree_audit/spec/spec_helper.rb
279
293
  - lib/ree_lib/packages/ree_dao/.gitignore
280
294
  - lib/ree_lib/packages/ree_dao/.rspec
281
295
  - lib/ree_lib/packages/ree_dao/bin/console
@@ -897,6 +911,7 @@ files:
897
911
  - lib/ree_lib/packages/ree_roda/package/ree_roda/services/status_from_error.rb
898
912
  - lib/ree_lib/packages/ree_roda/spec/ree_roda/app_spec.rb
899
913
  - lib/ree_lib/packages/ree_roda/spec/ree_roda/plugins/ree_logger_spec.rb
914
+ - lib/ree_lib/packages/ree_roda/spec/ree_roda/plugins/ree_routes_audit_spec.rb
900
915
  - lib/ree_lib/packages/ree_roda/spec/ree_roda/services/build_routing_tree_spec.rb
901
916
  - lib/ree_lib/packages/ree_roda/spec/ree_roda/services/build_swagger_from_routes_spec.rb
902
917
  - lib/ree_lib/packages/ree_roda/spec/ree_roda/services/locales/en.yml