Integração com Rails
Integre o ImmutableLog em qualquer aplicação Rails com um Rack middleware. Ele captura o status do retorno de `@app.call(env)` e envia o evento em uma Thread fire-and-forget — sem alterar nenhum controller.
Código do middleware
Salve em `app/middleware/immutable_log_middleware.rb`. O middleware lê request_id e a trilha do `env`, classifica o evento e envia em uma Thread. Query params passam por `redact`.
# app/middleware/immutable_log_middleware.rb
require "net/http"
require "json"
require "securerandom"
require "set"
require "time"
require "uri"
class ImmutableLogMiddleware
# Chaves cujos valores sao mascarados antes do envio (PII/segredos).
SENSITIVE = %w[
password pwd senha token access_token refresh_token api_key apikey
secret client_secret authorization auth credit_card card_number cvv cpf otp code
].freeze
def initialize(app, api_key:, service: "rails-service", env: "production",
api_url: nil, skip_paths: ["/health", "/up"])
@app = app
@api_key = api_key
@service = service
@env = env
@api_url = api_url || ENV.fetch("IMTBL_URL", "https://api.immutablelog.com")
@skip = skip_paths.to_set
end
def call(env)
return @app.call(env) if @skip.include?(env["PATH_INFO"])
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
status, headers, body = @app.call(env)
latency_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).to_i
request_id = env["HTTP_X_REQUEST_ID"] || SecureRandom.uuid
# fire-and-forget — nao bloqueia a resposta.
Thread.new { emit(env, status, latency_ms, request_id) }
[status, headers, body]
rescue StandardError => e
rid = env["HTTP_X_REQUEST_ID"] || SecureRandom.uuid
Thread.new { emit(env, 500, 0, rid, e) }
raise
end
private
def emit(env, status, latency_ms, request_id, exc = nil)
return if @api_key.to_s.empty?
kind = if exc || status >= 400 then "error"
elsif status >= 300 then "info"
elsif status >= 200 then "success"
else "info" end
method = env["REQUEST_METHOD"]
path = env["PATH_INFO"]
event_name = env["imtbl.event_name"] || "http.#{method}.#{path}"
query = Rack::Utils.parse_nested_query(env["QUERY_STRING"].to_s)
payload = {
"id" => SecureRandom.uuid,
"kind" => kind,
"message" => "#{method} #{path} -> #{status}",
"timestamp" => Time.now.utc.iso8601,
"context" => { "ip" => client_ip(env), "user_agent" => env["HTTP_USER_AGENT"] || "unknown" },
"request" => {
"request_id" => request_id, "method" => method, "path" => path,
# query params passam por redact() — nunca enviar segredos em claro.
"query_params" => query.empty? ? nil : redact(query),
},
"metrics" => { "latency_ms" => latency_ms, "status_code" => status },
"severity" => kind == "error" ? "high" : "low",
}
if kind == "error"
err = { "status_code" => status, "retryable" => [408, 429, 500, 502, 503, 504].include?(status) }
if exc
err["exception"] = exc.class.name
err["exception_message"] = exc.message.to_s[0, 500]
end
payload["error"] = err
end
# Trilha: env["imtbl.trail"] -> header X-Imtbl-Trail.
trail = self.class.sanitize_trail(env["imtbl.trail"] || env["HTTP_X_IMTBL_TRAIL"])
# Todos os valores de meta precisam ser strings.
meta = {
"type" => kind, "event_name" => event_name, "service" => @service,
"request_id" => request_id, "env" => @env,
}
meta["immutable_trail"] = trail if trail
post({ "payload" => JSON.generate(payload), "meta" => meta }, event_name, request_id, trail)
rescue StandardError
# Never let audit logging break the application.
end
def post(body, event_name, request_id, trail)
uri = URI.join(@api_url, "/v1/events")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = uri.scheme == "https"
http.open_timeout = 5
http.read_timeout = 5
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{@api_key}"
req["Content-Type"] = "application/json"
# Idempotency-Key e OBRIGATORIO (sem ele a API responde 400).
req["Idempotency-Key"] = "#{event_name}-#{request_id}"
req["Request-Id"] = request_id
req["X-Client-TZ"] = "America/Sao_Paulo"
req["X-Client-Offset-Minutes"] = "-180"
req["X-Imtbl-Trail"] = trail if trail
req.body = JSON.generate(body)
http.request(req)
end
def client_ip(env)
(env["HTTP_X_FORWARDED_FOR"] || env["REMOTE_ADDR"] || "unknown").split(",").first.to_s.strip
end
def redact(obj)
case obj
when Hash
obj.each_with_object({}) do |(k, v), h|
h[k] = SENSITIVE.include?(k.to_s.downcase) ? "***REDACTED***" : redact(v)
end
when Array then obj.map { |v| redact(v) }
else obj
end
end
# Normaliza o immutable_trail (trim, nao-vazio, max 256, sem ':').
def self.sanitize_trail(value)
return nil unless value.is_a?(String)
v = value.strip
return nil if v.empty?
v = v.tr(":", "-")
v = v[0, 256] if v.length > 256
v
end
endRegistro do middleware
Adicione o middleware na pilha Rack no `config/application.rb`. Use variáveis de ambiente para as credenciais.
# config/application.rb
require_relative "../app/middleware/immutable_log_middleware"
module MyApp
class Application < Rails::Application
# Registre o middleware na pilha Rack.
config.middleware.use ImmutableLogMiddleware,
api_key: ENV["IMTBL_API_KEY"],
service: "my-rails-app",
env: Rails.env.to_s
end
endNunca coloque o token no código. Use ENV["IMTBL_API_KEY"] (ex: via Rails credentials ou dotenv).
Como funciona
@app.call(env)
Executa a app; o retorno traz [status, headers, body]
Thread.new
Evento enviado em uma Thread — não bloqueia a resposta
rescue + raise
Exceções viram evento de erro e são re-lançadas
Evento e trilha customizados
Em qualquer controller, defina `request.env["imtbl.event_name"]` e `request.env["imtbl.trail"]`. O middleware lê esses valores no `env` ao montar o evento.
class PaymentsController < ApplicationController
def create
# Sobrescreve o nome do evento e agrupa numa trilha auditavel.
request.env["imtbl.event_name"] = "payment.created"
request.env["imtbl.trail"] = "order-7782"
# ... lógica de negócio / business logic ...
render json: { ok: true }
end
endPadrão automático: http.METHOD.path.
Trilha imutável (immutable_trail)
A trilha agrupa eventos relacionados em uma mesma linha do tempo auditável. Defina `request.env["imtbl.trail"]` em um controller, ou propague entre serviços com o header `X-Imtbl-Trail`. O valor passa por `sanitize_trail` antes do envio.
def run
request.env["imtbl.trail"] = "flow-#{params[:id]}"
# ... business logic ...
render json: { status: "ok" }
endA trilha não pode ser vazia, exceder 256 caracteres ou conter `:` — violações retornam `400 invalid_immutable_trail`. O `sanitize_trail` corrige o valor antes do envio.
Resposta e códigos de status
A API de ingestão é assíncrona. Sucesso retorna 202 (novo) ou 200 (duplicata idempotente). Como o middleware envia em uma Thread, esses códigos importam principalmente se você enviar eventos manualmente.
| Status | Significado |
|---|---|
| 202 | Evento aceito e enfileirado (novo) |
| 200 | Idempotency-Key já existente — duplicate: true |
| 400 | Idempotency-Key ausente/vazia ou invalid_immutable_trail |
| 403 | Assinatura inativa/expirada, escopo ou retenção |
| 413 | payload_too_large (limite do servidor: 16KB) |
| 429 | monthly_limit_exceeded — não fazer retry |
| 503 | mempool_full — transitório, pode dar retry |
Campos de meta
Além do payload, o objeto meta carrega os campos que o SIEM usa para normalizar (ECS) e enriquecer o evento. Todos os valores de meta são strings.
| Campo em meta | O que faz no SIEM |
|---|---|
event_name | O rótulo do evento (ex.: http.GET.auth-me-user, Pagamento erro) — o identificador que aparece na listagem. Vira event.action e deriva event.category (ex.: nome com login → authentication). |
client_ip | Vira source.ip com precedência máxima e proveniência client_asserted. Ver a seção abaixo. |
immutable_trail | Vira imtbl.immutable_trail — agrupa eventos relacionados para investigação (ex.: um pedido, um usuário). |
type | Severidade do evento: error / warning / info / success. Colore o badge na listagem. (event_type é aceito como sinônimo.) |
service, env, request_id | Metadados: indexados e consultáveis no painel (busca/filtros). |
client_ip — IP do usuário final
O problema que resolve
Entre o navegador e o core há proxies/ALB. O IP que o core observa na conexão é o do salto anterior (o backend do cliente ou um proxy da AWS), não o do usuário. Sem ação, source.ip seria o IP do proxy — inútil para geo, threat e detecção por IP. (Exemplo real: o XFF chegava 44.192.13.3 (AWS) enquanto o usuário era 179.110.4.205.)
A solução
O backend do cliente é o único que enxerga o IP real do navegador — então ele encaminha esse IP em meta.client_ip ao chamar POST /v1/events.
Como o SIEM trata
- Valida que é um IP (v4/v6). Um valor não-IP é ignorado — não derruba o evento.
- Escreve em
source.ipcom precedência máxima (ver ordem abaixo). - Carimba
imtbl.source_ip_origin = "client_asserted"— a UI mostra o selo “IP do usuário”. - Alimenta GeoIP, threat intel e regras de detecção por IP.
client_ipX-Forwarded-For (1º hop)X-Real-IPIP de conexãoProveniência e confiança
client_ip é asserido pelo tenant — o backend do cliente afirma o IP, e é spoofável por quem controla aquele backend. Porém é auto-contido ao próprio tenant (não cruza a fronteira de outro tenant). Um hit de threat/geo sobre um client_asserted é uma alegação, não uma observação de borda.
Como obter o IP no seu backend
Pegue o primeiro IP do X-Forwarded-For (o mais próximo do usuário); no fallback, use o IP de conexão.
def client_ip(request)
xff = request.get_header("HTTP_X_FORWARDED_FOR").to_s
return xff.split(",").first.strip unless xff.empty? # primeiro hop
request.ip
end
meta = { event_name: "user.login", client_ip: client_ip(request) }Exemplo de payload
{
"payload": "{\"user\":\"bob\",\"action\":\"login\"}",
"meta": {
"event_name": "user.login",
"client_ip": "179.110.4.205",
"immutable_trail": "user-bob"
}
}Nota de PII: client_ip é PII e vai selado no bloco imutável (permanente). Decida conscientemente entre evidência forense e “direito ao esquecimento” antes de enviá-lo.
Esta documentação reflete o comportamento atual da integração. Para dúvidas ou integrações avançadas, entre em contato com o time de suporte.
