Files
baffle-hub/app/models/rule.rb
2025-11-04 09:47:11 +11:00

197 lines
5.6 KiB
Ruby

# frozen_string_literal: true
class Rule < ApplicationRecord
# 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
# 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 :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? && !expired?
end
def expired?
expires_at.present? && expires_at <= Time.current
end
# Convert to format for agent consumption
def to_agent_format
{
id: id,
rule_type: rule_type,
action: action,
conditions: conditions || {},
priority: priority,
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)
# 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
end
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 set_defaults
self.enabled = true if enabled.nil?
self.conditions ||= {}
self.metadata ||= {}
self.source ||= "manual"
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
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
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")
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 validate_path_pattern_conditions
patterns = conditions&.dig("patterns")
if patterns.blank? || !patterns.is_a?(Array)
errors.add(:conditions, "must include 'patterns' array for path_pattern rules")
end
end
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
end