Accepts incoming events and correctly parses them into events. GeoLite2 integration complete"

This commit is contained in:
Dan Milne
2025-11-04 00:11:10 +11:00
parent 0cbd462e7c
commit 5ff166613e
49 changed files with 4489 additions and 322 deletions

View File

@@ -88,40 +88,63 @@ class Event < ApplicationRecord
after_validation :normalize_event_fields, if: :should_normalize?
def self.create_from_waf_payload!(event_id, payload, project)
# Normalize headers in payload during import phase
normalized_payload = normalize_payload_headers(payload)
# Create the WAF request event
create!(
project: project,
event_id: event_id,
timestamp: parse_timestamp(payload["timestamp"]),
payload: payload,
timestamp: parse_timestamp(normalized_payload["timestamp"]),
payload: normalized_payload,
# WAF-specific fields
ip_address: payload.dig("request", "ip"),
user_agent: payload.dig("request", "headers", "User-Agent"),
request_method: payload.dig("request", "method")&.downcase,
request_path: payload.dig("request", "path"),
request_url: payload.dig("request", "url"),
request_protocol: payload.dig("request", "protocol"),
response_status: payload.dig("response", "status_code"),
response_time_ms: payload.dig("response", "duration_ms"),
waf_action: normalize_action(payload["waf_action"]), # Normalize incoming action values
rule_matched: payload["rule_matched"],
blocked_reason: payload["blocked_reason"],
ip_address: normalized_payload.dig("request", "ip"),
user_agent: normalized_payload.dig("request", "headers", "user-agent") || normalized_payload.dig("request", "headers", "User-Agent"),
# request_method will be set by extract_fields_from_payload + normalize_event_fields
request_path: normalized_payload.dig("request", "path"),
request_url: normalized_payload.dig("request", "url"),
# request_protocol will be set by extract_fields_from_payload + normalize_event_fields
response_status: normalized_payload.dig("response", "status_code"),
response_time_ms: normalized_payload.dig("response", "duration_ms"),
waf_action: normalize_action(normalized_payload["waf_action"]), # Normalize incoming action values
rule_matched: normalized_payload["rule_matched"],
blocked_reason: normalized_payload["blocked_reason"],
# Server/Environment info
server_name: payload["server_name"],
environment: payload["environment"],
server_name: normalized_payload["server_name"],
environment: normalized_payload["environment"],
# Geographic data
country_code: payload.dig("geo", "country_code"),
city: payload.dig("geo", "city"),
country_code: normalized_payload.dig("geo", "country_code"),
city: normalized_payload.dig("geo", "city"),
# WAF agent info
agent_version: payload.dig("agent", "version"),
agent_name: payload.dig("agent", "name")
agent_version: normalized_payload.dig("agent", "version"),
agent_name: normalized_payload.dig("agent", "name")
)
end
# Normalize headers in payload to lower case during import phase
def self.normalize_payload_headers(payload)
return payload unless payload.is_a?(Hash)
# Create a deep copy to avoid modifying the original
normalized = Marshal.load(Marshal.dump(payload))
# Normalize request headers
if normalized.dig("request", "headers")&.is_a?(Hash)
normalized["request"]["headers"] = normalized["request"]["headers"].transform_keys(&:downcase)
end
# Normalize response headers if they exist
if normalized.dig("response", "headers")&.is_a?(Hash)
normalized["response"]["headers"] = normalized["response"]["headers"].transform_keys(&:downcase)
end
normalized
end
def self.normalize_action(action)
return "allow" if action.nil? || action.blank?
@@ -195,7 +218,8 @@ class Event < ApplicationRecord
end
def headers
payload&.dig("request", "headers") || {}
raw_headers = payload&.dig("request", "headers") || {}
normalize_headers(raw_headers)
end
def query_params
@@ -237,6 +261,69 @@ class Event < ApplicationRecord
URI.parse(request_url).hostname rescue nil
end
# Normalize headers to lower case keys during import phase
def normalize_headers(headers)
return {} unless headers.is_a?(Hash)
headers.transform_keys(&:downcase)
end
# GeoIP enrichment methods
def enrich_geo_location!
return if ip_address.blank?
return if country_code.present? # Already has geo data
country = GeoIpService.lookup_country(ip_address)
update!(country_code: country) if country.present?
rescue => e
Rails.logger.error "Failed to enrich geo location for event #{id}: #{e.message}"
end
# Class method to enrich multiple events
def self.enrich_geo_location_batch(events = nil)
events ||= where(country_code: [nil, '']).where.not(ip_address: [nil, ''])
geo_service = GeoIpService.new
updated_count = 0
events.find_each do |event|
next if event.country_code.present?
country = geo_service.lookup_country(event.ip_address)
if country.present?
event.update!(country_code: country)
updated_count += 1
end
end
updated_count
end
# Lookup country code for this event's IP
def lookup_country
return country_code if country_code.present?
return nil if ip_address.blank?
GeoIpService.lookup_country(ip_address)
rescue => e
Rails.logger.error "GeoIP lookup failed for #{ip_address}: #{e.message}"
nil
end
# Check if event has valid geo location data
def has_geo_data?
country_code.present? || city.present?
end
# Get full geo location details
def geo_location
{
country_code: country_code,
city: city,
ip_address: ip_address,
has_data: has_geo_data?
}
end
private
def should_normalize?
@@ -257,7 +344,12 @@ class Event < ApplicationRecord
response_data = payload.dig("response") || {}
self.ip_address = request_data["ip"]
self.user_agent = request_data.dig("headers", "User-Agent")
# Extract user agent with header name standardization
headers = request_data["headers"] || {}
normalized_headers = normalize_headers(headers)
self.user_agent = normalized_headers["user-agent"] || normalized_headers["User-Agent"]
self.request_path = request_data["path"]
self.request_url = request_data["url"]
self.response_status = response_data["status_code"]
@@ -265,10 +357,11 @@ class Event < ApplicationRecord
self.rule_matched = payload["rule_matched"]
self.blocked_reason = payload["blocked_reason"]
# Store original values for normalization (these will be normalized to IDs)
@raw_request_method = request_data["method"]
@raw_request_protocol = request_data["protocol"]
@raw_action = payload["waf_action"]
# Store original values for normalization only if they don't exist yet
# This prevents overwriting during multiple callback runs
@raw_request_method ||= request_data["method"]
@raw_request_protocol ||= request_data["protocol"]
@raw_action ||= payload["waf_action"]
# Extract server/environment info
self.server_name = payload["server_name"]

