Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions common/auth/cognito_jwt/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,11 @@ class JSONWebTokenAuthentication(BaseAuthentication):
"""

def authenticate(self, request):
"""Entrypoint for Django Rest Framework"""
"""
The JWT token has arrived at an API endpoint and the journey starts here.
Entrypoint for the Django Rest Framework.
"""

jwt_token = self.get_jwt_token(request)
if jwt_token is None:
return None
Expand All @@ -46,7 +50,8 @@ def authenticate(self, request):
try:
token_validator = self.get_token_validator(request)
jwt_payload = token_validator.validate(jwt_token)
except TokenError:
except TokenError as error:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Arguably unneccessary? It's already throwing an error and I'm worried there's a risk that some permission set info or user info could be spat out as part of the error log

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dandammann Please can we avoid including work that is not related to the ticket in the same PR.
It makes it harder to review as reviewers have to figure out which code changes are related to the ticket, and which are achieving some other unknown goal.
If you find tech debt that need to be improved In existing code (and it's a quick fix), then these should be added to a seperate PR with a description that explains it and then it can be reviewed on it own merits.
Also in this instance, all these changes within the JWT code are going to cause a big headache of conflicts once the PR for CDD-3147 gets merged in too.

logger.warning("JWT validation failed: %s", error)
raise exceptions.AuthenticationFailed from None

custom_user_manager = self.get_custom_user_manager()
Expand Down
65 changes: 64 additions & 1 deletion common/auth/cognito_jwt/user_manager.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import logging
from typing import TYPE_CHECKING

from django.contrib.auth import get_user_model
from django.contrib.auth.models import BaseUserManager

from metrics.utils.permission_hierarchy import convert_permission_set_into_hierarchy

if TYPE_CHECKING: # just for IDE checks
from rest_framework.request import Request

logger = logging.getLogger(__name__)


Expand All @@ -14,9 +20,23 @@ def get_or_create_for_cognito(jwt_payload):
We don't need to store or retrieve any info, we use what's in the JWT,
so this speeds up the request by removing the need for any DB access
"""

try:
username = jwt_payload["entraObjectId"]
permission_sets = jwt_payload["permissionSets"]
raw_permission_sets = jwt_payload["permissionSets"]

# Manual testing (just for now)
# username = "678a605b-16f3-4342-9f02-db74613701ac"
# raw_permission_sets = {
# "permission_sets": [
# {
# "theme": {"id": "100", "name": "immunisation"},
# "sub_theme": {"id": "200", "name": "childhood-vaccines"},
# "topic": {"id": "-1", "name": "* (All)"},
# }
# ],
# "summary": {"has_global_access": False},
# }
except KeyError:
logger.debug(
"Error getting entraObjectId and/or permissionSets field(s)"
Expand All @@ -25,7 +45,50 @@ def get_or_create_for_cognito(jwt_payload):
)
return None

permission_sets = convert_permission_set_into_hierarchy(raw_permission_sets)
permission_count = len(permission_sets.get("permission_set_hierarchy", []))
has_global_access = bool(permission_sets.get("has_global_access", False))

logger.info(
"JWT token for user '%s' with permissions: permission_count=%d, has_global_access=%s",
username, permission_count, has_global_access,
)

user_class = get_user_model()
user = user_class(username=username)
user.permission_sets = permission_sets

return user


def extract_jwt_permissions(*, request: "Request | None") -> dict:
Comment thread
dandammann marked this conversation as resolved.
"""
Extract the normalized JWT permissions dict from an authenticated request.

Reads `request.user.permission_sets`, which is set by CognitoManager
during JWT authentication. Lives here because it is the counterpart to
the code above that "writes" user.permission_sets.

@param {Request | None} request, eg:
<rest_framework.request.Request: POST '/api/charts/v3'>

@return {dict}, eg:
{
"permission_set_hierarchy": [
{"theme": {"id": "100", "name": "immunisation"}, ...}
],
"has_global_access": False
}
"""
if request is None:
return {}

user = getattr(request, "user", None)
if user is None:
return {}

permission_sets = getattr(user, "permission_sets", {})
if not permission_sets:
return {}

return permission_sets
3 changes: 3 additions & 0 deletions metrics/api/views/charts/single_category_charts.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@
from django.http import FileResponse
from drf_spectacular.utils import OpenApiExample, extend_schema
from rest_framework import permissions
from rest_framework.authentication import SessionAuthentication
from rest_framework.response import Response
from rest_framework.views import APIView

import config
from caching.private_api.decorators import cache_response
from common.auth.cognito_jwt import JSONWebTokenAuthentication
from metrics.api.decorators.auth import require_authorisation
from metrics.api.enums import AppMode
from metrics.api.serializers import ChartsSerializer
Expand Down Expand Up @@ -218,6 +220,7 @@ def post(cls, request, *args, **kwargs):


