feat: add admin contact ident type validation

- Add new setting for allowed admin contact ident types
- Add validation for admin contact ident types on domain create/update
- Add UI controls for managing allowed ident types
- Add tests for new validation rules
- Update domain model to respect new settings

The changes allow configuring which identification types (private person,
organization, birthday) are allowed for administrative contacts. This is
enforced when creating new domains or adding new admin contacts.
This commit is contained in:
oleghasjanov 2025-02-03 13:59:03 +02:00
parent b641235bbd
commit f2978599b4
12 changed files with 346 additions and 11 deletions

View file

@ -27,12 +27,25 @@ module Admin
def casted_settings
settings = {}
params[:settings].each do |k, v|
settings[k] = { value: v }
setting = SettingEntry.find(k)
value = if setting.format == 'array'
processed_hash = available_options.each_with_object({}) do |option, hash|
hash[option] = (v[option] == "true")
end
processed_hash.to_json
else
v
end
settings[k] = { value: value }
end
settings
end
def available_options
%w[birthday priv org]
end
end
end

View file

@ -186,6 +186,8 @@ class Domain < ApplicationRecord
object_count: admin_contacts_validation_rules(for_org: false),
unless: :require_admin_contacts?
validate :validate_admin_contacts_ident_type, on: :create
validates :tech_domain_contacts,
object_count: tech_contacts_validation_rules(for_org: true),
if: :require_tech_contacts?
@ -857,10 +859,10 @@ class Domain < ApplicationRecord
end
def require_admin_contacts?
return true if registrant.org?
return true if registrant.org? && Setting.admin_contacts_required_for_org
return false unless registrant.priv?
underage_registrant?
underage_registrant? && Setting.admin_contacts_required_for_minors
end
def require_tech_contacts?
@ -916,4 +918,18 @@ class Domain < ApplicationRecord
Date.parse("#{birth_year}-#{month}-#{day}")
end
def validate_admin_contacts_ident_type
allowed_types = Setting.admin_contacts_allowed_ident_type
return if allowed_types.blank?
admin_contacts.each do |contact|
next if allowed_types[contact.ident_type] == true
errors.add(:admin_contacts, I18n.t(
'activerecord.errors.models.domain.admin_contact_invalid_ident_type',
ident_type: contact.ident_type
))
end
end
end

View file