171
app/models/ipv4_range.rb Normal file
View File

@@ -0,0 +1,171 @@
# frozen_string_literal: true
# Ipv4Range - Stores IPv4 network ranges with IP intelligence metadata
#
# Optimized for fast range lookups using network_start/network_end integers.
# Stores metadata about IP ranges including ASN, company, geographic info,
# and flags for datacenter/proxy/VPN detection.
class Ipv4Range < ApplicationRecord
# Validations
validates :network_start, presence: true
validates :network_end, presence: true
validates :network_prefix, presence: true,
numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 32 }
# Callbacks
before_validation :calculate_range, if: -> { cidr.present? }
# Scopes for common queries
scope :datacenter, -> { where(is_datacenter: true) }
scope :proxy, -> { where(is_proxy: true) }
scope :vpn, -> { where(is_vpn: true) }
scope :by_country, ->(country) { where(ip_api_country: country) }
scope :by_company, ->(company) { where(company: company) }
scope :by_asn, ->(asn) { where(asn: asn) }
# Virtual attribute for setting IP via CIDR notation
attr_accessor :cidr
# Find ranges that contain a specific IPv4 address
def self.contains_ip(ip_string)
ip_addr = IPAddr.new(ip_string)
raise ArgumentError, "Not an IPv4 address" unless ip_addr.ipv4?
ip_int = ip_addr.to_i
where("? BETWEEN network_start AND network_end", ip_int)
.order(network_prefix: :desc) # Most specific first
end
# Check if this range contains a specific IP
def contains_ip?(ip_string)
ip_addr = IPAddr.new(ip_string)
return false unless ip_addr.ipv4?
ip_int = ip_addr.to_i
ip_int >= network_start && ip_int <= network_end
end
# Get CIDR notation for this range
def to_cidr
return nil unless network_start.present?
ip_addr = IPAddr.new(network_start, Socket::AF_INET)
"#{ip_addr}/#{network_prefix}"
end
# String representation
def to_s
to_cidr || "Ipv4Range##{id}"
end
# Convenience methods for JSON fields
def abuser_scores_hash
abuser_scores ? JSON.parse(abuser_scores) : {}
rescue JSON::ParserError
{}
end
def abuser_scores_hash=(hash)
self.abuser_scores = hash.to_json
end
def additional_data_hash
additional_data ? JSON.parse(additional_data) : {}
rescue JSON::ParserError
{}
end
def additional_data_hash=(hash)
self.additional_data = hash.to_json
end
# GeoIP lookup methods
def geo_lookup_country!
return if ip_api_country.present? || geo2_country.present?
# Use the first IP in the range for lookup
sample_ip = IPAddr.new(network_start, Socket::AF_INET).to_s
country = GeoIpService.lookup_country(sample_ip)
if country.present?
# Update both country fields for redundancy
update!(ip_api_country: country, geo2_country: country)
country
end
rescue => e
Rails.logger.error "Failed to lookup geo location for IPv4 range #{id}: #{e.message}"
nil
end
def geo_lookup_country
return ip_api_country if ip_api_country.present?
return geo2_country if geo2_country.present?
# Use the first IP in the range for lookup
sample_ip = IPAddr.new(network_start, Socket::AF_INET).to_s
GeoIpService.lookup_country(sample_ip)
rescue => e
Rails.logger.error "Failed to lookup geo location for IPv4 range #{id}: #{e.message}"
nil
end
# Check if this range has any country information
def has_country_info?
ip_api_country.present? || geo2_country.present?
end
# Get the best available country code
def primary_country
ip_api_country || geo2_country
end
# Class method to lookup country for any IP in the range
def self.lookup_country_by_ip(ip_string)
range = contains_ip(ip_string).first
return nil unless range
range.geo_lookup_country
end
# Class method to enrich ranges without country data
def self.enrich_missing_geo_data(limit: 1000)
ranges_without_geo = where(ip_api_country: [nil, ''], geo2_country: [nil, ''])
.limit(limit)
updated_count = 0
geo_service = GeoIpService.new
ranges_without_geo.find_each do |range|
country = geo_service.lookup_country(IPAddr.new(range.network_start, Socket::AF_INET).to_s)
if country.present?
range.update!(ip_api_country: country, geo2_country: country)
updated_count += 1
end
end
updated_count
end
private
# Calculate network_start and network_end from CIDR notation
def calculate_range
return unless cidr.present?
ip_addr = IPAddr.new(cidr)
raise ArgumentError, "Not an IPv4 CIDR" unless ip_addr.ipv4?
# Get prefix from CIDR
self.network_prefix = cidr.split("/").last.to_i
# Calculate network range
first_ip = ip_addr.to_range.first
last_ip = ip_addr.to_range.last
self.network_start = first_ip.to_i
self.network_end = last_ip.to_i
rescue IPAddr::InvalidAddressError => e
errors.add(:cidr, "invalid IPv4 CIDR notation: #{e.message}")
end
end

