Repository: UKHSA-Internal/data-dashboard-api
Pull request #2366: Task/re implement fluent permissions/cdd 2531
Author: login: A-Ashiq
Created: 2025-04-09T10:35:00Z | Merged: 2025-04-15T11:11:00Z

# Description

This PR includes the following:

- Reimplements the `FluentPermissions` class so that it is able to wildcard match for any combination of parameters

Fixes #CDD-2531

---

## Type of change

Please select the options that are relevant.

- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] Tech debt item (this is focused solely on addressing any relevant technical debt)

---

# Checklist:

- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] I have added tests at the right levels to prove my change is effective
- [ ] I have added screenshots or screen grabs where appropriate
- [ ] I have added docstrings in the correct style [(google)](https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings)



Diff:
diff --git a/metrics/api/packages/permissions/README.md b/metrics/api/packages/permissions/README.md
deleted file mode 100644
index 0b26ae4b3..000000000
--- a/metrics/api/packages/permissions/README.md
+++ /dev/null
@@ -1,131 +0,0 @@
-# FluentPermissions Usage Guide
-
-## Introduction
-
-The `FluentPermissions` class is for handling Role-Based Access Control (RBAC) permissions.
-It allows for checking multiple fields against user permissions using a fluent API.
-
----
-
-## Installation & Setup
-
-Ensure your project includes the necessary imports:
-
-```python
-from metrics.data.models.rbac_models import RBACPermission
-from metrics.api.packages.permissions import FluentPermissions, FluentPermissionsError
-```
-
----
-
-## Creating FluentPermissions Instance
-
-To create an instance of `FluentPermissions`, pass in the **data** (the object containing the fields to check) 
-and the **group_permissions** (a list of `RBACPermission` objects associated with the user).
-
-### Example:
-```python
-permissions = FluentPermissions(
-    data={
-        "theme": "infectious_disease",
-        "sub_theme": "respiratory",
-        "topic": "COVID-19",
-    },
-    group_permissions=[user_permission_object],
-)
-```
-
----
-
-## Adding Fields to Check
-
-You must specify which fields you want to check by calling `.add_field(field_name)`. Multiple fields can be chained.
-
-### Example:
-```python
-permissions.add_field("theme").add_field("sub_theme").add_field("topic")
-```
-
----
-
-## Executing the Permissions Check
-
-Once the fields are added, call `.execute()` to process the matching logic.
-
-### Example:
-```python
-permissions.execute()
-```
-
-The results are stored in `permissions.match_fields_all`, which is a **read-only property** 
-that returns a list of dictionaries indicating whether each field matched.
-
-### Example Output:
-```python
-[
-    {
-        "theme": True,
-        "sub_theme": True,
-        "topic": False,
-        "geography_type": False,
-        "geography": False,
-        "metric": False,
-        "age": False,
-        "stratum": False,
-    }
-]
-```
-
----
-
-## Validating the Permissions
-
-To ensure that at least one set of permissions fully matches, call `.validate()`.
-If no complete match is found, it raises a `FluentPermissionsError`.
-
-### Example:
-```python
-try:
-    permissions.validate()
-except FluentPermissionsError:
-    print("Access denied")
-```
----
-
-## Integration with `filter_by_permissions` Decorator
-
-The `FluentPermissions` class is used within the `filter_by_permissions()` decorator to automatically enforce permissions when serializing API responses.
-
-### Example:
-```python
-@filter_by_permissions()
-class MySerializer(serializers.ModelSerializer):
-    class Meta:
-        model = MyModel
-        fields = "__all__"
-```
-
-When `to_representation()` is called on the serializer, the decorator ensures that unauthorized users cannot see restricted data.
-
----
-
-## Handling Public Data
-
-If an object contains `is_public = True`, the `FluentPermissions` logic is **bypassed**, and the data is always returned.
-
-### Example:
-```python
-{ "is_public": True, "theme": "infectious_disease", "sub_theme": "respiratory" }
-```
-The object above will be accessible to all users regardless of permissions.
-
----
-
-## Summary
-
-| **Method** | **Description** |
-|------------|----------------|
-| `add_field(field_name)` | Adds a field to be checked in permissions |
-| `execute()` | Runs the permission checks and stores results |
-| `validate()` | Raises `FluentPermissionsError` if no full match exists |
-| `match_fields_all` | Read-only property that stores a list of match results |
diff --git a/metrics/api/packages/permissions/__init__.py b/metrics/api/packages/permissions/__init__.py
deleted file mode 100644
index 269ae1311..000000000
--- a/metrics/api/packages/permissions/__init__.py
+++ /dev/null
@@ -1,10 +0,0 @@
-from metrics.api.packages.permissions.permissisons_actions import (
-    BaseAction,
-    new_match_dict,
-    MatchThemeSubThemeFieldsAction,
-    MatchFieldAction,
-)
-from metrics.api.packages.permissions.permissions_fluent import (
-    FluentPermissions,
-    FluentPermissionsError,
-)
diff --git a/metrics/api/packages/permissions/permissions_fluent.py b/metrics/api/packages/permissions/permissions_fluent.py
deleted file mode 100644
index 92bff718f..000000000
--- a/metrics/api/packages/permissions/permissions_fluent.py
+++ /dev/null
@@ -1,214 +0,0 @@
-from metrics.api.packages.permissions import (
-    BaseAction,
-    MatchFieldAction,
-    MatchThemeSubThemeFieldsAction,
-    new_match_dict,
-)
-from metrics.data.models.rbac_models import RBACPermission
-
-
-class FluentPermissionsError(Exception):
-    """
-    Raised when permission validation fails in FluentPermissions.
-
-    This exception is triggered when `validate()` is called but no valid permission match exists.
-    """
-
-
-class FluentPermissions:
-    """
-    A class for handling Role-Based Access Control (RBAC) permissions using a fluent API.
-
-    This class checks multiple fields against a list of RBAC permissions (`group_permissions`),
-    storing the results in a list of dictionaries where:
-
-    - **Keys** represent the field names being checked (e.g., "theme", "sub_theme", "topic").
-    - **Values** are boolean flags (`True/False`), indicating whether each field matched
-        the corresponding permission.
-
-    ### Setup:
-    1. Instantiate `FluentPermissions` with `data` and `group_permissions`.
-    2. Use `.add_field(field_name)` to define which fields should be checked. Ensure all required fields are added before executing the validation.
-    3. Call `.execute()` to evaluate permissions.
-    4. Use `.match_fields_all` to access the results.
-    5. Call `.validate()` to ensure at least one valid match exists.
-
-    ### Example Usage:
-    from metrics.data.models.rbac_models import RBACPermission
-    from metrics.api.decorators.permissions_fluent import FluentPermissions, FluentPermissionsError
-
-    # Define user permissions (mocked for example)
-    user_permission = RBACPermission()
-    user_permission.theme.name = "infectious_disease"
-    user_permission.sub_theme.name = "respiratory"
-    user_permission.topic.name = "COVID-19"
-
-    # Data to check
-    data = {
-        "theme": "infectious_disease",
-        "sub_theme": "respiratory",
-        "topic": "COVID-19",
-    }
-
-    # Create FluentPermissions instance
-    permissions = FluentPermissions(
-        data=data,
-        group_permissions=[user_permission],
-    )
-
-    # Add fields and execute permission check
-    permissions.add_field("theme").add_field("sub_theme").add_field("topic").execute()
-
-    # Validate access
-    try:
-        permissions.validate()
-        print("Access granted")
-    except FluentPermissionsError:
-        print("Access denied")
-    """
-
-    # Stores the matching status for individual fields in the current execution cycle.
-    # It is a dictionary where the keys are field names (e.g., "theme", "metric"),
-    # and the values are boolean flags indicating whether the field matched a permission.
-    match_fields: dict[str, bool]
-
-    # A list of match dictionaries from multiple permission evaluations.
-    # Each dictionary represents the result of a single permission check.
-    # This is a read-only property accessed via the `match_fields_all` getter.
-    _match_fields_all: list[dict[str, bool]]
-
-    # A list of field names that will be checked in the permission validation process.
-    # The fields are added dynamically using the `add_field()` method.
-    field_names: list[str]
-
-    # Represents a single RBACPermission object being evaluated.
-    # Used during individual permission checks in the execution process.
-    group_permission: RBACPermission
-
-    # A list of RBACPermission objects representing all the permissions
-    # available for the user or role being evaluated.
-    group_permissions: list[RBACPermission]
-
-    def __init__(
-        self, *, data: dict[str, str], group_permissions: list[RBACPermission]
-    ):
-        self.data = data
-        self.group_permissions = group_permissions
-        self.field_names = []
-        self._match_fields_all = []
-
-    @property
-    def match_fields_all(self) -> list[dict[str, bool]]:
-        """Read-only property for accessing the match results."""
-        return self._match_fields_all
-
-    def add_field(self, field_name: str) -> "FluentPermissions":
-        """
-        Adds a field to be checked during permission validation.
-
-        Fields added with this method will be evaluated when `execute()` is called.
-
-        Args:
-            field_name (str): The name of the field to check.
-
-        Returns:
-            FluentPermissions: The current instance, allowing method chaining.
-        """
-        self.field_names.append(field_name)
-        return self
-
-    def execute(self) -> "FluentPermissions":
-        """
-        Evaluates permissions for each group, storing match results and skipping irrelevant checks.
-
-        This method iterates through each `RBACPermission` in `group_permissions`,
-        comparing its fields against the provided `data`. If the `theme` and `sub_theme`
-        do not match, the permission check is skipped for optimization.
-
-        Returns:
-            FluentPermissions: Returns the instance itself for method chaining.
-
-        Example:
-            ```python
-            permissions = FluentPermissions(data=data, group_permissions=[user_permission])
-            permissions.add_field("theme").add_field("sub_theme").execute()
-            matches = permissions.match_fields_all  # Access stored results
-            ```
-        """
-        for group_permission in self.group_permissions:
-            self.match_fields = new_match_dict()
-            # Optimization - continue if theme or sub_theme don't match
-            if not self._match_theme_sub_theme(group_permission):
-                continue
-
-            theme_sub_theme_action = MatchThemeSubThemeFieldsAction(data=self.data)
-            field_action = MatchFieldAction(data=self.data)
-
-            self._execute_actions_and_store_results(
-                theme_sub_theme_action=theme_sub_theme_action,
-                field_action=field_action,
-                group_permission=group_permission,
-            )
-            self._match_fields_all.append(self.match_fields)
-        return self
-
-    def validate(self) -> None:
-        """
-        Ensures at least one set of permissions fully matches the provided data.
-
-        This method checks whether any match in `_match_fields_all` contains all `True` values.
-        If no complete match is found, it raises a `FluentPermissionsError`.
-
-        Raises:
-            FluentPermissionsError: If no valid permission match exists.
-        """
-        if not self._validate_match_field():
-            raise FluentPermissionsError
-
-    def _execute_actions_and_store_results(
-        self,
-        *,
-        theme_sub_theme_action: BaseAction,
-        field_action: BaseAction,
-        group_permission: RBACPermission,
-    ) -> None:
-        """Execute actions & store results in match_fields dict"""
-        for field_name in self.field_names:
-            match field_name:
-                case "theme" | "sub_theme":
-                    self._run_action(
-                        theme_sub_theme_action, field_name, group_permission
-                    )
-                case _:
-                    self._run_action(field_action, field_name, group_permission)
-
-    def _match_theme_sub_theme(self, group_permission: RBACPermission) -> bool:
-        """
-        Checks if the given group permission matches the `theme` and `sub_theme` in `self.data`.
-        Returns False if `theme` or `sub_theme` keys are missing.
-        """
-        theme = self.data.get("theme")
-        sub_theme = self.data.get("sub_theme")
-
-        if theme is None or sub_theme is None:
-            return False
-        return [
-            group_permission.theme.name,
-            group_permission.sub_theme.name,
-        ] == [self.data["theme"], self.data["sub_theme"]]
-
-    def _validate_match_field(self) -> bool:
-        matched = [
-            all(values for values in match_plot.values())
-            for match_plot in self._match_fields_all
-        ]
-        return any(matched)
-
-    def _run_action(
-        self, action: BaseAction, field_name: str, group_permission: RBACPermission
-    ):
-        self.match_fields = action.execute(
-            field_name=field_name,
-            group_permission=group_permission,
-            match_fields=self.match_fields,
-        )
diff --git a/metrics/api/packages/permissions/permissisons_actions.py b/metrics/api/packages/permissions/permissisons_actions.py
deleted file mode 100644
index c9ad4ca96..000000000
--- a/metrics/api/packages/permissions/permissisons_actions.py
+++ /dev/null
@@ -1,125 +0,0 @@
-from abc import ABC, abstractmethod
-
-from metrics.data.models.rbac_models import RBACPermission
-
-
-def _is_wildcard(group_permission: RBACPermission, attr: str) -> bool:
-    return not getattr(group_permission, attr)
-
-
-def new_match_dict() -> dict[str, bool]:
-    """
-    Creates a new match dictionary with default values set to False.
-
-    This dictionary is used to track whether each field has a valid match
-    during permission evaluation.
-
-    Returns:
-        dict[str, bool]: A dictionary with predefined keys representing
-        permission fields, all initialized to False.
-
-    Example:
-        ```python
-        match_fields = new_match_dict()
-        print(match_fields)
-        # Output: {'theme': False, 'sub_theme': False, 'topic': False, ...}
-        ```
-    """
-    return {
-        "theme": False,
-        "sub_theme": False,
-        "topic": False,
-        "geography_type": False,
-        "geography": False,
-        "metric": False,
-        "age": False,
-        "stratum": False,
-    }
-
-
-class BaseAction(ABC):
-    """
-    Abstract base class for permission matching actions.
-
-    This class defines the interface for actions that evaluate whether a field
-    in `data` matches the corresponding field in `group_permission`.
-    Subclasses must implement the `execute()` method.
-
-    Attributes:
-        data (Dict[str,str]): The input data against which permissions will be checked.
-    """
-
-    def __init__(self, *, data: dict[str, str]):
-        self.data = data
-
-    @abstractmethod
-    def execute(
-        self,
-        *,
-        field_name: str,
-        group_permission: RBACPermission,
-        match_fields: dict[str, bool]
-    ) -> dict[str, bool]:
-        """
-        Executes the permission check for a specific field.
-
-        Args:
-          field_name (str): The name of the field to validate.
-          group_permission (RBACPermission): The permission object containing allowed values.
-          match_fields (dict[str, bool]): A dictionary tracking which fields matched.
-
-        Returns:
-          dict[str, bool]: Updated `match_fields` dictionary with the result of the check.
-        """
-
-
-class MatchThemeSubThemeFieldsAction(BaseAction):
-    """
-    Action for matching theme and subtheme fields in RBAC permissions.
-
-    This action checks whether the provided `field_name` in `data` matches the corresponding
-    value in `group_permission`. If a match is found, the field is marked as `True` in
-    `match_fields`.
-    """
-
-    def execute(
-        self,
-        *,
-        field_name: str,
-        group_permission: RBACPermission,
-        match_fields: dict[str, bool]
-    ) -> dict[str, bool]:
-        group_permission_field = getattr(group_permission, field_name)
-        permission_theme_value = group_permission_field.name
-        if self.data[field_name] == permission_theme_value:
-            match_fields[field_name] = True
-        return match_fields
-
-
-class MatchFieldAction(BaseAction):
-    """
-    Action for matching general fields in RBAC permissions.
-
-    This action checks whether the specified `field_name` in `data` matches the corresponding
-    value in `group_permission`. If a match is found or if the permission allows a wildcard,
-    the field is marked as `True` in `match_fields`.
-
-    Inherits from:
-        BaseAction: Defines the structure for permission-matching actions.
-    """
-
-    def execute(
-        self,
-        *,
-        field_name: str,
-        group_permission: RBACPermission,
-        match_fields: dict[str, bool]
-    ) -> dict[str, bool]:
-        permission_stratum = getattr(group_permission, field_name, None)
-        permission_value = getattr(permission_stratum, "name", None)
-        if (
-            _is_wildcard(group_permission, field_name)
-            or self.data[field_name] == permission_value
-        ):
-            match_fields[field_name] = True
-        return match_fields
diff --git a/metrics/api/permissions/__init__.py b/metrics/api/permissions/__init__.py
new file mode 100644
index 000000000..5f50fe7d2
--- /dev/null
+++ b/metrics/api/permissions/__init__.py
@@ -0,0 +1 @@
+from metrics.api.permissions.fluent_permissions import FluentPermissions
diff --git a/metrics/api/permissions/fluent_permissions.py b/metrics/api/permissions/fluent_permissions.py
new file mode 100644
index 000000000..8b8a92189
--- /dev/null
+++ b/metrics/api/permissions/fluent_permissions.py
@@ -0,0 +1,91 @@
+from collections.abc import Iterable
+from dataclasses import dataclass
+
+from metrics.data.models.rbac_models import RBACPermission
+
+
+@dataclass
+class RequestedDataParameters:
+    theme: str
+    sub_theme: str
+    topic: str
+    metric: str
+    geography: str
+    geography_type: str
+    age: str
+    sex: str
+    stratum: str
+
+
+class FluentPermissions:
+    def __init__(self, *, requested_data_parameters: RequestedDataParameters):
+        self.requested_data_parameters = requested_data_parameters
+
+    def check_permission_allows_access(self, rbac_permission: RBACPermission) -> bool:
+        """Checks if the given `rbac_permission` provides access `requested_data_parameters`
+
+        Notes:
+            This provides wildcard matching in accordance with the hierarchy.
+            For example, if the `theme` is matched and the rest of the permission is empty
+            this is considered a wildcard, and will match data parameters.
+
+        Args:
+             `rbac_permission`: The `RBACPermission` model which contains
+                the individual permission details
+
+        Returns:
+            True if the given permission allows access to the requested dataset.
+            False otherwise.
+
+        """
+        fields = [
+            "theme",
+            "sub_theme",
+            "topic",
+            "metric",
+            "geography_type",
+            "geography",
+            "age",
+            "stratum",
+        ]
+
+        for field in fields:
+            allowed_model = getattr(rbac_permission, field, None)
+            # Django throws a `RelatedObjectDoesNotExist` error when the related attr is `None`.
+            # In this case we're happy to just bury the error and handle the returned object as `None`
+            if not allowed_model:
+                # Since the field was not specified on the `RBACPermission`.
+                # We consider this to be a wildcard and skip to the next field for evaluation.
+                continue
+
+            requested_value: str = getattr(self.requested_data_parameters, field)
+            if requested_value != allowed_model.name:
+                # The field was specified on the `RBACPermission`.
+                # And it has not matched, so we mark the permission as False
+                # i.e. this permission does not allow access to the dataset
+                return False
+
+        # All the fields have been evaluated as either:
+        # - The field was `None` and therefore wildcarded
+        # - The field was a match
+        # In this case we can say the permission does allow access to the dataset
+        return True
+
+    def check_if_any_permissions_allow_access(
+        self, rbac_permissions: Iterable[RBACPermission]
+    ) -> bool:
+        """Checks if any of the given `rbac_permissions` provides access to the `requested_data_parameters`
+
+        Args:
+             `rbac_permissions`: The `RBACPermission` models which contain
+                the details for each permission.
+
+        Returns:
+            True if any of the given permissions allows access to the requested dataset.
+            False otherwise.
+
+        """
+        return any(
+            self.check_permission_allows_access(rbac_permission=rbac_permission)
+            for rbac_permission in rbac_permissions
+        )
diff --git a/tests/fakes/factories/metrics/rbac_models/rbac_permission.py b/tests/fakes/factories/metrics/rbac_models/rbac_permission.py
index 4a44132c7..27b321fb9 100644
--- a/tests/fakes/factories/metrics/rbac_models/rbac_permission.py
+++ b/tests/fakes/factories/metrics/rbac_models/rbac_permission.py
@@ -1,12 +1,16 @@
 import factory
 
