Merge branch 'main' into za/1102-epp-contact-disclose-update

This commit is contained in:
zandercymatics 2023-10-19 10:49:59 -06:00
commit c30428b6f8
No known key found for this signature in database
GPG key ID: FF4636ABEC9682B7
70 changed files with 5603 additions and 1331 deletions

View file

@ -1,34 +1,35 @@
name: Issue
description: Capture uncategorized work or content
description: Describe an idea, feature, content, or non-bug finding
body:
- type: markdown
id: help
id: title-help
attributes:
value: |
> **Note**
> GitHub Issues use [GitHub Flavored Markdown](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax) for formatting.
> Titles should be short, descriptive, and compelling.
- type: textarea
id: issue
id: issue-description
attributes:
label: Issue Description
label: Issue description and context
description: |
Describe the issue you are adding or content you are suggesting.
Share any next steps that should be taken our outcomes that would be beneficial.
Describe the issue so that someone who wasn't present for its discovery can understand the problem and why it matters. Use full sentences, plain language, and good [formatting](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax). Share desired outcomes or potential next steps. Images or links to other content/context (like documents or Slack discussions) are welcome.
validations:
required: true
- type: textarea
id: additional-context
id: acceptance-criteria
attributes:
label: Additional Context (optional)
description: "Include additional references (screenshots, design links, documentation, etc.) that are relevant"
label: Acceptance criteria
description: "If known, share 1-3 statements that would need to be true for this issue to be considered resolved. Use a [task list](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/about-task-lists#creating-task-lists) if appropriate."
placeholder: "- [ ] The button does the thing."
- type: textarea
id: issue-links
id: links-to-other-issues
attributes:
label: Issue Links
label: Links to other issues
description: |
What other issues does this story relate to and how?
Example:
- 🚧 Blocked by: #123
- 🔄 Relates to: #234
Add the issue #number of other issues this relates to and how (e.g., 🚧 Blocks, ⛔️ Is blocked by, 🔄 Relates to).
placeholder: 🔄 Relates to...
- type: markdown
id: note
attributes:
value: |
> We may edit this issue's text to document our understanding and clarify the product work.

View file

