python-general-be-lib 0.11.3__py3-none-any.whl → 0.12.1__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,68 @@
1
+ """Authentication against a Keycloak realm, shared by the backends.
2
+
3
+ ``KeycloakAuth`` verifies the access tokens of a realm — signature through the keys of its
4
+ JWKS endpoint, expiry, audience, issuer — and exposes the FastAPI dependencies built on
5
+ it; ``AuthContextMiddleware`` verifies the token once per request and publishes the user
6
+ and the claims in ``general.context``. It needs the ``[auth]`` extra (``pyjwt[crypto]``).
7
+
8
+ The public symbols are resolved lazily (PEP 562): importing this package does not import
9
+ the modules behind them, so reaching one of them does not pay for the heavier siblings.
10
+ The ``TYPE_CHECKING`` block is what keeps the static types — without it a type checker
11
+ would see every symbol as ``Any``, silently, and the ``py.typed`` marker would promise
12
+ types the consumer does not get.
13
+ """
14
+ from typing import TYPE_CHECKING
15
+
16
+ __all__ = [
17
+ "AuthContextMiddleware",
18
+ "JWTModel",
19
+ "RealmAccessModel",
20
+ "JwksKeyProvider",
21
+ "KeycloakAuth",
22
+ "RoleChecker",
23
+ ]
24
+
25
+ if TYPE_CHECKING: # i type checker leggono da qui, il runtime no
26
+ from .jwt_model import JWTModel, RealmAccessModel
27
+ from .key_provider import JwksKeyProvider
28
+ from .keycloak_auth import KeycloakAuth, RoleChecker
29
+ from .middleware import AuthContextMiddleware
30
+
31
+ _MODULES = {
32
+ "JWTModel": ".jwt_model",
33
+ "RealmAccessModel": ".jwt_model",
34
+ "JwksKeyProvider": ".key_provider",
35
+ "KeycloakAuth": ".keycloak_auth",
36
+ "RoleChecker": ".keycloak_auth",
37
+ "AuthContextMiddleware": ".middleware",
38
+ }
39
+
40
+
41
+ def __getattr__(name: str):
42
+ """Resolve a public symbol, importing its module on first access.
43
+
44
+ Args:
45
+ name (str): Name of the attribute being read.
46
+
47
+ Returns:
48
+ Any: The requested symbol.
49
+
50
+ Raises:
51
+ AttributeError: If the name is not part of the public surface.
52
+ """
53
+ module = _MODULES.get(name)
54
+ if module is None:
55
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
56
+
57
+ from importlib import import_module
58
+
59
+ return getattr(import_module(module, __name__), name)
60
+
61
+
62
+ def __dir__() -> list[str]:
63
+ """List the public surface without importing anything.
64
+
65
+ Returns:
66
+ list[str]: The names in ``__all__``.
67
+ """
68
+ return list(__all__)
@@ -0,0 +1,92 @@
1
+ """Pydantic models of the claims of a Keycloak access token.
2
+
3
+ ``JWTModel`` declares the standard claims Keycloak puts in an access token. The claims a
4
+ realm adds through its own mappers (``user_id``, ``partner_id``, …) do not belong here:
5
+ each backend declares them in a subclass and passes it to ``KeycloakAuth`` as ``model``.
6
+ """
7
+
8
+ __all__ = ["JWTModel", "RealmAccessModel"]
9
+
10
+ from typing import Optional
11
+
12
+ from pydantic import BaseModel, ConfigDict
13
+
14
+
15
+ class RealmAccessModel(BaseModel):
16
+ """Roles granted to the user, in the shape Keycloak uses for both realm and client roles.
17
+
18
+ Attributes:
19
+ roles (list[str]): Names of the roles. Empty when the claim carries none.
20
+ """
21
+ roles: list[str] = []
22
+
23
+
24
+ class JWTModel(BaseModel):
25
+ """Standard claims of a Keycloak access token.
26
+
27
+ Every claim is optional, so a token lacking one validates anyway, and the unknown ones
28
+ are ignored: a realm mapper added after deployment does not break the validation. To
29
+ read a custom claim, declare it in a subclass.
30
+
31
+ Attributes:
32
+ aud (str | list[str], optional): Audience. Keycloak emits a string with one
33
+ audience and a list with more than one.
34
+ sub (str, optional): Identifier of the user in the realm.
35
+ realm_access (RealmAccessModel, optional): Realm roles of the user.
36
+ resource_access (dict[str, RealmAccessModel], optional): Client roles of the user,
37
+ keyed by client id.
38
+ """
39
+ model_config = ConfigDict(extra="ignore")
40
+
41
+ exp: Optional[int] = None
42
+ iat: Optional[int] = None
43
+ jti: Optional[str] = None
44
+ iss: Optional[str] = None
45
+ aud: Optional[str | list[str]] = None
46
+ sub: Optional[str] = None
47
+ typ: Optional[str] = None
48
+ azp: Optional[str] = None
49
+ session_state: Optional[str] = None
50
+ acr: Optional[str] = None
51
+ realm_access: Optional[RealmAccessModel] = None
52
+ resource_access: Optional[dict[str, RealmAccessModel]] = None
53
+ scope: Optional[str] = None
54
+ sid: Optional[str] = None
55
+ email_verified: Optional[bool] = None
56
+ name: Optional[str] = None
57
+ preferred_username: Optional[str] = None
58
+ given_name: Optional[str] = None
59
+ family_name: Optional[str] = None
60
+ email: Optional[str] = None
61
+
62
+ def roles_of(self, client: Optional[str] = None) -> list[str]:
63
+ """Return the roles of the user, empty when the token carries none.
64
+
65
+ Args:
66
+ client (str, optional): Client id whose roles are read from
67
+ ``resource_access``. When omitted, the realm roles are read from
68
+ ``realm_access``.
69
+
70
+ Returns:
71
+ list[str]: The roles, never ``None``.
72
+ """
73
+ if client is None:
74
+ return self.realm_access.roles if self.realm_access is not None else []
75
+ access = (self.resource_access or {}).get(client)
76
+ return access.roles if access is not None else []
77
+
78
+ def has_role(self, role: str, client: Optional[str] = None) -> bool:
79
+ """Tell whether the user holds a role.
80
+
81
+ Unlike ``role in jwt.realm_access.roles`` it does not raise when the token carries
82
+ no roles at all.
83
+
84
+ Args:
85
+ role (str): Name of the role.
86
+ client (str, optional): Client id the role belongs to. When omitted, the role
87
+ is looked up among the realm roles.
88
+
89
+ Returns:
90
+ bool: ``True`` if the user holds the role.
91
+ """
92
+ return role in self.roles_of(client)
@@ -0,0 +1,203 @@
1
+ """Signing keys of a Keycloak realm, read from its JWKS endpoint and cached in memory.
2
+
3
+ The JWKS endpoint (``/protocol/openid-connect/certs``) publishes every active key of the
4
+ realm with its ``kid``, and the token header names the one it was signed with: during a
5
+ key rotation both keys are published, so no token is rejected while it happens. The
6
+ ``public_key`` field of the realm endpoint, read by the handlers this module replaces,
7
+ carries one key only.
8
+ """
9
+
10
+ __all__ = ["JwksKeyProvider"]
11
+
12
+ import json
13
+ import logging
14
+ import threading
15
+ import time
16
+ from typing import Any, Callable, Optional
17
+ from urllib.request import urlopen
18
+
19
+ try:
20
+ from jwt import PyJWK, PyJWKSet
21
+ except ImportError as e: # pragma: no cover - dipende dagli extra installati
22
+ raise ImportError("general.auth needs the [auth] extra: pip install 'python-general-be-lib[auth]'") from e
23
+
24
+ from ..exception.crud_exceptions import ServiceUnavailableException
25
+
26
+
27
+ class JwksKeyProvider:
28
+ """Thread-safe, in-memory cache of the signing keys of a realm.
29
+
30
+ The keys are fetched on the first request, and fetched again when the cache is older
31
+ than ``ttl`` or a token names a ``kid`` the cache does not know, which is how a key
32
+ rotation is picked up. Every fetch, forced ones included, is **rate limited** to one per
33
+ ``min_refresh_interval``: a token naming a made-up ``kid`` cannot turn each request
34
+ into a call to Keycloak. A failed fetch keeps the keys already cached, so an
35
+ unreachable Keycloak does not reject tokens it signed before going down.
36
+
37
+ Attributes:
38
+ jwks_url (str): URL of the JWKS endpoint.
39
+ ttl (float): Seconds after which the cached keys are fetched again.
40
+ min_refresh_interval (float): Minimum seconds between two fetch attempts.
41
+ timeout (float): Timeout in seconds of the HTTP request.
42
+ """
43
+ __slots__ = (
44
+ "jwks_url",
45
+ "ttl",
46
+ "min_refresh_interval",
47
+ "timeout",
48
+ "_fetch",
49
+ "_lock",
50
+ "_keys",
51
+ "_fetched_at",
52
+ "_last_attempt",
53
+ )
54
+
55
+ def __init__(
56
+ self,
57
+ jwks_url: str,
58
+ ttl: float = 3600,
59
+ min_refresh_interval: float = 30,
60
+ timeout: float = 5,
61
+ fetch: Optional[Callable[[], dict[str, Any]]] = None,
62
+ ):
63
+ """Configure the provider. Nothing is fetched until the first key is requested.
64
+
65
+ Args:
66
+ jwks_url (str): URL of the JWKS endpoint, ``http`` or ``https``.
67
+ ttl (float): Seconds after which the cached keys are fetched again.
68
+ Default: ``3600``.
69
+ min_refresh_interval (float): Minimum seconds between two fetch attempts.
70
+ Default: ``30``.
71
+ timeout (float): Timeout in seconds of the HTTP request. Default: ``5``.
72
+ fetch (Callable[[], dict], optional): Function returning the JWKS document,
73
+ in place of the HTTP request to ``jwks_url``.
74
+
75
+ Raises:
76
+ ValueError: If ``jwks_url`` is not an ``http(s)`` URL.
77
+ """
78
+ if not isinstance(jwks_url, str) or not jwks_url.startswith(("http://", "https://")):
79
+ raise ValueError(f"JwksKeyProvider: jwks_url must be an http(s) URL, got {jwks_url!r}")
80
+ self.jwks_url = jwks_url
81
+ self.ttl = ttl
82
+ self.min_refresh_interval = min_refresh_interval
83
+ self.timeout = timeout
84
+ self._fetch = fetch if fetch is not None else self._fetch_jwks
85
+ self._lock = threading.Lock()
86
+ self._keys: dict[Optional[str], PyJWK] = {}
87
+ self._fetched_at: Optional[float] = None
88
+ self._last_attempt: Optional[float] = None
89
+
90
+ def needs_fetch(self, kid: Optional[str]) -> bool:
91
+ """Tell whether resolving ``kid`` now would make an HTTP request.
92
+
93
+ It lets an async caller keep the common case, a fresh key already cached, on the
94
+ event loop, and move to a worker thread only the calls that would block on the
95
+ network.
96
+
97
+ Args:
98
+ kid (str, optional): Key id read from the token header.
99
+
100
+ Returns:
101
+ bool: ``True`` if ``get_key`` would fetch the JWKS.
102
+ """
103
+ now = time.monotonic()
104
+ fresh = self._fetched_at is not None and now - self._fetched_at < self.ttl
105
+ if fresh and self._select(kid) is not None:
106
+ return False
107
+ return self._last_attempt is None or now - self._last_attempt >= self.min_refresh_interval
108
+
109
+ def get_key(self, kid: Optional[str]) -> Optional[PyJWK]:
110
+ """Return the signing key named by ``kid``, fetching the JWKS when needed.
111
+
112
+ Args:
113
+ kid (str, optional): Key id read from the token header. A token without one is
114
+ accepted only while the realm publishes a single signing key.
115
+
116
+ Returns:
117
+ PyJWK | None: The key, or ``None`` if the realm does not publish it.
118
+
119
+ Raises:
120
+ ServiceUnavailableException: If no key has ever been fetched, because the
121
+ JWKS endpoint is unreachable.
122
+ """
123
+ if self.needs_fetch(kid):
124
+ with self._lock:
125
+ # ricontrollato sotto lock: un altro thread può aver appena fatto il fetch
126
+ if self.needs_fetch(kid):
127
+ self._refresh()
128
+ if not self._keys:
129
+ raise ServiceUnavailableException()
130
+ return self._select(kid)
131
+
132
+ def refresh(self) -> bool:
133
+ """Fetch the JWKS now, ignoring both the TTL and the rate limit.
134
+
135
+ Meant for the startup, to fill the cache before the first request.
136
+
137
+ Returns:
138
+ bool: ``True`` if the fetch succeeded. On failure the keys already cached are
139
+ kept.
140
+ """
141
+ with self._lock:
142
+ return self._refresh()
143
+
144
+ def _refresh(self) -> bool:
145
+ """Fetch and parse the JWKS, replacing the cache only on success. Call under lock.
146
+
147
+ Returns:
148
+ bool: ``True`` if the fetch succeeded.
149
+ """
150
+ # `_last_attempt` si scrive a tentativo **concluso**: scritto prima, chi arriva durante
151
+ # il primo fetch vedrebbe il rate limit attivo e la cache vuota, e avrebbe un 503
152
+ # invece di aspettare sul lock l'esito del fetch in corso
153
+ try:
154
+ keys = self._parse(self._fetch())
155
+ except Exception as e:
156
+ self._last_attempt = time.monotonic()
157
+ logging.warning(f"JWKS fetch from {self.jwks_url} failed - {e!r}")
158
+ return False
159
+ self._keys = keys
160
+ self._fetched_at = self._last_attempt = time.monotonic()
161
+ return True
162
+
163
+ def _select(self, kid: Optional[str]) -> Optional[PyJWK]:
164
+ """Look ``kid`` up in the cache, without fetching.
165
+
166
+ Args:
167
+ kid (str, optional): Key id read from the token header.
168
+
169
+ Returns:
170
+ PyJWK | None: The cached key, or ``None`` if it is not there.
171
+ """
172
+ keys = self._keys
173
+ if kid is None:
174
+ return next(iter(keys.values())) if len(keys) == 1 else None
175
+ return keys.get(kid)
176
+
177
+ def _fetch_jwks(self) -> dict[str, Any]:
178
+ """Download the JWKS document from ``jwks_url``.
179
+
180
+ Returns:
181
+ dict[str, Any]: The parsed JSON document.
182
+ """
183
+ with urlopen(self.jwks_url, timeout=self.timeout) as response:
184
+ return json.load(response)
185
+
186
+ @staticmethod
187
+ def _parse(jwks: dict[str, Any]) -> dict[Optional[str], PyJWK]:
188
+ """Keep the signing keys of a JWKS document, keyed by ``kid``.
189
+
190
+ Keycloak publishes the encryption keys (``"use": "enc"``) at the same endpoint;
191
+ they cannot verify a signature, so they are left out.
192
+
193
+ Args:
194
+ jwks (dict[str, Any]): The JWKS document.
195
+
196
+ Returns:
197
+ dict[str | None, PyJWK]: The signing keys.
198
+
199
+ Raises:
200
+ PyJWKSetError: If the document holds no usable signing key.
201
+ """
202
+ signing = [key for key in jwks.get("keys", []) if key.get("use", "sig") == "sig"]
203
+ return {key.key_id: key for key in PyJWKSet.from_dict({"keys": signing}).keys}
@@ -0,0 +1,376 @@
1
+ """Verification of Keycloak access tokens and the FastAPI dependencies built on it.
2
+
3
+ ``KeycloakAuth`` holds the configuration of one backend — realm, audience, claims model —
4
+ and exposes what the backends used to copy in their own ``jwt_handler.py``: the bearer
5
+ scheme, the dependency returning the verified claims, the role checks. ``RoleChecker`` is
6
+ the role check alone, callable by hand where a dependency does not fit.
7
+ """
8
+
9
+ __all__ = ["KeycloakAuth", "RoleChecker"]
10
+
11
+ from contextvars import ContextVar
12
+ from typing import Annotated, Any, Awaitable, Callable, Optional, Sequence, overload
13
+
14
+ from fastapi import Depends, HTTPException
15
+ from fastapi.params import Depends as DependsParam
16
+ from fastapi.security import OAuth2PasswordBearer
17
+ from pydantic import ValidationError
18
+ from starlette.concurrency import run_in_threadpool
19
+
20
+ try:
21
+ import jwt
22
+ except ImportError as e: # pragma: no cover - dipende dagli extra installati
23
+ raise ImportError("general.auth needs the [auth] extra: pip install 'python-general-be-lib[auth]'") from e
24
+
25
+ from ..exception.access_exceptions import ForbiddenException, UnauthorizedException
26
+ from ..exception.crud_exceptions import ServiceUnavailableException
27
+ from .jwt_model import JWTModel
28
+ from .key_provider import JwksKeyProvider
29
+
30
+ # esito della verifica fatta da `AuthContextMiddleware`, letto dalla dependency `current`:
31
+ # (auth, token, claims oppure l'eccezione). Il token sta nella tupla perché la dependency
32
+ # riusi l'esito solo per lo stesso header che legge lei, e l'auth perché lo riusi solo
33
+ # l'istanza che l'ha prodotto.
34
+ _verified: ContextVar[Optional[tuple["KeycloakAuth[Any]", str, Any]]] = ContextVar("general_auth_verified", default=None)
35
+
36
+
37
+ class RoleChecker:
38
+ """Check that the claims of a token grant some roles.
39
+
40
+ It is the policy alone, with no dependency injection: call it on already verified
41
+ claims, e.g. for a check that applies only to some requests. To guard an endpoint, use
42
+ ``KeycloakAuth.require_roles``, which wraps it in a dependency.
43
+
44
+ Attributes:
45
+ roles (frozenset[str]): Roles to look for.
46
+ require_all (bool): If ``True`` every role is required, otherwise one is enough.
47
+ client (str | None): Client id the roles belong to; ``None`` for realm roles.
48
+ """
49
+ __slots__ = "roles", "require_all", "client"
50
+
51
+ def __init__(self, *roles: str, require_all: bool = False, client: Optional[str] = None):
52
+ """Configure the check.
53
+
54
+ Args:
55
+ *roles (str): Roles to look for.
56
+ require_all (bool): If ``True`` every role is required, otherwise one is
57
+ enough. Default: ``False``.
58
+ client (str, optional): Client id whose roles are read from
59
+ ``resource_access``. When omitted, the realm roles are read.
60
+
61
+ Raises:
62
+ ValueError: If no role is given.
63
+ """
64
+ if not roles:
65
+ raise ValueError("RoleChecker needs at least one role")
66
+ self.roles = frozenset(roles)
67
+ self.require_all = require_all
68
+ self.client = client
69
+
70
+ def __call__(self, claims: JWTModel) -> bool:
71
+ """Run the check.
72
+
73
+ Args:
74
+ claims (JWTModel): Verified claims of the token.
75
+
76
+ Returns:
77
+ bool: Always ``True``: a failed check raises instead.
78
+
79
+ Raises:
80
+ ForbiddenException: If the roles are not granted, including when the token
81
+ carries no roles at all.
82
+ """
83
+ held = set(claims.roles_of(self.client))
84
+ granted = held >= self.roles if self.require_all else not held.isdisjoint(self.roles)
85
+ if not granted:
86
+ raise ForbiddenException("Operazione selezionata non consentita")
87
+ return True
88
+
89
+
90
+ class KeycloakAuth[M: JWTModel]:
91
+ """Verification of the access tokens of one Keycloak realm, configured per backend.
92
+
93
+ Built once per application, it verifies signature, expiry, audience and issuer of a
94
+ token against the signing keys of the realm, and validates the claims into ``model``.
95
+ **Every** invalid token yields a 401, audience and issuer included; a 503 is left for
96
+ a JWKS endpoint that is unreachable while no key has been cached yet.
97
+
98
+ Attributes:
99
+ realm_url (str): URL of the realm, without the trailing slash.
100
+ issuer (str | None): Expected ``iss`` claim; ``None`` when not verified.
101
+ audience (list[str]): Accepted audiences; the token must name at least one.
102
+ algorithms (list[str]): Accepted signing algorithms.
103
+ leeway (float): Seconds of clock skew tolerated on ``exp`` and ``iat``.
104
+ model (type[M]): Model the claims are validated into.
105
+ user_claim (str | Callable[[M], Any]): Claim, or function of the claims,
106
+ identifying the user for the audit.
107
+ key_provider (JwksKeyProvider): Cache of the signing keys of the realm.
108
+ oauth2_scheme (OAuth2PasswordBearer): Bearer scheme, which also documents the
109
+ authentication in the OpenAPI schema.
110
+ bearer (Depends): Dependency on ``oauth2_scheme``, to guard a router with
111
+ ``dependencies=[auth.bearer]``.
112
+ current (Callable[..., Awaitable[M]]): Dependency returning the verified claims
113
+ of the request. Behind ``AuthContextMiddleware`` it reuses the verification the
114
+ middleware already made, error included, instead of verifying the token again.
115
+ """
116
+ __slots__ = (
117
+ "realm_url",
118
+ "issuer",
119
+ "audience",
120
+ "algorithms",
121
+ "leeway",
122
+ "model",
123
+ "user_claim",
124
+ "key_provider",
125
+ "oauth2_scheme",
126
+ "bearer",
127
+ "current",
128
+ )
129
+
130
+ realm_url: str
131
+ issuer: Optional[str]
132
+ audience: list[str]
133
+ algorithms: list[str]
134
+ leeway: float
135
+ model: type[M]
136
+ user_claim: str | Callable[[M], Any]
137
+ key_provider: JwksKeyProvider
138
+ oauth2_scheme: OAuth2PasswordBearer
139
+ bearer: DependsParam
140
+ current: Callable[..., Awaitable[M]]
141
+
142
+ # senza `model` il type checker non ha da dove ricavare M: il primo overload lo fissa a
143
+ # JWTModel, altrimenti chi omette il modello si vede chiedere un'annotazione
144
+ @overload
145
+ def __init__(
146
+ self: "KeycloakAuth[JWTModel]",
147
+ realm_url: str,
148
+ audience: str | Sequence[str],
149
+ *,
150
+ user_claim: str | Callable[[JWTModel], Any] = ...,
151
+ token_url: str = ...,
152
+ algorithms: Sequence[str] = ...,
153
+ issuer: Optional[str] = ...,
154
+ verify_issuer: bool = ...,
155
+ leeway: float = ...,
156
+ key_provider: Optional[JwksKeyProvider] = ...,
157
+ ) -> None: ...
158
+
159
+ @overload
160
+ def __init__(
161
+ self,
162
+ realm_url: str,
163
+ audience: str | Sequence[str],
164
+ model: type[M],
165
+ user_claim: str | Callable[[M], Any] = ...,
166
+ token_url: str = ...,
167
+ algorithms: Sequence[str] = ...,
168
+ issuer: Optional[str] = ...,
169
+ verify_issuer: bool = ...,
170
+ leeway: float = ...,
171
+ key_provider: Optional[JwksKeyProvider] = ...,
172
+ ) -> None: ...
173
+
174
+ def __init__(
175
+ self,
176
+ realm_url: str,
177
+ audience: str | Sequence[str],
178
+ model: type[M] = JWTModel, # type: ignore[assignment]
179
+ user_claim: str | Callable[[M], Any] = "sub",
180
+ token_url: str = "token",
181
+ algorithms: Sequence[str] = ("RS256",),
182
+ issuer: Optional[str] = None,
183
+ verify_issuer: bool = True,
184
+ leeway: float = 0,
185
+ key_provider: Optional[JwksKeyProvider] = None,
186
+ ):
187
+ """Configure the verification. Nothing is fetched until ``warm_up`` or the first token.
188
+
189
+ Args:
190
+ realm_url (str): URL of the realm, ``{KC_URL}/realms/{realm}``, with or without
191
+ the trailing slash. Issuer and JWKS endpoint are derived from it.
192
+ audience (str | Sequence[str]): Accepted audiences, e.g.
193
+ ``[KC_CLIENT_ID, "account"]``.
194
+ model (type[M]): Model the claims are validated into; a subclass of
195
+ ``JWTModel`` declaring the custom claims of the realm. Default: ``JWTModel``.
196
+ user_claim (str | Callable[[M], Any]): Name of the claim identifying the user,
197
+ or a function computing it from the claims. The value is turned into a
198
+ string, which is the type of the audit columns. Default: ``"sub"``.
199
+ token_url (str): ``tokenUrl`` of the bearer scheme, the endpoint Swagger asks
200
+ the token to. Default: ``"token"``.
201
+ algorithms (Sequence[str]): Accepted signing algorithms. Default: ``("RS256",)``.
202
+ issuer (str, optional): Expected ``iss`` claim, in place of the one derived
203
+ from ``realm_url``. Needed when the backend reaches Keycloak through a URL
204
+ other than the public one the tokens are issued with.
205
+ verify_issuer (bool): If ``False`` the ``iss`` claim is not verified.
206
+ Default: ``True``.
207
+ leeway (float): Seconds of clock skew tolerated on ``exp`` and ``iat``.
208
+ Default: ``0``.
209
+ key_provider (JwksKeyProvider, optional): Cache of the signing keys, in place
210
+ of the one built on the JWKS endpoint of ``realm_url``.
211
+
212
+ Raises:
213
+ ValueError: If ``realm_url`` is not an ``http(s)`` URL, or ``audience`` or
214
+ ``algorithms`` are empty or hold an empty value — typically an environment
215
+ variable that is not set.
216
+ """
217
+ if not isinstance(realm_url, str) or not realm_url.startswith(("http://", "https://")):
218
+ raise ValueError(f"KeycloakAuth: realm_url must be an http(s) URL, got {realm_url!r}")
219
+ audiences = [audience] if isinstance(audience, str) or audience is None else list(audience)
220
+ if not audiences or not all(audiences):
221
+ raise ValueError(f"KeycloakAuth: audience must hold non-empty values, got {audience!r}")
222
+ if not algorithms or not all(algorithms) or isinstance(algorithms, str):
223
+ raise ValueError(f"KeycloakAuth: algorithms must be a non-empty sequence, got {algorithms!r}")
224
+
225
+ self.realm_url = realm_url.rstrip("/")
226
+ self.issuer = (issuer or self.realm_url) if verify_issuer else None
227
+ self.audience = audiences
228
+ self.algorithms = list(algorithms)
229
+ self.leeway = leeway
230
+ self.model = model
231
+ self.user_claim = user_claim
232
+ self.key_provider = key_provider or JwksKeyProvider(f"{self.realm_url}/protocol/openid-connect/certs")
233
+ self.oauth2_scheme = OAuth2PasswordBearer(tokenUrl=token_url)
234
+ self.bearer = Depends(self.oauth2_scheme)
235
+
236
+ # una closure e non un metodo: FastAPI legge la dependency dalla firma, e il default
237
+ # `Depends(self.oauth2_scheme)` esiste solo per istanza
238
+ async def current(token: Annotated[str, Depends(self.oauth2_scheme)]) -> M:
239
+ verified = _verified.get()
240
+ if verified is not None and verified[0] is self and verified[1] == token:
241
+ outcome = verified[2]
242
+ if isinstance(outcome, HTTPException):
243
+ raise outcome
244
+ return outcome
245
+ return await self.adecode(token)
246
+
247
+ self.current = current
248
+
249
+ def decode(self, token: str) -> M:
250
+ """Verify a token and validate its claims.
251
+
252
+ It is synchronous and may block on the JWKS fetch, at most once per
253
+ ``min_refresh_interval`` of the key provider.
254
+
255
+ Args:
256
+ token (str): The encoded token, without the ``Bearer`` prefix.
257
+
258
+ Returns:
259
+ M: The verified claims.
260
+
261
+ Raises:
262
+ UnauthorizedException: If the token is expired, malformed, signed by a key the
263
+ realm does not publish, or its audience, issuer or claims are not valid.
264
+ ServiceUnavailableException: If the JWKS endpoint is unreachable and no key has
265
+ been cached yet.
266
+ """
267
+ key = self.key_provider.get_key(self._kid_of(token))
268
+ if key is None:
269
+ raise UnauthorizedException("Token non valido")
270
+ try:
271
+ payload = jwt.decode(
272
+ token,
273
+ key=key.key,
274
+ algorithms=self.algorithms,
275
+ audience=self.audience,
276
+ issuer=self.issuer,
277
+ leeway=self.leeway,
278
+ options={"require": ["exp", "iat"]},
279
+ )
280
+ except jwt.ExpiredSignatureError:
281
+ raise UnauthorizedException("Token scaduto")
282
+ except jwt.PyJWTError:
283
+ raise UnauthorizedException("Token non valido")
284
+ try:
285
+ return self.model.model_validate(payload)
286
+ except ValidationError:
287
+ raise UnauthorizedException("Token non valido")
288
+
289
+ async def adecode(self, token: str) -> M:
290
+ """Verify a token like ``decode``, without blocking the event loop.
291
+
292
+ The common case — a fresh key already cached — runs on the event loop, with no
293
+ thread hop; only a verification that would fetch the JWKS moves to a worker thread.
294
+
295
+ Args:
296
+ token (str): The encoded token, without the ``Bearer`` prefix.
297
+
298
+ Returns:
299
+ M: The verified claims.
300
+
301
+ Raises:
302
+ UnauthorizedException: As ``decode``.
303
+ ServiceUnavailableException: As ``decode``.
304
+ """
305
+ if self.key_provider.needs_fetch(self._kid_of(token)):
306
+ return await run_in_threadpool(self.decode, token)
307
+ return self.decode(token)
308
+
309
+ @staticmethod
310
+ def _kid_of(token: str) -> Optional[str]:
311
+ """Read the key id from the header of a token, without verifying it.
312
+
313
+ Args:
314
+ token (str): The encoded token.
315
+
316
+ Returns:
317
+ str | None: The ``kid`` of the header, ``None`` when missing.
318
+
319
+ Raises:
320
+ UnauthorizedException: If the header cannot be read.
321
+ """
322
+ try:
323
+ return jwt.get_unverified_header(token).get("kid")
324
+ except jwt.PyJWTError:
325
+ raise UnauthorizedException("Token non valido")
326
+
327
+ def user_of(self, claims: M) -> Optional[str]:
328
+ """Identify the user the claims belong to, as the audit columns store it.
329
+
330
+ Args:
331
+ claims (M): Verified claims of the token.
332
+
333
+ Returns:
334
+ str | None: The value of ``user_claim`` as a string, or ``None`` if missing.
335
+ """
336
+ value = self.user_claim(claims) if callable(self.user_claim) else getattr(claims, self.user_claim, None)
337
+ return None if value is None else str(value)
338
+
339
+ def require_roles(self, *roles: str, require_all: bool = False, client: Optional[str] = None) -> DependsParam:
340
+ """Build a dependency guarding an endpoint by role.
341
+
342
+ It goes through ``current``, which FastAPI resolves once per request: several role
343
+ checks on the same endpoint verify the token once.
344
+
345
+ Args:
346
+ *roles (str): Roles to look for.
347
+ require_all (bool): If ``True`` every role is required, otherwise one is
348
+ enough. Default: ``False``.
349
+ client (str, optional): Client id whose roles are read from
350
+ ``resource_access``. When omitted, the realm roles are read.
351
+
352
+ Returns:
353
+ Depends: The dependency, for an endpoint parameter or ``dependencies=[...]``.
354
+
355
+ Raises:
356
+ ValueError: If no role is given.
357
+ """
358
+ checker = RoleChecker(*roles, require_all=require_all, client=client)
359
+
360
+ # async: il controllo non blocca, e una dependency sincrona costerebbe un thread hop
361
+ async def check(claims: Annotated[JWTModel, Depends(self.current)]) -> bool:
362
+ return checker(claims)
363
+
364
+ return Depends(check)
365
+
366
+ def warm_up(self) -> None:
367
+ """Fetch the signing keys now, so that the first request does not wait for them.
368
+
369
+ Call it at startup, in the lifespan of the application: with several workers each
370
+ process has its own cache, so a call made before the workers start fills none.
371
+
372
+ Raises:
373
+ ServiceUnavailableException: If the JWKS endpoint cannot be reached.
374
+ """
375
+ if not self.key_provider.refresh():
376
+ raise ServiceUnavailableException()
@@ -0,0 +1,94 @@
1
+ """ASGI middleware setting the context of the request from its bearer token.
2
+
3
+ It verifies the token once per request and publishes the outcome: the user in
4
+ ``current_user`` (read by ``AuditMixin``), the claims in ``current_jwt``, and the result,
5
+ error included, for the ``KeycloakAuth.current`` dependency.
6
+ """
7
+
8
+ __all__ = ["AuthContextMiddleware"]
9
+
10
+ from typing import Any
11
+
12
+ from fastapi import HTTPException
13
+ from fastapi.security.utils import get_authorization_scheme_param
14
+ from starlette.types import ASGIApp, Receive, Scope, Send
15
+
16
+ from ..context import current_jwt, current_user
17
+ from .keycloak_auth import KeycloakAuth, _verified
18
+
19
+
20
+ class AuthContextMiddleware:
21
+ """Verify the bearer token of each request and set the context from it.
22
+
23
+ It is a pure ASGI middleware, not a ``BaseHTTPMiddleware``, and it **never answers on
24
+ its own**: an invalid token leaves the context empty and records the error, which the
25
+ ``KeycloakAuth.current`` dependency raises inside the application. So the 401 goes
26
+ through the exception handlers and the CORS middleware, whatever the order the
27
+ middlewares were added in, and an endpoint without an auth dependency stays reachable
28
+ even with an expired token.
29
+
30
+ Attributes:
31
+ app (ASGIApp): The wrapped application.
32
+ auth (KeycloakAuth): The verification the tokens go through.
33
+ """
34
+ __slots__ = "app", "auth"
35
+
36
+ def __init__(self, app: ASGIApp, auth: KeycloakAuth[Any]):
37
+ """Wrap the application.
38
+
39
+ Args:
40
+ app (ASGIApp): The application, passed by ``add_middleware``.
41
+ auth (KeycloakAuth): The verification the tokens go through, the same instance
42
+ whose dependencies guard the endpoints.
43
+ """
44
+ self.app = app
45
+ self.auth = auth
46
+
47
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
48
+ """Verify the token, if any, then call the application with the context set.
49
+
50
+ Args:
51
+ scope (Scope): ASGI connection scope.
52
+ receive (Receive): ASGI receive channel.
53
+ send (Send): ASGI send channel.
54
+ """
55
+ token = self._bearer(scope) if scope["type"] in ("http", "websocket") else None
56
+ if token is None:
57
+ await self.app(scope, receive, send)
58
+ return
59
+
60
+ claims = None
61
+ outcome: Any
62
+ try:
63
+ claims = outcome = await self.auth.adecode(token)
64
+ except HTTPException as e:
65
+ outcome = e
66
+
67
+ user_token = current_user.set(self.auth.user_of(claims) if claims is not None else None)
68
+ jwt_token = current_jwt.set(claims)
69
+ verified_token = _verified.set((self.auth, token, outcome))
70
+ try:
71
+ await self.app(scope, receive, send)
72
+ finally:
73
+ _verified.reset(verified_token)
74
+ current_jwt.reset(jwt_token)
75
+ current_user.reset(user_token)
76
+
77
+ @staticmethod
78
+ def _bearer(scope: Scope) -> str | None:
79
+ """Extract the bearer token from the ``Authorization`` header.
80
+
81
+ It parses the header the way ``OAuth2PasswordBearer`` does, so that the dependency
82
+ finds the very same token and reuses the outcome.
83
+
84
+ Args:
85
+ scope (Scope): ASGI connection scope.
86
+
87
+ Returns:
88
+ str | None: The token, or ``None`` if the header is missing or not a bearer.
89
+ """
90
+ for name, value in scope.get("headers", ()):
91
+ if name == b"authorization":
92
+ scheme, param = get_authorization_scheme_param(value.decode("latin-1"))
93
+ return param if scheme.lower() == "bearer" and param else None
94
+ return None
general/context.py CHANGED
@@ -8,16 +8,20 @@ The module imports nothing from the library and nothing from SQLAlchemy, so a mi
8
8
  can set the context without paying for the data access layer.