+from tests.fakes.factories.metrics.age_factory import FakeAgeFactory
 from tests.fakes.factories.metrics.geography_factory import FakeGeographyFactory
+from tests.fakes.factories.metrics.geography_type_factory import (
+    FakeGeographyTypeFactory,
+)
 from tests.fakes.factories.metrics.metric_factory import FakeMetricFactory
 from tests.fakes.factories.metrics.stratum_factory import FakeStratumFactory
-from tests.fakes.models.metrics.age import FakeAge
-from tests.fakes.models.metrics.metric import FakeMetric
+from tests.fakes.factories.metrics.sub_theme_factory import FakeSubThemeFactory
+from tests.fakes.factories.metrics.theme_factory import FakeThemeFactory
+from tests.fakes.factories.metrics.topic_factory import FakeTopicFactory
 from tests.fakes.models.metrics.rbac_models.rbac_permission import FakeRBACPermission
-from tests.fakes.models.metrics.stratum import FakeStratum
 
 
 class FakeRBACPermissionFactory(factory.Factory):
@@ -20,43 +24,75 @@ class Meta:
     @classmethod
     def build_rbac_permission(
         cls,
-        theme_name: str = "infectious_disease",
-        sub_theme_name: str = "respiratory",
-        metric_name: str = "COVID-19_cases_casesByDay",
-        topic_name: str = "COVID-19",
-        geography_name: str = "England",
-        geography_type_name: str = "Nation",
-        age_name: str = "all",
-        stratum_name: str = "default",
+        theme: str = "",
+        sub_theme: str = "",
+        metric: str = "",
+        topic: str = "",
+        geography: str = "",
+        geography_type: str = "",
+        age: str = "",
+        stratum: str = "",
     ) -> FakeRBACPermission:
