Skip to content

Commit

Permalink
merge main into branch
Browse files Browse the repository at this point in the history
  • Loading branch information
jamesstottmoj committed Apr 4, 2024
2 parents c27b801 + 12bea09 commit 86fd452
Show file tree
Hide file tree
Showing 20 changed files with 362 additions and 21 deletions.
3 changes: 2 additions & 1 deletion .github/workflows/test-and-push-docker-image.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ name: Run tests and push Docker image on success
branches: [main]
pull_request:
release:
types: [published]

jobs:
test-and-push:
Expand Down Expand Up @@ -38,7 +39,7 @@ jobs:
uses: aws-actions/amazon-ecr-login@v1
with:
registries: 593291632749

- name: Prep Tags
id: prep
run: |
Expand Down
17 changes: 17 additions & 0 deletions controlpanel/api/migrations/0036_user_justice_email.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Generated by Django 4.2.7 on 2024-03-26 12:21

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("api", "0035_user_is_bedrock_enabled"),
]

operations = [
migrations.AddField(
model_name="user",
name="justice_email",
field=models.EmailField(blank=True, max_length=254, null=True, unique=True),
),
]
1 change: 1 addition & 0 deletions controlpanel/api/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class User(AbstractUser):
default=VOID,
)
is_bedrock_enabled = models.BooleanField(default=False)
justice_email = models.EmailField(blank=True, null=True, unique=True)

REQUIRED_FIELDS = ["email", "auth0_id"]

Expand Down
3 changes: 3 additions & 0 deletions controlpanel/api/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ class S3BucketPermissions(RulesBasePermissions):
class UserPermissions(RulesBasePermissions):
resource = "user"

def has_object_permission(self, request, view, obj):
return request.user.is_superuser


class AppS3BucketPermissions(RulesBasePermissions):
resource = "apps3bucket"
Expand Down
28 changes: 28 additions & 0 deletions controlpanel/frontend/jinja2/justice_email.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{% extends "base.html" %}

{% set page_name = "home" %}
{% set hide_nav = True %}
{% set page_title = "Hello " ~ ( request.user.name if request.user ) %}

{% block content %}

<div class="govuk-grid-row">
<div class="govuk-grid-column-two-thirds">
<h1 class="govuk-heading-xl">Authenticate with your Justice identity</h1>
<p class="govuk-body-l">As part of upcoming work to offer new tools and services, all Analytical Platform will need to authenticate with their Justice identity so that we can store your @justice.gov.uk email address.</p>
<p class="govuk-body">You will need to complete authentication by 30th April 2024. If you do not currently have a @justice.gov.uk email address, <a href="#" class="govuk-link">see our guidance on requesting one.</a></p>
<div class="govuk-button-group">
<form method="POST" action=".">
{{ csrf_input }}
<button type="submit" class="govuk-button" data-module="govuk-button">
Authenticate with Justice identity
</button>
<a class="govuk-button govuk-button--secondary" href="{{ url('list-tools') }}">
Skip for now
</a>
</form>
</div>
</div>
</div>

{% endblock %}
2 changes: 1 addition & 1 deletion controlpanel/frontend/jinja2/webapp-create.html
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ <h1 class="govuk-heading-xl">{{ page_title }}</h1>

<div class="govuk-form-group" id="container-element">

<label class="govuk-label govuk-label--m" for="display_result_repo">Github repository
<label class="govuk-label govuk-label--m" for="display_result_repo">Github repository URL
</label>

{% set error_repo_msg = form.errors.get("repo_url") %}
Expand Down
2 changes: 1 addition & 1 deletion controlpanel/frontend/jinja2/webapp-list.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ <h1 class="govuk-heading-xl">{{ page_title }}</h1>
{% if request.user.has_perm('api.list_app') %}
{{ app_list(apps, request.user) }}

{% if request.user.is_superuser and request.user.has_perm('api.create_app') %}
{% if request.user.has_perm('api.create_app') %}
<p class="govuk-body">
<a class="govuk-button" href="{{ url('create-app') }}">
Register an app
Expand Down
1 change: 1 addition & 0 deletions controlpanel/frontend/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

