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

4
.gitignore vendored
View file

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

View file

@ -5,6 +5,7 @@ init:
pip3 install -r requirements.txt pip3 install -r requirements.txt
up: up:
docker context use default
docker-compose -f compose.authentik.yml up -d docker-compose -f compose.authentik.yml up -d
docker-compose -f compose.wekan.yml up -d docker-compose -f compose.wekan.yml up -d
@ -15,13 +16,15 @@ down:
run: run:
./env/bin/uvicorn app.main:app --reload --host 0.0.0.0 ./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: 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
.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://pydantic-docs.helpmanual.io/
https://jsontopydantic.com/ 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 import requests
from requests import Request from requests import Request
import structlog import structlog
from app.authentik.settings import AuthentikSettings
from .models import User from .models import User
logging = structlog.get_logger() logging = structlog.get_logger()
class Authentik: class Authentik:
def __init__(self, token, base): def __init__(self, settings: AuthentikSettings):
self.base = f"{base}api/v3/" self.base = f"{settings.baseurl}api/v3/"
self.token = token self.token = settings.token
self.headers = {"Authorization": f"Bearer {token}"} self.headers = {"Authorization": f"Bearer {self.token}"}
self.hook_endpoint = None self.hook_endpoint = None
self.property_mapping = None self.property_mapping = None
self.event_matcher_policy = None self.event_matcher_policy = None
@ -22,9 +25,9 @@ class Authentik:
self.event_rule_link = None self.event_rule_link = None
# check connection # 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 assert r.status_code == 200
def get(self, endpoint: str, params: Dict = {}) -> Request: def get(self, endpoint: str, params: Dict = {}) -> Request:
return requests.get(url=f"{self.base}{endpoint}", params=params, headers=self.headers) 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_mapping": property_mapping_pk,
"webhook_url": hook_endpoint "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) r: Request = self.post(url, data)
if r.status_code == 201: if r.status_code == 201:
return r.json() return r.json()
@ -74,7 +80,11 @@ class Authentik:
event_transport 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) r = self.post(url, data)
if r.status_code == 201: if r.status_code == 201:
return r.json() return r.json()
@ -143,8 +153,6 @@ class Authentik:
return User(**r) return User(**r)
raise Exception(r) raise Exception(r)
def get_user(self, user: User) -> Optional[User]: def get_user(self, user: User) -> Optional[User]:
if user.pk: if user.pk:
r = self.get(f"core/users/{user.pk}").json() r = self.get(f"core/users/{user.pk}").json()

View file

@ -1,15 +1,5 @@
from pydantic import BaseSettings, Field 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): class AuthentikSettings(BaseSettings):
baseurl: str = "" baseurl: str = ""
token: str = "" token: str = ""

View file

