fastlifeweb 0.13.0__py3-none-any.whl → 0.15.0__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.
- fastlife/config/configurator.py +144 -63
- fastlife/config/openapiextra.py +22 -0
- fastlife/config/registry.py +1 -9
- fastlife/config/resources.py +3 -1
- fastlife/config/settings.py +0 -3
- fastlife/request/request.py +28 -1
- fastlife/security/policy.py +158 -13
- fastlife/services/policy.py +39 -0
- fastlife/services/translations.py +4 -4
- {fastlifeweb-0.13.0.dist-info → fastlifeweb-0.15.0.dist-info}/METADATA +1 -1
- {fastlifeweb-0.13.0.dist-info → fastlifeweb-0.15.0.dist-info}/RECORD +13 -11
- {fastlifeweb-0.13.0.dist-info → fastlifeweb-0.15.0.dist-info}/LICENSE +0 -0
- {fastlifeweb-0.13.0.dist-info → fastlifeweb-0.15.0.dist-info}/WHEEL +0 -0
fastlife/config/configurator.py
CHANGED
@@ -14,11 +14,12 @@ phase.
|
|
14
14
|
import importlib
|
15
15
|
import inspect
|
16
16
|
import logging
|
17
|
-
from collections
|
17
|
+
from collections import defaultdict
|
18
|
+
from collections.abc import Mapping, Sequence
|
18
19
|
from enum import Enum
|
19
20
|
from pathlib import Path
|
20
21
|
from types import ModuleType
|
21
|
-
from typing import TYPE_CHECKING, Annotated, Any, Callable, Self, Tuple, Type
|
22
|
+
from typing import TYPE_CHECKING, Annotated, Any, Callable, Self, Tuple, Type
|
22
23
|
|
23
24
|
import venusian
|
24
25
|
from fastapi import Depends, FastAPI
|
@@ -27,20 +28,22 @@ from fastapi import Response
|
|
27
28
|
from fastapi.params import Depends as DependsType
|
28
29
|
from fastapi.staticfiles import StaticFiles
|
29
30
|
from fastapi.types import IncEx
|
30
|
-
from pydantic import BaseModel, Field
|
31
31
|
|
32
|
+
from fastlife.config.openapiextra import OpenApiTag
|
32
33
|
from fastlife.middlewares.base import AbstractMiddleware
|
33
34
|
from fastlife.request.request import Request
|
34
35
|
from fastlife.routing.route import Route
|
35
36
|
from fastlife.routing.router import Router
|
36
37
|
from fastlife.security.csrf import check_csrf
|
38
|
+
from fastlife.services.policy import check_permission
|
37
39
|
from fastlife.shared_utils.resolver import resolve
|
38
40
|
|
39
41
|
from .settings import Settings
|
40
42
|
|
41
43
|
if TYPE_CHECKING:
|
44
|
+
from fastlife.security.policy import AbstractSecurityPolicy # coverage: ignore
|
42
45
|
from fastlife.services.templates import (
|
43
|
-
AbstractTemplateRendererFactory, # coverage: ignore
|
46
|
+
AbstractTemplateRendererFactory, # coverage: ignore
|
44
47
|
)
|
45
48
|
|
46
49
|
from .registry import AppRegistry, LocaleNegociator # coverage: ignore
|
@@ -48,6 +51,8 @@ if TYPE_CHECKING:
|
|
48
51
|
log = logging.getLogger(__name__)
|
49
52
|
VENUSIAN_CATEGORY = "fastlife"
|
50
53
|
|
54
|
+
venusian_ignored_item = str | Callable[[str], bool]
|
55
|
+
|
51
56
|
|
52
57
|
class ConfigurationError(Exception):
|
53
58
|
"""
|
@@ -55,24 +60,53 @@ class ConfigurationError(Exception):
|
|
55
60
|
"""
|
56
61
|
|
57
62
|
|
58
|
-
|
59
|
-
"""
|
63
|
+
def rebuild_router(router: Router) -> Router:
|
64
|
+
"""
|
65
|
+
Fix the router.
|
60
66
|
|
61
|
-
|
62
|
-
"""link's description."""
|
63
|
-
url: str
|
64
|
-
"""link's URL."""
|
67
|
+
FastAPI routers has dependencies that are injected to routes where they are added.
|
65
68
|
|
69
|
+
It means that if you add a dependencies in the router after the route has
|
70
|
+
been added, then the dependencies is missing in the route added before.
|
66
71
|
|
67
|
-
|
68
|
-
"""OpenAPI tag object."""
|
72
|
+
To prenvents issues, we rebuild the router route with the dependency.
|
69
73
|
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
74
|
+
:param router: the router to rebuild
|
75
|
+
:return: a new router with fixed routes.
|
76
|
+
"""
|
77
|
+
if not router.dependencies:
|
78
|
+
return router
|
79
|
+
_router = Router(prefix=router.prefix)
|
80
|
+
_router.dependencies = router.dependencies
|
81
|
+
route: Route
|
82
|
+
for route in router.routes: # type: ignore
|
83
|
+
dependencies = [
|
84
|
+
dep for dep in route.dependencies if dep not in _router.dependencies
|
85
|
+
]
|
86
|
+
_router.add_api_route(
|
87
|
+
path=route.path,
|
88
|
+
endpoint=route.endpoint,
|
89
|
+
response_model=route.response_model,
|
90
|
+
status_code=route.status_code,
|
91
|
+
tags=route.tags,
|
92
|
+
dependencies=dependencies,
|
93
|
+
summary=route.summary,
|
94
|
+
description=route.description,
|
95
|
+
response_description=route.response_description,
|
96
|
+
deprecated=route.deprecated,
|
97
|
+
methods=route.methods,
|
98
|
+
operation_id=route.operation_id,
|
99
|
+
response_model_include=route.response_model_include,
|
100
|
+
response_model_exclude=route.response_model_exclude,
|
101
|
+
response_model_by_alias=route.response_model_by_alias,
|
102
|
+
response_model_exclude_unset=route.response_model_exclude_unset,
|
103
|
+
response_model_exclude_defaults=route.response_model_exclude_defaults,
|
104
|
+
response_model_exclude_none=route.response_model_exclude_none,
|
105
|
+
include_in_schema=route.include_in_schema,
|
106
|
+
name=route.name,
|
107
|
+
openapi_extra=route.openapi_extra,
|
108
|
+
)
|
109
|
+
return _router
|
76
110
|
|
77
111
|
|
78
112
|
class Configurator:
|
@@ -102,18 +136,35 @@ class Configurator:
|
|
102
136
|
self.api_description: str = ""
|
103
137
|
self.api_summary: str | None = None
|
104
138
|
|
105
|
-
self.
|
139
|
+
self._route_prefix: str = ""
|
140
|
+
self._routers: dict[str, Router] = defaultdict(Router)
|
141
|
+
self._security_policies: dict[str, "type[AbstractSecurityPolicy[Any]]"] = {}
|
142
|
+
|
106
143
|
self.scanner = venusian.Scanner(fastlife=self)
|
107
144
|
self.include("fastlife.views")
|
108
145
|
self.include("fastlife.middlewares")
|
109
146
|
|
147
|
+
@property
|
148
|
+
def _current_router(self) -> Router:
|
149
|
+
return self._routers[self._route_prefix]
|
150
|
+
|
110
151
|
def build_asgi_app(self) -> FastAPI:
|
111
152
|
"""
|
112
153
|
Build the app after configuration in order to start after beeing configured.
|
113
154
|
|
114
155
|
:return: FastAPI application.
|
115
156
|
"""
|
116
|
-
|
157
|
+
|
158
|
+
# register our main template renderer at then end, to ensure that
|
159
|
+
# if settings have been manipulated, everythins is taken into account.
|
160
|
+
self.add_renderer(
|
161
|
+
self.registry.settings.jinjax_file_ext,
|
162
|
+
resolve("fastlife.adapters.jinjax.renderer:JinjaxTemplateRenderer")(
|
163
|
+
self.registry.settings
|
164
|
+
),
|
165
|
+
)
|
166
|
+
|
167
|
+
app = FastAPI(
|
117
168
|
title=self.api_title,
|
118
169
|
version=self.api_version,
|
119
170
|
description=self.api_description,
|
@@ -125,48 +176,35 @@ class Configurator:
|
|
125
176
|
if self.tags
|
126
177
|
else None,
|
127
178
|
)
|
128
|
-
|
129
|
-
for _route in self.router.routes:
|
130
|
-
route = cast(Route, _route)
|
131
|
-
_app.router.add_api_route(
|
132
|
-
path=route.path,
|
133
|
-
endpoint=route.endpoint,
|
134
|
-
response_model=route.response_model,
|
135
|
-
status_code=route.status_code,
|
136
|
-
tags=route.tags,
|
137
|
-
dependencies=route.dependencies,
|
138
|
-
summary=route.summary,
|
139
|
-
description=route.description,
|
140
|
-
response_description=route.response_description,
|
141
|
-
deprecated=route.deprecated,
|
142
|
-
methods=route.methods,
|
143
|
-
operation_id=route.operation_id,
|
144
|
-
response_model_include=route.response_model_include,
|
145
|
-
response_model_exclude=route.response_model_exclude,
|
146
|
-
response_model_by_alias=route.response_model_by_alias,
|
147
|
-
response_model_exclude_unset=route.response_model_exclude_unset,
|
148
|
-
response_model_exclude_defaults=route.response_model_exclude_defaults,
|
149
|
-
response_model_exclude_none=route.response_model_exclude_none,
|
150
|
-
include_in_schema=route.include_in_schema,
|
151
|
-
name=route.name,
|
152
|
-
openapi_extra=route.openapi_extra,
|
153
|
-
)
|
179
|
+
app.router.route_class = Route
|
154
180
|
|
155
181
|
for middleware_class, options in self.middlewares:
|
156
|
-
|
182
|
+
app.add_middleware(middleware_class, **options) # type: ignore
|
157
183
|
|
158
184
|
for status_code_or_exc, exception_handler in self.exception_handlers:
|
159
|
-
|
185
|
+
app.add_exception_handler(status_code_or_exc, exception_handler)
|
186
|
+
|
187
|
+
for prefix, router in self._routers.items():
|
188
|
+
app.include_router(rebuild_router(router), prefix=prefix)
|
160
189
|
|
161
190
|
for route_path, directory, name in self.mounts:
|
162
|
-
|
163
|
-
return
|
191
|
+
app.mount(route_path, StaticFiles(directory=directory), name=name)
|
192
|
+
return app
|
164
193
|
|
165
|
-
def include(
|
194
|
+
def include(
|
195
|
+
self,
|
196
|
+
module: str | ModuleType,
|
197
|
+
route_prefix: str = "",
|
198
|
+
ignore: venusian_ignored_item | Sequence[venusian_ignored_item] | None = None,
|
199
|
+
) -> Self:
|
166
200
|
"""
|
167
201
|
Include a module in order to load its configuration.
|
168
202
|
|
169
|
-
It will
|
203
|
+
It will scan and load all the submodule until you add an ignore rule.
|
204
|
+
|
205
|
+
The `ignore` argument allows you to ignore certain modules.
|
206
|
+
If it is a scrint, it can be an absolute module name or a relative
|
207
|
+
one, if starts with a dot.
|
170
208
|
|
171
209
|
Here is an example.
|
172
210
|
|
@@ -182,6 +220,8 @@ class Configurator:
|
|
182
220
|
```
|
183
221
|
|
184
222
|
:param module: a module to include.
|
223
|
+
:param route_prefix: prepend all included route with a prefix
|
224
|
+
:param ignore: ignore submodules
|
185
225
|
"""
|
186
226
|
if isinstance(module, str):
|
187
227
|
package = None
|
@@ -190,7 +230,15 @@ class Configurator:
|
|
190
230
|
package = caller_module.__name__ if caller_module else "__main__"
|
191
231
|
|
192
232
|
module = importlib.import_module(module, package)
|
193
|
-
self.
|
233
|
+
old, self._route_prefix = self._route_prefix, route_prefix
|
234
|
+
try:
|
235
|
+
self.scanner.scan( # type: ignore
|
236
|
+
module,
|
237
|
+
categories=[VENUSIAN_CATEGORY],
|
238
|
+
ignore=ignore,
|
239
|
+
)
|
240
|
+
finally:
|
241
|
+
self._route_prefix = old
|
194
242
|
return self
|
195
243
|
|
196
244
|
def set_locale_negociator(self, locale_negociator: "LocaleNegociator") -> Self:
|
@@ -247,6 +295,27 @@ class Configurator:
|
|
247
295
|
self.middlewares.append((middleware_class, options))
|
248
296
|
return self
|
249
297
|
|
298
|
+
def set_security_policy(
|
299
|
+
self, security_policy: "type[AbstractSecurityPolicy[Any]]"
|
300
|
+
) -> Self:
|
301
|
+
"""
|
302
|
+
Set a security policy for the application.
|
303
|
+
|
304
|
+
```{important}
|
305
|
+
The security policy is **per route_prefix**.
|
306
|
+
It means that if the application is splitted via multiple
|
307
|
+
route_prefix using the {meth}`Configurator.include`, they
|
308
|
+
all have a distinct security policy. A secutity policy has
|
309
|
+
to be install by all of those include call.
|
310
|
+
|
311
|
+
:param security_policy: The security policy that will applied for the app
|
312
|
+
portion behind the route prefix.
|
313
|
+
```
|
314
|
+
"""
|
315
|
+
self._security_policies[self._route_prefix] = security_policy
|
316
|
+
self._current_router.dependencies.append(Depends(security_policy))
|
317
|
+
return self
|
318
|
+
|
250
319
|
def add_api_route(
|
251
320
|
self,
|
252
321
|
name: str,
|
@@ -296,9 +365,7 @@ class Configurator:
|
|
296
365
|
:param path: path of the route, use `{curly_brace}` to inject FastAPI Path
|
297
366
|
parameters.
|
298
367
|
:param endpoint: the function that will reveive the request.
|
299
|
-
:param permission: a permission to validate by the
|
300
|
-
:attr:`fastlife.config.settings.Settings.check_permission` function.
|
301
|
-
|
368
|
+
:param permission: a permission to validate by the security policy.
|
302
369
|
:param methods: restrict route to a list of http methods.
|
303
370
|
:param description:OpenAPI description for the route.
|
304
371
|
:param summary: OpenAPI summary for the route.
|
@@ -324,9 +391,9 @@ class Configurator:
|
|
324
391
|
"""
|
325
392
|
dependencies: list[DependsType] = []
|
326
393
|
if permission:
|
327
|
-
dependencies.append(Depends(
|
394
|
+
dependencies.append(Depends(check_permission(permission)))
|
328
395
|
|
329
|
-
self.
|
396
|
+
self._current_router.add_api_route(
|
330
397
|
path,
|
331
398
|
endpoint,
|
332
399
|
# response_model=response_model,
|
@@ -379,15 +446,13 @@ class Configurator:
|
|
379
446
|
:param path: path of the route, use `{curly_brace}` to inject FastAPI Path
|
380
447
|
parameters.
|
381
448
|
:param endpoint: the function that will reveive the request.
|
382
|
-
:param permission: a permission to validate by the
|
383
|
-
:attr:`fastlife.config.settings.Settings.check_permission` function.
|
384
|
-
|
449
|
+
:param permission: a permission to validate by the security policy.
|
385
450
|
:param methods: restrict route to a list of http methods.
|
386
451
|
:return: the configurator.
|
387
452
|
"""
|
388
453
|
dependencies: list[DependsType] = []
|
389
454
|
if permission:
|
390
|
-
dependencies.append(Depends(
|
455
|
+
dependencies.append(Depends(check_permission(permission)))
|
391
456
|
|
392
457
|
if template:
|
393
458
|
|
@@ -404,7 +469,7 @@ class Configurator:
|
|
404
469
|
|
405
470
|
endpoint = render
|
406
471
|
|
407
|
-
self.
|
472
|
+
self._current_router.add_api_route(
|
408
473
|
path,
|
409
474
|
endpoint,
|
410
475
|
status_code=status_code,
|
@@ -444,6 +509,11 @@ class Configurator:
|
|
444
509
|
"""
|
445
510
|
|
446
511
|
def exception_handler(request: BaseRequest, exc: Exception) -> Any:
|
512
|
+
# FastAPI exception handler does not provide our request object
|
513
|
+
# it seems like it is rebuild from the asgi scope. Even the router
|
514
|
+
# class is wrong.
|
515
|
+
# Until we store a security policy per rooter, we rebuild an
|
516
|
+
# incomplete request here.
|
447
517
|
request = Request(self.registry, request)
|
448
518
|
resp = handler(request, exc)
|
449
519
|
if isinstance(resp, Response):
|
@@ -479,6 +549,17 @@ class Configurator:
|
|
479
549
|
self.registry.renderers[f".{file_ext.lstrip('.')}"] = renderer # type: ignore
|
480
550
|
return self
|
481
551
|
|
552
|
+
def add_template_search_path(self, path: str) -> Self:
|
553
|
+
"""
|
554
|
+
Add a template search path directly from the code.
|
555
|
+
|
556
|
+
:param path: template path.
|
557
|
+
"""
|
558
|
+
self.registry.settings.template_search_path = (
|
559
|
+
f"{self.registry.settings.template_search_path},{path}"
|
560
|
+
)
|
561
|
+
return self
|
562
|
+
|
482
563
|
|
483
564
|
def configure(
|
484
565
|
wrapped: Callable[[Configurator], None],
|
@@ -0,0 +1,22 @@
|
|
1
|
+
"""Types for OpenAPI documentation."""
|
2
|
+
from pydantic import BaseModel, Field
|
3
|
+
|
4
|
+
|
5
|
+
class ExternalDocs(BaseModel):
|
6
|
+
"""OpenAPI externalDocs object."""
|
7
|
+
|
8
|
+
description: str
|
9
|
+
"""link's description."""
|
10
|
+
url: str
|
11
|
+
"""link's URL."""
|
12
|
+
|
13
|
+
|
14
|
+
class OpenApiTag(BaseModel):
|
15
|
+
"""OpenAPI tag object."""
|
16
|
+
|
17
|
+
name: str
|
18
|
+
"""name of the tag."""
|
19
|
+
description: str
|
20
|
+
"""explanation of the tag."""
|
21
|
+
external_docs: ExternalDocs | None = Field(alias="externalDocs", default=None)
|
22
|
+
"""external link to the doc."""
|
fastlife/config/registry.py
CHANGED
@@ -5,9 +5,7 @@ from fastapi import Depends
|
|
5
5
|
from fastapi import Request as FastAPIRequest
|
6
6
|
|
7
7
|
from fastlife.request.request import Request
|
8
|
-
from fastlife.security.policy import CheckPermission
|
9
8
|
from fastlife.services.translations import LocalizerFactory
|
10
|
-
from fastlife.shared_utils.resolver import resolve
|
11
9
|
|
12
10
|
if TYPE_CHECKING:
|
13
11
|
from fastlife.services.templates import ( # coverage: ignore
|
@@ -34,19 +32,13 @@ class AppRegistry:
|
|
34
32
|
|
35
33
|
settings: Settings
|
36
34
|
renderers: Mapping[str, "AbstractTemplateRendererFactory"]
|
37
|
-
check_permission: CheckPermission
|
38
35
|
locale_negociator: LocaleNegociator
|
39
36
|
localizer: LocalizerFactory
|
40
37
|
|
41
38
|
def __init__(self, settings: Settings) -> None:
|
42
39
|
self.settings = settings
|
43
|
-
self.check_permission = resolve(settings.check_permission)
|
44
40
|
self.locale_negociator = _default_negociator(self.settings)
|
45
|
-
self.renderers = {
|
46
|
-
f".{settings.jinjax_file_ext}": resolve(
|
47
|
-
"fastlife.adapters.jinjax.renderer:JinjaxTemplateRenderer"
|
48
|
-
)(settings),
|
49
|
-
}
|
41
|
+
self.renderers = {}
|
50
42
|
self.localizer = LocalizerFactory()
|
51
43
|
|
52
44
|
def get_renderer(self, template: str) -> "AbstractTemplateRendererFactory":
|
fastlife/config/resources.py
CHANGED
@@ -13,11 +13,12 @@ from typing import Any, Callable
|
|
13
13
|
import venusian
|
14
14
|
from fastapi.types import IncEx
|
15
15
|
|
16
|
+
from fastlife.config.openapiextra import ExternalDocs
|
17
|
+
|
16
18
|
from .configurator import (
|
17
19
|
VENUSIAN_CATEGORY,
|
18
20
|
ConfigurationError,
|
19
21
|
Configurator,
|
20
|
-
ExternalDocs,
|
21
22
|
OpenApiTag,
|
22
23
|
)
|
23
24
|
|
@@ -143,6 +144,7 @@ def resource(
|
|
143
144
|
|
144
145
|
|
145
146
|
def resource_view(
|
147
|
+
*,
|
146
148
|
permission: str | None = None,
|
147
149
|
status_code: int | None = None,
|
148
150
|
summary: str | None = None,
|
fastlife/config/settings.py
CHANGED
@@ -106,9 +106,6 @@ class Settings(BaseSettings):
|
|
106
106
|
domain_name: str = Field(default="", title="domain name where the app is served")
|
107
107
|
"""Domain name whre the app is served."""
|
108
108
|
|
109
|
-
check_permission: str = Field(default="fastlife.security.policy:check_permission")
|
110
|
-
"""Handler for checking permission set on any views using the configurator."""
|
111
|
-
|
112
109
|
decode_reverse_proxy_headers: bool = Field(default=True)
|
113
110
|
"""Ensure that the request object has information based on http proxy headers."""
|
114
111
|
|
fastlife/request/request.py
CHANGED
@@ -1,11 +1,15 @@
|
|
1
1
|
"""HTTP Request representation in a python object."""
|
2
2
|
|
3
|
-
from typing import TYPE_CHECKING
|
3
|
+
from typing import TYPE_CHECKING, Any
|
4
4
|
|
5
5
|
from fastapi import Request as FastAPIRequest
|
6
6
|
|
7
7
|
if TYPE_CHECKING:
|
8
8
|
from fastlife.config.registry import AppRegistry # coverage: ignore
|
9
|
+
from fastlife.security.policy import ( # coverage: ignore
|
10
|
+
AbstractSecurityPolicy,
|
11
|
+
HasPermission,
|
12
|
+
)
|
9
13
|
|
10
14
|
|
11
15
|
class Request(FastAPIRequest):
|
@@ -16,7 +20,30 @@ class Request(FastAPIRequest):
|
|
16
20
|
locale_name: str
|
17
21
|
"""Request locale used for the i18n of the response."""
|
18
22
|
|
23
|
+
security_policy: "AbstractSecurityPolicy[Any] | None"
|
24
|
+
"""Request locale used for the i18n of the response."""
|
25
|
+
|
19
26
|
def __init__(self, registry: "AppRegistry", request: FastAPIRequest) -> None:
|
20
27
|
super().__init__(request.scope, request.receive)
|
21
28
|
self.registry = registry
|
22
29
|
self.locale_name = registry.locale_negociator(self)
|
30
|
+
self.security_policy = None # build it from the ? registry
|
31
|
+
|
32
|
+
async def has_permission(
|
33
|
+
self, permission: str
|
34
|
+
) -> "HasPermission | type[HasPermission]":
|
35
|
+
"""
|
36
|
+
A helper to check that a user has the given permission.
|
37
|
+
|
38
|
+
Not that this method does not raised, it return a boolean like object.
|
39
|
+
It allows batch permission checks.
|
40
|
+
You might need to check multiple permissions in different contexts or
|
41
|
+
for different resources before raising an http error.
|
42
|
+
"""
|
43
|
+
if self.security_policy is None:
|
44
|
+
raise RuntimeError(
|
45
|
+
f"Request {self.url.path} require a security policy, "
|
46
|
+
"explicit fastlife.security.policy.InsecurePolicy is required."
|
47
|
+
)
|
48
|
+
|
49
|
+
return await self.security_policy.has_permission(permission)
|
fastlife/security/policy.py
CHANGED
@@ -1,26 +1,171 @@
|
|
1
1
|
"""Security policy."""
|
2
|
-
|
2
|
+
|
3
|
+
import abc
|
4
|
+
import logging
|
5
|
+
from typing import Any, Callable, Coroutine, Literal, TypeVar
|
6
|
+
from uuid import UUID
|
7
|
+
|
8
|
+
from fastapi import HTTPException
|
9
|
+
from starlette.status import HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN
|
10
|
+
from typing_extensions import Generic
|
11
|
+
|
12
|
+
from fastlife import Request
|
3
13
|
|
4
14
|
CheckPermissionHook = Callable[..., Coroutine[Any, Any, None]] | Callable[..., None]
|
5
15
|
CheckPermission = Callable[[str], CheckPermissionHook]
|
6
16
|
|
17
|
+
TUser = TypeVar("TUser")
|
18
|
+
|
19
|
+
log = logging.getLogger(__name__)
|
20
|
+
|
21
|
+
|
22
|
+
class Unauthorized(HTTPException):
|
23
|
+
"""An exception raised to stop a request exectution and return an HTTP Error."""
|
24
|
+
|
25
|
+
def __init__(
|
26
|
+
self,
|
27
|
+
status_code: int = HTTP_401_UNAUTHORIZED,
|
28
|
+
detail: str = "Unauthorized",
|
29
|
+
headers: dict[str, str] | None = None,
|
30
|
+
) -> None:
|
31
|
+
super().__init__(status_code, detail, headers)
|
32
|
+
|
33
|
+
|
34
|
+
class Forbidden(HTTPException):
|
35
|
+
"""An exception raised to stop a request exectution and return an HTTP Error."""
|
36
|
+
|
37
|
+
def __init__(
|
38
|
+
self,
|
39
|
+
status_code: int = HTTP_403_FORBIDDEN,
|
40
|
+
detail: str = "Forbidden",
|
41
|
+
headers: dict[str, str] | None = None,
|
42
|
+
) -> None:
|
43
|
+
super().__init__(status_code, detail, headers)
|
44
|
+
|
45
|
+
|
46
|
+
class BoolMeta(type):
|
47
|
+
def __bool__(cls) -> bool:
|
48
|
+
return cls.kind == "allowed" # type: ignore
|
49
|
+
|
50
|
+
def __repr__(cls) -> str:
|
51
|
+
return cls.reason # type: ignore
|
52
|
+
|
53
|
+
|
54
|
+
class HasPermission(int, metaclass=BoolMeta):
|
55
|
+
kind: Literal["allowed", "unauthenticated", "denied"]
|
56
|
+
reason: str
|
57
|
+
|
58
|
+
def __new__(cls, reason: str) -> "HasPermission":
|
59
|
+
instance = super().__new__(cls)
|
60
|
+
instance.reason = reason
|
61
|
+
return instance
|
62
|
+
|
63
|
+
def __repr__(self) -> str:
|
64
|
+
return self.reason
|
65
|
+
|
66
|
+
def __bool__(self) -> bool:
|
67
|
+
return self.kind == "allowed"
|
68
|
+
|
69
|
+
|
70
|
+
class Allowed(HasPermission):
|
71
|
+
"""Represent a permission check result that is allowed."""
|
72
|
+
|
73
|
+
kind = "allowed"
|
74
|
+
reason = "Allowed"
|
75
|
+
|
7
76
|
|
8
|
-
|
77
|
+
class Unauthenticated(HasPermission):
|
78
|
+
"""
|
79
|
+
Represent a permission check result that is not allowed due to
|
80
|
+
missing authentication mechanism.
|
9
81
|
"""
|
10
|
-
A closure that check that a user as the given permission_name.
|
11
82
|
|
12
|
-
|
13
|
-
|
83
|
+
kind = "unauthenticated"
|
84
|
+
reason = "Authentication required"
|
14
85
|
|
15
|
-
When the check permission is properly set in the settings., the hook is called
|
16
|
-
for every route added with a permission keyword.
|
17
|
-
{meth}`fastlife.config.configurator.Configurator.add_route`
|
18
86
|
|
19
|
-
|
20
|
-
|
87
|
+
class Denied(HasPermission):
|
88
|
+
"""
|
89
|
+
Represent a permission check result that is not allowed due to lack of permission.
|
21
90
|
"""
|
22
91
|
|
23
|
-
|
24
|
-
|
92
|
+
kind = "denied"
|
93
|
+
reason = "Access denied to this resource"
|
94
|
+
|
95
|
+
|
96
|
+
class AbstractSecurityPolicy(abc.ABC, Generic[TUser]):
|
97
|
+
"""Security policy base classe."""
|
98
|
+
|
99
|
+
Forbidden = Forbidden
|
100
|
+
"""The exception raised if the user identified is not granted."""
|
101
|
+
Unauthorized = Unauthorized
|
102
|
+
"""The exception raised if no user has been identified."""
|
103
|
+
|
104
|
+
def __init__(self, request: Request):
|
105
|
+
"""
|
106
|
+
Build the security policy.
|
107
|
+
|
108
|
+
When implementing a security policy, multiple parameters can be added
|
109
|
+
to the constructor as FastAPI dependencies, using the `Depends` FastAPI
|
110
|
+
annotation.
|
111
|
+
The security policy is installed has a depenency of the router that hold
|
112
|
+
a route prefix of the application.
|
113
|
+
"""
|
114
|
+
self.request = request
|
115
|
+
self.request.security_policy = self # we do backref to implement has_permission
|
116
|
+
|
117
|
+
@abc.abstractmethod
|
118
|
+
async def identity(self) -> TUser | None:
|
119
|
+
"""
|
120
|
+
Return app-specific user object or raise an HTTPException.
|
121
|
+
"""
|
122
|
+
|
123
|
+
@abc.abstractmethod
|
124
|
+
async def authenticated_userid(self) -> str | UUID | None:
|
125
|
+
"""
|
126
|
+
Return app-specific user object or raise an HTTPException.
|
127
|
+
"""
|
128
|
+
|
129
|
+
@abc.abstractmethod
|
130
|
+
async def has_permission(
|
131
|
+
self, permission: str
|
132
|
+
) -> HasPermission | type[HasPermission]:
|
133
|
+
"""Allow access to everything if signed in."""
|
134
|
+
|
135
|
+
@abc.abstractmethod
|
136
|
+
async def remember(self, user: TUser) -> None:
|
137
|
+
"""Save the user identity in the request session."""
|
138
|
+
|
139
|
+
@abc.abstractmethod
|
140
|
+
async def forget(self) -> None:
|
141
|
+
"""Destroy the request session."""
|
142
|
+
|
143
|
+
|
144
|
+
class InsecurePolicy(AbstractSecurityPolicy[None]):
|
145
|
+
"""
|
146
|
+
An implementation of the security policy made for explicit unsecured access.
|
147
|
+
|
148
|
+
Setting a permission on a view require a security policy, if not set, accessing
|
149
|
+
to a view will raise a RuntimeError. To bypass this error for testing purpose
|
150
|
+
or your own reason, the InsecurePolicy has to be set to the configurator.
|
151
|
+
"""
|
152
|
+
|
153
|
+
async def identity(self) -> None:
|
154
|
+
"""Nobodies is identified."""
|
155
|
+
return None
|
156
|
+
|
157
|
+
async def authenticated_userid(self) -> str | UUID:
|
158
|
+
"""An uuid mades of 0."""
|
159
|
+
return UUID(int=0)
|
160
|
+
|
161
|
+
async def has_permission(
|
162
|
+
self, permission: str
|
163
|
+
) -> HasPermission | type[HasPermission]:
|
164
|
+
"""Access is allways granted."""
|
165
|
+
return Allowed
|
166
|
+
|
167
|
+
async def remember(self, user: None) -> None:
|
168
|
+
"""Do nothing."""
|
25
169
|
|
26
|
-
|
170
|
+
async def forget(self) -> None:
|
171
|
+
"""Do nothing."""
|
@@ -0,0 +1,39 @@
|
|
1
|
+
"""Security policy."""
|
2
|
+
|
3
|
+
from typing import Any, Callable, Coroutine
|
4
|
+
|
5
|
+
CheckPermissionHook = Callable[..., Coroutine[Any, Any, None]] | Callable[..., None]
|
6
|
+
CheckPermission = Callable[[str], CheckPermissionHook]
|
7
|
+
|
8
|
+
|
9
|
+
def check_permission(permission_name: str) -> CheckPermissionHook:
|
10
|
+
"""
|
11
|
+
A closure that check that a user as the given permission_name.
|
12
|
+
|
13
|
+
Adding a permission on the route requires that a security policy has been
|
14
|
+
added using the method
|
15
|
+
{meth}`fastlife.config.configurator.Configurator.set_security_policy`
|
16
|
+
|
17
|
+
:param permission_name: a permission name set in a view to check access.
|
18
|
+
:return: a function that raise http exceptions or any configured exception here.
|
19
|
+
"""
|
20
|
+
# the check_permission is called by the configurator
|
21
|
+
# and the request is exposed in the public module creating a circular dependency.
|
22
|
+
from fastlife import Request # a type must be resolved to inject a dependency.
|
23
|
+
|
24
|
+
async def depencency_injection(request: Request) -> None:
|
25
|
+
if request.security_policy is None:
|
26
|
+
raise RuntimeError(
|
27
|
+
f"Request {request.url.path} require a security policy, "
|
28
|
+
"explicit fastlife.security.policy.InsecurePolicy is required"
|
29
|
+
)
|
30
|
+
allowed = await request.security_policy.has_permission(permission_name)
|
31
|
+
match allowed.kind:
|
32
|
+
case "allowed":
|
33
|
+
return
|
34
|
+
case "denied":
|
35
|
+
raise request.security_policy.Forbidden(detail=allowed.reason)
|
36
|
+
case "unauthenticated":
|
37
|
+
raise request.security_policy.Unauthorized(detail=allowed.reason)
|
38
|
+
|
39
|
+
return depencency_injection
|
@@ -114,10 +114,10 @@ class LocalizerFactory:
|
|
114
114
|
for locale_name, domain, file_ in find_mo_files(root_path):
|
115
115
|
with file_.open("rb") as f:
|
116
116
|
t = Translations(f, domain)
|
117
|
-
if locale_name in self._translations:
|
118
|
-
self._translations[locale_name]
|
119
|
-
|
120
|
-
|
117
|
+
if locale_name not in self._translations:
|
118
|
+
self._translations[locale_name] = Translations()
|
119
|
+
self._translations[locale_name].add(t)
|
120
|
+
self._translations[locale_name].merge(t)
|
121
121
|
|
122
122
|
def __call__(self, request: "Request") -> Localizer:
|
123
123
|
"""Create the translation context for the given request."""
|
@@ -1666,11 +1666,12 @@ fastlife/components/pydantic_form/Textarea.jinja,sha256=NzfCi5agRUSVcb5RXw0QamM8
|
|
1666
1666
|
fastlife/components/pydantic_form/Union.jinja,sha256=czTska54z9KCZKu-FaycLmOvtH6y6CGUFQ8DHnkjrJk,1461
|
1667
1667
|
fastlife/components/pydantic_form/Widget.jinja,sha256=EXskDqt22D5grpGVwlZA3ndve2Wr_6yQH4qVE9c31Og,397
|
1668
1668
|
fastlife/config/__init__.py,sha256=O_Mw2XOxo55SArHdGKRhlTrroRN8ymwfzYKlHG0eV_s,418
|
1669
|
-
fastlife/config/configurator.py,sha256=
|
1669
|
+
fastlife/config/configurator.py,sha256=e0PeDn6S0swsXAJ79-E6zpnonrYwoFHsfEIEzVaCzls,21222
|
1670
1670
|
fastlife/config/exceptions.py,sha256=2MS2MFgb3mDbtdHEwnC-QYubob3Rl0v8O8y615LY0ds,1180
|
1671
|
-
fastlife/config/
|
1672
|
-
fastlife/config/
|
1673
|
-
fastlife/config/
|
1671
|
+
fastlife/config/openapiextra.py,sha256=_9rBYeTqB7nVuzvUHMwZU387bTfYFHYLlP05NP0vEDs,513
|
1672
|
+
fastlife/config/registry.py,sha256=Zm3i9ZfQZOP3vNKNKQ8nar7XevI91bqy2tUXX492Fuk,1749
|
1673
|
+
fastlife/config/resources.py,sha256=pM0j5VKVbVak4Z5mFRHBjAjUqORP4TAtCnZM3res5o8,8776
|
1674
|
+
fastlife/config/settings.py,sha256=FepHFZVHPLy3yA3dQux79GiH65MkipqWyT-zgXUBOKE,4028
|
1674
1675
|
fastlife/config/views.py,sha256=Dxi6lO4gFs6GriAW7Rh5GDvebwbrpS2HzYhf30pXJiE,2058
|
1675
1676
|
fastlife/middlewares/__init__.py,sha256=C3DUOzR5EhlAv5Zq7h-Abyvkd7bUsJohTRSB2wpRYQE,220
|
1676
1677
|
fastlife/middlewares/base.py,sha256=9OYqByRuVoIrLt353NOedPQTLdr7LSmxhb2BZcp20qk,638
|
@@ -1684,16 +1685,17 @@ fastlife/request/__init__.py,sha256=C5lZAsUZJDw0TfTegRyN5BEhzgfiqvfc8YXu2vp0siU,
|
|
1684
1685
|
fastlife/request/form.py,sha256=FucGua79LCKqNBP6Ycle7-5JU6EMI6SrHCgoJBcvGY4,3532
|
1685
1686
|
fastlife/request/form_data.py,sha256=yoP-AYF-dSClpCQuZNRTY-c1OnDga5MoTjBKIzgpTs8,4459
|
1686
1687
|
fastlife/request/localizer.py,sha256=9MXAcsod-Po5qeg4lttD3dyumiI0y5vGHCwSSmt9or8,349
|
1687
|
-
fastlife/request/request.py,sha256=
|
1688
|
+
fastlife/request/request.py,sha256=NiRtEV_iw8BuESQPf7gqXxiHsQwLiyOu1yzQy7Ck9eM,1779
|
1688
1689
|
fastlife/routing/__init__.py,sha256=8EMnQE5n8oA4J9_c3nxzwKDVt3tefZ6fGH0d2owE8mo,195
|
1689
1690
|
fastlife/routing/route.py,sha256=8RMnAqun8GTfWp6PFIWmWu-q-K89M3SiVSi2A-DYqyM,1405
|
1690
1691
|
fastlife/routing/router.py,sha256=bLZ4k2aDs4L423znwGnw-X2LnM36O8fjhDWc8q1WewI,481
|
1691
1692
|
fastlife/security/__init__.py,sha256=QYDcJ3oXQzqXQxoDD_6biGAtercFrtePttoifiL1j34,25
|
1692
1693
|
fastlife/security/csrf.py,sha256=PvC9Fqdb6c0IzzsnaMx2quQdjjKrb-nOPoAHfcwoAe8,2141
|
1693
|
-
fastlife/security/policy.py,sha256=
|
1694
|
+
fastlife/security/policy.py,sha256=3aENmA_plxHbnUKzsIWtCAboT3oyVew0dcJKDrrILqE,4880
|
1694
1695
|
fastlife/services/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
1696
|
+
fastlife/services/policy.py,sha256=ZK4K3fVGT1fUeBdo5sSWnEcJg49CZuaI3z2gyg3KguQ,1653
|
1695
1697
|
fastlife/services/templates.py,sha256=7gPJxGWD-XqputbZpy_Icsz3WHKJaWg2JgkVOeKrjfA,3840
|
1696
|
-
fastlife/services/translations.py,sha256=
|
1698
|
+
fastlife/services/translations.py,sha256=Bo5CIjdbQ3g_ihbv4Bz60hzd8VOtqEEPOyhJEbGcvP4,4363
|
1697
1699
|
fastlife/shared_utils/__init__.py,sha256=i66ytuf-Ezo7jSiNQHIsBMVIcB-tDX0tg28-pUOlhzE,26
|
1698
1700
|
fastlife/shared_utils/infer.py,sha256=CJjsL_F5OQRG7-0_89MQiZhyd7IcMGyirlQhjtcaIT4,728
|
1699
1701
|
fastlife/shared_utils/resolver.py,sha256=BRU88Ej4oA1iDIyG4Z6T7Q9WFvPHiMm6zuSh623312A,1725
|
@@ -1704,7 +1706,7 @@ fastlife/testing/__init__.py,sha256=vuqwoNUd3BuIp3fm7nkvmYkIGjIimf5zUGhDkeWrg2s,
|
|
1704
1706
|
fastlife/testing/testclient.py,sha256=BC7lLQ_jc59UmknAKzgRtW9a3cpX_V_QLp9Mg2ScLA8,20546
|
1705
1707
|
fastlife/views/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
1706
1708
|
fastlife/views/pydantic_form.py,sha256=fvjk_5-JAugpBPxwD5GkxxsQiz9eAxzWeCSU9kiyc6s,1438
|
1707
|
-
fastlifeweb-0.
|
1708
|
-
fastlifeweb-0.
|
1709
|
-
fastlifeweb-0.
|
1710
|
-
fastlifeweb-0.
|
1709
|
+
fastlifeweb-0.15.0.dist-info/LICENSE,sha256=F75xSseSKMwqzFj8rswYU6NWS3VoWOc_gY3fJYf9_LI,1504
|
1710
|
+
fastlifeweb-0.15.0.dist-info/METADATA,sha256=_1D_y-_GWIJCaw4ZbZs9LT7O4nfhwdQ4p9Kzlo0BAY4,3278
|
1711
|
+
fastlifeweb-0.15.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
|
1712
|
+
fastlifeweb-0.15.0.dist-info/RECORD,,
|
File without changes
|
File without changes
|