omni_events 0.1.1
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 +7 -0
- data/.rspec +3 -0
- data/CHANGELOG.md +25 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/Gemfile +13 -0
- data/Gemfile.lock +249 -0
- data/LICENSE.txt +21 -0
- data/README.md +461 -0
- data/Rakefile +8 -0
- data/app/controllers/omni_event/receiver_controller.rb +38 -0
- data/app/jobs/omni_event/new_relic_job.rb +45 -0
- data/app/jobs/omni_event/process_webhook_job.rb +10 -0
- data/app/models/omni_event/application_record.rb +5 -0
- data/app/models/omni_event/log.rb +20 -0
- data/app/models/omni_event/notifier.rb +34 -0
- data/app/models/omni_event/webhook_event.rb +35 -0
- data/config/routes.rb +3 -0
- data/db/migrate/20240101000001_create_omni_event_notifiers.rb +13 -0
- data/db/migrate/20240101000002_create_omni_event_webhook_events.rb +14 -0
- data/db/migrate/20240101000003_create_omni_event_logs.rb +14 -0
- data/db/migrate/20240101000004_add_security_to_omni_event_notifiers.rb +8 -0
- data/lib/generators/omni_event/install_generator.rb +78 -0
- data/lib/omni_event/base_processor.rb +50 -0
- data/lib/omni_event/configuration.rb +23 -0
- data/lib/omni_event/engine.rb +5 -0
- data/lib/omni_event/process_dispatcher.rb +42 -0
- data/lib/omni_event/signature_verifier.rb +87 -0
- data/lib/omni_event/version.rb +5 -0
- data/lib/omni_event.rb +17 -0
- data/lib/tasks/omni_event_tasks.rake +12 -0
- data/sig/omni_event.rbs +4 -0
- metadata +148 -0
data/README.md
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
# 🚀 OmniEvent
|
|
2
|
+
|
|
3
|
+
> *"One Gem to rule them all, One Gem to find them, One Gem to bring all logs, and in the shadows, trace them."*
|
|
4
|
+
|
|
5
|
+
[](#)
|
|
6
|
+
[](#)
|
|
7
|
+
[](https://opensource.org/licenses/MIT)
|
|
8
|
+
[](#)
|
|
9
|
+
|
|
10
|
+
**OmniEvent** is a production-ready Rails Engine that unifies your system's entire event lifecycle — from secure external webhook ingestion to detailed internal process auditing — through a single, traceable pipeline.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Table of Contents
|
|
15
|
+
|
|
16
|
+
- [Key Features](#-key-features)
|
|
17
|
+
- [Installation](#️-installation)
|
|
18
|
+
- [Configuration](#-configuration)
|
|
19
|
+
- [Receiving Webhooks](#-receiving-webhooks)
|
|
20
|
+
- [Processing Pipeline](#-processing-pipeline)
|
|
21
|
+
- [Polymorphic Logging](#-polymorphic-logging)
|
|
22
|
+
- [Security](#-security)
|
|
23
|
+
- [Database Maintenance](#️-database-maintenance)
|
|
24
|
+
- [Testing](#-testing)
|
|
25
|
+
- [Docker Development](#-docker-development)
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## 🌟 Key Features
|
|
30
|
+
|
|
31
|
+
- **Secure Webhook Receiver** — token auth, IP whitelisting, HMAC signature verification, and replay attack protection out of the box.
|
|
32
|
+
- **Step Pipeline** — organize complex business logic into traceable steps with automatic error capturing and context logging.
|
|
33
|
+
- **Polymorphic Logging** — attach structured logs to any model (`Order`, `User`, `Payment`, etc.) with a unified API.
|
|
34
|
+
- **Processor Registry** — map each webhook source (Notifier) to its own processor class via configuration.
|
|
35
|
+
- **Async Monitoring** — non-blocking New Relic Insights integration via ActiveJob.
|
|
36
|
+
- **Smart Cleanup** — built-in Rake task for data retention based on configurable `retention_days`.
|
|
37
|
+
- **Zero-Boilerplate DX** — Devise-like installation, Rails callback-inspired syntax.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## 🛠️ Installation
|
|
42
|
+
|
|
43
|
+
**1. Add to your Gemfile:**
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
gem 'omni_event'
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
**2. Run the installer:**
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
bundle install
|
|
53
|
+
rails generate omni_event:install
|
|
54
|
+
rails db:migrate
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The generator creates:
|
|
58
|
+
- `config/initializers/omni_event.rb` — your configuration file
|
|
59
|
+
- `app/models/log.rb` — local proxy for `OmniEvent::Log`
|
|
60
|
+
- `app/models/webhook_event.rb` — local proxy for `OmniEvent::WebhookEvent`
|
|
61
|
+
|
|
62
|
+
**3. Mount the engine in `config/routes.rb`:**
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
mount OmniEvent::Engine => "/omni_events"
|
|
66
|
+
# Exposes: POST /omni_events/receiver/:token
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## ⚙️ Configuration
|
|
72
|
+
|
|
73
|
+
All options live in `config/initializers/omni_event.rb`:
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
OmniEvent.configure do |config|
|
|
77
|
+
# ── Monitoring ─────────────────────────────────────────────────────────────
|
|
78
|
+
config.new_relic_enabled = true
|
|
79
|
+
config.new_relic_api_key = ENV['NEW_RELIC_KEY']
|
|
80
|
+
config.new_relic_account_id = ENV['NEW_RELIC_ACCOUNT_ID']
|
|
81
|
+
|
|
82
|
+
# ── Processing ─────────────────────────────────────────────────────────────
|
|
83
|
+
config.process_async = true # false = synchronous (useful for testing)
|
|
84
|
+
config.retention_days = 30 # used by rake omni_event:cleanup
|
|
85
|
+
|
|
86
|
+
# ── Custom log types ────────────────────────────────────────────────────────
|
|
87
|
+
# Define domain-specific action types for your business context.
|
|
88
|
+
config.custom_log_types = {
|
|
89
|
+
system_info: 0,
|
|
90
|
+
system_error: 1,
|
|
91
|
+
payment_received: 10,
|
|
92
|
+
user_update: 20,
|
|
93
|
+
fiscal_validation: 30
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
# ── Processor registry ──────────────────────────────────────────────────────
|
|
97
|
+
# Maps each Notifier name to the processor class that handles its events.
|
|
98
|
+
config.processors = {
|
|
99
|
+
"PaymentGateway" => Webhooks::PaymentGatewayProcessor,
|
|
100
|
+
"BillingService" => Webhooks::BillingServiceProcessor,
|
|
101
|
+
"CrmSystem" => Webhooks::CrmSystemProcessor
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## 📡 Receiving Webhooks
|
|
109
|
+
|
|
110
|
+
### 1. Create a Notifier
|
|
111
|
+
|
|
112
|
+
A **Notifier** represents one external webhook source (e.g. a payment gateway, a payment processor). Each has its own security configuration.
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
# Minimal — token auth only
|
|
116
|
+
notifier = OmniEvent::Notifier.create!(name: "Stripe")
|
|
117
|
+
# => token is auto-generated: SecureRandom.hex(24)
|
|
118
|
+
|
|
119
|
+
# Full security configuration
|
|
120
|
+
notifier = OmniEvent::Notifier.create!(
|
|
121
|
+
name: "Payment Gateway",
|
|
122
|
+
secret_key: ENV['WEBHOOK_SECRET'], # enables HMAC verification
|
|
123
|
+
timestamp_tolerance: 300, # 5-minute replay window (seconds)
|
|
124
|
+
check_ip: true,
|
|
125
|
+
allowed_ips: ["185.60.216.35", "185.60.218.35"]
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# The webhook endpoint for this notifier:
|
|
129
|
+
# POST /omni_events/receiver/#{notifier.token}
|
|
130
|
+
puts notifier.token # => "a3f9c2b1e4d7..."
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### 2. Register the processor
|
|
134
|
+
|
|
135
|
+
In `config/initializers/omni_event.rb`, map the notifier name to a processor class:
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
config.processors = {
|
|
139
|
+
"Payment Gateway" => Webhooks::PaymentGatewayProcessor
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### 3. Send a webhook
|
|
144
|
+
|
|
145
|
+
The partner sends a `POST` request to your endpoint:
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
curl -X POST https://yourapp.com/omni_events/receiver/a3f9c2b1e4d7... \
|
|
149
|
+
-H "Content-Type: application/json" \
|
|
150
|
+
-H "X-OmniEvent-Timestamp: $(date +%s)" \
|
|
151
|
+
-H "X-OmniEvent-Signature: sha256=$(echo -n '{"event":"payment.confirmed"}' | openssl dgst -sha256 -hmac 'your_secret')" \
|
|
152
|
+
-d '{"event":"payment.confirmed","charge_id":"ch_abc123","status":"paid"}'
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
The receiver will:
|
|
156
|
+
1. Validate payload size (max 1MB)
|
|
157
|
+
2. Authenticate via token
|
|
158
|
+
3. Check IP whitelist (if enabled)
|
|
159
|
+
4. Verify HMAC signature + timestamp (if `secret_key` is set)
|
|
160
|
+
5. Persist the `WebhookEvent`
|
|
161
|
+
6. Dispatch to the registered processor (async or sync)
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## 🔄 Processing Pipeline
|
|
166
|
+
|
|
167
|
+
Define your business logic as a sequence of named steps. OmniEvent automatically logs any step failure with full context (step name, error class, backtrace).
|
|
168
|
+
|
|
169
|
+
```ruby
|
|
170
|
+
# app/services/webhooks/payment_gateway_processor.rb
|
|
171
|
+
class Webhooks::PaymentGatewayProcessor < OmniEvent::BaseProcessor
|
|
172
|
+
steps :validate_payload,
|
|
173
|
+
:update_payment_status,
|
|
174
|
+
:notify_customer,
|
|
175
|
+
:record_audit_log
|
|
176
|
+
|
|
177
|
+
def validate_payload
|
|
178
|
+
raise "Missing charge ID" if event.payload[:charge_id].blank?
|
|
179
|
+
raise "Unknown status '#{event.payload[:status]}'" unless valid_status?
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def update_payment_status
|
|
183
|
+
payment.update!(status: event.payload[:status])
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def notify_customer
|
|
187
|
+
CustomerMailer.payment_update(payment).deliver_later
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def record_audit_log
|
|
191
|
+
Log.create!(
|
|
192
|
+
loggable: payment,
|
|
193
|
+
action_type: :payment_processed,
|
|
194
|
+
content: "Payment status updated to '#{event.payload[:status]}'",
|
|
195
|
+
metadata: { gateway: "Stripe", source: "webhook", timestamp: Time.current.iso8601 }
|
|
196
|
+
)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
private
|
|
200
|
+
|
|
201
|
+
def payment
|
|
202
|
+
@payment ||= Payment.find_by!(charge_id: event.payload[:charge_id])
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def valid_status?
|
|
206
|
+
%w[paid pending failed refunded disputed].include?(event.payload[:status])
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
When a step raises an error, OmniEvent automatically creates a `system_error` log with the context and re-raises so the job can retry:
|
|
212
|
+
|
|
213
|
+
```ruby
|
|
214
|
+
# Auto-created by OmniEvent on step failure:
|
|
215
|
+
OmniEvent::Log.create!(
|
|
216
|
+
loggable: event,
|
|
217
|
+
action_type: :system_error,
|
|
218
|
+
content: "FAILURE in step [Validate payload]: Missing charge ID",
|
|
219
|
+
metadata: {
|
|
220
|
+
error_class: "RuntimeError",
|
|
221
|
+
method: :validate_payload,
|
|
222
|
+
backtrace: [...]
|
|
223
|
+
}
|
|
224
|
+
)
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
---
|
|
228
|
+
|
|
229
|
+
## 📋 Polymorphic Logging
|
|
230
|
+
|
|
231
|
+
Use `Log` (the local proxy generated by the installer) to attach structured log entries to any model.
|
|
232
|
+
|
|
233
|
+
```ruby
|
|
234
|
+
# Attach to any ActiveRecord model
|
|
235
|
+
Log.create!(
|
|
236
|
+
loggable: @order,
|
|
237
|
+
action_type: :payment_received,
|
|
238
|
+
content: "Payment of R$ 1.250,00 confirmed via PIX",
|
|
239
|
+
metadata: { gateway: "Stripe", charge_id: "ch_abc123", amount_cents: 125_000 }
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
# Query logs for a specific record
|
|
243
|
+
@order.logs.where(action_type: :system_error).order(created_at: :desc)
|
|
244
|
+
|
|
245
|
+
# Custom scopes on your local Log model (app/models/log.rb)
|
|
246
|
+
class Log < OmniEvent::Log
|
|
247
|
+
scope :recent_errors, -> { where(action_type: :system_error).where('created_at > ?', 24.hours.ago) }
|
|
248
|
+
scope :for_gateway, ->(gw) { where("metadata->>'gateway' = ?", gw) }
|
|
249
|
+
end
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
### Custom log types
|
|
253
|
+
|
|
254
|
+
Define your domain vocabulary in the initializer:
|
|
255
|
+
|
|
256
|
+
```ruby
|
|
257
|
+
config.custom_log_types = {
|
|
258
|
+
system_info: 0,
|
|
259
|
+
system_error: 1,
|
|
260
|
+
payment_received: 10,
|
|
261
|
+
payment_failed: 11,
|
|
262
|
+
payment_processed: 20,
|
|
263
|
+
fiscal_validation: 30
|
|
264
|
+
}
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
---
|
|
268
|
+
|
|
269
|
+
## 🔒 Security
|
|
270
|
+
|
|
271
|
+
OmniEvent provides **4 independent security layers**, all configurable per Notifier. Each layer is opt-in and backward compatible.
|
|
272
|
+
|
|
273
|
+
### Layer 1 — Token Authentication
|
|
274
|
+
|
|
275
|
+
Every webhook endpoint is identified by a unique, cryptographically random token (48-char hex). Requests without a valid token receive `401 Unauthorized`.
|
|
276
|
+
|
|
277
|
+
```ruby
|
|
278
|
+
notifier = OmniEvent::Notifier.create!(name: "Partner")
|
|
279
|
+
# Endpoint: POST /omni_events/receiver/#{notifier.token}
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
### Layer 2 — IP Whitelisting
|
|
283
|
+
|
|
284
|
+
Restrict which IPs can send requests to each notifier.
|
|
285
|
+
|
|
286
|
+
```ruby
|
|
287
|
+
OmniEvent::Notifier.create!(
|
|
288
|
+
name: "Stripe",
|
|
289
|
+
check_ip: true,
|
|
290
|
+
allowed_ips: ["54.187.174.169", "54.187.205.235"]
|
|
291
|
+
)
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
Requests from non-whitelisted IPs receive `403 Forbidden`.
|
|
295
|
+
|
|
296
|
+
### Layer 3 — HMAC Signature Verification
|
|
297
|
+
|
|
298
|
+
The gold standard for webhook security. The sender signs the raw request body with a shared secret using HMAC-SHA256. OmniEvent verifies the signature using constant-time comparison (preventing timing attacks).
|
|
299
|
+
|
|
300
|
+
```ruby
|
|
301
|
+
OmniEvent::Notifier.create!(
|
|
302
|
+
name: "Stripe",
|
|
303
|
+
secret_key: ENV['STRIPE_WEBHOOK_SECRET'] # e.g. "whsec_abc123..."
|
|
304
|
+
)
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
**Required header from the sender:**
|
|
308
|
+
```
|
|
309
|
+
X-OmniEvent-Signature: sha256=<HMAC-SHA256(secret_key, raw_body)>
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
**Example — generating the signature (sender side):**
|
|
313
|
+
|
|
314
|
+
```ruby
|
|
315
|
+
# Ruby
|
|
316
|
+
signature = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret_key, raw_body)}"
|
|
317
|
+
|
|
318
|
+
# Node.js
|
|
319
|
+
const sig = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex')
|
|
320
|
+
|
|
321
|
+
# Python
|
|
322
|
+
import hmac, hashlib
|
|
323
|
+
sig = 'sha256=' + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
### Layer 4 — Replay Attack Protection
|
|
327
|
+
|
|
328
|
+
When `secret_key` is set, OmniEvent also validates a timestamp header to reject requests that are too old — preventing replay attacks where a valid captured request is re-sent.
|
|
329
|
+
|
|
330
|
+
```ruby
|
|
331
|
+
OmniEvent::Notifier.create!(
|
|
332
|
+
name: "Stripe",
|
|
333
|
+
secret_key: ENV['STRIPE_WEBHOOK_SECRET'],
|
|
334
|
+
timestamp_tolerance: 300 # reject requests older than 5 minutes (default)
|
|
335
|
+
)
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
**Required header from the sender:**
|
|
339
|
+
```
|
|
340
|
+
X-OmniEvent-Timestamp: <Unix timestamp, e.g. 1711800000>
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
Set `timestamp_tolerance: 0` to disable timestamp checking while keeping signature verification.
|
|
344
|
+
|
|
345
|
+
### Layer 5 — Payload Size Limit
|
|
346
|
+
|
|
347
|
+
All requests are automatically capped at **1MB**. Oversized payloads receive `413 Payload Too Large` before any processing occurs.
|
|
348
|
+
|
|
349
|
+
### Complete security setup example
|
|
350
|
+
|
|
351
|
+
```ruby
|
|
352
|
+
# Notifier with all layers active
|
|
353
|
+
notifier = OmniEvent::Notifier.create!(
|
|
354
|
+
name: "Stripe Payments",
|
|
355
|
+
secret_key: ENV['STRIPE_WEBHOOK_SECRET'],
|
|
356
|
+
timestamp_tolerance: 300,
|
|
357
|
+
check_ip: true,
|
|
358
|
+
allowed_ips: ["185.60.216.35"]
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
# Check which security features are active
|
|
362
|
+
notifier.signature_verification? # => true
|
|
363
|
+
notifier.check_ip? # => true
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
### Security response codes
|
|
367
|
+
|
|
368
|
+
| Condition | HTTP Status |
|
|
369
|
+
|---|---|
|
|
370
|
+
| Payload > 1MB | `413 Payload Too Large` |
|
|
371
|
+
| Invalid or missing token | `401 Unauthorized` |
|
|
372
|
+
| IP not whitelisted | `403 Forbidden` |
|
|
373
|
+
| Invalid/missing signature | `401 Unauthorized` |
|
|
374
|
+
| Timestamp outside window | `401 Unauthorized` |
|
|
375
|
+
|
|
376
|
+
---
|
|
377
|
+
|
|
378
|
+
## 🗄️ Database Maintenance
|
|
379
|
+
|
|
380
|
+
Prevent database bloating by periodically deleting old records:
|
|
381
|
+
|
|
382
|
+
```bash
|
|
383
|
+
rake omni_event:cleanup
|
|
384
|
+
# => [OmniEvent] Cleanup complete: 1543 logs and 892 webhook events deleted (older than 30 days).
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
Configure the retention period in your initializer:
|
|
388
|
+
|
|
389
|
+
```ruby
|
|
390
|
+
config.retention_days = 90 # keep records for 90 days
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
Schedule it in production (e.g. with `whenever` or Heroku Scheduler):
|
|
394
|
+
|
|
395
|
+
```ruby
|
|
396
|
+
# config/schedule.rb (whenever gem)
|
|
397
|
+
every 1.day, at: '2:00 am' do
|
|
398
|
+
rake "omni_event:cleanup"
|
|
399
|
+
end
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
---
|
|
403
|
+
|
|
404
|
+
## 🧪 Testing
|
|
405
|
+
|
|
406
|
+
### Unit tests (no database required)
|
|
407
|
+
|
|
408
|
+
```bash
|
|
409
|
+
bundle exec rspec
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
### Integration tests (requires the dummy Rails app)
|
|
413
|
+
|
|
414
|
+
```bash
|
|
415
|
+
INTEGRATION=1 bundle exec rspec
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
### Testing your processors
|
|
419
|
+
|
|
420
|
+
```ruby
|
|
421
|
+
RSpec.describe Webhooks::PaymentGatewayProcessor do
|
|
422
|
+
let(:notifier) { create(:omni_event_notifier) }
|
|
423
|
+
let(:event) { create(:omni_event_webhook_event, webhook_notifier: notifier, payload: { charge_id: "ch_abc123", status: "paid" }) }
|
|
424
|
+
|
|
425
|
+
it "updates the payment status" do
|
|
426
|
+
payment = create(:payment, charge_id: "ch_abc123")
|
|
427
|
+
described_class.new(event).process!
|
|
428
|
+
expect(payment.reload.status).to eq("paid")
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
it "creates an audit log" do
|
|
432
|
+
create(:payment, charge_id: "ch_abc123")
|
|
433
|
+
expect { described_class.new(event).process! }.to change(Log, :count).by(1)
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
it "creates a system_error log when a step fails" do
|
|
437
|
+
allow_any_instance_of(described_class).to receive(:validate_payload).and_raise("boom")
|
|
438
|
+
expect { described_class.new(event).process! }.to raise_error("boom")
|
|
439
|
+
expect(OmniEvent::Log.last.action_type).to eq("system_error")
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
---
|
|
445
|
+
|
|
446
|
+
## 🐳 Docker Development
|
|
447
|
+
|
|
448
|
+
```bash
|
|
449
|
+
docker compose up -d
|
|
450
|
+
docker compose exec app bash
|
|
451
|
+
bundle exec rspec
|
|
452
|
+
rake omni_event:cleanup
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
---
|
|
456
|
+
|
|
457
|
+
## 📄 License
|
|
458
|
+
|
|
459
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
460
|
+
|
|
461
|
+
Developed with ❤️ by [Antonio Neto](https://github.com/antonioneto1)
|
data/Rakefile
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module OmniEvent
|
|
2
|
+
class ReceiverController < ActionController::API
|
|
3
|
+
MAX_PAYLOAD_SIZE = 1.megabyte
|
|
4
|
+
|
|
5
|
+
# POST /omni_events/receiver/:token
|
|
6
|
+
def create
|
|
7
|
+
# ── 1. Payload size guard ──────────────────────────────────────────────
|
|
8
|
+
if request.content_length.to_i > MAX_PAYLOAD_SIZE
|
|
9
|
+
return render json: { error: 'Payload too large' }, status: :payload_too_large
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# ── 2. Token authentication ────────────────────────────────────────────
|
|
13
|
+
notifier = OmniEvent::Notifier.find_by(token: params[:token])
|
|
14
|
+
return render json: { error: 'Unauthorized' }, status: :unauthorized unless notifier
|
|
15
|
+
|
|
16
|
+
# ── 3. IP whitelist ────────────────────────────────────────────────────
|
|
17
|
+
if notifier.check_ip? && !notifier.allows_ip?(request.remote_ip)
|
|
18
|
+
return render json: { error: 'Forbidden — IP not whitelisted' }, status: :forbidden
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# ── 4. HMAC signature + replay attack protection ───────────────────────
|
|
22
|
+
verification = OmniEvent::SignatureVerifier.call(notifier, request)
|
|
23
|
+
unless verification.success?
|
|
24
|
+
Rails.logger.warn "[OmniEvent] Security check failed for notifier '#{notifier.name}': #{verification.error}"
|
|
25
|
+
return render json: { error: verification.error }, status: :unauthorized
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# ── 5. Store and dispatch ──────────────────────────────────────────────
|
|
29
|
+
event = OmniEvent::WebhookEvent.create_from_request!(notifier, request)
|
|
30
|
+
event.dispatch!
|
|
31
|
+
|
|
32
|
+
render json: { received: true, event_id: event.id }, status: :ok
|
|
33
|
+
rescue => e
|
|
34
|
+
Rails.logger.error "[OmniEvent] ReceiverController error: #{e.message}"
|
|
35
|
+
render json: { error: 'Internal error' }, status: :unprocessable_entity
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
module OmniEvent
|
|
2
|
+
class NewRelicJob < ActiveJob::Base
|
|
3
|
+
queue_as :default
|
|
4
|
+
|
|
5
|
+
def perform(log_attributes)
|
|
6
|
+
config = OmniEvent.configuration
|
|
7
|
+
|
|
8
|
+
return unless config.new_relic_enabled
|
|
9
|
+
return unless config.new_relic_api_key.present?
|
|
10
|
+
return unless config.new_relic_account_id.present?
|
|
11
|
+
|
|
12
|
+
payload = build_payload(log_attributes)
|
|
13
|
+
|
|
14
|
+
response = HTTParty.post(
|
|
15
|
+
"https://insights-collector.newrelic.com/v1/accounts/#{config.new_relic_account_id}/events",
|
|
16
|
+
headers: {
|
|
17
|
+
"Content-Type" => "application/json",
|
|
18
|
+
"X-Insert-Key" => config.new_relic_api_key
|
|
19
|
+
},
|
|
20
|
+
body: payload.to_json
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
unless response.success?
|
|
24
|
+
Rails.logger.warn "[OmniEvent] NewRelicJob: unexpected response #{response.code}"
|
|
25
|
+
end
|
|
26
|
+
rescue => e
|
|
27
|
+
Rails.logger.error "[OmniEvent] NewRelicJob failed: #{e.message}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def build_payload(attrs)
|
|
33
|
+
metadata = attrs["metadata"].is_a?(String) ? JSON.parse(attrs["metadata"]) : attrs["metadata"].to_h
|
|
34
|
+
|
|
35
|
+
{
|
|
36
|
+
eventType: "OmniEventLog",
|
|
37
|
+
actionType: attrs["action_type"],
|
|
38
|
+
content: attrs["content"],
|
|
39
|
+
loggableType: attrs["loggable_type"],
|
|
40
|
+
loggableId: attrs["loggable_id"],
|
|
41
|
+
timestamp: attrs["created_at"].to_i
|
|
42
|
+
}.merge(metadata)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module OmniEvent
|
|
2
|
+
class Log < ApplicationRecord
|
|
3
|
+
self.table_name = "omni_event_logs"
|
|
4
|
+
|
|
5
|
+
belongs_to :loggable, polymorphic: true, optional: true
|
|
6
|
+
has_one_attached :payload_debug
|
|
7
|
+
|
|
8
|
+
serialize :metadata, coder: JSON
|
|
9
|
+
|
|
10
|
+
after_create_commit :dispatch_external_monitoring
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def dispatch_external_monitoring
|
|
15
|
+
OmniEvent::NewRelicJob.perform_later(attributes)
|
|
16
|
+
rescue => e
|
|
17
|
+
Rails.logger.error "[OmniEvent] Failed to enqueue NewRelicJob: #{e.message}"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
module OmniEvent
|
|
2
|
+
class Notifier < ApplicationRecord
|
|
3
|
+
self.table_name = "omni_event_notifiers"
|
|
4
|
+
|
|
5
|
+
has_many :webhook_events,
|
|
6
|
+
class_name: "OmniEvent::WebhookEvent",
|
|
7
|
+
foreign_key: :webhook_notifier_id,
|
|
8
|
+
dependent: :destroy
|
|
9
|
+
|
|
10
|
+
validates :name, presence: true
|
|
11
|
+
validates :token, presence: true, uniqueness: true
|
|
12
|
+
|
|
13
|
+
before_validation :generate_token, on: :create
|
|
14
|
+
|
|
15
|
+
# Returns true if the given IP is allowed to send requests.
|
|
16
|
+
# Skipped when check_ip is false.
|
|
17
|
+
def allows_ip?(ip)
|
|
18
|
+
return true unless check_ip?
|
|
19
|
+
|
|
20
|
+
Array(allowed_ips).include?(ip.to_s)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Returns true if HMAC signature verification is active for this notifier.
|
|
24
|
+
def signature_verification?
|
|
25
|
+
secret_key.present?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def generate_token
|
|
31
|
+
self.token ||= SecureRandom.hex(24)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module OmniEvent
|
|
2
|
+
class WebhookEvent < ApplicationRecord
|
|
3
|
+
self.table_name = "omni_event_webhook_events"
|
|
4
|
+
|
|
5
|
+
belongs_to :webhook_notifier, class_name: "OmniEvent::Notifier"
|
|
6
|
+
|
|
7
|
+
serialize :headers, coder: JSON
|
|
8
|
+
serialize :payload, coder: JSON
|
|
9
|
+
|
|
10
|
+
enum status: {
|
|
11
|
+
pending: 'pending',
|
|
12
|
+
processing: 'processing',
|
|
13
|
+
processed: 'processed',
|
|
14
|
+
failed: 'failed'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
def self.create_from_request!(notifier, request)
|
|
18
|
+
create!(
|
|
19
|
+
webhook_notifier: notifier,
|
|
20
|
+
headers: request.headers.to_h.select { |k, _| k == k.upcase },
|
|
21
|
+
payload: request.request_parameters.presence ||
|
|
22
|
+
request.query_parameters.presence ||
|
|
23
|
+
request.raw_post
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def dispatch!
|
|
28
|
+
if OmniEvent.configuration.process_async
|
|
29
|
+
OmniEvent::ProcessWebhookJob.perform_later(id)
|
|
30
|
+
else
|
|
31
|
+
OmniEvent::ProcessDispatcher.call(self)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
class CreateOmniEventNotifiers < ActiveRecord::Migration[6.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :omni_event_notifiers do |t|
|
|
4
|
+
t.string :name, null: false
|
|
5
|
+
t.string :token, null: false
|
|
6
|
+
t.boolean :check_ip, null: false, default: false
|
|
7
|
+
t.jsonb :allowed_ips, null: false, default: []
|
|
8
|
+
t.timestamps
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
add_index :omni_event_notifiers, :token, unique: true
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
class CreateOmniEventWebhookEvents < ActiveRecord::Migration[6.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :omni_event_webhook_events do |t|
|
|
4
|
+
t.references :webhook_notifier, null: false,
|
|
5
|
+
foreign_key: { to_table: :omni_event_notifiers }
|
|
6
|
+
t.jsonb :headers, null: false, default: {}
|
|
7
|
+
t.jsonb :payload, null: false, default: {}
|
|
8
|
+
t.string :status, null: false, default: 'pending'
|
|
9
|
+
t.timestamps
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
add_index :omni_event_webhook_events, :status
|
|
13
|
+
end
|
|
14
|
+
end
|