class EncodedChartsView(APIView):
authentication_classes = [SessionAuthentication, JSONWebTokenAuthentication]
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should not be being set.
Authenticaion is already being handled for all views by the JWT token code, and the permission sets are being made available in the request object (request.user.permission_sets)

permission_classes = []

@classmethod
Expand Down
48 changes: 39 additions & 9 deletions metrics/data/managers/core_models/headline.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
from metrics.api.permissions.fluent_permissions import (
validate_permissions_for_non_public,
)
from metrics.utils.permissions import (
check_any_permissions_allow_access,
)


class CoreHeadlineQuerySet(models.QuerySet):
Expand Down Expand Up @@ -320,21 +323,22 @@
return CoreHeadlineQuerySet(model=self.model, using=self.db)

def query_for_data(
self,
*,
topic: str,
metric: str,
fields_to_export: list[str],
geography: str = "England",
geography_type: str = "Nation",
geography_code: str = "",
stratum: str = "",
sex: str = "",
age: str = "",
theme: str = "",
sub_theme: str = "",
rbac_permissions: Iterable["RBACPermission"] | None = None,
jwt_permissions: dict | None = None,
**kwargs,

Check warning on line 341 in metrics/data/managers/core_models/headline.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Method "query_for_data" has 14 parameters, which is greater than the 13 authorized.

See more on https://sonarcloud.io/project/issues?id=UKHSA-Internal_winter-pressures-api&issues=AZ5EnP_k7oQN5WJKIM58&open=AZ5EnP_k7oQN5WJKIM58&pullRequest=3201
):
"""Filters for a N-item list of dicts by the given params if `fields_to_export` is used.

Expand Down Expand Up @@ -373,6 +377,10 @@
rbac_permissions: The RBAC permissions available
to the given request. This dictates whether the given
request is permitted access to non-public data or not.
The new JWT-based authorization below takes precedence
over RBAC permissions, which is not in use anymore.
jwt_permissions: The JWT permissions extracted from the Cognito token.
Contains 'permission_set_hierarchy' (list) and 'has_global_access' (bool).

Returns:
Queryset of (x_axis, y_axis) where x_axis represents the variable on the x_axis
Expand All @@ -382,16 +390,38 @@
Examples:
<CoreHeadlineQuerySet [{'age__name': '01-04', 'metric_value': Decimal('534.0000')}]>
"""

rbac_permissions = rbac_permissions or []
has_access_to_non_public_data: bool = validate_permissions_for_non_public(
theme=theme,
sub_theme=sub_theme,
topic=topic,
metric=metric,
geography=geography,
geography_type=geography_type,
rbac_permissions=rbac_permissions,
)

has_access_to_non_public_data: bool

if jwt_permissions:
# Check JWT permissions first (new authorization takes precedence)
has_global_access = jwt_permissions.get("has_global_access", False)

if has_global_access:
has_access_to_non_public_data = True
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could simplify this has_global_access check to just be part of the return value from check_any_permissions_allow_access
This is how it's being done in 3171, are we not able to just reuse those functions for these endpoints too? They should be doing the same job and we don't want to have 2 different function that both check permissions

else:
has_access_to_non_public_data = check_any_permissions_allow_access(
jwt_permissions=jwt_permissions,
theme=theme,
sub_theme=sub_theme,
topic=topic,
metric=metric,
geography_type=geography_type,
geography=geography,
)
else:
# Legacy RBAC permissions (not in use) (to be removed in a future release)
has_access_to_non_public_data = validate_permissions_for_non_public(
theme=theme,
sub_theme=sub_theme,
topic=topic,
metric=metric,
geography=geography,
geography_type=geography_type,
rbac_permissions=rbac_permissions,
)

if has_access_to_non_public_data:
queryset = self.get_queryset().get_all_headlines_released_from_embargo(
Expand Down
57 changes: 43 additions & 14 deletions metrics/data/managers/core_models/time_series.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
validate_permissions_for_non_public,
)
from metrics.data.models import RBACPermission
from metrics.utils.permissions import (
check_any_permissions_allow_access,
)

ALLOWABLE_METRIC_VALUE_RANGE_TYPE = tuple[str | float | int, str | float | int]

Expand Down Expand Up @@ -533,6 +536,7 @@ def query_for_data(
sub_theme: str = "",
metric_value_ranges: list[str | float | int] | None = None,
rbac_permissions: Iterable[RBACPermission] | None = None,
jwt_permissions: dict | None = None,
) -> CoreTimeSeriesQuerySet:
"""Filters for a 2-item object by the given params. Slices all values older than the `date_from`.

Expand Down Expand Up @@ -579,11 +583,14 @@ def query_for_data(
i.e. to filter for all record with values
between 0 -> 80 AND 90 -> 100,
this can be provided as `[(0, 80), (90, 100)]`.
rbac_permissions: The RBAC permissions available
to the given request. This dictates whether the given
request is permitted access to non-public data or not.

Notes:
rbac_permissions: The RBAC permissions available
to the given request. This dictates whether the given
request is permitted access to non-public data or not.
jwt_permissions: JWT permissions dict extracted from Cognito token.
Contains 'has_global_access' (bool) and 'permission_set_hierarchy' (list).
Used for new JWT-based authorization (takes precedence over RBAC permissions).

Notes:
If we have the following input `queryset`:
----------------------------------------
| 2023-01-01 | 2023-01-02 | 2023-01-03 |
Expand Down Expand Up @@ -611,16 +618,38 @@ def query_for_data(
]>`

"""