@ -36,6 +36,23 @@ class Epp::Domain < Domain
ok = false
end
end
# Validate admin contacts ident type only for new domains or new admin contacts
allowed_types = Setting.admin_contacts_allowed_ident_type
if allowed_types.present?
active_admins.each do |admin_contact|
next if !new_record? && admin_contact.persisted? && !admin_contact.changed?
contact = admin_contact.contact
unless allowed_types[contact.ident_type] == true
add_epp_error('2306', 'contact', contact.code,
I18n.t('activerecord.errors.models.domain.admin_contact_invalid_ident_type',
ident_type: contact.ident_type))
ok = false
end
end
end
ok
end
@ -95,7 +112,8 @@ class Epp::Domain < Domain
[:base, :key_data_not_allowed],
[:period, :not_a_number],
[:period, :not_an_integer],
[:registrant, :cannot_be_missing]
[:registrant, :cannot_be_missing],
[:admin_contacts, :invalid_ident_type]
],
'2308' => [
[:base, :domain_name_blocked, { value: { obj: 'name', val: name_dirty } }],
@ -414,15 +432,15 @@ class Epp::Domain < Domain
end
def require_admin_contacts?
return true if registrant.org?
return true if registrant.org? && Setting.admin_contacts_required_for_org
return false unless registrant.priv?
underage_registrant?
underage_registrant? && Setting.admin_contacts_required_for_minors
end
def tech_contacts_validation_rules(for_org:)
{
min: 0, # Технический контакт опционален для всех
min: 0,
max: -> { Setting.tech_contacts_max_count }
}
end

View file

@ -87,6 +87,17 @@ class SettingEntry < ApplicationRecord
end
def array_format
JSON.parse(value).to_a
begin
if value.is_a?(String)
JSON.parse(value)
elsif value.is_a?(Hash)
value
else
{ 'birthday' => true, 'priv' => true, 'org' => true }
end
rescue JSON::ParserError => e
puts "JSON Parse error: #{e.message}"
{ 'birthday' => true, 'priv' => true, 'org' => true }
end
end
end

View file

@ -1,6 +1,32 @@
%tr{class: (@errors && @errors.has_key?(setting.code) && "danger")}
%td.col-md-6= setting.code.humanize
- if [TrueClass, FalseClass].include?(setting.retrieve.class)
- if setting.format == 'array'
%td.col-md-6
- available_options = ['birthday', 'priv', 'org']
- begin
- raw_value = setting.retrieve
- current_values = if raw_value.is_a?(Hash)
- raw_value
- elsif raw_value.is_a?(Array) && raw_value.first.is_a?(Array)
- Hash[raw_value]
- elsif raw_value.is_a?(Array)
- available_options.each_with_object({}) { |opt, hash| hash[opt] = raw_value.include?(opt) }
- else
- begin
- parsed = JSON.parse(raw_value.to_s)
- parsed.is_a?(Hash) ? parsed : available_options.each_with_object({}) { |opt, hash| hash[opt] = true }
- rescue => e
- available_options.each_with_object({}) { |opt, hash| hash[opt] = true }
.row
- available_options.each do |option|
.col-md-4
.checkbox
%label
= check_box_tag "settings[#{setting.id}][#{option}]", "true", current_values[option],
id: "setting_#{setting.id}_#{option}",
data: { value: current_values[option] }
= option.humanize
- elsif [TrueClass, FalseClass].include?(setting.retrieve.class)
%td.col-md-6
= hidden_field_tag("[settings][#{setting.id}]", '', id: nil)
= check_box_tag("[settings][#{setting.id}]", true, setting.retrieve)

View file

@ -81,6 +81,7 @@ en:
domain:
<<: *epp_domain_ar_attributes
admin_contact_invalid_ident_type: "Administrative contact with identification type '%{ident_type}' is not allowed"
nameserver:
attributes:

View file

@ -7,6 +7,19 @@ ActiveRecord::Base.transaction do
SettingEntry.create(code: 'directo_sales_agent', value: 'HELEN', format: 'string', group: 'billing')
SettingEntry.create(code: 'admin_contacts_min_count', value: '1', format: 'integer', group: 'domain_validation')
SettingEntry.create(code: 'admin_contacts_max_count', value: '10', format: 'integer', group: 'domain_validation')
SettingEntry.create(code: 'admin_contacts_required_for_org', value: 'true', format: 'boolean', group: 'domain_validation')
SettingEntry.create(code: 'admin_contacts_required_for_minors', value: 'true', format: 'boolean', group: 'domain_validation')
SettingEntry.create(
code: 'admin_contacts_allowed_ident_type',
value: {
'birthday' => true,
'priv' => true,
'org' => true
}.to_json,
format: 'array',
group: 'domain_validation'
)
SettingEntry.create(code: 'tech_contacts_min_count', value: '1', format: 'integer', group: 'domain_validation')
SettingEntry.create(code: 'tech_contacts_max_count', value: '10', format: 'integer', group: 'domain_validation')
SettingEntry.create(code: 'orphans_contacts_in_months', value: '6', format: 'integer', group: 'domain_validation')

View file

@ -469,3 +469,27 @@ ip_whitelist_max_count:
format: integer
created_at: <%= Time.zone.parse('2010-07-05') %>
updated_at: <%= Time.zone.parse('2010-07-05') %>
admin_contacts_required_for_org:
code: admin_contacts_required_for_org
value: 'true'
group: domain_validation
format: boolean
created_at: <%= Time.zone.parse('2010-07-05') %>
updated_at: <%= Time.zone.parse('2010-07-05') %>
admin_contacts_required_for_minors:
code: admin_contacts_required_for_minors
value: 'true'
group: domain_validation
format: boolean
created_at: <%= Time.zone.parse('2010-07-05') %>
updated_at: <%= Time.zone.parse('2010-07-05') %>
admin_contacts_allowed_ident_type:
code: admin_contacts_allowed_ident_type
value: '{"birthday":true,"priv":true,"org":false}'
group: domain_validation
format: array
created_at: <%= Time.zone.parse('2010-07-05') %>
updated_at: <%= Time.zone.parse('2010-07-05') %>

View file

@ -597,6 +597,8 @@ class EppDomainCreateBaseTest < EppTestCase
end
def test_registers_new_domain_with_required_attributes
Setting.admin_contacts_allowed_ident_type = { 'org' => true, 'priv' => true, 'birthday' => true }
now = Time.zone.parse('2010-07-05')
travel_to now
name = "new.#{dns_zones(:one).origin}"
@ -644,6 +646,8 @@ class EppDomainCreateBaseTest < EppTestCase
default_registration_period = 1.year + 1.day
assert_equal now + default_registration_period, domain.expire_time
Setting.admin_contacts_allowed_ident_type = { 'org' => false, 'priv' => true, 'birthday' => true }
end
def test_registers_domain_without_legaldoc_if_optout
@ -1108,4 +1112,78 @@ class EppDomainCreateBaseTest < EppTestCase
ENV["shunter_default_threshold"] = '10000'
ENV["shunter_enabled"] = 'false'
end
def test_does_not_register_domain_with_invalid_admin_contact_ident_type
name = "new.#{dns_zones(:one).origin}"
contact = contacts(:john)
registrant = contact.becomes(Registrant)
admin_contact = contacts(:william)
admin_contact.update!(ident_type: 'org')
request_xml = <<-XML
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<epp xmlns="#{Xsd::Schema.filename(for_prefix: 'epp-ee', for_version: '1.0')}">
<command>
<create>
<domain:create xmlns:domain="#{Xsd::Schema.filename(for_prefix: 'domain-ee', for_version: '1.2')}">
<domain:name>#{name}</domain:name>
<domain:registrant>#{registrant.code}</domain:registrant>
<domain:contact type="admin">#{admin_contact.code}</domain:contact>
</domain:create>
</create>
<extension>
<eis:extdata xmlns:eis="#{Xsd::Schema.filename(for_prefix: 'eis', for_version: '1.0')}">
<eis:legalDocument type="pdf">#{'test' * 2000}</eis:legalDocument>
</eis:extdata>
</extension>
</command>
</epp>
XML
assert_no_difference 'Domain.count' do
post epp_create_path, params: { frame: request_xml },
headers: { 'HTTP_COOKIE' => 'session=api_bestnames' }
end
response_xml = Nokogiri::XML(response.body)
assert_correct_against_schema response_xml
assert_epp_response :parameter_value_policy_error
end
def test_registers_domain_with_valid_admin_contact_ident_type
name = "new.#{dns_zones(:one).origin}"
contact = contacts(:john)
registrant = contact.becomes(Registrant)
admin_contact = contacts(:william)
admin_contact.update!(ident_type: 'priv')
request_xml = <<-XML
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<epp xmlns="#{Xsd::Schema.filename(for_prefix: 'epp-ee', for_version: '1.0')}">
<command>
<create>
<domain:create xmlns:domain="#{Xsd::Schema.filename(for_prefix: 'domain-ee', for_version: '1.2')}">
<domain:name>#{name}</domain:name>
<domain:registrant>#{registrant.code}</domain:registrant>
<domain:contact type="admin">#{admin_contact.code}</domain:contact>
</domain:create>
</create>
<extension>
<eis:extdata xmlns:eis="#{Xsd::Schema.filename(for_prefix: 'eis', for_version: '1.0')}">
<eis:legalDocument type="pdf">#{'test' * 2000}</eis:legalDocument>
</eis:extdata>
</extension>
</command>
</epp>
XML
assert_difference 'Domain.count' do
post epp_create_path, params: { frame: request_xml },
headers: { 'HTTP_COOKIE' => 'session=api_bestnames' }
end
response_xml = Nokogiri::XML(response.body)
assert_correct_against_schema response_xml
assert_epp_response :completed_successfully
end
end

View file

@ -969,6 +969,98 @@ class EppDomainUpdateBaseTest < EppTestCase
ENV["shunter_enabled"] = 'false'
end
def test_does_not_update_domain_with_invalid_admin_contact_ident_type
admin_contact = contacts(:william)
admin_contact.update!(ident_type: 'org')
request_xml = <<-XML
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<epp xmlns="#{Xsd::Schema.filename(for_prefix: 'epp-ee', for_version: '1.0')}">
<command>
<update>
<domain:update xmlns:domain="#{Xsd::Schema.filename(for_prefix: 'domain-ee', for_version: '1.2')}">
<domain:name>shop.test</domain:name>
<domain:add>
<domain:contact type="admin">#{admin_contact.code}</domain:contact>
</domain:add>
</domain:update>
</update>
</command>
</epp>
XML
post epp_update_path, params: { frame: request_xml },
headers: { 'HTTP_COOKIE' => 'session=api_bestnames' }
response_xml = Nokogiri::XML(response.body)
assert_correct_against_schema response_xml
assert_epp_response :parameter_value_policy_error
end
def test_updates_domain_with_valid_admin_contact_ident_type
admin_contact = contacts(:william)
admin_contact.update!(ident_type: 'priv')
request_xml = <<-XML
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<epp xmlns="#{Xsd::Schema.filename(for_prefix: 'epp-ee', for_version: '1.0')}">
<command>
<update>
<domain:update xmlns:domain="#{Xsd::Schema.filename(for_prefix: 'domain-ee', for_version: '1.2')}">
<domain:name>shop.test</domain:name>
<domain:add>
<domain:contact type="admin">#{admin_contact.code}</domain:contact>
</domain:add>
</domain:update>
</update>
</command>
</epp>
XML
post epp_update_path, params: { frame: request_xml },
headers: { 'HTTP_COOKIE' => 'session=api_bestnames' }
response_xml = Nokogiri::XML(response.body)
assert_correct_against_schema response_xml
assert_epp_response :completed_successfully
end
def test_skips_admin_contact_ident_type_validation_for_existing_contacts
admin_contact = contacts(:william)
admin_contact.update!(ident_type: 'org')
@domain.admin_contacts << admin_contact
@domain.save!
# Change allowed types after domain is created
Setting.admin_contacts_allowed_ident_type = { 'birthday' => true, 'priv' => true, 'org' => false }
# Try to update domain with some other changes
request_xml = <<-XML
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<epp xmlns="#{Xsd::Schema.filename(for_prefix: 'epp-ee', for_version: '1.0')}">
<command>
<update>
<domain:update xmlns:domain="#{Xsd::Schema.filename(for_prefix: 'domain-ee', for_version: '1.2')}">
<domain:name>shop.test</domain:name>
<domain:chg>
<domain:authInfo>
<domain:pw>new_pw</domain:pw>
</domain:authInfo>
</domain:chg>
</domain:update>
</update>
</command>
</epp>
XML
post epp_update_path, params: { frame: request_xml },
headers: { 'HTTP_COOKIE' => 'session=api_bestnames' }
response_xml = Nokogiri::XML(response.body)
assert_correct_against_schema response_xml
assert_epp_response :completed_successfully
end
private
def assert_verification_and_notification_emails

View file

@ -14,6 +14,7 @@ class DomainVersionTest < ActiveSupport::TestCase
end
def test_assigns_creator_to_paper_trail_whodunnit
Setting.admin_contacts_allowed_ident_type = { 'org' => true, 'priv' => true, 'birthday' => true }
duplicate_domain = prepare_duplicate_domain
PaperTrail.request.whodunnit = @user.id_role_username

View file

@ -9,6 +9,9 @@ class DomainTest < ActiveSupport::TestCase
@original_max_admin_contact_count = Setting.admin_contacts_max_count
@original_min_tech_contact_count = Setting.tech_contacts_min_count
@original_max_tech_contact_count = Setting.tech_contacts_max_count
@original_admin_contacts_required_for_org = Setting.admin_contacts_required_for_org
@original_admin_contacts_required_for_minors = Setting.admin_contacts_required_for_minors
@original_admin_contacts_allowed_ident_type = Setting.admin_contacts_allowed_ident_type
end
teardown do
@ -17,6 +20,9 @@ class DomainTest < ActiveSupport::TestCase
Setting.admin_contacts_max_count = @original_max_admin_contact_count
Setting.tech_contacts_min_count = @original_min_tech_contact_count
Setting.tech_contacts_max_count = @original_max_tech_contact_count
Setting.admin_contacts_required_for_org = @original_admin_contacts_required_for_org
Setting.admin_contacts_required_for_minors = @original_admin_contacts_required_for_minors
Setting.admin_contacts_allowed_ident_type = @original_admin_contacts_allowed_ident_type
end
def test_valid_domain_is_valid
@ -615,6 +621,42 @@ class DomainTest < ActiveSupport::TestCase
assert domain.valid?
end
def test_validates_admin_contact_required_for_org_based_on_setting
domain = valid_domain
domain.registrant.update!(ident_type: 'org')
domain.reload
# When setting is true
Setting.admin_contacts_required_for_org = true
domain.admin_domain_contacts.clear
assert domain.invalid?
assert_includes domain.errors.full_messages,
'Admin domain contacts Admin contacts count must be between 1-10'
# When setting is false
Setting.admin_contacts_required_for_org = false
domain.admin_domain_contacts.clear
assert domain.valid?
end
def test_validates_admin_contact_required_for_minors_based_on_setting
domain = valid_domain
domain.registrant.update!(ident_type: 'birthday', ident: '2010-07-05')
domain.reload
# When setting is true
Setting.admin_contacts_required_for_minors = true
domain.admin_domain_contacts.clear
assert domain.invalid?
assert_includes domain.errors.full_messages,
'Admin domain contacts Admin contacts count must be between 1-10'
# When setting is false
Setting.admin_contacts_required_for_minors = false
domain.admin_domain_contacts.clear
assert domain.valid?
end
private
def valid_domain