diff --git a/.github/ISSUE_TEMPLATE/issue-default.yml b/.github/ISSUE_TEMPLATE/issue-default.yml index 35c816e35..27ec10415 100644 --- a/.github/ISSUE_TEMPLATE/issue-default.yml +++ b/.github/ISSUE_TEMPLATE/issue-default.yml @@ -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 \ No newline at end of file + 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. diff --git a/docs/developer/README.md b/docs/developer/README.md index de97b6107..c23671aac 100644 --- a/docs/developer/README.md +++ b/docs/developer/README.md @@ -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. diff --git a/docs/developer/user-permissions.md b/docs/developer/user-permissions.md index af5aa1259..31b69d3b3 100644 --- a/docs/developer/user-permissions.md +++ b/docs/developer/user-permissions.md @@ -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) diff --git a/docs/django-admin/roles.md b/docs/django-admin/roles.md index ab4867184..91c2949eb 100644 --- a/docs/django-admin/roles.md +++ b/docs/django-admin/roles.md @@ -1,21 +1,19 @@ # 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 \ No newline at end of file +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) \ No newline at end of file diff --git a/docs/operations/README.md b/docs/operations/README.md index e4ab64135..4de866cf5 100644 --- a/docs/operations/README.md +++ b/docs/operations/README.md @@ -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 diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 275f67bb3..174500f28 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -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): @@ -405,11 +432,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 +651,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 +813,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 +900,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 +917,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) diff --git a/src/registrar/fixtures.py b/src/registrar/fixtures_applications.py similarity index 51% rename from src/registrar/fixtures.py rename to src/registrar/fixtures_applications.py index e1db054b1..18be79814 100644 --- a/src/registrar/fixtures.py +++ b/src/registrar/fixtures_applications.py @@ -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. diff --git a/src/registrar/fixtures_users.py b/src/registrar/fixtures_users.py new file mode 100644 index 000000000..6b6e191d8 --- /dev/null +++ b/src/registrar/fixtures_users.py @@ -0,0 +1,177 @@ +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", + }, + { + "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", + }, + ] + + 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") diff --git a/src/registrar/forms/domain.py b/src/registrar/forms/domain.py index a905b58ae..79fe46add 100644 --- a/src/registrar/forms/domain.py +++ b/src/registrar/forms/domain.py @@ -65,7 +65,7 @@ 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): diff --git a/src/registrar/management/commands/load.py b/src/registrar/management/commands/load.py index 589d37260..757d1a6e9 100644 --- a/src/registrar/management/commands/load.py +++ b/src/registrar/management/commands/load.py @@ -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__) diff --git a/src/registrar/migrations/0034_usergroup.py b/src/registrar/migrations/0034_usergroup.py new file mode 100644 index 000000000..618188230 --- /dev/null +++ b/src/registrar/migrations/0034_usergroup.py @@ -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()), + ], + ), + ] diff --git a/src/registrar/migrations/0035_alter_user_options.py b/src/registrar/migrations/0035_alter_user_options.py new file mode 100644 index 000000000..7ed81cdf5 --- /dev/null +++ b/src/registrar/migrations/0035_alter_user_options.py @@ -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"), + ] + }, + ), + ] diff --git a/src/registrar/migrations/0036_contenttypes_permissions.py b/src/registrar/migrations/0036_contenttypes_permissions.py new file mode 100644 index 000000000..a4f980e82 --- /dev/null +++ b/src/registrar/migrations/0036_contenttypes_permissions.py @@ -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)] diff --git a/src/registrar/migrations/0037_create_groups_v01.py b/src/registrar/migrations/0037_create_groups_v01.py new file mode 100644 index 000000000..27a14f8b9 --- /dev/null +++ b/src/registrar/migrations/0037_create_groups_v01.py @@ -0,0 +1,34 @@ +# 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: +# 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 +# Alternatively: +# Only step: duplicate the migtation that loads data and run: 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, + ), + ] diff --git a/src/registrar/models/__init__.py b/src/registrar/models/__init__.py index fa4ce7e2a..f287c401c 100644 --- a/src/registrar/models/__init__.py +++ b/src/registrar/models/__init__.py @@ -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) diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index 8397ea35c..e75692bf7 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -16,12 +16,15 @@ from epplibwrapper import ( RegistryError, ErrorCode, ) + from registrar.utility.errors import ( ActionNotAllowed, NameserverError, NameserverErrorCodes as nsErrorCodes, ) -from registrar.models.utility.contact_error import ContactError + +from registrar.models.utility.contact_error import ContactError, ContactErrorCodes + from .utility.domain_field import DomainField from .utility.domain_helper import DomainHelper @@ -799,7 +802,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") @@ -872,10 +878,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 @@ -884,14 +890,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 @@ -1055,8 +1057,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( diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py index 5b04c628d..acf59cb68 100644 --- a/src/registrar/models/user.py +++ b/src/registrar/models/user.py @@ -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"), + ] diff --git a/src/registrar/models/user_group.py b/src/registrar/models/user_group.py new file mode 100644 index 000000000..b6f5b41b2 --- /dev/null +++ b/src/registrar/models/user_group.py @@ -0,0 +1,132 @@ +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"], + }, + ] + + # 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 can’t 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}") diff --git a/src/registrar/models/utility/contact_error.py b/src/registrar/models/utility/contact_error.py index 93084eca2..cf392cb6e 100644 --- a/src/registrar/models/utility/contact_error.py +++ b/src/registrar/models/utility/contact_error.py @@ -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}" diff --git a/src/registrar/templates/django/admin/domain_change_form.html b/src/registrar/templates/django/admin/domain_change_form.html index ac26fc922..2ed3d7532 100644 --- a/src/registrar/templates/django/admin/domain_change_form.html +++ b/src/registrar/templates/django/admin/domain_change_form.html @@ -13,10 +13,10 @@ {% elif original.state == original.State.ON_HOLD %} {% endif %} - - + + {% if original.state != original.State.DELETED %} - + {% endif %} {{ block.super }} diff --git a/src/registrar/templates/domain_detail.html b/src/registrar/templates/domain_detail.html index 6a700b393..bcf775fe5 100644 --- a/src/registrar/templates/domain_detail.html +++ b/src/registrar/templates/domain_detail.html @@ -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 %} diff --git a/src/registrar/templates/domain_nameservers.html b/src/registrar/templates/domain_nameservers.html index 2dabac1af..a7371ee0b 100644 --- a/src/registrar/templates/domain_nameservers.html +++ b/src/registrar/templates/domain_nameservers.html @@ -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 %} -
Before your domain can be used we'll need information about your domain name servers.
diff --git a/src/registrar/templates/domain_security_email.html b/src/registrar/templates/domain_security_email.html index 8175fa394..8fb0ccfb0 100644 --- a/src/registrar/templates/domain_security_email.html +++ b/src/registrar/templates/domain_security_email.html @@ -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 %} -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 .gov domain data we provide.
A security contact should be capable of evaluating or triaging security reports for your entire domain. Use a team email address, not an individual’s email. We recommend using an alias, like security@domain.gov.
- {% include "includes/required_fields.html" %} -