ree_lib 1.3.12 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e10f8d9ba3558f200f081f325d2a7f10b81696459c8fbdd9054fd4e0b72c72ed
4
- data.tar.gz: fc2f2bba979d98082ed296caeeb8823cf1cef3201cbda6244adb131e7ccc5db1
3
+ metadata.gz: 5a4843ec9ab12ce886333af41c258c9994ced5666fc6720a443fd42e7b8e1300
4
+ data.tar.gz: de155878c3031c3bcbfc24a3ca1b2fb1cf6822420cbd3959f3221da9172f1769
5
5
  SHA512:
6
- metadata.gz: 4cbd59039f6e3b238db9c88763e75f753a567c13edd080eda2e6fafbed838e3c61a71d9ee40af35b7042f58cee315f5aa3c7cdf8be8b2e7ae339f9ed7883906d
7
- data.tar.gz: e0dddd85f84fec535514ebbec51e78425511b32a8ee877b65fdf4ac31f513717f9c12ab80d4772d7649ad1856ad625bf1d967e9c56d78823dc1435196b438c85
6
+ metadata.gz: 399d35c7b455d605572dcf8c5ebd426de556401e9eb6fd75b0c47745abf691dc8956f743e94e3ab8674c991d6bea040d55901e0b9443d7ec55a7c7bbc609f65a
7
+ data.tar.gz: ecad1589e6231b1fee0baa64d7bfcc5fea36dcb23deb5b02b6bbe9f0bcc307934f50ec086b7ec795c55a8a22c899516c8680b1bb0943c50136dcb9020963914d
data/Gemfile.lock CHANGED
@@ -9,7 +9,7 @@ PATH
9
9
  PATH
10
10
  remote: .
11
11
  specs:
12
- ree_lib (1.3.12)
12
+ ree_lib (1.3.13)
13
13
  bigdecimal
14
14
  binding_of_caller
15
15
  i18n
@@ -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
+ ```
@@ -56,7 +56,7 @@ class ReeAudit::Audit
56
56
  (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_monotonic) * 1000
57
57
  ).round
58
58
  ReeAudit::Context.pop
59
- write_safely(event)
59
+ write_safely(event) if event.recordable?
60
60
  end
61
61
  end
62
62
 
@@ -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,
@@ -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
@@ -146,7 +146,8 @@ class Roda
146
146
  path: route.path,
147
147
  request_path: r.path,
148
148
  params: filtered_params,
149
- accessor: accessor
149
+ accessor: accessor,
150
+ on_annotation: route.audit_on_annotation?
150
151
  )
151
152
 
152
153
  audit.around(audit_event) do
@@ -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 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
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ReeLib
4
- VERSION = "1.3.12"
4
+ VERSION = "1.3.13"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ree_lib
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.12
4
+ version: 1.3.13
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ruslan Gatiyatov
@@ -277,6 +277,7 @@ 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