ree_lib 1.3.9 → 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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +12 -0
  3. data/Gemfile.lock +2 -2
  4. data/lib/ree_lib/Packages.schema.json +4 -0
  5. data/lib/ree_lib/packages/ree_audit/.rspec +2 -0
  6. data/lib/ree_lib/packages/ree_audit/package/ree_audit/beans/audit.rb +83 -0
  7. data/lib/ree_lib/packages/ree_audit/package/ree_audit/config.rb +62 -0
  8. data/lib/ree_lib/packages/ree_audit/package/ree_audit/context.rb +44 -0
  9. data/lib/ree_lib/packages/ree_audit/package/ree_audit/event.rb +78 -0
  10. data/lib/ree_lib/packages/ree_audit/package/ree_audit/functions/filter_params.rb +75 -0
  11. data/lib/ree_lib/packages/ree_audit/package/ree_audit/sinks/logger_sink.rb +15 -0
  12. data/lib/ree_lib/packages/ree_audit/package/ree_audit/sinks/null_sink.rb +11 -0
  13. data/lib/ree_lib/packages/ree_audit/package/ree_audit/sinks/sink.rb +10 -0
  14. data/lib/ree_lib/packages/ree_audit/package/ree_audit.rb +37 -0
  15. data/lib/ree_lib/packages/ree_audit/spec/ree_audit/beans/audit_spec.rb +161 -0
  16. data/lib/ree_lib/packages/ree_audit/spec/ree_audit/context_spec.rb +47 -0
  17. data/lib/ree_lib/packages/ree_audit/spec/ree_audit/functions/filter_params_spec.rb +58 -0
  18. data/lib/ree_lib/packages/ree_audit/spec/spec_helper.rb +13 -0
  19. data/lib/ree_lib/packages/ree_dao/package/ree_dao/dataset_extensions.rb +15 -13
  20. data/lib/ree_lib/packages/ree_dao/spec/ree_dao/db_load_performance_mode_spec.rb +190 -0
  21. data/lib/ree_lib/packages/ree_dto/package/ree_dto/dto/dto_class_methods.rb +14 -0
  22. data/lib/ree_lib/packages/ree_dto/spec/ree_dto/build_from_attrs_spec.rb +48 -0
  23. data/lib/ree_lib/packages/ree_mapper/package/ree_mapper/db_load_compiler.rb +145 -0
  24. data/lib/ree_lib/packages/ree_mapper/package/ree_mapper/mapper.rb +25 -0
  25. data/lib/ree_lib/packages/ree_mapper/package/ree_mapper/mapper_strategy.rb +1 -3
  26. data/lib/ree_lib/packages/ree_mapper/package/ree_mapper/types/abstract_type.rb +9 -0
  27. data/lib/ree_lib/packages/ree_mapper/package/ree_mapper/types/any.rb +4 -0
  28. data/lib/ree_lib/packages/ree_mapper/package/ree_mapper/types/bool.rb +4 -0
  29. data/lib/ree_lib/packages/ree_mapper/package/ree_mapper/types/date.rb +5 -0
  30. data/lib/ree_lib/packages/ree_mapper/package/ree_mapper/types/date_time.rb +4 -0
  31. data/lib/ree_lib/packages/ree_mapper/package/ree_mapper/types/float.rb +4 -0
  32. data/lib/ree_lib/packages/ree_mapper/package/ree_mapper/types/integer.rb +4 -0
  33. data/lib/ree_lib/packages/ree_mapper/package/ree_mapper/types/rational.rb +4 -0
  34. data/lib/ree_lib/packages/ree_mapper/package/ree_mapper/types/string.rb +4 -0
  35. data/lib/ree_lib/packages/ree_mapper/package/ree_mapper/types/time.rb +5 -0
  36. data/lib/ree_lib/packages/ree_mapper/package/ree_mapper/wrappers/abstract_wrapper.rb +5 -0
  37. data/lib/ree_lib/packages/ree_mapper/package/ree_mapper.rb +6 -0
  38. data/lib/ree_lib/packages/ree_mapper/spec/ree_mapper/benchmarks/db_load_benchmark_spec.rb +218 -0
  39. data/lib/ree_lib/packages/ree_mapper/spec/ree_mapper/db_load_performance_mode_spec.rb +339 -0
  40. data/lib/ree_lib/packages/ree_roda/package/ree_roda/plugins/ree_routes.rb +29 -1
  41. data/lib/ree_lib/packages/ree_roda/package/ree_roda.rb +1 -0
  42. data/lib/ree_lib/packages/ree_roda/spec/ree_roda/plugins/ree_routes_audit_spec.rb +281 -0
  43. data/lib/ree_lib/packages/ree_roda/spec/spec_helper.rb +3 -0
  44. data/lib/ree_lib/packages/ree_routes/package/ree_routes/route.rb +11 -1
  45. data/lib/ree_lib/packages/ree_routes/package/ree_routes/route_builder.rb +7 -0
  46. data/lib/ree_lib/packages/ree_routes/spec/ree_routes/dsl_spec.rb +43 -0
  47. data/lib/ree_lib/version.rb +1 -1
  48. metadata +21 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 237cf0a00005f59af6c0741f79302159d84433b60b63e401baa4ae95ab0dd2ae
4
- data.tar.gz: f9c00515a41e9d78fc2c3ac1785d5a8a21d1f4142461537299e4c1678d4a6b61
3
+ metadata.gz: 49fa98abf408507fb7c59dea94efc94717156aa50e76b3015aee6dcada1e0f17
4
+ data.tar.gz: 9a5927b28c56d6b8982e2565340d7816600f894becd05493bfcb9178196e85c6
5
5
  SHA512:
6
- metadata.gz: c712530b729aa1e48d53de393ee737b7605c233108940e78f09f4561c36b69c413136880f8b72f73f694d4a6ac5ee7e4010d2d46ff2dcdb30450ad1fd2bb9a56
7
- data.tar.gz: e148d3f589e0c2863a9b1958d903458407c1646f0fca6ca79c5fb0839893c0bc00059b4a80a9c0b45cf6710e4e9b6802f6a94ee9b8958fa485701164cd99a240
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
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ../ree
3
3
  specs:
4
- ree (1.3.3)
4
+ ree (1.3.5)
5
5
  base64
6
6
  commander
7
7
  logger
@@ -9,7 +9,7 @@ PATH
9
9
  PATH
10
10
  remote: .
11
11
  specs:
12
- ree_lib (1.3.9)
12
+ ree_lib (1.3.10)
13
13
  bigdecimal
14
14
  binding_of_caller
15
15
  i18n
@@ -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