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.
@@ -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
+ }