Second revision of Domain model draft

This commit is contained in:
Seamus Johnston 2022-11-17 07:50:28 -06:00
parent 523c699865
commit 2b91a3c1d1
No known key found for this signature in database
GPG key ID: 2F21225985069105
12 changed files with 561 additions and 33 deletions

View file

@ -11,7 +11,7 @@ import requests
from cachetools.func import ttl_cache from cachetools.func import ttl_cache
from registrar.models import Website from registrar.models import Domain
DOMAIN_FILE_URL = ( DOMAIN_FILE_URL = (
"https://raw.githubusercontent.com/cisagov/dotgov-data/main/current-full.csv" "https://raw.githubusercontent.com/cisagov/dotgov-data/main/current-full.csv"
@ -35,7 +35,7 @@ def _domains():
# get the domain before the first comma # get the domain before the first comma
domain = line.split(",", 1)[0] domain = line.split(",", 1)[0]
# sanity-check the string we got from the file here # 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 # lowercase everything when we put it in domains
domains.add(domain.lower()) domains.add(domain.lower())
return domains return domains
@ -68,8 +68,8 @@ def available(request, domain=""):
# validate that the given domain could be a domain name and fail early if # validate that the given domain could be a domain name and fail early if
# not. # not.
if not ( if not (
Website.string_could_be_domain(domain) Domain.string_could_be_domain(domain)
or Website.string_could_be_domain(domain + ".gov") or Domain.string_could_be_domain(domain + ".gov")
): ):
raise BadRequest("Invalid request.") raise BadRequest("Invalid request.")
# a domain is available if it is NOT in the list of current domains # a domain is available if it is NOT in the list of current domains

0
src/epp/__init__.py Normal file
View file

40
src/epp/mock_epp.py Normal file
View file

@ -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(),
}

View file

@ -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",
),
),
]

View file

@ -2,6 +2,10 @@ from auditlog.registry import auditlog # type: ignore
from .contact import Contact from .contact import Contact
from .domain_application import DomainApplication 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_profile import UserProfile
from .user import User from .user import User
from .website import Website from .website import Website
@ -9,6 +13,10 @@ from .website import Website
__all__ = [ __all__ = [
"Contact", "Contact",
"DomainApplication", "DomainApplication",
"Domain",
"HostIP",
"Host",
"Nameserver",
"UserProfile", "UserProfile",
"User", "User",
"Website", "Website",
@ -16,6 +24,10 @@ __all__ = [
auditlog.register(Contact) auditlog.register(Contact)
auditlog.register(DomainApplication) auditlog.register(DomainApplication)
auditlog.register(Domain)
auditlog.register(HostIP)
auditlog.register(Host)
auditlog.register(Nameserver)
auditlog.register(UserProfile) auditlog.register(UserProfile)
auditlog.register(User) auditlog.register(User)
auditlog.register(Website) auditlog.register(Website)

View file

@ -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}(?<!-)\.[A-Za-z]{2,6}")
@classmethod
def string_could_be_domain(cls, domain: str) -> 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="",
)

View file

@ -147,12 +147,12 @@ class DomainApplication(TimeStampedModel):
related_name="current+", related_name="current+",
) )
requested_domain = models.ForeignKey( requested_domain = models.OneToOneField(
Website, "Domain",
null=True, null=True,
blank=True, blank=True,
help_text="The requested domain", help_text="The requested domain",
related_name="requested+", related_name="domain_application",
on_delete=models.PROTECT, on_delete=models.PROTECT,
) )
alternative_domains = models.ManyToManyField( alternative_domains = models.ManyToManyField(

View file

@ -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",
)

View file

@ -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",
)

View file

@ -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

View file

@ -1,5 +1,3 @@
import re
from django.db import models from django.db import models
@ -16,26 +14,5 @@ class Website(models.Model):
help_text="", 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}(?<!-)\.[A-Za-z]{2,6}")
@classmethod
def string_could_be_domain(cls, domain: str) -> 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: def __str__(self) -> str:
return str(self.website) return str(self.website)

View file

@ -1,7 +1,7 @@
from django.test import TestCase from django.test import TestCase
from django.db.utils import IntegrityError 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): class TestDomainApplication(TestCase):
@ -22,6 +22,7 @@ class TestDomainApplication(TestCase):
contact = Contact.objects.create() contact = Contact.objects.create()
com_website, _ = Website.objects.get_or_create(website="igorville.com") com_website, _ = Website.objects.get_or_create(website="igorville.com")
gov_website, _ = Website.objects.get_or_create(website="igorville.gov") gov_website, _ = Website.objects.get_or_create(website="igorville.gov")
domain, _ = Domain.objects.get_or_create(name="igorville.gov")
application = DomainApplication.objects.create( application = DomainApplication.objects.create(
creator=user, creator=user,
investigator=user, investigator=user,
@ -35,7 +36,7 @@ class TestDomainApplication(TestCase):
state_territory="CA", state_territory="CA",
zip_code="12345-6789", zip_code="12345-6789",
authorizing_official=contact, authorizing_official=contact,
requested_domain=gov_website, requested_domain=domain,
submitter=contact, submitter=contact,
purpose="Igorville rules!", purpose="Igorville rules!",
security_email="security@igorville.gov", security_email="security@igorville.gov",
@ -56,9 +57,50 @@ class TestDomainApplication(TestCase):
def test_status_fsm_submit_succeed(self): def test_status_fsm_submit_succeed(self):
user, _ = User.objects.get_or_create() user, _ = User.objects.get_or_create()
site = Website.objects.create(website="igorville.gov") site = Domain.objects.create(name="igorville.gov")
application = DomainApplication.objects.create( application = DomainApplication.objects.create(
creator=user, requested_domain=site creator=user, requested_domain=site
) )
application.submit() application.submit()
self.assertEqual(application.status, application.SUBMITTED) 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()