diff --git a/src/api/views.py b/src/api/views.py index 042a447e3..ab9a151d6 100644 --- a/src/api/views.py +++ b/src/api/views.py @@ -11,7 +11,7 @@ import requests from cachetools.func import ttl_cache -from registrar.models import Website +from registrar.models import Domain DOMAIN_FILE_URL = ( "https://raw.githubusercontent.com/cisagov/dotgov-data/main/current-full.csv" @@ -35,7 +35,7 @@ def _domains(): # get the domain before the first comma domain = line.split(",", 1)[0] # sanity-check the string we got from the file here - if Website.string_could_be_domain(domain): + if Domain.string_could_be_domain(domain): # lowercase everything when we put it in domains domains.add(domain.lower()) return domains @@ -68,8 +68,8 @@ def available(request, domain=""): # validate that the given domain could be a domain name and fail early if # not. if not ( - Website.string_could_be_domain(domain) - or Website.string_could_be_domain(domain + ".gov") + Domain.string_could_be_domain(domain) + or Domain.string_could_be_domain(domain + ".gov") ): raise BadRequest("Invalid request.") # a domain is available if it is NOT in the list of current domains diff --git a/src/epp/__init__.py b/src/epp/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/epp/mock_epp.py b/src/epp/mock_epp.py new file mode 100644 index 000000000..8d3121d83 --- /dev/null +++ b/src/epp/mock_epp.py @@ -0,0 +1,40 @@ +""" +This file defines a number of mock functions which can be used to simulate +communication with the registry until that integration is implemented. +""" +from datetime import datetime + +def domain_check(_): + """ Is domain available for registration? """ + return True + +def domain_info(domain): + """ What does the registry know about this domain? """ + return { + "name": domain, + "roid": "EXAMPLE1-REP", + "status": ["ok"], + "registrant": "jd1234", + "contact": { + "admin": "sh8013", + "tech": None, + }, + "ns": { + f"ns1.{domain}", + f"ns2.{domain}", + }, + "host": [ + f"ns1.{domain}", + f"ns2.{domain}", + ], + "sponsor": "ClientX", + "creator": "ClientY", + # TODO: think about timezones + "creation_date": datetime.today(), + "updator": "ClientX", + "last_update_date": datetime.today(), + "expiration_date": datetime.today(), + "last_transfer_date": datetime.today(), + } + + diff --git a/src/registrar/migrations/0002_domain_host_nameserver_hostip_and_more.py b/src/registrar/migrations/0002_domain_host_nameserver_hostip_and_more.py new file mode 100644 index 000000000..b41c5908d --- /dev/null +++ b/src/registrar/migrations/0002_domain_host_nameserver_hostip_and_more.py @@ -0,0 +1,164 @@ +# Generated by Django 4.1.3 on 2022-11-17 13:46 + +from django.conf import settings +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import django_fsm + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="Domain", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "name", + models.CharField( + default=None, + help_text="Fully qualified domain name", + max_length=253, + ), + ), + ( + "is_active", + django_fsm.FSMField( + choices=[(True, "Yes"), (False, "No")], + default=False, + help_text="Domain is live in the registry", + max_length=50, + protected=True, + ), + ), + ("owners", models.ManyToManyField(to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name="Host", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "name", + models.CharField( + default=None, + help_text="Fully qualified domain name", + max_length=253, + unique=True, + ), + ), + ( + "domain", + models.ForeignKey( + help_text="Domain to which this host belongs", + on_delete=django.db.models.deletion.PROTECT, + to="registrar.domain", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="Nameserver", + fields=[ + ( + "host_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="registrar.host", + ), + ), + ], + options={ + "abstract": False, + }, + bases=("registrar.host",), + ), + migrations.CreateModel( + name="HostIP", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "address", + models.CharField( + default=None, + help_text="IP address", + max_length=46, + validators=[django.core.validators.validate_ipv46_address], + ), + ), + ( + "host", + models.ForeignKey( + help_text="Host to which this IP address belongs", + on_delete=django.db.models.deletion.PROTECT, + to="registrar.host", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.AlterField( + model_name="domainapplication", + name="requested_domain", + field=models.OneToOneField( + blank=True, + help_text="The requested domain", + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="domain_application", + to="registrar.domain", + ), + ), + migrations.AddConstraint( + model_name="domain", + constraint=models.UniqueConstraint( + condition=models.Q(("is_active", True)), + fields=("name",), + name="unique_domain_name_in_registry", + ), + ), + ] diff --git a/src/registrar/models/__init__.py b/src/registrar/models/__init__.py index ef2f2e587..1bb9dde84 100644 --- a/src/registrar/models/__init__.py +++ b/src/registrar/models/__init__.py @@ -2,6 +2,10 @@ from auditlog.registry import auditlog # type: ignore from .contact import Contact from .domain_application import DomainApplication +from .domain import Domain +from .host_ip import HostIP +from .host import Host +from .nameserver import Nameserver from .user_profile import UserProfile from .user import User from .website import Website @@ -9,6 +13,10 @@ from .website import Website __all__ = [ "Contact", "DomainApplication", + "Domain", + "HostIP", + "Host", + "Nameserver", "UserProfile", "User", "Website", @@ -16,6 +24,10 @@ __all__ = [ auditlog.register(Contact) auditlog.register(DomainApplication) +auditlog.register(Domain) +auditlog.register(HostIP) +auditlog.register(Host) +auditlog.register(Nameserver) auditlog.register(UserProfile) auditlog.register(User) auditlog.register(Website) diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py new file mode 100644 index 000000000..ab0ecaa28 --- /dev/null +++ b/src/registrar/models/domain.py @@ -0,0 +1,221 @@ +import logging +import re + +from django.db import models +from django_fsm import FSMField, transition # type: ignore + +from epp.mock_epp import domain_info, domain_check + +from .utility.time_stamped_model import TimeStampedModel +from .domain_application import DomainApplication +from .user import User + +logger = logging.getLogger(__name__) + + +class Domain(TimeStampedModel): + """ + Manage the lifecycle of domain names. + + The registry is the source of truth for this data and this model exists: + 1. To tie ownership information in the registrar to + DNS entries in the registry; and + 2. To allow a new registrant to draft DNS entries before their + application is approved + """ + class Meta: + constraints = [ + # draft domains may share the same name, but + # once approved, they must be globally unique + models.UniqueConstraint( + fields=['name'], + condition=models.Q(is_active=True), + name='unique_domain_name_in_registry' + ), + ] + + class Status(models.TextChoices): + """The status codes we can receive from the registry.""" + # Requests to delete the object MUST be rejected. + CLIENT_DELETE_PROHIBITED = "clientDeleteProhibited" + SERVER_DELETE_PROHIBITED = "serverDeleteProhibited" + + # DNS delegation information MUST NOT be published for the object. + CLIENT_HOLD = "clientHold" + SERVER_HOLD = "serverHold" + + # Requests to renew the object MUST be rejected. + CLIENT_RENEW_PROHIBITED = "clientRenewProhibited" + SERVER_RENEW_PROHIBITED = "serverRenewProhibited" + + # Requests to transfer the object MUST be rejected. + CLIENT_TRANSFER_PROHIBITED = "clientTransferProhibited" + SERVER_TRANSFER_PROHIBITED = "serverTransferProhibited" + + # Requests to update the object (other than to remove this status) + # MUST be rejected. + CLIENT_UPDATE_PROHIBITED = "clientUpdateProhibited" + SERVER_UPDATE_PROHIBITED = "serverUpdateProhibited" + + # Delegation information has not been associated with the object. + # This is the default status when a domain object is first created + # and there are no associated host objects for the DNS delegation. + # This status can also be set by the server when all host-object + # associations are removed. + INACTIVE = "inactive" + + # This is the normal status value for an object that has no pending + # operations or prohibitions. This value is set and removed by the + # server as other status values are added or removed. + OK = "ok" + + # A transform command has been processed for the object, but the + # action has not been completed by the server. Server operators can + # delay action completion for a variety of reasons, such as to allow + # for human review or third-party action. A transform command that + # is processed, but whose requested action is pending, is noted with + # response code 1001. + PENDING_CREATE = "pendingCreate" + PENDING_DELETE = "pendingDelete" + PENDING_RENEW = "pendingRenew" + PENDING_TRANSFER = "pendingTransfer" + PENDING_UPDATE = "pendingUpdate" + + + # a domain name is alphanumeric or hyphen, up to 63 characters, doesn't + # begin or end with a hyphen, followed by a TLD of 2-6 alphabetic characters + DOMAIN_REGEX = re.compile(r"^(?!-)[A-Za-z0-9-]{1,63}(? bool: + """Return True if the string could be a domain name, otherwise False.""" + if cls.DOMAIN_REGEX.match(domain): + return True + return False + + @classmethod + def available(cls, domain: str) -> bool: + """Check if a domain is available. Not implemented. """ + return domain_check(domain) + + def transfer(self): + """ Going somewhere. Not implemented. """ + pass + + def renew(self): + """ Time to renew. Not implemented. """ + pass + + def _get_property(self, property): + """Get some info about a domain.""" + if not self.is_active: return None + if not hasattr(self, "info"): + try: + # get info from registry + self.info = domain_info(self.name) + except Exception as e: + logger.error(e) + # TODO: back off error handling + return None + if hasattr(self, "info") and (property in self.info): + return self.info[property] + else: + return None + + def could_be_domain(self) -> bool: + """Could this instance be a domain?""" + # short-circuit if self.website is null/None + if not self.name: + return False + return self.string_could_be_domain(str(self.name)) + + @transition(field="is_active", source="*", target=True) + def activate(self): + """This domain should be made live.""" + if hasattr(self, "domain_application"): + if self.domain_application.status != DomainApplication.APPROVED: + raise ValueError("Cannot activate. Application must be approved.") + if Domain.objects.filter(name=self.name, is_active=True).exists(): + raise ValueError("Cannot activate. Domain name is already in use.") + # TODO: depending on the details of our registry integration + # we will either contact the registry and deploy the domain + # in this function OR we will verify that it has already been + # activated and reject this state transition if it has not + pass + + @transition(field="is_active", source="*", target=False) + def deactivate(self): + """This domain should not be live.""" + # there are security concerns to having this function exist + # within the codebase; discuss these with the project lead + # if there is a feature request to implement this + raise Exception("Cannot revoke, contact registry.") + + def __str__(self) -> str: + return self.name + + @property + def roid(self): + return self._get_property("roid") + + @property + def status(self): + return self._get_property("status") + + @property + def registrant(self): + return self._get_property("registrant") + + @property + def sponsor(self): + return self._get_property("sponsor") + + @property + def creator(self): + return self._get_property("creator") + + @property + def creation_date(self): + return self._get_property("creation_date") + + @property + def updator(self): + return self._get_property("updator") + + @property + def last_update_date(self): + return self._get_property("last_update_date") + + @property + def expiration_date(self): + return self._get_property("expiration_date") + + @property + def last_transfer_date(self): + return self._get_property("last_transfer_date") + + name = models.CharField( + max_length=253, + blank=False, + default=None, # prevent saving without a value + help_text="Fully qualified domain name", + ) + + # we use `is_active` rather than `domain_application.status` + # because domains may exist without associated applications + is_active = FSMField( + choices=[ + (True, "Yes"), + (False, "No"), + ], + default=False, + protected=True, + help_text="Domain is live in the registry", + ) + + # TODO: determine the relationship between this field + # and the domain application's `creator` and `submitter` + owners = models.ManyToManyField( + User, + help_text="", + ) diff --git a/src/registrar/models/domain_application.py b/src/registrar/models/domain_application.py index 736735ace..f1d611489 100644 --- a/src/registrar/models/domain_application.py +++ b/src/registrar/models/domain_application.py @@ -147,12 +147,12 @@ class DomainApplication(TimeStampedModel): related_name="current+", ) - requested_domain = models.ForeignKey( - Website, + requested_domain = models.OneToOneField( + "Domain", null=True, blank=True, help_text="The requested domain", - related_name="requested+", + related_name="domain_application", on_delete=models.PROTECT, ) alternative_domains = models.ManyToManyField( diff --git a/src/registrar/models/host.py b/src/registrar/models/host.py new file mode 100644 index 000000000..0e4133a71 --- /dev/null +++ b/src/registrar/models/host.py @@ -0,0 +1,30 @@ +from django.db import models + +from .utility.time_stamped_model import TimeStampedModel +from .domain import Domain + +class Host(TimeStampedModel): + """ + Hosts are internet-connected computers. + + They may handle email, serve websites, or perform other tasks. + + The registry is the source of truth for this data. + + This model exists ONLY to allow a new registrant to draft DNS entries + before their application is approved. + """ + name = models.CharField( + max_length=253, + null=False, + blank=False, + default=None, # prevent saving without a value + unique=True, + help_text="Fully qualified domain name", + ) + + domain = models.ForeignKey( + Domain, + on_delete=models.PROTECT, + help_text="Domain to which this host belongs", + ) diff --git a/src/registrar/models/host_ip.py b/src/registrar/models/host_ip.py new file mode 100644 index 000000000..87feca14f --- /dev/null +++ b/src/registrar/models/host_ip.py @@ -0,0 +1,30 @@ +from django.db import models +from django.core.validators import validate_ipv46_address + +from .utility.time_stamped_model import TimeStampedModel +from .host import Host + +class HostIP(TimeStampedModel): + """ + Hosts may have one or more IP addresses. + + The registry is the source of truth for this data. + + This model exists ONLY to allow a new registrant to draft DNS entries + before their application is approved. + """ + address = models.CharField( + max_length=46, + null=False, + blank=False, + default=None, # prevent saving without a value + validators=[validate_ipv46_address], + help_text="IP address", + ) + + host = models.ForeignKey( + Host, + on_delete=models.PROTECT, + help_text="Host to which this IP address belongs", + ) + diff --git a/src/registrar/models/nameserver.py b/src/registrar/models/nameserver.py new file mode 100644 index 000000000..8716c0383 --- /dev/null +++ b/src/registrar/models/nameserver.py @@ -0,0 +1,12 @@ +from .host import Host + +class Nameserver(Host): + """ + A nameserver is a host which has been delegated to respond to DNS queries. + + The registry is the source of truth for this data. + + This model exists ONLY to allow a new registrant to draft DNS entries + before their application is approved. + """ + pass \ No newline at end of file diff --git a/src/registrar/models/website.py b/src/registrar/models/website.py index 83c4b8222..a0db7a2a2 100644 --- a/src/registrar/models/website.py +++ b/src/registrar/models/website.py @@ -1,5 +1,3 @@ -import re - from django.db import models @@ -16,26 +14,5 @@ class Website(models.Model): help_text="", ) - # a domain name is alphanumeric or hyphen, up to 63 characters, doesn't - # begin or end with a hyphen, followed by a TLD of 2-6 alphabetic characters - DOMAIN_REGEX = re.compile(r"^(?!-)[A-Za-z0-9-]{1,63}(? bool: - """Return True if the string could be a domain name, otherwise False. - - TODO: when we have a Domain class, this could be a classmethod there. - """ - if cls.DOMAIN_REGEX.match(domain): - return True - return False - - def could_be_domain(self) -> bool: - """Could this instance be a domain?""" - # short-circuit if self.website is null/None - if not self.website: - return False - return self.string_could_be_domain(str(self.website)) - def __str__(self) -> str: return str(self.website) diff --git a/src/registrar/tests/test_models.py b/src/registrar/tests/test_models.py index 761f25a22..a77a4a935 100644 --- a/src/registrar/tests/test_models.py +++ b/src/registrar/tests/test_models.py @@ -1,7 +1,7 @@ from django.test import TestCase from django.db.utils import IntegrityError -from registrar.models import Contact, DomainApplication, User, Website +from registrar.models import Contact, DomainApplication, User, Website, Domain class TestDomainApplication(TestCase): @@ -22,6 +22,7 @@ class TestDomainApplication(TestCase): contact = Contact.objects.create() com_website, _ = Website.objects.get_or_create(website="igorville.com") gov_website, _ = Website.objects.get_or_create(website="igorville.gov") + domain, _ = Domain.objects.get_or_create(name="igorville.gov") application = DomainApplication.objects.create( creator=user, investigator=user, @@ -35,7 +36,7 @@ class TestDomainApplication(TestCase): state_territory="CA", zip_code="12345-6789", authorizing_official=contact, - requested_domain=gov_website, + requested_domain=domain, submitter=contact, purpose="Igorville rules!", security_email="security@igorville.gov", @@ -56,9 +57,50 @@ class TestDomainApplication(TestCase): def test_status_fsm_submit_succeed(self): user, _ = User.objects.get_or_create() - site = Website.objects.create(website="igorville.gov") + site = Domain.objects.create(name="igorville.gov") application = DomainApplication.objects.create( creator=user, requested_domain=site ) application.submit() self.assertEqual(application.status, application.SUBMITTED) + + +class TestDomain(TestCase): + def test_empty_create_fails(self): + """Can't create a completely empty domain.""" + with self.assertRaisesRegex(IntegrityError, "name"): + Domain.objects.create() + + def test_minimal_create(self): + """Can create with just a name.""" + domain = Domain.objects.create(name="igorville.gov") + self.assertEquals(domain.is_active, False) + + def test_get_status(self): + """Returns proper status based on `is_active`.""" + domain = Domain.objects.create(name="igorville.gov") + domain.save() + self.assertEquals(None, domain.status) + domain.activate() + domain.save() + self.assertIn("ok", domain.status) + + + def test_fsm_activate_fail_unique(self): + # can't activate domain if name is not unique + d1, _ = Domain.objects.get_or_create(name="igorville.gov") + d2, _ = Domain.objects.get_or_create(name="igorville.gov") + d1.activate() + d1.save() + with self.assertRaises(ValueError): + d2.activate() + + def test_fsm_activate_fail_unapproved(self): + # can't activate domain if application isn't approved + d1, _ = Domain.objects.get_or_create(name="igorville.gov") + user, _ = User.objects.get_or_create() + application = DomainApplication.objects.create(creator=user) + d1.domain_application = application + d1.save() + with self.assertRaises(ValueError): + d1.activate()