9
9
  """
10
10
 
11
- __all__ = ["current_user"]
11
+ __all__ = ["current_user", "current_jwt"]
12
12
 
13
13
  from contextvars import ContextVar
14
- from typing import Optional
14
+ from typing import TYPE_CHECKING, Optional
15
+
16
+ if TYPE_CHECKING: # solo per il tipo: a runtime il modulo non importa niente della libreria
17
+ from .auth.jwt_model import JWTModel
15
18
 
16
19
  current_user: ContextVar[Optional[str]] = ContextVar("current_user", default=None)
17
20
  """Identifier of the user the current operation is attributed to.
18
21
 
19
- Read by ``AuditMixin`` to fill ``create_uid``/``write_uid``. Setting it is up to the
20
- consumer, typically in the authentication middleware::
22
+ Read by ``AuditMixin`` to fill ``create_uid``/``write_uid``. ``general.auth.AuthContextMiddleware``
23
+ sets it from the verified token; without it, setting it is up to the consumer, typically
24
+ in its own authentication middleware::
21
25
 
22
26
  from general.context import current_user
23
27
 
@@ -29,3 +33,13 @@ consumer, typically in the authentication middleware::
29
33
  Left unset it holds ``None``, which is what ends up in the columns for the operations
30
34
  having no user behind them: scheduled jobs, migrations, a console session.
