Skip to content

Commit

Permalink
Embedding QS into Control Panel (#1350)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
jamesstottmoj authored Oct 18, 2024
1 parent 37e7056 commit 661095c
Show file tree
Hide file tree
Showing 14 changed files with 214 additions and 4 deletions.
30 changes: 30 additions & 0 deletions controlpanel/api/aws.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Expand Down
1 change: 1 addition & 0 deletions controlpanel/api/rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions controlpanel/frontend/jinja2/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion controlpanel/frontend/jinja2/govuk-frontend.html
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@

{% block main %}
{% block beforeContent %}{% endblock %}
<div class="govuk-width-container">
<div class="{% block container_class_names %}govuk-width-container{% endblock container_class_names %}">
<main class="govuk-main-wrapper {{ mainClasses or "" }}" id="main-content" role="main">
{% block content %}{% endblock %}
</main>
Expand Down
67 changes: 67 additions & 0 deletions controlpanel/frontend/jinja2/quicksight.html
Original file line number Diff line number Diff line change
@@ -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 %}
<body onload="embedConsole()">
<div id="experience-container"></div>
</body>
{% else %}
<div class="govuk-width-container">
<h2 class="govuk-heading-m">Something went wrong, try refreshing the page</h2>
<p class="govuk-body">If the problem persists, please contact the AP support team.</p>
</div>
{% endif %}
{% endblock content %}

{% block body_end %}
<script src="https://unpkg.com/[email protected]/dist/quicksight-embedding-js-sdk.min.js"></script>
<script type="text/javascript">
const embedConsole = async() => {
const {
createEmbeddingContext,
} = QuickSightEmbedding;

const embeddingContext = await createEmbeddingContext({
onChange: (changeEvent, metadata) => {
console.log('Context received a change', changeEvent, metadata);
},
});

const frameOptions = {
url: "{{ embed_url|safe }}", // replace this value with the url generated via embedding API
container: '#experience-container',
height: "700px",
width: "100%",
onChange: (changeEvent, metadata) => {
switch (changeEvent.eventName) {
case 'FRAME_MOUNTED': {
console.log("Do something when the experience frame is mounted.");
break;
}
case 'FRAME_LOADED': {
console.log("Do something when the experience frame is loaded.");
break;
}
}
},
};

const contentOptions = {
onMessage: async (messageEvent, experienceMetadata) => {
switch (messageEvent.eventName) {
case 'ERROR_OCCURRED': {
console.log("Do something when the embedded experience fails loading.");
break;
}
}
}
};
const embeddedConsoleExperience = await embeddingContext.embedConsole(frameOptions, contentOptions);
};
</script>
{% endblock body_end %}
1 change: 1 addition & 0 deletions controlpanel/frontend/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,4 +260,5 @@
name="create-parameter",
),
path("parameters/<int:pk>/delete/", views.ParameterDelete.as_view(), name="delete-parameter"),
path("quicksight/", views.QuicksightView.as_view(), name="quicksight"),
]
1 change: 1 addition & 0 deletions controlpanel/frontend/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
IAMManagedPolicyList,
IAMManagedPolicyRemoveUser,
)
from controlpanel.frontend.views.quicksight import QuicksightView
from controlpanel.frontend.views.release import (
ReleaseCreate,
ReleaseDelete,
Expand Down
27 changes: 27 additions & 0 deletions controlpanel/frontend/views/quicksight.py
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions controlpanel/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions controlpanel/settings/development.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand Down
5 changes: 5 additions & 0 deletions controlpanel/settings/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
4 changes: 2 additions & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 22 additions & 1 deletion tests/api/test_aws.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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="[email protected]")
url = quicksight_service.get_embed_url(mock_user)
assert url == embedded_url
32 changes: 32 additions & 0 deletions tests/frontend/views/test_quicksight.py
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 661095c

Please sign in to comment.