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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 44ee00e821c324db9b3aba699c39e04eff9b471b1d1dfd5779deae13c6b2f293
4
- data.tar.gz: cc49b0ca5595307c1461067dc06467a1f056c998b8e795a35beb29510aeb44d7
3
+ metadata.gz: 8ab9fc2af772216e10bb2376e7eb5c7dab31393f41500bf9375564b94c848128
4
+ data.tar.gz: ad4ba3df9b4b5b86cba423aaf50d2f9c87f832f78dde26873ecb3a4c2939f5f5
5
5
  SHA512:
6
- metadata.gz: 3b50e99331b77e089addca33877bf5ba22c38b7f37f3303fb911593382c48200b74b36eaeb321ace3baa748e1a47ac9f5c7c622e98609656f6b067d54bb38e10
7
- data.tar.gz: 8559093f201881f42ab2482949b5128c9778f095e0e394fa74187df8b1d6ff48120d8a0093b4032f03feefd47a24d3fbc76552a5bd103f839dac1f301cf032a9
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.1.0
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. Use the Macros
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
- In Rails 5.2, `remote_ip` raises `IpSpoofAttackError` when `Client-IP` and `X-Forwarded-For` disagree.
183
- The gem then logs a warning and uses `request.ip`, so the event is still sent and the request is not affected.
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 | Truncated to 45. |
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
- Releases are published to rubygems.org from Bitbucket Pipelines with the manual `custom: publish` pipeline.
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
 
@@ -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 response.status >= 200 && response.status < 400
83
+ return unless optiaudit_trackable_response?
59
84
 
60
85
  optiaudit_track(event_type, module_name, metadata: metadata)
61
86
  end
@@ -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, an invalid actor_email is omitted and a non-integer
13
- # organization_id is omitted.
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 Time.now.utc.iso8601(3)
124
+ when nil then current_timestamp
111
125
  when Time, DateTime then value.to_time.utc.iso8601(3)
112
- else value.to_s
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
@@ -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. In Rails 5.2 it raises
21
- # when Client-IP and X-Forwarded-For disagree.
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}), using request.ip")
26
- request.ip
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.
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Optiaudit
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
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.1.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-22 00:00:00.000000000 Z
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