31
35
  """
36
+
37
+ current_jwt: ContextVar[Optional["JWTModel"]] = ContextVar("current_jwt", default=None)
38
+ """Verified claims of the token of the current request.
39
+
40
+ Set by ``general.auth.AuthContextMiddleware`` when the request carries a valid bearer
41
+ token, and left ``None`` otherwise — no token, an invalid one, a job with no request
42
+ behind it. It holds an instance of the ``model`` the ``KeycloakAuth`` was configured with,
43
+ so code outside the dependency injection reads the claims without verifying the token
44
+ again.
45
+ """
@@ -1,8 +1,8 @@
1
1
  """HTTP exceptions for authentication and authorization.
2
2
 
3
- Unlike ``crud_exceptions``, these exceptions are never raised automatically by the
4
- library: they are available to the authentication and authorization layers of the
5
- projects using it.
3
+ Unlike ``crud_exceptions``, these exceptions are never raised by the data access layer:
4
+ the library raises them only in ``general.auth``, for a rejected token (401) or a missing
5
+ role (403), and they are available to the authorization logic of the projects using it.
6
6
  """
7
7
 
8
8
  __all__ = ["ForbiddenException", "UnauthorizedException"]
@@ -169,14 +169,23 @@ class CrudRepository[Entity: Base | Type[Base]]:
169
169
  return _reduced_model(self.entity, frozenset(selected_columns))
170
170
 
171
171
  def _handle_exception(self, session: Session, e: Exception):
172
- """Close the session, log the error and raise an HTTP-friendly exception.
172
+ """Roll back and close the session, log the error and raise an HTTP-friendly exception.
173
173
 
174
174
  An ``OperationalError`` becomes a ``ServiceUnavailableException``, a
175
175
  ``NoResultFound`` an ``EntityNotFoundException``, a ``DataError`` or an
176
176
  ``IntegrityError`` a ``BadRequestException``; any other exception is re-raised as is.
177
177
 
178
+ ``close`` alone already rolls back the database transaction. What the explicit
179
+ ``rollback`` adds is the expiry of the ORM instances: an entity still held by the
180
+ caller raises ``DetachedInstanceError`` on access instead of exposing values that
181
+ were flushed but never committed. It runs on every error, a failed SELECT included,
182
+ since on PostgreSQL that aborts the transaction as well. An external session is
183
+ rolled back and closed too, so the caller's transaction is lost. A failure of the
184
+ rollback itself is logged and does not replace ``e``, and the session is closed
185
+ anyway.
186
+
178
187
  Args:
179
- session (Session): Session to close.
188
+ session (Session): Session to roll back and close.
180
189
  e (Exception): Caught exception to handle.
181
190
 
182
191
  Raises:
@@ -184,6 +193,11 @@ class CrudRepository[Entity: Base | Type[Base]]:
184
193
  EntityNotFoundException: If no record matches.
185
194
  BadRequestException: If the data is malformed or violates a constraint.
186
195
  """
