ree_lib 1.3.10 → 1.3.12
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 +18 -0
- data/Gemfile.lock +1 -1
- 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/annotate_audit.rb +25 -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/annotate_audit_spec.rb +28 -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_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 +18 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e10f8d9ba3558f200f081f325d2a7f10b81696459c8fbdd9054fd4e0b72c72ed
|
|
4
|
+
data.tar.gz: fc2f2bba979d98082ed296caeeb8823cf1cef3201cbda6244adb131e7ccc5db1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4cbd59039f6e3b238db9c88763e75f753a567c13edd080eda2e6fafbed838e3c61a71d9ee40af35b7042f58cee315f5aa3c7cdf8be8b2e7ae339f9ed7883906d
|
|
7
|
+
data.tar.gz: e0dddd85f84fec535514ebbec51e78425511b32a8ee877b65fdf4ac31f513717f9c12ab80d4772d7649ad1856ad625bf1d967e9c56d78823dc1435196b438c85
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [1.3.12] - 2026-09-02
|
|
4
|
+
|
|
5
|
+
- `ree_audit`: `annotate_audit` fn — the entry point product code uses to attach
|
|
6
|
+
facts to the current audited call. Declared `freeze false` so a host
|
|
7
|
+
application can stub it in its own specs.
|
|
8
|
+
|
|
9
|
+
## [1.3.11] - 2026-09-02
|
|
10
|
+
|
|
11
|
+
- New `ree_audit` package: an opt-in trail of audited action calls (event,
|
|
12
|
+
per-fiber context with `ReeAudit.annotate`, recursive params filter, sinks).
|
|
13
|
+
Disabled by default via `AUDIT_ENABLED`.
|
|
14
|
+
- `ree_routes`: `Route#audited?` and the `audit true/false` route DSL. Internal
|
|
15
|
+
routes are audited by default; public ones are not.
|
|
16
|
+
- `ree_routes`: `Route` got the missing `before` and `redirect` accessors —
|
|
17
|
+
`RouteBuilder#before` and `#redirect` wrote to setters that did not exist.
|
|
18
|
+
- `ree_roda`: the `ree_routes` plugin accepts `audit:`. Without the option the
|
|
19
|
+
plugin behaves exactly as before.
|
|
20
|
+
|
|
3
21
|
## [1.3.0] - 2025-05-14
|
|
4
22
|
|
|
5
23
|
- 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,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Точка входа для продуктового кода: прикрепить к текущему аудируемому вызову
|
|
4
|
+
# то, что известно только внутри — какое право проверено, к чьим данным и к
|
|
5
|
+
# какому объекту обратились.
|
|
6
|
+
#
|
|
7
|
+
# Почему fn, а не голая константа ReeAudit.annotate: одного `depends_on
|
|
8
|
+
# :ree_audit` хватило бы, чтобы пакет загрузился, но проверка объявленной
|
|
9
|
+
# зависимости (check_package_dependency_added) срабатывает только для
|
|
10
|
+
# `link`/`import`. Голая константа проходит мимо неё — и удаление `depends_on`
|
|
11
|
+
# как «неиспользуемого» уронило бы приложение NameError'ом уже в проде.
|
|
12
|
+
class ReeAudit::AnnotateAudit
|
|
13
|
+
include Ree::FnDSL
|
|
14
|
+
|
|
15
|
+
fn :annotate_audit do
|
|
16
|
+
# fn из общего пакета иначе не подменить в спеках приложения
|
|
17
|
+
freeze false
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
doc("Прикрепляет факты приложения к текущему событию аудита. nil — если аудит выключен или вызов пришёл не из аудируемого маршрута.")
|
|
21
|
+
contract(Ksplat[RestKeys => Any] => Nilor[Hash])
|
|
22
|
+
def call(**attrs)
|
|
23
|
+
ReeAudit.annotate(**attrs)
|
|
24
|
+
end
|
|
25
|
+
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
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
package_require('ree_audit/functions/annotate_audit')
|
|
4
|
+
package_require('ree_audit/event')
|
|
5
|
+
|
|
6
|
+
RSpec.describe :annotate_audit do
|
|
7
|
+
link :annotate_audit, from: :ree_audit
|
|
8
|
+
|
|
9
|
+
before { ReeAudit::Context.clear }
|
|
10
|
+
after { ReeAudit::Context.clear }
|
|
11
|
+
|
|
12
|
+
it "returns nil outside an audited call" do
|
|
13
|
+
expect(annotate_audit(permission: :access_uploads)).to be_nil
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it "merges into the current event" do
|
|
17
|
+
event = ReeAudit::Event.build(action_name: :some_query)
|
|
18
|
+
ReeAudit::Context.push(event)
|
|
19
|
+
|
|
20
|
+
annotate_audit(permission: :access_uploads)
|
|
21
|
+
annotate_audit(subject: {type: :upload, id: "abc"})
|
|
22
|
+
|
|
23
|
+
expect(event.annotations).to eq({
|
|
24
|
+
permission: :access_uploads,
|
|
25
|
+
subject: {type: :upload, id: "abc"}
|
|
26
|
+
})
|
|
27
|
+
end
|
|
28
|
+
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
|
-
|
|
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)
|
|
@@ -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
|
|
@@ -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
|
data/lib/ree_lib/version.rb
CHANGED
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.
|
|
4
|
+
version: 1.3.12
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ruslan Gatiyatov
|
|
@@ -276,6 +276,22 @@ 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/annotate_audit.rb
|
|
286
|
+
- lib/ree_lib/packages/ree_audit/package/ree_audit/functions/filter_params.rb
|
|
287
|
+
- lib/ree_lib/packages/ree_audit/package/ree_audit/sinks/logger_sink.rb
|
|
288
|
+
- lib/ree_lib/packages/ree_audit/package/ree_audit/sinks/null_sink.rb
|
|
289
|
+
- lib/ree_lib/packages/ree_audit/package/ree_audit/sinks/sink.rb
|
|
290
|
+
- lib/ree_lib/packages/ree_audit/spec/ree_audit/beans/audit_spec.rb
|
|
291
|
+
- lib/ree_lib/packages/ree_audit/spec/ree_audit/context_spec.rb
|
|
292
|
+
- lib/ree_lib/packages/ree_audit/spec/ree_audit/functions/annotate_audit_spec.rb
|
|
293
|
+
- lib/ree_lib/packages/ree_audit/spec/ree_audit/functions/filter_params_spec.rb
|
|
294
|
+
- lib/ree_lib/packages/ree_audit/spec/spec_helper.rb
|
|
279
295
|
- lib/ree_lib/packages/ree_dao/.gitignore
|
|
280
296
|
- lib/ree_lib/packages/ree_dao/.rspec
|
|
281
297
|
- lib/ree_lib/packages/ree_dao/bin/console
|
|
@@ -897,6 +913,7 @@ files:
|
|
|
897
913
|
- lib/ree_lib/packages/ree_roda/package/ree_roda/services/status_from_error.rb
|
|
898
914
|
- lib/ree_lib/packages/ree_roda/spec/ree_roda/app_spec.rb
|
|
899
915
|
- lib/ree_lib/packages/ree_roda/spec/ree_roda/plugins/ree_logger_spec.rb
|
|
916
|
+
- lib/ree_lib/packages/ree_roda/spec/ree_roda/plugins/ree_routes_audit_spec.rb
|
|
900
917
|
- lib/ree_lib/packages/ree_roda/spec/ree_roda/services/build_routing_tree_spec.rb
|
|
901
918
|
- lib/ree_lib/packages/ree_roda/spec/ree_roda/services/build_swagger_from_routes_spec.rb
|
|
902
919
|
- lib/ree_lib/packages/ree_roda/spec/ree_roda/services/locales/en.yml
|