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,294 @@
|
|
|
1
|
+
"""Connections: coded credential records of a contributed kind, redacted in every response.
|
|
2
|
+
|
|
3
|
+
A connection kind's ``config_model`` marks its secret fields with ``SecretStr``, and that single
|
|
4
|
+
declaration is what makes the API redact them and the engine encrypt them. Nothing here ever
|
|
5
|
+
returns a secret: a write accepts one, an envelope stores it, a read replaces it with the
|
|
6
|
+
redaction marker, and no endpoint reveals a stored credential. An update that sends the marker
|
|
7
|
+
back keeps the secret it stands for, which reserves the literal marker as a secret value.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import sqlalchemy as sa
|
|
11
|
+
from fastapi import APIRouter, HTTPException, Response, status
|
|
12
|
+
from pydantic import ValidationError
|
|
13
|
+
from sqlalchemy.exc import IntegrityError
|
|
14
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
15
|
+
|
|
16
|
+
from dirigent_client.schemas import ConnectionIn, ConnectionOut, ConnectionUpdate, Page
|
|
17
|
+
from dirigent_common import HealthReport, JsonMap
|
|
18
|
+
from dirigent_core.engine.services import EngineServices
|
|
19
|
+
from dirigent_core.models import Connection, utcnow
|
|
20
|
+
from dirigent_core.secrets import REDACTED, SecretError, redact, secret_fields
|
|
21
|
+
from dirigent_server.dependencies import ServicesDep, SessionDep
|
|
22
|
+
from dirigent_server.logging import get_logger
|
|
23
|
+
from dirigent_server.pagination import DEFAULT_PAGE, AfterParam, LimitParam, clip
|
|
24
|
+
from dirigent_server.security import AdminDep, PrincipalDep
|
|
25
|
+
from dirigent_server.transactions import Transactional
|
|
26
|
+
|
|
27
|
+
router = APIRouter(route_class=Transactional, tags=["connections"])
|
|
28
|
+
|
|
29
|
+
#: What a raised check is allowed to say on a row that every principal can read.
|
|
30
|
+
CHECK_FAILED_HINT = "the check raised; the server log has the message"
|
|
31
|
+
|
|
32
|
+
_logger = get_logger("connections")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def summarise_failure(error: Exception) -> str:
|
|
36
|
+
"""Render a raised check for a row that everyone can read: the class, and nothing else.
|
|
37
|
+
|
|
38
|
+
A driver's exception message routinely embeds the whole connection string -- host, user,
|
|
39
|
+
often the password -- and ``last_check_detail`` is served to every principal by
|
|
40
|
+
``/system/info``. The class name still separates "wrong password" from "host
|
|
41
|
+
unreachable"; the message goes to the process log only. An unhealthy report a connection
|
|
42
|
+
kind returns of its own accord is untouched.
|
|
43
|
+
"""
|
|
44
|
+
return f"{type(error).__name__}: {CHECK_FAILED_HINT}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def render(row: Connection, services: EngineServices) -> ConnectionOut:
|
|
48
|
+
"""Render a stored connection with its secret fields replaced by the redaction marker."""
|
|
49
|
+
contributed = services.host.connection_kinds.get(row.kind)
|
|
50
|
+
model = contributed.config_model if contributed else None
|
|
51
|
+
fields = secret_fields(model) if model else []
|
|
52
|
+
visible = redact(model, dict(row.config)) if model else dict(row.config)
|
|
53
|
+
# A sealed field is not on the row at all, so the marker is put back: a form must know
|
|
54
|
+
# the credential is set without being told what it is.
|
|
55
|
+
sealed = REDACTED if row.secret_envelope is not None else None
|
|
56
|
+
for name in fields:
|
|
57
|
+
visible.setdefault(name, sealed)
|
|
58
|
+
return ConnectionOut(
|
|
59
|
+
id=row.id,
|
|
60
|
+
code=row.code,
|
|
61
|
+
name=row.name,
|
|
62
|
+
kind=row.kind,
|
|
63
|
+
description=row.description,
|
|
64
|
+
config=visible,
|
|
65
|
+
secret_fields=fields,
|
|
66
|
+
last_check_at=row.last_check_at,
|
|
67
|
+
last_check_healthy=row.last_check_healthy,
|
|
68
|
+
last_check_detail=row.last_check_detail,
|
|
69
|
+
created_at=row.created_at,
|
|
70
|
+
updated_at=row.updated_at,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def find(session: AsyncSession, code: str) -> Connection:
|
|
75
|
+
"""Read one connection by code, or say this instance has no such credential."""
|
|
76
|
+
found = await session.execute(sa.select(Connection).where(Connection.code == code))
|
|
77
|
+
row = found.scalar_one_or_none()
|
|
78
|
+
if row is None:
|
|
79
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"no connection coded {code!r}")
|
|
80
|
+
return row
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def restore_marked_secrets(row: Connection, config: JsonMap, services: EngineServices) -> JsonMap:
|
|
84
|
+
"""Put each stored secret back where an update carries the marker a read showed for it.
|
|
85
|
+
|
|
86
|
+
A read returns ``***`` for a secret that is set, so a form that edits what it read and
|
|
87
|
+
sends the whole document back means "keep this one" by the marker: the stored value is
|
|
88
|
+
substituted before the config is validated and sealed again. The marker with nothing
|
|
89
|
+
stored behind it is refused, because nothing distinguishes it from somebody choosing three
|
|
90
|
+
asterisks as a password. That reserves the literal marker: it cannot be stored as a secret.
|
|
91
|
+
"""
|
|
92
|
+
contributed = services.host.connection_kinds.get(row.kind)
|
|
93
|
+
if contributed is None:
|
|
94
|
+
return config
|
|
95
|
+
marked = [name for name in secret_fields(contributed.config_model) if config.get(name) == REDACTED]
|
|
96
|
+
if not marked:
|
|
97
|
+
return config
|
|
98
|
+
try:
|
|
99
|
+
stored = services.secrets.open(row.secret_envelope, key_id=row.secret_key_id)
|
|
100
|
+
except SecretError as error:
|
|
101
|
+
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
|
|
102
|
+
empty = sorted(name for name in marked if stored.get(name) is None)
|
|
103
|
+
if empty:
|
|
104
|
+
raise HTTPException(
|
|
105
|
+
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
106
|
+
detail=(
|
|
107
|
+
f"{', '.join(empty)} came back as {REDACTED!r}, which is what a read shows for a secret "
|
|
108
|
+
f"that is set, not a secret. Send the real value, or leave the field out to keep what is stored."
|
|
109
|
+
),
|
|
110
|
+
)
|
|
111
|
+
return {**config, **{name: stored[name] for name in marked}}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def seal(services: EngineServices, kind_id: str, config: JsonMap) -> tuple[JsonMap, bytes | None, str | None]:
|
|
115
|
+
"""Validate a config against its kind's model and split it into public and sealed halves."""
|
|
116
|
+
contributed = services.host.connection_kinds.get(kind_id)
|
|
117
|
+
if contributed is None:
|
|
118
|
+
known = ", ".join(sorted(services.host.connection_kinds)) or "none are installed"
|
|
119
|
+
raise HTTPException(
|
|
120
|
+
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
121
|
+
detail=f"no connection kind {kind_id!r} is installed ({known})",
|
|
122
|
+
)
|
|
123
|
+
try:
|
|
124
|
+
validated = contributed.config_model.model_validate(config)
|
|
125
|
+
except ValidationError as error:
|
|
126
|
+
# include_input=False: the input here is a credential, and pydantic's default error
|
|
127
|
+
# payload echoes the value that failed into the 422 body and whatever logs it.
|
|
128
|
+
raise HTTPException(
|
|
129
|
+
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
130
|
+
detail=error.errors(include_input=False),
|
|
131
|
+
) from error
|
|
132
|
+
try:
|
|
133
|
+
return services.secrets.encrypt_config(contributed.config_model, validated)
|
|
134
|
+
except SecretError as error:
|
|
135
|
+
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@router.get(
|
|
139
|
+
"/connections",
|
|
140
|
+
operation_id="listConnections",
|
|
141
|
+
summary="List connections",
|
|
142
|
+
response_model=Page[ConnectionOut],
|
|
143
|
+
)
|
|
144
|
+
async def list_connections(
|
|
145
|
+
session: SessionDep,
|
|
146
|
+
services: ServicesDep,
|
|
147
|
+
principal: PrincipalDep,
|
|
148
|
+
after: AfterParam = None,
|
|
149
|
+
limit: LimitParam = DEFAULT_PAGE,
|
|
150
|
+
) -> Page[ConnectionOut]:
|
|
151
|
+
"""List every stored credential record, with its secrets redacted."""
|
|
152
|
+
statement = sa.select(Connection).order_by(Connection.code).limit(limit + 1)
|
|
153
|
+
if after is not None:
|
|
154
|
+
statement = statement.where(Connection.code > after)
|
|
155
|
+
rows = await session.execute(statement)
|
|
156
|
+
found = [render(row, services) for row in rows.scalars()]
|
|
157
|
+
items, following = clip(found, limit, lambda row: row.code)
|
|
158
|
+
return Page(items=items, next=following)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@router.post(
|
|
162
|
+
"/connections",
|
|
163
|
+
operation_id="createConnection",
|
|
164
|
+
summary="Create a connection",
|
|
165
|
+
response_model=ConnectionOut,
|
|
166
|
+
status_code=status.HTTP_201_CREATED,
|
|
167
|
+
)
|
|
168
|
+
async def create_connection(
|
|
169
|
+
payload: ConnectionIn,
|
|
170
|
+
session: SessionDep,
|
|
171
|
+
services: ServicesDep,
|
|
172
|
+
principal: AdminDep,
|
|
173
|
+
) -> ConnectionOut:
|
|
174
|
+
"""Validate a credential against its kind, seal its secret half, and store it."""
|
|
175
|
+
existing = await session.execute(sa.select(Connection).where(Connection.code == payload.code))
|
|
176
|
+
if existing.scalar_one_or_none() is not None:
|
|
177
|
+
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=f"a connection coded {payload.code!r} exists")
|
|
178
|
+
public, envelope, key_id = seal(services, payload.kind, payload.config)
|
|
179
|
+
row = Connection(
|
|
180
|
+
code=payload.code,
|
|
181
|
+
name=payload.name,
|
|
182
|
+
kind=payload.kind,
|
|
183
|
+
description=payload.description,
|
|
184
|
+
config=public,
|
|
185
|
+
secret_envelope=envelope,
|
|
186
|
+
secret_key_id=key_id,
|
|
187
|
+
)
|
|
188
|
+
session.add(row)
|
|
189
|
+
await session.flush()
|
|
190
|
+
return render(row, services)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@router.get(
|
|
194
|
+
"/connections/{code}",
|
|
195
|
+
operation_id="getConnection",
|
|
196
|
+
summary="Read a connection",
|
|
197
|
+
response_model=ConnectionOut,
|
|
198
|
+
)
|
|
199
|
+
async def get_connection(
|
|
200
|
+
code: str, session: SessionDep, services: ServicesDep, principal: PrincipalDep
|
|
201
|
+
) -> ConnectionOut:
|
|
202
|
+
"""Read one credential record, with its secrets redacted."""
|
|
203
|
+
return render(await find(session, code), services)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@router.patch(
|
|
207
|
+
"/connections/{code}",
|
|
208
|
+
operation_id="updateConnection",
|
|
209
|
+
summary="Update a connection",
|
|
210
|
+
response_model=ConnectionOut,
|
|
211
|
+
)
|
|
212
|
+
async def update_connection(
|
|
213
|
+
code: str,
|
|
214
|
+
payload: ConnectionUpdate,
|
|
215
|
+
session: SessionDep,
|
|
216
|
+
services: ServicesDep,
|
|
217
|
+
principal: AdminDep,
|
|
218
|
+
) -> ConnectionOut:
|
|
219
|
+
"""Replace a connection's settings; the config is sent whole, not merged field by field.
|
|
220
|
+
|
|
221
|
+
A secret field carrying the redaction marker keeps the secret already stored for it.
|
|
222
|
+
"""
|
|
223
|
+
row = await find(session, code)
|
|
224
|
+
if payload.changing("name"):
|
|
225
|
+
row.name = payload.name
|
|
226
|
+
if payload.changing("description"):
|
|
227
|
+
row.description = payload.description
|
|
228
|
+
if payload.config is not None:
|
|
229
|
+
config = restore_marked_secrets(row, payload.config, services)
|
|
230
|
+
public, envelope, key_id = seal(services, row.kind, config)
|
|
231
|
+
row.config = public
|
|
232
|
+
row.secret_envelope = envelope
|
|
233
|
+
row.secret_key_id = key_id
|
|
234
|
+
await session.flush()
|
|
235
|
+
return render(row, services)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@router.delete(
|
|
239
|
+
"/connections/{code}",
|
|
240
|
+
operation_id="deleteConnection",
|
|
241
|
+
summary="Delete a connection",
|
|
242
|
+
status_code=status.HTTP_204_NO_CONTENT,
|
|
243
|
+
)
|
|
244
|
+
async def delete_connection(code: str, session: SessionDep, principal: AdminDep) -> Response:
|
|
245
|
+
"""Remove a credential record, refusing one something still delivers or connects through."""
|
|
246
|
+
row = await find(session, code)
|
|
247
|
+
await session.delete(row)
|
|
248
|
+
try:
|
|
249
|
+
await session.flush()
|
|
250
|
+
except IntegrityError as error:
|
|
251
|
+
raise HTTPException(
|
|
252
|
+
status_code=status.HTTP_409_CONFLICT,
|
|
253
|
+
detail=f"connection {code!r} is still referenced; delete what uses it first",
|
|
254
|
+
) from error
|
|
255
|
+
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@router.post(
|
|
259
|
+
"/connections/{code}/$check",
|
|
260
|
+
operation_id="checkConnection",
|
|
261
|
+
summary="Check a connection against its external system",
|
|
262
|
+
response_model=HealthReport,
|
|
263
|
+
)
|
|
264
|
+
async def check_connection(
|
|
265
|
+
code: str,
|
|
266
|
+
session: SessionDep,
|
|
267
|
+
services: ServicesDep,
|
|
268
|
+
principal: AdminDep,
|
|
269
|
+
) -> HealthReport:
|
|
270
|
+
"""Open the credential and ask its kind whether the external system answers."""
|
|
271
|
+
row = await find(session, code)
|
|
272
|
+
report = await check(row, services)
|
|
273
|
+
row.last_check_at = utcnow()
|
|
274
|
+
row.last_check_healthy = report.healthy
|
|
275
|
+
row.last_check_detail = report.detail
|
|
276
|
+
await session.flush()
|
|
277
|
+
return report
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
async def check(row: Connection, services: EngineServices) -> HealthReport:
|
|
281
|
+
"""Run one connection's own health check, turning any raised failure into a report."""
|
|
282
|
+
contributed = services.host.connection_kinds.get(row.kind)
|
|
283
|
+
if contributed is None:
|
|
284
|
+
return HealthReport(healthy=False, detail=f"no connection kind {row.kind!r} is installed")
|
|
285
|
+
try:
|
|
286
|
+
config = services.secrets.decrypt_config(
|
|
287
|
+
contributed.config_model, dict(row.config), row.secret_envelope, key_id=row.secret_key_id
|
|
288
|
+
)
|
|
289
|
+
return await contributed.check(config)
|
|
290
|
+
except Exception as error:
|
|
291
|
+
# The full text goes to the process log; only a bounded summary reaches the row,
|
|
292
|
+
# which /system/info serves to every principal.
|
|
293
|
+
_logger.warning("connection check failed", connection=row.code, kind=row.kind, error=str(error))
|
|
294
|
+
return HealthReport(healthy=False, detail=summarise_failure(error))
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Webhook intake.
|
|
2
|
+
|
|
3
|
+
Mounted outside ``/api/v1``, which requires a principal as a property of the mount: a
|
|
4
|
+
webhook's token *is* its credential, and it authenticates as the trigger, not as a person.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter, Header, Request, Response, status
|
|
10
|
+
|
|
11
|
+
from dirigent_client.enums import WebhookOutcome
|
|
12
|
+
from dirigent_client.schemas import HookAccepted
|
|
13
|
+
from dirigent_core.ratelimit import TokenBucket
|
|
14
|
+
from dirigent_core.triggers import SIGNATURE_HEADER, deliver, resolve_webhook
|
|
15
|
+
from dirigent_core.triggers.webhooks import UNKNOWN_TOKEN, hash_token
|
|
16
|
+
from dirigent_server.dependencies import ServicesDep, SessionDep
|
|
17
|
+
from dirigent_server.logging import get_logger
|
|
18
|
+
from dirigent_server.transactions import Transactional
|
|
19
|
+
|
|
20
|
+
router = APIRouter(route_class=Transactional, tags=["hooks"])
|
|
21
|
+
|
|
22
|
+
#: Per-process, not distributed: behind N API replicas the effective limit is the configured
|
|
23
|
+
#: rate times the replica count.
|
|
24
|
+
BUCKETS = TokenBucket()
|
|
25
|
+
|
|
26
|
+
#: Passed *before* anything is looked up, and keyed on the hash of the token that was
|
|
27
|
+
#: offered rather than on a token that exists. A limit applied only after the lookup would
|
|
28
|
+
#: give a real-but-disabled token a 429 past its rate while an unknown token never got one,
|
|
29
|
+
#: telling the holder of a revoked token that it addresses something real.
|
|
30
|
+
INTAKE_BUCKETS = TokenBucket()
|
|
31
|
+
|
|
32
|
+
_logger = get_logger("hooks")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@router.post(
|
|
36
|
+
"/hooks/{token}",
|
|
37
|
+
operation_id="deliverWebhook",
|
|
38
|
+
summary="Deliver a webhook payload",
|
|
39
|
+
response_model=HookAccepted,
|
|
40
|
+
status_code=status.HTTP_201_CREATED,
|
|
41
|
+
responses={
|
|
42
|
+
202: {"description": "Accepted, but the pipeline's concurrency policy started no run."},
|
|
43
|
+
400: {"description": "The body is not a JSON object, or a mapped path is not in it."},
|
|
44
|
+
401: {"description": "A required signature is missing or does not match the body."},
|
|
45
|
+
404: {"description": "No webhook accepts this token; a disabled one answers the same way."},
|
|
46
|
+
409: {"description": "The pipeline cannot accept a run right now."},
|
|
47
|
+
413: {"description": "The body is larger than this instance reads."},
|
|
48
|
+
422: {"description": "The mapped payload does not satisfy the pipeline's parameters."},
|
|
49
|
+
429: {"description": "This token is delivering faster than its rate limit."},
|
|
50
|
+
},
|
|
51
|
+
)
|
|
52
|
+
async def deliver_hook(
|
|
53
|
+
token: str,
|
|
54
|
+
request: Request,
|
|
55
|
+
response: Response,
|
|
56
|
+
session: SessionDep,
|
|
57
|
+
services: ServicesDep,
|
|
58
|
+
signature: Annotated[str | None, Header(alias=SIGNATURE_HEADER)] = None,
|
|
59
|
+
) -> HookAccepted:
|
|
60
|
+
"""Verify, map, and enqueue one inbound delivery, and answer with the run it started."""
|
|
61
|
+
offered = hash_token(token)
|
|
62
|
+
intake_limit = services.settings.webhook_intake_rate_per_minute
|
|
63
|
+
if not INTAKE_BUCKETS.allow(offered, per_minute=intake_limit):
|
|
64
|
+
_logger.warning("webhook intake rate limited", prefix=offered[:8], limit=intake_limit)
|
|
65
|
+
return _throttled(response, f"this endpoint accepts {intake_limit} deliveries a minute per token")
|
|
66
|
+
|
|
67
|
+
webhook = await resolve_webhook(session, token)
|
|
68
|
+
if webhook is None:
|
|
69
|
+
_logger.warning("webhook token did not resolve", prefix=offered[:8])
|
|
70
|
+
response.status_code = status.HTTP_404_NOT_FOUND
|
|
71
|
+
return HookAccepted(outcome=WebhookOutcome.REJECTED, detail=UNKNOWN_TOKEN)
|
|
72
|
+
|
|
73
|
+
# Only a live webhook gets its own limit. A disabled one is answered exactly as an
|
|
74
|
+
# unknown token is, and applying its configured limit here would give it a different 429
|
|
75
|
+
# threshold from an unknown token -- the oracle the matching 404 bodies exist to close.
|
|
76
|
+
if webhook.active and not BUCKETS.allow(webhook.token_hash, per_minute=webhook.rate_limit_per_minute):
|
|
77
|
+
_logger.warning("webhook rate limited", webhook=webhook.name, limit=webhook.rate_limit_per_minute)
|
|
78
|
+
return _throttled(response, f"this webhook accepts {webhook.rate_limit_per_minute} deliveries a minute")
|
|
79
|
+
|
|
80
|
+
delivered = await deliver(
|
|
81
|
+
session,
|
|
82
|
+
services,
|
|
83
|
+
webhook,
|
|
84
|
+
body=await _read_body(request, limit=services.settings.webhook_max_payload),
|
|
85
|
+
signature=signature,
|
|
86
|
+
source=request.client.host if request.client else None,
|
|
87
|
+
)
|
|
88
|
+
response.status_code = delivered.status
|
|
89
|
+
return HookAccepted(run_id=delivered.run_id, outcome=delivered.outcome, detail=delivered.reason)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _throttled(response: Response, detail: str) -> HookAccepted:
|
|
93
|
+
"""Answer a throttled caller identically wherever the refusal came from."""
|
|
94
|
+
response.status_code = status.HTTP_429_TOO_MANY_REQUESTS
|
|
95
|
+
response.headers["Retry-After"] = "60"
|
|
96
|
+
return HookAccepted(outcome=WebhookOutcome.REJECTED, detail=detail)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
async def _read_body(request: Request, *, limit: int) -> bytes:
|
|
100
|
+
"""Read the body, stopping one byte past the limit rather than after all of it.
|
|
101
|
+
|
|
102
|
+
``request.body()`` buffers whatever arrives before the size can be checked, so an
|
|
103
|
+
unauthenticated caller could make the process allocate a gigabyte to be told a megabyte
|
|
104
|
+
is the maximum. What comes back goes to the ordinary refusal path, so an oversized
|
|
105
|
+
delivery is still recorded in the history.
|
|
106
|
+
"""
|
|
107
|
+
chunks: list[bytes] = []
|
|
108
|
+
size = 0
|
|
109
|
+
async for chunk in request.stream():
|
|
110
|
+
chunks.append(chunk)
|
|
111
|
+
size += len(chunk)
|
|
112
|
+
if size > limit:
|
|
113
|
+
break
|
|
114
|
+
return b"".join(chunks)
|