Repository: UKHSA-Internal/data-dashboard-api
Pull request #2252: Task/auth middleware cdd 2432 serialiser decorator
Author: login: mxrman
Created: 2025-03-07T14:25:47Z | Merged: 2025-03-20T14:44:29Z

# Description

This PR includes the following:

**Declares *but does **NOT** implement* `@filter_by_permissions()` for RBAC Enforcement**

This PR introduces the `@filter_by_permissions()` decorator, which enforces Role-Based Access Control when serialising API responses. This implementation ensures that users only receive data they have permission to access, based on their assigned RBAC permissions.

**This change adds the following**:
- Introduced `@filter_by_permissions()` to filter API responses based on user permissions.
- Integrated FluentPermissions for structured permission validation.
- Ensured "is_public" handling to allow public data to bypass permission checks.
- Implemented `to_representation()` logic to filter or exclude unauthorised data dynamically.
- Added test cases covering various permission scenarios to validate the implementation.

Fixes #CDD-2521

---

## Type of change

Please select the options that are relevant.

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] 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:

- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] I have added tests at the right levels to prove my change is effective
- [x] I have added screenshots or screen grabs where appropriate
- [x] 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/decorators/__init__.py b/metrics/api/decorators/__init__.py
index e69de29bb..c14f2df36 100644
--- a/metrics/api/decorators/__init__.py
+++ b/metrics/api/decorators/__init__.py
@@ -0,0 +1,3 @@
+from metrics.api.decorators.permissions import (
+    filter_by_permissions,
+)
diff --git a/metrics/api/decorators/permissions.py b/metrics/api/decorators/permissions.py
new file mode 100644
index 000000000..e0586b729
--- /dev/null
+++ b/metrics/api/decorators/permissions.py
@@ -0,0 +1,98 @@
+import logging
+from functools import wraps
+
+from metrics.api.packages.permissions import (
+    FluentPermissions,
+    FluentPermissionsError,
+)
+from metrics.api.settings.private_api import is_auth_enabled
+from metrics.data.models.rbac_models import (
+    RBACPermission,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def filter_by_permissions():
+    def decorator(serializer_class):
+        _init = serializer_class.__init__
+
+        @wraps(_init)
+        def init(self, *args, **kwargs):
+            super(serializer_class, self).__init__(*args, **kwargs)
+            original_to_representation = self.to_representation
+
+            self.to_representation = _get_public_or_private_representation(
+                self=self,
+                original_to_representation=original_to_representation,
+            )
+
+        serializer_class.__init__ = init
+        return serializer_class
+
+    return decorator
+
+
+def _get_public_or_private_representation(*, self, original_to_representation):
+    if not is_auth_enabled():
+        return _new_representation_public(
+            self=self,
+            representation=original_to_representation,
+        )
+    return _new_to_representation_private(
+        self=self,
+        representation=original_to_representation,
+    )
+
+
+def _new_representation_public(*, self, representation):
+    def wrapper(instance):
+        data = representation(instance) or {}
+        data.pop("is_public", None)
+        return data
+
+    return wrapper
+
+
+def _new_to_representation_private(*, self, representation):
+    def wrapper(instance):
+        data = representation(instance) or {}
+
+        # Handle public data
+        if data.get("is_public", None) is True:
+            data.pop("is_public", None)
+            return data
+        data.pop("is_public", None)
+
+        # Get RBAC permissions from request object
+        request = self.context.get("request", None)
+        group_permissions: list[RBACPermission] = getattr(
+            request, "group_permissions", []
+        )
+
+        # If there are no permissions but the data is not public return None
+        if not group_permissions:
+            return None
+
+        fluent_permissions = FluentPermissions(
+            group_permissions=group_permissions,
+            data=data,
+        )
+        (
+            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()
+        )
+        try:
+            fluent_permissions.validate()
+        except FluentPermissionsError:
+            return None
+        return data
+
+    return wrapper
diff --git a/metrics/api/packages/__init__.py b/metrics/api/packages/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/metrics/api/packages/permissions/README.md b/metrics/api/packages/permissions/README.md
new file mode 100644
index 000000000..0b26ae4b3
--- /dev/null
+++ b/metrics/api/packages/permissions/README.md
@@ -0,0 +1,131 @@
+# 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
new file mode 100644
index 000000000..269ae1311
--- /dev/null
+++ b/metrics/api/packages/permissions/__init__.py
@@ -0,0 +1,10 @@
+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
new file mode 100644
index 000000000..92bff718f
--- /dev/null
+++ b/metrics/api/packages/permissions/permissions_fluent.py
@@ -0,0 +1,214 @@
+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
new file mode 100644
index 000000000..c9ad4ca96
--- /dev/null
+++ b/metrics/api/packages/permissions/permissisons_actions.py
@@ -0,0 +1,125 @@
+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/settings/private_api.py b/metrics/api/settings/private_api.py
index 3957f2ec5..c416d9873 100644
--- a/metrics/api/settings/private_api.py
+++ b/metrics/api/settings/private_api.py
@@ -2,7 +2,13 @@
 
 import config
 
-AUTH_ENABLED: bool = os.environ.get("AUTH_ENABLED", "").lower() in {"true", "1"}
+
+def is_auth_enabled():
+    return os.environ.get("AUTH_ENABLED", "").lower() in {"true", "1"}
+
+
+AUTH_ENABLED = is_auth_enabled()
+
 
 if AUTH_ENABLED:
     CACHES = {
diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py
index ad869863f..4e46cdeda 100644
--- a/tests/integration/conftest.py
+++ b/tests/integration/conftest.py
@@ -1,4 +1,7 @@
 import datetime
+import os
+import importlib
+from unittest import mock
 
 import pytest
 from django.utils import timezone
@@ -14,6 +17,8 @@
     Topic,
 )
 
+from metrics.api.settings import private_api
+
 
 @pytest.fixture
 def core_headline_example() -> CoreHeadline:
@@ -111,3 +116,13 @@ def core_timeseries_example() -> list[CoreTimeSeries]:
         )
         for i in range(2)
     ]