-        metric: FakeMetric = FakeMetricFactory.build_example_metric(
-            metric_name=metric_name,
-            topic_name=topic_name,
-            theme_name=theme_name,
-            sub_theme_name=sub_theme_name,
-        )
+        if theme:
+            fake_theme = FakeThemeFactory.build_example_theme(name=theme)
+        else:
+            fake_theme = None
 
-        topic = metric.topic
-        sub_theme = topic.sub_theme
-        theme = sub_theme.theme
+        if sub_theme:
+            fake_sub_theme = FakeSubThemeFactory.build_example_sub_theme(
+                name=sub_theme, theme=fake_theme
+            )
+        else:
+            fake_sub_theme = None
 
-        geography = FakeGeographyFactory.build_example(
-            geography_name=geography_name, geography_type_name=geography_type_name
-        )
-        geography_type = geography.geography_type
+        if topic:
+            fake_topic = FakeTopicFactory.build_example_topic(
+                name=topic, sub_theme=fake_sub_theme
+            )
+        else:
+            fake_topic = None
 
-        stratum: FakeStratum = FakeStratumFactory.build_example(
-            stratum_name=stratum_name,
-        )
-        age = FakeAge(name=age_name)
+        if metric:
+            fake_metric = FakeMetricFactory.build(
+                name=metric,
+                topic=fake_topic,
+            )
+        else:
+            fake_metric = None
+
+        if geography_type:
+            fake_geography_type = FakeGeographyTypeFactory.build_example(
+                geography_type_name=geography_type
+            )
+        else:
+            fake_geography_type = None
+
+        if geography:
+            fake_geography = FakeGeographyFactory.build(
+                name=geography, geography_type=fake_geography_type
+            )
+        else:
+            fake_geography = None
+
+        if stratum:
+            fake_stratum = FakeStratumFactory.build_example(
+                stratum_name=stratum,
+            )
+        else:
+            fake_stratum = None
+
+        if age:
+            fake_age = FakeAgeFactory.build_example(age_name=age)
+        else:
+            fake_age = None
 
         return cls.build(
-            theme=theme,
-            sub_theme=sub_theme,
-            topic=topic,
-            metric=metric,
-            geography_type=geography_type,
-            geography=geography,
-            stratum=stratum,
-            age=age,
+            theme=fake_theme,
+            sub_theme=fake_sub_theme,
+            topic=fake_topic,
+            metric=fake_metric,
+            geography_type=fake_geography_type,
+            geography=fake_geography,
+            stratum=fake_stratum,
+            age=fake_age,
         )