196
+ try:
197
+ session.rollback()
198
+ except Exception:
199
+ # un rollback fallito non deve sostituire l'errore originale, né saltare la close
200
+ logging.exception("Rollback failed")
187
201
  session.close()
188
202
  logging.error(e)
189
203
  raise_http_exception(e)
@@ -195,12 +209,13 @@ class CrudRepository[Entity: Base | Type[Base]]:
195
209
  session (Session): Session to commit.
196
210
 
197
211
  Raises:
198
- ServiceUnavailableException | BadRequestException: On error during the commit.
212
+ ServiceUnavailableException | BadRequestException: On error during the commit,
213
+ after rolling back and closing the session.
199
214
  """
200
215
  try:
201
216
  session.commit()
202
217
  except Exception as e:
203
- self._handle_exception(session, e)
218
+ self._handle_exception(session=session, e=e)
204
219
 
205
220
  def _flush(self, session: Session):
206
221
  """Flush the session, handling the possible exceptions.
@@ -214,12 +229,13 @@ class CrudRepository[Entity: Base | Type[Base]]:
214
229
  session (Session): Session to flush.
215
230
 
216
231
  Raises:
217
- ServiceUnavailableException | BadRequestException: On error during the flush.
232
+ ServiceUnavailableException | BadRequestException: On error during the flush,
233
+ after rolling back and closing the session.
218
234
  """
219
235
  try:
220
236
  session.flush()
221
237
  except Exception as e:
222
- self._handle_exception(session, e)
238
+ self._handle_exception(session=session, e=e)
223
239
 
224
240
  def _entity_has_columns(self, *columns: str, entity: Type[Base] | Base = None) -> bool:
225
241
  """Check that every given name is a column of the entity.
