optiaudit-rails 0.1.0 → 0.2.0
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 +38 -1
- data/README.md +68 -9
- data/lib/optiaudit/controller.rb +26 -1
- data/lib/optiaudit/event.rb +33 -4
- data/lib/optiaudit/railtie.rb +17 -0
- data/lib/optiaudit/registry.rb +121 -0
- data/lib/optiaudit/request_data.rb +5 -4
- data/lib/optiaudit/testing.rb +8 -0
- data/lib/optiaudit/version.rb +1 -1
- data/lib/optiaudit.rb +15 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8ab9fc2af772216e10bb2376e7eb5c7dab31393f41500bf9375564b94c848128
|
|
4
|
+
data.tar.gz: ad4ba3df9b4b5b86cba423aaf50d2f9c87f832f78dde26873ecb3a4c2939f5f5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 454b2763272e3483aab0f1fb6f8c5dddf647e60dedda80929df8cdb30c0801810b52b146eed5bfea5794f337428e6429443d91c847470002cdfbca295a5e104f
|
|
7
|
+
data.tar.gz: e2f094abd10c8e528f7fcd316c1f9f208c6e53143c6316b57b223774e2cc507bddfa3e1a33211e05216947d290655fb90cf3506939ed53a39011f9b6bd588987
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.0] - 2026-10-23
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Registro central de acciones auditadas** en `config/optiaudit.rb`, que la gema carga sola al arrancar:
|
|
15
|
+
`Optiaudit.audit { page_view "o/pax/itineraries#index", module: :itineraries, formats: :html, if: ... }` y
|
|
16
|
+
`event :create, "controller#action", module: ...`. Auditar una pantalla nueva es una línea en ese archivo, sin tocar
|
|
17
|
+
controllers. Las macros siguen funcionando.
|
|
18
|
+
- **Validación al arrancar**: cada entrada se compara con las rutas de la app, cuando Rails termina de dibujarlas
|
|
19
|
+
(un bloque `routes.append`, que corre en `RouteSet#finalize!`): sin reordenar initializers ni cargar las rutas dos
|
|
20
|
+
veces, y con los initializers de la app ya aplicados. En development y test una entrada sin
|
|
21
|
+
ruta detiene el arranque (`Optiaudit::InvalidRegistry`); en production se registra un warning y la app arranca.
|
|
22
|
+
`Optiaudit::Testing.verify_registry!` para tenerlo como test.
|
|
23
|
+
- Opción `formats:` (por ejemplo, solo `:html`).
|
|
24
|
+
|
|
25
|
+
### Performance
|
|
26
|
+
|
|
27
|
+
Un único `after_action` global busca la acción en un hash de dos niveles congelado. Para acciones que no están en el
|
|
28
|
+
registro suma entre 0,6 y 3,7 µs por request (300 entradas, Ruby 2.7.5 + Rails 5.2.8, benchmark intercalado con y sin
|
|
29
|
+
el hook, GC desactivado). La búsqueda en sí son ~117 ns.
|
|
30
|
+
|
|
31
|
+
## [0.1.1] - 2026-10-22
|
|
32
|
+
|
|
33
|
+
Correcciones de la verificación del README contra OptiAudit real, con la 0.1.0 publicada.
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- **IP falsificada.** Cuando `Client-IP` y `X-Forwarded-For` no coinciden (`IpSpoofAttackError`), la 0.1.0 mandaba `request.ip`, que es el valor que eligió el cliente. Ahora el evento sale sin `ip_address`, con un warning.
|
|
38
|
+
- **`ip_address` inválido** pasado a mano: OptiAudit lo acepta con 202 y después su worker pierde el evento sin avisar. Ahora la gema lo omite.
|
|
39
|
+
- **`client_timestamp` que no es ISO 8601** pasado a mano: OptiAudit rechazaba el evento con 400. Ahora se reemplaza por la hora actual, con un warning.
|
|
40
|
+
|
|
41
|
+
### Changed
|
|
42
|
+
|
|
43
|
+
- README: con el adaptador `:thread`, OptiAudit pisa el `metadata.request_id` de cada evento con el id del POST del batch. El spoofing de IP ocurre en Rails 5.2 a 7.0, y en Rails 5.2 con log `info` es Rails, no la gema, el que responde 500.
|
|
44
|
+
|
|
10
45
|
## [0.1.0] - 2026-10-22
|
|
11
46
|
|
|
12
47
|
Primera versión: el equivalente para Rails de `@theoptimalpartner/nestjs-optiaudit` 0.1.8 (SC25-2703).
|
|
@@ -23,5 +58,7 @@ Primera versión: el equivalente para Rails de `@theoptimalpartner/nestjs-optiau
|
|
|
23
58
|
- **`Optiaudit::Testing`** para que las apps verifiquen sus eventos en tests sin HTTP.
|
|
24
59
|
- Compatibilidad probada en CI: Ruby 2.7 + Rails 5.2 + Sidekiq 6.3, Ruby 3.0 + Rails 6.1 + Sidekiq 6.5 y Ruby 3.2 + Rails 7.0 + Sidekiq 7.2.
|
|
25
60
|
|
|
26
|
-
[Unreleased]: https://bitbucket.org/RoutingUC/optiaudit-rails/branches/compare/main%0Dv0.
|
|
61
|
+
[Unreleased]: https://bitbucket.org/RoutingUC/optiaudit-rails/branches/compare/main%0Dv0.2.0
|
|
62
|
+
[0.2.0]: https://bitbucket.org/RoutingUC/optiaudit-rails/src/v0.2.0/
|
|
63
|
+
[0.1.1]: https://bitbucket.org/RoutingUC/optiaudit-rails/src/v0.1.1/
|
|
27
64
|
[0.1.0]: https://bitbucket.org/RoutingUC/optiaudit-rails/src/v0.1.0/
|
data/README.md
CHANGED
|
@@ -9,6 +9,7 @@ Integrating an app is adding the gem, two environment variables and one line per
|
|
|
9
9
|
## Features
|
|
10
10
|
|
|
11
11
|
- **Zero-config**: Reads `OPTIAUDIT_URL` and `OPTIAUDIT_WRITE_KEY` from the environment
|
|
12
|
+
- **One list of audited actions**: `config/optiaudit.rb` declares what the app audits, without touching controllers, checked against the routes at boot
|
|
12
13
|
- **Controller macros**: `optiaudit_page_view` and `optiaudit_event`, the equivalent of `@TrackPageView()` and `@TrackEvent()`
|
|
13
14
|
- **Auto context extraction**: actor from `current_user`, client IP, user agent, path, request id and a hashed session id
|
|
14
15
|
- **Never breaks the request**: delivery runs in Sidekiq or a background thread, and every error is logged as a warning
|
|
@@ -56,7 +57,44 @@ If the app runs Sidekiq, events go through the `optiaudit` queue. Add it to the
|
|
|
56
57
|
|
|
57
58
|
Apps without Sidekiq need nothing: the gem uses a background thread (see [Delivery](#delivery)).
|
|
58
59
|
|
|
59
|
-
### 3.
|
|
60
|
+
### 3. Declare what to audit
|
|
61
|
+
|
|
62
|
+
List the audited actions in `config/optiaudit.rb`. The gem loads it at boot: auditing a new screen is
|
|
63
|
+
one line in this file, and no controller changes.
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
# config/optiaudit.rb
|
|
67
|
+
Optiaudit.audit do
|
|
68
|
+
page_view "routes#index", module: :routes, formats: :html
|
|
69
|
+
page_view "routes#show", module: :routes, formats: :html
|
|
70
|
+
event :update, "routes#update", module: :routes
|
|
71
|
+
event :delete, "routes#destroy", module: :routes,
|
|
72
|
+
metadata: ->(controller) { { action: "delete_route", route_id: controller.params[:id] } }
|
|
73
|
+
end
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
- **Key:** `"<controller_path>#<action>"` of the controller that serves the request (`Admin::RoutesController`
|
|
77
|
+
is `"admin/routes"`). A parent controller does not cover its subclasses: an action served by two
|
|
78
|
+
controllers needs two lines.
|
|
79
|
+
- **Options:** `module:` (required), `formats:` (one or more, e.g. `:html`; default all), `if:` / `unless:`
|
|
80
|
+
(a controller method name or a lambda that receives the controller) and `metadata:` (a hash or a lambda
|
|
81
|
+
that receives the controller).
|
|
82
|
+
- **Checked at boot:** an entry without a route (a renamed controller, a typo) stops the boot in
|
|
83
|
+
development and test, so it never reaches production unnoticed. In production it is logged
|
|
84
|
+
(`<key> has no route: that action is not being audited`) and the app boots.
|
|
85
|
+
- The same rules as the macros apply: only 2xx and 3xx, only with an actor, never raises.
|
|
86
|
+
- **Cost:** one global `after_action` looks the action up in a frozen hash. For actions not in the list
|
|
87
|
+
it adds about 1–4 µs per request (measured with 300 entries on Ruby 2.7 / Rails 5.2).
|
|
88
|
+
- **Keep metadata in memory:** lambdas run inside the request; use params and instance variables the
|
|
89
|
+
action already loaded, never database queries.
|
|
90
|
+
|
|
91
|
+
Changes to this file go through code review and a deploy, so the audit trail of what is audited is the
|
|
92
|
+
repository history.
|
|
93
|
+
|
|
94
|
+
### Or use the Macros
|
|
95
|
+
|
|
96
|
+
The registry is the recommended way. The macros remain for apps that prefer to declare tracking in
|
|
97
|
+
the controller:
|
|
60
98
|
|
|
61
99
|
```ruby
|
|
62
100
|
class RoutesController < ApplicationController
|
|
@@ -166,7 +204,7 @@ Request data, also filled in by `optiaudit_track`:
|
|
|
166
204
|
| `ip_address` | `request.remote_ip` (see below) |
|
|
167
205
|
| `user_agent` | `request.user_agent` |
|
|
168
206
|
| `session_id` | SHA-256 of the session id. The raw id is a credential and never leaves the app. Only sent when the action already loaded the session. |
|
|
169
|
-
| `metadata.request_id` | `request.request_id` (`X-Request-ID`), also sent as the `X-Request-ID` header to OptiAudit |
|
|
207
|
+
| `metadata.request_id` | `request.request_id` (`X-Request-ID`), also sent as the `X-Request-ID` header to OptiAudit. With the `:thread` adapter OptiAudit replaces it with the id of the batch request (see [Delivery](#delivery)). |
|
|
170
208
|
| `metadata.module` | the module given to the macro |
|
|
171
209
|
|
|
172
210
|
## Client IP Resolution
|
|
@@ -179,15 +217,18 @@ rule as the NestJS package since `v0.1.8`: values a client injects on the left a
|
|
|
179
217
|
If your proxies use public addresses (a CDN in front of the load balancer), add them to
|
|
180
218
|
`config.action_dispatch.trusted_proxies`, or `remote_ip` will return the proxy.
|
|
181
219
|
|
|
182
|
-
|
|
183
|
-
The gem
|
|
220
|
+
When `Client-IP` and `X-Forwarded-For` disagree, `remote_ip` raises `IpSpoofAttackError` (Rails 5.2 to 7.0).
|
|
221
|
+
The gem logs a warning and sends the event without `ip_address`: the other sources of the IP, like
|
|
222
|
+
`request.ip`, return a value the client chose. The gem never raises, but Rails itself may: in Rails 5.2
|
|
223
|
+
the request logger calls `remote_ip` at log level `info`, so with those headers the request fails with
|
|
224
|
+
500 before reaching the controller, with or without this gem.
|
|
184
225
|
|
|
185
226
|
## Delivery
|
|
186
227
|
|
|
187
228
|
| Adapter | When | How |
|
|
188
229
|
|---------|------|-----|
|
|
189
230
|
| `:sidekiq` | Default when Sidekiq is loaded | One job per event in the `optiaudit` queue. Survives deploys. Retried with Sidekiq's backoff (5 times by default) on 5xx, 429, timeouts and connection errors. Other 4xx are dropped with a warning: retrying would not fix them. |
|
|
190
|
-
| `:thread` | Default without Sidekiq | In-memory buffer sent to `POST /events/batch` (up to 100 events) every 5 seconds or when full, from a background thread. Works with forking servers (Puma, Unicorn): each process starts its own thread. Pending events are flushed at exit. Events still in memory are lost if the process is killed, and failed batches are not retried. |
|
|
231
|
+
| `:thread` | Default without Sidekiq | In-memory buffer sent to `POST /events/batch` (up to 100 events) every 5 seconds or when full, from a background thread. Works with forking servers (Puma, Unicorn): each process starts its own thread. Pending events are flushed at exit. Events still in memory are lost if the process is killed, and failed batches are not retried. OptiAudit sets `metadata.request_id` of every event in a batch to the id of the batch request, so the request id of each event is lost. |
|
|
191
232
|
| `:inline` | Console, scripts, debugging | Synchronous `POST /events` in the caller's thread. Adds up to `timeout` seconds to a request. |
|
|
192
233
|
|
|
193
234
|
Sidekiq jobs receive the event as a hash of string keys and JSON values, so they work with Sidekiq 7's
|
|
@@ -255,6 +296,9 @@ it "audits the update" do
|
|
|
255
296
|
"metadata" => include("module" => "routes")
|
|
256
297
|
)
|
|
257
298
|
end
|
|
299
|
+
|
|
300
|
+
# Fails when an entry of config/optiaudit.rb has no route
|
|
301
|
+
it("audits only routed actions") { Optiaudit::Testing.verify_registry! }
|
|
258
302
|
```
|
|
259
303
|
|
|
260
304
|
## Track Params
|
|
@@ -271,11 +315,11 @@ Fields accepted by `Optiaudit.track` and `optiaudit_track`, with the limits of O
|
|
|
271
315
|
| `resource_type` | string | Truncated to 100. |
|
|
272
316
|
| `resource_id` | string | Truncated to 100. |
|
|
273
317
|
| `resource_path` | string | Truncated to 500. |
|
|
274
|
-
| `ip_address` | string |
|
|
318
|
+
| `ip_address` | string | IPv4 or IPv6. Omitted if it is not an IP (OptiAudit would accept it and then lose the event). |
|
|
275
319
|
| `user_agent` | string | |
|
|
276
320
|
| `session_id` | string | Truncated to 255. `optiaudit_track` sends a SHA-256. |
|
|
277
321
|
| `metadata` | hash | Free-form. Converted to JSON types. |
|
|
278
|
-
| `client_timestamp` | Time or ISO 8601 string | Default: when `track` is called, not when the event is sent. |
|
|
322
|
+
| `client_timestamp` | Time or ISO 8601 string | Default: when `track` is called, not when the event is sent. A string that is not ISO 8601 is replaced by the current time, with a warning. |
|
|
279
323
|
|
|
280
324
|
OptiAudit rejects the whole event when one field is invalid, and in a batch, the whole batch. That is why
|
|
281
325
|
the gem truncates or omits fields instead of sending them as they are.
|
|
@@ -289,11 +333,15 @@ the gem truncates or omits fields instead of sending them as they are.
|
|
|
289
333
|
| No actor | Event not sent |
|
|
290
334
|
| Action raised or response 4xx/5xx | Event not sent |
|
|
291
335
|
| Unknown `event_type` | Event not sent, logs warning |
|
|
292
|
-
| Invalid `actor_email` / `organization_id` | Field omitted, event sent |
|
|
336
|
+
| Invalid `actor_email` / `organization_id` / `ip_address` | Field omitted, event sent |
|
|
337
|
+
| Invalid `client_timestamp` | Replaced by the current time, logs warning, event sent |
|
|
338
|
+
| `Client-IP` and `X-Forwarded-For` disagree (IP spoofing) | Event sent without `ip_address`, logs warning |
|
|
293
339
|
| HTTP 5xx, 429, timeout (3s), connection error | Logs warning, doesn't raise. Sidekiq retries. |
|
|
294
340
|
| HTTP 4xx (other) | Logs warning, doesn't raise, no retry |
|
|
295
341
|
| HTTP 207 on a batch | Logs warning with the rejected events |
|
|
296
342
|
| Redis down when enqueueing | Logs warning, doesn't raise |
|
|
343
|
+
| `config/optiaudit.rb` entry without a route | Raises at boot in development and test; logs a warning in production |
|
|
344
|
+
| `if:` / `metadata:` lambda raises | Event not sent, logs warning, doesn't raise |
|
|
297
345
|
|
|
298
346
|
## Contributing
|
|
299
347
|
|
|
@@ -312,7 +360,18 @@ To try the gem end to end against a real OptiAudit (e.g. development), `examples
|
|
|
312
360
|
OPTIAUDIT_URL=https://... OPTIAUDIT_WRITE_KEY=oat-... bundle exec ruby examples/smoke.rb
|
|
313
361
|
```
|
|
314
362
|
|
|
315
|
-
|
|
363
|
+
## Releasing
|
|
364
|
+
|
|
365
|
+
The gem is owned by the `routinguc` account on rubygems.org and requires MFA (`rubygems_mfa_required`),
|
|
366
|
+
so every push asks for a one-time password and releases are published by hand, not from CI:
|
|
367
|
+
|
|
368
|
+
1. Update `lib/optiaudit/version.rb` and `CHANGELOG.md`, commit and wait for the CI matrix to pass.
|
|
369
|
+
2. Tag the release: `git tag v<version> && git push origin v<version>`.
|
|
370
|
+
3. Build and push with an API key that has the "Push rubygem" scope (rubygems.org → Settings → API keys):
|
|
371
|
+
```bash
|
|
372
|
+
gem build optiaudit-rails.gemspec
|
|
373
|
+
GEM_HOST_API_KEY=<api-key> gem push optiaudit-rails-<version>.gem # asks for the OTP
|
|
374
|
+
```
|
|
316
375
|
|
|
317
376
|
## License
|
|
318
377
|
|
data/lib/optiaudit/controller.rb
CHANGED
|
@@ -33,6 +33,31 @@ module Optiaudit
|
|
|
33
33
|
|
|
34
34
|
private
|
|
35
35
|
|
|
36
|
+
# Global after_action (added by the Railtie): tracks the action if it is in
|
|
37
|
+
# the registry. Unregistered actions cost one hash lookup.
|
|
38
|
+
def optiaudit_track_registered
|
|
39
|
+
entry = Optiaudit.registry.lookup(controller_path, action_name)
|
|
40
|
+
return unless entry && optiaudit_trackable_response?
|
|
41
|
+
return unless entry.formats.nil? || entry.formats.include?(request.format.to_sym)
|
|
42
|
+
return unless optiaudit_condition(entry.if, true) && !optiaudit_condition(entry.unless, false)
|
|
43
|
+
|
|
44
|
+
optiaudit_track(entry.event_type, entry.module_name, metadata: entry.metadata)
|
|
45
|
+
rescue StandardError => e
|
|
46
|
+
Optiaudit.log_warning("could not track #{entry&.key}: #{e.class}: #{e.message}")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def optiaudit_condition(condition, default)
|
|
50
|
+
case condition
|
|
51
|
+
when nil then default
|
|
52
|
+
when Symbol, String then send(condition)
|
|
53
|
+
else condition.call(self)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def optiaudit_trackable_response?
|
|
58
|
+
response.status >= 200 && response.status < 400
|
|
59
|
+
end
|
|
60
|
+
|
|
36
61
|
# Tracks an event with the actor and request data of this controller.
|
|
37
62
|
# `params` accepts every field of Optiaudit.track and overrides the defaults;
|
|
38
63
|
# `metadata` may also be a lambda that receives the controller.
|
|
@@ -55,7 +80,7 @@ module Optiaudit
|
|
|
55
80
|
end
|
|
56
81
|
|
|
57
82
|
def optiaudit_track_action(event_type, module_name, metadata)
|
|
58
|
-
return unless
|
|
83
|
+
return unless optiaudit_trackable_response?
|
|
59
84
|
|
|
60
85
|
optiaudit_track(event_type, module_name, metadata: metadata)
|
|
61
86
|
end
|
data/lib/optiaudit/event.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "ipaddr"
|
|
3
4
|
require "json"
|
|
4
5
|
require "time"
|
|
5
6
|
|
|
@@ -9,8 +10,9 @@ module Optiaudit
|
|
|
9
10
|
# OptiAudit validates every field and rejects the whole event (and, in a batch,
|
|
10
11
|
# the whole batch) when one is invalid. So instead of sending something that
|
|
11
12
|
# will come back as 400/422, fields are fixed here: strings are truncated to
|
|
12
|
-
# the API limits
|
|
13
|
-
# organization_id
|
|
13
|
+
# the API limits; an invalid actor_email or ip_address and a non-integer
|
|
14
|
+
# organization_id are omitted; an invalid client_timestamp becomes the
|
|
15
|
+
# current time.
|
|
14
16
|
module Event
|
|
15
17
|
# Limits from OptiAudit's CreateEventDto.
|
|
16
18
|
MAX_LENGTHS = {
|
|
@@ -59,6 +61,7 @@ module Optiaudit
|
|
|
59
61
|
end
|
|
60
62
|
event["actor_type"] ||= DEFAULT_ACTOR_TYPE
|
|
61
63
|
event.delete("actor_email") unless email?(event["actor_email"])
|
|
64
|
+
event.delete("ip_address") unless ip?(event["ip_address"])
|
|
62
65
|
|
|
63
66
|
organization_id = integer(params["organization_id"])
|
|
64
67
|
event["organization_id"] = organization_id if organization_id
|
|
@@ -99,6 +102,17 @@ module Optiaudit
|
|
|
99
102
|
labels.size >= 2 && labels.all? { |label| label.match?(DOMAIN_LABEL) } && labels.last.match?(TOP_LEVEL_DOMAIN)
|
|
100
103
|
end
|
|
101
104
|
|
|
105
|
+
# OptiAudit stores it in an INET column: anything else is accepted by the
|
|
106
|
+
# API and then lost by its worker.
|
|
107
|
+
def ip?(value)
|
|
108
|
+
return false if value.nil?
|
|
109
|
+
|
|
110
|
+
IPAddr.new(value)
|
|
111
|
+
true
|
|
112
|
+
rescue IPAddr::Error
|
|
113
|
+
false
|
|
114
|
+
end
|
|
115
|
+
|
|
102
116
|
def integer(value)
|
|
103
117
|
return value if value.is_a?(Integer)
|
|
104
118
|
|
|
@@ -107,10 +121,25 @@ module Optiaudit
|
|
|
107
121
|
|
|
108
122
|
def timestamp(value)
|
|
109
123
|
case value
|
|
110
|
-
when nil then
|
|
124
|
+
when nil then current_timestamp
|
|
111
125
|
when Time, DateTime then value.to_time.utc.iso8601(3)
|
|
112
|
-
else
|
|
126
|
+
else
|
|
127
|
+
return value.to_s if iso8601?(value.to_s)
|
|
128
|
+
|
|
129
|
+
Optiaudit.log_warning("client_timestamp #{value.to_s.inspect} is not ISO 8601, using the current time")
|
|
130
|
+
current_timestamp
|
|
113
131
|
end
|
|
114
132
|
end
|
|
133
|
+
|
|
134
|
+
def iso8601?(value)
|
|
135
|
+
Time.iso8601(value)
|
|
136
|
+
true
|
|
137
|
+
rescue ArgumentError
|
|
138
|
+
false
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def current_timestamp
|
|
142
|
+
Time.now.utc.iso8601(3)
|
|
143
|
+
end
|
|
115
144
|
end
|
|
116
145
|
end
|
data/lib/optiaudit/railtie.rb
CHANGED
|
@@ -8,6 +8,7 @@ module Optiaudit
|
|
|
8
8
|
ActiveSupport.on_load(:action_controller) do
|
|
9
9
|
require "optiaudit/controller"
|
|
10
10
|
include Optiaudit::Controller
|
|
11
|
+
after_action :optiaudit_track_registered
|
|
11
12
|
end
|
|
12
13
|
end
|
|
13
14
|
|
|
@@ -16,5 +17,21 @@ module Optiaudit
|
|
|
16
17
|
config.after_initialize do
|
|
17
18
|
require "optiaudit/sidekiq_job" if defined?(::Sidekiq)
|
|
18
19
|
end
|
|
20
|
+
|
|
21
|
+
# The app's list of audited actions (config/optiaudit.rb), loaded and
|
|
22
|
+
# verified against the routes when Rails finishes drawing them: an entry
|
|
23
|
+
# without a route stops the boot in development and test, and is logged in
|
|
24
|
+
# production. A routes.append block runs at that point (RouteSet#finalize!)
|
|
25
|
+
# in every supported Rails, without reordering initializers or loading the
|
|
26
|
+
# routes twice. By then the app initializers ran, so its config applies.
|
|
27
|
+
initializer "optiaudit.registry" do |app|
|
|
28
|
+
path = app.root.join("config", "optiaudit.rb")
|
|
29
|
+
next unless File.exist?(path)
|
|
30
|
+
|
|
31
|
+
app.routes.append do
|
|
32
|
+
Optiaudit::Registry.load_file(path)
|
|
33
|
+
Optiaudit.registry.verify!(app.routes, strict: !::Rails.env.production?)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
19
36
|
end
|
|
20
37
|
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Optiaudit
|
|
4
|
+
# Raised by Registry#verify! in strict mode when entries have no route.
|
|
5
|
+
class InvalidRegistry < StandardError; end
|
|
6
|
+
|
|
7
|
+
# The actions an app audits, declared in one place (config/optiaudit.rb)
|
|
8
|
+
# instead of with macros in each controller:
|
|
9
|
+
#
|
|
10
|
+
# Optiaudit.audit do
|
|
11
|
+
# page_view "o/pax/itineraries#index", module: :itineraries, formats: :html, if: :sky?
|
|
12
|
+
# event :create, "o/sky/demand_set_imports#confirm", module: :demand_sets,
|
|
13
|
+
# metadata: ->(controller) { { import_id: controller.params[:id] } }
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# Keys are "<controller_path>#<action>" of the controller that serves the
|
|
17
|
+
# request: a parent controller does not cover its subclasses.
|
|
18
|
+
#
|
|
19
|
+
# Every request looks itself up here, so the registry is a frozen two-level
|
|
20
|
+
# hash ({ "o/pax/itineraries" => { "index" => entry } }) and the lookup
|
|
21
|
+
# allocates nothing.
|
|
22
|
+
class Registry
|
|
23
|
+
Entry = Struct.new(:key, :event_type, :module_name, :formats, :if, :unless, :metadata, keyword_init: true)
|
|
24
|
+
|
|
25
|
+
# The DSL of Optiaudit.audit.
|
|
26
|
+
class Builder
|
|
27
|
+
def initialize(config)
|
|
28
|
+
@config = config
|
|
29
|
+
@entries = {}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def page_view(key, **options)
|
|
33
|
+
event(:page_view, key, **options)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Options: module (required), formats, if, unless, metadata. if/unless
|
|
37
|
+
# take a controller method name or a lambda that receives the controller.
|
|
38
|
+
def event(event_type, key, module: nil, formats: nil, if: nil, unless: nil, metadata: nil)
|
|
39
|
+
module_name = binding.local_variable_get(:module)
|
|
40
|
+
controller_path, action = parse_key(key)
|
|
41
|
+
validate!(key, event_type.to_s, module_name)
|
|
42
|
+
|
|
43
|
+
actions = (@entries[controller_path] ||= {})
|
|
44
|
+
raise ArgumentError, "#{key} declared twice in the Optiaudit registry" if actions.key?(action)
|
|
45
|
+
|
|
46
|
+
actions[action] = Entry.new(
|
|
47
|
+
key: key, event_type: event_type.to_s, module_name: module_name.to_s,
|
|
48
|
+
formats: formats && Array(formats).map(&:to_sym).freeze,
|
|
49
|
+
if: binding.local_variable_get(:if), unless: binding.local_variable_get(:unless), metadata: metadata
|
|
50
|
+
).freeze
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def build
|
|
54
|
+
Registry.new(@entries.transform_values(&:freeze).freeze)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def parse_key(key)
|
|
60
|
+
controller_path, action = key.to_s.split("#", 2)
|
|
61
|
+
if Util.blank?(controller_path) || Util.blank?(action)
|
|
62
|
+
raise ArgumentError, %(#{key.inspect} is not "controller_path#action")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
[controller_path.freeze, action.freeze]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def validate!(key, event_type, module_name)
|
|
69
|
+
raise ArgumentError, "#{key}: module is required" if Util.blank?(module_name)
|
|
70
|
+
return if @config.event_types.map(&:to_s).include?(event_type)
|
|
71
|
+
|
|
72
|
+
raise ArgumentError, "#{key}: unknown event_type #{event_type.inspect}"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Loads the app's list (config/optiaudit.rb). Returns false when there is none.
|
|
77
|
+
def self.load_file(path)
|
|
78
|
+
return false unless File.exist?(path)
|
|
79
|
+
|
|
80
|
+
load path.to_s
|
|
81
|
+
true
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def initialize(entries)
|
|
85
|
+
@entries = entries
|
|
86
|
+
freeze
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def lookup(controller_path, action_name)
|
|
90
|
+
actions = @entries[controller_path]
|
|
91
|
+
actions && actions[action_name]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def empty?
|
|
95
|
+
@entries.empty?
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def keys
|
|
99
|
+
@entries.flat_map { |_, actions| actions.values.map(&:key) }.sort
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Checks every entry against the app routes, so that renaming a controller
|
|
103
|
+
# does not silently stop auditing it. Strict mode raises; otherwise each
|
|
104
|
+
# missing action is logged. Returns the keys without a route.
|
|
105
|
+
def verify!(routes, strict:)
|
|
106
|
+
routed = routes.routes.each_with_object({}) do |route, set|
|
|
107
|
+
controller, action = route.defaults.values_at(:controller, :action)
|
|
108
|
+
set["#{controller}##{action}"] = true if controller && action
|
|
109
|
+
end
|
|
110
|
+
missing = keys.reject { |key| routed.key?(key) }
|
|
111
|
+
return missing if missing.empty?
|
|
112
|
+
|
|
113
|
+
raise InvalidRegistry, "Optiaudit registry entries without a route: #{missing.join(', ')}" if strict
|
|
114
|
+
|
|
115
|
+
missing.each { |key| Optiaudit.log_warning("#{key} has no route: that action is not being audited") }
|
|
116
|
+
missing
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
EMPTY = new({}.freeze)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -17,13 +17,14 @@ module Optiaudit
|
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
# remote_ip skips trusted proxies (private ranges by default), so behind a
|
|
20
|
-
# load balancer it returns the client's public IP.
|
|
21
|
-
#
|
|
20
|
+
# load balancer it returns the client's public IP. It raises when
|
|
21
|
+
# Client-IP and X-Forwarded-For disagree; then the IP is omitted: the
|
|
22
|
+
# alternatives (request.ip) return a value the client chose.
|
|
22
23
|
def client_ip(request)
|
|
23
24
|
request.remote_ip
|
|
24
25
|
rescue ActionDispatch::RemoteIp::IpSpoofAttackError => e
|
|
25
|
-
Optiaudit.log_warning("remote_ip rejected (#{e.message}),
|
|
26
|
-
|
|
26
|
+
Optiaudit.log_warning("remote_ip rejected (#{e.message}), event sent without ip_address")
|
|
27
|
+
nil
|
|
27
28
|
end
|
|
28
29
|
|
|
29
30
|
# Hashed: the raw session id is a credential and never leaves the app.
|
data/lib/optiaudit/testing.rb
CHANGED
|
@@ -49,5 +49,13 @@ module Optiaudit
|
|
|
49
49
|
def clear!
|
|
50
50
|
events.clear
|
|
51
51
|
end
|
|
52
|
+
|
|
53
|
+
# For the app's test suite: fails when an entry of config/optiaudit.rb has
|
|
54
|
+
# no route.
|
|
55
|
+
#
|
|
56
|
+
# it("audits only routed actions") { Optiaudit::Testing.verify_registry! }
|
|
57
|
+
def verify_registry!
|
|
58
|
+
Optiaudit.registry.verify!(::Rails.application.routes, strict: true)
|
|
59
|
+
end
|
|
52
60
|
end
|
|
53
61
|
end
|
data/lib/optiaudit/version.rb
CHANGED
data/lib/optiaudit.rb
CHANGED
|
@@ -9,6 +9,7 @@ require "optiaudit/event"
|
|
|
9
9
|
require "optiaudit/client"
|
|
10
10
|
require "optiaudit/actor"
|
|
11
11
|
require "optiaudit/request_data"
|
|
12
|
+
require "optiaudit/registry"
|
|
12
13
|
require "optiaudit/testing"
|
|
13
14
|
|
|
14
15
|
# Sends audit events to OptiAudit. See README.md.
|
|
@@ -35,6 +36,19 @@ module Optiaudit
|
|
|
35
36
|
Testing.fake? || configuration.enabled?
|
|
36
37
|
end
|
|
37
38
|
|
|
39
|
+
# Declares the actions the app audits (see Optiaudit::Registry). Called from
|
|
40
|
+
# config/optiaudit.rb, which the Railtie loads at boot. Replaces any previous
|
|
41
|
+
# declaration.
|
|
42
|
+
def audit(&block)
|
|
43
|
+
builder = Registry::Builder.new(configuration)
|
|
44
|
+
builder.instance_exec(&block)
|
|
45
|
+
@registry = builder.build
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def registry
|
|
49
|
+
@registry || Registry::EMPTY
|
|
50
|
+
end
|
|
51
|
+
|
|
38
52
|
# Tracks an event. Returns true when it was handed to the adapter, false when
|
|
39
53
|
# it was skipped (disabled, invalid) or failed. Never raises.
|
|
40
54
|
#
|
|
@@ -80,6 +94,7 @@ module Optiaudit
|
|
|
80
94
|
def reset!
|
|
81
95
|
discard_adapter
|
|
82
96
|
@configuration = nil
|
|
97
|
+
@registry = nil
|
|
83
98
|
end
|
|
84
99
|
|
|
85
100
|
private
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: optiaudit-rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- The Optimal Partner
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-23 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: railties
|
|
@@ -50,6 +50,7 @@ files:
|
|
|
50
50
|
- lib/optiaudit/event.rb
|
|
51
51
|
- lib/optiaudit/rails.rb
|
|
52
52
|
- lib/optiaudit/railtie.rb
|
|
53
|
+
- lib/optiaudit/registry.rb
|
|
53
54
|
- lib/optiaudit/request_data.rb
|
|
54
55
|
- lib/optiaudit/sidekiq_job.rb
|
|
55
56
|
- lib/optiaudit/testing.rb
|