Fix geo rule re-enablement bug
When rules expire and are disabled by ExpiredRulesCleanupJob, the system was unable to re-enable them due to unique index constraints. This caused geo-based blocking to stop working in production. Implemented find-or-update-or-create pattern in WafPolicy#create_rule_for_network_range: - Re-enables disabled rules and sets new expiration (7 days) - Extends expiration for enabled rules - Creates new rules with 7-day TTL - Handles race conditions gracefully Added test coverage for all three scenarios. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -159,49 +159,23 @@ validate :targets_must_be_array
|
||||
return nil
|
||||
end
|
||||
|
||||
# Try to create the rule, handling duplicates gracefully
|
||||
begin
|
||||
rule = Rule.create!(
|
||||
waf_rule_type: 'network',
|
||||
waf_action: policy_action.to_sym,
|
||||
network_range: network_range,
|
||||
waf_policy: self,
|
||||
user: user,
|
||||
source: "policy",
|
||||
metadata: build_rule_metadata(network_range),
|
||||
priority: network_range.prefix_length
|
||||
)
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
# Rule already exists (created by another job or earlier in this job)
|
||||
# Find and return the existing rule
|
||||
Rails.logger.debug "Rule already exists for #{network_range.cidr} with policy #{name}"
|
||||
return Rule.find_by(
|
||||
# Find existing rule (enabled or disabled)
|
||||
existing_rule = Rule.find_by(
|
||||
waf_rule_type: 'network',
|
||||
waf_action: policy_action,
|
||||
network_range: network_range,
|
||||
waf_policy: self,
|
||||
source: "policy"
|
||||
)
|
||||
|
||||
if existing_rule
|
||||
# Re-enable disabled rules or extend expiration for enabled rules
|
||||
update_existing_rule(existing_rule)
|
||||
return existing_rule
|
||||
end
|
||||
|
||||
# Handle redirect/challenge specific data
|
||||
if redirect_action? && additional_data['redirect_url']
|
||||
rule.update!(
|
||||
metadata: rule.metadata.merge(
|
||||
redirect_url: additional_data['redirect_url'],
|
||||
redirect_status: additional_data['redirect_status'] || 302
|
||||
)
|
||||
)
|
||||
elsif challenge_action?
|
||||
rule.update!(
|
||||
metadata: rule.metadata.merge(
|
||||
challenge_type: additional_data['challenge_type'] || 'captcha',
|
||||
challenge_message: additional_data['challenge_message']
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
rule
|
||||
# Create new rule
|
||||
create_new_rule(network_range)
|
||||
end
|
||||
|
||||
def create_rule_for_event(event)
|
||||
@@ -451,6 +425,74 @@ validate :targets_must_be_array
|
||||
end
|
||||
end
|
||||
|
||||
def update_existing_rule(rule)
|
||||
updates = { updated_at: Time.current }
|
||||
|
||||
# Re-enable if disabled
|
||||
unless rule.enabled?
|
||||
updates[:enabled] = true
|
||||
Rails.logger.info "Re-enabling rule ##{rule.id} for #{rule.network_range.cidr}"
|
||||
end
|
||||
|
||||
# Set/extend expiration to 7 days from now
|
||||
# (Can be made configurable later via policy.rule_ttl field)
|
||||
updates[:expires_at] = 7.days.from_now
|
||||
|
||||
rule.update!(updates) unless updates.empty?
|
||||
end
|
||||
|
||||
def create_new_rule(network_range)
|
||||
begin
|
||||
rule = Rule.create!(
|
||||
waf_rule_type: 'network',
|
||||
waf_action: policy_action.to_sym,
|
||||
network_range: network_range,
|
||||
waf_policy: self,
|
||||
user: user,
|
||||
source: "policy",
|
||||
metadata: build_rule_metadata(network_range),
|
||||
priority: network_range.prefix_length,
|
||||
expires_at: 7.days.from_now # Set expiration for new rules
|
||||
)
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
# Race condition: rule created between find_by and create
|
||||
# Retry by finding and updating
|
||||
existing_rule = Rule.find_by(
|
||||
waf_rule_type: 'network',
|
||||
waf_action: policy_action,
|
||||
network_range: network_range,
|
||||
waf_policy: self,
|
||||
source: "policy"
|
||||
)
|
||||
|
||||
if existing_rule
|
||||
update_existing_rule(existing_rule)
|
||||
return existing_rule
|
||||
else
|
||||
raise # Re-raise if we still can't find it
|
||||
end
|
||||
end
|
||||
|
||||
# Handle redirect/challenge specific data
|
||||
if redirect_action? && additional_data['redirect_url']
|
||||
rule.update!(
|
||||
metadata: rule.metadata.merge(
|
||||
redirect_url: additional_data['redirect_url'],
|
||||
redirect_status: additional_data['redirect_status'] || 302
|
||||
)
|
||||
)
|
||||
elsif challenge_action?
|
||||
rule.update!(
|
||||
metadata: rule.metadata.merge(
|
||||
challenge_type: additional_data['challenge_type'] || 'captcha',
|
||||
challenge_message: additional_data['challenge_message']
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
rule
|
||||
end
|
||||
|
||||
# Matching logic for different policy types
|
||||
def matches_country?(network_range)
|
||||
country = network_range.country || network_range.inherited_intelligence[:country]
|
||||
|
||||
@@ -442,7 +442,7 @@ class WafPolicyTest < ActiveSupport::TestCase
|
||||
assert_equal 0, @policy.generated_rules_count
|
||||
|
||||
# Create some rules
|
||||
network_range = NetworkRange.create!(ip_range: "192.168.1.0/24")
|
||||
network_range = NetworkRange.create!(cidr: "192.168.1.0/24", country: "BR")
|
||||
@policy.create_rule_for_network_range(network_range)
|
||||
|
||||
assert_equal 1, @policy.generated_rules_count
|
||||
@@ -461,6 +461,75 @@ class WafPolicyTest < ActiveSupport::TestCase
|
||||
assert_equal 2, stats[:targets_count]
|
||||
end
|
||||
|
||||
# Rule creation and re-enablement tests
|
||||
test "create_rule_for_network_range re-enables disabled rule" do
|
||||
@policy.save!
|
||||
|
||||
# Create a network range that matches the policy
|
||||
network_range = NetworkRange.create!(
|
||||
cidr: "192.168.1.0/24",
|
||||
country: "BR"
|
||||
)
|
||||
|
||||
# Create a rule via policy
|
||||
rule = @policy.create_rule_for_network_range(network_range)
|
||||
assert rule.enabled?, "Rule should be enabled initially"
|
||||
assert rule.expires_at.present?, "Rule should have expiration"
|
||||
|
||||
# Disable it (simulating expiration cleanup)
|
||||
rule.update!(enabled: false)
|
||||
assert_not rule.enabled?, "Rule should be disabled"
|
||||
|
||||
# Try to create again - should re-enable
|
||||
result = @policy.create_rule_for_network_range(network_range)
|
||||
|
||||
assert_equal rule.id, result.id, "Should return same rule"
|
||||
assert result.reload.enabled?, "Rule should be re-enabled"
|
||||
assert result.expires_at.present?, "Should have new expiration"
|
||||
assert result.expires_at > Time.current, "Expiration should be in the future"
|
||||
end
|
||||
|
||||
test "create_rule_for_network_range extends enabled rule expiration" do
|
||||
@policy.save!
|
||||
|
||||
# Create a network range that matches the policy
|
||||
network_range = NetworkRange.create!(
|
||||
cidr: "192.168.1.0/24",
|
||||
country: "BR"
|
||||
)
|
||||
|
||||
# Create a rule with expiration
|
||||
rule = @policy.create_rule_for_network_range(network_range)
|
||||
original_expires_at = rule.expires_at
|
||||
|
||||
travel 3.days do
|
||||
# Try to create again - should extend expiration
|
||||
result = @policy.create_rule_for_network_range(network_range)
|
||||
|
||||
assert_equal rule.id, result.id, "Should return same rule"
|
||||
assert result.reload.expires_at > original_expires_at, "Expiration should be extended"
|
||||
assert_in_delta 7.days.from_now, result.expires_at, 5.seconds, "Expiration should be ~7 days from now"
|
||||
end
|
||||
end
|
||||
|
||||
test "create_rule_for_network_range creates new rule when none exists" do
|
||||
@policy.save!
|
||||
|
||||
# Create a network range that matches the policy
|
||||
network_range = NetworkRange.create!(
|
||||
cidr: "192.168.1.0/24",
|
||||
country: "BR"
|
||||
)
|
||||
|
||||
rule = @policy.create_rule_for_network_range(network_range)
|
||||
|
||||
assert rule.persisted?, "Rule should be persisted"
|
||||
assert rule.enabled?, "Rule should be enabled"
|
||||
assert_equal network_range, rule.network_range
|
||||
assert rule.expires_at.present?, "New rule should have expiration"
|
||||
assert_in_delta 7.days.from_now, rule.expires_at, 5.seconds, "Expiration should be ~7 days from now"
|
||||
end
|
||||
|
||||
# String representations
|
||||
test "to_s returns name" do
|
||||
assert_equal @policy.name, @policy.to_s
|
||||
|
||||
Reference in New Issue
Block a user