ree_lib 1.3.11 → 1.3.13
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 +6 -0
- data/Gemfile.lock +1 -1
- data/lib/ree_lib/packages/ree_audit/README.md +202 -0
- data/lib/ree_lib/packages/ree_audit/package/ree_audit/beans/audit.rb +1 -1
- data/lib/ree_lib/packages/ree_audit/package/ree_audit/event.rb +13 -2
- data/lib/ree_lib/packages/ree_audit/package/ree_audit/functions/annotate_audit.rb +25 -0
- data/lib/ree_lib/packages/ree_audit/spec/ree_audit/beans/audit_spec.rb +37 -0
- data/lib/ree_lib/packages/ree_audit/spec/ree_audit/functions/annotate_audit_spec.rb +28 -0
- data/lib/ree_lib/packages/ree_roda/package/ree_roda/plugins/ree_routes.rb +2 -1
- data/lib/ree_lib/packages/ree_roda/spec/ree_roda/plugins/ree_routes_audit_spec.rb +74 -0
- data/lib/ree_lib/packages/ree_routes/package/ree_routes/route.rb +10 -1
- data/lib/ree_lib/packages/ree_routes/package/ree_routes/route_builder.rb +12 -3
- data/lib/ree_lib/packages/ree_routes/spec/ree_routes/dsl_spec.rb +17 -0
- data/lib/ree_lib/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5a4843ec9ab12ce886333af41c258c9994ced5666fc6720a443fd42e7b8e1300
|
|
4
|
+
data.tar.gz: de155878c3031c3bcbfc24a3ca1b2fb1cf6822420cbd3959f3221da9172f1769
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 399d35c7b455d605572dcf8c5ebd426de556401e9eb6fd75b0c47745abf691dc8956f743e94e3ab8674c991d6bea040d55901e0b9443d7ec55a7c7bbc609f65a
|
|
7
|
+
data.tar.gz: ecad1589e6231b1fee0baa64d7bfcc5fea36dcb23deb5b02b6bbe9f0bcc307934f50ec086b7ec795c55a8a22c899516c8680b1bb0943c50136dcb9020963914d
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
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
|
+
|
|
3
9
|
## [1.3.11] - 2026-09-02
|
|
4
10
|
|
|
5
11
|
- New `ree_audit` package: an opt-in trail of audited action calls (event,
|
data/Gemfile.lock
CHANGED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# ree_audit
|
|
2
|
+
|
|
3
|
+
An opt-in trail of audited action calls.
|
|
4
|
+
|
|
5
|
+
The package answers one question: **who called what, when, and how did it end.**
|
|
6
|
+
It says nothing about what happened inside the action, and nothing about files
|
|
7
|
+
downloaded straight from object storage — see *Boundaries* below.
|
|
8
|
+
|
|
9
|
+
## Why it exists
|
|
10
|
+
|
|
11
|
+
An application that serves an internal (admin) API eventually has to answer a
|
|
12
|
+
customer, an auditor or a regulator: *did your staff look at my data, and when?*
|
|
13
|
+
Answering that from request logs does not work — they rotate, they contain
|
|
14
|
+
secrets, and they cannot be handed to a customer. This package produces a
|
|
15
|
+
structured record per audited call that an application can persist wherever it
|
|
16
|
+
wants and export as-is.
|
|
17
|
+
|
|
18
|
+
## Off by default
|
|
19
|
+
|
|
20
|
+
`AUDIT_ENABLED` defaults to `false`, and an application that never passes
|
|
21
|
+
`audit:` to the `ree_routes` plugin behaves exactly as it did before the gem was
|
|
22
|
+
bumped. Both properties are covered by specs: a shared framework must not start
|
|
23
|
+
recording anything because someone upgraded a dependency.
|
|
24
|
+
|
|
25
|
+
When audit is disabled, `Audit#around` costs a single `if`.
|
|
26
|
+
|
|
27
|
+
## Wiring it up
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
class MyApp < ReeRoda::App
|
|
31
|
+
link :audit, from: :ree_audit, import: -> { Audit }
|
|
32
|
+
link :my_sink, from: :my_package, import: -> { MySink }
|
|
33
|
+
|
|
34
|
+
AUDIT = Audit.new # singleton bean
|
|
35
|
+
AUDIT.sink = MySink.new # the one and only mutation, at boot
|
|
36
|
+
|
|
37
|
+
plugin :ree_routes, audit: AUDIT
|
|
38
|
+
end
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
A sink implements one method:
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
class MySink < ReeAudit::Sink
|
|
45
|
+
def write(event)
|
|
46
|
+
# persist it
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The package ships `NullSink` (default) and `LoggerSink` (dev). A sink that
|
|
52
|
+
raises never fails the request: `Audit#write_safely` catches it and reports
|
|
53
|
+
through `ree_logger`.
|
|
54
|
+
|
|
55
|
+
## What gets audited
|
|
56
|
+
|
|
57
|
+
`Route#audited?` returns `internal?` unless the route says otherwise:
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
get "api/v1/admin/users" do
|
|
61
|
+
visibility :internal # audited by default
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
get "api/v1/admin/health" do
|
|
65
|
+
visibility :internal
|
|
66
|
+
audit false # deliberately silent
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
get "api/v1/exports" do
|
|
70
|
+
audit true # public route reaching into someone else's data
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
get "api/v1/organizations/:id/balance" do
|
|
74
|
+
audit :on_annotation # the owner calls it too — see below
|
|
75
|
+
end
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Tying the default to `visibility` rather than to a hand-kept list is deliberate:
|
|
79
|
+
`visibility` is mandatory on admin paths (the routes DSL fails at boot without
|
|
80
|
+
it), so the audited set cannot drift away from the set of routes that actually
|
|
81
|
+
touch client data.
|
|
82
|
+
|
|
83
|
+
### `audit :on_annotation`
|
|
84
|
+
|
|
85
|
+
Some routes are called by two different people: the owner of the data, reading
|
|
86
|
+
its own, and a staff member reading someone else's. Which one it was is known
|
|
87
|
+
only after the permission check has run, inside the action — so the event is
|
|
88
|
+
always assembled, and it reaches the sink only if the code annotated it:
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
def call(organization_id, user_id)
|
|
92
|
+
admin = platform_admins.active.by_user(user_id).first
|
|
93
|
+
|
|
94
|
+
if admin
|
|
95
|
+
# this call was made by staff, and that is what makes it worth recording
|
|
96
|
+
annotate_audit(organization_id: organization_id, staff_id: admin.id)
|
|
97
|
+
end
|
|
98
|
+
...
|
|
99
|
+
end
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`audit true` on such a route would bury the handful of staff calls under every
|
|
103
|
+
ordinary request the owners make; leaving it unaudited would lose them.
|
|
104
|
+
Annotating before the check, not after, keeps a refusal in the trail as well:
|
|
105
|
+
`Event#recordable?` is decided in `ensure`, when the annotation is already there.
|
|
106
|
+
|
|
107
|
+
## The event
|
|
108
|
+
|
|
109
|
+
| field | meaning |
|
|
110
|
+
|---|---|
|
|
111
|
+
| `action_name`, `package_name` | which action ran |
|
|
112
|
+
| `summary`, `sections` | route metadata, useful for grouping and for classifying sensitivity |
|
|
113
|
+
| `request_method`, `path`, `request_path` | `path` is the DSL template (stable), `request_path` is the actual path (shows the object) |
|
|
114
|
+
| `params` | what the action received, after filtering |
|
|
115
|
+
| `accessor` | the authenticated object — the application decides how to read it |
|
|
116
|
+
| `status` | `:ok`, `:denied`, `:error` |
|
|
117
|
+
| `error_type`, `error_message` | present when the call failed; message truncated to 512 chars |
|
|
118
|
+
| `started_at`, `duration_ms` | timing (monotonic clock) |
|
|
119
|
+
| `annotations` | whatever the application attached during the call |
|
|
120
|
+
|
|
121
|
+
`:denied` means the action raised a `ReeErrors::Error` whose type is
|
|
122
|
+
`:permission`. A refusal to show client data is as much an audit fact as
|
|
123
|
+
showing it.
|
|
124
|
+
|
|
125
|
+
There is **no HTTP status** on the event, and that is not an oversight: the
|
|
126
|
+
wrapper sits around the action call, while Roda sets the status afterwards (and,
|
|
127
|
+
on an exception, in the application's `error` block). Observing it here is
|
|
128
|
+
impossible, and deriving it from the error type would put a guess into an audit
|
|
129
|
+
trail. `status` carries the fact instead.
|
|
130
|
+
|
|
131
|
+
## Annotations
|
|
132
|
+
|
|
133
|
+
Product code attaches what only it knows:
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
class MyPackage::AdminUploadQuery
|
|
137
|
+
action :admin_upload_query do
|
|
138
|
+
link :annotate_audit, from: :ree_audit
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def call(access, attrs)
|
|
142
|
+
upload = find_upload(attrs)
|
|
143
|
+
annotate_audit(organization_id: upload.organization_id,
|
|
144
|
+
subject: {type: :upload, id: upload.uuid})
|
|
145
|
+
...
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
`annotate_audit` returns `nil` when audit is off or the code was reached from
|
|
151
|
+
outside an audited route, so it is always safe to call.
|
|
152
|
+
|
|
153
|
+
Use the `annotate_audit` fn rather than `ReeAudit.annotate` directly. A bare
|
|
154
|
+
constant works, but Ree only verifies a declared package dependency for
|
|
155
|
+
`link`/`import` — so removing `depends_on :ree_audit` as "unused" would compile
|
|
156
|
+
fine and blow up with `NameError` in production. With `link`, the same removal
|
|
157
|
+
fails loudly at load time.
|
|
158
|
+
|
|
159
|
+
## Params filtering
|
|
160
|
+
|
|
161
|
+
Two limits, applied before the event reaches the sink:
|
|
162
|
+
|
|
163
|
+
* keys containing any `filter_words` are replaced with `'FILTERED'` **at any
|
|
164
|
+
depth** (the logger only inspects the top level);
|
|
165
|
+
* anything nested deeper than `AUDIT_MAX_PARAMS_DEPTH` becomes
|
|
166
|
+
`'[TRUNCATED_DEPTH]'`, and a payload whose JSON exceeds
|
|
167
|
+
`AUDIT_MAX_PARAMS_BYTES` is replaced with `{truncated: true, size: N}`.
|
|
168
|
+
|
|
169
|
+
This is a blacklist, and it is the framework's job: it protects any host
|
|
170
|
+
application from leaking a credential. An application that exports the trail to
|
|
171
|
+
its customers should apply its own **whitelist** on top — a blacklist silently
|
|
172
|
+
passes through a key someone adds six months from now.
|
|
173
|
+
|
|
174
|
+
## Nested calls
|
|
175
|
+
|
|
176
|
+
Events live on a per-fiber stack (`ReeAudit::Context`), so an audited action may
|
|
177
|
+
call another one without the inner call stealing the outer one's annotations.
|
|
178
|
+
The stack is unwound in `ensure`, including when the action raises — a leftover
|
|
179
|
+
event would attach one client's context to the next request served by the same
|
|
180
|
+
fiber.
|
|
181
|
+
|
|
182
|
+
## Configuration
|
|
183
|
+
|
|
184
|
+
| variable | default | meaning |
|
|
185
|
+
|---|---|---|
|
|
186
|
+
| `AUDIT_ENABLED` | `false` | master switch |
|
|
187
|
+
| `AUDIT_MAX_PARAMS_BYTES` | `4096` | size limit for the serialized params |
|
|
188
|
+
| `AUDIT_MAX_PARAMS_DEPTH` | `4` | nesting limit |
|
|
189
|
+
| `AUDIT_FILTER_WORDS` | see `Config::DEFAULT_FILTER_WORDS` | comma-separated |
|
|
190
|
+
|
|
191
|
+
## Boundaries
|
|
192
|
+
|
|
193
|
+
The package answers *who called what*. It does not see anything that bypasses
|
|
194
|
+
the application: direct database access, an object-storage console, or a file
|
|
195
|
+
fetched with a pre-signed URL that was handed out earlier. An application that
|
|
196
|
+
needs those answers has to record them where they happen.
|
|
197
|
+
|
|
198
|
+
## Specs
|
|
199
|
+
|
|
200
|
+
```
|
|
201
|
+
cd ree_lib/lib/ree_lib && bundle exec ree spec ree_audit
|
|
202
|
+
```
|
|
@@ -9,7 +9,7 @@ class ReeAudit::Event
|
|
|
9
9
|
attr_accessor :action_name, :package_name, :summary, :sections,
|
|
10
10
|
:request_method, :path, :request_path, :params, :accessor,
|
|
11
11
|
:status, :error_type, :error_message, :started_at,
|
|
12
|
-
:duration_ms, :annotations
|
|
12
|
+
:duration_ms, :annotations, :on_annotation
|
|
13
13
|
|
|
14
14
|
class << self
|
|
15
15
|
# Keyword-only on purpose: callers live in another package and must not
|
|
@@ -23,7 +23,8 @@ class ReeAudit::Event
|
|
|
23
23
|
path: nil,
|
|
24
24
|
request_path: nil,
|
|
25
25
|
params: {},
|
|
26
|
-
accessor: nil
|
|
26
|
+
accessor: nil,
|
|
27
|
+
on_annotation: false
|
|
27
28
|
)
|
|
28
29
|
event = new
|
|
29
30
|
|
|
@@ -37,6 +38,7 @@ class ReeAudit::Event
|
|
|
37
38
|
event.params = params || {}
|
|
38
39
|
event.accessor = accessor
|
|
39
40
|
event.annotations = {}
|
|
41
|
+
event.on_annotation = on_annotation
|
|
40
42
|
|
|
41
43
|
event
|
|
42
44
|
end
|
|
@@ -47,6 +49,7 @@ class ReeAudit::Event
|
|
|
47
49
|
@params = {}
|
|
48
50
|
@annotations = {}
|
|
49
51
|
@status = :ok
|
|
52
|
+
@on_annotation = false
|
|
50
53
|
end
|
|
51
54
|
|
|
52
55
|
def ok?
|
|
@@ -57,6 +60,14 @@ class ReeAudit::Event
|
|
|
57
60
|
status == :denied
|
|
58
61
|
end
|
|
59
62
|
|
|
63
|
+
# An `on_annotation` event is the same call seen from two sides: the client
|
|
64
|
+
# asking for its own data, and a staff member asking for the client's. Only
|
|
65
|
+
# the code that resolved the caller can tell them apart, and it says so by
|
|
66
|
+
# annotating — so an untouched event of that kind is not worth recording.
|
|
67
|
+
def recordable?
|
|
68
|
+
!on_annotation || !annotations.empty?
|
|
69
|
+
end
|
|
70
|
+
|
|
60
71
|
def to_h
|
|
61
72
|
{
|
|
62
73
|
action_name: action_name,
|
|
@@ -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
|
|
@@ -158,4 +158,41 @@ RSpec.describe :audit do
|
|
|
158
158
|
it "ignores an annotation made outside an audited call" do
|
|
159
159
|
expect(ReeAudit.annotate(permission: :access_uploads)).to be_nil
|
|
160
160
|
end
|
|
161
|
+
|
|
162
|
+
context "an :on_annotation event" do
|
|
163
|
+
let(:event) {
|
|
164
|
+
ReeAudit::Event.build(action_name: :balance_query, on_annotation: true)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
it "drops the call nobody annotated" do
|
|
168
|
+
result = audit.around(event) { :action_result }
|
|
169
|
+
|
|
170
|
+
expect(result).to eq(:action_result)
|
|
171
|
+
expect(test_sink.events).to be_empty
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
it "writes the call the action annotated" do
|
|
175
|
+
audit.around(event) do
|
|
176
|
+
ReeAudit.annotate(platform_admin_id: 7)
|
|
177
|
+
:action_result
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
expect(test_sink.events.size).to eq(1)
|
|
181
|
+
expect(test_sink.events.first.annotations).to eq({platform_admin_id: 7})
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
it "writes a refusal that was annotated before the raise" do
|
|
185
|
+
klass = permission_error(:no_access)
|
|
186
|
+
|
|
187
|
+
expect {
|
|
188
|
+
audit.around(event) do
|
|
189
|
+
ReeAudit.annotate(platform_admin_id: 7)
|
|
190
|
+
raise klass.new("not allowed")
|
|
191
|
+
end
|
|
192
|
+
}.to raise_error(klass)
|
|
193
|
+
|
|
194
|
+
expect(test_sink.events.size).to eq(1)
|
|
195
|
+
expect(test_sink.events.first.status).to eq(:denied)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
161
198
|
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
|
|
@@ -49,6 +49,34 @@ RSpec.describe "ree_routes audit" do
|
|
|
49
49
|
end
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
+
# Клиентская ручка глазами сотрудника: код опознаёт служебное обращение и
|
|
53
|
+
# помечает его аннотацией.
|
|
54
|
+
class ReeRodaAuditTest::StaffCmd
|
|
55
|
+
include ReeActions::DSL
|
|
56
|
+
|
|
57
|
+
action :staff_cmd
|
|
58
|
+
|
|
59
|
+
def call(access, attrs)
|
|
60
|
+
ReeAudit.annotate(staff_id: 42)
|
|
61
|
+
{result: "ok"}
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
class ReeRodaAuditTest::DeniedStaffCmd
|
|
66
|
+
include ReeActions::DSL
|
|
67
|
+
|
|
68
|
+
action :denied_staff_cmd do
|
|
69
|
+
link :permission_error, from: :ree_errors
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
PermissionErr = permission_error(:no_access, msg: "not allowed")
|
|
73
|
+
|
|
74
|
+
def call(access, attrs)
|
|
75
|
+
ReeAudit.annotate(staff_id: 42)
|
|
76
|
+
raise PermissionErr
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
52
80
|
class ReeRodaAuditTest::BoomCmd
|
|
53
81
|
include ReeActions::DSL
|
|
54
82
|
|
|
@@ -95,6 +123,27 @@ RSpec.describe "ree_routes audit" do
|
|
|
95
123
|
action :ok_cmd, **opts
|
|
96
124
|
end
|
|
97
125
|
|
|
126
|
+
get "api/on_annotation_client" do
|
|
127
|
+
summary "Client reading its own data"
|
|
128
|
+
sections "Public"
|
|
129
|
+
audit :on_annotation
|
|
130
|
+
action :ok_cmd, **opts
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
get "api/on_annotation_staff" do
|
|
134
|
+
summary "Staff reading someone else's data"
|
|
135
|
+
sections "Public"
|
|
136
|
+
audit :on_annotation
|
|
137
|
+
action :staff_cmd, **opts
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
get "api/on_annotation_staff_denied" do
|
|
141
|
+
summary "Staff refused someone else's data"
|
|
142
|
+
sections "Public"
|
|
143
|
+
audit :on_annotation
|
|
144
|
+
action :denied_staff_cmd, **opts
|
|
145
|
+
end
|
|
146
|
+
|
|
98
147
|
get "api/internal_denied" do
|
|
99
148
|
summary "Internal route refusing access"
|
|
100
149
|
sections "Admin"
|
|
@@ -247,6 +296,31 @@ RSpec.describe "ree_routes audit" do
|
|
|
247
296
|
expect(test_sink.events.first.status).to eq(:ok)
|
|
248
297
|
end
|
|
249
298
|
|
|
299
|
+
it "leaves an unannotated :on_annotation route out of the trail" do
|
|
300
|
+
get "api/on_annotation_client"
|
|
301
|
+
|
|
302
|
+
expect(last_response.status).to eq(200)
|
|
303
|
+
expect(test_sink.events).to be_empty
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
it "writes an :on_annotation route once the action annotated it" do
|
|
307
|
+
get "api/on_annotation_staff"
|
|
308
|
+
|
|
309
|
+
expect(last_response.status).to eq(200)
|
|
310
|
+
expect(test_sink.events.size).to eq(1)
|
|
311
|
+
expect(test_sink.events.first.annotations).to eq({staff_id: 42})
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# Отказ сотруднику — такой же факт журнала, как и показ данных: аннотация
|
|
315
|
+
# успевает лечь до того, как действие бросит исключение.
|
|
316
|
+
it "writes an annotated :on_annotation route that ended in a refusal" do
|
|
317
|
+
get "api/on_annotation_staff_denied"
|
|
318
|
+
|
|
319
|
+
expect(last_response.status).to eq(403)
|
|
320
|
+
expect(test_sink.events.size).to eq(1)
|
|
321
|
+
expect(test_sink.events.first.status).to eq(:denied)
|
|
322
|
+
end
|
|
323
|
+
|
|
250
324
|
it "records a refusal and still answers 403" do
|
|
251
325
|
get "api/internal_denied"
|
|
252
326
|
|
|
@@ -21,7 +21,16 @@ class ReeRoutes::Route
|
|
|
21
21
|
# drift away from the set of routes that actually touch client data — which a
|
|
22
22
|
# hand-maintained list of audited routes inevitably would.
|
|
23
23
|
def audited?
|
|
24
|
-
@audit.nil? ? internal? : @audit
|
|
24
|
+
@audit.nil? ? internal? : @audit != false
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# `audit :on_annotation` — a public route that the client calls for its own
|
|
28
|
+
# data and a staff member calls for someone else's. The event is always
|
|
29
|
+
# assembled, but it only reaches the sink if the code annotated the call;
|
|
30
|
+
# auditing such a route unconditionally would bury the staff access under
|
|
31
|
+
# every ordinary client request.
|
|
32
|
+
def audit_on_annotation?
|
|
33
|
+
@audit == :on_annotation
|
|
25
34
|
end
|
|
26
35
|
|
|
27
36
|
def valid?
|
|
@@ -6,6 +6,7 @@ class ReeRoutes::RouteBuilder
|
|
|
6
6
|
Redirect = Struct.new(:path, :code)
|
|
7
7
|
|
|
8
8
|
VISIBILITIES = [:public, :internal].freeze
|
|
9
|
+
AUDIT_MODES = [true, false, :on_annotation].freeze
|
|
9
10
|
|
|
10
11
|
def initialize
|
|
11
12
|
@route = ReeRoutes::Route.new
|
|
@@ -44,10 +45,18 @@ class ReeRoutes::RouteBuilder
|
|
|
44
45
|
@route.summary = str
|
|
45
46
|
end
|
|
46
47
|
|
|
47
|
-
# Needed in exactly
|
|
48
|
-
# the trail on for a public route that reaches into someone else's data
|
|
49
|
-
|
|
48
|
+
# Needed in exactly three cases: silencing a noisy internal route, turning
|
|
49
|
+
# the trail on for a public route that reaches into someone else's data, and
|
|
50
|
+
# `:on_annotation` for a route both the owner of the data and a staff member
|
|
51
|
+
# can call — see Route#audit_on_annotation?.
|
|
52
|
+
contract Or[Bool, Symbol] => Or[Bool, Symbol]
|
|
50
53
|
def audit(value)
|
|
54
|
+
if !AUDIT_MODES.include?(value)
|
|
55
|
+
raise ArgumentError.new(
|
|
56
|
+
"audit should be one of #{AUDIT_MODES.inspect}, got #{value.inspect}"
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
51
60
|
@route.audit = value
|
|
52
61
|
end
|
|
53
62
|
|
|
@@ -98,6 +98,12 @@ RSpec.describe ReeRoutes::DSL, type: [:autoclean] do
|
|
|
98
98
|
action :cmd, from: :ree_routes_test
|
|
99
99
|
audit true
|
|
100
100
|
end
|
|
101
|
+
|
|
102
|
+
get "public_on_annotation" do
|
|
103
|
+
summary "Public route the owner and a staff member both call"
|
|
104
|
+
action :cmd, from: :ree_routes_test
|
|
105
|
+
audit :on_annotation
|
|
106
|
+
end
|
|
101
107
|
end
|
|
102
108
|
end
|
|
103
109
|
end
|
|
@@ -134,5 +140,16 @@ RSpec.describe ReeRoutes::DSL, type: [:autoclean] do
|
|
|
134
140
|
expect(routes["internal_silenced"].audited?).to eq(false)
|
|
135
141
|
expect(routes["public_default"].audited?).to eq(false)
|
|
136
142
|
expect(routes["public_audited"].audited?).to eq(true)
|
|
143
|
+
expect(routes["public_on_annotation"].audited?).to eq(true)
|
|
144
|
+
|
|
145
|
+
expect(routes["internal_default"].audit_on_annotation?).to eq(false)
|
|
146
|
+
expect(routes["public_audited"].audit_on_annotation?).to eq(false)
|
|
147
|
+
expect(routes["public_on_annotation"].audit_on_annotation?).to eq(true)
|
|
137
148
|
}
|
|
149
|
+
|
|
150
|
+
it "refuses an audit mode it does not know" do
|
|
151
|
+
expect {
|
|
152
|
+
ReeRoutes::RouteBuilder.new.audit(:on_annotations)
|
|
153
|
+
}.to raise_error(ArgumentError, /audit should be one of/)
|
|
154
|
+
end
|
|
138
155
|
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.13
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ruslan Gatiyatov
|
|
@@ -277,17 +277,20 @@ files:
|
|
|
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
279
|
- lib/ree_lib/packages/ree_audit/.rspec
|
|
280
|
+
- lib/ree_lib/packages/ree_audit/README.md
|
|
280
281
|
- lib/ree_lib/packages/ree_audit/package/ree_audit.rb
|
|
281
282
|
- lib/ree_lib/packages/ree_audit/package/ree_audit/beans/audit.rb
|
|
282
283
|
- lib/ree_lib/packages/ree_audit/package/ree_audit/config.rb
|
|
283
284
|
- lib/ree_lib/packages/ree_audit/package/ree_audit/context.rb
|
|
284
285
|
- lib/ree_lib/packages/ree_audit/package/ree_audit/event.rb
|
|
286
|
+
- lib/ree_lib/packages/ree_audit/package/ree_audit/functions/annotate_audit.rb
|
|
285
287
|
- lib/ree_lib/packages/ree_audit/package/ree_audit/functions/filter_params.rb
|
|
286
288
|
- lib/ree_lib/packages/ree_audit/package/ree_audit/sinks/logger_sink.rb
|
|
287
289
|
- lib/ree_lib/packages/ree_audit/package/ree_audit/sinks/null_sink.rb
|
|
288
290
|
- lib/ree_lib/packages/ree_audit/package/ree_audit/sinks/sink.rb
|
|
289
291
|
- lib/ree_lib/packages/ree_audit/spec/ree_audit/beans/audit_spec.rb
|
|
290
292
|
- lib/ree_lib/packages/ree_audit/spec/ree_audit/context_spec.rb
|
|
293
|
+
- lib/ree_lib/packages/ree_audit/spec/ree_audit/functions/annotate_audit_spec.rb
|
|
291
294
|
- lib/ree_lib/packages/ree_audit/spec/ree_audit/functions/filter_params_spec.rb
|
|
292
295
|
- lib/ree_lib/packages/ree_audit/spec/spec_helper.rb
|
|
293
296
|
- lib/ree_lib/packages/ree_dao/.gitignore
|