Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update create user to expect users from Entra #1429

Merged
merged 8 commits into from
Jan 13, 2025
Merged
13 changes: 12 additions & 1 deletion controlpanel/api/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,17 @@ def show_webapp_data_link(self):

return self.userapps.filter(is_admin=True).exists()

@property
def is_github_user(self):
"""
Determine if the user was created via the github connection. We can use this to limit what
actions non-github users do within the AP.
This has been added specifically to allow CICA users to authenticate with EntraID in order
to access QuickSight in the AP. This is a temporary change, that should be removed once auth
with Entra is fully implemented.
"""
return self.auth0_id.startswith("github|")

def is_app_admin(self, app_id):
return (
self.userapps.filter(
Expand Down Expand Up @@ -161,7 +172,7 @@ def set_quicksight_access(self, enable):

def save(self, *args, **kwargs):
existing = User.objects.filter(pk=self.pk).first()
if not existing:
if not existing and self.is_github_user:
cluster.User(self).create()

already_superuser = existing and existing.is_superuser
Expand Down
24 changes: 15 additions & 9 deletions controlpanel/api/rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,13 @@ def is_app_admin(user, obj):
return False


add_perm("api.list_app", is_authenticated)
add_perm("api.create_app", is_authenticated)
@predicate
def is_github_user(user):
return user.is_github_user


add_perm("api.list_app", is_authenticated & is_github_user)
add_perm("api.create_app", is_authenticated & is_github_user)
add_perm("api.retrieve_app", is_authenticated & is_app_admin)
add_perm("api.update_app", is_authenticated & is_app_admin)
add_perm("api.destroy_app", is_authenticated & is_superuser)
Expand Down Expand Up @@ -126,8 +131,8 @@ def is_database_admin(user):
return user.is_database_admin


add_perm("api.list_s3bucket", is_authenticated)
add_perm("api.create_s3bucket", is_authenticated)
add_perm("api.list_s3bucket", is_authenticated & is_github_user)
add_perm("api.create_s3bucket", is_authenticated & is_github_user)
add_perm("api.retrieve_s3bucket", is_authenticated & has_bucket_access)
add_perm("api.update_s3bucket", is_authenticated & has_bucket_write_access)
add_perm("api.destroy_s3bucket", is_authenticated & is_bucket_admin)
Expand Down Expand Up @@ -167,7 +172,7 @@ def is_self(user, other):
add_perm("api.update_tool_release", is_authenticated & is_superuser)


add_perm("api.list_apps3bucket", is_authenticated)
add_perm("api.list_apps3bucket", is_authenticated & is_github_user)
add_perm("api.create_apps3bucket", is_authenticated & is_superuser)
add_perm(
"api.retrieve_apps3bucket",
Expand All @@ -180,14 +185,14 @@ def is_self(user, other):
add_perm("api.destroy_apps3bucket", is_authenticated & is_superuser)


add_perm("api.list_users3bucket", is_authenticated)
add_perm("api.list_users3bucket", is_authenticated & is_github_user)
add_perm("api.create_users3bucket", is_authenticated & is_bucket_admin)
add_perm("api.retrieve_users3bucket", is_authenticated & is_bucket_admin)
add_perm("api.update_users3bucket", is_authenticated & is_bucket_admin)
add_perm("api.destroy_users3bucket", is_authenticated & is_bucket_admin)


add_perm("api.list_tool", is_authenticated)
add_perm("api.list_tool", is_authenticated & is_github_user)
add_perm("api.create_tool", is_authenticated & is_superuser)
add_perm("api.retrieve_tool", is_authenticated & is_superuser)
add_perm("api.update_tool", is_authenticated & is_superuser)
Expand Down Expand Up @@ -216,15 +221,16 @@ def is_owner(user, obj):
return False


# TODO these are not used and should be removed
add_perm("api.list_deployment", is_authenticated)
add_perm("api.create_deployment", is_authenticated)
add_perm("api.retrieve_deployment", is_authenticated & is_owner)
add_perm("api.update_deployment", is_authenticated & is_owner)
add_perm("api.destroy_deployment", is_authenticated & is_owner)


add_perm("api.list_parameter", is_authenticated)
add_perm("api.create_parameter", is_authenticated)
add_perm("api.list_parameter", is_authenticated & is_github_user)
add_perm("api.create_parameter", is_authenticated & is_github_user)
add_perm("api.retrieve_parameter", is_authenticated & is_owner)
add_perm("api.update_parameter", is_authenticated & is_owner)
add_perm("api.destroy_parameter", is_authenticated & is_owner)
Expand Down
4 changes: 4 additions & 0 deletions controlpanel/frontend/jinja2/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -99,11 +99,13 @@
"href": url("admin:index"),
},
{
"hide": not request.user.is_github_user,
"text": "Analytical tools",
"href": url("list-tools"),
"active": page_name == "tools",
},
{
"hide": not request.user.is_github_user,
"text": "Warehouse data",
"href": url("list-warehouse-datasources"),
"active": page_name == "warehouse-datasource-list",
Expand All @@ -115,6 +117,7 @@
"active": page_name == "webapp-datasource-list",
},
{
"hide": not request.user.is_github_user,
"text": "Webapps",
"href": url("list-apps"),
"active": page_name == "webapps",
Expand All @@ -126,6 +129,7 @@
"active": page_name == "databases",
},
{
"hide": not request.user.is_github_user,
"text": "Parameters",
"href": url("list-parameters"),
"active": page_name == "parameters",
Expand Down
4 changes: 4 additions & 0 deletions controlpanel/frontend/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,10 @@ def get(self, request, *args, **kwargs):
if settings.features.justice_auth.enabled and not request.user.justice_email:
return super().get(request, *args, **kwargs)

# this is temporary change for users authorising via Entra, specifically CICA users
if not request.user.is_github_user:
return HttpResponseRedirect(reverse("help"))

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

Expand Down
31 changes: 23 additions & 8 deletions controlpanel/oidc.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,26 @@ class OIDCSubAuthenticationBackend(OIDCAuthenticationBackend):
"""

def create_user(self, claims):
return User.objects.create(
pk=claims.get("sub"),
username=claims.get(settings.OIDC_FIELD_USERNAME),
email=claims.get(settings.OIDC_FIELD_EMAIL),
name=claims.get(settings.OIDC_FIELD_NAME),
)
user_details = {
"pk": claims.get("sub"),
"username": claims.get(settings.OIDC_FIELD_USERNAME),
"email": claims.get(settings.OIDC_FIELD_EMAIL),
"name": self.normalise_name(claims.get(settings.OIDC_FIELD_NAME)),
}
email_domain = user_details["email"].split("@")[-1]
if email_domain in settings.JUSTICE_EMAIL_DOMAINS:
user_details["justice_email"] = user_details["email"]

return User.objects.create(**user_details)

def normalise_name(self, name):
"""
Normalise name to be in the format "Firstname Lastname"
"""
if "," in name:
parts = [part.strip() for part in name.split(",")]
name = " ".join(reversed(parts))
return name

def update_user(self, user, claims):
# Update the non-key information to sync the user's info
Expand All @@ -44,8 +58,9 @@ def update_user(self, user, claims):
if user.email != claims.get(settings.OIDC_FIELD_EMAIL):
user.email = claims.get(settings.OIDC_FIELD_EMAIL)
user.save()
if user.name != claims.get(settings.OIDC_FIELD_NAME):
user.name = claims.get(settings.OIDC_FIELD_NAME)
normalised_name = self.normalise_name(claims.get(settings.OIDC_FIELD_NAME))
if user.name != normalised_name:
user.name = normalised_name
user.save()
return user

Expand Down
2 changes: 2 additions & 0 deletions controlpanel/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,8 @@
OIDC_FIELD_USERNAME = "nickname"
OIDC_STORE_ID_TOKEN = True

JUSTICE_EMAIL_DOMAINS = ["justice.gov.uk", "cica.justice.gov.uk"]

# Auth0
AUTH0 = {
"client_id": OIDC_RP_CLIENT_ID,
Expand Down
4 changes: 2 additions & 2 deletions tests/api/filters/test_app_filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@

class AppFilterTest(APITestCase):
def setUp(self):
self.superuser = baker.make("api.User", is_superuser=True)
self.app_admin = baker.make("api.User", is_superuser=False)
self.superuser = baker.make("api.User", auth0_id="github|superuser", is_superuser=True)
self.app_admin = baker.make("api.User", auth0_id="github|user", is_superuser=False)
self.app_1 = baker.make("api.App", name="App 1")
self.app_2 = baker.make("api.App", name="App 2")
baker.make("api.UserApp", user=self.app_admin, app=self.app_1, is_admin=True)
Expand Down
6 changes: 3 additions & 3 deletions tests/api/filters/test_users3bucket_filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@

class UserS3BucketFilterTest(APITestCase):
def setUp(self):
self.superuser = baker.make("api.User", is_superuser=True)
self.normal_user = baker.make("api.User", is_superuser=False)
self.other_user = baker.make("api.User", is_superuser=False)
self.superuser = baker.make("api.User", auth0_id="github|superuser", is_superuser=True)
self.normal_user = baker.make("api.User", auth0_id="github|user", is_superuser=False)
self.other_user = baker.make("api.User", auth0_id="github|other_user", is_superuser=False)

self.s3bucket_1 = baker.make("api.S3Bucket", name="test-bucket-1")
self.s3bucket_2 = baker.make("api.S3Bucket", name="test-bucket-2")
Expand Down
21 changes: 21 additions & 0 deletions tests/api/models/test_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,24 @@ def test_set_quicksight_access(users, enable):
policy=cluster.User.QUICKSIGHT_POLICY_NAME,
attach=enable,
)


@pytest.mark.parametrize(
"auth0_id, expected",
[
("github|user_1", True),
("github|user_2", True),
("entra|user_1", False),
("other|user_2", False),
],
)
def test_user_is_github_user(auth0_id, expected):
user = User(auth0_id=auth0_id)
assert user.is_github_user == expected


def test_non_github_user_not_provisioned():
user = User.objects.create(auth0_id="not_github|user_1")
with patch.object(cluster.User, "create") as mock_create:
user.save()
mock_create.assert_not_called()
2 changes: 1 addition & 1 deletion tests/api/views/test_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]) == 6


def test_detail(client, users):
Expand Down
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ def users(
is_database_admin=True,
),
"quicksight_user": quicksight_user,
"non_tool_user": baker.make("api.User"),
}


Expand Down
2 changes: 1 addition & 1 deletion tests/frontend/views/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ def github_api_token():
def users(users):
users.update(
{
"app_admin": baker.make("api.User", username="app_admin"),
"app_admin": baker.make("api.User", auth0_id="github|user", username="app_admin"),
}
)
return users
Expand Down
2 changes: 1 addition & 1 deletion tests/frontend/views/test_app_variables.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
def users(users):
users.update(
{
"owner": baker.make("api.User", username="owner"),
"owner": baker.make("api.User", auth0_id="github|user", username="owner"),
}
)
return users
Expand Down
8 changes: 6 additions & 2 deletions tests/frontend/views/test_datasource.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,12 @@ def enable_db_for_all_tests(db):
def users(users):
users.update(
{
"bucket_viewer": baker.make("api.User", username="bucket_viewer"),
"bucket_admin": baker.make("api.User", username="bucket_admin"),
"bucket_viewer": baker.make(
"api.User", auth0_id="github|bucket_viewer", username="bucket_viewer"
),
"bucket_admin": baker.make(
"api.User", auth0_id="github|bucket_admin", username="bucket_admin"
),
}
)
return users
Expand Down
11 changes: 11 additions & 0 deletions tests/frontend/views/test_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,17 @@ def test_with_justice_email(self, client, users):
assert response.status_code == 302
assert response.url == reverse("list-tools")

def test_not_tool_user(self, client, users):
user = users["non_tool_user"]
user.justice_email = "[email protected]"
user.save()
client.force_login(user)

response = client.get("/")

assert response.status_code == 302
assert response.url == reverse("help")


class TestPost:

Expand Down
2 changes: 1 addition & 1 deletion tests/frontend/views/test_parameter.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
def users(users):
users.update(
{
"owner": baker.make("api.User", username="owner"),
"owner": baker.make("api.User", auth0_id="github|user", username="owner"),
}
)
return users
Expand Down
2 changes: 1 addition & 1 deletion tests/frontend/views/test_secrets.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ def github_api_token():
def users(users):
users.update(
{
"app_admin": baker.make("api.User", username="app_admin"),
"app_admin": baker.make("api.User", auth0_id="github|user", username="app_admin"),
}
)
return users
Expand Down
2 changes: 1 addition & 1 deletion tests/frontend/views/test_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ def test_permission(client, users, view, user, expected_status):
@pytest.mark.parametrize(
"view,user,expected_count",
[
(list, "superuser", 5),
(list, "superuser", 6),
],
)
def test_list(client, users, view, user, expected_count):
Expand Down
29 changes: 27 additions & 2 deletions tests/test_oidc.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
# Standard library
from unittest.mock import Mock
from unittest.mock import Mock, patch

# Third-party
import pytest
from django.conf import settings

# First-party/Local
from controlpanel.oidc import StateMismatchHandler
from controlpanel.oidc import OIDCSubAuthenticationBackend, StateMismatchHandler


@pytest.mark.parametrize(
Expand All @@ -24,3 +25,27 @@ def test_success_url(users, email, success_url):
view.request = request
view.user = user
assert view.success_url == success_url


@pytest.mark.django_db
@pytest.mark.parametrize(
"email, name, expected_name, expected_justice_email",
[
("[email protected]", "User, Test", "Test User", None),
("[email protected]", "Test User", "Test User", None),
("[email protected]", "User, Test", "Test User", "[email protected]"),
("[email protected]", "Test User", "Test User", "[email protected]"),
],
)
def test_create_user(email, name, expected_name, expected_justice_email):
with patch("controlpanel.api.cluster.User.create"):
user = OIDCSubAuthenticationBackend().create_user(
{
"sub": "123",
settings.OIDC_FIELD_USERNAME: "testuser",
settings.OIDC_FIELD_EMAIL: email,
settings.OIDC_FIELD_NAME: name,
}
)
assert user.name == expected_name
assert user.justice_email == expected_justice_email
Loading