diff --git a/controlpanel/api/aws.py b/controlpanel/api/aws.py index 1468f7313..d599f3a10 100644 --- a/controlpanel/api/aws.py +++ b/controlpanel/api/aws.py @@ -1425,3 +1425,157 @@ def get_table(self, database_name, table_name, catalog_id=None): raise error return response + + +class AWSSSOAdmin(AWSService): + + def __init__(self, assume_role_name=None, profile_name=None, region_name=None): + super().__init__(assume_role_name, profile_name, region_name) + region = region_name or settings.AWS_DEFAULT_REGION + self.client = self.boto3_session.client("sso-admin", region_name=region) + self.identity_store_id = None + + def get_identity_store_id(self): + + if self.identity_store_id: + return self.identity_store_id + + response = self.client.list_instances() + self.identity_store_id = response["Instances"][0]["IdentityStoreId"] + return self.identity_store_id + + +class AWSIdentityStore(AWSService): + + def __init__(self, assume_role_name=None, profile_name=None, region_name=None): + super().__init__(assume_role_name, profile_name, region_name) + region = region_name or settings.AWS_DEFAULT_REGION + self.client = self.boto3_session.client("identitystore", region_name=region) + self.sso_client = AWSSSOAdmin( + assume_role_name=assume_role_name, profile_name=profile_name, region_name=region_name + ) + + def get_user_id(self, user_email): + try: + response = self.client.get_user_id( + IdentityStoreId=self.sso_client.get_identity_store_id(), + AlternateIdentifier={ + "UniqueAttribute": {"AttributePath": "userName", "AttributeValue": user_email} + }, + ) + + return response["UserId"] + except self.client.exceptions.ResourceNotFoundException: + return None + + def get_group_id(self, group_name): + try: + response = self.client.get_group_id( + IdentityStoreId=self.sso_client.get_identity_store_id(), + AlternateIdentifier={ + "UniqueAttribute": { + "AttributePath": "displayName", + "AttributeValue": group_name, + } + }, + ) + + return response["GroupId"] + except self.client.exceptions.ResourceNotFoundException as error: + log.exception(error.response["Error"]["Message"]) + raise error + + def get_group_membership_id(self, group_name, user_email): + try: + response = self.client.get_group_membership_id( + IdentityStoreId=self.sso_client.get_identity_store_id(), + GroupId=self.get_group_id(group_name), + MemberId={"UserId": self.get_user_id(user_email)}, + ) + + return response["MembershipId"] + except self.client.exceptions.ResourceNotFoundException as error: + log.info(error.response["Error"]["Message"]) + raise None + + def get_name_from_email(self, user_email): + """ + gets name from justice email as user.name is not guaranteed to be a name + (can be an email, forename only or handle) + """ + + name = user_email.split("@")[0] + forename, surname = name.split(".") + surname = "".join((c for c in surname if not c.isdigit())) + return forename, surname + + def create_user(self, user_email): + + if self.get_user_id(user_email): + log.debug("User already exists in Identity Store.") + return + + try: + forename, surname = self.get_name_from_email(user_email) + + self.client.create_user( + IdentityStoreId=self.sso_client.get_identity_store_id(), + UserName=user_email, + DisplayName=user_email, + Name={ + "FamilyName": surname, + "GivenName": forename, + }, + Emails=[{"Value": user_email, "Type": "EntraId", "Primary": True}], + ) + except Exception as error: + log.exception(error) + raise error + + def create_group_membership(self, group_name, user_email): + + try: + membership_id = self.get_group_membership_id(group_name, user_email) + + if membership_id is not None: + log.debug("User is already a member of this group. Skipping") + return + + response = self.client.create_group_membership( + IdentityStoreId=self.sso_client.get_identity_store_id(), + GroupId=self.get_group_id(group_name), + MemberId={"UserId": self.get_user_id(user_email)}, + ) + + return response + except Exception as error: + log.exception(error) + raise error + + def delete_group_membership(self, group_name, user_email): + try: + membership_id = self.get_group_membership_id(group_name, user_email) + + if membership_id is None: + log.debug("User is not a member of this group. Skipping") + return + + self.client.delete_group_membership( + IdentityStoreId=self.sso_client.get_identity_store_id(), + MembershipId=membership_id, + ) + except Exception as error: + log.exception(error.response["Error"]["Message"]) + raise error + + def add_user_to_group(self, justice_email, quicksight_group): + if not justice_email: + message = ( + "Cannot create an Identity Center user without an associated @justice.gov.uk email" + ) + log.exception(message) + raise Exception(message) + + self.create_user(justice_email) + self.create_group_membership(quicksight_group, justice_email) + self.create_group_membership(settings.AZURE_HOLDING_GROUP_NAME, justice_email) diff --git a/controlpanel/api/migrations/0055_alter_user_options.py b/controlpanel/api/migrations/0055_alter_user_options.py new file mode 100644 index 000000000..edb57f967 --- /dev/null +++ b/controlpanel/api/migrations/0055_alter_user_options.py @@ -0,0 +1,29 @@ +# Generated by Django 5.1.2 on 2025-01-09 12:24 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("api", "0054_alter_tool_description"), + ] + + operations = [ + migrations.AlterModelOptions( + name="user", + options={ + "ordering": ("username",), + "permissions": [ + ( + "quicksight_embed_author_access", + "Can access embedded Quicksight as an author", + ), + ( + "quicksight_embed_reader_access", + "Can access embedded Quicksight as a reader", + ), + ], + }, + ), + ] diff --git a/controlpanel/api/models/__init__.py b/controlpanel/api/models/__init__.py index 81cd1f7a6..d5d8a4cf1 100644 --- a/controlpanel/api/models/__init__.py +++ b/controlpanel/api/models/__init__.py @@ -15,6 +15,10 @@ from controlpanel.api.models.s3bucket import S3Bucket from controlpanel.api.models.task import Task from controlpanel.api.models.tool import HomeDirectory, Tool, ToolDeployment -from controlpanel.api.models.user import QUICKSIGHT_EMBED_PERMISSION, User +from controlpanel.api.models.user import ( + QUICKSIGHT_EMBED_AUTHOR_PERMISSION, + QUICKSIGHT_EMBED_READER_PERMISSION, + User, +) from controlpanel.api.models.userapp import UserApp from controlpanel.api.models.users3bucket import UserS3Bucket diff --git a/controlpanel/api/models/user.py b/controlpanel/api/models/user.py index 39e464292..38acfcc4d 100644 --- a/controlpanel/api/models/user.py +++ b/controlpanel/api/models/user.py @@ -11,7 +11,8 @@ from controlpanel.api.signals import prometheus_login_event from controlpanel.utils import sanitize_dns_label -QUICKSIGHT_EMBED_PERMISSION = "quicksight_embed_access" +QUICKSIGHT_EMBED_AUTHOR_PERMISSION = "quicksight_embed_author_access" +QUICKSIGHT_EMBED_READER_PERMISSION = "quicksight_embed_reader_access" class User(AbstractUser): @@ -50,7 +51,10 @@ class User(AbstractUser): class Meta: db_table = "control_panel_api_user" ordering = ("username",) - permissions = [(QUICKSIGHT_EMBED_PERMISSION, "Can access the embedded Quicksight")] + permissions = [ + (QUICKSIGHT_EMBED_AUTHOR_PERMISSION, "Can access embedded Quicksight as an author"), + (QUICKSIGHT_EMBED_READER_PERMISSION, "Can access embedded Quicksight as a reader"), + ] def __repr__(self): return f"" diff --git a/controlpanel/frontend/forms.py b/controlpanel/frontend/forms.py index 301c903f4..7ce483bb6 100644 --- a/controlpanel/frontend/forms.py +++ b/controlpanel/frontend/forms.py @@ -3,6 +3,7 @@ # Third-party from django import forms +from django.conf import settings from django.contrib.auth.models import Permission from django.contrib.postgres.forms import SimpleArrayField from django.core.exceptions import ValidationError @@ -10,11 +11,13 @@ # First-party/Local from controlpanel.api import validators +from controlpanel.api.aws import AWSIdentityStore from controlpanel.api.cluster import AWSRoleCategory from controlpanel.api.cluster import S3Folder as ClusterS3Folder from controlpanel.api.github import GithubAPI, RepositoryNotFound, extract_repo_info_from_url from controlpanel.api.models import ( - QUICKSIGHT_EMBED_PERMISSION, + QUICKSIGHT_EMBED_AUTHOR_PERMISSION, + QUICKSIGHT_EMBED_READER_PERMISSION, App, Feedback, S3Bucket, @@ -636,17 +639,30 @@ class CreateParameterForm(forms.Form): class QuicksightAccessForm(forms.Form): QUICKSIGHT_LEGACY = "quicksight_legacy" - QUICKSIGHT_COMPUTE = "quicksight_compute" + QUICKSIGHT_COMPUTE_AUTHOR = "quicksight_compute_author" + QUICKSIGHT_COMPUTE_READER = "quicksight_compute_reader" enable_quicksight = forms.MultipleChoiceField( choices=[ (QUICKSIGHT_LEGACY, "Legacy"), - (QUICKSIGHT_COMPUTE, "Compute"), + (QUICKSIGHT_COMPUTE_AUTHOR, "Author"), + (QUICKSIGHT_COMPUTE_READER, "Reader"), ], widget=forms.CheckboxSelectMultiple, required=False, ) + quicksight_config_data = { + QUICKSIGHT_COMPUTE_AUTHOR: { + "codename": QUICKSIGHT_EMBED_AUTHOR_PERMISSION, + "group": settings.QUICKSIGHT_AUTHOR_GROUP_NAME, + }, + QUICKSIGHT_COMPUTE_READER: { + "codename": QUICKSIGHT_EMBED_READER_PERMISSION, + "group": settings.QUICKSIGHT_READER_GROUP_NAME, + }, + } + def __init__(self, *args, **kwargs): self.user = kwargs.pop("user") super().__init__(*args, **kwargs) @@ -655,11 +671,28 @@ def grant_access(self): quicksight_access = self.cleaned_data["enable_quicksight"] self.user.set_quicksight_access(enable=self.QUICKSIGHT_LEGACY in quicksight_access) - permission = Permission.objects.get(codename=QUICKSIGHT_EMBED_PERMISSION) - if self.QUICKSIGHT_COMPUTE in quicksight_access: + self.set_quicksight_embed_access(self.QUICKSIGHT_COMPUTE_AUTHOR, quicksight_access) + self.set_quicksight_embed_access(self.QUICKSIGHT_COMPUTE_READER, quicksight_access) + + def set_quicksight_embed_access(self, permission_name, quicksight_access): + identity_store = AWSIdentityStore( + settings.IDENTITY_CENTER_ASSUMED_ROLE, + "APCPIdentityCenterAccess", + settings.IDENTITY_CENTER_ACCOUNT_REGION, + ) + if self.user.is_superuser: + return + + codename = self.quicksight_config_data.get(permission_name)["codename"] + group = self.quicksight_config_data.get(permission_name)["group"] + permission = Permission.objects.get(codename=codename) + + if permission_name in quicksight_access and not self.user.has_perm(f"api.{codename}"): self.user.user_permissions.add(permission) - else: + identity_store.add_user_to_group(self.user.justice_email, group) + elif self.user.has_perm(f"api.{codename}"): self.user.user_permissions.remove(permission) + identity_store.delete_group_membership(self.user.justice_email, group) class FeedbackForm(forms.ModelForm): diff --git a/controlpanel/frontend/jinja2/user-detail.html b/controlpanel/frontend/jinja2/user-detail.html index 59edcc044..42442439f 100644 --- a/controlpanel/frontend/jinja2/user-detail.html +++ b/controlpanel/frontend/jinja2/user-detail.html @@ -124,9 +124,15 @@

