perfact-api-main 0.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- perfact_api_main-0.2/.gitea/workflows/check.yml +35 -0
- perfact_api_main-0.2/.gitignore +7 -0
- perfact_api_main-0.2/.vscode/launch.json +41 -0
- perfact_api_main-0.2/.vscode/settings.json +6 -0
- perfact_api_main-0.2/PKG-INFO +117 -0
- perfact_api_main-0.2/README.md +99 -0
- perfact_api_main-0.2/bandit.yml +2 -0
- perfact_api_main-0.2/pyproject.toml +44 -0
- perfact_api_main-0.2/setup.cfg +4 -0
- perfact_api_main-0.2/src/perfact/api/main/__init__.py +4 -0
- perfact_api_main-0.2/src/perfact/api/main/app.py +42 -0
- perfact_api_main-0.2/src/perfact/api/main/assignworker.py +63 -0
- perfact_api_main-0.2/src/perfact/api/main/auth.py +346 -0
- perfact_api_main-0.2/src/perfact/api/main/config.py +34 -0
- perfact_api_main-0.2/src/perfact/api/main/dbsession.py +61 -0
- perfact_api_main-0.2/src/perfact/api/main/perfact_generic.py +47 -0
- perfact_api_main-0.2/src/perfact/api/main/py.typed +0 -0
- perfact_api_main-0.2/src/perfact/api/main/utils.py +30 -0
- perfact_api_main-0.2/src/perfact_api_main.egg-info/PKG-INFO +117 -0
- perfact_api_main-0.2/src/perfact_api_main.egg-info/SOURCES.txt +22 -0
- perfact_api_main-0.2/src/perfact_api_main.egg-info/dependency_links.txt +1 -0
- perfact_api_main-0.2/src/perfact_api_main.egg-info/requires.txt +6 -0
- perfact_api_main-0.2/src/perfact_api_main.egg-info/top_level.txt +1 -0
- perfact_api_main-0.2/tox.ini +32 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
name: Tox tests
|
|
2
|
+
on:
|
|
3
|
+
push:
|
|
4
|
+
branches:
|
|
5
|
+
- 'main'
|
|
6
|
+
pull_request: {}
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
build:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
matrix:
|
|
13
|
+
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
|
|
14
|
+
|
|
15
|
+
steps:
|
|
16
|
+
- name: Install SSH key
|
|
17
|
+
run: |
|
|
18
|
+
mkdir -p /root/.ssh
|
|
19
|
+
printf '%s' '${{ secrets.SSH_DEPLOYMENT_KEY }}' > /root/.ssh/id_rsa
|
|
20
|
+
printf '%s' '${{ secrets.KNOWN_HOSTS }}' > /root/.ssh/known_hosts
|
|
21
|
+
chmod 600 /root/.ssh/id_rsa
|
|
22
|
+
|
|
23
|
+
- uses: actions/checkout@v4
|
|
24
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
25
|
+
uses: actions/setup-python@v5
|
|
26
|
+
with:
|
|
27
|
+
python-version: ${{ matrix.python-version }}
|
|
28
|
+
- name: Install dependencies
|
|
29
|
+
run: |
|
|
30
|
+
python -m pip install --upgrade pip
|
|
31
|
+
python -m pip install tox tox-gh-actions
|
|
32
|
+
- name: Test with tox
|
|
33
|
+
run: |
|
|
34
|
+
ls -lh /root/.ssh
|
|
35
|
+
tox
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
// Verwendet IntelliSense zum Ermitteln möglicher Attribute.
|
|
3
|
+
// Zeigen Sie auf vorhandene Attribute, um die zugehörigen Beschreibungen anzuzeigen.
|
|
4
|
+
// Weitere Informationen finden Sie unter https://go.microsoft.com/fwlink/?linkid=830387
|
|
5
|
+
"version": "0.2.0",
|
|
6
|
+
"configurations": [
|
|
7
|
+
|
|
8
|
+
{
|
|
9
|
+
"name": "FastAPI: App",
|
|
10
|
+
"type": "debugpy",
|
|
11
|
+
"request": "launch",
|
|
12
|
+
"module": "uvicorn",
|
|
13
|
+
"args": [
|
|
14
|
+
"perfact.api.main.app:app",
|
|
15
|
+
"--reload",
|
|
16
|
+
"--reload-include",
|
|
17
|
+
"../"
|
|
18
|
+
],
|
|
19
|
+
"jinja": true,
|
|
20
|
+
"env": {
|
|
21
|
+
"PERFACT_API_CONFIG_PATH": "./config.yaml"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"name": "FastAPI: Assignworker",
|
|
26
|
+
"type": "debugpy",
|
|
27
|
+
"request": "launch",
|
|
28
|
+
"module": "uvicorn",
|
|
29
|
+
"args": [
|
|
30
|
+
"perfact.api.main.assignworker:app",
|
|
31
|
+
"--reload",
|
|
32
|
+
"--reload-include",
|
|
33
|
+
"../"
|
|
34
|
+
],
|
|
35
|
+
"jinja": true,
|
|
36
|
+
"env": {
|
|
37
|
+
"PERFACT_API_CONFIG_PATH": "./config.yaml"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
]
|
|
41
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: perfact-api-main
|
|
3
|
+
Version: 0.2
|
|
4
|
+
Summary: PerFact API - FastAPI main package (middleware, auth, entrypoints)
|
|
5
|
+
Author-email: Viktor Dick <viktor.dick@perfact.de>
|
|
6
|
+
License-Expression: GPL-2.0-or-later
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Programming Language :: SQL
|
|
9
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: argon2-cffi
|
|
13
|
+
Requires-Dist: psycopg[c]
|
|
14
|
+
Requires-Dist: fastapi[standard-no-fastapi-cloud-cli]
|
|
15
|
+
Requires-Dist: sqlalchemy
|
|
16
|
+
Requires-Dist: pydantic-settings
|
|
17
|
+
Requires-Dist: perfact-api-app-model
|
|
18
|
+
|
|
19
|
+
# PerFact API Base
|
|
20
|
+
The main FastAPI package for the PerFact API. Provides authentication, authorisation, database session handling, and plugin discovery. All domain-specific API modules are in separate packages and loaded automatically at startup via entry points.
|
|
21
|
+
|
|
22
|
+
## Applications
|
|
23
|
+
|
|
24
|
+
### `perfact.api.main.app` — user-facing API
|
|
25
|
+
|
|
26
|
+
Hosts all plugins registered under the `perfact.api` entry point group. Users authenticate via a login cookie or an `Authorization: Apikey …` header. Access to individual endpoints can be restricted by role:
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from perfact.api.main.auth import require_roles
|
|
30
|
+
|
|
31
|
+
@router.get("/my-endpoint")
|
|
32
|
+
@require_roles("MyRole")
|
|
33
|
+
def my_endpoint(session: DBSession) -> ...:
|
|
34
|
+
...
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### `perfact.api.main.assignworker` — task runner API
|
|
38
|
+
|
|
39
|
+
Hosts all plugins registered under the `perfact.assignapi` entry point group. Intended for internal use by `assignd`. Authentication is HTTP Basic Auth with a pre-shared password hash configured in the YAML config file.
|
|
40
|
+
|
|
41
|
+
## Plugin discovery
|
|
42
|
+
|
|
43
|
+
At startup, both apps iterate over their respective entry point group and call `mount(app)` on each discovered plugin:
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
INFO - start plugin discovery
|
|
47
|
+
INFO - try to include plugin: perfact.api.pd.routes:mount
|
|
48
|
+
INFO - finished discovery and include: 1 plugins
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
A plugin registers itself by declaring an entry point in its `pyproject.toml`:
|
|
52
|
+
|
|
53
|
+
```toml
|
|
54
|
+
[project.entry-points.'perfact.api']
|
|
55
|
+
myplugin = 'perfact.api.myplugin.routes:mount'
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## getting started as developer
|
|
59
|
+
After you checked out this repository, do the following steps to be able to debug your application:
|
|
60
|
+
1. create and activate *venv*
|
|
61
|
+
```sh
|
|
62
|
+
python -m venv .venv
|
|
63
|
+
|
|
64
|
+
source .venv/bin/activate # linux
|
|
65
|
+
.venv/Scripts/Activate.ps1 # PowerShell
|
|
66
|
+
```
|
|
67
|
+
1. Install the API plugins you want to include without doing changes by installing them from pypi:
|
|
68
|
+
```sh
|
|
69
|
+
pip install perfact-api-base-model
|
|
70
|
+
```
|
|
71
|
+
1. Install the API plugins you want to include and **change** in your instance by checking them out somewhere and installing/linking them into your application via `pip install -e`:
|
|
72
|
+
```sh
|
|
73
|
+
pip install -e ../perfact-api-base-model/ # required
|
|
74
|
+
pip install -e ../perfact-api-app-model/ # required
|
|
75
|
+
pip install -e ../perfact-api-pd-model/ # example
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
**Hint**: Every code change is available in the application after the next restart/reload. Changes to the entrypoints are available after the next install command execution (as this is recreating the `ENTRYPOINTS`-file belonging to the package in the site-packages folder).
|
|
79
|
+
|
|
80
|
+
You can install as much as plugins you like, even custom ones.
|
|
81
|
+
|
|
82
|
+
*pip* will automaticly check if more dependencies needed by the plugin while installing, e.g.
|
|
83
|
+
```
|
|
84
|
+
Requirement already satisfied: fastapi
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
Now you can run the application:
|
|
89
|
+
```
|
|
90
|
+
uvicorn perfact.api.main.app:app # run API for frontend
|
|
91
|
+
uvicorn perfact.api.main.assignworker:app # run API for assignd worker APIs
|
|
92
|
+
uvicorn perfact.api.main.app:app --reload --reload-include ../ # run API for frontend with auto-reload for all editable dependencies
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
There is a `launch.json`-file provided to debug the application in VS Code. To use that, you have to create a configuration file (see below).
|
|
96
|
+
|
|
97
|
+
## Configuration
|
|
98
|
+
The application can be configured by providing a configuration yaml-File containing informations about the database connection and basic auth credentials (for `assignworker`).
|
|
99
|
+
The configuration file is given to the application by providing the location (absolute or relative to working dir) in the environment variable `PERFACT_API_CONFIG_PATH`.
|
|
100
|
+
|
|
101
|
+
Example:
|
|
102
|
+
```yaml
|
|
103
|
+
connstr: postgresql+psycopg://zope@/perfactema # default, can be left out
|
|
104
|
+
basicauth: "$argon2id$v=19$m=65536,t=3,p=4$6itEouPTNwWEXDKScKmMrw$NwhN1vAUN8EZjC62HrtXjx7n+K1Mujjd/QqioaHvyOg" # password=test
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Dependencies
|
|
108
|
+
- `perfact-api-app-model`
|
|
109
|
+
- `fastapi`
|
|
110
|
+
- `sqlalchemy`
|
|
111
|
+
- `psycopg[c]`
|
|
112
|
+
- `argon2-cffi`
|
|
113
|
+
- `pydantic-settings`
|
|
114
|
+
|
|
115
|
+
## Maintainers
|
|
116
|
+
- Viktor Dick <viktor.dick@perfact.de>
|
|
117
|
+
- Alexander Rolfes <alexander.rolfes@perfact.de>
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# PerFact API Base
|
|
2
|
+
The main FastAPI package for the PerFact API. Provides authentication, authorisation, database session handling, and plugin discovery. All domain-specific API modules are in separate packages and loaded automatically at startup via entry points.
|
|
3
|
+
|
|
4
|
+
## Applications
|
|
5
|
+
|
|
6
|
+
### `perfact.api.main.app` — user-facing API
|
|
7
|
+
|
|
8
|
+
Hosts all plugins registered under the `perfact.api` entry point group. Users authenticate via a login cookie or an `Authorization: Apikey …` header. Access to individual endpoints can be restricted by role:
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
from perfact.api.main.auth import require_roles
|
|
12
|
+
|
|
13
|
+
@router.get("/my-endpoint")
|
|
14
|
+
@require_roles("MyRole")
|
|
15
|
+
def my_endpoint(session: DBSession) -> ...:
|
|
16
|
+
...
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
### `perfact.api.main.assignworker` — task runner API
|
|
20
|
+
|
|
21
|
+
Hosts all plugins registered under the `perfact.assignapi` entry point group. Intended for internal use by `assignd`. Authentication is HTTP Basic Auth with a pre-shared password hash configured in the YAML config file.
|
|
22
|
+
|
|
23
|
+
## Plugin discovery
|
|
24
|
+
|
|
25
|
+
At startup, both apps iterate over their respective entry point group and call `mount(app)` on each discovered plugin:
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
INFO - start plugin discovery
|
|
29
|
+
INFO - try to include plugin: perfact.api.pd.routes:mount
|
|
30
|
+
INFO - finished discovery and include: 1 plugins
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
A plugin registers itself by declaring an entry point in its `pyproject.toml`:
|
|
34
|
+
|
|
35
|
+
```toml
|
|
36
|
+
[project.entry-points.'perfact.api']
|
|
37
|
+
myplugin = 'perfact.api.myplugin.routes:mount'
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## getting started as developer
|
|
41
|
+
After you checked out this repository, do the following steps to be able to debug your application:
|
|
42
|
+
1. create and activate *venv*
|
|
43
|
+
```sh
|
|
44
|
+
python -m venv .venv
|
|
45
|
+
|
|
46
|
+
source .venv/bin/activate # linux
|
|
47
|
+
.venv/Scripts/Activate.ps1 # PowerShell
|
|
48
|
+
```
|
|
49
|
+
1. Install the API plugins you want to include without doing changes by installing them from pypi:
|
|
50
|
+
```sh
|
|
51
|
+
pip install perfact-api-base-model
|
|
52
|
+
```
|
|
53
|
+
1. Install the API plugins you want to include and **change** in your instance by checking them out somewhere and installing/linking them into your application via `pip install -e`:
|
|
54
|
+
```sh
|
|
55
|
+
pip install -e ../perfact-api-base-model/ # required
|
|
56
|
+
pip install -e ../perfact-api-app-model/ # required
|
|
57
|
+
pip install -e ../perfact-api-pd-model/ # example
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**Hint**: Every code change is available in the application after the next restart/reload. Changes to the entrypoints are available after the next install command execution (as this is recreating the `ENTRYPOINTS`-file belonging to the package in the site-packages folder).
|
|
61
|
+
|
|
62
|
+
You can install as much as plugins you like, even custom ones.
|
|
63
|
+
|
|
64
|
+
*pip* will automaticly check if more dependencies needed by the plugin while installing, e.g.
|
|
65
|
+
```
|
|
66
|
+
Requirement already satisfied: fastapi
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
Now you can run the application:
|
|
71
|
+
```
|
|
72
|
+
uvicorn perfact.api.main.app:app # run API for frontend
|
|
73
|
+
uvicorn perfact.api.main.assignworker:app # run API for assignd worker APIs
|
|
74
|
+
uvicorn perfact.api.main.app:app --reload --reload-include ../ # run API for frontend with auto-reload for all editable dependencies
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
There is a `launch.json`-file provided to debug the application in VS Code. To use that, you have to create a configuration file (see below).
|
|
78
|
+
|
|
79
|
+
## Configuration
|
|
80
|
+
The application can be configured by providing a configuration yaml-File containing informations about the database connection and basic auth credentials (for `assignworker`).
|
|
81
|
+
The configuration file is given to the application by providing the location (absolute or relative to working dir) in the environment variable `PERFACT_API_CONFIG_PATH`.
|
|
82
|
+
|
|
83
|
+
Example:
|
|
84
|
+
```yaml
|
|
85
|
+
connstr: postgresql+psycopg://zope@/perfactema # default, can be left out
|
|
86
|
+
basicauth: "$argon2id$v=19$m=65536,t=3,p=4$6itEouPTNwWEXDKScKmMrw$NwhN1vAUN8EZjC62HrtXjx7n+K1Mujjd/QqioaHvyOg" # password=test
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Dependencies
|
|
90
|
+
- `perfact-api-app-model`
|
|
91
|
+
- `fastapi`
|
|
92
|
+
- `sqlalchemy`
|
|
93
|
+
- `psycopg[c]`
|
|
94
|
+
- `argon2-cffi`
|
|
95
|
+
- `pydantic-settings`
|
|
96
|
+
|
|
97
|
+
## Maintainers
|
|
98
|
+
- Viktor Dick <viktor.dick@perfact.de>
|
|
99
|
+
- Alexander Rolfes <alexander.rolfes@perfact.de>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.2", "setuptools-scm>=8.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "perfact-api-main"
|
|
7
|
+
authors = [
|
|
8
|
+
{name="Viktor Dick", email="viktor.dick@perfact.de"},
|
|
9
|
+
]
|
|
10
|
+
description = "PerFact API - FastAPI main package (middleware, auth, entrypoints)"
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
license = "GPL-2.0-or-later"
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: SQL",
|
|
16
|
+
"Operating System :: POSIX :: Linux",
|
|
17
|
+
]
|
|
18
|
+
dependencies = [
|
|
19
|
+
"argon2-cffi",
|
|
20
|
+
"psycopg[c]",
|
|
21
|
+
"fastapi[standard-no-fastapi-cloud-cli]",
|
|
22
|
+
"sqlalchemy",
|
|
23
|
+
"pydantic-settings",
|
|
24
|
+
"perfact-api-app-model",
|
|
25
|
+
]
|
|
26
|
+
dynamic = ["version"]
|
|
27
|
+
requires-python = ">=3.10"
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
|
|
31
|
+
[tool.distutils.bdist_wheel]
|
|
32
|
+
universal = 1
|
|
33
|
+
|
|
34
|
+
[tool.setuptools]
|
|
35
|
+
include-package-data = true
|
|
36
|
+
|
|
37
|
+
[tool.setuptools.packages.find]
|
|
38
|
+
where = ["src"]
|
|
39
|
+
|
|
40
|
+
[tool.setuptools_scm]
|
|
41
|
+
|
|
42
|
+
[tool.ruff]
|
|
43
|
+
[tool.ruff.lint]
|
|
44
|
+
select = ["E", "F", "W", "I"]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from fastapi import FastAPI, Response, status
|
|
4
|
+
|
|
5
|
+
from .auth import (
|
|
6
|
+
Auth,
|
|
7
|
+
SameSitePostMiddleware,
|
|
8
|
+
add_403_to_openapi,
|
|
9
|
+
)
|
|
10
|
+
from .config import Configuration
|
|
11
|
+
from .dbsession import DBSessionMiddleware, set_connstr
|
|
12
|
+
from .utils import default_logging_settings, discover_add_routes_from_entrypoint
|
|
13
|
+
|
|
14
|
+
default_logging_settings()
|
|
15
|
+
|
|
16
|
+
log = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def lifespan(app: FastAPI):
|
|
20
|
+
config = Configuration()
|
|
21
|
+
set_connstr(config.get_connection_string())
|
|
22
|
+
|
|
23
|
+
discover_add_routes_from_entrypoint(app, "perfact.api")
|
|
24
|
+
|
|
25
|
+
add_403_to_openapi(app)
|
|
26
|
+
yield
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
app = FastAPI(title="PerFact API", lifespan=lifespan)
|
|
30
|
+
app.add_middleware(DBSessionMiddleware)
|
|
31
|
+
app.add_middleware(SameSitePostMiddleware)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.get("/user/roles")
|
|
35
|
+
async def roles(user: Auth, response: Response) -> list[str]:
|
|
36
|
+
"""
|
|
37
|
+
Return list of roles the user has. Returns Unauthorized if no user is found
|
|
38
|
+
"""
|
|
39
|
+
if user is None:
|
|
40
|
+
response.status_code = status.HTTP_401_UNAUTHORIZED
|
|
41
|
+
return []
|
|
42
|
+
return user.roles
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from argon2 import PasswordHasher
|
|
4
|
+
from fastapi import Depends, FastAPI, HTTPException, status
|
|
5
|
+
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
|
6
|
+
from pydantic_settings import BaseSettings
|
|
7
|
+
|
|
8
|
+
from .config import Configuration
|
|
9
|
+
from .dbsession import DBSessionMiddleware, set_connstr
|
|
10
|
+
from .utils import default_logging_settings, discover_add_routes_from_entrypoint
|
|
11
|
+
|
|
12
|
+
default_logging_settings()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
log = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Settings(BaseSettings):
|
|
19
|
+
pwhash: str = ""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
settings = Settings()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def lifespan(app: FastAPI):
|
|
26
|
+
config = Configuration()
|
|
27
|
+
set_connstr(config.get_connection_string())
|
|
28
|
+
settings.pwhash = config.get_basic_auth_credentials_secret()
|
|
29
|
+
|
|
30
|
+
discover_add_routes_from_entrypoint(app, "perfact.assignapi")
|
|
31
|
+
|
|
32
|
+
yield
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
basic_auth = HTTPBasic()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _verify_credentials(credentials: HTTPBasicCredentials = Depends(basic_auth)):
|
|
39
|
+
ph = PasswordHasher()
|
|
40
|
+
try:
|
|
41
|
+
ph.verify(settings.pwhash, credentials.password)
|
|
42
|
+
except Exception as _:
|
|
43
|
+
raise HTTPException(
|
|
44
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
45
|
+
detail="Incorrect username or password",
|
|
46
|
+
headers={"WWW-Authenticate": "Basic"},
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
app = FastAPI(
|
|
51
|
+
lifespan=lifespan,
|
|
52
|
+
title="PerFact API for assignd (task runner)",
|
|
53
|
+
dependencies=[Depends(_verify_credentials)],
|
|
54
|
+
)
|
|
55
|
+
app.add_middleware(DBSessionMiddleware)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@app.get("/test")
|
|
59
|
+
async def test() -> bool:
|
|
60
|
+
"""
|
|
61
|
+
Returns True if the authentification is valid; otherwise Unauthorized.
|
|
62
|
+
"""
|
|
63
|
+
return True
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import os
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from functools import wraps
|
|
5
|
+
from typing import Annotated, Callable, Optional, TypeVar
|
|
6
|
+
|
|
7
|
+
import argon2
|
|
8
|
+
from fastapi import Depends, Header, HTTPException, Query, Response, status
|
|
9
|
+
from fastapi.routing import APIRoute
|
|
10
|
+
from fastapi.security import APIKeyCookie, APIKeyHeader
|
|
11
|
+
from perfact.api.app.model import (
|
|
12
|
+
AppGroup,
|
|
13
|
+
AppPermXGroup,
|
|
14
|
+
AppPermXStc,
|
|
15
|
+
AppStc,
|
|
16
|
+
AppStc_Paths,
|
|
17
|
+
AppUser,
|
|
18
|
+
AppUserKey,
|
|
19
|
+
AppUserLogin,
|
|
20
|
+
AppUserXPerm,
|
|
21
|
+
AppUserXStc,
|
|
22
|
+
)
|
|
23
|
+
from pydantic import BaseModel, Field
|
|
24
|
+
from sqlalchemy import (
|
|
25
|
+
any_,
|
|
26
|
+
func,
|
|
27
|
+
not_,
|
|
28
|
+
or_,
|
|
29
|
+
select,
|
|
30
|
+
)
|
|
31
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
32
|
+
from starlette.responses import JSONResponse
|
|
33
|
+
|
|
34
|
+
from .dbsession import DBSession
|
|
35
|
+
from .perfact_generic import secret_check_sha1
|
|
36
|
+
|
|
37
|
+
COOKIE = "__user_cookie"
|
|
38
|
+
DUMMY_HASH = argon2.PasswordHasher().hash(os.urandom(16).hex())
|
|
39
|
+
|
|
40
|
+
LoginCookieDep = Annotated[
|
|
41
|
+
str,
|
|
42
|
+
Depends(
|
|
43
|
+
APIKeyCookie(name=COOKIE, description="Generated by /login", auto_error=False)
|
|
44
|
+
),
|
|
45
|
+
]
|
|
46
|
+
ApikeyHeaderDep = Annotated[
|
|
47
|
+
str,
|
|
48
|
+
Depends(APIKeyHeader(name="Authorization", auto_error=False)),
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ContextParams(BaseModel):
|
|
53
|
+
appstc_id: Optional[int] = Field(default=None, alias="__appstc_id")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
ContextParamsDep = Annotated[ContextParams, Query()]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _get_appperm_grant(
|
|
60
|
+
appperm_grant: Annotated[list[int] | None, Header(alias="x-appperm-grant")] = None,
|
|
61
|
+
) -> list[int]:
|
|
62
|
+
return appperm_grant or []
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
AppPermGrantDep = Annotated[list[int], Depends(_get_appperm_grant)]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class AuthInfo:
|
|
70
|
+
"""
|
|
71
|
+
Authentication information for the current user
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
name: str
|
|
75
|
+
roles: list[str]
|
|
76
|
+
appstc: Optional[AppStc]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _auth_apikey(session: DBSession, key: str) -> Optional[AppUser]:
|
|
80
|
+
"""
|
|
81
|
+
Split provided Apikey on first dash. The first part is an identifier so we only
|
|
82
|
+
check keys in the database that have the same initial part.
|
|
83
|
+
The rest is checked against the hashed value in the database.
|
|
84
|
+
TODO: Our database does not actually have Argon2 hashes yet, we need to
|
|
85
|
+
also check for ssha.
|
|
86
|
+
"""
|
|
87
|
+
if not key.startswith("Apikey "):
|
|
88
|
+
raise HTTPException(
|
|
89
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
90
|
+
detail="Invalid authorization header",
|
|
91
|
+
)
|
|
92
|
+
key = key.split()[1]
|
|
93
|
+
ident, key = key.split("-", 1)
|
|
94
|
+
candidates = session.execute(
|
|
95
|
+
select(AppUserKey, AppUser)
|
|
96
|
+
.where(func.regexp_match(AppUserKey.key, (ident + "-.*")).isnot(None))
|
|
97
|
+
.where(AppUserKey.appuser_id == AppUser.id)
|
|
98
|
+
)
|
|
99
|
+
hasher = argon2.PasswordHasher()
|
|
100
|
+
if not candidates:
|
|
101
|
+
try:
|
|
102
|
+
hasher.verify(DUMMY_HASH, key)
|
|
103
|
+
except argon2.exceptions.VerifyMismatchError:
|
|
104
|
+
pass
|
|
105
|
+
return None
|
|
106
|
+
for appuserkey, appuser in candidates:
|
|
107
|
+
_, encrypted = appuserkey.key.split("-", 1)
|
|
108
|
+
try:
|
|
109
|
+
hasher.verify(encrypted, key)
|
|
110
|
+
return appuser
|
|
111
|
+
except argon2.exceptions.VerifyMismatchError:
|
|
112
|
+
pass
|
|
113
|
+
except argon2.exceptions.InvalidHashError:
|
|
114
|
+
pass
|
|
115
|
+
if secret_check_sha1(encrypted, key):
|
|
116
|
+
return appuser
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _auth_cookie(
|
|
121
|
+
session: DBSession, cookie: str, response: Response
|
|
122
|
+
) -> Optional[AppUser]:
|
|
123
|
+
"""
|
|
124
|
+
Compare user cookie against appuserlogin_cookie and appuserlogin_nextcookie.
|
|
125
|
+
If the user sent nextcookie, rotate them (assuming that from now on the old
|
|
126
|
+
cookie will no longer be sent). In any case, set the cookie to be used in
|
|
127
|
+
the future in the response (there was something about browsers sometimes no
|
|
128
|
+
longer sending a cookie if we don't remind them with every request that
|
|
129
|
+
this cookie is to be set).
|
|
130
|
+
"""
|
|
131
|
+
row = session.execute(
|
|
132
|
+
select(AppUserLogin, AppUser)
|
|
133
|
+
.join_from(AppUserLogin, AppUser)
|
|
134
|
+
.where(
|
|
135
|
+
or_(
|
|
136
|
+
AppUserLogin.cookie == cookie,
|
|
137
|
+
AppUserLogin.nextcookie == cookie,
|
|
138
|
+
),
|
|
139
|
+
not_(AppUserLogin.done),
|
|
140
|
+
)
|
|
141
|
+
).first()
|
|
142
|
+
if not row:
|
|
143
|
+
return None
|
|
144
|
+
login, user = row
|
|
145
|
+
|
|
146
|
+
send_cookie = login.nextcookie or login.cookie
|
|
147
|
+
if cookie == login.nextcookie:
|
|
148
|
+
login.cookie = login.nextcookie
|
|
149
|
+
login.nextcookie = None
|
|
150
|
+
|
|
151
|
+
response.set_cookie(
|
|
152
|
+
key=COOKIE,
|
|
153
|
+
value=send_cookie,
|
|
154
|
+
httponly=True,
|
|
155
|
+
secure=True,
|
|
156
|
+
samesite="strict",
|
|
157
|
+
)
|
|
158
|
+
return user
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _process_auth(
|
|
162
|
+
session: DBSession,
|
|
163
|
+
cookie: LoginCookieDep,
|
|
164
|
+
apikey: ApikeyHeaderDep,
|
|
165
|
+
params: ContextParamsDep,
|
|
166
|
+
response: Response,
|
|
167
|
+
appperm_grant: AppPermGrantDep,
|
|
168
|
+
) -> Optional[AuthInfo]:
|
|
169
|
+
"""
|
|
170
|
+
Process authorization at the beginning of (essentially) every request.
|
|
171
|
+
(If a path does not depend on Auth, it is accessible anonymously)
|
|
172
|
+
1) Check for user using either a login cookie or an API key
|
|
173
|
+
2) Check appstc (organization area). If an appstc_id is provided, check that the
|
|
174
|
+
user is allowed to activate it. Otherwise select a default appstc
|
|
175
|
+
3) Check which roles the user has in this appstc
|
|
176
|
+
"""
|
|
177
|
+
appuser = None
|
|
178
|
+
if apikey is not None:
|
|
179
|
+
appuser = _auth_apikey(session, apikey)
|
|
180
|
+
elif cookie is not None:
|
|
181
|
+
appuser = _auth_cookie(session, cookie, response)
|
|
182
|
+
|
|
183
|
+
if not appuser:
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
# Check for appstc
|
|
187
|
+
appstc_id: int | None = params.appstc_id
|
|
188
|
+
if appstc_id:
|
|
189
|
+
if not session.execute(
|
|
190
|
+
select(
|
|
191
|
+
select(1)
|
|
192
|
+
.select_from(AppUserXStc)
|
|
193
|
+
.join(AppStc_Paths, AppUserXStc.appstc_id == any_(AppStc_Paths.id_path))
|
|
194
|
+
.where(AppStc_Paths.id == appstc_id)
|
|
195
|
+
.where(AppUserXStc.appuser_id == appuser.id)
|
|
196
|
+
.exists()
|
|
197
|
+
)
|
|
198
|
+
).scalar():
|
|
199
|
+
appstc_id = None
|
|
200
|
+
else:
|
|
201
|
+
# Find the "first" appstc for the user
|
|
202
|
+
appstc_id = session.execute(
|
|
203
|
+
select(AppUserXStc.appstc_id)
|
|
204
|
+
.join(AppStc_Paths, AppUserXStc.appstc_id == AppStc_Paths.id)
|
|
205
|
+
.where(AppUserXStc.appuser_id == appuser.id)
|
|
206
|
+
.order_by(AppStc_Paths.depth, AppStc_Paths.id_path)
|
|
207
|
+
.limit(1)
|
|
208
|
+
).scalar()
|
|
209
|
+
|
|
210
|
+
roles: list[str] = []
|
|
211
|
+
if appstc_id:
|
|
212
|
+
rows = session.execute(
|
|
213
|
+
select(func.array_agg(AppGroup.zoperole))
|
|
214
|
+
.join(AppPermXGroup, AppPermXGroup.appgroup_id == AppGroup.id)
|
|
215
|
+
.join(
|
|
216
|
+
AppUserXPerm,
|
|
217
|
+
(AppUserXPerm.appperm_id == AppPermXGroup.appperm_id)
|
|
218
|
+
& (AppUserXPerm.appuser_id == appuser.id)
|
|
219
|
+
& (
|
|
220
|
+
not_(AppUserXPerm.needsgrant)
|
|
221
|
+
| AppUserXPerm.appperm_id.in_(appperm_grant)
|
|
222
|
+
),
|
|
223
|
+
)
|
|
224
|
+
.join(AppPermXStc, AppPermXStc.appperm_id == AppPermXGroup.appperm_id)
|
|
225
|
+
.join(
|
|
226
|
+
AppStc_Paths,
|
|
227
|
+
(AppPermXStc.appstc_id == any_(AppStc_Paths.id_path))
|
|
228
|
+
& (AppStc_Paths.id == appstc_id),
|
|
229
|
+
)
|
|
230
|
+
).all()
|
|
231
|
+
roles = rows[0][0] or []
|
|
232
|
+
|
|
233
|
+
appstc = None
|
|
234
|
+
if appstc_id:
|
|
235
|
+
appstc = session.execute(
|
|
236
|
+
select(AppStc).where(AppStc.id == appstc_id)
|
|
237
|
+
).scalar_one()
|
|
238
|
+
response.headers["X-PerFact-stc"] = str(appstc.id)
|
|
239
|
+
|
|
240
|
+
result = AuthInfo(name=appuser.name, roles=roles, appstc=appstc)
|
|
241
|
+
# We commit here, so the authentication phase and the payload phase are
|
|
242
|
+
# done in separate transactions.
|
|
243
|
+
session.commit()
|
|
244
|
+
return result
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
Auth = Annotated[Optional[AuthInfo], Depends(_process_auth)]
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
_F = TypeVar("_F", bound=Callable[..., object])
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def require_roles(*roles: str) -> Callable[[_F], _F]:
|
|
254
|
+
"""
|
|
255
|
+
Decorator to check for given roles
|
|
256
|
+
"""
|
|
257
|
+
|
|
258
|
+
def require(required: tuple[str, ...]):
|
|
259
|
+
|
|
260
|
+
def checker(user: Auth):
|
|
261
|
+
# Check if all required scopes are present
|
|
262
|
+
roles = set() if user is None else user.roles
|
|
263
|
+
missing = set(required) - set(roles)
|
|
264
|
+
if missing:
|
|
265
|
+
raise HTTPException(
|
|
266
|
+
status_code=403,
|
|
267
|
+
detail={
|
|
268
|
+
"msg": "Missing required permissions",
|
|
269
|
+
"missing": list(missing),
|
|
270
|
+
},
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
return True
|
|
274
|
+
|
|
275
|
+
return checker
|
|
276
|
+
|
|
277
|
+
dep = Depends(require(roles))
|
|
278
|
+
|
|
279
|
+
def decorator(func: _F) -> _F:
|
|
280
|
+
sig = inspect.signature(func)
|
|
281
|
+
|
|
282
|
+
# create a new parameter for the dependency (keyword-only)
|
|
283
|
+
dep_param = inspect.Parameter(
|
|
284
|
+
"scope_check",
|
|
285
|
+
kind=inspect.Parameter.KEYWORD_ONLY,
|
|
286
|
+
annotation=Annotated[None, dep],
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
# build new signature: keep original params, append dep_param
|
|
290
|
+
params = list(sig.parameters.values()) + [dep_param]
|
|
291
|
+
new_sig = sig.replace(parameters=params)
|
|
292
|
+
|
|
293
|
+
@wraps(func)
|
|
294
|
+
async def wrapper(*args, **kwargs):
|
|
295
|
+
# remove scope_check if present before calling original func
|
|
296
|
+
kwargs.pop("scope_check", None)
|
|
297
|
+
return func(*args, **kwargs)
|
|
298
|
+
|
|
299
|
+
# attach the new signature so FastAPI sees the dependency
|
|
300
|
+
wrapper.__signature__ = new_sig # type: ignore
|
|
301
|
+
# Inject OpenAPI metadata
|
|
302
|
+
wrapper.__dict__["required_roles"] = roles
|
|
303
|
+
return wrapper # type: ignore
|
|
304
|
+
|
|
305
|
+
return decorator
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def add_403_to_openapi(app):
|
|
309
|
+
"""
|
|
310
|
+
Call this after all routes are added.
|
|
311
|
+
"""
|
|
312
|
+
for route in app.routes:
|
|
313
|
+
if isinstance(route, APIRoute):
|
|
314
|
+
endpoint = route.endpoint
|
|
315
|
+
roles = getattr(endpoint, "required_roles", None)
|
|
316
|
+
if roles:
|
|
317
|
+
# Add 403 response if not already present
|
|
318
|
+
if route.openapi_extra is None:
|
|
319
|
+
route.openapi_extra = {}
|
|
320
|
+
route.openapi_extra["x-required-roles"] = roles
|
|
321
|
+
route.responses.setdefault(
|
|
322
|
+
403,
|
|
323
|
+
{
|
|
324
|
+
"description": "Forbidden – missing required permissions",
|
|
325
|
+
"model": list[str],
|
|
326
|
+
},
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
class SameSitePostMiddleware(BaseHTTPMiddleware):
|
|
331
|
+
"""
|
|
332
|
+
Rejects POST/PUT/PATCH requests from browser clients where Sec-Fetch-Site
|
|
333
|
+
is present but not "same-origin". Non-browser clients (no Sec-Fetch-Site
|
|
334
|
+
header) are not subject to CSRF and are allowed through.
|
|
335
|
+
"""
|
|
336
|
+
|
|
337
|
+
async def dispatch(self, request, call_next):
|
|
338
|
+
if request.method in ("POST", "PUT", "PATCH"):
|
|
339
|
+
sec_fetch_site = request.headers.get("sec-fetch-site")
|
|
340
|
+
if sec_fetch_site is not None and sec_fetch_site != "same-origin":
|
|
341
|
+
return JSONResponse(
|
|
342
|
+
status_code=401,
|
|
343
|
+
content={"detail": f"Unauthorized: cross-site {request.method}"},
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
return await call_next(request)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
import yaml
|
|
5
|
+
|
|
6
|
+
log = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Configuration:
|
|
10
|
+
def __init__(self):
|
|
11
|
+
"""
|
|
12
|
+
Reads environment variable for path to
|
|
13
|
+
config and reads the configuration file from there.
|
|
14
|
+
"""
|
|
15
|
+
config_path = os.environ.get("PERFACT_API_CONFIG_PATH")
|
|
16
|
+
if not config_path:
|
|
17
|
+
log.warning("No configuration file detected. Use default configuration.")
|
|
18
|
+
self.config = {}
|
|
19
|
+
return
|
|
20
|
+
log.info(f"Use configuration from file: {config_path}")
|
|
21
|
+
with open(config_path) as f:
|
|
22
|
+
self.config = yaml.safe_load(f)
|
|
23
|
+
|
|
24
|
+
def get_connection_string(self) -> str | None:
|
|
25
|
+
"""returns the connection string if configured"""
|
|
26
|
+
if "connstr" not in self.config:
|
|
27
|
+
return None
|
|
28
|
+
return self.config["connstr"]
|
|
29
|
+
|
|
30
|
+
def get_basic_auth_credentials_secret(self) -> str:
|
|
31
|
+
"""returns the auth hash if configured"""
|
|
32
|
+
if "basicauth" not in self.config:
|
|
33
|
+
raise RuntimeError("No auth hash configured!")
|
|
34
|
+
return self.config["basicauth"]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from typing import Annotated
|
|
2
|
+
|
|
3
|
+
from fastapi import Depends, Request, Response
|
|
4
|
+
from pydantic_settings import BaseSettings
|
|
5
|
+
from sqlalchemy import create_engine
|
|
6
|
+
from sqlalchemy.orm import Session
|
|
7
|
+
from sqlalchemy.pool import NullPool
|
|
8
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Settings(BaseSettings):
|
|
12
|
+
connstr: str = "postgresql+psycopg://zope@/perfactema"
|
|
13
|
+
sql_debug: bool = False
|
|
14
|
+
pooling: bool = True # Set to False for testing
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
settings = Settings()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def set_connstr(connstr):
|
|
21
|
+
"""
|
|
22
|
+
To be called before the app starts up,
|
|
23
|
+
set the connection string from there.
|
|
24
|
+
"""
|
|
25
|
+
if connstr:
|
|
26
|
+
settings.connstr = connstr
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class DBSessionMiddleware(BaseHTTPMiddleware):
|
|
30
|
+
"""
|
|
31
|
+
Start a DB session for each request. Commit it at the end if there is no error,
|
|
32
|
+
otherwise roll back and return a generic 500 error. Note that this does not mean
|
|
33
|
+
that a request is not allowed to do its own commits in between.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
async def dispatch(self, request, call_next):
|
|
37
|
+
if not hasattr(self, "engine"):
|
|
38
|
+
self.engine = create_engine(
|
|
39
|
+
settings.connstr,
|
|
40
|
+
pool_pre_ping=True,
|
|
41
|
+
echo=settings.sql_debug,
|
|
42
|
+
poolclass=NullPool if not settings.pooling else None,
|
|
43
|
+
)
|
|
44
|
+
response = Response("Internal server error", status_code=500)
|
|
45
|
+
try:
|
|
46
|
+
request.state.db = Session(self.engine)
|
|
47
|
+
response = await call_next(request)
|
|
48
|
+
request.state.db.commit()
|
|
49
|
+
except Exception:
|
|
50
|
+
request.state.db.rollback()
|
|
51
|
+
raise
|
|
52
|
+
finally:
|
|
53
|
+
request.state.db.close()
|
|
54
|
+
return response
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _get_session(request: Request):
|
|
58
|
+
return request.state.db
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
DBSession = Annotated[Session, Depends(_get_session)]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import hashlib
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
This file is duplicated code from https://git.perfact.de/DebianPackages/python-perfact/src/branch/master/perfact/generic.py
|
|
6
|
+
|
|
7
|
+
There is no public available for this code;
|
|
8
|
+
as soon as it exists we can switch to there and
|
|
9
|
+
add the lib as a dependency to this package.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def to_bytes(value, enc="utf-8"):
|
|
14
|
+
"""This method delivers bytes (encoded strings)."""
|
|
15
|
+
if isinstance(value, memoryview):
|
|
16
|
+
return value.tobytes()
|
|
17
|
+
if isinstance(value, bytes):
|
|
18
|
+
return value
|
|
19
|
+
if isinstance(value, str):
|
|
20
|
+
return value.encode(enc)
|
|
21
|
+
try:
|
|
22
|
+
return to_bytes(str(value))
|
|
23
|
+
except Exception:
|
|
24
|
+
raise ValueError("could not convert '%s' to bytes!" % str((value,)))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def secret_check_sha1(encrypted, secret):
|
|
28
|
+
"""Check a secret against its encrypted form.
|
|
29
|
+
|
|
30
|
+
>>> secret_check_sha1('{SHA}dYXR9865D9Cxq0LQpso5/PVQZcc=', 'my_secret')
|
|
31
|
+
True
|
|
32
|
+
>>> secret_check_sha1(
|
|
33
|
+
... '{SSHA}PtXj4zEBsS0Rxz55sW+USwQizCZy4prJ', 'my_secret')
|
|
34
|
+
True
|
|
35
|
+
>>> secret_check_sha1('{SHA}XXXX9865D9Cxq0LQpso5/PVQZcc=', 'my_secret')
|
|
36
|
+
False
|
|
37
|
+
"""
|
|
38
|
+
encrypted = to_bytes(encrypted)
|
|
39
|
+
secret = to_bytes(secret)
|
|
40
|
+
encoded = encrypted[encrypted.find(b"}") + 1 :]
|
|
41
|
+
challenge_bytes = base64.urlsafe_b64decode(encoded)
|
|
42
|
+
digest = challenge_bytes[:20]
|
|
43
|
+
hr = hashlib.sha1(secret) # nosec B324
|
|
44
|
+
if len(challenge_bytes) > 20:
|
|
45
|
+
salt = challenge_bytes[20:]
|
|
46
|
+
hr.update(salt)
|
|
47
|
+
return digest == hr.digest()
|
|
File without changes
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from importlib.metadata import entry_points
|
|
3
|
+
|
|
4
|
+
from fastapi import FastAPI
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def discover_add_routes_from_entrypoint(app: FastAPI, entrypoint_group="perfact.api"):
|
|
8
|
+
"""
|
|
9
|
+
Add all routes discovered for the given entrypoint to the given FastAPI-application
|
|
10
|
+
"""
|
|
11
|
+
log = logging.getLogger("perfact.api.main.discovery")
|
|
12
|
+
|
|
13
|
+
log.info("start plugin discovery")
|
|
14
|
+
plugin_count = 0
|
|
15
|
+
for plugin in entry_points(group=entrypoint_group):
|
|
16
|
+
log.info(f"try to include plugin: {plugin.value}")
|
|
17
|
+
try:
|
|
18
|
+
plugin.load()(app)
|
|
19
|
+
plugin_count += 1
|
|
20
|
+
except Exception:
|
|
21
|
+
log.exception(f"failed to include plugin {plugin.value}")
|
|
22
|
+
log.info(f"finished plugin discovery, included {plugin_count} plugins")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def default_logging_settings():
|
|
26
|
+
logging.basicConfig(
|
|
27
|
+
level=logging.INFO,
|
|
28
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
29
|
+
datefmt="%Y-%m-%dT%H:%M:%S%z",
|
|
30
|
+
)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: perfact-api-main
|
|
3
|
+
Version: 0.2
|
|
4
|
+
Summary: PerFact API - FastAPI main package (middleware, auth, entrypoints)
|
|
5
|
+
Author-email: Viktor Dick <viktor.dick@perfact.de>
|
|
6
|
+
License-Expression: GPL-2.0-or-later
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Programming Language :: SQL
|
|
9
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: argon2-cffi
|
|
13
|
+
Requires-Dist: psycopg[c]
|
|
14
|
+
Requires-Dist: fastapi[standard-no-fastapi-cloud-cli]
|
|
15
|
+
Requires-Dist: sqlalchemy
|
|
16
|
+
Requires-Dist: pydantic-settings
|
|
17
|
+
Requires-Dist: perfact-api-app-model
|
|
18
|
+
|
|
19
|
+
# PerFact API Base
|
|
20
|
+
The main FastAPI package for the PerFact API. Provides authentication, authorisation, database session handling, and plugin discovery. All domain-specific API modules are in separate packages and loaded automatically at startup via entry points.
|
|
21
|
+
|
|
22
|
+
## Applications
|
|
23
|
+
|
|
24
|
+
### `perfact.api.main.app` — user-facing API
|
|
25
|
+
|
|
26
|
+
Hosts all plugins registered under the `perfact.api` entry point group. Users authenticate via a login cookie or an `Authorization: Apikey …` header. Access to individual endpoints can be restricted by role:
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from perfact.api.main.auth import require_roles
|
|
30
|
+
|
|
31
|
+
@router.get("/my-endpoint")
|
|
32
|
+
@require_roles("MyRole")
|
|
33
|
+
def my_endpoint(session: DBSession) -> ...:
|
|
34
|
+
...
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### `perfact.api.main.assignworker` — task runner API
|
|
38
|
+
|
|
39
|
+
Hosts all plugins registered under the `perfact.assignapi` entry point group. Intended for internal use by `assignd`. Authentication is HTTP Basic Auth with a pre-shared password hash configured in the YAML config file.
|
|
40
|
+
|
|
41
|
+
## Plugin discovery
|
|
42
|
+
|
|
43
|
+
At startup, both apps iterate over their respective entry point group and call `mount(app)` on each discovered plugin:
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
INFO - start plugin discovery
|
|
47
|
+
INFO - try to include plugin: perfact.api.pd.routes:mount
|
|
48
|
+
INFO - finished discovery and include: 1 plugins
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
A plugin registers itself by declaring an entry point in its `pyproject.toml`:
|
|
52
|
+
|
|
53
|
+
```toml
|
|
54
|
+
[project.entry-points.'perfact.api']
|
|
55
|
+
myplugin = 'perfact.api.myplugin.routes:mount'
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## getting started as developer
|
|
59
|
+
After you checked out this repository, do the following steps to be able to debug your application:
|
|
60
|
+
1. create and activate *venv*
|
|
61
|
+
```sh
|
|
62
|
+
python -m venv .venv
|
|
63
|
+
|
|
64
|
+
source .venv/bin/activate # linux
|
|
65
|
+
.venv/Scripts/Activate.ps1 # PowerShell
|
|
66
|
+
```
|
|
67
|
+
1. Install the API plugins you want to include without doing changes by installing them from pypi:
|
|
68
|
+
```sh
|
|
69
|
+
pip install perfact-api-base-model
|
|
70
|
+
```
|
|
71
|
+
1. Install the API plugins you want to include and **change** in your instance by checking them out somewhere and installing/linking them into your application via `pip install -e`:
|
|
72
|
+
```sh
|
|
73
|
+
pip install -e ../perfact-api-base-model/ # required
|
|
74
|
+
pip install -e ../perfact-api-app-model/ # required
|
|
75
|
+
pip install -e ../perfact-api-pd-model/ # example
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
**Hint**: Every code change is available in the application after the next restart/reload. Changes to the entrypoints are available after the next install command execution (as this is recreating the `ENTRYPOINTS`-file belonging to the package in the site-packages folder).
|
|
79
|
+
|
|
80
|
+
You can install as much as plugins you like, even custom ones.
|
|
81
|
+
|
|
82
|
+
*pip* will automaticly check if more dependencies needed by the plugin while installing, e.g.
|
|
83
|
+
```
|
|
84
|
+
Requirement already satisfied: fastapi
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
Now you can run the application:
|
|
89
|
+
```
|
|
90
|
+
uvicorn perfact.api.main.app:app # run API for frontend
|
|
91
|
+
uvicorn perfact.api.main.assignworker:app # run API for assignd worker APIs
|
|
92
|
+
uvicorn perfact.api.main.app:app --reload --reload-include ../ # run API for frontend with auto-reload for all editable dependencies
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
There is a `launch.json`-file provided to debug the application in VS Code. To use that, you have to create a configuration file (see below).
|
|
96
|
+
|
|
97
|
+
## Configuration
|
|
98
|
+
The application can be configured by providing a configuration yaml-File containing informations about the database connection and basic auth credentials (for `assignworker`).
|
|
99
|
+
The configuration file is given to the application by providing the location (absolute or relative to working dir) in the environment variable `PERFACT_API_CONFIG_PATH`.
|
|
100
|
+
|
|
101
|
+
Example:
|
|
102
|
+
```yaml
|
|
103
|
+
connstr: postgresql+psycopg://zope@/perfactema # default, can be left out
|
|
104
|
+
basicauth: "$argon2id$v=19$m=65536,t=3,p=4$6itEouPTNwWEXDKScKmMrw$NwhN1vAUN8EZjC62HrtXjx7n+K1Mujjd/QqioaHvyOg" # password=test
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Dependencies
|
|
108
|
+
- `perfact-api-app-model`
|
|
109
|
+
- `fastapi`
|
|
110
|
+
- `sqlalchemy`
|
|
111
|
+
- `psycopg[c]`
|
|
112
|
+
- `argon2-cffi`
|
|
113
|
+
- `pydantic-settings`
|
|
114
|
+
|
|
115
|
+
## Maintainers
|
|
116
|
+
- Viktor Dick <viktor.dick@perfact.de>
|
|
117
|
+
- Alexander Rolfes <alexander.rolfes@perfact.de>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
.gitignore
|
|
2
|
+
README.md
|
|
3
|
+
bandit.yml
|
|
4
|
+
pyproject.toml
|
|
5
|
+
tox.ini
|
|
6
|
+
.gitea/workflows/check.yml
|
|
7
|
+
.vscode/launch.json
|
|
8
|
+
.vscode/settings.json
|
|
9
|
+
src/perfact/api/main/__init__.py
|
|
10
|
+
src/perfact/api/main/app.py
|
|
11
|
+
src/perfact/api/main/assignworker.py
|
|
12
|
+
src/perfact/api/main/auth.py
|
|
13
|
+
src/perfact/api/main/config.py
|
|
14
|
+
src/perfact/api/main/dbsession.py
|
|
15
|
+
src/perfact/api/main/perfact_generic.py
|
|
16
|
+
src/perfact/api/main/py.typed
|
|
17
|
+
src/perfact/api/main/utils.py
|
|
18
|
+
src/perfact_api_main.egg-info/PKG-INFO
|
|
19
|
+
src/perfact_api_main.egg-info/SOURCES.txt
|
|
20
|
+
src/perfact_api_main.egg-info/dependency_links.txt
|
|
21
|
+
src/perfact_api_main.egg-info/requires.txt
|
|
22
|
+
src/perfact_api_main.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
perfact
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[tox]
|
|
2
|
+
envlist = py3
|
|
3
|
+
isolated_build = true
|
|
4
|
+
|
|
5
|
+
[pytest]
|
|
6
|
+
|
|
7
|
+
[testenv]
|
|
8
|
+
passenv = SSH_AUTH_SOCK, PYTHONPATH, HTTP_PROXY, HTTPS_PROXY
|
|
9
|
+
setenv =
|
|
10
|
+
GIT_SSH_VARIANT=ssh
|
|
11
|
+
GIT_SSH_COMMAND=ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no
|
|
12
|
+
|
|
13
|
+
deps =
|
|
14
|
+
ruff
|
|
15
|
+
pytest
|
|
16
|
+
coverage
|
|
17
|
+
psycopg[c]
|
|
18
|
+
pytest-postgresql
|
|
19
|
+
pytest-cov
|
|
20
|
+
pytest-typing
|
|
21
|
+
bandit
|
|
22
|
+
mypy
|
|
23
|
+
types-pyyaml
|
|
24
|
+
perfact-api-base-model @ git+ssh://git@git.perfact.de:3022/PythonPackages/perfact-api-base-model
|
|
25
|
+
perfact-api-app-model @ git+ssh://git@git.perfact.de:3022/PythonPackages/perfact-api-app-model
|
|
26
|
+
|
|
27
|
+
commands =
|
|
28
|
+
ruff format --check
|
|
29
|
+
ruff check
|
|
30
|
+
bandit --configfile bandit.yml -r src
|
|
31
|
+
mypy src
|
|
32
|
+
# pytest --doctest-modules --cov-branch --cov=src --cov-report=term-missing {posargs:src}
|