diff --git a/tests/fakes/factories/metrics/sub_theme_factory.py b/tests/fakes/factories/metrics/sub_theme_factory.py
new file mode 100644
index 000000000..705b75ea9
--- /dev/null
+++ b/tests/fakes/factories/metrics/sub_theme_factory.py
@@ -0,0 +1,22 @@
+import factory
+
+from tests.fakes.models.metrics.metric import FakeMetric
+from tests.fakes.models.metrics.sub_theme import FakeSubTheme
+from tests.fakes.models.metrics.theme import FakeTheme
+
+
+class FakeSubThemeFactory(factory.Factory):
+    """
+    Factory for creating `FakeSubTheme` instances for tests
+    """
+
+    class Meta:
+        model = FakeSubTheme
+
+    @classmethod
+    def build_example_sub_theme(
+        cls,
+        name: str,
+        theme: FakeTheme,
+    ) -> FakeSubTheme:
+        return cls.build(name=name, theme=theme)
diff --git a/tests/fakes/factories/metrics/theme_factory.py b/tests/fakes/factories/metrics/theme_factory.py
new file mode 100644
index 000000000..6342a8cc0
--- /dev/null
+++ b/tests/fakes/factories/metrics/theme_factory.py
@@ -0,0 +1,19 @@
+import factory
+
+from tests.fakes.models.metrics.theme import FakeTheme
+
+
+class FakeThemeFactory(factory.Factory):
+    """
+    Factory for creating `FakeTheme` instances for tests
+    """
+
+    class Meta:
+        model = FakeTheme
+
+    @classmethod
+    def build_example_theme(
+        cls,
+        name: str,
+    ) -> FakeTheme:
+        return cls.build(name=name)
diff --git a/tests/fakes/factories/metrics/topic_factory.py b/tests/fakes/factories/metrics/topic_factory.py
new file mode 100644
index 000000000..34d1af256
--- /dev/null
+++ b/tests/fakes/factories/metrics/topic_factory.py
@@ -0,0 +1,21 @@
+import factory
+
+from tests.fakes.models.metrics.sub_theme import FakeSubTheme
+from tests.fakes.models.metrics.topic import FakeTopic
+
+
+class FakeTopicFactory(factory.Factory):
+    """
+    Factory for creating `FakeTopic` instances for tests
+    """
+
+    class Meta:
+        model = FakeTopic
+
+    @classmethod
+    def build_example_topic(
+        cls,
+        name: str,
+        sub_theme: FakeSubTheme,
+    ) -> FakeTopic:
+        return cls.build(name=name, sub_theme=sub_theme)
diff --git a/tests/integration/metrics/api/packages/__init__.py b/tests/integration/metrics/api/packages/__init__.py
deleted file mode 100644
index e69de29bb..000000000
diff --git a/tests/integration/metrics/api/packages/permissions/__init__.py b/tests/integration/metrics/api/packages/permissions/__init__.py
deleted file mode 100644
index e69de29bb..000000000
diff --git a/tests/integration/metrics/api/packages/permissions/test_permission_actions.py b/tests/integration/metrics/api/packages/permissions/test_permission_actions.py
deleted file mode 100644
index 5c27c1e35..000000000
--- a/tests/integration/metrics/api/packages/permissions/test_permission_actions.py
+++ /dev/null
@@ -1,143 +0,0 @@
-import pytest
-
-from unittest.mock import MagicMock
-
-from metrics.api.packages.permissions import (
-    new_match_dict,
-    MatchThemeSubThemeFieldsAction,
-    MatchFieldAction,
-)
-
-core_headline_data = {
-    "theme": "infectious_disease",
-    "sub_theme": "respiratory",
-    "topic": "COVID-19",
-    "geography_type": "",
-    "geography": "",
-    "metric": "COVID-19_headline_vaccines_spring24Uptake",
-    "stratum": "default",
-    "sex": "",
-    "age": "01-04",
-    "metric_value": 123.45,
-    "period_start": "2024-01-01 00:00:00",
-    "period_end": "2024-02-02 00:00:00",
-    "is_private": False,
-}
-
-
-class TestNewMatchDict:
-
-    def test_new_match_dict_returns_expected_dict(self):
-        """
-        Given no input parameters
-        When `new_match_dict()` is called
-        Then it returns a dictionary with predefined keys
-        And all values are `False`
-        """
-        # When
-        result = new_match_dict()
-
-        # Then
-        expected_keys = {
-            "theme",
-            "sub_theme",
-            "topic",
-            "geography_type",
-            "geography",
-            "metric",
-            "age",
-            "stratum",
-        }
-
-        assert (
-            set(result.keys()) == expected_keys
-        ), "Unexpected keys in the returned dictionary"
-
-        # And all values should be False
-        assert all(
-            value is False for value in result.values()
-        ), "All values should be False"
-
-
-class TestMatchTopLevelFieldsAction:
-
-    @pytest.mark.parametrize(
-        "field_name,field_value,expected",
-        [
-            ("theme", "infectious_disease", True),
-            ("theme", "WRONG_VALUE", False),
-            ("sub_theme", "respiratory", True),
-            ("sub_theme", "WRONG_VALUE", False),
-        ],
-    )
-    @pytest.mark.django_db
-    def test_execute(self, field_name, field_value, expected):
-        """
-        Given a `field_name` and `field_value`
-        When `execute()` is called with a mock permission object
-        Then it returns `True` if the field value matches
-        And it returns `False` otherwise
-        """
-        # Given
-        fake_permission = MagicMock()
-        fake_permission_field = MagicMock()
-        fake_permission_field.name = field_value
-
-        setattr(fake_permission, field_name, fake_permission_field)
-        data = core_headline_data
-
-        matched_fields = new_match_dict()
-        action = MatchThemeSubThemeFieldsAction(data=data)
-
-        # When
-        matched_fields = action.execute(
-            field_name=field_name,
-            group_permission=fake_permission,
-            match_fields=matched_fields,
-        )
-
-        # Then
-        assert matched_fields[field_name] is expected
-
-
-class TestMatchFieldAction:
-
-    @pytest.mark.parametrize(
-        "field_name,field_value,expected",
-        [
-            ("theme", "infectious_disease", True),
-            ("theme", "WRONG_VALUE", False),
-            ("sub_theme", "respiratory", True),
-            ("sub_theme", "WRONG_VALUE", False),
-            ("topic", "COVID-19", True),
-            ("topic", "WRONG_VALUE", False),
-        ],
-    )
-    @pytest.mark.django_db
-    def test_execute(self, field_name, field_value, expected):
-        """
-        Given a `field_name` and `field_value`
-        When `execute()` is called with a mock permission object
-        Then it returns `True` if the field value matches
-        And it returns `False` otherwise
-        """
-        # Given
-        fake_permission = MagicMock()
-
-        # Mock field attribute
-        mock_permission_field = MagicMock()
-        mock_permission_field.name = field_value
-        setattr(fake_permission, field_name, mock_permission_field)
-
-        data = core_headline_data
-        action = MatchFieldAction(data=data)
-        matched_fields = new_match_dict()
-        # When
-        matched_fields = action.execute(
-            field_name=field_name,
-            group_permission=fake_permission,
-            match_fields=matched_fields,
-        )
-
-        # Then
-        assert matched_fields[field_name] is expected
diff --git a/tests/integration/metrics/api/packages/permissions/test_permissions_fluent.py b/tests/integration/metrics/api/packages/permissions/test_permissions_fluent.py
deleted file mode 100644
index d0915b2d0..000000000
--- a/tests/integration/metrics/api/packages/permissions/test_permissions_fluent.py
+++ /dev/null
@@ -1,292 +0,0 @@
-import pytest
-from unittest import mock
-from metrics.api.packages.permissions import (
-    FluentPermissions,
-    FluentPermissionsError,
-    new_match_dict,
-)
-from tests.fakes.factories.metrics.rbac_models.rbac_permission import (
-    FakeRBACPermissionFactory,
-)
-from tests.fakes.models.metrics.rbac_models.rbac_permission import FakeRBACPermission
-
-MODULE_PATH = "metrics.api.packages.permissions.permissions_fluent"
-
-
-class TestFluentPermissions:
-
-    data = {
-        "theme": "infectious_disease",
-        "sub_theme": "respiratory",
-        "topic": "COVID-19",
-        "metric": "COVID-19_cases_casesByDay",
-        "geography": "England",
-        "geography_type": "Nation",
-        "sex": "all",
-        "age": "all",
-        "stratum": "default",
-    }
-
-    @pytest.fixture
-    def fake_rbac_permission(self) -> FakeRBACPermission:
-        """Creates a fake RBACPermission object"""
-        return FakeRBACPermissionFactory.build_rbac_permission(
-            theme_name=self.data["theme"],
-            sub_theme_name=self.data["sub_theme"],
-            topic_name=self.data["topic"],
-            metric_name=self.data["metric"],
-            geography_name=self.data["geography"],
-            geography_type_name=self.data["geography_type"],
-            age_name=self.data["age"],
-            stratum_name=self.data["stratum"],
-        )
-
-    def test_add_field_adds_fields_to_list(self):
-        """
-        Given a FluentPermissions instance
-        When add_field() is called multiple times
-        Then the fields are added to the internal list
-        """
-        # Given
-        fluent_permissions = FluentPermissions(data={}, group_permissions=[])
-
-        # When
-        fluent_permissions.add_field("theme").add_field("sub_theme").add_field("metric")
-
-        # Then
-        assert fluent_permissions.field_names == ["theme", "sub_theme", "metric"]
-
-    def test_execute_with_matching_permissions(
-        self, fake_rbac_permission: FakeRBACPermission
-    ):
-        """
-        Given a FluentPermissions instance with a matching permission
-        When execute() is called
-        Then match_fields should correctly reflect matches
-        """
-        # Given
-        fluent_permissions = FluentPermissions(
-            data=self.data, group_permissions=[fake_rbac_permission]
-        )
-        fluent_permissions.add_field("theme").add_field("sub_theme").add_field("topic")
-
-        # When
-        fluent_permissions.execute()
-
-        # Then
-        expected = {
-            "theme": True,
-            "sub_theme": True,
-            "topic": True,
-            "geography_type": False,
-            "geography": False,
-            "metric": False,
-            "age": False,
-            "stratum": False,
-        }
-        assert fluent_permissions._match_fields_all == [expected]
-
-    def test_execute_skips_non_matching_theme_subtheme(
-        self, fake_rbac_permission: FakeRBACPermission
-    ):
-        """
-        Given a FluentPermissions instance with a non-matching theme/subtheme
-        When execute() is called
-        Then the permissions check should be skipped
-        """
-        # Given
-        data = {"theme": "extreme_event", "sub_theme": "weather_alert"}
-        fluent_permissions = FluentPermissions(
-            data=data, group_permissions=[fake_rbac_permission]
-        )
-        fluent_permissions.add_field("theme").add_field("sub_theme")
-
-        # When
-        fluent_permissions.execute()
-
-        # Then
-        assert fluent_permissions._match_fields_all == []
-
-    def test_validate_raises_error_when_no_match(self):
-        """
-        Given a FluentPermissions instance with no matches
-        When validate() is called
-        Then FluentPermissionsError is raised
-        """
-        # Given
-        fluent_permissions = FluentPermissions(data={}, group_permissions=[])
-
-        # When/Then
-        with pytest.raises(FluentPermissionsError):
-            fluent_permissions.validate()
-
-    def test_validate_raises_error_when_permission_cannot_be_matched(self):
-        """
-        Given a FluentPermissions instance
-            with a permission for a different dataset
-        When validate() is called
-        Then a `FluentPermissionsError` is raised
-        """
-        # Given
-        non_matching_permission = FakeRBACPermissionFactory.build_rbac_permission(
-            theme_name="extreme_event",
-            sub_theme_name="weather_alert",
-        )
-        requested_data = {
-            "theme": "infectious_disease",
-            "sub_theme": "respiratory",
-        }
-
-        fluent_permissions = FluentPermissions(
-            data=requested_data, group_permissions=[non_matching_permission]
-        )
-
-        # When/Then
-        with pytest.raises(FluentPermissionsError):
-            fluent_permissions.validate()
-
-    def test_validate_passes_when_match_exists(
-        self, fake_rbac_permission: FakeRBACPermission
-    ):
-        """
-        Given a FluentPermissions instance with valid matches
-        When validate() is called
-        Then no error is raised
-        """
-        # Given
-        fluent_permissions = FluentPermissions(
-            data=self.data, group_permissions=[fake_rbac_permission]
-        )
-        (
-            fluent_permissions.add_field("theme")
-            .add_field("sub_theme")
-            .add_field("topic")
-            .add_field("geography_type")
-            .add_field("geography")
-            .add_field("metric")
-            .add_field("age")
-            .add_field("stratum")
-            .execute()
-        )
-        # When/Then
-        fluent_permissions.validate()  # Should not raise an exception
-
-    def test_validate_passes_when_wildcard_permission_matches_requested_dataset(
-        self, fake_rbac_permission: FakeRBACPermission
-    ):
-        """
-        Given a FluentPermissions instance
-            with an `RBACPPermission` which wildcards the dataset
-        When validate() is called
-        Then no error is raised
-        """
-        # Given
-        wildcarded_permission = fake_rbac_permission
-        wildcarded_permission.metric = None
-        wildcarded_permission.topic = None
-        wildcarded_permission.age = None
-        wildcarded_permission.geography = None
-        # Permission wildcards for the selected theme & sub_theme
-
-        fluent_permissions = FluentPermissions(
-            data=self.data, group_permissions=[fake_rbac_permission]
-        )
-        (
-            fluent_permissions.add_field("theme")
-            .add_field("sub_theme")
-            .add_field("topic")
-            .add_field("geography_type")
-            .add_field("geography")
-            .add_field("metric")
-            .add_field("age")
-            .add_field("stratum")
-            .execute()
-        )
-        # When/Then
-        fluent_permissions.validate()  # Should not raise an exception
-
-    @mock.patch(f"{MODULE_PATH}.MatchFieldAction")
-    @mock.patch(f"{MODULE_PATH}.MatchThemeSubThemeFieldsAction")
-    def test_execute_calls_correct_actions(
-        self,
-        mocked_theme_subtheme_action: mock.MagicMock,
-        mocked_field_action: mock.MagicMock,
-        fake_rbac_permission: FakeRBACPermission,
-    ):
-        """
-        Given a FluentPermissions instance
-        When execute() is called
-        Then it should invoke the correct action classes
-        """
-        # Given
-        mock_theme_action = mocked_theme_subtheme_action.return_value
-        mock_field_action = mocked_field_action.return_value
-        mock_theme_action.execute.return_value = True
-        mock_field_action.execute.return_value = new_match_dict()
-
-        fluent_permissions = FluentPermissions(
-            data=self.data, group_permissions=[fake_rbac_permission]
-        )
-        (
-            fluent_permissions.add_field("theme")
-            .add_field("sub_theme")
-            .add_field("topic")
-            .add_field("geography_type")
-            .add_field("geography")
-            .add_field("metric")
-            .add_field("age")
-            .add_field("stratum")
-        )
-        # When
-        fluent_permissions.execute()
-
-        # Then
-        mocked_theme_subtheme_action.assert_called_once_with(data=self.data)
-        mocked_field_action.assert_called_once_with(data=self.data)
-        mock_theme_action.execute.assert_called()
-        mock_field_action.execute.assert_called()
-
-    def test_match_fields_all_property(self):
-        """
-        Given a FluentPermissions instance
-        When match_fields_all is accessed
-        Then it should return the stored _match_fields_all data
-        """
-        # Given
-        fluent_permissions = FluentPermissions(data={}, group_permissions=[])
-        fluent_permissions._match_fields_all = [new_match_dict()]
-
-        # When
-        result = fluent_permissions.match_fields_all
-
-        # Then
-        assert result == [new_match_dict()]
-
-    @pytest.mark.parametrize(
-        "data",
-        [
-            {"theme": "infectious_disease"},
-            {"sub_theme": "respiratory"},
-            {},
-        ],
-    )
-    def test_match_theme_sub_theme_returns_false_when_keys_missing(
-        self,
-        fake_rbac_permission: FakeRBACPermission,
-        data: dict[str, str],
-    ):
-        """
-        Given a FluentPermissions instance
-        When _match_theme_sub_theme() is called with missing theme or sub_theme
-        Then it should return False
-        """
-        # Given
-        fluent_permissions = FluentPermissions(
-            data=data, group_permissions=[fake_rbac_permission]
-        )
-
-        # When
-        result = fluent_permissions._match_theme_sub_theme(fake_rbac_permission)
-
-        # Then
-        assert result is False
diff --git a/metrics/api/packages/__init__.py b/tests/integration/metrics/api/permissions/__init__.py
similarity index 100%
rename from metrics/api/packages/__init__.py
rename to tests/integration/metrics/api/permissions/__init__.py
diff --git a/tests/integration/metrics/api/permissions/test_fluent_permissions.py b/tests/integration/metrics/api/permissions/test_fluent_permissions.py
new file mode 100644
index 000000000..8a552bfee
--- /dev/null
+++ b/tests/integration/metrics/api/permissions/test_fluent_permissions.py
@@ -0,0 +1,352 @@
+import pytest
+from metrics.api.permissions.fluent_permissions import (
+    FluentPermissions,
+)
+from metrics.api.permissions.fluent_permissions import RequestedDataParameters
+from tests.fakes.factories.metrics.rbac_models.rbac_permission import (
+    FakeRBACPermissionFactory,
+)
+from tests.fakes.models.metrics.rbac_models.rbac_permission import FakeRBACPermission
+
+
+DATA_PARAMETERS = {
+    "theme": "infectious_disease",
+    "sub_theme": "respiratory",
+    "topic": "COVID-19",
+    "metric": "COVID-19_cases_casesByDay",
+    "geography": "England",
+    "geography_type": "Nation",
+    "sex": "all",
+    "age": "all",
+    "stratum": "default",
+}
+
+
+class TestFluentPermissions:
+    def test_check_permission_allows_access_validates_for_exact_fine_grained_permission(
+        self,
+    ):
+        """
+        Given a requested set of parameters
+        And an `RBACPermission` which specifies the exact requested dataset
+        When `check_permission_allows_access()` is called
+            from an instance of `FluentPermissions`
+        Then True is returned
+        """
+        # Given
+        requested_data_parameters = RequestedDataParameters(**DATA_PARAMETERS)
+        fluent_permissions = FluentPermissions(
+            requested_data_parameters=requested_data_parameters
+        )
+        fake_exact_matching_rbac_permission = (
+            FakeRBACPermissionFactory.build_rbac_permission(
+                theme=DATA_PARAMETERS["theme"],
+                sub_theme=DATA_PARAMETERS["sub_theme"],
+                topic=DATA_PARAMETERS["topic"],
+                metric=DATA_PARAMETERS["metric"],
+                geography=DATA_PARAMETERS["geography"],
+                geography_type=DATA_PARAMETERS["geography_type"],
+                age=DATA_PARAMETERS["age"],
+                stratum=DATA_PARAMETERS["stratum"],
+            )
+        )
+
+        # When
+        is_access_allowed: bool = fluent_permissions.check_permission_allows_access(
+            rbac_permission=fake_exact_matching_rbac_permission
+        )
+
+        # Then
+        assert is_access_allowed
+
+    @pytest.mark.parametrize(
+        "matching_fields",
+        (
+            # Show all data for `infectious_disease`
+            {
+                "theme",
+            },
+            # Show all data for `infectious_disease` `respiratory`
+            {
+                "theme",
+                "sub_theme",
+            },
+            # Show all data for `COVID-19`
+            {
+                "theme",
+                "sub_theme",
+                "topic",
+            },
+            # Show all data for `COVID-19_cases_casesByDay`
+            {
+                "theme",
+                "sub_theme",
+                "topic",
+                "metric",
+            },
+            # Show all data under the `Nation` geography type scope
+            {
+                "geography_type",
+            },
+            # Show for all data in `England`
+            {
+                "geography_type",
+                "geography",
+            },
+            # Show all data for `COVID-19_cases_casesByDay` in `England`
+            {
+                "theme",
+                "sub_theme",
+                "topic",
+                "metric",
+                "geography",
+                "geography_type",
+            },
+            # Show all data for `all` ages of `COVID-19_cases_casesByDay` in `England`
+            {
+                "theme",
+                "sub_theme",
+                "topic",
+                "geography",
+                "geography_type",
+                "age",
+            },
+            # Show all data for `COVID-19` in `England
+            {
+                "theme",
+                "sub_theme",
+                "topic",
+                "geography",
+                "geography_type",
+            },
+            # Show all data for the stratum of `default`
+            {
+                "stratum",
+            },
+        ),
+    )
+    def test_check_permission_allows_access_validates_for_wildcard_permissions_with_enough_of_a_match(
+        self, matching_fields: set[str]
+    ):
+        """
+        Given a requested set of parameters
+        And an `RBACPermission` which specifies enough of the parameters
+            to incur a wildcard match
+        When `check_permission_allows_access()` is called
+            from an instance of `FluentPermissions`
+        Then True is returned
+        """
+        # Given
+        requested_data_parameters = RequestedDataParameters(**DATA_PARAMETERS)
+        fluent_permissions = FluentPermissions(
+            requested_data_parameters=requested_data_parameters
+        )
+
+        permission_values = {
+            k: v for k, v in DATA_PARAMETERS.items() if k in matching_fields
+        }
+        fake_rbac_permission = FakeRBACPermissionFactory.build_rbac_permission(
+            **permission_values
+        )
+
+        # When
+        is_access_allowed: bool = fluent_permissions.check_permission_allows_access(
+            rbac_permission=fake_rbac_permission
+        )
+
+        # Then
+        assert is_access_allowed
+
+    @pytest.mark.parametrize(
+        "permission_values",
+        (
+            # Permission for data for a different theme i.e. `extreme_event` instead of `infectious_disease`
+            {
+                "theme": "extreme_event",
+            },
+            # Permission for data for a different sub_theme i.e. `vaccine_preventable` instead of `respiratory`
+            {
+                "theme": DATA_PARAMETERS["theme"],
+                "sub_theme": "vaccine_preventable",
+            },
+            # Permission for data for a different topic i.e. `RSV` instead of `COVID-19`
+            {
+                "theme": DATA_PARAMETERS["theme"],
+                "sub_theme": DATA_PARAMETERS["sub_theme"],
+                "topic": "RSV",
+            },
+            # Permission for data for a different metric i.e. `COVID-19_vaccinations_autumn22_dosesByDay`
+            {
+                "theme": DATA_PARAMETERS["theme"],
+                "sub_theme": DATA_PARAMETERS["sub_theme"],
+                "topic": DATA_PARAMETERS["topic"],
+                "metric": "COVID-19_vaccinations_autumn22_dosesByDay",
+            },
+            # Permission for data for a different geography_type i.e. `Lower Tier Local Authority` instead of `Nation`
+            {
+                "geography_type": "Lower Tier Local Authority",
+            },
+            # Permission for data a different geography i.e. `Wales` instead of `England`
+            {
+                "geography_type": DATA_PARAMETERS["geography_type"],
+                "geography": "Wales",
+            },
+            # Permission for data a different geography but with the same metric i.e. `Wales` instead of `England`
+            {
+                "theme": DATA_PARAMETERS["theme"],
+                "sub_theme": DATA_PARAMETERS["sub_theme"],
+                "topic": DATA_PARAMETERS["topic"],
+                "metric": DATA_PARAMETERS["metric"],
+                "geography": "Wales",
+                "geography_type": DATA_PARAMETERS["geography_type"],
+            },
+            # Permission for data for ages of `f` instead of `all`
+            {
+                "theme": DATA_PARAMETERS["theme"],
+                "sub_theme": DATA_PARAMETERS["sub_theme"],
+                "topic": DATA_PARAMETERS["topic"],
+                "metric": DATA_PARAMETERS["metric"],
+                "geography": DATA_PARAMETERS["geography"],
+                "geography_type": DATA_PARAMETERS["geography_type"],
+                "age": "f",
+            },
+            # Permission for data a different geography but with the same topic i.e. `Wales` instead of `England`
+            {
+                "theme": DATA_PARAMETERS["theme"],
+                "sub_theme": DATA_PARAMETERS["sub_theme"],
+                "topic": DATA_PARAMETERS["topic"],
+                "geography": "Wales",
+                "geography_type": DATA_PARAMETERS["geography_type"],
+            },
+            # Permission for data a different stratum i.e. `some-other-value` instead of `default`
+            {
+                "stratum": "some-other-value",
+            },
+        ),
+    )
+    def test_check_permission_allows_access_invalidates_for_permissions_without_match(
+        self, permission_values: dict[str, str]
+    ):
+        """
+        Given a requested set of parameters
+        And an `RBACPermission` which specifies parameters
+            resulting in a wildcard mismatch
+        When `check_permission_allows_access()` is called
+            from an instance of `FluentPermissions`
+        Then False is returned
+        """
+        # Given
+        requested_data_parameters = RequestedDataParameters(**DATA_PARAMETERS)
+        fluent_permissions = FluentPermissions(
+            requested_data_parameters=requested_data_parameters
+        )
+
+        fake_rbac_permission = FakeRBACPermissionFactory.build_rbac_permission(
+            **permission_values
+        )
+
+        # When
+        is_access_allowed: bool = fluent_permissions.check_permission_allows_access(
+            rbac_permission=fake_rbac_permission
+        )
+
+        # Then
+        assert not is_access_allowed
+
+    def test_check_permission_allows_access_invalidates_for_non_matching_wildcard_permission(
+        self,
+    ):
+        """
+        Given a requested set of parameters
+        And an `RBACPermission` which specifies a different theme and sub_theme
+        When `check_permission_allows_access()` is called
+            from an instance of `FluentPermissions`
+        Then False is returned
+        """
+        # Given
+        requested_data_parameters = RequestedDataParameters(**DATA_PARAMETERS)
+        fluent_permissions = FluentPermissions(
+            requested_data_parameters=requested_data_parameters
+        )
+        fake_rbac_permission = FakeRBACPermissionFactory.build_rbac_permission(
+            theme="extreme_event",
+            sub_theme="weather_alert",
+        )
+
+        # When
+        is_access_allowed: bool = fluent_permissions.check_permission_allows_access(
+            rbac_permission=fake_rbac_permission
+        )
+
+        # Then
+        assert not is_access_allowed
+
+    def test_check_if_any_permissions_allow_access_validates_correctly(self):
+        """
+        Given a requested set of parameters
+        And a number of `RBACPermission` models
+            one of which provides a match
+        When `check_if_any_permissions_allow_access()` is called
+            from an instance of `FluentPermissions`
+        Then True is returned
+        """
+        # Given
+        requested_data_parameters = RequestedDataParameters(**DATA_PARAMETERS)
+        fluent_permissions = FluentPermissions(
+            requested_data_parameters=requested_data_parameters
+        )
+        non_matching_permission = FakeRBACPermissionFactory.build_rbac_permission(
+            theme="extreme_event",
+            sub_theme="weather_alert",
+        )
+        matching_permission = FakeRBACPermissionFactory.build_rbac_permission(
+            theme=DATA_PARAMETERS["theme"],
+            sub_theme=DATA_PARAMETERS["sub_theme"],
+        )
+
+        # When
+        is_access_allowed: bool = (
+            fluent_permissions.check_if_any_permissions_allow_access(
+                rbac_permissions=[non_matching_permission, matching_permission]
+            )
+        )
+
+        # Then
+        assert is_access_allowed
+
+    def test_check_if_any_permissions_allow_access_invalidates_correctly(self):
+        """
+        Given a requested set of parameters
+        And a number of `RBACPermission` models
+            none of which provide any match
+        When `check_if_any_permissions_allow_access()` is called
+            from an instance of `FluentPermissions`
+        Then False is returned
+        """
+        # Given
+        requested_data_parameters = RequestedDataParameters(**DATA_PARAMETERS)
+        fluent_permissions = FluentPermissions(
+            requested_data_parameters=requested_data_parameters
+        )
+        non_matching_permission = FakeRBACPermissionFactory.build_rbac_permission(
+            theme="extreme_event",
+            sub_theme="weather_alert",
+        )
+        other_non_matching_permission = FakeRBACPermissionFactory.build_rbac_permission(
+            theme=DATA_PARAMETERS["theme"],
+            sub_theme=DATA_PARAMETERS["sub_theme"],
+            topic="RSV",
+        )
+
+        # When
+        is_access_allowed: bool = (
+            fluent_permissions.check_if_any_permissions_allow_access(
+                rbac_permissions=[
+                    non_matching_permission,
+                    other_non_matching_permission,
+                ]
+            )
+        )
+
+        # Then
+        assert not is_access_allowed


