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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +12 -0
- data/Gemfile.lock +2 -2
- data/lib/ree_lib/Packages.schema.json +4 -0
- data/lib/ree_lib/packages/ree_audit/.rspec +2 -0
- data/lib/ree_lib/packages/ree_audit/package/ree_audit/beans/audit.rb +83 -0
- data/lib/ree_lib/packages/ree_audit/package/ree_audit/config.rb +62 -0
- data/lib/ree_lib/packages/ree_audit/package/ree_audit/context.rb +44 -0
- data/lib/ree_lib/packages/ree_audit/package/ree_audit/event.rb +78 -0
- data/lib/ree_lib/packages/ree_audit/package/ree_audit/functions/filter_params.rb +75 -0
- data/lib/ree_lib/packages/ree_audit/package/ree_audit/sinks/logger_sink.rb +15 -0
- data/lib/ree_lib/packages/ree_audit/package/ree_audit/sinks/null_sink.rb +11 -0
- data/lib/ree_lib/packages/ree_audit/package/ree_audit/sinks/sink.rb +10 -0
- data/lib/ree_lib/packages/ree_audit/package/ree_audit.rb +37 -0
- data/lib/ree_lib/packages/ree_audit/spec/ree_audit/beans/audit_spec.rb +161 -0
- data/lib/ree_lib/packages/ree_audit/spec/ree_audit/context_spec.rb +47 -0
- data/lib/ree_lib/packages/ree_audit/spec/ree_audit/functions/filter_params_spec.rb +58 -0
- data/lib/ree_lib/packages/ree_audit/spec/spec_helper.rb +13 -0
- data/lib/ree_lib/packages/ree_dao/package/ree_dao/dataset_extensions.rb +15 -13
- data/lib/ree_lib/packages/ree_dao/spec/ree_dao/db_load_performance_mode_spec.rb +190 -0
- data/lib/ree_lib/packages/ree_dto/package/ree_dto/dto/dto_class_methods.rb +14 -0
- data/lib/ree_lib/packages/ree_dto/spec/ree_dto/build_from_attrs_spec.rb +48 -0
- data/lib/ree_lib/packages/ree_mapper/package/ree_mapper/db_load_compiler.rb +145 -0
- data/lib/ree_lib/packages/ree_mapper/package/ree_mapper/mapper.rb +25 -0
- data/lib/ree_lib/packages/ree_mapper/package/ree_mapper/mapper_strategy.rb +1 -3
- data/lib/ree_lib/packages/ree_mapper/package/ree_mapper/types/abstract_type.rb +9 -0
- data/lib/ree_lib/packages/ree_mapper/package/ree_mapper/types/any.rb +4 -0
- data/lib/ree_lib/packages/ree_mapper/package/ree_mapper/types/bool.rb +4 -0
- data/lib/ree_lib/packages/ree_mapper/package/ree_mapper/types/date.rb +5 -0
- data/lib/ree_lib/packages/ree_mapper/package/ree_mapper/types/date_time.rb +4 -0
- data/lib/ree_lib/packages/ree_mapper/package/ree_mapper/types/float.rb +4 -0
- data/lib/ree_lib/packages/ree_mapper/package/ree_mapper/types/integer.rb +4 -0
- data/lib/ree_lib/packages/ree_mapper/package/ree_mapper/types/rational.rb +4 -0
- data/lib/ree_lib/packages/ree_mapper/package/ree_mapper/types/string.rb +4 -0
- data/lib/ree_lib/packages/ree_mapper/package/ree_mapper/types/time.rb +5 -0
- data/lib/ree_lib/packages/ree_mapper/package/ree_mapper/wrappers/abstract_wrapper.rb +5 -0
- data/lib/ree_lib/packages/ree_mapper/package/ree_mapper.rb +6 -0
- data/lib/ree_lib/packages/ree_mapper/spec/ree_mapper/benchmarks/db_load_benchmark_spec.rb +218 -0
- data/lib/ree_lib/packages/ree_mapper/spec/ree_mapper/db_load_performance_mode_spec.rb +339 -0
- data/lib/ree_lib/packages/ree_roda/package/ree_roda/plugins/ree_routes.rb +29 -1
- data/lib/ree_lib/packages/ree_roda/package/ree_roda.rb +1 -0
- data/lib/ree_lib/packages/ree_roda/spec/ree_roda/plugins/ree_routes_audit_spec.rb +281 -0
- data/lib/ree_lib/packages/ree_roda/spec/spec_helper.rb +3 -0
- data/lib/ree_lib/packages/ree_routes/package/ree_routes/route.rb +11 -1
- data/lib/ree_lib/packages/ree_routes/package/ree_routes/route_builder.rb +7 -0
- data/lib/ree_lib/packages/ree_routes/spec/ree_routes/dsl_spec.rb +43 -0
- data/lib/ree_lib/version.rb +1 -1
- metadata +21 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 49fa98abf408507fb7c59dea94efc94717156aa50e76b3015aee6dcada1e0f17
|
|
4
|
+
data.tar.gz: 9a5927b28c56d6b8982e2565340d7816600f894becd05493bfcb9178196e85c6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
@@ -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,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,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
|