diff --git a/backend/dissemination/mixins.py b/backend/dissemination/mixins.py new file mode 100644 index 0000000000..f39dbac6f5 --- /dev/null +++ b/backend/dissemination/mixins.py @@ -0,0 +1,43 @@ +import logging + +from django.core.exceptions import PermissionDenied +from django.http import Http404 + +from dissemination.models import General +from users.permissions import can_read_tribal + + +logger = logging.getLogger(__name__) + + +class ReportAccessRequiredMixin: + def dispatch(self, request, *args, **kwargs): + report_id = kwargs["report_id"] + try: + general = General.objects.get(report_id=report_id) + + if general.is_public: + return super().dispatch(request, *args, **kwargs) + + if not request.user: + logger.debug( + f"denying anonymous user access to non-public report {report_id}" + ) + raise PermissionDenied + + if not request.user.is_authenticated: + logger.debug( + f"denying anonymous user access to non-public report {report_id}" + ) + raise PermissionDenied + + if not can_read_tribal(request.user): + logger.debug( + f"denying user {request.user.email} access to non-public report {report_id}" + ) + raise PermissionDenied + + return super().dispatch(request, *args, **kwargs) + + except General.DoesNotExist: + raise Http404() diff --git a/backend/dissemination/search.py b/backend/dissemination/search.py index 6baad5c929..66fc7f215d 100644 --- a/backend/dissemination/search.py +++ b/backend/dissemination/search.py @@ -12,40 +12,20 @@ def search_general( cog_or_oversight=None, agency_name=None, audit_years=None, + include_private=False, ): - query = Q(is_public=True) + query = Q() - if alns: - query.add(_get_aln_match_query(alns), Q.AND) + query.add(_get_aln_match_query(alns), Q.AND) + query.add(_get_names_match_query(names), Q.AND) + query.add(_get_uei_or_eins_match_query(uei_or_eins), Q.AND) + query.add(_get_start_date_match_query(start_date), Q.AND) + query.add(_get_end_date_match_query(end_date), Q.AND) + query.add(_get_cog_or_oversight_match_query(agency_name, cog_or_oversight), Q.AND) + query.add(_get_audit_years_match_query(audit_years), Q.AND) - if names: - query.add(_get_names_match_query(names), Q.AND) - - if uei_or_eins: - uei_or_ein_match = Q( - Q(auditee_uei__in=uei_or_eins) | Q(auditee_ein__in=uei_or_eins) - ) - query.add(uei_or_ein_match, Q.AND) - - if start_date: - start_date_match = Q(fac_accepted_date__gte=start_date) - query.add(start_date_match, Q.AND) - - if end_date: - end_date_match = Q(fac_accepted_date__lte=end_date) - query.add(end_date_match, Q.AND) - - if cog_or_oversight: - if cog_or_oversight.lower() == "cog": - cog_match = Q(cognizant_agency__in=[agency_name]) - query.add(cog_match, Q.AND) - elif cog_or_oversight.lower() == "oversight": - oversight_match = Q(oversight_agency__in=[agency_name]) - query.add(oversight_match, Q.AND) - - if audit_years: - fiscal_year_match = Q(audit_year__in=audit_years) - query.add(fiscal_year_match, Q.AND) + if not include_private: + query.add(Q(is_public=True), Q.AND) results = General.objects.filter(query).order_by("-fac_accepted_date") @@ -65,6 +45,10 @@ def _get_aln_match_query(alns): # If there's a prefix and extention, search on both. # 3. Add the report_ids from the identified awards to the search params. """ + + if not alns: + return Q() + # Split each ALN into (prefix, extention) split_alns = set() agency_numbers = set() @@ -118,6 +102,9 @@ def _get_names_match_query(names): """ Given a list of (potential) names, return the query object that searches auditee and firm names. """ + if not names: + return Q() + name_fields = [ "auditee_city", "auditee_contact_name", @@ -139,3 +126,44 @@ def _get_names_match_query(names): names_match.add(Q(**{"%s__search" % field: names}), Q.OR) return names_match + + +def _get_uei_or_eins_match_query(uei_or_eins): + if not uei_or_eins: + return Q() + + uei_or_ein_match = Q( + Q(auditee_uei__in=uei_or_eins) | Q(auditee_ein__in=uei_or_eins) + ) + return uei_or_ein_match + + +def _get_start_date_match_query(start_date): + if not start_date: + return Q() + + return Q(fac_accepted_date__gte=start_date) + + +def _get_end_date_match_query(end_date): + if not end_date: + return Q() + + return Q(fac_accepted_date__lte=end_date) + + +def _get_cog_or_oversight_match_query(agency_name, cog_or_oversight): + if not cog_or_oversight: + return Q() + + if cog_or_oversight.lower() == "cog": + return Q(cognizant_agency__in=[agency_name]) + elif cog_or_oversight.lower() == "oversight": + return Q(oversight_agency__in=[agency_name]) + + +def _get_audit_years_match_query(audit_years): + if not audit_years: + return Q() + + return Q(audit_year__in=audit_years) diff --git a/backend/dissemination/test_mixins.py b/backend/dissemination/test_mixins.py new file mode 100644 index 0000000000..f326979c98 --- /dev/null +++ b/backend/dissemination/test_mixins.py @@ -0,0 +1,107 @@ +from django.contrib.auth import get_user_model +from django.core.exceptions import PermissionDenied +from django.http.response import Http404 +from django.test import TestCase +from django.test.client import RequestFactory +from django.views.generic import View + +from dissemination.models import General +from dissemination.mixins import ReportAccessRequiredMixin + +from users.models import Permission, UserPermission + +from model_bakery import baker + +User = get_user_model() + + +class ReportAccessRequiredMixinTests(TestCase): + class ViewStub(ReportAccessRequiredMixin, View): + def get(self, request, *args, **kwargs): + pass + + def test_missing_report_id_raises(self): + request = RequestFactory().get("/") + + self.assertRaises(KeyError, self.ViewStub().dispatch, request) + + def test_nonexistent_report_raises(self): + request = RequestFactory().get("/") + + self.assertRaises( + Http404, + self.ViewStub().dispatch, + request, + report_id="not-a-real-report-id", + ) + + def test_public_report_passes(self): + request = RequestFactory().get("/") + + general = baker.make(General, is_public=True) + + self.ViewStub().dispatch(request, report_id=general.report_id) + + def test_non_public_raises_for_anonymous(self): + request = RequestFactory().get("/") + request.user = None + + general = baker.make(General, is_public=False) + + self.assertRaises( + PermissionDenied, + self.ViewStub().dispatch, + request, + report_id=general.report_id, + ) + + def test_non_public_raises_for_unpermissioned(self): + request = RequestFactory().get("/") + + user = baker.make(User) + request.user = user + + general = baker.make(General, is_public=False) + + self.assertRaises( + PermissionDenied, + self.ViewStub().dispatch, + request, + report_id=general.report_id, + ) + + def test_non_public_passes_for_permissioned(self): + request = RequestFactory().get("/") + + user = baker.make(User) + request.user = user + + permission = Permission.objects.get(slug=Permission.PermissionType.READ_TRIBAL) + baker.make(UserPermission, user=user, email=user.email, permission=permission) + + general = baker.make(General, is_public=False) + + self.ViewStub().dispatch(request, report_id=general.report_id) + + def test_public_passes_for_unpermissioned(self): + request = RequestFactory().get("/") + + user = baker.make(User) + request.user = user + + general = baker.make(General, is_public=True) + + self.ViewStub().dispatch(request, report_id=general.report_id) + + def test_public_passes_for_permissioned(self): + request = RequestFactory().get("/") + + user = baker.make(User) + request.user = user + + permission = Permission.objects.get(slug=Permission.PermissionType.READ_TRIBAL) + baker.make(UserPermission, user=user, email=user.email, permission=permission) + + general = baker.make(General, is_public=True) + + self.ViewStub().dispatch(request, report_id=general.report_id) diff --git a/backend/dissemination/test_views.py b/backend/dissemination/test_views.py index 6c026bda07..ca34bda649 100644 --- a/backend/dissemination/test_views.py +++ b/backend/dissemination/test_views.py @@ -1,3 +1,4 @@ +from django.contrib.auth import get_user_model from django.test import Client, TestCase from django.urls import reverse @@ -7,22 +8,32 @@ SingleAuditReportFile, generate_sac_report_id, ) -from dissemination.models import General +from dissemination.models import ( + General, + FederalAward, + Finding, + FindingText, + CapText, + Note, +) +from users.models import Permission, UserPermission from model_bakery import baker from unittest.mock import patch +User = get_user_model() + class PdfDownloadViewTests(TestCase): def setUp(self): self.client = Client() - def _make_sac_and_general(self): + def _make_sac_and_general(self, is_public=True): sac = baker.make( SingleAuditChecklist, report_id=generate_sac_report_id(end_date="2023-12-31"), ) - general = baker.make(General, is_public=True, report_id=sac.report_id) + general = baker.make(General, is_public=is_public, report_id=sac.report_id) return sac, general def test_bad_report_id_returns_404(self): @@ -71,17 +82,154 @@ def test_file_exists_returns_302(self, mock_file_exists): self.assertEqual(response.status_code, 302) self.assertIn(file.filename, response.url) + @patch("audit.file_downloads.file_exists") + def test_private_returns_403_for_anonymous(self, mock_file_exists): + mock_file_exists.return_value = True + + sac, general = self._make_sac_and_general(is_public=False) + + url = reverse( + "dissemination:PdfDownload", kwargs={"report_id": general.report_id} + ) + + response = self.client.get(url) + + self.assertEqual(response.status_code, 403) + + @patch("audit.file_downloads.file_exists") + def test_private_returns_403_for_unpermissioned(self, mock_file_exists): + mock_file_exists.return_value = True + + sac, general = self._make_sac_and_general(is_public=False) + + user = baker.make(User) + + url = reverse( + "dissemination:PdfDownload", kwargs={"report_id": general.report_id} + ) + + self.client.force_login(user) + response = self.client.get(url) + + self.assertEqual(response.status_code, 403) + + @patch("audit.file_downloads.file_exists") + def test_private_returns_302_for_permissioned(self, mock_file_exists): + mock_file_exists.return_value = True + + sac, general = self._make_sac_and_general(is_public=False) + + user = baker.make(User) + permission = Permission.objects.get(slug=Permission.PermissionType.READ_TRIBAL) + baker.make( + UserPermission, + email=user.email, + user=user, + permission=permission, + ) + file = baker.make(SingleAuditReportFile, sac=sac) + + url = reverse( + "dissemination:PdfDownload", kwargs={"report_id": general.report_id} + ) + + self.client.force_login(user) + response = self.client.get(url) + + self.assertEqual(response.status_code, 302) + self.assertIn(file.filename, response.url) + + +class SearchViewTests(TestCase): + def setUp(self): + self.anon_client = Client() + self.auth_client = Client() + self.perm_client = Client() + + self.auth_user = baker.make(User) + self.auth_client.force_login(self.auth_user) + + self.perm_user = baker.make(User) + permission = Permission.objects.get(slug=Permission.PermissionType.READ_TRIBAL) + baker.make( + UserPermission, + email=self.perm_user.email, + user=self.perm_user, + permission=permission, + ) + self.perm_client.force_login(self.perm_user) + + def _search_url(self): + return reverse("dissemination:Search") + + def test_allows_anonymous(self): + response = self.anon_client.get(self._search_url()) + self.assertEqual(response.status_code, 200) + + def test_search(self): + response = self.anon_client.post(self._search_url(), {}) + self.assertContains(response, "Search single audit reports") + self.assertNotContains(response, "Results: ") + + def test_anonymous_returns_only_public(self): + public = baker.make(General, is_public=True, _quantity=5) + private = baker.make(General, is_public=False, _quantity=5) + + response = self.anon_client.post(self._search_url(), {}) + + self.assertContains(response, "Results: 5") + + # all of the public reports should show up on the page + for p in public: + self.assertContains(response, p.report_id) + + # none of the private reports should show up on the page + for p in private: + self.assertNotContains(response, p.report_id) + + def test_non_permissioned_returns_only_public(self): + public = baker.make(General, is_public=True, _quantity=5) + private = baker.make(General, is_public=False, _quantity=5) + + response = self.auth_client.post(self._search_url(), {}) + + self.assertContains(response, "Results: 5") + + # all of the public reports should show up on the page + for p in public: + self.assertContains(response, p.report_id) + + # none of the private reports should show up on the page + for p in private: + self.assertNotContains(response, p.report_id) + + def test_permissioned_returns_all(self): + public = baker.make(General, is_public=True, _quantity=5) + private = baker.make(General, is_public=False, _quantity=5) + + response = self.perm_client.post(self._search_url(), {}) + + self.assertContains(response, "Results: 10") + + # all of the public reports should show up on the page + for p in public: + self.assertContains(response, p.report_id) + + # all of the private reports should show up on the page + for p in private: + self.assertContains(response, p.report_id) + class XlsxDownloadViewTests(TestCase): def setUp(self): self.client = Client() - def _make_sac_and_general(self): + def _make_sac_and_general(self, is_public=True): sac = baker.make( SingleAuditChecklist, report_id=generate_sac_report_id(end_date="2023-12-31"), ) - general = baker.make(General, is_public=True, report_id=sac.report_id) + general = baker.make(General, is_public=is_public, report_id=sac.report_id) return sac, general def test_bad_report_id_returns_404(self): @@ -94,7 +242,7 @@ def test_bad_report_id_returns_404(self): self.assertEqual(response.status_code, 404) - def test_not_public_returns_403(self): + def test_not_public_returns_403_for_anon(self): general = baker.make(General, is_public=False) url = reverse( @@ -144,3 +292,159 @@ def test_file_exists_returns_302(self, mock_file_exists): self.assertEqual(response.status_code, 302) self.assertIn(file.filename, response.url) + + @patch("audit.file_downloads.file_exists") + def test_private_returns_403_for_anonymous(self, mock_file_exists): + mock_file_exists.return_value = True + + sac, general = self._make_sac_and_general(is_public=False) + + url = reverse( + "dissemination:XlsxDownload", + kwargs={ + "report_id": general.report_id, + "file_type": "FederalAwardsExpended", + }, + ) + + response = self.client.get(url) + + self.assertEqual(response.status_code, 403) + + @patch("audit.file_downloads.file_exists") + def test_private_returns_403_for_unpermissioned(self, mock_file_exists): + mock_file_exists.return_value = True + + sac, general = self._make_sac_and_general(is_public=False) + + user = baker.make(User) + + url = reverse( + "dissemination:XlsxDownload", + kwargs={ + "report_id": general.report_id, + "file_type": "FederalAwardsExpended", + }, + ) + + self.client.force_login(user) + response = self.client.get(url) + + self.assertEqual(response.status_code, 403) + + @patch("audit.file_downloads.file_exists") + def test_private_returns_302_for_permissioned(self, mock_file_exists): + mock_file_exists.return_value = True + + sac, general = self._make_sac_and_general(is_public=False) + + user = baker.make(User) + permission = Permission.objects.get(slug=Permission.PermissionType.READ_TRIBAL) + baker.make( + UserPermission, + email=user.email, + user=user, + permission=permission, + ) + file = baker.make(ExcelFile, sac=sac, form_section="FederalAwardsExpended") + + url = reverse( + "dissemination:XlsxDownload", + kwargs={ + "report_id": general.report_id, + "file_type": "FederalAwardsExpended", + }, + ) + + self.client.force_login(user) + response = self.client.get(url) + + self.assertEqual(response.status_code, 302) + self.assertIn(file.filename, response.url) + + +class SummaryViewTests(TestCase): + def setUp(self): + self.client = Client() + + def test_public_summary(self): + """ + A public audit should have a viewable summary, and returns 200. + """ + baker.make(General, report_id="2022-12-GSAFAC-0000000001", is_public=True) + url = reverse( + "dissemination:Summary", kwargs={"report_id": "2022-12-GSAFAC-0000000001"} + ) + + response = self.client.get(url) + self.assertEquals(response.status_code, 200) + + def test_private_summary(self): + """ + Anonymous requests for private audit summaries should return 403 + """ + baker.make(General, report_id="2022-12-GSAFAC-0000000001", is_public=False) + url = reverse( + "dissemination:Summary", kwargs={"report_id": "2022-12-GSAFAC-0000000001"} + ) + + response = self.client.get(url) + self.assertEquals(response.status_code, 403) + + def test_permissioned_private_summary(self): + """ + Permissioned requests for private audit summaries should return 200 + """ + general = baker.make(General, is_public=False) + user = baker.make(User) + + permission = Permission.objects.get(slug=Permission.PermissionType.READ_TRIBAL) + baker.make(UserPermission, user=user, email=user.email, permission=permission) + + url = reverse("dissemination:Summary", kwargs={"report_id": general.report_id}) + + self.client.force_login(user) + response = self.client.get(url) + + self.assertEquals(response.status_code, 200) + + def test_summary_context(self): + """ + The summary context should include the same data that is in the models. + Create a bunch of fake DB data under the same report_id. Then, check a few + fields in the context for the summary page to verify that the fake data persists. + """ + baker.make(General, report_id="2022-12-GSAFAC-0000000001", is_public=True) + award = baker.make(FederalAward, report_id="2022-12-GSAFAC-0000000001") + finding = baker.make(Finding, report_id="2022-12-GSAFAC-0000000001") + finding_text = baker.make(FindingText, report_id="2022-12-GSAFAC-0000000001") + cap_text = baker.make(CapText, report_id="2022-12-GSAFAC-0000000001") + note = baker.make(Note, report_id="2022-12-GSAFAC-0000000001") + + url = reverse( + "dissemination:Summary", kwargs={"report_id": "2022-12-GSAFAC-0000000001"} + ) + + response = self.client.get(url) + self.assertEquals( + response.context["data"]["Awards"][0]["additional_award_identification"], + award.additional_award_identification, + ) + self.assertEquals( + response.context["data"]["Audit Findings"][0]["reference_number"], + finding.reference_number, + ) + self.assertEquals( + response.context["data"]["Audit Findings Text"][0]["finding_ref_number"], + finding_text.finding_ref_number, + ) + self.assertEquals( + response.context["data"]["Corrective Action Plan"][0][ + "contains_chart_or_table" + ], + cap_text.contains_chart_or_table, + ) + self.assertEquals( + response.context["data"]["Notes to SEFA"][0]["accounting_policies"], + note.accounting_policies, + ) diff --git a/backend/dissemination/tests.py b/backend/dissemination/tests.py index 86607de84e..0913c3fbfe 100644 --- a/backend/dissemination/tests.py +++ b/backend/dissemination/tests.py @@ -3,21 +3,10 @@ import os import requests -from model_bakery import baker - -from django.test import Client, TestCase -from django.urls import reverse +from django.test import TestCase from config import settings from dissemination.templatetags.field_name_to_label import field_name_to_label -from dissemination.models import ( - General, - FederalAward, - Finding, - FindingText, - CapText, - Note, -) class APIViewTests(TestCase): @@ -85,73 +74,3 @@ def test_field_name_to_label(self): sample_field = "auditee_contact_title" converted_sample_field = field_name_to_label(sample_field) self.assertEquals(converted_sample_field, "Auditee Contact Title") - - -class SummaryViewTests(TestCase): - def setUp(self): - self.client = Client() - - def test_public_summary(self): - """ - A public audit should have a viewable summary, and returns 200. - """ - baker.make(General, report_id="2022-12-GSAFAC-0000000001", is_public=True) - url = reverse( - "dissemination:Summary", kwargs={"report_id": "2022-12-GSAFAC-0000000001"} - ) - - response = self.client.get(url) - self.assertEquals(response.status_code, 200) - - def test_private_summary(self): - """ - A private audit should not have a viewable summary, and returns 404. - """ - baker.make(General, report_id="2022-12-GSAFAC-0000000001", is_public=False) - url = reverse( - "dissemination:Summary", kwargs={"report_id": "2022-12-GSAFAC-0000000001"} - ) - - response = self.client.get(url) - self.assertEquals(response.status_code, 404) - - def test_summary_context(self): - """ - The summary context should include the same data that is in the models. - Create a bunch of fake DB data under the same report_id. Then, check a few - fields in the context for the summary page to verify that the fake data persists. - """ - baker.make(General, report_id="2022-12-GSAFAC-0000000001", is_public=True) - award = baker.make(FederalAward, report_id="2022-12-GSAFAC-0000000001") - finding = baker.make(Finding, report_id="2022-12-GSAFAC-0000000001") - finding_text = baker.make(FindingText, report_id="2022-12-GSAFAC-0000000001") - cap_text = baker.make(CapText, report_id="2022-12-GSAFAC-0000000001") - note = baker.make(Note, report_id="2022-12-GSAFAC-0000000001") - - url = reverse( - "dissemination:Summary", kwargs={"report_id": "2022-12-GSAFAC-0000000001"} - ) - - response = self.client.get(url) - self.assertEquals( - response.context["data"]["Awards"][0]["additional_award_identification"], - award.additional_award_identification, - ) - self.assertEquals( - response.context["data"]["Audit Findings"][0]["reference_number"], - finding.reference_number, - ) - self.assertEquals( - response.context["data"]["Audit Findings Text"][0]["finding_ref_number"], - finding_text.finding_ref_number, - ) - self.assertEquals( - response.context["data"]["Corrective Action Plan"][0][ - "contains_chart_or_table" - ], - cap_text.contains_chart_or_table, - ) - self.assertEquals( - response.context["data"]["Notes to SEFA"][0]["accounting_policies"], - note.accounting_policies, - ) diff --git a/backend/dissemination/views.py b/backend/dissemination/views.py index f96d785998..6cc0aff2d5 100644 --- a/backend/dissemination/views.py +++ b/backend/dissemination/views.py @@ -1,6 +1,6 @@ import math -from django.core.exceptions import BadRequest, PermissionDenied +from django.core.exceptions import BadRequest from django.core.paginator import Paginator from django.http import Http404 from django.shortcuts import get_object_or_404, redirect, render @@ -11,6 +11,7 @@ from dissemination.forms import SearchForm from dissemination.search import search_general +from dissemination.mixins import ReportAccessRequiredMixin from dissemination.models import ( General, FederalAward, @@ -23,6 +24,18 @@ AdditionalUei, ) +from users.permissions import can_read_tribal + + +def include_private_results(request): + if not request.user.is_authenticated: + return False + + if not can_read_tribal(request.user): + return False + + return True + class Search(View): def get(self, request, *args, **kwargs): @@ -52,6 +65,9 @@ def post(self, request, *args, **kwargs): # Changed in the form via pagination links page = int(form.cleaned_data["page"] or 1) + # is the user authenticated? + include_private = include_private_results(request) + results = search_general( names=names, alns=alns, @@ -61,6 +77,7 @@ def post(self, request, *args, **kwargs): cog_or_oversight=cog_or_oversight, agency_name=agency_name, audit_years=audit_years, + include_private=include_private, ) results_count = results.count() # Reset page to one if the page number surpasses how many pages there actually are @@ -92,7 +109,7 @@ def post(self, request, *args, **kwargs): return render(request, "search.html", context) -class AuditSummaryView(View): +class AuditSummaryView(ReportAccessRequiredMixin, View): def get(self, request, report_id): """ Grab any information about the given report in the dissemination tables. @@ -100,9 +117,8 @@ def get(self, request, report_id): 2. Grab all relevant info from dissem tables. Some items may not exist if they had no findings. 3. Wrap up the data all nice in a context object for display. """ - # Viewable audits __MUST__ be public. - general = General.objects.filter(report_id=report_id, is_public=True) + general = General.objects.filter(report_id=report_id) if not general.exists(): raise Http404( "The report with this ID does not exist in the dissemination database." @@ -165,14 +181,10 @@ def get_audit_content(self, report_id): return data -class PdfDownloadView(View): +class PdfDownloadView(ReportAccessRequiredMixin, View): def get(self, request, report_id): # only allow PDF downloads for disseminated submissions - disseminated = get_object_or_404(General, report_id=report_id) - - # only allow PDF downloads for public submissions - if not disseminated.is_public: - raise PermissionDenied("You do not have access to this audit report.") + get_object_or_404(General, report_id=report_id) sac = get_object_or_404(SingleAuditChecklist, report_id=report_id) filename = get_filename(sac, "report") @@ -180,14 +192,10 @@ def get(self, request, report_id): return redirect(get_download_url(filename)) -class XlsxDownloadView(View): +class XlsxDownloadView(ReportAccessRequiredMixin, View): def get(self, request, report_id, file_type): # only allow xlsx downloads from disseminated submissions - disseminated = get_object_or_404(General, report_id=report_id) - - # only allow xlsx downloads for public submissions - if not disseminated.is_public: - raise PermissionDenied("You do not have access to this file.") + get_object_or_404(General, report_id=report_id) sac = get_object_or_404(SingleAuditChecklist, report_id=report_id) filename = get_filename(sac, file_type) diff --git a/backend/users/admin.py b/backend/users/admin.py index d554a0b090..d5c00f3b00 100644 --- a/backend/users/admin.py +++ b/backend/users/admin.py @@ -1,8 +1,33 @@ from django.contrib import admin +from django.contrib.auth import get_user_model -from .models import StaffUserLog, UserProfile, StaffUser +from .models import Permission, StaffUser, StaffUserLog, UserPermission, UserProfile +from .permissions import can_read_tribal as _can_read_tribal + +User = get_user_model() admin.site.register(UserProfile) +admin.site.unregister(User) + + +@admin.register(Permission) +class PermissionAdmin(admin.ModelAdmin): + list_display = ["slug", "description"] + + +@admin.register(User) +class UserAdmin(admin.ModelAdmin): + list_display = ["email", "can_read_tribal", "last_login", "date_joined"] + exclude = ["groups", "user_permissions", "password"] + readonly_fields = ["date_joined", "last_login"] + + def can_read_tribal(self, obj): + return _can_read_tribal(obj) + + +@admin.register(UserPermission) +class UserPermissionAdmin(admin.ModelAdmin): + list_display = ["user", "email", "permission"] @admin.register(StaffUserLog) diff --git a/backend/users/auth.py b/backend/users/auth.py index 90f6aca4d4..4566b17a6e 100644 --- a/backend/users/auth.py +++ b/backend/users/auth.py @@ -2,6 +2,7 @@ from djangooidc.backends import OpenIdConnectBackend from audit.models import Access +from users.models import UserPermission import logging @@ -23,12 +24,26 @@ def claim_audit_access(user, all_emails): logger.debug(f"{user.email} granted access to {access_invites} new audits") +def claim_permissions(user, all_emails): + """ + user is our system user + all_emails is the list of email addresses from the login.gov JWT + """ + l_emails = [ea.lower() for ea in all_emails] + for email in l_emails: + user_permissions = UserPermission.objects.filter(email__iexact=email).update( + user=user, email=email + ) + logger.debug(f"{user.email} granted {user_permissions} permissions") + + class FACAuthenticationBackend(OpenIdConnectBackend): def authenticate(self, request, **user_info): user = super().authenticate(request, **user_info) if user: all_emails = user_info.get("all_emails", []) claim_audit_access(user, all_emails) + claim_permissions(user, all_emails) return user diff --git a/backend/users/migrations/0003_permission_userpermission.py b/backend/users/migrations/0003_permission_userpermission.py new file mode 100644 index 0000000000..94c2f30fc2 --- /dev/null +++ b/backend/users/migrations/0003_permission_userpermission.py @@ -0,0 +1,78 @@ +# Generated by Django 4.2.6 on 2023-11-15 14:37 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +def seed_permissions(apps, schema_editor): + Permission = apps.get_model("users", "Permission") + Permission.objects.create(slug="read-tribal", description="Can read tribal data") + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("users", "0002_staffuser_staffuserlog"), + ] + + operations = [ + migrations.CreateModel( + name="Permission", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "slug", + models.CharField( + choices=[("read-tribal", "Read tribal audit data")], + max_length=255, + unique=True, + ), + ), + ("description", models.TextField()), + ], + ), + migrations.CreateModel( + name="UserPermission", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("email", models.EmailField(max_length=254)), + ( + "permission", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + to="users.permission", + ), + ), + ( + "user", + models.ForeignKey( + null=True, + blank=True, + on_delete=django.db.models.deletion.PROTECT, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "unique_together": {("user", "permission")}, + }, + ), + migrations.RunPython(seed_permissions), + ] diff --git a/backend/users/models.py b/backend/users/models.py index 1db2f3191f..de502a92db 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -1,9 +1,32 @@ from django.db import models from django.contrib.auth import get_user_model +from django.utils.translation import gettext_lazy as _ User = get_user_model() +class Permission(models.Model): + class PermissionType: + READ_TRIBAL = "read-tribal" + + PERMISSION_CHOICES = ((PermissionType.READ_TRIBAL, _("Read tribal audit data")),) + + slug = models.CharField(max_length=255, choices=PERMISSION_CHOICES, unique=True) + description = models.TextField() + + def __str__(self): + return self.slug + + +class UserPermission(models.Model): + email = models.EmailField() + user = models.ForeignKey(User, null=True, blank=True, on_delete=models.PROTECT) + permission = models.ForeignKey(Permission, on_delete=models.PROTECT) + + class Meta: + unique_together = ("user", "permission") + + class UserProfile(models.Model): user = models.OneToOneField(User, related_name="profile", on_delete=models.CASCADE) diff --git a/backend/users/permissions.py b/backend/users/permissions.py new file mode 100644 index 0000000000..b14d7bc3e7 --- /dev/null +++ b/backend/users/permissions.py @@ -0,0 +1,10 @@ +from users.models import Permission, UserPermission + + +def can_read_tribal(user): + return ( + UserPermission.objects.filter( + user=user, permission__slug=Permission.PermissionType.READ_TRIBAL + ).count() + > 0 + )