"checked": user.is_quicksight_enabled }, { - "value": "quicksight_compute", - "text": "Embedded Quicksight enabled (running in the compute-" + env + " account)", - "checked": user.has_perm("api.quicksight_embed_access"), + "value": "quicksight_compute_author", + "text": "Embedded Quicksight enabled (running in the compute-" + env + " account) as an author", + "checked": user.has_perm("api.quicksight_embed_author_access"), + "disabled": user.is_superuser + }, + { + "value": "quicksight_compute_reader", + "text": "Embedded Quicksight enabled (running in the compute-" + env + " account) as a reader", + "checked": user.has_perm("api.quicksight_embed_reader_access"), "disabled": user.is_superuser }, ] diff --git a/controlpanel/frontend/views/quicksight.py b/controlpanel/frontend/views/quicksight.py index 1d314e3f0..ef47f3caa 100644 --- a/controlpanel/frontend/views/quicksight.py +++ b/controlpanel/frontend/views/quicksight.py @@ -16,7 +16,12 @@ class QuicksightView(OIDCLoginRequiredMixin, PermissionRequiredMixin, TemplateView): template_name = "quicksight.html" - permission_required = "api.quicksight_embed_access" + + def has_permission(self): + user = self.request.user + return user.has_perm("api.quicksight_embed_author_access") or user.has_perm( + "api.quicksight_embed_reader_access" + ) def get_context_data(self, **kwargs: Any) -> dict[str, Any]: context = super().get_context_data(**kwargs) diff --git a/controlpanel/frontend/views/user.py b/controlpanel/frontend/views/user.py index fd4cd334e..4630ca755 100644 --- a/controlpanel/frontend/views/user.py +++ b/controlpanel/frontend/views/user.py @@ -2,6 +2,7 @@ from datetime import datetime, timedelta # Third-party +from django.conf import settings from django.contrib import messages from django.contrib.auth.models import Permission from django.forms import BaseModelForm @@ -16,8 +17,9 @@ from rules.contrib.views import PermissionRequiredMixin # First-party/Local +from controlpanel.api.aws import AWSIdentityStore from controlpanel.api.cluster import User as ClusterUser -from controlpanel.api.models import User +from controlpanel.api.models import QUICKSIGHT_EMBED_AUTHOR_PERMISSION, User from controlpanel.frontend import forms from controlpanel.frontend.mixins import PolicyAccessMixin from controlpanel.oidc import OIDCLoginRequiredMixin @@ -98,6 +100,22 @@ class SetSuperadmin(OIDCLoginRequiredMixin, PermissionRequiredMixin, View): def post(self, request, *args, **kwargs): user = get_object_or_404(User, pk=kwargs["pk"]) is_superuser = "is_superuser" in request.POST + + identity_store = AWSIdentityStore( + settings.IDENTITY_CENTER_ASSUMED_ROLE, + "APCPIdentityCenterAccess", + settings.IDENTITY_CENTER_ACCOUNT_REGION, + ) + + if is_superuser: + identity_store.add_user_to_group( + user.justice_email, settings.QUICKSIGHT_AUTHOR_GROUP_NAME + ) + else: + identity_store.delete_group_membership( + user.justice_email, settings.QUICKSIGHT_AUTHOR_GROUP_NAME + ) + user.is_superuser = is_superuser user.is_staff = is_superuser user.save() diff --git a/controlpanel/settings/common.py b/controlpanel/settings/common.py index 837649ea7..84c3a5857 100644 --- a/controlpanel/settings/common.py +++ b/controlpanel/settings/common.py @@ -506,6 +506,13 @@ QUICKSIGHT_ACCOUNT_REGION = os.environ.get("QUICKSIGHT_ACCOUNT_REGION") QUICKSIGHT_ASSUMED_ROLE = os.environ.get("QUICKSIGHT_ASSUMED_ROLE") +IDENTITY_CENTER_ASSUMED_ROLE = os.environ.get("IDENTITY_CENTER_ASSUMED_ROLE") +IDENTITY_CENTER_ACCOUNT_REGION = os.environ.get("IDENTITY_CENTER_ACCOUNT_REGION") +QUICKSIGHT_READER_GROUP_NAME = os.environ.get("QUICKSIGHT_READER_GROUP_NAME") +QUICKSIGHT_AUTHOR_GROUP_NAME = os.environ.get("QUICKSIGHT_AUTHOR_GROUP_NAME") +QUICKSIGHT_ADMIN_GROUP_NAME = os.environ.get("QUICKSIGHT_ADMIN_GROUP_NAME") +AZURE_HOLDING_GROUP_NAME = os.environ.get("AZURE_HOLDING_GROUP_NAME") + # The EKS OIDC provider, referenced in user policies to allow service accounts # to grant AWS permissions. OIDC_EKS_PROVIDER = os.environ.get("OIDC_EKS_PROVIDER") diff --git a/controlpanel/settings/test.py b/controlpanel/settings/test.py index eba1132a2..34f431da8 100644 --- a/controlpanel/settings/test.py +++ b/controlpanel/settings/test.py @@ -34,8 +34,14 @@ QUICKSIGHT_ACCOUNT_ID = "123456789012" QUICKSIGHT_ACCOUNT_REGION = "eu-west-2" -QUICKSIGHT_DOMAINS = "http://localhost:8000" +QUICKSIGHT_DOMAINS = ["http://localhost:8000"] QUICKSIGHT_ASSUMED_ROLE = "arn:aws:iam::123456789012:role/quicksight_test" +IDENTITY_CENTER_ASSUMED_ROLE = "arn:aws:iam::123456789012:role/identity_center_test" +IDENTITY_CENTER_ACCOUNT_REGION = "eu-west-2" +QUICKSIGHT_READER_GROUP_NAME = "test-reader-group" +QUICKSIGHT_AUTHOR_GROUP_NAME = "test-author-group" +QUICKSIGHT_ADMIN_GROUP_NAME = "test-admin-group" +AZURE_HOLDING_GROUP_NAME = "test-holding-group" OIDC_CPANEL_API_AUDIENCE = "test-audience" diff --git a/tests/api/fixtures/aws.py b/tests/api/fixtures/aws.py index 7209b80d2..680a7d910 100644 --- a/tests/api/fixtures/aws.py +++ b/tests/api/fixtures/aws.py @@ -296,3 +296,88 @@ def root_folder_bucket(s3): def quicksight(aws_creds): with moto.mock_aws(): yield boto3.client("quicksight", region_name="eu-west-1") + + +@pytest.fixture(autouse=True) +def sso_admin(aws_creds): + with moto.mock_aws(): + yield boto3.client("sso-admin", region_name="eu-west-2") + + +@pytest.fixture(autouse=True) +def identity_store_id(sso_admin): + response = sso_admin.list_instances() + yield response["Instances"][0]["IdentityStoreId"] + + +@pytest.fixture(autouse=True) +def identity_store(aws_creds): + with moto.mock_aws(): + client = boto3.client("identitystore", region_name="eu-west-2") + yield client + + +@pytest.fixture +def group_ids(identity_store_id, identity_store): + group_ids = {} + + group_ids["quicksight_compute_reader"] = identity_store.create_group( + IdentityStoreId=identity_store_id, DisplayName=settings.QUICKSIGHT_READER_GROUP_NAME + )["GroupId"] + group_ids["quicksight_compute_author"] = identity_store.create_group( + IdentityStoreId=identity_store_id, DisplayName=settings.QUICKSIGHT_AUTHOR_GROUP_NAME + )["GroupId"] + group_ids["azure_holding"] = identity_store.create_group( + IdentityStoreId=identity_store_id, DisplayName=settings.AZURE_HOLDING_GROUP_NAME + )["GroupId"] + + yield group_ids + + +@pytest.fixture +def identity_store_user_setup(users, identity_store_id, group_ids, identity_store): + + for key, user in users.items(): + if user.justice_email is not None: + forename, surname = user.justice_email.split("@")[0].split(".") + response = identity_store.create_user( + IdentityStoreId=identity_store_id, + UserName=user.justice_email, + DisplayName=user.justice_email, + Name={ + "FamilyName": surname, + "GivenName": forename, + }, + Emails=[{"Value": user.justice_email, "Type": "EntraId", "Primary": True}], + ) + user.identity_center_id = response["UserId"] + + if user.is_superuser: + identity_store.create_group_membership( + IdentityStoreId=identity_store_id, + GroupId=group_ids["azure_holding"], + MemberId={"UserId": user.identity_center_id}, + ) + + response = identity_store.create_group_membership( + IdentityStoreId=identity_store_id, + GroupId=group_ids["quicksight_compute_author"], + MemberId={"UserId": user.identity_center_id}, + ) + + user.group_membership_id = response["MembershipId"] + + if key in group_ids: + identity_store.create_group_membership( + IdentityStoreId=identity_store_id, + GroupId=group_ids["azure_holding"], + MemberId={"UserId": user.identity_center_id}, + ) + + response = identity_store.create_group_membership( + IdentityStoreId=identity_store_id, + GroupId=group_ids[key], + MemberId={"UserId": user.identity_center_id}, + ) + + user.group_membership_id = response["MembershipId"] diff --git a/tests/api/permissions/test_user_permissions.py b/tests/api/permissions/test_user_permissions.py index bb7183e0f..9df93c80b 100644 --- a/tests/api/permissions/test_user_permissions.py +++ b/tests/api/permissions/test_user_permissions.py @@ -44,7 +44,7 @@ def user_create(client, users): def user_update(client, users): data = { "username": "foo", - "auth0_id": "github|888", + "auth0_id": users["other_user"].auth0_id, "email": "foo@example.com", "is_admin": True, } @@ -56,7 +56,7 @@ def user_update(client, users): def user_update_self(client, users): - data = {"username": "foo", "auth0_id": "github|888"} + data = {"username": "foo", "auth0_id": users["normal_user"].auth0_id} return client.put( reverse("user-detail", (users["normal_user"].auth0_id,)), json.dumps(data), diff --git a/tests/api/views/test_user.py b/tests/api/views/test_user.py index b02339932..4e498fc23 100644 --- a/tests/api/views/test_user.py +++ b/tests/api/views/test_user.py @@ -30,7 +30,7 @@ def ExtendedAuth0(): def test_list(client, users): response = client.get(reverse("user-list")) assert response.status_code == status.HTTP_200_OK - assert len(response.data["results"]) == 5 + assert len(response.data["results"]) == 8 def test_detail(client, users): @@ -127,7 +127,7 @@ def test_create_superuser(client, slack, superuser): def test_update(client, users): - data = {"username": "foo", "auth0_id": "github|888"} + data = {"username": "foo", "auth0_id": users["normal_user"].auth0_id} response = client.put( reverse("user-detail", (users["normal_user"].auth0_id,)), json.dumps(data), diff --git a/tests/conftest.py b/tests/conftest.py index 613d0ee2f..384d9fe7b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,7 +11,10 @@ # First-party/Local from controlpanel.api import auth0 -from controlpanel.api.models import QUICKSIGHT_EMBED_PERMISSION +from controlpanel.api.models import ( + QUICKSIGHT_EMBED_AUTHOR_PERMISSION, + QUICKSIGHT_EMBED_READER_PERMISSION, +) from controlpanel.utils import load_app_conf_from_file from tests.api.fixtures.aws import * from tests.api.fixtures.helm_mojanalytics_index import HELM_MOJANALYTICS_INDEX @@ -97,46 +100,91 @@ def superuser(db, slack_WebClient, iam, managed_policy, airflow_dev_policy, airf auth0_id="github|user_1", is_superuser=True, username="alice", + justice_email="Alice.Hula@justice.gov.uk", ) @pytest.fixture -def quicksight_user(db): +def no_justice_superuser( + db, slack_WebClient, iam, managed_policy, airflow_dev_policy, airflow_prod_policy +): + return baker.make( + "api.User", + auth0_id="github|user_8", + is_superuser=True, + username="steve", + ) + + +@pytest.fixture +def quicksight_author_user(db): user = baker.make( "api.User", auth0_id="github|user_5", - username="foobar", + username="qs_author", + justice_email="Quicksight.Author@justice.gov.uk", + is_superuser=False, + ) + user.user_permissions.add(Permission.objects.get(codename=QUICKSIGHT_EMBED_AUTHOR_PERMISSION)) + return user + + +@pytest.fixture +def quicksight_reader_user(db): + user = baker.make( + "api.User", + auth0_id="github|user_6", + username="qs_reader", + justice_email="Quicksight.Reader@justice.gov.uk", is_superuser=False, ) - user.user_permissions.add(Permission.objects.get(codename=QUICKSIGHT_EMBED_PERMISSION)) + user.user_permissions.add(Permission.objects.get(codename=QUICKSIGHT_EMBED_READER_PERMISSION)) return user @pytest.fixture def users( - db, superuser, iam, managed_policy, airflow_dev_policy, airflow_prod_policy, quicksight_user + db, + superuser, + no_justice_superuser, + iam, + managed_policy, + airflow_dev_policy, + airflow_prod_policy, + quicksight_author_user, + quicksight_reader_user, ): return { "superuser": superuser, + "no_justice_superuser": no_justice_superuser, "normal_user": baker.make( "api.User", auth0_id="github|user_2", username="bob", + justice_email="Bob.Carolgees@justice.gov.uk", is_superuser=False, ), "other_user": baker.make( "api.User", username="carol", + justice_email="Carol.Vorderman@justice.gov.uk", auth0_id="github|user_3", ), "database_user": baker.make( "api.User", auth0_id="github|user_4", username="dave", + justice_email="Dave.Hoff@justice.gov.uk", is_superuser=False, is_database_admin=True, ), - "quicksight_user": quicksight_user, + "no_justice_user": baker.make( + "api.User", + auth0_id="github|user_7", + username="ronnie", + ), + "quicksight_compute_author": quicksight_author_user, + "quicksight_compute_reader": quicksight_reader_user, } diff --git a/tests/frontend/views/test_auth.py b/tests/frontend/views/test_auth.py index c7b22b01c..04abc8e7b 100644 --- a/tests/frontend/views/test_auth.py +++ b/tests/frontend/views/test_auth.py @@ -30,7 +30,7 @@ def test_success(self, oauth, client, users): oauth.azure.authorize_access_token.return_value = { "userinfo": {"email": "email@example.com", "oid": "12345678"}, } - user = users["normal_user"] + user = users["no_justice_user"] assert user.justice_email is None client.force_login(user) @@ -44,7 +44,7 @@ def test_success(self, oauth, client, users): @patch("controlpanel.frontend.views.auth.oauth") def test_failure(self, oauth, client, users): oauth.azure.authorize_access_token.side_effect = OAuthError() - user = users["normal_user"] + user = users["no_justice_user"] assert user.justice_email is None client.force_login(user) diff --git a/tests/frontend/views/test_index.py b/tests/frontend/views/test_index.py index 848028644..8e979d94f 100644 --- a/tests/frontend/views/test_index.py +++ b/tests/frontend/views/test_index.py @@ -42,9 +42,9 @@ def test_justice_auth_feature_flag_disabled_for_normal_user( class TestGetAsSuperuser: - def test_without_justice_email(self, client, superuser): - client.force_login(superuser) - assert superuser.justice_email is None + def test_without_justice_email(self, client, no_justice_superuser): + client.force_login(no_justice_superuser) + assert no_justice_superuser.justice_email is None response = client.get("/") @@ -52,8 +52,6 @@ def test_without_justice_email(self, client, superuser): assert response.template_name == ["justice_email.html"] def test_with_justice_email(self, client, superuser): - superuser.justice_email = "email@example.com" - superuser.save() client.force_login(superuser) response = client.get("/") @@ -64,7 +62,7 @@ def test_with_justice_email(self, client, superuser): class TestGetAsNormalUser: def test_without_justice_email(self, client, users): - user = users["normal_user"] + user = users["no_justice_user"] client.force_login(user) assert user.justice_email is None diff --git a/tests/frontend/views/test_quicksight.py b/tests/frontend/views/test_quicksight.py index 7efebbdc2..a945e376a 100644 --- a/tests/frontend/views/test_quicksight.py +++ b/tests/frontend/views/test_quicksight.py @@ -12,6 +12,20 @@ orig = botocore.client.BaseClient._make_api_call +# Mocked botocore _make_api_call function +def mock_make_api_call(self, operation_name, kwarg): + op_names = [ + {"GenerateEmbedUrlForRegisteredUser": {}}, + ] + + for operation in op_names: + if operation_name in operation: + return operation[operation_name] + + # If we don't want to patch the API call + return orig(self, operation_name, kwarg) + + def quicksight(client): return client.get(reverse("quicksight")) @@ -22,10 +36,12 @@ def quicksight(client): (quicksight, "superuser", status.HTTP_200_OK), (quicksight, "database_user", status.HTTP_403_FORBIDDEN), (quicksight, "normal_user", status.HTTP_403_FORBIDDEN), - (quicksight, "quicksight_user", status.HTTP_200_OK), + (quicksight, "quicksight_compute_author", status.HTTP_200_OK), + (quicksight, "quicksight_compute_reader", status.HTTP_200_OK), ], ) def test_permission(client, users, view, user, expected_status): client.force_login(users[user]) - response = view(client) - assert response.status_code == expected_status + with patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call): + response = view(client) + assert response.status_code == expected_status diff --git a/tests/frontend/views/test_user.py b/tests/frontend/views/test_user.py index 9864e7baf..704e4effc 100644 --- a/tests/frontend/views/test_user.py +++ b/tests/frontend/views/test_user.py @@ -7,8 +7,7 @@ from rest_framework import status # First-party/Local -from controlpanel.api import cluster -from controlpanel.api.models import QUICKSIGHT_EMBED_PERMISSION +from controlpanel.api import aws, cluster @pytest.fixture(autouse=True) @@ -81,7 +80,6 @@ def set_database_admin(client, users, *args): (retrieve, "superuser", status.HTTP_200_OK), (retrieve, "normal_user", status.HTTP_403_FORBIDDEN), (retrieve, "other_user", status.HTTP_200_OK), - (set_admin, "superuser", status.HTTP_302_FOUND), (set_admin, "normal_user", status.HTTP_403_FORBIDDEN), (set_admin, "other_user", status.HTTP_403_FORBIDDEN), (reset_mfa, "superuser", status.HTTP_302_FOUND), @@ -104,6 +102,7 @@ def set_database_admin(client, users, *args): def test_permission(client, users, view, user, expected_status): for key, val in users.items(): client.force_login(val) + client.force_login(users[user]) response = view(client, users) assert response.status_code == expected_status @@ -112,7 +111,7 @@ def test_permission(client, users, view, user, expected_status): @pytest.mark.parametrize( "view,user,expected_count", [ - (list, "superuser", 5), + (list, "superuser", 8), ], ) def test_list(client, users, view, user, expected_count): @@ -127,9 +126,23 @@ def slack(): yield slack -def test_grant_superuser_access(client, users, slack): +@patch.object(aws.AWSIdentityStore, "get_group_membership_id") +@patch.object(aws.AWSIdentityStore, "get_user_id") +def test_grant_superuser_access( + get_user_id, + get_group_membership_id, + identity_store_user_setup, + identity_store, + identity_store_id, + group_ids, + client, + users, + slack, +): request_user = users["superuser"] user = users["other_user"] + get_user_id.return_value = user.identity_center_id + get_group_membership_id.side_effect = [None, None] client.force_login(request_user) response = set_admin(client, users) assert response.status_code == status.HTTP_302_FOUND @@ -138,20 +151,32 @@ def test_grant_superuser_access(client, users, slack): by_username=request_user.username, ) + response = identity_store.list_group_memberships_for_member( + IdentityStoreId=identity_store_id, + MemberId={"UserId": user.identity_center_id}, + ) + + assert len(response["GroupMemberships"]) == 2 + expected_groups = ["quicksight_compute_author", "azure_holding"] + + groups = [group_ids[group] for group in expected_groups] + + for group_membership in response["GroupMemberships"]: + if group_membership["GroupId"] not in groups: + raise AssertionError + @pytest.mark.parametrize( - "data, legacy_access, compute_access", + "data, legacy_access", [ - ({"enable_quicksight": ["quicksight_legacy"]}, True, False), - ({"enable_quicksight": ["quicksight_compute"]}, False, True), - ({"enable_quicksight": ["quicksight_legacy", "quicksight_compute"]}, True, True), - ({}, False, False), + ({"enable_quicksight": ["quicksight_legacy"]}, True), + ({}, False), ], - ids=["legacy enabled", "compute enabled", "both enabled", "no access"], + ids=["legacy enabled", "no access"], ) @patch("controlpanel.api.models.user.cluster.User.update_policy_attachment") -def test_enable_quicksight_access( - update_policy_attachment, data, legacy_access, compute_access, client, users +def test_enable_quicksight_access_legacy( + update_policy_attachment, data, legacy_access, client, users ): request_user = users["superuser"] user = users["other_user"] @@ -165,7 +190,160 @@ def test_enable_quicksight_access( policy=cluster.User.QUICKSIGHT_POLICY_NAME, attach=legacy_access, ) - assert ( - user.user_permissions.filter(codename=QUICKSIGHT_EMBED_PERMISSION).exists() - is compute_access + + +@patch.object(aws.AWSIdentityStore, "get_group_membership_id") +@patch.object(aws.AWSIdentityStore, "get_user_id") +@pytest.mark.parametrize( + "user, data, group_membership_calls, expected_groups, expected_result", + [ + ( + "superuser", + {"enable_quicksight": ["quicksight_compute_reader"]}, + [None, "Mock-Azure-Holding-Id"], + ["quicksight_compute_author", "azure_holding"], + 2, + ), + ( + "normal_user", + {"enable_quicksight": ["quicksight_compute_reader"]}, + [None, None], + ["quicksight_compute_reader", "azure_holding"], + 2, + ), + ( + "superuser", + {"enable_quicksight": ["quicksight_compute_author"]}, + ["Mock-Author-Id", "Mock-Azure-Holding-Id"], + ["quicksight_compute_author", "azure_holding"], + 2, + ), + ( + "normal_user", + {"enable_quicksight": ["quicksight_compute_author"]}, + [None, None], + ["quicksight_compute_author", "azure_holding"], + 2, + ), + ( + "normal_user", + {"enable_quicksight": ["quicksight_compute_author", "quicksight_compute_reader"]}, + [None, None, None, "Mock-Azure-Holding-Id"], + ["quicksight_compute_author", "quicksight_compute_reader", "azure_holding"], + 3, + ), + ( + "quicksight_compute_reader", + {"enable_quicksight": ["quicksight_compute_reader"]}, + ["Mock-Reader-Id", "Mock-Azure-Holding-Id"], + ["quicksight_compute_reader", "azure_holding"], + 2, + ), + ], +) +def test_quicksight_form_add_to_groups( + get_user_id, + get_group_membership_id, + identity_store_user_setup, + users, + identity_store, + identity_store_id, + client, + group_ids, + user, + data, + group_membership_calls, + expected_groups, + expected_result, +): + """ + Tests adding a user to the correct group plus the azure holding group. + Super users should not be added to these groups as they should already be in them + """ + + test_user = users[user] + get_user_id.return_value = test_user.identity_center_id + get_group_membership_id.side_effect = group_membership_calls + + request_user = users["superuser"] + url = reverse("set-quicksight", kwargs={"pk": test_user.auth0_id}) + + client.force_login(request_user) + response = client.post(url, data=data) + assert response.status_code == status.HTTP_302_FOUND + + response = identity_store.list_group_memberships_for_member( + IdentityStoreId=identity_store_id, + MemberId={"UserId": test_user.identity_center_id}, + ) + + assert len(response["GroupMemberships"]) == expected_result + + groups = [group_ids[group] for group in expected_groups] + + for group_membership in response["GroupMemberships"]: + if group_membership["GroupId"] not in groups: + raise AssertionError + + +@patch.object(aws.AWSIdentityStore, "get_group_membership_id") +@patch.object(aws.AWSIdentityStore, "get_user_id") +@pytest.mark.parametrize( + "user, data, expected_result", + [ + ( + "quicksight_compute_author", + {"enable_quicksight": []}, + 1, + ), + ( + "quicksight_compute_reader", + {"enable_quicksight": []}, + 1, + ), + ( + "normal_user", + {"enable_quicksight": []}, + 0, + ), + ], +) +def test_quicksight_form_remove_from_group( + get_user_id, + get_group_membership_id, + identity_store_user_setup, + client, + users, + identity_store, + identity_store_id, + group_ids, + user, + data, + expected_result, +): + """ + Tests removing a user from their group. + Should still be part of azure holding group if already in a group + """ + + test_user = users[user] + get_user_id.return_value = test_user.identity_center_id + get_group_membership_id.return_value = ( + test_user.group_membership_id if expected_result > 0 else None + ) + request_user = users["superuser"] + url = reverse("set-quicksight", kwargs={"pk": test_user.auth0_id}) + + client.force_login(request_user) + response = client.post(url, data=data) + assert response.status_code == status.HTTP_302_FOUND + + response = identity_store.list_group_memberships_for_member( + IdentityStoreId=identity_store_id, + MemberId={"UserId": test_user.identity_center_id}, ) + + assert len(response["GroupMemberships"]) == expected_result + + if expected_result > 0: + assert response["GroupMemberships"][0]["GroupId"] == group_ids["azure_holding"]