171
app/models/ipv6_range.rb Normal file
View File

@@ -0,0 +1,171 @@
# frozen_string_literal: true
# Ipv6Range - Stores IPv6 network ranges with IP intelligence metadata
#
# Optimized for fast range lookups using network_start/network_end binary storage.
# Stores metadata about IP ranges including ASN, company, geographic info,
# and flags for datacenter/proxy/VPN detection.
class Ipv6Range < ApplicationRecord
# Validations
validates :network_start, presence: true
validates :network_end, presence: true
validates :network_prefix, presence: true,
numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 128 }
# Callbacks
before_validation :calculate_range, if: -> { cidr.present? }
# Scopes for common queries
scope :datacenter, -> { where(is_datacenter: true) }
scope :proxy, -> { where(is_proxy: true) }
scope :vpn, -> { where(is_vpn: true) }
scope :by_country, ->(country) { where(ip_api_country: country) }
scope :by_company, ->(company) { where(company: company) }
scope :by_asn, ->(asn) { where(asn: asn) }
# Virtual attribute for setting IP via CIDR notation
attr_accessor :cidr
# Find ranges that contain a specific IPv6 address
def self.contains_ip(ip_string)
ip_addr = IPAddr.new(ip_string)
raise ArgumentError, "Not an IPv6 address" unless ip_addr.ipv6?
ip_bytes = ip_addr.hton
where("? BETWEEN network_start AND network_end", ip_bytes)
.order(network_prefix: :desc) # Most specific first
end
# Check if this range contains a specific IP
def contains_ip?(ip_string)
ip_addr = IPAddr.new(ip_string)
return false unless ip_addr.ipv6?
ip_bytes = ip_addr.hton
ip_bytes >= network_start && ip_bytes <= network_end
end
# Get CIDR notation for this range
def to_cidr
return nil unless network_start.present?
ip_addr = IPAddr.new_ntoh(network_start)
"#{ip_addr}/#{network_prefix}"
end
# String representation
def to_s
to_cidr || "Ipv6Range##{id}"
end
# Convenience methods for JSON fields
def abuser_scores_hash
abuser_scores ? JSON.parse(abuser_scores) : {}
rescue JSON::ParserError
{}
end
def abuser_scores_hash=(hash)
self.abuser_scores = hash.to_json
end
def additional_data_hash
additional_data ? JSON.parse(additional_data) : {}
rescue JSON::ParserError
{}
end
def additional_data_hash=(hash)
self.additional_data = hash.to_json
end
# GeoIP lookup methods
def geo_lookup_country!
return if ip_api_country.present? || geo2_country.present?
# Use the first IP in the range for lookup
sample_ip = IPAddr.new_ntoh(network_start).to_s
country = GeoIpService.lookup_country(sample_ip)
if country.present?
# Update both country fields for redundancy
update!(ip_api_country: country, geo2_country: country)
country
end
rescue => e
Rails.logger.error "Failed to lookup geo location for IPv6 range #{id}: #{e.message}"
nil
end
def geo_lookup_country
return ip_api_country if ip_api_country.present?
return geo2_country if geo2_country.present?
# Use the first IP in the range for lookup
sample_ip = IPAddr.new_ntoh(network_start).to_s
GeoIpService.lookup_country(sample_ip)
rescue => e
Rails.logger.error "Failed to lookup geo location for IPv6 range #{id}: #{e.message}"
nil
end
# Check if this range has any country information
def has_country_info?
ip_api_country.present? || geo2_country.present?
end
# Get the best available country code
def primary_country
ip_api_country || geo2_country
end
# Class method to lookup country for any IP in the range
def self.lookup_country_by_ip(ip_string)
range = contains_ip(ip_string).first
return nil unless range
range.geo_lookup_country
end
# Class method to enrich ranges without country data
def self.enrich_missing_geo_data(limit: 1000)
ranges_without_geo = where(ip_api_country: [nil, ''], geo2_country: [nil, ''])
.limit(limit)
updated_count = 0
geo_service = GeoIpService.new
ranges_without_geo.find_each do |range|
country = geo_service.lookup_country(IPAddr.new_ntoh(range.network_start).to_s)
if country.present?
range.update!(ip_api_country: country, geo2_country: country)
updated_count += 1
end
end
updated_count
end
private
# Calculate network_start and network_end from CIDR notation
def calculate_range
return unless cidr.present?
ip_addr = IPAddr.new(cidr)
raise ArgumentError, "Not an IPv6 CIDR" unless ip_addr.ipv6?
# Get prefix from CIDR
self.network_prefix = cidr.split("/").last.to_i
# Calculate network range (binary format for IPv6)
first_ip = ip_addr.to_range.first
last_ip = ip_addr.to_range.last
self.network_start = first_ip.hton
self.network_end = last_ip.hton
rescue IPAddr::InvalidAddressError => e
errors.add(:cidr, "invalid IPv6 CIDR notation: #{e.message}")
end
end

