make apps abstract

This commit is contained in:
Philipp Rothmann 2022-04-29 14:13:54 +02:00
parent b0112e05b5
commit 104a576908
26 changed files with 342 additions and 99 deletions

2
.gitignore vendored
View file

@ -1,2 +1,4 @@
env
.env
__pycache__
.vscode/

View file

@ -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
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

View file

@ -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/

View file

@ -0,0 +1,2 @@
from .wekan import main
from .nextcloud import main

View file

@ -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,10 +25,10 @@ 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()

View file

@ -1,15 +1,5 @@
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 = ""

View file

@ -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

0
app/dependencies.py Normal file
View file

View file

@ -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

View file

@ -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")

View file

26
app/nextcloud/main.py Normal file
View file

@ -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

0
app/routers/__init__.py Normal file
View file

View file

@ -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

27
app/sink.py Normal file
View file

@ -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

View file

@ -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)

58
app/test_integration.py Normal file
View file

@ -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

View file

@ -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"}}
"""

28
app/test_sink.py Normal file
View file

@ -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()

View file

@ -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:

30
app/wekan/main.py Normal file
View file

@ -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

10
app/wekan/settings.py Normal file
View file

@ -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_'

0
app/wekan/sources.py Normal file
View file

View file

@ -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")

View file

@ -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:

View file

@ -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