diff --git a/controlpanel/api/auth0.py b/controlpanel/api/auth0.py index 99e0158de..725a170db 100644 --- a/controlpanel/api/auth0.py +++ b/controlpanel/api/auth0.py @@ -46,6 +46,8 @@ class ExtendedAuth0(Auth0): DEFAULT_GRANT_TYPES = ["authorization_code", "client_credentials"] DEFAULT_APP_TYPE = "regular_web" + M2M_APP_TYPE = "non_interactive" + M2M_GRANT_TYPES = ["client_credentials"] DEFAULT_CONNECTION_OPTION = "email" @@ -185,6 +187,41 @@ def setup_auth0_client(self, client_name, app_url_name=None, connections=None, a self._enable_connections_for_new_client(client_id, chosen_connections=new_connections) return client, group + def setup_m2m_client(self, client_name, scopes): + client, created = self.clients.get_or_create( + { + "name": client_name, + "app_type": "non_interactive", + "grant_types": ExtendedAuth0.M2M_GRANT_TYPES, + } + ) + if not created: + return client + + try: + body = { + "client_id": client["client_id"], + "scope": scopes, + "audience": settings.OIDC_CPANEL_API_AUDIENCE, + } + self.client_grants.create(body=body) + except exceptions.Auth0Error as error: + # if the client grant already exists, it will raise 409 error, so we can ignore it. + # otherwise, raise the error + if error.status_code != 409: + self.clients.delete(client["client_id"]) + raise Auth0Error(error.__str__(), code=error.status_code) + + return client + + def rotate_m2m_client_secret(self, client_id): + try: + return self.clients.rotate_secret(client_id) + except exceptions.Auth0Error as error: + if error.status_code == 404: + return None + raise Auth0Error(error.__str__(), code=error.status_code) + def add_group_members_by_emails(self, emails, user_options={}, group_id=None, group_name=None): user_ids = self.users.add_users_by_emails(emails, user_options=user_options) self.groups.add_group_members(user_ids=user_ids, group_id=group_id, group_name=group_name) @@ -417,9 +454,11 @@ def search_first_match(self, resource): def get_or_create(self, resource): result = self.search_first_match(resource) + created = False if result is None: result = self.create(resource) - return result + created = True + return result, created class ExtendedClients(ExtendedAPIMethods, Clients): diff --git a/controlpanel/api/cluster.py b/controlpanel/api/cluster.py index 22d68961d..90b6ec86a 100644 --- a/controlpanel/api/cluster.py +++ b/controlpanel/api/cluster.py @@ -392,6 +392,7 @@ class App(EntityResource): AUTHENTICATION_REQUIRED = "AUTHENTICATION_REQUIRED" AUTH0_PASSWORDLESS = "AUTH0_PASSWORDLESS" # gitleaks:allow APP_ROLE_ARN = "APP_ROLE_ARN" + API_SCOPES = ["retrieve:app", "customers:app", "add_customers:app"] def __init__(self, app, github_api_token=None, auth0_instance=None): super(App, self).__init__() @@ -693,6 +694,36 @@ def create_auth_settings( ) return client, group + def create_m2m_client(self): + m2m_client = self._get_auth0_instance().setup_m2m_client( + client_name=self.app.auth0_client_name("m2m"), + scopes=self.API_SCOPES, + ) + if not self.app.app_conf: + self.app.app_conf = {} + + # save the client ID, which we can use to retrieve the client secret + self.app.app_conf["m2m"] = { + "client_id": m2m_client["client_id"], + } + self.app.save() + return m2m_client + + def rotate_m2m_client_secret(self): + m2m_client = self._get_auth0_instance().rotate_m2m_client_secret( + client_id=self.app.m2m_client_id + ) + if not m2m_client: + self.app.app_conf.pop("m2m", None) + self.app.save() + return m2m_client + + def delete_m2m_client(self): + response = self._get_auth0_instance().clients.delete(id=self.app.m2m_client_id) + self.app.app_conf.pop("m2m", None) + self.app.save() + return response + def remove_auth_settings(self, env_name): try: secrets_require_remove = [App.AUTH0_CLIENT_ID, App.AUTH0_CLIENT_SECRET] diff --git a/controlpanel/api/models/app.py b/controlpanel/api/models/app.py index 7f5f8f06f..ba974d143 100644 --- a/controlpanel/api/models/app.py +++ b/controlpanel/api/models/app.py @@ -98,6 +98,12 @@ def release_name(self): def iam_role_arn(self): return cluster.iam_arn(f"role/{self.iam_role_name}") + @property + def m2m_client_id(self): + if self.app_conf is None: + return None + return self.app_conf.get("m2m", {}).get("client_id") + def get_group_id(self, env_name): return self.get_auth_client(env_name).get("group_id") @@ -218,12 +224,14 @@ def delete_customers(self, user_ids, env_name=None, group_id=None): except auth0.Auth0Error as e: raise DeleteCustomerError from e - def delete_customer_by_email(self, email, group_id): + def delete_customer_by_email(self, email, group_id=None, env_name=None): """ Attempt to find a customer by email and delete them from the group. If the user is not found, or the user does not belong to the given group, raise an error. """ + if not group_id: + group_id = self.get_auth_client(env_name).get("group_id") auth0_client = auth0.ExtendedAuth0() try: user = auth0_client.users.get_users_email_search( @@ -239,7 +247,7 @@ def delete_customer_by_email(self, email, group_id): if group_id == group["_id"]: return self.delete_customers(user_ids=[user["user_id"]], group_id=group_id) - raise DeleteCustomerError(f"User {email} cannot be found in this application group") + raise DeleteCustomerError(f"User {email} not found for this application and environment") @property def status(self): diff --git a/controlpanel/api/pagination.py b/controlpanel/api/pagination.py index 7365b1f33..1d2262fc5 100644 --- a/controlpanel/api/pagination.py +++ b/controlpanel/api/pagination.py @@ -1,6 +1,9 @@ # Third-party -from django.core.paginator import Paginator +from django.core.paginator import InvalidPage, Paginator +from rest_framework import serializers from rest_framework.pagination import PageNumberPagination, _positive_int +from rest_framework.response import Response +from rest_framework.utils.urls import replace_query_param class CustomPageNumberPagination(PageNumberPagination): @@ -53,3 +56,35 @@ def __init__(self, object_list, per_page, total_count=25, **kwargs): def count(self): """Return the total number of objects, across all pages.""" return self.total_count + + +class Auth0ApiPagination(Auth0Paginator): + + def __init__(self, request, page_number, *args, **kwargs): + self.request = request + super().__init__(*args, **kwargs) + self._page = self.get_page(page_number) + + def get_page_url(self, page_number): + url = self.request.build_absolute_uri() + return replace_query_param(url, "page", page_number) + + def get_next_link(self): + if not self._page.has_next(): + return None + return self.get_page_url(self._page.next_page_number()) + + def get_previous_link(self): + if not self._page.has_previous(): + return None + return self.get_page_url(self._page.previous_page_number()) + + def get_paginated_response(self): + return Response( + { + "count": self.count, + "next": self.get_next_link(), + "previous": self.get_previous_link(), + "results": self.object_list, + } + ) diff --git a/controlpanel/api/permissions.py b/controlpanel/api/permissions.py index fc0726ba2..0e429b6f9 100644 --- a/controlpanel/api/permissions.py +++ b/controlpanel/api/permissions.py @@ -32,6 +32,15 @@ def has_object_permission(self, request, view, obj): return hasattr(request.user, "is_client") and request.user.is_client +class AppJwtPermissions(JWTTokenResourcePermissions): + + def has_object_permission(self, request, view, obj): + if not super().has_object_permission(request, view, obj): + return False + client_id = request.user.pk.removesuffix("@clients") + return client_id == obj.m2m_client_id + + class IsSuperuser(BasePermission): """ Only superusers are authorised diff --git a/controlpanel/api/rules.py b/controlpanel/api/rules.py index 1293e4044..6d692e70d 100644 --- a/controlpanel/api/rules.py +++ b/controlpanel/api/rules.py @@ -62,6 +62,8 @@ def is_app_admin(user, obj): add_perm("api.manage_groups", is_authenticated & is_superuser) add_perm("api.create_policys3bucket", is_authenticated & is_superuser) add_perm("api.update_app_settings", is_authenticated & is_app_admin) +add_perm("api.customers_app", is_authenticated & is_app_admin) +add_perm("api.add_customers_app", is_authenticated & is_app_admin) add_perm("api.update_app_ip_allowlists", is_authenticated & is_app_admin) diff --git a/controlpanel/api/serializers.py b/controlpanel/api/serializers.py index 029a73d2d..fecdd1820 100644 --- a/controlpanel/api/serializers.py +++ b/controlpanel/api/serializers.py @@ -286,6 +286,11 @@ class Meta: ) +class DeleteAppCustomerSerializer(serializers.Serializer): + email = serializers.EmailField(required=True) + env_name = serializers.CharField(max_length=64, required=True) + + class ToolDeploymentSerializer(serializers.Serializer): old_chart_name = serializers.CharField(max_length=64, required=False) version = serializers.CharField(max_length=64, required=True) diff --git a/controlpanel/api/tasks/handlers/base.py b/controlpanel/api/tasks/handlers/base.py index 5dd70f982..24071a215 100644 --- a/controlpanel/api/tasks/handlers/base.py +++ b/controlpanel/api/tasks/handlers/base.py @@ -1,9 +1,12 @@ # Third-party +import structlog from celery import Task as CeleryTask # First-party/Local from controlpanel.api.models import Task +log = structlog.getLogger(__name__) + class BaseTaskHandler(CeleryTask): # can be applied to project settings also @@ -16,12 +19,20 @@ class BaseTaskHandler(CeleryTask): task_obj = None def complete(self): - if self.task_obj: - self.task_obj.completed = True - self.task_obj.save() + if not self.task_obj: + return log.warn("Task completed, but no object to mark as completed.") + + self.task_obj.completed = True + self.task_obj.save() + log.info(f"Task object completed: {self.task_obj.task_id}") def get_task_obj(self): - return Task.objects.filter(task_id=self.request.id).first() + task_id = self.request.id + log.info(f"Getting task object with ID: {task_id}") + task = Task.objects.filter(task_id=task_id).first() + if not task: + log.warn(f"Task object not found with ID: {task_id}. Continuing...") + return task def run(self, *args, **kwargs): self.task_obj = self.get_task_obj() diff --git a/controlpanel/api/views/apps.py b/controlpanel/api/views/apps.py index 2def516f0..683cde0ea 100644 --- a/controlpanel/api/views/apps.py +++ b/controlpanel/api/views/apps.py @@ -1,10 +1,20 @@ +# Standard library +import re + # Third-party +from django.core.exceptions import ValidationError as DjangoValidationError +from django.core.validators import EmailValidator from django_filters.rest_framework import DjangoFilterBackend -from rest_framework import mixins, viewsets +from rest_framework import mixins, status, viewsets +from rest_framework.decorators import action +from rest_framework.exceptions import ValidationError +from rest_framework.fields import get_error_detail +from rest_framework.response import Response # First-party/Local from controlpanel.api import permissions, serializers from controlpanel.api.models import App +from controlpanel.api.pagination import Auth0ApiPagination class AppByNameViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet): @@ -13,7 +23,89 @@ class AppByNameViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet): queryset = App.objects.all() serializer_class = serializers.AppSerializer - permission_classes = (permissions.AppPermissions | permissions.JWTTokenResourcePermissions,) + permission_classes = (permissions.AppPermissions | permissions.AppJwtPermissions,) filter_backends = (DjangoFilterBackend,) - http_method_names = ["get"] lookup_field = "name" + + def dispatch(self, request, *args, **kwargs): + return super().dispatch(request, *args, **kwargs) + + def get_serializer_class(self, *args, **kwargs): + mapping = { + "customers": serializers.AppCustomerSerializer, + "add_customers": serializers.AppCustomerSerializer, + "delete_customers": serializers.DeleteAppCustomerSerializer, + } + serializer = mapping.get(self.action) + if serializer: + return serializer + return super().get_serializer_class(*args, **kwargs) + + @action(detail=True, methods=["get"]) + def customers(self, request, *args, **kwargs): + if "env_name" not in request.query_params: + raise ValidationError({"env_name": "This field is required."}) + + app = self.get_object() + group_id = app.get_group_id(request.query_params.get("env_name", "")) + page_number = request.query_params.get("page", 1) + per_page = request.query_params.get("per_page", 25) + customers = app.customer_paginated( + page=page_number, + group_id=group_id, + per_page=per_page, + ) + serializer = self.get_serializer(data=customers["users"], many=True) + serializer.is_valid() + pagination = Auth0ApiPagination( + request, + page_number, + object_list=serializer.validated_data, + total_count=customers["total"], + per_page=per_page, + ) + return pagination.get_paginated_response() + + @customers.mapping.post + def add_customers(self, request, *args, **kwargs): + if "env_name" not in request.query_params: + raise ValidationError({"env_name": "This field is required."}) + + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + app = self.get_object() + + delimiters = re.compile(r"[,; ]+") + emails = delimiters.split(serializer.validated_data["email"]) + errors = [] + for email in emails: + validator = EmailValidator(message=f"{email} is not a valid email address") + try: + validator(email) + except DjangoValidationError as error: + errors.extend(get_error_detail(error)) + if errors: + raise ValidationError(errors) + + app.add_customers(emails, env_name=request.query_params.get("env_name", "")) + + return Response(serializer.data, status=status.HTTP_201_CREATED) + + @customers.mapping.delete + def delete_customers(self, request, *args, **kwargs): + """ + Delete a customer from an environment. Requires the customers email and the env name. + """ + app = self.get_object() + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + try: + app.delete_customer_by_email( + serializer.validated_data["email"], env_name=serializer.validated_data["env_name"] + ) + except app.DeleteCustomerError as error: + raise ValidationError({"email": error.args[0]}) + + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/controlpanel/api/views/models.py b/controlpanel/api/views/models.py index b8052173d..274c60564 100644 --- a/controlpanel/api/views/models.py +++ b/controlpanel/api/views/models.py @@ -26,7 +26,7 @@ class AppViewSet(viewsets.ModelViewSet): serializer_class = serializers.AppSerializer filter_backends = (DjangoFilterBackend,) - permission_classes = (permissions.AppPermissions | permissions.JWTTokenResourcePermissions,) + permission_classes = (permissions.AppPermissions | permissions.AppJwtPermissions,) filterset_fields = ("name", "repo_url", "slug") lookup_field = "res_id" diff --git a/controlpanel/frontend/jinja2/includes/webapp-m2m-client.html b/controlpanel/frontend/jinja2/includes/webapp-m2m-client.html new file mode 100644 index 000000000..ad2a00bfc --- /dev/null +++ b/controlpanel/frontend/jinja2/includes/webapp-m2m-client.html @@ -0,0 +1,38 @@ +
+

Machine-to-machine API access

+ + {% if not app.m2m_client_id %} +

If your app needs access to the Control Panel API, you can create a machine-to-machine client using the button below.

+
+ {{ csrf_input }} + + +
+ {% else %} +

Use the button below to rotate your machine-to-machine client secret. Your client ID will remain the same.

+
+ {{ csrf_input }} + + +
+ +

If you no longer require API access you can delete your machine-to-machine client using the button below. If you delete your client, you will have the option of creating a new one.

+
+ {{ csrf_input }} + + +
+ + {% endif %} +

See our user guidance for full details about access to the Control Panel API.

+ +
diff --git a/controlpanel/frontend/jinja2/user-detail.html b/controlpanel/frontend/jinja2/user-detail.html index ab9c65183..59edcc044 100644 --- a/controlpanel/frontend/jinja2/user-detail.html +++ b/controlpanel/frontend/jinja2/user-detail.html @@ -8,7 +8,7 @@ {% set page_title = user_name(user) %} {% set pronoun = "Your" if user == request.user else "User's" %} -{% set env = settings.ENV %} +{% set env = "production" if settings.ENV == "alpha" else settings.ENV %} {% block content %} User diff --git a/controlpanel/frontend/jinja2/webapp-detail.html b/controlpanel/frontend/jinja2/webapp-detail.html index 32060ba8c..c6dfedcd2 100644 --- a/controlpanel/frontend/jinja2/webapp-detail.html +++ b/controlpanel/frontend/jinja2/webapp-detail.html @@ -276,6 +276,10 @@

App data sources

{% endif %} +{% if settings.features.app_m2m_client.enabled and request.user.has_perm('api.update_app', app) %} + {% include "includes/webapp-m2m-client.html" %} +{% endif %} + {% if request.user.has_perm('api.add_superuser') %}
diff --git a/controlpanel/frontend/urls.py b/controlpanel/frontend/urls.py index eabd51774..22bdeb343 100644 --- a/controlpanel/frontend/urls.py +++ b/controlpanel/frontend/urls.py @@ -102,6 +102,21 @@ views.SetupAppAuth0.as_view(), name="create-auth0-client", ), + path( + "webapps//create-m2m-client/", + views.SetupM2MClient.as_view(), + name="create-m2m-client", + ), + path( + "webapps//refresh-m2m-client/", + views.RotateM2MCredentials.as_view(), + name="rotate-m2m-credentials", + ), + path( + "webapps//delete-m2m-client/", + views.DeleteM2MClient.as_view(), + name="delete-m2m-client", + ), path( "webapps//remove_auth0_client/", views.RemoveAppAuth0.as_view(), diff --git a/controlpanel/frontend/views/__init__.py b/controlpanel/frontend/views/__init__.py index 255e03809..31a62e43c 100644 --- a/controlpanel/frontend/views/__init__.py +++ b/controlpanel/frontend/views/__init__.py @@ -27,6 +27,9 @@ RevokeAdmin, RevokeAppAccess, SetupAppAuth0, + SetupM2MClient, + RotateM2MCredentials, + DeleteM2MClient, RemoveAppAuth0, UpdateAppAccess, UpdateAppAuth0Connections, diff --git a/controlpanel/frontend/views/app.py b/controlpanel/frontend/views/app.py index 1b7e1ef76..dc738ae78 100644 --- a/controlpanel/frontend/views/app.py +++ b/controlpanel/frontend/views/app.py @@ -455,6 +455,63 @@ def post(self, request, *args, **kwargs): return super().post(request, *args, **kwargs) +class M2MClientMixin( + OIDCLoginRequiredMixin, + PermissionRequiredMixin, + SingleObjectMixin, +): + http_method_names = ["post"] + permission_required = "api.update_app_settings" + model = App + + def get_redirect_url(self, *args, **kwargs): + return reverse_lazy("manage-app", kwargs={"pk": kwargs["pk"]}) + + +class SetupM2MClient(M2MClientMixin, RedirectView): + + def post(self, request, *args, **kwargs): + app = self.get_object() + client = cluster.App(app, self.request.user.github_api_token).create_m2m_client() + messages.success( + self.request, + f"Successfully created machine-to-machine client. Your client credentials are shown below, ensure to store them securely as you will not be able to view them again.", # noqa + ) + messages.info(self.request, f"Client ID: {client['client_id']}") + messages.info(self.request, f"Client Secret: {client['client_secret']}") + return super().post(request, *args, **kwargs) + + +class RotateM2MCredentials(M2MClientMixin, RedirectView): + + def post(self, request, *args, **kwargs): + app = self.get_object() + client = cluster.App(app, self.request.user.github_api_token).rotate_m2m_client_secret() + if not client: + messages.error( + self.request, + "Failed to find a machine-to-machine client for this app, please try creating a new one.", # noqa + ) + return self.get(request, *args, **kwargs) + + messages.success( + self.request, + f"Successfully rotated machine-to-machine client secret. Your client ID and new client secret are shown below, ensure to store them securely as you will not be able to view them again.", # noqa + ) + messages.info(self.request, f"Client ID: {client['client_id']}") + messages.info(self.request, f"Client Secret: {client['client_secret']}") + return super().post(request, *args, **kwargs) + + +class DeleteM2MClient(M2MClientMixin, RedirectView): + + def post(self, request, *args, **kwargs): + app = self.get_object() + cluster.App(app, self.request.user.github_api_token).delete_m2m_client() + messages.success(self.request, "Successfully deleted machine-to-machine client.") + return super().post(request, *args, **kwargs) + + class RemoveAppAuth0( OIDCLoginRequiredMixin, PermissionRequiredMixin, SingleObjectMixin, RedirectView ): diff --git a/controlpanel/settings/test.py b/controlpanel/settings/test.py index e1822d3ab..71b606d1d 100644 --- a/controlpanel/settings/test.py +++ b/controlpanel/settings/test.py @@ -8,10 +8,6 @@ LOGGING["loggers"]["django_structlog"]["level"] = "WARNING" # noqa: F405 LOGGING["loggers"]["controlpanel"]["level"] = "WARNING" # noqa: F405 -AUTHENTICATION_BACKENDS = [ - "rules.permissions.ObjectPermissionBackend", - "django.contrib.auth.backends.ModelBackend", -] MIDDLEWARE.remove("mozilla_django_oidc.middleware.SessionRefresh") # noqa: F405 REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"].remove( # noqa: F405 "mozilla_django_oidc.contrib.drf.OIDCAuthentication", @@ -40,3 +36,5 @@ QUICKSIGHT_ACCOUNT_REGION = "eu-west-2" QUICKSIGHT_DOMAINS = "http://localhost:8000" QUICKSIGHT_ASSUMED_ROLE = "arn:aws:iam::123456789012:role/quicksight_test" + +OIDC_CPANEL_API_AUDIENCE = "test-audience" diff --git a/settings.yaml b/settings.yaml index b024537b6..2e5317e4d 100644 --- a/settings.yaml +++ b/settings.yaml @@ -16,6 +16,11 @@ enabled_features: _HOST_dev: true _HOST_prod: false _HOST_alpha: false + app_m2m_client: + _DEFAULT: false + _HOST_dev: true + _HOST_prod: false + _HOST_alpha: false AWS_SERVICE_URL: _HOST_dev: "https://aws.services.dev.analytical-platform.service.justice.gov.uk" @@ -89,7 +94,7 @@ AUTH0_NOMIS_GATEWAY_URL: "https://testing.com" BROADCAST_MESSAGE: > - AWS have increased the S3 bucket limit from one thousand to one million. | While this means that the restriction on creating new buckets has been lifted, we still recommend that you use buckets efficiently. + AWS have increased the S3 bucket limit from one thousand to one million. | While this means that the restriction on creating new buckets has been lifted, we still recommend that you use buckets efficiently. GITHUB_VERSION: "2022-11-28" diff --git a/tests/api/cluster/test_app.py b/tests/api/cluster/test_app.py index 5f61543e2..7f784e0a3 100644 --- a/tests/api/cluster/test_app.py +++ b/tests/api/cluster/test_app.py @@ -16,7 +16,7 @@ def app(): return models.App( slug="test-app", - repo_url="https://gitpub.example.com/test-repo", + repo_url="https://gitpub.example.com/test-app", namespace="test-namespace", ) @@ -240,6 +240,67 @@ def test_remove_auth_settings_repo_error( auth0_instance.clear_up_app.assert_called_once() -# TODO can this be removed? -mock_ingress = MagicMock(name="Ingress") -mock_ingress.spec.rules = [MagicMock(name="Rule", host="test-app.example.com")] +def test_create_m2m_client(app, authz): + """ + Test that the client_id is stored against the app + """ + assert app.app_conf is None + with ( + patch.object(authz, "setup_m2m_client") as setup_m2m_client, + patch.object(app, "save"), + ): + m2mclient = {"client_id": "test-client-id", "client_secret": "test-client-secret"} + setup_m2m_client.return_value = m2mclient + + assert cluster.App(app).create_m2m_client() == m2mclient + setup_m2m_client.assert_called_once_with( + client_name=app.auth0_client_name("m2m"), + scopes=["retrieve:app", "customers:app", "add_customers:app"], + ) + app.save.assert_called_once() + assert app.app_conf["m2m"] == {"client_id": "test-client-id"} + + +@pytest.mark.parametrize( + "client, save_called", + [ + ({"client_id": "old-client-id", "client_secret": "new-client-secret"}, False), + (None, True), + ], + ids=["secret_rotated", "secret_not_rotated"], +) +def test_rotate_m2m_client(app, authz, client, save_called): + """ + Test that the client_id is stored against the app + """ + app.app_conf = {"m2m": {"client_id": "old-client-id"}} + + with ( + patch.object(authz, "rotate_m2m_client_secret") as rotate_m2m_client_secret, + patch.object(app, "save"), + ): + rotate_m2m_client_secret.return_value = client + + result = cluster.App(app).rotate_m2m_client_secret() + + assert result == client + app.save.called is save_called + if save_called: + assert "m2m" not in app.app_conf + else: + assert app.app_conf["m2m"] == {"client_id": "old-client-id"} + + +def test_delete_m2m_client(app, authz): + app.app_conf = {"m2m": {"client_id": "test-client-id"}} + + with ( + patch.object(authz.clients, "delete") as delete_client, + patch.object(app, "save"), + ): + response = cluster.App(app).delete_m2m_client() + + delete_client.assert_called_once_with(id="test-client-id") + assert "m2m" not in app.app_conf + app.save.assert_called_once() + assert response is delete_client.return_value diff --git a/tests/api/models/test_app.py b/tests/api/models/test_app.py index 5abcf435b..d4dfc8ffe 100644 --- a/tests/api/models/test_app.py +++ b/tests/api/models/test_app.py @@ -155,7 +155,7 @@ def test_delete_customer_by_email_user_missing_group(auth0): app = baker.prepare("api.App") with pytest.raises( app.DeleteCustomerError, - match="User foo@email.com cannot be found in this application group", + match="User foo@email.com not found for this application and environment", ): app.delete_customer_by_email("foo@email.com", group_id="123") @@ -241,3 +241,17 @@ def test_get_logs_url(env): ) app = App(namespace="example-namespace") assert app.get_logs_url(env=env) == expected + + +@pytest.mark.parametrize( + "app_conf, expected", + [ + (None, None), + ({}, None), + ({"m2m": {}}, None), + ({"m2m": {"client_id": "test-client-id"}}, "test-client-id"), + ], +) +def test_m2m_client_id(app_conf, expected): + app = App(app_conf=app_conf) + assert app.m2m_client_id == expected diff --git a/tests/api/permissions/test_app_permissions.py b/tests/api/permissions/test_app_permissions.py index c71b259e4..e5d2e08fb 100644 --- a/tests/api/permissions/test_app_permissions.py +++ b/tests/api/permissions/test_app_permissions.py @@ -10,11 +10,16 @@ from model_bakery import baker from rest_framework import status from rest_framework.reverse import reverse +from rest_framework.test import APIClient from rules import perm_exists +# First-party/Local +from controlpanel.api.jwt_auth import AuthenticatedServiceClient +from controlpanel.api.permissions import AppJwtPermissions + @pytest.fixture -def users(users): +def users(users, authenticated_client, invalid_client_sub, invalid_client_scope): users.update( { "app_admin": baker.make( @@ -27,6 +32,9 @@ def users(users): username="testing", auth0_id="github|user_5", ), + "authenticated_client": authenticated_client, + "invalid_client_sub": invalid_client_sub, + "invalid_client_scope": invalid_client_scope, } ) return users @@ -34,13 +42,16 @@ def users(users): @pytest.fixture(autouse=True) def app(users): - app = baker.make("api.App", name="Test App 1") + app = baker.make("api.App", name="Test App 1", app_conf={"m2m": {"client_id": "abc123"}}) user = users["app_admin"] baker.make("api.UserApp", user=user, app=app, is_admin=True) user = users["app_user"] baker.make("api.UserApp", user=user, app=app, is_admin=False) - return app + with patch( + "controlpanel.api.models.App.customer_paginated", return_value={"users": [], "total": 0} + ): + yield app @pytest.fixture # noqa: F405 @@ -49,6 +60,33 @@ def authz(): yield authz() +@pytest.fixture +def authenticated_client(): + payload = { + "sub": "abc123@clients", + "scope": "retrieve:app customers:app add_customers:app", + } + return AuthenticatedServiceClient(jwt_payload=payload) + + +@pytest.fixture +def invalid_client_sub(): + payload = { + "sub": "invalid@clients", + "scope": "retrieve:app customers:app add_customers:app", + } + return AuthenticatedServiceClient(jwt_payload=payload) + + +@pytest.fixture +def invalid_client_scope(): + payload = { + "sub": "invalid@clients", + "scope": "foo:app bar:app", + } + return AuthenticatedServiceClient(jwt_payload=payload) + + def app_list(client, *args): return client.get(reverse("app-list")) @@ -75,6 +113,27 @@ def app_update(client, app, *args): ) +def app_by_name_detail(client, app, *args): + return client.get(reverse("apps-by-name-detail", kwargs={"name": app.name})) + + +def app_by_name_customers(client, app, *args): + return client.get( + reverse("apps-by-name-customers", kwargs={"name": app.name}), + query_params={"env_name": "test"}, + ) + + +def app_by_name_add_customers(client, app, *args): + data = {"email": "example@email.com"} + with patch("controlpanel.api.models.App.add_customers"): + return client.post( + reverse("apps-by-name-customers", kwargs={"name": app.name}), + data, + query_params={"env_name": "test"}, + ) + + def test_perm_rules_setup(): assert perm_exists("api.list_app") @@ -105,12 +164,25 @@ def test_authenticated_user_has_basic_perms(client, users): (app_delete, "app_admin", status.HTTP_403_FORBIDDEN), (app_create, "app_admin", status.HTTP_201_CREATED), (app_update, "app_admin", status.HTTP_200_OK), + (app_by_name_detail, "app_admin", status.HTTP_200_OK), + (app_by_name_detail, "authenticated_client", status.HTTP_200_OK), + (app_by_name_detail, "invalid_client_sub", status.HTTP_403_FORBIDDEN), + (app_by_name_detail, "invalid_client_scope", status.HTTP_403_FORBIDDEN), + (app_by_name_customers, "app_admin", status.HTTP_200_OK), + (app_by_name_customers, "authenticated_client", status.HTTP_200_OK), + (app_by_name_customers, "invalid_client_sub", status.HTTP_403_FORBIDDEN), + (app_by_name_customers, "invalid_client_scope", status.HTTP_403_FORBIDDEN), + (app_by_name_add_customers, "app_admin", status.HTTP_201_CREATED), + (app_by_name_add_customers, "authenticated_client", status.HTTP_201_CREATED), + (app_by_name_add_customers, "invalid_client_sub", status.HTTP_403_FORBIDDEN), + (app_by_name_add_customers, "invalid_client_scope", status.HTTP_403_FORBIDDEN), ], ) @pytest.mark.django_db -def test_permission(client, app, users, view, user, expected_status, authz): +def test_permission(app, users, view, user, expected_status, authz): u = users[user] - client.force_login(u) + client = APIClient() + client.force_authenticate(u) with patch("controlpanel.api.views.models.App.delete"): response = view(client, app) @@ -137,3 +209,14 @@ def test_apps_by_name_permission(client, app, users, view, user, expected_status response = view(client, app) assert response.status_code == expected_status + + +@pytest.mark.parametrize( + "sub, expected", [("abc123", True), ("abc123@clients", True), ("abc1234", False)] +) +def test_app_jwt_permissions_has_object_permissions(rf, authenticated_client, app, sub, expected): + authenticated_client.jwt_payload["sub"] = sub + request = rf.get(reverse("apps-by-name-customers", kwargs={"name": app.name})) + request.user = authenticated_client + + assert AppJwtPermissions().has_object_permission(request, None, app) is expected diff --git a/tests/api/test_auth0.py b/tests/api/test_auth0.py index 602615a3b..ebb869a7f 100644 --- a/tests/api/test_auth0.py +++ b/tests/api/test_auth0.py @@ -1,6 +1,6 @@ # Standard library import json -from unittest.mock import ANY, call, patch +from unittest.mock import ANY, MagicMock, call, patch # Third-party import pytest @@ -42,7 +42,7 @@ def fixture_users_200(ExtendedAuth0): def fixture_users_create(ExtendedAuth0): with patch.object(ExtendedAuth0.users, "create") as request: request.side_effect = [{"name": "create-testing-bob"}] - yield + yield request def test_get_all_with_more_than_100(ExtendedAuth0, fixture_users_200): @@ -51,23 +51,27 @@ def test_get_all_with_more_than_100(ExtendedAuth0, fixture_users_200): def test_search_first_match_by_name_exist(ExtendedAuth0, fixture_users_200): - user = ExtendedAuth0.users.search_first_match(dict(name="Test User 1")) + user = ExtendedAuth0.users.search_first_match({"name": "Test User 1"}) assert user["name"] == "Test User 1" def test_search_first_match_by_name_not(ExtendedAuth0, fixture_users_200): - user = ExtendedAuth0.users.search_first_match(dict(name="Different User")) + user = ExtendedAuth0.users.search_first_match({"name": "Different User"}) assert user is None def test_get_or_create_new(ExtendedAuth0, fixture_users_200, fixture_users_create): - user = ExtendedAuth0.users.get_or_create(dict(name="bob")) + user, created = ExtendedAuth0.users.get_or_create({"name": "bob"}) assert user["name"] == "create-testing-bob" + assert created is True + fixture_users_create.assert_called_once_with({"name": "bob"}) def test_get_or_create_existed(ExtendedAuth0, fixture_users_200, fixture_users_create): - user = ExtendedAuth0.users.search_first_match(dict(name="Test User 1")) + user, created = ExtendedAuth0.users.get_or_create({"name": "Test User 1"}) assert user["name"] == "Test User 1" + assert created is False + fixture_users_create.assert_not_called() @pytest.fixture @@ -595,3 +599,127 @@ def test_create_custom_connection_with_notallowed_error(ExtendedAuth0): }, ) connection_create.assert_called_once_with(ANY) + + +def test_setup_m2m_client(ExtendedAuth0): + client_name = "test_m2m_client" + client_id = "test_m2m_client_id" + scopes = ["test:scope"] + + with ( + patch.object(ExtendedAuth0.clients, "get_or_create") as client_create, + patch.object(ExtendedAuth0.client_grants, "create") as client_grants_create, + ): + client_create.return_value = ( + { + "client_id": client_id, + "name": client_name, + }, + True, + ) + ExtendedAuth0.setup_m2m_client(client_name, scopes=scopes) + + client_create.assert_called_once_with( + { + "name": client_name, + "app_type": "non_interactive", + "grant_types": ["client_credentials"], + } + ) + client_grants_create.assert_called_once_with( + body={ + "client_id": client_id, + "scope": scopes, + "audience": "test-audience", + } + ) + + +def test_setup_m2m_client_already_exists(ExtendedAuth0): + client_name = "test_m2m_client" + client_id = "test_m2m_client_id" + scopes = ["test:scope"] + + with ( + patch.object(ExtendedAuth0.clients, "get_or_create") as client_create, + patch.object(ExtendedAuth0.client_grants, "create") as client_grants_create, + ): + client_create.return_value = ( + { + "client_id": client_id, + "name": client_name, + }, + False, + ) + ExtendedAuth0.setup_m2m_client(client_name, scopes=scopes) + + client_create.assert_called_once_with( + { + "name": client_name, + "app_type": "non_interactive", + "grant_types": ["client_credentials"], + } + ) + client_grants_create.assert_not_called() + + +def test_setup_m2m_client_grant_error(ExtendedAuth0): + client_name = "test_m2m_client" + client_id = "test_m2m_client_id" + scopes = ["test:scope"] + + with ( + patch.object(ExtendedAuth0.clients, "get_or_create") as client_create, + patch.object(ExtendedAuth0.client_grants, "create") as client_grants_create, + patch.object(ExtendedAuth0.clients, "delete") as client_delete, + ): + client_create.return_value = ( + { + "client_id": client_id, + "name": client_name, + }, + True, + ) + client_grants_create.side_effect = exceptions.Auth0Error(400, 400, "Error") + + with pytest.raises(auth0.Auth0Error, match="400: Error"): + ExtendedAuth0.setup_m2m_client(client_name, scopes=scopes) + + client_create.assert_called_once_with( + { + "name": client_name, + "app_type": "non_interactive", + "grant_types": ["client_credentials"], + } + ) + client_grants_create.assert_called_once_with( + body={ + "client_id": client_id, + "scope": scopes, + "audience": "test-audience", + } + ) + client_delete.assert_called_once_with(client_id) + + +@pytest.mark.parametrize( + "side_effect, expected", + [ + (MagicMock(return_value=None), None), + (exceptions.Auth0Error(404, 404, "Error"), None), + (exceptions.Auth0Error(400, 400, "Error"), "400: Error"), + (exceptions.Auth0Error(401, 401, "Error"), "401: Error"), + (exceptions.Auth0Error(403, 403, "Error"), "403: Error"), + (exceptions.Auth0Error(429, 429, "Error"), "429: Error"), + ], +) +def test_rotate_m2m_client_secret(ExtendedAuth0, side_effect, expected): + + with patch.object(ExtendedAuth0.clients, "rotate_secret") as rotate_secret: + rotate_secret.side_effect = side_effect + + if expected is None: + assert ExtendedAuth0.rotate_m2m_client_secret("test_m2m_client_id") is None + else: + with pytest.raises(auth0.Auth0Error, match=expected): + ExtendedAuth0.rotate_m2m_client_secret("test_m2m_client_id") diff --git a/tests/api/views/test_app.py b/tests/api/views/test_app.py index aa9769e91..93d1132ef 100644 --- a/tests/api/views/test_app.py +++ b/tests/api/views/test_app.py @@ -102,6 +102,52 @@ def test_detail(client, app): assert set(userapp["user"]) == expected_fields +@pytest.fixture +def customer(): + return { + "email": "a.user@digital.justice.gov.uk", + "user_id": "email|5955f7ee86da0c1d55foobar", + "nickname": "a.user", + "name": "a.user@digital.justice.gov.uk", + "foo": "bar", + "baz": "bat", + } + + +@pytest.mark.parametrize("env_name", ["dev", "prod"]) +def test_app_by_name_get_customers(client, app, customer, env_name): + with patch("controlpanel.api.models.App.customer_paginated") as customer_paginated: + customer_paginated.return_value = {"total": 1, "users": [customer]} + + response = client.get( + reverse("apps-by-name-customers", kwargs={"name": app.name}), + query_params={"env_name": env_name}, + ) + assert response.status_code == status.HTTP_200_OK + app.customer_paginated.assert_called_once() + + expected_fields = { + "email", + "user_id", + "nickname", + "name", + } + assert response.data["results"] == [{field: customer[field] for field in expected_fields}] + + +@pytest.mark.parametrize("env_name", ["dev", "prod"]) +def test_app_by_name_add_customers(client, app, env_name): + emails = ["test1@example.com", "test2@example.com"] + data = {"email": ", ".join(emails)} + + with patch("controlpanel.api.models.App.add_customers") as add_customers: + url = reverse("apps-by-name-customers", kwargs={"name": app.name}) + response = client.post(f"{url}?env_name={env_name}", data=data) + + add_customers.assert_called_once_with(emails, env_name=env_name) + assert response.status_code == status.HTTP_201_CREATED + + def test_app_detail_by_name(client, app): response = client.get(reverse("apps-by-name-detail", (app.name,))) assert response.status_code == status.HTTP_200_OK diff --git a/tests/frontend/views/test_app.py b/tests/frontend/views/test_app.py index ae1f4fefc..6ed554c85 100644 --- a/tests/frontend/views/test_app.py +++ b/tests/frontend/views/test_app.py @@ -8,10 +8,10 @@ import requests from bs4 import BeautifulSoup from django.conf import settings -from django.contrib.messages import get_messages +from django.contrib.messages import Message, constants, get_messages from django.urls import reverse from model_bakery import baker -from pytest_django.asserts import assertQuerySetEqual +from pytest_django.asserts import assertMessages, assertQuerySetEqual from rest_framework import status # First-party/Local @@ -294,6 +294,22 @@ def set_textract(client, app, *args): return client.post(reverse("set-textract-app", kwargs=kwargs), data) +@patch("controlpanel.api.cluster.App.create_m2m_client") +def setup_m2m_client(client, app, *args): + kwargs = {"pk": app.id} + return client.post(reverse("create-m2m-client", kwargs=kwargs)) + + +def rotate_m2m_credentials(client, app, *args): + kwargs = {"pk": app.id} + return client.post(reverse("rotate-m2m-credentials", kwargs=kwargs)) + + +def delete_m2m_client(client, app, *args): + kwargs = {"pk": app.id} + return client.post(reverse("delete-m2m-client", kwargs=kwargs)) + + @pytest.mark.parametrize( "view,user,expected_status", [ @@ -345,6 +361,15 @@ def set_textract(client, app, *args): (set_textract, "superuser", status.HTTP_302_FOUND), (set_textract, "app_admin", status.HTTP_403_FORBIDDEN), (set_textract, "normal_user", status.HTTP_403_FORBIDDEN), + (setup_m2m_client, "superuser", status.HTTP_302_FOUND), + (setup_m2m_client, "app_admin", status.HTTP_302_FOUND), + (setup_m2m_client, "normal_user", status.HTTP_403_FORBIDDEN), + (rotate_m2m_credentials, "superuser", status.HTTP_302_FOUND), + (rotate_m2m_credentials, "app_admin", status.HTTP_302_FOUND), + (rotate_m2m_credentials, "normal_user", status.HTTP_403_FORBIDDEN), + (delete_m2m_client, "superuser", status.HTTP_302_FOUND), + (delete_m2m_client, "app_admin", status.HTTP_302_FOUND), + (delete_m2m_client, "normal_user", status.HTTP_403_FORBIDDEN), ], ) def test_permissions( @@ -845,3 +870,112 @@ def test_update_app_ip_allowlist_fails(app): ).exists() is False ) + + +@pytest.mark.parametrize("user", ["superuser", "app_admin"]) +def test_create_m2m_client_success(app, users, client, user): + user = users[user] + client.force_login(user) + url = reverse("create-m2m-client", kwargs={"pk": app.id}) + + with patch("controlpanel.api.cluster.App.create_m2m_client") as m2m_client: + m2m_client.return_value = { + "client_id": "test-client-id", + "client_secret": "test-client-secret", + } + response = client.post(url) + + m2m_client.assert_called_once() + assert response.status_code == 302 + assert response.url == reverse("manage-app", kwargs={"pk": app.id}) + assertMessages( + response, + [ + Message( + level=constants.SUCCESS, + message="Successfully created machine-to-machine client. Your client credentials are shown below, ensure to store them securely as you will not be able to view them again.", # noqa + ), + Message(level=constants.INFO, message="Client ID: test-client-id"), + Message(level=constants.INFO, message="Client Secret: test-client-secret"), + ], + ordered=True, + ) + + +@pytest.mark.parametrize("user", ["superuser", "app_admin"]) +def test_rotate_m2m_credentials_success(app, users, client, user): + user = users[user] + client.force_login(user) + url = reverse("rotate-m2m-credentials", kwargs={"pk": app.id}) + + with patch("controlpanel.api.cluster.App.rotate_m2m_client_secret") as m2m_client: + m2m_client.return_value = { + "client_id": "test-client-id", + "client_secret": "test-client-secret", + } + response = client.post(url) + + m2m_client.assert_called_once() + assert response.status_code == 302 + assert response.url == reverse("manage-app", kwargs={"pk": app.id}) + assertMessages( + response, + [ + Message( + level=constants.SUCCESS, + message="Successfully rotated machine-to-machine client secret. Your client ID and new client secret are shown below, ensure to store them securely as you will not be able to view them again.", # noqa + ), + Message(level=constants.INFO, message="Client ID: test-client-id"), + Message(level=constants.INFO, message="Client Secret: test-client-secret"), + ], + ordered=True, + ) + + +@pytest.mark.parametrize("user", ["superuser", "app_admin"]) +def test_rotate_m2m_credentials_fails(app, users, client, user): + user = users[user] + client.force_login(user) + url = reverse("rotate-m2m-credentials", kwargs={"pk": app.id}) + + with patch("controlpanel.api.cluster.App.rotate_m2m_client_secret") as m2m_client: + m2m_client.return_value = None + response = client.post(url) + + m2m_client.assert_called_once() + assert response.status_code == 302 + assert response.url == reverse("manage-app", kwargs={"pk": app.id}) + assertMessages( + response, + [ + Message( + level=constants.ERROR, + message="Failed to find a machine-to-machine client for this app, please try creating a new one.", # noqa + ), + ], + ordered=True, + ) + + +@pytest.mark.parametrize("user", ["superuser", "app_admin"]) +def test_delete_m2m_client_success(app, users, client, user): + user = users[user] + client.force_login(user) + url = reverse("delete-m2m-client", kwargs={"pk": app.id}) + + with patch("controlpanel.api.cluster.App.delete_m2m_client") as delete_m2m_client: + response = client.post(url) + + delete_m2m_client.assert_called_once() + assert response.status_code == 302 + assert response.url == reverse("manage-app", kwargs={"pk": app.id}) + assertMessages( + response, + [ + Message( + level=constants.SUCCESS, + message="Successfully deleted machine-to-machine client.", # noqa + ), + ], + ordered=True, + )