Merge branch 'main' into sspj/domain-interface

This commit is contained in:
Seamus Johnston 2023-06-01 10:13:24 -05:00
commit 5b78b0ec67
No known key found for this signature in database
GPG key ID: 2F21225985069105
31 changed files with 1167 additions and 215 deletions

View file

@ -4,6 +4,12 @@ title: "[Bug]: "
labels: ["bug"]
body:
- type: markdown
id: 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.
- type: textarea
id: current-behavior
attributes:
@ -24,25 +30,30 @@ body:
id: steps-to-reproduce
attributes:
label: Steps to Reproduce
description: "How can the issue be reliably reproduced? feel free to include screenshots or other supporting artifacts"
placeholder: |
description: |
How can the issue be reliably reproduced? Feel free to include screenshots or other supporting artifacts
Example:
1. In the test environment, fill out the application for a new domain
2. Click the button to trigger a save/submit on the final page and complete the application
3. See the error
value: |
1.
validations:
required: true
- type: textarea
id: environment
attributes:
label: Environment
description: "Where is this issue occurring? If related to development environment, list the specific relevant tool versions"
placeholder: |
label: Environment (optional)
description: |
Where is this issue occurring? If related to development environment, list the relevant tool versions.
Example:
- Environment: Sandbox
- Browser: Chrome x.y.z
- Python: x.y.z
- type: textarea
id: additional-context
attributes:
label: Additional Context
label: Additional Context (optional)
description: "Please include additional references, screenshots, documentation, etc. that are relevant"

62
.github/ISSUE_TEMPLATE/user-story.yml vendored Normal file
View file

@ -0,0 +1,62 @@
name: User Story
description: Capture actionable sprint work
title: "[Story]: "
labels: ["story"]
body:
- type: markdown
id: 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.
- type: textarea
id: story
attributes:
label: User Story
description: |
Please add the "as a, I want, so that" details that describe the user story.
If more than one "as a, I want, so that" describes the story, add multiple.
Example:
As an administrator
I want the ability to approve a domain application
so that a request can be fulfilled and a new .gov domain can be provisioned
value: |
As a
I want
so that
validations:
required: true
- type: textarea
id: acceptance-criteria
attributes:
label: Acceptance Criteria
description: |
Please add the acceptance criteria using one or more "given, when, then" formulae
Example:
Given that I am an administrator who has finished reviewing a domain application
When I click to approve a domain application
Then the domain provisioning process should be initiated, and the applicant should receive an email update.
value: |
Given
When
Then
validations:
required: true
- type: textarea
id: additional-context
attributes:
label: Additional Context (optional)
description: "Please include additional references, screenshots, documentation, etc. that are relevant"
- type: textarea
id: issue-links
attributes:
label: Issue Links (optional)
description: |
What other issues does this story relate to and how?
Example:
- 🚧 Blocked by: #123
- 🔄 Relates to: #234

5
.github/SECURITY.md vendored Normal file
View file

