From 6150ec7ccd68c154d69c78ef98d8640c94b870f3 Mon Sep 17 00:00:00 2001 From: sambles Date: Thu, 27 Feb 2025 14:17:25 +0000 Subject: [PATCH] Azure Service Principal for Postgres SQL authentication - (#1169) (#1170) * Azure Service Principal for Postgres SQL authentication - (#1169) * custom-backend changes Update in settings.py Part of OASIS Service Principal Authentication * custom backend wrapper Create base.py * Create __init__.py init * Create __init__.py * Create readme.md * Update readme.md * Update readme.md * Update readme.md * azure-identity package imported to requirements-server.in * Update requirements-server.txt * Update postgres.docker-compose.yml SPA tenant_id, client_id params introduced to compose file * Update readme.md * Update readme.md * Update readme.md * Update readme.md * Update readme.md * Updated Package Requirements: azure-identity * pep --------- Co-authored-by: Saseem75 <156656857+Saseem75@users.noreply.github.com> Co-authored-by: awsbuild --- requirements-server.in | 1 + requirements-server.txt | 22 ++++- requirements-worker.txt | 2 +- requirements.txt | 6 +- .../oasisapi/custom_db_backend/__init__.py | 0 .../custom_db_backend/base/__init__.py | 0 .../oasisapi/custom_db_backend/base/base.py | 39 ++++++++ .../oasisapi/custom_db_backend/readme.md | 99 +++++++++++++++++++ src/server/oasisapi/settings/base.py | 22 +++++ 9 files changed, 186 insertions(+), 5 deletions(-) create mode 100644 src/server/oasisapi/custom_db_backend/__init__.py create mode 100644 src/server/oasisapi/custom_db_backend/base/__init__.py create mode 100644 src/server/oasisapi/custom_db_backend/base/base.py create mode 100644 src/server/oasisapi/custom_db_backend/readme.md diff --git a/requirements-server.in b/requirements-server.in index d8981cdea..25d49de99 100755 --- a/requirements-server.in +++ b/requirements-server.in @@ -33,3 +33,4 @@ redis sqlalchemy mysqlclient==2.1.1 whitenoise +azure-identity diff --git a/requirements-server.txt b/requirements-server.txt index d2f57260e..540bcdc06 100644 --- a/requirements-server.txt +++ b/requirements-server.txt @@ -28,8 +28,11 @@ automat==24.8.1 # via twisted azure-core==1.31.0 # via + # azure-identity # azure-storage-blob # django-storages +azure-identity==1.20.0 + # via -r requirements-server.in azure-storage-blob==12.23.1 # via django-storages billiard==4.2.1 @@ -83,9 +86,12 @@ cramjam==2.9.0 cryptography==43.0.3 # via # autobahn + # azure-identity # azure-storage-blob # josepy # mozilla-django-oidc + # msal + # pyjwt # pyopenssl # service-identity daphne==2.5.0 @@ -191,6 +197,12 @@ markupsafe==3.0.2 # via jinja2 mozilla-django-oidc==4.0.1 # via -r requirements-server.in +msal==1.31.1 + # via + # azure-identity + # msal-extensions +msal-extensions==1.2.0 + # via azure-identity msgpack==0.6.2 # via channels-redis mysqlclient==2.1.1 @@ -223,6 +235,8 @@ pandas==2.2.3 # ods-tools pathlib2==2.3.7.post1 # via -r requirements-server.in +portalocker==2.10.1 + # via msal-extensions prompt-toolkit==3.0.48 # via click-repl psycopg2-binary==2.9.10 @@ -237,8 +251,10 @@ pyasn1-modules==0.4.1 # via service-identity pycparser==2.22 # via cffi -pyjwt==2.9.0 - # via djangorestframework-simplejwt +pyjwt[crypto]==2.9.0 + # via + # djangorestframework-simplejwt + # msal pymysql==1.1.1 # via -r requirements-server.in pyopenssl==24.2.1 @@ -273,6 +289,7 @@ requests==2.32.3 # azure-core # coreapi # mozilla-django-oidc + # msal rpds-py==0.20.0 # via # jsonschema @@ -299,6 +316,7 @@ txaio==23.1.1 typing-extensions==4.12.2 # via # azure-core + # azure-identity # azure-storage-blob # oasis-data-manager # sqlalchemy diff --git a/requirements-worker.txt b/requirements-worker.txt index 012a04c22..e21c05f12 100644 --- a/requirements-worker.txt +++ b/requirements-worker.txt @@ -38,7 +38,7 @@ azure-core==1.31.0 # azure-storage-blob azure-datalake-store==0.0.53 # via adlfs -azure-identity==1.19.0 +azure-identity==1.20.0 # via adlfs azure-storage-blob==12.23.1 # via diff --git a/requirements.txt b/requirements.txt index fe11f1480..bccaecc58 100644 --- a/requirements.txt +++ b/requirements.txt @@ -58,8 +58,10 @@ azure-core==1.31.0 # django-storages azure-datalake-store==0.0.53 # via adlfs -azure-identity==1.19.0 - # via adlfs +azure-identity==1.20.0 + # via + # -r /home/runner/work/OasisPlatform/OasisPlatform/requirements-server.in + # adlfs azure-storage-blob==12.23.1 # via # -r /home/runner/work/OasisPlatform/OasisPlatform/requirements-worker.in diff --git a/src/server/oasisapi/custom_db_backend/__init__.py b/src/server/oasisapi/custom_db_backend/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/server/oasisapi/custom_db_backend/base/__init__.py b/src/server/oasisapi/custom_db_backend/base/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/server/oasisapi/custom_db_backend/base/base.py b/src/server/oasisapi/custom_db_backend/base/base.py new file mode 100644 index 000000000..f7cce8315 --- /dev/null +++ b/src/server/oasisapi/custom_db_backend/base/base.py @@ -0,0 +1,39 @@ +from django.db.backends.postgresql.base import DatabaseWrapper as PostgresDatabaseWrapper +from azure.identity import ClientSecretCredential +import psycopg2 + + +class DatabaseWrapper(PostgresDatabaseWrapper): + def __init__(self, settings_dict, alias='default'): + super().__init__(settings_dict, alias) + self.settings_dict = settings_dict # Store settings for later use + self.credential = None # Initialize credential object + + def get_azure_access_token(self): + """ + Fetches a fresh access token using Azure Service Principal credentials. + """ + settings = self.settings_dict + tenant_id = settings.get('TENANT_ID') + client_id = settings.get('CLIENT_ID') + client_secret = settings.get('CLIENT_SECRET') + + if not all([tenant_id, client_id, client_secret]): + raise ValueError("Azure AD credentials are missing in DATABASES settings.") + + # Initialize credential object only once + if self.credential is None: + self.credential = ClientSecretCredential(tenant_id, client_id, client_secret) + + # Fetch a fresh token + token = self.credential.get_token("https://ossrdbms-aad.database.windows.net/.default") + return token.token + + def get_new_connection(self, conn_params): + """ + Establish a new PostgreSQL connection with a fresh Azure AD token. + """ + conn_params["password"] = self.get_azure_access_token() # Get fresh token + conn_params["sslmode"] = "require" # Ensure SSL connection + + return psycopg2.connect(**conn_params) diff --git a/src/server/oasisapi/custom_db_backend/readme.md b/src/server/oasisapi/custom_db_backend/readme.md new file mode 100644 index 000000000..1a2bda9c8 --- /dev/null +++ b/src/server/oasisapi/custom_db_backend/readme.md @@ -0,0 +1,99 @@ +# Azure Service Principal for PostgreSQL Authentication + +#### Defining custom DB Engine for Azure Service Principal Authentication +#### Tree + +```/var/www/oasis/ +│── src/ +│ ├── server/ +│ │ ├── oasisapi/ +│ │ │ ├── settings.py # Configures database backend +│ │ │ ├── custom_db_backend/ +│ │ │ │ ├── __init__.py # existing +│ │ │ │ ├── readme.md # Defining the custom DB Engine +│ │ │ │ ├── base/ +│ │ │ │ | ├── base.py # where we have the DatabaseWrapper +│ │ │ │ | ├── __init__.py # existing +``` + +## Overview +This document explains how to implement **Azure Service Principal Authentication** with **token refresh** for a **Django application** using **PostgreSQL** as the authentication backend. The goal is to authenticate applications securely while enabling token renewal for continuous access. + +#### Key Components +1. **Azure Service Principal**: A non-human identity used by applications to authenticate and authorize access to Azure resources. +2. **PostgreSQL Database**: A database-driven authentication system that stores service principal credentials and permissions. +3. **Custom PostgreSQL Database Wrapper**: Extends Django’s default PostgreSQL database backend to authenticate using Azure AD tokens. + +#### Architecture +1. **Service Principal Registration**: Store client ID, hashed secret, and permissions in PostgreSQL. +2. **Authentication Flow**: + - The application sends authentication requests with client ID and secret. + - The Django authentication wrapper validates credentials against the database. + - On success, an access token and a refresh token are issued. +3. **Token Refresh Flow**: + - The application sends a refresh request with a valid refresh token. + - The wrapper validates the refresh token and issues a new access token. +4. **Database Authentication via Azure AD**: + - The custom database wrapper fetches a fresh Azure AD token for database authentication. + +#### Implementation Steps +##### 1. *_Custom settings.py_* + +```python +# For Azure Service Principal Authentication with token rotation + + DATABASES = { + 'default': { + 'ENGINE': DB_ENGINE, + 'NAME': iniconf.settings.get('server', 'db_name'), + 'USER': iniconf.settings.get('server', 'AZURE_SERVICE_PRINCIPAL_USER'), + 'PASSWORD': '', # Database-Custom-backendWrapper.get_token + 'HOST': iniconf.settings.get('server', 'db_host'), + 'PORT': iniconf.settings.get('server', 'db_port'), + 'TENANT_ID': iniconf.settings.get('server','AZURE_TENANT_ID', fallback=None), + 'CLIENT_ID': iniconf.settings.get('server','AZURE_CLIENT_ID', fallback=None), + 'CLIENT_SECRET': iniconf.settings.get('server','AZURE_CLIENT_SECRET', fallback=None), + } + } +``` + +##### 2. Custom Database Backend (base.py) +Create a custom database backend that authenticates using Azure AD. + +```python +class DatabaseWrapper(PostgresDatabaseWrapper): + def __init__(self, settings_dict, alias='default'): + super().__init__(settings_dict, alias) + self.settings_dict = settings_dict # Store settings for later use + self.credential = None # Initialize credential object + + def get_azure_access_token(self): + """ + Fetches a fresh access token using Azure Service Principal credentials. + """ + settings = self.settings_dict + tenant_id = settings.get('TENANT_ID') + client_id = settings.get('CLIENT_ID') + client_secret = settings.get('CLIENT_SECRET') + + if not all([tenant_id, client_id, client_secret]): + raise ValueError("Azure AD credentials are missing in DATABASES settings.") + + # Initialize credential object only once + if self.credential is None: + self.credential = ClientSecretCredential(tenant_id, client_id, client_secret) + + # Fetch a fresh token + token = self.credential.get_token("https://ossrdbms-aad.database.windows.net/.default") + return token.token +``` + + +### Security Best Practices followed +- **Short-Lived Access Tokens**: Implement tokens with a short expiration time and refresh capability. +- **Access Control**: Enforce fine-grained permissions stored in PostgreSQL. +- **Secure Database Connection**: Enforce SSL and use short-lived Azure AD tokens for authentication. + +## Conclusion +This implementation provides a secure way to authenticate Azure service principals using a Django application with PostgreSQL while enabling token refresh for uninterrupted access. The custom database backend ensures secure connections using Azure AD authentication. + diff --git a/src/server/oasisapi/settings/base.py b/src/server/oasisapi/settings/base.py index 1c48e5250..b383851aa 100644 --- a/src/server/oasisapi/settings/base.py +++ b/src/server/oasisapi/settings/base.py @@ -164,7 +164,29 @@ 'NAME': os.path.join(BASE_DIR, iniconf.settings.get('server', 'db_name', fallback='db.sqlite3')), } } + + +elif DB_ENGINE == 'src.server.oasisapi.custom_db_backend.base': + + # For Azure Service Principal Authentication with token rotation + + DATABASES = { + 'default': { + 'ENGINE': DB_ENGINE, + 'NAME': iniconf.settings.get('server', 'db_name'), + 'USER': iniconf.settings.get('server', 'AZURE_SERVICE_PRINCIPAL_USER'), + 'PASSWORD': '', # Database-Custom-backendWrapper.get_token + 'HOST': iniconf.settings.get('server', 'db_host'), + 'PORT': iniconf.settings.get('server', 'db_port'), + 'TENANT_ID': iniconf.settings.get('server', 'AZURE_TENANT_ID', fallback=None), + 'CLIENT_ID': iniconf.settings.get('server', 'AZURE_CLIENT_ID', fallback=None), + 'CLIENT_SECRET': iniconf.settings.get('server', 'AZURE_CLIENT_SECRET', fallback=None), + } + } + + else: + DATABASES = { 'default': { 'ENGINE': DB_ENGINE,