diff --git a/.github/workflows/filter-lib.yml b/.github/workflows/filter-lib.yml index 4de0337ea..ea6fff516 100644 --- a/.github/workflows/filter-lib.yml +++ b/.github/workflows/filter-lib.yml @@ -4,10 +4,12 @@ on: push: paths: - lib/filter_lib/** + - lib/python3.12/filter_lib/** - .github/worlflows/filter-lib.yml pull_request: paths: - lib/filter_lib/** + - lib/python3.12/filter_lib/** - .github/worlflows/filter-lib.yml jobs: filter-lib-pre-commit-actions: diff --git a/.github/workflows/python3.12/filter-lib b/.github/workflows/python_3.12_filter-lib.yml similarity index 91% rename from .github/workflows/python3.12/filter-lib rename to .github/workflows/python_3.12_filter-lib.yml index dbe487557..a6ae0fe71 100644 --- a/.github/workflows/python3.12/filter-lib +++ b/.github/workflows/python_3.12_filter-lib.yml @@ -13,7 +13,7 @@ jobs: filter-lib-pre-commit-actions: strategy: matrix: - python-version: [ "3.12.1" ] + python-version: [ "3.12.0", "3.12.1" ] runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 @@ -41,7 +41,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [ "3.12.1" ] + python-version: [ "3.12.0", "3.12.1" ] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} @@ -53,10 +53,8 @@ jobs: run: | python -m pip install --upgrade pip cd lib/python3.12/filter_lib - pip install -r requirements.txt - pip install -r requirements-dev.txt + pip install ".[dev]" - name: Test with pytest run: | cd lib/python3.12/filter_lib pytest - diff --git a/.github/workflows/python3.12/filter-lib.yml b/.github/workflows/python_3.12_tenants.yml similarity index 64% rename from .github/workflows/python3.12/filter-lib.yml rename to .github/workflows/python_3.12_tenants.yml index dbe487557..9e87e67f8 100644 --- a/.github/workflows/python3.12/filter-lib.yml +++ b/.github/workflows/python_3.12_tenants.yml @@ -1,19 +1,19 @@ -name: filter_lib precommit and test -run-name: filter_lib precommit and test +name: tenants precommit and test +run-name: tenants precommit and test on: push: paths: - - lib/python3.12/filter_lib/** - - .github/worlflows/python3.12/filter-lib.yml + - lib/python3.12/tenants/** + - .github/worlflows/python3.12/tenants.yml pull_request: paths: - - lib/python3.12/filter_lib/** - - .github/worlflows/python3.12/filter-lib.yml + - lib/python3.12/tenants/** + - .github/worlflows/python3.12/tenants.yml jobs: - filter-lib-pre-commit-actions: + tenants-pre-commit-actions: strategy: matrix: - python-version: [ "3.12.1" ] + python-version: [ "3.12.0", "3.12.1" ] runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 @@ -24,7 +24,7 @@ jobs: - name: Run isort uses: isort/isort-action@v1.1.0 with: - sort-paths: lib/python3.12/filter_lib/* + sort-paths: lib/python3.12/tenants/* configuration: --check-only --profile=black @@ -33,15 +33,15 @@ jobs: - name: Black uses: psf/black@stable with: - src: "lib/python3.12/filter_lib" + src: "lib/python3.12/tenants" options: '--check --line-length=79 --exclude="tests" --verbose' - run: pip install flake8 - - run: flake8 --exclude=tests --extend-ignore=E203 lib/python3.12/filter_lib - filter-lib-build: + - run: flake8 --exclude=tests --extend-ignore=E203 lib/python3.12/tenants + tenants-build: runs-on: ubuntu-latest strategy: matrix: - python-version: [ "3.12.1" ] + python-version: [ "3.12.0", "3.12.1" ] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} @@ -52,11 +52,9 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - cd lib/python3.12/filter_lib - pip install -r requirements.txt - pip install -r requirements-dev.txt + cd lib/python3.12/tenants + pip install ".[dev]" - name: Test with pytest run: | - cd lib/python3.12/filter_lib + cd lib/python3.12/tenants pytest - diff --git a/lib/python3.12/tenants/.gitignore b/lib/python3.12/tenants/.gitignore new file mode 100644 index 000000000..3f46b40f2 --- /dev/null +++ b/lib/python3.12/tenants/.gitignore @@ -0,0 +1,3 @@ +/venv/ +/.pytest_cache/ +/.mypy_cache/ diff --git a/lib/python3.12/tenants/.pre-commit-config.yaml b/lib/python3.12/tenants/.pre-commit-config.yaml new file mode 100644 index 000000000..60a8973cf --- /dev/null +++ b/lib/python3.12/tenants/.pre-commit-config.yaml @@ -0,0 +1,49 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.0.1 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - repo: https://github.com/pycqa/isort + rev: 5.9.1 + hooks: + - id: isort + args: + - --profile=black + - --line-length=79 + exclude: tests/ + - repo: https://github.com/psf/black + rev: 21.6b0 + hooks: + - id: black + language_version: python3 + args: + - --line-length=79 + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v0.910 + hooks: + - id: mypy + name: mypy + entry: mypy + language: python + types: [python] + args: + - --ignore-missing-imports + - --scripts-are-modules + - --allow-untyped-decorators + - --strict + - --no-strict-optional + require_serial: true + exclude: tests/ + additional_dependencies: + - 'pydantic' + - repo: https://github.com/pycqa/pylint + rev: pylint-2.8.1 + hooks: + - id: pylint + types: [ python ] + args: + - --max-line-length=79 + - --errors-only + - --disable=E0401,E0611 diff --git a/lib/python3.12/tenants/README.md b/lib/python3.12/tenants/README.md new file mode 100644 index 000000000..a54f20464 --- /dev/null +++ b/lib/python3.12/tenants/README.md @@ -0,0 +1,58 @@ +## How to use with BadgerDoc + +Import and create an instance of tenant dependency. +To make this dependency work with BadgerDoc you need to provide an url to keycloak and set 'algorithm' arg to `RS256`. + +```Python +from tenant_dependency import TenantData, get_tenant_info + +# inner url would look like this "http://bagerdoc-keycloack" (http://{keycloak_pod_name}) +# outer url would look like this "http://dev1.gcov.ru" + +tenant = get_tenant_info(url="http://dev1.gcov.ru", algorithm="RS256", debug=True) +``` +## Note: +#### If you're using algorithm RS256 (Current BadgerDoc algorithm) you don't need arg "key", use only "url". +#### Otherwise, if you're using algorithm HS256, you need "key" arg and don't need "url". Check `usage_example/main.py`. +Param `debug` is needed to render `Authorize` button in `/openapi` docs. + +Now you can add this dependency to your FastAPI endpoint: + +```Python +from fastapi import Depends, FastAPI +from tenant_dependency import TenantData, get_tenant_info + +app = FastAPI() +tenant = get_tenant_info(url="http://dev1.gcov.ru", algorithm="RS256", debug=True) + + +@app.post("/test") +async def get_nums(token: TenantData = Depends(tenant)): + return token.dict() +``` +Default values for `algorithm` and `debug` is `RS256` and `True`, +so you can create an instance like that: +```Python +tenant = get_tenant_info(url="http://dev1.gcov.ru") +``` + + +Go to docs and check how it works. + +1) Click `Authorize` and submit token. +2) Try to use endpoint. + +Without valid jwt this endpoint `/test` will raise 401 Error. + + +```Python +@app.post("/test") +async def get_nums(token: TenantData = Depends(tenant)): + return token.dict() +``` +This dependency will: +1) Check if incoming request came with header `Authorization`, if that header wasn't provided Error 401 will be raised. +2) If header `Authorization` exists, dependency will try to validate it with signature key or url those you put as args to dependency `tenant = get_tenant_info(key="SECRET") / tenant = get_tenant_info(url="http://bagerdoc-keycloack")`. If is key invalid or token is expired 401 Error will be raised. +3) It also checks header for arg "X-Current-Tenant", so if that arg isn't provided 401 will be raised. In case "X-Current-Tenant" provided dependency will check "X-Current-Tenant" containing in `tenants` array. Raises 401 if check wasn't successful. +4) If token is valid dependency will parse token's body and get data from it. Token must contain data about `tenants`, `user_id` and `roles`, otherwise 403 error will be raised. +5) If previous steps were successful, your endpoint can do anything further, and you can work with `token` arg that is actually is a pydantic model, so you can work with it like you work with other pydantic models. diff --git a/lib/python3.12/tenants/pyproject.toml b/lib/python3.12/tenants/pyproject.toml new file mode 100644 index 000000000..2b46a64c3 --- /dev/null +++ b/lib/python3.12/tenants/pyproject.toml @@ -0,0 +1,20 @@ +[tool.black] +line-length = 79 + +[tool.isort] +profile = "black" +line_length = 79 + +[tool.mypy] +plugins = "pydantic.mypy" +ignore_missing_imports = "True" +scripts_are_modules = "True" +allow_untyped_decorators = "True" +strict = "True" +no_strict_optional = "True" +disallow_untyped_calls = "False" + +[tool.pylint.basic] +max-line-length=79 +errors-only = "True" +disable = ["E0401", "E0611"] diff --git a/lib/python3.12/tenants/requirements-dev.txt b/lib/python3.12/tenants/requirements-dev.txt new file mode 100644 index 000000000..1c8adf282 --- /dev/null +++ b/lib/python3.12/tenants/requirements-dev.txt @@ -0,0 +1,7 @@ +pytest +requests +pytest-cov +black +isort==5.9.1 +pylint +mypy==0.910 diff --git a/lib/python3.12/tenants/requirements.txt b/lib/python3.12/tenants/requirements.txt new file mode 100644 index 000000000..bdf6d27eb --- /dev/null +++ b/lib/python3.12/tenants/requirements.txt @@ -0,0 +1,3 @@ +fastapi>=0.68.0 +httpx +PyJWT[crypto]==2.3.0 diff --git a/lib/python3.12/tenants/setup.py b/lib/python3.12/tenants/setup.py new file mode 100644 index 000000000..108a6d138 --- /dev/null +++ b/lib/python3.12/tenants/setup.py @@ -0,0 +1,27 @@ +from typing import List + +from setuptools import setup + + +def get_requirements(path: str) -> List[str]: + with open(path, "r", encoding="utf-8") as file: + return [row.strip() for row in file.readlines()] + + +def get_long_description(path: str) -> str: + with open(path, "r", encoding="utf-8") as file: + return file.read() + + +setup( + name="tenant_dependency", + version="0.1.3", + description="Package for validating and parsing jwt via FastAPI dependency", # noqa + long_description=get_long_description("README.md"), + author="Roman Kuzianov", + author_email="Roman_Kuzianov@epam.com", + packages=["tenant_dependency"], + package_dir={"tenant_dependency": "src"}, + install_requires=get_requirements("requirements.txt"), + extras_require={"dev": get_requirements("requirements-dev.txt")}, +) diff --git a/lib/python3.12/tenants/src/__init__.py b/lib/python3.12/tenants/src/__init__.py new file mode 100644 index 000000000..b560bac7f --- /dev/null +++ b/lib/python3.12/tenants/src/__init__.py @@ -0,0 +1,2 @@ +from .dependency import get_tenant_info # noqa +from .schema import TenantData # noqa diff --git a/lib/python3.12/tenants/src/dependency.py b/lib/python3.12/tenants/src/dependency.py new file mode 100644 index 000000000..42c5178ed --- /dev/null +++ b/lib/python3.12/tenants/src/dependency.py @@ -0,0 +1,185 @@ +import logging +import os +from typing import Any, Dict, Optional, Union + +import jwt +from fastapi import HTTPException, Request, status +from fastapi.openapi.models import HTTPBearer +from fastapi.security.base import SecurityBase +from fastapi.security.utils import get_authorization_scheme_param + +from .schema import SupportedAlgorithms, TenantData + +logger = logging.getLogger(__name__) +logger.setLevel(os.getenv("LOG_LEVEL", "INFO")) + + +class TenantDependencyBase: + def __init__( + self, + key: str = "", + algorithm: str = "RS256", + # TODO: Fix type from bagerdoc to badgerdoc + url: str = "http://bagerdoc-keycloack", + ) -> None: + """ + For usage with alg HS256 you must provide 'key' and 'alg' arguments. + For usage with alg RS256 you must provide 'url' and 'alg' arguments. + + Args: + key: a private key for decoding tokens with hs256 alg + algorithm: an alg for tokens, will be checked in available algorithms # noqa + url: an url to auth service (http://bagerdoc-keycloack, http://dev1.gcov.ru) # noqa + """ + self.key = key + self.algorithm = self._check_algorithm(algorithm) + self.jwk_client: jwt.PyJWKClient = jwt.PyJWKClient( + self._create_url(url) + ) + + async def __call__(self, request: Request) -> TenantData: + authorization: str = request.headers.get("Authorization") + if not authorization: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="No authorization provided!", + ) + current_tenant: str = request.headers.get("X-Current-Tenant") + if not current_tenant: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="No X-Current-Tenant provided!", + ) + _, token = get_authorization_scheme_param(authorization) + decoded: Dict[str, Any] = {} + logger.debug( + "Decoding token: %s, with algorithm %s", token, self.algorithm + ) + if self.algorithm == SupportedAlgorithms.HS256: + decoded = self.decode_hs256(token) + elif self.algorithm == SupportedAlgorithms.RS256: + decoded = self.decode_rs256(token) + + sub = decoded.get("sub") + realm_access = decoded.get("realm_access") + roles = False + if realm_access: + roles = realm_access.get("roles") + tenants = decoded.get("tenants") + + if decoded.get("clientId") == "pipelines": + return TenantData( + token=token, user_id=sub, roles=roles, tenants=tenants + ) + + if not (sub and roles and tenants): + logger.debug("Sub %s, roles: %s, tenants: %s", sub, roles, tenants) + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Wrong data provided in jwt!", + ) + + if current_tenant not in tenants: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="X-Current-Tenant not in jwt tenants!", + ) + + return TenantData( + token=token, user_id=sub, roles=roles, tenants=tenants + ) + + def decode_hs256(self, token: str) -> Dict[str, Any]: + try: + decoded = jwt.decode(token, self.key, algorithms=[self.algorithm]) + except jwt.ExpiredSignatureError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token is expired!", + ) + except (jwt.PyJWTError, jwt.exceptions.DecodeError): + logger.exception("Cannot decode invalid hs256 token") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token is invalid!", + ) + return decoded + + def decode_rs256(self, token: str) -> Dict[str, Any]: + try: + signing_key = self.jwk_client.get_signing_key_from_jwt(token) + decoded = jwt.decode( + token, signing_key.key, algorithms=[self.algorithm] + ) + except jwt.ExpiredSignatureError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token is expired!", + ) + except (jwt.PyJWTError, jwt.exceptions.DecodeError): + logger.exception("Cannot decode invalid rs256 token") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token is invalid!", + ) + return decoded + + @staticmethod + def _check_algorithm(alg: str) -> str: + if alg not in SupportedAlgorithms.members(): + raise ValueError( + f"Available algorithms {SupportedAlgorithms.members()}" + ) + return alg + + @staticmethod + def _create_url(url: str) -> str: + auth_path = "auth/realms/master/protocol/openid-connect/certs" + return url.rstrip("/") + "/" + auth_path + + +class TenantDependencyDocs(TenantDependencyBase, SecurityBase): + def __init__( + self, + key: str = "", + algorithm: str = "RS256", + url: str = "http://bagerdoc-keycloack", + scheme_name: Optional[str] = None, + description: Optional[str] = None, + ) -> None: + super().__init__(key, algorithm, url) + self.model = HTTPBearer(description=description) + self.scheme_name = scheme_name or self.__class__.__name__ + + +def get_tenant_info( + key: str = "", + algorithm: str = "RS256", + url: str = "http://bagerdoc-keycloack", + scheme_name: Optional[str] = None, + description: Optional[str] = None, + debug: bool = True, +) -> Union[TenantDependencyBase, TenantDependencyDocs]: + """ + For usage with alg HS256 you must provide 'key' and 'alg' arguments. + For usage with alg RS256 you must provide 'url' and 'alg' arguments. + + Examples: + RS256: + tenant = get_tenant_info(algorithm="RS256", url="http://dev1.gcov.ru"). # noqa + HS256: + tenant = get_tenant_info(algorithm="HS256", key="some_secret_key"). # noqa + + Args: + key: a private key for decoding tokens with hs256 alg. + algorithm: an alg for tokens, will be checked in available algorithms. + url: an url to auth service (http://bagerdoc-keycloack, http://dev1.gcov.ru). # noqa + scheme_name: a name for TenantDependency on Swagger, if not provided class name will be used. # noqa + description: a description for TenantDependency on Swagger, if not provided description will be empty. # noqa + debug: If True button 'Authorize' will be rendered on Swagger. + """ + if debug: + return TenantDependencyDocs( + key, algorithm, url, scheme_name, description + ) + return TenantDependencyBase(key, algorithm, url) diff --git a/lib/python3.12/tenants/src/schema.py b/lib/python3.12/tenants/src/schema.py new file mode 100644 index 000000000..7022540e3 --- /dev/null +++ b/lib/python3.12/tenants/src/schema.py @@ -0,0 +1,20 @@ +import enum +from typing import List, Optional + +from pydantic import BaseModel + + +class TenantData(BaseModel): + token: str + user_id: str + roles: List[str] + tenants: Optional[List[str]] + + +class SupportedAlgorithms(str, enum.Enum): + HS256 = "HS256" + RS256 = "RS256" + + @classmethod + def members(cls) -> List[str]: + return [el for el in cls.__members__.keys()] diff --git a/lib/python3.12/tenants/tests/__init__.py b/lib/python3.12/tenants/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/lib/python3.12/tenants/tests/conftest.py b/lib/python3.12/tenants/tests/conftest.py new file mode 100644 index 000000000..23abda3a3 --- /dev/null +++ b/lib/python3.12/tenants/tests/conftest.py @@ -0,0 +1,186 @@ +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient + +from src import TenantData +from src.dependency import get_tenant_info +from usage_example.jwt_generator import create_access_token + +SECRET_KEY = "test_secret_key" + + +def get_key(filename: str) -> str: + path = Path(__file__).parent / filename + with open(path, encoding="utf-8") as file_out: + return file_out.read() + + +private_key = get_key("private_key") +public_key = get_key("public_key") + + +@pytest.fixture +def mock_jwk_client(): + with patch( + "src.dependency.jwt.PyJWKClient.__init__", return_value=None + ) as mock: + yield mock + + +@pytest.fixture +def mock_sig_key(): + m = MagicMock() + m.key = public_key + with patch( + "src.dependency.jwt.PyJWKClient.get_signing_key_from_jwt", + return_value=m, + ) as mock: + yield mock + + +@pytest.fixture +def test_app_rs256(mock_jwk_client, mock_sig_key): + app = FastAPI() + tenant = get_tenant_info(algorithm="RS256", url="") + + @app.post("/test") + async def get_test(auth: TenantData = Depends(tenant)): + return auth.model_dump() + + test_client = TestClient(app) + yield test_client + + +@pytest.fixture +def token_mock_rs256(): + payload = { + "sub": "901", + "realm_access": {"roles": ["role-annotator"]}, + "tenants": ["tenant1", "epam"], + } + + token = create_access_token( + data=payload, secret=private_key, expires_delta=15, algorithm="RS256" + ) + yield token + + +@pytest.fixture +def client_token_mock_rs256(): + payload = { + "sub": "3855eb45-2c11-4b15-8989-257b3a51649c", + "realm_access": { + "roles": [ + "default-roles-master", + "offline_access", + "uma_authorization", + ] + }, + "clientId": "pipelines", + } + + token = create_access_token( + data=payload, secret=private_key, expires_delta=15, algorithm="RS256" + ) + yield token + + +@pytest.fixture +def wrong_client_token_mock_rs256(): + payload = { + "sub": "3855eb45-2c11-4b15-8989-257b3a51649c", + "realm_access": { + "roles": [ + "default-roles-master", + "offline_access", + "uma_authorization", + ] + }, + "clientId": "not_pipelines", + } + + token = create_access_token( + data=payload, secret=private_key, expires_delta=15, algorithm="RS256" + ) + yield token + + +@pytest.fixture +def expired_token_mock_rs256(): + payload = { + "sub": "901", + "realm_access": {"roles": ["role-annotator"]}, + "tenants": ["tenant1", "epam"], + } + token = create_access_token( + data=payload, secret=private_key, expires_delta=-15, algorithm="RS256" + ) + yield token + + +@pytest.fixture +def wrong_data_token_mock_rs256(): + payload = { + "sub": "901", + "realm_access": {"roles": ["role-annotator"]}, + "qtenants": ["tenant1"], + } + token = create_access_token( + data=payload, secret=private_key, expires_delta=15, algorithm="RS256" + ) + yield token + + +@pytest.fixture +def token_mock_hs256(): + payload = { + "sub": "901", + "realm_access": {"roles": ["role-annotator"]}, + "tenants": ["tenant1", "epam"], + } + token = create_access_token( + data=payload, secret=SECRET_KEY, expires_delta=15 + ) + yield token + + +@pytest.fixture +def expired_token_mock_hs256(): + payload = { + "sub": "901", + "realm_access": {"roles": ["role-annotator"]}, + "tenants": ["tenant1", "epam"], + } + token = create_access_token( + data=payload, secret=SECRET_KEY, expires_delta=-15 + ) + yield token + + +@pytest.fixture +def wrong_data_token_mock_hs256(): + payload = { + "sub": "901", + "realm_access": {"roles": ["role-annotator"]}, + "qtenants": ["tenant1"], + } + token = create_access_token( + data=payload, secret=SECRET_KEY, expires_delta=15 + ) + yield token + + +@pytest.fixture +def test_app_hs256(mock_jwk_client, mock_sig_key): + app = FastAPI() + tenant = get_tenant_info(key=SECRET_KEY, algorithm="HS256") + + @app.post("/test") + async def get_test(auth: TenantData = Depends(tenant)): + return auth.model_dump() + + test_client = TestClient(app) + yield test_client diff --git a/lib/python3.12/tenants/tests/private_key b/lib/python3.12/tenants/tests/private_key new file mode 100644 index 000000000..e06967f3e --- /dev/null +++ b/lib/python3.12/tenants/tests/private_key @@ -0,0 +1,15 @@ +-----BEGIN RSA PRIVATE KEY----- +MIICXAIBAAKBgQCqrZrzCY1oTEQJW6vUJ3p9DgBm3OBnWGIeeNEqft6woiSymwNd +HxfQsZrfLOfnwxdWkPIHkYTshHYQQzWXba8D578O41OPJhC+juISrlYtNBQKru/n +AT1NfPceyBHKb2/Pt2firFR3IAmJFMBmIc13+pElZlkxwsVZ/g6Zg/BWswIDAQAB +AoGATVeyOaQAglzdD+iMquzg4r5vjC1XOz2f6PMSxdEMFLrFKnJ4ScCHSDjTV4K/ +7BRKBR5VrvxQYeV8os3yARbhEYQZYVhf+2mN/3Zk/Og6uSiEnzXYn5ZcyHN9OXZ8 +maSBDVDA8FFFnXZ69FOBD90/mECboMXMcZjzOBAbuEL/SyECQQDfiacMy9COgXYq +oOCDrnBpTovs8s5XmxbVmnK+ZuS4GoTn9Cgu1nBQ8A+F7Wcz0jCiKyPahYF8LoWA +f010qZVpAkEAw3bR3hHtOxaXrQ5lIq0avZWn5/hlBz6tvzDW1JoEnktdCPItHlyB +WgSA952S5lThYoln0fMa/3dboAZa2Hk7uwJAFiyMcpuBK7Gx7BabTtSt9/Q/sxh+ +2Xfb8wJoIXUJeS3AQ1YX6lWBPLYjhBCBrUVLZ7rJyrJ1nsDqo019fHYaCQJAL6wb +I35bzb2E4MBSClMN4o9NlFYQzeOLnMXcn52w4qqe1j+6oV0Ob1YJ3lqgKW0qS04X +9CzGuOfzgOQL5CAtYQJBAMZN6eBWkMehwMlywL0Xk+j8WmRcduJ0Z+GdPkhVu9LA +MCNOIVqoAVIRU/Pi1GEuOIwYsi6HAsNNs8hk3VA5MwY= +-----END RSA PRIVATE KEY----- diff --git a/lib/python3.12/tenants/tests/public_key b/lib/python3.12/tenants/tests/public_key new file mode 100644 index 000000000..8074dda95 --- /dev/null +++ b/lib/python3.12/tenants/tests/public_key @@ -0,0 +1,6 @@ +-----BEGIN PUBLIC KEY----- +MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCqrZrzCY1oTEQJW6vUJ3p9DgBm +3OBnWGIeeNEqft6woiSymwNdHxfQsZrfLOfnwxdWkPIHkYTshHYQQzWXba8D578O +41OPJhC+juISrlYtNBQKru/nAT1NfPceyBHKb2/Pt2firFR3IAmJFMBmIc13+pEl +ZlkxwsVZ/g6Zg/BWswIDAQAB +-----END PUBLIC KEY----- diff --git a/lib/python3.12/tenants/tests/test_dependency_hs256.py b/lib/python3.12/tenants/tests/test_dependency_hs256.py new file mode 100644 index 000000000..8b71bc0b5 --- /dev/null +++ b/lib/python3.12/tenants/tests/test_dependency_hs256.py @@ -0,0 +1,77 @@ +from src.dependency import ( + TenantDependencyBase, + TenantDependencyDocs, + get_tenant_info, +) + +CURRENT_TENANT = "tenant1" + + +def test_dependency_positive(test_app_hs256, token_mock_hs256): + headers = { + "Authorization": f"Bearer {token_mock_hs256}", + "X-Current-Tenant": CURRENT_TENANT, + } + response_body = { + "roles": ["role-annotator"], + "tenants": ["tenant1", "epam"], + "token": f"{token_mock_hs256}", + "user_id": "901", + } + res = test_app_hs256.post("/test", headers=headers) + assert res.status_code == 200 + assert res.json() == response_body + + +def test_dependency_expired_token(test_app_hs256, expired_token_mock_hs256): + headers = { + "Authorization": f"Bearer {expired_token_mock_hs256}", + "X-Current-Tenant": CURRENT_TENANT, + } + res = test_app_hs256.post("/test", headers=headers) + assert res.status_code == 401 + assert res.json() == {"detail": "Token is expired!"} + + +def test_dependency_invalid_token(test_app_hs256): + headers = { + "Authorization": "Bearer does not exist", + "X-Current-Tenant": CURRENT_TENANT, + } + res = test_app_hs256.post("/test", headers=headers) + assert res.status_code == 401 + assert res.json() == {"detail": "Token is invalid!"} + + +def test_dependency_wrong_data(test_app_hs256, wrong_data_token_mock_hs256): + headers = { + "Authorization": f"Bearer {wrong_data_token_mock_hs256}", + "X-Current-Tenant": CURRENT_TENANT, + } + res = test_app_hs256.post("/test", headers=headers) + assert res.status_code == 403 + assert res.json() == {"detail": "Wrong data provided in jwt!"} + + +def test_tenant_class_generator(): + docs_dep = get_tenant_info("key", "HS256", debug=True) + assert isinstance(docs_dep, TenantDependencyDocs) + base_dep = get_tenant_info("key", "HS256") + assert isinstance(base_dep, TenantDependencyBase) + + +def test_current_tenant_not_provided(test_app_hs256, token_mock_hs256): + headers = {"Authorization": f"Bearer {token_mock_hs256}"} + res = test_app_hs256.post("/test", headers=headers) + assert res.status_code == 401 + assert res.json() == {"detail": "No X-Current-Tenant provided!"} + + +def test_current_tenant_wrong(test_app_hs256, token_mock_hs256): + headers = { + "Authorization": f"Bearer {token_mock_hs256}", + "X-Current-Tenant": "wrong", + } + res = test_app_hs256.post("/test", headers=headers) + assert res.status_code == 403 + assert res.json() == {"detail": "X-Current-Tenant not in jwt tenants!"} diff --git a/lib/python3.12/tenants/tests/test_dependency_rs256.py b/lib/python3.12/tenants/tests/test_dependency_rs256.py new file mode 100644 index 000000000..b7f8280dc --- /dev/null +++ b/lib/python3.12/tenants/tests/test_dependency_rs256.py @@ -0,0 +1,109 @@ +from src.dependency import ( + TenantDependencyBase, + TenantDependencyDocs, + get_tenant_info, +) + +CURRENT_TENANT = "tenant1" + + +def test_dependency_positive(token_mock_rs256, test_app_rs256): + headers = { + "Authorization": f"Bearer {token_mock_rs256}", + "X-Current-Tenant": CURRENT_TENANT, + } + response_body = { + "roles": ["role-annotator"], + "tenants": ["tenant1", "epam"], + "token": f"{token_mock_rs256}", + "user_id": "901", + } + res = test_app_rs256.post("/test", headers=headers) + assert res.status_code == 200 + assert res.json() == response_body + + +def test_dependency_expired_token(expired_token_mock_rs256, test_app_rs256): + headers = { + "Authorization": f"Bearer {expired_token_mock_rs256}", + "X-Current-Tenant": CURRENT_TENANT, + } + res = test_app_rs256.post("/test", headers=headers) + assert res.status_code == 401 + assert res.json() == {"detail": "Token is expired!"} + + +def test_dependency_invalid_token(test_app_rs256): + headers = { + "Authorization": "Bearer does not exist", + "X-Current-Tenant": CURRENT_TENANT, + } + res = test_app_rs256.post("/test", headers=headers) + assert res.status_code == 401 + assert res.json() == {"detail": "Token is invalid!"} + + +def test_dependency_wrong_data(wrong_data_token_mock_rs256, test_app_rs256): + headers = { + "Authorization": f"Bearer {wrong_data_token_mock_rs256}", + "X-Current-Tenant": CURRENT_TENANT, + } + res = test_app_rs256.post("/test", headers=headers) + assert res.status_code == 403 + assert res.json() == {"detail": "Wrong data provided in jwt!"} + + +def test_tenant_class_generator(): + docs_dep = get_tenant_info(url="key", algorithm="RS256", debug=True) + assert isinstance(docs_dep, TenantDependencyDocs) + base_dep = get_tenant_info("key", "RS256") + assert isinstance(base_dep, TenantDependencyBase) + + +def test_current_tenant_not_provided(token_mock_rs256, test_app_rs256): + headers = {"Authorization": f"Bearer {token_mock_rs256}"} + res = test_app_rs256.post("/test", headers=headers) + assert res.status_code == 401 + assert res.json() == {"detail": "No X-Current-Tenant provided!"} + + +def test_current_tenant_wrong(token_mock_rs256, test_app_rs256): + headers = { + "Authorization": f"Bearer {token_mock_rs256}", + "X-Current-Tenant": "wrong", + } + res = test_app_rs256.post("/test", headers=headers) + assert res.status_code == 403 + assert res.json() == {"detail": "X-Current-Tenant not in jwt tenants!"} + + +def test_client_token_positive(client_token_mock_rs256, test_app_rs256): + headers = { + "Authorization": f"Bearer {client_token_mock_rs256}", + "X-Current-Tenant": CURRENT_TENANT, + } + response_body = { + "roles": [ + "default-roles-master", + "offline_access", + "uma_authorization", + ], + "tenants": None, + "token": f"{client_token_mock_rs256}", + "user_id": "3855eb45-2c11-4b15-8989-257b3a51649c", + } + res = test_app_rs256.post("/test", headers=headers) + assert res.status_code == 200 + assert res.json() == response_body + + +def test_wrong_client_token_data( + wrong_client_token_mock_rs256, test_app_rs256 +): + headers = { + "Authorization": f"Bearer {wrong_client_token_mock_rs256}", + "X-Current-Tenant": CURRENT_TENANT, + } + res = test_app_rs256.post("/test", headers=headers) + assert res.status_code == 403 + assert res.json() == {"detail": "Wrong data provided in jwt!"} diff --git a/lib/python3.12/tenants/tests/test_schema.py b/lib/python3.12/tenants/tests/test_schema.py new file mode 100644 index 000000000..57508b10c --- /dev/null +++ b/lib/python3.12/tenants/tests/test_schema.py @@ -0,0 +1,58 @@ +import pytest +from pydantic import ValidationError + +from src.schema import SupportedAlgorithms, TenantData + + +@pytest.mark.parametrize( + ("token", "user_id", "roles", "tenants", "expected_result"), + [ + ( + "token", + "901", + ["admin", "ml engineer", "devops"], + ["tenant1"], + { + "token": "token", + "user_id": "901", + "roles": ["admin", "ml engineer", "devops"], + "tenants": ["tenant1"], + }, + ), + ( + "token", + "901", + ["admin"], + ["tenant1"], + { + "token": "token", + "user_id": "901", + "roles": ["admin"], + "tenants": ["tenant1"], + }, + ), + ], +) +def test_tenant_data_positive(token, user_id, roles, tenants, expected_result): + assert ( + TenantData( + token=token, user_id=user_id, roles=roles, tenants=tenants + ).model_dump() + == expected_result + ) + + +def tenant_data_negative(): + with pytest.raises(ValidationError): + TenantData(user_id=None, tenant="tenant1", roles=["guest"]) + with pytest.raises(ValidationError): + TenantData(user_id=1, tenant=None, roles=["admin"]) + with pytest.raises(ValidationError): + TenantData(user_id=1, tenant="tenant1", roles=[]) + + +def test_enum_members(): + assert SupportedAlgorithms.members() == [ + SupportedAlgorithms.HS256, + SupportedAlgorithms.RS256, + ] diff --git a/lib/python3.12/tenants/usage_example/jwt_generator.py b/lib/python3.12/tenants/usage_example/jwt_generator.py new file mode 100644 index 000000000..fba10bde1 --- /dev/null +++ b/lib/python3.12/tenants/usage_example/jwt_generator.py @@ -0,0 +1,35 @@ +from datetime import UTC, datetime, timedelta +from typing import Any, Dict, Optional, Union + +import jwt + +SECRET = "some_secret_key" + + +def create_access_token( + data: Dict[str, Any], + secret: Union[str, bytes], + expires_delta: Optional[int] = None, + algorithm: str = "HS256", +) -> Any: + to_encode = data.copy() + if expires_delta: + expire = datetime.now(UTC) + timedelta(minutes=expires_delta) + else: + expire = datetime.now(UTC) + timedelta(minutes=15) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, secret, algorithm=algorithm) + return encoded_jwt + + +access_token = create_access_token( + data={ + "sub": "901", + "realm_access": {"roles": ["role-annotator"]}, + "tenants": ["tenant1", "epam"], + }, + secret=SECRET, + expires_delta=15, + algorithm="HS256", +) +print(access_token) diff --git a/lib/python3.12/tenants/usage_example/main.py b/lib/python3.12/tenants/usage_example/main.py new file mode 100644 index 000000000..6c03be401 --- /dev/null +++ b/lib/python3.12/tenants/usage_example/main.py @@ -0,0 +1,29 @@ +from typing import Any + +from fastapi import Depends, FastAPI, Header +from tenant_dependency import TenantData, get_tenant_info + +# RS256 (BadgerDoc) +# url=http://dev1.gcov.ru for local testing, url=http://bagerdoc-keycloack for deployed service # noqa +tenant_ = get_tenant_info(url="http://dev1.gcov.ru", algorithm="RS256") +app_ = FastAPI() + + +@app_.post("/test_") +async def some_( + x_current_tenant: str = Header(...), token: TenantData = Depends(tenant_) +) -> Any: + return {"token": token.dict(), "current tenant": x_current_tenant} + + +# HS256 +SECRET = "some_secret_key" +tenant = get_tenant_info(key=SECRET, algorithm="HS256") +app = FastAPI() + + +@app.post("/test") +async def some( + x_current_tenant: str = Header(...), token: TenantData = Depends(tenant) +) -> Any: + return {"token": token.dict(), "current tenant": x_current_tenant}