urlpatterns = [
path("", views.IndexView.as_view(), name="index"),
path("oidc/entraid/auth/", views.EntraIdAuthView.as_view(), name="entraid-auth"),
path("oidc/logout/", views.LogoutView.as_view(), name="oidc_logout"),
path("datasources/", views.AdminBucketList.as_view(), name="list-all-datasources"),
path(
Expand Down
57 changes: 46 additions & 11 deletions controlpanel/frontend/views/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# Standard library
from django.conf import settings
# Third-party
from django.http import HttpResponseRedirect
from django.urls import reverse
Expand All @@ -6,6 +8,7 @@

# First-party/Local
from controlpanel.frontend.views.accessibility import Accessibility
from controlpanel.frontend.views.auth import EntraIdAuthView

# isort: off
from controlpanel.frontend.views.app import (
Expand Down Expand Up @@ -69,33 +72,65 @@
ReleaseList,
)
from controlpanel.frontend.views.reset import ResetHome
from controlpanel.frontend.views.task import TaskList
from controlpanel.frontend.views.tool import RestartTool, ToolList
from controlpanel.frontend.views.user import (
EnableBedrockUser,
ResetMFA,
SetSuperadmin,
EnableBedrockUser,
UserDelete,
UserDetail,
UserList,
)
from controlpanel.oidc import OIDCLoginRequiredMixin
from controlpanel.frontend.views.task import TaskList
from controlpanel.oidc import OIDCLoginRequiredMixin, get_code_challenge, oauth


class IndexView(OIDCLoginRequiredMixin, TemplateView):
template_name = "home.html"
http_method_names = ["get", "post"]

def get(self, request):
def get_template_names(self):
"""
Returns the template to instruct users to authenticate with their Justice
account, unless this has already been captured.
"""
if not self.request.user.justice_email:
return ["justice_email.html"]

return [self.template_name]

def get(self, request, *args, **kwargs):
"""
If the user is a superuser display the home page (containing useful
admin related links). Otherwise, redirect the user to the list of the
tools they currently have available on the platform.
If the user has not authenticated with their Justice account, displays page to
ask them to authenticate, to allow us to capture their email address.
If their Justice email has been captured, normal users are redirected to their
tools. Superusers are displayed the home page (containing useful
admin related links).
"""

if request.user.is_superuser:
return super().get(request)
else:
# Redirect to the tools page.
return HttpResponseRedirect(reverse("list-tools"))
return super().get(request, *args, **kwargs)

# TODO add feature request check
if settings.features.justice_auth.enabled and not request.user.justice_email:
return super().get(request, *args, **kwargs)

# Redirect to the tools page.
return HttpResponseRedirect(reverse("list-tools"))

def post(self, request, *args, **kwargs):
"""
Redirects user to authenticate with Azure EntraID.
"""
if not settings.features.justice_auth.enabled and not request.user.is_superuser:
return self.http_method_not_allowed(request, *args, **kwargs)

redirect_uri = request.build_absolute_uri(reverse("entraid-auth"))
return oauth.azure.authorize_redirect(
request,
redirect_uri,
code_challenge=get_code_challenge(),
)


class LogoutView(OIDCLogoutView):
Expand Down
60 changes: 60 additions & 0 deletions controlpanel/frontend/views/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Standard library

# Third-party
import sentry_sdk
from authlib.integrations.django_client import OAuthError
from django.conf import settings
from django.contrib import messages
from django.http import HttpResponseRedirect, Http404
from django.urls import reverse
from django.views import View

# First-party/Local
from controlpanel.oidc import OIDCLoginRequiredMixin, oauth


class EntraIdAuthView(OIDCLoginRequiredMixin, View):
"""
This view is used as the callback after a user authenticates with their Justice
identity via Azure EntraID, in order to capture a users Justice email address.
"""
http_method_names = ["get"]

def _get_access_token(self):
"""
Attempts to valiate and return the access token
"""
try:
token = oauth.azure.authorize_access_token(self.request)
except OAuthError as error:
sentry_sdk.capture_exception(error)
token = None
return token

def get(self, request, *args, **kwargs):
"""
Attempts to retrieve the auth token, and update the user.
"""
if not settings.features.justice_auth.enabled and not request.user.is_superuser:
raise Http404()

token = self._get_access_token()
if not token:
messages.error(request, "Something went wrong, please try again")
return HttpResponseRedirect(reverse("index"))

self.update_user(token=token)
messages.success(
request=request,
message=f"Successfully authenticated with your email {request.user.justice_email}",
)
return HttpResponseRedirect(reverse("index"))

def update_user(self, token):
"""
Update user with details from the ID token returned by the provided EntraID
access token
"""
email = token["userinfo"]["email"]
self.request.user.justice_email = email
self.request.user.save()
4 changes: 4 additions & 0 deletions controlpanel/frontend/views/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,10 @@ def get_context_data(self, *args, **kwargs):
tools_info = self._retrieve_detail_tool_info(
user, context["tools"], charts_info
)
if 'visual-studio-code' in tools_info:
url = tools_info['visual-studio-code']['url']
tools_info['visual-studio-code']['url'] = f"{url}?folder=/home/analyticalplatform/workspace"

self._add_deployed_charts_info(tools_info, user, id_token, charts_info)
context["tools_info"] = tools_info
return context
Expand Down
17 changes: 17 additions & 0 deletions controlpanel/oidc.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
# Standard library
import base64
import hashlib
from urllib.parse import urlencode

# Third-party
import structlog
from authlib.common.security import generate_token
from authlib.integrations.django_client import OAuth
from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import SuspiciousOperation
Expand Down Expand Up @@ -96,3 +100,16 @@ def dispatch(self, request, *args, **kwargs):
if token_expiry_seconds and current_seconds > token_expiry_seconds:
return self.handle_no_permission()
return super().dispatch(request, *args, **kwargs)


def get_code_challenge():
code_verifier = generate_token(64)
digest = hashlib.sha256(code_verifier.encode()).digest()
return base64.urlsafe_b64encode(digest).rstrip(b"=").decode()


oauth = OAuth()
oauth.register(
name="azure",
**settings.AUTHLIB_OAUTH_CLIENTS["azure"]
)
20 changes: 20 additions & 0 deletions controlpanel/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,26 @@
# The audience for Control Panel RESTful APIs
OIDC_CPANEL_API_AUDIENCE = os.environ.get("OIDC_CPANEL_API_AUDIENCE")

# For authentication with EntraID
AZURE_TENANT_ID = os.environ.get("AZURE_TENANT_ID")
AZURE_OP_CONF_URL = f"https://login.microsoftonline.com/{AZURE_TENANT_ID}/v2.0/.well-known/openid-configuration"
AZURE_RP_SCOPES = "openid email profile"
AZURE_CODE_CHALLENGE_METHOD = os.environ.get("AZURE_CODE_CHALLENGE_METHOD", "S256")
AUTHLIB_OAUTH_CLIENTS = {
"azure": {
"client_id": os.environ.get("AZURE_CLIENT_ID"),
# TODO client_secret is not strictly required but would be better to use
"server_metadata_url": AZURE_OP_CONF_URL,
"client_kwargs": {
"scope": AZURE_RP_SCOPES,
"response_type": "code",
"token_endpoint_auth_method": "none",
"code_challenge_method": AZURE_CODE_CHALLENGE_METHOD,
},

}
}

# -- Security

SECRET_KEY = os.environ.get("SECRET_KEY", "change-me")
Expand Down
1 change: 0 additions & 1 deletion controlpanel/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,4 @@
path("metrics", exports.ExportToDjangoView, name="prometheus-django-metrics"),
]


urlpatterns += staticfiles_urlpatterns()
2 changes: 1 addition & 1 deletion requirements.dev.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
black==24.2.0
black==24.3.0
django-debug-toolbar==4.3.0
django-debug-toolbar-requests==1.0.5
django-elasticsearch-debug-toolbar==3.0.2
Expand Down
7 changes: 4 additions & 3 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
asgiref==3.7.2
auth0-python==4.7.0
authlib==1.3.0
beautifulsoup4==4.12.3
boto3==1.34.41
boto3==1.34.64
celery[sqs]==5.3.6
channels==4.0.0
channels-redis==4.2.0
daphne==4.1.0
Django==5.0.2
django-crequest==2018.5.11
django-extensions==3.2.3
django-filter==23.5
django-filter==24.1
django-prometheus==2.3.1
django-redis==5.4.0
django-simple-history==3.4.0
Expand All @@ -35,4 +36,4 @@ rules==3.3
sentry-sdk==1.40.5
slackclient==2.9.4
urllib3==2.0.7
uvicorn[standard]==0.27.1
uvicorn[standard]==0.28.0
4 changes: 4 additions & 0 deletions settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ enabled_features:
_HOST_dev: false
_HOST_prod: false
_HOST_alpha: false
justice_auth:
_DEFAULT: false
_HOST_dev: false
_HOST_test: true


AWS_SERVICE_URL:
Expand Down
6 changes: 4 additions & 2 deletions tests/api/permissions/test_user_permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,14 @@ def auth0():
(user_update, "superuser", status.HTTP_200_OK),
(user_list, "normal_user", status.HTTP_403_FORBIDDEN),
(user_detail, "normal_user", status.HTTP_403_FORBIDDEN),
(user_own_detail, "normal_user", status.HTTP_200_OK),
(user_own_detail, "superuser", status.HTTP_200_OK),
(user_own_detail, "normal_user", status.HTTP_403_FORBIDDEN),
(user_delete, "normal_user", status.HTTP_403_FORBIDDEN),
(user_delete_self, "normal_user", status.HTTP_403_FORBIDDEN),
(user_create, "normal_user", status.HTTP_403_FORBIDDEN),
(user_update, "normal_user", status.HTTP_403_FORBIDDEN),
(user_update_self, "normal_user", status.HTTP_200_OK),
(user_update_self, "superuser", status.HTTP_200_OK),
(user_update_self, "normal_user", status.HTTP_403_FORBIDDEN),
],
)
@pytest.mark.django_db
Expand Down
Loading

0 comments on commit 86fd452

Please sign in to comment.