Reviews:
user: login: A-Ashiq
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T10:54:22Z
user: login: A-Ashiq
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T10:55:24Z
user: login: A-Ashiq
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T10:59:14Z
user: login: A-Ashiq
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T11:00:43Z
user: login: A-Ashiq
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T20:00:52Z
user: login: phill-stanley
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T10:26:19Z

Review comments:
diff_hunk: @@ -0,0 +1,80 @@
+from collections.abc import Iterable
+from dataclasses import dataclass
+
+from metrics.data.models.rbac_models import RBACPermission
+
+
+@dataclass
+class RequestedDataParameters:
+    theme: str
+    sub_theme: str
+    topic: str
+    metric: str
+    geography: str
+    geography_type: str
+    age: str
+    sex: str
+    stratum: str
+
+
+class FluentPermissions:
+    def __init__(self, *, requested_data_parameters: RequestedDataParameters):
+        self.requested_data_parameters = requested_data_parameters
+
+    def check_permission_allows_access(self, rbac_permission: RBACPermission) -> bool:
+        """Checks if the given `rbac_permission` provides access `requested_data_parameters`
+
+        Notes:
+            This provides wildcard matching in accordance with the hierarchy.
+            For example, if the `theme` is matched and the rest of the permission is empty
+            this is considered a wildcard, and will match data parameters.
+
+        Args:
+             `rbac_permission`: The `RBACPermission` model which contains
+                the individual permission details
+
+        Returns:
+            True if the given permission allows access to the requested dataset.
+            False otherwise.
+
+        """
+        fields = [
+            "theme",
+            "sub_theme",
+            "topic",
+            "metric",
+            "geography_type",
+            "geography",
+            "age",
+            "stratum",
+        ]
+
+        for field in fields:
+            supporting_model = getattr(rbac_permission, field)
+            requested_value: str = getattr(self.requested_data_parameters, field)
+            if (
+                supporting_model is not None
+                and requested_value != supporting_model.name
+            ):
+                return False
+
+        return True
+
+    def check_if_any_permissions_allow_access(
+        self, rbac_permissions: Iterable[RBACPermission]
+    ) -> bool:
+        """Checks if any of the given `rbac_permissions` provides access to the `requested_data_parameters`
+
+        Args:
+             `rbac_permissions`: The `RBACPermission` models which contain
+                the details for each permission.
+
+        Returns:
+            True if any of the given permissions allows access to the requested dataset.
+            False otherwise.
+
+        """
+        for rbac_permission in rbac_permissions:
+            if self.check_permission_allows_access(rbac_permission=rbac_permission):
+                return True
path: metrics/api/permissions/fluent_permissions.py
user: login: A-Ashiq
body: As soon as we've determined that there is a single `RBACPermission` which gives us access to the dataset we've asked for. Then we can bow out early and we don't need to evaluate any of the remaning permissions
created_at: 2025-04-09T10:54:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,80 @@
+from collections.abc import Iterable
+from dataclasses import dataclass
+
+from metrics.data.models.rbac_models import RBACPermission
+
+
+@dataclass
+class RequestedDataParameters:
+    theme: str
+    sub_theme: str
+    topic: str
+    metric: str
+    geography: str
+    geography_type: str
+    age: str
+    sex: str
+    stratum: str
+
+
+class FluentPermissions:
+    def __init__(self, *, requested_data_parameters: RequestedDataParameters):
+        self.requested_data_parameters = requested_data_parameters
+
+    def check_permission_allows_access(self, rbac_permission: RBACPermission) -> bool:
+        """Checks if the given `rbac_permission` provides access `requested_data_parameters`
+
+        Notes:
+            This provides wildcard matching in accordance with the hierarchy.
+            For example, if the `theme` is matched and the rest of the permission is empty
+            this is considered a wildcard, and will match data parameters.
+
+        Args:
+             `rbac_permission`: The `RBACPermission` model which contains
+                the individual permission details
+
+        Returns:
+            True if the given permission allows access to the requested dataset.
+            False otherwise.
+
+        """
+        fields = [
+            "theme",
+            "sub_theme",
+            "topic",
+            "metric",
+            "geography_type",
+            "geography",
+            "age",
+            "stratum",
path: metrics/api/permissions/fluent_permissions.py
user: login: A-Ashiq
body: We'll need to add `sex` later but for now this captures what we need. Note that the `sex` field is slightly different because it's not a supporting table, but rather it lives directly on the headline/timeseries models
created_at: 2025-04-09T10:55:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,80 @@
+from collections.abc import Iterable
+from dataclasses import dataclass
+
+from metrics.data.models.rbac_models import RBACPermission
+
+
+@dataclass
+class RequestedDataParameters:
+    theme: str
+    sub_theme: str
+    topic: str
+    metric: str
+    geography: str
+    geography_type: str
+    age: str
+    sex: str
+    stratum: str
+
+
+class FluentPermissions:
+    def __init__(self, *, requested_data_parameters: RequestedDataParameters):
+        self.requested_data_parameters = requested_data_parameters
+
+    def check_permission_allows_access(self, rbac_permission: RBACPermission) -> bool:
+        """Checks if the given `rbac_permission` provides access `requested_data_parameters`
+
+        Notes:
+            This provides wildcard matching in accordance with the hierarchy.
+            For example, if the `theme` is matched and the rest of the permission is empty
+            this is considered a wildcard, and will match data parameters.
+
+        Args:
+             `rbac_permission`: The `RBACPermission` model which contains
+                the individual permission details
+
+        Returns:
+            True if the given permission allows access to the requested dataset.
+            False otherwise.
+
+        """
+        fields = [
+            "theme",
+            "sub_theme",
+            "topic",
+            "metric",
+            "geography_type",
+            "geography",
+            "age",
+            "stratum",
+        ]
+
+        for field in fields:
+            supporting_model = getattr(rbac_permission, field)
+            requested_value: str = getattr(self.requested_data_parameters, field)
+            if (
+                supporting_model is not None
+                and requested_value != supporting_model.name
path: metrics/api/permissions/fluent_permissions.py
user: login: A-Ashiq
body: Here we're saying, if the permission specifed the value for a supporting model i.e. a `Topic` or a `Theme` etc.
Then the requested value should match that supporting model name. 
So if I asked for `theme=infectious_disease` then the `Theme` model that I fetch via the `RBACPermission` should have the name `infectious_disease`. If False, then we don't have a match and we can assume.

Note that if the supporting model is `None` as in the `RBACPermission` didn't specify that, then this is a wildcard for that field. And as such, we don't return False for that field check. 
created_at: 2025-04-09T10:59:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,80 @@
+from collections.abc import Iterable
+from dataclasses import dataclass
+
+from metrics.data.models.rbac_models import RBACPermission
+
+
+@dataclass
+class RequestedDataParameters:
+    theme: str
+    sub_theme: str
+    topic: str
+    metric: str
+    geography: str
+    geography_type: str
+    age: str
+    sex: str
+    stratum: str
+
+
+class FluentPermissions:
+    def __init__(self, *, requested_data_parameters: RequestedDataParameters):
+        self.requested_data_parameters = requested_data_parameters
+
+    def check_permission_allows_access(self, rbac_permission: RBACPermission) -> bool:
+        """Checks if the given `rbac_permission` provides access `requested_data_parameters`
+
+        Notes:
+            This provides wildcard matching in accordance with the hierarchy.
+            For example, if the `theme` is matched and the rest of the permission is empty
+            this is considered a wildcard, and will match data parameters.
+
+        Args:
+             `rbac_permission`: The `RBACPermission` model which contains
+                the individual permission details
+
+        Returns:
+            True if the given permission allows access to the requested dataset.
+            False otherwise.
+
+        """
+        fields = [
+            "theme",
+            "sub_theme",
+            "topic",
+            "metric",
+            "geography_type",
+            "geography",
+            "age",
+            "stratum",
+        ]
+
+        for field in fields:
+            supporting_model = getattr(rbac_permission, field)
+            requested_value: str = getattr(self.requested_data_parameters, field)
+            if (
+                supporting_model is not None
+                and requested_value != supporting_model.name
+            ):
+                return False
+
+        return True
path: metrics/api/permissions/fluent_permissions.py
user: login: A-Ashiq
body: At this point we've exhausted all the fields and we have the following:
- Either a field was left as `None`
- The field was matched to the supporting model declared by the `RBACPermission`
Which in turn, means our `RBACPermission` model is allowing access to the requested dataset
created_at: 2025-04-09T11:00:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -20,43 +24,75 @@ class Meta:
     @classmethod
     def build_rbac_permission(
         cls,
-        theme_name: str = "infectious_disease",
-        sub_theme_name: str = "respiratory",
-        metric_name: str = "COVID-19_cases_casesByDay",
-        topic_name: str = "COVID-19",
-        geography_name: str = "England",
-        geography_type_name: str = "Nation",
-        age_name: str = "all",
-        stratum_name: str = "default",
+        theme: str = "",
+        sub_theme: str = "",
+        metric: str = "",
+        topic: str = "",
+        geography: str = "",
+        geography_type: str = "",
+        age: str = "",
+        stratum: str = "",
     ) -> FakeRBACPermission:
-        metric: FakeMetric = FakeMetricFactory.build_example_metric(
-            metric_name=metric_name,
-            topic_name=topic_name,
-            theme_name=theme_name,
-            sub_theme_name=sub_theme_name,
-        )
+        if theme:
path: tests/fakes/factories/metrics/rbac_models/rbac_permission.py
user: login: A-Ashiq
body: This wall of gross looking if/else conditions allows us to more easily mix and match the fields on the permission
created_at: 2025-04-09T20:00:52Z
author_association: CONTRIBUTOR

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-04-15T10:39:18Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=UKHSA-Internal_winter-pressures-api&pullRequest=2366) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [1 New issue](https://sonarcloud.io/project/issues?id=UKHSA-Internal_winter-pressures-api&pullRequest=2366&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=UKHSA-Internal_winter-pressures-api&pullRequest=2366&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=UKHSA-Internal_winter-pressures-api&pullRequest=2366&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=UKHSA-Internal_winter-pressures-api&pullRequest=2366&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=UKHSA-Internal_winter-pressures-api&pullRequest=2366&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=UKHSA-Internal_winter-pressures-api&pullRequest=2366)


author_association: NONE