rbac_permissions: Iterable[RBACPermission] = rbac_permissions or []
has_access_to_non_public_data: bool = validate_permissions_for_non_public(
theme=theme,
sub_theme=sub_theme,
topic=topic,
metric=metric,
geography_type=geography_type,
geography=geography,
rbac_permissions=rbac_permissions,
)

has_access_to_non_public_data: bool

if jwt_permissions:
# Check JWT permissions first (new authorization takes precedence)
has_global_access = jwt_permissions.get("has_global_access", False)

if has_global_access:
has_access_to_non_public_data = True
else:
has_access_to_non_public_data = check_any_permissions_allow_access(
jwt_permissions=jwt_permissions,
theme=theme,
sub_theme=sub_theme,
topic=topic,
metric=metric,
geography_type=geography_type,
geography=geography,
)
else:
# Legacy RBAC permissions (not in use) (to be removed in a future release)
has_access_to_non_public_data = validate_permissions_for_non_public(
theme=theme,
sub_theme=sub_theme,
topic=topic,
metric=metric,
geography_type=geography_type,
geography=geography,
rbac_permissions=rbac_permissions,
)

return self.get_queryset().query_for_data(
fields_to_export=fields_to_export,
Expand Down
5 changes: 4 additions & 1 deletion metrics/interfaces/plots/access.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from django.db.models import Manager, QuerySet
from pydantic import BaseModel

from common.auth.cognito_jwt.user_manager import extract_jwt_permissions
from metrics.api.settings import auth
from metrics.data.models.core_models import CoreTimeSeries, Topic
from metrics.domain.common.utils import ChartAxisFields
Expand Down Expand Up @@ -162,7 +163,9 @@ def get_queryset_from_core_model_manager(
plot_params["fields_to_export"].append("lower_confidence")

return self.core_model_manager.query_for_data(
**plot_params, rbac_permissions=self.chart_request_params.rbac_permissions
**plot_params,
rbac_permissions=self.chart_request_params.rbac_permissions, # legacy permissions to be removed
jwt_permissions=extract_jwt_permissions(request=self.chart_request_params.request), # new permissions
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as with previous comment, you should be able to just use something like:
jwt_permissions=request.user.permission_sets if request.auth else None,

)

def build_plot_data_from_parameters_with_complete_queryset(
Expand Down
48 changes: 48 additions & 0 deletions metrics/utils/permission_hierarchy.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,54 @@
)


def convert_permission_set_into_hierarchy(raw_permission_sets: dict) -> dict:
"""
Convert a "permission_set" back into a "permission_set_hierarchy" again
(the NormalizedPermission class does it the other way round)

@param {dict} raw_permission_sets, eg:
{
"permission_sets": [
{
"theme": {"id": "100", "name": "immunisation"},
"sub_theme": {"id": "200", "name": "childhood-vaccines"},
"topic": {"id": "215", "name": "MMR1"},
}
],
"summary": {
"has_global_access": False
},
}

@return {dict}, eg:
{
"permission_set_hierarchy": [
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the value in this over using the raw permissions?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dandammann I''m not sure what this is for. I was under the impression that the permission set hierarchy was already returning the permissions sets into the JWT in the most usable format for the endpoints to consume when filtering the data.
If the permission sets are not in the right format when we come to use them for filtering for pages, DB data etc, then we should be updating the format of the permission sets before they go into the JWT, so it happens just once, rather than on 100s of requests at the point it is being consumed (although there is also a tradeoff to consider here too, of avoiding making the JWT too large).

{
"theme": {"id": "100", "name": "immunisation"},
"sub_theme": {"id": "200", "name": "childhood-vaccines"},
"topic": {"id": "215", "name": "MMR1"},
}
],
"has_global_access": False,
}
"""

permission_set_hierarchy = raw_permission_sets.get("permission_set_hierarchy")
if permission_set_hierarchy is None:
permission_set_hierarchy = raw_permission_sets.get("permission_sets", [])

has_global_access = raw_permission_sets.get("has_global_access")
if has_global_access is None:
has_global_access = raw_permission_sets.get("summary", {}).get(
"has_global_access", False
)

return {
"permission_set_hierarchy": permission_set_hierarchy,
"has_global_access": bool(has_global_access),
}


@dataclass
class NormalizedPermission:
"""
Expand Down
Loading
Loading