Repository: privacera/paig (language: python)

Issue:
[ENH]: Restructure code directory and add plugin and common codebase
### Contact Details

_No response_

### Feature Description

1. Restructure code
2. Add Paig Plugin/client codebase
3. Add Paig Common codebase

### Code of Conduct

- [X] I agree to follow this project's Code of Conduct
[ENH]: Restructure code directory and add plugin and common codebase
### Contact Details

_No response_

### Feature Description

1. Restructure code
2. Add Paig Plugin/client codebase
3. Add Paig Common codebase

### Code of Conduct

- [X] I agree to follow this project's Code of Conduct


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/paig-common-ci.yml b/.github/workflows/paig-common-ci.yml
new file mode 100644
index 00000000..6a6a8164
--- /dev/null
+++ b/.github/workflows/paig-common-ci.yml
@@ -0,0 +1,64 @@
+name: paig-common-ci
+
+on:
+  push:
+    branches: [ "main" ]
+    paths:
+      - 'paig-common/**'
+  pull_request:
+    branches: [ "main" ]
+    paths:
+      - 'paig-common/**'
+
+permissions:
+  contents: read
+
+jobs:
+  build_and_test:
+    runs-on: ubuntu-latest
+    steps:
+    - uses: actions/checkout@v4  # Ensure the repo is checked out
+
+    - name: Set up Python 3.10
+      uses: actions/setup-python@v5
+      with:
+        python-version: "3.10"
+
+    - name: Install dependencies
+      run: |
+        python3 -m pip install virtualenv
+        virtualenv -p python3 venv && . venv/bin/activate
+        pip install twine build pytest pytest-cov
+        pip install -r paig-common/requirements.txt
+
+    - name: Test with pytest
+      run: |
+        filepath=$PWD
+        . venv/bin/activate && cd paig-common/src
+        python3 -m pytest --cov="." --cov-report term --cov-report xml:$filepath/coverage.xml --junitxml=$filepath/junit.xml tests
+
+    - name: Upload coverage report
+      uses: actions/upload-artifact@v4
+      with:
+        name: coverage-report
+        path: coverage.xml
+
+    - name: Upload test results
+      uses: actions/upload-artifact@v4
+      with:
+        name: junit-results
+        path: junit.xml
+
+    - name: Build the wheel
+      run: |
+        . venv/bin/activate
+        cd paig-common
+        python3 -m build -w
+        cd ..
+
+    - name: Install created package
+      run: |
+          rm -rf venv
+          virtualenv -p python3 venv && . venv/bin/activate
+          cd paig-common
+          pip install dist/*.whl
\ No newline at end of file
diff --git a/.github/workflows/paig-common-release.yml b/.github/workflows/paig-common-release.yml
new file mode 100644
index 00000000..4b7f2c33
--- /dev/null
+++ b/.github/workflows/paig-common-release.yml
@@ -0,0 +1,65 @@
+name: paig-common upload to PyPI
+
+on:
+  workflow_dispatch: # Allows manual trigger
+
+permissions:
+  contents: read
+
+jobs:
+  release:
+    runs-on: ubuntu-latest
+    steps:
+    - uses: actions/checkout@v4  # Ensure the repo is checked out
+
+    - name: Set up Python 3.10
+      uses: actions/setup-python@v5
+      with:
+        python-version: "3.10"
+
+    - name: Install dependencies
+      run: |
+        python3 -m pip install virtualenv
+        virtualenv -p python3 venv && . venv/bin/activate
+        pip install twine build pytest pytest-cov
+        pip install -r paig-common/requirements.txt
+
+    - name: Test with pytest
+      run: |
+        filepath=$PWD
+        . venv/bin/activate && cd paig-common/src
+        python3 -m pytest --cov="." --cov-report term --cov-report xml:$filepath/coverage.xml --junitxml=$filepath/junit.xml tests
+
+    - name: Upload coverage report
+      uses: actions/upload-artifact@v4
+      with:
+        name: coverage-report
+        path: coverage.xml
+
+    - name: Upload test results
+      uses: actions/upload-artifact@v4
+      with:
+        name: junit-results
+        path: junit.xml
+
+    - name: Build the wheel
+      run: |
+        . venv/bin/activate
+        cd paig-common
+        python3 -m build -w
+
+    - name: Install created package
+      run: |
+        rm -rf venv
+        virtualenv -p python3 venv && . venv/bin/activate
+        cd paig-common
+        pip install dist/*.whl
+
+    - name: Release to PyPI
+      env:
+        TWINE_USERNAME: ${{ secrets.TWINE_USERNAME }}
+        TWINE_PASSWORD: ${{ secrets.TWINE_PYPI_API_TOKEN }}
+      run: |
+        . venv/bin/activate
+        pip install twine
+        twine upload paig-common/dist/*
\ No newline at end of file
diff --git a/paig-common/README.md b/paig-common/README.md
new file mode 100644
index 00000000..72e2cfba
--- /dev/null
+++ b/paig-common/README.md
@@ -0,0 +1,32 @@
+## Common library for Privacera AI Governance Applications
+The paig_common provides the common classes/functions used by the privacera AI governance applications.
+
+
+## Development
+```
+# go inside the project repository
+cd paig-common
+
+# setup environment
+python3  -m venv venv
+
+# activate environment
+. venv/bin/activate
+
+# install dependencies
+pip install -r requirements.txt
+
+```
+
+## Tests
+```
+# go to shield directory inside the project repository
+cd paig-common
+
+# activate environment
+. venv/bin/activate
+
+# run tests with coverage
+cd src
+sh run_unit_test.sh
+```
\ No newline at end of file
diff --git a/paig-common/pyproject.toml b/paig-common/pyproject.toml
new file mode 100644
index 00000000..f55c4992
--- /dev/null
+++ b/paig-common/pyproject.toml
@@ -0,0 +1,31 @@
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[project]
+name = "paig-common"
+dynamic = ["version"]
+authors = [
+  { name = "Privacera ", email = "support@privacera.com" },
+]
+description = "PAIG Shield Common Library"
+readme = "README.md"
+license = {file = "../LICENSE"}
+requires-python = ">=3.8.1"
+classifiers = [
+    "Programming Language :: Python :: 3",
+    "License :: Other/Proprietary License",
+    "Operating System :: OS Independent",
+]
+dependencies = [
+    "urllib3>=2.0.6",
+    "cryptography>=41.0.4",
+    "jproperties>=2.1.1"
+]
+
+[project.urls]
+"Homepage" = "https://github.com/pypa/paig_common"
+"Bug Tracker" = "https://github.com/pypa/paig_common/issues"
+
+[tool.hatch.version]
+path = "src/paig_common/VERSION"
diff --git a/paig-common/requirements.txt b/paig-common/requirements.txt
new file mode 100644
index 00000000..7a8306fc
--- /dev/null
+++ b/paig-common/requirements.txt
@@ -0,0 +1,15 @@
+urllib3==2.2.1
+cryptography==42.0.5
+build==1.2.1
+pytest==8.0.0
+pytest-cov==4.1.0
+pytest-html==4.1.1
+pytest-mock==3.12.0
+twine==5.0.0
+boto3==1.34.82
+python-gitlab==4.4.0
+jproperties==2.1.1
+Flask==3.0.3
+fastapi==0.111.0
+httpx==0.27.0
+pytest-asyncio==0.23.7
\ No newline at end of file
diff --git a/paig-common/src/paig_common/VERSION b/paig-common/src/paig_common/VERSION
new file mode 100644
index 00000000..b8023d8b
--- /dev/null
+++ b/paig-common/src/paig_common/VERSION
@@ -0,0 +1,1 @@
+__version__ = '0.0.1'
diff --git a/paig-common/src/paig_common/__init__.py b/paig-common/src/paig_common/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/paig-common/src/paig_common/async_base_rest_http_client.py b/paig-common/src/paig_common/async_base_rest_http_client.py
new file mode 100644
index 00000000..0eb2e75a
--- /dev/null
+++ b/paig-common/src/paig_common/async_base_rest_http_client.py
@@ -0,0 +1,141 @@
+import asyncio
+import json
+from typing import Collection
+import httpx
+
+from . import config_utils
+from .async_http_transport import AsyncHttpTransport
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class AsyncBaseRESTHttpClient:
+    """
+    An asynchronous base class for making HTTP requests to a RESTful API using httpx.
+    """
+    _setup_done = False  # Flag to track if setup has been performed
+    _setup_lock = asyncio.Lock()
+    _max_retries = config_utils.get_property_value_int("http.rest.client.max_retries", 4)
+    _backoff_factor = config_utils.get_property_value_int("http.rest.client.backoff_factor", 1)
+    _allowed_methods_str = config_utils.get_property_value("http.rest.client.allowed_methods",
+                                                           '["GET", "POST", "PUT", "DELETE"]')
+    _allowed_methods: Collection[str] = eval(_allowed_methods_str)
+    _status_forcelist_str = config_utils.get_property_value("http.rest.client.status_forcelist",
+                                                            '[500, 502, 503, 504]')
+    _status_forcelist: Collection[int] = eval(_status_forcelist_str)
+
+    def __init__(self, base_url):
+        """
+        Initialize the AsyncBaseRESTHttpClient instance.
+
+        Args:
+            base_url (str): The base URL of the REST API.
+        """
+        self.baseUrl = base_url
+
+    @staticmethod
+    async def setup():
+        """
+        Set up the Async HTTP transport configuration (called only once).
+
+        Returns:
+        None
+        """
+        async with AsyncBaseRESTHttpClient._setup_lock:
+            if not AsyncBaseRESTHttpClient._setup_done:
+                connect_timeout_sec = config_utils.get_property_value_float("http.rest.client.connect_timeout_sec", 2.0)
+                read_timeout_sec = config_utils.get_property_value_float("http.rest.client.read_timeout_sec", 7.0)
+                await AsyncHttpTransport.setup(
+                    connect_timeout_sec=connect_timeout_sec,
+                    read_timeout_sec=read_timeout_sec
+                )
+                AsyncBaseRESTHttpClient._setup_done = True
+
+    def get_auth(self):
+        """
+        Get the authentication configuration for the HTTP requests.
+
+        Returns:
+            None or httpx.Auth: None if no authentication is needed, an httpx.Auth object otherwise.
+        """
+        return None
+
+    def get_default_headers(self):
+        """
+        Get the default HTTP headers to include in each request.
+
+        Returns:
+            dict: A dictionary containing default headers.
+        """
+        return {"Content-Type": "application/json", "Accept": "application/json"}
+
+    async def request(self, *args, **kwargs):
+        """
+        Make an asynchronous HTTP request to the API.
+
+        Args:
+            *args: Positional arguments for the request method.
+            **kwargs: Keyword arguments for the request.
+
+        Returns:
+            AsyncReturnValue: The HTTP response object.
+        """
+        headers = kwargs.get("headers", {})
+
+        if "url" in kwargs:
+            kwargs["url"] = self.baseUrl + kwargs["url"]
+
+        updated_headers = self.get_default_headers()
+        updated_headers.update(headers)
+        kwargs["headers"] = updated_headers
+
+        auth = self.get_auth()
+        if auth is not None:
+            kwargs["auth"] = auth
+
+        await AsyncBaseRESTHttpClient.setup()
+        client = await AsyncHttpTransport.get_client()
+        retries = AsyncBaseRESTHttpClient._max_retries
+        backoff_factor = AsyncBaseRESTHttpClient._backoff_factor
+
+        for attempt in range(retries + 1):
+            try:
+                response = await client.request(*args, auth=auth, **kwargs)
+                if response.status_code not in AsyncBaseRESTHttpClient._status_forcelist:
+                    return AsyncReturnValue(response)
+            except httpx.RequestError as exc:
+                if attempt == retries:
+                    # Log error and raise exception after max retries
+                    logger.error(f"Max retries reached without a successful response getting this exception: {exc}")
+                    raise httpx.RequestError(
+                        f"Max retries reached without a successful response getting this exception: {exc}")
+                # Wait before retrying
+                await asyncio.sleep(backoff_factor * (2 ** attempt))
+
+    async def get(self, *args, **kwargs):
+        return await self.request(method='GET', *args, **kwargs)
+
+    async def post(self, *args, **kwargs):
+        return await self.request(method='POST', *args, **kwargs)
+
+    async def put(self, *args, **kwargs):
+        return await self.request(method='PUT', *args, **kwargs)
+
+    async def delete(self, *args, **kwargs):
+        return await self.request(method='DELETE', *args, **kwargs)
+
+    async def patch(self, *args, **kwargs):
+        return await self.request(method='PATCH', *args, **kwargs)
+
+
+class AsyncReturnValue:
+    def __init__(self, response: httpx.Response):
+        self.status_code = response.status_code
+        self.text = response.text
+
+    def json(self):
+        return json.loads(self.text)
+
+    def __str__(self):
+        return f"Response return value: (status_code={self.status_code}, text={self.text})"
diff --git a/paig-common/src/paig_common/async_http_transport.py b/paig-common/src/paig_common/async_http_transport.py
new file mode 100644
index 00000000..825b678d
--- /dev/null
+++ b/paig-common/src/paig_common/async_http_transport.py
@@ -0,0 +1,73 @@
+import httpx
+import asyncio
+import logging
+
+from . import config_utils
+
+logger = logging.getLogger(__name__)
+
+
+class AsyncHttpTransport:
+    """
+    AsyncHttpTransport class maintains a single instance of httpx.AsyncClient for all the AsyncShieldRestHttpClient instances.
+    """
+    _client: httpx.AsyncClient = None
+    _lock = asyncio.Lock()
+    _connect_timeout_sec = 2.0
+    _read_timeout_sec = 7.0
+    """
+    These are default settings that can be overridden by calling the setup method.
+    """
+
+    @staticmethod
+    async def setup(**kwargs):
+        """
+        This optional method allows you to pass your own settings to be used by all the
+        AsyncShieldRestHttpClient instances.
+        :param kwargs:
+            - connect_timeout_sec
+            - read_timeout_sec
+        :return:
+        """
+        AsyncHttpTransport._connect_timeout_sec = kwargs.get('connect_timeout_sec',
+                                                             AsyncHttpTransport._connect_timeout_sec)
+        AsyncHttpTransport._read_timeout_sec = kwargs.get('read_timeout_sec', AsyncHttpTransport._read_timeout_sec)
+
+        await AsyncHttpTransport.create_default_client()
+
+    @staticmethod
+    async def get_client():
+        if not AsyncHttpTransport._client:
+            await AsyncHttpTransport.create_default_client()
+        return AsyncHttpTransport._client
+
+    @staticmethod
+    async def create_default_client():
+        async with AsyncHttpTransport._lock:
+            if not AsyncHttpTransport._client:
+
+                timeout = httpx.Timeout(
+                    connect=AsyncHttpTransport._connect_timeout_sec,
+                    read=AsyncHttpTransport._read_timeout_sec,
+                    write=None,
+                    pool=None
+                )
+                max_keepalive_connections = config_utils.get_property_value_int("httpx.rest.client.max.keepalive.connections", 50)
+                max_connections = config_utils.get_property_value_int("httpx.rest.client.max.connections", 100)
+                keepalive_expiry = config_utils.get_property_value_float("httpx.rest.client.keep.alive.expiry", 60.0)
+                limits = httpx.Limits(max_keepalive_connections=max_keepalive_connections, max_connections=max_connections, keepalive_expiry=keepalive_expiry)
+                transport = httpx.AsyncHTTPTransport(limits=limits)
+
+                AsyncHttpTransport._client = httpx.AsyncClient(
+                    timeout=timeout,
+                    transport=transport
+                )
+
+    @staticmethod
+    async def close():
+        """
+        Close the httpx AsyncClient.
+        """
+        if AsyncHttpTransport._client:
+            await AsyncHttpTransport._client.aclose()
+            AsyncHttpTransport._client = None
diff --git a/paig-common/src/paig_common/audit_spooler.py b/paig-common/src/paig_common/audit_spooler.py
new file mode 100644
index 00000000..02cace6c
--- /dev/null
+++ b/paig-common/src/paig_common/audit_spooler.py
@@ -0,0 +1,289 @@
+import abc
+import json
+import logging
+import os
+import queue
+import threading
+import time
+from abc import ABC, abstractmethod
+from datetime import datetime, date
+from queue import Queue
+from typing import List, TypeVar
+
+from .file_utils import FileUtils
+from .paig_exception import AuditEventQueueFullException, DiskFullException
+
+_logger = logging.getLogger(__name__)
+T = TypeVar('T')
+
+
+class AuditEvent(ABC):
+
+    def __init__(self, **kwargs):
+        """
+        Initializes a AuditEvent object.
+
+        Keyword Arguments:
+            event_time (str): The timestamp of the event.
+        """
+
+        self.event_time = kwargs.get("event_time")
+
+    @abstractmethod
+    def to_payload_dict(self):
+        """
+        Returns a dictionary representation of the AuditEvent object.
+
+        Returns:
+            dict: A dictionary containing the attributes of the event.
+        """
+        pass
+
+    @classmethod
+    @abstractmethod
+    def from_payload_dict(cls, payload_dict):
+        """
+        Creates a AuditEvent object from a dictionary payload.
+
+        Args:
+            payload_dict (dict): Dictionary containing attributes of the event.
+
+        Returns:
+            AuditEvent: An instance of AuditEvent.
+        """
+        pass
+
+
+class AuditSpooler:
+    """
+    Class to manage spooled audit events.
+
+    Args:
+        audit_spool_dir (str): The directory path where spooled audit events are stored.
+    """
+
+    def __init__(self, audit_spool_dir: str, audit_event_cls):
+        """
+        Initializes an AuditSpooler object.
+
+        Args:
+            audit_spool_dir (str): The directory path where spooled audit events are stored.
+        """
+        self.audit_spool_dir = audit_spool_dir
+        self.audit_event_cls = audit_event_cls
+
+        if not os.path.exists(self.audit_spool_dir):
+            os.makedirs(self.audit_spool_dir)
+
+        self.lock = threading.Lock()
+
+    def get_spooled_audit_events(self):
+        """
+        Retrieves spooled audit events from the spool directory.
+
+        Returns:
+            List[AuditEvent]: A list of AuditEvent objects representing spooled audit events.
+        """
+        spooled_audit_events = []
+
+        file_paths = FileUtils.get_file_paths_in_directory(self.audit_spool_dir)
+        for file_path in file_paths:
+            with self.lock:
+                spooled_events = FileUtils.load_json_from_file(file_path)
+                for event_dict_str in spooled_events:
+                    event_dict = json.loads(event_dict_str)
+                    access_audit_event = self.audit_event_cls.from_payload_dict(event_dict)
+                    spooled_audit_events.append(access_audit_event)
+
+        return spooled_audit_events
+
+    def add_audit_event(self, access_audit_event: T):
+        """
+        Adds an audit event to the spool directory.
+
+        Args:
+            access_audit_event (AuditEvent): The audit event to add.
+        """
+        event_time = self.get_event_time(access_audit_event)
+        file_path = self.get_file_path_for_event_time(event_time)
+        access_audit_event_dict = access_audit_event.to_payload_dict()
+        try:
+            with self.lock:
+                FileUtils.append_json_to_file(file_path, access_audit_event_dict)
+        except OSError as e:
+            if e.errno == 28:
+                _logger.error("No space left on device. Disk is full.Please increase the disk size or free "
+                              "up some space to push audits successfully.")
+                raise DiskFullException("No space left on device. Disk is full. Please increase the disk size or free "
+                                        "up some space to push audits successfully.")
+            else:
+                _logger.error(f"Error writing audit event to file: {e}")
+                raise Exception(f"Error writing audit event to file: {e}")
+
+    def get_event_time(self, access_audit_event):
+        event_time = None
+        if hasattr(access_audit_event, 'event_time'):
+            event_time = access_audit_event.event_time
+        elif hasattr(access_audit_event, 'eventTime'):
+            event_time = access_audit_event.eventTime
+        return event_time
+
+    def remove_audit_event(self, access_audit_event: T):
+        """
+        Removes an audit event from the spool directory.
+
+        Args:
+            access_audit_event (AuditEvent): The audit event to remove.
+        """
+        event_time = self.get_event_time(access_audit_event)
+        file_path = self.get_file_path_for_event_time(event_time)
+        access_audit_event_dict = access_audit_event.to_payload_dict()
+        with self.lock:
+            FileUtils.remove_line_from_file(file_path, access_audit_event_dict)
+
+        today = date.today()
+        today_file_name = today.strftime("audit_spool_%Y-%m-%d.json")
+
+        # Call remove_empty_files method with today's file name in the exclude list
+        exclude_files = [today_file_name]
+        FileUtils.remove_empty_files(self.audit_spool_dir, exclude_files)
+
+    def get_file_path_for_event_time(self, event_time_millis) -> str:
+        """
+        Generates the file path for the given event time.
+
+        Args:
+            event_time_millis (int): The event time in milliseconds.
+
+        Returns:
+            str: The file path for the given event time.
+        """
+        event_date = datetime.fromtimestamp(event_time_millis / 1000)  # Convert milliseconds to seconds
+        return os.path.join(self.audit_spool_dir, f"audit_spool_{event_date.strftime('%Y-%m-%d')}.json")
+
+
+class AuditLogger(threading.Thread, metaclass=abc.ABCMeta):
+    """
+    A class to log audit data and push it to a server.
+
+    Attributes:
+        audit_spool_dir (str): A directory to store spooled audit files.
+    """
+
+    def __init__(self, audit_spool_dir: str, audit_event_cls, max_queue_size=10000, audit_event_queue_timeout=5):
+        """
+        Initializes the AuditLogger.
+
+        Args:
+            audit_spool_dir (str): Audit spool directory
+            audit_event_cls (cls): Class of audit event
+        """
+        super().__init__()
+
+        self.audit_event_queue_timeout = audit_event_queue_timeout
+        self.audit_spooler = AuditSpooler(audit_spool_dir, audit_event_cls)
+
+        self.audit_event_queue = Queue(maxsize=max_queue_size)
+        self.failed_audit_event_queue = Queue(maxsize=max_queue_size)
+
+        # Load spooled audits
+        self.load_spooled_audit_events()
+
+        self.retry_failed_audit_thread = threading.Thread(target=self.retry_failed_audit_events)
+        self.retry_failed_audit_thread.daemon = True  # Daemonize the thread
+        self.retry_failed_audit_thread.start()
+
+    def retry_failed_audit_events(self):
+        """
+        Periodically retries failed audit events from the failed audit event queue.
+
+        This method continuously retrieves failed audit events from the failed audit event queue,
+        adds them back to the audit event queue for retry, and waits for 2 minutes before retrying
+        again. The retry process ensures that failed audit events are reprocessed at regular intervals.
+
+        """
+        while True:
+            failed_audit_events = []
+            while not self.failed_audit_event_queue.empty():
+                failed_audit_event = self.failed_audit_event_queue.get()
+                failed_audit_events.append(failed_audit_event)
+
+            if failed_audit_events:
+                _logger.debug(f"Retrying {len(failed_audit_events)} failed audit events.")
+                for failed_audit_event in failed_audit_events:
+                    self.audit_event_queue.put(failed_audit_event, timeout=self.audit_event_queue_timeout)
+            else:
+                _logger.debug("No failed audit events to retry.")
+
+            # Wait for 2 minutes and try this failed audit retries every 2 mins interval
+            time.sleep(120)
+
+    def load_spooled_audit_events(self):
+        """
+        Loads spooled audit events from the AuditSpooler and puts them into the audit event queue.
+
+        This method retrieves spooled audit events from the AuditSpooler and puts them into the audit event queue for processing.
+
+        """
+        spooled_audit_events = self.audit_spooler.get_spooled_audit_events()
+        for audit_event in spooled_audit_events:
+            self.audit_event_queue.put(audit_event, timeout=self.audit_event_queue_timeout)
+
+    def log(self, audit_event: T):
+        """
+        Logs the provided data.
+
+        Args:
+            audit_event (object): The audit event to be logged.
+        """
+        # Add event to spool file
+        self.audit_spooler.add_audit_event(audit_event)
+        try:
+            self.audit_event_queue.put(audit_event, timeout=self.audit_event_queue_timeout)
+        except queue.Full:
+            _logger.error("Audit event queue is full.The push rate is too high for the audit spooler to process.")
+            raise AuditEventQueueFullException("Audit event queue is full.The push rate is too high for the audit "
+                                               "spooler to process.")
+
+    def run(self):
+        """
+        Runs the AuditLogger thread.
+
+        Continuously checks for new events in the audit_event_queue and pushes them to the server.
+        """
+        try:
+            while True:
+                # Check for new logs in the audit_event_queue
+                # This is a blocking queue, it will implicitly wait till record available in queue
+                audit_event = self.audit_event_queue.get()
+                try:
+                    # Push audit to server
+                    self.push_audit_event_to_server(audit_event)
+                    if _logger.isEnabledFor(logging.DEBUG):
+                        _logger.debug("Audit event data pushed to server: %s", audit_event)
+
+                    # After successfully pushing audits to server, removing it from spool directory
+                    self.audit_spooler.remove_audit_event(audit_event)
+                except Exception as e:
+                    _logger.warning("Failed to push audit event data %s to server: %s. Retrying request. Will add it "
+                                    "back in the audit-queue.", audit_event, e)
+                    # Adding failed audits for retries
+                    self.failed_audit_event_queue.put(audit_event, timeout=self.audit_event_queue_timeout)
+        except Exception as e:
+            _logger.error("An error occurred in AuditLogger: %s", e)
+
+    @abstractmethod
+    def push_audit_event_to_server(self, audit_event: T):
+        """
+        Abstract method to push an audit event to the server.
+
+        Subclasses must implement this method to define how audit events are pushed to the server.
+
+        Args:
+            audit_event (T): The audit event to be pushed to the server.
+                The type of the audit event is specified by the generic type parameter T.
+
+        Raises:
+            NotImplementedError: If the method is not implemented by the subclass.
+        """
+        pass
diff --git a/paig-common/src/paig_common/config_utils.py b/paig-common/src/paig_common/config_utils.py
new file mode 100644
index 00000000..79701284
--- /dev/null
+++ b/paig-common/src/paig_common/config_utils.py
@@ -0,0 +1,78 @@
+import logging
+from jproperties import Properties
+
+logger = logging.getLogger(__name__)
+
+configs_properties = Properties()
+
+
+# You can load multiple config files, but order matters. The latest one will overwrite any
+# properties defined by previous config files
+def load_config_file(config_file_path):
+    print(f"Loading config file {config_file_path}")
+    logger.info(f"Loading config file {config_file_path}")
+    with open(config_file_path, 'rb') as config_file:
+        configs_properties.load(config_file)
+
+
+def get_property_value(property_name, default_value=None):
+    property_value = configs_properties.get(property_name)
+    if property_value and property_value.data:
+        return property_value.data
+    return default_value
+
+
+def get_property_value_list(property_name, default_value=None):
+    value = get_property_value(property_name, default_value)
+    try:
+        return value.split(",")
+    except Exception as e:
+        logger.error(f"The property '{property_name}' with value '{value}' and default value '{default_value}' cannot "
+                     f"be converted to list. Error: {e}")
+        return None
+
+
+def get_property_value_int(property_name, default_value=None):
+    value = get_property_value(property_name, default_value)
+    if not value:
+        return default_value
+
+    try:
+        # Try to convert it to an integer and return
+        return int(value)
+    except ValueError:
+        logger.error(f"The property '{property_name}' with value '{value}' cannot be converted to an integer.")
+        return default_value
+
+
+def get_property_value_float(property_name, default_value=None):
+    value = get_property_value(property_name, default_value)
+    if not value:
+        return default_value
+    try:
+        # Try to convert it to an integer and return
+        return float(value)
+    except ValueError:
+        logger.error(f"The property '{property_name}' with value '{value}' cannot be converted to float.")
+        return default_value
+
+
+def get_property_value_boolean(property_name, default_value=None):
+    value = get_property_value(property_name, default_value)
+    if not value:
+        return default_value
+
+    try:
+        if isinstance(value, bool):
+            return value
+        elif isinstance(value, str):
+            return value.lower() in ['true']
+        else:
+            return default_value
+    except ValueError:
+        logger.error(f"The property '{property_name}' with value '{value}' cannot be converted to boolean.")
+        return default_value
+
+
+def get_keys():
+    return configs_properties.keys()
diff --git a/paig-common/src/paig_common/constants.py b/paig-common/src/paig_common/constants.py
new file mode 100644
index 00000000..4f3c0e77
--- /dev/null
+++ b/paig-common/src/paig_common/constants.py
@@ -0,0 +1,4 @@
+X_USER_ROLE_HEADER = 'x-user-role'
+X_TENANT_ID_HEADER = 'x-tenant-id'
+DEFAULT_OWNER_ROLE = "OWNER"
+DEFAULT_API_URL_PATTERN = '/api/.*'
diff --git a/paig-common/src/paig_common/encryption.py b/paig-common/src/paig_common/encryption.py
new file mode 100644
index 00000000..98021263
--- /dev/null
+++ b/paig-common/src/paig_common/encryption.py
@@ -0,0 +1,154 @@
+import base64
+import logging
+
+from cryptography.hazmat.backends import default_backend
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives.asymmetric import padding, rsa
+
+_logger = logging.getLogger(__name__)
+
+
+class RSAKeyUtil:
+    KEY_ALGO_NAME = "RSA"
+    DEFAULT_KEY_LENGTH = 1024
+
+    @staticmethod
+    def generate_key_pair():
+
+        private_key = rsa.generate_private_key(
+            public_exponent=65537,
+            key_size=RSAKeyUtil.DEFAULT_KEY_LENGTH,
+            backend=default_backend()
+        )
+
+        private_key_pem = private_key.private_bytes(
+            encoding=serialization.Encoding.PEM,
+            format=serialization.PrivateFormat.PKCS8,
+            encryption_algorithm=serialization.NoEncryption()
+        )
+
+        public_key = private_key.public_key()
+
+        # Serialize the public key to PEM format
+        public_key_pem = public_key.public_bytes(
+            encoding=serialization.Encoding.PEM,
+            format=serialization.PublicFormat.SubjectPublicKeyInfo
+        )
+
+        public_key_str = public_key_pem.decode("utf-8")
+
+        # Remove "BEGIN PUBLIC KEY" and "END PUBLIC KEY" lines
+        public_key_str = public_key_str.replace("-----BEGIN PUBLIC KEY-----", "")
+        public_key_str = public_key_str.replace("-----END PUBLIC KEY-----", "")
+        public_key_str = public_key_str.replace("\n", "")
+
+        private_key_str = private_key_pem.decode("utf-8")
+
+        # Remove "BEGIN PUBLIC KEY" and "END PUBLIC KEY" lines
+        private_key_str = private_key_str.replace("-----BEGIN PRIVATE KEY-----", "")
+        private_key_str = private_key_str.replace("-----END PRIVATE KEY-----", "")
+        private_key_str = private_key_str.replace("\n", "")
+
+        return RSAKeyInfo(private_key_str, public_key_str)
+
+    @staticmethod
+    def str_to_private_key(private_key_str):
+        try:
+            private_key_bytes = base64.b64decode(private_key_str)
+            private_key_instance = serialization.load_der_private_key(private_key_bytes, password=None)
+            return private_key_instance
+        except (ValueError, Exception) as e:
+            print(f"Failed to parse private_key_content: {str(e)}")
+            return None
+
+    @staticmethod
+    def str_to_public_key(public_key_str):
+        try:
+            public_key_bytes = base64.b64decode(public_key_str)
+            public_key_instance = serialization.load_der_public_key(public_key_bytes)
+            return public_key_instance
+        except (ValueError, Exception) as e:
+            print(f"Failed to parse public_key_content: {str(e)}")
+            return None
+
+
+class RSAKeyInfo:
+    def __init__(self, private_key_encoded_str, public_key_encoded_str):
+        self.private_key_encoded_str = private_key_encoded_str
+        self.public_key_encoded_str = public_key_encoded_str
+
+    def __str__(self):
+        return f"privateKeyEncodedStr={self.private_key_encoded_str}, publicKeyEncodedStr={self.public_key_encoded_str}"
+
+
+class DataEncryptor:
+
+    def __init__(self, public_key: str, private_key: str):
+        _logger.debug("==> DataEncryptor()")
+
+        # Load public key
+        self.public_key = None
+        if public_key is not None:
+            self.public_key = RSAKeyUtil.str_to_public_key(public_key)
+
+        # Load private key
+        self.private_key = None
+        if private_key is not None:
+            self.private_key = RSAKeyUtil.str_to_private_key(private_key)
+
+        _logger.debug("<== DataEncryptor()")
+
+    def encrypt_data(self, data):
+        # first convert to byte array the entire data string using UTF-8 encoding
+        # then split into 1024 bit chunks and encrypt each chunk to base64 encoded string
+        # join all the base64 encoded strings and return the encrypted data as a string
+        data_bytes = data.encode("utf-8")
+
+        # since it is a 1024 bit key, we can encrypt upto 118 bytes at a time so
+        # we need to break the data into chunks of 100 bytes (safe side)
+        chunk_size = 100
+        chunks = [data_bytes[i:i + chunk_size] for i in range(0, len(data_bytes), chunk_size)]
+
+        processed_data_parts = [self.encrypt_chunk(chunk) for chunk in chunks]
+
+        return ''.join(processed_data_parts)
+
+    def decrypt_data(self, data):
+        # split the encrypted data string which is base64 encoded into 172 bytes chunks which is 128 bytes of encrypted data
+        # decrypt each chunk and get the bytes and join all the bytes to get the original data
+        # finally encode the entire byte array to string using UTF-8 encoding
+
+        # the encrypted data will be 128 byte long which is 172 bytes base64 encoded
+        chunk_size = 172
+        chunks = [data[i:i + chunk_size] for i in range(0, len(data), chunk_size)]
+
+        processed_data_parts_bytes = [self.decrypt_chunk(chunk) for chunk in chunks]
+
+        return (b''.join(processed_data_parts_bytes)).decode("utf-8")
+
+    def encrypt_chunk(self, chunk: bytes) -> str:
+        """Encrypts a chunk of data bytes and returns the base64 encoded encrypted data."""
+        encrypted_data = self.public_key.encrypt(
+            chunk,
+            padding.PKCS1v15()
+        )
+        return base64.b64encode(encrypted_data).decode("utf-8")
+
+    def decrypt_chunk(self, chunk: str) -> bytes:
+        """Decrypts a chunk of base64 encoded data and returns the decrypted data bytes."""
+        encrypted_data_bytes = base64.b64decode(chunk)
+        decrypted_data_bytes = self.private_key.decrypt(
+            encrypted_data_bytes,
+            padding.PKCS1v15()
+        )
+        return decrypted_data_bytes
+
+    def encrypt(self, data):
+        if self.public_key is None:
+            raise ValueError("public key is not set")
+        return self.encrypt_data(data)
+
+    def decrypt(self, data):
+        if self.private_key is None:
+            raise ValueError("private key is not set")
+        return self.decrypt_data(data)
diff --git a/paig-common/src/paig_common/file_utils.py b/paig-common/src/paig_common/file_utils.py
new file mode 100644
index 00000000..07b8a729
--- /dev/null
+++ b/paig-common/src/paig_common/file_utils.py
@@ -0,0 +1,100 @@
+import json
+import os
+
+
+class FileUtils:
+    @staticmethod
+    def get_file_paths_in_directory(directory):
+        """
+        Retrieves file paths in the specified directory.
+
+        Args:
+            directory (str): The directory path.
+
+        Returns:
+            list: A list of file paths in the directory.
+        """
+        return [os.path.join(directory, filename) for filename in os.listdir(directory) if os.path.isfile(os.path.join(directory, filename))]
+
+    @staticmethod
+    def load_json_from_file(file_path):
+        """
+        Loads JSON data from the specified file.
+
+        Args:
+            file_path (str): The path to the JSON file.
+
+        Returns:
+            obj: The JSON data loaded from the file.
+
+        Raises:
+            FileNotFoundError: If the file does not exist.
+        """
+        try:
+            with open(file_path, 'r') as f:
+                return f.readlines()
+        except FileNotFoundError:
+            return []
+
+    @staticmethod
+    def read_json_file(file_path):
+        """
+        Loads list of JSON data from the specified file.
+
+        Args:
+            file_path (str): The path to the JSON file.
+
+        Returns:
+            obj: The list of JSON data loaded from the file.
+
+        Raises:
+            FileNotFoundError: If the file does not exist.
+        """
+        try:
+            with open(file_path, 'r') as f:
+                return json.load(f)
+        except FileNotFoundError:
+            return []
+
+    @staticmethod
+    def append_json_to_file(file_path, data):
+        """
+        Appends JSON data to the specified file.
+
+        Args:
+            file_path (str): The path to the JSON file.
+            data (obj): The JSON data to append to the file.
+        """
+        with open(file_path, 'a') as f:
+            json.dump(data, f)
+            f.write('\n')
+
+    @staticmethod
+    def remove_line_from_file(file_path, line_to_remove):
+        """
+        Removes a specific line from a file.
+
+        Args:
+            file_path (str): The path to the file.
+            line_to_remove (obj): The line to remove from the file.
+        """
+        temp_file_path = file_path + '.temp'
+        with open(file_path, 'r') as read_file, open(temp_file_path, 'w') as write_file:
+            for line in read_file:
+                if json.loads(line) != line_to_remove:
+                    write_file.write(line)
+        os.replace(temp_file_path, file_path)
+
+    @staticmethod
+    def remove_empty_files(spool_directory, exclude_files=[]):
+        """
+        Removes empty files from the spool directory, excluding specified files.
+
+        Args:
+            spool_directory (str): The directory containing spool files.
+            exclude_files (list): List of file names to exclude from deletion.
+        """
+        for file_name in os.listdir(spool_directory):
+            file_path = os.path.join(spool_directory, file_name)
+            if os.path.isfile(file_path) and os.path.getsize(file_path) == 0 and file_name not in exclude_files:
+                os.remove(file_path)
\ No newline at end of file
diff --git a/paig-common/src/paig_common/http_transport.py b/paig-common/src/paig_common/http_transport.py
new file mode 100644
index 00000000..0b562e27
--- /dev/null
+++ b/paig-common/src/paig_common/http_transport.py
@@ -0,0 +1,65 @@
+import urllib3
+import threading
+from urllib3 import Timeout, Retry
+
+
+class HttpTransport:
+    """
+    HttpTransport class maintains a single instance of urllib3.PoolManager for all the ShieldRestHttpClient instances.
+    """
+    _http: urllib3.PoolManager = None
+    _rw_lock = threading.RLock()
+
+    _max_retries = 4
+    _backoff_factor = 1
+    _allowed_methods = ["GET", "POST", "PUT", "DELETE"]
+    _status_forcelist = [500, 502, 503, 504]
+    _connect_timeout_sec = 2.0
+    _read_timeout_sec = 7.0
+    """
+    These are default settings that can be overridden by calling the setup method.
+    """
+
+    @staticmethod
+    def setup(**kwargs):
+        """
+        This optional method allows you to pass your own instance of the PoolManager to be used by all the
+        ShieldRestHttpClient instances.
+        :param kwargs:
+            - http: Instance of urllib3.PoolManager
+            - max_retries
+            - backoff_factor
+            - allowed_methods
+            - status_forcelist
+            - connect_timeout_sec
+            - read_timeout_sec
+        :return:
+        """
+        HttpTransport._http = kwargs.get('http', HttpTransport._http)
+        HttpTransport._max_retries = kwargs.get('max_retries', HttpTransport._max_retries)
+        HttpTransport._backoff_factor = kwargs.get('backoff_factor', HttpTransport._backoff_factor)
+        HttpTransport._allowed_methods = kwargs.get('allowed_methods', HttpTransport._allowed_methods)
+        HttpTransport._status_forcelist = kwargs.get('status_forcelist', HttpTransport._status_forcelist)
+        HttpTransport._connect_timeout_sec = kwargs.get('connect_timeout_sec', HttpTransport._connect_timeout_sec)
+        HttpTransport._read_timeout_sec = kwargs.get('read_timeout_sec', HttpTransport._read_timeout_sec)
+
+    @staticmethod
+    def get_http():
+        if not HttpTransport._http:
+            HttpTransport.create_default_http()
+        return HttpTransport._http
+
+    @staticmethod
+    def create_default_http():
+        with HttpTransport._rw_lock:
+            if not HttpTransport._http:
+                # TODO: add proxy support
+                # TODO: add ignore SSL support
+                # TODO: expose any metrics
+
+                retries = Retry(total=HttpTransport._max_retries,
+                                backoff_factor=HttpTransport._backoff_factor,
+                                allowed_methods=HttpTransport._allowed_methods,
+                                status_forcelist=HttpTransport._status_forcelist)
+                timeout = Timeout(connect=HttpTransport._connect_timeout_sec, read=HttpTransport._read_timeout_sec)
+                HttpTransport._http = urllib3.PoolManager(num_pools=50, block=True, retries=retries, timeout=timeout)
diff --git a/paig-common/src/paig_common/lru_cache.py b/paig-common/src/paig_common/lru_cache.py
new file mode 100644
index 00000000..bffd8bff
--- /dev/null
+++ b/paig-common/src/paig_common/lru_cache.py
@@ -0,0 +1,169 @@
+import time
+from abc import abstractmethod
+from collections import OrderedDict
+from datetime import datetime, timedelta
+from threading import Lock, Thread
+import threading
+
+
+class LRUCacheEvictCallback:
+    """
+    A callback to be called when an item is evicted from the cache.
+    """
+
+    @abstractmethod
+    def __call__(self, key, value):
+        """
+        Call the callback function.
+
+        Args:
+            key (str): The key of the item to be evicted.
+            value (Any): The value of the item to be evicted.
+
+        Returns:
+            None
+        """
+        raise NotImplementedError
+
+
+class LRUCache:
+    """
+    An implementation of a Least Recently Used (LRU) Cache.
+
+    Args:
+        capacity (int): The maximum number of items the cache can hold.
+        max_idle_time (int): The maximum time (in seconds) an item can remain idle in the cache.
+        cleanup_interval_sec (int): The interval (in seconds) to call cache clearing check.
+
+    Attributes:
+        cache (OrderedDict): An ordered dictionary to store key-value pairs, maintaining insertion order.
+        capacity (int): The maximum capacity of the cache.
+        max_idle_time (int): The maximum idle time (in seconds) allowed for cache items.
+        last_access (Dict[str, datetime]): A dictionary to store the last access time of each cache item.
+        lock (Lock): A threading lock to ensure thread safety.
+        cleanup_interval_sec (int): The interval (in seconds) at which the cache cleanup is performed.
+        cleanup_thread (Thread): A background thread for periodic cache cleanup.
+        hits (int): The number of cache hits.
+        misses (int): The number of cache misses.
+        evictions (int): The number of cache evictions.
+    """
+
+    def __init__(self, cache_name, capacity, max_idle_time, cleanup_interval_sec, evict_callback: LRUCacheEvictCallback | None = None):
+        self.cache = OrderedDict()
+        self.capacity = capacity
+        self.max_idle_time = max_idle_time
+        self.last_access = {}
+        self.lock = Lock()
+        self.cleanup_interval_sec = cleanup_interval_sec
+        self.stop_cleanup_thread_event = threading.Event()
+        self.cleanup_thread = Thread(target=self.cleanup_idle_instance, name=f'{cache_name}-cleanup-thread')
+        self.cleanup_thread.daemon = True
+        self.cleanup_thread.start()
+        self.cache_name = cache_name
+        self.evict_callback = evict_callback
+
+        # Metrics
+        self.hits = 0
+        self.misses = 0
+        self.evictions = 0
+
+    def stop_cleanup_thread(self):
+        """
+        Stop the cache cleanup thread.
+
+        Returns:
+            None
+        """
+        self.stop_cleanup_thread_event.set()
+
+    def get(self, key):
+        """
+        Retrieve a value from the cache by key.
+
+        Args:
+            key (str): The key to retrieve the value for.
+
+        Returns:
+            Any: The value associated with the key, or None if the key is not found.
+        """
+        with self.lock:
+            if key in self.cache:
+                value = self.cache.pop(key)
+                self.cache[key] = value
+                self.last_access[key] = datetime.now()
+                self.hits += 1
+                return value
+            else:
+                self.misses += 1
+                return None
+
+    def put(self, key, value):
+        """
+        Put a key-value pair into the cache.
+
+        Args:
+            key (str): The key of the item to be stored.
+            value (Any): The value to be stored.
+
+        Returns:
+            None
+        """
+        with self.lock:
+            if key in self.cache:
+                # Remove the old entry and re-insert it to update its position
+                self.cache.pop(key)
+            elif len(self.cache) >= self.capacity:
+                # Evict the least recently used item (the first item in OrderedDict)
+                oldest_key, oldest_value = self.cache.popitem(last=False)
+                if self.evict_callback:
+                    self.evict_callback(oldest_key, oldest_value)
+                self.last_access.pop(oldest_key)
+                self.evictions += 1
+
+            # Insert the new key-value pair
+            self.cache[key] = value
+            self.last_access[key] = datetime.now()
+
+    def remove(self, key):
+        """
+        Remove an item from the cache by key.
+
+        Args:
+            key (str): The key of the item to be removed.
+
+        Returns:
+            None
+        """
+        with self.lock:
+            if key in self.cache:
+                if self.evict_callback:
+                    self.evict_callback(key, self.cache[key])
+                self.cache.pop(key)
+                self.last_access.pop(key)
+                self.evictions += 1
+
+    def keys(self):
+        """
+        Get the keys of the cache.
+
+        Returns:
+            List[str]: The list of keys in the cache.
+        """
+        with self.lock:
+            return list(self.cache.keys())
+
+    def cleanup_idle_instance(self):
+        """
+        Clean up idle cache items periodically.
+
+        Returns:
+            None
+        """
+        while not self.stop_cleanup_thread_event.is_set():
+            current_time = datetime.now()
+            keys_to_delete = [key for key, last_access_time in self.last_access.items()
+                              if (current_time - last_access_time) > timedelta(seconds=self.max_idle_time)]
+            for key in keys_to_delete:
+                self.remove(key)
+
+            time.sleep(self.cleanup_interval_sec)
diff --git a/paig-common/src/paig_common/paig_exception.py b/paig-common/src/paig_common/paig_exception.py
new file mode 100644
index 00000000..101756b3
--- /dev/null
+++ b/paig-common/src/paig_common/paig_exception.py
@@ -0,0 +1,14 @@
+class PAIGException(Exception):
+    def __init__(self, message: str = None):
+        self.message = message
+        super().__init__(self.message)
+
+
+class AuditEventQueueFullException(PAIGException):
+    def __init__(self, message: str = None):
+        super().__init__(message)
+
+
+class DiskFullException(PAIGException):
+    def __init__(self, message: str = None):
+        super().__init__(message)
diff --git a/paig-common/src/paig_common/rbac_manager.py b/paig-common/src/paig_common/rbac_manager.py
new file mode 100644
index 00000000..cde2f75c
--- /dev/null
+++ b/paig-common/src/paig_common/rbac_manager.py
@@ -0,0 +1,115 @@
+from dataclasses import dataclass, field
+from typing import List, Optional, Collection
+
+from .response import Response
+from .constants import X_USER_ROLE_HEADER, DEFAULT_OWNER_ROLE, DEFAULT_API_URL_PATTERN
+from .paig_exception import PAIGException
+from .file_utils import FileUtils
+import logging
+import re
+
+_logger = logging.getLogger(__name__)
+
+
+@dataclass
+class Permission:
+    url_endpoint_pattern: str
+    is_exclude: bool
+    roles: List[str]
+    methods: Optional[List[str]] = field(default_factory=list)
+
+    def to_dict(self):
+        return {
+            "url_endpoint_pattern": self.url_endpoint_pattern,
+            "is_exclude": self.is_exclude,
+            "roles": self.roles,
+            "methods": self.methods
+        }
+
+    @staticmethod
+    def from_dict(data):
+        return Permission(
+            url_endpoint_pattern=data.get("url_endpoint_pattern"),
+            is_exclude=data.get("isExclude", False),
+            roles=data.get("roles", []),
+            methods=data.get("method", [])
+        )
+
+
+class RBACManager:
+    _instance = None
+
+    def __new__(cls, rbac_enabled: bool,
+                rbac_permission_mapping_file_path="rbac_permission_mapping_file_path.json",
+                default_url_patterns=None, default_roles=None, *args, **kwargs):
+        if cls._instance is None:
+            cls._instance = super(RBACManager, cls).__new__(cls)
+            cls._instance._initialized = False
+        return cls._instance
+
+    def __init__(self, rbac_enabled: bool,
+                 rbac_permission_mapping_file_path="rbac_permission_mapping_file_path.json",
+                 default_url_patterns=None, default_roles=None):
+
+        if not self._initialized:
+            if default_roles is None:
+                default_roles = [DEFAULT_OWNER_ROLE]
+            if default_url_patterns is None:
+                default_url_patterns = [DEFAULT_API_URL_PATTERN]
+            self.rbac_enabled = rbac_enabled
+            self.rbac_permission_mapping_file_path = rbac_permission_mapping_file_path
+            self.default_url_patterns = default_url_patterns
+            self.default_roles = default_roles
+            self.permission_list = self.load_permissions()
+            self._initialized = True
+
+    def load_permissions(self):
+        try:
+            permission_json = FileUtils.read_json_file(self.rbac_permission_mapping_file_path)
+            return [Permission.from_dict(data) for data in permission_json]
+        except Exception as e:
+            _logger.error("Failed to load the permissions json file: %s", e)
+            raise PAIGException("Failed to load the permissions json file")
+
+    def get_roles_with_permission(self, endpoint, method):
+        for permission in self.permission_list:
+            if re.match(permission.url_endpoint_pattern, endpoint) and (
+                    not permission.methods or (method in permission.methods)):
+                return [] if permission.is_exclude else permission.roles
+
+        default_endpoint_patterns = self.default_url_patterns
+        default_roles = self.default_roles
+
+        for pattern in default_endpoint_patterns:
+            if re.match(pattern, endpoint):
+                return default_roles
+
+        return []
+
+    @staticmethod
+    def get_instance():
+        return RBACManager._instance
+
+
+def check_user_role_permission(path, method, headers):
+    is_filter_enabled = RBACManager.get_instance().rbac_enabled
+    user_role = None
+    if is_filter_enabled:
+        try:
+            role_for_endpoint = RBACManager.get_instance().get_roles_with_permission(path, method)
+            if role_for_endpoint:
+                user_role = headers.get(X_USER_ROLE_HEADER)
+                if not user_role:
+                    _logger.error(f"Header x-user-role not found for request: {path}")
+                    return Response(f"Missing 'x-user-role' header in the request: {path}", status_code=400)
+                if user_role not in role_for_endpoint:
+                    _logger.error(f"User Role with role: {user_role}, is not authorized to access this request: {path}")
+                    return Response(
+                        f"User Role with role: {user_role}, is not authorized to access this request: {path}",
+                        status_code=403)
+        except PAIGException as e:
+            _logger.error(f"Failed to check permissions for request: {path}. Error: {e}")
+            return Response(f"Failed to check permissions for request: {path}", status_code=500)
+    return Response(
+        f"User Role with role: {user_role}, is authorized/RBAC is disable to access this request: {path}",
+        status_code=200)
diff --git a/paig-common/src/paig_common/response.py b/paig-common/src/paig_common/response.py
new file mode 100644
index 00000000..66881121
--- /dev/null
+++ b/paig-common/src/paig_common/response.py
@@ -0,0 +1,13 @@
+class Response:
+    def __init__(self, content: str = "", status_code: int = 200):
+        self.content = content
+        self.status_code = status_code
+
+    def to_flask_response(self):
+        from flask import Response as FlaskResponse
+        return FlaskResponse(response=self.content, status=self.status_code)
+
+    def to_fastapi_response(self):
+        from fastapi.responses import Response as FastApiResponse
+        return FastApiResponse(content=self.content, status_code=self.status_code)
+


Test patch:
diff --git a/paig-common/src/run_unit_test.sh b/paig-common/src/run_unit_test.sh
new file mode 100644
index 00000000..f6577073
--- /dev/null
+++ b/paig-common/src/run_unit_test.sh
@@ -0,0 +1,1 @@
+python3 -m pytest --cov=. --cov-report term:skip-covered --cov-report html:coverage_reports tests
\ No newline at end of file
diff --git a/paig-common/src/tests/test_async_base_rest_http_client.py b/paig-common/src/tests/test_async_base_rest_http_client.py
new file mode 100644
index 00000000..07c84558
--- /dev/null
+++ b/paig-common/src/tests/test_async_base_rest_http_client.py
@@ -0,0 +1,72 @@
+import pytest
+from unittest.mock import patch, MagicMock, AsyncMock
+from paig_common.async_base_rest_http_client import AsyncBaseRESTHttpClient, AsyncReturnValue
+
+
+@pytest.mark.asyncio
+@patch('paig_common.async_http_transport.AsyncHttpTransport.setup')
+@patch('paig_common.config_utils.get_property_value_int')
+@patch('paig_common.config_utils.get_property_value')
+@patch('paig_common.config_utils.get_property_value_float')
+async def test_setup(mock_get_property_value_float, mock_get_property_value, mock_get_property_value_int, mock_setup):
+    mock_get_property_value_int.side_effect = [4, 1]
+    mock_get_property_value.side_effect = ['["GET", "POST", "PUT", "DELETE"]', '[500, 502, 503, 504]']
+    mock_get_property_value_float.side_effect = [2.0, 7.0]
+
+    client = AsyncBaseRESTHttpClient("http://example.com")
+    await client.setup()
+
+    mock_setup.assert_called_once_with(
+        connect_timeout_sec=2.0,
+        read_timeout_sec=7.0
+    )
+
+
+def test_get_auth():
+    client = AsyncBaseRESTHttpClient("http://example.com")
+    assert client.get_auth() is None
+
+
+def test_get_default_headers():
+    client = AsyncBaseRESTHttpClient("http://example.com")
+    headers = client.get_default_headers()
+    assert headers == {"Content-Type": "application/json", "Accept": "application/json"}
+
+
+@pytest.mark.asyncio
+@patch('paig_common.async_http_transport.AsyncHttpTransport.get_client')
+@patch('httpx.Response')
+async def test_request(mock_response, mock_get_client):
+    mock_client = AsyncMock()
+    mock_get_client.return_value = mock_client
+    mock_response.status_code = 200
+    mock_response.text = '{"key": "value"}'
+    mock_client.request.return_value = mock_response
+
+    client = AsyncBaseRESTHttpClient("http://example.com")
+    response = await client.request(url="/test", method="GET")
+
+    assert isinstance(response, AsyncReturnValue)
+    assert response.status_code == 200
+    assert response.json() == {"key": "value"}
+
+
+@pytest.mark.asyncio
+@patch('paig_common.async_base_rest_http_client.AsyncBaseRESTHttpClient.request')
+async def test_http_methods(mock_request):
+    client = AsyncBaseRESTHttpClient("http://example.com")
+
+    await client.get(url="/test")
+    mock_request.assert_called_with(method='GET', url="/test")
+
+    await client.post(url="/test")
+    mock_request.assert_called_with(method='POST', url="/test")
+
+    await client.put(url="/test")
+    mock_request.assert_called_with(method='PUT', url="/test")
+
+    await client.delete(url="/test")
+    mock_request.assert_called_with(method='DELETE', url="/test")
+
+    await client.patch(url="/test")
+    mock_request.assert_called_with(method='PATCH', url="/test")
diff --git a/paig-common/src/tests/test_async_http_transport.py b/paig-common/src/tests/test_async_http_transport.py
new file mode 100644
index 00000000..257e72ff
--- /dev/null
+++ b/paig-common/src/tests/test_async_http_transport.py
@@ -0,0 +1,27 @@
+import pytest
+from unittest.mock import patch, MagicMock
+from paig_common.async_http_transport import AsyncHttpTransport
+
+@pytest.mark.asyncio
+@patch('paig_common.async_http_transport.AsyncHttpTransport.create_default_client')
+async def test_setup(mock_create_default_client):
+    await AsyncHttpTransport.setup(connect_timeout_sec=5.0, read_timeout_sec=10.0)
+    assert AsyncHttpTransport._connect_timeout_sec == 5.0
+    assert AsyncHttpTransport._read_timeout_sec == 10.0
+    mock_create_default_client.assert_called_once()
+
+@pytest.mark.asyncio
+@patch('paig_common.async_http_transport.AsyncHttpTransport.create_default_client')
+async def test_get_client(mock_create_default_client):
+    AsyncHttpTransport._client = None
+    await AsyncHttpTransport.get_client()
+    mock_create_default_client.assert_called_once()
+
+@pytest.mark.asyncio
+@patch('httpx.AsyncClient')
+@patch('httpx.AsyncHTTPTransport')
+async def test_create_default_client(mock_async_http_transport, mock_async_client):
+    mock_async_http_transport_instance = MagicMock()
+    mock_async_http_transport.return_value = mock_async_http_transport_instance
+    await AsyncHttpTransport.create_default_client()
+    mock_async_client.assert_called_once()
\ No newline at end of file
diff --git a/paig-common/src/tests/test_audit_spooler.py b/paig-common/src/tests/test_audit_spooler.py
new file mode 100644
index 00000000..b65e753b
--- /dev/null
+++ b/paig-common/src/tests/test_audit_spooler.py
@@ -0,0 +1,144 @@
+import os
+import random
+from unittest.mock import patch
+
+import pytest
+
+from paig_common.audit_spooler import AuditSpooler, AuditEvent
+from paig_common.file_utils import FileUtils
+from paig_common.paig_exception import DiskFullException
+
+
+class TestAuditEvent:
+
+    def __init__(self, **kwargs):
+        self.event_time = kwargs.get("event_time")
+
+    def to_payload_dict(self):
+        request_dict = {
+            "eventTime": self.event_time
+        }
+
+        return request_dict
+
+    @classmethod
+    def from_payload_dict(cls, payload_dict):
+        kwargs = {
+            "event_time": payload_dict.get("eventTime")
+        }
+        return cls(**kwargs)
+
+
+@pytest.fixture(scope="module")
+def temp_directory(tmpdir_factory):
+    return tmpdir_factory.mktemp("test_data")
+
+
+def test_get_spooled_audit_events(temp_directory):
+    # Create a temporary directory for spooling
+    audit_spool_dir = str(temp_directory.join("spool"))
+
+    # Create an instance of AuditSpooler
+    spooler = AuditSpooler(audit_spool_dir=audit_spool_dir, audit_event_cls=TestAuditEvent)
+
+    # Create some temporary audit event files
+    file1 = audit_spool_dir + "/audit_spool_2022-01-01.json"
+    FileUtils.append_json_to_file(file1, {"eventTime": 1640995200000})
+
+    file1 = audit_spool_dir + "/audit_spool_2022-01-02.json"
+    FileUtils.append_json_to_file(file1, {"eventTime": 1641081600000})
+
+    # Call the method being tested
+    audit_events = spooler.get_spooled_audit_events()
+
+    # Check the result
+    assert len(audit_events) == 2
+
+
+def test_add_audit_event(temp_directory):
+    # Create a temporary directory for spooling
+    audit_spool_dir = str(temp_directory.join("spool" + str(random.randint(0, 1000))))
+
+    # Create an instance of AuditSpooler
+    spooler = AuditSpooler(audit_spool_dir=audit_spool_dir, audit_event_cls=TestAuditEvent)
+
+    # Create a mock audit event
+    event_time = 1640995200000  # January 1, 2022
+    test_event = TestAuditEvent.from_payload_dict({"eventTime": event_time})
+
+    # Call the method being tested
+    spooler.add_audit_event(test_event)
+
+    # Check if the audit event file is created
+    expected_file_path = os.path.join(audit_spool_dir, "audit_spool_2022-01-01.json")
+
+    # Read the content of the audit event file and check if the event is present
+    data = spooler.get_spooled_audit_events()
+    assert len(data) == 1
+    assert data[0].event_time == 1640995200000
+
+
+def test_remove_audit_event(temp_directory):
+    # Create a temporary directory for spooling
+    audit_spool_dir = str(temp_directory.join("spool_test_remove_audit_event_" + str(random.randint(0, 1000))))
+
+    # Create an instance of AuditSpooler
+    spooler = AuditSpooler(audit_spool_dir=audit_spool_dir, audit_event_cls=TestAuditEvent)
+
+    # Create a mock audit event
+    event_time = 1640995200000  # January 1, 2022
+    test_event = TestAuditEvent.from_payload_dict({"eventTime": event_time})
+
+    # Add the mock event to the spooler
+    spooler.add_audit_event(test_event)
+
+    # Call the method being tested
+    spooler.remove_audit_event(test_event)
+
+    # Check if the audit event file is removed
+    files_present = FileUtils.get_file_paths_in_directory(audit_spool_dir)
+    assert len(files_present) == 0
+
+
+def test_get_file_path_for_event_time(temp_directory):
+    # Create a temporary directory for spooling
+    audit_spool_dir = str(temp_directory.join("spool"))
+
+    # Create an instance of AuditSpooler
+    spooler = AuditSpooler(audit_spool_dir=audit_spool_dir, audit_event_cls=TestAuditEvent)
+
+    # Define an event time in milliseconds
+    event_time_millis = 1640995200000  # January 1, 2022
+
+    # Call the method being tested
+    file_path = spooler.get_file_path_for_event_time(event_time_millis)
+
+    # Check if the file path is generated correctly
+    expected_file_path = os.path.join(audit_spool_dir, "audit_spool_2022-01-01.json")
+    assert file_path == expected_file_path
+
+
+class MockAuditEvent(AuditEvent):
+    def to_payload_dict(self):
+        return {"eventTime": 1640995200000}
+
+    @classmethod
+    def from_payload_dict(cls, payload_dict):
+        return cls()
+
+
+@patch('paig_common.audit_spooler.FileUtils.append_json_to_file')
+@patch('paig_common.audit_spooler.AuditSpooler.get_file_path_for_event_time')
+def test_add_audit_event_exception(mock_get_file_path_for_event_time, mock_append_json_to_file, temp_directory):
+    # Create a temporary directory for spooling
+    audit_spool_dir = str(temp_directory.join("spool"))
+    mock_get_file_path_for_event_time.return_value = audit_spool_dir
+    mock_append_json_to_file.side_effect = OSError(28, "No space left on device. Disk is full. Please increase the "
+                                                       "disk size or free"
+                                                       "up some space to push audits successfully.")
+    spooler = AuditSpooler(audit_spool_dir, MockAuditEvent)
+
+    with pytest.raises(DiskFullException) as e:
+        spooler.add_audit_event(MockAuditEvent())
+    assert str(e.value) == ("No space left on device. Disk is full. Please increase the disk size or free up some "
+                            "space to push audits successfully.")
diff --git a/paig-common/src/tests/test_config_utils.py b/paig-common/src/tests/test_config_utils.py
new file mode 100644
index 00000000..da252fde
--- /dev/null
+++ b/paig-common/src/tests/test_config_utils.py
@@ -0,0 +1,87 @@
+from unittest import mock
+from io import BytesIO
+from jproperties import Properties
+
+from paig_common import config_utils
+
+
+# Mock logger
+@mock.patch('paig_common.config_utils.logger')
+def test_load_config_file(mock_logger):
+    # Mock open to simulate reading a config file
+    with mock.patch('builtins.open', mock.mock_open(read_data=b'key1=value1\nkey2=value2')):
+        config_utils.load_config_file('dummy_path')
+        assert config_utils.configs_properties.get('key1').data == 'value1'
+        assert config_utils.configs_properties.get('key2').data == 'value2'
+        mock_logger.info.assert_called_with('Loading config file dummy_path')
+
+
+def test_get_property_value():
+    # Prepare properties
+    props = Properties()
+    props.load(BytesIO(b'key1=value1\nkey2=value2'))
+    config_utils.configs_properties = props
+
+    # Test existing and non-existing keys
+    assert config_utils.get_property_value('key1') == 'value1'
+    assert config_utils.get_property_value('key2') == 'value2'
+    assert config_utils.get_property_value('key3', 'default') == 'default'
+    assert config_utils.get_property_value('key3') is None
+
+
+def test_get_property_value_int():
+    props = Properties()
+    props.load(BytesIO(b'key1=1\nkey2=not_an_int'))
+    config_utils.configs_properties = props
+
+    assert config_utils.get_property_value_int('key1') == 1
+    assert config_utils.get_property_value_int('key2') is None
+    assert config_utils.get_property_value_int('key2', 42) == 42
+    assert config_utils.get_property_value_int('key3', 42) == 42
+
+
+def test_get_property_value_float():
+    props = Properties()
+    props.load(BytesIO(b'key1=1.5\nkey2=not_a_float'))
+    config_utils.configs_properties = props
+
+    assert config_utils.get_property_value_float('key1') == 1.5
+    assert config_utils.get_property_value_float('key2') is None
+    assert config_utils.get_property_value_float('key2', 42.0) == 42.0
+    assert config_utils.get_property_value_float('key3', 42.0) == 42.0
+
+
+def test_get_property_value_boolean():
+    props = Properties()
+    props.load(BytesIO(b'key1=true\nkey2=false\nkey3=not_a_boolean'))
+    config_utils.configs_properties = props
+
+    assert config_utils.get_property_value_boolean('key1') is True
+    assert config_utils.get_property_value_boolean('key2') is False
+    assert config_utils.get_property_value_boolean('key3') is False
+    assert config_utils.get_property_value_boolean('key3', True) is False
+    assert config_utils.get_property_value_boolean('key4', False) is False
+
+
+def test_get_keys():
+    props = Properties()
+    props.load(BytesIO(b'key1=value1\nkey2=value2'))
+    config_utils.configs_properties = props
+
+    keys = config_utils.get_keys()
+    assert 'key1' in keys
+    assert 'key2' in keys
+    assert 'key3' not in keys
+
+
+def test_get_property_value_list():
+    # Prepare properties
+    props = Properties()
+    props.load(BytesIO(b'key1=value1,value2,value3\nkey2=value4'))
+    config_utils.configs_properties = props
+
+    # Test existing and non-existing keys
+    assert config_utils.get_property_value_list('key1') == ['value1', 'value2', 'value3']
+    assert config_utils.get_property_value_list('key2') == ['value4']
+    assert config_utils.get_property_value_list('key3', 'default') == ['default']
+    assert config_utils.get_property_value_list('key3') is None
diff --git a/paig-common/src/tests/test_encryption.py b/paig-common/src/tests/test_encryption.py
new file mode 100644
index 00000000..ee95266a
--- /dev/null
+++ b/paig-common/src/tests/test_encryption.py
@@ -0,0 +1,69 @@
+import base64
+import pytest
+
+from cryptography.hazmat.primitives.asymmetric import padding
+
+from paig_common.encryption import DataEncryptor, RSAKeyUtil
+
+rsa_key_info = RSAKeyUtil().generate_key_pair()
+data_encryptor = DataEncryptor(public_key=rsa_key_info.public_key_encoded_str,
+                               private_key=rsa_key_info.private_key_encoded_str)
+
+public_key_local = RSAKeyUtil.str_to_public_key(rsa_key_info.public_key_encoded_str)
+private_key_local = RSAKeyUtil.str_to_private_key(rsa_key_info.private_key_encoded_str)
+
+msg_data = "Hello, World!"
+
+
+def test_encrypt_decrypt():
+    encrypted_data = data_encryptor.encrypt(msg_data)
+    decrypted_data = data_encryptor.decrypt(encrypted_data)
+    assert decrypted_data == msg_data
+
+
+def test_encrypt_decrypt_error():
+    # below line contain non-ascii characters “ ” . It should raise UnicodeDecodeError
+    non_ascii_char_data = "IndiaandotherSouthAsianCountries to transition to a “high-performing, low-emission, energy secure” economy"
+    encrypted_data = data_encryptor.encrypt(non_ascii_char_data)
+    with pytest.raises(UnicodeDecodeError):
+        print("found issue with old decrypt code! This is expected!")
+        decrypt_data(encrypted_data)
+
+    decrypted_data = data_encryptor.decrypt(encrypted_data)
+    assert decrypted_data == non_ascii_char_data
+
+
+# following code is from encryption.py. to simulate the old behavior of decrypt .
+def decrypt_chunk(chunk: str) -> str:
+    encrypted_data_bytes = base64.b64decode(chunk)
+    decrypted_data = private_key_local.decrypt(
+        encrypted_data_bytes,
+        padding.PKCS1v15()
+    )
+    return decrypted_data.decode("utf-8")
+
+
+def decrypt_data(data):
+    # the encrypted data will be 128 byte long which is 172 bytes base64 encoded
+    chunk_size = 172
+    chunks = [data[i:i + chunk_size] for i in range(0, len(data), chunk_size)]
+    processed_data_parts = [decrypt_chunk(chunk) for chunk in chunks]
+    return ''.join(processed_data_parts)
+
+
+def test_init_data_encryptor_with_none_keys():
+    data_encryptor_local = DataEncryptor(public_key=None, private_key=None)
+
+    with pytest.raises(ValueError):
+        data_encryptor_local.encrypt(msg_data)
+
+    with pytest.raises(ValueError):
+        data_encryptor_local.decrypt(msg_data)
+
+
+def test_rsa_util_error_keys():
+    public_key_err = RSAKeyUtil.str_to_public_key("MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCwn0+nJ2WQfR5zKsPJv7ghGsYYy+En5hDO86IdH5oyY9Y7fN3iQSGCvFPVIiGbpg309Yzxj/9e8pK0QzuinklKS97VBoo+fJUQJt+UUgpfGFQ0Ce3pThDm2KtmZDS3ApB18u1Y4PutZlyiZxqCxftIr9nN8u5CHAGro3ivIOrS2QIDA")
+    private_key_err = RSAKeyUtil.str_to_private_key("MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBALCfT6cnZZB9HnMqw8m/uCEaxhjL4SfmEM7zoh0fmjJj1jt83eJBIYK8U9UiIZumDfT1jPGP/17ykrRDO6KeSUpL3tUGij58lRAm35RSCl8YVDQJ7elOEObYq2ZkNLcCkHXy7Vjg+61mXKJnGoLF+0iv2c3y7kIcAaujeK8g6tLZAgMBAAECgYAfvNji2AT7pEgS+NXKzI0pQbbkIMq6UTCnB4eThSi/skn+UY9Lh1metPm5fFMetYWfhXpItA/2/07WXph6Pcg8aZp+vWjhE5vumAaZB6hsQ7xaToeNPfHNhHxY0DixHcHE/pwpk3zSlOyhYlFD+cGkOGBEbUthE7NDdSZEN/mR6QJBANlV8rMGN7lpli8+DvtyMSMTEndnijGStiSHzuVjXI+x8TfPTU0EKvbPr1iE5Eb8PJ87MTQBIoaHpUkzO5c8jQ0CQQDQCyohLaZi5CCR4Y92c+wAOALTg6uK/L1zyTg0V7LvaO5ZP6BwH2ta8vMDvqTEXwheealf1jE+lOaSwM7Bx+H9AkA5pjrCkhul6wQTc/q1aAzwqNZ1JdnaXHhEKGIRJtIeq6y4iRyQGVLsNRrl+Bo1WiMaebELgmGOHd0SFazw6PbhAkEAlSrMJEnk+Rb0u2RtRtNLLcT7Uckg4GDjPffS0sTmyX4FF/zk/j+o6+cyk0bIQPyatZKp/MtJN8Pvpt2T4aF9yQJADTPbNSwjEOp6wwH5GevMIloJlc32z5kM1IPaxaHv9GVUecW2MNcpRaI9RqDrKfTSMFbvhJYezQgVuV3j77UU")
+
+    assert public_key_err is None
+    assert private_key_err is None
diff --git a/paig-common/src/tests/test_file_utils.py b/paig-common/src/tests/test_file_utils.py
new file mode 100644
index 00000000..707fc0b0
--- /dev/null
+++ b/paig-common/src/tests/test_file_utils.py
@@ -0,0 +1,108 @@
+import json
+import os
+
+import pytest
+
+from paig_common.file_utils import FileUtils
+
+
+@pytest.fixture(scope="module")
+def temp_directory(tmpdir_factory):
+    return tmpdir_factory.mktemp("test_data")
+
+
+def test_get_file_paths_in_directory(temp_directory):
+    # Create some temporary files
+    file1 = temp_directory.join("file1.txt")
+    file1.write("Test data")
+    file2 = temp_directory.join("file2.txt")
+    file2.write("More test data")
+
+    # Call the method being tested
+    file_paths = FileUtils.get_file_paths_in_directory(temp_directory)
+
+    # Check the result
+    assert len(file_paths) == 2
+    assert file1 in file_paths
+    assert file2 in file_paths
+
+
+def test_load_json_from_file(temp_directory):
+    # Create a temporary JSON file
+    json_data = {"key": "value"}
+    json_file = temp_directory.join("test.json")
+    with open(json_file, "w") as f:
+        json.dump(json_data, f)
+
+    # Call the method being tested
+    loaded_data = FileUtils.load_json_from_file(str(json_file))
+
+    # Check the result
+    assert loaded_data[0] == json.dumps(json_data)
+
+
+def test_load_from_file(temp_directory):
+    # Create a temporary JSON file
+    json_data = [{"key": "value"}]
+    json_file = temp_directory.join("test.json")
+    with open(json_file, "w") as f:
+        json.dump(json_data, f)
+
+    # Call the method being tested
+    loaded_data = FileUtils.read_json_file(str(json_file))
+
+    # Check the result
+    assert loaded_data == json_data
+
+
+def test_append_json_to_file(temp_directory):
+    # Create a temporary JSON file
+    json_file = temp_directory.join("test.json")
+    os.remove(str(json_file))
+
+    # Append JSON data to the file
+    data_to_append = {"new_key": "new_value"}
+    FileUtils.append_json_to_file(str(json_file), data_to_append)
+
+    # Read the file and check if data is appended correctly
+    with open(json_file, "r") as f:
+        appended_data = json.loads(f.read().strip())
+    assert appended_data == data_to_append
+
+
+def test_remove_line_from_file(temp_directory):
+    # Create a temporary text file
+    text_file = temp_directory.join("test.txt")
+    with open(text_file, "w") as f:
+        f.write('{"1": "1"}')
+        f.write("\n")
+        f.write('{"2": "2"}')
+        f.write("\n")
+        f.write('{"1": "1"}')
+        f.write("\n")
+
+    # Remove a line from the file
+    FileUtils.remove_line_from_file(str(text_file), '{"2": "2"}')
+
+    # Read the file and check if line is removed
+    with open(text_file, "r") as f:
+        lines = f.readlines()
+    assert '{"2": "2"}' not in lines
+
+
+def test_remove_empty_files(temp_directory):
+    # Create some temporary files (including an empty file)
+    file1 = temp_directory.join("file1.txt")
+    file1.write("Test data")
+    file2 = temp_directory.join("file2.txt")
+    file2.write("")
+    file3 = temp_directory.join("file3.txt")
+    file3.write("More test data")
+
+    # Remove empty files
+    FileUtils.remove_empty_files(str(temp_directory))
+
+    # Check if empty files are removed
+    assert not os.path.exists(str(file2))
+    assert os.path.exists(str(file1))
+    assert os.path.exists(str(file3))
diff --git a/paig-common/src/tests/test_http_transport.py b/paig-common/src/tests/test_http_transport.py
new file mode 100644
index 00000000..6ac6a042
--- /dev/null
+++ b/paig-common/src/tests/test_http_transport.py
@@ -0,0 +1,56 @@
+import urllib3
+
+from paig_common.http_transport import HttpTransport
+
+
+class TestHttpTransport:
+
+    #  HttpTransport.get_http() returns an instance of urllib3.PoolManager
+    def test_get_http_returns_instance_of_pool_manager(self):
+        # Invoke the method under test
+        http = HttpTransport.get_http()
+
+        # Assert that the method returns an instance of urllib3.PoolManager
+        assert isinstance(http, urllib3.PoolManager)
+
+    #  HttpTransport.create_default_http() creates a default instance of urllib3.PoolManager
+    def test_create_default_http_creates_default_instance_of_pool_manager(self):
+        # Invoke the method under test
+        HttpTransport.create_default_http()
+        retries = urllib3.Retry(total=4,
+                                backoff_factor=1,
+                                allowed_methods=["GET", "POST", "PUT", "DELETE"],
+                                status_forcelist=[500, 502, 503, 504])
+        timeout = urllib3.Timeout(connect=2.0, read=7.0)
+        # Assert that the PoolManager constructor is called with the correct arguments
+        assert HttpTransport._http.connection_pool_kw.get('block') == True
+        assert compare_objects(HttpTransport._http.connection_pool_kw.get('retries'), retries)
+        assert compare_objects(HttpTransport._http.connection_pool_kw.get('timeout'), timeout)
+
+    #  HttpTransport.setup() allows overriding default settings
+    def test_setup_allows_overriding_default_settings(self):
+        # Invoke the method under test
+        HttpTransport.setup(max_retries=3, backoff_factor=2, allowed_methods=["GET"], status_forcelist=[500])
+
+        # Assert that the default settings are overridden
+        assert HttpTransport._max_retries == 3
+        assert HttpTransport._backoff_factor == 2
+        assert HttpTransport._allowed_methods == ["GET"]
+        assert HttpTransport._status_forcelist == [500]
+
+
+def compare_objects(obj1, obj2):
+    # Check if the objects are of the same type
+    if type(obj1) != type(obj2):
+        return False
+
+    # Get the dictionary of attributes for each object
+    attrs1 = vars(obj1)
+    attrs2 = vars(obj2)
+
+    # Compare attributes
+    for key in attrs1.keys():
+        if attrs1[key] != attrs2.get(key):
+            return False
+
+    return True
diff --git a/paig-common/src/tests/test_lru_cache.py b/paig-common/src/tests/test_lru_cache.py
new file mode 100644
index 00000000..4e7b6b78
--- /dev/null
+++ b/paig-common/src/tests/test_lru_cache.py
@@ -0,0 +1,96 @@
+import time
+from threading import Thread
+
+import pytest
+from unittest.mock import Mock
+from paig_common.lru_cache import LRUCache, LRUCacheEvictCallback
+
+
+@pytest.fixture
+def cache():
+    return LRUCache("test-cache", 5, 5, 1)  # Create a cache with capacity 5 and max idle time 5 seconds
+
+
+def test_put_and_get(cache):
+    cache.put("key1", "value1")
+    assert cache.get("key1") == "value1"  # Check if the value is retrieved correctly
+
+
+def test_cache_eviction(cache):
+    for i in range(5):
+        cache.put(f"key{i}", f"value{i}")
+    for i in range(4, -1, -1):
+        cache.get(f"key{i}")
+    cache.put("key6", "value6")
+    assert cache.get("key4") is None  # Check if key0 has been evicted
+
+
+def test_cache_cleanup(cache):
+    cache.put("key1", "value1")
+    cache.put("key2", "value2")
+    time.sleep(6)
+    assert cache.get("key1") is None
+
+
+def test_cache_stop_cleanup(cache):
+    cache.put("key1", "value1")
+    cache.put("key2", "value2")
+    cache.stop_cleanup_thread()
+    time.sleep(6)
+    assert cache.get("key1") == "value1"
+
+
+def test_cache_hits_and_misses(cache):
+    cache.put("key1", "value1")
+    assert cache.get("key1") == "value1"  # Cache hit
+    assert cache.get("key2") is None  # Cache miss
+    assert cache.hits == 1
+    assert cache.misses == 1
+
+
+def test_cache_capacity_handling(cache):
+    for i in range(5):
+        cache.put(f"key{i}", f"value{i}")
+    cache.put("key5", "value5")  # This should cause the first inserted key to be evicted
+    assert len(cache.keys()) == 5  # Ensure cache size is within capacity
+    assert cache.get("key0") is None  # Ensure the oldest item was evicted
+
+
+def test_eviction_callback(cache):
+    mock_callback = Mock(spec=LRUCacheEvictCallback)
+    cache_with_callback = LRUCache("test-cache-cb", 2, 5, 1, evict_callback=mock_callback)
+    cache_with_callback.put("key1", "value1")
+    cache_with_callback.put("key2", "value2")
+    cache_with_callback.put("key3", "value3")  # This should trigger eviction of key1
+
+    mock_callback.assert_called_once_with("key1", "value1")
+
+
+def test_thread_safety(cache):
+    def put_items():
+        for i in range(50):
+            cache.put(f"key{i}", f"value{i}")
+
+    def get_items():
+        for i in range(50):
+            cache.get(f"key{i}")
+
+    threads = [Thread(target=put_items), Thread(target=get_items)]
+
+    for thread in threads:
+        thread.start()
+
+    for thread in threads:
+        thread.join()
+
+    # Ensure the cache size doesn't exceed the capacity and no exceptions were raised
+    assert len(cache.keys()) <= cache.capacity
+
+
+def test_manual_removal_of_items(cache):
+    cache.put("key1", "value1")
+    cache.put("key2", "value2")
+    cache.remove("key1")
+    assert cache.get("key1") is None
+    assert cache.get("key2") == "value2"
+    assert "key1" not in cache.keys()
diff --git a/paig-common/src/tests/test_paig_exception.py b/paig-common/src/tests/test_paig_exception.py
new file mode 100644
index 00000000..1f7e390e
--- /dev/null
+++ b/paig-common/src/tests/test_paig_exception.py
@@ -0,0 +1,9 @@
+import pytest
+from paig_common.paig_exception import PAIGException
+
+
+def test_paig_exception():
+    message = "Test exception message"
+    exception = PAIGException(message)
+    assert str(exception) == message
+    assert exception.message == message
diff --git a/paig-common/src/tests/test_rbac_manager.py b/paig-common/src/tests/test_rbac_manager.py
new file mode 100644
index 00000000..5e014bfe
--- /dev/null
+++ b/paig-common/src/tests/test_rbac_manager.py
@@ -0,0 +1,75 @@
+import pytest
+from unittest.mock import patch, MagicMock
+
+from paig_common.paig_exception import PAIGException
+from paig_common.rbac_manager import RBACManager, check_user_role_permission
+
+
+@patch('paig_common.rbac_manager.FileUtils.read_json_file')
+def test_rbac_manager(mock_load_from_file):
+    mock_load_from_file.return_value = [
+        {
+            "url_endpoint_pattern": "/api/test",
+            "is_exclude": False,
+            "roles": ["OWNER"],
+            "methods": ["GET"]
+        }
+    ]
+    rbac_manager = RBACManager(True)
+    assert rbac_manager.rbac_enabled == True
+    assert rbac_manager.rbac_permission_mapping_file_path == "rbac_permission_mapping_file_path.json"
+    assert rbac_manager.default_url_patterns == ['/api/.*']
+    assert rbac_manager.default_roles == ['OWNER']
+
+
+@patch('paig_common.rbac_manager.RBACManager.get_instance')
+def test_check_user_role_permission(mock_get_instance):
+    mock_instance = MagicMock()
+    mock_instance.rbac_enabled = True
+    mock_instance.get_roles_with_permission.return_value = ['OWNER']
+    mock_get_instance.return_value = mock_instance
+
+    headers = {'x-user-role': 'OWNER'}
+    response = check_user_role_permission('/api/test', 'GET', headers)
+    assert response.status_code == 200
+    assert 'is authorized' in response.content
+
+    headers = {'x-user-role': 'USER'}
+    response = check_user_role_permission('/api/test', 'GET', headers)
+    assert response.status_code == 403
+    assert 'is not authorized' in response.content
+
+    response = check_user_role_permission('/api/test', 'GET', {})
+    assert response.status_code == 400
+    assert "Missing 'x-user-role' header" in response.content
+
+
+@patch('paig_common.rbac_manager.FileUtils.read_json_file')
+def test_get_roles_with_permission(mock_load_from_file):
+    mock_load_from_file.return_value = [
+        {
+            "url_endpoint_pattern": "/api/test",
+            "is_exclude": False,
+            "roles": ["OWNER"],
+            "method": ["GET"]
+        }
+    ]
+    rbac_manager = RBACManager(True)
+    roles = rbac_manager.get_roles_with_permission('/api/test', 'GET')
+    assert roles == ['OWNER']
+
+    roles = rbac_manager.get_roles_with_permission('/api/other', 'GET')
+    assert roles == ['OWNER']
+
+    roles = rbac_manager.get_roles_with_permission('/test', 'POST')
+    assert roles == []
+
+
+@patch('paig_common.rbac_manager.FileUtils.read_json_file')
+def test_load_permissions_exception(mock_load_from_file):
+    mock_load_from_file.side_effect = Exception("File not found")
+
+    with pytest.raises(PAIGException) as e:
+        RBACManager(True).load_permissions()
+    assert str(e.value) == "Failed to load the permissions json file"
+
diff --git a/paig-common/src/tests/test_response.py b/paig-common/src/tests/test_response.py
new file mode 100644
index 00000000..f6cc352b
--- /dev/null
+++ b/paig-common/src/tests/test_response.py
@@ -0,0 +1,26 @@
+import pytest
+from flask import Response as FlaskResponse
+from fastapi.responses import Response as FastApiResponse
+from paig_common.response import Response
+
+
+def test_response_initialization():
+    response = Response("Test content", 404)
+    assert response.content == "Test content"
+    assert response.status_code == 404
+
+
+def test_to_flask_response():
+    response = Response("Test content", 404)
+    flask_response = response.to_flask_response()
+    assert isinstance(flask_response, FlaskResponse)
+    assert flask_response.get_data(as_text=True) == "Test content"
+    assert flask_response.status_code == 404
+
+
+def test_to_fastapi_response():
+    response = Response("Test content", 404)
+    fastapi_response = response.to_fastapi_response()
+    assert isinstance(fastapi_response, FastApiResponse)
+    assert fastapi_response.body == b"Test content"
+    assert fastapi_response.status_code == 404