diff --git a/.gitignore b/.gitignore index 3cf8c6d..4304b9e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ env -__pycache__ \ No newline at end of file +.env +__pycache__ +.vscode/ diff --git a/Makefile b/Makefile index 7dec298..09eb6bd 100644 --- a/Makefile +++ b/Makefile @@ -5,6 +5,7 @@ init: pip3 install -r requirements.txt up: + docker context use default docker-compose -f compose.authentik.yml up -d docker-compose -f compose.wekan.yml up -d @@ -15,13 +16,15 @@ down: run: ./env/bin/uvicorn app.main:app --reload --host 0.0.0.0 -run integration: - ./env/bin/uvicorn app.main:app --reload --host 0.0.0.0 test: - ./env/bin/pytest app + ./env/bin/pytest app + +integration: + ./env/bin/uvicorn app.main:app --reload --host 0.0.0.0 + +requirements: + ./env/bin/pip freeze -l > requirements.txt - - -.PHONY: init run test \ No newline at end of file +.PHONY: init run test diff --git a/README.md b/README.md index 9535277..1b18b25 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,26 @@ make down ``` +# notes + +**Provider:** a leading system with userdata and a notification channel integrate can connect with (e.g. Authentik) +**Apps:** Have API that integrate can interact with and e.g. can create user (e.g. Nextcloud, Wekan) + + +## challenges + + + +* How to handle errors in connected Apps + +* Apps can have different unique constrains than the Provider + e.g. Wekan requires a unique Mail addr and authentik doesn't + the specifig module for the apps api needs to know how to handle these errors + +* User in App could already exist +* + + https://pydantic-docs.helpmanual.io/ https://jsontopydantic.com/ diff --git a/app/__init__.py b/app/__init__.py index e69de29..eb79e6a 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -0,0 +1,2 @@ +from .wekan import main +from .nextcloud import main \ No newline at end of file diff --git a/app/authentik/api.py b/app/authentik/api.py index f9af8a7..aedaab8 100644 --- a/app/authentik/api.py +++ b/app/authentik/api.py @@ -3,16 +3,19 @@ from typing import Dict, Optional import requests from requests import Request import structlog + +from app.authentik.settings import AuthentikSettings from .models import User logging = structlog.get_logger() + class Authentik: - def __init__(self, token, base): - self.base = f"{base}api/v3/" - self.token = token - self.headers = {"Authorization": f"Bearer {token}"} + def __init__(self, settings: AuthentikSettings): + self.base = f"{settings.baseurl}api/v3/" + self.token = settings.token + self.headers = {"Authorization": f"Bearer {self.token}"} self.hook_endpoint = None self.property_mapping = None self.event_matcher_policy = None @@ -22,9 +25,9 @@ class Authentik: self.event_rule_link = None # check connection - r= requests.get(url=f"{self.base}/admin/version", headers=self.headers) + r = requests.get( + url=f"{self.base}/admin/version", headers=self.headers) assert r.status_code == 200 - def get(self, endpoint: str, params: Dict = {}) -> Request: return requests.get(url=f"{self.base}{endpoint}", params=params, headers=self.headers) @@ -57,8 +60,11 @@ class Authentik: "webhook_mapping": property_mapping_pk, "webhook_url": hook_endpoint } - print(data) - # TODO: add check if model with same name already exists + + for result in self.get(url).json()["results"]: + if result["name"] == "my hook": + raise Exception("Event Rule already exists") + r: Request = self.post(url, data) if r.status_code == 201: return r.json() @@ -74,7 +80,11 @@ class Authentik: event_transport ] } - # TODO: add check if model with same name already exists + + for result in self.get(url).json()["results"]: + if result["name"] == "event-rule": + raise Exception("Event Rule already exists") + r = self.post(url, data) if r.status_code == 201: return r.json() @@ -143,8 +153,6 @@ class Authentik: return User(**r) raise Exception(r) - - def get_user(self, user: User) -> Optional[User]: if user.pk: r = self.get(f"core/users/{user.pk}").json() diff --git a/app/authentik/settings.py b/app/authentik/settings.py new file mode 100644 index 0000000..5eb0fd5 --- /dev/null +++ b/app/authentik/settings.py @@ -0,0 +1,9 @@ +from pydantic import BaseSettings, Field + +class AuthentikSettings(BaseSettings): + baseurl: str = "" + token: str = "" + + class Config: + env_file = '.env' + env_prefix = 'AUTHENTIK_' \ No newline at end of file diff --git a/app/authentik/test_authentik.py b/app/authentik/test_authentik.py index c3c2463..1583a05 100644 --- a/app/authentik/test_authentik.py +++ b/app/authentik/test_authentik.py @@ -13,6 +13,24 @@ def api() -> Authentik: pytest.skip("API not reachable? Did you start docker-compose?") + +def test_create_event_transport_already_exists(api: Authentik): + exception = None + try: + api.create_event_transport("/","asd") + except Exception as e: + exception = e + assert not exception == None # TODO create exception types + + +def test_create_event_rule_already_exists(api: Authentik): + exception = None + try: + api.create_event_rule({"pk" : "asd"},"webhook") + except Exception as e: + exception = e + assert not exception == None # TODO create exception types + def test_get_user_by_username(api: Authentik): u = User(username="akadmin", name="", @@ -42,3 +60,5 @@ def test_create_user(api: Authentik): c = api.create_user(u) assert u.username == c.username assert not c.pk == None + + assert api.delete_user(c) == True \ No newline at end of file diff --git a/app/dependencies.py b/app/dependencies.py new file mode 100644 index 0000000..e69de29 diff --git a/app/event_controller.py b/app/event_controller.py index 09eca9d..f1dc924 100644 --- a/app/event_controller.py +++ b/app/event_controller.py @@ -1,10 +1,15 @@ +import structlog +from typing import List from pydantic import BaseModel -from fastapi import FastAPI +from fastapi import FastAPI, Depends from app.authentik.api import Authentik from app.authentik.models import User -from app.wekan.api import Wekan -from app.settings import AuthentikSettings, WekanSettings +from app.sink import Sink +from app.wekan.api import WekanApi +from app.authentik.settings import AuthentikSettings +from app.wekan.main import WekanSink +logging = structlog.get_logger() class Authentik_Hook_Model(BaseModel): pk: str @@ -20,29 +25,30 @@ class Http_request(BaseModel): authentikSettings = AuthentikSettings() -wekanSettings = WekanSettings() - class EventController: - def __init__(self): - try: - self.authentik = Authentik( - authentikSettings.token, authentikSettings.baseurl) - self.wekan = Wekan(wekanSettings.baseurl, - wekanSettings.user, wekanSettings.password) - except Exception as e: - raise Exception("Failed to init Api", e) + def __init__(self, authentik_api: Authentik): + # try: + self._authentik = authentik_api + self._sinks = [] + for sc in Sink.__subclasses__(): + obj = sc() + self._sinks.append(obj) + + + # except Exception as e: + # raise Exception("Failed to init Api", e) self.jobs = [] pass - def register_api(self, authentik: Authentik, wekan: Wekan): - self.authentik = authentik - self.wekan = wekan + def register_api(self, authentik: Authentik, sinks: List[Sink]): + self._authentik = authentik + self._sinks = sinks def handle_model_created_event(self, model: Authentik_Hook_Model): - user: User = self.authentik.get_user_by_pk(model.pk) - if not self.wekan.get_user(user.name): - self.wekan.create_user( - username=user.username, email=user.email, password="") + user: User = self._authentik.get_user_by_pk(model.pk) + for sink in self._sinks: + logging.info(f"Creating User {user.username} in {sink.__class__}") + sink.create_user(user) return True diff --git a/app/main.py b/app/main.py index d1cda92..0000070 100644 --- a/app/main.py +++ b/app/main.py @@ -1,34 +1,29 @@ -import logging import structlog from unicodedata import name from urllib.error import HTTPError from fastapi import Depends, FastAPI, Request, BackgroundTasks from pydantic import BaseModel +from app.sink import BaseUser, Sink from app.authentik.api import Authentik from app.authentik.models import User from app.event_controller import Authentik_Hook_Model, EventController, Http_request -from app.settings import AuthentikSettings -from .wekan.api import Wekan +from app.authentik.settings import AuthentikSettings +from .wekan.api import WekanApi +from .routers import identityProvider import json logging = structlog.get_logger() app = FastAPI() +app.include_router(identityProvider.router) + @app.get("/") async def root(): return {'message': 'Hello World'} -@app.post("/authentik/hook/") -async def hook(model: Authentik_Hook_Model, - http_request: Http_request, - ec: EventController = Depends()): - logging.info(model) - logging.info(http_request) - if http_request.path == "/api/v3/core/users/": - ec.handle_model_created_event(model) - return 200 +### for testing purposes @app.get("/authentik/create_hook/") async def hook(request: Request): a = Authentik(base="http://localhost:9000/", token="foobar123") diff --git a/app/nextcloud/__init__.py b/app/nextcloud/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/nextcloud/main.py b/app/nextcloud/main.py new file mode 100644 index 0000000..39b1b52 --- /dev/null +++ b/app/nextcloud/main.py @@ -0,0 +1,26 @@ +from unittest.mock import MagicMock +from app.sink import BaseGroup, BaseUser, Sink + +class Api: + def create_user(self): + pass + +class NextcloudSink(Sink): + + def __init__(self): + self._api: Api = Api() + + @property + def api(self): + return self._api + + @api.setter + def api(self, api): + self._api = api + + def create_user(self, user: BaseUser): # Wekan): + return self.api.create_user() + + def create_group(self, group: BaseGroup): + print("Create Nextcloud Group: ", group) + pass \ No newline at end of file diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routers/identityProvider.py b/app/routers/identityProvider.py new file mode 100644 index 0000000..54a43c7 --- /dev/null +++ b/app/routers/identityProvider.py @@ -0,0 +1,20 @@ +import logging +from app import dependencies +from app.authentik.api import Authentik +from app.event_controller import Authentik_Hook_Model, EventController, Http_request +from fastapi import APIRouter, Depends + +from app.authentik.settings import AuthentikSettings + +router = APIRouter() + +@router.post("/authentik/hook/") +async def hook(model: Authentik_Hook_Model, + http_request: Http_request, + ): + logging.info(model) + logging.info(http_request) + ec = EventController(Authentik(AuthentikSettings())) + if http_request.path == "/api/v3/core/users/": + ec.handle_model_created_event(model) + return 200 \ No newline at end of file diff --git a/app/settings.py b/app/settings.py deleted file mode 100644 index 1c31964..0000000 --- a/app/settings.py +++ /dev/null @@ -1,19 +0,0 @@ -from pydantic import BaseSettings, Field - - -class WekanSettings(BaseSettings): - baseurl: str = "" - user: str = "" - password: str = "" - - class Config: - env_file = '.env' - env_prefix = 'WEKAN_' - -class AuthentikSettings(BaseSettings): - baseurl: str = "" - token: str = "" - - class Config: - env_file = '.env' - env_prefix = 'AUTHENTIK_' \ No newline at end of file diff --git a/app/sink.py b/app/sink.py new file mode 100644 index 0000000..31051e7 --- /dev/null +++ b/app/sink.py @@ -0,0 +1,27 @@ +from abc import ABC, abstractclassmethod, abstractproperty +from pydantic import BaseModel + + +class BaseUser(BaseModel): + email: str + id: str + name: str + username: str + +class BaseGroup(BaseModel): + name: str + + +class Sink(ABC): + + @abstractproperty + def api(self): + pass + + @abstractclassmethod + def create_user(user: BaseUser) -> BaseUser: + pass + + @abstractclassmethod + def create_group(group: BaseGroup) -> BaseGroup: + pass \ No newline at end of file diff --git a/app/test_event_controller.py b/app/test_event_controller.py index cd49622..9e13615 100644 --- a/app/test_event_controller.py +++ b/app/test_event_controller.py @@ -11,12 +11,11 @@ def test_handle_model_created_event(mocker: MockerFixture): authentik_mock = mocker.MagicMock() authentik_mock.get_user_by_pk.return_value = mock_user model = Authentik_Hook_Model(pk=mock_user.pk, app="authentik_core", name=mock_user.name, model_name="user") - ec = EventController() - ec.register_api(authentik_mock, wekan_mock) + ec = EventController(authentik_mock) + ec.register_api(authentik_mock, [wekan_mock]) ec.handle_model_created_event(model) - ec.authentik.get_user_by_pk.assert_called() - ec.authentik.get_user_by_pk.assert_called_with("5") - ec.wekan.get_user.assert_called() - ec.wekan.get_user.assert_called_with("asd") - ec.wekan.create_user.assert_called() - ec.wekan.create_user.assert_called_with(username=mock_user.username, email=mock_user.email, password="") + ec._authentik.get_user_by_pk.assert_called() + ec._authentik.get_user_by_pk.assert_called_with("5") + + wekan_mock.create_user.assert_called() + wekan_mock.create_user.assert_called_with(mock_user) diff --git a/app/test_integration.py b/app/test_integration.py new file mode 100644 index 0000000..691466e --- /dev/null +++ b/app/test_integration.py @@ -0,0 +1,58 @@ +from cmath import log +from email.mime import base +import logging +from re import A +from time import sleep +import pytest + +import requests +from app import event_controller + +from app.authentik.api import Authentik +from app.authentik.models import User +from app.authentik.settings import AuthentikSettings +from app.wekan.models import User as WekanUser +from app.wekan.api import WekanApi + + +@pytest.fixture() +def wekan_api(): + w = None + try: + r = requests.post("http://localhost:3000/users/register", json={"username": "api", "password": "foobar123", "email": "foo@example.org"}) + w = WekanApi("http://localhost:3000","api", "foobar123") + except Exception as e: + logging.error(e) + return w + +@pytest.fixture() +def authentik_api(): + + settings = AuthentikSettings(baseurl="http://localhost:9000/", token="foobar123") + a = Authentik(settings) + try: + r = a.create_web_hook(hook_endpoint="http://172.17.0.1:8000/authentik/hook/") # docker localhost + except Exception as e: + logging.info(e) + return a + + + +def test_create_user(wekan_api: WekanApi, authentik_api: Authentik): + user = authentik_api.create_user(User(username="banane43", email="banane@example.org", name="Herr Banane")) + print(user) + sleep(5) + authentik_api.delete_user(user) + # authentik username == wekan username + # user must be created from authentik user and noch api to trigger the notiftication rule? + logging.error("authentik notifcation rule doesn't work with api?? , plz create user in authentik") + assert False + +@pytest.mark.skip() +def test_create_user_with_same_email(wekan_api, authentik_api): + logging.error("authentik notifcation rule doesn't work with api?? , create two user with identical email in authentik") + assert False + +@pytest.mark.skip() +def test_user_already_exists_excepts(): + assert False \ No newline at end of file diff --git a/app/test_main.py b/app/test_main.py index ddbce48..62cc91d 100644 --- a/app/test_main.py +++ b/app/test_main.py @@ -1,7 +1,7 @@ from fastapi.testclient import TestClient import pytest -from app.wekan.api import Wekan +from app.wekan.api import WekanApi from .main import Authentik_Hook_Model, app @@ -25,7 +25,7 @@ def test_hook_fails_for_wrong_input(): assert response.status_code == 422 def test_hook_model_created(mocker): - mock = mocker.patch("app.event_controller.Event_Controller.handle_model_created_event") + mock = mocker.patch("app.event_controller.EventController.handle_model_created_event") d = """ {"model": {"pk": 5, "app": "authentik_core", "name": "asd", "model_name": "user"}, "http_request": {"args": {}, "path": "/api/v3/core/users/", "method": "POST"}} """ diff --git a/app/test_sink.py b/app/test_sink.py new file mode 100644 index 0000000..df2c550 --- /dev/null +++ b/app/test_sink.py @@ -0,0 +1,28 @@ +from .nextcloud.main import BaseUser, Sink +from .wekan import main +from .nextcloud import main +from pytest_mock import MockerFixture +import pytest + +# TODO how to import all sink from smth like a "addons" folder? + +@pytest.fixture +def testUser(): + return BaseUser(email="foo", id="asd", name="sd", username="sdfsfd") + +def test_get_subclasses(): + l = [] + for sc in Sink.__subclasses__(): + l.append(sc.__name__) + assert "NextcloudSink" in l + assert "WekanSink" in l + +@pytest.mark.skip() +def test_create_user(mocker: MockerFixture, testUser: BaseUser): + u = [] + for sc in Sink.__subclasses__(): + print(sc.__name__) + # app = sc() + # app.api = mocker.MagicMock() + # app.create_user(testUser) + # app.api.create_user.assert_called() diff --git a/app/wekan/api.py b/app/wekan/api.py index 5665548..6d131f0 100644 --- a/app/wekan/api.py +++ b/app/wekan/api.py @@ -1,3 +1,4 @@ +import logging from optparse import Option import string import requests @@ -5,19 +6,20 @@ from requests import Request from pydantic import BaseModel, Field from typing import Any, Dict, List, Optional -from app.wekan.models import User, UserBase +from .models import User, UserBase +from .settings import WekanSettings -class Wekan: +class WekanApi: - def __init__(self, base: str, api_user: str, password: str): - self.base = base - r = requests.post(f'{base}/users/login', data={ - "username": api_user, - "password": password + def __init__(self, settings: WekanSettings): + self.base = settings.baseurl + r = requests.post(f'{self.base}/users/login', data={ + "username": settings.user, + "password": settings.password }, ) if not r.status_code == 200: - raise Exception(r.url, r.text) + raise Exception("[WekanAPI] Failed to init") t = r.json() self.token = { @@ -60,7 +62,7 @@ class Wekan: data = { "username": username, "email": email, - "password": "" + "password": password } r = self.post("users/", data).json() if "error" in r: diff --git a/app/wekan/main.py b/app/wekan/main.py new file mode 100644 index 0000000..8cd4fd9 --- /dev/null +++ b/app/wekan/main.py @@ -0,0 +1,30 @@ + +from unittest.mock import MagicMock +from .settings import WekanSettings +from app.sink import BaseGroup, BaseUser, Sink +from .api import WekanApi +from .models import User + + +class WekanSink(Sink): + + def __init__(self): + self._settings = WekanSettings() + self._api = WekanApi(self._settings) + + @property + def api(self): + return self._api + + @api.setter + def api(self, api): + self._api = api + + def create_user(self, user: BaseUser): + # TODO if not self.wekan.get_user(user.name): + return self._api.create_user(username=user.username, email=user.email, password="") + + def create_group(self, group: BaseGroup): + print("Create Wekan Group: ", group) + pass + diff --git a/app/wekan/settings.py b/app/wekan/settings.py new file mode 100644 index 0000000..d71e01f --- /dev/null +++ b/app/wekan/settings.py @@ -0,0 +1,10 @@ +from pydantic import BaseSettings + +class WekanSettings(BaseSettings): + baseurl: str = "" + user: str = "" + password: str = "" + + class Config: + env_file = '.env' + env_prefix = 'WEKAN_' \ No newline at end of file diff --git a/app/wekan/sources.py b/app/wekan/sources.py new file mode 100644 index 0000000..e69de29 diff --git a/app/wekan/test_wekan.py b/app/wekan/test_wekan.py index 5777d1d..b8eb386 100644 --- a/app/wekan/test_wekan.py +++ b/app/wekan/test_wekan.py @@ -1,18 +1,18 @@ import requests from app.wekan.models import User, UserBase -from .api import Wekan +from .api import WekanApi import pytest @pytest.fixture -def api() -> Wekan: +def api() -> WekanApi: try: r = requests.post("http://localhost:3000/users/register", json={"username": "api", "password": "foobar123", "email": "foo@example.org"}) - return Wekan("http://localhost:3000", "api", "foobar123") + return WekanApi("http://localhost:3000", "api", "foobar123") except: pytest.skip("API not reachable? Did you start docker-compose?") -def test_get_user(api: Wekan): +def test_get_user(api: WekanApi): user = api.get_user("api") assert user.username == "api" assert type(user) == User @@ -20,15 +20,15 @@ def test_get_user(api: Wekan): user = api.get_user("doesnotexist") assert user == None -def test_get_users(api: Wekan): +def test_get_users(api: WekanApi): assert True if "api" in [u.username for u in api.get_all_users()] else False -def test_create_user(api: Wekan): +def test_create_user(api: WekanApi): user = api.create_user("foo", "foo@bar.com", "") assert api.get_user("foo").username == "foo" assert type(user) is User -def test_delete_user(api: Wekan): +def test_delete_user(api: WekanApi): api.create_user("foo", "foo@bar.com", "") api.delete_user("foo") # TODO: doesn't work? pytest.skip("smth wrong with wekan api") diff --git a/compose.authentik.yml b/compose.authentik.yml index 7ffbf39..0783448 100644 --- a/compose.authentik.yml +++ b/compose.authentik.yml @@ -15,7 +15,7 @@ services: image: redis:alpine restart: unless-stopped server: - image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2022.2.1} + image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2022.4.1} restart: unless-stopped command: server environment: @@ -38,7 +38,7 @@ services: - "0.0.0.0:${AUTHENTIK_PORT_HTTP:-9000}:9000" - "0.0.0.0:${AUTHENTIK_PORT_HTTPS:-9443}:9443" worker: - image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2022.2.1} + image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2022.4.1} restart: unless-stopped command: worker environment: diff --git a/requirements.txt b/requirements.txt index 36c54d9..eee6fe5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,29 +1,26 @@ anyio==3.5.0 asgiref==3.5.0 attrs==21.4.0 -autopep8==1.6.0 certifi==2021.10.8 charset-normalizer==2.0.12 -click==8.0.4 -fastapi==0.74.1 +click==8.1.2 +fastapi==0.75.2 h11==0.13.0 idna==3.3 iniconfig==1.1.1 packaging==21.3 pluggy==1.0.0 py==1.11.0 -pycodestyle==2.8.0 pydantic==1.9.0 -pyparsing==3.0.7 -pytest==7.0.1 +pyparsing==3.0.8 +pytest==7.1.2 pytest-mock==3.7.0 -python-dotenv==0.19.2 +python-dotenv==0.20.0 requests==2.27.1 sniffio==1.2.0 starlette==0.17.1 structlog==21.5.0 -toml==0.10.2 tomli==2.0.1 -typing_extensions==4.1.1 -urllib3==1.26.8 -uvicorn==0.17.5 +typing_extensions==4.2.0 +urllib3==1.26.9 +uvicorn==0.17.6