@ -0,0 +1,89 @@
# 22. Use geventconnpool library for Connection Pooling
Date: 2023-13-10
## Status
In Review
## Context
When sending and receiving data from the registry, we use the [EPPLib](https://github.com/cisagov/epplib) library to facilitate that process. To manage these connections within our application, we utilize a module named `epplibwrapper` which serves as a bridge between getgov and the EPPLib library. As part of this process, `epplibwrapper` will instantiate a client that handles sending/receiving data.
At present, each time we need to send a command to the registry, the client establishes a new connection to handle this task. This becomes inefficient when dealing with multiple calls in parallel or in series, as we have to initiate a handshake for each of them. To mitigate this issue, a widely adopted solution is to use a [connection pool](https://en.wikipedia.org/wiki/Connection_pool). In general, a connection pool stores a cache of active connections so that rather than restarting the handshake process when it is unnecessary, we can utilize an existing connection to avoid this problem.
In practice, the lack of a connection pool has resulted in performance issues when dealing with connections to and from the registry. Given the unique nature of our development stack, our options for prebuilt libraries are limited. Out of our available options, a library called [`geventconnpool`](https://github.com/rasky/geventconnpool) was identified that most closely matched our needs.
## Considered Options
**Option 1:** Use the existing connection pool library `geventconnpool`.
<details open>
<summary> Pros</summary>
- Saves development time and effort.
- A tiny library that is easy to audit and understand.
- Built to be flexible, so every built-in function can be overridden with minimal effort.
- This library has been used for [EPP before](https://github.com/rasky/geventconnpool/issues/9).
- Uses [`gevent`](http://www.gevent.org/) for coroutines, which is reliable and well maintained.
- [`gevent`](http://www.gevent.org/) is used in our WSGI web server.
- This library is the closest match to our needs that we have found.
</details>
<details open>
<summary> Cons</summary>
- Not a well maintained library, could require a fork if a dependency breaks.
- Heavily reliant on `gevent`.
</details>
**Option 2:** Write our own connection pool logic.
<details open>
<summary> Pros</summary>
- Full control over functionality, can be tailored to our specific needs.
- Highly specific to our stack, could be fine tuned for performance.
</details>
<details open>
<summary> Cons</summary>
- Requires significant development time and effort, needs thorough testing.
- Would require managing with and developing around concurrency.
- Introduces the potential for many unseen bugs.
</details>
**Option 3:** Modify an existing library which we will then tailor to our needs.
<details open>
<summary> Pros</summary>
- Savings in development time and effort, can be tailored to our specific needs.
- Good middleground between the first two options.
</details>
<details open>
<summary> Cons</summary>
- Could introduce complexity, potential issues with maintaining the modified library.
- May not be necessary if the given library is flexible enough.
</details>
## Decision
We have decided to go with option 1, which is to use the `geventconnpool` library. It closely matches our needs and offers several advantages. Of note, it significantly saves on development time and it is inherently flexible. This allows us to easily change functionality with minimal effort. In addition, the gevent library (which this uses) offers performance benefits due to it being a) written in [cython](https://cython.org/), b) very well maintained and purpose built for tasks such as these, and c) used in our WGSI server.
In summary, this decision was driven by the library's flexibility, simplicity, and compatibility with our tech stack. We acknowledge the risk associated with its maintenance status, but believe that the benefit outweighs the risk.
## Consequences
While its small size makes it easy to work around, `geventconnpool` is not actively maintained. Its last update was in 2021, and as such there is a risk that its dependencies (gevent) will outpace this library and cause it to break. If such an event occurs, it would require that we fork the library and fix those issues. See option 3 pros/cons.
## Mitigation Plan
To manage this risk, we'll:
1. Monitor the gevent library for updates.
2. Design the connection pool logic abstractly such that we can easily swap the underlying logic out without needing (or minimizing the need) to rewrite code in `epplibwrapper`.
3. Document a process for forking and maintaining the library if it becomes necessary, including testing procedures.
4. Establish a contingency plan for reverting to a previous system state or switching to a different library if significant issues arise with `gevent` or `geventconnpool`.

View file

@ -80,7 +80,7 @@ The endpoint /admin can be used to view and manage site content, including but n
1. Login via login.gov
2. Go to the home page and make sure you can see the part where you can submit an application
3. Go to /admin and it will tell you that UUID is not authorized, copy that UUID for use in 4
4. in src/registrar/fixtures.py add to the `ADMINS` list in that file by adding your UUID as your username along with your first and last name. See below:
4. in src/registrar/fixtures_users.py add to the `ADMINS` list in that file by adding your UUID as your username along with your first and last name. See below:
```
ADMINS = [
@ -102,7 +102,7 @@ Analysts are a variant of the admin role with limited permissions. The process f
1. Login via login.gov (if you already exist as an admin, you will need to create a separate login.gov account for this: i.e. first.last+1@email.com)
2. Go to the home page and make sure you can see the part where you can submit an application
3. Go to /admin and it will tell you that UUID is not authorized, copy that UUID for use in 4 (this will be a different UUID than the one obtained from creating an admin)
4. in src/registrar/fixtures.py add to the `STAFF` list in that file by adding your UUID as your username along with your first and last name. See below:
4. in src/registrar/fixtures_users.py add to the `STAFF` list in that file by adding your UUID as your username along with your first and last name. See below:
```
STAFF = [
@ -145,7 +145,7 @@ You can change the logging verbosity, if needed. Do a web search for "django log
## Mock data
There is a `post_migrate` signal in [signals.py](../../src/registrar/signals.py) that will load the fixtures from [fixtures.py](../../src/registrar/fixtures.py), giving you some test data to play with while developing.
There is a `post_migrate` signal in [signals.py](../../src/registrar/signals.py) that will load the fixtures from [fixtures_user.py](../../src/registrar/fixtures_users.py) and [fixtures_applications.py](../../src/registrar/fixtures_applications.py), giving you some test data to play with while developing.
See the [database-access README](./database-access.md) for information on how to pull data to update these fixtures.

View file

@ -48,3 +48,7 @@ future, as we add additional roles that our product vision calls for
(read-only? editing only some information?), we need to add conditional
behavior in the permission mixin, or additional mixins that more clearly
express what is allowed for those new roles.
# Admin User Permissions
Refer to [Django Admin Roles](../django-admin/roles.md)

View file

@ -1,21 +1,20 @@
# Django admin user roles
Roles other than superuser should be defined in authentication and authorization groups in django admin
For our MVP, we create and maintain 2 admin roles:
Full access and CISA analyst. Both have the role `staff`.
Permissions on these roles are set through groups:
`full_access_group` and `cisa_analysts_group`. These
groups and the methods to create them are defined in
our `user_group` model and run in a migration.
## Superuser
For more details, refer to the [user group model](../../src/registrar/models/user_group.py).
Full access
## Editing group permissions through code
## CISA analyst
We can edit and deploy new group permissions by:
### Basic permission level
Staff
### Additional group permissions
auditlog | log entry | can view log entry
registrar | contact | can view contact
registrar | domain application | can change domain application
registrar | domain | can view domain
registrar | user | can view user
1. Editing `user_group` then:
2. Duplicating migration `0036_create_groups_01`
and running migrations (append the name with a version number
to help django detect the migration eg 0037_create_groups_02)
3. Making sure to update the dependency on the new migration with the previous migration

View file

@ -89,7 +89,8 @@ command in the running Cloud.gov container. For example, to run our Django
admin command that loads test fixture data:
```
cf run-task getgov-{environment} --command "./manage.py load" --name fixtures
cf run-task getgov-{environment} --command "./manage.py load" --name fixtures--users
cf run-task getgov-{environment} --command "./manage.py load" --name fixtures--applications
```
However, this task runs asynchronously in the background without any command

View file

@ -1,11 +1,12 @@
# Registrar Data Migration
There is an existing registrar/registry at Verisign. They will provide us with an
export of the data from that system. The goal of our data migration is to take
the provided data and use it to create as much as possible a _matching_ state
The original system has an existing registrar/registry that we will import.
The company of that system will provide us with an export of the data.
The goal of our data migration is to take the provided data and use
it to create as much as possible a _matching_ state
in our registrar.
There is no way to make our registrar _identical_ to the Verisign system
There is no way to make our registrar _identical_ to the original system
because we have a different data model and workflow model. Instead, we should
focus our migration efforts on creating a state in our new registrar that will
primarily allow users of the system to perform the tasks that they want to do.
@ -18,7 +19,7 @@ Login.gov account can make an account on the new registrar, and the first time
that person logs in through Login.gov, we make a corresponding account in our
user table. Because we cannot know the Universal Unique ID (UUID) for a
person's Login.gov account, we cannot pre-create user accounts for individuals
in our new registrar based on the data from Verisign.
in our new registrar based on the original data.
## Domains
@ -27,7 +28,7 @@ information is the registry, but the registrar needs a copy of that
information to make connections between registry users and the domains that
they manage. The registrar stores very few fields about a domain except for
its name, so it could be straightforward to import the exported list of domains
from Verisign's `escrow_domains.daily.dotgov.GOV.txt`. It doesn't appear that
from `escrow_domains.daily.dotgov.GOV.txt`. It doesn't appear that
that table stores a flag for active or inactive.
An example Django management command that can load the delimited text file
@ -43,7 +44,7 @@ docker compose run -T app ./manage.py load_domains_data < /tmp/escrow_domains.da
## User access to domains
The Verisign data contains a `escrow_domain_contacts.daily.dotgov.txt` file
The data export contains a `escrow_domain_contacts.daily.dotgov.txt` file
that links each domain to three different types of contacts: `billing`,
`tech`, and `admin`. The ID of the contact in this linking table corresponds
to the ID of a contact in the `escrow_contacts.daily.dotgov.txt` file. In the
@ -59,9 +60,9 @@ invitation's domain.
For the purposes of migration, we can prime the invitation system by creating
an invitation in the system for each email address listed in the
`domain_contacts` file. This means that if a person is currently a user in the
Verisign system, and they use the same email address with Login.gov, then they
original system, and they use the same email address with Login.gov, then they
will end up with access to the same domains in the new registrar that they
were associated with in the Verisign system.
were associated with in the original system.
A management command that does this needs to process two data files, one for
the contact information and one for the domain/contact association, so we
@ -76,3 +77,56 @@ An example script using this technique is in
```shell
docker compose run app ./manage.py load_domain_invitations /app/escrow_domain_contacts.daily.dotgov.GOV.txt /app/escrow_contacts.daily.dotgov.GOV.txt
```
## Transition Domains
We are provided with information about Transition Domains in 3 files:
FILE 1: **escrow_domain_contacts.daily.gov.GOV.txt** -> has the map of domain names to contact ID. Domains in this file will usually have 3 contacts each
FILE 2: **escrow_contacts.daily.gov.GOV.txt** -> has the mapping of contact id to contact email address (which is what we care about for sending domain invitations)
FILE 3: **escrow_domain_statuses.daily.gov.GOV.txt** -> has the map of domains and their statuses
Transferring this data from these files into our domain tables happens in two steps;
***IMPORTANT: only run the following locally, to avoid publicizing PII in our public repo.***
### STEP 1: Load Transition Domain data into TransitionDomain table
**SETUP**
In order to use the management command, we need to add the files to a folder under `src/`.
This will allow Docker to mount the files to a container (under `/app`) for our use.
- Create a folder called `tmp` underneath `src/`
- Add the above files to this folder
- Open a terminal and navigate to `src/`
Then run the following command (This will parse the three files in your `tmp` folder and load the information into the TransitionDomain table);
```shell
docker compose run -T app ./manage.py load_transition_domain /app/tmp/escrow_domain_contacts.daily.gov.GOV.txt /app/tmp/escrow_contacts.daily.gov.GOV.txt /app/tmp/escrow_domain_statuses.daily.gov.GOV.txt
```
**OPTIONAL COMMAND LINE ARGUMENTS**:
`--debug`
This will print out additional, detailed logs.
`--limitParse 100`
Directs the script to load only the first 100 entries into the table. You can adjust this number as needed for testing purposes.
`--resetTable`
This will delete all the data loaded into transtion_domain. It is helpful if you want to see the entries reload from scratch or for clearing test data.
### STEP 2: Transfer Transition Domain data into main Domain tables
Now that we've loaded all the data into TransitionDomain, we need to update the main Domain and DomainInvitation tables with this information.
In the same terminal as used in STEP 1, run the command below;
(This will parse the data in TransitionDomain and either create a corresponding Domain object, OR, if a corresponding Domain already exists, it will update that Domain with the incoming status. It will also create DomainInvitation objects for each user associated with the domain):
```shell
docker compose run -T app ./manage.py transfer_transition_domains_to_domains
```
**OPTIONAL COMMAND LINE ARGUMENTS**:
`--debug`
This will print out additional, detailed logs.
`--limitParse 100`
Directs the script to load only the first 100 entries into the table. You can adjust this number as needed for testing purposes.

View file

@ -45,7 +45,7 @@ except NameError:
# Attn: these imports should NOT be at the top of the file
try:
from .client import CLIENT, commands
from .errors import RegistryError, ErrorCode
from .errors import RegistryError, ErrorCode, CANNOT_CONTACT_REGISTRY, GENERIC_ERROR
from epplib.models import common, info
from epplib.responses import extensions
from epplib import responses
@ -61,4 +61,6 @@ __all__ = [
"info",
"ErrorCode",
"RegistryError",
"CANNOT_CONTACT_REGISTRY",
"GENERIC_ERROR",
]

View file

@ -1,5 +1,8 @@
from enum import IntEnum
CANNOT_CONTACT_REGISTRY = "Update failed. Cannot contact the registry."
GENERIC_ERROR = "Value entered was wrong."
class ErrorCode(IntEnum):
"""

View file

@ -3,6 +3,7 @@ from django import forms
from django_fsm import get_available_FIELD_transitions
from django.contrib import admin, messages
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.models import Group
from django.contrib.contenttypes.models import ContentType
from django.http.response import HttpResponseRedirect
from django.urls import reverse
@ -137,8 +138,9 @@ class MyUserAdmin(BaseUserAdmin):
"email",
"first_name",
"last_name",
"is_staff",
"is_superuser",
# Group is a custom property defined within this file,
# rather than in a model like the other properties
"group",
"status",
)
@ -163,6 +165,9 @@ class MyUserAdmin(BaseUserAdmin):
("Important dates", {"fields": ("last_login", "date_joined")}),
)
# Hide Username (uuid), Groups and Permissions
# Q: Now that we're using Groups and Permissions,
# do we expose those to analysts to view?
analyst_fieldsets = (
(
None,
@ -174,14 +179,23 @@ class MyUserAdmin(BaseUserAdmin):
{
"fields": (
"is_active",
"is_staff",
"is_superuser",
"groups",
)
},
),
("Important dates", {"fields": ("last_login", "date_joined")}),
)
analyst_list_display = [
"email",
"first_name",
"last_name",
"group",
"status",
]
# NOT all fields are readonly for admin, otherwise we would have
# set this at the permissions level. The exception is 'status'
analyst_readonly_fields = [
"password",
"Personal Info",
@ -190,43 +204,56 @@ class MyUserAdmin(BaseUserAdmin):
"email",
"Permissions",
"is_active",
"is_staff",
"is_superuser",
"groups",
"Important dates",
"last_login",
"date_joined",
]
def get_list_display(self, request):
if not request.user.is_superuser:
# Customize the list display for staff users
return (
"email",
"first_name",
"last_name",
"is_staff",
"is_superuser",
"status",
)
list_filter = (
"is_active",
"groups",
)
# Use the default list display for non-staff users
return super().get_list_display(request)
# Let's define First group
# (which should in theory be the ONLY group)
def group(self, obj):
if obj.groups.filter(name="full_access_group").exists():
return "Full access"
elif obj.groups.filter(name="cisa_analysts_group").exists():
return "Analyst"
return ""
def get_list_display(self, request):
# The full_access_permission perm will load onto the full_access_group
# which is equivalent to superuser. The other group we use to manage
# perms is cisa_analysts_group. cisa_analysts_group will never contain
# full_access_permission
if request.user.has_perm("registrar.full_access_permission"):
# Use the default list display for all access users
return super().get_list_display(request)
# Customize the list display for analysts
return self.analyst_list_display
def get_fieldsets(self, request, obj=None):
if not request.user.is_superuser:
# If the user doesn't have permission to change the model,
# show a read-only fieldset
if request.user.has_perm("registrar.full_access_permission"):
# Show all fields for all access users
return super().get_fieldsets(request, obj)
elif request.user.has_perm("registrar.analyst_access_permission"):
# show analyst_fieldsets for analysts
return self.analyst_fieldsets
# If the user has permission to change the model, show all fields
return super().get_fieldsets(request, obj)
else:
# any admin user should belong to either full_access_group
# or cisa_analyst_group
return []
def get_readonly_fields(self, request, obj=None):
if request.user.is_superuser:
return () # No read-only fields for superusers
elif request.user.is_staff:
return self.analyst_readonly_fields # Read-only fields for staff
return () # No read-only fields for other users
if request.user.has_perm("registrar.full_access_permission"):
return () # No read-only fields for all access users
# Return restrictive Read-only fields for analysts and
# users who might not belong to groups
return self.analyst_readonly_fields
class HostIPInline(admin.StackedInline):
@ -315,6 +342,12 @@ class DomainInvitationAdmin(ListHeaderAdmin):
]
search_help_text = "Search by email or domain."
# Mark the FSM field 'status' as readonly
# to allow admin users to create Domain Invitations
# without triggering the FSM Transition Not Allowed
# error.
readonly_fields = ["status"]
class DomainInformationAdmin(ListHeaderAdmin):
"""Customize domain information admin class."""
@ -405,11 +438,12 @@ class DomainInformationAdmin(ListHeaderAdmin):
readonly_fields = list(self.readonly_fields)
if request.user.is_superuser:
return readonly_fields
else:
readonly_fields.extend([field for field in self.analyst_readonly_fields])
if request.user.has_perm("registrar.full_access_permission"):
return readonly_fields
# Return restrictive Read-only fields for analysts and
# users who might not belong to groups
readonly_fields.extend([field for field in self.analyst_readonly_fields])
return readonly_fields # Read-only fields for analysts
class DomainApplicationAdminForm(forms.ModelForm):
@ -623,11 +657,12 @@ class DomainApplicationAdmin(ListHeaderAdmin):
["current_websites", "other_contacts", "alternative_domains"]
)
if request.user.is_superuser:
return readonly_fields
else:
readonly_fields.extend([field for field in self.analyst_readonly_fields])
if request.user.has_perm("registrar.full_access_permission"):
return readonly_fields
# Return restrictive Read-only fields for analysts and
# users who might not belong to groups
readonly_fields.extend([field for field in self.analyst_readonly_fields])
return readonly_fields
def display_restricted_warning(self, request, obj):
if obj and obj.creator.status == models.User.RESTRICTED:
@ -784,7 +819,8 @@ class DomainAdmin(ListHeaderAdmin):
else:
self.message_user(
request,
("Domain statuses are %s" ". Thanks!") % statuses,
f"The registry statuses are {statuses}. "
"These statuses are from the provider of the .gov registry.",
)
return HttpResponseRedirect(".")
@ -870,7 +906,9 @@ class DomainAdmin(ListHeaderAdmin):
# Fixes a bug wherein users which are only is_staff
# can access 'change' when GET,
# but cannot access this page when it is a request of type POST.
if request.user.is_staff:
if request.user.has_perm(
"registrar.full_access_permission"
) or request.user.has_perm("registrar.analyst_access_permission"):
return True
return super().has_change_permission(request, obj)
@ -885,6 +923,10 @@ class DraftDomainAdmin(ListHeaderAdmin):
admin.site.unregister(LogEntry) # Unregister the default registration
admin.site.register(LogEntry, CustomLogEntryAdmin)
admin.site.register(models.User, MyUserAdmin)
# Unregister the built-in Group model
admin.site.unregister(Group)
# Register UserGroup
admin.site.register(models.UserGroup)
admin.site.register(models.UserDomainRole, UserDomainRoleAdmin)
admin.site.register(models.Contact, ContactAdmin)
admin.site.register(models.DomainInvitation, DomainInvitationAdmin)

View file

@ -236,28 +236,150 @@ function handleValidationClick(e) {
* Only does something on a single page, but it should be fast enough to run
* it everywhere.
*/
(function prepareForms() {
let serverForm = document.querySelectorAll(".server-form")
let container = document.querySelector("#form-container")
let addButton = document.querySelector("#add-form")
let totalForms = document.querySelector("#id_form-TOTAL_FORMS")
(function prepareNameserverForms() {
let serverForm = document.querySelectorAll(".server-form");
let container = document.querySelector("#form-container");
let addButton = document.querySelector("#add-nameserver-form");
let totalForms = document.querySelector("#id_form-TOTAL_FORMS");
let formNum = serverForm.length-1
addButton.addEventListener('click', addForm)
let formNum = serverForm.length-1;
if (addButton)
addButton.addEventListener('click', addForm);
function addForm(e){
let newForm = serverForm[2].cloneNode(true)
let formNumberRegex = RegExp(`form-(\\d){1}-`,'g')
let formLabelRegex = RegExp(`Name server (\\d){1}`, 'g')
let formExampleRegex = RegExp(`ns(\\d){1}`, 'g')
let newForm = serverForm[2].cloneNode(true);
let formNumberRegex = RegExp(`form-(\\d){1}-`,'g');
let formLabelRegex = RegExp(`Name server (\\d){1}`, 'g');
let formExampleRegex = RegExp(`ns(\\d){1}`, 'g');
formNum++
newForm.innerHTML = newForm.innerHTML.replace(formNumberRegex, `form-${formNum}-`)
newForm.innerHTML = newForm.innerHTML.replace(formLabelRegex, `Name server ${formNum+1}`)
newForm.innerHTML = newForm.innerHTML.replace(formExampleRegex, `ns${formNum+1}`)
container.insertBefore(newForm, addButton)
newForm.querySelector("input").value = ""
formNum++;
newForm.innerHTML = newForm.innerHTML.replace(formNumberRegex, `form-${formNum}-`);
newForm.innerHTML = newForm.innerHTML.replace(formLabelRegex, `Name server ${formNum+1}`);
newForm.innerHTML = newForm.innerHTML.replace(formExampleRegex, `ns${formNum+1}`);
container.insertBefore(newForm, addButton);
newForm.querySelector("input").value = "";
totalForms.setAttribute('value', `${formNum+1}`)
totalForms.setAttribute('value', `${formNum+1}`);
}
})();
function prepareDeleteButtons() {
let deleteButtons = document.querySelectorAll(".delete-record");
let totalForms = document.querySelector("#id_form-TOTAL_FORMS");
// Loop through each delete button and attach the click event listener
deleteButtons.forEach((deleteButton) => {
deleteButton.addEventListener('click', removeForm);
});
function removeForm(e){
let formToRemove = e.target.closest(".ds-record");
formToRemove.remove();
let forms = document.querySelectorAll(".ds-record");
totalForms.setAttribute('value', `${forms.length}`);
let formNumberRegex = RegExp(`form-(\\d){1}-`, 'g');
let formLabelRegex = RegExp(`DS Data record (\\d){1}`, 'g');
forms.forEach((form, index) => {
// Iterate over child nodes of the current element
Array.from(form.querySelectorAll('label, input, select')).forEach((node) => {
// Iterate through the attributes of the current node
Array.from(node.attributes).forEach((attr) => {
// Check if the attribute value matches the regex
if (formNumberRegex.test(attr.value)) {
// Replace the attribute value with the updated value
attr.value = attr.value.replace(formNumberRegex, `form-${index}-`);
}
});
});
Array.from(form.querySelectorAll('h2, legend')).forEach((node) => {
node.textContent = node.textContent.replace(formLabelRegex, `DS Data record ${index + 1}`);
});
});
}
}
/**
* An IIFE that attaches a click handler for our dynamic DNSSEC forms
*
*/
(function prepareDNSSECForms() {
let serverForm = document.querySelectorAll(".ds-record");
let container = document.querySelector("#form-container");
let addButton = document.querySelector("#add-ds-form");
let totalForms = document.querySelector("#id_form-TOTAL_FORMS");
// Attach click event listener on the delete buttons of the existing forms
prepareDeleteButtons();
// Attack click event listener on the add button
if (addButton)
addButton.addEventListener('click', addForm);
/*
* Add a formset to the end of the form.
* For each element in the added formset, name the elements with the prefix,
* form-{#}-{element_name} where # is the index of the formset and element_name
* is the element's name.
* Additionally, update the form element's metadata, including totalForms' value.
*/
function addForm(e){
let forms = document.querySelectorAll(".ds-record");
let formNum = forms.length;
let newForm = serverForm[0].cloneNode(true);
let formNumberRegex = RegExp(`form-(\\d){1}-`,'g');
let formLabelRegex = RegExp(`DS Data record (\\d){1}`, 'g');
formNum++;
newForm.innerHTML = newForm.innerHTML.replace(formNumberRegex, `form-${formNum-1}-`);
newForm.innerHTML = newForm.innerHTML.replace(formLabelRegex, `DS Data record ${formNum}`);
container.insertBefore(newForm, addButton);
let inputs = newForm.querySelectorAll("input");
// Reset the values of each input to blank
inputs.forEach((input) => {
input.classList.remove("usa-input--error");
if (input.type === "text" || input.type === "number" || input.type === "password") {
input.value = ""; // Set the value to an empty string
} else if (input.type === "checkbox" || input.type === "radio") {
input.checked = false; // Uncheck checkboxes and radios
}
});
// Reset any existing validation classes
let selects = newForm.querySelectorAll("select");
selects.forEach((select) => {
select.classList.remove("usa-input--error");
select.selectedIndex = 0; // Set the value to an empty string
});
let labels = newForm.querySelectorAll("label");
labels.forEach((label) => {
label.classList.remove("usa-label--error");
});
let usaFormGroups = newForm.querySelectorAll(".usa-form-group");
usaFormGroups.forEach((usaFormGroup) => {
usaFormGroup.classList.remove("usa-form-group--error");
});
// Remove any existing error messages
let usaErrorMessages = newForm.querySelectorAll(".usa-error-message");
usaErrorMessages.forEach((usaErrorMessage) => {
let parentDiv = usaErrorMessage.closest('div');
if (parentDiv) {
parentDiv.remove(); // Remove the parent div if it exists
}
});
totalForms.setAttribute('value', `${formNum}`);
// Attach click event listener on the delete buttons of the new form
prepareDeleteButtons();
}
})();

View file

@ -179,4 +179,4 @@ h1, h2, h3 {
text-align: left;
background: var(--primary);
color: var(--header-link-color);
}
}

View file

@ -0,0 +1,17 @@
// Fixes some font size disparities with the Figma
// for usa-alert alert elements
.usa-alert {
.usa-alert__heading.larger-font-sizing {
font-size: units(3);
}
}
// The icon was off center for some reason
// Fixes that issue
@media (min-width: 64em){
.usa-alert--warning{
.usa-alert__body::before {
left: 1rem !important;
}
}
}

View file

@ -0,0 +1,127 @@
@use "uswds-core" as *;
/* Styles for making visible to screen reader / AT users only. */
.sr-only {
@include sr-only;
}
* {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
display: flex;
flex-direction: column;
min-height: 100vh;
}
#wrapper {
flex-grow: 1;
padding-top: units(3);
padding-bottom: units(6) * 2 ; //Workaround because USWDS units jump from 10 to 15
}
#wrapper.dashboard {
background-color: color('primary-lightest');
padding-top: units(5);
}
.usa-logo {
@include at-media(desktop) {
margin-top: units(2);
}
}
.usa-logo__text {
@include typeset('sans', 'xl', 2);
color: color('primary-darker');
}
.usa-nav__primary {
margin-top: units(1);
}
.section--outlined {
background-color: color('white');
border: 1px solid color('base-lighter');
border-radius: 4px;
padding: 0 units(2) units(3);
margin-top: units(3);
h2 {
color: color('primary-dark');
margin-top: units(2);
margin-bottom: units(2);
}
p {
margin-bottom: 0;
}
@include at-media(mobile-lg) {
margin-top: units(5);
h2 {
margin-bottom: 0;
}
}
}
.break-word {
word-break: break-word;
}
.dotgov-status-box {
background-color: color('primary-lightest');
border-color: color('accent-cool-lighter');
}
.dotgov-status-box--action-need {
background-color: color('warning-lighter');
border-color: color('warning');
}
footer {
border-top: 1px solid color('primary-darker');
}
.usa-footer__secondary-section {
background-color: color('primary-lightest');
}
.usa-footer__secondary-section a {
color: color('primary');
}
.usa-identifier__logo {
height: units(7);
}
abbr[title] {
// workaround for underlining abbr element
border-bottom: none;
text-decoration: none;
}
@include at-media(tablet) {
.float-right-tablet {
float: right;
}
.float-left-tablet {
float: left;
}
}
@include at-media(desktop) {
.float-right-desktop {
float: right;
}
.float-left-desktop {
float: left;
}
}
.flex-end {
align-items: flex-end;
}

View file

@ -0,0 +1,125 @@
@use "uswds-core" as *;
/* Make "placeholder" links visually obvious */
a[href$="todo"]::after {
background-color: yellow;
color: color(blue-80v);
content: " [link TBD]";
font-style: italic;
}
a.breadcrumb__back {
display:flex;
align-items: center;
margin-bottom: units(2.5);
&:visited {
color: color('primary');
}
@include at-media('tablet') {
//align to top of sidebar
margin-top: units(-0.5);
}
}
a.usa-button {
text-decoration: none;
color: color('white');
}
a.usa-button:visited,
a.usa-button:hover,
a.usa-button:focus,
a.usa-button:active {
color: color('white');
}
a.usa-button--outline,
a.usa-button--outline:visited {
box-shadow: inset 0 0 0 2px color('primary');
color: color('primary');
}
a.usa-button--outline:hover,
a.usa-button--outline:focus {
box-shadow: inset 0 0 0 2px color('primary-dark');
color: color('primary-dark');
}
a.usa-button--outline:active {
box-shadow: inset 0 0 0 2px color('primary-darker');
color: color('primary-darker');
}
a.withdraw {
background-color: color('error');
}
a.withdraw_outline,
a.withdraw_outline:visited {
box-shadow: inset 0 0 0 2px color('error');
color: color('error');
}
a.withdraw_outline:hover,
a.withdraw_outline:focus {
box-shadow: inset 0 0 0 2px color('error-dark');
color: color('error-dark');
}
a.withdraw_outline:active {
box-shadow: inset 0 0 0 2px color('error-darker');
color: color('error-darker');
}
a.withdraw:hover,
a.withdraw:focus {
background-color: color('error-dark');
}
a.withdraw:active {
background-color: color('error-darker');
}
.usa-button--unstyled .usa-icon {
vertical-align: bottom;
}
a.usa-button--unstyled:visited {
color: color('primary');
}
.dotgov-button--green {
background-color: color('success-dark');
&:hover {
background-color: color('success-darker');
}
&:active {
background-color: color('green-80v');
}
}
// Cancel button used on the
// DNSSEC main page
// We want to center this button on mobile
// and add some extra left margin on tablet+
.usa-button--cancel {
text-align: center;
@include at-media('tablet') {
margin-left: units(2);
}
}
// WARNING: crazy hack ahead:
// Cancel button(s) on the DNSSEC form pages
// We want to position the cancel button on the
// dnssec forms next to the submit button
// This button's markup is in its own form
.btn-cancel {
position: relative;
top: -39.2px;
left: 88px;
}

View file

@ -0,0 +1,10 @@
@use "uswds-core" as *;
fieldset {
border: solid 1px color('base-lighter');
padding: units(3);
}
fieldset:not(:first-child) {
margin-top: units(2);
}

View file

@ -0,0 +1,25 @@
@use "uswds-core" as *;
.usa-form .usa-button {
margin-top: units(3);
}
.usa-form--extra-large {
max-width: none;
}
.usa-form--text-width {
max-width: measure(5);
}
.usa-textarea {
@include at-media('tablet') {
height: units('mobile');
}
}
.usa-form-group--unstyled-error {
margin-left: 0;
padding-left: 0;
border-left: none;
}

View file

@ -0,0 +1,80 @@
@use "uswds-core" as *;
@use "typography" as *;
.register-form-step > h1 {
//align to top of sidebar on first page of the form
margin-top: units(-1);
}
//Tighter spacing when H2 is immediatly after H1
.register-form-step .usa-fieldset:first-of-type h2:first-of-type,
.register-form-step h1 + h2 {
margin-top: units(1);
}
.register-form-step h3 {
color: color('primary-dark');
letter-spacing: $letter-space--xs;
margin-top: units(3);
margin-bottom: 0;
+ p {
margin-top: units(0.5);
}
}
.register-form-step h4 {
margin-bottom: 0;
+ p {
margin-top: units(0.5);
}
}
.register-form-step a {
color: color('primary');
&:visited {
color: color('violet-70v'); //USWDS default
}
}
.register-form-step .usa-form-group:first-of-type,
.register-form-step .usa-label:first-of-type {
margin-top: units(1);
}
.ao_example p {
margin-top: units(1);
}
.domain_example {
p {
margin-bottom: 0;
}
.usa-list {
margin-top: units(0.5);
}
}
.review__step {
margin-top: units(3);
}
.summary-item hr,
.review__step hr {
border: none; //reset
border-top: 1px solid color('primary-dark');
margin-top: 0;
margin-bottom: units(0.5);
}
.review__step__title a:visited {
color: color('primary');
}
.review__step__name {
color: color('primary-dark');
font-weight: font-weight('semibold');
margin-bottom: units(0.5);
}

View file

@ -0,0 +1,30 @@
@use "uswds-core" as *;
.usa-sidenav {
.usa-sidenav__item {
span {
a.link_usa-checked {
padding: 0;
}
}
}
}
.sidenav__step--locked {
color: color('base-darker');
span {
display: flex;
align-items: flex-start;
padding: units(1);
.usa-icon {
flex-shrink: 0;
//align lock body to x-height
margin: units('2px') units(1) 0 0;
}
}
}
.stepnav {
margin-top: units(2);
}

View file

@ -0,0 +1,93 @@
@use "uswds-core" as *;
.dotgov-table--stacked {
td, th {
padding: units(1) units(2) units(2px) 0;
border: none;
}
tr:first-child th:first-child {
border-top: none;
}
tr {
border-bottom: none;
border-top: 2px solid color('base-light');
margin-top: units(2);
&:first-child {
margin-top: 0;
}
}
td[data-label]:before,
th[data-label]:before {
color: color('primary-darker');
padding-bottom: units(2px);
}
}
.dotgov-table {
width: 100%;
a {
display: flex;
align-items: flex-start;
color: color('primary');
&:visited {
color: color('primary');
}
.usa-icon {
// align icon with x height
margin-top: units(0.5);
margin-right: units(0.5);
}
}
th[data-sortable]:not([aria-sort]) .usa-table__header__button {
right: auto;
}
tbody th {
word-break: break-word;
}
@include at-media(mobile-lg) {
margin-top: units(1);
tr {
border: none;
}
td, th {
border-bottom: 1px solid color('base-light');
}
thead th {
color: color('primary-darker');
border-bottom: 2px solid color('base-light');
}
tbody tr:last-of-type {
td, th {
border-bottom: 0;
}
}
td, th,
.usa-tabel th{
padding: units(2) units(2) units(2) 0;
}
th:first-of-type {
padding-left: 0;
}
thead tr:first-child th:first-child {
border-top: none;
}
}
}

View file

@ -0,0 +1,24 @@
@use "uswds-core" as *;
// Finer grained letterspacing adjustments
$letter-space--xs: .0125em;
p,
address,
.usa-list li {
@include typeset('sans', 'sm', 5);
max-width: measure(5);
}
h1 {
@include typeset('sans', '2xl', 2);
margin: 0 0 units(2);
color: color('primary-darker');
}
h2 {
font-weight: font-weight('semibold');
line-height: line-height('heading', 3);
margin: units(4) 0 units(1);
color: color('primary-darker');
}

View file

@ -1,457 +0,0 @@
/*
* * * * * ==============================
* * * * * ==============================
* * * * * ==============================
* * * * * ==============================
========================================
========================================
========================================
----------------------------------------
USWDS THEME CUSTOM STYLES
----------------------------------------
!! Copy this file to your project's
sass root. Don't edit the version
in node_modules.
----------------------------------------
Custom project SASS goes here.
i.e.
@include u-padding-right('05');
----------------------------------------
*/
// Finer grained letterspacing adjustments
$letter-space--xs: .0125em;
@use "uswds-core" as *;
/* Styles for making visible to screen reader / AT users only. */
.sr-only {
@include sr-only;
}
* {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
display: flex;
flex-direction: column;
min-height: 100vh;
}
#wrapper {
flex-grow: 1;
}
.usa-logo {
@include at-media(desktop) {
margin-top: units(2);
}
}
.usa-logo__text {
@include typeset('sans', 'xl', 2);
color: color('primary-darker');
}
.usa-nav__primary {
margin-top: units(1);
}
p,
address,
.usa-list li {
@include typeset('sans', 'sm', 5);
max-width: measure(5);
}
h1 {
@include typeset('sans', '2xl', 2);
margin: 0 0 units(2);
color: color('primary-darker');
}
h2 {
font-weight: font-weight('semibold');
line-height: line-height('heading', 3);
margin: units(4) 0 units(1);
color: color('primary-darker');
}
.register-form-step > h1 {
//align to top of sidebar on first page of the form
margin-top: units(-1);
}
//Tighter spacing when H2 is immediatly after H1
.register-form-step .usa-fieldset:first-of-type h2:first-of-type,
.register-form-step h1 + h2 {
margin-top: units(1);
}
.register-form-step h3 {
color: color('primary-dark');
letter-spacing: $letter-space--xs;
margin-top: units(3);
margin-bottom: 0;
+ p {
margin-top: units(0.5);
}
}
.register-form-step h4 {
margin-bottom: 0;
+ p {
margin-top: units(0.5);
}
}
.register-form-step a {
color: color('primary');
&:visited {
color: color('violet-70v'); //USWDS default
}
}
.register-form-step .usa-form-group:first-of-type,
.register-form-step .usa-label:first-of-type {
margin-top: units(1);
}
/* Make "placeholder" links visually obvious */
a[href$="todo"]::after {
background-color: yellow;
color: color(blue-80v);
content: " [link TBD]";
font-style: italic;
}
a.breadcrumb__back {
display:flex;
align-items: center;
margin-bottom: units(2.5);
&:visited {
color: color('primary');
}
@include at-media('tablet') {
//align to top of sidebar
margin-top: units(-0.5);
}
}
a.withdraw {
background-color: color('error');
}
a.withdraw_outline,
a.withdraw_outline:visited {
box-shadow: inset 0 0 0 2px color('error');
color: color('error');
}
a.withdraw_outline:hover,
a.withdraw_outline:focus {
box-shadow: inset 0 0 0 2px color('error-dark');
color: color('error-dark');
}
a.withdraw_outline:active {
box-shadow: inset 0 0 0 2px color('error-darker');
color: color('error-darker');
}
a.withdraw:hover,
a.withdraw:focus {
background-color: color('error-dark');
}
a.withdraw:active {
background-color: color('error-darker');
}
.usa-sidenav {
.usa-sidenav__item {
span {
a.link_usa-checked {
padding: 0;
}
}
}
}
.sidenav__step--locked {
color: color('base-darker');
span {
display: flex;
align-items: flex-start;
padding: units(1);
.usa-icon {
flex-shrink: 0;
//align lock body to x-height
margin: units('2px') units(1) 0 0;
}
}
}
.stepnav {
margin-top: units(2);
}
.ao_example p {
margin-top: units(1);
}
.domain_example {
p {
margin-bottom: 0;
}
.usa-list {
margin-top: units(0.5);
}
}
.review__step {
margin-top: units(3);
}
.summary-item hr,
.review__step hr {
border: none; //reset
border-top: 1px solid color('primary-dark');
margin-top: 0;
margin-bottom: units(0.5);
}
.review__step__title a:visited {
color: color('primary');
}
.review__step__name {
color: color('primary-dark');
font-weight: font-weight('semibold');
margin-bottom: units(0.5);
}
.usa-form .usa-button {
margin-top: units(3);
}
.usa-button--unstyled .usa-icon {
vertical-align: bottom;
}
a.usa-button--unstyled:visited {
color: color('primary');
}
.dotgov-button--green {
background-color: color('success-dark');
&:hover {
background-color: color('success-darker');
}
&:active {
background-color: color('green-80v');
}
}
/** ---- DASHBOARD ---- */
#wrapper.dashboard {
background-color: color('primary-lightest');
padding-top: units(5);
}
.section--outlined {
background-color: color('white');
border: 1px solid color('base-lighter');
border-radius: 4px;
padding: 0 units(2) units(3);
margin-top: units(3);
h2 {
color: color('primary-dark');
margin-top: units(2);
margin-bottom: units(2);
}
p {
margin-bottom: 0;
}
@include at-media(mobile-lg) {
margin-top: units(5);
h2 {
margin-bottom: 0;
}
}
}
.dotgov-table--stacked {
td, th {
padding: units(1) units(2) units(2px) 0;
border: none;
}
tr:first-child th:first-child {
border-top: none;
}
tr {
border-bottom: none;
border-top: 2px solid color('base-light');
margin-top: units(2);
&:first-child {
margin-top: 0;
}
}
td[data-label]:before,
th[data-label]:before {
color: color('primary-darker');
padding-bottom: units(2px);
}
}
.dotgov-table {
width: 100%;
a {
display: flex;
align-items: flex-start;
color: color('primary');
&:visited {
color: color('primary');
}
.usa-icon {
// align icon with x height
margin-top: units(0.5);
margin-right: units(0.5);
}
}
th[data-sortable]:not([aria-sort]) .usa-table__header__button {
right: auto;
}
tbody th {
word-break: break-word;
}
@include at-media(mobile-lg) {
margin-top: units(1);
tr {
border: none;
}
td, th {
border-bottom: 1px solid color('base-light');
}
thead th {
color: color('primary-darker');
border-bottom: 2px solid color('base-light');
}
tbody tr:last-of-type {
td, th {
border-bottom: 0;
}
}
td, th,
.usa-tabel th{
padding: units(2) units(2) units(2) 0;
}
th:first-of-type {
padding-left: 0;
}
thead tr:first-child th:first-child {
border-top: none;
}
}
}
.break-word {
word-break: break-word;
}
.dotgov-status-box {
background-color: color('primary-lightest');
border-color: color('accent-cool-lighter');
}
.dotgov-status-box--action-need {
background-color: color('warning-lighter');
border-color: color('warning');
}
#wrapper {
padding-top: units(3);
padding-bottom: units(6) * 2 ; //Workaround because USWDS units jump from 10 to 15
}
footer {
border-top: 1px solid color('primary-darker');
}
.usa-footer__secondary-section {
background-color: color('primary-lightest');
}
.usa-footer__secondary-section a {
color: color('primary');
}
.usa-identifier__logo {
height: units(7);
}
abbr[title] {
// workaround for underlining abbr element
border-bottom: none;
text-decoration: none;
}
.usa-textarea {
@include at-media('tablet') {
height: units('mobile');
}
}
// Fixes some font size disparities with the Figma
// for usa-alert alert elements
.usa-alert {
.usa-alert__heading.larger-font-sizing {
font-size: units(3);
}
}
// The icon was off center for some reason
// Fixes that issue
@media (min-width: 64em){
.usa-alert--warning{
.usa-alert__body::before {
left: 1rem !important;
}
}
}

View file

@ -8,7 +8,15 @@
/*--------------------------------------------------
--- Custom Styles ---------------------------------*/
@forward "uswds-theme-custom-styles";
@forward "base";
@forward "typography";
@forward "buttons";
@forward "forms";
@forward "fieldsets";
@forward "alerts";
@forward "tables";
@forward "sidenav";
@forward "register-form";
/*--------------------------------------------------
--- Admin ---------------------------------*/

View file

@ -652,6 +652,9 @@ SESSION_COOKIE_SAMESITE = "Lax"
# instruct browser to only send cookie via HTTPS
SESSION_COOKIE_SECURE = True
# session engine to cache session information
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
# ~ Set by django.middleware.clickjacking.XFrameOptionsMiddleware
# prevent clickjacking by instructing the browser not to load
# our site within an iframe

View file

@ -81,9 +81,29 @@ urlpatterns = [
path("domain/<int:pk>", views.DomainView.as_view(), name="domain"),
path("domain/<int:pk>/users", views.DomainUsersView.as_view(), name="domain-users"),
path(
"domain/<int:pk>/nameservers",
"domain/<int:pk>/dns",
views.DomainDNSView.as_view(),
name="domain-dns",
),
path(
"domain/<int:pk>/dns/nameservers",
views.DomainNameserversView.as_view(),
name="domain-nameservers",
name="domain-dns-nameservers",
),
path(
"domain/<int:pk>/dns/dnssec",
views.DomainDNSSECView.as_view(),
name="domain-dns-dnssec",
),
path(
"domain/<int:pk>/dns/dnssec/dsdata",
views.DomainDsDataView.as_view(),
name="domain-dns-dnssec-dsdata",
),
path(
"domain/<int:pk>/dns/dnssec/keydata",
views.DomainKeyDataView.as_view(),
name="domain-dns-dnssec-keydata",
),
path(
"domain/<int:pk>/your-contact-information",

View file

@ -10,255 +10,10 @@ from registrar.models import (
Website,
)
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType
fake = Faker()
logger = logging.getLogger(__name__)
class UserFixture:
"""
Load users into the database.
Make sure this class' `load` method is called from `handle`
in management/commands/load.py, then use `./manage.py load`
to run this code.
"""
ADMINS = [
{
"username": "5f283494-31bd-49b5-b024-a7e7cae00848",
"first_name": "Rachid",
"last_name": "Mrad",
},
{
"username": "eb2214cd-fc0c-48c0-9dbd-bc4cd6820c74",
"first_name": "Alysia",
"last_name": "Broddrick",
},
{
"username": "8f8e7293-17f7-4716-889b-1990241cbd39",
"first_name": "Katherine",
"last_name": "Osos",
},
{
"username": "70488e0a-e937-4894-a28c-16f5949effd4",
"first_name": "Gaby",
"last_name": "DiSarli",
},
{
"username": "83c2b6dd-20a2-4cac-bb40-e22a72d2955c",
"first_name": "Cameron",
"last_name": "Dixon",
},
{
"username": "0353607a-cbba-47d2-98d7-e83dcd5b90ea",
"first_name": "Ryan",
"last_name": "Brooks",
},
{
"username": "30001ee7-0467-4df2-8db2-786e79606060",
"first_name": "Zander",
"last_name": "Adkinson",
},
{
"username": "2bf518c2-485a-4c42-ab1a-f5a8b0a08484",
"first_name": "Paul",
"last_name": "Kuykendall",
},
{
"username": "2a88a97b-be96-4aad-b99e-0b605b492c78",
"first_name": "Rebecca",
"last_name": "Hsieh",
},
{
"username": "fa69c8e8-da83-4798-a4f2-263c9ce93f52",
"first_name": "David",
"last_name": "Kennedy",
},
{
"username": "f14433d8-f0e9-41bf-9c72-b99b110e665d",
"first_name": "Nicolle",
"last_name": "LeClair",
},
{
"username": "24840450-bf47-4d89-8aa9-c612fe68f9da",
"first_name": "Erin",
"last_name": "Song",
},
{
"username": "e0ea8b94-6e53-4430-814a-849a7ca45f21",
"first_name": "Kristina",
"last_name": "Yin",
},
]
STAFF = [
{
"username": "319c490d-453b-43d9-bc4d-7d6cd8ff6844",
"first_name": "Rachid-Analyst",
"last_name": "Mrad-Analyst",
"email": "rachid.mrad@gmail.com",
},
{
"username": "b6a15987-5c88-4e26-8de2-ca71a0bdb2cd",
"first_name": "Alysia-Analyst",
"last_name": "Alysia-Analyst",
},
{
"username": "91a9b97c-bd0a-458d-9823-babfde7ebf44",
"first_name": "Katherine-Analyst",
"last_name": "Osos-Analyst",
"email": "kosos@truss.works",
},
{
"username": "2cc0cde8-8313-4a50-99d8-5882e71443e8",
"first_name": "Zander-Analyst",
"last_name": "Adkinson-Analyst",
},
{
"username": "57ab5847-7789-49fe-a2f9-21d38076d699",
"first_name": "Paul-Analyst",
"last_name": "Kuykendall-Analyst",
},
{
"username": "e474e7a9-71ca-449d-833c-8a6e094dd117",
"first_name": "Rebecca-Analyst",
"last_name": "Hsieh-Analyst",
},
{
"username": "5dc6c9a6-61d9-42b4-ba54-4beff28bac3c",
"first_name": "David-Analyst",
"last_name": "Kennedy-Analyst",
},
{
"username": "0eb6f326-a3d4-410f-a521-aa4c1fad4e47",
"first_name": "Gaby-Analyst",
"last_name": "DiSarli-Analyst",
"email": "gaby@truss.works",
},
{
"username": "cfe7c2fc-e24a-480e-8b78-28645a1459b3",
"first_name": "Nicolle-Analyst",
"last_name": "LeClair-Analyst",
"email": "nicolle.leclair@ecstech.com",
},
{
"username": "378d0bc4-d5a7-461b-bd84-3ae6f6864af9",
"first_name": "Erin-Analyst",
"last_name": "Song-Analyst",
"email": "erin.song+1@gsa.gov",
},
{
"username": "9a98e4c9-9409-479d-964e-4aec7799107f",
"first_name": "Kristina-Analyst",
"last_name": "Yin-Analyst",
"email": "kristina.yin+1@gsa.gov",
},
]
STAFF_PERMISSIONS = [
{
"app_label": "auditlog",
"model": "logentry",
"permissions": ["view_logentry"],
},
{"app_label": "registrar", "model": "contact", "permissions": ["view_contact"]},
{
"app_label": "registrar",
"model": "domaininformation",
"permissions": ["change_domaininformation"],
},
{
"app_label": "registrar",
"model": "domainapplication",
"permissions": ["change_domainapplication"],
},
{"app_label": "registrar", "model": "domain", "permissions": ["view_domain"]},
{
"app_label": "registrar",
"model": "draftdomain",
"permissions": ["change_draftdomain"],
},
{"app_label": "registrar", "model": "user", "permissions": ["change_user"]},
]
@classmethod
def load(cls):
logger.info("Going to load %s superusers" % str(len(cls.ADMINS)))
for admin in cls.ADMINS:
try:
user, _ = User.objects.get_or_create(
username=admin["username"],
)
user.is_superuser = True
user.first_name = admin["first_name"]
user.last_name = admin["last_name"]
if "email" in admin.keys():
user.email = admin["email"]
user.is_staff = True
user.is_active = True
user.save()
logger.debug("User object created for %s" % admin["first_name"])
except Exception as e:
logger.warning(e)
logger.info("All superusers loaded.")
logger.info("Going to load %s CISA analysts (staff)" % str(len(cls.STAFF)))
for staff in cls.STAFF:
try:
user, _ = User.objects.get_or_create(
username=staff["username"],
)
user.is_superuser = False
user.first_name = staff["first_name"]
user.last_name = staff["last_name"]
if "email" in admin.keys():
user.email = admin["email"]
user.is_staff = True
user.is_active = True
for permission in cls.STAFF_PERMISSIONS:
app_label = permission["app_label"]
model_name = permission["model"]
permissions = permission["permissions"]
# Retrieve the content type for the app and model
content_type = ContentType.objects.get(
app_label=app_label, model=model_name
)
# Retrieve the permissions based on their codenames
permissions = Permission.objects.filter(
content_type=content_type, codename__in=permissions
)
# Assign the permissions to the user
user.user_permissions.add(*permissions)
# Convert the permissions QuerySet to a list of codenames
permission_list = list(
permissions.values_list("codename", flat=True)
)
logger.debug(
app_label
+ " | "
+ model_name
+ " | "
+ ", ".join(permission_list)
+ " added for user "
+ staff["first_name"]
)
user.save()
logger.debug("User object created for %s" % staff["first_name"])
except Exception as e:
logger.warning(e)
logger.info("All CISA analysts (staff) loaded.")
class DomainApplicationFixture:
"""
Load domain applications into the database.

View file

@ -0,0 +1,178 @@
import logging
from faker import Faker
from registrar.models import (
User,
UserGroup,
)
fake = Faker()
logger = logging.getLogger(__name__)
class UserFixture:
"""
Load users into the database.
Make sure this class' `load` method is called from `handle`
in management/commands/load.py, then use `./manage.py load`
to run this code.
"""
ADMINS = [
{
"username": "5f283494-31bd-49b5-b024-a7e7cae00848",
"first_name": "Rachid",
"last_name": "Mrad",
},
{
"username": "eb2214cd-fc0c-48c0-9dbd-bc4cd6820c74",
"first_name": "Alysia",
"last_name": "Broddrick",
},
{
"username": "8f8e7293-17f7-4716-889b-1990241cbd39",
"first_name": "Katherine",
"last_name": "Osos",
},
{
"username": "70488e0a-e937-4894-a28c-16f5949effd4",
"first_name": "Gaby",
"last_name": "DiSarli",
"email": "gaby@truss.works",
},
{
"username": "83c2b6dd-20a2-4cac-bb40-e22a72d2955c",
"first_name": "Cameron",
"last_name": "Dixon",
},
{
"username": "0353607a-cbba-47d2-98d7-e83dcd5b90ea",
"first_name": "Ryan",
"last_name": "Brooks",
},
{
"username": "30001ee7-0467-4df2-8db2-786e79606060",
"first_name": "Zander",
"last_name": "Adkinson",
},
{
"username": "2bf518c2-485a-4c42-ab1a-f5a8b0a08484",
"first_name": "Paul",
"last_name": "Kuykendall",
},
{
"username": "2a88a97b-be96-4aad-b99e-0b605b492c78",
"first_name": "Rebecca",
"last_name": "Hsieh",
},
{
"username": "fa69c8e8-da83-4798-a4f2-263c9ce93f52",
"first_name": "David",
"last_name": "Kennedy",
},
{
"username": "f14433d8-f0e9-41bf-9c72-b99b110e665d",
"first_name": "Nicolle",
"last_name": "LeClair",
},
{
"username": "24840450-bf47-4d89-8aa9-c612fe68f9da",
"first_name": "Erin",
"last_name": "Song",
},
{
"username": "e0ea8b94-6e53-4430-814a-849a7ca45f21",
"first_name": "Kristina",
"last_name": "Yin",
},
]
STAFF = [
{
"username": "319c490d-453b-43d9-bc4d-7d6cd8ff6844",
"first_name": "Rachid-Analyst",
"last_name": "Mrad-Analyst",
"email": "rachid.mrad@gmail.com",
},
{
"username": "b6a15987-5c88-4e26-8de2-ca71a0bdb2cd",
"first_name": "Alysia-Analyst",
"last_name": "Alysia-Analyst",
},
{
"username": "91a9b97c-bd0a-458d-9823-babfde7ebf44",
"first_name": "Katherine-Analyst",
"last_name": "Osos-Analyst",
"email": "kosos@truss.works",
},
{
"username": "2cc0cde8-8313-4a50-99d8-5882e71443e8",
"first_name": "Zander-Analyst",
"last_name": "Adkinson-Analyst",
},
{
"username": "57ab5847-7789-49fe-a2f9-21d38076d699",
"first_name": "Paul-Analyst",
"last_name": "Kuykendall-Analyst",
},
{
"username": "e474e7a9-71ca-449d-833c-8a6e094dd117",
"first_name": "Rebecca-Analyst",
"last_name": "Hsieh-Analyst",
},
{
"username": "5dc6c9a6-61d9-42b4-ba54-4beff28bac3c",
"first_name": "David-Analyst",
"last_name": "Kennedy-Analyst",
},
{
"username": "0eb6f326-a3d4-410f-a521-aa4c1fad4e47",
"first_name": "Gaby-Analyst",
"last_name": "DiSarli-Analyst",
"email": "gaby+1@truss.works",
},
{
"username": "cfe7c2fc-e24a-480e-8b78-28645a1459b3",
"first_name": "Nicolle-Analyst",
"last_name": "LeClair-Analyst",
"email": "nicolle.leclair@ecstech.com",
},
{
"username": "378d0bc4-d5a7-461b-bd84-3ae6f6864af9",
"first_name": "Erin-Analyst",
"last_name": "Song-Analyst",
"email": "erin.song+1@gsa.gov",
},
{
"username": "9a98e4c9-9409-479d-964e-4aec7799107f",
"first_name": "Kristina-Analyst",
"last_name": "Yin-Analyst",
"email": "kristina.yin+1@gsa.gov",
},
]
def load_users(cls, users, group_name):
logger.info(f"Going to load {len(users)} users in group {group_name}")
for user_data in users:
try:
user, _ = User.objects.get_or_create(username=user_data["username"])
user.is_superuser = False
user.first_name = user_data["first_name"]
user.last_name = user_data["last_name"]
if "email" in user_data:
user.email = user_data["email"]
user.is_staff = True
user.is_active = True
group = UserGroup.objects.get(name=group_name)
user.groups.add(group)
user.save()
logger.debug(f"User object created for {user_data['first_name']}")
except Exception as e:
logger.warning(e)
logger.info(f"All users in group {group_name} loaded.")
@classmethod
def load(cls):
cls.load_users(cls, cls.ADMINS, "full_access_group")
cls.load_users(cls, cls.STAFF, "cisa_analysts_group")

View file

@ -5,4 +5,9 @@ from .domain import (
DomainSecurityEmailForm,
DomainOrgNameAddressForm,
ContactForm,
DomainDnssecForm,
DomainDsdataFormset,
DomainDsdataForm,
DomainKeydataFormset,
DomainKeydataForm,
)

View file

@ -0,0 +1,38 @@
# common.py
#
# ALGORITHM_CHOICES are options for alg attribute in DS Data and Key Data
# reference:
# https://www.iana.org/assignments/dns-sec-alg-numbers/dns-sec-alg-numbers.xhtml
ALGORITHM_CHOICES = [
(1, "(1) ERSA/MD5 [RSAMD5]"),
(2, "(2) Diffie-Hellman [DH]"),
(3, "(3) DSA/SHA-1 [DSA]"),
(5, "(5) RSA/SHA-1 [RSASHA1]"),
(6, "(6) DSA-NSEC3-SHA1"),
(7, "(7) RSASHA1-NSEC3-SHA1"),
(8, "(8) RSA/SHA-256 [RSASHA256]"),
(10, "(10) RSA/SHA-512 [RSASHA512]"),
(12, "(12) GOST R 34.10-2001 [ECC-GOST]"),
(13, "(13) ECDSA Curve P-256 with SHA-256 [ECDSAP256SHA256]"),
(14, "(14) ECDSA Curve P-384 with SHA-384 [ECDSAP384SHA384]"),
(15, "(15) Ed25519"),
(16, "(16) Ed448"),
]
# DIGEST_TYPE_CHOICES are options for digestType attribute in DS Data
# reference: https://datatracker.ietf.org/doc/html/rfc4034#appendix-A.2
DIGEST_TYPE_CHOICES = [
(0, "(0) Reserved"),
(1, "(1) SHA-256"),
]
# PROTOCOL_CHOICES are options for protocol attribute in Key Data
# reference: https://datatracker.ietf.org/doc/html/rfc4034#section-2.1.2
PROTOCOL_CHOICES = [
(3, "(3) DNSSEC"),
]
# FLAG_CHOICES are options for flags attribute in Key Data
# reference: https://datatracker.ietf.org/doc/html/rfc4034#section-2.1.1
FLAG_CHOICES = [
(0, "(0)"),
(256, "(256) ZSK"),
(257, "(257) KSK"),
]

View file

@ -1,26 +1,31 @@
"""Forms for domain management."""
from django import forms
from django.core.validators import RegexValidator
from django.core.validators import MinValueValidator, MaxValueValidator, RegexValidator
from django.forms import formset_factory
from phonenumber_field.widgets import RegionalPhoneNumberWidget
from ..models import Contact, DomainInformation
from .common import (
ALGORITHM_CHOICES,
DIGEST_TYPE_CHOICES,
FLAG_CHOICES,
PROTOCOL_CHOICES,
)
class DomainAddUserForm(forms.Form):
"""Form for adding a user to a domain."""
email = forms.EmailField(label="Email")
class DomainNameserverForm(forms.Form):
"""Form for changing nameservers."""
server = forms.CharField(label="Name server")
server = forms.CharField(label="Name server", strip=True)
# when adding IPs to this form ensure they are stripped as well
NameserverFormset = formset_factory(
@ -30,7 +35,6 @@ NameserverFormset = formset_factory(
class ContactForm(forms.ModelForm):
"""Form for updating contacts."""
class Meta:
@ -61,14 +65,12 @@ class ContactForm(forms.ModelForm):
class DomainSecurityEmailForm(forms.Form):
"""Form for adding or editing a security email to a domain."""
security_email = forms.EmailField(label="Security email")
security_email = forms.EmailField(label="Security email", required=False)
class DomainOrgNameAddressForm(forms.ModelForm):
"""Form for updating the organization name and mailing address."""
zipcode = forms.CharField(
@ -139,3 +141,91 @@ class DomainOrgNameAddressForm(forms.ModelForm):
self.fields[field_name].required = True
self.fields["state_territory"].widget.attrs.pop("maxlength", None)
self.fields["zipcode"].widget.attrs.pop("maxlength", None)
class DomainDnssecForm(forms.Form):
"""Form for enabling and disabling dnssec"""
class DomainDsdataForm(forms.Form):
"""Form for adding or editing DNSSEC DS Data to a domain."""
key_tag = forms.IntegerField(
required=True,
label="Key tag",
validators=[
MinValueValidator(0, message="Value must be between 0 and 65535"),
MaxValueValidator(65535, message="Value must be between 0 and 65535"),
],
error_messages={"required": ("Key tag is required.")},
)
algorithm = forms.TypedChoiceField(
required=True,
label="Algorithm",
coerce=int, # need to coerce into int so dsData objects can be compared
choices=[(None, "--Select--")] + ALGORITHM_CHOICES, # type: ignore
error_messages={"required": ("Algorithm is required.")},
)
digest_type = forms.TypedChoiceField(
required=True,
label="Digest type",
coerce=int, # need to coerce into int so dsData objects can be compared
choices=[(None, "--Select--")] + DIGEST_TYPE_CHOICES, # type: ignore
error_messages={"required": ("Digest Type is required.")},
)
digest = forms.CharField(
required=True,
label="Digest",
error_messages={"required": ("Digest is required.")},
)
DomainDsdataFormset = formset_factory(
DomainDsdataForm,
extra=0,
can_delete=True,
)
class DomainKeydataForm(forms.Form):
"""Form for adding or editing DNSSEC Key Data to a domain."""
flag = forms.TypedChoiceField(
required=True,
label="Flag",
coerce=int,
choices=FLAG_CHOICES,
error_messages={"required": ("Flag is required.")},
)
protocol = forms.TypedChoiceField(
required=True,
label="Protocol",
coerce=int,
choices=PROTOCOL_CHOICES,
error_messages={"required": ("Protocol is required.")},
)
algorithm = forms.TypedChoiceField(
required=True,
label="Algorithm",
coerce=int,
choices=[(None, "--Select--")] + ALGORITHM_CHOICES, # type: ignore
error_messages={"required": ("Algorithm is required.")},
)
pub_key = forms.CharField(
required=True,
label="Pub key",
error_messages={"required": ("Pub key is required.")},
)
DomainKeydataFormset = formset_factory(
DomainKeydataForm,
extra=0,
can_delete=True,
)

View file

@ -4,7 +4,8 @@ from django.core.management.base import BaseCommand
from auditlog.context import disable_auditlog # type: ignore
from registrar.fixtures import UserFixture, DomainApplicationFixture, DomainFixture
from registrar.fixtures_users import UserFixture
from registrar.fixtures_applications import DomainApplicationFixture, DomainFixture
logger = logging.getLogger(__name__)

View file

@ -0,0 +1,524 @@
import sys
import csv
import logging
import argparse
from collections import defaultdict
from django.core.management import BaseCommand
from registrar.models import TransitionDomain
logger = logging.getLogger(__name__)
class termColors:
"""Colors for terminal outputs
(makes reading the logs WAY easier)"""
HEADER = "\033[95m"
OKBLUE = "\033[94m"
OKCYAN = "\033[96m"
OKGREEN = "\033[92m"
YELLOW = "\033[93m"
FAIL = "\033[91m"
ENDC = "\033[0m"
BOLD = "\033[1m"
UNDERLINE = "\033[4m"
BackgroundLightYellow = "\033[103m"
def query_yes_no(question: str, default="yes") -> bool:
"""Ask a yes/no question via raw_input() and return their answer.
"question" is a string that is presented to the user.
"default" is the presumed answer if the user just hits <Enter>.
It must be "yes" (the default), "no" or None (meaning
an answer is required of the user).
The "answer" return value is True for "yes" or False for "no".
"""
valid = {"yes": True, "y": True, "ye": True, "no": False, "n": False}
if default is None:
prompt = " [y/n] "
elif default == "yes":
prompt = " [Y/n] "
elif default == "no":
prompt = " [y/N] "
else:
raise ValueError("invalid default answer: '%s'" % default)
while True:
logger.info(question + prompt)
choice = input().lower()
if default is not None and choice == "":
return valid[default]
elif choice in valid:
return valid[choice]
else:
logger.info("Please respond with 'yes' or 'no' " "(or 'y' or 'n').\n")
class Command(BaseCommand):
help = """Loads data for domains that are in transition
(populates transition_domain model objects)."""
def add_arguments(self, parser):
"""Add our three filename arguments (in order: domain contacts,
contacts, and domain statuses)
OPTIONAL ARGUMENTS:
--sep
The default delimiter is set to "|", but may be changed using --sep
--debug
A boolean (default to true), which activates additional print statements
--limitParse
Used to set a limit for the number of data entries to insert. Set to 0
(or just don't use this argument) to parse every entry.
--resetTable
Use this to trigger a prompt for deleting all table entries. Useful
for testing purposes, but USE WITH CAUTION
"""
parser.add_argument(
"domain_contacts_filename", help="Data file with domain contact information"
)
parser.add_argument(
"contacts_filename",
help="Data file with contact information",
)
parser.add_argument(
"domain_statuses_filename", help="Data file with domain status information"
)
parser.add_argument("--sep", default="|", help="Delimiter character")
parser.add_argument("--debug", action=argparse.BooleanOptionalAction)
parser.add_argument(
"--limitParse", default=0, help="Sets max number of entries to load"
)
parser.add_argument(
"--resetTable",
help="Deletes all data in the TransitionDomain table",
action=argparse.BooleanOptionalAction,
)
def print_debug_mode_statements(
self, debug_on: bool, debug_max_entries_to_parse: int
):
"""Prints additional terminal statements to indicate if --debug
or --limitParse are in use"""
if debug_on:
logger.info(
f"""{termColors.OKCYAN}
----------DEBUG MODE ON----------
Detailed print statements activated.
{termColors.ENDC}
"""
)
if debug_max_entries_to_parse > 0:
logger.info(
f"""{termColors.OKCYAN}
----------LIMITER ON----------
Parsing of entries will be limited to
{debug_max_entries_to_parse} lines per file.")
Detailed print statements activated.
{termColors.ENDC}
"""
)
def get_domain_user_dict(
self, domain_statuses_filename: str, sep: str
) -> defaultdict[str, str]:
"""Creates a mapping of domain name -> status"""
domain_status_dictionary = defaultdict(str)
logger.info("Reading domain statuses data file %s", domain_statuses_filename)
with open(domain_statuses_filename, "r") as domain_statuses_file: # noqa
for row in csv.reader(domain_statuses_file, delimiter=sep):
domainName = row[0].lower()
domainStatus = row[1].lower()
domain_status_dictionary[domainName] = domainStatus
logger.info("Loaded statuses for %d domains", len(domain_status_dictionary))
return domain_status_dictionary
def get_user_emails_dict(
self, contacts_filename: str, sep
) -> defaultdict[str, str]:
"""Creates mapping of userId -> emails"""
user_emails_dictionary = defaultdict(str)
logger.info("Reading domain-contacts data file %s", contacts_filename)
with open(contacts_filename, "r") as contacts_file:
for row in csv.reader(contacts_file, delimiter=sep):
user_id = row[0]
user_email = row[6]
user_emails_dictionary[user_id] = user_email
logger.info("Loaded emails for %d users", len(user_emails_dictionary))
return user_emails_dictionary
def get_mapped_status(self, status_to_map: str):
"""
Given a verisign domain status, return a corresponding
status defined for our domains.
We map statuses as follows;
"serverHold” fields will map to hold, clientHold to hold
and any ok state should map to Ready.
"""
status_maps = {
"hold": TransitionDomain.StatusChoices.ON_HOLD,
"serverhold": TransitionDomain.StatusChoices.ON_HOLD,
"clienthold": TransitionDomain.StatusChoices.ON_HOLD,
"created": TransitionDomain.StatusChoices.READY,
"ok": TransitionDomain.StatusChoices.READY,
}
mapped_status = status_maps.get(status_to_map)
return mapped_status
def print_summary_duplications(
self,
duplicate_domain_user_combos: list[TransitionDomain],
duplicate_domains: list[TransitionDomain],
users_without_email: list[str],
):
"""Called at the end of the script execution to print out a summary of
data anomalies in the imported Verisign data. Currently, we check for:
- duplicate domains
- duplicate domain - user pairs
- any users without e-mails (this would likely only happen if the contacts
file is missing a user found in the domain_contacts file)
"""
total_duplicate_pairs = len(duplicate_domain_user_combos)
total_duplicate_domains = len(duplicate_domains)
total_users_without_email = len(users_without_email)
if total_users_without_email > 0:
users_without_email_as_string = "{}".format(
", ".join(map(str, duplicate_domain_user_combos))
)
logger.warning(
f"{termColors.YELLOW} No e-mails found for users: {users_without_email_as_string}" # noqa
)
if total_duplicate_pairs > 0 or total_duplicate_domains > 0:
duplicate_pairs_as_string = "{}".format(
", ".join(map(str, duplicate_domain_user_combos))
)
duplicate_domains_as_string = "{}".format(
", ".join(map(str, duplicate_domains))
)
logger.warning(
f"""{termColors.YELLOW}
----DUPLICATES FOUND-----
{total_duplicate_pairs} DOMAIN - USER pairs
were NOT unique in the supplied data files;
{duplicate_pairs_as_string}
{total_duplicate_domains} DOMAINS were NOT unique in
the supplied data files;
{duplicate_domains_as_string}
{termColors.ENDC}"""
)
def print_summary_status_findings(
self, domains_without_status: list[str], outlier_statuses: list[str]
):
"""Called at the end of the script execution to print out a summary of
status anomolies in the imported Verisign data. Currently, we check for:
- domains without a status
- any statuses not accounted for in our status mappings (see
get_mapped_status() function)
"""
total_domains_without_status = len(domains_without_status)
total_outlier_statuses = len(outlier_statuses)
if total_domains_without_status > 0:
domains_without_status_as_string = "{}".format(
", ".join(map(str, domains_without_status))
)
logger.warning(
f"""{termColors.YELLOW}
--------------------------------------------
Found {total_domains_without_status} domains
without a status (defaulted to READY)
---------------------------------------------
{domains_without_status_as_string}
{termColors.ENDC}"""
)
if total_outlier_statuses > 0:
domains_without_status_as_string = "{}".format(
", ".join(map(str, outlier_statuses))
) # noqa
logger.warning(
f"""{termColors.YELLOW}
--------------------------------------------
Found {total_outlier_statuses} unaccounted
for statuses-
--------------------------------------------
No mappings found for the following statuses
(defaulted to Ready):
{domains_without_status_as_string}
{termColors.ENDC}"""
)
def print_debug(self, print_condition: bool, print_statement: str):
"""This function reduces complexity of debug statements
in other functions.
It uses the logger to write the given print_statement to the
terminal if print_condition is TRUE"""
# DEBUG:
if print_condition:
logger.info(print_statement)
def prompt_table_reset(self):
"""Brings up a prompt in the terminal asking
if the user wishes to delete data in the
TransitionDomain table. If the user confirms,
deletes all the data in the TransitionDomain table"""
confirm_reset = query_yes_no(
f"""
{termColors.FAIL}
WARNING: Resetting the table will permanently delete all
the data!
Are you sure you want to continue?{termColors.ENDC}"""
)
if confirm_reset:
logger.info(
f"""{termColors.YELLOW}
----------Clearing Table Data----------
(please wait)
{termColors.ENDC}"""
)
TransitionDomain.objects.all().delete()
def handle( # noqa: C901
self,
domain_contacts_filename,
contacts_filename,
domain_statuses_filename,
**options,
):
"""Parse the data files and create TransitionDomains."""
sep = options.get("sep")
# If --resetTable was used, prompt user to confirm
# deletion of table data
if options.get("resetTable"):
self.prompt_table_reset()
# Get --debug argument
debug_on = options.get("debug")
# Get --LimitParse argument
debug_max_entries_to_parse = int(
options.get("limitParse")
) # set to 0 to parse all entries
# print message to terminal about which args are in use
self.print_debug_mode_statements(debug_on, debug_max_entries_to_parse)
# STEP 1:
# Create mapping of domain name -> status
domain_status_dictionary = self.get_domain_user_dict(
domain_statuses_filename, sep
)
# STEP 2:
# Create mapping of userId -> email
user_emails_dictionary = self.get_user_emails_dict(contacts_filename, sep)
# STEP 3:
# Parse the domain_contacts file and create TransitionDomain objects,
# using the dictionaries from steps 1 & 2 to lookup needed information.
to_create = []
# keep track of statuses that don't match our available
# status values
outlier_statuses = []
# keep track of domains that have no known status
domains_without_status = []
# keep track of users that have no e-mails
users_without_email = []
# keep track of duplications..
duplicate_domains = []
duplicate_domain_user_combos = []
# keep track of domains we ADD or UPDATE
total_updated_domain_entries = 0
total_new_entries = 0
# if we are limiting our parse (for testing purposes, keep
# track of total rows parsed)
total_rows_parsed = 0
# Start parsing the main file and create TransitionDomain objects
logger.info("Reading domain-contacts data file %s", domain_contacts_filename)
with open(domain_contacts_filename, "r") as domain_contacts_file:
for row in csv.reader(domain_contacts_file, delimiter=sep):
total_rows_parsed += 1
# fields are just domain, userid, role
# lowercase the domain names
new_entry_domain_name = row[0].lower()
user_id = row[1]
new_entry_status = TransitionDomain.StatusChoices.READY
new_entry_email = ""
new_entry_emailSent = False # set to False by default
# PART 1: Get the status
if new_entry_domain_name not in domain_status_dictionary:
# This domain has no status...default to "Create"
# (For data analysis purposes, add domain name
# to list of all domains without status
# (avoid duplicate entries))
if new_entry_domain_name not in domains_without_status:
domains_without_status.append(new_entry_domain_name)
else:
# Map the status
original_status = domain_status_dictionary[new_entry_domain_name]
mapped_status = self.get_mapped_status(original_status)
if mapped_status is None:
# (For data analysis purposes, check for any statuses
# that don't have a mapping and add to list
# of "outlier statuses")
logger.info("Unknown status: " + original_status)
outlier_statuses.append(original_status)
else:
new_entry_status = mapped_status
# PART 2: Get the e-mail
if user_id not in user_emails_dictionary:
# this user has no e-mail...this should never happen
if user_id not in users_without_email:
users_without_email.append(user_id)
else:
new_entry_email = user_emails_dictionary[user_id]
# PART 3: Create the transition domain object
# Check for duplicate data in the file we are
# parsing so we do not add duplicates
# NOTE: Currently, we allow duplicate domains,
# but not duplicate domain-user pairs.
# However, track duplicate domains for now,
# since we are still deciding on whether
# to make this field unique or not. ~10/25/2023
existing_domain = next(
(x for x in to_create if x.domain_name == new_entry_domain_name),
None,
)
existing_domain_user_pair = next(
(
x
for x in to_create
if x.username == new_entry_email
and x.domain_name == new_entry_domain_name
),
None,
)
if existing_domain is not None:
# DEBUG:
self.print_debug(
debug_on,
f"{termColors.YELLOW} DUPLICATE file entries found for domain: {new_entry_domain_name} {termColors.ENDC}", # noqa
)
if new_entry_domain_name not in duplicate_domains:
duplicate_domains.append(new_entry_domain_name)
if existing_domain_user_pair is not None:
# DEBUG:
self.print_debug(
debug_on,
f"""{termColors.YELLOW} DUPLICATE file entries found for domain - user {termColors.BackgroundLightYellow} PAIR {termColors.ENDC}{termColors.YELLOW}:
{new_entry_domain_name} - {new_entry_email} {termColors.ENDC}""", # noqa
)
if existing_domain_user_pair not in duplicate_domain_user_combos:
duplicate_domain_user_combos.append(existing_domain_user_pair)
else:
entry_exists = TransitionDomain.objects.filter(
username=new_entry_email, domain_name=new_entry_domain_name
).exists()
if entry_exists:
try:
existing_entry = TransitionDomain.objects.get(
username=new_entry_email,
domain_name=new_entry_domain_name,
)
if existing_entry.status != new_entry_status:
# DEBUG:
self.print_debug(
debug_on,
f"{termColors.OKCYAN}"
f"Updating entry: {existing_entry}"
f"Status: {existing_entry.status} > {new_entry_status}" # noqa
f"Email Sent: {existing_entry.email_sent} > {new_entry_emailSent}" # noqa
f"{termColors.ENDC}",
)
existing_entry.status = new_entry_status
existing_entry.email_sent = new_entry_emailSent
existing_entry.save()
except TransitionDomain.MultipleObjectsReturned:
logger.info(
f"{termColors.FAIL}"
f"!!! ERROR: duplicate entries exist in the"
f"transtion_domain table for domain:"
f"{new_entry_domain_name}"
f"----------TERMINATING----------"
)
sys.exit()
else:
# no matching entry, make one
new_entry = TransitionDomain(
username=new_entry_email,
domain_name=new_entry_domain_name,
status=new_entry_status,
email_sent=new_entry_emailSent,
)
to_create.append(new_entry)
total_new_entries += 1
# DEBUG:
self.print_debug(
debug_on,
f"{termColors.OKCYAN} Adding entry {total_new_entries}: {new_entry} {termColors.ENDC}", # noqa
)
# Check Parse limit and exit loop if needed
if (
total_rows_parsed >= debug_max_entries_to_parse
and debug_max_entries_to_parse != 0
):
logger.info(
f"{termColors.YELLOW}"
f"----PARSE LIMIT REACHED. HALTING PARSER.----"
f"{termColors.ENDC}"
)
break
TransitionDomain.objects.bulk_create(to_create)
logger.info(
f"""{termColors.OKGREEN}
============= FINISHED ===============
Created {total_new_entries} transition domain entries,
updated {total_updated_domain_entries} transition domain entries
{termColors.ENDC}
"""
)
# Print a summary of findings (duplicate entries,
# missing data..etc.)
self.print_summary_duplications(
duplicate_domain_user_combos, duplicate_domains, users_without_email
)
self.print_summary_status_findings(domains_without_status, outlier_statuses)

View file

@ -0,0 +1,409 @@
import logging
import argparse
import sys
from django_fsm import TransitionNotAllowed # type: ignore
from django.core.management import BaseCommand
from registrar.models import TransitionDomain
from registrar.models import Domain
from registrar.models import DomainInvitation
logger = logging.getLogger(__name__)
class termColors:
"""Colors for terminal outputs
(makes reading the logs WAY easier)"""
HEADER = "\033[95m"
OKBLUE = "\033[94m"
OKCYAN = "\033[96m"
OKGREEN = "\033[92m"
YELLOW = "\033[93m"
FAIL = "\033[91m"
ENDC = "\033[0m"
BOLD = "\033[1m"
UNDERLINE = "\033[4m"
BackgroundLightYellow = "\033[103m"
class Command(BaseCommand):
help = """Load data from transition domain tables
into main domain tables. Also create domain invitation
entries for every domain we ADD (but not for domains
we UPDATE)"""
def add_arguments(self, parser):
parser.add_argument("--debug", action=argparse.BooleanOptionalAction)
parser.add_argument(
"--limitParse",
default=0,
help="Sets max number of entries to load, set to 0 to load all entries",
)
def print_debug_mode_statements(
self, debug_on: bool, debug_max_entries_to_parse: int
):
"""Prints additional terminal statements to indicate if --debug
or --limitParse are in use"""
self.print_debug(
debug_on,
f"""{termColors.OKCYAN}
----------DEBUG MODE ON----------
Detailed print statements activated.
{termColors.ENDC}
""",
)
self.print_debug(
debug_max_entries_to_parse > 0,
f"""{termColors.OKCYAN}
----------LIMITER ON----------
Parsing of entries will be limited to
{debug_max_entries_to_parse} lines per file.")
Detailed print statements activated.
{termColors.ENDC}
""",
)
def print_debug(self, print_condition: bool, print_statement: str):
"""This function reduces complexity of debug statements
in other functions.
It uses the logger to write the given print_statement to the
terminal if print_condition is TRUE"""
# DEBUG:
if print_condition:
logger.info(print_statement)
def update_domain_status(
self, transition_domain: TransitionDomain, target_domain: Domain, debug_on: bool
) -> bool:
"""Given a transition domain that matches an existing domain,
updates the existing domain object with that status of
the transition domain.
Returns TRUE if an update was made. FALSE if the states
matched and no update was made"""
transition_domain_status = transition_domain.status
existing_status = target_domain.state
if transition_domain_status != existing_status:
if transition_domain_status == TransitionDomain.StatusChoices.ON_HOLD:
target_domain.place_client_hold(ignoreEPP=True)
else:
target_domain.revert_client_hold(ignoreEPP=True)
target_domain.save()
# DEBUG:
self.print_debug(
debug_on,
f"""{termColors.YELLOW}
>> Updated {target_domain.name} state from
'{existing_status}' to '{target_domain.state}'
(no domain invitation entry added)
{termColors.ENDC}""",
)
return True
return False
def print_summary_of_findings(
self,
domains_to_create,
updated_domain_entries,
domain_invitations_to_create,
skipped_domain_entries,
debug_on,
):
"""Prints to terminal a summary of findings from
transferring transition domains to domains"""
total_new_entries = len(domains_to_create)
total_updated_domain_entries = len(updated_domain_entries)
total_domain_invitation_entries = len(domain_invitations_to_create)
logger.info(
f"""{termColors.OKGREEN}
============= FINISHED ===============
Created {total_new_entries} transition domain entries,
Updated {total_updated_domain_entries} transition domain entries
Created {total_domain_invitation_entries} domain invitation entries
(NOTE: no invitations are SENT in this script)
{termColors.ENDC}
"""
)
if len(skipped_domain_entries) > 0:
logger.info(
f"""{termColors.FAIL}
============= SKIPPED DOMAINS (ERRORS) ===============
{skipped_domain_entries}
{termColors.ENDC}
"""
)
# determine domainInvitations we SKIPPED
skipped_domain_invitations = []
for domain in domains_to_create:
skipped_domain_invitations.append(domain)
for domain_invite in domain_invitations_to_create:
if domain_invite.domain in skipped_domain_invitations:
skipped_domain_invitations.remove(domain_invite.domain)
if len(skipped_domain_invitations) > 0:
logger.info(
f"""{termColors.FAIL}
============= SKIPPED DOMAIN INVITATIONS (ERRORS) ===============
{skipped_domain_invitations}
{termColors.ENDC}
"""
)
# DEBUG:
self.print_debug(
debug_on,
f"""{termColors.YELLOW}
Created Domains:
{domains_to_create}
Updated Domains:
{updated_domain_entries}
{termColors.ENDC}
""",
)
def try_add_domain_invitation(
self, domain_email: str, associated_domain: Domain
) -> DomainInvitation | None:
"""If no domain invitation exists for the given domain and
e-mail, create and return a new domain invitation object.
If one already exists, or if the email is invalid, return NONE"""
# this should never happen, but adding it just in case
if associated_domain is None:
logger.warning(
f"""
{termColors.FAIL}
!!! ERROR: Domain cannot be null for a
Domain Invitation object!
RECOMMENDATION:
Somehow, an empty domain object is
being passed to the subroutine in charge
of making domain invitations. Walk through
the code to see what is amiss.
----------TERMINATING----------"""
)
sys.exit()
# check that the given e-mail is valid
if domain_email is not None and domain_email != "":
# check that a domain invitation doesn't already
# exist for this e-mail / Domain pair
domain_email_already_in_domain_invites = DomainInvitation.objects.filter(
email=domain_email.lower(), domain=associated_domain
).exists()
if not domain_email_already_in_domain_invites:
# Create new domain invitation
new_domain_invitation = DomainInvitation(
email=domain_email.lower(), domain=associated_domain
)
return new_domain_invitation
return None
def handle(
self,
**options,
):
"""Parse entries in TransitionDomain table
and create (or update) corresponding entries in the
Domain and DomainInvitation tables."""
# grab command line arguments and store locally...
debug_on = options.get("debug")
debug_max_entries_to_parse = int(
options.get("limitParse")
) # set to 0 to parse all entries
self.print_debug_mode_statements(debug_on, debug_max_entries_to_parse)
# domains to ADD
domains_to_create = []
domain_invitations_to_create = []
# domains we UPDATED
updated_domain_entries = []
# domains we SKIPPED
skipped_domain_entries = []
# if we are limiting our parse (for testing purposes, keep
# track of total rows parsed)
total_rows_parsed = 0
logger.info(
f"""{termColors.OKGREEN}
==========================
Beginning Data Transfer
==========================
{termColors.ENDC}"""
)
for transition_domain in TransitionDomain.objects.all():
transition_domain_name = transition_domain.domain_name
transition_domain_status = transition_domain.status
transition_domain_email = transition_domain.username
# DEBUG:
self.print_debug(
debug_on,
f"""{termColors.OKCYAN}
Processing Transition Domain: {transition_domain_name}, {transition_domain_status}, {transition_domain_email}
{termColors.ENDC}""", # noqa
)
new_domain_invitation = None
# Check for existing domain entry
domain_exists = Domain.objects.filter(name=transition_domain_name).exists()
if domain_exists:
try:
# get the existing domain
domain_to_update = Domain.objects.get(name=transition_domain_name)
# DEBUG:
self.print_debug(
debug_on,
f"""{termColors.YELLOW}
> Found existing entry in Domain table for: {transition_domain_name}, {domain_to_update.state}
{termColors.ENDC}""", # noqa
)
# for existing entry, update the status to
# the transition domain status
update_made = self.update_domain_status(
transition_domain, domain_to_update, debug_on
)
if update_made:
# keep track of updated domains for data analysis purposes
updated_domain_entries.append(transition_domain.domain_name)
# check if we need to add a domain invitation
# (eg. for a new user)
new_domain_invitation = self.try_add_domain_invitation(
transition_domain_email, domain_to_update
)
except Domain.MultipleObjectsReturned:
# This exception was thrown once before during testing.
# While the circumstances that led to corrupt data in
# the domain table was a freak accident, and the possibility of it
# happening again is safe-guarded by a key constraint,
# better to keep an eye out for it since it would require
# immediate attention.
logger.warning(
f"""
{termColors.FAIL}
!!! ERROR: duplicate entries already exist in the
Domain table for the following domain:
{transition_domain_name}
RECOMMENDATION:
This means the Domain table is corrupt. Please
check the Domain table data as there should be a key
constraint which prevents duplicate entries.
----------TERMINATING----------"""
)
sys.exit()
except TransitionNotAllowed as err:
skipped_domain_entries.append(transition_domain_name)
logger.warning(
f"""{termColors.FAIL}
Unable to change state for {transition_domain_name}
RECOMMENDATION:
This indicates there might have been changes to the
Domain model which were not accounted for in this
migration script. Please check state change rules
in the Domain model and ensure we are following the
correct state transition pathways.
INTERNAL ERROR MESSAGE:
'TRANSITION NOT ALLOWED' exception
{err}
----------SKIPPING----------"""
)
else:
# no entry was found in the domain table
# for the given domain. Create a new entry.
# first see if we are already adding an entry for this domain.
# The unique key constraint does not allow duplicate domain entries
# even if there are different users.
existing_domain_in_to_create = next(
(x for x in domains_to_create if x.name == transition_domain_name),
None,
)
if existing_domain_in_to_create is not None:
self.print_debug(
debug_on,
f"""{termColors.YELLOW}
Duplicate Detected: {transition_domain_name}.
Cannot add duplicate entry for another username.
Violates Unique Key constraint.
Checking for unique user e-mail for Domain Invitations...
{termColors.ENDC}""",
)
new_domain_invitation = self.try_add_domain_invitation(
transition_domain_email, existing_domain_in_to_create
)
else:
# no matching entry, make one
new_domain = Domain(
name=transition_domain_name, state=transition_domain_status
)
domains_to_create.append(new_domain)
# DEBUG:
self.print_debug(
debug_on,
f"{termColors.OKCYAN} Adding domain: {new_domain} {termColors.ENDC}", # noqa
)
new_domain_invitation = self.try_add_domain_invitation(
transition_domain_email, new_domain
)
if new_domain_invitation is None:
logger.info(
f"{termColors.YELLOW} ! No new e-mail detected !" # noqa
f"(SKIPPED ADDING DOMAIN INVITATION){termColors.ENDC}"
)
else:
# DEBUG:
self.print_debug(
debug_on,
f"{termColors.OKCYAN} Adding domain invitation: {new_domain_invitation} {termColors.ENDC}", # noqa
)
domain_invitations_to_create.append(new_domain_invitation)
# Check parse limit and exit loop if parse limit has been reached
if (
debug_max_entries_to_parse > 0
and total_rows_parsed >= debug_max_entries_to_parse
):
logger.info(
f"""{termColors.YELLOW}
----PARSE LIMIT REACHED. HALTING PARSER.----
{termColors.ENDC}
"""
)
break
Domain.objects.bulk_create(domains_to_create)
DomainInvitation.objects.bulk_create(domain_invitations_to_create)
self.print_summary_of_findings(
domains_to_create,
updated_domain_entries,
domain_invitations_to_create,
skipped_domain_entries,
debug_on,
)

View file

@ -0,0 +1,39 @@
# Generated by Django 4.2.1 on 2023-09-20 19:04
import django.contrib.auth.models
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
("registrar", "0033_alter_userdomainrole_role"),
]
operations = [
migrations.CreateModel(
name="UserGroup",
fields=[
(
"group_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="auth.group",
),
),
],
options={
"verbose_name": "User group",
"verbose_name_plural": "User groups",
},
bases=("auth.group",),
managers=[
("objects", django.contrib.auth.models.GroupManager()),
],
),
]

View file

@ -0,0 +1,21 @@
# Generated by Django 4.2.1 on 2023-09-27 18:53
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("registrar", "0034_usergroup"),
]
operations = [
migrations.AlterModelOptions(
name="user",
options={
"permissions": [
("analyst_access_permission", "Analyst Access Permission"),
("full_access_permission", "Full Access Permission"),
]
},
),
]

View file

@ -0,0 +1,43 @@
# From mithuntnt's answer on:
# https://stackoverflow.com/questions/26464838/getting-model-contenttype-in-migration-django-1-7
# The problem is that ContentType and Permission objects are not already created
# while we're still running migrations, so we'll go ahead and speed up that process
# a bit before we attempt to create groups which require Permissions and ContentType.
from django.conf import settings
from django.db import migrations
def create_all_contenttypes(**kwargs):
from django.apps import apps
from django.contrib.contenttypes.management import create_contenttypes
for app_config in apps.get_app_configs():
create_contenttypes(app_config, **kwargs)
def create_all_permissions(**kwargs):
from django.contrib.auth.management import create_permissions
from django.apps import apps
for app_config in apps.get_app_configs():
create_permissions(app_config, **kwargs)
def forward(apps, schema_editor):
create_all_contenttypes()
create_all_permissions()
def backward(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("contenttypes", "0002_remove_content_type_name"),
("registrar", "0035_alter_user_options"),
]
operations = [migrations.RunPython(forward, backward)]

View file

@ -0,0 +1,37 @@
# This migration creates the create_full_access_group and create_cisa_analyst_group groups
# It is dependent on 0035 (which populates ContentType and Permissions)
# If permissions on the groups need changing, edit CISA_ANALYST_GROUP_PERMISSIONS
# in the user_group model then:
# [NOT RECOMMENDED]
# step 1: docker-compose exec app ./manage.py migrate --fake registrar 0035_contenttypes_permissions
# step 2: docker-compose exec app ./manage.py migrate registrar 0036_create_groups
# step 3: fake run the latest migration in the migrations list
# [RECOMMENDED]
# Alternatively:
# step 1: duplicate the migration that loads data
# step 2: docker-compose exec app ./manage.py migrate
from django.db import migrations
from registrar.models import UserGroup
from typing import Any
# For linting: RunPython expects a function reference,
# so let's give it one
def create_groups(apps, schema_editor) -> Any:
UserGroup.create_cisa_analyst_group(apps, schema_editor)
UserGroup.create_full_access_group(apps, schema_editor)
class Migration(migrations.Migration):
dependencies = [
("registrar", "0036_contenttypes_permissions"),
]
operations = [
migrations.RunPython(
create_groups,
reverse_code=migrations.RunPython.noop,
atomic=True,
),
]

View file

@ -0,0 +1,37 @@
# This migration creates the create_full_access_group and create_cisa_analyst_group groups
# It is dependent on 0035 (which populates ContentType and Permissions)
# If permissions on the groups need changing, edit CISA_ANALYST_GROUP_PERMISSIONS
# in the user_group model then:
# [NOT RECOMMENDED]
# step 1: docker-compose exec app ./manage.py migrate --fake registrar 0035_contenttypes_permissions
# step 2: docker-compose exec app ./manage.py migrate registrar 0036_create_groups
# step 3: fake run the latest migration in the migrations list
# [RECOMMENDED]
# Alternatively:
# step 1: duplicate the migration that loads data
# step 2: docker-compose exec app ./manage.py migrate
from django.db import migrations
from registrar.models import UserGroup
from typing import Any
# For linting: RunPython expects a function reference,
# so let's give it one
def create_groups(apps, schema_editor) -> Any:
UserGroup.create_cisa_analyst_group(apps, schema_editor)
UserGroup.create_full_access_group(apps, schema_editor)
class Migration(migrations.Migration):
dependencies = [
("registrar", "0037_create_groups_v01"),
]
operations = [
migrations.RunPython(
create_groups,
reverse_code=migrations.RunPython.noop,
atomic=True,
),
]

View file

@ -0,0 +1,22 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("registrar", "0038_create_groups_v02"),
]
operations = [
migrations.AlterField(
model_name="transitiondomain",
name="status",
field=models.CharField(
blank=True,
choices=[("ready", "Ready"), ("on hold", "On Hold")],
default="ready",
help_text="domain status during the transfer",
max_length=255,
verbose_name="Status",
),
),
]

View file

@ -12,6 +12,7 @@ from .nameserver import Nameserver
from .user_domain_role import UserDomainRole
from .public_contact import PublicContact
from .user import User
from .user_group import UserGroup
from .website import Website
from .transition_domain import TransitionDomain
@ -28,6 +29,7 @@ __all__ = [
"UserDomainRole",
"PublicContact",
"User",
"UserGroup",
"Website",
"TransitionDomain",
]
@ -42,6 +44,7 @@ auditlog.register(Host)
auditlog.register(Nameserver)
auditlog.register(UserDomainRole)
auditlog.register(PublicContact)
auditlog.register(User)
auditlog.register(User, m2m_fields=["user_permissions", "groups"])
auditlog.register(UserGroup, m2m_fields=["permissions"])
auditlog.register(Website)
auditlog.register(TransitionDomain)

View file

@ -1,11 +1,14 @@
from itertools import zip_longest
import logging
import ipaddress
import re
from datetime import date
from string import digits
from typing import Optional
from django_fsm import FSMField, transition, TransitionNotAllowed # type: ignore
from django.db import models
from typing import Any
from epplibwrapper import (
CLIENT as registry,
commands,
@ -15,7 +18,15 @@ from epplibwrapper import (
RegistryError,
ErrorCode,
)
from registrar.models.utility.contact_error import ContactError
from registrar.utility.errors import (
ActionNotAllowed,
NameserverError,
NameserverErrorCodes as nsErrorCodes,
)
from registrar.models.utility.contact_error import ContactError, ContactErrorCodes
from .utility.domain_field import DomainField
from .utility.domain_helper import DomainHelper
@ -218,13 +229,13 @@ class Domain(TimeStampedModel, DomainHelper):
raise NotImplementedError()
@Cache
def nameservers(self) -> list[tuple[str]]:
def nameservers(self) -> list[tuple[str, list]]:
"""
Get or set a complete list of nameservers for this domain.
Hosts are provided as a list of tuples, e.g.
[("ns1.example.com",), ("ns1.example.gov", "0.0.0.0")]
[("ns1.example.com",), ("ns1.example.gov", ["0.0.0.0"])]
Subordinate hosts (something.your-domain.gov) MUST have IP addresses,
while non-subordinate hosts MUST NOT.
@ -232,42 +243,23 @@ class Domain(TimeStampedModel, DomainHelper):
try:
hosts = self._get_property("hosts")
except Exception as err:
# Don't throw error as this is normal for a new domain
# TODO - 433 error handling ticket should address this
# Do not raise error when missing nameservers
# this is a standard occurence when a domain
# is first created
logger.info("Domain is missing nameservers %s" % err)
return []
# TODO-687 fix this return value
hostList = []
for host in hosts:
# TODO - this should actually have a second tuple value with the ip address
# ignored because uncertain if we will even have a way to display mult.
# and adresses can be a list of mult address
hostList.append((host["name"],))
hostList.append((host["name"], host["addrs"]))
return hostList
def _check_host(self, hostnames: list[str]):
"""check if host is available, True if available
returns boolean"""
checkCommand = commands.CheckHost(hostnames)
try:
response = registry.send(checkCommand, cleaned=True)
return response.res_data[0].avail
except RegistryError as err:
logger.warning(
"Couldn't check hosts %s. Errorcode was %s, error was %s",
hostnames,
err.code,
err,
)
return False
def _create_host(self, host, addrs):
"""Call _check_host first before using this function,
This creates the host object in the registry
"""Creates the host object in the registry
doesn't add the created host to the domain
returns ErrorCode (int)"""
logger.info("Creating host")
if addrs is not None:
addresses = [epp.Ip(addr=addr) for addr in addrs]
request = commands.CreateHost(name=host, addrs=addresses)
@ -282,76 +274,381 @@ class Domain(TimeStampedModel, DomainHelper):
logger.error("Error _create_host, code was %s error was %s" % (e.code, e))
return e.code
def _convert_list_to_dict(self, listToConvert: list[tuple[str, list]]):
"""converts a list of hosts into a dictionary
Args:
list[tuple[str, list]]: such as [("123",["1","2","3"])]
This is the list of hosts to convert
returns:
convertDict (dict(str,list))- such as{"123":["1","2","3"]}"""
newDict: dict[str, Any] = {}
for tup in listToConvert:
if len(tup) == 1:
newDict[tup[0]] = None
elif len(tup) == 2:
newDict[tup[0]] = tup[1]
return newDict
def isSubdomain(self, nameserver: str):
"""Returns boolean if the domain name is found in the argument passed"""
subdomain_pattern = r"([\w-]+\.)*"
full_pattern = subdomain_pattern + self.name
regex = re.compile(full_pattern)
return bool(regex.match(nameserver))
def checkHostIPCombo(self, nameserver: str, ip: list[str]):
"""Checks the parameters past for a valid combination
raises error if:
- nameserver is a subdomain but is missing ip
- nameserver is not a subdomain but has ip
- nameserver is a subdomain but an ip passed is invalid
Args:
hostname (str)- nameserver or subdomain
ip (list[str])-list of ip strings
Throws:
NameserverError (if exception hit)
Returns:
None"""
if self.isSubdomain(nameserver) and (ip is None or ip == []):
raise NameserverError(code=nsErrorCodes.MISSING_IP, nameserver=nameserver)
elif not self.isSubdomain(nameserver) and (ip is not None and ip != []):
raise NameserverError(
code=nsErrorCodes.GLUE_RECORD_NOT_ALLOWED, nameserver=nameserver, ip=ip
)
elif ip is not None and ip != []:
for addr in ip:
if not self._valid_ip_addr(addr):
raise NameserverError(
code=nsErrorCodes.INVALID_IP, nameserver=nameserver, ip=ip
)
return None
def _valid_ip_addr(self, ipToTest: str):
"""returns boolean if valid ip address string
We currently only accept v4 or v6 ips
returns:
isValid (boolean)-True for valid ip address"""
try:
ip = ipaddress.ip_address(ipToTest)
return ip.version == 6 or ip.version == 4
except ValueError:
return False
def getNameserverChanges(
self, hosts: list[tuple[str, list]]
) -> tuple[list, list, dict, dict]:
"""
calls self.nameserver, it should pull from cache but may result
in an epp call
Args:
hosts: list[tuple[str, list]] such as [("123",["1","2","3"])]
Throws:
NameserverError (if exception hit)
Returns:
tuple[list, list, dict, dict]
These four tuple values as follows:
deleted_values: list[str]
updated_values: list[str]
new_values: dict(str,list)
prevHostDict: dict(str,list)"""
oldNameservers = self.nameservers
previousHostDict = self._convert_list_to_dict(oldNameservers)
newHostDict = self._convert_list_to_dict(hosts)
deleted_values = []
# TODO-currently a list of tuples, why not dict? for consistency
updated_values = []
new_values = {}
for prevHost in previousHostDict:
addrs = previousHostDict[prevHost]
# get deleted values-which are values in previous nameserver list
# but are not in the list of new host values
if prevHost not in newHostDict:
deleted_values.append(prevHost)
# if the host exists in both, check if the addresses changed
else:
# TODO - host is being updated when previous was None+new is empty list
# add check here
if newHostDict[prevHost] is not None and set(
newHostDict[prevHost]
) != set(addrs):
self.checkHostIPCombo(nameserver=prevHost, ip=newHostDict[prevHost])
updated_values.append((prevHost, newHostDict[prevHost]))
new_values = {
key: newHostDict.get(key)
for key in newHostDict
if key not in previousHostDict and key.strip() != ""
}
for nameserver, ip in new_values.items():
self.checkHostIPCombo(nameserver=nameserver, ip=ip)
return (deleted_values, updated_values, new_values, previousHostDict)
def _update_host_values(self, updated_values, oldNameservers):
for hostTuple in updated_values:
updated_response_code = self._update_host(
hostTuple[0], hostTuple[1], oldNameservers.get(hostTuple[0])
)
if updated_response_code not in [
ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY,
ErrorCode.OBJECT_EXISTS,
]:
logger.warning(
"Could not update host %s. Error code was: %s "
% (hostTuple[0], updated_response_code)
)
def createNewHostList(self, new_values: dict):
"""convert the dictionary of new values to a list of HostObjSet
for use in the UpdateDomain epp message
Args:
new_values: dict(str,list)- dict of {nameserver:ips} to add to domain
Returns:
tuple [list[epp.HostObjSet], int]
list[epp.HostObjSet]-epp object for use in the UpdateDomain epp message
defaults to empty list
int-number of items being created default 0
"""
hostStringList = []
for key, value in new_values.items():
createdCode = self._create_host(
host=key, addrs=value
) # creates in registry
if (
createdCode == ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY
or createdCode == ErrorCode.OBJECT_EXISTS
):
hostStringList.append(key)
if hostStringList == []:
return [], 0
addToDomainObject = epp.HostObjSet(hosts=hostStringList)
return [addToDomainObject], len(hostStringList)
def createDeleteHostList(self, hostsToDelete: list[str]):
"""
Args:
hostsToDelete (list[str])- list of nameserver/host names to remove
Returns:
tuple [list[epp.HostObjSet], int]
list[epp.HostObjSet]-epp object for use in the UpdateDomain epp message
defaults to empty list
int-number of items being created default 0
"""
deleteStrList = []
for nameserver in hostsToDelete:
deleteStrList.append(nameserver)
if deleteStrList == []:
return [], 0
deleteObj = epp.HostObjSet(hosts=hostsToDelete)
return [deleteObj], len(deleteStrList)
@Cache
def dnssecdata(self) -> extensions.DNSSECExtension:
return self._get_property("dnssecdata")
def dnssecdata(self) -> Optional[extensions.DNSSECExtension]:
"""
Get a complete list of dnssecdata extensions for this domain.
dnssecdata are provided as a list of DNSSECExtension objects.
A DNSSECExtension object includes:
maxSigLife: Optional[int]
dsData: Optional[Sequence[DSData]]
keyData: Optional[Sequence[DNSSECKeyData]]
"""
try:
return self._get_property("dnssecdata")
except Exception as err:
# Don't throw error as this is normal for a new domain
logger.info("Domain does not have dnssec data defined %s" % err)
return None
def getDnssecdataChanges(
self, _dnssecdata: Optional[extensions.DNSSECExtension]
) -> tuple[dict, dict]:
"""
calls self.dnssecdata, it should pull from cache but may result
in an epp call
returns tuple of 2 values as follows:
addExtension: dict
remExtension: dict
addExtension includes all dsData or keyData to be added
remExtension includes all dsData or keyData to be removed
method operates on dsData OR keyData, never a mix of the two;
operates based on which is present in _dnssecdata;
if neither is present, addExtension will be empty dict, and
remExtension will be all existing dnssecdata to be deleted
"""
oldDnssecdata = self.dnssecdata
addDnssecdata: dict = {}
remDnssecdata: dict = {}
if _dnssecdata and _dnssecdata.dsData is not None:
# initialize addDnssecdata and remDnssecdata for dsData
addDnssecdata["dsData"] = _dnssecdata.dsData
if oldDnssecdata and len(oldDnssecdata.dsData) > 0:
# if existing dsData not in new dsData, mark for removal
dsDataForRemoval = [
dsData
for dsData in oldDnssecdata.dsData
if dsData not in _dnssecdata.dsData
]
if len(dsDataForRemoval) > 0:
remDnssecdata["dsData"] = dsDataForRemoval
# if new dsData not in existing dsData, mark for add
dsDataForAdd = [
dsData
for dsData in _dnssecdata.dsData
if dsData not in oldDnssecdata.dsData
]
if len(dsDataForAdd) > 0:
addDnssecdata["dsData"] = dsDataForAdd
else:
addDnssecdata["dsData"] = None
elif _dnssecdata and _dnssecdata.keyData is not None:
# initialize addDnssecdata and remDnssecdata for keyData
addDnssecdata["keyData"] = _dnssecdata.keyData
if oldDnssecdata and len(oldDnssecdata.keyData) > 0:
# if existing keyData not in new keyData, mark for removal
keyDataForRemoval = [
keyData
for keyData in oldDnssecdata.keyData
if keyData not in _dnssecdata.keyData
]
if len(keyDataForRemoval) > 0:
remDnssecdata["keyData"] = keyDataForRemoval
# if new keyData not in existing keyData, mark for add
keyDataForAdd = [
keyData
for keyData in _dnssecdata.keyData
if keyData not in oldDnssecdata.keyData
]
if len(keyDataForAdd) > 0:
addDnssecdata["keyData"] = keyDataForAdd
else:
# there are no new dsData or keyData, remove all
remDnssecdata["dsData"] = getattr(oldDnssecdata, "dsData", None)
remDnssecdata["keyData"] = getattr(oldDnssecdata, "keyData", None)
return addDnssecdata, remDnssecdata
@dnssecdata.setter # type: ignore
def dnssecdata(self, _dnssecdata: extensions.DNSSECExtension):
updateParams = {
"maxSigLife": _dnssecdata.get("maxSigLife", None),
"dsData": _dnssecdata.get("dsData", None),
"keyData": _dnssecdata.get("keyData", None),
"remAllDsKeyData": True,
def dnssecdata(self, _dnssecdata: Optional[extensions.DNSSECExtension]):
_addDnssecdata, _remDnssecdata = self.getDnssecdataChanges(_dnssecdata)
addParams = {
"maxSigLife": _addDnssecdata.get("maxSigLife", None),
"dsData": _addDnssecdata.get("dsData", None),
"keyData": _addDnssecdata.get("keyData", None),
}
request = commands.UpdateDomain(name=self.name)
extension = commands.UpdateDomainDNSSECExtension(**updateParams)
request.add_extension(extension)
remParams = {
"maxSigLife": _remDnssecdata.get("maxSigLife", None),
"remDsData": _remDnssecdata.get("dsData", None),
"remKeyData": _remDnssecdata.get("keyData", None),
}
addRequest = commands.UpdateDomain(name=self.name)
addExtension = commands.UpdateDomainDNSSECExtension(**addParams)
addRequest.add_extension(addExtension)
remRequest = commands.UpdateDomain(name=self.name)
remExtension = commands.UpdateDomainDNSSECExtension(**remParams)
remRequest.add_extension(remExtension)
try:
registry.send(request, cleaned=True)
if (
"dsData" in _addDnssecdata
and _addDnssecdata["dsData"] is not None
or "keyData" in _addDnssecdata
and _addDnssecdata["keyData"] is not None
):
registry.send(addRequest, cleaned=True)
if (
"dsData" in _remDnssecdata
and _remDnssecdata["dsData"] is not None
or "keyData" in _remDnssecdata
and _remDnssecdata["keyData"] is not None
):
registry.send(remRequest, cleaned=True)
except RegistryError as e:
logger.error("Error adding DNSSEC, code was %s error was %s" % (e.code, e))
logger.error(
"Error updating DNSSEC, code was %s error was %s" % (e.code, e)
)
raise e
@nameservers.setter # type: ignore
def nameservers(self, hosts: list[tuple[str]]):
"""host should be a tuple of type str, str,... where the elements are
def nameservers(self, hosts: list[tuple[str, list]]):
"""Host should be a tuple of type str, str,... where the elements are
Fully qualified host name, addresses associated with the host
example: [(ns1.okay.gov, 127.0.0.1, others ips)]"""
# TODO: ticket #848 finish this implementation
# must delete nameservers as well or update
# ip version checking may need to be added in a different ticket
example: [(ns1.okay.gov, [127.0.0.1, others ips])]"""
if len(hosts) > 13:
raise ValueError(
"Too many hosts provided, you may not have more than 13 nameservers."
)
raise NameserverError(code=nsErrorCodes.TOO_MANY_HOSTS)
if self.state not in [self.State.DNS_NEEDED, self.State.READY]:
raise ActionNotAllowed("Nameservers can not be " "set in the current state")
logger.info("Setting nameservers")
logger.info(hosts)
for hostTuple in hosts:
host = hostTuple[0]
addrs = None
if len(hostTuple) > 1:
addrs = hostTuple[1:]
avail = self._check_host([host])
if avail:
createdCode = self._create_host(host=host, addrs=addrs)
# update the domain obj
if createdCode == ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY:
# add host to domain
request = commands.UpdateDomain(
name=self.name, add=[epp.HostObjSet([host])]
)
# get the changes made by user and old nameserver values
(
deleted_values,
updated_values,
new_values,
oldNameservers,
) = self.getNameserverChanges(hosts=hosts)
try:
registry.send(request, cleaned=True)
except RegistryError as e:
logger.error(
"Error adding nameserver, code was %s error was %s"
% (e.code, e)
)
_ = self._update_host_values(
updated_values, oldNameservers
) # returns nothing, just need to be run and errors
addToDomainList, addToDomainCount = self.createNewHostList(new_values)
deleteHostList, deleteCount = self.createDeleteHostList(deleted_values)
responseCode = self.addAndRemoveHostsFromDomain(
hostsToAdd=addToDomainList, hostsToDelete=deleteHostList
)
try:
self.ready()
self.save()
except Exception as err:
logger.info(
"nameserver setter checked for create state "
"and it did not succeed. Error: %s" % err
)
# TODO - handle removed nameservers here will need to change the state
# then go back to DNS_NEEDED
# if unable to update domain raise error and stop
if responseCode != ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY:
raise NameserverError(code=nsErrorCodes.UNABLE_TO_UPDATE_DOMAIN)
successTotalNameservers = len(oldNameservers) - deleteCount + addToDomainCount
self._delete_hosts_if_not_used(hostsToDelete=deleted_values)
if successTotalNameservers < 2:
try:
self.dns_needed()
self.save()
except Exception as err:
logger.info(
"nameserver setter checked for dns_needed state "
"and it did not succeed. Warning: %s" % err
)
elif successTotalNameservers >= 2 and successTotalNameservers <= 13:
try:
self.ready()
self.save()
except Exception as err:
logger.info(
"nameserver setter checked for create state "
"and it did not succeed. Warning: %s" % err
)
@Cache
def statuses(self) -> list[str]:
@ -520,7 +817,7 @@ class Domain(TimeStampedModel, DomainHelper):
and errorCode != ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY
):
# TODO- ticket #433 look here for error handling
raise Exception("Unable to add contact to registry")
raise RegistryError(code=errorCode)
# contact doesn't exist on the domain yet
logger.info("_set_singleton_contact()-> contact has been added to the registry")
@ -625,7 +922,10 @@ class Domain(TimeStampedModel, DomainHelper):
def get_security_email(self):
logger.info("get_security_email-> getting the contact ")
secContact = self.security_contact
return secContact.email
if secContact is not None:
return secContact.email
else:
return None
def clientHoldStatus(self):
return epp.Status(state=self.Status.CLIENT_HOLD, description="", lang="en")
@ -698,10 +998,10 @@ class Domain(TimeStampedModel, DomainHelper):
return None
if contact_type is None:
raise ContactError("contact_type is None")
raise ContactError(code=ContactErrorCodes.CONTACT_TYPE_NONE)
if contact_id is None:
raise ContactError("contact_id is None")
raise ContactError(code=ContactErrorCodes.CONTACT_ID_NONE)
# Since contact_id is registry_id,
# check that its the right length
@ -710,14 +1010,10 @@ class Domain(TimeStampedModel, DomainHelper):
contact_id_length > PublicContact.get_max_id_length()
or contact_id_length < 1
):
raise ContactError(
"contact_id is of invalid length. "
"Cannot exceed 16 characters, "
f"got {contact_id} with a length of {contact_id_length}"
)
raise ContactError(code=ContactErrorCodes.CONTACT_ID_INVALID_LENGTH)
if not isinstance(contact, eppInfo.InfoContactResultData):
raise ContactError("Contact must be of type InfoContactResultData")
raise ContactError(code=ContactErrorCodes.CONTACT_INVALID_TYPE)
auth_info = contact.auth_info
postal_info = contact.postal_info
@ -881,8 +1177,8 @@ class Domain(TimeStampedModel, DomainHelper):
return self._handle_registrant_contact(desired_contact)
_registry_id: str
if contact_type in contacts:
_registry_id: str = ""
if contacts is not None and contact_type in contacts:
_registry_id = contacts.get(contact_type)
desired = PublicContact.objects.filter(
@ -948,7 +1244,6 @@ class Domain(TimeStampedModel, DomainHelper):
count = 0
while not exitEarly and count < 3:
try:
logger.info("Getting domain info from epp")
req = commands.InfoDomain(name=self.name)
domainInfoResponse = registry.send(req, cleaned=True)
exitEarly = True
@ -964,7 +1259,7 @@ class Domain(TimeStampedModel, DomainHelper):
if e.code == ErrorCode.OBJECT_DOES_NOT_EXIST:
# avoid infinite loop
already_tried_to_create = True
self.pendingCreate()
self.dns_needed_from_unknown()
self.save()
else:
logger.error(e)
@ -978,7 +1273,7 @@ class Domain(TimeStampedModel, DomainHelper):
return registrant.registry_id
@transition(field="state", source=State.UNKNOWN, target=State.DNS_NEEDED)
def pendingCreate(self):
def dns_needed_from_unknown(self):
logger.info("Changing to dns_needed")
registrantID = self.addRegistrant()
@ -1011,20 +1306,29 @@ class Domain(TimeStampedModel, DomainHelper):
@transition(
field="state", source=[State.READY, State.ON_HOLD], target=State.ON_HOLD
)
def place_client_hold(self):
"""place a clienthold on a domain (no longer should resolve)"""
def place_client_hold(self, ignoreEPP=False):
"""place a clienthold on a domain (no longer should resolve)
ignoreEPP (boolean) - set to true to by-pass EPP (used for transition domains)
"""
# TODO - ensure all requirements for client hold are made here
# (check prohibited statuses)
logger.info("clientHold()-> inside clientHold")
self._place_client_hold()
# In order to allow transition domains to by-pass EPP calls,
# include this ignoreEPP flag
if not ignoreEPP:
self._place_client_hold()
# TODO -on the client hold ticket any additional error handling here
@transition(field="state", source=[State.READY, State.ON_HOLD], target=State.READY)
def revert_client_hold(self):
"""undo a clienthold placed on a domain"""
def revert_client_hold(self, ignoreEPP=False):
"""undo a clienthold placed on a domain
ignoreEPP (boolean) - set to true to by-pass EPP (used for transition domains)
"""
logger.info("clientHold()-> inside clientHold")
self._remove_client_hold()
if not ignoreEPP:
self._remove_client_hold()
# TODO -on the client hold ticket any additional error handling here
@transition(
@ -1054,26 +1358,54 @@ class Domain(TimeStampedModel, DomainHelper):
else:
self._invalidate_cache()
# def is_dns_needed(self):
# """Commented out and kept in the codebase
# as this call should be made, but adds
# a lot of processing time
# when EPP calling is made more efficient
# this should be added back in
# The goal is to double check that
# the nameservers we set are in fact
# on the registry
# """
# self._invalidate_cache()
# nameserverList = self.nameservers
# return len(nameserverList) < 2
# def dns_not_needed(self):
# return not self.is_dns_needed()
@transition(
field="state",
source=[State.DNS_NEEDED],
target=State.READY,
# conditions=[dns_not_needed]
)
def ready(self):
"""Transition to the ready state
domain should have nameservers and all contacts
and now should be considered live on a domain
"""
# TODO - in nameservers tickets 848 and 562
# check here if updates need to be made
# consider adding these checks as constraints
# within the transistion itself
nameserverList = self.nameservers
logger.info("Changing to ready state")
if len(nameserverList) < 2 or len(nameserverList) > 13:
raise ValueError("Not ready to become created, cannot transition yet")
logger.info("able to transition to ready state")
@transition(
field="state",
source=[State.READY],
target=State.DNS_NEEDED,
# conditions=[is_dns_needed]
)
def dns_needed(self):
"""Transition to the DNS_NEEDED state
domain should NOT have nameservers but
SHOULD have all contacts
Going to check nameservers and will
result in an EPP call
"""
logger.info("Changing to DNS_NEEDED state")
logger.info("able to transition to DNS_NEEDED state")
def _disclose_fields(self, contact: PublicContact):
"""creates a disclose object that can be added to a contact Create using
.disclose= <this function> on the command before sending.
@ -1197,6 +1529,10 @@ class Domain(TimeStampedModel, DomainHelper):
raise e
def is_ipv6(self, ip: str):
ip_addr = ipaddress.ip_address(ip)
return ip_addr.version == 6
def _fetch_hosts(self, host_data):
"""Fetch host info."""
hosts = []
@ -1214,84 +1550,214 @@ class Domain(TimeStampedModel, DomainHelper):
hosts.append({k: v for k, v in host.items() if v is not ...})
return hosts
def _update_or_create_host(self, host):
raise NotImplementedError()
def _convert_ips(self, ip_list: list[str]):
"""Convert Ips to a list of epp.Ip objects
use when sending update host command.
if there are no ips an empty list will be returned
def _delete_host(self, host):
raise NotImplementedError()
Args:
ip_list (list[str]): the new list of ips, may be empty
Returns:
edited_ip_list (list[epp.Ip]): list of epp.ip objects ready to
be sent to the registry
"""
edited_ip_list = []
if ip_list is None:
return []
for ip_addr in ip_list:
if self.is_ipv6(ip_addr):
edited_ip_list.append(epp.Ip(addr=ip_addr, ip="v6"))
else: # default ip addr is v4
edited_ip_list.append(epp.Ip(addr=ip_addr))
return edited_ip_list
def _update_host(self, nameserver: str, ip_list: list[str], old_ip_list: list[str]):
"""Update an existing host object in EPP. Sends the update host command
can result in a RegistryError
Args:
nameserver (str): nameserver or subdomain
ip_list (list[str]): the new list of ips, may be empty
old_ip_list (list[str]): the old ip list, may also be empty
Returns:
errorCode (int): one of ErrorCode enum type values
"""
try:
if (
ip_list is None
or len(ip_list) == 0
and isinstance(old_ip_list, list)
and len(old_ip_list) != 0
):
return ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY
added_ip_list = set(ip_list).difference(old_ip_list)
removed_ip_list = set(old_ip_list).difference(ip_list)
request = commands.UpdateHost(
name=nameserver,
add=self._convert_ips(list(added_ip_list)),
rem=self._convert_ips(list(removed_ip_list)),
)
response = registry.send(request, cleaned=True)
logger.info("_update_host()-> sending req as %s" % request)
return response.code
except RegistryError as e:
logger.error("Error _update_host, code was %s error was %s" % (e.code, e))
return e.code
def addAndRemoveHostsFromDomain(
self, hostsToAdd: list[str], hostsToDelete: list[str]
):
"""sends an UpdateDomain message to the registry with the hosts provided
Args:
hostsToDelete (list[epp.HostObjSet])- list of host objects to delete
hostsToAdd (list[epp.HostObjSet])- list of host objects to add
Returns:
response code (int)- RegistryErrorCode integer value
defaults to return COMMAND_COMPLETED_SUCCESSFULLY
if there is nothing to add or delete
"""
if hostsToAdd == [] and hostsToDelete == []:
return ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY
try:
updateReq = commands.UpdateDomain(
name=self.name, rem=hostsToDelete, add=hostsToAdd
)
logger.info(
"addAndRemoveHostsFromDomain()-> sending update domain req as %s"
% updateReq
)
response = registry.send(updateReq, cleaned=True)
return response.code
except RegistryError as e:
logger.error(
"Error addAndRemoveHostsFromDomain, code was %s error was %s"
% (e.code, e)
)
return e.code
def _delete_hosts_if_not_used(self, hostsToDelete: list[str]):
"""delete the host object in registry,
will only delete the host object, if it's not being used by another domain
Performs just the DeleteHost epp call
Supresses regstry error, as registry can disallow delete for various reasons
Args:
hostsToDelete (list[str])- list of nameserver/host names to remove
Returns:
None
"""
try:
for nameserver in hostsToDelete:
deleteHostReq = commands.DeleteHost(name=nameserver)
registry.send(deleteHostReq, cleaned=True)
logger.info(
"_delete_hosts_if_not_used()-> sending delete host req as %s"
% deleteHostReq
)
except RegistryError as e:
if e.code == ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION:
logger.info(
"Did not remove host %s because it is in use on another domain."
% nameserver
)
else:
logger.error(
"Error _delete_hosts_if_not_used, code was %s error was %s"
% (e.code, e)
)
def _fetch_cache(self, fetch_hosts=False, fetch_contacts=False):
"""Contact registry for info about a domain."""
try:
# get info from registry
dataResponse = self._get_or_create_domain()
data = dataResponse.res_data[0]
# extract properties from response
# (Ellipsis is used to mean "null")
cache = {
"auth_info": getattr(data, "auth_info", ...),
"_contacts": getattr(data, "contacts", ...),
"cr_date": getattr(data, "cr_date", ...),
"ex_date": getattr(data, "ex_date", ...),
"_hosts": getattr(data, "hosts", ...),
"name": getattr(data, "name", ...),
"registrant": getattr(data, "registrant", ...),
"statuses": getattr(data, "statuses", ...),
"tr_date": getattr(data, "tr_date", ...),
"up_date": getattr(data, "up_date", ...),
}
# remove null properties (to distinguish between "a value of None" and null)
cleaned = {k: v for k, v in cache.items() if v is not ...}
data_response = self._get_or_create_domain()
cache = self._extract_data_from_response(data_response)
# remove null properties (to distinguish between "a value of None" and null)
cleaned = self._remove_null_properties(cache)
# statuses can just be a list no need to keep the epp object
if "statuses" in cleaned:
cleaned["statuses"] = [status.state for status in cleaned["statuses"]]
# get extensions info, if there is any
# DNSSECExtension is one possible extension, make sure to handle
# only DNSSECExtension and not other type extensions
returned_extensions = dataResponse.extensions
cleaned["dnssecdata"] = None
for extension in returned_extensions:
if isinstance(extension, extensions.DNSSECExtension):
cleaned["dnssecdata"] = extension
cleaned["dnssecdata"] = self._get_dnssec_data(data_response.extensions)
# Capture and store old hosts and contacts from cache if they exist
old_cache_hosts = self._cache.get("hosts")
old_cache_contacts = self._cache.get("contacts")
# get contact info, if there are any
if (
fetch_contacts
and "_contacts" in cleaned
and isinstance(cleaned["_contacts"], list)
and len(cleaned["_contacts"]) > 0
):
cleaned["contacts"] = self._fetch_contacts(cleaned["_contacts"])
# We're only getting contacts, so retain the old
# hosts that existed in cache (if they existed)
# and pass them along.
if fetch_contacts:
cleaned["contacts"] = self._get_contacts(cleaned.get("_contacts", []))
if old_cache_hosts is not None:
logger.debug("resetting cleaned['hosts'] to old_cache_hosts")
cleaned["hosts"] = old_cache_hosts
# get nameserver info, if there are any
if (
fetch_hosts
and "_hosts" in cleaned
and isinstance(cleaned["_hosts"], list)
and len(cleaned["_hosts"])
):
cleaned["hosts"] = self._fetch_hosts(cleaned["_hosts"])
# We're only getting hosts, so retain the old
# contacts that existed in cache (if they existed)
# and pass them along.
if fetch_hosts:
cleaned["hosts"] = self._get_hosts(cleaned.get("_hosts", []))
if old_cache_contacts is not None:
cleaned["contacts"] = old_cache_contacts
# replace the prior cache with new data
self._cache = cleaned
except RegistryError as e:
logger.error(e)
def _extract_data_from_response(self, data_response):
data = data_response.res_data[0]
return {
"auth_info": getattr(data, "auth_info", ...),
"_contacts": getattr(data, "contacts", ...),
"cr_date": getattr(data, "cr_date", ...),
"ex_date": getattr(data, "ex_date", ...),
"_hosts": getattr(data, "hosts", ...),
"name": getattr(data, "name", ...),
"registrant": getattr(data, "registrant", ...),
"statuses": getattr(data, "statuses", ...),
"tr_date": getattr(data, "tr_date", ...),
"up_date": getattr(data, "up_date", ...),
}
def _remove_null_properties(self, cache):
return {k: v for k, v in cache.items() if v is not ...}
def _get_dnssec_data(self, response_extensions):
# get extensions info, if there is any
# DNSSECExtension is one possible extension, make sure to handle
# only DNSSECExtension and not other type extensions
dnssec_data = None
for extension in response_extensions:
if isinstance(extension, extensions.DNSSECExtension):
dnssec_data = extension
return dnssec_data
def _get_contacts(self, contacts):
choices = PublicContact.ContactTypeChoices
# We expect that all these fields get populated,
# so we can create these early, rather than waiting.
cleaned_contacts = {
choices.ADMINISTRATIVE: None,
choices.SECURITY: None,
choices.TECHNICAL: None,
}
if contacts and isinstance(contacts, list) and len(contacts) > 0:
cleaned_contacts = self._fetch_contacts(contacts)
return cleaned_contacts
def _get_hosts(self, hosts):
cleaned_hosts = []
if hosts and isinstance(hosts, list):
cleaned_hosts = self._fetch_hosts(hosts)
return cleaned_hosts
def _get_or_create_public_contact(self, public_contact: PublicContact):
"""Tries to find a PublicContact object in our DB.
If it can't, it'll create it. Returns PublicContact"""

View file

@ -5,7 +5,7 @@ from .utility.time_stamped_model import TimeStampedModel
class StatusChoices(models.TextChoices):
READY = "ready", "Ready"
HOLD = "hold", "Hold"
ON_HOLD = "on hold", "On Hold"
class TransitionDomain(TimeStampedModel):
@ -13,6 +13,10 @@ class TransitionDomain(TimeStampedModel):
state of a domain upon transition between registry
providers"""
# This is necessary to expose the enum to external
# classes that import TransitionDomain
StatusChoices = StatusChoices
username = models.TextField(
null=False,
blank=False,

View file

@ -81,3 +81,9 @@ class User(AbstractUser):
logger.warn(
"Failed to retrieve invitation %s", invitation, exc_info=True
)
class Meta:
permissions = [
("analyst_access_permission", "Analyst Access Permission"),
("full_access_permission", "Full Access Permission"),
]

View file

@ -0,0 +1,137 @@
from django.contrib.auth.models import Group
import logging
logger = logging.getLogger(__name__)
class UserGroup(Group):
class Meta:
verbose_name = "User group"
verbose_name_plural = "User groups"
def create_cisa_analyst_group(apps, schema_editor):
"""This method gets run from a data migration."""
# Hard to pass self to these methods as the calls from migrations
# are only expecting apps and schema_editor, so we'll just define
# apps, schema_editor in the local scope instead
CISA_ANALYST_GROUP_PERMISSIONS = [
{
"app_label": "auditlog",
"model": "logentry",
"permissions": ["view_logentry"],
},
{
"app_label": "registrar",
"model": "contact",
"permissions": ["view_contact"],
},
{
"app_label": "registrar",
"model": "domaininformation",
"permissions": ["change_domaininformation"],
},
{
"app_label": "registrar",
"model": "domainapplication",
"permissions": ["change_domainapplication"],
},
{
"app_label": "registrar",
"model": "domain",
"permissions": ["view_domain"],
},
{
"app_label": "registrar",
"model": "draftdomain",
"permissions": ["change_draftdomain"],
},
{
"app_label": "registrar",
"model": "user",
"permissions": ["analyst_access_permission", "change_user"],
},
{
"app_label": "registrar",
"model": "domaininvitation",
"permissions": ["add_domaininvitation", "view_domaininvitation"],
},
]
# Avoid error: You can't execute queries until the end
# of the 'atomic' block.
# From django docs:
# https://docs.djangoproject.com/en/4.2/topics/migrations/#data-migrations
# We cant import the Person model directly as it may be a newer
# version than this migration expects. We use the historical version.
ContentType = apps.get_model("contenttypes", "ContentType")
Permission = apps.get_model("auth", "Permission")
UserGroup = apps.get_model("registrar", "UserGroup")
logger.info("Going to create the Analyst Group")
try:
cisa_analysts_group, _ = UserGroup.objects.get_or_create(
name="cisa_analysts_group",
)
cisa_analysts_group.permissions.clear()
for permission in CISA_ANALYST_GROUP_PERMISSIONS:
app_label = permission["app_label"]
model_name = permission["model"]
permissions = permission["permissions"]
# Retrieve the content type for the app and model
content_type = ContentType.objects.get(
app_label=app_label, model=model_name
)
# Retrieve the permissions based on their codenames
permissions = Permission.objects.filter(
content_type=content_type, codename__in=permissions
)
# Assign the permissions to the group
cisa_analysts_group.permissions.add(*permissions)
# Convert the permissions QuerySet to a list of codenames
permission_list = list(permissions.values_list("codename", flat=True))
logger.debug(
app_label
+ " | "
+ model_name
+ " | "
+ ", ".join(permission_list)
+ " added to group "
+ cisa_analysts_group.name
)
cisa_analysts_group.save()
logger.debug(
"CISA Analyt permissions added to group " + cisa_analysts_group.name
)
except Exception as e:
logger.error(f"Error creating analyst permissions group: {e}")
def create_full_access_group(apps, schema_editor):
"""This method gets run from a data migration."""
Permission = apps.get_model("auth", "Permission")
UserGroup = apps.get_model("registrar", "UserGroup")
logger.info("Going to create the Full Access Group")
try:
full_access_group, _ = UserGroup.objects.get_or_create(
name="full_access_group",
)
# Get all available permissions
all_permissions = Permission.objects.all()
# Assign all permissions to the group
full_access_group.permissions.add(*all_permissions)
full_access_group.save()
logger.debug("All permissions added to group " + full_access_group.name)
except Exception as e:
logger.error(f"Error creating full access group: {e}")

View file

@ -1,2 +1,51 @@
from enum import IntEnum
class ContactErrorCodes(IntEnum):
"""Used in the ContactError class for
error mapping.
Overview of contact error codes:
- 2000 CONTACT_TYPE_NONE
- 2001 CONTACT_ID_NONE
- 2002 CONTACT_ID_INVALID_LENGTH
- 2003 CONTACT_INVALID_TYPE
"""
CONTACT_TYPE_NONE = 2000
CONTACT_ID_NONE = 2001
CONTACT_ID_INVALID_LENGTH = 2002
CONTACT_INVALID_TYPE = 2003
CONTACT_NOT_FOUND = 2004
class ContactError(Exception):
...
"""
Overview of contact error codes:
- 2000 CONTACT_TYPE_NONE
- 2001 CONTACT_ID_NONE
- 2002 CONTACT_ID_INVALID_LENGTH
- 2003 CONTACT_INVALID_TYPE
- 2004 CONTACT_NOT_FOUND
"""
# For linter
_contact_id_error = "contact_id has an invalid length. Cannot exceed 16 characters."
_contact_invalid_error = "Contact must be of type InfoContactResultData"
_contact_not_found_error = "No contact was found in cache or the registry"
_error_mapping = {
ContactErrorCodes.CONTACT_TYPE_NONE: "contact_type is None",
ContactErrorCodes.CONTACT_ID_NONE: "contact_id is None",
ContactErrorCodes.CONTACT_ID_INVALID_LENGTH: _contact_id_error,
ContactErrorCodes.CONTACT_INVALID_TYPE: _contact_invalid_error,
ContactErrorCodes.CONTACT_NOT_FOUND: _contact_not_found_error,
}
def __init__(self, *args, code=None, **kwargs):
super().__init__(*args, **kwargs)
self.code = code
if self.code in self._error_mapping:
self.message = self._error_mapping.get(self.code)
def __str__(self):
return f"{self.message}"

View file

@ -13,10 +13,10 @@
{% elif original.state == original.State.ON_HOLD %}
<input type="submit" value="Remove hold" name="_remove_client_hold">
{% endif %}
<input id="manageDomainSubmitButton" type="submit" value="Manage Domain" name="_edit_domain">
<input type="submit" value="get status" name="_get_status">
<input id="manageDomainSubmitButton" type="submit" value="Manage domain" name="_edit_domain">
<input type="submit" value="Get registry status" name="_get_status">
{% if original.state != original.State.DELETED %}
<input type="submit" value="Delete Domain in Registry" name="_delete_domain">
<input type="submit" value="Delete domain in registry" name="_delete_domain">
{% endif %}
</div>
{{ block.super }}

View file

@ -27,7 +27,7 @@
</div>
<br>
{% url 'domain-nameservers' pk=domain.id as url %}
{% url 'domain-dns-nameservers' pk=domain.id as url %}
{% if domain.nameservers|length > 0 %}
{% include "includes/summary_item.html" with title='DNS name servers' value=domain.nameservers list='true' edit_link=url %}
{% else %}
@ -46,8 +46,11 @@
{% include "includes/summary_item.html" with title='Your contact information' value=request.user.contact contact='true' edit_link=url %}
{% url 'domain-security-email' pk=domain.id as url %}
{% include "includes/summary_item.html" with title='Security email' value=domain.security_email edit_link=url %}
{% if security_email is not None and security_email != default_security_email%}
{% include "includes/summary_item.html" with title='Security email' value=security_email edit_link=url %}
{% else %}
{% include "includes/summary_item.html" with title='Security email' value='None provided' edit_link=url %}
{% endif %}
{% url 'domain-users' pk=domain.id as url %}
{% include "includes/summary_item.html" with title='User management' users='true' list=True value=domain.permissions.all edit_link=url %}

View file

@ -0,0 +1,20 @@
{% extends "domain_base.html" %}
{% load static field_helpers url_helpers %}
{% block title %}DNS | {{ domain.name }} | {% endblock %}
{% block domain_content %}
<h1>DNS</h1>
<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 your 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>
{% url 'domain-dns-nameservers' pk=domain.id as url %}
<p><a href="{{ url }}">DNS name servers</a></p>
{% url 'domain-dnssec' pk=domain.id as url %}
<p><a href="{{ url }}">DNSSEC</a></p>
{% endblock %} {# domain_content #}

View file

@ -0,0 +1,68 @@
{% extends "domain_base.html" %}
{% load static field_helpers url_helpers %}
{% block title %}DNSSEC | {{ domain.name }} | {% endblock %}
{% block domain_content %}
<h1>DNSSEC</h1>
<p>DNSSEC, or DNS Security Extensions, is additional security layer to protect your domain. Enabling DNSSEC ensures that when someone visits your domain, they can be certain that it's connecting to the correct server, preventing potential hijacking or tampering with your domain's records.</p>
<form class="usa-form usa-form--text-width" method="post">
{% csrf_token %}
{% if has_dnssec_records %}
<div class="usa-alert usa-alert--info usa-alert--slim">
<div class="usa-alert__body">
In order to fully disable DNSSEC on your domain, you will need to work with your DNS provider to remove your DNSSEC-related records from your zone.
</div>
</div>
<a
href="#toggle-dnssec-alert"
class="usa-button"
aria-controls="toggle-dnssec-alert"
data-open-modal
>Disable DNSSEC</a
>
{% elif dnssec_enabled %}
<div id="add-records">
<h2> Add DS Records </h2>
<p>In order to enable DNSSEC and add Delegation Signer (DS) records, you must first configure it with your DNS hosting service. Your configuration will determine whether you need to add DS Data or Key Data. Contact your DNS hosting provider if you are unsure which record type to add.</p>
<p>
<a href="{% url 'domain-dns-dnssec-dsdata' pk=domain.id %}" class="usa-button usa-button--outline">Add DS Data</a>
<a href="{% url 'domain-dns-dnssec-keydata' pk=domain.id %}" class="usa-button usa-button--outline">Add Key Data</a>
<button
type="submit"
class="usa-button usa-button--unstyled usa-button--cancel"
name="cancel_dnssec"
id="cancel_dnssec"
>Cancel</button>
</p>
</div>
{% else %}
<div id="enable-dnssec">
<div class="usa-alert usa-alert--info usa-alert--slim">
<div class="usa-alert__body">
It is strongly recommended that you only enable DNSSEC if you know how to set it up properly at your hosting service. If you make a mistake, it could cause your domain name to stop working.
</div>
</div>
<button
type="submit"
class="usa-button"
name="enable_dnssec"
id="enable_dnssec"
>Enable DNSSEC</button>
</div>
{% endif %}
</form>
<div
class="usa-modal"
id="toggle-dnssec-alert"
aria-labelledby="Are you sure you want to continue?"
aria-describedby="Your DNSSEC records will be deleted from the registry."
>
{% include 'includes/modal.html' with modal_heading="Are you sure you want to continue?" modal_description="Your DNSSEC records will be deleted from the registry." modal_button=modal_button|safe %}
</div>
{% endblock %} {# domain_content #}

View file

@ -0,0 +1,123 @@
{% extends "domain_base.html" %}
{% load static field_helpers url_helpers %}
{% block title %}DS Data | {{ domain.name }} | {% endblock %}
{% block domain_content %}
{% for form in formset %}
{% include "includes/form_errors.html" with form=form %}
{% endfor %}
{% if domain.dnssecdata is None and not dnssec_ds_confirmed %}
<div class="usa-alert usa-alert--info usa-alert--slim margin-bottom-3">
<div class="usa-alert__body">
You have no DS Data added. Enable DNSSEC by adding DS Data or return to the DNSSEC page and click 'enable.'
</div>
</div>
{% endif %}
<h1>DS Data</h1>
{% if domain.dnssecdata is not None and domain.dnssecdata.keyData is not None %}
<div class="usa-alert usa-alert--warning usa-alert--slim margin-bottom-3">
<div class="usa-alert__body">
<h4 class="usa-alert__heading">Warning, you cannot add DS Data</h4>
<p class="usa-alert__text">
You cannot add DS Data because you have already added Key Data. Delete your Key Data records in order to add DS Data.
</p>
</div>
</div>
{% elif not dnssec_ds_confirmed %}
<p>In order to enable DNSSEC, you must first configure it with your DNS hosting service.</p>
<p>Enter the values given by your DNS provider for DS Data.</p>
<p>Required fields are marked with an asterisk (<abbr
title="required"
class="usa-hint usa-hint--required"
>*</abbr>).</p>
<form class="usa-form usa-form--large" method="post" novalidate id="form-container">
{% csrf_token %}
<button type="submit" class="usa-button usa-button--unstyled display-block" name="confirm-ds">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#add_circle"></use>
</svg><span class="margin-left-05">Add new record</span>
</button>
</form>
{% else %}
<p>Enter the values given by your DNS provider for DS Data.</p>
{% include "includes/required_fields.html" %}
<form class="usa-form usa-form--extra-large" method="post" novalidate id="form-container">
{% csrf_token %}
{{ formset.management_form }}
{% for form in formset %}
<fieldset class="ds-record">
<legend class="sr-only">DS Data record {{forloop.counter}}</legend>
<h2 class="margin-top-0">DS Data record {{forloop.counter}}</h2>
<div class="grid-row grid-gap-2 flex-end">
<div class="tablet:grid-col-4">
{% with attr_required=True add_group_class="usa-form-group--unstyled-error" %}
{% input_with_errors form.key_tag %}
{% endwith %}
</div>
<div class="tablet:grid-col-4">
{% with attr_required=True add_group_class="usa-form-group--unstyled-error" %}
{% input_with_errors form.algorithm %}
{% endwith %}
</div>
<div class="tablet:grid-col-4">
{% with attr_required=True add_group_class="usa-form-group--unstyled-error" %}
{% input_with_errors form.digest_type %}
{% endwith %}
</div>
</div>
<div class="grid-row">
<div class="grid-col">
{% with attr_required=True add_group_class="usa-form-group--unstyled-error" %}
{% input_with_errors form.digest %}
{% endwith %}
</div>
</div>
<div class="grid-row margin-top-1">
<div class="grid-col">
<button type="button" class="usa-button usa-button--unstyled display-block float-right-tablet delete-record">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#delete"></use>
</svg><span class="margin-left-05">Delete</span>
</button>
</div>
</div>
</fieldset>
{% endfor %}
<button type="button" class="usa-button usa-button--unstyled display-block margin-bottom-2" id="add-ds-form">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#add_circle"></use>
</svg><span class="margin-left-05">Add new record</span>
</button>
<button
type="submit"
class="usa-button"
>Save
</button>
</form>
<form aria-label="form to undo changes to the DS records">
<button
type="submit"
class="usa-button usa-button--outline btn-cancel"
name="btn-cancel-click"
aria-label="Reset the data in the DS records to the registry state (undo changes)"
>Cancel
</button>
</form>
{% endif %}
{% endblock %} {# domain_content #}

View file

@ -0,0 +1,110 @@
{% extends "domain_base.html" %}
{% load static field_helpers url_helpers %}
{% block title %}Key Data | {{ domain.name }} | {% endblock %}
{% block domain_content %}
{% for form in formset %}
{% include "includes/form_errors.html" with form=form %}
{% endfor %}
<h1>Key Data</h1>
{% if domain.dnssecdata is not None and domain.dnssecdata.dsData is not None %}
<div class="usa-alert usa-alert--warning usa-alert--slim margin-bottom-3">
<div class="usa-alert__body">
<h4 class="usa-alert__heading">Warning, you cannot add Key Data</h4>
<p class="usa-alert__text">
You cannot add Key Data because you have already added DS Data. Delete your DS Data records in order to add Key Data.
</p>
</div>
</div>
{% elif not dnssec_key_confirmed %}
<p>In order to enable DNSSEC and add DS records, you must first configure it with your DNS hosting service. Your configuration will determine whether you need to add DS Data or Key Data. Contact your DNS hosting provider if you are unsure which record type to add.</p>
<form class="usa-form usa-form--large" method="post" novalidate id="form-container">
{% csrf_token %}
<button
type="submit"
class="usa-button"
name="confirm-key"
>Add DS Key record</button>
</form>
{% else %}
<p>Enter the values given by your DNS provider for DS Key Data.</p>
{% include "includes/required_fields.html" %}
<form class="usa-form usa-form--extra-large" method="post" novalidate id="form-container">
{% csrf_token %}
{{ formset.management_form }}
{% for form in formset %}
<fieldset class="ds-record">
<legend class="sr-only">DS Data record {{forloop.counter}}</legend>
<h2 class="margin-top-0">DS Data record {{forloop.counter}}</h2>
<div class="grid-row grid-gap-2 flex-end">
<div class="tablet:grid-col-4">
{% with attr_required=True add_group_class="usa-form-group--unstyled-error" %}
{% input_with_errors form.flag %}
{% endwith %}
</div>
<div class="tablet:grid-col-4">
{% with attr_required=True add_group_class="usa-form-group--unstyled-error" %}
{% input_with_errors form.protocol %}
{% endwith %}
</div>
<div class="tablet:grid-col-4">
{% with attr_required=True add_group_class="usa-form-group--unstyled-error" %}
{% input_with_errors form.algorithm %}
{% endwith %}
</div>
</div>
<div class="grid-row">
<div class="grid-col">
{% with attr_required=True add_group_class="usa-form-group--unstyled-error" %}
{% input_with_errors form.pub_key %}
{% endwith %}
</div>
</div>
<div class="grid-row margin-top-2">
<div class="grid-col">
<button type="button" class="usa-button usa-button--unstyled display-block float-right-tablet delete-record">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#delete"></use>
</svg><span class="margin-left-05">Delete</span>
</button>
</div>
</div>
</fieldset>
{% endfor %}
<button type="button" class="usa-button usa-button--unstyled display-block margin-bottom-2" id="add-ds-form">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#add_circle"></use>
</svg><span class="margin-left-05">Add new record</span>
</button>
<button
type="submit"
class="usa-button"
>Save
</button>
</form>
<form aria-label="form to undo changes to the DS records">
<button
type="submit"
class="usa-button usa-button--outline btn-cancel"
name="btn-cancel-click"
aria-label="Reset the data in the DS records to the registry state (undo changes)"
>Cancel
</button>
</form>
{% endif %}
{% endblock %} {# domain_content #}

View file

@ -1,7 +1,7 @@
{% extends "domain_base.html" %}
{% load static field_helpers%}
{% block title %}Domain name servers | {{ domain.name }} | {% endblock %}
{% block title %}DNS name servers | {{ domain.name }} | {% endblock %}
{% block domain_content %}
{# this is right after the messages block in the parent template #}
@ -9,7 +9,7 @@
{% include "includes/form_errors.html" with form=form %}
{% endfor %}
<h1>Domain name servers</h1>
<h1>DNS name servers</h1>
<p>Before your domain can be used we'll need information about your domain
name servers.</p>
@ -34,7 +34,7 @@
</div>
{% endfor %}
<button type="button" class="usa-button usa-button--unstyled display-block" id="add-form">
<button type="button" class="usa-button usa-button--unstyled display-block" id="add-nameserver-form">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#add_circle"></use>
</svg><span class="margin-left-05">Add another name server</span>

View file

@ -1,18 +1,16 @@
{% extends "domain_base.html" %}
{% load static field_helpers url_helpers %}
{% block title %}Domain security email | {{ domain.name }} | {% endblock %}
{% block title %}Security email | {{ domain.name }} | {% endblock %}
{% block domain_content %}
<h1>Domain security email</h1>
<h1>Security email</h1>
<p>We strongly recommend that you provide a security email. This email will allow the public to report observed or suspected security issues on your domain. Security emails are made public and included in the <a href="{% public_site_url 'about/data/' %}">.gov domain data</a> we provide.</p>
<p>A security contact should be capable of evaluating or triaging security reports for your entire domain. Use a team email address, not an individuals email. We recommend using an alias, like security@domain.gov.</p>
{% include "includes/required_fields.html" %}
<form class="usa-form usa-form--large" method="post" novalidate>
{% csrf_token %}

View file

@ -13,12 +13,50 @@
</li>
<li class="usa-sidenav__item">
{% url 'domain-nameservers' pk=domain.id as url %}
<a href="{{ url }}"
{% if request.path == url %}class="usa-current"{% endif %}
>
DNS name servers
{% url 'domain-dns' pk=domain.id as url %}
<a href="{{ url }}" {% if request.path|startswith:url %}class="usa-current"{% endif %}>
DNS
</a>
<ul class="usa-sidenav__sublist">
<li class="usa-sidenav__item">
{% url 'domain-dns-nameservers' pk=domain.id as url %}
<a href="{{ url }}"
{% if request.path == url %}class="usa-current"{% endif %}
>
Name servers
</a>
</li>
<li class="usa-sidenav__item">
{% url 'domain-dns-dnssec' pk=domain.id as url %}
<a href="{{ url }}"
{% if request.path|startswith:url %}class="usa-current"{% endif %}
>
DNSSEC
</a>
{% if domain.dnssecdata is not None or request.path|startswith:url and request.path|endswith:'data' %}
<ul class="usa-sidenav__sublist">
<li class="usa-sidenav__item">
{% url 'domain-dns-dnssec-dsdata' pk=domain.id as url %}
<a href="{{ url }}"
{% if request.path == url %}class="usa-current"{% endif %}
>
DS Data
</a>
</li>
<li class="usa-sidenav__item">
{% url 'domain-dns-dnssec-keydata' pk=domain.id as url %}
<a href="{{ url }}"
{% if request.path == url %}class="usa-current"{% endif %}
>
DS Key Data
</a>
</li>
</ul>
{% endif %}
</li>
</ul>
</li>
<li class="usa-sidenav__item">

View file

@ -1,11 +1,11 @@
{% extends "domain_base.html" %}
{% load static field_helpers %}
{% block title %}Domain contact information | {{ domain.name }} | {% endblock %}
{% block title %}Your contact information | {{ domain.name }} | {% endblock %}
{% block domain_content %}
<h1>Domain contact information</h1>
<h1>Your contact information</h1>
<p>If youd like us to use a different name, email, or phone number you can make those changes below. <strong>Updating your contact information here will update the contact information for all domains in your account.</strong> However, it wont affect your Login.gov account information.
</p>

View file

@ -0,0 +1,42 @@
<div class="usa-modal__content">
<div class="usa-modal__main">
<h2 class="usa-modal__heading" id="modal-1-heading">
{{ modal_heading }}
</h2>
<div class="usa-prose">
<p id="modal-1-description">
{{ modal_description }}
</p>
</div>
<div class="usa-modal__footer">
<ul class="usa-button-group">
<li class="usa-button-group__item">
<form method="post">
{% csrf_token %}
{{ modal_button }}
</form>
</li>
<li class="usa-button-group__item">
<button
type="button"
class="usa-button usa-button--unstyled padding-105 text-center"
data-close-modal
>
Cancel
</button>
</li>
</ul>
</div>
</div>
<button
type="button"
class="usa-button usa-modal__close"
aria-label="Close this window"
data-close-modal
>
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
<use xlink:href="/assets/img/sprite.svg#close"></use>
</svg>
</button>
</div>

View file

@ -43,6 +43,7 @@
{% else %}
{% include "includes/contact.html" with contact=value %}
{% endif %}
<!-- TODO #687 add here another elif for nameservers to show item Ips -->
{% elif list %}
{% if value|length == 1 %}
{% if users %}

View file

@ -149,7 +149,7 @@ def input_with_errors(context, field=None): # noqa: C901
# see Widget.get_context() on
# https://docs.djangoproject.com/en/4.1/ref/forms/widgets
widget = field.field.widget.get_context(
field.html_name, field.value() or field.initial, field.build_widget_attrs(attrs)
field.html_name, field.value(), field.build_widget_attrs(attrs)
) # -> {"widget": {"name": ...}}
context["widget"] = widget["widget"]

View file

@ -19,6 +19,13 @@ def startswith(text, starts):
return False
@register.filter("endswith")
def endswith(text, ends):
if isinstance(text, str):
return text.endswith(ends)
return False
@register.simple_tag
def public_site_url(url_path):
"""Make a full URL for this path at our public site.

View file

@ -19,6 +19,7 @@ from registrar.models import (
DomainApplication,
DomainInvitation,
User,
UserGroup,
DomainInformation,
PublicContact,
Domain,
@ -26,11 +27,14 @@ from registrar.models import (
from epplibwrapper import (
commands,
common,
extensions,
info,
RegistryError,
ErrorCode,
)
from registrar.models.utility.contact_error import ContactError, ContactErrorCodes
logger = logging.getLogger(__name__)
@ -95,7 +99,10 @@ class MockUserLogin:
}
user, _ = UserModel.objects.get_or_create(**args)
user.is_staff = True
user.is_superuser = True
# Create or retrieve the group
group, _ = UserGroup.objects.get_or_create(name="full_access_group")
# Add the user to the group
user.groups.set([group])
user.save()
backend = settings.AUTHENTICATION_BACKENDS[-1]
login(request, user, backend=backend)
@ -427,22 +434,33 @@ def mock_user():
def create_superuser():
User = get_user_model()
p = "adminpass"
return User.objects.create_superuser(
user = User.objects.create_user(
username="superuser",
email="admin@example.com",
is_staff=True,
password=p,
)
# Retrieve the group or create it if it doesn't exist
group, _ = UserGroup.objects.get_or_create(name="full_access_group")
# Add the user to the group
user.groups.set([group])
return user
def create_user():
User = get_user_model()
p = "userpass"
return User.objects.create_user(
user = User.objects.create_user(
username="staffuser",
email="user@example.com",
is_staff=True,
password=p,
)
# Retrieve the group or create it if it doesn't exist
group, _ = UserGroup.objects.get_or_create(name="cisa_analysts_group")
# Add the user to the group
user.groups.set([group])
return user
def create_ready_domain():
@ -556,6 +574,8 @@ class MockEppLib(TestCase):
contacts=...,
hosts=...,
statuses=...,
avail=...,
addrs=...,
registrant=...,
):
self.auth_info = auth_info
@ -563,6 +583,8 @@ class MockEppLib(TestCase):
self.contacts = contacts
self.hosts = hosts
self.statuses = statuses
self.avail = avail # use for CheckDomain
self.addrs = addrs
self.registrant = registrant
def dummyInfoContactResultData(
@ -705,9 +727,100 @@ class MockEppLib(TestCase):
hosts=["fake.host.com"],
)
infoDomainThreeHosts = fakedEppObject(
"my-nameserver.gov",
cr_date=datetime.datetime(2023, 5, 25, 19, 45, 35),
contacts=[],
hosts=[
"ns1.my-nameserver-1.com",
"ns1.my-nameserver-2.com",
"ns1.cats-are-superior3.com",
],
)
infoDomainNoHost = fakedEppObject(
"my-nameserver.gov",
cr_date=datetime.datetime(2023, 5, 25, 19, 45, 35),
contacts=[],
hosts=[],
)
infoDomainTwoHosts = fakedEppObject(
"my-nameserver.gov",
cr_date=datetime.datetime(2023, 5, 25, 19, 45, 35),
contacts=[],
hosts=["ns1.my-nameserver-1.com", "ns1.my-nameserver-2.com"],
)
mockDataInfoHosts = fakedEppObject(
"lastPw",
cr_date=datetime.datetime(2023, 8, 25, 19, 45, 35),
addrs=["1.2.3.4", "2.3.4.5"],
)
mockDataHostChange = fakedEppObject(
"lastPw", cr_date=datetime.datetime(2023, 8, 25, 19, 45, 35)
)
addDsData1 = {
"keyTag": 1234,
"alg": 3,
"digestType": 1,
"digest": "ec0bdd990b39feead889f0ba613db4adec0bdd99",
}
addDsData2 = {
"keyTag": 2345,
"alg": 3,
"digestType": 1,
"digest": "ec0bdd990b39feead889f0ba613db4adecb4adec",
}
keyDataDict = {
"flags": 257,
"protocol": 3,
"alg": 1,
"pubKey": "AQPJ////4Q==",
}
dnssecExtensionWithDsData = extensions.DNSSECExtension(
**{
"dsData": [
common.DSData(**addDsData1) # type: ignore
], # type: ignore
}
)
dnssecExtensionWithMultDsData = extensions.DNSSECExtension(
**{
"dsData": [
common.DSData(**addDsData1), # type: ignore
common.DSData(**addDsData2), # type: ignore
], # type: ignore
}
)
dnssecExtensionWithKeyData = extensions.DNSSECExtension(
**{
"keyData": [common.DNSSECKeyData(**keyDataDict)], # type: ignore
}
)
dnssecExtensionRemovingDsData = extensions.DNSSECExtension()
infoDomainHasIP = fakedEppObject(
"nameserverwithip.gov",
cr_date=datetime.datetime(2023, 5, 25, 19, 45, 35),
contacts=[],
hosts=[
"ns1.nameserverwithip.gov",
"ns2.nameserverwithip.gov",
"ns3.nameserverwithip.gov",
],
addrs=["1.2.3.4", "2.3.4.5"],
)
infoDomainCheckHostIPCombo = fakedEppObject(
"nameserversubdomain.gov",
cr_date=datetime.datetime(2023, 5, 25, 19, 45, 35),
contacts=[],
hosts=[
"ns1.nameserversubdomain.gov",
"ns2.nameserversubdomain.gov",
],
)
def mockSend(self, _request, cleaned):
"""Mocks the registry.send function used inside of domain.py
@ -715,18 +828,95 @@ class MockEppLib(TestCase):
returns objects that simulate what would be in a epp response
but only relevant pieces for tests"""
if isinstance(_request, commands.InfoDomain):
if getattr(_request, "name", None) == "security.gov":
return MagicMock(res_data=[self.infoDomainNoContact])
elif getattr(_request, "name", None) == "freeman.gov":
return MagicMock(res_data=[self.InfoDomainWithContacts])
elif getattr(_request, "name", None) == "defaultsecurity.gov":
return self.mockInfoDomainCommands(_request, cleaned)
elif isinstance(_request, commands.InfoContact):
return self.mockInfoContactCommands(_request, cleaned)
elif isinstance(_request, commands.UpdateDomain):
return self.mockUpdateDomainCommands(_request, cleaned)
elif isinstance(_request, commands.CreateContact):
return self.mockCreateContactCommands(_request, cleaned)
elif isinstance(_request, commands.CreateHost):
return MagicMock(
res_data=[self.mockDataHostChange],
code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY,
)
elif isinstance(_request, commands.UpdateHost):
return MagicMock(
res_data=[self.mockDataHostChange],
code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY,
)
elif isinstance(_request, commands.DeleteHost):
return MagicMock(
res_data=[self.mockDataHostChange],
code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY,
)
elif (
isinstance(_request, commands.DeleteDomain)
and getattr(_request, "name", None) == "failDelete.gov"
):
name = getattr(_request, "name", None)
fake_nameserver = "ns1.failDelete.gov"
if name in fake_nameserver:
raise RegistryError(
code=ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION
)
return MagicMock(res_data=[self.mockDataInfoHosts])
def mockUpdateDomainCommands(self, _request, cleaned):
if getattr(_request, "name", None) == "dnssec-invalid.gov":
raise RegistryError(code=ErrorCode.PARAMETER_VALUE_RANGE_ERROR)
else:
return MagicMock(
res_data=[self.mockDataHostChange],
code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY,
)
def mockInfoDomainCommands(self, _request, cleaned):
request_name = getattr(_request, "name", None)
# Define a dictionary to map request names to data and extension values
request_mappings = {
"security.gov": (self.infoDomainNoContact, None),
"dnssec-dsdata.gov": (
self.mockDataInfoDomain,
self.dnssecExtensionWithDsData,
),
"dnssec-multdsdata.gov": (
self.mockDataInfoDomain,
self.dnssecExtensionWithMultDsData,
),
"dnssec-keydata.gov": (
self.mockDataInfoDomain,
self.dnssecExtensionWithKeyData,
),
"dnssec-none.gov": (self.mockDataInfoDomain, None),
"my-nameserver.gov": (
self.infoDomainTwoHosts
if self.mockedSendFunction.call_count == 5
else self.infoDomainNoHost,
None,
),
"nameserverwithip.gov": (self.infoDomainHasIP, None),
"namerserversubdomain.gov": (self.infoDomainCheckHostIPCombo, None),
"freeman.gov": (self.InfoDomainWithContacts, None),
"threenameserversDomain.gov": (self.infoDomainThreeHosts, None),
}
TODO = elif getattr(_request, "name", None) == "defaultsecurity.gov":
return MagicMock(res_data=[self.InfoDomainWithDefaultSecurityContact])
elif getattr(_request, "name", None) == "defaulttechnical.gov":
return MagicMock(res_data=[self.InfoDomainWithDefaultTechnicalContact])
else:
return MagicMock(res_data=[self.mockDataInfoDomain])
elif isinstance(_request, commands.InfoContact):
mocked_result: info.InfoContactResultData
# Retrieve the corresponding values from the dictionary
res_data, extensions = request_mappings.get(
request_name, (self.mockDataInfoDomain, None)
)
return MagicMock(
res_data=[res_data],
extensions=[extensions] if extensions is not None else [],
)
def mockInfoContactCommands(self, _request, cleaned):
mocked_result: info.InfoContactResultData
# For testing contact types
match getattr(_request, "id", None):
@ -746,25 +936,24 @@ class MockEppLib(TestCase):
# Default contact return
mocked_result = self.mockDataInfoContact
return MagicMock(res_data=[mocked_result])
elif (
isinstance(_request, commands.CreateContact)
and getattr(_request, "id", None) == "fail"
return MagicMock(res_data=[mocked_result])
def mockCreateContactCommands(self, _request, cleaned):
if (
getattr(_request, "id", None) == "fail"
and self.mockedSendFunction.call_count == 3
):
# use this for when a contact is being updated
# sets the second send() to fail
raise RegistryError(code=ErrorCode.OBJECT_EXISTS)
elif (
isinstance(_request, commands.DeleteDomain)
and getattr(_request, "name", None) == "failDelete.gov"
):
name = getattr(_request, "name", None)
fake_nameserver = "ns1.failDelete.gov"
if name in fake_nameserver:
raise RegistryError(
code=ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION
)
elif getattr(_request, "email", None) == "test@failCreate.gov":
# use this for when a contact is being updated
# mocks a registry error on creation
raise RegistryError(code=None)
elif getattr(_request, "email", None) == "test@contactError.gov":
# use this for when a contact is being updated
# mocks a contact error on creation
raise ContactError(code=ContactErrorCodes.CONTACT_TYPE_NONE)
return MagicMock(res_data=[self.mockDataInfoHosts])
def setUp(self):

View file

@ -52,6 +52,7 @@ class TestDomainAdmin(MockEppLib):
self.factory = RequestFactory()
super().setUp()
@skip("Why did this test stop working, and is is a good test")
def test_place_and_remove_hold(self):
domain = create_ready_domain()
# get admin page and assert Place Hold button
@ -108,12 +109,12 @@ class TestDomainAdmin(MockEppLib):
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, domain.name)
self.assertContains(response, "Delete Domain in Registry")
self.assertContains(response, "Delete domain in registry")
# Test the info dialog
request = self.factory.post(
"/admin/registrar/domain/{}/change/".format(domain.pk),
{"_delete_domain": "Delete Domain in Registry", "name": domain.name},
{"_delete_domain": "Delete domain in registry", "name": domain.name},
follow=True,
)
request.user = self.client
@ -148,12 +149,12 @@ class TestDomainAdmin(MockEppLib):
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, domain.name)
self.assertContains(response, "Delete Domain in Registry")
self.assertContains(response, "Delete domain in registry")
# Test the error
request = self.factory.post(
"/admin/registrar/domain/{}/change/".format(domain.pk),
{"_delete_domain": "Delete Domain in Registry", "name": domain.name},
{"_delete_domain": "Delete domain in registry", "name": domain.name},
follow=True,
)
request.user = self.client
@ -193,12 +194,12 @@ class TestDomainAdmin(MockEppLib):
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, domain.name)
self.assertContains(response, "Delete Domain in Registry")
self.assertContains(response, "Delete domain in registry")
# Test the info dialog
request = self.factory.post(
"/admin/registrar/domain/{}/change/".format(domain.pk),
{"_delete_domain": "Delete Domain in Registry", "name": domain.name},
{"_delete_domain": "Delete domain in registry", "name": domain.name},
follow=True,
)
request.user = self.client
@ -220,7 +221,7 @@ class TestDomainAdmin(MockEppLib):
# Test the info dialog
request = self.factory.post(
"/admin/registrar/domain/{}/change/".format(domain.pk),
{"_delete_domain": "Delete Domain in Registry", "name": domain.name},
{"_delete_domain": "Delete domain in registry", "name": domain.name},
follow=True,
)
request.user = self.client
@ -933,14 +934,13 @@ class MyUserAdminTest(TestCase):
request.user = create_user()
list_display = self.admin.get_list_display(request)
expected_list_display = (
expected_list_display = [
"email",
"first_name",
"last_name",
"is_staff",
"is_superuser",
"group",
"status",
)
]
self.assertEqual(list_display, expected_list_display)
self.assertNotIn("username", list_display)
@ -952,14 +952,14 @@ class MyUserAdminTest(TestCase):
expected_fieldsets = super(MyUserAdmin, self.admin).get_fieldsets(request)
self.assertEqual(fieldsets, expected_fieldsets)
def test_get_fieldsets_non_superuser(self):
def test_get_fieldsets_cisa_analyst(self):
request = self.client.request().wsgi_request
request.user = create_user()
fieldsets = self.admin.get_fieldsets(request)
expected_fieldsets = (
(None, {"fields": ("password", "status")}),
("Personal Info", {"fields": ("first_name", "last_name", "email")}),
("Permissions", {"fields": ("is_active", "is_staff", "is_superuser")}),
("Permissions", {"fields": ("is_active", "groups")}),
("Important dates", {"fields": ("last_login", "date_joined")}),
)
self.assertEqual(fieldsets, expected_fieldsets)

View file

@ -0,0 +1,54 @@
from django.test import TestCase
from registrar.models import (
UserGroup,
)
import logging
logger = logging.getLogger(__name__)
class TestGroups(TestCase):
def test_groups_created(self):
"""The test enviroment contains data that was created in migration,
so we are able to test groups and permissions.
- Test cisa_analysts_group and full_access_group created
- Test permissions on full_access_group
"""
# Get the UserGroup objects
cisa_analysts_group = UserGroup.objects.get(name="cisa_analysts_group")
full_access_group = UserGroup.objects.get(name="full_access_group")
# Assert that the cisa_analysts_group exists in the database
self.assertQuerysetEqual(
UserGroup.objects.filter(name="cisa_analysts_group"), [cisa_analysts_group]
)
# Assert that the full_access_group exists in the database
self.assertQuerysetEqual(
UserGroup.objects.filter(name="full_access_group"), [full_access_group]
)
# Test permissions for cisa_analysts_group
# Verifies permission data migrations ran as expected.
# Define the expected permission codenames
expected_permissions = [
"view_logentry",
"view_contact",
"view_domain",
"change_domainapplication",
"change_domaininformation",
"add_domaininvitation",
"view_domaininvitation",
"change_draftdomain",
"analyst_access_permission",
"change_user",
]
# Get the codenames of actual permissions associated with the group
actual_permissions = [p.codename for p in cisa_analysts_group.permissions.all()]
# Assert that the actual permissions match the expected permissions
self.assertListEqual(actual_permissions, expected_permissions)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,46 @@
from django.test import TestCase
from registrar.utility.errors import (
NameserverError,
NameserverErrorCodes as nsErrorCodes,
)
class TestNameserverError(TestCase):
def test_with_no_ip(self):
"""Test NameserverError when no ip address is passed"""
nameserver = "nameserver val"
expected = (
f"Nameserver {nameserver} needs to have an "
"IP address because it is a subdomain"
)
nsException = NameserverError(
code=nsErrorCodes.MISSING_IP, nameserver=nameserver
)
self.assertEqual(nsException.message, expected)
self.assertEqual(nsException.code, nsErrorCodes.MISSING_IP)
def test_with_only_code(self):
"""Test NameserverError when no ip address
and no nameserver is passed"""
nameserver = "nameserver val"
expected = "Too many hosts provided, you may not have more than 13 nameservers."
nsException = NameserverError(
code=nsErrorCodes.TOO_MANY_HOSTS, nameserver=nameserver
)
self.assertEqual(nsException.message, expected)
self.assertEqual(nsException.code, nsErrorCodes.TOO_MANY_HOSTS)
def test_with_ip_nameserver(self):
"""Test NameserverError when ip and nameserver are passed"""
ip = "ip val"
nameserver = "nameserver val"
expected = f"Nameserver {nameserver} has an invalid IP address: {ip}"
nsException = NameserverError(
code=nsErrorCodes.INVALID_IP, nameserver=nameserver, ip=ip
)
self.assertEqual(nsException.message, expected)
self.assertEqual(nsException.code, nsErrorCodes.INVALID_IP)

View file

@ -18,6 +18,7 @@ from registrar.models import (
DraftDomain,
DomainInvitation,
Contact,
PublicContact,
Website,
UserDomainRole,
User,
@ -1070,21 +1071,60 @@ class TestWithDomainPermissions(TestWithUser):
def setUp(self):
super().setUp()
self.domain, _ = Domain.objects.get_or_create(name="igorville.gov")
self.domain_dsdata, _ = Domain.objects.get_or_create(name="dnssec-dsdata.gov")
self.domain_multdsdata, _ = Domain.objects.get_or_create(
name="dnssec-multdsdata.gov"
)
self.domain_keydata, _ = Domain.objects.get_or_create(name="dnssec-keydata.gov")
# We could simply use domain (igorville) but this will be more readable in tests
# that inherit this setUp
self.domain_dnssec_none, _ = Domain.objects.get_or_create(
name="dnssec-none.gov"
)
self.domain_information, _ = DomainInformation.objects.get_or_create(
creator=self.user, domain=self.domain
)
DomainInformation.objects.get_or_create(
creator=self.user, domain=self.domain_dsdata
)
DomainInformation.objects.get_or_create(
creator=self.user, domain=self.domain_multdsdata
)
DomainInformation.objects.get_or_create(
creator=self.user, domain=self.domain_keydata
)
DomainInformation.objects.get_or_create(
creator=self.user, domain=self.domain_dnssec_none
)
self.role, _ = UserDomainRole.objects.get_or_create(
user=self.user, domain=self.domain, role=UserDomainRole.Roles.ADMIN
)
UserDomainRole.objects.get_or_create(
user=self.user, domain=self.domain_dsdata, role=UserDomainRole.Roles.ADMIN
)
UserDomainRole.objects.get_or_create(
user=self.user,
domain=self.domain_multdsdata,
role=UserDomainRole.Roles.ADMIN,
)
UserDomainRole.objects.get_or_create(
user=self.user, domain=self.domain_keydata, role=UserDomainRole.Roles.ADMIN
)
UserDomainRole.objects.get_or_create(
user=self.user,
domain=self.domain_dnssec_none,
role=UserDomainRole.Roles.ADMIN,
)
def tearDown(self):
try:
self.domain_information.delete()
UserDomainRole.objects.all().delete()
if hasattr(self.domain, "contacts"):
self.domain.contacts.all().delete()
DomainApplication.objects.all().delete()
self.domain.delete()
self.role.delete()
PublicContact.objects.all().delete()
Domain.objects.all().delete()
UserDomainRole.objects.all().delete()
except ValueError: # pass if already deleted
pass
super().tearDown()
@ -1097,7 +1137,7 @@ class TestDomainPermissions(TestWithDomainPermissions):
"domain",
"domain-users",
"domain-users-add",
"domain-nameservers",
"domain-dns-nameservers",
"domain-org-name-address",
"domain-authorizing-official",
"domain-your-contact-information",
@ -1118,7 +1158,7 @@ class TestDomainPermissions(TestWithDomainPermissions):
"domain",
"domain-users",
"domain-users-add",
"domain-nameservers",
"domain-dns-nameservers",
"domain-org-name-address",
"domain-authorizing-official",
"domain-your-contact-information",
@ -1132,7 +1172,7 @@ class TestDomainPermissions(TestWithDomainPermissions):
self.assertEqual(response.status_code, 403)
class TestDomainDetail(TestWithDomainPermissions, WebTest, MockEppLib):
class TestDomainOverview(TestWithDomainPermissions, WebTest):
def setUp(self):
super().setUp()
self.app.set_user(self.user.username)
@ -1142,10 +1182,24 @@ class TestDomainDetail(TestWithDomainPermissions, WebTest, MockEppLib):
home_page = self.app.get("/")
self.assertContains(home_page, "igorville.gov")
# click the "Edit" link
detail_page = home_page.click("Manage")
detail_page = home_page.click("Manage", index=0)
self.assertContains(detail_page, "igorville.gov")
self.assertContains(detail_page, "Status")
def test_domain_overview_blocked_for_ineligible_user(self):
"""We could easily duplicate this test for all domain management
views, but a single url test should be solid enough since all domain
management pages share the same permissions class"""
self.user.status = User.RESTRICTED
self.user.save()
home_page = self.app.get("/")
self.assertContains(home_page, "igorville.gov")
with less_console_noise():
response = self.client.get(reverse("domain", kwargs={"pk": self.domain.id}))
self.assertEqual(response.status_code, 403)
class TestDomainUserManagement(TestDomainOverview):
def test_domain_user_management(self):
response = self.client.get(
reverse("domain-users", kwargs={"pk": self.domain.id})
@ -1304,12 +1358,14 @@ class TestDomainDetail(TestWithDomainPermissions, WebTest, MockEppLib):
home_page = self.app.get(reverse("home"))
self.assertContains(home_page, self.domain.name)
class TestDomainNameservers(TestDomainOverview):
def test_domain_nameservers(self):
"""Can load domain's nameservers page."""
page = self.client.get(
reverse("domain-nameservers", kwargs={"pk": self.domain.id})
reverse("domain-dns-nameservers", kwargs={"pk": self.domain.id})
)
self.assertContains(page, "Domain name servers")
self.assertContains(page, "DNS name servers")
@skip("Broken by adding registry connection fix in ticket 848")
def test_domain_nameservers_form(self):
@ -1318,7 +1374,7 @@ class TestDomainDetail(TestWithDomainPermissions, WebTest, MockEppLib):
Uses self.app WebTest because we need to interact with forms.
"""
nameservers_page = self.app.get(
reverse("domain-nameservers", kwargs={"pk": self.domain.id})
reverse("domain-dns-nameservers", kwargs={"pk": self.domain.id})
)
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
@ -1328,7 +1384,7 @@ class TestDomainDetail(TestWithDomainPermissions, WebTest, MockEppLib):
self.assertEqual(result.status_code, 302)
self.assertEqual(
result["Location"],
reverse("domain-nameservers", kwargs={"pk": self.domain.id}),
reverse("domain-dns-nameservers", kwargs={"pk": self.domain.id}),
)
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
page = result.follow()
@ -1341,7 +1397,7 @@ class TestDomainDetail(TestWithDomainPermissions, WebTest, MockEppLib):
Uses self.app WebTest because we need to interact with forms.
"""
nameservers_page = self.app.get(
reverse("domain-nameservers", kwargs={"pk": self.domain.id})
reverse("domain-dns-nameservers", kwargs={"pk": self.domain.id})
)
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
@ -1355,6 +1411,8 @@ class TestDomainDetail(TestWithDomainPermissions, WebTest, MockEppLib):
# the field.
self.assertContains(result, "This field is required", count=2, status_code=200)
class TestDomainAuthorizingOfficial(TestDomainOverview):
def test_domain_authorizing_official(self):
"""Can load domain's authorizing official page."""
page = self.client.get(
@ -1373,6 +1431,8 @@ class TestDomainDetail(TestWithDomainPermissions, WebTest, MockEppLib):
)
self.assertContains(page, "Testy")
class TestDomainOrganization(TestDomainOverview):
def test_domain_org_name_address(self):
"""Can load domain's org name and mailing address page."""
page = self.client.get(
@ -1409,12 +1469,14 @@ class TestDomainDetail(TestWithDomainPermissions, WebTest, MockEppLib):
self.assertContains(success_result_page, "Not igorville")
self.assertContains(success_result_page, "Faketown")
class TestDomainContactInformation(TestDomainOverview):
def test_domain_your_contact_information(self):
"""Can load domain's your contact information page."""
page = self.client.get(
reverse("domain-your-contact-information", kwargs={"pk": self.domain.id})
)
self.assertContains(page, "Domain contact information")
self.assertContains(page, "Your contact information")
def test_domain_your_contact_information_content(self):
"""Logged-in user's contact information appears on the page."""
@ -1425,6 +1487,8 @@ class TestDomainDetail(TestWithDomainPermissions, WebTest, MockEppLib):
)
self.assertContains(page, "Testy")
class TestDomainSecurityEmail(TestDomainOverview):
def test_domain_security_email_existing_security_contact(self):
"""Can load domain's security email page."""
self.mockSendPatch = patch("registrar.models.domain.registry.send")
@ -1439,7 +1503,7 @@ class TestDomainDetail(TestWithDomainPermissions, WebTest, MockEppLib):
)
# Loads correctly
self.assertContains(page, "Domain security email")
self.assertContains(page, "Security email")
self.assertContains(page, "security@mail.gov")
self.mockSendPatch.stop()
@ -1455,7 +1519,7 @@ class TestDomainDetail(TestWithDomainPermissions, WebTest, MockEppLib):
)
# Loads correctly
self.assertContains(page, "Domain security email")
self.assertContains(page, "Security email")
self.assertNotContains(page, "dotgov@cisa.dhs.gov")
self.mockSendPatch.stop()
@ -1464,7 +1528,7 @@ class TestDomainDetail(TestWithDomainPermissions, WebTest, MockEppLib):
page = self.client.get(
reverse("domain-security-email", kwargs={"pk": self.domain.id})
)
self.assertContains(page, "Domain security email")
self.assertContains(page, "Security email")
def test_domain_security_email_form(self):
"""Adding a security email works.
@ -1490,6 +1554,66 @@ class TestDomainDetail(TestWithDomainPermissions, WebTest, MockEppLib):
success_page, "The security email for this domain has been updated"
)
def test_security_email_form_messages(self):
"""
Test against the success and error messages that are defined in the view
"""
p = "adminpass"
self.client.login(username="superuser", password=p)
form_data_registry_error = {
"security_email": "test@failCreate.gov",
}
form_data_contact_error = {
"security_email": "test@contactError.gov",
}
form_data_success = {
"security_email": "test@something.gov",
}
test_cases = [
(
"RegistryError",
form_data_registry_error,
"Update failed. Cannot contact the registry.",
),
("ContactError", form_data_contact_error, "Value entered was wrong."),
(
"RegistrySuccess",
form_data_success,
"The security email for this domain has been updated.",
),
# Add more test cases with different scenarios here
]
for test_name, data, expected_message in test_cases:
response = self.client.post(
reverse("domain-security-email", kwargs={"pk": self.domain.id}),
data=data,
follow=True,
)
# Check the response status code, content, or any other relevant assertions
self.assertEqual(response.status_code, 200)
# Check if the expected message tag is set
if test_name == "RegistryError" or test_name == "ContactError":
message_tag = "error"
elif test_name == "RegistrySuccess":
message_tag = "success"
else:
# Handle other cases if needed
message_tag = "info" # Change to the appropriate default
# Check the message tag
messages = list(response.context["messages"])
self.assertEqual(len(messages), 1)
message = messages[0]
self.assertEqual(message.tags, message_tag)
self.assertEqual(message.message, expected_message)
def test_domain_overview_blocked_for_ineligible_user(self):
"""We could easily duplicate this test for all domain management
views, but a single url test should be solid enough since all domain
@ -1503,6 +1627,215 @@ class TestDomainDetail(TestWithDomainPermissions, WebTest, MockEppLib):
self.assertEqual(response.status_code, 403)
class TestDomainDNSSEC(TestDomainOverview):
"""MockEPPLib is already inherited."""
def test_dnssec_page_refreshes_enable_button(self):
"""DNSSEC overview page loads when domain has no DNSSEC data
and shows a 'Enable DNSSEC' button. When button is clicked the template
updates. When user navigates away then comes back to the page, the
'Enable DNSSEC' button is shown again."""
# home_page = self.app.get("/")
page = self.client.get(
reverse("domain-dns-dnssec", kwargs={"pk": self.domain.id})
)
self.assertContains(page, "Enable DNSSEC")
# Prepare the data for the POST request
post_data = {
"enable_dnssec": "Enable DNSSEC",
}
updated_page = self.client.post(
reverse("domain-dns-dnssec", kwargs={"pk": self.domain.id}),
post_data,
follow=True,
)
self.assertEqual(updated_page.status_code, 200)
self.assertContains(updated_page, "Add DS Data")
self.assertContains(updated_page, "Add Key Data")
self.app.get("/")
back_to_page = self.client.get(
reverse("domain-dns-dnssec", kwargs={"pk": self.domain.id})
)
self.assertContains(back_to_page, "Enable DNSSEC")
def test_dnssec_page_loads_with_data_in_domain(self):
"""DNSSEC overview page loads when domain has DNSSEC data
and the template contains a button to disable DNSSEC."""
page = self.client.get(
reverse("domain-dns-dnssec", kwargs={"pk": self.domain_multdsdata.id})
)
self.assertContains(page, "Disable DNSSEC")
# Prepare the data for the POST request
post_data = {
"disable_dnssec": "Disable DNSSEC",
}
updated_page = self.client.post(
reverse("domain-dns-dnssec", kwargs={"pk": self.domain.id}),
post_data,
follow=True,
)
self.assertEqual(updated_page.status_code, 200)
self.assertContains(updated_page, "Enable DNSSEC")
def test_ds_form_loads_with_no_domain_data(self):
"""DNSSEC Add DS Data page loads when there is no
domain DNSSEC data and shows a button to Add new record"""
page = self.client.get(
reverse(
"domain-dns-dnssec-dsdata", kwargs={"pk": self.domain_dnssec_none.id}
)
)
self.assertContains(page, "You have no DS Data added")
self.assertContains(page, "Add new record")
def test_ds_form_loads_with_ds_data(self):
"""DNSSEC Add DS Data page loads when there is
domain DNSSEC DS data and shows the data"""
page = self.client.get(
reverse("domain-dns-dnssec-dsdata", kwargs={"pk": self.domain_dsdata.id})
)
self.assertContains(page, "DS Data record 1")
def test_ds_form_loads_with_key_data(self):
"""DNSSEC Add DS Data page loads when there is
domain DNSSEC KEY data and shows an alert"""
page = self.client.get(
reverse("domain-dns-dnssec-dsdata", kwargs={"pk": self.domain_keydata.id})
)
self.assertContains(page, "Warning, you cannot add DS Data")
def test_key_form_loads_with_no_domain_data(self):
"""DNSSEC Add Key Data page loads when there is no
domain DNSSEC data and shows a button to Add DS Key record"""
page = self.client.get(
reverse(
"domain-dns-dnssec-keydata", kwargs={"pk": self.domain_dnssec_none.id}
)
)
self.assertContains(page, "Add DS Key record")
def test_key_form_loads_with_key_data(self):
"""DNSSEC Add Key Data page loads when there is
domain DNSSEC Key data and shows the data"""
page = self.client.get(
reverse("domain-dns-dnssec-keydata", kwargs={"pk": self.domain_keydata.id})
)
self.assertContains(page, "DS Data record 1")
def test_key_form_loads_with_ds_data(self):
"""DNSSEC Add Key Data page loads when there is
domain DNSSEC DS data and shows an alert"""
page = self.client.get(
reverse("domain-dns-dnssec-keydata", kwargs={"pk": self.domain_dsdata.id})
)
self.assertContains(page, "Warning, you cannot add Key Data")
def test_ds_data_form_submits(self):
"""DS Data form submits successfully
Uses self.app WebTest because we need to interact with forms.
"""
add_data_page = self.app.get(
reverse("domain-dns-dnssec-dsdata", kwargs={"pk": self.domain_dsdata.id})
)
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
with less_console_noise(): # swallow log warning message
result = add_data_page.forms[0].submit()
# form submission was a post, response should be a redirect
self.assertEqual(result.status_code, 302)
self.assertEqual(
result["Location"],
reverse("domain-dns-dnssec-dsdata", kwargs={"pk": self.domain_dsdata.id}),
)
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
page = result.follow()
self.assertContains(
page, "The DS Data records for this domain have been updated."
)
def test_ds_data_form_invalid(self):
"""DS Data form errors with invalid data
Uses self.app WebTest because we need to interact with forms.
"""
add_data_page = self.app.get(
reverse("domain-dns-dnssec-dsdata", kwargs={"pk": self.domain_dsdata.id})
)
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
# first two nameservers are required, so if we empty one out we should
# get a form error
add_data_page.forms[0]["form-0-key_tag"] = ""
with less_console_noise(): # swallow logged warning message
result = add_data_page.forms[0].submit()
# form submission was a post with an error, response should be a 200
# error text appears twice, once at the top of the page, once around
# the field.
self.assertContains(result, "Key tag is required", count=2, status_code=200)
def test_key_data_form_submits(self):
"""Key Data form submits successfully
Uses self.app WebTest because we need to interact with forms.
"""
add_data_page = self.app.get(
reverse("domain-dns-dnssec-keydata", kwargs={"pk": self.domain_keydata.id})
)
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
with less_console_noise(): # swallow log warning message
result = add_data_page.forms[0].submit()
# form submission was a post, response should be a redirect
self.assertEqual(result.status_code, 302)
self.assertEqual(
result["Location"],
reverse("domain-dns-dnssec-keydata", kwargs={"pk": self.domain_keydata.id}),
)
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
page = result.follow()
self.assertContains(
page, "The Key Data records for this domain have been updated."
)
def test_key_data_form_invalid(self):
"""Key Data form errors with invalid data
Uses self.app WebTest because we need to interact with forms.
"""
add_data_page = self.app.get(
reverse("domain-dns-dnssec-keydata", kwargs={"pk": self.domain_keydata.id})
)
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
# first two nameservers are required, so if we empty one out we should
# get a form error
add_data_page.forms[0]["form-0-pub_key"] = ""
with less_console_noise(): # swallow logged warning message
result = add_data_page.forms[0].submit()
# form submission was a post with an error, response should be a 200
# error text appears twice, once at the top of the page, once around
# the field.
self.assertContains(result, "Pub key is required", count=2, status_code=200)
class TestApplicationStatus(TestWithUser, WebTest):
def setUp(self):
super().setUp()

View file

@ -1,3 +1,6 @@
from enum import IntEnum
class BlankValueError(ValueError):
pass
@ -8,3 +11,65 @@ class ExtraDotsError(ValueError):
class DomainUnavailableError(ValueError):
pass
class ActionNotAllowed(Exception):
"""User accessed an action that is not
allowed by the current state"""
pass
class NameserverErrorCodes(IntEnum):
"""Used in the NameserverError class for
error mapping.
Overview of nameserver error codes:
- 1 MISSING_IP ip address is missing for a nameserver
- 2 GLUE_RECORD_NOT_ALLOWED a host has a nameserver
value but is not a subdomain
- 3 INVALID_IP invalid ip address format or invalid version
- 4 TOO_MANY_HOSTS more than the max allowed host values
"""
MISSING_IP = 1
GLUE_RECORD_NOT_ALLOWED = 2
INVALID_IP = 3
TOO_MANY_HOSTS = 4
UNABLE_TO_UPDATE_DOMAIN = 5
class NameserverError(Exception):
"""
NameserverError class used to raise exceptions on
the nameserver getter
"""
_error_mapping = {
NameserverErrorCodes.MISSING_IP: "Nameserver {} needs to have an "
"IP address because it is a subdomain",
NameserverErrorCodes.GLUE_RECORD_NOT_ALLOWED: "Nameserver {} cannot be linked "
"because it is not a subdomain",
NameserverErrorCodes.INVALID_IP: "Nameserver {} has an invalid IP address: {}",
NameserverErrorCodes.TOO_MANY_HOSTS: (
"Too many hosts provided, you may not have more than 13 nameservers."
),
NameserverErrorCodes.UNABLE_TO_UPDATE_DOMAIN: (
"Unable to update domain, changes were not applied."
"Check logs as a Registry Error is the likely cause"
),
}
def __init__(self, *args, code=None, nameserver=None, ip=None, **kwargs):
super().__init__(*args, **kwargs)
self.code = code
if self.code in self._error_mapping:
self.message = self._error_mapping.get(self.code)
if nameserver is not None and ip is not None:
self.message = self.message.format(str(nameserver), str(ip))
elif nameserver is not None:
self.message = self.message.format(str(nameserver))
elif ip is not None:
self.message = self.message.format(str(ip))
def __str__(self):
return f"{self.message}"

View file

@ -3,7 +3,11 @@ from .domain import (
DomainView,
DomainAuthorizingOfficialView,
DomainOrgNameAddressView,
DomainDNSView,
DomainNameserversView,
DomainDNSSECView,
DomainDsDataView,
DomainKeyDataView,
DomainYourContactInformationView,
DomainSecurityEmailView,
DomainUsersView,

View file

@ -11,6 +11,7 @@ from django.contrib import messages
from django.contrib.messages.views import SuccessMessageMixin
from django.db import IntegrityError
from django.shortcuts import redirect
from django.template import RequestContext
from django.urls import reverse
from django.views.generic.edit import FormMixin
@ -21,6 +22,8 @@ from registrar.models import (
User,
UserDomainRole,
)
from registrar.models.public_contact import PublicContact
from registrar.models.utility.contact_error import ContactError
from ..forms import (
ContactForm,
@ -28,7 +31,21 @@ from ..forms import (
DomainAddUserForm,
DomainSecurityEmailForm,
NameserverFormset,
DomainDnssecForm,
DomainDsdataFormset,
DomainDsdataForm,
DomainKeydataFormset,
DomainKeydataForm,
)
from epplibwrapper import (
common,
extensions,
RegistryError,
CANNOT_CONTACT_REGISTRY,
GENERIC_ERROR,
)
from ..utility.email import send_templated_email, EmailSendingError
from .utility import DomainPermissionView, DomainInvitationPermissionDeleteView
@ -36,14 +53,100 @@ from .utility import DomainPermissionView, DomainInvitationPermissionDeleteView
logger = logging.getLogger(__name__)
class DomainView(DomainPermissionView):
class DomainBaseView(DomainPermissionView):
"""
Base View for the Domain. Handles getting and setting the domain
in session cache on GETs. Also provides methods for getting
and setting the domain in cache
"""
def get(self, request, *args, **kwargs):
self._get_domain(request)
context = self.get_context_data(object=self.object)
return self.render_to_response(context)
def _get_domain(self, request):
"""
get domain from session cache or from db and set
to self.object
set session to self for downstream functions to
update session cache
"""
self.session = request.session
# domain:private_key is the session key to use for
# caching the domain in the session
domain_pk = "domain:" + str(self.kwargs.get("pk"))
cached_domain = self.session.get(domain_pk)
if cached_domain:
self.object = cached_domain
else:
self.object = self.get_object()
self._update_session_with_domain()
def _update_session_with_domain(self):
"""
update domain in the session cache
"""
domain_pk = "domain:" + str(self.kwargs.get("pk"))
self.session[domain_pk] = self.object
class DomainFormBaseView(DomainBaseView, FormMixin):
"""
Form Base View for the Domain. Handles getting and setting
domain in cache when dealing with domain forms. Provides
implementations of post, form_valid and form_invalid.
"""
def post(self, request, *args, **kwargs):
"""Form submission posts to this view.
This post method harmonizes using DomainBaseView and FormMixin
"""
self._get_domain(request)
form = self.get_form()
if form.is_valid():
return self.form_valid(form)
else:
return self.form_invalid(form)
def form_valid(self, form):
# updates session cache with domain
self._update_session_with_domain()
# superclass has the redirect
return super().form_valid(form)
def form_invalid(self, form):
# updates session cache with domain
self._update_session_with_domain()
# superclass has the redirect
return super().form_invalid(form)
class DomainView(DomainBaseView):
"""Domain detail overview page."""
template_name = "domain_detail.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
class DomainOrgNameAddressView(DomainPermissionView, FormMixin):
default_email = self.object.get_default_security_contact().email
context["default_security_email"] = default_email
security_email = self.object.get_security_email()
if security_email is None or security_email == default_email:
context["security_email"] = None
return context
context["security_email"] = security_email
return context
class DomainOrgNameAddressView(DomainFormBaseView):
"""Organization name and mailing address view"""
model = Domain
@ -54,25 +157,13 @@ class DomainOrgNameAddressView(DomainPermissionView, FormMixin):
def get_form_kwargs(self, *args, **kwargs):
"""Add domain_info.organization_name instance to make a bound form."""
form_kwargs = super().get_form_kwargs(*args, **kwargs)
form_kwargs["instance"] = self.get_object().domain_info
form_kwargs["instance"] = self.object.domain_info
return form_kwargs
def get_success_url(self):
"""Redirect to the overview page for the domain."""
return reverse("domain-org-name-address", kwargs={"pk": self.object.pk})
def post(self, request, *args, **kwargs):
"""Form submission posts to this view.
This post method harmonizes using DetailView and FormMixin together.
"""
self.object = self.get_object()
form = self.get_form()
if form.is_valid():
return self.form_valid(form)
else:
return self.form_invalid(form)
def form_valid(self, form):
"""The form is valid, save the organization name and mailing address."""
form.save()
@ -85,8 +176,7 @@ class DomainOrgNameAddressView(DomainPermissionView, FormMixin):
return super().form_valid(form)
class DomainAuthorizingOfficialView(DomainPermissionView, FormMixin):
class DomainAuthorizingOfficialView(DomainFormBaseView):
"""Domain authorizing official editing view."""
model = Domain
@ -97,25 +187,13 @@ class DomainAuthorizingOfficialView(DomainPermissionView, FormMixin):
def get_form_kwargs(self, *args, **kwargs):
"""Add domain_info.authorizing_official instance to make a bound form."""
form_kwargs = super().get_form_kwargs(*args, **kwargs)
form_kwargs["instance"] = self.get_object().domain_info.authorizing_official
form_kwargs["instance"] = self.object.domain_info.authorizing_official
return form_kwargs
def get_success_url(self):
"""Redirect to the overview page for the domain."""
return reverse("domain-authorizing-official", kwargs={"pk": self.object.pk})
def post(self, request, *args, **kwargs):
"""Form submission posts to this view.
This post method harmonizes using DetailView and FormMixin together.
"""
self.object = self.get_object()
form = self.get_form()
if form.is_valid():
return self.form_valid(form)
else:
return self.form_invalid(form)
def form_valid(self, form):
"""The form is valid, save the authorizing official."""
form.save()
@ -128,8 +206,13 @@ class DomainAuthorizingOfficialView(DomainPermissionView, FormMixin):
return super().form_valid(form)
class DomainNameserversView(DomainPermissionView, FormMixin):
class DomainDNSView(DomainBaseView):
"""DNS Information View."""
template_name = "domain_dns.html"
class DomainNameserversView(DomainFormBaseView):
"""Domain nameserver editing view."""
template_name = "domain_nameservers.html"
@ -137,8 +220,7 @@ class DomainNameserversView(DomainPermissionView, FormMixin):
def get_initial(self):
"""The initial value for the form (which is a formset here)."""
domain = self.get_object()
nameservers = domain.nameservers
nameservers = self.object.nameservers
initial_data = []
if nameservers is not None:
@ -153,7 +235,7 @@ class DomainNameserversView(DomainPermissionView, FormMixin):
def get_success_url(self):
"""Redirect to the nameservers page for the domain."""
return reverse("domain-nameservers", kwargs={"pk": self.object.pk})
return reverse("domain-dns-nameservers", kwargs={"pk": self.object.pk})
def get_context_data(self, **kwargs):
"""Adjust context from FormMixin for formsets."""
@ -174,16 +256,6 @@ class DomainNameserversView(DomainPermissionView, FormMixin):
form.fields["server"].required = False
return formset
def post(self, request, *args, **kwargs):
"""Formset submission posts to this view."""
self.object = self.get_object()
formset = self.get_form()
if formset.is_valid():
return self.form_valid(formset)
else:
return self.form_invalid(formset)
def form_valid(self, formset):
"""The formset is valid, perform something with it."""
@ -196,8 +268,7 @@ class DomainNameserversView(DomainPermissionView, FormMixin):
except KeyError:
# no server information in this field, skip it
pass
domain = self.get_object()
domain.nameservers = nameservers
self.object.nameservers = nameservers
messages.success(
self.request, "The name servers for this domain have been updated."
@ -207,8 +278,293 @@ class DomainNameserversView(DomainPermissionView, FormMixin):
return super().form_valid(formset)
class DomainYourContactInformationView(DomainPermissionView, FormMixin):
class DomainDNSSECView(DomainFormBaseView):
"""Domain DNSSEC editing view."""
template_name = "domain_dnssec.html"
form_class = DomainDnssecForm
def get_context_data(self, **kwargs):
"""The initial value for the form (which is a formset here)."""
context = super().get_context_data(**kwargs)
has_dnssec_records = self.object.dnssecdata is not None
# Create HTML for the modal button
modal_button = (
'<button type="submit" '
'class="usa-button" '
'name="disable_dnssec">Disable DNSSEC</button>'
)
context["modal_button"] = modal_button
context["has_dnssec_records"] = has_dnssec_records
context["dnssec_enabled"] = self.request.session.pop("dnssec_enabled", False)
return context
def get_success_url(self):
"""Redirect to the DNSSEC page for the domain."""
return reverse("domain-dns-dnssec", kwargs={"pk": self.object.pk})
def post(self, request, *args, **kwargs):
"""Form submission posts to this view."""
self._get_domain(request)
form = self.get_form()
if form.is_valid():
if "disable_dnssec" in request.POST:
try:
self.object.dnssecdata = {}
except RegistryError as err:
errmsg = "Error removing existing DNSSEC record(s)."
logger.error(errmsg + ": " + err)
messages.error(self.request, errmsg)
request.session["dnssec_ds_confirmed"] = False
request.session["dnssec_key_confirmed"] = False
elif "enable_dnssec" in request.POST:
request.session["dnssec_enabled"] = True
request.session["dnssec_ds_confirmed"] = False
request.session["dnssec_key_confirmed"] = False
return self.form_valid(form)
class DomainDsDataView(DomainFormBaseView):
"""Domain DNSSEC ds data editing view."""
template_name = "domain_dsdata.html"
form_class = DomainDsdataFormset
form = DomainDsdataForm
def get_initial(self):
"""The initial value for the form (which is a formset here)."""
dnssecdata: extensions.DNSSECExtension = self.object.dnssecdata
initial_data = []
if dnssecdata is not None:
if dnssecdata.keyData is not None:
# TODO: Throw an error
# Note: This is moot if we're
# removing key data
pass
if dnssecdata.dsData is not None:
# Add existing nameservers as initial data
initial_data.extend(
{
"key_tag": record.keyTag,
"algorithm": record.alg,
"digest_type": record.digestType,
"digest": record.digest,
}
for record in dnssecdata.dsData
)
# Ensure at least 1 record, filled or empty
while len(initial_data) == 0:
initial_data.append({})
return initial_data
def get_success_url(self):
"""Redirect to the DS Data page for the domain."""
return reverse("domain-dns-dnssec-dsdata", kwargs={"pk": self.object.pk})
def get_context_data(self, **kwargs):
"""Adjust context from FormMixin for formsets."""
context = super().get_context_data(**kwargs)
# use "formset" instead of "form" for the key
context["formset"] = context.pop("form")
# set the dnssec_ds_confirmed flag in the context for this view
# based either on the existence of DS Data in the domain,
# or on the flag stored in the session
dnssecdata: extensions.DNSSECExtension = self.object.dnssecdata
if dnssecdata is not None and dnssecdata.dsData is not None:
self.request.session["dnssec_ds_confirmed"] = True
context["dnssec_ds_confirmed"] = self.request.session.get(
"dnssec_ds_confirmed", False
)
return context
def post(self, request, *args, **kwargs):
"""Formset submission posts to this view."""
self._get_domain(request)
formset = self.get_form()
if "confirm-ds" in request.POST:
request.session["dnssec_ds_confirmed"] = True
request.session["dnssec_key_confirmed"] = False
return super().form_valid(formset)
if "btn-cancel-click" in request.POST:
return redirect("/", {"formset": formset}, RequestContext(request))
if formset.is_valid():
return self.form_valid(formset)
else:
return self.form_invalid(formset)
def form_valid(self, formset):
"""The formset is valid, perform something with it."""
# Set the dnssecdata from the formset
dnssecdata = extensions.DNSSECExtension()
for form in formset:
try:
# if 'delete' not in form.cleaned_data
# or form.cleaned_data['delete'] == False:
dsrecord = {
"keyTag": form.cleaned_data["key_tag"],
"alg": int(form.cleaned_data["algorithm"]),
"digestType": int(form.cleaned_data["digest_type"]),
"digest": form.cleaned_data["digest"],
}
if dnssecdata.dsData is None:
dnssecdata.dsData = []
dnssecdata.dsData.append(common.DSData(**dsrecord))
except KeyError:
# no cleaned_data provided for this form, but passed
# as valid; this can happen if form has been added but
# not been interacted with; in that case, want to ignore
pass
try:
self.object.dnssecdata = dnssecdata
except RegistryError as err:
errmsg = "Error updating DNSSEC data in the registry."
logger.error(errmsg)
logger.error(err)
messages.error(self.request, errmsg)
return self.form_invalid(formset)
else:
messages.success(
self.request, "The DS Data records for this domain have been updated."
)
# superclass has the redirect
return super().form_valid(formset)
class DomainKeyDataView(DomainFormBaseView):
"""Domain DNSSEC key data editing view."""
template_name = "domain_keydata.html"
form_class = DomainKeydataFormset
form = DomainKeydataForm
def get_initial(self):
"""The initial value for the form (which is a formset here)."""
dnssecdata: extensions.DNSSECExtension = self.object.dnssecdata
initial_data = []
if dnssecdata is not None:
if dnssecdata.dsData is not None:
# TODO: Throw an error?
# Note: this is moot if we're
# removing Key data
pass
if dnssecdata.keyData is not None:
# Add existing keydata as initial data
initial_data.extend(
{
"flag": record.flags,
"protocol": record.protocol,
"algorithm": record.alg,
"pub_key": record.pubKey,
}
for record in dnssecdata.keyData
)
# Ensure at least 1 record, filled or empty
while len(initial_data) == 0:
initial_data.append({})
return initial_data
def get_success_url(self):
"""Redirect to the Key Data page for the domain."""
return reverse("domain-dns-dnssec-keydata", kwargs={"pk": self.object.pk})
def get_context_data(self, **kwargs):
"""Adjust context from FormMixin for formsets."""
context = super().get_context_data(**kwargs)
# use "formset" instead of "form" for the key
context["formset"] = context.pop("form")
# set the dnssec_key_confirmed flag in the context for this view
# based either on the existence of Key Data in the domain,
# or on the flag stored in the session
dnssecdata: extensions.DNSSECExtension = self.object.dnssecdata
if dnssecdata is not None and dnssecdata.keyData is not None:
self.request.session["dnssec_key_confirmed"] = True
context["dnssec_key_confirmed"] = self.request.session.get(
"dnssec_key_confirmed", False
)
return context
def post(self, request, *args, **kwargs):
"""Formset submission posts to this view."""
self._get_domain(request)
formset = self.get_form()
if "confirm-key" in request.POST:
request.session["dnssec_key_confirmed"] = True
request.session["dnssec_ds_confirmed"] = False
self.object.save()
return super().form_valid(formset)
if "btn-cancel-click" in request.POST:
return redirect("/", {"formset": formset}, RequestContext(request))
if formset.is_valid():
return self.form_valid(formset)
else:
return self.form_invalid(formset)
def form_valid(self, formset):
"""The formset is valid, perform something with it."""
# Set the nameservers from the formset
dnssecdata = extensions.DNSSECExtension()
for form in formset:
try:
# if 'delete' not in form.cleaned_data
# or form.cleaned_data['delete'] == False:
keyrecord = {
"flags": int(form.cleaned_data["flag"]),
"protocol": int(form.cleaned_data["protocol"]),
"alg": int(form.cleaned_data["algorithm"]),
"pubKey": form.cleaned_data["pub_key"],
}
if dnssecdata.keyData is None:
dnssecdata.keyData = []
dnssecdata.keyData.append(common.DNSSECKeyData(**keyrecord))
except KeyError:
# no server information in this field, skip it
pass
try:
self.object.dnssecdata = dnssecdata
except RegistryError as err:
errmsg = "Error updating DNSSEC data in the registry."
logger.error(errmsg)
logger.error(err)
messages.error(self.request, errmsg)
return self.form_invalid(formset)
else:
messages.success(
self.request, "The Key Data records for this domain have been updated."
)
# superclass has the redirect
return super().form_valid(formset)
class DomainYourContactInformationView(DomainFormBaseView):
"""Domain your contact information editing view."""
template_name = "domain_your_contact_information.html"
@ -224,16 +580,6 @@ class DomainYourContactInformationView(DomainPermissionView, FormMixin):
"""Redirect to the your contact information for the domain."""
return reverse("domain-your-contact-information", kwargs={"pk": self.object.pk})
def post(self, request, *args, **kwargs):
"""Form submission posts to this view."""
self.object = self.get_object()
form = self.get_form()
if form.is_valid():
# there is a valid email address in the form
return self.form_valid(form)
else:
return self.form_invalid(form)
def form_valid(self, form):
"""The form is valid, call setter in model."""
@ -248,8 +594,7 @@ class DomainYourContactInformationView(DomainPermissionView, FormMixin):
return super().form_valid(form)
class DomainSecurityEmailView(DomainPermissionView, FormMixin):
class DomainSecurityEmailView(DomainFormBaseView):
"""Domain security email editing view."""
template_name = "domain_security_email.html"
@ -257,9 +602,8 @@ class DomainSecurityEmailView(DomainPermissionView, FormMixin):
def get_initial(self):
"""The initial value for the form."""
domain = self.get_object()
initial = super().get_initial()
security_contact = domain.security_contact
security_contact = self.object.security_contact
if security_contact is None or security_contact.email == "dotgov@cisa.dhs.gov":
initial["security_email"] = None
return initial
@ -270,44 +614,54 @@ class DomainSecurityEmailView(DomainPermissionView, FormMixin):
"""Redirect to the security email page for the domain."""
return reverse("domain-security-email", kwargs={"pk": self.object.pk})
def post(self, request, *args, **kwargs):
"""Form submission posts to this view."""
self.object = self.get_object()
form = self.get_form()
if form.is_valid():
# there is a valid email address in the form
return self.form_valid(form)
else:
return self.form_invalid(form)
def form_valid(self, form):
"""The form is valid, call setter in model."""
# Set the security email from the form
new_email = form.cleaned_data.get("security_email", "")
new_email: str = form.cleaned_data.get("security_email", "")
# If we pass nothing for the sec email, set to the default
if new_email is None or new_email.strip() == "":
new_email = PublicContact.get_default_security().email
contact = self.object.security_contact
# If no default is created for security_contact,
# then we cannot connect to the registry.
if contact is None:
messages.error(self.request, CANNOT_CONTACT_REGISTRY)
return redirect(self.get_success_url())
domain = self.get_object()
contact = domain.security_contact
contact.email = new_email
contact.save()
messages.success(
self.request, "The security email for this domain has been updated."
)
try:
contact.save()
except RegistryError as Err:
if Err.is_connection_error():
messages.error(self.request, CANNOT_CONTACT_REGISTRY)
logger.error(f"Registry connection error: {Err}")
else:
messages.error(self.request, GENERIC_ERROR)
logger.error(f"Registry error: {Err}")
except ContactError as Err:
messages.error(self.request, GENERIC_ERROR)
logger.error(f"Generic registry error: {Err}")
else:
messages.success(
self.request, "The security email for this domain has been updated."
)
# superclass has the redirect
return redirect(self.get_success_url())
class DomainUsersView(DomainPermissionView):
class DomainUsersView(DomainBaseView):
"""User management page in the domain details."""
template_name = "domain_users.html"
class DomainAddUserView(DomainPermissionView, FormMixin):
class DomainAddUserView(DomainFormBaseView):
"""Inside of a domain's user management, a form for adding users.
Multiple inheritance is used here for permissions, form handling, and
@ -320,15 +674,6 @@ class DomainAddUserView(DomainPermissionView, FormMixin):
def get_success_url(self):
return reverse("domain-users", kwargs={"pk": self.object.pk})
def post(self, request, *args, **kwargs):
self.object = self.get_object()
form = self.get_form()
if form.is_valid():
# there is a valid email address in the form
return self.form_valid(form)
else:
return self.form_invalid(form)
def _domain_abs_url(self):
"""Get an absolute URL for this domain."""
return self.request.build_absolute_uri(

View file

@ -63,9 +63,9 @@ class DomainPermission(PermissionsLoginMixin):
"""
# Check if the user is permissioned...
user_is_analyst_or_superuser = (
self.request.user.is_staff or self.request.user.is_superuser
)
user_is_analyst_or_superuser = self.request.user.has_perm(
"registrar.analyst_access_permission"
) or self.request.user.has_perm("registrar.full_access_permission")
if not user_is_analyst_or_superuser:
return False

View file

@ -33,7 +33,9 @@ class DomainPermissionView(DomainPermission, DetailView, abc.ABC):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
user = self.request.user
context["is_analyst_or_superuser"] = user.is_staff or user.is_superuser
context["is_analyst_or_superuser"] = user.has_perm(
"registrar.analyst_access_permission"
) or user.has_perm("registrar.full_access_permission")
# Stored in a variable for the linter
action = "analyst_action"
action_location = "analyst_action_location"