Merge branch 'main' into subissue-template

This commit is contained in:
Cameron Dixon 2024-12-09 11:24:00 -05:00 committed by GitHub
commit 658a72da3d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 321 additions and 9 deletions

View file

@ -378,3 +378,18 @@ Then, copy the variables under the section labled `s3`.
## Request Flow FSM Diagram ## Request Flow FSM Diagram
The [.gov Domain Request & Domain Status Digram](https://miro.com/app/board/uXjVMuqbLOk=/?moveToWidget=3458764594819017396&cot=14) visualizes the domain request flow and resulting domain objects. The [.gov Domain Request & Domain Status Digram](https://miro.com/app/board/uXjVMuqbLOk=/?moveToWidget=3458764594819017396&cot=14) visualizes the domain request flow and resulting domain objects.
## Testing the prototype add DNS record feature (delete this after we are done testing!)
We are currently testing using cloudflare to add DNS records. Specifically, an A record. To use this, you will need to enable the
`prototype_dns_flag` waffle flag and navigate to `igorville.gov`, `dns.gov`, or `domainops.gov`. Click manage, then click DNS. From there, click the `Prototype DNS record creator` button.
Before we can send data to cloudflare, you will need these values in your .env file:
```
REGISTRY_TENANT_KEY = {tenant key}
REGISTRY_SERVICE_EMAIL = {An email address}
REGISTRY_TENANT_NAME = {Name of the bucket, i.e. "CISA" }
```
You can obtain these by following the steps outlined in the [dns hosting discovery doc](https://docs.google.com/document/d/1Yq5d2M3MgM2vPhUBZ0k5wOmCQst4vND9-2qEZ55-h-Y/edit?tab=t.0), BUT it is far easier to just get these from someone else. Reach out to Zander for this information if you do not have it.
Alternatively, if you are testing on a sandbox, you will need to add those to getgov-credentials.

View file

@ -59,6 +59,9 @@ services:
- AWS_S3_BUCKET_NAME - AWS_S3_BUCKET_NAME
# File encryption credentials # File encryption credentials
- SECRET_ENCRYPT_METADATA - SECRET_ENCRYPT_METADATA
- REGISTRY_TENANT_KEY
- REGISTRY_SERVICE_EMAIL
- REGISTRY_TENANT_NAME
stdin_open: true stdin_open: true
tty: true tty: true
ports: ports:

View file

@ -3785,6 +3785,14 @@ class WaffleFlagAdmin(FlagAdmin):
model = models.WaffleFlag model = models.WaffleFlag
fields = "__all__" fields = "__all__"
# Hack to get the dns_prototype_flag to auto populate when you navigate to
# the waffle flag page.
def changelist_view(self, request, extra_context=None):
if extra_context is None:
extra_context = {}
extra_context["dns_prototype_flag"] = flag_is_active_for_user(request.user, "dns_prototype_flag")
return super().changelist_view(request, extra_context=extra_context)
class DomainGroupAdmin(ListHeaderAdmin, ImportExportModelAdmin): class DomainGroupAdmin(ListHeaderAdmin, ImportExportModelAdmin):
list_display = ["name", "portfolio"] list_display = ["name", "portfolio"]

View file

@ -86,6 +86,11 @@ secret_registry_key = b64decode(secret("REGISTRY_KEY", ""))
secret_registry_key_passphrase = secret("REGISTRY_KEY_PASSPHRASE", "") secret_registry_key_passphrase = secret("REGISTRY_KEY_PASSPHRASE", "")
secret_registry_hostname = secret("REGISTRY_HOSTNAME") secret_registry_hostname = secret("REGISTRY_HOSTNAME")
# PROTOTYPE: Used for DNS hosting
secret_registry_tenant_key = secret("REGISTRY_TENANT_KEY", None)
secret_registry_tenant_name = secret("REGISTRY_TENANT_NAME", None)
secret_registry_service_email = secret("REGISTRY_SERVICE_EMAIL", None)
# region: Basic Django Config-----------------------------------------------### # region: Basic Django Config-----------------------------------------------###
# Build paths inside the project like this: BASE_DIR / "subdir". # Build paths inside the project like this: BASE_DIR / "subdir".
@ -685,6 +690,9 @@ SECRET_REGISTRY_CERT = secret_registry_cert
SECRET_REGISTRY_KEY = secret_registry_key SECRET_REGISTRY_KEY = secret_registry_key
SECRET_REGISTRY_KEY_PASSPHRASE = secret_registry_key_passphrase SECRET_REGISTRY_KEY_PASSPHRASE = secret_registry_key_passphrase
SECRET_REGISTRY_HOSTNAME = secret_registry_hostname SECRET_REGISTRY_HOSTNAME = secret_registry_hostname
SECRET_REGISTRY_TENANT_KEY = secret_registry_tenant_key
SECRET_REGISTRY_TENANT_NAME = secret_registry_tenant_name
SECRET_REGISTRY_SERVICE_EMAIL = secret_registry_service_email
# endregion # endregion
# region: Security and Privacy----------------------------------------------### # region: Security and Privacy----------------------------------------------###

View file

@ -293,6 +293,7 @@ urlpatterns = [
name="todo", name="todo",
), ),
path("domain/<int:pk>", views.DomainView.as_view(), name="domain"), path("domain/<int:pk>", views.DomainView.as_view(), name="domain"),
path("domain/<int:pk>/prototype-dns", views.PrototypeDomainDNSRecordView.as_view(), name="prototype-domain-dns"),
path("domain/<int:pk>/users", views.DomainUsersView.as_view(), name="domain-users"), path("domain/<int:pk>/users", views.DomainUsersView.as_view(), name="domain-users"),
path( path(
"domain/<int:pk>/dns", "domain/<int:pk>/dns",

View file

@ -0,0 +1,28 @@
# Generated by Django 4.2.10 on 2024-11-27 21:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("registrar", "0138_alter_domaininvitation_status"),
]
operations = [
migrations.AlterField(
model_name="domainrequest",
name="action_needed_reason",
field=models.TextField(
blank=True,
choices=[
("eligibility_unclear", "Unclear organization eligibility"),
("questionable_senior_official", "Questionable senior official"),
("already_has_a_domain", "Already has a domain"),
("bad_name", "Doesnt meet naming requirements"),
("other", "Other (no auto-email sent)"),
],
null=True,
),
),
]

View file

@ -4,7 +4,6 @@ import ipaddress
import re import re
from datetime import date from datetime import date
from typing import Optional from typing import Optional
from django_fsm import FSMField, transition, TransitionNotAllowed # type: ignore from django_fsm import FSMField, transition, TransitionNotAllowed # type: ignore
from django.db import models from django.db import models

View file

@ -280,7 +280,7 @@ class DomainRequest(TimeStampedModel):
ELIGIBILITY_UNCLEAR = ("eligibility_unclear", "Unclear organization eligibility") ELIGIBILITY_UNCLEAR = ("eligibility_unclear", "Unclear organization eligibility")
QUESTIONABLE_SENIOR_OFFICIAL = ("questionable_senior_official", "Questionable senior official") QUESTIONABLE_SENIOR_OFFICIAL = ("questionable_senior_official", "Questionable senior official")
ALREADY_HAS_DOMAINS = ("already_has_domains", "Already has domains") ALREADY_HAS_A_DOMAIN = ("already_has_a_domain", "Already has a domain")
BAD_NAME = ("bad_name", "Doesnt meet naming requirements") BAD_NAME = ("bad_name", "Doesnt meet naming requirements")
OTHER = ("other", "Other (no auto-email sent)") OTHER = ("other", "Other (no auto-email sent)")

View file

@ -28,6 +28,7 @@
<p>The Domain Name System (DNS) is the internet service that translates your domain name into an IP address. Before your .gov domain can be used, you'll need to connect it to a DNS hosting service and provide us with your name server information.</p> <p>The Domain Name System (DNS) is the internet service that translates your domain name into an IP address. Before your .gov domain can be used, you'll need to connect it to a DNS hosting service and provide us with your name server information.</p>
<p>You can enter your name servers, as well as other DNS-related information, in the following sections:</p> <p>You can enter your name servers, as well as other DNS-related information, in the following sections:</p>
{% url 'domain-dns-nameservers' pk=domain.id as url %} {% url 'domain-dns-nameservers' pk=domain.id as url %}
<ul class="usa-list"> <ul class="usa-list">
@ -35,6 +36,9 @@
{% url 'domain-dns-dnssec' pk=domain.id as url %} {% url 'domain-dns-dnssec' pk=domain.id as url %}
<li><a href="{{ url }}">DNSSEC</a></li> <li><a href="{{ url }}">DNSSEC</a></li>
{% if dns_prototype_flag and is_valid_domain %}
<li><a href="{% url 'prototype-domain-dns' pk=domain.id %}">Prototype DNS record creator</a></li>
{% endif %}
</ul> </ul>
{% endblock %} {# domain_content #} {% endblock %} {# domain_content #}

View file

@ -0,0 +1,34 @@
{% extends "domain_base.html" %}
{% load static field_helpers url_helpers %}
{% block title %}Prototype DNS | {{ domain.name }} | {% endblock %}
{% block domain_content %}
{% include "includes/form_errors.html" with form=form %}
<h1>Add DNS records</h1>
<p>
This is a prototype that demonstrates adding an 'A' record to a zone.
Do note that this just adds records, but does not update or delete existing ones.
</p>
<p>
You can only use this functionality on a limited set of domains:
<strong>
igorville.gov, dns.gov (non-prod), and domainops.gov (non-prod).
</strong>
</p>
<form class="usa-form usa-form--large" method="post" novalidate id="form-container">
{% csrf_token %}
{% input_with_errors form.name %}
{% input_with_errors form.content %}
{% input_with_errors form.ttl %}
<button
type="submit"
class="usa-button"
>
Add record
</button>
</form>
{% endblock %} {# domain_content #}

View file

@ -203,7 +203,7 @@ class TestDomainRequestAdmin(MockEppLib):
domain_request.save() domain_request.save()
domain_request.action_needed() domain_request.action_needed()
domain_request.action_needed_reason = DomainRequest.ActionNeededReasons.ALREADY_HAS_DOMAINS domain_request.action_needed_reason = DomainRequest.ActionNeededReasons.ALREADY_HAS_A_DOMAIN
domain_request.save() domain_request.save()
# Let's just change the action needed reason # Let's just change the action needed reason
@ -230,7 +230,7 @@ class TestDomainRequestAdmin(MockEppLib):
"In review", "In review",
"Rejected - Purpose requirements not met", "Rejected - Purpose requirements not met",
"Action needed - Unclear organization eligibility", "Action needed - Unclear organization eligibility",
"Action needed - Already has domains", "Action needed - Already has a domain",
"In review", "In review",
"Submitted", "Submitted",
"Started", "Started",
@ -241,7 +241,7 @@ class TestDomainRequestAdmin(MockEppLib):
assert_status_count(normalized_content, "Started", 1) assert_status_count(normalized_content, "Started", 1)
assert_status_count(normalized_content, "Submitted", 1) assert_status_count(normalized_content, "Submitted", 1)
assert_status_count(normalized_content, "In review", 2) assert_status_count(normalized_content, "In review", 2)
assert_status_count(normalized_content, "Action needed - Already has domains", 1) assert_status_count(normalized_content, "Action needed - Already has a domain", 1)
assert_status_count(normalized_content, "Action needed - Unclear organization eligibility", 1) assert_status_count(normalized_content, "Action needed - Unclear organization eligibility", 1)
assert_status_count(normalized_content, "Rejected - Purpose requirements not met", 1) assert_status_count(normalized_content, "Rejected - Purpose requirements not met", 1)
@ -685,9 +685,9 @@ class TestDomainRequestAdmin(MockEppLib):
# Create a sample domain request # Create a sample domain request
domain_request = completed_domain_request(status=in_review, user=_creator) domain_request = completed_domain_request(status=in_review, user=_creator)
# Test the email sent out for already_has_domains # Test the email sent out for already_has_a_domain
already_has_domains = DomainRequest.ActionNeededReasons.ALREADY_HAS_DOMAINS already_has_a_domain = DomainRequest.ActionNeededReasons.ALREADY_HAS_A_DOMAIN
self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=already_has_domains) self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=already_has_a_domain)
self.assert_email_is_accurate("ORGANIZATION ALREADY HAS A .GOV DOMAIN", 0, EMAIL, bcc_email_address=BCC_EMAIL) self.assert_email_is_accurate("ORGANIZATION ALREADY HAS A .GOV DOMAIN", 0, EMAIL, bcc_email_address=BCC_EMAIL)
self.assertEqual(len(self.mock_client.EMAILS_SENT), 1) self.assertEqual(len(self.mock_client.EMAILS_SENT), 1)

View file

@ -13,6 +13,7 @@ from .domain import (
DomainAddUserView, DomainAddUserView,
DomainInvitationCancelView, DomainInvitationCancelView,
DomainDeleteUserView, DomainDeleteUserView,
PrototypeDomainDNSRecordView,
) )
from .user_profile import UserProfileView, FinishProfileSetupView from .user_profile import UserProfileView, FinishProfileSetupView
from .health import * from .health import *

View file

@ -7,7 +7,7 @@ inherit from `DomainPermissionView` (or DomainInvitationPermissionCancelView).
from datetime import date from datetime import date
import logging import logging
import requests
from django.contrib import messages from django.contrib import messages
from django.contrib.messages.views import SuccessMessageMixin from django.contrib.messages.views import SuccessMessageMixin
from django.db import IntegrityError from django.db import IntegrityError
@ -64,6 +64,7 @@ from epplibwrapper import (
from ..utility.email import send_templated_email, EmailSendingError from ..utility.email import send_templated_email, EmailSendingError
from .utility import DomainPermissionView, DomainInvitationPermissionCancelView from .utility import DomainPermissionView, DomainInvitationPermissionCancelView
from django import forms
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -454,6 +455,216 @@ class DomainDNSView(DomainBaseView):
"""DNS Information View.""" """DNS Information View."""
template_name = "domain_dns.html" template_name = "domain_dns.html"
valid_domains = ["igorville.gov", "domainops.gov", "dns.gov"]
def get_context_data(self, **kwargs):
"""Adds custom context."""
context = super().get_context_data(**kwargs)
context["dns_prototype_flag"] = flag_is_active_for_user(self.request.user, "dns_prototype_flag")
context["is_valid_domain"] = self.object.name in self.valid_domains
return context
class PrototypeDomainDNSRecordForm(forms.Form):
"""Form for adding DNS records in prototype."""
name = forms.CharField(label="DNS record name (A record)", required=True, help_text="DNS record name")
content = forms.GenericIPAddressField(
label="IPv4 Address",
required=True,
protocol="IPv4",
)
ttl = forms.ChoiceField(
label="TTL",
choices=[
(1, "Automatic"),
(60, "1 minute"),
(300, "5 minutes"),
(1800, "30 minutes"),
(3600, "1 hour"),
(7200, "2 hours"),
(18000, "5 hours"),
(43200, "12 hours"),
(86400, "1 day"),
],
initial=1,
)
class PrototypeDomainDNSRecordView(DomainFormBaseView):
template_name = "prototype_domain_dns.html"
form_class = PrototypeDomainDNSRecordForm
valid_domains = ["igorville.gov", "domainops.gov", "dns.gov"]
def has_permission(self):
has_permission = super().has_permission()
if not has_permission:
return False
flag_enabled = flag_is_active_for_user(self.request.user, "dns_prototype_flag")
if not flag_enabled:
return False
self.object = self.get_object()
if self.object.name not in self.valid_domains:
return False
return True
def get_success_url(self):
return reverse("prototype-domain-dns", kwargs={"pk": self.object.pk})
def find_by_name(self, items, name):
"""Find an item by name in a list of dictionaries."""
return next((item.get("id") for item in items if item.get("name") == name), None)
def post(self, request, *args, **kwargs):
"""Handle form submission."""
self.object = self.get_object()
form = self.get_form()
errors = []
if form.is_valid():
try:
if settings.IS_PRODUCTION and self.object.name != "igorville.gov":
raise Exception(f"create dns record was called for domain {self.name}")
if not settings.IS_PRODUCTION and self.object.name not in self.valid_domains:
raise Exception(
f"Can only create DNS records for: {self.valid_domains}."
" Create one in a test environment if it doesn't already exist."
)
base_url = "https://api.cloudflare.com/client/v4"
headers = {
"X-Auth-Email": settings.SECRET_REGISTRY_SERVICE_EMAIL,
"X-Auth-Key": settings.SECRET_REGISTRY_TENANT_KEY,
"Content-Type": "application/json",
}
params = {"tenant_name": settings.SECRET_REGISTRY_TENANT_NAME}
# 1. Get tenant details
tenant_response = requests.get(f"{base_url}/user/tenants", headers=headers, params=params, timeout=5)
tenant_response_json = tenant_response.json()
logger.info(f"Found tenant: {tenant_response_json}")
tenant_id = tenant_response_json["result"][0]["tenant_tag"]
errors = tenant_response_json.get("errors", [])
tenant_response.raise_for_status()
# 2. Create or get a account under tenant
# Check to see if the account already exists. Filters accounts by tenant_id / account_name.
account_name = f"account-{self.object.name}"
params = {"tenant_id": tenant_id, "name": account_name}
account_response = requests.get(f"{base_url}/accounts", headers=headers, params=params, timeout=5)
account_response_json = account_response.json()
logger.debug(f"account get: {account_response_json}")
errors = account_response_json.get("errors", [])
account_response.raise_for_status()
# See if we already made an account.
# This maybe doesn't need to be a for loop (1 record or 0) but alas, here we are
accounts = account_response_json.get("result", [])
account_id = self.find_by_name(accounts, account_name)
# If we didn't, create one
if not account_id:
account_response = requests.post(
f"{base_url}/accounts",
headers=headers,
json={"name": account_name, "type": "enterprise", "unit": {"id": tenant_id}},
timeout=5,
)
account_response_json = account_response.json()
logger.info(f"Created account: {account_response_json}")
account_id = account_response_json["result"]["id"]
errors = account_response_json.get("errors", [])
account_response.raise_for_status()
# 3. Create or get a zone under account
# Try to find an existing zone first by searching on the current id
zone_name = self.object.name
params = {"account.id": account_id, "name": zone_name}
zone_response = requests.get(f"{base_url}/zones", headers=headers, params=params, timeout=5)
zone_response_json = zone_response.json()
logger.debug(f"get zone: {zone_response_json}")
errors = zone_response_json.get("errors", [])
zone_response.raise_for_status()
# Get the zone id
zones = zone_response_json.get("result", [])
zone_id = self.find_by_name(zones, zone_name)
# Create one if it doesn't presently exist
if not zone_id:
zone_response = requests.post(
f"{base_url}/zones",
headers=headers,
json={"name": zone_name, "account": {"id": account_id}, "type": "full"},
timeout=5,
)
zone_response_json = zone_response.json()
logger.info(f"Created zone: {zone_response_json}")
zone_id = zone_response_json.get("result", {}).get("id")
errors = zone_response_json.get("errors", [])
zone_response.raise_for_status()
# 4. Add or get a zone subscription
# See if one already exists
subscription_response = requests.get(
f"{base_url}/zones/{zone_id}/subscription", headers=headers, timeout=5
)
subscription_response_json = subscription_response.json()
logger.debug(f"get subscription: {subscription_response_json}")
# Create a subscription if one doesn't exist already.
# If it doesn't, we get this error message (code 1207):
# Add a core subscription first and try again. The zone does not have an active core subscription.
# Note that status code and error code are different here.
if subscription_response.status_code == 404:
subscription_response = requests.post(
f"{base_url}/zones/{zone_id}/subscription",
headers=headers,
json={"rate_plan": {"id": "PARTNERS_ENT"}, "frequency": "annual"},
timeout=5,
)
subscription_response.raise_for_status()
subscription_response_json = subscription_response.json()
logger.info(f"Created subscription: {subscription_response_json}")
else:
subscription_response.raise_for_status()
# # 5. Create DNS record
# # Format the DNS record according to Cloudflare's API requirements
dns_response = requests.post(
f"{base_url}/zones/{zone_id}/dns_records",
headers=headers,
json={
"type": "A",
"name": form.cleaned_data["name"],
"content": form.cleaned_data["content"],
"ttl": int(form.cleaned_data["ttl"]),
"comment": "Test record (will need clean up)",
},
timeout=5,
)
dns_response_json = dns_response.json()
logger.info(f"Created DNS record: {dns_response_json}")
errors = dns_response_json.get("errors", [])
dns_response.raise_for_status()
dns_name = dns_response_json["result"]["name"]
messages.success(request, f"DNS A record '{dns_name}' created successfully.")
except Exception as err:
logger.error(f"Error creating DNS A record for {self.object.name}: {err}")
messages.error(request, f"An error occurred: {err}")
finally:
if errors:
messages.error(request, f"Request errors: {errors}")
return super().post(request)
class DomainNameserversView(DomainFormBaseView): class DomainNameserversView(DomainFormBaseView):