├── .github └── workflows │ └── publish.yaml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── docs ├── index.md └── pages │ ├── api.md │ ├── authentication.md │ ├── authorization.md │ └── getting_started.md ├── idp_user ├── __init__.py ├── agents.py ├── apps.py ├── auth │ ├── __init__.py │ ├── admin.py │ ├── drf.py │ └── ninja.py ├── checks.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── put_app_entities_records_to_kafka.py ├── migrations │ ├── 0001_initial.py │ ├── 0001_initial_squashed_0005_alter_userrole_id.py │ ├── 0002_auto_20220120_1617.py │ ├── 0003_auto_20220513_1025.py │ ├── 0004_auto_20220817_1526.py │ ├── 0005_alter_userrole_id.py │ ├── 0006_userrole_organization_and_more.py │ ├── 0007_user_demo.py │ └── __init__.py ├── models │ ├── __init__.py │ ├── user.py │ └── user_role.py ├── producer.py ├── schema_extensions.py ├── services │ ├── __init__.py │ ├── async_user.py │ ├── base_user.py │ └── user.py ├── settings.py ├── signals.py ├── urls.py └── utils │ ├── __init__.py │ ├── choices.py │ ├── classes.py │ ├── exceptions.py │ ├── functions.py │ └── typing.py ├── mkdocs.yml ├── pyproject.toml ├── requirements.txt ├── scripts ├── format.sh ├── lint.sh ├── test.sh └── update_version.py ├── setup.py └── tests ├── __init__.py ├── conftest.py ├── test_app_entity.py ├── test_apps.py ├── test_auth ├── __init__.py └── test_authentication.py ├── test_roles.py └── test_settings.py /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: Publish a new version of django-idp-user 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | permissions: 8 | contents: read 9 | 10 | jobs: 11 | deploy: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: Set up Python 18 | uses: actions/setup-python@v3 19 | with: 20 | python-version: '3.10' 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install build 25 | - name: Build package 26 | run: python -m build 27 | - name: Publish package 28 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 29 | with: 30 | user: __token__ 31 | password: ${{ secrets.PYPI_API_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .venv/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2023 CardoAI 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL := /bin/sh 2 | APP_NAME := idp_user 3 | PROJECTNAME := $(shell basename $(CURDIR)) 4 | 5 | 6 | define HELP 7 | 8 | Manage $(PROJECTNAME). Usage: 9 | 10 | make lint Run linter 11 | make format Run formatter 12 | make test Run tests 13 | make update-version Update version in readme.md 14 | make pre-commit Install pre-commit hooks 15 | 16 | endef 17 | 18 | export HELP 19 | 20 | lint: 21 | @bash ./scripts/lint.sh 22 | 23 | format: 24 | @bash ./scripts/format.sh 25 | 26 | test: 27 | @bash ./scripts/test.sh 28 | 29 | update-version: 30 | python3 ./scripts/update_version.py 31 | 32 | pre-commit: 33 | pre-commit install 34 | 35 | all help: 36 | @echo "$$HELP" 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django IDP User 2 | 3 | [![pypi-badge]][pypi] 4 | [![build-status-image]][build-status] 5 | [![package-status]][repo] 6 | [![github-last-commit]][repo] 7 | 8 | --- 9 | 10 | ## Installation 11 | 12 | 1. Install the package: 13 | ``` 14 | pip install django-idp-user 15 | ``` 16 | 17 | If you want to use the async version of the package, you can install it with the `async` extra: 18 | ``` 19 | pip install django-idp-user[async] 20 | ``` 21 | 22 | 2. Add `idp_user` to your `INSTALLED_APPS`: 23 | ```python 24 | INSTALLED_APPS = [ 25 | # ... 26 | 'idp_user', 27 | ] 28 | ``` 29 | 30 | 3. Add the settings of the app in `settings.py` like this: 31 | ```python3 32 | AUTH_USER_MODEL = 'idp_user.User' 33 | 34 | IDP_USER_APP = { 35 | "IDP_ENVIRONMENT": "staging/production/etc.", 36 | "APP_IDENTIFIER": "str", 37 | "ROLES": "path.to.roles_choices", 38 | "FAUST_APP_PATH": "backend.kafka_consumer.app", 39 | "USE_REDIS_CACHE": True, 40 | "IDP_URL": "idp_url", # Optional 41 | "APP_ENTITIES": { 42 | "": { 43 | "model": "", 44 | "identifier_attr": "", 45 | "label_attr": "", 46 | } 47 | }, 48 | } 49 | 50 | REST_FRAMEWORK = { 51 | 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema' or 'idp_user.schema_extensions.AutoSchemaWithRole', 52 | 'DEFAULT_AUTHENTICATION_CLASSES': ( 53 | 'idp_user.auth.AuthenticationBackend', 54 | ), 55 | } 56 | 57 | SPECTACULAR_SETTINGS = { 58 | 'DEFAULT_AUTHENTICATION_CLASSES': ( 59 | 'idp_user.schema_extensions.BearerTokenScheme', 60 | ), 61 | 'SERVE_AUTHENTICATION': () 62 | } 63 | 64 | # Kafka Related 65 | KAFKA_ARN = "" # Encoded in base64 66 | KAFKA_AWS_ACCESS_KEY_ID = "" 67 | KAFKA_AWS_SECRET_ACCESS_KEY = "" # Encoded in base64 68 | AWS_S3_REGION_NAME = "" 69 | ``` 70 | 71 | 4. Create the database tables for the app by running the following command: 72 | ``` 73 | python manage.py migrate 74 | ``` 75 | 76 | 77 | ## Async Support 78 | 79 | Django version 4.1.1 is required for async support. 80 | 81 | To use the async version of the package, you need to add the `async` extra when installing the package: 82 | ``` 83 | pip install django-idp-user[async] 84 | ``` 85 | 86 | If you are using Channels for websockets, you can use the `IDPChannelsAuthenticationMiddleware` like so: 87 | ```python 88 | from channels.routing import ProtocolTypeRouter, URLRouter 89 | from idp_user.auth import IDPChannelsAuthenticationMiddleware 90 | 91 | application = ProtocolTypeRouter({ 92 | "websocket": IDPChannelsAuthenticationMiddleware( 93 | AuthMiddlewareStack( 94 | URLRouter( 95 | # ... 96 | ) 97 | ) 98 | ), 99 | }) 100 | ``` 101 | 102 | 103 | ## Settings Reference 104 | 105 | * ``IDP_ENVIRONMENT`` 106 | 107 | * The environment of the IDP with which the app will communicate. 108 | * Used mainly for the Kafka Consumer & Producer. 109 | 110 | 111 | * ``APP_IDENTIFIER`` 112 | 113 | * The app identifier, as defined in the IDP. 114 | 115 | 116 | * ``ROLES`` 117 | 118 | * The path to the roles choices. 119 | 120 | 121 | * ``FAUST_APP_PATH`` 122 | 123 | * The path to the Faust app. 124 | 125 | 126 | * ``IDP_URL`` 127 | 128 | * The URL of the IDP, used for local development, or when using the IDP as an Authentication Backend. 129 | 130 | 131 | * ``USE_REDIS_CACHE`` 132 | 133 | * If True, the cache will be used 134 | * When developing locally, you can leave this as ``False``. 135 | 136 | 137 | * ``APP_ENTITIES`` 138 | 139 | * This dict links the AppEntityTypes declared on the IDP for this app to their actual models, 140 | so that they can be used for authorization purposes. In the value dicts, the attributes that will be 141 | used as the identifier and label are declared as well. 142 | 143 | 144 | [repo]: https://github.com/CardoAI/django-idp-user 145 | [package-status]: https://img.shields.io/badge/package--status-production-green 146 | [pypi]: https://pypi.org/project/django-idp-user/ 147 | [pypi-badge]: https://img.shields.io/badge/version-2.3.0 148 | [github-last-commit]: https://img.shields.io/github/last-commit/CardoAI/django-idp-user 149 | [build-status-image]: https://github.com/CardoAI/django-idp-user/actions/workflows/workflow.yml/badge.svg 150 | [build-status]: https://github.com/CardoAI/django-idp-user/actions/workflows/workflow.yml -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Django IDP User 2 | 3 | [![pypi-badge]][pypi] 4 | [![build-status-image]][build-status] 5 | [![package-status]][repo] 6 | [![github-last-commit]][repo] 7 | 8 | --- 9 | 10 | 11 | ## Installation 12 | 13 |
14 | 15 | ```console 16 | $ pip install django-idp-user 17 | 18 | ---> 100% 19 | ``` 20 | 21 |
22 | 23 | If you want to use the async version of the package, you can install it with the `async` extra: 24 | 25 |
26 | 27 | ```console 28 | $ pip install "django-idp-user[async]" 29 | 30 | ---> 100% 31 | ``` 32 | 33 |
34 | 35 | Add `idp_user` to your `INSTALLED_APPS`: 36 | ```python3 37 | INSTALLED_APPS = [ 38 | # ... 39 | 'idp_user', 40 | ] 41 | ``` 42 | 43 | Add the settings of the app in `settings.py` like this: 44 | ```python3 45 | AUTH_USER_MODEL = 'idp_user.User' 46 | 47 | IDP_USER_APP = { 48 | "IDP_ENVIRONMENT": "staging/production/etc.", 49 | "APP_IDENTIFIER": "str", 50 | "ROLES": "path.to.roles_choices", 51 | "FAUST_APP_PATH": "backend.kafka_consumer.app", 52 | "USE_REDIS_CACHE": True, 53 | "IDP_URL": "idp_url", # Optional 54 | "APP_ENTITIES": { 55 | "": { 56 | "model": "", 57 | "identifier_attr": "", 58 | "label_attr": "", 59 | } 60 | }, 61 | } 62 | 63 | REST_FRAMEWORK = { 64 | 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema' or 'idp_user.schema_extensions.AutoSchemaWithRole', 65 | 'DEFAULT_AUTHENTICATION_CLASSES': ( 66 | 'idp_user.auth.AuthenticationBackend', 67 | ), 68 | } 69 | 70 | SPECTACULAR_SETTINGS = { 71 | 'DEFAULT_AUTHENTICATION_CLASSES': ( 72 | 'idp_user.schema_extensions.BearerTokenScheme', 73 | ), 74 | 'SERVE_AUTHENTICATION': () 75 | } 76 | 77 | # Kafka Related 78 | KAFKA_ARN = "" # Encoded in base64 79 | KAFKA_AWS_ACCESS_KEY_ID = "" 80 | KAFKA_AWS_SECRET_ACCESS_KEY = "" # Encoded in base64 81 | AWS_S3_REGION_NAME = "" 82 | ``` 83 | 84 | Create the database tables for the app by running the following command: 85 | 86 |
87 | 88 | ```console 89 | $ python manage.py migrate 90 | ``` 91 | 92 |
93 | 94 | ## Async Support 95 | 96 | At least Django version 4.1.1 is required for async support. 97 | 98 | To use the async version of the package, you need to add the `async` extra when installing the package: 99 | 100 |
101 | 102 | ```console 103 | $ pip install "django-idp-user[async]" 104 | 105 | ---> 100% 106 | ``` 107 | 108 |
109 | 110 | 111 | If you are using Channels for websockets, you can use the `IDPChannelsAuthenticationMiddleware` like so: 112 | ```python3 113 | from channels.routing import ProtocolTypeRouter, URLRouter 114 | from idp_user.auth import IDPChannelsAuthenticationMiddleware 115 | 116 | application = ProtocolTypeRouter({ 117 | "websocket": IDPChannelsAuthenticationMiddleware( 118 | AuthMiddlewareStack( 119 | URLRouter( 120 | # ... 121 | ) 122 | ) 123 | ), 124 | }) 125 | ``` 126 | 127 | 128 | ## Settings Reference 129 | 130 | * ``IDP_ENVIRONMENT`` 131 | 132 | * The environment of the IDP with which the app will communicate. 133 | * Used mainly for the Kafka Consumer & Producer. 134 | 135 | 136 | * ``APP_IDENTIFIER`` 137 | 138 | * The app identifier, as defined in the IDP. 139 | 140 | 141 | * ``ROLES`` 142 | 143 | * The path to the roles choices. 144 | 145 | 146 | * ``FAUST_APP_PATH`` 147 | 148 | * The path to the Faust app. 149 | 150 | 151 | * ``IDP_URL`` 152 | 153 | * The URL of the IDP, used for local development, or when using the IDP as an Authentication Backend. 154 | 155 | 156 | * ``USE_REDIS_CACHE`` 157 | 158 | * If True, the cache will be used 159 | * When developing locally, you can leave this as ``False``. 160 | 161 | 162 | * ``APP_ENTITIES`` 163 | 164 | * This dict links the AppEntityTypes declared on the IDP for this app to their actual models, 165 | so that they can be used for authorization purposes. In the value dicts, the attributes that will be 166 | used as the identifier and label are declared as well. 167 | 168 | 169 | [repo]: https://github.com/CardoAI/django-drf-async 170 | [package-status]: https://img.shields.io/badge/package--status-production-green 171 | [pypi]: https://pypi.org/project/django-idp-user/ 172 | [pypi-badge]: https://img.shields.io/badge/version-2.2.0.dev1-blue 173 | [github-last-commit]: https://img.shields.io/github/last-commit/CardoAI/django-idp-user 174 | [build-status-image]: https://github.com/CardoAI/django-idp-user/actions/workflows/workflow.yml/badge.svg 175 | [build-status]: https://github.com/CardoAI/django-idp-user/actions/workflows/workflow.yml -------------------------------------------------------------------------------- /docs/pages/api.md: -------------------------------------------------------------------------------- 1 | **:WIP:** 2 | -------------------------------------------------------------------------------- /docs/pages/authentication.md: -------------------------------------------------------------------------------- 1 | **:WIP:** 2 | -------------------------------------------------------------------------------- /docs/pages/authorization.md: -------------------------------------------------------------------------------- 1 | **:WIP:** 2 | -------------------------------------------------------------------------------- /docs/pages/getting_started.md: -------------------------------------------------------------------------------- 1 | **:WIP:** 2 | -------------------------------------------------------------------------------- /idp_user/__init__.py: -------------------------------------------------------------------------------- 1 | from idp_user import checks, signals # noqa 2 | 3 | default_app_config = 'idp_user.apps.IDPUserConfig' 4 | -------------------------------------------------------------------------------- /idp_user/agents.py: -------------------------------------------------------------------------------- 1 | import faust 2 | from asgiref.sync import sync_to_async 3 | from django.conf import settings 4 | from django.utils.module_loading import import_string 5 | from faust import StreamT 6 | 7 | from idp_user.services import UserService 8 | from idp_user.settings import IDP_ENVIRONMENT 9 | 10 | app = import_string(settings.IDP_USER_APP["FAUST_APP_PATH"]) 11 | 12 | """ 13 | { 14 | "username": "", 15 | "first_name": "", 16 | "last_name": "", 17 | "email": "", 18 | "app_specific_configs": { 19 | "app_identifier": { 20 | "tenant": { 21 | "Servicer": { 22 | "app_entities_restrictions": {"vehicle": [1, 2]}, 23 | "permission_restrictions": { 24 | "synchronizeDoD": False 25 | } 26 | } 27 | }, 28 | } 29 | } 30 | } 31 | """ 32 | 33 | 34 | class UserRecord(faust.Record): 35 | first_name: str 36 | last_name: str 37 | username: str 38 | email: str 39 | is_active: bool 40 | is_staff: bool 41 | is_superuser: bool 42 | date_joined: str 43 | app_specific_configs: dict 44 | is_demo: bool 45 | 46 | 47 | USER_UPDATES_TOPIC_NAME = f"{IDP_ENVIRONMENT}_user_updates" 48 | 49 | user_updates = app.topic(USER_UPDATES_TOPIC_NAME, value_type=UserRecord) 50 | 51 | 52 | async def update_user(user_record: UserRecord): 53 | await sync_to_async(UserService.process_user)(user_record.asdict()) 54 | 55 | 56 | async def verify_if_user_exists_and_delete_roles(user_record: UserRecord): 57 | await sync_to_async(UserService.verify_if_user_exists_and_delete_roles)( 58 | user_record.asdict() 59 | ) 60 | 61 | 62 | @app.agent(user_updates) 63 | async def update_user_stream_processor(user_records: StreamT[UserRecord]): 64 | async for user_record in user_records: 65 | if user_record.app_specific_configs.get( 66 | settings.IDP_USER_APP["APP_IDENTIFIER"] 67 | ): 68 | await update_user(user_record) 69 | else: 70 | # Having arrived here means that the user does not have access in the current app 71 | # Verify however if the user already exists in the database of any tenant 72 | # If this is the case, delete his/her roles 73 | await verify_if_user_exists_and_delete_roles(user_record) 74 | -------------------------------------------------------------------------------- /idp_user/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.conf import settings 3 | 4 | 5 | from django.db.models.signals import post_delete, post_save 6 | 7 | 8 | class IDPUserConfig(AppConfig): 9 | name = "idp_user" 10 | 11 | def ready(self): 12 | # If Kafka is not configured, do not register signals 13 | is_kafka_configured = getattr(settings, "KAFKA_ARN", None) or getattr( 14 | settings, "KAFKA_BROKER", None 15 | ) 16 | if not is_kafka_configured: 17 | return 18 | 19 | from idp_user.services.base_user import BaseUserService 20 | from idp_user.settings import APP_ENTITIES 21 | 22 | for ( 23 | _app_entity_type, 24 | config, 25 | ) in APP_ENTITIES.items(): 26 | model = config["model"] 27 | post_save.connect( 28 | receiver=BaseUserService.process_app_entity_record_post_save, 29 | sender=model, 30 | ) 31 | post_delete.connect( 32 | receiver=BaseUserService.process_app_entity_record_post_delete, 33 | sender=model, 34 | ) 35 | -------------------------------------------------------------------------------- /idp_user/auth/__init__.py: -------------------------------------------------------------------------------- 1 | from .admin import IDPAuthBackend 2 | -------------------------------------------------------------------------------- /idp_user/auth/admin.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import requests 4 | from django.conf import settings 5 | from django.contrib.auth.backends import ModelBackend 6 | from jwt.exceptions import InvalidTokenError 7 | 8 | from idp_user.models.user import User 9 | from idp_user.utils.functions import get_or_none, get_jwt_payload 10 | 11 | IDP_URL = settings.IDP_USER_APP.get("IDP_URL") 12 | HTTP_200_OK = 200 13 | 14 | 15 | class IDPAuthBackend(ModelBackend): 16 | def authenticate(self, request, **kwargs): 17 | access_token = self._fetch_token(request) 18 | if not access_token: 19 | return None 20 | 21 | try: 22 | jwt_data = get_jwt_payload(access_token) 23 | except InvalidTokenError: 24 | return None 25 | 26 | username = jwt_data["username"] 27 | return get_or_none(User.objects, username=username) 28 | 29 | @staticmethod 30 | def _fetch_token(request) -> Optional[str]: 31 | response = requests.post( 32 | url=f"{IDP_URL}/api/login/", 33 | json={ 34 | "username": request.POST.get("username"), 35 | "password": request.POST.get("password"), 36 | }, 37 | ) 38 | 39 | if response.status_code == HTTP_200_OK: 40 | return response.json()["access"] 41 | 42 | def has_module_perms(self, user_obj, app_label): 43 | return user_obj.is_active and user_obj.is_staff 44 | 45 | def has_perm(self, user_obj, perm, obj=None): 46 | return user_obj.is_active and user_obj.is_staff 47 | -------------------------------------------------------------------------------- /idp_user/auth/drf.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from jwt.exceptions import InvalidTokenError 4 | from rest_framework import authentication 5 | from rest_framework.authentication import get_authorization_header 6 | from rest_framework.exceptions import AuthenticationFailed 7 | from rest_framework.request import Request 8 | 9 | from idp_user.models.user import User 10 | from idp_user.utils.functions import get_or_none, get_jwt_payload, authorize_request_with_idp 11 | from idp_user.utils.typing import JwtData 12 | 13 | 14 | class AuthenticationBackend(authentication.TokenAuthentication): 15 | keyword = "Bearer" 16 | 17 | def authenticate(self, request): 18 | token = self._get_token(request) 19 | if not token: 20 | return None 21 | 22 | return self.authenticate_credentials(token) 23 | 24 | def authenticate_credentials(self, token: str): 25 | user = get_or_none(User.objects, username=self._get_username(token)) 26 | return user, self 27 | 28 | @classmethod 29 | def _get_username(cls, token) -> JwtData: 30 | try: 31 | jwt_payload = get_jwt_payload(token) 32 | if "username" not in jwt_payload: 33 | raise AuthenticationFailed("Invalid token: username not present.") 34 | return jwt_payload["username"] 35 | except InvalidTokenError as e: 36 | raise AuthenticationFailed(f"Invalid token: {str(e)}") 37 | 38 | def _get_token(self, request: Request) -> Optional[str]: 39 | if token_in_header := self._get_token_from_header(request): 40 | return token_in_header 41 | 42 | if token_in_cookie := self._get_token_from_cookie(request): 43 | return token_in_cookie 44 | 45 | def _get_token_from_header(self, request: Request) -> Optional[str]: 46 | """ 47 | This part of the token validation is similar to what DRF is doing in TokenAuthentication.authenticate 48 | """ 49 | auth = get_authorization_header(request).split() 50 | 51 | if not auth: 52 | return None 53 | 54 | if auth[0].lower() != self.keyword.lower().encode(): 55 | raise AuthenticationFailed("Invalid token header: not bearer.") 56 | 57 | if len(auth) == 1: 58 | msg = 'Invalid token header. No credentials provided.' 59 | raise AuthenticationFailed(msg) 60 | elif len(auth) > 2: 61 | msg = 'Invalid token header. Token string should not contain spaces.' 62 | raise AuthenticationFailed(msg) 63 | 64 | try: 65 | token = auth[1].decode() 66 | except UnicodeError: 67 | msg = 'Invalid token header. Token string should not contain invalid characters.' 68 | raise AuthenticationFailed(msg) 69 | 70 | return token 71 | 72 | @staticmethod 73 | def _get_token_from_cookie(request: Request) -> Optional[str]: 74 | """ 75 | When interacting with a browser, the access token is stored in a cookie. 76 | """ 77 | return request.COOKIES.get("access_token") 78 | 79 | 80 | class DRFAuthenticationBackendWithIDPAuthorization(AuthenticationBackend): 81 | def authenticate(self, request): 82 | token = self._get_token(request) 83 | if not token: 84 | return None 85 | 86 | if auth_error := authorize_request_with_idp(request, token): 87 | raise AuthenticationFailed(auth_error) 88 | 89 | return super().authenticate_credentials(token) 90 | -------------------------------------------------------------------------------- /idp_user/auth/ninja.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Optional 3 | 4 | from django.conf import settings 5 | from django.contrib.auth import get_user_model 6 | from django.http import HttpRequest 7 | from jwt import InvalidTokenError 8 | from ninja.errors import HttpError 9 | from ninja.security import HttpBearer 10 | 11 | from idp_user.utils.functions import get_jwt_payload, authorize_request_with_idp, authorize_request_with_idp_async 12 | 13 | logger = logging.getLogger() 14 | 15 | 16 | class NinjaAuthBearer(HttpBearer): 17 | def __call__(self, request: HttpRequest): 18 | token = self._get_token(request) 19 | if not token: 20 | return None 21 | 22 | return self.authenticate(request, token) 23 | 24 | def _get_token(self, request: HttpRequest) -> Optional[str]: 25 | if token_in_header := self._get_token_from_header(request): 26 | return token_in_header 27 | 28 | if token_in_cookie := self._get_token_from_cookie(request): 29 | return token_in_cookie 30 | 31 | def _get_token_from_header(self, request: HttpRequest) -> Optional[str]: 32 | """ 33 | This part of the token validation is similar top what Django ninja is doing in HttpBearer.__call__ 34 | """ 35 | headers = request.headers 36 | auth_value = headers.get(self.header) 37 | if not auth_value: 38 | return None 39 | parts = auth_value.split(" ") 40 | 41 | if parts[0].lower() != self.openapi_scheme: 42 | if settings.DEBUG: 43 | logger.error(f"Unexpected auth - '{auth_value}'") 44 | return None 45 | 46 | return " ".join(parts[1:]) 47 | 48 | @staticmethod 49 | def _get_token_from_cookie(request: HttpRequest) -> Optional[str]: 50 | """ 51 | When interacting with a browser, the access token is stored in a cookie. 52 | """ 53 | return request.COOKIES.get("access_token") 54 | 55 | @staticmethod 56 | def _get_username(token): 57 | try: 58 | jwt_payload = get_jwt_payload(token) 59 | except InvalidTokenError: 60 | return None 61 | 62 | return jwt_payload.get('username') 63 | 64 | def authenticate(self, request, token): 65 | username = self._get_username(token) 66 | if not username: 67 | return None 68 | 69 | user_model = get_user_model() 70 | user = user_model.objects.filter(username=username).first() 71 | 72 | if user: 73 | request.user = user 74 | 75 | return user 76 | 77 | 78 | class NinjaAuthBearerAsync(NinjaAuthBearer): 79 | """ 80 | Same as NinjaAuthBearer, but just with async __call__ and authenticate methods. 81 | """ 82 | 83 | async def __call__(self, request: HttpRequest): 84 | token = self._get_token(request) 85 | if not token: 86 | return None 87 | 88 | return await self.authenticate(request, token) 89 | 90 | async def authenticate(self, request, token): 91 | username = self._get_username(token) 92 | if not username: 93 | return None 94 | 95 | user_model = get_user_model() 96 | user = await user_model.objects.filter(username=username).afirst() 97 | 98 | if user: 99 | request.user = user 100 | 101 | return user 102 | 103 | 104 | class NinjaAuthBearerWithIDPAuthorization(NinjaAuthBearer): 105 | def authenticate(self, request: HttpRequest, token: str): 106 | auth_error = authorize_request_with_idp(request, token) 107 | if auth_error: 108 | raise HttpError(403, auth_error) 109 | 110 | return super().authenticate(request, token) 111 | 112 | 113 | class NinjaAuthBearerAsyncWithIDPAuthorization(NinjaAuthBearerAsync): 114 | async def authenticate(self, request: HttpRequest, token: str): 115 | auth_error = await authorize_request_with_idp_async(request, token) 116 | if auth_error: 117 | raise HttpError(403, auth_error) 118 | 119 | return await super().authenticate(request, token) 120 | -------------------------------------------------------------------------------- /idp_user/checks.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core.checks import Error, Warning, register 3 | 4 | 5 | @register() 6 | def check_idp_user_settings(*args, **kwargs): 7 | def verify_idp_user_app_attr_exists(_attr, error=False): 8 | if _attr not in settings.IDP_USER_APP: 9 | issue_kwargs = { 10 | "msg": f"Missing {_attr} in IDP_USER_APP.", 11 | "hint": f"You must declare the attribute {_attr} in the dict IDP_USER_APP in settings.py.", 12 | "obj": settings, 13 | "id": f"idp_user.{_attr}", 14 | } 15 | if error: 16 | issues.append(Error(**issue_kwargs)) 17 | else: 18 | issues.append(Warning(**issue_kwargs)) 19 | 20 | issues = [] 21 | 22 | try: 23 | auth_user_model = settings.AUTH_USER_MODEL 24 | if auth_user_model != "idp_user.User": 25 | issues.append( 26 | Warning( 27 | "Wrong AUTH_USER_MODEL.", 28 | hint="You must set AUTH_USER_MODEL = 'idp_user.User' in settings.py.", 29 | obj=settings, 30 | id="idp_user.E002", 31 | ) 32 | ) 33 | 34 | except AttributeError: 35 | issues.append( 36 | Warning( 37 | "Missing AUTH_USER_MODEL", 38 | hint="You must set AUTH_USER_MODEL = 'idp_user.user' in settings.py.", 39 | obj=settings, 40 | id="idp_user.E003", 41 | ) 42 | ) 43 | 44 | try: 45 | for attr in ["IDP_ENVIRONMENT", "APP_IDENTIFIER", "FAUST_APP_PATH"]: 46 | verify_idp_user_app_attr_exists(attr, error=True) 47 | 48 | for attr in ["ROLES"]: 49 | verify_idp_user_app_attr_exists(attr, error=False) 50 | 51 | except AttributeError: 52 | issues.append( 53 | Error( 54 | "Missing IDP_USER_APP.", 55 | hint="You must declare the idp_user settings in the variable IDP_USER_APP in settings.py.", 56 | obj=settings, 57 | id="idp_user.E004", 58 | ) 59 | ) 60 | 61 | return issues 62 | -------------------------------------------------------------------------------- /idp_user/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CardoAI/django-idp-user/0e0e799dd4d121397a9057ca7a0472ce6c7eff6a/idp_user/management/__init__.py -------------------------------------------------------------------------------- /idp_user/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CardoAI/django-idp-user/0e0e799dd4d121397a9057ca7a0472ce6c7eff6a/idp_user/management/commands/__init__.py -------------------------------------------------------------------------------- /idp_user/management/commands/put_app_entities_records_to_kafka.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.core.management import BaseCommand 4 | 5 | from idp_user.services import UserService 6 | from idp_user.settings import APP_ENTITIES 7 | 8 | logger = logging.getLogger() 9 | 10 | 11 | class Command(BaseCommand): 12 | help = ( 13 | "Put the data of the app entities that are used in the scope of authorization in Kafka, " 14 | "so that the IDP is notified." 15 | ) 16 | 17 | def handle(self, **options): 18 | logger.info("Putting data of vehicles...") 19 | 20 | for ( 21 | app_entity_type, 22 | config, 23 | ) in APP_ENTITIES.items(): # type: str, AppEntityTypeConfig 24 | for record in config["model"].objects.all(): 25 | UserService.send_app_entity_record_event_to_kafka( 26 | app_entity_type=app_entity_type, app_entity_record=record 27 | ) 28 | -------------------------------------------------------------------------------- /idp_user/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.13 on 2021-10-26 09:12 2 | 3 | import django.contrib.auth.models 4 | import django.contrib.auth.validators 5 | import django.utils.timezone 6 | from django.conf import settings 7 | from django.db import migrations, models 8 | 9 | 10 | class Migration(migrations.Migration): 11 | initial = True 12 | 13 | dependencies = [ 14 | ('auth', '0012_alter_user_first_name_max_length'), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='User', 20 | fields=[ 21 | ('password', models.CharField(max_length=128, verbose_name='password')), 22 | ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), 23 | ('is_superuser', models.BooleanField(default=False, 24 | help_text='Designates that this user has all permissions without explicitly assigning them.', 25 | verbose_name='superuser status')), 26 | ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, 27 | help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', 28 | max_length=150, unique=True, 29 | validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], 30 | verbose_name='username')), 31 | ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), 32 | ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), 33 | ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), 34 | ('is_staff', models.BooleanField(default=False, 35 | help_text='Designates whether the user can log into this admin site.', 36 | verbose_name='staff status')), 37 | ('is_active', models.BooleanField(default=True, 38 | help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', 39 | verbose_name='active')), 40 | ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), 41 | ('idp_user_id', models.BigIntegerField(primary_key=True, serialize=False)), 42 | ('groups', models.ManyToManyField(blank=True, 43 | help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', 44 | related_name='user_set', related_query_name='user', to='auth.Group', 45 | verbose_name='groups')), 46 | ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', 47 | related_name='user_set', related_query_name='user', 48 | to='auth.Permission', verbose_name='user permissions')), 49 | ], 50 | options={ 51 | 'verbose_name': 'user', 52 | 'verbose_name_plural': 'users', 53 | 'abstract': False, 54 | }, 55 | managers=[ 56 | ('objects', django.contrib.auth.models.UserManager()), 57 | ], 58 | ), 59 | migrations.CreateModel( 60 | name='UserRole', 61 | fields=[ 62 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 63 | ('role', models.CharField(max_length=125)), 64 | ('app_config', models.JSONField(null=True)), 65 | ('permission_restrictions', models.JSONField(default=dict)), 66 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_roles', 67 | to=settings.AUTH_USER_MODEL)), 68 | ], 69 | options={ 70 | 'unique_together': {('user', 'role')}, 71 | }, 72 | ), 73 | ] 74 | -------------------------------------------------------------------------------- /idp_user/migrations/0001_initial_squashed_0005_alter_userrole_id.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2 on 2023-04-16 23:27 2 | 3 | import django.contrib.auth.models 4 | import django.contrib.auth.validators 5 | import django.db.models.deletion 6 | import django.utils.timezone 7 | from django.conf import settings 8 | from django.db import migrations, models 9 | 10 | 11 | class Migration(migrations.Migration): 12 | replaces = [ 13 | ("idp_user", "0001_initial"), 14 | ("idp_user", "0002_auto_20220120_1617"), 15 | ("idp_user", "0003_auto_20220513_1025"), 16 | ("idp_user", "0004_auto_20220817_1526"), 17 | ("idp_user", "0005_alter_userrole_id"), 18 | ] 19 | 20 | dependencies = [ 21 | ("auth", "0012_alter_user_first_name_max_length"), 22 | ] 23 | 24 | operations = [ 25 | migrations.CreateModel( 26 | name="User", 27 | fields=[ 28 | ("password", models.CharField(max_length=128, verbose_name="password")), 29 | ( 30 | "last_login", 31 | models.DateTimeField( 32 | blank=True, null=True, verbose_name="last login" 33 | ), 34 | ), 35 | ( 36 | "is_superuser", 37 | models.BooleanField( 38 | default=False, 39 | help_text="Designates that this user has all permissions without explicitly assigning them.", 40 | verbose_name="superuser status", 41 | ), 42 | ), 43 | ( 44 | "username", 45 | models.CharField( 46 | error_messages={ 47 | "unique": "A user with that username already exists." 48 | }, 49 | help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", 50 | max_length=150, 51 | unique=True, 52 | validators=[ 53 | django.contrib.auth.validators.UnicodeUsernameValidator() 54 | ], 55 | verbose_name="username", 56 | ), 57 | ), 58 | ( 59 | "first_name", 60 | models.CharField( 61 | blank=True, max_length=150, verbose_name="first name" 62 | ), 63 | ), 64 | ( 65 | "last_name", 66 | models.CharField( 67 | blank=True, max_length=150, verbose_name="last name" 68 | ), 69 | ), 70 | ( 71 | "email", 72 | models.EmailField( 73 | blank=True, max_length=254, verbose_name="email address" 74 | ), 75 | ), 76 | ( 77 | "is_staff", 78 | models.BooleanField( 79 | default=False, 80 | help_text="Designates whether the user can log into this admin site.", 81 | verbose_name="staff status", 82 | ), 83 | ), 84 | ( 85 | "is_active", 86 | models.BooleanField( 87 | default=True, 88 | help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", 89 | verbose_name="active", 90 | ), 91 | ), 92 | ( 93 | "date_joined", 94 | models.DateTimeField( 95 | default=django.utils.timezone.now, verbose_name="date joined" 96 | ), 97 | ), 98 | ( 99 | "id", 100 | models.BigAutoField( 101 | auto_created=True, 102 | primary_key=True, 103 | serialize=False, 104 | verbose_name="ID", 105 | ), 106 | ), 107 | ( 108 | "groups", 109 | models.ManyToManyField( 110 | blank=True, 111 | help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", 112 | related_name="user_set", 113 | related_query_name="user", 114 | to="auth.group", 115 | verbose_name="groups", 116 | ), 117 | ), 118 | ( 119 | "user_permissions", 120 | models.ManyToManyField( 121 | blank=True, 122 | help_text="Specific permissions for this user.", 123 | related_name="user_set", 124 | related_query_name="user", 125 | to="auth.permission", 126 | verbose_name="user permissions", 127 | ), 128 | ), 129 | ], 130 | options={ 131 | "verbose_name": "user", 132 | "verbose_name_plural": "users", 133 | "abstract": False, 134 | }, 135 | managers=[ 136 | ("objects", django.contrib.auth.models.UserManager()), 137 | ], 138 | ), 139 | migrations.CreateModel( 140 | name="UserRole", 141 | fields=[ 142 | ( 143 | "id", 144 | models.BigAutoField( 145 | auto_created=True, 146 | primary_key=True, 147 | serialize=False, 148 | verbose_name="ID", 149 | ), 150 | ), 151 | ("role", models.CharField(max_length=140)), 152 | ("app_entities_restrictions", models.JSONField(null=True)), 153 | ("permission_restrictions", models.JSONField(default=dict)), 154 | ( 155 | "user", 156 | models.ForeignKey( 157 | on_delete=django.db.models.deletion.CASCADE, 158 | related_name="user_roles", 159 | to=settings.AUTH_USER_MODEL, 160 | ), 161 | ), 162 | ], 163 | options={ 164 | "unique_together": {("user", "role")}, 165 | }, 166 | ), 167 | ] 168 | -------------------------------------------------------------------------------- /idp_user/migrations/0002_auto_20220120_1617.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.14 on 2022-01-20 16:17 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('idp_user', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='userrole', 15 | name='role', 16 | field=models.CharField(max_length=140), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /idp_user/migrations/0003_auto_20220513_1025.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.14 on 2022-05-13 10:25 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('idp_user', '0002_auto_20220120_1617'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RenameField( 14 | model_name='userrole', 15 | old_name='app_config', 16 | new_name='app_entities_restrictions', 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /idp_user/migrations/0004_auto_20220817_1526.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.6 on 2022-08-17 15:26 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('idp_user', '0003_auto_20220513_1025'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RenameField( 14 | model_name='user', 15 | old_name='idp_user_id', 16 | new_name='id', 17 | ), 18 | migrations.AlterField( 19 | model_name='user', 20 | name='id', 21 | field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /idp_user/migrations/0005_alter_userrole_id.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.6 on 2022-08-18 10:03 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('idp_user', '0004_auto_20220817_1526'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='userrole', 15 | name='id', 16 | field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /idp_user/migrations/0006_userrole_organization_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.2 on 2024-03-19 08:54 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ('idp_user', '0001_initial_squashed_0005_alter_userrole_id'), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name='userrole', 14 | name='organization', 15 | field=models.CharField(help_text='The name of the organization the user role belongs to, if any.', 16 | max_length=140, null=True), 17 | ), 18 | migrations.AlterField( 19 | model_name='userrole', 20 | name='app_entities_restrictions', 21 | field=models.JSONField( 22 | help_text='This dictionary contains explicit restrictions about the app entities that the user can access, in the form: {: [1, 2]}', 23 | null=True), 24 | ), 25 | migrations.AlterField( 26 | model_name='userrole', 27 | name='permission_restrictions', 28 | field=models.JSONField(default=dict, 29 | help_text="This dictionary contains explicit restrictions regarding app permissions, in the form: {'perform_operation_1': {'entity_type': [1, 2]}, 'perform_operation_2': false}"), 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /idp_user/migrations/0007_user_demo.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | 4 | class Migration(migrations.Migration): 5 | dependencies = [ 6 | ('idp_user', '0006_userrole_organization_and_more'), 7 | ] 8 | 9 | operations = [ 10 | migrations.AddField( 11 | model_name='user', 12 | name='is_demo', 13 | field=models.BooleanField(default=False, help_text='Whether this user is a demo user.'), 14 | ), 15 | ] 16 | -------------------------------------------------------------------------------- /idp_user/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CardoAI/django-idp-user/0e0e799dd4d121397a9057ca7a0472ce6c7eff6a/idp_user/migrations/__init__.py -------------------------------------------------------------------------------- /idp_user/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .user import User 2 | from .user_role import UserRole 3 | -------------------------------------------------------------------------------- /idp_user/models/user.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import AbstractUser 2 | from django.db import models 3 | 4 | 5 | class User(AbstractUser): 6 | is_demo = models.BooleanField(default=False, null=False, help_text='Whether this user is a demo user.') 7 | -------------------------------------------------------------------------------- /idp_user/models/user_role.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class UserRole(models.Model): 5 | user = models.ForeignKey( 6 | to="idp_user.User", related_name="user_roles", on_delete=models.CASCADE 7 | ) 8 | role = models.CharField(max_length=140) 9 | app_entities_restrictions = models.JSONField( 10 | null=True, 11 | help_text="This dictionary contains explicit restrictions about the app entities that the user can access, " 12 | "in the form: {: [1, 2]}" 13 | ) 14 | permission_restrictions = models.JSONField( 15 | default=dict, 16 | help_text="This dictionary contains explicit restrictions regarding app permissions, in the form: " 17 | "{'perform_operation_1': {'entity_type': [1, 2]}, 'perform_operation_2': false}" 18 | ) 19 | organization = models.CharField( 20 | max_length=140, 21 | null=True, 22 | help_text="The name of the organization the user role belongs to, if any." 23 | ) 24 | 25 | class Meta: 26 | unique_together = [("user", "role")] 27 | -------------------------------------------------------------------------------- /idp_user/producer.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from aiokafka import AIOKafkaProducer 4 | from django.core.serializers.json import DjangoJSONEncoder 5 | from kafka import KafkaProducer 6 | 7 | from idp_user.utils.classes import Singleton 8 | from idp_user.utils.functions import get_kafka_bootstrap_servers 9 | import ssl 10 | 11 | 12 | class Producer(metaclass=Singleton): 13 | __connection = None 14 | 15 | def __init__(self): 16 | self.__connection = KafkaProducer( 17 | bootstrap_servers=get_kafka_bootstrap_servers(include_uri_scheme=False), 18 | value_serializer=lambda v: json.dumps(v, cls=DjangoJSONEncoder).encode( 19 | "utf-8" 20 | ), 21 | ssl_context=ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH), 22 | security_protocol="SSL" 23 | ) 24 | 25 | def send_message(self, topic: str, key: str, data: dict): 26 | self.__connection.send(topic=topic, key=key.encode("utf-8"), value=data) 27 | # Sometimes messages do not get sent. 28 | # Flushing after each message seems to solve the issue 29 | self.__connection.flush() 30 | 31 | 32 | class AioKafkaProducer(metaclass=Singleton): 33 | _producer = None 34 | 35 | async def get_producer(self): 36 | if self._producer is None: 37 | self._producer = AIOKafkaProducer( 38 | bootstrap_servers=get_kafka_bootstrap_servers(include_uri_scheme=False), 39 | value_serializer=lambda v: json.dumps(v, cls=DjangoJSONEncoder).encode( 40 | "utf-8" 41 | ), 42 | ) 43 | await self._producer.start() 44 | return self._producer 45 | 46 | async def send_message(self, topic: str, key: str, data: dict): 47 | producer = await self.get_producer() 48 | await producer.send_and_wait(topic=topic, key=key.encode("utf-8"), value=data) 49 | 50 | async def close(self): 51 | await self._producer.stop() 52 | -------------------------------------------------------------------------------- /idp_user/schema_extensions.py: -------------------------------------------------------------------------------- 1 | from drf_spectacular.extensions import OpenApiAuthenticationExtension 2 | from drf_spectacular.openapi import AutoSchema 3 | from drf_spectacular.utils import OpenApiParameter 4 | 5 | from idp_user.auth.drf import AuthenticationBackend 6 | 7 | 8 | class BearerTokenScheme(OpenApiAuthenticationExtension): 9 | target_class = AuthenticationBackend # full import path OR class ref 10 | name = "IDPAuthentication" # name used in the schema 11 | 12 | def get_security_definition(self, auto_schema): 13 | return { 14 | "type": "http", 15 | "scheme": "bearer", 16 | "bearerFormat": "JWT", 17 | } 18 | 19 | 20 | class AutoSchemaWithRole(AutoSchema): 21 | def get_override_parameters(self): 22 | return [ 23 | OpenApiParameter( 24 | "role", type=str, location=OpenApiParameter.QUERY, required=True 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /idp_user/services/__init__.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | from idp_user.services.async_user import UserServiceAsync 4 | from idp_user.services.user import UserService 5 | -------------------------------------------------------------------------------- /idp_user/services/async_user.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from collections import defaultdict 3 | from typing import Any, Optional, Union 4 | 5 | from asgiref.sync import sync_to_async 6 | from django.core.exceptions import PermissionDenied 7 | from django.db import models 8 | from django.db.models import Q, QuerySet 9 | from django.http import HttpRequest 10 | 11 | from idp_user.models import User 12 | from idp_user.models.user_role import UserRole 13 | from idp_user.settings import APP_ENTITIES, ROLES 14 | from idp_user.signals import post_create_idp_user 15 | from idp_user.utils.functions import ( 16 | cache_user_service_results, 17 | get_or_none, 18 | keep_keys, 19 | parse_query_params_from_scope, 20 | update_record, 21 | ) 22 | from idp_user.utils.typing import ALL, AppEntityTypeConfig, UserTenantData 23 | 24 | logger = logging.getLogger(__name__) 25 | 26 | 27 | class UserServiceAsync: 28 | # Service Methods Used by Django Application 29 | @staticmethod 30 | def get_role(request: HttpRequest): 31 | return request.query_params.get("role") 32 | 33 | @staticmethod 34 | async def get_role_from_scope(scope): 35 | query_params = parse_query_params_from_scope(scope) 36 | return query_params.get("role")[0] if query_params.get("role") else None 37 | 38 | @staticmethod 39 | async def authorize_and_get_records_or_get_all_allowed( 40 | user: User, 41 | role: ROLES, 42 | app_entity_type: str, 43 | app_entity_records_identifiers: Optional[list[Any]], 44 | permission: str = None, 45 | ) -> models.QuerySet: 46 | """ 47 | Make sure that the user can access the entity records being requested and return them. 48 | If no identifiers are being provided, return all allowed entity records. 49 | 50 | Args: 51 | user: The user performing the request 52 | role: The role that the user is acting as. 53 | app_entity_type: The app entity being accessed 54 | app_entity_records_identifiers: The identifiers of the records belonging to the specified app entity 55 | permission: In case of specific permissions we can have permission restrictions 56 | through IDP. The value is the name of the permission 57 | 58 | 59 | Raises: 60 | PermissionDenied: In case the requested records are not allowed 61 | """ 62 | 63 | if not app_entity_records_identifiers: 64 | return await UserServiceAsync.get_allowed_app_entity_records( 65 | user=user, 66 | role=role, 67 | app_entity_type=app_entity_type, 68 | permission=permission, 69 | ) 70 | await UserServiceAsync.authorize_app_entity_records( 71 | user=user, 72 | role=role, 73 | app_entity_type=app_entity_type, 74 | app_entity_records_identifiers=app_entity_records_identifiers, 75 | permission=permission, 76 | ) 77 | return await UserServiceAsync._get_records( 78 | app_entity_type=app_entity_type, 79 | records_identifiers=app_entity_records_identifiers, 80 | ) 81 | 82 | @staticmethod 83 | async def authorize_app_entity_records( 84 | user: User, 85 | role: ROLES, 86 | app_entity_type: str, 87 | app_entity_records_identifiers: list[Any], 88 | permission: str = None, 89 | ): 90 | """ 91 | Verify if the user has access to the requested entity records. 92 | If a permission is specified and it has restrictable=True, the access is verified based on it. 93 | 94 | Args: 95 | user: The user performing the request 96 | role: The role that the user is acting as. 97 | app_entity_type: The app entity being accessed 98 | app_entity_records_identifiers: The identifiers of the records belonging to the specified app entity 99 | permission: In case of specific permissions we can have permission restrictions 100 | through IDP. The value is the name of the permission 101 | 102 | Raises: 103 | PermissionDenied: In case the requested records are not allowed 104 | """ 105 | 106 | assert ( 107 | app_entity_type in APP_ENTITIES.keys() 108 | ), f"Unknown app entity: {app_entity_type}!" 109 | allowed_app_entity_records_identifiers = ( 110 | await UserServiceAsync._get_allowed_app_entity_records_identifiers( 111 | user=user, 112 | role=role, 113 | app_entity_type=app_entity_type, 114 | permission=permission, 115 | ) 116 | ) 117 | if allowed_app_entity_records_identifiers == ALL: 118 | return 119 | 120 | if not set(app_entity_records_identifiers).issubset( 121 | set(allowed_app_entity_records_identifiers) 122 | ): 123 | raise PermissionDenied( 124 | "You are not allowed to access the records in the requested entity!" 125 | ) 126 | 127 | @staticmethod 128 | async def get_allowed_app_entity_records( 129 | user: User, role: ROLES, app_entity_type: str, permission: str = None 130 | ) -> models.QuerySet: 131 | """ 132 | Gets the app entity records that the user can access. 133 | 134 | Args: 135 | user: The user performing the request 136 | role: The role that the user is acting as. 137 | app_entity_type: The app entity being accessed 138 | permission: In case of specific permissions we can have permission restrictions 139 | through IDP. The value is the name of the permission 140 | 141 | Returns: 142 | QuerySet of App Entity Records that the user can access 143 | """ 144 | 145 | assert ( 146 | app_entity_type in APP_ENTITIES.keys() 147 | ), f"Unknown app entity: {app_entity_type}!" 148 | 149 | allowed_app_entity_records_identifiers = ( 150 | await UserServiceAsync._get_allowed_app_entity_records_identifiers( 151 | user=user, 152 | role=role, 153 | app_entity_type=app_entity_type, 154 | permission=permission, 155 | ) 156 | ) 157 | return await UserServiceAsync._get_records( 158 | app_entity_type=app_entity_type, 159 | records_identifiers=allowed_app_entity_records_identifiers, 160 | ) 161 | 162 | @staticmethod 163 | async def _get_app_entity_type_configs(app_entity_type: str) -> AppEntityTypeConfig: 164 | try: 165 | return APP_ENTITIES[app_entity_type] 166 | except KeyError as e: 167 | raise KeyError( 168 | f"No config declared for app_entity_type={app_entity_type} " 169 | f"in IDP_USER_APP['APP_ENTITIES']!" 170 | ) from e 171 | 172 | @staticmethod 173 | async def _get_records( 174 | app_entity_type: str, records_identifiers: Union[list[Any], ALL] 175 | ) -> models.QuerySet: 176 | app_entity_type_configs = await UserServiceAsync._get_app_entity_type_configs( 177 | app_entity_type 178 | ) 179 | model = app_entity_type_configs["model"] 180 | 181 | if records_identifiers == ALL: 182 | return await sync_to_async(list)(model.objects.all()) 183 | model_identifier_attr = app_entity_type_configs["identifier_attr"] 184 | return await sync_to_async(model.objects.filter)( 185 | **{f"{model_identifier_attr}__in": records_identifiers} 186 | ) 187 | 188 | @staticmethod 189 | @cache_user_service_results 190 | async def _get_allowed_app_entity_records_identifiers( 191 | user: User, role: ROLES, app_entity_type: str, permission: str = None 192 | ) -> Union[list[Any], ALL]: 193 | """ 194 | Gets the identifiers of the app entity records that the user can access. 195 | If no restriction both on role and permission level, return '__all__' 196 | 197 | Args: 198 | user: The user performing the request 199 | role: The role that the user is acting as. 200 | app_entity_type: The app entity being accessed 201 | permission: In case of specific permissions we can have permission restrictions 202 | through IDP. The value is the name of the permission 203 | 204 | Returns: 205 | List of identifiers of App Entity Records that the user can access 206 | """ 207 | if ROLES.as_dict().get(role) is None: 208 | raise PermissionDenied(f"Role does not exist: {role}") 209 | 210 | try: 211 | user_role = await UserRole.objects.aget(user=user, role=role) 212 | except UserRole.DoesNotExist: 213 | return [] 214 | 215 | # Permission restriction get precedence, if existing, for the given app_entity 216 | if permission: 217 | permission_restrictions = user_role.permission_restrictions 218 | if permission_restrictions and permission in permission_restrictions.keys(): 219 | permission_restriction = permission_restrictions.get(permission) 220 | if permission_app_entity_restriction := permission_restriction.get( 221 | app_entity_type 222 | ): 223 | return permission_app_entity_restriction 224 | 225 | # Verify if there is any restriction on the entity for the user 226 | app_entities_restrictions = user_role.app_entities_restrictions 227 | if app_entities_restrictions and ( 228 | app_entity_restriction := app_entities_restrictions.get(app_entity_type) 229 | ): 230 | return app_entity_restriction 231 | 232 | return ALL 233 | 234 | @staticmethod 235 | async def _create_or_update_user(data: UserTenantData) -> User: 236 | user = await sync_to_async(get_or_none)( 237 | User.objects, username=data.get("username") 238 | ) 239 | user_data = keep_keys( 240 | data, 241 | [ 242 | "username", 243 | "email", 244 | "first_name", 245 | "last_name", 246 | "is_active", 247 | "is_staff", 248 | "is_superuser", 249 | "date_joined", 250 | ], 251 | ) 252 | if user: 253 | await sync_to_async(update_record)(user, **user_data) 254 | else: 255 | user = await User.objects.acreate(**user_data) 256 | post_create_idp_user.send(sender=UserServiceAsync, user=user) 257 | return user 258 | 259 | @staticmethod 260 | async def _update_user(data: UserTenantData): 261 | """ 262 | This method makes sure that the changes that are coming from the IDP 263 | for a user are propagated in the internal product Authorization Schemas 264 | 265 | Step 1: Create or update User Object 266 | Step 2: Create/Update/Delete User Roles for this user. 267 | """ 268 | 269 | user = await UserServiceAsync._create_or_update_user(data) 270 | 271 | current_user_roles = defaultdict() 272 | async for user_role in user.user_roles.all(): 273 | current_user_roles[user_role.role] = user_role 274 | 275 | roles_data = data.get("app_specific_configs") 276 | 277 | for role, role_data in roles_data.items(): 278 | if existing_user_role := current_user_roles.get(role): 279 | await sync_to_async(update_record)( 280 | existing_user_role, 281 | permission_restrictions=role_data.get("permission_restrictions"), 282 | app_entities_restrictions=role_data.get( 283 | "app_entities_restrictions" 284 | ), 285 | organization=role_data.get("organization") 286 | ) 287 | else: 288 | await UserRole.objects.acreate( 289 | user=user, 290 | role=role, 291 | permission_restrictions=role_data.get("permission_restrictions"), 292 | app_entities_restrictions=role_data.get( 293 | "app_entities_restrictions" 294 | ), 295 | organization=role_data.get("organization") 296 | ) 297 | 298 | # Verify if any of the previous user roles is not being reported anymore 299 | # Delete it if this is the case 300 | for role, user_role in current_user_roles.items(): # type: str, UserRole 301 | if roles_data.get(role) is None: 302 | user_role.delete() 303 | 304 | @staticmethod 305 | async def get_users_with_access_to_app_entity_record( 306 | app_entity_type: str, record_identifier: Any, roles: list[str] 307 | ) -> QuerySet: 308 | """ 309 | Get users that have access to the required app entity record in the given roles. 310 | """ 311 | 312 | assert ( 313 | app_entity_type in APP_ENTITIES.keys() 314 | ), f"Unknown app entity: {app_entity_type}!" 315 | 316 | roles = await sync_to_async(UserRole.objects.filter)( 317 | Q(user__is_active=True) 318 | & Q(role__in=roles) 319 | & ( 320 | Q(app_entities_restrictions__isnull=True) 321 | | Q(**{f"app_entities_restrictions__{app_entity_type}__isnull": True}) 322 | | Q( 323 | **{ 324 | f"app_entities_restrictions__{app_entity_type}__contains": record_identifier 325 | } 326 | ) 327 | ) 328 | ) 329 | return await sync_to_async(User.objects.filter)( 330 | pk__in=list(roles.values_list("user__pk", flat=True)) 331 | ) 332 | 333 | @staticmethod 334 | async def get_user(username: str) -> User: 335 | """ 336 | Get user by username 337 | """ 338 | return await sync_to_async(get_or_none)(User.objects, username=username) 339 | -------------------------------------------------------------------------------- /idp_user/services/base_user.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime 3 | from typing import Any, Type 4 | 5 | from django.db import models 6 | 7 | from idp_user.producer import Producer 8 | from idp_user.settings import ( 9 | APP_ENTITIES, 10 | APP_ENTITY_RECORD_EVENT_TOPIC, 11 | APP_IDENTIFIER, 12 | ) 13 | from idp_user.utils.exceptions import UnsupportedAppEntityType 14 | from idp_user.utils.typing import AppEntityRecordEventDict 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | class BaseUserService: 20 | @classmethod 21 | def send_app_entity_record_event_to_kafka( 22 | cls, app_entity_type: str, app_entity_record: Any, deleted=False 23 | ): 24 | app_entity_type_config = APP_ENTITIES[app_entity_type] 25 | 26 | event: AppEntityRecordEventDict = { 27 | "app_identifier": APP_IDENTIFIER, 28 | "app_entity_type": app_entity_type, 29 | "record_identifier": getattr( 30 | app_entity_record, app_entity_type_config["identifier_attr"] 31 | ), 32 | "label": getattr(app_entity_record, app_entity_type_config["label_attr"]), 33 | "deleted": deleted, 34 | } 35 | 36 | logger.info(f"Sending update {event}...") 37 | 38 | Producer().send_message( 39 | topic=APP_ENTITY_RECORD_EVENT_TOPIC, key=str(datetime.now()), data=event 40 | ) 41 | 42 | @classmethod 43 | def _get_app_entity_type_from_model(cls, model: Type[models.Model]): 44 | for ( 45 | app_entity_type, 46 | config, 47 | ) in APP_ENTITIES.items(): # type: str, AppEntityTypeConfig 48 | if config["model"] == model: 49 | return app_entity_type 50 | 51 | raise UnsupportedAppEntityType(model) 52 | 53 | @classmethod 54 | def process_app_entity_record_post_save( 55 | cls, sender: Type[models.Model], instance, **kwargs 56 | ): 57 | """ 58 | Whenever an app entity record is saved (created/updated), 59 | send a message to Kafka to notify the IDP. 60 | 61 | kwargs are required for signal receivers. 62 | """ 63 | 64 | cls.send_app_entity_record_event_to_kafka( 65 | app_entity_type=cls._get_app_entity_type_from_model(sender), 66 | app_entity_record=instance, 67 | ) 68 | 69 | @classmethod 70 | def process_app_entity_record_post_delete( 71 | cls, sender: Type[models.Model], instance, **kwargs 72 | ): 73 | """ 74 | Whenever an app entity record is deleted, 75 | send a message to Kafka to notify the IDP. 76 | 77 | kwargs are required for signal receivers. 78 | """ 79 | 80 | cls.send_app_entity_record_event_to_kafka( 81 | app_entity_type=cls._get_app_entity_type_from_model(sender), 82 | app_entity_record=instance, 83 | deleted=True, 84 | ) 85 | -------------------------------------------------------------------------------- /idp_user/services/user.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from collections import defaultdict 3 | from copy import deepcopy 4 | from typing import Any, Optional, Union 5 | 6 | from django.conf import settings 7 | from django.core.cache import cache 8 | from django.core.exceptions import PermissionDenied 9 | from django.db import models, transaction 10 | from django.db.models import Q, QuerySet 11 | 12 | from idp_user.models import UserRole 13 | from idp_user.models.user import User 14 | from idp_user.services.base_user import BaseUserService 15 | from idp_user.settings import APP_ENTITIES, APP_IDENTIFIER, ROLES, TENANTS 16 | from idp_user.signals import ( 17 | post_create_idp_user, 18 | post_update_idp_user, 19 | pre_update_idp_user, 20 | ) 21 | from idp_user.utils.functions import ( 22 | cache_user_service_results, 23 | get_or_none, 24 | keep_keys, 25 | update_record, 26 | ) 27 | from idp_user.utils.typing import ( 28 | ALL, 29 | AppEntityTypeConfig, 30 | UserRecordDict, 31 | UserTenantData, 32 | ) 33 | 34 | logger = logging.getLogger(__name__) 35 | 36 | 37 | class UserService(BaseUserService): 38 | # Service Methods Used by Django Application 39 | @staticmethod 40 | def get_role(request): 41 | return request.query_params.get("role") 42 | 43 | @staticmethod 44 | def authorize_and_get_records_or_get_all_allowed( 45 | user: User, 46 | role: ROLES, 47 | app_entity_type: str, 48 | app_entity_records_identifiers: Optional[list[Any]], 49 | permission: str = None, 50 | ) -> models.QuerySet: 51 | """ 52 | Make sure that the user can access the entity records being requested and return them. 53 | If no identifiers are being provided, return all allowed entity records. 54 | 55 | Args: 56 | user: The user performing the request 57 | role: The role that the user is acting as. 58 | app_entity_type: The app entity being accessed 59 | app_entity_records_identifiers: The identifiers of the records belonging to the specified app entity 60 | permission: In case of specific permissions we can have permission restrictions 61 | through IDP. The value is the name of the permission 62 | 63 | 64 | Raises: 65 | PermissionDenied: In case the requested records are not allowed 66 | """ 67 | 68 | if not app_entity_records_identifiers: 69 | return UserService.get_allowed_app_entity_records( 70 | user=user, 71 | role=role, 72 | app_entity_type=app_entity_type, 73 | permission=permission, 74 | ) 75 | UserService.authorize_app_entity_records( 76 | user=user, 77 | role=role, 78 | app_entity_type=app_entity_type, 79 | app_entity_records_identifiers=app_entity_records_identifiers, 80 | permission=permission, 81 | ) 82 | return UserService._get_records( 83 | app_entity_type=app_entity_type, 84 | records_identifiers=app_entity_records_identifiers, 85 | ) 86 | 87 | @staticmethod 88 | def authorize_app_entity_records( 89 | user: User, 90 | role: ROLES, 91 | app_entity_type: str, 92 | app_entity_records_identifiers: list[Any], 93 | permission: str = None, 94 | ): 95 | """ 96 | Verify if the user has access to the requested entity records. 97 | If a permission is specified and it has restrictable=True, the access is verified based on it. 98 | 99 | Args: 100 | user: The user performing the request 101 | role: The role that the user is acting as. 102 | app_entity_type: The app entity being accessed 103 | app_entity_records_identifiers: The identifiers of the records belonging to the specified app entity 104 | permission: In case of specific permissions we can have permission restrictions 105 | through IDP. The value is the name of the permission 106 | 107 | Raises: 108 | PermissionDenied: In case the requested records are not allowed 109 | """ 110 | 111 | assert ( 112 | app_entity_type in APP_ENTITIES.keys() 113 | ), f"Unknown app entity: {app_entity_type}!" 114 | 115 | allowed_app_entity_records_identifiers = ( 116 | UserService._get_allowed_app_entity_records_identifiers( 117 | user=user, 118 | role=role, 119 | app_entity_type=app_entity_type, 120 | permission=permission, 121 | ) 122 | ) 123 | 124 | if allowed_app_entity_records_identifiers == ALL: 125 | return 126 | 127 | if not set(app_entity_records_identifiers).issubset( 128 | set(allowed_app_entity_records_identifiers) 129 | ): 130 | raise PermissionDenied( 131 | "You are not allowed to access the records in the requested entity!" 132 | ) 133 | 134 | @staticmethod 135 | def get_allowed_app_entity_records( 136 | user: User, role: ROLES, app_entity_type: str, permission: str = None 137 | ) -> models.QuerySet: 138 | """ 139 | Gets the app entity records that the user can access. 140 | 141 | Args: 142 | user: The user performing the request 143 | role: The role that the user is acting as. 144 | app_entity_type: The app entity being accessed 145 | permission: In case of specific permissions we can have permission restrictions 146 | through IDP. The value is the name of the permission 147 | 148 | Returns: 149 | QuerySet of App Entity Records that the user can access 150 | """ 151 | 152 | assert ( 153 | app_entity_type in APP_ENTITIES.keys() 154 | ), f"Unknown app entity: {app_entity_type}!" 155 | 156 | allowed_app_entity_records_identifiers = ( 157 | UserService._get_allowed_app_entity_records_identifiers( 158 | user=user, 159 | role=role, 160 | app_entity_type=app_entity_type, 161 | permission=permission, 162 | ) 163 | ) 164 | 165 | return UserService._get_records( 166 | app_entity_type=app_entity_type, 167 | records_identifiers=allowed_app_entity_records_identifiers, 168 | ) 169 | 170 | @staticmethod 171 | def _get_app_entity_type_configs(app_entity_type: str) -> AppEntityTypeConfig: 172 | try: 173 | return APP_ENTITIES[app_entity_type] 174 | except KeyError: 175 | raise KeyError( 176 | f"No config declared for app_entity_type={app_entity_type} " 177 | f"in IDP_USER_APP['APP_ENTITIES']!" 178 | ) from None 179 | 180 | @staticmethod 181 | def _get_records( 182 | app_entity_type: str, records_identifiers: Union[list[Any], ALL] 183 | ) -> models.QuerySet: 184 | app_entity_type_configs = UserService._get_app_entity_type_configs( 185 | app_entity_type 186 | ) 187 | model = app_entity_type_configs["model"] 188 | 189 | if records_identifiers == ALL: 190 | return model.objects.all() 191 | else: 192 | model_identifier_attr = app_entity_type_configs["identifier_attr"] 193 | return model.objects.filter( 194 | **{f"{model_identifier_attr}__in": records_identifiers} 195 | ) 196 | 197 | @staticmethod 198 | @cache_user_service_results 199 | def _get_allowed_app_entity_records_identifiers( 200 | user: User, role: ROLES, app_entity_type: str, permission: str = None 201 | ) -> Union[list[Any], ALL]: 202 | """ 203 | Gets the identifiers of the app entity records that the user can access. 204 | If no restriction both on role and permission level, return '__all__' 205 | 206 | Args: 207 | user: The user performing the request 208 | role: The role that the user is acting as. 209 | app_entity_type: The app entity being accessed 210 | permission: In case of specific permissions we can have permission restrictions 211 | through IDP. The value is the name of the permission 212 | 213 | Returns: 214 | List of identifiers of App Entity Records that the user can access 215 | """ 216 | if ROLES.as_dict().get(role) is None: 217 | raise PermissionDenied(f"Role does not exist: {role}") 218 | 219 | try: 220 | user_role = UserRole.objects.get(user=user, role=role) 221 | except UserRole.DoesNotExist: 222 | return [] 223 | 224 | # Permission restriction get precedence, if existing, for the given app_entity 225 | if permission: 226 | permission_restrictions = user_role.permission_restrictions 227 | if permission_restrictions and permission in permission_restrictions.keys(): 228 | permission_restriction = permission_restrictions.get(permission) 229 | if permission_app_entity_restriction := permission_restriction.get( 230 | app_entity_type 231 | ): 232 | return permission_app_entity_restriction 233 | 234 | # Verify if there is any restriction on the entity for the user 235 | app_entities_restrictions = user_role.app_entities_restrictions 236 | if app_entities_restrictions and ( 237 | app_entity_restriction := app_entities_restrictions.get(app_entity_type) 238 | ): 239 | return app_entity_restriction 240 | 241 | return ALL 242 | 243 | @staticmethod 244 | def _create_or_update_user(data: UserTenantData) -> User: 245 | user = get_or_none(User.objects, username=data.get("username")) 246 | user_data = keep_keys( 247 | data, 248 | [ 249 | "username", 250 | "email", 251 | "first_name", 252 | "last_name", 253 | "is_active", 254 | "is_staff", 255 | "is_superuser", 256 | "date_joined", 257 | "is_demo" 258 | ], 259 | ) 260 | if user: 261 | update_record(user, **user_data) 262 | UserService._invalidate_user_cache_entries(user=user) 263 | return user 264 | else: 265 | user = User.objects.create(**user_data) 266 | post_create_idp_user.send(sender=UserService, user=user) 267 | 268 | return user 269 | 270 | @staticmethod 271 | def _invalidate_user_cache_entries(user: User): 272 | """ 273 | Invalidate all the entries in the cache for the given user. 274 | To do this, find all the entries that start with the app identifier and username of the user. 275 | """ 276 | if settings.IDP_USER_APP.get("USE_REDIS_CACHE", False): 277 | cache.delete_pattern(f"{APP_IDENTIFIER}-{user.username}*") 278 | 279 | @classmethod 280 | def process_user(cls, data: UserRecordDict): 281 | """ 282 | Extract tenants from the user record and call _update_user for each tenant. 283 | Remove tenant information from the payload of _update_user since it is not needed 284 | inside of it. 285 | 286 | Send signals before and after calling the _update_user method for each tenant 287 | separately. This gives possibility to the project to react on the user update. 288 | 289 | One case of handling this signal is to switch database connection to the tenant's 290 | database. In this way the user can be updated in the correct database. 291 | 292 | """ 293 | reported_user_app_configs = UserService._get_reported_user_app_configs(data) 294 | tenants = reported_user_app_configs.keys() 295 | 296 | for tenant in tenants: 297 | if tenant not in TENANTS: 298 | logger.info(f"Tenant {tenant} not present, skipping.") 299 | continue 300 | 301 | logger.info(f"Updating user {data['username']} for tenant {tenant}") 302 | pre_update_idp_user.send(sender=cls.__class__, tenant=tenant) 303 | 304 | # Extract specific tenant information 305 | user_record_for_tenant = deepcopy(data) 306 | user_record_for_tenant["app_specific_configs"] = reported_user_app_configs[ 307 | tenant 308 | ] 309 | 310 | try: 311 | with transaction.atomic(using=tenant): 312 | UserService._update_user(user_record_for_tenant) # type: ignore 313 | finally: 314 | post_update_idp_user.send(sender=cls.__class__, tenant=tenant) 315 | 316 | @classmethod 317 | def verify_if_user_exists_and_delete_roles(cls, data: UserRecordDict): 318 | """ 319 | Verify if the user exists in any of the tenants and delete all the roles associated with it. 320 | """ 321 | for tenant in TENANTS: 322 | pre_update_idp_user.send(sender=cls.__class__, tenant=tenant) 323 | 324 | if user := get_or_none(User.objects, username=data["username"]): 325 | logger.info( 326 | f"Deleting roles for user {data['username']} in tenant {tenant}" 327 | ) 328 | UserRole.objects.filter(user=user).delete() # type: ignore 329 | 330 | post_update_idp_user.send(sender=cls.__class__, tenant=tenant) 331 | 332 | @staticmethod 333 | def _update_user(data: UserTenantData): 334 | """ 335 | This method makes sure that the changes that are coming from the IDP 336 | for a user are propagated in the internal product Authorization Schemas 337 | 338 | Step 1: Create or update User Object 339 | Step 2: Create/Update/Delete User Roles for this user. 340 | """ 341 | 342 | user = UserService._create_or_update_user(data) 343 | 344 | current_user_roles = defaultdict() 345 | for user_role in user.user_roles.all(): 346 | current_user_roles[user_role.role] = user_role 347 | 348 | roles_data = data.get("app_specific_configs") 349 | 350 | for role, role_data in roles_data.items(): 351 | if existing_user_role := current_user_roles.get(role): 352 | update_record( 353 | existing_user_role, 354 | permission_restrictions=role_data.get("permission_restrictions"), 355 | app_entities_restrictions=role_data.get( 356 | "app_entities_restrictions" 357 | ), 358 | organization=role_data.get("organization") 359 | ) 360 | else: 361 | UserRole.objects.create( 362 | user=user, 363 | role=role, 364 | permission_restrictions=role_data.get("permission_restrictions"), 365 | app_entities_restrictions=role_data.get( 366 | "app_entities_restrictions" 367 | ), 368 | organization=role_data.get("organization") 369 | ) 370 | 371 | # Verify if any of the previous user roles is not being reported anymore 372 | # Delete it if this is the case 373 | for role, user_role in current_user_roles.items(): # type: str, UserRole 374 | if roles_data.get(role) is None: 375 | user_role.delete() 376 | 377 | @staticmethod 378 | def _get_reported_user_app_configs(data): 379 | return data.get("app_specific_configs", {}).get(APP_IDENTIFIER, {}) 380 | 381 | @staticmethod 382 | def get_users_with_access_to_app_entity_record( 383 | app_entity_type: str, record_identifier: Any, roles: list[str] 384 | ) -> QuerySet: 385 | """ 386 | Get users that have access to the required app entity record in the given roles. 387 | """ 388 | 389 | assert ( 390 | app_entity_type in APP_ENTITIES.keys() 391 | ), f"Unknown app entity: {app_entity_type}!" 392 | 393 | roles = UserRole.objects.filter( 394 | Q(user__is_active=True) 395 | & Q(role__in=roles) 396 | & ( 397 | Q(app_entities_restrictions__isnull=True) 398 | | Q(**{f"app_entities_restrictions__{app_entity_type}__isnull": True}) 399 | | Q( 400 | **{ 401 | f"app_entities_restrictions__{app_entity_type}__contains": record_identifier 402 | } 403 | ) 404 | ) 405 | ) 406 | 407 | return User.objects.filter( 408 | pk__in=list(roles.values_list("user__pk", flat=True)) 409 | ) 410 | 411 | @staticmethod 412 | def get_organization_names() -> list[str]: 413 | """Get the names of all organizations.""" 414 | 415 | return list(UserRole.objects.exclude(organization=None).values_list("organization", flat=True).distinct()) 416 | 417 | @staticmethod 418 | def get_organization_users(organization_name: str) -> QuerySet[User]: 419 | """ 420 | Get users that belong to the given organization. 421 | """ 422 | 423 | roles = UserRole.objects.filter( 424 | user__is_active=True, 425 | organization=organization_name 426 | ) 427 | 428 | return User.objects.filter( 429 | id__in=list(roles.values_list("user__id", flat=True)) 430 | ) 431 | -------------------------------------------------------------------------------- /idp_user/settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.utils.module_loading import import_string 3 | 4 | IDP_ENVIRONMENT = settings.IDP_USER_APP.get("IDP_ENVIRONMENT") 5 | ASYNC_MODE = settings.IDP_USER_APP.get("ASYNC_MODE", False) 6 | APP_IDENTIFIER = settings.IDP_USER_APP["APP_IDENTIFIER"] 7 | ROLES = import_string(settings.IDP_USER_APP.get("ROLES")) 8 | APP_ENTITIES = settings.IDP_USER_APP.get("APP_ENTITIES") or {} 9 | TENANTS = settings.IDP_USER_APP.get("TENANTS") or list(settings.DATABASES.keys()) 10 | 11 | if APP_ENTITIES: 12 | for _, config_dict in APP_ENTITIES.items(): 13 | config_dict["model"] = import_string(config_dict["model"]) 14 | 15 | 16 | APP_ENTITY_RECORD_EVENT_TOPIC = f"{IDP_ENVIRONMENT}_app_entity_record_events" 17 | 18 | AWS_S3_REGION_NAME = getattr(settings, "AWS_S3_REGION_NAME", None) or "eu-central-1" 19 | -------------------------------------------------------------------------------- /idp_user/signals.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import Signal 2 | 3 | pre_update_idp_user = Signal() 4 | post_update_idp_user = Signal() 5 | 6 | post_create_idp_user = Signal() 7 | -------------------------------------------------------------------------------- /idp_user/urls.py: -------------------------------------------------------------------------------- 1 | urlpatterns = [] 2 | -------------------------------------------------------------------------------- /idp_user/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CardoAI/django-idp-user/0e0e799dd4d121397a9057ca7a0472ce6c7eff6a/idp_user/utils/__init__.py -------------------------------------------------------------------------------- /idp_user/utils/choices.py: -------------------------------------------------------------------------------- 1 | class ChoicesMixin: 2 | """ 3 | A helper class, mostly useful to declare roles, in the form: 4 | 5 | class Roles(ChoicesMixin): 6 | RoleName = "role_database_name" 7 | """ 8 | 9 | @classmethod 10 | def choices(cls): 11 | return { 12 | label: value 13 | for (label, value) in cls.__dict__.items() 14 | if not label.startswith("_") 15 | } 16 | 17 | @classmethod 18 | def as_list(cls): 19 | return [ 20 | (value, label) 21 | for (label, value) in cls.__dict__.items() 22 | if not label.startswith("_") 23 | ] 24 | 25 | @classmethod 26 | def as_dict(cls): 27 | return { 28 | value: label 29 | for (label, value) in cls.__dict__.items() 30 | if not label.startswith("_") 31 | } 32 | -------------------------------------------------------------------------------- /idp_user/utils/classes.py: -------------------------------------------------------------------------------- 1 | class Singleton(type): 2 | _instances = {} 3 | 4 | def __call__(cls, *args, **kwargs): 5 | if cls not in cls._instances: 6 | cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs) 7 | else: 8 | cls._instances[cls].__init__(*args, **kwargs) 9 | return cls._instances[cls] 10 | -------------------------------------------------------------------------------- /idp_user/utils/exceptions.py: -------------------------------------------------------------------------------- 1 | class UnsupportedAppEntityType(Exception): 2 | def __init__(self, app_entity_type): 3 | super().__init__(f"Unsupported app entity type: {str(app_entity_type)}") 4 | -------------------------------------------------------------------------------- /idp_user/utils/functions.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import json 3 | import os 4 | from typing import Optional 5 | from urllib.parse import parse_qs 6 | 7 | import aiohttp 8 | import boto3 9 | import jwt 10 | import requests 11 | from django.conf import settings 12 | from django.core.cache import cache 13 | from django.core.exceptions import ObjectDoesNotExist 14 | from django.http import HttpRequest 15 | 16 | APP_IDENTIFIER = settings.IDP_USER_APP.get("APP_IDENTIFIER") 17 | IDP_URL = settings.IDP_USER_APP.get("IDP_URL") 18 | IDP_VALIDATE_URL = f"{IDP_URL}/api/validate/" 19 | 20 | 21 | def keep_keys(dictionary, keys): 22 | return {k: v for k, v in dictionary.items() if k in keys} 23 | 24 | 25 | def get_or_none(records, *args, **kwargs): 26 | try: 27 | return records.get(*args, **kwargs) 28 | except ObjectDoesNotExist: 29 | return None 30 | 31 | 32 | def update_record(record, save=True, **data): 33 | if data: 34 | for key, value in data.items(): 35 | setattr(record, key, value) 36 | if save: 37 | record.save() 38 | return record 39 | 40 | 41 | def cache_user_service_results(function): 42 | from idp_user.settings import APP_IDENTIFIER 43 | 44 | def wrapper(user, *args, **kwargs): 45 | cache_key = f"{APP_IDENTIFIER}-{user.username}-{function.__name__}" 46 | for arg in args: 47 | cache_key += f",{arg}" 48 | for key, value in kwargs.items(): 49 | cache_key += f",{key}={value}" 50 | 51 | result = cache.get(cache_key) 52 | if result: 53 | return json.loads(result) 54 | result = function(user=user, *args, **kwargs) # noqa 55 | cache.set(cache_key, json.dumps(result)) 56 | return result 57 | 58 | if settings.IDP_USER_APP.get('USE_REDIS_CACHE', False) is False: 59 | return function 60 | 61 | return wrapper 62 | 63 | 64 | def get_kafka_bootstrap_servers(include_uri_scheme=True): 65 | """ 66 | If ARN is available, it means we can connect to the production servers. 67 | We have to find the bootstrap servers and create the connection using them. 68 | """ 69 | if kafka_arn := settings.KAFKA_ARN: 70 | resource = boto3.client("kafka", region_name=os.getenv("AWS_REGION", "eu-central-1")) 71 | response = resource.get_bootstrap_brokers( 72 | ClusterArn=base64.b64decode(kafka_arn).decode("utf-8") 73 | ) 74 | assert ( 75 | "BootstrapBrokerStringTls" in response.keys() 76 | ), "Something went wrong while receiving kafka servers!" 77 | 78 | bootstrap_servers = response.get("BootstrapBrokerStringTls").split(",") 79 | if not include_uri_scheme: 80 | return bootstrap_servers 81 | return [f"kafka://{host}" for host in bootstrap_servers] 82 | else: 83 | kafka_url = settings.KAFKA_BROKER 84 | return f"kafka://{kafka_url}" if include_uri_scheme else kafka_url 85 | 86 | 87 | def parse_query_params_from_scope(scope): 88 | """ 89 | Parse query params from scope 90 | 91 | Parameters: 92 | scope (dict): scope from consumer 93 | 94 | Returns: 95 | dict: query params 96 | """ 97 | return parse_qs(scope["query_string"].decode("utf-8")) 98 | 99 | 100 | def get_jwt_payload(token: str) -> dict: 101 | """ 102 | Get payload from JWT token. 103 | 104 | Args: 105 | token (str): JWT token 106 | 107 | Returns: 108 | dict: payload 109 | 110 | Raises: 111 | jwt.exceptions.InvalidTokenError: If token is invalid 112 | """ 113 | return jwt.decode( 114 | token, 115 | options={"verify_signature": False}, # Signature is verified from IDP 116 | ) 117 | 118 | 119 | def _get_headers_for_idp_authorization(request: HttpRequest, token: str) -> dict: 120 | headers = { 121 | "Authorization": f"Bearer {token}", 122 | "X-Original-Method": request.method, 123 | "X-Auth-Request-Redirect": request.get_full_path(), 124 | } 125 | if tenant := request.headers.get("X-TENANT"): 126 | headers["X-TENANT"] = tenant 127 | return headers 128 | 129 | 130 | def _get_query_params_for_idp_authorization(request: HttpRequest) -> dict: 131 | query_params = {"app": APP_IDENTIFIER} 132 | if tenant := request.headers.get("X-TENANT"): 133 | query_params["tenant"] = tenant 134 | return query_params 135 | 136 | 137 | def authorize_request_with_idp(request: HttpRequest, token: str) -> Optional[str]: 138 | """ 139 | Validate token with IDP. 140 | 141 | Args: 142 | request: The original request 143 | token: The JWT token provided 144 | 145 | Return: 146 | The error message if any, otherwise None 147 | """ 148 | response = requests.get( 149 | IDP_VALIDATE_URL, 150 | params=_get_query_params_for_idp_authorization(request), 151 | headers=_get_headers_for_idp_authorization(request, token), 152 | ) 153 | 154 | if not response.ok: 155 | try: 156 | return response.json().get("detail") 157 | except Exception as error: 158 | return str(error) 159 | 160 | 161 | async def authorize_request_with_idp_async(request: HttpRequest, token: str) -> Optional[str]: 162 | """ 163 | Validate token with IDP (async). 164 | 165 | Args: 166 | request: The original request 167 | token: The JWT token provided 168 | 169 | Return: 170 | The error message if any, otherwise None 171 | """ 172 | headers = _get_headers_for_idp_authorization(request, token) 173 | params = _get_query_params_for_idp_authorization(request) 174 | async with aiohttp.ClientSession(headers=headers) as session: 175 | async with session.get(IDP_VALIDATE_URL, params=params) as response: 176 | if not response.ok: 177 | try: 178 | response_content = await response.json() 179 | return response_content.get("detail") 180 | except Exception as error: 181 | return str(error) 182 | -------------------------------------------------------------------------------- /idp_user/utils/typing.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List, Optional, Type, TypedDict, Union 2 | 3 | from django.db import models 4 | 5 | ALL = "all" 6 | 7 | 8 | class JwtData(TypedDict): 9 | iat: int 10 | nbf: int 11 | jti: str 12 | exp: str 13 | type: str 14 | fresh: str 15 | user_id: int 16 | email: str 17 | username: str 18 | 19 | 20 | class UserFeaturesPermissions(TypedDict): 21 | dod_manager: Union[List, bool] 22 | cash_flow_projection: Union[List, bool] 23 | notes_manager: Union[List, bool] 24 | 25 | 26 | class AppSpecificConfigs(TypedDict): 27 | app_entities_restrictions: Optional[dict[str, list]] 28 | permission_restrictions: dict[str, Union[bool, Any]] 29 | organization: Optional[str] 30 | 31 | 32 | Role = str 33 | UserAppSpecificConfigs = dict[Role, AppSpecificConfigs] 34 | 35 | 36 | class UserTenantData(TypedDict): 37 | first_name: str 38 | last_name: str 39 | username: str 40 | email: str 41 | is_active: bool 42 | is_staff: bool 43 | is_superuser: bool 44 | date_joined: str 45 | app_specific_configs: UserAppSpecificConfigs 46 | 47 | 48 | """ 49 | "data": [ 50 | { 51 | "first_name": "str", 52 | "last_name": "str", 53 | "username": "str", 54 | "email": "str", 55 | "is_active": "bool", 56 | "is_staff": "bool", 57 | "is_superuser": "bool", 58 | "date_joined": "datetime" 59 | "app_specific_configs": { 60 | "app_identifier": { 61 | "Servicer": { 62 | "app_entities_restrictions": {"vehicle": [1, 2]}, 63 | "permission_restrictions": { 64 | "viewDoD": {"vehicle_ids": [1]}, 65 | "synchronizeDoD": false 66 | } 67 | } 68 | } 69 | } 70 | } 71 | ] 72 | """ 73 | 74 | # === 75 | AppIdentifier = str 76 | TenantIdentifier = str 77 | 78 | UserRecordAppSpecificConfigs = dict[ 79 | AppIdentifier, dict[TenantIdentifier, AppSpecificConfigs] 80 | ] 81 | 82 | 83 | class UserRecordDict(TypedDict): 84 | first_name: str 85 | last_name: str 86 | username: str 87 | email: str 88 | is_active: bool 89 | is_staff: bool 90 | is_superuser: bool 91 | date_joined: str 92 | app_specific_configs: UserRecordAppSpecificConfigs 93 | 94 | 95 | """ 96 | Example of a user record from kafka: 97 | { 98 | "first_name": "str", 99 | "last_name": "str", 100 | "username": "str", 101 | "email": "str", 102 | "is_active": "bool", 103 | "is_staff": "bool", 104 | "is_superuser": "bool", 105 | "date_joined": "datetime" 106 | "app_specific_configs": { 107 | "app_identifier": { 108 | "tenant": { 109 | "Servicer": { 110 | "app_entities_restrictions": {"vehicle": [1, 2]}, 111 | "permission_restrictions": { 112 | "viewDoD": {"vehicle": [1]}, 113 | "synchronizeDoD": false 114 | } 115 | } 116 | } 117 | } 118 | } 119 | } 120 | """ 121 | 122 | 123 | class AppEntityTypeConfig(TypedDict): 124 | model: Union[str, Type[models.Model]] 125 | identifier_attr: str 126 | label_attr: str 127 | 128 | 129 | class AppEntityRecordEventDict(TypedDict): 130 | app_identifier: str 131 | app_entity_type: str 132 | record_identifier: Any 133 | deleted: bool 134 | label: Optional[str] 135 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Django IDP User 2 | site_description: A Django app that handles the communication between the IDP and the products for the authorization of users. 3 | repo_name: CardoAI/django-idp-user 4 | repo_url: https://github.com/CardoAI/django-idp-user 5 | 6 | theme: 7 | name: material 8 | palette: 9 | - scheme: default 10 | toggle: 11 | icon: material/lightbulb 12 | name: Switch to dark mode 13 | - scheme: slate 14 | toggle: 15 | icon: material/lightbulb-outline 16 | name: Switch to light mode 17 | features: 18 | - navigation.footer 19 | - search.suggest 20 | - search.highlight 21 | - content.tabs.link 22 | icon: 23 | repo: fontawesome/brands/github-alt 24 | language: en 25 | plugins: 26 | - search 27 | 28 | nav: 29 | - Django IDP User: index.md 30 | - Users: pages/getting_started.md 31 | - Authentication: pages/authentication.md 32 | - Authorization: pages/authorization.md 33 | - API: pages/api.md 34 | 35 | markdown_extensions: 36 | - toc: 37 | permalink: true 38 | - markdown.extensions.codehilite: 39 | guess_lang: false 40 | - mdx_include: 41 | base_path: docs 42 | - admonition 43 | - codehilite 44 | - extra 45 | - pymdownx.superfences: 46 | custom_fences: 47 | - name: mermaid 48 | class: mermaid 49 | format: !!python/name:pymdownx.superfences.fence_code_format '' 50 | - pymdownx.tabbed: 51 | alternate_style: true 52 | - attr_list 53 | - md_in_html 54 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "django-idp-user" 7 | description = "A Django app that handles the communication between the IDP and the products for the authorization of users." 8 | requires-python = ">=3.9" 9 | version = "3.1.dev0" 10 | readme = "README.md" 11 | classifiers = [ 12 | "Development Status :: 5 - Production/Stable", 13 | "Framework :: Django", 14 | "Framework :: Django :: 3.2", 15 | "Framework :: Django :: 4.0", 16 | "Intended Audience :: Developers", 17 | "License :: OSI Approved :: MIT License", 18 | "Operating System :: OS Independent", 19 | "Programming Language :: Python", 20 | "Programming Language :: Python :: 3.9", 21 | "Programming Language :: Python :: 3.10", 22 | "Programming Language :: Python :: 3.11", 23 | "Topic :: Internet :: WWW/HTTP", 24 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content", 25 | "Topic :: Software Development :: Libraries :: Application Frameworks", 26 | "Topic :: Software Development :: Libraries :: Python Modules", 27 | ] 28 | dependencies = [ 29 | "Pyjwt ==2.6.0", 30 | "boto3", 31 | "Django", 32 | "faust-streaming", 33 | "requests", 34 | "kafka-python", 35 | ] 36 | 37 | [project.license] 38 | file = "LICENSE" 39 | 40 | [[project.authors]] 41 | email = "hello@cardoai.com" 42 | 43 | [[project.authors]] 44 | name = "Mahmoud Al-Rawy" 45 | 46 | [[project.authors]] 47 | name = "Euron Metaliaj" 48 | 49 | [[project.authors]] 50 | name = "Klajdi Çaushi" 51 | 52 | [[project.authors]] 53 | name = "Aleksandër Nasto" 54 | 55 | [[project.authors]] 56 | name = "Andi Çuku" 57 | 58 | [[project.authors]] 59 | name = "Klement Omeri" 60 | 61 | [[project.authors]] 62 | name = "Lirim Shala" 63 | 64 | [project.optional-dependencies] 65 | async = [ 66 | "aiohttp >=3.8.4", 67 | "aiokafka >=0.8.1", 68 | "Django >=4.2.1", 69 | ] 70 | drf = [ 71 | "djangorestframework", 72 | "drf-spectacular", 73 | ] 74 | ninja = [ 75 | "django-ninja", 76 | ] 77 | dev = [ 78 | "black ==23.3.0", 79 | "build ==0.10.0", 80 | "coverage ==7.2.5", 81 | "Faker ==18.9.0", 82 | "pre-commit ==3.3.2", 83 | "pytest ==7.3.1", 84 | "pytest-django ==4.5.2", 85 | "pytest-sugar ==0.9.7", 86 | "ruff ==0.0.269", 87 | ] 88 | docs = [ 89 | "mdx-include ==1.4.2", 90 | "mkdocs-markdownextradata-plugin ==0.2.5", 91 | "mkdocs-material ==9.1.14", 92 | "termynal ==0.3.0", 93 | ] 94 | 95 | [tool.hatch.metadata] 96 | allow-direct-references = true 97 | allow-ambiguous-features = true 98 | 99 | [tool.hatch.build] 100 | exclude = [ 101 | "/docs", 102 | "/scripts", 103 | "/tests", 104 | "/venv", 105 | "/.gitignore", 106 | "/Makefile", 107 | "/mkdocs.yml", 108 | ] 109 | 110 | [tool.hatch.build.targets.wheel] 111 | packages = ["idp_user"] 112 | 113 | [tool.pytest.ini_options] 114 | minversion = "6.0" 115 | addopts = "-ra -q --force-sugar --no-migrations --reuse-db --log-cli-level=INFO" 116 | testpaths = ["tests"] 117 | pythonpath = [ 118 | ".", 119 | "idp_user", 120 | ] 121 | python_files = "tests.py test_*.py *_tests.py" 122 | DJANGO_SETTINGS_MODULE = "tests.test_settings" 123 | filterwarnings = [ 124 | "ignore::DeprecationWarning:kombu.*:", 125 | "ignore::DeprecationWarning:celery.*:", 126 | ] 127 | 128 | [tool.coverage.report] 129 | fail_under = 85 130 | show_missing = "true" 131 | exclude_lines = [ 132 | "pragma: no cover", 133 | "raise NotImplementedError", 134 | "if TYPE_CHECKING:", 135 | "if __name__ == .__main__.:", 136 | "import*", 137 | "def __str__", 138 | "def on_success", 139 | "def clean", 140 | ] 141 | 142 | [tool.coverage.run] 143 | omit = [ 144 | "*/tests/*", 145 | "*/migrations/*", 146 | "*/urls.py", 147 | "*/settings/*", 148 | "*/wsgi.py", 149 | "manage.py", 150 | "*__init__.py", 151 | ] 152 | source = ["idp_user"] 153 | 154 | [tool.black] 155 | line-length = 88 156 | target-version = ["py311"] 157 | include = '\.pyi?$' 158 | extend-exclude = """ 159 | ^(.*/)?migrations/.*$ 160 | """ 161 | 162 | [tool.ruff] 163 | format = "grouped" 164 | line-length = 88 165 | extend-exclude = [ 166 | ".migrations", 167 | ".media", 168 | ".static", 169 | "manage.py", 170 | ".test_data", 171 | "__init__.py", 172 | ] 173 | select = [ 174 | "E", 175 | "F", 176 | ] 177 | ignore = [ 178 | "E501", 179 | "B008", 180 | "C901", 181 | "F405", 182 | ] 183 | dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" 184 | target-version = "py311" 185 | 186 | [tool.ruff.mccabe] 187 | max-complexity = 10 188 | 189 | [tool.ruff.isort] 190 | force-to-top = ["idp_user"] 191 | known-first-party = ["idp_user"] 192 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Django 2 | djangorestframework 3 | pyjwt==2.6.0 4 | requests 5 | drf_spectacular 6 | faust-streaming 7 | boto3 8 | asgiref 9 | pytest 10 | aiohttp 11 | aiokafka 12 | kafka-python 13 | django-ninja 14 | -------------------------------------------------------------------------------- /scripts/format.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | APP_PATH="idp_user" 4 | 5 | ruff $APP_PATH --fix 6 | black $APP_PATH 7 | -------------------------------------------------------------------------------- /scripts/lint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | APP_PATH="idp_user" 4 | 5 | ruff $APP_PATH 6 | black $APP_PATH --check 7 | -------------------------------------------------------------------------------- /scripts/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | coverage run -m pytest -v 4 | -------------------------------------------------------------------------------- /scripts/update_version.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | # Read the __init__.py file to find the __version__ value 4 | with open('idp_user/__init__.py', 'r') as init_file: 5 | init_content = init_file.read() 6 | version_match = re.search(r'__version__ = "(\d+\.\d+\.\d+)"', init_content) 7 | if version_match: 8 | version = version_match.group(1) 9 | else: 10 | version = None 11 | 12 | # Replace the version value in the README.md file 13 | if version: 14 | with open('README.md', 'r+') as readme_file: 15 | readme_content = readme_file.read() 16 | updated_content = re.sub(r'\[pypi-badge\]: https://img.shields.io/badge/version-(.+?)-blue', f'[pypi-badge]: https://img.shields.io/badge/version-{version}-blue', readme_content) 17 | 18 | if updated_content != readme_content: 19 | readme_file.seek(0) 20 | readme_file.write(updated_content) 21 | readme_file.truncate() 22 | print('Version replaced successfully in README.md') 23 | print(f'New version: {version}') 24 | else: 25 | print('Version in README.md is already up to date') 26 | with open('docs/index.md', 'r+') as index_file: 27 | index_content = index_file.read() 28 | updated_content = re.sub(r'\[pypi-badge\]: https://img.shields.io/badge/version-(.+?)-blue', f'[pypi-badge]: https://img.shields.io/badge/version-{version}-blue', index_content) 29 | 30 | if updated_content != index_content: 31 | index_file.seek(0) 32 | index_file.write(updated_content) 33 | index_file.truncate() 34 | print('Version replaced successfully in docs/index.md') 35 | print(f'New version: {version}') 36 | else: 37 | print('Version in docs/index.md is already up to date') 38 | else: 39 | print('Unable to find __version__ value in idp_user/__init__.py') 40 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | include_package_data=True 5 | ) 6 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CardoAI/django-idp-user/0e0e799dd4d121397a9057ca7a0472ce6c7eff6a/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | from unittest import mock 3 | 4 | import pytest 5 | 6 | from idp_user.auth.drf import AuthenticationBackend 7 | 8 | 9 | @pytest.fixture(scope="session") 10 | def django_db_setup(django_db_setup, django_db_blocker): 11 | with django_db_blocker.unblock(): 12 | # runtests() 13 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") 14 | 15 | 16 | @pytest.fixture(autouse=True) 17 | def enable_db_access_for_all_tests(db): 18 | """ 19 | This fixture enables database access for all tests. 20 | """ 21 | pass 22 | 23 | 24 | @pytest.fixture 25 | def auth_backend() -> AuthenticationBackend: 26 | return AuthenticationBackend() 27 | 28 | 29 | @pytest.fixture 30 | def mock_request(): 31 | with mock.patch("rest_framework.request.Request"): 32 | request = mock.MagicMock() 33 | request.headers = mock.MagicMock() 34 | request.META = mock.MagicMock() 35 | return request 36 | -------------------------------------------------------------------------------- /tests/test_app_entity.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class AppEntityTest(models.Model): 5 | name = models.CharField(max_length=255) 6 | 7 | def __str__(self): 8 | return self.name 9 | 10 | def __init__(self, *args, **kwargs): 11 | super().__init__(*args, **kwargs) 12 | -------------------------------------------------------------------------------- /tests/test_apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class AppTestConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "tests" 7 | -------------------------------------------------------------------------------- /tests/test_auth/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CardoAI/django-idp-user/0e0e799dd4d121397a9057ca7a0472ce6c7eff6a/tests/test_auth/__init__.py -------------------------------------------------------------------------------- /tests/test_auth/test_authentication.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import pytest 4 | 5 | from idp_user.auth.drf import AuthenticationBackend 6 | from idp_user.models.user import User 7 | from idp_user.utils.typing import JwtData 8 | 9 | 10 | class TestAuthenticationBackend: 11 | jwt_data: JwtData = {"other_claim": "value"} 12 | 13 | @pytest.fixture(autouse=True) 14 | def setup(self, auth_backend, mock_request): 15 | self._auth_backend = auth_backend 16 | self._mock_request = mock_request 17 | 18 | def test_authenticate(self): 19 | access_token = "access_token" 20 | jwt_payload = {"username": "test_user"} 21 | auth_header = f"Bearer {access_token}" 22 | self._mock_request.headers.get.return_value = auth_header 23 | with mock.patch.object( 24 | AuthenticationBackend, 25 | "_get_user", 26 | return_value=User(username=jwt_payload["username"]), 27 | ): 28 | with mock.patch("jwt.decode", return_value=jwt_payload): 29 | user, auth = self._auth_backend.authenticate(self._mock_request) 30 | assert user is not None and auth is not None 31 | -------------------------------------------------------------------------------- /tests/test_roles.py: -------------------------------------------------------------------------------- 1 | from idp_user.utils.choices import ChoicesMixin 2 | 3 | 4 | class ROLES(ChoicesMixin): 5 | test_role = "test_role" 6 | -------------------------------------------------------------------------------- /tests/test_settings.py: -------------------------------------------------------------------------------- 1 | import django 2 | 3 | DATABASES = { 4 | 'default': { 5 | 'ENGINE': 'django.db.backends.sqlite3', 6 | 'NAME': ':memory:', 7 | } 8 | } 9 | INSTALLED_APPS = ( 10 | 'django.contrib.contenttypes', 11 | 'django.contrib.auth', 12 | 'django.contrib.sites', 13 | 'django.contrib.sessions', 14 | 'django.contrib.messages', 15 | 'django.contrib.staticfiles', 16 | "tests.test_apps.AppTestConfig", 17 | 'idp_user', 18 | ) 19 | ROOT_URLCONF = '' # tests override urlconf, but it still needs to be defined 20 | MIDDLEWARE_CLASSES = ( 21 | 'django.contrib.sessions.middleware.SessionMiddleware', 22 | 'django.middleware.common.CommonMiddleware', 23 | 'django.middleware.csrf.CsrfViewMiddleware', 24 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 25 | 'django.contrib.messages.middleware.MessageMiddleware', 26 | ) 27 | TEMPLATES = [ 28 | { 29 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 30 | 'DIRS': [], 31 | 'APP_DIRS': True, 32 | 'OPTIONS': { 33 | 'context_processors': [ 34 | 'django.template.context_processors.debug', 35 | 'django.template.context_processors.request', 36 | 'django.contrib.auth.context_processors.auth', 37 | 'django.contrib.messages.context_processors.messages', 38 | ], 39 | }, 40 | }, 41 | ] 42 | 43 | USE_TZ = True 44 | 45 | IDP_USER_APP = { 46 | "IDP_ENVIRONMENT": "test", 47 | "APP_IDENTIFIER": "test_app", 48 | "ROLES": "tests.test_roles.ROLES", 49 | # "FAUST_APP_PATH": "conf.kafka_consumer.app", 50 | "IDP_URL": "https://idp-backend-staging.service.cardoai.com", 51 | "USE_REDIS_CACHE": False, 52 | "APP_ENTITIES": { 53 | "test_model": { 54 | "model": "tests.test_app_entity.AppEntityTest", 55 | "identifier_attr": "id", 56 | "label_attr": "name", 57 | }, 58 | }, 59 | } 60 | 61 | django.setup() 62 | --------------------------------------------------------------------------------