@ -0,0 +1,5 @@
* If you've found a security or privacy issue on the **.gov top-level domain infrastructure**, submit it to our [vulnerabilty disclosure form](https://forms.office.com/Pages/ResponsePage.aspx?id=bOfNPG2UEkq7evydCEI1SqHke9Gh6wJEl3kQ5EjWUKlUMTZZS1lBVkxHUzZURFpLTkE2NEJFVlhVRi4u) or email dotgov@cisa.dhs.gov.
* If you see a security or privacy issue on **an individual .gov domain**, check [current-full.csv](https://flatgithub.com/cisagov/dotgov-data/blob/main/?filename=current-full.csv) or [Whois](https://domains.dotgov.gov/dotgov-web/registration/whois.xhtml) (same data) to check whether the domain has a security contact to report your finding directly. You are welcome to Cc dotgov@cisa.dhs.gov on the email.
* If you are unable to find a contact or receive no response from the security contact, email dotgov@cisa.dhs.gov.
Note that most federal (executive branch) agencies maintain a [vulnerability disclosure policy](https://github.com/cisagov/vdp-in-fceb/).

View file

@ -34,8 +34,8 @@ jobs:
- name: Delete existing data for ${{ github.event.inputs.environment }}
uses: 18f/cg-deploy-action@main
with:
cf_username: ${{ secrets[CF_USERNAME] }}
cf_password: ${{ secrets[CF_PASSWORD] }}
cf_username: ${{ secrets[env.CF_USERNAME] }}
cf_password: ${{ secrets[env.CF_PASSWORD] }}
cf_org: cisa-getgov-prototyping
cf_space: ${{ github.event.inputs.environment }}
full_command: "cf run-task getgov-${{ github.event.inputs.environment }} --command 'python manage.py flush --no-input' --name flush"

View file

@ -0,0 +1,158 @@
# Data Model Timeline
This diagram connects the data models along with various workflow stages.
1. The applicant starts the process at `/register` interacting with the
`DomainApplication` object.
2. The analyst approves the application using the `DomainApplication`'s
`approve()` method which creates many related objects: `UserDomainRole`,
`Domain`, and `DomainInformation`.
3. After the domain is approved, users interact with various
`/domain/<id>/...` views which make changes to the `Domain`,
`DomainInformation`, and `UserDomainRole` models. For inviting new users,
there is a `DomainInvitation` model that allows people to be added to
domains who are not already users.
A more complete diagram of the data models, their fields, and their
relationships are in [models_diagram.md](./models_diagram.md), created with
the `django-model2puml` plugin.
![Data model timeline diagram](./model_timeline.svg)
<details>
<summary>PlantUML source code</summary>
To regenerate this image using Docker, run
```bash
$ docker run -v $(pwd):$(pwd) -w $(pwd) -it plantuml/plantuml -tsvg model_timeline.md
```
```plantuml
@startuml
allowmixing
left to right direction
class DomainApplication {
Application for a domain
--
creator (User)
investigator (User)
authorizing_official (Contact)
submitter (Contact)
other_contacts (Contacts)
approved_domain (Domain)
requested_domain (DraftDomain)
current_websites (Websites)
alternative_domains (Websites)
--
Request information...
}
class User {
Django's user class
--
...
--
}
note left of User
Created by DjangoOIDC
when users arrive back
from Login.gov
<b>username</b> is the Login UUID
end note
DomainApplication -l- User : creator, investigator
class Contact {
Contact info for a person
--
first_name
middle_name
last_name
title
email
phone
--
}
DomainApplication *-r-* Contact : authorizing_official, submitter, other_contacts
class DraftDomain {
Requested domain
--
name
--
}
DomainApplication -l- DraftDomain : requested_domain
class Domain {
Approved domain
--
name
--
<b>EPP methods</b>
}
DomainApplication .right[#blue].> Domain : approve()
class DomainInformation {
Registrar information on a domain
--
domain (Domain)
domain_application (DomainApplication)
security_email
--
Request information...
}
DomainInformation -- Domain
DomainInformation -- DomainApplication
DomainApplication .[#blue].> DomainInformation : approve()
class UserDomainRole {
Permissions
--
domain (Domain)
user (User)
role="ADMIN"
--
}
UserDomainRole -- User
UserDomainRole -- Domain
DomainApplication .[#blue].> UserDomainRole : approve()
class DomainInvitation {
Email invitations sent
--
email
domain (Domain)
status
--
}
DomainInvitation -- Domain
DomainInvitation .[#green].> UserDomainRole : User.first_login()
actor applicant #Red
applicant -d-> DomainApplication : **/register**
actor analyst #Blue
analyst -[#blue]-> DomainApplication : **approve()**
actor user1 #Green
user1 -[#green]-> Domain : **/domain/<id>/nameservers**
actor user2 #Green
user2 -[#green]-> DomainInformation : **/domain/<id>/?????**
actor user3 #Green
user3 -right[#green]-> UserDomainRole : **/domain/<id>/users/add**
user3 -right[#green]-> DomainInvitation : **/domain/<id>/users/add**
@enduml
```
</details>

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 30 KiB

View file

@ -0,0 +1,291 @@
# Complete model documentation
This is an auto-generated diagram of our data models generated with the
[django-model2puml](https://github.com/sen-den/django-model2puml) library
using the command
```bash
$ docker compose app ./manage.py generate_puml --include registrar
```
![Complete data models diagram](./models_diagram.svg)
<details>
<summary>PlantUML source code</summary>
To regenerate this image using Docker, run
```bash
$ docker run -v $(pwd):$(pwd) -w $(pwd) -it plantuml/plantuml -tsvg models_diagram.md
```
```plantuml
@startuml
class "registrar.Contact <Registrar>" as registrar.Contact #d6f4e9 {
contact
--
+ id (BigAutoField)
+ created_at (DateTimeField)
+ updated_at (DateTimeField)
~ user (OneToOneField)
+ first_name (TextField)
+ middle_name (TextField)
+ last_name (TextField)
+ title (TextField)
+ email (TextField)
+ phone (PhoneNumberField)
--
}
registrar.Contact -- registrar.User
class "registrar.DomainApplication <Registrar>" as registrar.DomainApplication #d6f4e9 {
domain application
--
+ id (BigAutoField)
+ created_at (DateTimeField)
+ updated_at (DateTimeField)
+ status (FSMField)
~ creator (ForeignKey)
~ investigator (ForeignKey)
+ organization_type (CharField)
+ federally_recognized_tribe (BooleanField)
+ state_recognized_tribe (BooleanField)
+ tribe_name (TextField)
+ federal_agency (TextField)
+ federal_type (CharField)
+ is_election_board (BooleanField)
+ organization_name (TextField)
+ address_line1 (TextField)
+ address_line2 (CharField)
+ city (TextField)
+ state_territory (CharField)
+ zipcode (CharField)
+ urbanization (TextField)
+ type_of_work (TextField)
+ more_organization_information (TextField)
~ authorizing_official (ForeignKey)
~ approved_domain (OneToOneField)
~ requested_domain (OneToOneField)
~ submitter (ForeignKey)
+ purpose (TextField)
+ no_other_contacts_rationale (TextField)
+ anything_else (TextField)
+ is_policy_acknowledged (BooleanField)
# current_websites (ManyToManyField)
# alternative_domains (ManyToManyField)
# other_contacts (ManyToManyField)
--
}
registrar.DomainApplication -- registrar.User
registrar.DomainApplication -- registrar.User
registrar.DomainApplication -- registrar.Contact
registrar.DomainApplication -- registrar.DraftDomain
registrar.DomainApplication -- registrar.Domain
registrar.DomainApplication -- registrar.Contact
registrar.DomainApplication *--* registrar.Website
registrar.DomainApplication *--* registrar.Website
registrar.DomainApplication *--* registrar.Contact
class "registrar.DomainInformation <Registrar>" as registrar.DomainInformation #d6f4e9 {
domain information
--
+ id (BigAutoField)
+ created_at (DateTimeField)
+ updated_at (DateTimeField)
~ creator (ForeignKey)
~ domain_application (OneToOneField)
+ organization_type (CharField)
+ federally_recognized_tribe (BooleanField)
+ state_recognized_tribe (BooleanField)
+ tribe_name (TextField)
+ federal_agency (TextField)
+ federal_type (CharField)
+ is_election_board (BooleanField)
+ organization_name (TextField)
+ address_line1 (TextField)
+ address_line2 (CharField)
+ city (TextField)
+ state_territory (CharField)
+ zipcode (CharField)
+ urbanization (TextField)
+ type_of_work (TextField)
+ more_organization_information (TextField)
~ authorizing_official (ForeignKey)
~ domain (OneToOneField)
~ submitter (ForeignKey)
+ purpose (TextField)
+ no_other_contacts_rationale (TextField)
+ anything_else (TextField)
+ is_policy_acknowledged (BooleanField)
+ security_email (EmailField)
# other_contacts (ManyToManyField)
--
}
registrar.DomainInformation -- registrar.User
registrar.DomainInformation -- registrar.DomainApplication
registrar.DomainInformation -- registrar.Contact
registrar.DomainInformation -- registrar.Domain
registrar.DomainInformation -- registrar.Contact
registrar.DomainInformation *--* registrar.Contact
class "registrar.DraftDomain <Registrar>" as registrar.DraftDomain #d6f4e9 {
draft domain
--
+ id (BigAutoField)
+ created_at (DateTimeField)
+ updated_at (DateTimeField)
+ name (CharField)
--
}
class "registrar.Domain <Registrar>" as registrar.Domain #d6f4e9 {
domain
--
+ id (BigAutoField)
+ created_at (DateTimeField)
+ updated_at (DateTimeField)
+ name (CharField)
--
}
class "registrar.HostIP <Registrar>" as registrar.HostIP #d6f4e9 {
host ip
--
+ id (BigAutoField)
+ created_at (DateTimeField)
+ updated_at (DateTimeField)
+ address (CharField)
~ host (ForeignKey)
--
}
registrar.HostIP -- registrar.Host
class "registrar.Host <Registrar>" as registrar.Host #d6f4e9 {
host
--
+ id (BigAutoField)
+ created_at (DateTimeField)
+ updated_at (DateTimeField)
+ name (CharField)
~ domain (ForeignKey)
--
}
registrar.Host -- registrar.Domain
class "registrar.UserDomainRole <Registrar>" as registrar.UserDomainRole #d6f4e9 {
user domain role
--
+ id (BigAutoField)
+ created_at (DateTimeField)
+ updated_at (DateTimeField)
~ user (ForeignKey)
~ domain (ForeignKey)
+ role (TextField)
--
}
registrar.UserDomainRole -- registrar.User
registrar.UserDomainRole -- registrar.Domain
class "registrar.DomainInvitation <Registrar>" as registrar.DomainInvitation #d6f4e9 {
domain invitation
--
+ id (BigAutoField)
+ created_at (DateTimeField)
+ updated_at (DateTimeField)
+ email (EmailField)
~ domain (ForeignKey)
+ status (FSMField)
--
}
registrar.DomainInvitation -- registrar.Domain
class "registrar.Nameserver <Registrar>" as registrar.Nameserver #d6f4e9 {
nameserver
--
+ id (BigAutoField)
+ created_at (DateTimeField)
+ updated_at (DateTimeField)
+ name (CharField)
~ domain (ForeignKey)
~ host_ptr (OneToOneField)
--
}
registrar.Nameserver -- registrar.Domain
registrar.Nameserver -- registrar.Host
class "registrar.PublicContact <Registrar>" as registrar.PublicContact #d6f4e9 {
public contact
--
+ id (BigAutoField)
+ created_at (DateTimeField)
+ updated_at (DateTimeField)
+ contact_type (CharField)
+ registry_id (CharField)
~ domain (ForeignKey)
+ name (TextField)
+ org (TextField)
+ street1 (TextField)
+ street2 (TextField)
+ street3 (TextField)
+ city (TextField)
+ sp (TextField)
+ pc (TextField)
+ cc (TextField)
+ email (TextField)
+ voice (TextField)
+ fax (TextField)
+ pw (TextField)
--
}
registrar.PublicContact -- registrar.Domain
class "registrar.User <Registrar>" as registrar.User #d6f4e9 {
user
--
+ id (BigAutoField)
+ password (CharField)
+ last_login (DateTimeField)
+ is_superuser (BooleanField)
+ username (CharField)
+ first_name (CharField)
+ last_name (CharField)
+ email (EmailField)
+ is_staff (BooleanField)
+ is_active (BooleanField)
+ date_joined (DateTimeField)
+ phone (PhoneNumberField)
# groups (ManyToManyField)
# user_permissions (ManyToManyField)
# domains (ManyToManyField)
--
}
registrar.User *--* registrar.Domain
class "registrar.Website <Registrar>" as registrar.Website #d6f4e9 {
website
--
+ id (BigAutoField)
+ created_at (DateTimeField)
+ updated_at (DateTimeField)
+ website (CharField)
--
}
@enduml
```
</details>

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 72 KiB

View file

@ -0,0 +1,5 @@
# How to change the default contact data
The defaults are located in [src/registrar/models/public_contact.py](../../src/registrar/models/public_contact.py). Change them in the source code and re-deploy.
The choice of which fields to disclose is hardcoded in [src/registrar/models/domain.py](../../src/registrar/models/domain.py) (May 23) but in the future this may become customizable by registrants.

View file

@ -37,3 +37,4 @@ django-webtest = "*"
types-cachetools = "*"
boto3-mocking = "*"
boto3-stubs = "*"
django-model2puml = "*"

171
src/Pipfile.lock generated
View file

@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "ebec8b958bcfde525ad74aa1e777b55855e86b2d63264612bc2855bf167070b1"
"sha256": "b6c1a957da6c715c734906059a81da21cb0eb4c4ab04f204eb58a48ddb8f7234"
},
"pipfile-spec": 6,
"requires": {},
@ -24,19 +24,19 @@
},
"boto3": {
"hashes": [
"sha256:38ca632be379963f2a2749b5f63a81fe1679913b954914f470ad282c77674bbc",
"sha256:4d575c180312bec6108852bae12e6396b9d1bb404154d652c57ee849c62fbb83"
"sha256:62285ecee7629a4388d55ae369536f759622d68d5b9a0ced7c58a0c1a409c0f7",
"sha256:8ff0af0b25266a01616396abc19eb34dc3d44bd867fa4158985924128b9034fb"
],
"index": "pypi",
"version": "==1.26.122"
"version": "==1.26.133"
},
"botocore": {
"hashes": [
"sha256:9e4984a9e9777c6b949aa1e98323fa35480d9f99d447af7e179ae611f7ed5af9",
"sha256:c3b41078d235761b9c5dc22f534a76952622ef96787b96bbd10242ec4d73f2a5"
"sha256:7b38e540f73c921d8cb0ac72794072000af9e10758c04ba7f53d5629cc52fa87",
"sha256:b266185d7414a559952569005009a400de50af91fd3da44f05cf05b00946c4a7"
],
"markers": "python_version >= '3.7'",
"version": "==1.29.122"
"version": "==1.29.133"
},
"cachetools": {
"hashes": [
@ -48,11 +48,11 @@
},
"certifi": {
"hashes": [
"sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3",
"sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18"
"sha256:0f0d56dc5a6ad56fd4ba36484d6cc34451e1c6548c61daad8c320169f91eddc7",
"sha256:c6c2e98f5c7869efca1f8916fed228dd91539f9f1b444c314c06eef02980c716"
],
"markers": "python_version >= '3.6'",
"version": "==2022.12.7"
"version": "==2023.5.7"
},
"cfenv": {
"hashes": [
@ -261,11 +261,11 @@
},
"django": {
"hashes": [
"sha256:ad33ed68db9398f5dfb33282704925bce044bef4261cd4fb59e4e7f9ae505a78",
"sha256:c36e2ab12824e2ac36afa8b2515a70c53c7742f0d6eaefa7311ec379558db997"
"sha256:066b6debb5ac335458d2a713ed995570536c8b59a580005acb0732378d5eb1ee",
"sha256:7efa6b1f781a6119a10ac94b4794ded90db8accbe7802281cd26f8664ffed59c"
],
"index": "pypi",
"version": "==4.2"
"version": "==4.2.1"
},
"django-allow-cidr": {
"hashes": [
@ -338,11 +338,11 @@
},
"faker": {
"hashes": [
"sha256:49060d40e6659e116f53353c5771ad2f2cbcd12b15771f49e3000a3a451f13ec",
"sha256:ac903ba8cb5adbce2cdd15e5536118d484bbe01126f3c774dd9f6df77b61232d"
"sha256:38dbc3b80e655d7301e190426ab30f04b6b7f6ca4764c5dd02772ffde0fa6dcd",
"sha256:f02c6d3fdb5bc781f80b440cf2bdec336ed47ecfb8d620b20c3d4188ed051831"
],
"index": "pypi",
"version": "==18.6.0"
"version": "==18.7.0"
},
"furl": {
"hashes": [
@ -623,19 +623,19 @@
},
"requests": {
"hashes": [
"sha256:e8f3c9be120d3333921d213eef078af392fba3933ab7ed2d1cba3b56f2568c3b",
"sha256:f2e34a75f4749019bb0e3effb66683630e4ffeaf75819fb51bebef1bf5aef059"
"sha256:10e94cc4f3121ee6da529d358cdaeaff2f1c409cd377dbc72b825852f2f7e294",
"sha256:239d7d4458afcb28a692cdd298d87542235f4ca8d36d03a15bfc128a6559a2f4"
],
"index": "pypi",
"version": "==2.29.0"
"version": "==2.30.0"
},
"s3transfer": {
"hashes": [
"sha256:06176b74f3a15f61f1b4f25a1fc29a4429040b7647133a463da8fa5bd28d5ecd",
"sha256:2ed07d3866f523cc561bf4a00fc5535827981b117dd7876f036b0c1aca42c947"
"sha256:3c0da2d074bf35d6870ef157158641178a4204a6e689e82546083e31e0311346",
"sha256:640bb492711f4c0c0905e1f62b6aaeb771881935ad27884852411f8e9cacbca9"
],
"markers": "python_version >= '3.7'",
"version": "==0.6.0"
"version": "==0.6.1"
},
"setuptools": {
"hashes": [
@ -752,11 +752,11 @@
},
"boto3": {
"hashes": [
"sha256:38ca632be379963f2a2749b5f63a81fe1679913b954914f470ad282c77674bbc",
"sha256:4d575c180312bec6108852bae12e6396b9d1bb404154d652c57ee849c62fbb83"
"sha256:62285ecee7629a4388d55ae369536f759622d68d5b9a0ced7c58a0c1a409c0f7",
"sha256:8ff0af0b25266a01616396abc19eb34dc3d44bd867fa4158985924128b9034fb"
],
"index": "pypi",
"version": "==1.26.122"
"version": "==1.26.133"
},
"boto3-mocking": {
"hashes": [
@ -768,27 +768,27 @@
},
"boto3-stubs": {
"hashes": [
"sha256:401e7fe51d88a51b527d883d195ed20c7f57aeb2c0aea24bbb3e911b6d2ad3aa",
"sha256:743a37bfd7d1eed4d67cdf825283abc1d93b7900b81d7426aab7e691e075c897"
"sha256:a921814574761842073822dc5e9fc7ca4f1c5fdeaa53d83cd8831e060dae09c8",
"sha256:cc6a662700e755c1e3dec2383c146b89cd8c70b5921033504bfb8367d03a538f"
],
"index": "pypi",
"version": "==1.26.122"
"version": "==1.26.133"
},
"botocore": {
"hashes": [
"sha256:9e4984a9e9777c6b949aa1e98323fa35480d9f99d447af7e179ae611f7ed5af9",
"sha256:c3b41078d235761b9c5dc22f534a76952622ef96787b96bbd10242ec4d73f2a5"
"sha256:7b38e540f73c921d8cb0ac72794072000af9e10758c04ba7f53d5629cc52fa87",
"sha256:b266185d7414a559952569005009a400de50af91fd3da44f05cf05b00946c4a7"
],
"markers": "python_version >= '3.7'",
"version": "==1.29.122"
"version": "==1.29.133"
},
"botocore-stubs": {
"hashes": [
"sha256:59873a3b535ec3ff0b6bf5f41c9f8a0f8c48032a871bea4d6e4faebbbfc68e8b",
"sha256:e6e6c527a6cac0ec69dd1b755d530c9b2dab01d423ce47bdc636dd01ebb01b1b"
"sha256:5f6f1967d23c45834858a055cbf65b66863f9f28d05f32f57bf52864a13512d9",
"sha256:622c4a5cd740498439008d81c5ded612146f4f0d575341c12591f978edbbe733"
],
"markers": "python_version >= '3.7' and python_version < '4.0'",
"version": "==1.29.122"
"version": "==1.29.130"
},
"click": {
"hashes": [
@ -800,11 +800,11 @@
},
"django": {
"hashes": [
"sha256:ad33ed68db9398f5dfb33282704925bce044bef4261cd4fb59e4e7f9ae505a78",
"sha256:c36e2ab12824e2ac36afa8b2515a70c53c7742f0d6eaefa7311ec379558db997"
"sha256:066b6debb5ac335458d2a713ed995570536c8b59a580005acb0732378d5eb1ee",
"sha256:7efa6b1f781a6119a10ac94b4794ded90db8accbe7802281cd26f8664ffed59c"
],
"index": "pypi",
"version": "==4.2"
"version": "==4.2.1"
},
"django-debug-toolbar": {
"hashes": [
@ -814,6 +814,13 @@
"index": "pypi",
"version": "==4.0.0"
},
"django-model2puml": {
"hashes": [
"sha256:6e773d742e556020a04d3216ce5dee5d3551da162e2d42a997f85b4ed1854771"
],
"index": "pypi",
"version": "==0.4.1"
},
"django-stubs": {
"hashes": [
"sha256:93baff824f0a056e71036b423b942a74f07b909e45e3fa38185b910f597c5c08",
@ -896,35 +903,35 @@
},
"mypy": {
"hashes": [
"sha256:023fe9e618182ca6317ae89833ba422c411469156b690fde6a315ad10695a521",
"sha256:031fc69c9a7e12bcc5660b74122ed84b3f1c505e762cc4296884096c6d8ee140",
"sha256:2de7babe398cb7a85ac7f1fd5c42f396c215ab3eff731b4d761d68d0f6a80f48",
"sha256:2e93a8a553e0394b26c4ca683923b85a69f7ccdc0139e6acd1354cc884fe0128",
"sha256:390bc685ec209ada4e9d35068ac6988c60160b2b703072d2850457b62499e336",
"sha256:3a2d219775a120581a0ae8ca392b31f238d452729adbcb6892fa89688cb8306a",
"sha256:3efde4af6f2d3ccf58ae825495dbb8d74abd6d176ee686ce2ab19bd025273f41",
"sha256:4a99fe1768925e4a139aace8f3fb66db3576ee1c30b9c0f70f744ead7e329c9f",
"sha256:4b41412df69ec06ab141808d12e0bf2823717b1c363bd77b4c0820feaa37249e",
"sha256:4c8d8c6b80aa4a1689f2a179d31d86ae1367ea4a12855cc13aa3ba24bb36b2d8",
"sha256:4d19f1a239d59f10fdc31263d48b7937c585810288376671eaf75380b074f238",
"sha256:4e4a682b3f2489d218751981639cffc4e281d548f9d517addfd5a2917ac78119",
"sha256:695c45cea7e8abb6f088a34a6034b1d273122e5530aeebb9c09626cea6dca4cb",
"sha256:701189408b460a2ff42b984e6bd45c3f41f0ac9f5f58b8873bbedc511900086d",
"sha256:70894c5345bea98321a2fe84df35f43ee7bb0feec117a71420c60459fc3e1eed",
"sha256:8293a216e902ac12779eb7a08f2bc39ec6c878d7c6025aa59464e0c4c16f7eb9",
"sha256:8d26b513225ffd3eacece727f4387bdce6469192ef029ca9dd469940158bc89e",
"sha256:a197ad3a774f8e74f21e428f0de7f60ad26a8d23437b69638aac2764d1e06a6a",
"sha256:bea55fc25b96c53affab852ad94bf111a3083bc1d8b0c76a61dd101d8a388cf5",
"sha256:c9a084bce1061e55cdc0493a2ad890375af359c766b8ac311ac8120d3a472950",
"sha256:d0e9464a0af6715852267bf29c9553e4555b61f5904a4fc538547a4d67617937",
"sha256:d8e9187bfcd5ffedbe87403195e1fc340189a68463903c39e2b63307c9fa0394",
"sha256:eaeaa0888b7f3ccb7bcd40b50497ca30923dba14f385bde4af78fac713d6d6f6",
"sha256:f46af8d162f3d470d8ffc997aaf7a269996d205f9d746124a179d3abe05ac602",
"sha256:f70a40410d774ae23fcb4afbbeca652905a04de7948eaf0b1789c8d1426b72d1",
"sha256:fe91be1c51c90e2afe6827601ca14353bbf3953f343c2129fa1e247d55fd95ba"
"sha256:1c4c42c60a8103ead4c1c060ac3cdd3ff01e18fddce6f1016e08939647a0e703",
"sha256:44797d031a41516fcf5cbfa652265bb994e53e51994c1bd649ffcd0c3a7eccbf",
"sha256:473117e310febe632ddf10e745a355714e771ffe534f06db40702775056614c4",
"sha256:4c99c3ecf223cf2952638da9cd82793d8f3c0c5fa8b6ae2b2d9ed1e1ff51ba85",
"sha256:550a8b3a19bb6589679a7c3c31f64312e7ff482a816c96e0cecec9ad3a7564dd",
"sha256:658fe7b674769a0770d4b26cb4d6f005e88a442fe82446f020be8e5f5efb2fae",
"sha256:6e33bb8b2613614a33dff70565f4c803f889ebd2f859466e42b46e1df76018dd",
"sha256:6e42d29e324cdda61daaec2336c42512e59c7c375340bd202efa1fe0f7b8f8ca",
"sha256:74bc9b6e0e79808bf8678d7678b2ae3736ea72d56eede3820bd3849823e7f305",
"sha256:76ec771e2342f1b558c36d49900dfe81d140361dd0d2df6cd71b3db1be155409",
"sha256:7d23370d2a6b7a71dc65d1266f9a34e4cde9e8e21511322415db4b26f46f6b8c",
"sha256:87df44954c31d86df96c8bd6e80dfcd773473e877ac6176a8e29898bfb3501cb",
"sha256:8c5979d0deb27e0f4479bee18ea0f83732a893e81b78e62e2dda3e7e518c92ee",
"sha256:95d8d31a7713510685b05fbb18d6ac287a56c8f6554d88c19e73f724a445448a",
"sha256:a22435632710a4fcf8acf86cbd0d69f68ac389a3892cb23fbad176d1cddaf228",
"sha256:a8763e72d5d9574d45ce5881962bc8e9046bf7b375b0abf031f3e6811732a897",
"sha256:c1eb485cea53f4f5284e5baf92902cd0088b24984f4209e25981cc359d64448d",
"sha256:c5d2cc54175bab47011b09688b418db71403aefad07cbcd62d44010543fc143f",
"sha256:cbc07246253b9e3d7d74c9ff948cd0fd7a71afcc2b77c7f0a59c26e9395cb152",
"sha256:d0b6c62206e04061e27009481cb0ec966f7d6172b5b936f3ead3d74f29fe3dcf",
"sha256:ddae0f39ca146972ff6bb4399f3b2943884a774b8771ea0a8f50e971f5ea5ba8",
"sha256:e1f4d16e296f5135624b34e8fb741eb0eadedca90862405b1f1fde2040b9bd11",
"sha256:e86c2c6852f62f8f2b24cb7a613ebe8e0c7dc1402c61d36a609174f63e0ff017",
"sha256:ebc95f8386314272bbc817026f8ce8f4f0d2ef7ae44f947c4664efac9adec929",
"sha256:f9dca1e257d4cc129517779226753dbefb4f2266c4eaad610fc15c6a7e14283e",
"sha256:faff86aa10c1aa4a10e1a301de160f3d8fc8703b88c7e98de46b531ff1276a9a"
],
"index": "pypi",
"version": "==1.2.0"
"version": "==1.3.0"
},
"mypy-extensions": {
"hashes": [
@ -968,11 +975,11 @@
},
"platformdirs": {
"hashes": [
"sha256:47692bc24c1958e8b0f13dd727307cff1db103fca36399f457da8e05f222fdc4",
"sha256:7954a68d0ba23558d753f73437c55f89027cf8f5108c19844d4b82e5af396335"
"sha256:412dae91f52a6f84830f39a8078cecd0e866cb72294a5c66808e74d5e88d251f",
"sha256:e2378146f1964972c03c085bb5662ae80b2b8c06226c54b2ff4aa9483e8a13a5"
],
"markers": "python_version >= '3.7'",
"version": "==3.5.0"
"version": "==3.5.1"
},
"pycodestyle": {
"hashes": [
@ -1062,11 +1069,11 @@
},
"s3transfer": {
"hashes": [
"sha256:06176b74f3a15f61f1b4f25a1fc29a4429040b7647133a463da8fa5bd28d5ecd",
"sha256:2ed07d3866f523cc561bf4a00fc5535827981b117dd7876f036b0c1aca42c947"
"sha256:3c0da2d074bf35d6870ef157158641178a4204a6e689e82546083e31e0311346",
"sha256:640bb492711f4c0c0905e1f62b6aaeb771881935ad27884852411f8e9cacbca9"
],
"markers": "python_version >= '3.7'",
"version": "==0.6.0"
"version": "==0.6.1"
},
"six": {
"hashes": [
@ -1118,11 +1125,11 @@
},
"types-awscrt": {
"hashes": [
"sha256:40854d9d7ce055620d5d41e5adc84df11b879aedbd2cf20de84e73f084aa5797",
"sha256:fe38c6fd71199a9f739b69a7c2f3a574585457c4f63730a62830628a7bffc5b0"
"sha256:9e447df3ad46767887d14fa9c856df94f80e8a0a7f0169577ab23b52ee37bcdf",
"sha256:e28fb3f20568ce9e96e33e01e0b87b891822f36b8f368adb582553b016d4aa08"
],
"markers": "python_version >= '3.7' and python_version < '4.0'",
"version": "==0.16.16"
"version": "==0.16.17"
},
"types-cachetools": {
"hashes": [
@ -1148,26 +1155,26 @@
},
"types-requests": {
"hashes": [
"sha256:0d580652ce903f643f8c3b494dd01d29367ea57cea0c7ad7f65cf3169092edb0",
"sha256:cc1aba862575019306b2ed134eb1ea994cab1c887a22e18d3383e6dd42e9789b"
"sha256:c6cf08e120ca9f0dc4fa4e32c3f953c3fba222bcc1db6b97695bce8da1ba9864",
"sha256:dec781054324a70ba64430ae9e62e7e9c8e4618c185a5cb3f87a6738251b5a31"
],
"index": "pypi",
"version": "==2.28.11.17"
"version": "==2.30.0.0"
},
"types-s3transfer": {
"hashes": [
"sha256:40e665643f0647832d51c4a26d8a8275cda9134b02bf22caf28198b79bcad382",
"sha256:d9c669b30fdd61347720434aacb8ecc4645d900712a70b10f495104f9039c07b"
"sha256:6d1ac1dedac750d570428362acdf60fdd4f277b0788855c3894d3226756b2bfb",
"sha256:75ac1d7143d58c1e6af467cfd4a96c67ee058a3adf7c249d9309999e1f5f41e4"
],
"markers": "python_version >= '3.7' and python_version < '4.0'",
"version": "==0.6.0.post7"
"version": "==0.6.1"
},
"types-urllib3": {
"hashes": [
"sha256:04235e792139cf3624b25d38faab593456738fbdb7439634046172e3b1339400",
"sha256:697102ddf4f781eed6f692353f40cee1098643526f5a8b99f49d2ede90fd3754"
"sha256:3300538c9dc11dad32eae4827ac313f5d986b8b21494801f1bf97a1ac6c03ae5",
"sha256:5dbd1d2bef14efee43f5318b5d36d805a489f6600252bb53626d4bfafd95e27c"
],
"version": "==1.26.25.11"
"version": "==1.26.25.13"
},
"typing-extensions": {
"hashes": [

View file

@ -43,10 +43,15 @@ 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 epplib.models import common
except ImportError:
pass
__all__ = [
"CLIENT",
"commands",
"common",
"ErrorCode",
"RegistryError",
]

View file

@ -67,54 +67,47 @@ class EPPLibWrapper:
def _send(self, command):
"""Helper function used by `send`."""
try:
cmd_type = command.__class__.__name__
with self._connect as wire:
response = wire.send(command)
except (ValueError, ParsingError) as err:
logger.warning(
"%s failed to execute due to some syntax error."
% command.__class__.__name__,
exc_info=True,
)
raise RegistryError() from err
message = "%s failed to execute due to some syntax error."
logger.warning(message, cmd_type, exc_info=True)
raise RegistryError(message) from err
except TransportError as err:
logger.warning(
"%s failed to execute due to a connection error."
% command.__class__.__name__,
exc_info=True,
)
raise RegistryError() from err
message = "%s failed to execute due to a connection error."
logger.warning(message, cmd_type, exc_info=True)
raise RegistryError(message) from err
except LoginError as err:
logger.warning(
"%s failed to execute due to a registry login error."
% command.__class__.__name__,
exc_info=True,
)
raise RegistryError() from err
message = "%s failed to execute due to a registry login error."
logger.warning(message, cmd_type, exc_info=True)
raise RegistryError(message) from err
except Exception as err:
logger.warning(
"%s failed to execute due to an unknown error."
% command.__class__.__name__,
exc_info=True,
)
raise RegistryError() from err
message = "%s failed to execute due to an unknown error."
logger.warning(message, cmd_type, exc_info=True)
raise RegistryError(message) from err
else:
if response.code >= 2000:
raise RegistryError(response.msg)
raise RegistryError(response.msg, code=response.code)
else:
return response
def send(self, command):
def send(self, command, *, cleaned=False):
"""Login, send the command, then close the connection. Tries 3 times."""
# try to prevent use of this method without appropriate safeguards
if not cleaned:
raise ValueError("Please sanitize user input before sending it.")
counter = 0 # we'll try 3 times
while True:
try:
return self._send(command)
except RegistryError as err:
if counter == 3: # don't try again
raise err
else:
if err.should_retry() and counter < 3:
counter += 1
sleep((counter * 50) / 1000) # sleep 50 ms to 150 ms
else: # don't try again
raise err
try:

View file

@ -1,5 +1,77 @@
from enum import IntEnum
class ErrorCode(IntEnum):
"""
Overview of registry response codes from RFC 5730. See RFC 5730 for full text.
- 1000 - 1500 Success
- 2000 - 2308 Registrar did something silly
- 2400 - 2500 Registry did something silly
- 2501 - 2502 Something malicious or abusive may have occurred
"""
COMMAND_COMPLETED_SUCCESSFULLY = 1000
COMMAND_COMPLETED_SUCCESSFULLY_ACTION_PENDING = 1001
COMMAND_COMPLETED_SUCCESSFULLY_NO_MESSAGES = 1300
COMMAND_COMPLETED_SUCCESSFULLY_ACK_TO_DEQUEUE = 1301
COMMAND_COMPLETED_SUCCESSFULLY_ENDING_SESSION = 1500
UNKNOWN_COMMAND = 2000
COMMAND_SYNTAX_ERROR = 2001
COMMAND_USE_ERROR = 2002
REQUIRED_PARAMETER_MISSING = 2003
PARAMETER_VALUE_RANGE_ERROR = 2004
PARAMETER_VALUE_SYNTAX_ERROR = 2005
UNIMPLEMENTED_PROTOCOL_VERSION = 2100
UNIMPLEMENTED_COMMAND = 2101
UNIMPLEMENTED_OPTION = 2102
UNIMPLEMENTED_EXTENSION = 2103
BILLING_FAILURE = 2104
OBJECT_IS_NOT_ELIGIBLE_FOR_RENEWAL = 2105
OBJECT_IS_NOT_ELIGIBLE_FOR_TRANSFER = 2106
AUTHENTICATION_ERROR = 2200
AUTHORIZATION_ERROR = 2201
INVALID_AUTHORIZATION_INFORMATION = 2202
OBJECT_PENDING_TRANSFER = 2300
OBJECT_NOT_PENDING_TRANSFER = 2301
OBJECT_EXISTS = 2302
OBJECT_DOES_NOT_EXIST = 2303
OBJECT_STATUS_PROHIBITS_OPERATION = 2304
OBJECT_ASSOCIATION_PROHIBITS_OPERATION = 2305
PARAMETER_VALUE_POLICY_ERROR = 2306
UNIMPLEMENTED_OBJECT_SERVICE = 2307
DATA_MANAGEMENT_POLICY_VIOLATION = 2308
COMMAND_FAILED = 2400
COMMAND_FAILED_SERVER_CLOSING_CONNECTION = 2500
AUTHENTICATION_ERROR_SERVER_CLOSING_CONNECTION = 2501
SESSION_LIMIT_EXCEEDED_SERVER_CLOSING_CONNECTION = 2502
class RegistryError(Exception):
pass
"""
Overview of registry response codes from RFC 5730. See RFC 5730 for full text.
- 1000 - 1500 Success
- 2000 - 2308 Registrar did something silly
- 2400 - 2500 Registry did something silly
- 2501 - 2502 Something malicious or abusive may have occurred
"""
def __init__(self, *args, code=None, **kwargs):
super().__init__(*args, **kwargs)
self.code = code
def should_retry(self):
return self.code == ErrorCode.COMMAND_FAILED
def is_server_error(self):
return self.code is not None and (self.code >= 2400 and self.code <= 2500)
def is_client_error(self):
return self.code is not None and (self.code >= 2000 and self.code <= 2308)
class LoginError(RegistryError):

View file

@ -109,6 +109,8 @@ INSTALLED_APPS = [
"registrar",
# Our internal API application
"api",
# Only for generating documentation, uncomment to run manage.py generate_puml
# "puml_generator",
]
# Middleware are routines for processing web requests.

View file

@ -59,12 +59,12 @@ urlpatterns = [
),
path(
"application/<int:pk>/withdraw",
views.ApplicationWithdraw.as_view(),
views.ApplicationWithdrawConfirmation.as_view(),
name="application-withdraw-confirmation",
),
path(
"application/<int:pk>/withdrawconfirmed",
views.ApplicationWithdraw.updatestatus,
views.ApplicationWithdrawn.as_view(),
name="application-withdrawn",
),
path("health/", views.health),
@ -83,6 +83,11 @@ urlpatterns = [
views.DomainNameserversView.as_view(),
name="domain-nameservers",
),
path(
"domain/<int:pk>/your-contact-information",
views.DomainYourContactInformationView.as_view(),
name="domain-your-contact-information",
),
path(
"domain/<int:pk>/security-email",
views.DomainSecurityEmailView.as_view(),

View file

@ -1,2 +1,7 @@
from .application_wizard import *
from .domain import DomainAddUserForm, NameserverFormset, DomainSecurityEmailForm
from .domain import (
DomainAddUserForm,
NameserverFormset,
DomainSecurityEmailForm,
ContactForm,
)

View file

@ -3,6 +3,10 @@
from django import forms
from django.forms import formset_factory
from phonenumber_field.widgets import RegionalPhoneNumberWidget
from ..models import Contact
class DomainAddUserForm(forms.Form):
@ -29,3 +33,34 @@ class DomainSecurityEmailForm(forms.Form):
"""Form for adding or editing a security email to a domain."""
security_email = forms.EmailField(label="Security email")
class ContactForm(forms.ModelForm):
"""Form for updating contacts."""
class Meta:
model = Contact
fields = ["first_name", "middle_name", "last_name", "title", "email", "phone"]
widgets = {
"first_name": forms.TextInput,
"middle_name": forms.TextInput,
"last_name": forms.TextInput,
"title": forms.TextInput,
"email": forms.EmailInput,
"phone": RegionalPhoneNumberWidget,
}
# the database fields have blank=True so ModelForm doesn't create
# required fields by default. Use this list in __init__ to mark each
# of these fields as required
required = ["first_name", "last_name", "title", "email", "phone"]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# take off maxlength attribute for the phone number field
# which interferes with our input_with_errors template tag
self.fields["phone"].widget.attrs.pop("maxlength", None)
for field_name in self.required:
self.fields[field_name].required = True

View file

@ -0,0 +1,44 @@
# Generated by Django 4.2.1 on 2023-05-31 23:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("registrar", "0022_draftdomain_domainapplication_approved_domain_and_more"),
]
operations = [
migrations.AlterField(
model_name="contact",
name="first_name",
field=models.TextField(
blank=True,
db_index=True,
help_text="First name",
null=True,
verbose_name="first name / given name",
),
),
migrations.AlterField(
model_name="contact",
name="last_name",
field=models.TextField(
blank=True,
db_index=True,
help_text="Last name",
null=True,
verbose_name="last name / family name",
),
),
migrations.AlterField(
model_name="contact",
name="title",
field=models.TextField(
blank=True,
help_text="Title",
null=True,
verbose_name="title or role in your organization",
),
),
]

View file

@ -20,6 +20,7 @@ class Contact(TimeStampedModel):
null=True,
blank=True,
help_text="First name",
verbose_name="first name / given name",
db_index=True,
)
middle_name = models.TextField(
@ -31,12 +32,14 @@ class Contact(TimeStampedModel):
null=True,
blank=True,
help_text="Last name",
verbose_name="last name / family name",
db_index=True,
)
title = models.TextField(
null=True,
blank=True,
help_text="Title",
verbose_name="title or role in your organization",
)
email = models.TextField(
null=True,

View file

@ -31,7 +31,7 @@
<p> <b class="review__step__name">Last updated:</b> {{domainapplication.updated_at|date:"F j, Y"}}<br>
<b class="review__step__name">Request #:</b> {{domainapplication.id}}</p>
<p>{% include "includes/domain_application.html" %}</p>
<p><a href="{% url 'application-withdraw-confirmation' domainapplication.id %}" class="usa-button usa-button--outline withdraw_outline">
<p><a href="{% url 'application-withdraw-confirmation' pk=domainapplication.id %}" class="usa-button usa-button--outline withdraw_outline">
Withdraw request</a>
</p>
</div>

View file

@ -40,7 +40,7 @@
</li>
<li class="usa-sidenav__item">
{% url 'todo' as url %}
{% url 'domain-your-contact-information' pk=domain.id as url %}
<a href="{{ url }}"
{% if request.path == url %}class="usa-current"{% endif %}
>

View file

@ -0,0 +1,35 @@
{% extends "domain_base.html" %}
{% load static field_helpers %}
{% block title %}Domain contact information | {{ domain.name }} | {% endblock %}
{% block domain_content %}
<h1>Domain contact information</h1>
<p>If youd like us to use a different name, email, or phone number you can make those changes below. Changing your contact information here wont affect your Login.gov account information.</p>
{% include "includes/required_fields.html" %}
<form class="usa-form usa-form--large" method="post" novalidate id="form-container">
{% csrf_token %}
{% input_with_errors form.first_name %}
{% input_with_errors form.middle_name %}
{% input_with_errors form.last_name %}
{% input_with_errors form.title %}
{% input_with_errors form.email %}
{% input_with_errors form.phone %}
<button
type="submit"
class="usa-button"
>Save</button>
</form>
{% endblock %} {# domain_content #}

View file

@ -13,6 +13,7 @@ import boto3_mocking # type: ignore
from registrar.models import (
DomainApplication,
Domain,
DomainInformation,
DraftDomain,
DomainInvitation,
Contact,
@ -1030,12 +1031,16 @@ class TestWithDomainPermissions(TestWithUser):
def setUp(self):
super().setUp()
self.domain, _ = Domain.objects.get_or_create(name="igorville.gov")
self.domain_information, _ = DomainInformation.objects.get_or_create(
creator=self.user, domain=self.domain
)
self.role, _ = UserDomainRole.objects.get_or_create(
user=self.user, domain=self.domain, role=UserDomainRole.Roles.ADMIN
)
def tearDown(self):
try:
self.domain_information.delete()
if hasattr(self.domain, "contacts"):
self.domain.contacts.all().delete()
self.domain.delete()
@ -1048,55 +1053,39 @@ class TestWithDomainPermissions(TestWithUser):
class TestDomainPermissions(TestWithDomainPermissions):
def test_not_logged_in(self):
"""Not logged in gets a redirect to Login."""
response = self.client.get(reverse("domain", kwargs={"pk": self.domain.id}))
self.assertEqual(response.status_code, 302)
response = self.client.get(
reverse("domain-users", kwargs={"pk": self.domain.id})
)
self.assertEqual(response.status_code, 302)
response = self.client.get(
reverse("domain-users-add", kwargs={"pk": self.domain.id})
)
self.assertEqual(response.status_code, 302)
response = self.client.get(
reverse("domain-nameservers", kwargs={"pk": self.domain.id})
)
self.assertEqual(response.status_code, 302)
response = self.client.get(
reverse("domain-security-email", kwargs={"pk": self.domain.id})
)
self.assertEqual(response.status_code, 302)
for view_name in [
"domain",
"domain-users",
"domain-users-add",
"domain-nameservers",
"domain-your-contact-information",
"domain-security-email",
]:
with self.subTest(view_name=view_name):
response = self.client.get(
reverse(view_name, kwargs={"pk": self.domain.id})
)
self.assertEqual(response.status_code, 302)
def test_no_domain_role(self):
"""Logged in but no role gets 403 Forbidden."""
self.client.force_login(self.user)
self.role.delete() # user no longer has a role on this domain
with less_console_noise():
response = self.client.get(reverse("domain", kwargs={"pk": self.domain.id}))
self.assertEqual(response.status_code, 403)
with less_console_noise():
response = self.client.get(
reverse("domain-users", kwargs={"pk": self.domain.id})
)
self.assertEqual(response.status_code, 403)
with less_console_noise():
response = self.client.get(
reverse("domain-users-add", kwargs={"pk": self.domain.id})
)
self.assertEqual(response.status_code, 403)
with less_console_noise():
response = self.client.get(
reverse("domain-nameservers", kwargs={"pk": self.domain.id})
)
self.assertEqual(response.status_code, 403)
for view_name in [
"domain",
"domain-users",
"domain-users-add",
"domain-nameservers",
"domain-your-contact-information",
"domain-security-email",
]:
with self.subTest(view_name=view_name):
with less_console_noise():
response = self.client.get(
reverse(view_name, kwargs={"pk": self.domain.id})
)
self.assertEqual(response.status_code, 403)
with less_console_noise():
response = self.client.get(
@ -1139,7 +1128,7 @@ class TestDomainDetail(TestWithDomainPermissions, WebTest):
self.assertContains(response, "Add another user")
def test_domain_user_add_form(self):
"""Adding a user works."""
"""Adding an existing user works."""
other_user, _ = get_user_model().objects.get_or_create(
email="mayor@igorville.gov"
)
@ -1222,6 +1211,22 @@ class TestDomainDetail(TestWithDomainPermissions, WebTest):
with self.assertRaises(DomainInvitation.DoesNotExist):
DomainInvitation.objects.get(id=invitation.id)
def test_domain_invitation_cancel_no_permissions(self):
"""Posting to the delete view as a different user should fail."""
EMAIL = "mayor@igorville.gov"
invitation, _ = DomainInvitation.objects.get_or_create(
domain=self.domain, email=EMAIL
)
other_user = User()
other_user.save()
self.client.force_login(other_user)
with less_console_noise(): # permission denied makes console errors
result = self.client.post(
reverse("invitation-delete", kwargs={"pk": invitation.id})
)
self.assertEqual(result.status_code, 403)
@boto3_mocking.patching
def test_domain_invitation_flow(self):
"""Send an invitation to a new user, log in and load the dashboard."""
@ -1296,6 +1301,23 @@ class TestDomainDetail(TestWithDomainPermissions, WebTest):
# the field.
self.assertContains(result, "This field is required", count=2, status_code=200)
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")
def test_domain_your_contact_information_content(self):
"""Your contact information appears on the page."""
self.domain_information.submitter = Contact(first_name="Testy")
self.domain_information.submitter.save()
self.domain_information.save()
page = self.app.get(
reverse("domain-your-contact-information", kwargs={"pk": self.domain.id})
)
self.assertContains(page, "Testy")
def test_domain_security_email(self):
"""Can load domain's security email page."""
page = self.client.get(
@ -1333,6 +1355,7 @@ class TestApplicationStatus(TestWithUser, WebTest):
def setUp(self):
super().setUp()
self.app.set_user(self.user.username)
self.client.force_login(self.user)
def _completed_application(
self,
@ -1446,3 +1469,24 @@ class TestApplicationStatus(TestWithUser, WebTest):
)
home_page = self.app.get("/")
self.assertContains(home_page, "Withdrawn")
def test_application_status_no_permissions(self):
"""Can't access applications without being the creator."""
application = self._completed_application()
other_user = User()
other_user.save()
application.creator = other_user
application.save()
# PermissionDeniedErrors make lots of noise in test output
with less_console_noise():
for url_name in [
"application-status",
"application-withdraw-confirmation",
"application-withdrawn",
]:
with self.subTest(url_name=url_name):
page = self.client.get(
reverse(url_name, kwargs={"pk": application.pk})
)
self.assertEqual(page.status_code, 403)

View file

@ -2,6 +2,7 @@ from .application import *
from .domain import (
DomainView,
DomainNameserversView,
DomainYourContactInformationView,
DomainSecurityEmailView,
DomainUsersView,
DomainAddUserView,

View file

@ -6,7 +6,6 @@ from django.shortcuts import redirect, render
from django.urls import resolve, reverse
from django.utils.translation import gettext_lazy as _
from django.views.generic import TemplateView
from django.views import generic
from django.contrib import messages
from registrar.forms import application_wizard as forms
@ -14,7 +13,7 @@ from registrar.models import DomainApplication
from registrar.utility import StrEnum
from registrar.views.utility import StepsHelper
from .utility import DomainPermission
from .utility import DomainApplicationPermissionView
logger = logging.getLogger(__name__)
@ -478,29 +477,31 @@ class Finished(ApplicationWizard):
return render(self.request, self.template_name, context)
class ApplicationStatus(generic.DetailView):
model = DomainApplication
class ApplicationStatus(DomainApplicationPermissionView):
template_name = "application_status.html"
def get_context_data(self, **kwargs):
"""Get context details to process information from application"""
context = super(ApplicationStatus, self).get_context_data(**kwargs)
return context
class ApplicationWithdrawConfirmation(DomainApplicationPermissionView):
"""This page will ask user to confirm if they want to withdraw
class ApplicationWithdraw(LoginRequiredMixin, generic.DetailView, DomainPermission):
model = DomainApplication
template_name = "application_withdraw_confirmation.html"
""" The page above will display asking user to confirm if they want to withdraw;
Note it uses "DomainPermission" from Domain to ensure that the person who
applied only have access to withdraw the request
The DomainApplicationPermissionView restricts access so that only the
`creator` of the application may withdraw it.
"""
def updatestatus(request, pk):
"""If user click on withdraw confirm button, it will be updated to withdraw
and send back to homepage"""
application = DomainApplication.objects.get(id=pk)
template_name = "application_withdraw_confirmation.html"
class ApplicationWithdrawn(DomainApplicationPermissionView):
# this view renders no template
template_name = ""
def get(self, *args, **kwargs):
"""View class that does the actual withdrawing.
If user click on withdraw confirm button, this view updates the status
to withdraw and send back to homepage.
"""
application = DomainApplication.objects.get(id=self.kwargs["pk"])
application.status = "withdrawn"
application.save()
return HttpResponseRedirect(reverse("home"))

View file

@ -1,4 +1,9 @@
"""View for a single Domain."""
"""Views for a single Domain.
Authorization is handled by the `DomainPermissionView`. To ensure that only
authorized users can see information on a domain, every view here should
inherit from `DomainPermissionView` (or DomainInvitationPermissionDeleteView).
"""
import logging
@ -7,35 +12,39 @@ from django.contrib.messages.views import SuccessMessageMixin
from django.db import IntegrityError
from django.shortcuts import redirect
from django.urls import reverse
from django.views.generic import DetailView
from django.views.generic.edit import DeleteView, FormMixin
from django.views.generic.edit import FormMixin
from registrar.models import Domain, DomainInvitation, User, UserDomainRole
from registrar.models import (
DomainInvitation,
User,
UserDomainRole,
)
from ..forms import DomainAddUserForm, NameserverFormset, DomainSecurityEmailForm
from ..forms import (
DomainAddUserForm,
NameserverFormset,
DomainSecurityEmailForm,
ContactForm,
)
from ..utility.email import send_templated_email, EmailSendingError
from .utility import DomainPermission
from .utility import DomainPermissionView, DomainInvitationPermissionDeleteView
logger = logging.getLogger(__name__)
class DomainView(DomainPermission, DetailView):
class DomainView(DomainPermissionView):
"""Domain detail overview page."""
model = Domain
template_name = "domain_detail.html"
context_object_name = "domain"
class DomainNameserversView(DomainPermission, FormMixin, DetailView):
class DomainNameserversView(DomainPermissionView, FormMixin):
"""Domain nameserver editing view."""
model = Domain
template_name = "domain_nameservers.html"
context_object_name = "domain"
form_class = NameserverFormset
def get_initial(self):
@ -44,7 +53,7 @@ class DomainNameserversView(DomainPermission, FormMixin, DetailView):
return [{"server": name} for name, *ip in domain.nameservers]
def get_success_url(self):
"""Redirect to the overview page for the domain."""
"""Redirect to the nameservers page for the domain."""
return reverse("domain-nameservers", kwargs={"pk": self.object.pk})
def get_context_data(self, **kwargs):
@ -97,13 +106,51 @@ class DomainNameserversView(DomainPermission, FormMixin, DetailView):
return super().form_valid(formset)
class DomainSecurityEmailView(DomainPermission, FormMixin, DetailView):
class DomainYourContactInformationView(DomainPermissionView, FormMixin):
"""Domain your contact information editing view."""
template_name = "domain_your_contact_information.html"
form_class = ContactForm
def get_form_kwargs(self, *args, **kwargs):
"""Add domain_info.submitter instance to make a bound form."""
form_kwargs = super().get_form_kwargs(*args, **kwargs)
form_kwargs["instance"] = self.get_object().domain_info.submitter
return form_kwargs
def get_success_url(self):
"""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."""
# Post to DB using values from the form
form.save()
messages.success(
self.request, "Your contact information for this domain has been updated."
)
# superclass has the redirect
return super().form_valid(form)
class DomainSecurityEmailView(DomainPermissionView, FormMixin):
"""Domain security email editing view."""
model = Domain
template_name = "domain_security_email.html"
context_object_name = "domain"
form_class = DomainSecurityEmailForm
def get_initial(self):
@ -114,11 +161,11 @@ class DomainSecurityEmailView(DomainPermission, FormMixin, DetailView):
return initial
def get_success_url(self):
"""Redirect to the overview page for the domain."""
"""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):
"""Formset submission posts to this view."""
"""Form submission posts to this view."""
self.object = self.get_object()
form = self.get_form()
if form.is_valid():
@ -144,16 +191,14 @@ class DomainSecurityEmailView(DomainPermission, FormMixin, DetailView):
return redirect(self.get_success_url())
class DomainUsersView(DomainPermission, DetailView):
class DomainUsersView(DomainPermissionView):
"""User management page in the domain details."""
model = Domain
template_name = "domain_users.html"
context_object_name = "domain"
class DomainAddUserView(DomainPermission, FormMixin, DetailView):
class DomainAddUserView(DomainPermissionView, FormMixin):
"""Inside of a domain's user management, a form for adding users.
@ -162,7 +207,6 @@ class DomainAddUserView(DomainPermission, FormMixin, DetailView):
"""
template_name = "domain_add_user.html"
model = Domain
form_class = DomainAddUserForm
def get_success_url(self):
@ -242,8 +286,9 @@ class DomainAddUserView(DomainPermission, FormMixin, DetailView):
return redirect(self.get_success_url())
class DomainInvitationDeleteView(SuccessMessageMixin, DeleteView):
model = DomainInvitation
class DomainInvitationDeleteView(
DomainInvitationPermissionDeleteView, SuccessMessageMixin
):
object: DomainInvitation # workaround for type mismatch in DeleteView
def get_success_url(self):

View file

@ -1,3 +1,8 @@
from .steps_helper import StepsHelper
from .always_404 import always_404
from .mixins import DomainPermission
from .permission_views import (
DomainPermissionView,
DomainApplicationPermissionView,
DomainInvitationPermissionDeleteView,
)

View file

@ -2,7 +2,7 @@
from django.contrib.auth.mixins import PermissionRequiredMixin
from registrar.models import UserDomainRole
from registrar.models import UserDomainRole, DomainApplication, DomainInvitation
class PermissionsLoginMixin(PermissionRequiredMixin):
@ -35,3 +35,48 @@ class DomainPermission(PermissionsLoginMixin):
# if we need to check more about the nature of role, do it here.
return True
class DomainApplicationPermission(PermissionsLoginMixin):
"""Does the logged-in user have access to this domain application?"""
def has_permission(self):
"""Check if this user has access to this domain application.
The user is in self.request.user and the domain needs to be looked
up from the domain's primary key in self.kwargs["pk"]
"""
if not self.request.user.is_authenticated:
return False
# user needs to be the creator of the application
# this query is empty if there isn't a domain application with this
# id and this user as creator
if not DomainApplication.objects.filter(
creator=self.request.user, id=self.kwargs["pk"]
).exists():
return False
return True
class DomainInvitationPermission(PermissionsLoginMixin):
"""Does the logged-in user have access to this domain invitation?
A user has access to a domain invitation if they have a role on the
associated domain.
"""
def has_permission(self):
"""Check if this user has a role on the domain of this invitation."""
if not self.request.user.is_authenticated:
return False
if not DomainInvitation.objects.filter(
id=self.kwargs["pk"], domain__permissions__user=self.request.user
).exists():
return False
return True

View file

@ -0,0 +1,69 @@
"""View classes that enforce authorization."""
import abc # abstract base class
from django.views.generic import DetailView, DeleteView
from registrar.models import Domain, DomainApplication, DomainInvitation
from .mixins import (
DomainPermission,
DomainApplicationPermission,
DomainInvitationPermission,
)
class DomainPermissionView(DomainPermission, DetailView, abc.ABC):
"""Abstract base view for domains that enforces permissions.
This abstract view cannot be instantiated. Actual views must specify
`template_name`.
"""
# DetailView property for what model this is viewing
model = Domain
# variable name in template context for the model object
context_object_name = "domain"
# Abstract property enforces NotImplementedError on an attribute.
@property
@abc.abstractmethod
def template_name(self):
raise NotImplementedError
class DomainApplicationPermissionView(DomainApplicationPermission, DetailView, abc.ABC):
"""Abstract base view for domain applications that enforces permissions
This abstract view cannot be instantiated. Actual views must specify
`template_name`.
"""
# DetailView property for what model this is viewing
model = DomainApplication
# variable name in template context for the model object
context_object_name = "domainapplication"
# Abstract property enforces NotImplementedError on an attribute.
@property
@abc.abstractmethod
def template_name(self):
raise NotImplementedError
class DomainInvitationPermissionDeleteView(
DomainInvitationPermission, DeleteView, abc.ABC
):
"""Abstract view for deleting a domain invitation.
This one is fairly specialized, but this is the only thing that we do
right now with domain invitations. We still have the full
`DomainInvitationPermission` class, but here we just pair it with a
DeleteView.
"""
model = DomainInvitation
object: DomainInvitation # workaround for type mismatch in DeleteView

View file

@ -52,6 +52,7 @@
10038 OUTOFSCOPE http://app:8080/users
10038 OUTOFSCOPE http://app:8080/users/add
10038 OUTOFSCOPE http://app:8080/nameservers
10038 OUTOFSCOPE http://app:8080/your-contact-information
10038 OUTOFSCOPE http://app:8080/security-email
10038 OUTOFSCOPE http://app:8080/delete
10038 OUTOFSCOPE http://app:8080/withdraw