View File

@@ -1,63 +0,0 @@
class NetworkRange < ApplicationRecord
validates :ip_address, presence: true
validates :network_prefix, presence: true, numericality: {greater_than_or_equal_to: 0, less_than_or_equal_to: 128}
validates :ip_version, presence: true, inclusion: {in: [4, 6]}
# Convenience methods for JSON fields
def abuser_scores_hash
abuser_scores ? JSON.parse(abuser_scores) : {}
end
def abuser_scores_hash=(hash)
self.abuser_scores = hash.to_json
end
def additional_data_hash
additional_data ? JSON.parse(additional_data) : {}
end
def additional_data_hash=(hash)
self.additional_data = hash.to_json
end
# Scope methods for common queries
scope :ipv4, -> { where(ip_version: 4) }
scope :ipv6, -> { where(ip_version: 6) }
scope :datacenter, -> { where(is_datacenter: true) }
scope :proxy, -> { where(is_proxy: true) }
scope :vpn, -> { where(is_vpn: true) }
scope :by_country, ->(country) { where(ip_api_country: country) }
scope :by_company, ->(company) { where(company: company) }
scope :by_asn, ->(asn) { where(asn: asn) }
# Find network ranges that contain a specific IP address
def self.contains_ip(ip_string)
ip_bytes = IPAddr.new(ip_string).hton
version = ip_string.include?(":") ? 6 : 4
where(ip_version: version).select do |range|
range.contains_ip_bytes?(ip_bytes)
end
end
def contains_ip?(ip_string)
contains_ip_bytes?(IPAddr.new(ip_string).hton)
end
def to_s
"#{ip_address_to_s}/#{network_prefix}"
end
private
def contains_ip_bytes?(ip_bytes)
# This is a simplified version - you'll need proper network math here
# For now, just check if the IP matches exactly
ip_address == ip_bytes
end
def ip_address_to_s
# Convert binary IP back to string representation
IPAddr.ntop(ip_address)
end
end

