Migrate to Postgresql for better network handling. Add more user functionality.
This commit is contained in:
@@ -1,20 +1,31 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Rule - WAF rule management with NetworkRange integration
|
||||
#
|
||||
# Rules define actions to take for matching traffic conditions.
|
||||
# Network rules are associated with NetworkRange objects for rich context.
|
||||
class Rule < ApplicationRecord
|
||||
# Rule types for the new architecture
|
||||
RULE_TYPES = %w[network_v4 network_v6 rate_limit path_pattern].freeze
|
||||
# Rule types and actions
|
||||
RULE_TYPES = %w[network 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
|
||||
SOURCES = %w[manual auto:scanner_detected auto:rate_limit_exceeded auto:bot_detected imported default manual:surgical_block manual:surgical_exception].freeze
|
||||
|
||||
# Associations
|
||||
belongs_to :user
|
||||
belongs_to :network_range, optional: true
|
||||
|
||||
# Validations
|
||||
validates :rule_type, presence: true, inclusion: { in: RULE_TYPES }
|
||||
validates :action, presence: true, inclusion: { in: ACTIONS }
|
||||
validates :conditions, presence: true
|
||||
validates :conditions, presence: true, unless: :network_rule?
|
||||
validates :enabled, inclusion: { in: [true, false] }
|
||||
validates :source, inclusion: { in: SOURCES }
|
||||
|
||||
# Custom validations based on rule type
|
||||
# Custom validations
|
||||
validate :validate_conditions_by_type
|
||||
validate :validate_metadata_by_action
|
||||
validate :network_range_required_for_network_rules
|
||||
validate :validate_network_consistency, if: :network_rule?
|
||||
|
||||
# Scopes
|
||||
scope :enabled, -> { where(enabled: true) }
|
||||
@@ -22,20 +33,80 @@ class Rule < ApplicationRecord
|
||||
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 :network_rules, -> { where(rule_type: "network") }
|
||||
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) }
|
||||
scope :surgical_blocks, -> { where(source: "manual:surgical_block") }
|
||||
scope :surgical_exceptions, -> { where(source: "manual:surgical_exception") }
|
||||
|
||||
# Sync queries (ordered by updated_at for incremental sync)
|
||||
scope :since, ->(timestamp) { where("updated_at >= ?", timestamp - 0.5.seconds).order(:updated_at, :id) }
|
||||
# Sync queries
|
||||
scope :since, ->(timestamp) { where("updated_at >= ?", Time.at(timestamp)).order(:updated_at, :id) }
|
||||
scope :sync_order, -> { order(:updated_at, :id) }
|
||||
|
||||
# Callbacks
|
||||
before_validation :set_defaults
|
||||
before_save :calculate_priority_from_cidr
|
||||
before_validation :parse_json_fields
|
||||
before_save :calculate_priority_for_network_rules
|
||||
|
||||
# Check if rule is currently active
|
||||
# Rule type checks
|
||||
def network_rule?
|
||||
rule_type == "network"
|
||||
end
|
||||
|
||||
def rate_limit_rule?
|
||||
rule_type == "rate_limit"
|
||||
end
|
||||
|
||||
def path_pattern_rule?
|
||||
rule_type == "path_pattern"
|
||||
end
|
||||
|
||||
# Network-specific methods
|
||||
def cidr
|
||||
network_rule? ? network_range&.cidr : conditions&.dig("cidr")
|
||||
end
|
||||
|
||||
def prefix_length
|
||||
network_rule? ? network_range&.prefix_length : cidr&.split("/")&.last&.to_i
|
||||
end
|
||||
|
||||
def network_intelligence
|
||||
return {} unless network_rule? && network_range
|
||||
|
||||
network_range.inherited_intelligence
|
||||
end
|
||||
|
||||
def network_address
|
||||
network_rule? ? network_range&.network_address : nil
|
||||
end
|
||||
|
||||
# Surgical block methods
|
||||
def surgical_block?
|
||||
source == "manual:surgical_block"
|
||||
end
|
||||
|
||||
def surgical_exception?
|
||||
source == "manual:surgical_exception"
|
||||
end
|
||||
|
||||
def related_surgical_rules
|
||||
if surgical_block?
|
||||
# Find the corresponding exception rule
|
||||
surgical_exceptions.where(
|
||||
conditions: { cidr: network_address ? "#{network_address}/32" : nil }
|
||||
)
|
||||
elsif surgical_exception?
|
||||
# Find the parent block rule
|
||||
surgical_blocks.joins(:network_range).where(
|
||||
network_ranges: { network: parent_cidr }
|
||||
)
|
||||
else
|
||||
Rule.none
|
||||
end
|
||||
end
|
||||
|
||||
# Rule lifecycle
|
||||
def active?
|
||||
enabled? && !expired?
|
||||
end
|
||||
@@ -44,14 +115,37 @@ class Rule < ApplicationRecord
|
||||
expires_at.present? && expires_at <= Time.current
|
||||
end
|
||||
|
||||
# Convert to format for agent consumption
|
||||
def activate!
|
||||
update!(enabled: true)
|
||||
end
|
||||
|
||||
def deactivate!
|
||||
update!(enabled: false)
|
||||
end
|
||||
|
||||
def disable!(reason: nil)
|
||||
update!(
|
||||
enabled: false,
|
||||
metadata: metadata.merge(
|
||||
disabled_at: Time.current.iso8601,
|
||||
disabled_reason: reason
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
def extend_expiry!(duration)
|
||||
new_expiry = Time.current + duration
|
||||
update!(expires_at: new_expiry)
|
||||
end
|
||||
|
||||
# Agent serialization
|
||||
def to_agent_format
|
||||
{
|
||||
format = {
|
||||
id: id,
|
||||
rule_type: rule_type,
|
||||
action: action,
|
||||
conditions: conditions || {},
|
||||
priority: priority,
|
||||
conditions: agent_conditions,
|
||||
priority: agent_priority,
|
||||
expires_at: expires_at&.iso8601,
|
||||
enabled: enabled,
|
||||
source: source,
|
||||
@@ -59,50 +153,118 @@ class Rule < ApplicationRecord
|
||||
created_at: created_at.iso8601,
|
||||
updated_at: updated_at.iso8601
|
||||
}
|
||||
end
|
||||
|
||||
# Class method to get latest version (for sync cursor)
|
||||
# Returns microsecond Unix timestamp for efficient machine comparison
|
||||
def self.latest_version
|
||||
max_time = maximum(:updated_at)
|
||||
if max_time
|
||||
# Convert to microseconds since epoch
|
||||
(max_time.to_f * 1_000_000).to_i
|
||||
else
|
||||
(Time.current.to_f * 1_000_000).to_i
|
||||
# Add network intelligence for debugging (optional)
|
||||
if network_rule? && network_range
|
||||
format[:network_intelligence] = network_intelligence
|
||||
end
|
||||
|
||||
format
|
||||
end
|
||||
|
||||
# Disable rule (soft delete)
|
||||
def disable!(reason: nil)
|
||||
update!(
|
||||
enabled: false,
|
||||
metadata: (metadata || {}).merge(
|
||||
disabled_at: Time.current.iso8601,
|
||||
disabled_reason: reason
|
||||
)
|
||||
# Class methods for rule creation
|
||||
def self.create_network_rule(cidr, action: 'deny', user: nil, **options)
|
||||
network_range = NetworkRange.find_or_create_by_cidr(cidr, user: user, source: 'user_created')
|
||||
|
||||
create!(
|
||||
rule_type: 'network',
|
||||
action: action,
|
||||
network_range: network_range,
|
||||
user: user,
|
||||
**options
|
||||
)
|
||||
end
|
||||
|
||||
# Enable rule
|
||||
def enable!
|
||||
update!(enabled: true)
|
||||
def self.create_surgical_block(ip_address, parent_cidr, user: nil, reason: nil, **options)
|
||||
# Create block rule for parent range
|
||||
network_range = NetworkRange.find_or_create_by_cidr(parent_cidr, user: user, source: 'user_created')
|
||||
|
||||
block_rule = create!(
|
||||
rule_type: 'network',
|
||||
action: 'deny',
|
||||
network_range: network_range,
|
||||
source: 'manual:surgical_block',
|
||||
user: user,
|
||||
metadata: {
|
||||
reason: reason,
|
||||
surgical_block: true,
|
||||
original_ip: ip_address,
|
||||
**options[:metadata]
|
||||
},
|
||||
**options.except(:metadata)
|
||||
)
|
||||
|
||||
# Create exception rule for specific IP
|
||||
ip_network_range = NetworkRange.find_or_create_by_cidr("#{ip_address}/#{ip_address.include?(':') ? '128' : '32'}", user: user, source: 'user_created')
|
||||
|
||||
exception_rule = create!(
|
||||
rule_type: 'network',
|
||||
action: 'allow',
|
||||
network_range: ip_network_range,
|
||||
source: 'manual:surgical_exception',
|
||||
user: user,
|
||||
priority: ip_network_range.prefix_length, # Higher priority = more specific
|
||||
metadata: {
|
||||
reason: "Exception for #{ip_address} in surgical block of #{parent_cidr}",
|
||||
surgical_exception: true,
|
||||
parent_rule_id: block_rule.id,
|
||||
**options[:metadata]
|
||||
},
|
||||
**options.except(:metadata)
|
||||
)
|
||||
|
||||
[block_rule, exception_rule]
|
||||
end
|
||||
|
||||
# Check if this is a network rule
|
||||
def network_rule?
|
||||
rule_type.in?(%w[network_v4 network_v6])
|
||||
def self.create_rate_limit_rule(cidr, limit:, window:, user: nil, **options)
|
||||
network_range = NetworkRange.find_or_create_by_cidr(cidr, user: user, source: 'user_created')
|
||||
|
||||
create!(
|
||||
rule_type: 'rate_limit',
|
||||
action: 'rate_limit',
|
||||
network_range: network_range,
|
||||
conditions: { cidr: cidr, scope: 'ip' },
|
||||
metadata: {
|
||||
limit: limit,
|
||||
window: window,
|
||||
**options[:metadata]
|
||||
},
|
||||
user: user,
|
||||
**options.except(:metadata)
|
||||
)
|
||||
end
|
||||
|
||||
# Get CIDR from conditions (for network rules)
|
||||
def cidr
|
||||
conditions&.dig("cidr") if network_rule?
|
||||
# Sync and versioning
|
||||
def self.latest_version
|
||||
max_time = maximum(:updated_at)
|
||||
max_time ? max_time.to_i : Time.current.to_i
|
||||
end
|
||||
|
||||
# Get prefix length from CIDR
|
||||
def prefix_length
|
||||
return nil unless cidr
|
||||
cidr.split("/").last.to_i
|
||||
def self.active_for_agent
|
||||
active.sync_order.map(&:to_agent_format)
|
||||
end
|
||||
|
||||
# Analytics methods
|
||||
def matching_events(limit: 100)
|
||||
return Event.none unless network_rule? && network_range
|
||||
|
||||
# This would need efficient IP range queries
|
||||
# For now, simple IP match
|
||||
Event.where(ip_address: network_range.network_address)
|
||||
.recent
|
||||
.limit(limit)
|
||||
end
|
||||
|
||||
def effectiveness_stats
|
||||
return {} unless network_rule?
|
||||
|
||||
events = matching_events
|
||||
{
|
||||
total_events: events.count,
|
||||
blocked_events: events.blocked.count,
|
||||
allowed_events: events.allowed.count,
|
||||
block_rate: events.count > 0 ? (events.blocked.count.to_f / events.count * 100).round(2) : 0
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
@@ -112,19 +274,40 @@ class Rule < ApplicationRecord
|
||||
self.conditions ||= {}
|
||||
self.metadata ||= {}
|
||||
self.source ||= "manual"
|
||||
|
||||
# Set system user for auto-generated rules if no user is set
|
||||
if source&.start_with?('auto:') || source == 'default'
|
||||
self.user ||= User.find_by(role: 1) # admin role
|
||||
end
|
||||
end
|
||||
|
||||
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
|
||||
def calculate_priority_for_network_rules
|
||||
if network_rule? && network_range
|
||||
self.priority = network_range.prefix_length
|
||||
end
|
||||
end
|
||||
|
||||
def agent_conditions
|
||||
if network_rule?
|
||||
{ cidr: cidr }
|
||||
else
|
||||
conditions || {}
|
||||
end
|
||||
end
|
||||
|
||||
def agent_priority
|
||||
if network_rule?
|
||||
prefix_length || 0
|
||||
else
|
||||
priority || 0
|
||||
end
|
||||
end
|
||||
|
||||
def validate_conditions_by_type
|
||||
case rule_type
|
||||
when "network_v4", "network_v6"
|
||||
validate_network_conditions
|
||||
when "network"
|
||||
# Network rules don't need conditions in DB - stored in network_range
|
||||
true
|
||||
when "rate_limit"
|
||||
validate_rate_limit_conditions
|
||||
when "path_pattern"
|
||||
@@ -132,29 +315,6 @@ class Rule < ApplicationRecord
|
||||
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
|
||||
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 validate_rate_limit_conditions
|
||||
scope = conditions&.dig("scope")
|
||||
cidr_value = conditions&.dig("cidr")
|
||||
@@ -163,11 +323,6 @@ class Rule < ApplicationRecord
|
||||
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
|
||||
@@ -193,4 +348,50 @@ class Rule < ApplicationRecord
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def network_range_required_for_network_rules
|
||||
if network_rule? && network_range.nil?
|
||||
errors.add(:network_range, "is required for network rules")
|
||||
end
|
||||
end
|
||||
|
||||
def validate_network_consistency
|
||||
return unless network_rule? && network_range
|
||||
|
||||
# For network rules, we don't use conditions - the network_range handles everything
|
||||
# So we can skip this validation for now
|
||||
true
|
||||
end
|
||||
|
||||
def parent_cidr
|
||||
return nil unless network_range
|
||||
|
||||
# Find a broader network range that contains this one
|
||||
network_range.parent_ranges.first&.cidr
|
||||
end
|
||||
|
||||
def parse_json_fields
|
||||
# Parse conditions if it's a string
|
||||
if conditions.is_a?(String) && conditions.present?
|
||||
begin
|
||||
self.conditions = JSON.parse(conditions) if conditions != "{}"
|
||||
rescue JSON::ParserError
|
||||
self.conditions = {}
|
||||
end
|
||||
end
|
||||
|
||||
# Parse metadata if it's a string
|
||||
if metadata.is_a?(String) && metadata.present?
|
||||
begin
|
||||
self.metadata = JSON.parse(metadata) if metadata != "{}"
|
||||
rescue JSON::ParserError
|
||||
self.metadata = {}
|
||||
end
|
||||
end
|
||||
|
||||
# Ensure they are hashes
|
||||
self.conditions ||= {}
|
||||
self.metadata ||= {}
|
||||
end
|
||||
|
||||
end
|
||||
Reference in New Issue
Block a user