From 661095cc545750d674e6a34eeb93123155cbaf6b Mon Sep 17 00:00:00 2001
From: James Stott <158563996+jamesstottmoj@users.noreply.github.com>
Date: Fri, 18 Oct 2024 15:41:34 +0100
Subject: [PATCH] Embedding QS into Control Panel (#1350)
* Inital commit
* Added code from UI to control panel
* Code added to enable embedding of quicksight
* Removed call to check group as not required
* Bumped dependencies. Added new env vars to test settings
* Added page name to template
* made QS superuser only for now
* Removed bucket message on quicksight page. Added tests
---
controlpanel/api/aws.py | 30 +++++++++
controlpanel/api/rules.py | 1 +
controlpanel/frontend/jinja2/base.html | 6 ++
.../frontend/jinja2/govuk-frontend.html | 2 +-
controlpanel/frontend/jinja2/quicksight.html | 67 +++++++++++++++++++
controlpanel/frontend/urls.py | 1 +
controlpanel/frontend/views/__init__.py | 1 +
controlpanel/frontend/views/quicksight.py | 27 ++++++++
controlpanel/settings/common.py | 18 +++++
controlpanel/settings/development.py | 1 +
controlpanel/settings/test.py | 5 ++
requirements.txt | 4 +-
tests/api/test_aws.py | 23 ++++++-
tests/frontend/views/test_quicksight.py | 32 +++++++++
14 files changed, 214 insertions(+), 4 deletions(-)
create mode 100644 controlpanel/frontend/jinja2/quicksight.html
create mode 100644 controlpanel/frontend/views/quicksight.py
create mode 100644 tests/frontend/views/test_quicksight.py
diff --git a/controlpanel/api/aws.py b/controlpanel/api/aws.py
index 8398cb136..a5035f397 100644
--- a/controlpanel/api/aws.py
+++ b/controlpanel/api/aws.py
@@ -1140,11 +1140,41 @@ def delete_messages(self, queue, messages):
class AWSQuicksight(AWSService):
+ service_name = "quicksight"
+
def __init__(self, assume_role_name=None, profile_name=None, region_name=None):
super().__init__(assume_role_name, profile_name, region_name or "eu-west-1")
self.client = self.boto3_session.client("quicksight")
+ def get_embed_url(self, user):
+
+ if not user.justice_email:
+ return None
+
+ user_arn = arn(
+ service=self.service_name,
+ resource=f"user/default/{user.justice_email}",
+ region=settings.QUICKSIGHT_ACCOUNT_REGION,
+ account=settings.QUICKSIGHT_ACCOUNT_ID,
+ )
+
+ response = self.client.generate_embed_url_for_registered_user(
+ AwsAccountId=settings.QUICKSIGHT_ACCOUNT_ID,
+ UserArn=user_arn,
+ ExperienceConfiguration={
+ "QuickSightConsole": {
+ "InitialPath": "/start",
+ "FeatureConfigurations": {"StatePersistence": {"Enabled": True}},
+ },
+ },
+ AllowedDomains=settings.QUICKSIGHT_DOMAINS,
+ )
+ if response:
+ return response["EmbedUrl"]
+
+ return response
+
class AWSLakeFormation(AWSService):
diff --git a/controlpanel/api/rules.py b/controlpanel/api/rules.py
index 1293e4044..a52532aa2 100644
--- a/controlpanel/api/rules.py
+++ b/controlpanel/api/rules.py
@@ -60,6 +60,7 @@ def is_app_admin(user, obj):
add_perm("api.remove_app_bucket", is_authenticated & is_superuser) # TODO change to is_app_admin
add_perm("api.view_app_logs", is_authenticated & is_app_admin)
add_perm("api.manage_groups", is_authenticated & is_superuser)
+add_perm("api.quicksight_embed_access", 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.update_app_ip_allowlists", is_authenticated & is_app_admin)
diff --git a/controlpanel/frontend/jinja2/base.html b/controlpanel/frontend/jinja2/base.html
index edd2d1fc0..bef1c61db 100644
--- a/controlpanel/frontend/jinja2/base.html
+++ b/controlpanel/frontend/jinja2/base.html
@@ -122,6 +122,12 @@
"href": url("list-parameters"),
"active": page_name == "parameters",
},
+ {
+ "hide": not request.user.is_superuser,
+ "text": "QuickSight",
+ "href": url("quicksight"),
+ "active": page_name == "quicksight",
+ },
{
"hide": not request.user.is_superuser,
"text": "Groups",
diff --git a/controlpanel/frontend/jinja2/govuk-frontend.html b/controlpanel/frontend/jinja2/govuk-frontend.html
index a2874439e..c70491430 100644
--- a/controlpanel/frontend/jinja2/govuk-frontend.html
+++ b/controlpanel/frontend/jinja2/govuk-frontend.html
@@ -53,7 +53,7 @@
{% block main %}
{% block beforeContent %}{% endblock %}
-
+
{% block content %}{% endblock %}
diff --git a/controlpanel/frontend/jinja2/quicksight.html b/controlpanel/frontend/jinja2/quicksight.html
new file mode 100644
index 000000000..536e8e822
--- /dev/null
+++ b/controlpanel/frontend/jinja2/quicksight.html
@@ -0,0 +1,67 @@
+{% extends "base.html" %}
+
+{% set page_name = "quicksight" %}
+{% set page_title = "Quicksight" %}
+
+{% block container_class_names %}govuk-grid-column-full{% endblock container_class_names %}
+
+{% block content %}
+ {% if embed_url %}
+
+
+
+ {% else %}
+
+
Something went wrong, try refreshing the page
+
If the problem persists, please contact the AP support team.
+
+ {% endif %}
+{% endblock content %}
+
+{% block body_end %}
+
+
+{% endblock body_end %}
diff --git a/controlpanel/frontend/urls.py b/controlpanel/frontend/urls.py
index a53078ac2..073b045cd 100644
--- a/controlpanel/frontend/urls.py
+++ b/controlpanel/frontend/urls.py
@@ -260,4 +260,5 @@
name="create-parameter",
),
path("parameters/
/delete/", views.ParameterDelete.as_view(), name="delete-parameter"),
+ path("quicksight/", views.QuicksightView.as_view(), name="quicksight"),
]
diff --git a/controlpanel/frontend/views/__init__.py b/controlpanel/frontend/views/__init__.py
index 9dda7f2df..5249a30ef 100644
--- a/controlpanel/frontend/views/__init__.py
+++ b/controlpanel/frontend/views/__init__.py
@@ -80,6 +80,7 @@
IAMManagedPolicyList,
IAMManagedPolicyRemoveUser,
)
+from controlpanel.frontend.views.quicksight import QuicksightView
from controlpanel.frontend.views.release import (
ReleaseCreate,
ReleaseDelete,
diff --git a/controlpanel/frontend/views/quicksight.py b/controlpanel/frontend/views/quicksight.py
new file mode 100644
index 000000000..3cd4741f9
--- /dev/null
+++ b/controlpanel/frontend/views/quicksight.py
@@ -0,0 +1,27 @@
+# Standard library
+from typing import Any
+
+# Third-party
+from django.conf import settings
+from django.views.generic import TemplateView
+from rules.contrib.views import PermissionRequiredMixin
+
+# First-party/Local
+from controlpanel.api.aws import AWSQuicksight
+from controlpanel.oidc import OIDCLoginRequiredMixin
+
+
+class QuicksightView(OIDCLoginRequiredMixin, PermissionRequiredMixin, TemplateView):
+ template_name = "quicksight.html"
+ permission_required = "api.quicksight_embed_access"
+
+ def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
+ context = super().get_context_data(**kwargs)
+ profile_name = f"quicksight_user_{self.request.user.justice_email}"
+ context["broadcast_messages"] = None
+ context["embed_url"] = AWSQuicksight(
+ assume_role_name=settings.QUICKSIGHT_ASSUMED_ROLE,
+ profile_name=profile_name,
+ region_name=settings.QUICKSIGHT_ACCOUNT_REGION,
+ ).get_embed_url(user=self.request.user)
+ return context
diff --git a/controlpanel/settings/common.py b/controlpanel/settings/common.py
index 1b4618910..ac0aa9813 100644
--- a/controlpanel/settings/common.py
+++ b/controlpanel/settings/common.py
@@ -2,6 +2,7 @@
import os
import sys
from os.path import abspath, dirname, join
+from socket import gaierror, gethostbyname, gethostname
# Third-party
import structlog
@@ -248,6 +249,19 @@
# Whitelist values for the HTTP Host header, to prevent certain attacks
ALLOWED_HOSTS = [host for host in os.environ.get("ALLOWED_HOSTS", "").split() if host]
+
+# set this before adding the IP address below
+# TODO We may be able to set this in terraform instead, we should check this
+QUICKSIGHT_DOMAINS = []
+for host in ALLOWED_HOSTS:
+ prefix = "*" if host.startswith(".") else ""
+ QUICKSIGHT_DOMAINS.append(f"https://{prefix}{host}")
+
+try:
+ ALLOWED_HOSTS.append(gethostbyname(gethostname()))
+except gaierror:
+ pass
+
# Sets the X-XSS-Protection: 1; mode=block header
SECURE_BROWSER_XSS_FILTER = True
@@ -486,6 +500,10 @@
# -- AWS
AWS_DATA_ACCOUNT_ID = os.environ.get("AWS_DATA_ACCOUNT_ID")
+QUICKSIGHT_ACCOUNT_ID = os.environ.get("QUICKSIGHT_ACCOUNT_ID")
+QUICKSIGHT_ACCOUNT_REGION = os.environ.get("QUICKSIGHT_ACCOUNT_REGION")
+QUICKSIGHT_DOMAINS = os.environ.get("QUICKSIGHT_DOMAINS")
+QUICKSIGHT_ASSUMED_ROLE = os.environ.get("QUICKSIGHT_ASSUMED_ROLE")
# The EKS OIDC provider, referenced in user policies to allow service accounts
# to grant AWS permissions.
diff --git a/controlpanel/settings/development.py b/controlpanel/settings/development.py
index 99b4bce89..a16ba3c71 100644
--- a/controlpanel/settings/development.py
+++ b/controlpanel/settings/development.py
@@ -9,6 +9,7 @@
# Allow all hostnames to access the server
ALLOWED_HOSTS = ["localhost", "127.0.0.1", "0.0.0.0"]
+QUICKSIGHT_DOMAINS = ["http://localhost:8000"]
# Enable Django debug toolbar
if os.environ.get("ENABLE_DJANGO_DEBUG_TOOLBAR"):
diff --git a/controlpanel/settings/test.py b/controlpanel/settings/test.py
index 71ce706db..e1822d3ab 100644
--- a/controlpanel/settings/test.py
+++ b/controlpanel/settings/test.py
@@ -35,3 +35,8 @@
DPR_DATABASE_NAME = "test_database"
SQS_REGION = "eu-west-1"
USE_LOCAL_MESSAGE_BROKER = False
+
+QUICKSIGHT_ACCOUNT_ID = "123456789012"
+QUICKSIGHT_ACCOUNT_REGION = "eu-west-2"
+QUICKSIGHT_DOMAINS = "http://localhost:8000"
+QUICKSIGHT_ASSUMED_ROLE = "arn:aws:iam::123456789012:role/quicksight_test"
diff --git a/requirements.txt b/requirements.txt
index 81bae1e8c..a1bde805a 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -2,12 +2,12 @@ asgiref==3.8.1
auth0-python==4.7.1
authlib==1.3.1
beautifulsoup4==4.12.3
-boto3==1.35.24
+boto3==1.35.39
celery[sqs]==5.3.6
channels==4.0.0
channels-redis==4.2.0
daphne==4.1.2
-Django==5.0.4
+Django==5.1.2
django-crequest==2018.5.11
django-extensions==3.2.3
django-filter==24.1
diff --git a/tests/api/test_aws.py b/tests/api/test_aws.py
index c83740688..733b856fb 100644
--- a/tests/api/test_aws.py
+++ b/tests/api/test_aws.py
@@ -2,7 +2,7 @@
import hashlib
import json
import uuid
-from unittest.mock import MagicMock, call, patch
+from unittest.mock import MagicMock, Mock, call, patch
# Third-party
import pytest
@@ -1134,3 +1134,24 @@ def test_list_attached_policies_returns_list_of_policies(iam, roles, test_policy
assert len(policies) == 1
assert policies[0].arn == test_policy["Arn"]
+
+
+@pytest.fixture
+def quicksight_service():
+ yield aws.AWSQuicksight()
+
+
+def test_get_embed_url(quicksight_service):
+ """
+ Patching client as no way to get url from moto.
+ Should return some URL anyway
+ """
+
+ embedded_url = "https://embedded-url.com"
+ client = Mock()
+ client.generate_embed_url_for_registered_user.return_value = {"EmbedUrl": embedded_url}
+
+ with patch.object(quicksight_service, "client", client):
+ mock_user = Mock(email="user@email.com")
+ url = quicksight_service.get_embed_url(mock_user)
+ assert url == embedded_url
diff --git a/tests/frontend/views/test_quicksight.py b/tests/frontend/views/test_quicksight.py
new file mode 100644
index 000000000..0ad903a20
--- /dev/null
+++ b/tests/frontend/views/test_quicksight.py
@@ -0,0 +1,32 @@
+# Standard library
+from unittest.mock import patch
+
+# Third-party
+import botocore
+import pytest
+from django.conf import settings
+from django.urls import reverse
+from rest_framework import status
+
+# Original botocore _make_api_call function
+orig = botocore.client.BaseClient._make_api_call
+
+
+def quicksight(client):
+ return client.get(reverse("quicksight"))
+
+
+@pytest.mark.parametrize(
+ "view,user,expected_status",
+ [
+ (quicksight, "superuser", status.HTTP_200_OK),
+ (quicksight, "database_user", status.HTTP_403_FORBIDDEN),
+ (quicksight, "normal_user", status.HTTP_403_FORBIDDEN),
+ ],
+)
+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)
+ assert response.status_code == expected_status