ree_lib 1.3.12 → 1.3.14
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/Gemfile.lock +1 -1
- data/lib/ree_lib/packages/ree_audit/README.md +204 -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/spec/ree_audit/beans/audit_spec.rb +37 -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 +91 -0
- data/lib/ree_lib/packages/ree_routes/package/ree_routes/dsl.rb +8 -0
- data/lib/ree_lib/packages/ree_routes/package/ree_routes/route.rb +20 -5
- 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 +28 -1
- data/lib/ree_lib/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2ebed252b6557db13e60268884fd6b1a0f405f256d28aaf18953764886ab6cb0
|
|
4
|
+
data.tar.gz: e6328fda6013213c588f834b5a0782c271c76686b8303a0f458cfac5e926c80d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 06ad16d1ca9361397b0e324d8ab0d9a3a064337831384f45616dd500efb88367c84be252537d203e07ec08cb109e8f976980ff39ac2de315f96938a21edc697e
|
|
7
|
+
data.tar.gz: fa3b6b910f9855585af6f0e985720db3773d7483779f65d3907badc16711e5bfdd159abcfcd8135466a5a2c57a75645da44c79ba532e0bcd9bf777d50aa7bdd5
|
data/Gemfile.lock
CHANGED
|
@@ -0,0 +1,204 @@
|
|
|
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
|
+
Nothing is audited until the route says so:
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
get "api/v1/admin/users" do
|
|
61
|
+
visibility :internal
|
|
62
|
+
audit true # recorded
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
get "api/v1/admin/health" do
|
|
66
|
+
visibility :internal
|
|
67
|
+
audit false # deliberately silent
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
get "api/v1/organizations/:id/balance" do
|
|
71
|
+
audit :on_annotation # the owner calls it too — see below
|
|
72
|
+
end
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The flag is declared at the route and nowhere else. Deriving it from
|
|
76
|
+
`visibility` would work just as well and read far worse: whether a call lands in
|
|
77
|
+
the access trail is exactly the kind of fact a reader must see where the route
|
|
78
|
+
is written, not infer from a flag that means something else.
|
|
79
|
+
|
|
80
|
+
Forgetting it is not possible where it matters: like `visibility`, `audit` is
|
|
81
|
+
mandatory on admin paths — a route whose path carries an `admin` segment fails
|
|
82
|
+
at boot until it declares one. So the audited set cannot drift away from the set
|
|
83
|
+
of routes that reach client data.
|
|
84
|
+
|
|
85
|
+
### `audit :on_annotation`
|
|
86
|
+
|
|
87
|
+
Some routes are called by two different people: the owner of the data, reading
|
|
88
|
+
its own, and a staff member reading someone else's. Which one it was is known
|
|
89
|
+
only after the permission check has run, inside the action — so the event is
|
|
90
|
+
always assembled, and it reaches the sink only if the code annotated it:
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
def call(organization_id, user_id)
|
|
94
|
+
admin = platform_admins.active.by_user(user_id).first
|
|
95
|
+
|
|
96
|
+
if admin
|
|
97
|
+
# this call was made by staff, and that is what makes it worth recording
|
|
98
|
+
annotate_audit(organization_id: organization_id, staff_id: admin.id)
|
|
99
|
+
end
|
|
100
|
+
...
|
|
101
|
+
end
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`audit true` on such a route would bury the handful of staff calls under every
|
|
105
|
+
ordinary request the owners make; leaving it unaudited would lose them.
|
|
106
|
+
Annotating before the check, not after, keeps a refusal in the trail as well:
|
|
107
|
+
`Event#recordable?` is decided in `ensure`, when the annotation is already there.
|
|
108
|
+
|
|
109
|
+
## The event
|
|
110
|
+
|
|
111
|
+
| field | meaning |
|
|
112
|
+
|---|---|
|
|
113
|
+
| `action_name`, `package_name` | which action ran |
|
|
114
|
+
| `summary`, `sections` | route metadata, useful for grouping and for classifying sensitivity |
|
|
115
|
+
| `request_method`, `path`, `request_path` | `path` is the DSL template (stable), `request_path` is the actual path (shows the object) |
|
|
116
|
+
| `params` | what the action received, after filtering |
|
|
117
|
+
| `accessor` | the authenticated object — the application decides how to read it |
|
|
118
|
+
| `status` | `:ok`, `:denied`, `:error` |
|
|
119
|
+
| `error_type`, `error_message` | present when the call failed; message truncated to 512 chars |
|
|
120
|
+
| `started_at`, `duration_ms` | timing (monotonic clock) |
|
|
121
|
+
| `annotations` | whatever the application attached during the call |
|
|
122
|
+
|
|
123
|
+
`:denied` means the action raised a `ReeErrors::Error` whose type is
|
|
124
|
+
`:permission`. A refusal to show client data is as much an audit fact as
|
|
125
|
+
showing it.
|
|
126
|
+
|
|
127
|
+
There is **no HTTP status** on the event, and that is not an oversight: the
|
|
128
|
+
wrapper sits around the action call, while Roda sets the status afterwards (and,
|
|
129
|
+
on an exception, in the application's `error` block). Observing it here is
|
|
130
|
+
impossible, and deriving it from the error type would put a guess into an audit
|
|
131
|
+
trail. `status` carries the fact instead.
|
|
132
|
+
|
|
133
|
+
## Annotations
|
|
134
|
+
|
|
135
|
+
Product code attaches what only it knows:
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
class MyPackage::AdminUploadQuery
|
|
139
|
+
action :admin_upload_query do
|
|
140
|
+
link :annotate_audit, from: :ree_audit
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def call(access, attrs)
|
|
144
|
+
upload = find_upload(attrs)
|
|
145
|
+
annotate_audit(organization_id: upload.organization_id,
|
|
146
|
+
subject: {type: :upload, id: upload.uuid})
|
|
147
|
+
...
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
`annotate_audit` returns `nil` when audit is off or the code was reached from
|
|
153
|
+
outside an audited route, so it is always safe to call.
|
|
154
|
+
|
|
155
|
+
Use the `annotate_audit` fn rather than `ReeAudit.annotate` directly. A bare
|
|
156
|
+
constant works, but Ree only verifies a declared package dependency for
|
|
157
|
+
`link`/`import` — so removing `depends_on :ree_audit` as "unused" would compile
|
|
158
|
+
fine and blow up with `NameError` in production. With `link`, the same removal
|
|
159
|
+
fails loudly at load time.
|
|
160
|
+
|
|
161
|
+
## Params filtering
|
|
162
|
+
|
|
163
|
+
Two limits, applied before the event reaches the sink:
|
|
164
|
+
|
|
165
|
+
* keys containing any `filter_words` are replaced with `'FILTERED'` **at any
|
|
166
|
+
depth** (the logger only inspects the top level);
|
|
167
|
+
* anything nested deeper than `AUDIT_MAX_PARAMS_DEPTH` becomes
|
|
168
|
+
`'[TRUNCATED_DEPTH]'`, and a payload whose JSON exceeds
|
|
169
|
+
`AUDIT_MAX_PARAMS_BYTES` is replaced with `{truncated: true, size: N}`.
|
|
170
|
+
|
|
171
|
+
This is a blacklist, and it is the framework's job: it protects any host
|
|
172
|
+
application from leaking a credential. An application that exports the trail to
|
|
173
|
+
its customers should apply its own **whitelist** on top — a blacklist silently
|
|
174
|
+
passes through a key someone adds six months from now.
|
|
175
|
+
|
|
176
|
+
## Nested calls
|
|
177
|
+
|
|
178
|
+
Events live on a per-fiber stack (`ReeAudit::Context`), so an audited action may
|
|
179
|
+
call another one without the inner call stealing the outer one's annotations.
|
|
180
|
+
The stack is unwound in `ensure`, including when the action raises — a leftover
|
|
181
|
+
event would attach one client's context to the next request served by the same
|
|
182
|
+
fiber.
|
|
183
|
+
|
|
184
|
+
## Configuration
|
|
185
|
+
|
|
186
|
+
| variable | default | meaning |
|
|
187
|
+
|---|---|---|
|
|
188
|
+
| `AUDIT_ENABLED` | `false` | master switch |
|
|
189
|
+
| `AUDIT_MAX_PARAMS_BYTES` | `4096` | size limit for the serialized params |
|
|
190
|
+
| `AUDIT_MAX_PARAMS_DEPTH` | `4` | nesting limit |
|
|
191
|
+
| `AUDIT_FILTER_WORDS` | see `Config::DEFAULT_FILTER_WORDS` | comma-separated |
|
|
192
|
+
|
|
193
|
+
## Boundaries
|
|
194
|
+
|
|
195
|
+
The package answers *who called what*. It does not see anything that bypasses
|
|
196
|
+
the application: direct database access, an object-storage console, or a file
|
|
197
|
+
fetched with a pre-signed URL that was handed out earlier. An application that
|
|
198
|
+
needs those answers has to record them where they happen.
|
|
199
|
+
|
|
200
|
+
## Specs
|
|
201
|
+
|
|
202
|
+
```
|
|
203
|
+
cd ree_lib/lib/ree_lib && bundle exec ree spec ree_audit
|
|
204
|
+
```
|
|
@@ -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
|
|
@@ -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
|
|
|
@@ -71,6 +99,14 @@ RSpec.describe "ree_routes audit" do
|
|
|
71
99
|
summary "Internal route"
|
|
72
100
|
sections "Admin"
|
|
73
101
|
visibility :internal
|
|
102
|
+
audit true
|
|
103
|
+
action :ok_cmd, **opts
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
get "api/internal_undeclared" do
|
|
107
|
+
summary "Internal route that never mentions audit"
|
|
108
|
+
sections "Admin"
|
|
109
|
+
visibility :internal
|
|
74
110
|
action :ok_cmd, **opts
|
|
75
111
|
end
|
|
76
112
|
|
|
@@ -95,10 +131,32 @@ RSpec.describe "ree_routes audit" do
|
|
|
95
131
|
action :ok_cmd, **opts
|
|
96
132
|
end
|
|
97
133
|
|
|
134
|
+
get "api/on_annotation_client" do
|
|
135
|
+
summary "Client reading its own data"
|
|
136
|
+
sections "Public"
|
|
137
|
+
audit :on_annotation
|
|
138
|
+
action :ok_cmd, **opts
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
get "api/on_annotation_staff" do
|
|
142
|
+
summary "Staff reading someone else's data"
|
|
143
|
+
sections "Public"
|
|
144
|
+
audit :on_annotation
|
|
145
|
+
action :staff_cmd, **opts
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
get "api/on_annotation_staff_denied" do
|
|
149
|
+
summary "Staff refused someone else's data"
|
|
150
|
+
sections "Public"
|
|
151
|
+
audit :on_annotation
|
|
152
|
+
action :denied_staff_cmd, **opts
|
|
153
|
+
end
|
|
154
|
+
|
|
98
155
|
get "api/internal_denied" do
|
|
99
156
|
summary "Internal route refusing access"
|
|
100
157
|
sections "Admin"
|
|
101
158
|
visibility :internal
|
|
159
|
+
audit true
|
|
102
160
|
action :denied_cmd, **opts
|
|
103
161
|
end
|
|
104
162
|
|
|
@@ -106,6 +164,7 @@ RSpec.describe "ree_routes audit" do
|
|
|
106
164
|
summary "Internal route blowing up"
|
|
107
165
|
sections "Admin"
|
|
108
166
|
visibility :internal
|
|
167
|
+
audit true
|
|
109
168
|
action :boom_cmd, **opts
|
|
110
169
|
end
|
|
111
170
|
end
|
|
@@ -225,6 +284,13 @@ RSpec.describe "ree_routes audit" do
|
|
|
225
284
|
expect(event.accessor).to eq({user: "visitor"})
|
|
226
285
|
end
|
|
227
286
|
|
|
287
|
+
it "leaves an internal route that never declared audit out of the trail" do
|
|
288
|
+
get "api/internal_undeclared"
|
|
289
|
+
|
|
290
|
+
expect(last_response.status).to eq(200)
|
|
291
|
+
expect(test_sink.events).to be_empty
|
|
292
|
+
end
|
|
293
|
+
|
|
228
294
|
it "leaves a silenced internal route out of the trail" do
|
|
229
295
|
get "api/internal_silenced"
|
|
230
296
|
|
|
@@ -247,6 +313,31 @@ RSpec.describe "ree_routes audit" do
|
|
|
247
313
|
expect(test_sink.events.first.status).to eq(:ok)
|
|
248
314
|
end
|
|
249
315
|
|
|
316
|
+
it "leaves an unannotated :on_annotation route out of the trail" do
|
|
317
|
+
get "api/on_annotation_client"
|
|
318
|
+
|
|
319
|
+
expect(last_response.status).to eq(200)
|
|
320
|
+
expect(test_sink.events).to be_empty
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
it "writes an :on_annotation route once the action annotated it" do
|
|
324
|
+
get "api/on_annotation_staff"
|
|
325
|
+
|
|
326
|
+
expect(last_response.status).to eq(200)
|
|
327
|
+
expect(test_sink.events.size).to eq(1)
|
|
328
|
+
expect(test_sink.events.first.annotations).to eq({staff_id: 42})
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Отказ сотруднику — такой же факт журнала, как и показ данных: аннотация
|
|
332
|
+
# успевает лечь до того, как действие бросит исключение.
|
|
333
|
+
it "writes an annotated :on_annotation route that ended in a refusal" do
|
|
334
|
+
get "api/on_annotation_staff_denied"
|
|
335
|
+
|
|
336
|
+
expect(last_response.status).to eq(403)
|
|
337
|
+
expect(test_sink.events.size).to eq(1)
|
|
338
|
+
expect(test_sink.events.first.status).to eq(:denied)
|
|
339
|
+
end
|
|
340
|
+
|
|
250
341
|
it "records a refusal and still answers 403" do
|
|
251
342
|
get "api/internal_denied"
|
|
252
343
|
|
|
@@ -97,6 +97,14 @@ module ReeRoutes
|
|
|
97
97
|
)
|
|
98
98
|
end
|
|
99
99
|
|
|
100
|
+
if !builder.get_route.audit_declared? && admin_path?(path)
|
|
101
|
+
raise ArgumentError.new(
|
|
102
|
+
"admin route #{path} must declare audit explicitly: " \
|
|
103
|
+
"`audit true` to record the call in the access trail, " \
|
|
104
|
+
"or `audit false` if the route touches nothing worth recording"
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
|
|
100
108
|
route = builder.get_route
|
|
101
109
|
|
|
102
110
|
@dsl.link(route.action.name, from: route.action.package_name)
|
|
@@ -16,12 +16,27 @@ class ReeRoutes::Route
|
|
|
16
16
|
!public?
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
19
|
+
# Auditing is declared at the route and nowhere else. Deriving it from
|
|
20
|
+
# `visibility` would work just as well and read far worse: whether a call
|
|
21
|
+
# lands in the access trail is exactly the kind of fact that must be visible
|
|
22
|
+
# where the route is written, not inferred from a flag that means something
|
|
23
|
+
# else. `audit` is mandatory on admin paths (see DSL#define_route), so the
|
|
24
|
+
# audited set still cannot drift away from the routes that touch client data.
|
|
23
25
|
def audited?
|
|
24
|
-
@audit
|
|
26
|
+
@audit == true || @audit == :on_annotation
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def audit_declared?
|
|
30
|
+
!@audit.nil?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# `audit :on_annotation` — a public route that the client calls for its own
|
|
34
|
+
# data and a staff member calls for someone else's. The event is always
|
|
35
|
+
# assembled, but it only reaches the sink if the code annotated the call;
|
|
36
|
+
# auditing such a route unconditionally would bury the staff access under
|
|
37
|
+
# every ordinary client request.
|
|
38
|
+
def audit_on_annotation?
|
|
39
|
+
@audit == :on_annotation
|
|
25
40
|
end
|
|
26
41
|
|
|
27
42
|
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
|
|
|
@@ -76,7 +76,14 @@ RSpec.describe ReeRoutes::DSL, type: [:autoclean] do
|
|
|
76
76
|
default_warden_scope :user
|
|
77
77
|
|
|
78
78
|
get "internal_default" do
|
|
79
|
-
summary "Internal route
|
|
79
|
+
summary "Internal route that says it is audited"
|
|
80
|
+
action :cmd, from: :ree_routes_test
|
|
81
|
+
visibility :internal
|
|
82
|
+
audit true
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
get "internal_undeclared" do
|
|
86
|
+
summary "Internal route that never mentions audit"
|
|
80
87
|
action :cmd, from: :ree_routes_test
|
|
81
88
|
visibility :internal
|
|
82
89
|
end
|
|
@@ -98,6 +105,12 @@ RSpec.describe ReeRoutes::DSL, type: [:autoclean] do
|
|
|
98
105
|
action :cmd, from: :ree_routes_test
|
|
99
106
|
audit true
|
|
100
107
|
end
|
|
108
|
+
|
|
109
|
+
get "public_on_annotation" do
|
|
110
|
+
summary "Public route the owner and a staff member both call"
|
|
111
|
+
action :cmd, from: :ree_routes_test
|
|
112
|
+
audit :on_annotation
|
|
113
|
+
end
|
|
101
114
|
end
|
|
102
115
|
end
|
|
103
116
|
end
|
|
@@ -131,8 +144,22 @@ RSpec.describe ReeRoutes::DSL, type: [:autoclean] do
|
|
|
131
144
|
}
|
|
132
145
|
|
|
133
146
|
expect(routes["internal_default"].audited?).to eq(true)
|
|
147
|
+
expect(routes["internal_undeclared"].audited?).to eq(false)
|
|
148
|
+
expect(routes["internal_undeclared"].audit_declared?).to eq(false)
|
|
134
149
|
expect(routes["internal_silenced"].audited?).to eq(false)
|
|
150
|
+
expect(routes["internal_silenced"].audit_declared?).to eq(true)
|
|
135
151
|
expect(routes["public_default"].audited?).to eq(false)
|
|
136
152
|
expect(routes["public_audited"].audited?).to eq(true)
|
|
153
|
+
expect(routes["public_on_annotation"].audited?).to eq(true)
|
|
154
|
+
|
|
155
|
+
expect(routes["internal_default"].audit_on_annotation?).to eq(false)
|
|
156
|
+
expect(routes["public_audited"].audit_on_annotation?).to eq(false)
|
|
157
|
+
expect(routes["public_on_annotation"].audit_on_annotation?).to eq(true)
|
|
137
158
|
}
|
|
159
|
+
|
|
160
|
+
it "refuses an audit mode it does not know" do
|
|
161
|
+
expect {
|
|
162
|
+
ReeRoutes::RouteBuilder.new.audit(:on_annotations)
|
|
163
|
+
}.to raise_error(ArgumentError, /audit should be one of/)
|
|
164
|
+
end
|
|
138
165
|
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.14
|
|
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
|