@ -13,6 +13,24 @@ def api() -> Authentik:
pytest.skip("API not reachable? Did you start docker-compose?") 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): def test_get_user_by_username(api: Authentik):
u = User(username="akadmin", u = User(username="akadmin",
name="", name="",
@ -42,3 +60,5 @@ def test_create_user(api: Authentik):
c = api.create_user(u) c = api.create_user(u)
assert u.username == c.username assert u.username == c.username
assert not c.pk == None 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 pydantic import BaseModel
from fastapi import FastAPI from fastapi import FastAPI, Depends
from app.authentik.api import Authentik from app.authentik.api import Authentik
from app.authentik.models import User from app.authentik.models import User
from app.wekan.api import Wekan from app.sink import Sink
from app.settings import AuthentikSettings, WekanSettings 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): class Authentik_Hook_Model(BaseModel):
pk: str pk: str
@ -20,29 +25,30 @@ class Http_request(BaseModel):
authentikSettings = AuthentikSettings() authentikSettings = AuthentikSettings()
wekanSettings = WekanSettings()
class EventController: class EventController:
def __init__(self): def __init__(self, authentik_api: Authentik):
try: # try:
self.authentik = Authentik( self._authentik = authentik_api
authentikSettings.token, authentikSettings.baseurl) self._sinks = []
self.wekan = Wekan(wekanSettings.baseurl, for sc in Sink.__subclasses__():
wekanSettings.user, wekanSettings.password) obj = sc()
except Exception as e: self._sinks.append(obj)
raise Exception("Failed to init Api", e)
# except Exception as e:
# raise Exception("Failed to init Api", e)
self.jobs = [] self.jobs = []
pass pass
def register_api(self, authentik: Authentik, wekan: Wekan): def register_api(self, authentik: Authentik, sinks: List[Sink]):
self.authentik = authentik self._authentik = authentik
self.wekan = wekan self._sinks = sinks
def handle_model_created_event(self, model: Authentik_Hook_Model): def handle_model_created_event(self, model: Authentik_Hook_Model):
user: User = self.authentik.get_user_by_pk(model.pk) user: User = self._authentik.get_user_by_pk(model.pk)
if not self.wekan.get_user(user.name): for sink in self._sinks:
self.wekan.create_user( logging.info(f"Creating User {user.username} in {sink.__class__}")
username=user.username, email=user.email, password="") sink.create_user(user)
return True return True

View file

@ -1,34 +1,29 @@
import logging
import structlog import structlog
from unicodedata import name from unicodedata import name
from urllib.error import HTTPError from urllib.error import HTTPError
from fastapi import Depends, FastAPI, Request, BackgroundTasks from fastapi import Depends, FastAPI, Request, BackgroundTasks
from pydantic import BaseModel from pydantic import BaseModel
from app.sink import BaseUser, Sink
from app.authentik.api import Authentik from app.authentik.api import Authentik
from app.authentik.models import User from app.authentik.models import User
from app.event_controller import Authentik_Hook_Model, EventController, Http_request from app.event_controller import Authentik_Hook_Model, EventController, Http_request
from app.settings import AuthentikSettings from app.authentik.settings import AuthentikSettings
from .wekan.api import Wekan from .wekan.api import WekanApi
from .routers import identityProvider
import json import json
logging = structlog.get_logger() logging = structlog.get_logger()
app = FastAPI() app = FastAPI()
app.include_router(identityProvider.router)
@app.get("/") @app.get("/")
async def root(): async def root():
return {'message': 'Hello World'} 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/") @app.get("/authentik/create_hook/")
async def hook(request: Request): async def hook(request: Request):
a = Authentik(base="http://localhost:9000/", token="foobar123") 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 = mocker.MagicMock()
authentik_mock.get_user_by_pk.return_value = mock_user 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") model = Authentik_Hook_Model(pk=mock_user.pk, app="authentik_core", name=mock_user.name, model_name="user")
ec = EventController() ec = EventController(authentik_mock)
ec.register_api(authentik_mock, wekan_mock) ec.register_api(authentik_mock, [wekan_mock])
ec.handle_model_created_event(model) ec.handle_model_created_event(model)
ec.authentik.get_user_by_pk.assert_called() ec._authentik.get_user_by_pk.assert_called()
ec.authentik.get_user_by_pk.assert_called_with("5") ec._authentik.get_user_by_pk.assert_called_with("5")
ec.wekan.get_user.assert_called()
ec.wekan.get_user.assert_called_with("asd") wekan_mock.create_user.assert_called()
ec.wekan.create_user.assert_called() wekan_mock.create_user.assert_called_with(mock_user)
ec.wekan.create_user.assert_called_with(username=mock_user.username, email=mock_user.email, password="")

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 from fastapi.testclient import TestClient
import pytest import pytest
from app.wekan.api import Wekan from app.wekan.api import WekanApi
from .main import Authentik_Hook_Model, app from .main import Authentik_Hook_Model, app
@ -25,7 +25,7 @@ def test_hook_fails_for_wrong_input():
assert response.status_code == 422 assert response.status_code == 422
def test_hook_model_created(mocker): 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 = """ d = """
{"model": {"pk": 5, "app": "authentik_core", "name": "asd", "model_name": "user"}, "http_request": {"args": {}, "path": "/api/v3/core/users/", "method": "POST"}} {"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 from optparse import Option
import string import string
import requests import requests
@ -5,19 +6,20 @@ from requests import Request
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from typing import Any, Dict, List, Optional 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): def __init__(self, settings: WekanSettings):
self.base = base self.base = settings.baseurl
r = requests.post(f'{base}/users/login', data={ r = requests.post(f'{self.base}/users/login', data={
"username": api_user, "username": settings.user,
"password": password "password": settings.password
}, ) }, )
if not r.status_code == 200: if not r.status_code == 200:
raise Exception(r.url, r.text) raise Exception("[WekanAPI] Failed to init")
t = r.json() t = r.json()
self.token = { self.token = {
@ -60,7 +62,7 @@ class Wekan:
data = { data = {
"username": username, "username": username,
"email": email, "email": email,
"password": "" "password": password
} }
r = self.post("users/", data).json() r = self.post("users/", data).json()
if "error" in r: 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 import requests
from app.wekan.models import User, UserBase from app.wekan.models import User, UserBase
from .api import Wekan from .api import WekanApi
import pytest import pytest
@pytest.fixture @pytest.fixture
def api() -> Wekan: def api() -> WekanApi:
try: try:
r = requests.post("http://localhost:3000/users/register", json={"username": "api", "password": "foobar123", "email": "foo@example.org"}) 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: except:
pytest.skip("API not reachable? Did you start docker-compose?") 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") user = api.get_user("api")
assert user.username == "api" assert user.username == "api"
assert type(user) == User assert type(user) == User
@ -20,15 +20,15 @@ def test_get_user(api: Wekan):
user = api.get_user("doesnotexist") user = api.get_user("doesnotexist")
assert user == None 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 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", "") user = api.create_user("foo", "foo@bar.com", "")
assert api.get_user("foo").username == "foo" assert api.get_user("foo").username == "foo"
assert type(user) is User 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.create_user("foo", "foo@bar.com", "")
api.delete_user("foo") # TODO: doesn't work? api.delete_user("foo") # TODO: doesn't work?
pytest.skip("smth wrong with wekan api") pytest.skip("smth wrong with wekan api")

View file

@ -15,7 +15,7 @@ services:
image: redis:alpine image: redis:alpine
restart: unless-stopped restart: unless-stopped
server: 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 restart: unless-stopped
command: server command: server
environment: environment:
@ -38,7 +38,7 @@ services:
- "0.0.0.0:${AUTHENTIK_PORT_HTTP:-9000}:9000" - "0.0.0.0:${AUTHENTIK_PORT_HTTP:-9000}:9000"
- "0.0.0.0:${AUTHENTIK_PORT_HTTPS:-9443}:9443" - "0.0.0.0:${AUTHENTIK_PORT_HTTPS:-9443}:9443"
worker: 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 restart: unless-stopped
command: worker command: worker
environment: environment:

View file

@ -1,29 +1,26 @@
anyio==3.5.0 anyio==3.5.0
asgiref==3.5.0 asgiref==3.5.0
attrs==21.4.0 attrs==21.4.0
autopep8==1.6.0
certifi==2021.10.8 certifi==2021.10.8
charset-normalizer==2.0.12 charset-normalizer==2.0.12
click==8.0.4 click==8.1.2
fastapi==0.74.1 fastapi==0.75.2
h11==0.13.0 h11==0.13.0
idna==3.3 idna==3.3
iniconfig==1.1.1 iniconfig==1.1.1
packaging==21.3 packaging==21.3
pluggy==1.0.0 pluggy==1.0.0
py==1.11.0 py==1.11.0
pycodestyle==2.8.0
pydantic==1.9.0 pydantic==1.9.0
pyparsing==3.0.7 pyparsing==3.0.8
pytest==7.0.1 pytest==7.1.2
pytest-mock==3.7.0 pytest-mock==3.7.0
python-dotenv==0.19.2 python-dotenv==0.20.0
requests==2.27.1 requests==2.27.1
sniffio==1.2.0 sniffio==1.2.0
starlette==0.17.1 starlette==0.17.1
structlog==21.5.0 structlog==21.5.0
toml==0.10.2
tomli==2.0.1 tomli==2.0.1
typing_extensions==4.1.1 typing_extensions==4.2.0
urllib3==1.26.8 urllib3==1.26.9
uvicorn==0.17.5 uvicorn==0.17.6