View File

@@ -1,126 +1,189 @@
# frozen_string_literal: true
class Rule < ApplicationRecord
belongs_to :rule_set
# Rule types for the new architecture
RULE_TYPES = %w[network_v4 network_v6 rate_limit path_pattern].freeze
ACTIONS = %w[allow deny rate_limit redirect log].freeze
SOURCES = %w[manual auto:scanner_detected auto:rate_limit_exceeded auto:bot_detected imported default].freeze
validates :rule_type, presence: true, inclusion: { in: RuleSet::RULE_TYPES }
validates :target, presence: true
validates :action, presence: true, inclusion: { in: RuleSet::ACTIONS }
validates :priority, presence: true, numericality: { greater_than: 0 }
# Validations
validates :rule_type, presence: true, inclusion: { in: RULE_TYPES }
validates :action, presence: true, inclusion: { in: ACTIONS }
validates :conditions, presence: true
validates :enabled, inclusion: { in: [true, false] }
# Custom validations based on rule type
validate :validate_conditions_by_type
validate :validate_metadata_by_action
# Scopes
scope :enabled, -> { where(enabled: true) }
scope :by_priority, -> { order(priority: :desc, created_at: :desc) }
scope :expired, -> { where("expires_at < ?", Time.current) }
scope :not_expired, -> { where("expires_at IS NULL OR expires_at > ?", Time.current) }
scope :disabled, -> { where(enabled: false) }
scope :active, -> { enabled.where("expires_at IS NULL OR expires_at > ?", Time.current) }
scope :expired, -> { where("expires_at IS NOT NULL AND expires_at <= ?", Time.current) }
scope :by_type, ->(type) { where(rule_type: type) }
scope :network_rules, -> { where(rule_type: ["network_v4", "network_v6"]) }
scope :rate_limit_rules, -> { where(rule_type: "rate_limit") }
scope :path_pattern_rules, -> { where(rule_type: "path_pattern") }
scope :by_source, ->(source) { where(source: source) }
# Sync queries (ordered by updated_at for incremental sync)
scope :since, ->(timestamp) { where("updated_at >= ?", timestamp - 0.5.seconds).order(:updated_at, :id) }
scope :sync_order, -> { order(:updated_at, :id) }
# Callbacks
before_validation :set_defaults
before_save :calculate_priority_from_cidr
# Check if rule is currently active
def active?
enabled? && (expires_at.nil? || expires_at > Time.current)
enabled? && !expired?
end
# Check if rule matches given request context
def matches?(context)
return false unless active?
case rule_type
when 'ip'
match_ip_rule?(context)
when 'cidr'
match_cidr_rule?(context)
when 'path'
match_path_rule?(context)
when 'user_agent'
match_user_agent_rule?(context)
when 'parameter'
match_parameter_rule?(context)
when 'method'
match_method_rule?(context)
when 'country'
match_country_rule?(context)
else
false
end
def expired?
expires_at.present? && expires_at <= Time.current
end
def to_waf_format
# Convert to format for agent consumption
def to_agent_format
{
id: id,
type: rule_type,
target: target,
rule_type: rule_type,
action: action,
conditions: conditions || {},
priority: priority,
expires_at: expires_at,
active: active?
expires_at: expires_at&.iso8601,
enabled: enabled,
source: source,
metadata: metadata || {},
created_at: created_at.iso8601,
updated_at: updated_at.iso8601
}
end
# Class method to get latest version (for sync cursor)
def self.latest_version
maximum(:updated_at)&.iso8601(6) || Time.current.iso8601(6)
end
# Disable rule (soft delete)
def disable!(reason: nil)
update!(
enabled: false,
metadata: (metadata || {}).merge(
disabled_at: Time.current.iso8601,
disabled_reason: reason
)
)
end
# Enable rule
def enable!
update!(enabled: true)
end
# Check if this is a network rule
def network_rule?
rule_type.in?(%w[network_v4 network_v6])
end
# Get CIDR from conditions (for network rules)
def cidr
conditions&.dig("cidr") if network_rule?
end
# Get prefix length from CIDR
def prefix_length
return nil unless cidr
cidr.split("/").last.to_i
end
private
def match_ip_rule?(context)
return false unless context[:ip_address]
target == context[:ip_address]
def set_defaults
self.enabled = true if enabled.nil?
self.conditions ||= {}
self.metadata ||= {}
self.source ||= "manual"
end
def match_cidr_rule?(context)
return false unless context[:ip_address]
def calculate_priority_from_cidr
# For network rules, priority is the prefix length (more specific = higher priority)
if network_rule? && cidr.present?
self.priority = prefix_length
end
end
def validate_conditions_by_type
case rule_type
when "network_v4", "network_v6"
validate_network_conditions
when "rate_limit"
validate_rate_limit_conditions
when "path_pattern"
validate_path_pattern_conditions
end
end
def validate_network_conditions
cidr_value = conditions&.dig("cidr")
if cidr_value.blank?
errors.add(:conditions, "must include 'cidr' for network rules")
return
end
# Validate CIDR format
begin
range = IPAddr.new(target)
range.include?(context[:ip_address])
rescue IPAddr::InvalidAddressError
false
addr = IPAddr.new(cidr_value)
# Check IPv4 vs IPv6 matches rule_type
if rule_type == "network_v4" && !addr.ipv4?
errors.add(:conditions, "cidr must be IPv4 for network_v4 rules")
elsif rule_type == "network_v6" && !addr.ipv6?
errors.add(:conditions, "cidr must be IPv6 for network_v6 rules")
end
rescue IPAddr::InvalidAddressError => e
errors.add(:conditions, "invalid CIDR format: #{e.message}")
end
end
def match_path_rule?(context)
return false unless context[:request_path]
def validate_rate_limit_conditions
scope = conditions&.dig("scope")
cidr_value = conditions&.dig("cidr")
# Support exact match and regex patterns
if conditions&.dig('regex') == true
Regexp.new(target).match?(context[:request_path])
else
context[:request_path].start_with?(target)
if scope.blank?
errors.add(:conditions, "must include 'scope' for rate_limit rules")
end
if cidr_value.blank?
errors.add(:conditions, "must include 'cidr' for rate_limit rules")
end
# Validate metadata has rate limit config
unless metadata&.dig("limit").present? && metadata&.dig("window").present?
errors.add(:metadata, "must include 'limit' and 'window' for rate_limit rules")
end
end
def match_user_agent_rule?(context)
return false unless context[:user_agent]
def validate_path_pattern_conditions
patterns = conditions&.dig("patterns")
# Support exact match and regex patterns
if conditions&.dig('regex') == true
Regexp.new(target, Regexp::IGNORECASE).match?(context[:user_agent])
else
context[:user_agent].downcase.include?(target.downcase)
if patterns.blank? || !patterns.is_a?(Array)
errors.add(:conditions, "must include 'patterns' array for path_pattern rules")
end
end
def match_parameter_rule?(context)
return false unless context[:query_params]
param_name = conditions&.dig('parameter_name') || target
param_value = context[:query_params][param_name]
return false unless param_value
# Check if parameter value matches pattern
if conditions&.dig('regex') == true
Regexp.new(target, Regexp::IGNORECASE).match?(param_value.to_s)
else
param_value.to_s.downcase.include?(target.downcase)
def validate_metadata_by_action
case action
when "redirect"
unless metadata&.dig("redirect_url").present?
errors.add(:metadata, "must include 'redirect_url' for redirect action")
end
when "rate_limit"
unless metadata&.dig("limit").present? && metadata&.dig("window").present?
errors.add(:metadata, "must include 'limit' and 'window' for rate_limit action")
end
end
end
def match_method_rule?(context)
return false unless context[:request_method]
target.upcase == context[:request_method].upcase
end
def match_country_rule?(context)
return false unless context[:country_code]
target.upcase == context[:country_code].upcase
end
end