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.
@@ -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)