+
+
+@pytest.fixture
+def patch_auth_enabled(monkeypatch):
+    monkeypatch.setenv("AUTH_ENABLED", "1")
+
+
+@pytest.fixture
+def patch_auth_disabled(monkeypatch):
+    monkeypatch.setenv("AUTH_ENABLED", "0")
diff --git a/tests/integration/metrics/api/decorators/test_permissions.py b/tests/integration/metrics/api/decorators/test_permissions.py
new file mode 100644
index 000000000..23c67037a
--- /dev/null
+++ b/tests/integration/metrics/api/decorators/test_permissions.py
@@ -0,0 +1,859 @@
+import pytest
+import copy
+from typing import Any, Callable
+from unittest.mock import MagicMock
+
+from django.core.serializers.base import Serializer
+
+from metrics.api.decorators import (
+    filter_by_permissions,
+)
+from tests.factories.metrics.time_series import CoreTimeSeriesFactory
+from metrics.data.models.rbac_models import RBACPermission
+from metrics.data.models.core_models import (
+    Theme,
+    SubTheme,
+    Topic,
+)
+
+MODULE_PATH = "metrics.api.settings.private_api"
+
+core_headline_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",
+    "metric_value": 123.45,
+    "period_start": "2024-01-01 00:00:00",
+    "period_end": "2024-02-02 00:00:00",
+    "is_public": True,
+}
+
+
+class FakeSerializer:
+    def __init__(self, *args, **kwargs):
+        self.context = kwargs.get("context", {})
+
+    def to_representation(self, instance):
+        return instance
+
+
+@pytest.fixture
+def mock_request():
+    mock_req = MagicMock()
+    mock_req.group_permissions = [MagicMock()]
+    return mock_req
+
+
+@pytest.fixture
+def fake_serializer(mock_request):
+    @filter_by_permissions()
+    class WrappedSerializer(FakeSerializer):
+        pass
+
+    return WrappedSerializer(context={"request": mock_request})
+
+
+class TestPermissions:
+
+    @pytest.fixture(autouse=True)
+    def setup(self):
+        # Theme
+        self.non_communicable = Theme.objects.create(name="non-communicable")
+
+        # Subtheme
+        self.respiratory_comm = SubTheme.objects.create(
+            name="respiratory", theme=self.non_communicable
+        )
+
+        # Topic
+        self.asthma = Topic.objects.create(
+            name="asthma", sub_theme=self.respiratory_comm
+        )
+
+        # CoreTimeSeries
+        self.core_covid_19 = CoreTimeSeriesFactory.create_record()
+
+        self.core_asthma = CoreTimeSeriesFactory.create_record(
+            theme_name=self.non_communicable.name,
+            sub_theme_name=self.respiratory_comm.name,
+            topic_name=self.asthma.name,
+            metric_name="COVID-19_cases_casesByDay2",
+        )
+
+        self.non_communicable_permission = RBACPermission.objects.create(
+            name="medical",
+            theme=self.non_communicable,
+            sub_theme=self.respiratory_comm,
+        )
+
+    @pytest.mark.django_db
+    def test_filter_by_permissions_non_private_api(
+        self: Any,
+        patch_auth_disabled: Callable[[], None],  # Sets AUTH_ENABLED "0"
+        fake_serializer: Serializer,
+    ):
+        """
+        Given `AUTH_ENABLED` is disabled
+        When `to_representation()` is called on the serializer
+        Then the `is_public` field is removed and all other fields are returned
+        """
+        # Given
+        serializer = fake_serializer
+
+        # When
+        result = serializer.to_representation(core_headline_data)
+
+        # Then
+        assert "is_public" not in result, "is_public should be removed"
+        assert result == core_headline_data, "all fields should be returned"
+
+    @pytest.mark.parametrize(
+        "theme,sub_theme",
+        [
+            ("infectious_disease", "respiratory"),
+            ("non-communicable", "respiratory"),
+        ],
+    )
+    @pytest.mark.django_db
+    def test_filter_by_permissions_theme_sub_theme(
+        self: Any,
+        patch_auth_enabled: Callable[[], None],  # Sets AUTH_ENABLED "1"
+        fake_serializer: Serializer,
+        theme: str,
+        sub_theme: str,
+    ):
+        """
+        Given authentication is enabled
+        And the user has matching RBAC permissions for theme & sub_theme
+        When `to_representation()` is called on the serializer
+        Then only the data matching the user’s permissions is returned
+        """
+        # Given
+        infectious_disease_permission = RBACPermission.objects.create(
+            name="infectious_disease_permission",
+            theme=self.core_covid_19.metric.topic.sub_theme.theme,
+            sub_theme=self.core_covid_19.metric.topic.sub_theme,
+        )
+        serializer = fake_serializer
+
+        test_data_public = copy.deepcopy(core_headline_data)
+        test_data_public["theme"] = theme
+        test_data_public["sub_theme"] = sub_theme
+        test_data_public["is_public"] = False
+
+        mock_request = MagicMock()
+        mock_request.group_permissions = [
+            infectious_disease_permission,
+            self.non_communicable_permission,
+        ]
+        serializer.context = {"request": mock_request}
+
+        # When
+        result = serializer.to_representation(test_data_public)
+
+        # Then
+        assert result == test_data_public
+        assert "is_public" not in result
+
+    @pytest.mark.parametrize(
+        "theme,sub_theme",
+        [
+            ("infectious_disease", "vaccine_preventable"),
+            ("non-communicable", "respiratory_other"),
+        ],
+    )
+    @pytest.mark.django_db
+    def test_filter_by_permissions_excludes_non_matching_theme_sub_theme(
+        self: Any,
+        patch_auth_enabled: Callable[[], None],  # Sets AUTH_ENABLED "1"
+        fake_serializer: Serializer,
+        theme: str,
+        sub_theme: str,
+    ):
+        """
+        Given authentication is enabled
+        And the user has RBAC permissions, but they do not match the provided theme & sub_theme
+        When `to_representation()` is called on the serializer
+        Then the result should be None, as no data matches the user’s permissions
+        """
+        # Given
+        infectious_disease_permission = RBACPermission.objects.create(
+            name="infectious_disease_permission",
+            theme=self.core_covid_19.metric.topic.sub_theme.theme,
+            sub_theme=self.core_covid_19.metric.topic.sub_theme,
+        )
+        serializer = fake_serializer
+
+        test_data_public = copy.deepcopy(core_headline_data)
+        test_data_public["theme"] = theme
+        test_data_public["sub_theme"] = sub_theme
+        test_data_public["is_public"] = False
+
+        mock_request = MagicMock()
+        mock_request.group_permissions = [
+            infectious_disease_permission,
+            self.non_communicable_permission,
+        ]
+        serializer.context = {"request": mock_request}
+
+        # When
+        result = serializer.to_representation(test_data_public)
+
+        # Then
+        assert result is None
+
+    @pytest.mark.parametrize(
+        "theme,sub_theme,topic",
+        [
+            ("infectious_disease", "respiratory", "COVID-19"),
+            ("non-communicable", "respiratory", "COVID-19"),
+        ],
+    )
+    @pytest.mark.django_db
+    def test_filter_by_permissions_topic(
+        self: Any,
+        patch_auth_enabled: Callable[[], None],  # Sets AUTH_ENABLED "1"
+        fake_serializer: Serializer,
+        theme: str,
+        sub_theme: str,
+        topic: str,
+    ):
+        """
+        Given authentication is enabled
+        And the user has RBAC permissions for a specific topic
+        When `to_representation()` is called on the serializer
+        Then only the data matching the user's topic permissions is returned
+        """
+        # Given
+        infectious_disease_permission = RBACPermission.objects.create(
+            name="infectious_disease_permission",
+            theme=self.core_covid_19.metric.topic.sub_theme.theme,
+            sub_theme=self.core_covid_19.metric.topic.sub_theme,
+            topic=self.core_covid_19.metric.topic,
+        )
+        serializer = fake_serializer
+
+        test_data_public = copy.deepcopy(core_headline_data)
+        test_data_public["theme"] = theme
+        test_data_public["sub_theme"] = sub_theme
+        test_data_public["topic"] = topic
+        test_data_public["is_public"] = False
+
+        mock_request = MagicMock()
+        mock_request.group_permissions = [
+            infectious_disease_permission,
+            self.non_communicable_permission,
+        ]
+        serializer.context = {"request": mock_request}
+
+        # When
+        result = serializer.to_representation(test_data_public)
+
+        # Then
+        assert result == test_data_public
+        assert "is_public" not in result
+
+    @pytest.mark.parametrize(
+        "theme,sub_theme,topic",
+        [
+            ("infectious_disease", "vaccine_preventable", "Measles"),
+            ("non-communicable", "respiratory_other", "COVID-19"),
+        ],
+    )
+    @pytest.mark.django_db
+    def test_filter_by_permissions_topic_without_correct_permissions(
+        self: Any,
+        patch_auth_enabled: Callable[[], None],  # Sets AUTH_ENABLED "1"
+        fake_serializer: Serializer,
+        theme: str,
+        sub_theme: str,
+        topic: str,
+    ):
+        """
+        Given authentication is enabled
+        And the user has RBAC permissions for a specific topic
+        When `to_representation()` is called on the serializer
+        Then the result should be None, as no data matches the user’s permissions
+        """
+        # Given
+        infectious_disease_permission = RBACPermission.objects.create(
+            name="infectious_disease_permission",
+            theme=self.core_covid_19.metric.topic.sub_theme.theme,
+            sub_theme=self.core_covid_19.metric.topic.sub_theme,
+            topic=self.core_covid_19.metric.topic,
+        )
+        serializer = fake_serializer
+
+        test_data_public = copy.deepcopy(core_headline_data)
+        test_data_public["theme"] = theme
+        test_data_public["sub_theme"] = sub_theme
+        test_data_public["topic"] = topic
+        test_data_public["is_public"] = False
+
+        mock_request = MagicMock()
+        mock_request.group_permissions = [
+            infectious_disease_permission,
+            self.non_communicable_permission,
+        ]
+        serializer.context = {"request": mock_request}
+
+        # When
+        result = serializer.to_representation(test_data_public)
+
+        # Then
+        assert result is None
+
+    @pytest.mark.parametrize(
+        "theme,sub_theme,topic,metric",
+        [
+            (
+                "infectious_disease",
+                "respiratory",
+                "COVID-19",
+                "COVID-19_cases_casesByDay",
+            ),
+            (
+                "non-communicable",
+                "respiratory",
+                "COVID-19",
+                "COVID-19_cases_casesByDay",
+            ),
+        ],
+    )
+    @pytest.mark.django_db
+    def test_filter_by_permissions_metric(
+        self: Any,
+        patch_auth_enabled: Callable[[], None],  # Sets AUTH_ENABLED "1"
+        fake_serializer: Serializer,
+        theme: str,
+        sub_theme: str,
+        topic: str,
+        metric: str,
+    ):
+        """
+        Given authentication is enabled
+        And the user has RBAC permissions for a specific metric
+        When `to_representation()` is called on the serializer
+        Then only the data matching the user's metric permissions is returned
+        """
+        # Given
+        infectious_disease_permission = RBACPermission.objects.create(
+            name="infectious_disease_permission",
+            theme=self.core_covid_19.metric.topic.sub_theme.theme,
+            sub_theme=self.core_covid_19.metric.topic.sub_theme,
+            topic=self.core_covid_19.metric.topic,
+            metric=self.core_covid_19.metric,
+        )
+        serializer = fake_serializer
+
+        test_data_public = copy.deepcopy(core_headline_data)
+        test_data_public["theme"] = theme
+        test_data_public["sub_theme"] = sub_theme
+        test_data_public["topic"] = topic
+        test_data_public["metric"] = metric
+        test_data_public["is_public"] = False
+
+        mock_request = MagicMock()
+        mock_request.group_permissions = [
+            infectious_disease_permission,
+            self.non_communicable_permission,
+        ]
+        serializer.context = {"request": mock_request}
+
+        # When
+        result = serializer.to_representation(test_data_public)
+
+        # Then
+        assert result == test_data_public
+        assert "is_public" not in result
+
+    @pytest.mark.parametrize(
+        "theme,sub_theme,topic,metric",
+        [
+            (
+                "infectious_disease",
+                "respiratory",
+                "COVID-19",
+                "COVID-20_cases_casesByDay2",
+            ),
+            (
+                "non-communicable",
+                "respiratory_other",
+                "COVID-19",
+                "COVID-19_cases_casesByDay",
+            ),
+        ],
+    )
+    @pytest.mark.django_db
+    def test_filter_by_permissions_metric_with_no_matching_permissions(
+        self: Any,
+        patch_auth_enabled: Callable[[], None],  # Sets AUTH_ENABLED "1"
+        fake_serializer: Serializer,
+        theme: str,
+        sub_theme: str,
+        topic: str,
+        metric: str,
+    ):
+        """
+        Given authentication is enabled
+        And the user has RBAC permissions for a specific metric
+        When `to_representation()` is called on the serializer
+        Then the result should be None, as no data matches the user’s permissions
+        """
+        # Given
+        infectious_disease_permission = RBACPermission.objects.create(
+            name="infectious_disease_permission",
+            theme=self.core_covid_19.metric.topic.sub_theme.theme,
+            sub_theme=self.core_covid_19.metric.topic.sub_theme,
+            topic=self.core_covid_19.metric.topic,
+            metric=self.core_covid_19.metric,
+        )
+        serializer = fake_serializer
+
+        test_data_public = copy.deepcopy(core_headline_data)
+        test_data_public["theme"] = theme
+        test_data_public["sub_theme"] = sub_theme
+        test_data_public["topic"] = topic
+        test_data_public["metric"] = metric
+        test_data_public["is_public"] = False
+
+        mock_request = MagicMock()
+        mock_request.group_permissions = [
+            infectious_disease_permission,
+            self.non_communicable_permission,
+        ]
+        serializer.context = {"request": mock_request}
+
+        # When
+        result = serializer.to_representation(test_data_public)
+
+        # Then
+        assert result == None
+
+    @pytest.mark.django_db
+    def test_filter_by_permissions_geography(
+        self: Any,
+        patch_auth_enabled: Callable[[], None],  # Sets AUTH_ENABLED "1"
+        fake_serializer: Serializer,
+    ):
+        """
+        Given authentication is enabled
+        And the user has RBAC permissions for a specific geography
+        When `to_representation()` is called on the serializer
+        Then only the data matching the user's geography permissions is returned
+        """
+        # Given
+        infectious_disease_permission = RBACPermission.objects.create(
+            name="infectious_disease_permission",
+            theme=self.core_covid_19.metric.topic.sub_theme.theme,
+            sub_theme=self.core_covid_19.metric.topic.sub_theme,
+            topic=self.core_covid_19.metric.topic,
+            metric=self.core_covid_19.metric,
+            geography=self.core_covid_19.geography,
+        )
+        serializer = fake_serializer
+
+        test_data_public = copy.deepcopy(core_headline_data)
+        test_data_public["geography"] = "England"
+        test_data_public["is_public"] = False
+
+        mock_request = MagicMock()
+        mock_request.group_permissions = [
+            infectious_disease_permission,
+            self.non_communicable_permission,
+        ]
+        serializer.context = {"request": mock_request}
+
+        # When
+        result = serializer.to_representation(test_data_public)
+
+        # Then
+        assert result == test_data_public
+        assert "is_public" not in result
+
+    @pytest.mark.django_db
+    def test_filter_by_permissions_geography_with_non_matching_permissions(
+        self: Any,
+        patch_auth_enabled: Callable[[], None],  # Sets AUTH_ENABLED "1"
+        fake_serializer: Serializer,
+    ):
+        """
+        Given authentication is enabled
+        And the user has RBAC permissions for a specific geography
+        When `to_representation()` is called on the serializer
+        Then the result should be None, as no data matches the user’s permissions
+        """
+        # Given
+        infectious_disease_permission = RBACPermission.objects.create(
+            name="infectious_disease_permission",
+            theme=self.core_covid_19.metric.topic.sub_theme.theme,
+            sub_theme=self.core_covid_19.metric.topic.sub_theme,
+            topic=self.core_covid_19.metric.topic,
+            metric=self.core_covid_19.metric,
+            geography=self.core_covid_19.geography,
+        )
+        serializer = fake_serializer
+
+        test_data_public = copy.deepcopy(core_headline_data)
+        test_data_public["geography"] = "Scotland"
+        test_data_public["is_public"] = False
+
+        mock_request = MagicMock()
+        mock_request.group_permissions = [
+            infectious_disease_permission,
+            self.non_communicable_permission,
+        ]
+        serializer.context = {"request": mock_request}
+
+        # When
+        result = serializer.to_representation(test_data_public)
+
+        # Then
+        assert result is None
+
+    @pytest.mark.django_db
+    def test_filter_by_permissions_geography_type(
+        self: Any,
+        patch_auth_enabled: Callable[[], None],  # Sets AUTH_ENABLED "1"
+        fake_serializer: Serializer,
+    ):
+        """
+        Given authentication is enabled
+        And the user has RBAC permissions for a specific geography type
+        When `to_representation()` is called on the serializer
+        Then only the data matching the user's geography type permissions is returned
+        """
+        # Given
+        infectious_disease_permission = RBACPermission.objects.create(
+            name="infectious_disease_permission",
+            theme=self.core_covid_19.metric.topic.sub_theme.theme,
+            sub_theme=self.core_covid_19.metric.topic.sub_theme,
+            topic=self.core_covid_19.metric.topic,
+            metric=self.core_covid_19.metric,
+            geography=self.core_covid_19.geography,
+            geography_type=self.core_covid_19.geography.geography_type,
+        )
+        serializer = fake_serializer
+
+        test_data_public = copy.deepcopy(core_headline_data)
+        test_data_public["geography_type"] = "Nation"
+        test_data_public["is_public"] = False
+
+        mock_request = MagicMock()
+        mock_request.group_permissions = [
+            infectious_disease_permission,
+            self.non_communicable_permission,
+        ]
+        serializer.context = {"request": mock_request}
+
+        # When
+        result = serializer.to_representation(test_data_public)
+
+        # Then
+        assert result == test_data_public
+        assert "is_public" not in result
+
+    @pytest.mark.django_db
+    def test_filter_by_permissions_geography_type_with_non_matching_permissions(
+        self: Any,
+        patch_auth_enabled: Callable[[], None],  # Sets AUTH_ENABLED "1"
+        fake_serializer: Serializer,
+    ):
+        """
+        Given authentication is enabled
+        And the user has RBAC permissions for a specific geography type
+        When `to_representation()` is called on the serializer
+        Then the result should be None, as no data matches the user’s permissions
+        """
+        # Given
+        infectious_disease_permission = RBACPermission.objects.create(
+            name="infectious_disease_permission",
+            theme=self.core_covid_19.metric.topic.sub_theme.theme,
+            sub_theme=self.core_covid_19.metric.topic.sub_theme,
+            topic=self.core_covid_19.metric.topic,
+            metric=self.core_covid_19.metric,
+            geography=self.core_covid_19.geography,
+            geography_type=self.core_covid_19.geography.geography_type,
+        )
+        serializer = fake_serializer
+
+        test_data_public = copy.deepcopy(core_headline_data)
+        test_data_public["geography_type"] = "Local"
+        test_data_public["is_public"] = False
+
+        mock_request = MagicMock()
+        mock_request.group_permissions = [
+            infectious_disease_permission,
+            self.non_communicable_permission,
+        ]
+        serializer.context = {"request": mock_request}
+
+        # When
+        result = serializer.to_representation(test_data_public)
+
+        # Then
+        assert result is None
+
+    @pytest.mark.django_db
+    def test_filter_by_permissions_age(
+        self: Any,
+        patch_auth_enabled: Callable[[], None],  # Sets AUTH_ENABLED "1"
+        fake_serializer: Serializer,
+    ):
+        """
+        Given authentication is enabled
+        And the user has RBAC permissions for a specific age group
+        When `to_representation()` is called on the serializer
+        Then only the data matching the user's age permissions is returned
+        """
+        # Given
+        infectious_disease_permission = RBACPermission.objects.create(
+            name="infectious_disease_permission",
+            theme=self.core_covid_19.metric.topic.sub_theme.theme,
+            sub_theme=self.core_covid_19.metric.topic.sub_theme,
+            topic=self.core_covid_19.metric.topic,
+            metric=self.core_covid_19.metric,
+            geography=self.core_covid_19.geography,
+            geography_type=self.core_covid_19.geography.geography_type,
+            age=self.core_covid_19.age,
+        )
+        serializer = fake_serializer
+
+        test_data_public = copy.deepcopy(core_headline_data)
+        test_data_public["age"] = "all"
+        test_data_public["is_public"] = False
+
+        mock_request = MagicMock()
+        mock_request.group_permissions = [
+            infectious_disease_permission,
+            self.non_communicable_permission,
+        ]
+        serializer.context = {"request": mock_request}
+
+        # When
+        result = serializer.to_representation(test_data_public)
+
+        # Then
+        assert result == test_data_public
+        assert "is_public" not in result
+
+    @pytest.mark.django_db
+    def test_filter_by_permissions_age_with_no_matching_permissions(
+        self: Any,
+        patch_auth_enabled: Callable[[], None],  # Sets AUTH_ENABLED "1"
+        fake_serializer: Serializer,
+    ):
+        """
+        Given authentication is enabled
+        And the user has RBAC permissions for a specific age group
+        When `to_representation()` is called on the serializer
+        Then the result should be None, as no data matches the user’s permissions
+        """
+        # Given
+        infectious_disease_permission = RBACPermission.objects.create(
+            name="infectious_disease_permission",
+            theme=self.core_covid_19.metric.topic.sub_theme.theme,
+            sub_theme=self.core_covid_19.metric.topic.sub_theme,
+            topic=self.core_covid_19.metric.topic,
+            metric=self.core_covid_19.metric,
+            geography=self.core_covid_19.geography,
+            geography_type=self.core_covid_19.geography.geography_type,
+            age=self.core_covid_19.age,
+        )
+        serializer = fake_serializer
+
+        test_data_public = copy.deepcopy(core_headline_data)
+        test_data_public["age"] = "01-04"
+        test_data_public["is_public"] = False
+
+        mock_request = MagicMock()
+        mock_request.group_permissions = [
+            infectious_disease_permission,
+            self.non_communicable_permission,
+        ]
+        serializer.context = {"request": mock_request}
+
+        # When
+        result = serializer.to_representation(test_data_public)
+
+        # Then
+        assert result is None
+
+    @pytest.mark.django_db
+    def test_filter_by_permissions_age(
+        self: Any,
+        patch_auth_enabled: Callable[[], None],  # Sets AUTH_ENABLED "1"
+        fake_serializer: Serializer,
+    ):
+        """
+        Given authentication is enabled
+        And the user has RBAC permissions for a specific stratum
+        When `to_representation()` is called on the serializer
+        Then only the data matching the user's stratum permissions is returned
+        """
+        # Given
+        infectious_disease_permission = RBACPermission.objects.create(
+            name="infectious_disease_permission",
+            theme=self.core_covid_19.metric.topic.sub_theme.theme,
+            sub_theme=self.core_covid_19.metric.topic.sub_theme,
+            topic=self.core_covid_19.metric.topic,
+            metric=self.core_covid_19.metric,
+            geography=self.core_covid_19.geography,
+            geography_type=self.core_covid_19.geography.geography_type,
+            age=self.core_covid_19.age,
+            stratum=self.core_covid_19.stratum,
+        )
+
+        serializer = fake_serializer
+
+        test_data_public = copy.deepcopy(core_headline_data)
+        test_data_public["stratum"] = "default"
+        test_data_public["is_public"] = False
+
+        mock_request = MagicMock()
+        mock_request.group_permissions = [
+            infectious_disease_permission,
+            self.non_communicable_permission,
+        ]
+        serializer.context = {"request": mock_request}
+
+        # When
+        result = serializer.to_representation(test_data_public)
+
+        # Then
+        assert result == test_data_public
+        assert "is_public" not in result
+
+    @pytest.mark.django_db
+    def test_filter_by_permissions_age_with_not_matching_permissions(
+        self: Any,
+        patch_auth_enabled: Callable[[], None],  # Sets AUTH_ENABLED "1"
+        fake_serializer: Serializer,
+    ):
+        """
+        Given authentication is enabled
+        And the user has RBAC permissions for a specific stratum
+        When `to_representation()` is called on the serializer
+        Then the result should be None, as no data matches the user’s permissions
+        """
+        # Given
+        infectious_disease_permission = RBACPermission.objects.create(
+            name="infectious_disease_permission",
+            theme=self.core_covid_19.metric.topic.sub_theme.theme,
+            sub_theme=self.core_covid_19.metric.topic.sub_theme,
+            topic=self.core_covid_19.metric.topic,
+            metric=self.core_covid_19.metric,
+            geography=self.core_covid_19.geography,
+            geography_type=self.core_covid_19.geography.geography_type,
+            age=self.core_covid_19.age,
+            stratum=self.core_covid_19.stratum,
+        )
+
+        serializer = fake_serializer
+
+        test_data_public = copy.deepcopy(core_headline_data)
+        test_data_public["stratum"] = "other"
+        test_data_public["is_public"] = False
+
+        mock_request = MagicMock()
+        mock_request.group_permissions = [
+            infectious_disease_permission,
+            self.non_communicable_permission,
+        ]
+        serializer.context = {"request": mock_request}
+
+        # When
+        result = serializer.to_representation(test_data_public)
+
+        # Then
+        assert result is None
+
+    @pytest.mark.django_db
+    def test_filter_by_permissions_returns_none_if_no_permissions(
+        self: Any,
+        patch_auth_enabled: Callable[[], None],  # Sets AUTH_ENABLED "1"
+        fake_serializer: Serializer,
+    ):
+        """
+        Given `request.group_permissions` is empty
+        When `to_representation()` is called on the serializer
+        Then it should return None
+        """
+        # Given
+        serializer = fake_serializer
+        test_data = copy.deepcopy(core_headline_data)
+        test_data["is_public"] = False  # Ensure private data
+
+        # Simulate an empty group_permissions list
+        mock_request = MagicMock()
+        mock_request.group_permissions = []  # No permissions available
+        serializer.context = {"request": mock_request}
+
+        # When
+        result = serializer.to_representation(test_data)
+
+        # Then
+        assert result is None
+
+    @pytest.mark.django_db
+    def test_filter_by_permissions_allows_access_when_public(
+        self: Any,
+        patch_auth_enabled: Callable[[], None],  # Ensures `AUTH_ENABLED=1`
+        fake_serializer: Serializer,
+    ):
+        """
+        Given `is_public` is `True`
+        When `to_representation()` is called on the serializer
+        Then it should return the data with `is_public` removed (without checking permissions)
+        """
+
+        # Given
+        serializer = fake_serializer
+        test_data = copy.deepcopy(core_headline_data)
+        test_data["is_public"] = True
+
+        mock_request = MagicMock()
+        mock_request.group_permissions = []
+        serializer.context = {"request": mock_request}
+
+        # When
+        result = serializer.to_representation(test_data)
+
+        # Then
+        assert result is not None
+        assert "is_public" not in result
+
+    @pytest.mark.django_db
+    @pytest.mark.parametrize("is_public_value", [None, False])
+    def test_filter_by_permissions_requires_permissions_when_not_public(
+        self: Any,
+        patch_auth_enabled: Callable[[], None],  # Ensures `AUTH_ENABLED=1`
+        fake_serializer: Serializer,
+        is_public_value: bool,
+    ):
+        """
+        Given `is_public` is `None` or `False`
+        When `to_representation()` is called on the serializer
+        Then if the user lacks permissions, return None
+        """
+
+        # Given
+        serializer = fake_serializer
+        test_data = copy.deepcopy(core_headline_data)
+        test_data["is_public"] = is_public_value
+
+        mock_request = MagicMock()
+
+        # When
+        mock_request.group_permissions = []
+        serializer.context = {"request": mock_request}
+        result = serializer.to_representation(test_data)
+
+        # Then
+        assert result is None
diff --git a/tests/integration/metrics/api/packages/__init__.py b/tests/integration/metrics/api/packages/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/integration/metrics/api/packages/permissions/__init__.py b/tests/integration/metrics/api/packages/permissions/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/integration/metrics/api/packages/permissions/test_permission_actions.py b/tests/integration/metrics/api/packages/permissions/test_permission_actions.py
new file mode 100644
index 000000000..5c27c1e35
--- /dev/null
+++ b/tests/integration/metrics/api/packages/permissions/test_permission_actions.py
@@ -0,0 +1,143 @@
+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
new file mode 100644
index 000000000..637faa066
--- /dev/null
+++ b/tests/integration/metrics/api/packages/permissions/test_permissions_fluent.py
@@ -0,0 +1,219 @@
+import pytest
+from unittest import mock
+from metrics.data.models.rbac_models import RBACPermission
+from metrics.api.packages.permissions import (
+    FluentPermissions,
+    FluentPermissionsError,
+    new_match_dict,
+)
+
+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):
+        """Creates a mock RBACPermission object"""
+        permission = mock.Mock(spec=RBACPermission)
+        permission.theme.name = "infectious_disease"
+        permission.sub_theme.name = "respiratory"
+        permission.topic.name = "COVID-19"
+        permission.metric.name = "COVID-19_cases_casesByDay"
+        permission.geography_type.name = "Nation"
+        permission.geography.name = "England"
+        permission.age.name = "all"
+        permission.stratum.name = "default"
+        return permission
+
+    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):
+        """
+        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):
+        """
+        Given a FluentPermissions instance with a non-matching theme/subtheme
+        When execute() is called
+        Then the permissions check should be skipped
+        """
+        # Given
+        data = {"theme": "Finance", "sub_theme": "Investment"}
+        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_passes_when_match_exists(self, fake_rbac_permission):
+        """
+        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
+
+    @mock.patch(f"{MODULE_PATH}.MatchFieldAction")
+    @mock.patch(f"{MODULE_PATH}.MatchThemeSubThemeFieldsAction")
+    def test_execute_calls_correct_actions(
+        self, MockThemeSubthemeAction, MockFieldAction, fake_rbac_permission
+    ):
+        """
+        Given a FluentPermissions instance
+        When execute() is called
+        Then it should invoke the correct action classes
+        """
+        # Given
+        mock_theme_action = MockThemeSubthemeAction.return_value
+        mock_field_action = MockFieldAction.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
+        MockThemeSubthemeAction.assert_called_once_with(data=self.data)
+        MockFieldAction.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, expected",
+        [
+            ({"theme": "infectious_disease"}, False),
+            ({"sub_theme": "respiratory"}, False),
+            ({}, False),
+        ],
+    )
+    def test_match_theme_sub_theme_returns_false_when_keys_missing(
+        self, fake_rbac_permission, data, expected
+    ):
+        """
+        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 expected


Reviews:
user: login: A-Ashiq
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T13:33:35Z
user: login: A-Ashiq
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T13:33:49Z
user: login: A-Ashiq
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T13:35:26Z
user: login: A-Ashiq
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T13:37:25Z
user: login: A-Ashiq
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T13:39:06Z
user: login: A-Ashiq
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T13:40:07Z
user: login: A-Ashiq
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T13:41:24Z
user: login: A-Ashiq
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T13:42:18Z
user: login: A-Ashiq
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T13:50:47Z
user: login: A-Ashiq
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T13:51:24Z
user: login: A-Ashiq
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T13:52:07Z
user: login: A-Ashiq
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T13:53:07Z
user: login: A-Ashiq
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T13:53:26Z
user: login: A-Ashiq
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T13:53:33Z
user: login: A-Ashiq
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T14:15:23Z
user: login: mxrman
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T15:06:16Z
user: login: mxrman
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T15:12:05Z
user: login: mxrman
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T16:36:01Z
user: login: A-Ashiq
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T10:05:30Z

Review comments:
diff_hunk: @@ -0,0 +1,131 @@
+# 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": "Finance", "sub_theme": "Banking" }
path: metrics/api/packages/permissions/README.md
user: login: A-Ashiq
body: **Nitpick**: Maybe pick some more suitable / related examples than finance and banking here?
created_at: 2025-03-12T13:33:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,131 @@
+# 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
path: metrics/api/packages/permissions/README.md
user: login: A-Ashiq
body: This doc is really good 👍 
created_at: 2025-03-12T13:33:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,216 @@
+from typing import Any
+
+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.
path: metrics/api/packages/permissions/permissions_fluent.py
user: login: A-Ashiq
body: Are there any situations in which we wouldn't want to check all the fields in the given `data`?
created_at: 2025-03-12T13:35:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,216 @@
+from typing import Any
+
+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.
+    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: Any, group_permissions: list[RBACPermission]):
path: metrics/api/packages/permissions/permissions_fluent.py
user: login: A-Ashiq
body: Can the `data` arg be `Any`? Don't we always expect it to be `dict[str, str]`?
created_at: 2025-03-12T13:37:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,216 @@
+from typing import Any
+
+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.
+    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: Any, 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.
path: metrics/api/packages/permissions/permissions_fluent.py
user: login: A-Ashiq
body: Well done for adding an explanation of why we need to return the object itself back 👏 
created_at: 2025-03-12T13:39:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,216 @@
+from typing import Any
+
+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.
+    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: Any, 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":
+        """
path: metrics/api/packages/permissions/permissions_fluent.py
user: login: A-Ashiq
body: Our docstrings are typically written with a 1 line-summary as the first thing
created_at: 2025-03-12T13:40:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,216 @@
+from typing import Any
+
+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.
+    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: Any, 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":
+        """
+        Given a set of user permissions (`group_permissions`)
+        When `execute()` is called
+        Then it evaluates field matches and stores results in `_match_fields_all`
+
+        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):
path: metrics/api/packages/permissions/permissions_fluent.py
user: login: A-Ashiq
body: At some point we're gonna have to rethink how we expand this out to the other categories, metric, geography etc. But for now this looks fine
created_at: 2025-03-12T13:41:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,216 @@
+from typing import Any
+
+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.
+    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: Any, 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":
+        """
+        Given a set of user permissions (`group_permissions`)
path: metrics/api/packages/permissions/permissions_fluent.py
user: login: A-Ashiq
body: Did you mean to put what looks like a test given/when/then docstring in the method itself?
created_at: 2025-03-12T13:42:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,553 @@
+import pytest
+import copy
+from unittest.mock import MagicMock
+
+from metrics.api.decorators import (
+    filter_by_permissions,
+)
+from tests.factories.metrics.time_series import CoreTimeSeriesFactory
+from metrics.data.models.rbac_models import RBACPermission
+from metrics.data.models.core_models import (
+    Theme,
+    SubTheme,
+    Topic,
+)
+
+MODULE_PATH = "metrics.api.settings.private_api"
+
+core_headline_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",
+    "metric_value": 123.45,
+    "period_start": "2024-01-01 00:00:00",
+    "period_end": "2024-02-02 00:00:00",
+    "is_public": True,
+}
+
+
+class FakeSerializer:
+    def __init__(self, *args, **kwargs):
+        self.context = kwargs.get("context", {})
+
+    def to_representation(self, instance):
+        return instance
+
+
+@pytest.fixture
+def mock_request():
+    mock_req = MagicMock()
+    mock_req.group_permissions = [MagicMock()]
+    return mock_req
+
+
+@pytest.fixture
+def fake_serializer(mock_request):
+    @filter_by_permissions()
+    class WrappedSerializer(FakeSerializer):
+        pass
+
+    return WrappedSerializer(context={"request": mock_request})
+
+
+class TestPermissions:
+
+    @pytest.fixture(autouse=True)
+    def setup(self):
+        # Theme
+        self.non_communicable = Theme.objects.create(name="non-communicable")
+
+        # Subtheme
+        self.respiratory_comm = SubTheme.objects.create(
+            name="respiratory", theme=self.non_communicable
+        )
+
+        # Topic
+        self.asthma = Topic.objects.create(
+            name="asthma", sub_theme=self.respiratory_comm
+        )
+
+        # CoreTimeSeries
+        self.core_covid_19 = CoreTimeSeriesFactory.create_record()
+
+        self.cor_asthma = CoreTimeSeriesFactory.create_record(
path: tests/integration/metrics/api/decorators/test_permissions.py
user: login: A-Ashiq
body: ```suggestion
        self.core_asthma = CoreTimeSeriesFactory.create_record(
```
created_at: 2025-03-12T13:50:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,553 @@
+import pytest
+import copy
+from unittest.mock import MagicMock
+
+from metrics.api.decorators import (
+    filter_by_permissions,
+)
+from tests.factories.metrics.time_series import CoreTimeSeriesFactory
+from metrics.data.models.rbac_models import RBACPermission
+from metrics.data.models.core_models import (
+    Theme,
+    SubTheme,
+    Topic,
+)
+
+MODULE_PATH = "metrics.api.settings.private_api"
+
+core_headline_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",
+    "metric_value": 123.45,
+    "period_start": "2024-01-01 00:00:00",
+    "period_end": "2024-02-02 00:00:00",
+    "is_public": True,
+}
+
+
+class FakeSerializer:
+    def __init__(self, *args, **kwargs):
+        self.context = kwargs.get("context", {})
+
+    def to_representation(self, instance):
+        return instance
+
+
+@pytest.fixture
+def mock_request():
+    mock_req = MagicMock()
+    mock_req.group_permissions = [MagicMock()]
+    return mock_req
+
+
+@pytest.fixture
+def fake_serializer(mock_request):
+    @filter_by_permissions()
+    class WrappedSerializer(FakeSerializer):
+        pass
+
+    return WrappedSerializer(context={"request": mock_request})
+
+
+class TestPermissions:
+
+    @pytest.fixture(autouse=True)
+    def setup(self):
+        # Theme
+        self.non_communicable = Theme.objects.create(name="non-communicable")
+
+        # Subtheme
+        self.respiratory_comm = SubTheme.objects.create(
+            name="respiratory", theme=self.non_communicable
+        )
+
+        # Topic
+        self.asthma = Topic.objects.create(
+            name="asthma", sub_theme=self.respiratory_comm
+        )
+
+        # CoreTimeSeries
+        self.core_covid_19 = CoreTimeSeriesFactory.create_record()
+
+        self.cor_asthma = CoreTimeSeriesFactory.create_record(
+            theme_name=self.non_communicable.name,
+            sub_theme_name=self.respiratory_comm.name,
+            topic_name=self.asthma.name,
+            metric_name="COVID-19_cases_casesByDay2",
+        )
+
+        self.non_communicable_permission = RBACPermission.objects.create(
+            name="medical",
+            theme=self.non_communicable,
+            sub_theme=self.respiratory_comm,
+        )
+
+    @pytest.mark.django_db
+    def test_filter_by_permissions_non_private_api(
+        self, patch_auth_disabled, fake_serializer
path: tests/integration/metrics/api/decorators/test_permissions.py
user: login: A-Ashiq
body: **Nitpick**: Can you type hint these?
created_at: 2025-03-12T13:51:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,553 @@
+import pytest
+import copy
+from unittest.mock import MagicMock
+
+from metrics.api.decorators import (
+    filter_by_permissions,
+)
+from tests.factories.metrics.time_series import CoreTimeSeriesFactory
+from metrics.data.models.rbac_models import RBACPermission
+from metrics.data.models.core_models import (
+    Theme,
+    SubTheme,
+    Topic,
+)
+
+MODULE_PATH = "metrics.api.settings.private_api"
+
+core_headline_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",
+    "metric_value": 123.45,
+    "period_start": "2024-01-01 00:00:00",
+    "period_end": "2024-02-02 00:00:00",
+    "is_public": True,
+}
+
+
+class FakeSerializer:
+    def __init__(self, *args, **kwargs):
+        self.context = kwargs.get("context", {})
+
+    def to_representation(self, instance):
+        return instance
+
+
+@pytest.fixture
+def mock_request():
+    mock_req = MagicMock()
+    mock_req.group_permissions = [MagicMock()]
+    return mock_req
+
+
+@pytest.fixture
+def fake_serializer(mock_request):
+    @filter_by_permissions()
+    class WrappedSerializer(FakeSerializer):
+        pass
+
+    return WrappedSerializer(context={"request": mock_request})
+
+
+class TestPermissions:
+
+    @pytest.fixture(autouse=True)
+    def setup(self):
+        # Theme
+        self.non_communicable = Theme.objects.create(name="non-communicable")
+
+        # Subtheme
+        self.respiratory_comm = SubTheme.objects.create(
+            name="respiratory", theme=self.non_communicable
+        )
+
+        # Topic
+        self.asthma = Topic.objects.create(
+            name="asthma", sub_theme=self.respiratory_comm
+        )
+
+        # CoreTimeSeries
+        self.core_covid_19 = CoreTimeSeriesFactory.create_record()
+
+        self.cor_asthma = CoreTimeSeriesFactory.create_record(
+            theme_name=self.non_communicable.name,
+            sub_theme_name=self.respiratory_comm.name,
+            topic_name=self.asthma.name,
+            metric_name="COVID-19_cases_casesByDay2",
+        )
+
+        self.non_communicable_permission = RBACPermission.objects.create(
+            name="medical",
+            theme=self.non_communicable,
+            sub_theme=self.respiratory_comm,
+        )
+
+    @pytest.mark.django_db
+    def test_filter_by_permissions_non_private_api(
+        self, patch_auth_disabled, fake_serializer
+    ):
+        """
+        Given `AUTH_ENABLED` is disabled
+        When `to_representation()` is called on the serializer
+        Then the `is_public` field is removed and all other fields are returned
+        """
+        # Given
+        serializer = fake_serializer
+
+        # When
+        result = serializer.to_representation(core_headline_data)
+
+        # Then
+        assert "is_public" not in result, "is_public should be removed"
+        assert result == core_headline_data, "all fields should be returned"
+
+    @pytest.mark.parametrize(
+        "theme,sub_theme,should_return",
+        [
+            ("infectious_disease", "respiratory", True),
+            ("infectious_disease", "vaccine_preventable", False),
+            ("non-communicable", "respiratory", True),
+            ("non-communicable", "respiratory_other", False),
+        ],
+    )
+    @pytest.mark.django_db
+    def test_filter_by_permissions_theme_sub_theme(
+        self, patch_auth_enabled, fake_serializer, theme, sub_theme, should_return
+    ):
+        """
+        Given authentication is enabled
+        And the user has matching RBAC permissions for theme & sub_theme
+        When `to_representation()` is called on the serializer
+        Then only the data matching the user’s permissions is returned
+        """
+        # Given
+        infectious_disease_permission = RBACPermission.objects.create(
+            name="infectious_disease_permission",
+            theme=self.core_covid_19.metric.topic.sub_theme.theme,
+            sub_theme=self.core_covid_19.metric.topic.sub_theme,
+        )
+        serializer = fake_serializer
+
+        test_data_public = copy.deepcopy(core_headline_data)
+        test_data_public["theme"] = theme
+        test_data_public["sub_theme"] = sub_theme
+        test_data_public["is_public"] = False
+
+        mock_request = MagicMock()
+        mock_request.group_permissions = [
+            infectious_disease_permission,
+            self.non_communicable_permission,
+        ]
+        serializer.context = {"request": mock_request}
+
+        # When
+        result = serializer.to_representation(test_data_public)
+
+        # Then
+        if should_return:
path: tests/integration/metrics/api/decorators/test_permissions.py
user: login: A-Ashiq
body: Generally speaking, we should not have conditional logic in tests. If we do that tends to be a sign that we should break the test up
created_at: 2025-03-12T13:52:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,553 @@
+import pytest
+import copy
+from unittest.mock import MagicMock
+
+from metrics.api.decorators import (
+    filter_by_permissions,
+)
+from tests.factories.metrics.time_series import CoreTimeSeriesFactory
+from metrics.data.models.rbac_models import RBACPermission
+from metrics.data.models.core_models import (
+    Theme,
+    SubTheme,
+    Topic,
+)
+
+MODULE_PATH = "metrics.api.settings.private_api"
+
+core_headline_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",
+    "metric_value": 123.45,
+    "period_start": "2024-01-01 00:00:00",
+    "period_end": "2024-02-02 00:00:00",
+    "is_public": True,
+}
+
+
+class FakeSerializer:
+    def __init__(self, *args, **kwargs):
+        self.context = kwargs.get("context", {})
+
+    def to_representation(self, instance):
+        return instance
+
+
+@pytest.fixture
+def mock_request():
+    mock_req = MagicMock()
+    mock_req.group_permissions = [MagicMock()]
+    return mock_req
+
+
+@pytest.fixture
+def fake_serializer(mock_request):
+    @filter_by_permissions()
+    class WrappedSerializer(FakeSerializer):
+        pass
+
+    return WrappedSerializer(context={"request": mock_request})
+
+
+class TestPermissions:
+
+    @pytest.fixture(autouse=True)
+    def setup(self):
+        # Theme
+        self.non_communicable = Theme.objects.create(name="non-communicable")
+
+        # Subtheme
+        self.respiratory_comm = SubTheme.objects.create(
+            name="respiratory", theme=self.non_communicable
+        )
+
+        # Topic
+        self.asthma = Topic.objects.create(
+            name="asthma", sub_theme=self.respiratory_comm
+        )
+
+        # CoreTimeSeries
+        self.core_covid_19 = CoreTimeSeriesFactory.create_record()
+
+        self.cor_asthma = CoreTimeSeriesFactory.create_record(
+            theme_name=self.non_communicable.name,
+            sub_theme_name=self.respiratory_comm.name,
+            topic_name=self.asthma.name,
+            metric_name="COVID-19_cases_casesByDay2",
+        )
+
+        self.non_communicable_permission = RBACPermission.objects.create(
+            name="medical",
+            theme=self.non_communicable,
+            sub_theme=self.respiratory_comm,
+        )
+
+    @pytest.mark.django_db
+    def test_filter_by_permissions_non_private_api(
+        self, patch_auth_disabled, fake_serializer
+    ):
+        """
+        Given `AUTH_ENABLED` is disabled
+        When `to_representation()` is called on the serializer
+        Then the `is_public` field is removed and all other fields are returned
+        """
+        # Given
+        serializer = fake_serializer
+
+        # When
+        result = serializer.to_representation(core_headline_data)
+
+        # Then
+        assert "is_public" not in result, "is_public should be removed"
+        assert result == core_headline_data, "all fields should be returned"
+
+    @pytest.mark.parametrize(
+        "theme,sub_theme,should_return",
+        [
+            ("infectious_disease", "respiratory", True),
+            ("infectious_disease", "vaccine_preventable", False),
+            ("non-communicable", "respiratory", True),
+            ("non-communicable", "respiratory_other", False),
+        ],
+    )
+    @pytest.mark.django_db
+    def test_filter_by_permissions_theme_sub_theme(
+        self, patch_auth_enabled, fake_serializer, theme, sub_theme, should_return
+    ):
+        """
+        Given authentication is enabled
+        And the user has matching RBAC permissions for theme & sub_theme
+        When `to_representation()` is called on the serializer
+        Then only the data matching the user’s permissions is returned
+        """
+        # Given
+        infectious_disease_permission = RBACPermission.objects.create(
+            name="infectious_disease_permission",
+            theme=self.core_covid_19.metric.topic.sub_theme.theme,
+            sub_theme=self.core_covid_19.metric.topic.sub_theme,
+        )
+        serializer = fake_serializer
+
+        test_data_public = copy.deepcopy(core_headline_data)
+        test_data_public["theme"] = theme
+        test_data_public["sub_theme"] = sub_theme
+        test_data_public["is_public"] = False
+
+        mock_request = MagicMock()
+        mock_request.group_permissions = [
+            infectious_disease_permission,
+            self.non_communicable_permission,
+        ]
+        serializer.context = {"request": mock_request}
+
+        # When
+        result = serializer.to_representation(test_data_public)
+
+        # Then
+        if should_return:
+            assert result == test_data_public
+            assert "is_public" not in result
+        else:
+            assert result is None
+
+    @pytest.mark.parametrize(
+        "theme,sub_theme,topic,should_return",
+        [
+            ("infectious_disease", "respiratory", "COVID-19", True),
+            ("infectious_disease", "vaccine_preventable", "Measles", False),
+            ("non-communicable", "respiratory", "COVID-19", True),
+            ("non-communicable", "respiratory_other", "COVID-19", False),
+        ],
+    )
+    @pytest.mark.django_db
+    def test_filter_by_permissions_topic(
+        self,
+        patch_auth_enabled,
+        fake_serializer,
+        theme,
+        sub_theme,
+        topic,
+        should_return,
+    ):
+        """
+        Given authentication is enabled
+        And the user has RBAC permissions for a specific topic
+        When `to_representation()` is called on the serializer
+        Then only the data matching the user's topic permissions is returned
+        """
+        # Given
+        infectious_disease_permission = RBACPermission.objects.create(
+            name="infectious_disease_permission",
+            theme=self.core_covid_19.metric.topic.sub_theme.theme,
+            sub_theme=self.core_covid_19.metric.topic.sub_theme,
+            topic=self.core_covid_19.metric.topic,
+        )
+        serializer = fake_serializer
+
+        test_data_public = copy.deepcopy(core_headline_data)
+        test_data_public["theme"] = theme
+        test_data_public["sub_theme"] = sub_theme
+        test_data_public["topic"] = topic
+        test_data_public["is_public"] = False
+
+        mock_request = MagicMock()
+        mock_request.group_permissions = [
+            infectious_disease_permission,
+            self.non_communicable_permission,
+        ]
+        serializer.context = {"request": mock_request}
+
+        # When
+        result = serializer.to_representation(test_data_public)
+
+        # Then
+        if should_return:
path: tests/integration/metrics/api/decorators/test_permissions.py
user: login: A-Ashiq
body: Same comment as above regarding conditional logic in tests
created_at: 2025-03-12T13:53:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,553 @@
+import pytest
+import copy
+from unittest.mock import MagicMock
+
+from metrics.api.decorators import (
+    filter_by_permissions,
+)
+from tests.factories.metrics.time_series import CoreTimeSeriesFactory
+from metrics.data.models.rbac_models import RBACPermission
+from metrics.data.models.core_models import (
+    Theme,
+    SubTheme,
+    Topic,
+)
+
+MODULE_PATH = "metrics.api.settings.private_api"
+
+core_headline_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",
+    "metric_value": 123.45,
+    "period_start": "2024-01-01 00:00:00",
+    "period_end": "2024-02-02 00:00:00",
+    "is_public": True,
+}
+
+
+class FakeSerializer:
+    def __init__(self, *args, **kwargs):
+        self.context = kwargs.get("context", {})
+
+    def to_representation(self, instance):
+        return instance
+
+
+@pytest.fixture
+def mock_request():
+    mock_req = MagicMock()
+    mock_req.group_permissions = [MagicMock()]
+    return mock_req
+
+
+@pytest.fixture
+def fake_serializer(mock_request):
+    @filter_by_permissions()
+    class WrappedSerializer(FakeSerializer):
+        pass
+
+    return WrappedSerializer(context={"request": mock_request})
+
+
+class TestPermissions:
+
+    @pytest.fixture(autouse=True)
+    def setup(self):
+        # Theme
+        self.non_communicable = Theme.objects.create(name="non-communicable")
+
+        # Subtheme
+        self.respiratory_comm = SubTheme.objects.create(
+            name="respiratory", theme=self.non_communicable
+        )
+
+        # Topic
+        self.asthma = Topic.objects.create(
+            name="asthma", sub_theme=self.respiratory_comm
+        )
+
+        # CoreTimeSeries
+        self.core_covid_19 = CoreTimeSeriesFactory.create_record()
+
+        self.cor_asthma = CoreTimeSeriesFactory.create_record(
+            theme_name=self.non_communicable.name,
+            sub_theme_name=self.respiratory_comm.name,
+            topic_name=self.asthma.name,
+            metric_name="COVID-19_cases_casesByDay2",
+        )
+
+        self.non_communicable_permission = RBACPermission.objects.create(
+            name="medical",
+            theme=self.non_communicable,
+            sub_theme=self.respiratory_comm,
+        )
+
+    @pytest.mark.django_db
+    def test_filter_by_permissions_non_private_api(
+        self, patch_auth_disabled, fake_serializer
+    ):
+        """
+        Given `AUTH_ENABLED` is disabled
+        When `to_representation()` is called on the serializer
+        Then the `is_public` field is removed and all other fields are returned
+        """
+        # Given
+        serializer = fake_serializer
+
+        # When
+        result = serializer.to_representation(core_headline_data)
+
+        # Then
+        assert "is_public" not in result, "is_public should be removed"
+        assert result == core_headline_data, "all fields should be returned"
+
+    @pytest.mark.parametrize(
+        "theme,sub_theme,should_return",
+        [
+            ("infectious_disease", "respiratory", True),
+            ("infectious_disease", "vaccine_preventable", False),
+            ("non-communicable", "respiratory", True),
+            ("non-communicable", "respiratory_other", False),
+        ],
+    )
+    @pytest.mark.django_db
+    def test_filter_by_permissions_theme_sub_theme(
+        self, patch_auth_enabled, fake_serializer, theme, sub_theme, should_return
+    ):
+        """
+        Given authentication is enabled
+        And the user has matching RBAC permissions for theme & sub_theme
+        When `to_representation()` is called on the serializer
+        Then only the data matching the user’s permissions is returned
+        """
+        # Given
+        infectious_disease_permission = RBACPermission.objects.create(
+            name="infectious_disease_permission",
+            theme=self.core_covid_19.metric.topic.sub_theme.theme,
+            sub_theme=self.core_covid_19.metric.topic.sub_theme,
+        )
+        serializer = fake_serializer
+
+        test_data_public = copy.deepcopy(core_headline_data)
+        test_data_public["theme"] = theme
+        test_data_public["sub_theme"] = sub_theme
+        test_data_public["is_public"] = False
+
+        mock_request = MagicMock()
+        mock_request.group_permissions = [
+            infectious_disease_permission,
+            self.non_communicable_permission,
+        ]
+        serializer.context = {"request": mock_request}
+
+        # When
+        result = serializer.to_representation(test_data_public)
+
+        # Then
+        if should_return:
+            assert result == test_data_public
+            assert "is_public" not in result
+        else:
+            assert result is None
+
+    @pytest.mark.parametrize(
+        "theme,sub_theme,topic,should_return",
+        [
+            ("infectious_disease", "respiratory", "COVID-19", True),
+            ("infectious_disease", "vaccine_preventable", "Measles", False),
+            ("non-communicable", "respiratory", "COVID-19", True),
+            ("non-communicable", "respiratory_other", "COVID-19", False),
+        ],
+    )
+    @pytest.mark.django_db
+    def test_filter_by_permissions_topic(
+        self,
+        patch_auth_enabled,
+        fake_serializer,
+        theme,
+        sub_theme,
+        topic,
+        should_return,
+    ):
+        """
+        Given authentication is enabled
+        And the user has RBAC permissions for a specific topic
+        When `to_representation()` is called on the serializer
+        Then only the data matching the user's topic permissions is returned
+        """
+        # Given
+        infectious_disease_permission = RBACPermission.objects.create(
+            name="infectious_disease_permission",
+            theme=self.core_covid_19.metric.topic.sub_theme.theme,
+            sub_theme=self.core_covid_19.metric.topic.sub_theme,
+            topic=self.core_covid_19.metric.topic,
+        )
+        serializer = fake_serializer
+
+        test_data_public = copy.deepcopy(core_headline_data)
+        test_data_public["theme"] = theme
+        test_data_public["sub_theme"] = sub_theme
+        test_data_public["topic"] = topic
+        test_data_public["is_public"] = False
+
+        mock_request = MagicMock()
+        mock_request.group_permissions = [
+            infectious_disease_permission,
+            self.non_communicable_permission,
+        ]
+        serializer.context = {"request": mock_request}
+
+        # When
+        result = serializer.to_representation(test_data_public)
+
+        # Then
+        if should_return:
+            assert result == test_data_public
+            assert "is_public" not in result
+        else:
+            assert result is None
+
+    @pytest.mark.parametrize(
+        "theme,sub_theme,topic,metric,should_return",
+        [
+            (
+                "infectious_disease",
+                "respiratory",
+                "COVID-19",
+                "COVID-19_cases_casesByDay",
+                True,
+            ),
+            (
+                "infectious_disease",
+                "respiratory",
+                "COVID-19",
+                "COVID-20_cases_casesByDay2",
+                False,
+            ),
+            (
+                "non-communicable",
+                "respiratory",
+                "COVID-19",
+                "COVID-19_cases_casesByDay",
+                True,
+            ),
+            (
+                "non-communicable",
+                "respiratory_other",
+                "COVID-19",
+                "COVID-19_cases_casesByDay",
+                False,
+            ),
+        ],
+    )
+    @pytest.mark.django_db
+    def test_filter_by_permissions_metric(
+        self,
+        patch_auth_enabled,
+        fake_serializer,
+        theme,
+        sub_theme,
+        topic,
+        metric,
+        should_return,
+    ):
+        """
+        Given authentication is enabled
+        And the user has RBAC permissions for a specific metric
+        When `to_representation()` is called on the serializer
+        Then only the data matching the user's metric permissions is returned
+        """
+        # Given
+        infectious_disease_permission = RBACPermission.objects.create(
+            name="infectious_disease_permission",
+            theme=self.core_covid_19.metric.topic.sub_theme.theme,
+            sub_theme=self.core_covid_19.metric.topic.sub_theme,
+            topic=self.core_covid_19.metric.topic,
+            metric=self.core_covid_19.metric,
+        )
+        serializer = fake_serializer
+
+        test_data_public = copy.deepcopy(core_headline_data)
+        test_data_public["theme"] = theme
+        test_data_public["sub_theme"] = sub_theme
+        test_data_public["topic"] = topic
+        test_data_public["metric"] = metric
+        test_data_public["is_public"] = False
+
+        mock_request = MagicMock()
+        mock_request.group_permissions = [
+            infectious_disease_permission,
+            self.non_communicable_permission,
+        ]
+        serializer.context = {"request": mock_request}
+
+        # When
+        result = serializer.to_representation(test_data_public)
+
+        # Then
+        if should_return:
path: tests/integration/metrics/api/decorators/test_permissions.py
user: login: A-Ashiq
body: As above
created_at: 2025-03-12T13:53:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,553 @@
+import pytest
+import copy
+from unittest.mock import MagicMock
+
+from metrics.api.decorators import (
+    filter_by_permissions,
+)
+from tests.factories.metrics.time_series import CoreTimeSeriesFactory
+from metrics.data.models.rbac_models import RBACPermission
+from metrics.data.models.core_models import (
+    Theme,
+    SubTheme,
+    Topic,
+)
+
+MODULE_PATH = "metrics.api.settings.private_api"
+
+core_headline_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",
+    "metric_value": 123.45,
+    "period_start": "2024-01-01 00:00:00",
+    "period_end": "2024-02-02 00:00:00",
+    "is_public": True,
+}
+
+
+class FakeSerializer:
+    def __init__(self, *args, **kwargs):
+        self.context = kwargs.get("context", {})
+
+    def to_representation(self, instance):
+        return instance
+
+
+@pytest.fixture
+def mock_request():
+    mock_req = MagicMock()
+    mock_req.group_permissions = [MagicMock()]
+    return mock_req
+
+
+@pytest.fixture
+def fake_serializer(mock_request):
+    @filter_by_permissions()
+    class WrappedSerializer(FakeSerializer):
+        pass
+
+    return WrappedSerializer(context={"request": mock_request})
+
+
+class TestPermissions:
+
+    @pytest.fixture(autouse=True)
+    def setup(self):
+        # Theme
+        self.non_communicable = Theme.objects.create(name="non-communicable")
+
+        # Subtheme
+        self.respiratory_comm = SubTheme.objects.create(
+            name="respiratory", theme=self.non_communicable
+        )
+
+        # Topic
+        self.asthma = Topic.objects.create(
+            name="asthma", sub_theme=self.respiratory_comm
+        )
+
+        # CoreTimeSeries
+        self.core_covid_19 = CoreTimeSeriesFactory.create_record()
+
+        self.cor_asthma = CoreTimeSeriesFactory.create_record(
+            theme_name=self.non_communicable.name,
+            sub_theme_name=self.respiratory_comm.name,
+            topic_name=self.asthma.name,
+            metric_name="COVID-19_cases_casesByDay2",
+        )
+
+        self.non_communicable_permission = RBACPermission.objects.create(
+            name="medical",
+            theme=self.non_communicable,
+            sub_theme=self.respiratory_comm,
+        )
+
+    @pytest.mark.django_db
+    def test_filter_by_permissions_non_private_api(
+        self, patch_auth_disabled, fake_serializer
+    ):
+        """
+        Given `AUTH_ENABLED` is disabled
+        When `to_representation()` is called on the serializer
+        Then the `is_public` field is removed and all other fields are returned
+        """
+        # Given
+        serializer = fake_serializer
+
+        # When
+        result = serializer.to_representation(core_headline_data)
+
+        # Then
+        assert "is_public" not in result, "is_public should be removed"
+        assert result == core_headline_data, "all fields should be returned"
+
+    @pytest.mark.parametrize(
+        "theme,sub_theme,should_return",
+        [
+            ("infectious_disease", "respiratory", True),
+            ("infectious_disease", "vaccine_preventable", False),
+            ("non-communicable", "respiratory", True),
+            ("non-communicable", "respiratory_other", False),
+        ],
+    )
+    @pytest.mark.django_db
+    def test_filter_by_permissions_theme_sub_theme(
+        self, patch_auth_enabled, fake_serializer, theme, sub_theme, should_return
+    ):
+        """
+        Given authentication is enabled
+        And the user has matching RBAC permissions for theme & sub_theme
+        When `to_representation()` is called on the serializer
+        Then only the data matching the user’s permissions is returned
+        """
+        # Given
+        infectious_disease_permission = RBACPermission.objects.create(
+            name="infectious_disease_permission",
+            theme=self.core_covid_19.metric.topic.sub_theme.theme,
+            sub_theme=self.core_covid_19.metric.topic.sub_theme,
+        )
+        serializer = fake_serializer
+
+        test_data_public = copy.deepcopy(core_headline_data)
+        test_data_public["theme"] = theme
+        test_data_public["sub_theme"] = sub_theme
+        test_data_public["is_public"] = False
+
+        mock_request = MagicMock()
+        mock_request.group_permissions = [
+            infectious_disease_permission,
+            self.non_communicable_permission,
+        ]
+        serializer.context = {"request": mock_request}
+
+        # When
+        result = serializer.to_representation(test_data_public)
+
+        # Then
+        if should_return:
+            assert result == test_data_public
+            assert "is_public" not in result
+        else:
+            assert result is None
+
+    @pytest.mark.parametrize(
+        "theme,sub_theme,topic,should_return",
+        [
+            ("infectious_disease", "respiratory", "COVID-19", True),
+            ("infectious_disease", "vaccine_preventable", "Measles", False),
+            ("non-communicable", "respiratory", "COVID-19", True),
+            ("non-communicable", "respiratory_other", "COVID-19", False),
+        ],
+    )
+    @pytest.mark.django_db
+    def test_filter_by_permissions_topic(
+        self,
+        patch_auth_enabled,
+        fake_serializer,
+        theme,
+        sub_theme,
+        topic,
+        should_return,
+    ):
+        """
+        Given authentication is enabled
+        And the user has RBAC permissions for a specific topic
+        When `to_representation()` is called on the serializer
+        Then only the data matching the user's topic permissions is returned
+        """
+        # Given
+        infectious_disease_permission = RBACPermission.objects.create(
+            name="infectious_disease_permission",
+            theme=self.core_covid_19.metric.topic.sub_theme.theme,
+            sub_theme=self.core_covid_19.metric.topic.sub_theme,
+            topic=self.core_covid_19.metric.topic,
+        )
+        serializer = fake_serializer
+
+        test_data_public = copy.deepcopy(core_headline_data)
+        test_data_public["theme"] = theme
+        test_data_public["sub_theme"] = sub_theme
+        test_data_public["topic"] = topic
+        test_data_public["is_public"] = False
+
+        mock_request = MagicMock()
+        mock_request.group_permissions = [
+            infectious_disease_permission,
+            self.non_communicable_permission,
+        ]
+        serializer.context = {"request": mock_request}
+
+        # When
+        result = serializer.to_representation(test_data_public)
+
+        # Then
+        if should_return:
+            assert result == test_data_public
+            assert "is_public" not in result
+        else:
+            assert result is None
+
+    @pytest.mark.parametrize(
+        "theme,sub_theme,topic,metric,should_return",
+        [
+            (
+                "infectious_disease",
+                "respiratory",
+                "COVID-19",
+                "COVID-19_cases_casesByDay",
+                True,
+            ),
+            (
+                "infectious_disease",
+                "respiratory",
+                "COVID-19",
+                "COVID-20_cases_casesByDay2",
+                False,
+            ),
+            (
+                "non-communicable",
+                "respiratory",
+                "COVID-19",
+                "COVID-19_cases_casesByDay",
+                True,
+            ),
+            (
+                "non-communicable",
+                "respiratory_other",
+                "COVID-19",
+                "COVID-19_cases_casesByDay",
+                False,
+            ),
+        ],
+    )
+    @pytest.mark.django_db
+    def test_filter_by_permissions_metric(
+        self,
+        patch_auth_enabled,
+        fake_serializer,
+        theme,
+        sub_theme,
+        topic,
+        metric,
+        should_return,
+    ):
+        """
+        Given authentication is enabled
+        And the user has RBAC permissions for a specific metric
+        When `to_representation()` is called on the serializer
+        Then only the data matching the user's metric permissions is returned
+        """
+        # Given
+        infectious_disease_permission = RBACPermission.objects.create(
+            name="infectious_disease_permission",
+            theme=self.core_covid_19.metric.topic.sub_theme.theme,
+            sub_theme=self.core_covid_19.metric.topic.sub_theme,
+            topic=self.core_covid_19.metric.topic,
+            metric=self.core_covid_19.metric,
+        )
+        serializer = fake_serializer
+
+        test_data_public = copy.deepcopy(core_headline_data)
+        test_data_public["theme"] = theme
+        test_data_public["sub_theme"] = sub_theme
+        test_data_public["topic"] = topic
+        test_data_public["metric"] = metric
+        test_data_public["is_public"] = False
+
+        mock_request = MagicMock()
+        mock_request.group_permissions = [
+            infectious_disease_permission,
+            self.non_communicable_permission,
+        ]
+        serializer.context = {"request": mock_request}
+
+        # When
+        result = serializer.to_representation(test_data_public)
+
+        # Then
+        if should_return:
+            assert result == test_data_public
+            assert "is_public" not in result
+        else:
+            assert result is None
+
+    @pytest.mark.parametrize(
+        "geography,should_return",
+        [
+            ("England", True),
+            ("Scotland", False),
+        ],
+    )
+    @pytest.mark.django_db
+    def test_filter_by_permissions_geography(
+        self, patch_auth_enabled, fake_serializer, geography, should_return
+    ):
+        """
+        Given authentication is enabled
+        And the user has RBAC permissions for a specific geography
+        When `to_representation()` is called on the serializer
+        Then only the data matching the user's geography permissions is returned
+        """
+        # Given
+        infectious_disease_permission = RBACPermission.objects.create(
+            name="infectious_disease_permission",
+            theme=self.core_covid_19.metric.topic.sub_theme.theme,
+            sub_theme=self.core_covid_19.metric.topic.sub_theme,
+            topic=self.core_covid_19.metric.topic,
+            metric=self.core_covid_19.metric,
+            geography=self.core_covid_19.geography,
+        )
+        serializer = fake_serializer
+
+        test_data_public = copy.deepcopy(core_headline_data)
+        test_data_public["geography"] = geography
+        test_data_public["is_public"] = False
+
+        mock_request = MagicMock()
+        mock_request.group_permissions = [
+            infectious_disease_permission,
+            self.non_communicable_permission,
+        ]
+        serializer.context = {"request": mock_request}
+
+        # When
+        result = serializer.to_representation(test_data_public)
+
+        # Then
+        if should_return:
path: tests/integration/metrics/api/decorators/test_permissions.py
user: login: A-Ashiq
body: As above
created_at: 2025-03-12T13:53:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,98 @@
+import logging
+from functools import wraps
+
+from metrics.api.packages.permissions import (
+    FluentPermissions,
+    FluentPermissionsError,
+)
+from metrics.api.settings.private_api import is_auth_enabled
+from metrics.data.models.rbac_models import (
+    RBACPermission,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def filter_by_permissions():
+    def decorator(serializer_class):
+        _init = serializer_class.__init__
+
+        @wraps(_init)
+        def init(self, *args, **kwargs):
+            super(serializer_class, self).__init__(*args, **kwargs)
+            original_to_representation = self.to_representation
+
+            self.to_representation = _get_public_or_private_representation(
+                self=self,
+                original_to_representation=original_to_representation,
+            )
+
+        serializer_class.__init__ = init
+        return serializer_class
+
+    return decorator
+
+
+def _get_public_or_private_representation(*, self, original_to_representation):
+    if not is_auth_enabled():
+        return _new_representation_public(
+            self=self,
+            representation=original_to_representation,
+        )
+    return _new_to_representation_private(
+        self=self,
+        representation=original_to_representation,
+    )
+
+
+def _new_representation_public(*, self, representation):
+    def wrapper(instance):
+        data = representation(instance) or {}
+        data.pop("is_public", None)
+        return data
+
+    return wrapper
+
+
+def _new_to_representation_private(*, self, representation):
+    def wrapper(instance):
+        data = representation(instance) or {}
+
+        # Handle public data
+        if data.get("is_public", None):
+            data.pop("is_public", None)
+            return data
+        data.pop("is_public", None)
+
+        # Get RBAC permissions from request object
+        request = self.context.get("request", None)
+        group_permissions: list[RBACPermission] = getattr(
+            request, "group_permissions", []
+        )
+
+        # If there are no permissions but the data is not public return None
+        if not group_permissions:
+            return None
+
+        try:
+            fluent_permissions = FluentPermissions(
path: metrics/api/decorators/permissions.py
user: login: A-Ashiq
body: Generally we'd expect the try clause to be small and focused. Only capturing the part where we expect that error to be thrown. Which in this case would be just the `validate()` call
created_at: 2025-03-12T14:15:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,216 @@
+from typing import Any
+
+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.
path: metrics/api/packages/permissions/permissions_fluent.py
user: login: mxrman
body: Only, the `is_public` field which gets popped is one example that need's to be ignored which does exist on the serialiser. But you're correct in that (so far) we need to include all fields.
Updated to:

```
2. Use `.add_field(field_name)` to define which fields should be checked. Ensure all required fields are added before executing the validation.
```
created_at: 2025-03-12T15:06:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,216 @@
+from typing import Any
+
+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.
+    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: Any, 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":
+        """
+        Given a set of user permissions (`group_permissions`)
path: metrics/api/packages/permissions/permissions_fluent.py
user: login: mxrman
body: Sorry, I did find and replace for my `execute` method tests and some how this also got updated, thanks. 
created_at: 2025-03-12T15:12:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,216 @@
+from typing import Any
+
+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.
+    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: Any, 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":
+        """
path: metrics/api/packages/permissions/permissions_fluent.py
user: login: mxrman
body: I have updated this but for some reason doesn't tag that it's "out dated"...
created_at: 2025-03-13T16:36:00Z
author_association: CONTRIBUTOR

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-03-20T14:41:18Z
body: ## [![Quality Gate Failed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-failed-20px.png 'Quality Gate Failed')](https://sonarcloud.io/dashboard?id=UKHSA-Internal_winter-pressures-api&pullRequest=2252) **Quality Gate failed**  
Failed conditions  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/failed-16px.png '') [9.9% Duplication on New Code](https://sonarcloud.io/component_measures?id=UKHSA-Internal_winter-pressures-api&pullRequest=2252&metric=new_duplicated_lines_density&view=list) (required ≤ 3%)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=UKHSA-Internal_winter-pressures-api&pullRequest=2252)


author_association: NONE