perfact-api-main 0.2__py2.py3-none-any.whl

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.
@@ -0,0 +1,4 @@
1
+ from .auth import Auth, require_roles
2
+ from .dbsession import DBSession
3
+
4
+ __all__ = ["require_roles", "Auth", "DBSession"]
@@ -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,13 @@
1
+ perfact/api/main/__init__.py,sha256=1W4JZgpLYRajUUHEBM2KGPaSCL6Hd9esVEEMg90UiQA,121
2
+ perfact/api/main/app.py,sha256=8d6qFtS5dWCF2SGDZ359mhJU2X_WWhHA9G-NNsSV2aI,1032
3
+ perfact/api/main/assignworker.py,sha256=FnpbPOiJCOZgj5Ex4frxRKFHiQ9ppmMgRNV7fsvT-PY,1540
4
+ perfact/api/main/auth.py,sha256=Ldt4h0gple17DeeyVt-wa1rAlI0HMYZRN_5gS4oU_wY,10799
5
+ perfact/api/main/config.py,sha256=rFIJbRUFbqfOpolpfgWgPNBGefjc0-WDD1btVIZgdA8,1084
6
+ perfact/api/main/dbsession.py,sha256=QQv7FlGjnD95iHqcTq1HR1SWqRJlRWNQ0GK27GVszdk,1796
7
+ perfact/api/main/perfact_generic.py,sha256=GFzbwcwOcy9hrut16Ko6ca5t6Mb8vO4ZnVgDUnRvuI0,1478
8
+ perfact/api/main/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ perfact/api/main/utils.py,sha256=t1rIg1PQubS3CCpfr2NBsYJLYQApSDow3laqS293ZrM,964
10
+ perfact_api_main-0.2.dist-info/METADATA,sha256=xZLY9JjbMCUs5K2FQeVBhsWDis1f3KMLi1Lao8HFTL4,4636
11
+ perfact_api_main-0.2.dist-info/WHEEL,sha256=TdQ5LtNwLuxTCjgxN51AgdU5w-KkB9ttmLbzjTH02pg,109
12
+ perfact_api_main-0.2.dist-info/top_level.txt,sha256=1odO3B1JiDF2Lqgnop8k7K4Xs1y_LdwehM53l1NDOnc,8
13
+ perfact_api_main-0.2.dist-info/RECORD,,
@@ -0,0 +1,6 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py2-none-any
5
+ Tag: py3-none-any
6
+
@@ -0,0 +1 @@
1
+ perfact