konklav-fastapi 1.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- konklav_fastapi-1.5.0/LICENSE +21 -0
- konklav_fastapi-1.5.0/PKG-INFO +49 -0
- konklav_fastapi-1.5.0/PYPI.md +30 -0
- konklav_fastapi-1.5.0/pyproject.toml +47 -0
- konklav_fastapi-1.5.0/src/konklav_fastapi/__init__.py +91 -0
- konklav_fastapi-1.5.0/src/konklav_fastapi/_routing.py +132 -0
- konklav_fastapi-1.5.0/src/konklav_fastapi/errors.py +144 -0
- konklav_fastapi-1.5.0/src/konklav_fastapi/py.typed +0 -0
- konklav_fastapi-1.5.0/src/konklav_fastapi/routes.py +504 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Albin Skott
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: konklav-fastapi
|
|
3
|
+
Version: 1.5.0
|
|
4
|
+
Summary: Konklav route policies for FastAPI: declare requirements on routes and routers, decided by the relying core
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Classifier: Framework :: FastAPI
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Topic :: Security
|
|
10
|
+
Classifier: Typing :: Typed
|
|
11
|
+
Requires-Dist: konklav-relying==1.5.0
|
|
12
|
+
Requires-Dist: fastapi>=0.143,<0.163
|
|
13
|
+
Requires-Python: >=3.12
|
|
14
|
+
Project-URL: Changelog, https://github.com/cstruct/konklav/releases
|
|
15
|
+
Project-URL: Documentation, https://github.com/cstruct/konklav/tree/main/items/py-fastapi
|
|
16
|
+
Project-URL: Issues, https://github.com/cstruct/konklav/issues
|
|
17
|
+
Project-URL: Repository, https://github.com/cstruct/konklav
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# konklav-fastapi
|
|
21
|
+
|
|
22
|
+
[Konklav](https://github.com/cstruct/konklav) route policies for FastAPI.
|
|
23
|
+
|
|
24
|
+
Declare an operation's requirements as a `RoutePolicy` on routes, routers
|
|
25
|
+
and `include_router`; install `KonklavRoutes` once with your started
|
|
26
|
+
`RelyingClient` from [`konklav-relying`](https://pypi.org/project/konklav-relying/),
|
|
27
|
+
and every request is decided by the relying core with one call.
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from fastapi import APIRouter
|
|
31
|
+
from konklav_fastapi import KonklavRoutes, RoutePolicy, TenantToken
|
|
32
|
+
|
|
33
|
+
router = APIRouter(dependencies=[RoutePolicy(tenant_actions={"project:read"})])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@router.get("/projects/{project}", dependencies=[RoutePolicy(scope_actions={"project": {"document:read"}})])
|
|
37
|
+
async def read_project(project: str, token: TenantToken) -> dict[str, str]:
|
|
38
|
+
return {"project": project, "subject": token.sub}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Startup validation refuses routes without a policy, policies that can't be
|
|
42
|
+
decided, and token dependencies the policy doesn't match. Each operation's
|
|
43
|
+
merged policy is published in OpenAPI as `x-konklav-policy`.
|
|
44
|
+
|
|
45
|
+
## Versions
|
|
46
|
+
|
|
47
|
+
The package has the same version as the Konklav server it was released
|
|
48
|
+
with, and requires `konklav-relying` of that version. It is supported only
|
|
49
|
+
against a server of the same version.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# konklav-fastapi
|
|
2
|
+
|
|
3
|
+
[Konklav](https://github.com/cstruct/konklav) route policies for FastAPI.
|
|
4
|
+
|
|
5
|
+
Declare an operation's requirements as a `RoutePolicy` on routes, routers
|
|
6
|
+
and `include_router`; install `KonklavRoutes` once with your started
|
|
7
|
+
`RelyingClient` from [`konklav-relying`](https://pypi.org/project/konklav-relying/),
|
|
8
|
+
and every request is decided by the relying core with one call.
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
from fastapi import APIRouter
|
|
12
|
+
from konklav_fastapi import KonklavRoutes, RoutePolicy, TenantToken
|
|
13
|
+
|
|
14
|
+
router = APIRouter(dependencies=[RoutePolicy(tenant_actions={"project:read"})])
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@router.get("/projects/{project}", dependencies=[RoutePolicy(scope_actions={"project": {"document:read"}})])
|
|
18
|
+
async def read_project(project: str, token: TenantToken) -> dict[str, str]:
|
|
19
|
+
return {"project": project, "subject": token.sub}
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Startup validation refuses routes without a policy, policies that can't be
|
|
23
|
+
decided, and token dependencies the policy doesn't match. Each operation's
|
|
24
|
+
merged policy is published in OpenAPI as `x-konklav-policy`.
|
|
25
|
+
|
|
26
|
+
## Versions
|
|
27
|
+
|
|
28
|
+
The package has the same version as the Konklav server it was released
|
|
29
|
+
with, and requires `konklav-relying` of that version. It is supported only
|
|
30
|
+
against a server of the same version.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build>=0.8.24,<0.9"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "konklav-fastapi"
|
|
7
|
+
# Set by `mise run release:bump`, with the rest of the release, as is the
|
|
8
|
+
# exact requirement on konklav-relying.
|
|
9
|
+
version = "1.5.0"
|
|
10
|
+
description = "Konklav route policies for FastAPI: declare requirements on routes and routers, decided by the relying core"
|
|
11
|
+
readme = "PYPI.md"
|
|
12
|
+
license = "MIT"
|
|
13
|
+
license-files = ["LICENSE"]
|
|
14
|
+
requires-python = ">=3.12"
|
|
15
|
+
# The FastAPI range is tested at both ends in CI: this package reads how
|
|
16
|
+
# FastAPI keeps the routes of included routers (see _routing.py).
|
|
17
|
+
dependencies = ["konklav-relying==1.5.0", "fastapi>=0.143,<0.163"]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Framework :: FastAPI",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Topic :: Security",
|
|
22
|
+
"Typing :: Typed",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Repository = "https://github.com/cstruct/konklav"
|
|
27
|
+
Documentation = "https://github.com/cstruct/konklav/tree/main/items/py-fastapi"
|
|
28
|
+
Changelog = "https://github.com/cstruct/konklav/releases"
|
|
29
|
+
Issues = "https://github.com/cstruct/konklav/issues"
|
|
30
|
+
|
|
31
|
+
[dependency-groups]
|
|
32
|
+
dev = [
|
|
33
|
+
"cryptography>=45",
|
|
34
|
+
"httpx>=0.28",
|
|
35
|
+
"konklav-control-plane",
|
|
36
|
+
"mypy==1.20.2",
|
|
37
|
+
"pytest>=8",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[tool.uv.sources]
|
|
41
|
+
konklav-relying = { path = "../py" }
|
|
42
|
+
konklav-control-plane = { path = "../py-control-plane" }
|
|
43
|
+
|
|
44
|
+
[tool.mypy]
|
|
45
|
+
files = ["src"]
|
|
46
|
+
strict = true
|
|
47
|
+
python_version = "3.12"
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Konklav route policies for FastAPI.
|
|
3
|
+
|
|
4
|
+
`RoutePolicy` is a `konklav_relying.Policy` you declare as a FastAPI
|
|
5
|
+
dependency on routes, routers and `include_router`. `KonklavRoutes`, which
|
|
6
|
+
an application installs once, holds the started `RelyingClient` and what
|
|
7
|
+
only the application knows: how to find each scope kind's id, the tenant a
|
|
8
|
+
route addresses, and hooks for every verified token.
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
from fastapi import APIRouter, FastAPI
|
|
12
|
+
from konklav_fastapi import KonklavRoutes, RoutePolicy, TenantToken
|
|
13
|
+
|
|
14
|
+
router = APIRouter(dependencies=[RoutePolicy(tenant_actions={"project:read"})])
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@router.get("/projects/{project}", dependencies=[RoutePolicy(scope_actions={"project": {"document:read"}})])
|
|
18
|
+
async def read_project(project: str, token: TenantToken) -> dict[str, str]:
|
|
19
|
+
return {"project": project, "subject": token.sub}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
routes = KonklavRoutes()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@asynccontextmanager
|
|
26
|
+
async def lifespan(app: FastAPI):
|
|
27
|
+
routes.client = RelyingClient(...)
|
|
28
|
+
await routes.client.start()
|
|
29
|
+
yield
|
|
30
|
+
await routes.client.stop()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
app = FastAPI(lifespan=lifespan)
|
|
34
|
+
app.include_router(router)
|
|
35
|
+
routes.install(app)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Each request makes one decision with the route's merged policy; denials
|
|
39
|
+
raise `KonklavDenied` subclasses (`konklav_fastapi.errors`), answered with
|
|
40
|
+
401, 403 or 503 unless the application handles them itself.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
import importlib.metadata
|
|
44
|
+
|
|
45
|
+
from konklav_fastapi.errors import (
|
|
46
|
+
Forbidden,
|
|
47
|
+
KonklavDenied,
|
|
48
|
+
KonklavUnavailable,
|
|
49
|
+
RoutePolicyError,
|
|
50
|
+
StepUpRequired,
|
|
51
|
+
Unauthenticated,
|
|
52
|
+
handle_denied,
|
|
53
|
+
)
|
|
54
|
+
from konklav_fastapi.routes import (
|
|
55
|
+
CurrentToken,
|
|
56
|
+
IdentityToken,
|
|
57
|
+
KonklavRoutes,
|
|
58
|
+
OptionalToken,
|
|
59
|
+
ResolvedScope,
|
|
60
|
+
RoutePolicy,
|
|
61
|
+
RouteTenantResolver,
|
|
62
|
+
ScopeResolver,
|
|
63
|
+
TenantToken,
|
|
64
|
+
TokenHook,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# r[impl item.py-fastapi.pure-python]
|
|
68
|
+
# r[impl item.py-fastapi.same-version]
|
|
69
|
+
# The release sets the version and the exact requirement on konklav-relying
|
|
70
|
+
# in pyproject.toml; this package is plain Python.
|
|
71
|
+
__version__ = importlib.metadata.version("konklav-fastapi")
|
|
72
|
+
|
|
73
|
+
__all__ = [
|
|
74
|
+
"CurrentToken",
|
|
75
|
+
"Forbidden",
|
|
76
|
+
"IdentityToken",
|
|
77
|
+
"KonklavDenied",
|
|
78
|
+
"KonklavRoutes",
|
|
79
|
+
"KonklavUnavailable",
|
|
80
|
+
"OptionalToken",
|
|
81
|
+
"ResolvedScope",
|
|
82
|
+
"RoutePolicy",
|
|
83
|
+
"RoutePolicyError",
|
|
84
|
+
"RouteTenantResolver",
|
|
85
|
+
"ScopeResolver",
|
|
86
|
+
"StepUpRequired",
|
|
87
|
+
"TenantToken",
|
|
88
|
+
"TokenHook",
|
|
89
|
+
"Unauthenticated",
|
|
90
|
+
"handle_denied",
|
|
91
|
+
]
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""
|
|
2
|
+
How FastAPI keeps the routes of included routers, read in one place.
|
|
3
|
+
|
|
4
|
+
Since 0.140, `include_router` no longer copies routes. The application's
|
|
5
|
+
routes hold the included router itself, and FastAPI derives a context per
|
|
6
|
+
inclusion with the combined dependencies:
|
|
7
|
+
|
|
8
|
+
- an included HTTP route stays the router's own `APIRoute`; the request's
|
|
9
|
+
`scope["fastapi"]["effective_route_context"]` carries that inclusion's
|
|
10
|
+
combined dependencies, and `scope["route"]` is the original route;
|
|
11
|
+
- an included websocket route is rebuilt as a new `APIWebSocketRoute` with
|
|
12
|
+
the combined dependencies, which is the request's `scope["route"]`, and
|
|
13
|
+
the context's own list stays empty.
|
|
14
|
+
|
|
15
|
+
`APIRouter.add_api_route` copies the router's own dependencies in front of
|
|
16
|
+
the route's, so a route's own dependencies are what follows them.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
# r[impl item.py-fastapi.fastapi-range]
|
|
20
|
+
# What this module reads is why pyproject.toml bounds the FastAPI versions.
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from collections.abc import Callable, Iterator, Sequence
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from fastapi import FastAPI
|
|
28
|
+
from fastapi.dependencies.models import Dependant
|
|
29
|
+
from fastapi.params import Depends
|
|
30
|
+
from fastapi.requests import HTTPConnection
|
|
31
|
+
from fastapi.routing import APIRoute, APIRouter, APIWebSocketRoute, iter_route_contexts
|
|
32
|
+
from starlette.routing import BaseRoute
|
|
33
|
+
|
|
34
|
+
_SCOPE_KEY = "fastapi"
|
|
35
|
+
_CONTEXT_KEY = "effective_route_context"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class ServedRoute:
|
|
40
|
+
"""One route as FastAPI serves it, once per inclusion."""
|
|
41
|
+
|
|
42
|
+
original: APIRoute | APIWebSocketRoute
|
|
43
|
+
"""The route as its router declared it."""
|
|
44
|
+
dependencies: Sequence[Depends]
|
|
45
|
+
"""Every layer's, outermost first."""
|
|
46
|
+
own_dependencies: Sequence[Depends]
|
|
47
|
+
"""The route's own, without its router's."""
|
|
48
|
+
path_format: str
|
|
49
|
+
methods: frozenset[str]
|
|
50
|
+
"""Empty for a websocket."""
|
|
51
|
+
path_parameters: frozenset[str]
|
|
52
|
+
dependant: Dependant | None
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def is_websocket(self) -> bool:
|
|
56
|
+
return isinstance(self.original, APIWebSocketRoute)
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def label(self) -> str:
|
|
60
|
+
return f"{','.join(sorted(self.methods)) or 'WEBSOCKET'} {self.path_format}"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def owners(router: APIRouter) -> dict[int, tuple[BaseRoute, APIRouter]]:
|
|
64
|
+
"""Each API and websocket route by `id`, with the router that declared it."""
|
|
65
|
+
found: dict[int, tuple[BaseRoute, APIRouter]] = {}
|
|
66
|
+
for route in router.routes:
|
|
67
|
+
included = getattr(route, "original_router", None)
|
|
68
|
+
if isinstance(included, APIRouter):
|
|
69
|
+
found.update(owners(included))
|
|
70
|
+
elif isinstance(route, APIRoute | APIWebSocketRoute):
|
|
71
|
+
found[id(route)] = (route, router)
|
|
72
|
+
return found
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def own_dependencies(route: APIRoute | APIWebSocketRoute, router: APIRouter | None) -> Sequence[Depends]:
|
|
76
|
+
declared = list(route.dependencies)
|
|
77
|
+
inherited = list(router.dependencies) if router is not None else []
|
|
78
|
+
if declared[: len(inherited)] == inherited:
|
|
79
|
+
return declared[len(inherited) :]
|
|
80
|
+
# The router's dependencies changed after the route was added: nothing
|
|
81
|
+
# can be told apart, so nothing counts as the route's own.
|
|
82
|
+
return []
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def served_routes(app: FastAPI) -> Iterator[ServedRoute]:
|
|
86
|
+
"""Every API and websocket route of `app`, once per inclusion."""
|
|
87
|
+
owner = owners(app.router)
|
|
88
|
+
for context in iter_route_contexts(app.routes):
|
|
89
|
+
original = context.original_route
|
|
90
|
+
if not isinstance(original, APIRoute | APIWebSocketRoute):
|
|
91
|
+
continue
|
|
92
|
+
served: Any = context
|
|
93
|
+
if isinstance(original, APIWebSocketRoute):
|
|
94
|
+
# 0.143 forwards to the inclusion's context, later versions to
|
|
95
|
+
# the rebuilt route.
|
|
96
|
+
rebuilt = getattr(context, "starlette_route", None)
|
|
97
|
+
if isinstance(rebuilt, APIWebSocketRoute):
|
|
98
|
+
served = rebuilt
|
|
99
|
+
_, router = owner.get(id(original), (original, None))
|
|
100
|
+
yield ServedRoute(
|
|
101
|
+
original=original,
|
|
102
|
+
dependencies=list(served.dependencies),
|
|
103
|
+
own_dependencies=own_dependencies(original, router),
|
|
104
|
+
path_format=served.path_format,
|
|
105
|
+
methods=frozenset(getattr(served, "methods", None) or ()) if isinstance(original, APIRoute) else frozenset(),
|
|
106
|
+
path_parameters=frozenset(served.param_convertors),
|
|
107
|
+
dependant=getattr(served, "dependant", None) or original.dependant,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def requested(connection: HTTPConnection) -> tuple[APIRoute | APIWebSocketRoute, Sequence[Depends]] | None:
|
|
112
|
+
"""The original route serving `connection` and its combined dependencies."""
|
|
113
|
+
scope = connection.scope
|
|
114
|
+
route = scope.get("route")
|
|
115
|
+
context: Any = scope.get(_SCOPE_KEY, {}).get(_CONTEXT_KEY)
|
|
116
|
+
if isinstance(route, APIWebSocketRoute):
|
|
117
|
+
original = context.original_route if context is not None and context.starlette_route is route else route
|
|
118
|
+
return (original, route.dependencies) if isinstance(original, APIWebSocketRoute) else None
|
|
119
|
+
if isinstance(route, APIRoute):
|
|
120
|
+
if context is not None and context.original_route is route:
|
|
121
|
+
return route, context.dependencies
|
|
122
|
+
return route, route.dependencies
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def dependency_calls(dependant: Dependant | None) -> Iterator[Callable[..., Any]]:
|
|
127
|
+
if dependant is None:
|
|
128
|
+
return
|
|
129
|
+
if dependant.call is not None:
|
|
130
|
+
yield dependant.call
|
|
131
|
+
for dependency in dependant.dependencies:
|
|
132
|
+
yield from dependency_calls(dependency)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""
|
|
2
|
+
What evaluating a route's policy can end in, other than an allow.
|
|
3
|
+
|
|
4
|
+
Each outcome is an exception, a subclass of `KonklavDenied`.
|
|
5
|
+
`KonklavRoutes.install` registers `handle_denied` for them, which answers
|
|
6
|
+
HTTP requests with the status and a JSON body and closes websockets with
|
|
7
|
+
code 1008. To answer differently, register your own handler for
|
|
8
|
+
`KonklavDenied` (or a subclass) after installing:
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
routes.install(app)
|
|
12
|
+
app.add_exception_handler(Forbidden, my_forbidden_handler)
|
|
13
|
+
```
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Any, ClassVar
|
|
19
|
+
|
|
20
|
+
from fastapi.requests import HTTPConnection
|
|
21
|
+
from fastapi.responses import JSONResponse
|
|
22
|
+
from fastapi.websockets import WebSocket
|
|
23
|
+
from konklav_relying import DenyReason, StepUp
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"Forbidden",
|
|
27
|
+
"KonklavDenied",
|
|
28
|
+
"KonklavUnavailable",
|
|
29
|
+
"RoutePolicyError",
|
|
30
|
+
"StepUpRequired",
|
|
31
|
+
"Unauthenticated",
|
|
32
|
+
"handle_denied",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class RoutePolicyError(ValueError):
|
|
37
|
+
"""Routes whose policies can't be evaluated, found by startup validation."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# r[impl item.py-fastapi.outcomes]
|
|
41
|
+
class KonklavDenied(Exception):
|
|
42
|
+
"""A request the route's policy doesn't allow."""
|
|
43
|
+
|
|
44
|
+
status_code: ClassVar[int] = 403
|
|
45
|
+
code: str
|
|
46
|
+
|
|
47
|
+
def __init__(self, code: str) -> None:
|
|
48
|
+
super().__init__(code)
|
|
49
|
+
self.code = code
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def headers(self) -> dict[str, str]:
|
|
53
|
+
return {}
|
|
54
|
+
|
|
55
|
+
def body(self) -> dict[str, Any]:
|
|
56
|
+
return {"error": {"code": self.code}}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Unauthenticated(KonklavDenied):
|
|
60
|
+
"""
|
|
61
|
+
No usable token: `missing_token`, `invalid_token` or
|
|
62
|
+
`invalid_dpop_proof`, with a `WWW-Authenticate` challenge. A refused
|
|
63
|
+
token is challenged in the scheme it was presented with (`scheme`, `DPoP`
|
|
64
|
+
or `Bearer`), a refused proof always with `DPoP`, and a missing token
|
|
65
|
+
with both.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
status_code = 401
|
|
69
|
+
|
|
70
|
+
def __init__(self, code: str, scheme: str | None = None) -> None:
|
|
71
|
+
super().__init__(code)
|
|
72
|
+
self.scheme = scheme
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def headers(self) -> dict[str, str]:
|
|
76
|
+
if self.code == "invalid_dpop_proof":
|
|
77
|
+
return {"WWW-Authenticate": 'DPoP error="invalid_dpop_proof"'}
|
|
78
|
+
if self.scheme is None:
|
|
79
|
+
return {"WWW-Authenticate": "Bearer, DPoP"}
|
|
80
|
+
return {"WWW-Authenticate": f'{self.scheme} error="{self.code}"'}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class Forbidden(KonklavDenied):
|
|
84
|
+
"""
|
|
85
|
+
A deny: `reason` is the decision's, or `token_tenant` when the route's
|
|
86
|
+
tenant isn't the token's.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
status_code = 403
|
|
90
|
+
|
|
91
|
+
def __init__(self, reason: DenyReason) -> None:
|
|
92
|
+
super().__init__(reason.code)
|
|
93
|
+
self.reason = reason
|
|
94
|
+
|
|
95
|
+
def body(self) -> dict[str, Any]:
|
|
96
|
+
# As Konklav writes deny reasons: a bare code, or the code with its fields.
|
|
97
|
+
details = dict(self.reason.details)
|
|
98
|
+
reason: Any = {self.reason.code: details} if details else self.reason.code
|
|
99
|
+
return {"error": {"code": "forbidden", "reason": reason}}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class StepUpRequired(KonklavDenied):
|
|
103
|
+
"""
|
|
104
|
+
Fresher or stronger authentication would allow the request: `step_up`
|
|
105
|
+
says what. Challenged in the token's `scheme` with
|
|
106
|
+
`error="insufficient_user_authentication"` (RFC 9470), and `max_age` when
|
|
107
|
+
the requirement has one. The methods it needs are `amr` values, which the
|
|
108
|
+
challenge's `acr_values` can't carry, so they are only in the body.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
status_code = 401
|
|
112
|
+
|
|
113
|
+
def __init__(self, step_up: StepUp, scheme: str = "Bearer") -> None:
|
|
114
|
+
super().__init__("step_up")
|
|
115
|
+
self.step_up = step_up
|
|
116
|
+
self.scheme = scheme
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def headers(self) -> dict[str, str]:
|
|
120
|
+
challenge = f'{self.scheme} error="insufficient_user_authentication"'
|
|
121
|
+
if self.step_up.max_age_seconds is not None:
|
|
122
|
+
challenge += f", max_age={self.step_up.max_age_seconds}"
|
|
123
|
+
return {"WWW-Authenticate": challenge}
|
|
124
|
+
|
|
125
|
+
def body(self) -> dict[str, Any]:
|
|
126
|
+
return {"error": {"code": "step_up", "requirement": self.step_up.to_json()}}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class KonklavUnavailable(KonklavDenied):
|
|
130
|
+
"""No decision could be reached, so the request is denied (fails closed)."""
|
|
131
|
+
|
|
132
|
+
status_code = 503
|
|
133
|
+
|
|
134
|
+
def __init__(self) -> None:
|
|
135
|
+
super().__init__("konklav_unavailable")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
async def handle_denied(connection: HTTPConnection, error: Exception) -> JSONResponse | None:
|
|
139
|
+
"""The default handler: a JSON answer, or for a websocket, a close with 1008."""
|
|
140
|
+
assert isinstance(error, KonklavDenied)
|
|
141
|
+
if isinstance(connection, WebSocket):
|
|
142
|
+
await connection.close(code=1008, reason=error.code)
|
|
143
|
+
return None
|
|
144
|
+
return JSONResponse(error.body(), status_code=error.status_code, headers=error.headers)
|
|
File without changes
|
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Route policies on FastAPI routes, evaluated by the relying core.
|
|
3
|
+
|
|
4
|
+
Declare a `RoutePolicy` on routes, routers or `include_router`, and install
|
|
5
|
+
one `KonklavRoutes` on the application:
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
router = APIRouter(dependencies=[RoutePolicy(tenant_actions={"project:read"})])
|
|
9
|
+
|
|
10
|
+
@router.post("/projects/{project}", dependencies=[RoutePolicy(scope_actions={"project": {"project:write"}})])
|
|
11
|
+
async def update_project(project: str, token: TenantToken) -> ...: ...
|
|
12
|
+
|
|
13
|
+
routes = KonklavRoutes(scope_resolvers={"project": project_resolver})
|
|
14
|
+
routes.install(app)
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Per request, the route's policies merge, outermost first; scope ids come
|
|
18
|
+
from the scope resolvers (or the path parameter of each kind's name); and
|
|
19
|
+
the token is verified once (`accept_request`) and one `decide_accepted_typed`
|
|
20
|
+
call decides.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
|
26
|
+
from contextlib import asynccontextmanager
|
|
27
|
+
from dataclasses import dataclass, field, replace
|
|
28
|
+
from functools import cache
|
|
29
|
+
from typing import Annotated, Any, ClassVar, Literal, cast
|
|
30
|
+
|
|
31
|
+
from fastapi import FastAPI
|
|
32
|
+
from fastapi.params import Depends
|
|
33
|
+
from fastapi.requests import HTTPConnection
|
|
34
|
+
from fastapi.websockets import WebSocket
|
|
35
|
+
from konklav_relying import (
|
|
36
|
+
AcceptedRequest,
|
|
37
|
+
AccessToken,
|
|
38
|
+
DenyReason,
|
|
39
|
+
InvalidDpopProof,
|
|
40
|
+
KonklavError,
|
|
41
|
+
Policy,
|
|
42
|
+
PolicyError,
|
|
43
|
+
RelyingClient,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
from konklav_fastapi import _routing
|
|
47
|
+
from konklav_fastapi.errors import (
|
|
48
|
+
Forbidden,
|
|
49
|
+
KonklavDenied,
|
|
50
|
+
KonklavUnavailable,
|
|
51
|
+
RoutePolicyError,
|
|
52
|
+
StepUpRequired,
|
|
53
|
+
Unauthenticated,
|
|
54
|
+
handle_denied,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
__all__ = [
|
|
58
|
+
"CurrentToken",
|
|
59
|
+
"IdentityToken",
|
|
60
|
+
"KonklavRoutes",
|
|
61
|
+
"OptionalToken",
|
|
62
|
+
"ResolvedScope",
|
|
63
|
+
"RouteTenantResolver",
|
|
64
|
+
"RoutePolicy",
|
|
65
|
+
"ScopeResolver",
|
|
66
|
+
"TenantToken",
|
|
67
|
+
"TokenHook",
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
TOKEN_KINDS = frozenset({"partial", "identity", "tenant", "reset_password", "invite"})
|
|
71
|
+
"""Every token kind Konklav issues."""
|
|
72
|
+
DEFAULT_TOKEN_KINDS = frozenset({"tenant"})
|
|
73
|
+
"""What a policy without `token_kinds` admits, as Konklav decides."""
|
|
74
|
+
|
|
75
|
+
_SCOPE_KEY = "konklav_fastapi"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class ResolvedScope:
|
|
80
|
+
"""
|
|
81
|
+
A scope a resolver found: its `id`, and optionally the scope `kind` the
|
|
82
|
+
decision uses, when the policy's kind stands for several, and the
|
|
83
|
+
scope's owner `tenant`, when it is another tenant's (then only shares
|
|
84
|
+
allow it).
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
id: str
|
|
88
|
+
kind: str | None = None
|
|
89
|
+
tenant: str | None = None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
type ScopeResolver = Callable[[HTTPConnection, AccessToken], Awaitable[str | ResolvedScope]]
|
|
93
|
+
"""Finds the id of one scope kind's scope for a request, given its verified token."""
|
|
94
|
+
|
|
95
|
+
type RouteTenantResolver = Callable[[HTTPConnection, AccessToken], Awaitable[str | None]]
|
|
96
|
+
"""The tenant a request addresses on its own, such as from its path, or `None`."""
|
|
97
|
+
|
|
98
|
+
type TokenHook = Callable[[HTTPConnection, AccessToken], Awaitable[None]]
|
|
99
|
+
"""Runs on every verified token, before handlers receive it; refuses it by raising."""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True)
|
|
103
|
+
class _Presented:
|
|
104
|
+
authorization: str
|
|
105
|
+
dpop: str | None
|
|
106
|
+
token: str
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def scheme(self) -> str:
|
|
110
|
+
"""The scheme to challenge a refused token in."""
|
|
111
|
+
return "DPoP" if self.authorization.partition(" ")[0].lower() == "dpop" else "Bearer"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True)
|
|
115
|
+
class _Outcome:
|
|
116
|
+
public: bool
|
|
117
|
+
token: AccessToken | None = None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# r[impl item.py-fastapi.shared-dependency]
|
|
121
|
+
async def _evaluate(connection: HTTPConnection) -> None:
|
|
122
|
+
"""The one dependency every `RoutePolicy` declares."""
|
|
123
|
+
if _SCOPE_KEY in connection.scope:
|
|
124
|
+
return
|
|
125
|
+
routes = KonklavRoutes.of(connection)
|
|
126
|
+
connection.scope[_SCOPE_KEY] = await routes._evaluate(connection)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# r[impl item.py-fastapi.route-policy]
|
|
130
|
+
@dataclass(frozen=True, kw_only=True)
|
|
131
|
+
class RoutePolicy(Policy, Depends):
|
|
132
|
+
"""
|
|
133
|
+
A route policy (`konklav_relying.policy.Policy`) to declare as a FastAPI
|
|
134
|
+
dependency, on a route, a router, `include_router` or the application.
|
|
135
|
+
|
|
136
|
+
Restrictive flags are admitted only by the policies declared on the
|
|
137
|
+
route itself: they intersect when merged, so a router can't admit one
|
|
138
|
+
for every route under it.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
# A factory: a function as a plain default would read back as a bound method.
|
|
142
|
+
dependency: Callable[..., Any] | None = field(default_factory=lambda: _evaluate, init=False, repr=False)
|
|
143
|
+
use_cache: bool = field(default=True, init=False, repr=False)
|
|
144
|
+
scope: Literal["function", "request"] | None = field(default=None, init=False, repr=False)
|
|
145
|
+
__hash__ = Policy.__hash__
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# r[impl item.py-fastapi.merge-order]
|
|
149
|
+
# r[impl item.py-fastapi.admit-flags-operation]
|
|
150
|
+
@cache
|
|
151
|
+
def _merged(layers: tuple[RoutePolicy, ...], own: tuple[RoutePolicy, ...]) -> Policy:
|
|
152
|
+
"""`layers` merged outermost first, admitting only the flags `own` admits."""
|
|
153
|
+
merged = Policy.merge(*layers)
|
|
154
|
+
if merged.is_public:
|
|
155
|
+
return merged
|
|
156
|
+
operation = Policy.merge(*own)
|
|
157
|
+
return replace(merged, admit_flags=None if operation.is_public else operation.admit_flags)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _policies(dependencies: Sequence[Depends]) -> tuple[RoutePolicy, ...]:
|
|
161
|
+
return tuple(dependency for dependency in dependencies if isinstance(dependency, RoutePolicy))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _method(connection: HTTPConnection) -> str:
|
|
165
|
+
# A websocket opens with a GET.
|
|
166
|
+
return "GET" if isinstance(connection, WebSocket) else str(connection.scope["method"])
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _url(connection: HTTPConnection) -> str:
|
|
170
|
+
url = connection.url
|
|
171
|
+
if url.scheme in ("ws", "wss"):
|
|
172
|
+
url = url.replace(scheme="https" if url.scheme == "wss" else "http")
|
|
173
|
+
return str(url)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@dataclass(kw_only=True)
|
|
177
|
+
class KonklavRoutes:
|
|
178
|
+
"""
|
|
179
|
+
What evaluating route policies needs from the application. Install it
|
|
180
|
+
once with `install`; set `client` to the started `RelyingClient`, such
|
|
181
|
+
as in the application's lifespan, before serving requests.
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
client: RelyingClient | None = None
|
|
185
|
+
scope_resolvers: Mapping[str, ScopeResolver] = field(default_factory=dict)
|
|
186
|
+
"""One per scope kind; a kind without one takes the path parameter of its name."""
|
|
187
|
+
route_tenant: RouteTenantResolver | None = None
|
|
188
|
+
"""When set, a route tenant that isn't the token's `tid` is denied before deciding."""
|
|
189
|
+
on_verified: Sequence[TokenHook] = ()
|
|
190
|
+
"""Run in order on every verified token."""
|
|
191
|
+
cookie: Callable[[HTTPConnection], str | None] | None = None
|
|
192
|
+
"""Reads the token from a cookie, for requests without an `Authorization` header."""
|
|
193
|
+
public_url: Callable[[HTTPConnection], str] | None = None
|
|
194
|
+
"""The URL the client addressed, behind a proxy; DPoP proofs are checked against it."""
|
|
195
|
+
|
|
196
|
+
_owners: dict[int, tuple[Any, Any]] = field(default_factory=dict, init=False, repr=False)
|
|
197
|
+
|
|
198
|
+
STATE_ATTRIBUTE: ClassVar[str] = "konklav_routes"
|
|
199
|
+
|
|
200
|
+
# -- installing -------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
def install(self, app: FastAPI) -> None:
|
|
203
|
+
"""
|
|
204
|
+
Makes `app`'s routes use this configuration, validates them when the
|
|
205
|
+
application starts, publishes each operation's policy in OpenAPI and
|
|
206
|
+
handles `KonklavDenied` with `handle_denied`.
|
|
207
|
+
"""
|
|
208
|
+
setattr(app.state, self.STATE_ATTRIBUTE, self)
|
|
209
|
+
# Starlette types handlers per connection kind; this one takes both.
|
|
210
|
+
app.add_exception_handler(KonklavDenied, cast(Any, handle_denied))
|
|
211
|
+
|
|
212
|
+
# r[impl item.py-fastapi.validation]
|
|
213
|
+
lifespan = app.router.lifespan_context
|
|
214
|
+
|
|
215
|
+
@asynccontextmanager
|
|
216
|
+
async def validated(application: Any) -> Any:
|
|
217
|
+
self.validate(app)
|
|
218
|
+
async with lifespan(application) as state:
|
|
219
|
+
yield state
|
|
220
|
+
|
|
221
|
+
app.router.lifespan_context = validated
|
|
222
|
+
|
|
223
|
+
# r[impl item.py-fastapi.openapi]
|
|
224
|
+
openapi = app.openapi
|
|
225
|
+
|
|
226
|
+
def described() -> dict[str, Any]:
|
|
227
|
+
if app.openapi_schema:
|
|
228
|
+
return app.openapi_schema
|
|
229
|
+
schema = openapi()
|
|
230
|
+
for served in _routing.served_routes(app):
|
|
231
|
+
operations = schema.get("paths", {}).get(served.path_format, {})
|
|
232
|
+
for method in served.methods:
|
|
233
|
+
if (operation := operations.get(method.lower())) is not None:
|
|
234
|
+
try:
|
|
235
|
+
operation["x-konklav-policy"] = self.describe(served)
|
|
236
|
+
except PolicyError:
|
|
237
|
+
pass
|
|
238
|
+
return schema
|
|
239
|
+
|
|
240
|
+
cast(Any, app).openapi = described
|
|
241
|
+
|
|
242
|
+
@classmethod
|
|
243
|
+
def of(cls, connection: HTTPConnection) -> KonklavRoutes:
|
|
244
|
+
"""The configuration installed on the application serving `connection`."""
|
|
245
|
+
routes = getattr(connection.app.state, cls.STATE_ATTRIBUTE, None)
|
|
246
|
+
if not isinstance(routes, KonklavRoutes):
|
|
247
|
+
raise RuntimeError("route policies need KonklavRoutes(...).install(app)")
|
|
248
|
+
return routes
|
|
249
|
+
|
|
250
|
+
# -- startup validation ---------------------------------------------
|
|
251
|
+
|
|
252
|
+
def validate(self, app: FastAPI) -> None:
|
|
253
|
+
"""
|
|
254
|
+
Checks every API and websocket route's policy.
|
|
255
|
+
|
|
256
|
+
Raises:
|
|
257
|
+
RoutePolicyError: listing every route that fails, and why.
|
|
258
|
+
"""
|
|
259
|
+
self._owners = _routing.owners(app.router)
|
|
260
|
+
errors = [
|
|
261
|
+
f"{served.label}: {problem}"
|
|
262
|
+
for served in _routing.served_routes(app)
|
|
263
|
+
if (problem := self._problem(served)) is not None
|
|
264
|
+
]
|
|
265
|
+
if errors:
|
|
266
|
+
raise RoutePolicyError("Routes without a valid route policy:\n" + "\n".join(errors))
|
|
267
|
+
|
|
268
|
+
def _problem(self, served: _routing.ServedRoute) -> str | None:
|
|
269
|
+
layers = _policies(served.dependencies)
|
|
270
|
+
own = _policies(served.own_dependencies)
|
|
271
|
+
if not layers:
|
|
272
|
+
return "declares no RoutePolicy; declare one, or RoutePolicy.public()"
|
|
273
|
+
inherited = layers[: len(layers) - len(own)]
|
|
274
|
+
if any(policy.admit_flags is not None for policy in inherited):
|
|
275
|
+
return "admit_flags set outside the route; only the route's own RoutePolicy can admit flags"
|
|
276
|
+
try:
|
|
277
|
+
policy = _merged(layers, own)
|
|
278
|
+
except PolicyError as error:
|
|
279
|
+
return str(error)
|
|
280
|
+
injected = [_INJECTED[call] for call in _routing.dependency_calls(served.dependant) if call in _INJECTED]
|
|
281
|
+
if policy.is_public:
|
|
282
|
+
for name, _ in injected:
|
|
283
|
+
if name != "OptionalToken":
|
|
284
|
+
return f"injects {name}, but its policy is public; use OptionalToken"
|
|
285
|
+
return None
|
|
286
|
+
if policy.token_kinds == frozenset():
|
|
287
|
+
return "merged policies admit no token kind"
|
|
288
|
+
if policy.step_up is not None and policy.step_up.allowed_methods == frozenset():
|
|
289
|
+
return "merged policies allow no step-up method"
|
|
290
|
+
for kind in sorted(policy.scope_kinds):
|
|
291
|
+
if kind not in self.scope_resolvers and kind not in served.path_parameters:
|
|
292
|
+
return f"needs a `{kind}` scope: configure a scope resolver or add a `{kind}` path parameter"
|
|
293
|
+
admitted = policy.token_kinds if policy.token_kinds is not None else DEFAULT_TOKEN_KINDS
|
|
294
|
+
for name, accepted in injected:
|
|
295
|
+
if not admitted <= accepted:
|
|
296
|
+
return (
|
|
297
|
+
f"injects {name}, which takes {', '.join(sorted(accepted))} tokens, "
|
|
298
|
+
f"but its policy admits {', '.join(sorted(admitted))}"
|
|
299
|
+
)
|
|
300
|
+
return None
|
|
301
|
+
|
|
302
|
+
# -- OpenAPI ------------------------------------------------------------
|
|
303
|
+
|
|
304
|
+
def describe(self, served: _routing.ServedRoute) -> dict[str, Any]:
|
|
305
|
+
"""The route's merged policy as published in `x-konklav-policy`."""
|
|
306
|
+
policy = _merged(_policies(served.dependencies), _policies(served.own_dependencies))
|
|
307
|
+
if policy.is_public:
|
|
308
|
+
return {"public": True}
|
|
309
|
+
requirements: dict[str, Any] = dict(policy.requirements({kind: "" for kind in policy.scope_kinds}))
|
|
310
|
+
if "scopes" in requirements:
|
|
311
|
+
requirements["scopes"] = [
|
|
312
|
+
{"kind": scope["kind"], "actions": scope["actions"]} for scope in requirements["scopes"]
|
|
313
|
+
]
|
|
314
|
+
return {"public": False, **requirements}
|
|
315
|
+
|
|
316
|
+
# -- evaluation -------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
def _policy(self, connection: HTTPConnection) -> Policy:
|
|
319
|
+
found = _routing.requested(connection)
|
|
320
|
+
if found is None:
|
|
321
|
+
raise RoutePolicyError("route policies only apply to API and websocket routes")
|
|
322
|
+
route, dependencies = found
|
|
323
|
+
layers = _policies(dependencies)
|
|
324
|
+
if not layers:
|
|
325
|
+
raise RoutePolicyError(f"{connection.url.path} declares no RoutePolicy")
|
|
326
|
+
if id(route) not in self._owners:
|
|
327
|
+
# Added after startup validation, or never validated.
|
|
328
|
+
self._owners = _routing.owners(connection.app.router)
|
|
329
|
+
_, router = self._owners.get(id(route), (route, None))
|
|
330
|
+
own = _routing.own_dependencies(route, router) if router is not None else []
|
|
331
|
+
return _merged(layers, _policies(own))
|
|
332
|
+
|
|
333
|
+
# r[impl item.py-fastapi.token-extraction]
|
|
334
|
+
def _presented(self, connection: HTTPConnection) -> _Presented | None:
|
|
335
|
+
dpop = connection.headers.get("dpop")
|
|
336
|
+
if authorization := connection.headers.get("authorization"):
|
|
337
|
+
return _Presented(authorization, dpop, authorization.partition(" ")[2].strip())
|
|
338
|
+
token = self.cookie(connection) if self.cookie is not None else None
|
|
339
|
+
if not token and isinstance(connection, WebSocket):
|
|
340
|
+
token = connection.query_params.get("token")
|
|
341
|
+
if not token:
|
|
342
|
+
return None
|
|
343
|
+
return _Presented(f"{'DPoP' if dpop else 'Bearer'} {token}", dpop, token)
|
|
344
|
+
|
|
345
|
+
def _url(self, connection: HTTPConnection) -> str:
|
|
346
|
+
return self.public_url(connection) if self.public_url is not None else _url(connection)
|
|
347
|
+
|
|
348
|
+
def _client(self) -> RelyingClient:
|
|
349
|
+
if self.client is None:
|
|
350
|
+
# Not started yet: fail closed.
|
|
351
|
+
raise KonklavUnavailable()
|
|
352
|
+
return self.client
|
|
353
|
+
|
|
354
|
+
async def _evaluate(self, connection: HTTPConnection) -> _Outcome:
|
|
355
|
+
policy = self._policy(connection)
|
|
356
|
+
# r[impl item.py-fastapi.public]
|
|
357
|
+
if policy.is_public:
|
|
358
|
+
return _Outcome(public=True)
|
|
359
|
+
presented = self._presented(connection)
|
|
360
|
+
if presented is None:
|
|
361
|
+
raise Unauthenticated("missing_token")
|
|
362
|
+
# r[impl item.py-fastapi.verify-then-decide]
|
|
363
|
+
accepted = await self._accept(connection, presented)
|
|
364
|
+
token = accepted.token
|
|
365
|
+
await self._verified(connection, token)
|
|
366
|
+
|
|
367
|
+
# r[impl item.py-fastapi.token-tenant]
|
|
368
|
+
# r[depends item.py.policy.token-tenant]
|
|
369
|
+
if self.route_tenant is not None:
|
|
370
|
+
tenant = await self.route_tenant(connection, token)
|
|
371
|
+
if tenant is not None and tenant != token.tid:
|
|
372
|
+
raise Forbidden(DenyReason(code="token_tenant"))
|
|
373
|
+
|
|
374
|
+
# r[impl item.py-fastapi.scope-resolvers]
|
|
375
|
+
ids: dict[str, str] = {}
|
|
376
|
+
owners: dict[str, str] = {}
|
|
377
|
+
kinds: dict[str, str] = {}
|
|
378
|
+
for kind in sorted(policy.scope_kinds):
|
|
379
|
+
resolver = self.scope_resolvers.get(kind)
|
|
380
|
+
if resolver is None:
|
|
381
|
+
ids[kind] = str(connection.path_params[kind])
|
|
382
|
+
continue
|
|
383
|
+
resolved = await resolver(connection, token)
|
|
384
|
+
if isinstance(resolved, str):
|
|
385
|
+
resolved = ResolvedScope(resolved)
|
|
386
|
+
ids[kind] = resolved.id
|
|
387
|
+
if resolved.tenant is not None:
|
|
388
|
+
owners[kind] = resolved.tenant
|
|
389
|
+
if resolved.kind is not None:
|
|
390
|
+
kinds[kind] = resolved.kind
|
|
391
|
+
requirements = policy.requirements(ids, scope_tenants=owners)
|
|
392
|
+
for scope in requirements.get("scopes", ()):
|
|
393
|
+
scope["kind"] = kinds.get(scope["kind"], scope["kind"])
|
|
394
|
+
|
|
395
|
+
# r[impl item.py-fastapi.single-decision]
|
|
396
|
+
try:
|
|
397
|
+
decision = await self._client().decide_accepted_typed(accepted, requirements)
|
|
398
|
+
except KonklavError:
|
|
399
|
+
raise KonklavUnavailable() from None
|
|
400
|
+
match decision.decision:
|
|
401
|
+
case "allow":
|
|
402
|
+
return _Outcome(public=False, token=token)
|
|
403
|
+
case "step_up":
|
|
404
|
+
assert decision.step_up is not None
|
|
405
|
+
raise StepUpRequired(decision.step_up, presented.scheme)
|
|
406
|
+
case _:
|
|
407
|
+
assert decision.reason is not None
|
|
408
|
+
if decision.reason.code in ("invalid_token", "invalid_dpop_proof"):
|
|
409
|
+
raise Unauthenticated(decision.reason.code, presented.scheme)
|
|
410
|
+
raise Forbidden(decision.reason)
|
|
411
|
+
|
|
412
|
+
async def _accept(self, connection: HTTPConnection, presented: _Presented) -> AcceptedRequest:
|
|
413
|
+
"""The presented token, its session and its DPoP proof, verified once."""
|
|
414
|
+
try:
|
|
415
|
+
accepted = await self._client().accept_request(
|
|
416
|
+
authorization=presented.authorization,
|
|
417
|
+
dpop=presented.dpop,
|
|
418
|
+
method=_method(connection),
|
|
419
|
+
url=self._url(connection),
|
|
420
|
+
)
|
|
421
|
+
except InvalidDpopProof:
|
|
422
|
+
raise Unauthenticated("invalid_dpop_proof") from None
|
|
423
|
+
except KonklavError:
|
|
424
|
+
raise KonklavUnavailable() from None
|
|
425
|
+
if accepted is None:
|
|
426
|
+
raise Unauthenticated("invalid_token", presented.scheme)
|
|
427
|
+
return accepted
|
|
428
|
+
|
|
429
|
+
# r[impl item.py-fastapi.hooks]
|
|
430
|
+
async def _verified(self, connection: HTTPConnection, token: AccessToken) -> None:
|
|
431
|
+
for hook in self.on_verified:
|
|
432
|
+
await hook(connection, token)
|
|
433
|
+
|
|
434
|
+
async def _verify(self, connection: HTTPConnection) -> AccessToken | None:
|
|
435
|
+
"""For public routes, which make no decision: a presented token, verified."""
|
|
436
|
+
presented = self._presented(connection)
|
|
437
|
+
if presented is None:
|
|
438
|
+
return None
|
|
439
|
+
try:
|
|
440
|
+
token = await self._client().verify_request_typed(
|
|
441
|
+
authorization=presented.authorization,
|
|
442
|
+
dpop=presented.dpop,
|
|
443
|
+
method=_method(connection),
|
|
444
|
+
url=self._url(connection),
|
|
445
|
+
)
|
|
446
|
+
except InvalidDpopProof:
|
|
447
|
+
raise Unauthenticated("invalid_dpop_proof") from None
|
|
448
|
+
except KonklavError:
|
|
449
|
+
raise KonklavUnavailable() from None
|
|
450
|
+
if token is None:
|
|
451
|
+
raise Unauthenticated("invalid_token", presented.scheme)
|
|
452
|
+
await self._verified(connection, token)
|
|
453
|
+
return token
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
# -- injected tokens ----------------------------------------------------------
|
|
457
|
+
|
|
458
|
+
_Evaluated = Annotated[None, Depends(_evaluate)]
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
# r[impl item.py-fastapi.injected-tokens]
|
|
462
|
+
async def _optional_token(connection: HTTPConnection, _: _Evaluated) -> AccessToken | None:
|
|
463
|
+
outcome: _Outcome = connection.scope[_SCOPE_KEY]
|
|
464
|
+
if outcome.public:
|
|
465
|
+
outcome = _Outcome(public=True, token=await KonklavRoutes.of(connection)._verify(connection))
|
|
466
|
+
connection.scope[_SCOPE_KEY] = replace(outcome, public=False)
|
|
467
|
+
return outcome.token
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
async def _current_token(token: Annotated[AccessToken | None, Depends(_optional_token)]) -> AccessToken:
|
|
471
|
+
if token is None:
|
|
472
|
+
raise Unauthenticated("missing_token")
|
|
473
|
+
return token
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _of_kind(kind: str) -> Callable[..., Awaitable[AccessToken]]:
|
|
477
|
+
async def token_of_kind(token: Annotated[AccessToken, Depends(_current_token)]) -> AccessToken:
|
|
478
|
+
if token.knd != kind:
|
|
479
|
+
raise Forbidden(DenyReason(code="token_kind"))
|
|
480
|
+
return token
|
|
481
|
+
|
|
482
|
+
token_of_kind.__name__ = f"_{kind}_token"
|
|
483
|
+
return token_of_kind
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
_identity_token = _of_kind("identity")
|
|
487
|
+
_tenant_token = _of_kind("tenant")
|
|
488
|
+
|
|
489
|
+
CurrentToken = Annotated[AccessToken, Depends(_current_token)]
|
|
490
|
+
"""The request's verified token, of any kind the route's policy admits."""
|
|
491
|
+
OptionalToken = Annotated[AccessToken | None, Depends(_optional_token)]
|
|
492
|
+
"""The request's verified token, or `None` on a public route without one."""
|
|
493
|
+
IdentityToken = Annotated[AccessToken, Depends(_identity_token)]
|
|
494
|
+
"""The request's verified `identity` token."""
|
|
495
|
+
TenantToken = Annotated[AccessToken, Depends(_tenant_token)]
|
|
496
|
+
"""The request's verified `tenant` token."""
|
|
497
|
+
|
|
498
|
+
# r[impl item.py-fastapi.injected-kinds]
|
|
499
|
+
_INJECTED: dict[Callable[..., Any], tuple[str, frozenset[str]]] = {
|
|
500
|
+
_optional_token: ("OptionalToken", TOKEN_KINDS),
|
|
501
|
+
_current_token: ("CurrentToken", TOKEN_KINDS),
|
|
502
|
+
_identity_token: ("IdentityToken", frozenset({"identity"})),
|
|
503
|
+
_tenant_token: ("TenantToken", frozenset({"tenant"})),
|
|
504
|
+
}
|