@@ -441,7 +457,7 @@ class CrudRepository[Entity: Base | Type[Base]]:
441
457
  try:
442
458
  entity = session.get(self.entity, values)
443
459
  except Exception as e:
444
- self._handle_exception(session, e)
460
+ self._handle_exception(session=session, e=e)
445
461
  else:
446
462
  return [entity] if entity is not None else []
447
463
 
@@ -607,7 +623,7 @@ class CrudRepository[Entity: Base | Type[Base]]:
607
623
  try:
608
624
  session.add_all(entities)
609
625
  except Exception as e:
610
- self._handle_exception(session, e)
626
+ self._handle_exception(session=session, e=e)
611
627
  return self._return(session=session, entities=entities, keep_open=keep_open, commit=True, parsing_model=parsing_model)
612
628
 
613
629
  def update(self, session: Session = None, where: dict[str, Any] = None, returning: bool = True, parsing_model: Type[BaseModel] | BaseModel = None, **kwargs) -> list[Entity] | list[BaseModel]:
@@ -1049,9 +1065,9 @@ class CrudRepository[Entity: Base | Type[Base]]:
1049
1065
  """Execute a SQLAlchemy statement and return the result as it is.
1050
1066
 
1051
1067
  The method owns the error handling and nothing else: every exception goes to
1052
- ``_handle_exception``, which closes the session and maps the error to an HTTP
1053
- status. Reading the result is left to the caller, which is the only one knowing the
1054
- shape it needs — ``.scalars().all()`` for entities, ``.scalar()`` for an aggregate,
1068
+ ``_handle_exception``, which rolls back and closes the session and maps the error
1069
+ to an HTTP status. Reading the result is left to the caller, which is the only one
1070
+ knowing the shape it needs — ``.scalars().all()`` for entities, ``.scalar()`` for an aggregate,
1055
1071
  ``.all()`` for the multi-column rows of a statement built with ``add_columns``,
1056
1072
  ``.rowcount`` for a DML without ``RETURNING``. The result is bound to the session,
1057
1073
  so it has to be consumed before the session is closed. ``Session.execute`` returns
@@ -1076,6 +1092,6 @@ class CrudRepository[Entity: Base | Type[Base]]:
1076
1092
  try:
1077
1093
  result = session.execute(statement=stmt, params=values)
1078
1094
  except Exception as e:
1079
- self._handle_exception(session, e)
1095
+ self._handle_exception(session=session, e=e)
1080
1096
  else:
1081
1097
  return result
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-general-be-lib
3
- Version: 0.11.3
4
- Summary: General purpose backend library — SQLAlchemy CRUD/Geometry repositories, FastAPI exceptions, Pydantic base models, logger utilities.
3
+ Version: 0.12.1
4
+ Summary: General purpose backend library — SQLAlchemy CRUD/Geometry repositories, FastAPI exceptions, Pydantic base models, Keycloak JWT auth, logger utilities.
5
5
  Author-email: Andrea Di Placido <a.diplacido@arpes.it>, "Arpes S.r.l." <it.admin@arpes.it>
6
6
  License: MIT
7
7
  Project-URL: Homepage, https://github.com/Arpes-IS/python-general-be-lib
8
8
  Project-URL: Repository, https://github.com/Arpes-IS/python-general-be-lib
9
9
  Project-URL: Changelog, https://github.com/Arpes-IS/python-general-be-lib/blob/main/CHANGELOG.md
10
- Keywords: backend,sqlalchemy,fastapi,pydantic,repository,crud,geoalchemy2
10
+ Keywords: backend,sqlalchemy,fastapi,pydantic,repository,crud,geoalchemy2,keycloak,jwt
11
11
  Classifier: Development Status :: 3 - Alpha
12
12
  Classifier: Intended Audience :: Developers
13
13
  Classifier: License :: OSI Approved :: MIT License
@@ -23,7 +23,10 @@ Requires-Dist: fastapi>=0.100.0
23
23
  Requires-Dist: pydantic>=2.0.0
24
24
  Requires-Dist: sqlalchemy>=2.0.0
25
25
  Requires-Dist: geoalchemy2>=0.14.0
26
+ Provides-Extra: auth
27
+ Requires-Dist: pyjwt[crypto]>=2.8; extra == "auth"
26
28
  Provides-Extra: dev
29
+ Requires-Dist: pyjwt[crypto]>=2.8; extra == "dev"
27
30
  Requires-Dist: pytest>=7; extra == "dev"
28
31
  Requires-Dist: pytest-cov; extra == "dev"
29
32
  Requires-Dist: pytest-benchmark>=5; extra == "dev"
@@ -38,7 +41,7 @@ Dynamic: license-file
38
41
 
39
42
  Libreria backend general purpose per progetti Python: repository CRUD e spaziali
40
43
  su **SQLAlchemy** / **GeoAlchemy2**, eccezioni **FastAPI**, modelli base
41
- **Pydantic** e utility di logging.
44
+ **Pydantic**, autenticazione **Keycloak** e utility di logging.
42
45
 
43
46
  - Distribuzione PyPI: `python-general-be-lib`
44
47
  - Pacchetto importabile: `general`
@@ -46,6 +49,7 @@ su **SQLAlchemy** / **GeoAlchemy2**, eccezioni **FastAPI**, modelli base
46
49
 
47
50
  ```bash
48
51
  pip install python-general-be-lib
52
+ pip install 'python-general-be-lib[auth]' # con l'autenticazione Keycloak (pyjwt[crypto])
49
53
  ```
50
54
 
51
55
  ```python
@@ -72,6 +76,8 @@ repo.find(active=True, order_by="name", asc=True)
72
76
  | Basi | `Base` (declarative base estesa), `ExtBaseModel`, `CamelExtBaseModel` |
73
77
  | Paginazione | `PaginatorRequestModel`, `PaginatorResponseModel` |
74
78
  | Eccezioni | `ExceptionInterface` e sottoclassi HTTP (400/401/403/404/422/500/503) |
79
+ | Autenticazione (extra `[auth]`) | `KeycloakAuth`, `AuthContextMiddleware`, `JWTModel`, `RoleChecker` — verifica dei token Keycloak via JWKS, dependency FastAPI, ruoli |
80
+ | Contesto | `current_user`, `current_jwt` — utente e claim della richiesta, per l'audit |
75
81
  | Logging | `init_logger`, decorator `@log` |
76
82
 
77
83
  ---
@@ -1,10 +1,15 @@
1
1
  general/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- general/context.py,sha256=ArxisBIbmij72IWKQwBfJBhmAucUGlMnEPRQnYl1A6k,1165
2
+ general/context.py,sha256=AbASV-jPnDM-M-Nih5c9vKHFlIS0kjp8Q1yuHC34FeA,1925
3
3
  general/logger.py,sha256=MnK8ZDA9x4xhX0KcpOxTieosnFIhFVwSa8b52Q4T9tA,5039
4
4
  general/paginator_dto.py,sha256=DB2hfcrckuEiHEIORLT4EgCcnX4AYYKGlHRr-l-Q1Cw,3741
5
5
  general/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ general/auth/__init__.py,sha256=6cYCA6Ir-nnBK9z_bUfh_io3wEYjJUwHAN8mdhjtKeI,2239
7
+ general/auth/jwt_model.py,sha256=KcopeNUPRSQOLvreoIuSo6kJybRtrmhzLoJ5oHFngGw,3447
8
+ general/auth/key_provider.py,sha256=QsyLEZ5sYkIPchraMt-l79IvwvqLAyF84Hk-415gYjI,8012
9
+ general/auth/keycloak_auth.py,sha256=G909oFHpFCMbj2cO9x_HUVb56RqMtKYpBVaiptPVO0o,16162
10
+ general/auth/middleware.py,sha256=nJJHOtlpzDjYdBqgl0TD1_GQZFxKXKMlO_lECcjQdxU,3645
6
11
  general/exception/__init__.py,sha256=MZpSDUKeopct7ZqMd1g4XlNS8sM9mmXIlHTIYPOhu9c,849
7
- general/exception/access_exceptions.py,sha256=tNIBjxM0-5iSOXDc7Il01eb36mEk-POQGVaHhCpG278,744
12
+ general/exception/access_exceptions.py,sha256=j5fas9f24l6a6FdKCrNMP3Djv5DD6I1LRYo3CATcMRU,826
8
13
  general/exception/crud_exceptions.py,sha256=7zA9J8KPlvK9MpHbhFEmFpdGVMcqw7hCyRGSJB4-K7U,4356
9
14
  general/exception/exception_interface.py,sha256=k7KreAYMwKPQQghkCissdu3ASZZqrRFWM1lAGzeR8sM,1294
10
15
  general/interface/__init__.py,sha256=G11sI1Oxtb-Z_-ORAuKS_Kkdwopt1uXmEXYtZEJYiLs,445
@@ -19,7 +24,7 @@ general/interface/mixin/audit_mixin.py,sha256=N2JDD9JxXDMeBqu0yAlhVWFOK3EeUA2b21
19
24
  general/interface/mixin/softdelete_mixin.py,sha256=526luzM4DBCorcq99kZ6BYqtQKKJCpnxX9rSEigfyIU,2148
20
25
  general/interface/mixin/timestamp_mixin.py,sha256=wmcIx2Y8jgVqg9A-1E61BvkcKCo52ZjKNHeKfXJmUl0,2220
21
26
  general/interface/repository/__init__.py,sha256=QQD6okLfRozC0uOZPGrwOUDKJyZTgpObvwJ3OOSJdbM,1813
22
- general/interface/repository/crud_repository.py,sha256=Hr7APBsN1uFqEfM78ZSIcgheg4ilvND4OMc7TBNOlcw,55962
27
+ general/interface/repository/crud_repository.py,sha256=Z7IgLz7dtfIyzZMJO7T_IDunHH1qTDYRApdWlbMkE2A,57031
23
28
  general/interface/repository/geometry_repository.py,sha256=Fmv0qhfqumnU5fiETMMAmiB0UyG43b19jlPXQ8cJ9CU,11289
24
29
  general/interface/repository/many_to_many_repository.py,sha256=KRJ2SMUGDYNoGV-ea541wlWg0FW4NQ_Ry85aJBarmC8,11183
25
30
  general/interface/repository/view_repository.py,sha256=wxDGw7kenuVrDd9nfh7-omw2m9Jex5WzF_lhEiqPoA0,7902
@@ -35,8 +40,8 @@ general/interface/repository/handler/soft_delete_handler.py,sha256=1WD1n6q5plyJ0
35
40
  general/interface/repository/handler/statement_manager.py,sha256=JSj6K-iQJ06kLdsMTOqK62cIvR87ioOA1LmLTGpqrB0,8205
36
41
  general/utils/__init__.py,sha256=_SPe88reIZ0JxwK7mp-u_p-9c4hCcZ-daUihi0gymNk,103
37
42
  general/utils/consts.py,sha256=nE75Ud7sVuDw9K_xmIJNjL_5f_BVJmwGNPGFYc26H3Y,264
38
- python_general_be_lib-0.11.3.dist-info/licenses/LICENSE,sha256=iUaO1XZyB9P3Tmog0OILuTisP6vXGe3QKz-4yRTxOFk,1069
39
- python_general_be_lib-0.11.3.dist-info/METADATA,sha256=kXWrKc_wD4H6khCeqnluMKmFoD-8AopOF8m3RXISA1s,3452
40
- python_general_be_lib-0.11.3.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
41
- python_general_be_lib-0.11.3.dist-info/top_level.txt,sha256=tTZePW8_CNUqSgKFd2SEH72ZbnhS0OYjRsgcv0ikSFY,8
42
- python_general_be_lib-0.11.3.dist-info/RECORD,,
43
+ python_general_be_lib-0.12.1.dist-info/licenses/LICENSE,sha256=iUaO1XZyB9P3Tmog0OILuTisP6vXGe3QKz-4yRTxOFk,1069
44
+ python_general_be_lib-0.12.1.dist-info/METADATA,sha256=8T5p8u9J9MUHT1xZjRf_Ss29Y97hEeJX5wZD71aVTyI,3995
45
+ python_general_be_lib-0.12.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
46
+ python_general_be_lib-0.12.1.dist-info/top_level.txt,sha256=tTZePW8_CNUqSgKFd2SEH72ZbnhS0OYjRsgcv0ikSFY,8
47
+ python_general_be_lib-0.12.1.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (84.0.0)
2
+ Generator: setuptools (84.2.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5