dirigent-server 0.9.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.
- dirigent_server/__init__.py +7 -0
- dirigent_server/app.py +171 -0
- dirigent_server/dependencies.py +51 -0
- dirigent_server/errors.py +116 -0
- dirigent_server/health.py +97 -0
- dirigent_server/logging.py +5 -0
- dirigent_server/pagination.py +56 -0
- dirigent_server/py.typed +0 -0
- dirigent_server/routes/__init__.py +79 -0
- dirigent_server/routes/alerts.py +289 -0
- dirigent_server/routes/auth.py +218 -0
- dirigent_server/routes/blocks.py +39 -0
- dirigent_server/routes/connections.py +294 -0
- dirigent_server/routes/hooks.py +114 -0
- dirigent_server/routes/pipelines.py +427 -0
- dirigent_server/routes/runs.py +818 -0
- dirigent_server/routes/schema.py +27 -0
- dirigent_server/routes/schemas.py +127 -0
- dirigent_server/routes/system.py +63 -0
- dirigent_server/routes/trigger_documents.py +109 -0
- dirigent_server/routes/triggers.py +537 -0
- dirigent_server/routes/users.py +226 -0
- dirigent_server/routes/workers.py +52 -0
- dirigent_server/security.py +186 -0
- dirigent_server/static/.gitkeep +0 -0
- dirigent_server/transactions.py +34 -0
- dirigent_server/ui.py +237 -0
- dirigent_server-0.9.0.dist-info/METADATA +17 -0
- dirigent_server-0.9.0.dist-info/RECORD +32 -0
- dirigent_server-0.9.0.dist-info/WHEEL +4 -0
- dirigent_server-0.9.0.dist-info/licenses/LICENSE +18 -0
- dirigent_server-0.9.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +631 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Local accounts: the listing, creation, edits, activation, password resets, and their tokens."""
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, HTTPException, Response, status
|
|
4
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
5
|
+
|
|
6
|
+
from dirigent_client.schemas import (
|
|
7
|
+
IssuedTokenOut,
|
|
8
|
+
Page,
|
|
9
|
+
PasswordResetRequest,
|
|
10
|
+
TokenOut,
|
|
11
|
+
TokenRequest,
|
|
12
|
+
UserIn,
|
|
13
|
+
UserOut,
|
|
14
|
+
UserUpdate,
|
|
15
|
+
)
|
|
16
|
+
from dirigent_core.auth import (
|
|
17
|
+
DuplicateEmail,
|
|
18
|
+
DuplicateUser,
|
|
19
|
+
LastAdmin,
|
|
20
|
+
WeakPassword,
|
|
21
|
+
activate_user,
|
|
22
|
+
create_user,
|
|
23
|
+
deactivate_user,
|
|
24
|
+
find_user,
|
|
25
|
+
issue_token,
|
|
26
|
+
list_tokens,
|
|
27
|
+
list_users,
|
|
28
|
+
reset_password,
|
|
29
|
+
revoke_token,
|
|
30
|
+
set_email,
|
|
31
|
+
set_role,
|
|
32
|
+
)
|
|
33
|
+
from dirigent_core.models import User
|
|
34
|
+
from dirigent_server.dependencies import SessionDep
|
|
35
|
+
from dirigent_server.pagination import DEFAULT_PAGE, AfterParam, LimitParam, clip, uuid_cursor
|
|
36
|
+
from dirigent_server.security import AdminDep
|
|
37
|
+
from dirigent_server.transactions import Transactional
|
|
38
|
+
|
|
39
|
+
router = APIRouter(route_class=Transactional, tags=["users"])
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def render(row: User) -> UserOut:
|
|
43
|
+
"""Render an account row for the API."""
|
|
44
|
+
return UserOut.model_validate(row, from_attributes=True)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def find(session: AsyncSession, username: str) -> User:
|
|
48
|
+
"""Read one account by name, or say this instance has no such account."""
|
|
49
|
+
row = await find_user(session, username)
|
|
50
|
+
if row is None:
|
|
51
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"no user named {username!r}")
|
|
52
|
+
return row
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@router.get("/users", operation_id="listUsers", summary="List accounts", response_model=Page[UserOut])
|
|
56
|
+
async def list_accounts(
|
|
57
|
+
session: SessionDep,
|
|
58
|
+
principal: AdminDep,
|
|
59
|
+
after: AfterParam = None,
|
|
60
|
+
limit: LimitParam = DEFAULT_PAGE,
|
|
61
|
+
) -> Page[UserOut]:
|
|
62
|
+
"""List every account."""
|
|
63
|
+
found = [render(row) for row in await list_users(session, after=after, limit=limit + 1)]
|
|
64
|
+
items, following = clip(found, limit, lambda row: row.username)
|
|
65
|
+
return Page(items=items, next=following)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@router.post(
|
|
69
|
+
"/users",
|
|
70
|
+
operation_id="createUser",
|
|
71
|
+
summary="Create an account",
|
|
72
|
+
response_model=UserOut,
|
|
73
|
+
status_code=status.HTTP_201_CREATED,
|
|
74
|
+
)
|
|
75
|
+
async def create_account(payload: UserIn, session: SessionDep, principal: AdminDep) -> UserOut:
|
|
76
|
+
"""Create an account with an Argon2id password hash."""
|
|
77
|
+
try:
|
|
78
|
+
user = await create_user(
|
|
79
|
+
session,
|
|
80
|
+
payload.username,
|
|
81
|
+
payload.password.get_secret_value(),
|
|
82
|
+
role=payload.role,
|
|
83
|
+
name=payload.name,
|
|
84
|
+
email=payload.email,
|
|
85
|
+
)
|
|
86
|
+
except (DuplicateEmail, DuplicateUser) as error:
|
|
87
|
+
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
|
|
88
|
+
except WeakPassword as error:
|
|
89
|
+
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(error)) from error
|
|
90
|
+
return render(user)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@router.patch("/users/{username}", operation_id="updateUser", summary="Update an account", response_model=UserOut)
|
|
94
|
+
async def update_account(
|
|
95
|
+
username: str,
|
|
96
|
+
payload: UserUpdate,
|
|
97
|
+
session: SessionDep,
|
|
98
|
+
principal: AdminDep,
|
|
99
|
+
) -> UserOut:
|
|
100
|
+
"""Change an account's display name, email or role; the username and the password are not editable."""
|
|
101
|
+
row = await find(session, username)
|
|
102
|
+
if payload.changing("name"):
|
|
103
|
+
row.name = payload.name
|
|
104
|
+
if payload.changing("email"):
|
|
105
|
+
try:
|
|
106
|
+
await set_email(session, row, payload.email)
|
|
107
|
+
except DuplicateEmail as error:
|
|
108
|
+
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
|
|
109
|
+
if payload.role is not None:
|
|
110
|
+
try:
|
|
111
|
+
await set_role(session, row, payload.role)
|
|
112
|
+
except LastAdmin as error:
|
|
113
|
+
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
|
|
114
|
+
await session.flush()
|
|
115
|
+
return render(row)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@router.post(
|
|
119
|
+
"/users/{username}/$deactivate",
|
|
120
|
+
operation_id="deactivateUser",
|
|
121
|
+
summary="Deactivate an account",
|
|
122
|
+
response_model=UserOut,
|
|
123
|
+
)
|
|
124
|
+
async def deactivate_account(username: str, session: SessionDep, principal: AdminDep) -> UserOut:
|
|
125
|
+
"""Bar an account from logging in and revoke the sessions it already holds."""
|
|
126
|
+
row = await find(session, username)
|
|
127
|
+
try:
|
|
128
|
+
await deactivate_user(session, row)
|
|
129
|
+
except LastAdmin as error:
|
|
130
|
+
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
|
|
131
|
+
return render(row)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@router.post(
|
|
135
|
+
"/users/{username}/$activate",
|
|
136
|
+
operation_id="activateUser",
|
|
137
|
+
summary="Activate an account",
|
|
138
|
+
response_model=UserOut,
|
|
139
|
+
)
|
|
140
|
+
async def activate_account(username: str, session: SessionDep, principal: AdminDep) -> UserOut:
|
|
141
|
+
"""Let an account log in again; the sessions it lost are not restored."""
|
|
142
|
+
return render(await activate_user(session, await find(session, username)))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@router.post(
|
|
146
|
+
"/users/{username}/$reset-password",
|
|
147
|
+
operation_id="resetPassword",
|
|
148
|
+
summary="Reset an account's password",
|
|
149
|
+
status_code=status.HTTP_204_NO_CONTENT,
|
|
150
|
+
)
|
|
151
|
+
async def reset_account_password(
|
|
152
|
+
username: str,
|
|
153
|
+
payload: PasswordResetRequest,
|
|
154
|
+
session: SessionDep,
|
|
155
|
+
principal: AdminDep,
|
|
156
|
+
) -> Response:
|
|
157
|
+
"""Set an account's password without presenting the old one, ending every session it holds and no API token."""
|
|
158
|
+
row = await find(session, username)
|
|
159
|
+
try:
|
|
160
|
+
await reset_password(session, row, payload.password.get_secret_value())
|
|
161
|
+
except WeakPassword as error:
|
|
162
|
+
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(error)) from error
|
|
163
|
+
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@router.get(
|
|
167
|
+
"/users/{username}/tokens",
|
|
168
|
+
operation_id="listUserTokens",
|
|
169
|
+
summary="List an account's API tokens",
|
|
170
|
+
response_model=Page[TokenOut],
|
|
171
|
+
)
|
|
172
|
+
async def list_account_tokens(
|
|
173
|
+
username: str,
|
|
174
|
+
session: SessionDep,
|
|
175
|
+
principal: AdminDep,
|
|
176
|
+
after: AfterParam = None,
|
|
177
|
+
limit: LimitParam = DEFAULT_PAGE,
|
|
178
|
+
) -> Page[TokenOut]:
|
|
179
|
+
"""List the API tokens one account holds, without their secrets; sessions are not listed."""
|
|
180
|
+
row = await find(session, username)
|
|
181
|
+
rows = await list_tokens(session, user_id=row.id, after=uuid_cursor(after), limit=limit + 1)
|
|
182
|
+
found = [TokenOut.model_validate(token, from_attributes=True) for token in rows]
|
|
183
|
+
items, following = clip(found, limit, lambda token: token.id)
|
|
184
|
+
return Page(items=items, next=following)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@router.post(
|
|
188
|
+
"/users/{username}/tokens",
|
|
189
|
+
operation_id="createUserToken",
|
|
190
|
+
summary="Create an API token for an account",
|
|
191
|
+
response_model=IssuedTokenOut,
|
|
192
|
+
status_code=status.HTTP_201_CREATED,
|
|
193
|
+
)
|
|
194
|
+
async def create_account_token(
|
|
195
|
+
username: str,
|
|
196
|
+
payload: TokenRequest,
|
|
197
|
+
session: SessionDep,
|
|
198
|
+
principal: AdminDep,
|
|
199
|
+
) -> IssuedTokenOut:
|
|
200
|
+
"""Mint a bearer token for another account and return its secret exactly once."""
|
|
201
|
+
row = await find(session, username)
|
|
202
|
+
issued = await issue_token(session, row, name=payload.name)
|
|
203
|
+
return IssuedTokenOut(
|
|
204
|
+
id=issued.id,
|
|
205
|
+
name=issued.name,
|
|
206
|
+
username=issued.username,
|
|
207
|
+
prefix=issued.prefix,
|
|
208
|
+
token=issued.secret.get_secret_value(),
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@router.delete(
|
|
213
|
+
"/users/{username}/tokens/{name}",
|
|
214
|
+
operation_id="revokeUserToken",
|
|
215
|
+
summary="Revoke an account's API token",
|
|
216
|
+
status_code=status.HTTP_204_NO_CONTENT,
|
|
217
|
+
)
|
|
218
|
+
async def revoke_account_token(username: str, name: str, session: SessionDep, principal: AdminDep) -> Response:
|
|
219
|
+
"""Revoke that account's live tokens of the given name."""
|
|
220
|
+
row = await find(session, username)
|
|
221
|
+
if not await revoke_token(session, user_id=row.id, name=name):
|
|
222
|
+
raise HTTPException(
|
|
223
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
224
|
+
detail=f"no live token named {name!r} for {username!r}",
|
|
225
|
+
)
|
|
226
|
+
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""The worker registry: who is alive, on what version, with which plugins and tags."""
|
|
2
|
+
|
|
3
|
+
import sqlalchemy as sa
|
|
4
|
+
from fastapi import APIRouter
|
|
5
|
+
|
|
6
|
+
from dirigent_client.enums import WorkerStatus
|
|
7
|
+
from dirigent_client.schemas import Page, WorkerOut
|
|
8
|
+
from dirigent_core.models import Worker, utcnow
|
|
9
|
+
from dirigent_core.registry import STALE_AFTER
|
|
10
|
+
from dirigent_server.dependencies import ServicesDep, SessionDep
|
|
11
|
+
from dirigent_server.pagination import DEFAULT_PAGE, AfterParam, LimitParam, clip
|
|
12
|
+
from dirigent_server.security import PrincipalDep
|
|
13
|
+
from dirigent_server.transactions import Transactional
|
|
14
|
+
|
|
15
|
+
router = APIRouter(route_class=Transactional, tags=["workers"])
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@router.get("/workers", operation_id="listWorkers", summary="List workers", response_model=Page[WorkerOut])
|
|
19
|
+
async def list_workers(
|
|
20
|
+
session: SessionDep,
|
|
21
|
+
services: ServicesDep,
|
|
22
|
+
principal: PrincipalDep,
|
|
23
|
+
after: AfterParam = None,
|
|
24
|
+
limit: LimitParam = DEFAULT_PAGE,
|
|
25
|
+
) -> Page[WorkerOut]:
|
|
26
|
+
"""List the worker registry, flagging anything stale or running different code."""
|
|
27
|
+
digest = services.host.catalog().digest
|
|
28
|
+
cutoff = utcnow() - STALE_AFTER
|
|
29
|
+
statement = sa.select(Worker).order_by(Worker.name).limit(limit + 1)
|
|
30
|
+
if after is not None:
|
|
31
|
+
statement = statement.where(Worker.name > after)
|
|
32
|
+
rows = await session.execute(statement)
|
|
33
|
+
found = [
|
|
34
|
+
WorkerOut(
|
|
35
|
+
id=row.id,
|
|
36
|
+
name=row.name,
|
|
37
|
+
hostname=row.hostname,
|
|
38
|
+
version=row.version,
|
|
39
|
+
status=row.status,
|
|
40
|
+
concurrency=row.concurrency,
|
|
41
|
+
tags=list(row.tags),
|
|
42
|
+
plugins=dict(row.plugins),
|
|
43
|
+
catalog_digest=row.catalog_digest,
|
|
44
|
+
code_matches_server=row.catalog_digest == digest,
|
|
45
|
+
stale=row.last_seen_at < cutoff and row.status is not WorkerStatus.STOPPED,
|
|
46
|
+
created_at=row.created_at,
|
|
47
|
+
last_seen_at=row.last_seen_at,
|
|
48
|
+
)
|
|
49
|
+
for row in rows.scalars()
|
|
50
|
+
]
|
|
51
|
+
items, following = clip(found, limit, lambda row: row.name)
|
|
52
|
+
return Page(items=items, next=following)
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Authentication and the three roles: a session cookie for the UI, a bearer token for automation.
|
|
2
|
+
|
|
3
|
+
Every route under the versioned API requires a principal. The only exemptions are the
|
|
4
|
+
health probes, the OpenAPI document and its viewers, and the login endpoint.
|
|
5
|
+
|
|
6
|
+
A viewer may read: every GET, the event streams, exports and reports, and whatever acts on
|
|
7
|
+
the caller's own credential. An operator may in addition define, apply, run, cancel, retry
|
|
8
|
+
and schedule. Anything that hands out authority -- accounts, API tokens, connections,
|
|
9
|
+
schemas -- and deleting a pipeline require admin.
|
|
10
|
+
|
|
11
|
+
The session cookie is an ambient credential: a browser attaches it to a request another
|
|
12
|
+
site's page caused. So a write that spends it has to say it came from this instance, which
|
|
13
|
+
is what the cross-site guard below requires. A bearer token is not ambient and is exempt.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from collections.abc import Awaitable, Callable
|
|
17
|
+
from typing import Annotated
|
|
18
|
+
|
|
19
|
+
from fastapi import FastAPI, HTTPException, Request, Response, Security, status
|
|
20
|
+
from fastapi.security import APIKeyCookie, HTTPAuthorizationCredentials, HTTPBearer
|
|
21
|
+
|
|
22
|
+
from dirigent_core.auth import Principal, resolve_token
|
|
23
|
+
from dirigent_core.config import Settings
|
|
24
|
+
from dirigent_core.logging import redact_path
|
|
25
|
+
from dirigent_server.dependencies import SessionDep
|
|
26
|
+
from dirigent_server.errors import answer, render
|
|
27
|
+
|
|
28
|
+
SESSION_COOKIE = "dirigent_session"
|
|
29
|
+
|
|
30
|
+
BEARER_PREFIX = "Bearer "
|
|
31
|
+
|
|
32
|
+
#: Must not hint at which credential would have worked.
|
|
33
|
+
UNAUTHENTICATED = "authentication required: present a bearer token or log in"
|
|
34
|
+
|
|
35
|
+
#: One detail for both role refusals, saying neither which role would have sufficed nor what
|
|
36
|
+
#: any role may do: a refusal is not the place to teach the permission model.
|
|
37
|
+
FORBIDDEN = "not permitted for your role"
|
|
38
|
+
|
|
39
|
+
#: Declared so the schemes reach the OpenAPI document; without them every operation renders
|
|
40
|
+
#: as open and a generated client has no place to put a token. ``auto_error=False`` on both,
|
|
41
|
+
#: so that :func:`require_principal` remains the one place a request is refused.
|
|
42
|
+
bearer_scheme = HTTPBearer(
|
|
43
|
+
scheme_name="bearerAuth",
|
|
44
|
+
description="An API token from `dg admin token create`, presented as `Authorization: Bearer <token>`.",
|
|
45
|
+
auto_error=False,
|
|
46
|
+
)
|
|
47
|
+
cookie_scheme = APIKeyCookie(
|
|
48
|
+
name=SESSION_COOKIE,
|
|
49
|
+
scheme_name="sessionCookie",
|
|
50
|
+
description="The http-only session cookie `POST /auth/login` sets, which the UI uses.",
|
|
51
|
+
auto_error=False,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
#: Methods that change state, and so may not be caused by another site's page.
|
|
56
|
+
UNSAFE_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
|
|
57
|
+
|
|
58
|
+
#: What a browser reports for a request that no other site initiated: a same-origin fetch,
|
|
59
|
+
#: or a typed URL, bookmark or redirect, which is ``none``.
|
|
60
|
+
SAME_SITE_FETCH = frozenset({"same-origin", "none"})
|
|
61
|
+
|
|
62
|
+
#: Login is guarded although it carries no session yet: a cross-site login POST logs a
|
|
63
|
+
#: person into an account the attacker controls, and every later action is attributed there.
|
|
64
|
+
LOGIN_PATH = "/auth/login"
|
|
65
|
+
|
|
66
|
+
CROSS_SITE = (
|
|
67
|
+
"this write was initiated by another site, and a session cookie may not be spent across "
|
|
68
|
+
"origins; automation authenticating with a bearer token is unaffected"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def presented_secret(request: Request) -> str | None:
|
|
73
|
+
"""Read the credential from the request: an Authorization header, or the session cookie."""
|
|
74
|
+
header = request.headers.get("authorization")
|
|
75
|
+
if header and header.startswith(BEARER_PREFIX):
|
|
76
|
+
return header[len(BEARER_PREFIX) :].strip() or None
|
|
77
|
+
return request.cookies.get(SESSION_COOKIE)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def optional_principal(
|
|
81
|
+
request: Request,
|
|
82
|
+
session: SessionDep,
|
|
83
|
+
_bearer: Annotated[HTTPAuthorizationCredentials | None, Security(bearer_scheme)] = None,
|
|
84
|
+
_cookie: Annotated[str | None, Security(cookie_scheme)] = None,
|
|
85
|
+
) -> Principal | None:
|
|
86
|
+
"""Resolve who is asking, or nothing when no usable credential was presented.
|
|
87
|
+
|
|
88
|
+
The two unused scheme parameters are what put the security schemes in the OpenAPI
|
|
89
|
+
document; the credential itself is read from the request.
|
|
90
|
+
"""
|
|
91
|
+
secret = presented_secret(request)
|
|
92
|
+
if secret is None:
|
|
93
|
+
return None
|
|
94
|
+
return await resolve_token(session, secret)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def require_principal(principal: Annotated[Principal | None, Security(optional_principal)]) -> Principal:
|
|
98
|
+
"""Refuse a request that carries no valid credential."""
|
|
99
|
+
if principal is None:
|
|
100
|
+
raise HTTPException(
|
|
101
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
102
|
+
detail=UNAUTHENTICATED,
|
|
103
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
104
|
+
)
|
|
105
|
+
return principal
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def require_operator(principal: Annotated[Principal, Security(require_principal)]) -> Principal:
|
|
109
|
+
"""Refuse a request from an account that may only read."""
|
|
110
|
+
if not principal.may_operate:
|
|
111
|
+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=FORBIDDEN)
|
|
112
|
+
return principal
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
async def require_admin(principal: Annotated[Principal, Security(require_principal)]) -> Principal:
|
|
116
|
+
"""Refuse a request from an account that may not manage accounts, tokens, or connections."""
|
|
117
|
+
if not principal.is_admin:
|
|
118
|
+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=FORBIDDEN)
|
|
119
|
+
return principal
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def initiated_elsewhere(request: Request) -> bool:
|
|
123
|
+
"""Whether a browser says another site caused this request.
|
|
124
|
+
|
|
125
|
+
Fetch metadata is the answer when the browser sends it; ``Origin`` is the fallback, and
|
|
126
|
+
a request carrying neither is not from a browser at all.
|
|
127
|
+
"""
|
|
128
|
+
site = request.headers.get("sec-fetch-site")
|
|
129
|
+
if site is not None:
|
|
130
|
+
return site not in SAME_SITE_FETCH
|
|
131
|
+
origin = request.headers.get("origin")
|
|
132
|
+
if origin is None:
|
|
133
|
+
return False
|
|
134
|
+
return origin.rstrip("/").lower() != f"{request.url.scheme}://{request.url.netloc}".lower()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def spends_the_cookie(request: Request, api_prefix: str) -> bool:
|
|
138
|
+
"""Whether this request is a write under the API that a session cookie would authenticate."""
|
|
139
|
+
if request.method not in UNSAFE_METHODS or not request.url.path.startswith(api_prefix):
|
|
140
|
+
return False
|
|
141
|
+
if request.url.path == f"{api_prefix.rstrip('/')}{LOGIN_PATH}":
|
|
142
|
+
return True
|
|
143
|
+
header = request.headers.get("authorization")
|
|
144
|
+
if header and header.startswith(BEARER_PREFIX):
|
|
145
|
+
return False
|
|
146
|
+
return SESSION_COOKIE in request.cookies
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def install_cross_site_guard(app: FastAPI, settings: Settings) -> None:
|
|
150
|
+
"""Refuse a state-changing request that a cookie would authenticate and another site caused.
|
|
151
|
+
|
|
152
|
+
Registered before the error handlers, so the version-header middleware they install wraps
|
|
153
|
+
this refusal too.
|
|
154
|
+
"""
|
|
155
|
+
prefix = settings.api_prefix
|
|
156
|
+
|
|
157
|
+
@app.middleware("http")
|
|
158
|
+
async def guard( # pyright: ignore[reportUnusedFunction]
|
|
159
|
+
request: Request, call_next: Callable[[Request], Awaitable[Response]]
|
|
160
|
+
) -> Response:
|
|
161
|
+
if spends_the_cookie(request, prefix) and initiated_elsewhere(request):
|
|
162
|
+
return answer(render(status.HTTP_403_FORBIDDEN, CROSS_SITE, instance=redact_path(request.url.path)))
|
|
163
|
+
return await call_next(request)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def set_session_cookie(response: Response, secret: str, *, max_age: int, secure: bool) -> None:
|
|
167
|
+
"""Attach a session cookie that JavaScript cannot read and another site cannot send."""
|
|
168
|
+
response.set_cookie(
|
|
169
|
+
SESSION_COOKIE,
|
|
170
|
+
secret,
|
|
171
|
+
max_age=max_age,
|
|
172
|
+
httponly=True,
|
|
173
|
+
samesite="lax",
|
|
174
|
+
secure=secure,
|
|
175
|
+
path="/",
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def clear_session_cookie(response: Response) -> None:
|
|
180
|
+
"""Remove the session cookie."""
|
|
181
|
+
response.delete_cookie(SESSION_COOKIE, path="/")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
PrincipalDep = Annotated[Principal, Security(require_principal)]
|
|
185
|
+
OperatorDep = Annotated[Principal, Security(require_operator)]
|
|
186
|
+
AdminDep = Annotated[Principal, Security(require_admin)]
|
|
File without changes
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Where a request's transaction is committed: before its response, not after."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Coroutine
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from fastapi import Request, Response
|
|
7
|
+
from fastapi.routing import APIRoute
|
|
8
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Transactional(APIRoute):
|
|
12
|
+
"""A route that commits its request's transaction before the response is sent.
|
|
13
|
+
|
|
14
|
+
The alternative is to commit in the exit of the dependency that opened it, which FastAPI
|
|
15
|
+
runs after the response has gone out. Two things follow from that, and both are wrong: a
|
|
16
|
+
client that reads straight back can miss the write it was just told about, and a commit
|
|
17
|
+
that fails does so after the client has a 2xx it will believe.
|
|
18
|
+
|
|
19
|
+
Committing here happens while the response can still be changed, so a commit that fails
|
|
20
|
+
is the error the client sees.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def get_route_handler(self) -> Callable[[Request], Coroutine[Any, Any, Response]]:
|
|
24
|
+
"""Wrap the handler so what it decided is durable before anybody is told."""
|
|
25
|
+
handle = super().get_route_handler()
|
|
26
|
+
|
|
27
|
+
async def commit_then_respond(request: Request) -> Response:
|
|
28
|
+
response = await handle(request)
|
|
29
|
+
session: AsyncSession | None = getattr(request.state, "session", None)
|
|
30
|
+
if session is not None and session.in_transaction():
|
|
31
|
+
await session.commit()
|
|
32
|
+
return response
|
|
33
|
+
|
|
34
|
+
return commit_then_respond
|