agentevo-cloud 0.1.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.
- agentevo_cloud/__init__.py +452 -0
- agentevo_cloud/analytics.py +238 -0
- agentevo_cloud/app.py +2091 -0
- agentevo_cloud/cli.py +124 -0
- agentevo_cloud/config.py +108 -0
- agentevo_cloud/db.py +442 -0
- agentevo_cloud/devices.py +786 -0
- agentevo_cloud/events.py +363 -0
- agentevo_cloud/jobs.py +553 -0
- agentevo_cloud/managed.py +325 -0
- agentevo_cloud/metering.py +264 -0
- agentevo_cloud/oidc.py +165 -0
- agentevo_cloud/openapi.py +44 -0
- agentevo_cloud/promote.py +215 -0
- agentevo_cloud/recall.py +449 -0
- agentevo_cloud/records.py +209 -0
- agentevo_cloud/revoke.py +213 -0
- agentevo_cloud/screen.py +240 -0
- agentevo_cloud/sync.py +343 -0
- agentevo_cloud/tenants.py +593 -0
- agentevo_cloud/tokens.py +388 -0
- agentevo_cloud/webhooks.py +730 -0
- agentevo_cloud/workers.py +985 -0
- agentevo_cloud-0.1.0.dist-info/METADATA +127 -0
- agentevo_cloud-0.1.0.dist-info/RECORD +28 -0
- agentevo_cloud-0.1.0.dist-info/WHEEL +4 -0
- agentevo_cloud-0.1.0.dist-info/entry_points.txt +2 -0
- agentevo_cloud-0.1.0.dist-info/licenses/LICENSE +90 -0
agentevo_cloud/app.py
ADDED
|
@@ -0,0 +1,2091 @@
|
|
|
1
|
+
"""Cloud Control API: FastAPI factory, error model, and routes.
|
|
2
|
+
|
|
3
|
+
Every route authenticates via Bearer tokens except /healthz and the auth
|
|
4
|
+
flows themselves. The effective tenant always derives from the verified
|
|
5
|
+
principal; request-body tenant IDs are consistency assertions only (400 on
|
|
6
|
+
mismatch, never authorization). Quota headers ride every authenticated
|
|
7
|
+
response; metering increments in the request transaction.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import sqlite3
|
|
13
|
+
from collections.abc import Awaitable, Callable, Iterator
|
|
14
|
+
from contextlib import suppress
|
|
15
|
+
from dataclasses import replace
|
|
16
|
+
from datetime import UTC, datetime, timedelta
|
|
17
|
+
from typing import Any
|
|
18
|
+
from uuid import uuid4
|
|
19
|
+
|
|
20
|
+
from fastapi import Depends, FastAPI, Header, Request, Response
|
|
21
|
+
from fastapi.exceptions import RequestValidationError
|
|
22
|
+
from fastapi.responses import JSONResponse
|
|
23
|
+
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
24
|
+
|
|
25
|
+
from agentevo_cloud.analytics import summarize
|
|
26
|
+
from agentevo_cloud.config import Settings
|
|
27
|
+
from agentevo_cloud.db import Database
|
|
28
|
+
from agentevo_cloud.devices import (
|
|
29
|
+
DEVICE_POLL_INTERVAL,
|
|
30
|
+
DeviceRecord,
|
|
31
|
+
approve_device_code,
|
|
32
|
+
deny_device_code,
|
|
33
|
+
exchange_ci_token,
|
|
34
|
+
link_repo,
|
|
35
|
+
list_device_keys,
|
|
36
|
+
list_devices,
|
|
37
|
+
list_repos,
|
|
38
|
+
poll_device_flow,
|
|
39
|
+
register_device,
|
|
40
|
+
revoke_device,
|
|
41
|
+
start_device_flow,
|
|
42
|
+
unlink_repo,
|
|
43
|
+
)
|
|
44
|
+
from agentevo_cloud.events import ingest_batch
|
|
45
|
+
from agentevo_cloud.jobs import (
|
|
46
|
+
JobState,
|
|
47
|
+
JobType,
|
|
48
|
+
cancel_job,
|
|
49
|
+
create_job,
|
|
50
|
+
get_job,
|
|
51
|
+
list_jobs,
|
|
52
|
+
)
|
|
53
|
+
from agentevo_cloud.managed import ManagedObject, get_managed, index_accepted
|
|
54
|
+
from agentevo_cloud.metering import (
|
|
55
|
+
RETRY_AFTER_HEADER,
|
|
56
|
+
Usage,
|
|
57
|
+
check_rate_limit,
|
|
58
|
+
current_cycle,
|
|
59
|
+
is_billable,
|
|
60
|
+
quota_headers,
|
|
61
|
+
quota_warning,
|
|
62
|
+
record_checked,
|
|
63
|
+
retry_after_for,
|
|
64
|
+
unrecord_billable,
|
|
65
|
+
usage_for,
|
|
66
|
+
)
|
|
67
|
+
from agentevo_cloud.oidc import JwksFetcher, fetch_jwks
|
|
68
|
+
from agentevo_cloud.promote import promote_managed
|
|
69
|
+
from agentevo_cloud.recall import parse_environment, parse_query, recall
|
|
70
|
+
from agentevo_cloud.records import (
|
|
71
|
+
audit,
|
|
72
|
+
idempotency_lookup,
|
|
73
|
+
idempotency_store,
|
|
74
|
+
list_audit,
|
|
75
|
+
)
|
|
76
|
+
from agentevo_cloud.revoke import revoke_object
|
|
77
|
+
from agentevo_cloud.sync import pull_page, push_batch
|
|
78
|
+
from agentevo_cloud.tenants import (
|
|
79
|
+
ADMIN_ROLE,
|
|
80
|
+
OWNER_ROLE,
|
|
81
|
+
add_membership,
|
|
82
|
+
create_tenant,
|
|
83
|
+
create_workspace,
|
|
84
|
+
delete_workspace_data,
|
|
85
|
+
entitlements_for,
|
|
86
|
+
export_workspace_data,
|
|
87
|
+
get_sync_mode,
|
|
88
|
+
get_tenant,
|
|
89
|
+
require_searchable,
|
|
90
|
+
set_sync_mode,
|
|
91
|
+
)
|
|
92
|
+
from agentevo_cloud.tokens import (
|
|
93
|
+
REFRESH_KIND,
|
|
94
|
+
Principal,
|
|
95
|
+
hash_token,
|
|
96
|
+
issue_pat,
|
|
97
|
+
list_tokens,
|
|
98
|
+
revoke_token,
|
|
99
|
+
rotate_refresh,
|
|
100
|
+
verify_token,
|
|
101
|
+
)
|
|
102
|
+
from agentevo_cloud.webhooks import (
|
|
103
|
+
Delivery,
|
|
104
|
+
Sender as WebhookSender,
|
|
105
|
+
WebhookConfig,
|
|
106
|
+
build_event,
|
|
107
|
+
create_config,
|
|
108
|
+
deliver_to_config,
|
|
109
|
+
dispatch,
|
|
110
|
+
get_config,
|
|
111
|
+
list_configs,
|
|
112
|
+
list_deliveries,
|
|
113
|
+
replay_delivery,
|
|
114
|
+
revoke_config,
|
|
115
|
+
rotate_config,
|
|
116
|
+
urllib_sender,
|
|
117
|
+
)
|
|
118
|
+
from agentevo_cloud.workers import (
|
|
119
|
+
DEFAULT_COMPILE_PROJECT,
|
|
120
|
+
ensure_workspace,
|
|
121
|
+
parse_bindings,
|
|
122
|
+
parse_canary_steps,
|
|
123
|
+
parse_fallbacks,
|
|
124
|
+
parse_required_str,
|
|
125
|
+
parse_rollback,
|
|
126
|
+
parse_skill_refs,
|
|
127
|
+
)
|
|
128
|
+
from agentevo_core.errors import HTTP_STATUS, AgentevoError, ErrorCode
|
|
129
|
+
from agentevo_core.objects import LearnedObjectType
|
|
130
|
+
from agentevo_core.trust import TrustState
|
|
131
|
+
from agentevo_local.revoke import RevocationReason
|
|
132
|
+
|
|
133
|
+
REQUEST_ID_HEADER = "X-Request-ID"
|
|
134
|
+
|
|
135
|
+
#: 2xx bounds: _commit_middleware commits successes, rolls back the rest.
|
|
136
|
+
_SUCCESS_MIN = 200
|
|
137
|
+
_SUCCESS_MAX = 300
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def create_app(db: Database, settings: Settings) -> FastAPI:
|
|
141
|
+
"""Build the Control API application over one database + settings."""
|
|
142
|
+
app = FastAPI(title="Agentevo Control API", version="v1")
|
|
143
|
+
app.state.db = db
|
|
144
|
+
app.state.settings = settings
|
|
145
|
+
app.exception_handler(AgentevoError)(_agentevo_handler)
|
|
146
|
+
app.exception_handler(RequestValidationError)(_validation_handler)
|
|
147
|
+
app.exception_handler(StarletteHTTPException)(_http_handler)
|
|
148
|
+
app.middleware("http")(_metering_middleware)
|
|
149
|
+
app.middleware("http")(_commit_middleware)
|
|
150
|
+
app.middleware("http")(_maintenance_middleware)
|
|
151
|
+
app.get("/healthz")(_healthz)
|
|
152
|
+
app.post("/v1/auth/tokens", status_code=201)(_issue_token)
|
|
153
|
+
app.get("/v1/auth/tokens")(_list_tokens)
|
|
154
|
+
app.delete("/v1/auth/tokens/{token_id}", status_code=204)(_revoke_token)
|
|
155
|
+
app.post("/v1/auth/token/refresh")(_refresh_token)
|
|
156
|
+
app.post("/v1/auth/device/code", status_code=201)(_device_code)
|
|
157
|
+
app.post("/v1/auth/device/token")(_device_token)
|
|
158
|
+
app.post("/v1/auth/device/approve")(_device_approve)
|
|
159
|
+
app.post("/v1/auth/device/deny")(_device_deny)
|
|
160
|
+
app.get("/v1/auth/oidc/config")(_oidc_config)
|
|
161
|
+
app.post("/v1/auth/ci/exchange", status_code=201)(_ci_exchange)
|
|
162
|
+
app.post("/v1/devices", status_code=201)(_register_device)
|
|
163
|
+
app.get("/v1/devices")(_list_devices)
|
|
164
|
+
app.delete("/v1/devices/{device_id}", status_code=204)(_revoke_device)
|
|
165
|
+
_register_workspace_routes(app)
|
|
166
|
+
app.get("/v1/analytics/summary")(_analytics_summary)
|
|
167
|
+
app.post("/v1/webhooks", status_code=201)(_create_webhook)
|
|
168
|
+
app.get("/v1/webhooks")(_list_webhooks)
|
|
169
|
+
app.delete("/v1/webhooks/{config_id}", status_code=204)(_revoke_webhook)
|
|
170
|
+
app.post("/v1/webhooks/{config_id}/rotate")(_rotate_webhook)
|
|
171
|
+
app.post("/v1/webhooks/test")(_test_webhook)
|
|
172
|
+
app.get("/v1/webhooks/{config_id}/deliveries")(_webhook_deliveries)
|
|
173
|
+
app.post("/v1/webhooks/deliveries/{delivery_id}/replay")(_replay_webhook)
|
|
174
|
+
app.post("/v1/sync/push")(_sync_push)
|
|
175
|
+
app.post("/v1/sync/pull")(_sync_pull)
|
|
176
|
+
app.post("/v1/events/batch", status_code=201)(_events_batch)
|
|
177
|
+
app.post("/v1/recall")(_recall)
|
|
178
|
+
_register_object_routes(app)
|
|
179
|
+
_register_job_routes(app)
|
|
180
|
+
_register_compile_routes(app)
|
|
181
|
+
app.get("/v1/revocations")(_revocations)
|
|
182
|
+
app.get("/v1/entitlements")(_entitlements)
|
|
183
|
+
app.get("/v1/usage")(_usage)
|
|
184
|
+
app.get("/v1/audit")(_audit)
|
|
185
|
+
# Re-exported for the provision CLI and tests.
|
|
186
|
+
app.state.create_tenant = create_tenant
|
|
187
|
+
app.state.add_membership = add_membership
|
|
188
|
+
app.state.create_workspace = create_workspace
|
|
189
|
+
return app
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _register_workspace_routes(app: FastAPI) -> None:
|
|
193
|
+
"""Workspace repos, BR-011 export/delete, and sync-mode consent."""
|
|
194
|
+
app.post("/v1/workspaces/{slug}/repos", status_code=201)(_link_repo)
|
|
195
|
+
app.get("/v1/workspaces/{slug}/repos")(_list_repos)
|
|
196
|
+
app.delete("/v1/workspaces/{slug}/repos/{repo:path}", status_code=204)(_unlink_repo)
|
|
197
|
+
app.get("/v1/workspaces/{slug}/export")(_export_workspace)
|
|
198
|
+
app.delete("/v1/workspaces/{slug}/cloud-data")(_delete_workspace_data)
|
|
199
|
+
app.get("/v1/workspaces/{slug}/sync-mode")(_get_sync_mode)
|
|
200
|
+
app.post("/v1/workspaces/{slug}/sync-mode")(_set_sync_mode)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _register_object_routes(app: FastAPI) -> None:
|
|
204
|
+
"""Managed object lifecycle: verify, revalidate, promote, fetch versions."""
|
|
205
|
+
app.post("/v1/objects/{object_id}/verify", status_code=202)(_verify_object)
|
|
206
|
+
app.post("/v1/objects/{object_id}/revalidate", status_code=202)(_revalidate_object)
|
|
207
|
+
app.post("/v1/objects/{object_id}/promote")(_promote_object)
|
|
208
|
+
app.post("/v1/objects/{object_id}/revoke")(_revoke_object)
|
|
209
|
+
app.get("/v1/objects/{object_id}/versions/{version}")(_get_object_version)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _register_job_routes(app: FastAPI) -> None:
|
|
213
|
+
"""Async job polling, listing, and best-effort cancellation."""
|
|
214
|
+
app.get("/v1/jobs/{job_id}")(_get_job)
|
|
215
|
+
app.get("/v1/jobs")(_list_jobs)
|
|
216
|
+
app.post("/v1/jobs/{job_id}/cancel")(_cancel_job)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _register_compile_routes(app: FastAPI) -> None:
|
|
220
|
+
"""Async compile submissions (experiences and workflows)."""
|
|
221
|
+
app.post("/v1/experiences/compile", status_code=202)(_compile_experiences)
|
|
222
|
+
app.post("/v1/workflows/compile", status_code=202)(_compile_workflows)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
async def _agentevo_handler(request: Request, exc: AgentevoError) -> JSONResponse:
|
|
226
|
+
_ = request
|
|
227
|
+
return _error_response(exc.code, exc.message, exc.details, exc)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
async def _validation_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
|
|
231
|
+
_ = request
|
|
232
|
+
details = {"errors": exc.errors()}
|
|
233
|
+
return _error_response(ErrorCode.INVALID_ARGUMENT, "request validation failed", details, None)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
async def _http_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse:
|
|
237
|
+
_ = request
|
|
238
|
+
if exc.status_code == HTTP_STATUS[ErrorCode.NOT_FOUND]:
|
|
239
|
+
return _error_response(ErrorCode.NOT_FOUND, "unknown route", {}, None)
|
|
240
|
+
return _error_response(ErrorCode.INVALID_ARGUMENT, str(exc.detail), {}, None)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
#: Methods that keep serving while the control plane is in maintenance.
|
|
244
|
+
_MAINTENANCE_SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
async def _maintenance_middleware(
|
|
248
|
+
request: Request, call_next: Callable[[Request], Awaitable[Response]]
|
|
249
|
+
) -> Response:
|
|
250
|
+
"""Global kill switch: read-only mode when maintenance is set.
|
|
251
|
+
|
|
252
|
+
Registered last so it runs outermost: mutations 503 before metering
|
|
253
|
+
or the database are touched (clients back off; the SDK maps 503 to
|
|
254
|
+
retryable), while status reads, job polls, and revocation reads keep
|
|
255
|
+
serving. The local runtime is unaffected by design.
|
|
256
|
+
"""
|
|
257
|
+
settings: Settings = request.app.state.settings
|
|
258
|
+
if settings.maintenance_mode and request.method not in _MAINTENANCE_SAFE_METHODS:
|
|
259
|
+
exc = AgentevoError(
|
|
260
|
+
ErrorCode.TEMPORARILY_UNAVAILABLE,
|
|
261
|
+
"control plane in maintenance",
|
|
262
|
+
{"maintenance": True},
|
|
263
|
+
)
|
|
264
|
+
response = _error_response(exc.code, exc.message, exc.details, exc)
|
|
265
|
+
response.headers[RETRY_AFTER_HEADER] = "60"
|
|
266
|
+
return response
|
|
267
|
+
return await call_next(request)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
async def _metering_middleware(
|
|
271
|
+
request: Request, call_next: Callable[[Request], Awaitable[Response]]
|
|
272
|
+
) -> Response:
|
|
273
|
+
# Header-only by design: metering writes happen in the request
|
|
274
|
+
# transaction, and only _commit_middleware settles it.
|
|
275
|
+
response = await call_next(request)
|
|
276
|
+
usage = getattr(request.state, "usage", None)
|
|
277
|
+
if usage is None:
|
|
278
|
+
return response
|
|
279
|
+
headers = quota_headers(usage)
|
|
280
|
+
for key, value in headers.items():
|
|
281
|
+
response.headers[key] = value
|
|
282
|
+
return response
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
async def _commit_middleware(
|
|
286
|
+
request: Request, call_next: Callable[[Request], Awaitable[Response]]
|
|
287
|
+
) -> Response:
|
|
288
|
+
"""Settle the request transaction before the response hits the wire.
|
|
289
|
+
|
|
290
|
+
Dependency teardown (where ``Database.connect`` commits) runs after
|
|
291
|
+
response bytes are sent, so without this a client that POSTs then
|
|
292
|
+
immediately reads can observe its own write as missing. Success (2xx)
|
|
293
|
+
commits here; errors roll back (failed attempts never bill); an
|
|
294
|
+
escaping exception rolls back and propagates. Teardown's own commit
|
|
295
|
+
stays as a no-op safety net (and the commit path for non-HTTP use).
|
|
296
|
+
"""
|
|
297
|
+
try:
|
|
298
|
+
response = await call_next(request)
|
|
299
|
+
except BaseException:
|
|
300
|
+
conn = getattr(request.state, "conn", None)
|
|
301
|
+
if conn is not None:
|
|
302
|
+
conn.rollback()
|
|
303
|
+
raise
|
|
304
|
+
conn = getattr(request.state, "conn", None)
|
|
305
|
+
if conn is not None:
|
|
306
|
+
if _SUCCESS_MIN <= response.status_code < _SUCCESS_MAX:
|
|
307
|
+
conn.commit()
|
|
308
|
+
else:
|
|
309
|
+
conn.rollback()
|
|
310
|
+
return response
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def get_conn(request: Request) -> Iterator[sqlite3.Connection]:
|
|
314
|
+
"""Request-scoped database connection."""
|
|
315
|
+
db: Database = request.app.state.db
|
|
316
|
+
with db.connect() as conn:
|
|
317
|
+
request.state.conn = conn
|
|
318
|
+
try:
|
|
319
|
+
yield conn
|
|
320
|
+
finally:
|
|
321
|
+
# Teardown runs before error responses reach middleware (but
|
|
322
|
+
# after success bytes are sent); clearing the handle tells
|
|
323
|
+
# _commit_middleware there is nothing left to settle. The
|
|
324
|
+
# context exit already rolled back on this path.
|
|
325
|
+
request.state.conn = None
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def current_principal(
|
|
329
|
+
request: Request,
|
|
330
|
+
authorization: str | None = Header(default=None),
|
|
331
|
+
conn: sqlite3.Connection = Depends(get_conn),
|
|
332
|
+
) -> Principal:
|
|
333
|
+
"""Verified bearer principal (tenant derives from here)."""
|
|
334
|
+
secret = _bearer_secret(authorization)
|
|
335
|
+
principal = verify_token(conn, secret)
|
|
336
|
+
_reject_refresh(principal)
|
|
337
|
+
if get_tenant(conn, principal.tenant_id) is None:
|
|
338
|
+
raise AgentevoError(ErrorCode.UNAUTHENTICATED, "tenant is gone", {})
|
|
339
|
+
request.state.principal = principal
|
|
340
|
+
return principal
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def optional_principal(
|
|
344
|
+
request: Request,
|
|
345
|
+
authorization: str | None = Header(default=None),
|
|
346
|
+
conn: sqlite3.Connection = Depends(get_conn),
|
|
347
|
+
) -> Principal | None:
|
|
348
|
+
"""Bearer principal when presented (401 on a bad credential, else None)."""
|
|
349
|
+
if authorization is None:
|
|
350
|
+
return None
|
|
351
|
+
principal = verify_token(conn, _bearer_secret(authorization))
|
|
352
|
+
_reject_refresh(principal)
|
|
353
|
+
if get_tenant(conn, principal.tenant_id) is None:
|
|
354
|
+
raise AgentevoError(ErrorCode.UNAUTHENTICATED, "tenant is gone", {})
|
|
355
|
+
request.state.principal = principal
|
|
356
|
+
request.state.usage = usage_for(conn, principal.tenant_id)
|
|
357
|
+
return principal
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def metered(
|
|
361
|
+
request: Request,
|
|
362
|
+
principal: Principal = Depends(current_principal),
|
|
363
|
+
conn: sqlite3.Connection = Depends(get_conn),
|
|
364
|
+
) -> Principal:
|
|
365
|
+
"""Rate guard + attempt metering (AWS-style; replays un-count later)."""
|
|
366
|
+
settings: Settings = request.app.state.settings
|
|
367
|
+
check_rate_limit(conn, f"token:{principal.token_id}", settings.rate_limit_per_minute)
|
|
368
|
+
if not is_billable(request.method, request.url.path):
|
|
369
|
+
request.state.usage = usage_for(conn, principal.tenant_id)
|
|
370
|
+
return principal
|
|
371
|
+
request.state.usage = _record_and_notify(request, conn, principal.tenant_id)
|
|
372
|
+
request.state.billable = True
|
|
373
|
+
return principal
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _record_and_notify(request: Request, conn: sqlite3.Connection, tenant_id: str) -> Usage:
|
|
377
|
+
"""Quota-checked metering plus 80%/exhausted webhook notification."""
|
|
378
|
+
try:
|
|
379
|
+
usage = record_checked(conn, tenant_id)
|
|
380
|
+
except AgentevoError as exc:
|
|
381
|
+
if exc.code is ErrorCode.QUOTA_EXCEEDED:
|
|
382
|
+
cycle = current_cycle()
|
|
383
|
+
if not _quota_event_sent(conn, tenant_id, "quota.exhausted", cycle):
|
|
384
|
+
_emit_quota_event(
|
|
385
|
+
request,
|
|
386
|
+
conn,
|
|
387
|
+
tenant_id,
|
|
388
|
+
"quota.exhausted",
|
|
389
|
+
cycle=cycle,
|
|
390
|
+
used=exc.details.get("used", 0),
|
|
391
|
+
quota=exc.details.get("quota"),
|
|
392
|
+
reset_at=str(exc.details.get("reset_at", "")),
|
|
393
|
+
)
|
|
394
|
+
# The 429 below rolls the request transaction back; persist
|
|
395
|
+
# the notification (and the restored counter) first.
|
|
396
|
+
conn.commit()
|
|
397
|
+
raise
|
|
398
|
+
before = replace(usage, used=usage.used - 1)
|
|
399
|
+
if quota_warning(usage) and not quota_warning(before):
|
|
400
|
+
_emit_quota_event(
|
|
401
|
+
request,
|
|
402
|
+
conn,
|
|
403
|
+
tenant_id,
|
|
404
|
+
"quota.warning",
|
|
405
|
+
cycle=usage.cycle,
|
|
406
|
+
used=usage.used,
|
|
407
|
+
quota=usage.quota,
|
|
408
|
+
reset_at=usage.reset_at,
|
|
409
|
+
)
|
|
410
|
+
return usage
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _quota_event_sent(
|
|
414
|
+
conn: sqlite3.Connection, tenant_id: str, event_type: str, cycle: str
|
|
415
|
+
) -> bool:
|
|
416
|
+
"""True when this cycle already recorded the quota event (dedupes 429 storms)."""
|
|
417
|
+
bound = f"{cycle}-01T00:00:00+00:00"
|
|
418
|
+
row = conn.execute(
|
|
419
|
+
"SELECT 1 FROM webhook_deliveries"
|
|
420
|
+
" WHERE tenant_id = ? AND event_type = ? AND created_at >= ? LIMIT 1",
|
|
421
|
+
(tenant_id, event_type, bound),
|
|
422
|
+
).fetchone()
|
|
423
|
+
return row is not None
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _emit_quota_event( # noqa: PLR0913 - notification carries quota context
|
|
427
|
+
request: Request,
|
|
428
|
+
conn: sqlite3.Connection,
|
|
429
|
+
tenant_id: str,
|
|
430
|
+
event_type: str,
|
|
431
|
+
*,
|
|
432
|
+
cycle: str,
|
|
433
|
+
used: int,
|
|
434
|
+
quota: int | None,
|
|
435
|
+
reset_at: str,
|
|
436
|
+
) -> None:
|
|
437
|
+
"""Best-effort quota fan-out (notification never fails the request)."""
|
|
438
|
+
payload = {"used": used, "quota": quota, "cycle": cycle, "reset_at": reset_at}
|
|
439
|
+
with suppress(Exception):
|
|
440
|
+
settings: Settings = request.app.state.settings
|
|
441
|
+
event = build_event(event_type, tenant_id, f"quota/{cycle}", payload=payload)
|
|
442
|
+
dispatch(
|
|
443
|
+
conn,
|
|
444
|
+
settings,
|
|
445
|
+
tenant_id,
|
|
446
|
+
event,
|
|
447
|
+
sender=_webhook_sender(request),
|
|
448
|
+
timeout_seconds=settings.webhook_timeout_seconds,
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def require_scope(scope: str) -> Any:
|
|
453
|
+
"""Dependency factory enforcing one token scope (403 when missing)."""
|
|
454
|
+
|
|
455
|
+
def scoped(principal: Principal = Depends(metered)) -> Principal:
|
|
456
|
+
if not principal.allows(scope):
|
|
457
|
+
raise AgentevoError(
|
|
458
|
+
ErrorCode.FORBIDDEN,
|
|
459
|
+
f"token lacks required scope: {scope}",
|
|
460
|
+
{"scope": scope},
|
|
461
|
+
)
|
|
462
|
+
return principal
|
|
463
|
+
|
|
464
|
+
return scoped
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
CONTROL_READ = require_scope("control:read")
|
|
468
|
+
AUDIT_READ = require_scope("audit:read")
|
|
469
|
+
SYNC_WRITE = require_scope("sync:write")
|
|
470
|
+
SYNC_READ = require_scope("sync:read")
|
|
471
|
+
RECALL_READ = require_scope("recall:read")
|
|
472
|
+
VERIFY_RUN = require_scope("verify:run")
|
|
473
|
+
OBJECT_PROMOTE = require_scope("object:promote")
|
|
474
|
+
OBJECT_REVOKE = require_scope("object:revoke")
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _reject_refresh(principal: Principal) -> None:
|
|
478
|
+
"""Refresh tokens rotate; they never authenticate API calls."""
|
|
479
|
+
if principal.kind == REFRESH_KIND:
|
|
480
|
+
raise AgentevoError(ErrorCode.UNAUTHENTICATED, "refresh tokens cannot call this API", {})
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _client_ip(request: Request) -> str:
|
|
484
|
+
client = request.client
|
|
485
|
+
return client.host if client is not None and client.host else "unknown"
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _auth_rate_limit(request: Request, conn: sqlite3.Connection, name: str) -> None:
|
|
489
|
+
"""IP-keyed burst guard for the unauthenticated auth flows."""
|
|
490
|
+
settings: Settings = request.app.state.settings
|
|
491
|
+
check_rate_limit(conn, f"auth:{name}:{_client_ip(request)}", settings.rate_limit_per_minute)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _idempotent( # noqa: PLR0913, PLR0917 - idempotency carries request context
|
|
495
|
+
request: Request,
|
|
496
|
+
conn: sqlite3.Connection,
|
|
497
|
+
tenant_id: str,
|
|
498
|
+
key: str | None,
|
|
499
|
+
body: dict[str, Any],
|
|
500
|
+
compute: Callable[[], tuple[int, dict[str, Any]]],
|
|
501
|
+
) -> JSONResponse:
|
|
502
|
+
"""Honor Idempotency-Key: replay stored responses, un-counting quota.
|
|
503
|
+
|
|
504
|
+
Anonymous bootstrap flows (device start, CI exchange) share the empty
|
|
505
|
+
tenant namespace; their responses carry no tenant data before approval.
|
|
506
|
+
"""
|
|
507
|
+
route = f"{request.method} {request.url.path}"
|
|
508
|
+
if key is not None:
|
|
509
|
+
replay = idempotency_lookup(conn, tenant_id, route, key, body)
|
|
510
|
+
if replay is not None:
|
|
511
|
+
if getattr(request.state, "billable", False):
|
|
512
|
+
request.state.usage = unrecord_billable(conn, tenant_id)
|
|
513
|
+
return JSONResponse(status_code=replay.status_code, content=replay.body)
|
|
514
|
+
status, payload = compute()
|
|
515
|
+
if key is not None:
|
|
516
|
+
settings: Settings = request.app.state.settings
|
|
517
|
+
raced = idempotency_store(
|
|
518
|
+
conn,
|
|
519
|
+
tenant_id,
|
|
520
|
+
route,
|
|
521
|
+
key,
|
|
522
|
+
body=body,
|
|
523
|
+
status_code=status,
|
|
524
|
+
response=payload,
|
|
525
|
+
ttl=timedelta(seconds=settings.idempotency_ttl_seconds),
|
|
526
|
+
)
|
|
527
|
+
if raced is not None:
|
|
528
|
+
# A concurrent twin stored first; serve the winner and un-count
|
|
529
|
+
# this attempt exactly like an idempotent replay.
|
|
530
|
+
if getattr(request.state, "billable", False):
|
|
531
|
+
request.state.usage = unrecord_billable(conn, tenant_id)
|
|
532
|
+
return JSONResponse(status_code=raced.status_code, content=raced.body)
|
|
533
|
+
return JSONResponse(status_code=status, content=payload)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _jwks_fetcher(request: Request) -> JwksFetcher:
|
|
537
|
+
fetcher: JwksFetcher | None = getattr(request.app.state, "jwks_fetcher", None)
|
|
538
|
+
return fetcher or fetch_jwks
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _webhook_sender(request: Request) -> WebhookSender:
|
|
542
|
+
sender: WebhookSender | None = getattr(request.app.state, "webhook_sender", None)
|
|
543
|
+
return sender or urllib_sender
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _healthz() -> dict[str, str]:
|
|
547
|
+
return {"status": "ok", "version": "v1"}
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _issue_token(
|
|
551
|
+
body: dict[str, Any],
|
|
552
|
+
request: Request,
|
|
553
|
+
principal: Principal = Depends(CONTROL_READ),
|
|
554
|
+
conn: sqlite3.Connection = Depends(get_conn),
|
|
555
|
+
idempotency_key: str | None = Header(default=None),
|
|
556
|
+
) -> JSONResponse:
|
|
557
|
+
name, scopes, workspaces, ttl = _token_body(body)
|
|
558
|
+
if principal.user_id is None:
|
|
559
|
+
raise AgentevoError(ErrorCode.FORBIDDEN, "service tokens cannot issue PATs", {})
|
|
560
|
+
|
|
561
|
+
def compute() -> tuple[int, dict[str, Any]]:
|
|
562
|
+
issued = issue_pat(
|
|
563
|
+
conn,
|
|
564
|
+
principal.tenant_id,
|
|
565
|
+
name,
|
|
566
|
+
scopes,
|
|
567
|
+
user_id=principal.user_id or "",
|
|
568
|
+
workspaces=workspaces,
|
|
569
|
+
ttl=ttl,
|
|
570
|
+
)
|
|
571
|
+
audit(
|
|
572
|
+
conn,
|
|
573
|
+
principal.tenant_id,
|
|
574
|
+
principal.token_id,
|
|
575
|
+
"token_issued",
|
|
576
|
+
issued.record.id,
|
|
577
|
+
details={"scopes": scopes, "name": name},
|
|
578
|
+
)
|
|
579
|
+
return 201, {
|
|
580
|
+
"id": issued.record.id,
|
|
581
|
+
"secret": issued.secret,
|
|
582
|
+
"scopes": list(issued.record.scopes),
|
|
583
|
+
"workspaces": list(issued.record.workspaces),
|
|
584
|
+
"expires_at": issued.record.expires_at,
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
return _idempotent(request, conn, principal.tenant_id, idempotency_key, body, compute)
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _list_tokens(
|
|
591
|
+
principal: Principal = Depends(CONTROL_READ),
|
|
592
|
+
conn: sqlite3.Connection = Depends(get_conn),
|
|
593
|
+
) -> dict[str, Any]:
|
|
594
|
+
return {
|
|
595
|
+
"tokens": [
|
|
596
|
+
{
|
|
597
|
+
"id": record.id,
|
|
598
|
+
"kind": record.kind,
|
|
599
|
+
"name": record.name,
|
|
600
|
+
"scopes": list(record.scopes),
|
|
601
|
+
"workspaces": list(record.workspaces),
|
|
602
|
+
"user_id": record.user_id,
|
|
603
|
+
"expires_at": record.expires_at,
|
|
604
|
+
"revoked_at": record.revoked_at,
|
|
605
|
+
"created_at": record.created_at,
|
|
606
|
+
"last_used_at": record.last_used_at,
|
|
607
|
+
}
|
|
608
|
+
for record in list_tokens(conn, principal.tenant_id)
|
|
609
|
+
]
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _revoke_token(
|
|
614
|
+
token_id: str,
|
|
615
|
+
principal: Principal = Depends(CONTROL_READ),
|
|
616
|
+
conn: sqlite3.Connection = Depends(get_conn),
|
|
617
|
+
) -> Response:
|
|
618
|
+
revoke_token(conn, principal.tenant_id, token_id)
|
|
619
|
+
audit(conn, principal.tenant_id, principal.token_id, "token_revoked", token_id, details={})
|
|
620
|
+
return Response(status_code=204)
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _refresh_token(
|
|
624
|
+
body: dict[str, Any],
|
|
625
|
+
request: Request,
|
|
626
|
+
conn: sqlite3.Connection = Depends(get_conn),
|
|
627
|
+
idempotency_key: str | None = Header(default=None),
|
|
628
|
+
) -> JSONResponse:
|
|
629
|
+
settings: Settings = request.app.state.settings
|
|
630
|
+
_auth_rate_limit(request, conn, "refresh")
|
|
631
|
+
secret = body.get("refresh_token")
|
|
632
|
+
if not isinstance(secret, str) or not secret:
|
|
633
|
+
raise AgentevoError(
|
|
634
|
+
ErrorCode.INVALID_ARGUMENT, "refresh_token must be a non-empty string", {}
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
def compute() -> tuple[int, dict[str, Any]]:
|
|
638
|
+
access, refresh = rotate_refresh(
|
|
639
|
+
conn,
|
|
640
|
+
secret,
|
|
641
|
+
access_ttl=timedelta(seconds=settings.access_token_ttl_seconds),
|
|
642
|
+
refresh_ttl=timedelta(seconds=settings.refresh_token_ttl_seconds),
|
|
643
|
+
)
|
|
644
|
+
return 200, {
|
|
645
|
+
"access_token": access.secret,
|
|
646
|
+
"refresh_token": refresh.secret,
|
|
647
|
+
"expires_in": settings.access_token_ttl_seconds,
|
|
648
|
+
"scopes": list(access.record.scopes),
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
tenant_id = _refresh_owner(conn, secret)
|
|
652
|
+
return _idempotent(request, conn, tenant_id, idempotency_key, body, compute)
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _refresh_owner(conn: sqlite3.Connection, secret: str) -> str:
|
|
656
|
+
"""Tenant owning a refresh secret ("" when unknown; rotation decides)."""
|
|
657
|
+
row = conn.execute(
|
|
658
|
+
"SELECT tenant_id FROM tokens WHERE token_hash = ? AND kind = ?",
|
|
659
|
+
(hash_token(secret), REFRESH_KIND),
|
|
660
|
+
).fetchone()
|
|
661
|
+
return row["tenant_id"] if row is not None else ""
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _entitlements(
|
|
665
|
+
principal: Principal = Depends(CONTROL_READ),
|
|
666
|
+
conn: sqlite3.Connection = Depends(get_conn),
|
|
667
|
+
) -> dict[str, Any]:
|
|
668
|
+
entitlements = entitlements_for(conn, principal.tenant_id)
|
|
669
|
+
return {
|
|
670
|
+
"tenant_id": entitlements.tenant_id,
|
|
671
|
+
"plan": entitlements.plan,
|
|
672
|
+
"quota_monthly": entitlements.quota_monthly,
|
|
673
|
+
"overage_enabled": entitlements.overage_enabled,
|
|
674
|
+
"scopes_grantable": list(entitlements.scopes_grantable),
|
|
675
|
+
"retention_note": entitlements.retention_note,
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _usage(
|
|
680
|
+
principal: Principal = Depends(CONTROL_READ),
|
|
681
|
+
conn: sqlite3.Connection = Depends(get_conn),
|
|
682
|
+
) -> dict[str, Any]:
|
|
683
|
+
usage = usage_for(conn, principal.tenant_id)
|
|
684
|
+
return {
|
|
685
|
+
"tenant_id": usage.tenant_id,
|
|
686
|
+
"plan": usage.plan,
|
|
687
|
+
"cycle": usage.cycle,
|
|
688
|
+
"used": usage.used,
|
|
689
|
+
"quota": usage.quota,
|
|
690
|
+
"remaining": usage.remaining,
|
|
691
|
+
"quota_warning": quota_warning(usage),
|
|
692
|
+
"overage_enabled": usage.overage_enabled,
|
|
693
|
+
"reset_at": usage.reset_at,
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def _device_code(
|
|
698
|
+
body: dict[str, Any],
|
|
699
|
+
request: Request,
|
|
700
|
+
conn: sqlite3.Connection = Depends(get_conn),
|
|
701
|
+
idempotency_key: str | None = Header(default=None),
|
|
702
|
+
) -> JSONResponse:
|
|
703
|
+
settings: Settings = request.app.state.settings
|
|
704
|
+
_auth_rate_limit(request, conn, "device-code")
|
|
705
|
+
scopes, workspace = _device_code_body(body)
|
|
706
|
+
|
|
707
|
+
def compute() -> tuple[int, dict[str, Any]]:
|
|
708
|
+
grant = start_device_flow(conn, settings, scopes=scopes, workspace_slug=workspace)
|
|
709
|
+
return 201, {
|
|
710
|
+
"device_code": grant.device_code,
|
|
711
|
+
"user_code": grant.user_code,
|
|
712
|
+
"verification_uri": grant.verification_uri,
|
|
713
|
+
"verification_uri_complete": grant.verification_uri_complete,
|
|
714
|
+
"expires_in": grant.expires_in,
|
|
715
|
+
"interval": grant.interval,
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
return _idempotent(request, conn, "", idempotency_key, body, compute)
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def _device_token(
|
|
722
|
+
body: dict[str, Any],
|
|
723
|
+
request: Request,
|
|
724
|
+
conn: sqlite3.Connection = Depends(get_conn),
|
|
725
|
+
) -> dict[str, Any]:
|
|
726
|
+
settings: Settings = request.app.state.settings
|
|
727
|
+
_auth_rate_limit(request, conn, "device-token")
|
|
728
|
+
code = body.get("device_code")
|
|
729
|
+
if not isinstance(code, str) or not code:
|
|
730
|
+
raise AgentevoError(
|
|
731
|
+
ErrorCode.INVALID_ARGUMENT, "device_code must be a non-empty string", {}
|
|
732
|
+
)
|
|
733
|
+
poll = poll_device_flow(conn, settings, device_code=code)
|
|
734
|
+
if poll.status == "approved" and poll.access is not None and poll.refresh is not None:
|
|
735
|
+
return {
|
|
736
|
+
"status": "approved",
|
|
737
|
+
"access_token": poll.access.secret,
|
|
738
|
+
"refresh_token": poll.refresh.secret,
|
|
739
|
+
"expires_in": settings.access_token_ttl_seconds,
|
|
740
|
+
"scopes": list(poll.access.record.scopes),
|
|
741
|
+
}
|
|
742
|
+
if poll.status == "expired":
|
|
743
|
+
return {"status": "expired"}
|
|
744
|
+
return {"status": "pending", "interval": DEVICE_POLL_INTERVAL}
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def _device_approve(
|
|
748
|
+
body: dict[str, Any],
|
|
749
|
+
request: Request,
|
|
750
|
+
principal: Principal | None = Depends(optional_principal),
|
|
751
|
+
conn: sqlite3.Connection = Depends(get_conn),
|
|
752
|
+
idempotency_key: str | None = Header(default=None),
|
|
753
|
+
) -> JSONResponse:
|
|
754
|
+
settings: Settings = request.app.state.settings
|
|
755
|
+
_auth_rate_limit(request, conn, "device-approve")
|
|
756
|
+
user_code, id_token = _decision_body(body)
|
|
757
|
+
if principal is not None and id_token is not None:
|
|
758
|
+
raise AgentevoError(
|
|
759
|
+
ErrorCode.INVALID_ARGUMENT,
|
|
760
|
+
"approve with exactly one credential: Bearer [REDACTED] or id_token",
|
|
761
|
+
{},
|
|
762
|
+
)
|
|
763
|
+
if principal is not None:
|
|
764
|
+
|
|
765
|
+
def compute() -> tuple[int, dict[str, Any]]:
|
|
766
|
+
flow, identity = approve_device_code(
|
|
767
|
+
conn, settings, user_code=user_code, approver=principal
|
|
768
|
+
)
|
|
769
|
+
audit(
|
|
770
|
+
conn,
|
|
771
|
+
identity.tenant_id,
|
|
772
|
+
identity.user_id,
|
|
773
|
+
"device_approved",
|
|
774
|
+
flow.device_code_hash,
|
|
775
|
+
details={"via": "session", "scopes": list(flow.scopes)},
|
|
776
|
+
)
|
|
777
|
+
return 200, {
|
|
778
|
+
"status": "approved",
|
|
779
|
+
"scopes": list(flow.scopes),
|
|
780
|
+
"workspace": flow.workspace_slug,
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
return _idempotent(request, conn, principal.tenant_id, idempotency_key, body, compute)
|
|
784
|
+
flow, identity = approve_device_code(
|
|
785
|
+
conn,
|
|
786
|
+
settings,
|
|
787
|
+
user_code=user_code,
|
|
788
|
+
id_token=id_token,
|
|
789
|
+
jwks_fetcher=_jwks_fetcher(request),
|
|
790
|
+
)
|
|
791
|
+
audit(
|
|
792
|
+
conn,
|
|
793
|
+
identity.tenant_id,
|
|
794
|
+
identity.user_id,
|
|
795
|
+
"device_approved",
|
|
796
|
+
flow.device_code_hash,
|
|
797
|
+
details={"via": "oidc", "scopes": list(flow.scopes)},
|
|
798
|
+
)
|
|
799
|
+
return JSONResponse(
|
|
800
|
+
status_code=200,
|
|
801
|
+
content={
|
|
802
|
+
"status": "approved",
|
|
803
|
+
"scopes": list(flow.scopes),
|
|
804
|
+
"workspace": flow.workspace_slug,
|
|
805
|
+
},
|
|
806
|
+
)
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def _device_deny(
|
|
810
|
+
body: dict[str, Any],
|
|
811
|
+
request: Request,
|
|
812
|
+
principal: Principal | None = Depends(optional_principal),
|
|
813
|
+
conn: sqlite3.Connection = Depends(get_conn),
|
|
814
|
+
idempotency_key: str | None = Header(default=None),
|
|
815
|
+
) -> JSONResponse:
|
|
816
|
+
settings: Settings = request.app.state.settings
|
|
817
|
+
_auth_rate_limit(request, conn, "device-deny")
|
|
818
|
+
user_code, id_token = _decision_body(body)
|
|
819
|
+
if principal is not None and id_token is not None:
|
|
820
|
+
raise AgentevoError(
|
|
821
|
+
ErrorCode.INVALID_ARGUMENT,
|
|
822
|
+
"deny with exactly one credential: Bearer [REDACTED] or id_token",
|
|
823
|
+
{},
|
|
824
|
+
)
|
|
825
|
+
if principal is not None:
|
|
826
|
+
|
|
827
|
+
def compute() -> tuple[int, dict[str, Any]]:
|
|
828
|
+
flow, identity = deny_device_code(
|
|
829
|
+
conn, settings, user_code=user_code, approver=principal
|
|
830
|
+
)
|
|
831
|
+
audit(
|
|
832
|
+
conn,
|
|
833
|
+
identity.tenant_id,
|
|
834
|
+
identity.user_id,
|
|
835
|
+
"device_denied",
|
|
836
|
+
flow.device_code_hash,
|
|
837
|
+
details={"via": "session"},
|
|
838
|
+
)
|
|
839
|
+
return 200, {"status": "denied"}
|
|
840
|
+
|
|
841
|
+
return _idempotent(request, conn, principal.tenant_id, idempotency_key, body, compute)
|
|
842
|
+
flow, identity = deny_device_code(
|
|
843
|
+
conn,
|
|
844
|
+
settings,
|
|
845
|
+
user_code=user_code,
|
|
846
|
+
id_token=id_token,
|
|
847
|
+
jwks_fetcher=_jwks_fetcher(request),
|
|
848
|
+
)
|
|
849
|
+
audit(
|
|
850
|
+
conn,
|
|
851
|
+
identity.tenant_id,
|
|
852
|
+
identity.user_id,
|
|
853
|
+
"device_denied",
|
|
854
|
+
flow.device_code_hash,
|
|
855
|
+
details={"via": "oidc"},
|
|
856
|
+
)
|
|
857
|
+
return JSONResponse(status_code=200, content={"status": "denied"})
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def _oidc_config(request: Request, conn: sqlite3.Connection = Depends(get_conn)) -> dict[str, Any]:
|
|
861
|
+
settings: Settings = request.app.state.settings
|
|
862
|
+
_auth_rate_limit(request, conn, "oidc-config")
|
|
863
|
+
return {
|
|
864
|
+
"configured": bool(settings.oidc_issuer),
|
|
865
|
+
"issuer": settings.oidc_issuer,
|
|
866
|
+
"client_id": settings.oidc_client_id,
|
|
867
|
+
"audience": settings.oidc_client_id,
|
|
868
|
+
"verification_uri": f"{settings.base_url.rstrip('/')}/device",
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
def _ci_exchange(
|
|
873
|
+
body: dict[str, Any],
|
|
874
|
+
request: Request,
|
|
875
|
+
conn: sqlite3.Connection = Depends(get_conn),
|
|
876
|
+
idempotency_key: str | None = Header(default=None),
|
|
877
|
+
) -> JSONResponse:
|
|
878
|
+
settings: Settings = request.app.state.settings
|
|
879
|
+
_auth_rate_limit(request, conn, "ci-exchange")
|
|
880
|
+
credential = body.get("credential")
|
|
881
|
+
if not isinstance(credential, str) or not credential:
|
|
882
|
+
raise AgentevoError(ErrorCode.INVALID_ARGUMENT, "credential must be a non-empty string", {})
|
|
883
|
+
|
|
884
|
+
def compute() -> tuple[int, dict[str, Any]]:
|
|
885
|
+
issued, link = exchange_ci_token(
|
|
886
|
+
conn, settings, credential=credential, jwks_fetcher=_jwks_fetcher(request)
|
|
887
|
+
)
|
|
888
|
+
audit(
|
|
889
|
+
conn,
|
|
890
|
+
link.tenant_id,
|
|
891
|
+
issued.record.id,
|
|
892
|
+
"ci_token_issued",
|
|
893
|
+
link.repo,
|
|
894
|
+
details={"workspace": link.workspace_slug},
|
|
895
|
+
)
|
|
896
|
+
return 201, {
|
|
897
|
+
"token": issued.secret,
|
|
898
|
+
"token_id": issued.record.id,
|
|
899
|
+
"workspace": link.workspace_slug,
|
|
900
|
+
"repository": link.repo,
|
|
901
|
+
"scopes": list(issued.record.scopes),
|
|
902
|
+
"expires_at": issued.record.expires_at,
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
return _idempotent(request, conn, "", idempotency_key, body, compute)
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def _register_device(
|
|
909
|
+
body: dict[str, Any],
|
|
910
|
+
request: Request,
|
|
911
|
+
principal: Principal = Depends(CONTROL_READ),
|
|
912
|
+
conn: sqlite3.Connection = Depends(get_conn),
|
|
913
|
+
idempotency_key: str | None = Header(default=None),
|
|
914
|
+
) -> JSONResponse:
|
|
915
|
+
device_id, name, workspace, public_key, key_id = _device_body(body)
|
|
916
|
+
_require_workspace(principal, workspace)
|
|
917
|
+
|
|
918
|
+
def compute() -> tuple[int, dict[str, Any]]:
|
|
919
|
+
record = register_device(
|
|
920
|
+
conn,
|
|
921
|
+
principal.tenant_id,
|
|
922
|
+
device_id=device_id,
|
|
923
|
+
name=name,
|
|
924
|
+
workspace_slug=workspace,
|
|
925
|
+
public_key=public_key,
|
|
926
|
+
key_id=key_id,
|
|
927
|
+
)
|
|
928
|
+
audit(
|
|
929
|
+
conn,
|
|
930
|
+
principal.tenant_id,
|
|
931
|
+
principal.token_id,
|
|
932
|
+
"device_registered",
|
|
933
|
+
device_id,
|
|
934
|
+
details={"workspace": workspace},
|
|
935
|
+
)
|
|
936
|
+
return 201, _device_json(record)
|
|
937
|
+
|
|
938
|
+
return _idempotent(request, conn, principal.tenant_id, idempotency_key, body, compute)
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def _list_devices(
|
|
942
|
+
principal: Principal = Depends(CONTROL_READ),
|
|
943
|
+
conn: sqlite3.Connection = Depends(get_conn),
|
|
944
|
+
) -> dict[str, Any]:
|
|
945
|
+
records = list_devices(conn, principal.tenant_id)
|
|
946
|
+
visible = [
|
|
947
|
+
record
|
|
948
|
+
for record in records
|
|
949
|
+
if not principal.workspaces or record.workspace_slug in principal.workspaces
|
|
950
|
+
]
|
|
951
|
+
return {"devices": [_device_json(record) for record in visible]}
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
def _revoke_device(
|
|
955
|
+
device_id: str,
|
|
956
|
+
compromised: bool = False,
|
|
957
|
+
principal: Principal = Depends(CONTROL_READ),
|
|
958
|
+
conn: sqlite3.Connection = Depends(get_conn),
|
|
959
|
+
) -> Response:
|
|
960
|
+
rows = list_device_keys(conn, principal.tenant_id, device_id)
|
|
961
|
+
if not rows:
|
|
962
|
+
raise AgentevoError(
|
|
963
|
+
ErrorCode.NOT_FOUND, f"unknown device: {device_id}", {"device_id": device_id}
|
|
964
|
+
)
|
|
965
|
+
for row in rows:
|
|
966
|
+
_require_workspace(principal, row.workspace_slug)
|
|
967
|
+
revoked = revoke_device(conn, principal.tenant_id, device_id, compromised=compromised)
|
|
968
|
+
audit(
|
|
969
|
+
conn,
|
|
970
|
+
principal.tenant_id,
|
|
971
|
+
principal.token_id,
|
|
972
|
+
"device_revoked",
|
|
973
|
+
device_id,
|
|
974
|
+
details={"compromised": compromised, "keys": len(revoked)},
|
|
975
|
+
)
|
|
976
|
+
return Response(status_code=204)
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def _link_repo( # noqa: PLR0913, PLR0917 - FastAPI handler signature
|
|
980
|
+
slug: str,
|
|
981
|
+
body: dict[str, Any],
|
|
982
|
+
request: Request,
|
|
983
|
+
principal: Principal = Depends(CONTROL_READ),
|
|
984
|
+
conn: sqlite3.Connection = Depends(get_conn),
|
|
985
|
+
idempotency_key: str | None = Header(default=None),
|
|
986
|
+
) -> JSONResponse:
|
|
987
|
+
_require_workspace(principal, slug)
|
|
988
|
+
repository = body.get("repository")
|
|
989
|
+
if not isinstance(repository, str) or not repository:
|
|
990
|
+
raise AgentevoError(ErrorCode.INVALID_ARGUMENT, "repository must be a non-empty string", {})
|
|
991
|
+
# Moving a link between workspaces needs visibility into both ends;
|
|
992
|
+
# a token scoped to the target alone cannot steal or strand the source.
|
|
993
|
+
for link in list_repos(conn, principal.tenant_id):
|
|
994
|
+
if link.repo == repository and link.workspace_slug != slug:
|
|
995
|
+
_require_workspace(principal, link.workspace_slug)
|
|
996
|
+
|
|
997
|
+
def compute() -> tuple[int, dict[str, Any]]:
|
|
998
|
+
link = link_repo(conn, principal.tenant_id, repository, slug)
|
|
999
|
+
audit(
|
|
1000
|
+
conn,
|
|
1001
|
+
principal.tenant_id,
|
|
1002
|
+
principal.token_id,
|
|
1003
|
+
"repo_linked",
|
|
1004
|
+
repository,
|
|
1005
|
+
details={"workspace": slug},
|
|
1006
|
+
)
|
|
1007
|
+
return 201, {"repository": link.repo, "workspace": link.workspace_slug}
|
|
1008
|
+
|
|
1009
|
+
return _idempotent(request, conn, principal.tenant_id, idempotency_key, body, compute)
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
def _list_repos(
|
|
1013
|
+
slug: str,
|
|
1014
|
+
principal: Principal = Depends(CONTROL_READ),
|
|
1015
|
+
conn: sqlite3.Connection = Depends(get_conn),
|
|
1016
|
+
) -> dict[str, Any]:
|
|
1017
|
+
_require_workspace(principal, slug)
|
|
1018
|
+
links = [link for link in list_repos(conn, principal.tenant_id) if link.workspace_slug == slug]
|
|
1019
|
+
return {
|
|
1020
|
+
"repos": [{"repository": link.repo, "workspace": link.workspace_slug} for link in links]
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
def _unlink_repo(
|
|
1025
|
+
slug: str,
|
|
1026
|
+
repo: str,
|
|
1027
|
+
principal: Principal = Depends(CONTROL_READ),
|
|
1028
|
+
conn: sqlite3.Connection = Depends(get_conn),
|
|
1029
|
+
) -> Response:
|
|
1030
|
+
_require_workspace(principal, slug)
|
|
1031
|
+
links = list_repos(conn, principal.tenant_id)
|
|
1032
|
+
current = next((link.workspace_slug for link in links if link.repo == repo), None)
|
|
1033
|
+
if current is not None and current != slug:
|
|
1034
|
+
# Wrong-workspace path: 404 without revealing the true workspace.
|
|
1035
|
+
raise AgentevoError(ErrorCode.NOT_FOUND, f"unknown repository link: {repo}", {})
|
|
1036
|
+
unlink_repo(conn, principal.tenant_id, repo)
|
|
1037
|
+
audit(
|
|
1038
|
+
conn,
|
|
1039
|
+
principal.tenant_id,
|
|
1040
|
+
principal.token_id,
|
|
1041
|
+
"repo_unlinked",
|
|
1042
|
+
repo,
|
|
1043
|
+
details={"workspace": slug},
|
|
1044
|
+
)
|
|
1045
|
+
return Response(status_code=204)
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
def _create_webhook(
|
|
1049
|
+
body: dict[str, Any],
|
|
1050
|
+
request: Request,
|
|
1051
|
+
principal: Principal = Depends(CONTROL_READ),
|
|
1052
|
+
conn: sqlite3.Connection = Depends(get_conn),
|
|
1053
|
+
idempotency_key: str | None = Header(default=None),
|
|
1054
|
+
) -> JSONResponse:
|
|
1055
|
+
settings: Settings = request.app.state.settings
|
|
1056
|
+
url, events = _webhook_body(body)
|
|
1057
|
+
|
|
1058
|
+
def compute() -> tuple[int, dict[str, Any]]:
|
|
1059
|
+
issued = create_config(conn, settings, principal.tenant_id, url, events)
|
|
1060
|
+
audit(
|
|
1061
|
+
conn,
|
|
1062
|
+
principal.tenant_id,
|
|
1063
|
+
principal.token_id,
|
|
1064
|
+
"webhook_created",
|
|
1065
|
+
issued.config.id,
|
|
1066
|
+
details={"events": list(issued.config.events)},
|
|
1067
|
+
)
|
|
1068
|
+
payload = _webhook_json(issued.config)
|
|
1069
|
+
payload["secret"] = issued.secret
|
|
1070
|
+
return 201, payload
|
|
1071
|
+
|
|
1072
|
+
return _idempotent(request, conn, principal.tenant_id, idempotency_key, body, compute)
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
def _list_webhooks(
|
|
1076
|
+
principal: Principal = Depends(CONTROL_READ),
|
|
1077
|
+
conn: sqlite3.Connection = Depends(get_conn),
|
|
1078
|
+
) -> dict[str, Any]:
|
|
1079
|
+
return {"webhooks": [_webhook_json(item) for item in list_configs(conn, principal.tenant_id)]}
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
def _revoke_webhook(
|
|
1083
|
+
config_id: str,
|
|
1084
|
+
principal: Principal = Depends(CONTROL_READ),
|
|
1085
|
+
conn: sqlite3.Connection = Depends(get_conn),
|
|
1086
|
+
) -> Response:
|
|
1087
|
+
revoke_config(conn, principal.tenant_id, config_id)
|
|
1088
|
+
audit(
|
|
1089
|
+
conn,
|
|
1090
|
+
principal.tenant_id,
|
|
1091
|
+
principal.token_id,
|
|
1092
|
+
"webhook_revoked",
|
|
1093
|
+
config_id,
|
|
1094
|
+
details={},
|
|
1095
|
+
)
|
|
1096
|
+
return Response(status_code=204)
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
def _rotate_webhook( # noqa: PLR0913, PLR0917 - FastAPI handler signature
|
|
1100
|
+
config_id: str,
|
|
1101
|
+
body: dict[str, Any],
|
|
1102
|
+
request: Request,
|
|
1103
|
+
principal: Principal = Depends(CONTROL_READ),
|
|
1104
|
+
conn: sqlite3.Connection = Depends(get_conn),
|
|
1105
|
+
idempotency_key: str | None = Header(default=None),
|
|
1106
|
+
) -> JSONResponse:
|
|
1107
|
+
settings: Settings = request.app.state.settings
|
|
1108
|
+
|
|
1109
|
+
def compute() -> tuple[int, dict[str, Any]]:
|
|
1110
|
+
issued = rotate_config(conn, settings, principal.tenant_id, config_id)
|
|
1111
|
+
audit(
|
|
1112
|
+
conn,
|
|
1113
|
+
principal.tenant_id,
|
|
1114
|
+
principal.token_id,
|
|
1115
|
+
"webhook_rotated",
|
|
1116
|
+
config_id,
|
|
1117
|
+
details={"key_id": issued.config.key_id},
|
|
1118
|
+
)
|
|
1119
|
+
return 200, {
|
|
1120
|
+
"id": issued.config.id,
|
|
1121
|
+
"key_id": issued.config.key_id,
|
|
1122
|
+
"secret": issued.secret,
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
return _idempotent(request, conn, principal.tenant_id, idempotency_key, body, compute)
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
def _test_webhook(
|
|
1129
|
+
body: dict[str, Any],
|
|
1130
|
+
request: Request,
|
|
1131
|
+
principal: Principal = Depends(CONTROL_READ),
|
|
1132
|
+
conn: sqlite3.Connection = Depends(get_conn),
|
|
1133
|
+
idempotency_key: str | None = Header(default=None),
|
|
1134
|
+
) -> JSONResponse:
|
|
1135
|
+
settings: Settings = request.app.state.settings
|
|
1136
|
+
config_id = body.get("config_id")
|
|
1137
|
+
if not isinstance(config_id, str) or not config_id:
|
|
1138
|
+
raise AgentevoError(ErrorCode.INVALID_ARGUMENT, "config_id must be a non-empty string", {})
|
|
1139
|
+
config = get_config(conn, principal.tenant_id, config_id)
|
|
1140
|
+
|
|
1141
|
+
def compute() -> tuple[int, dict[str, Any]]:
|
|
1142
|
+
event = build_event(
|
|
1143
|
+
"webhook.test",
|
|
1144
|
+
principal.tenant_id,
|
|
1145
|
+
config.id,
|
|
1146
|
+
payload={"config_id": config.id, "key_id": config.key_id},
|
|
1147
|
+
)
|
|
1148
|
+
delivery = deliver_to_config(
|
|
1149
|
+
conn,
|
|
1150
|
+
settings,
|
|
1151
|
+
principal.tenant_id,
|
|
1152
|
+
config.id,
|
|
1153
|
+
event,
|
|
1154
|
+
sender=_webhook_sender(request),
|
|
1155
|
+
timeout_seconds=settings.webhook_timeout_seconds,
|
|
1156
|
+
)
|
|
1157
|
+
audit(
|
|
1158
|
+
conn,
|
|
1159
|
+
principal.tenant_id,
|
|
1160
|
+
principal.token_id,
|
|
1161
|
+
"webhook_tested",
|
|
1162
|
+
config.id,
|
|
1163
|
+
details={"status": delivery.status, "delivery_id": delivery.id},
|
|
1164
|
+
)
|
|
1165
|
+
return 200, {"status": delivery.status, "delivery": _delivery_json(delivery)}
|
|
1166
|
+
|
|
1167
|
+
return _idempotent(request, conn, principal.tenant_id, idempotency_key, body, compute)
|
|
1168
|
+
|
|
1169
|
+
|
|
1170
|
+
def _webhook_deliveries(
|
|
1171
|
+
config_id: str,
|
|
1172
|
+
limit: int = 50,
|
|
1173
|
+
principal: Principal = Depends(CONTROL_READ),
|
|
1174
|
+
conn: sqlite3.Connection = Depends(get_conn),
|
|
1175
|
+
) -> dict[str, Any]:
|
|
1176
|
+
deliveries = list_deliveries(conn, principal.tenant_id, config_id, limit=limit)
|
|
1177
|
+
return {"deliveries": [_delivery_json(item) for item in deliveries]}
|
|
1178
|
+
|
|
1179
|
+
|
|
1180
|
+
def _replay_webhook( # noqa: PLR0913, PLR0917 - FastAPI handler signature
|
|
1181
|
+
delivery_id: str,
|
|
1182
|
+
body: dict[str, Any],
|
|
1183
|
+
request: Request,
|
|
1184
|
+
principal: Principal = Depends(CONTROL_READ),
|
|
1185
|
+
conn: sqlite3.Connection = Depends(get_conn),
|
|
1186
|
+
idempotency_key: str | None = Header(default=None),
|
|
1187
|
+
) -> JSONResponse:
|
|
1188
|
+
settings: Settings = request.app.state.settings
|
|
1189
|
+
|
|
1190
|
+
def compute() -> tuple[int, dict[str, Any]]:
|
|
1191
|
+
delivery = replay_delivery(
|
|
1192
|
+
conn,
|
|
1193
|
+
settings,
|
|
1194
|
+
principal.tenant_id,
|
|
1195
|
+
delivery_id,
|
|
1196
|
+
sender=_webhook_sender(request),
|
|
1197
|
+
timeout_seconds=settings.webhook_timeout_seconds,
|
|
1198
|
+
)
|
|
1199
|
+
audit(
|
|
1200
|
+
conn,
|
|
1201
|
+
principal.tenant_id,
|
|
1202
|
+
principal.token_id,
|
|
1203
|
+
"webhook_replayed",
|
|
1204
|
+
delivery.id,
|
|
1205
|
+
details={"event_id": delivery.event_id, "status": delivery.status},
|
|
1206
|
+
)
|
|
1207
|
+
return 200, {"delivery": _delivery_json(delivery)}
|
|
1208
|
+
|
|
1209
|
+
return _idempotent(request, conn, principal.tenant_id, idempotency_key, body, compute)
|
|
1210
|
+
|
|
1211
|
+
|
|
1212
|
+
def _audit(
|
|
1213
|
+
since_seq: int = 0,
|
|
1214
|
+
limit: int = 100,
|
|
1215
|
+
principal: Principal = Depends(AUDIT_READ),
|
|
1216
|
+
conn: sqlite3.Connection = Depends(get_conn),
|
|
1217
|
+
) -> dict[str, Any]:
|
|
1218
|
+
entries = list_audit(conn, principal.tenant_id, since_seq=since_seq, limit=limit)
|
|
1219
|
+
cursor = entries[-1].seq if entries else since_seq
|
|
1220
|
+
return {
|
|
1221
|
+
"entries": [
|
|
1222
|
+
{
|
|
1223
|
+
"seq": entry.seq,
|
|
1224
|
+
"actor": entry.actor,
|
|
1225
|
+
"action": entry.action,
|
|
1226
|
+
"resource": entry.resource,
|
|
1227
|
+
"details": entry.details,
|
|
1228
|
+
"created_at": entry.created_at,
|
|
1229
|
+
}
|
|
1230
|
+
for entry in entries
|
|
1231
|
+
],
|
|
1232
|
+
"next_cursor": cursor,
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
|
|
1236
|
+
def _require_workspace(principal: Principal, slug: str) -> None:
|
|
1237
|
+
"""Scoped tokens see only their workspaces (404, non-enumerating)."""
|
|
1238
|
+
if principal.workspaces and slug not in principal.workspaces:
|
|
1239
|
+
raise AgentevoError(ErrorCode.NOT_FOUND, f"unknown workspace: {slug}", {})
|
|
1240
|
+
|
|
1241
|
+
|
|
1242
|
+
def _require_admin(principal: Principal) -> None:
|
|
1243
|
+
"""BR-011: export/delete need an owner/admin membership (403 otherwise)."""
|
|
1244
|
+
if principal.role not in (OWNER_ROLE, ADMIN_ROLE):
|
|
1245
|
+
raise AgentevoError(
|
|
1246
|
+
ErrorCode.FORBIDDEN,
|
|
1247
|
+
"workspace export/delete requires an owner/admin role",
|
|
1248
|
+
{},
|
|
1249
|
+
)
|
|
1250
|
+
|
|
1251
|
+
|
|
1252
|
+
def _export_workspace(
|
|
1253
|
+
slug: str,
|
|
1254
|
+
principal: Principal = Depends(CONTROL_READ),
|
|
1255
|
+
conn: sqlite3.Connection = Depends(get_conn),
|
|
1256
|
+
) -> dict[str, Any]:
|
|
1257
|
+
_require_workspace(principal, slug)
|
|
1258
|
+
_require_admin(principal)
|
|
1259
|
+
export = export_workspace_data(conn, principal.tenant_id, slug)
|
|
1260
|
+
audit(
|
|
1261
|
+
conn,
|
|
1262
|
+
principal.tenant_id,
|
|
1263
|
+
principal.token_id,
|
|
1264
|
+
"workspace_data_exported",
|
|
1265
|
+
slug,
|
|
1266
|
+
details={
|
|
1267
|
+
"deltas": len(export.deltas),
|
|
1268
|
+
"devices": len(export.devices),
|
|
1269
|
+
"repos": len(export.repos),
|
|
1270
|
+
},
|
|
1271
|
+
)
|
|
1272
|
+
return {
|
|
1273
|
+
"workspace": export.workspace,
|
|
1274
|
+
"exported_at": export.exported_at,
|
|
1275
|
+
"deltas": list(export.deltas),
|
|
1276
|
+
"devices": list(export.devices),
|
|
1277
|
+
"repos": list(export.repos),
|
|
1278
|
+
"sync_mode": export.sync_mode.to_dict(),
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
|
|
1282
|
+
def _delete_workspace_data(
|
|
1283
|
+
slug: str,
|
|
1284
|
+
principal: Principal = Depends(CONTROL_READ),
|
|
1285
|
+
conn: sqlite3.Connection = Depends(get_conn),
|
|
1286
|
+
) -> dict[str, Any]:
|
|
1287
|
+
_require_workspace(principal, slug)
|
|
1288
|
+
_require_admin(principal)
|
|
1289
|
+
deletion = delete_workspace_data(conn, principal.tenant_id, slug)
|
|
1290
|
+
audit(
|
|
1291
|
+
conn,
|
|
1292
|
+
principal.tenant_id,
|
|
1293
|
+
principal.token_id,
|
|
1294
|
+
"workspace_data_deleted",
|
|
1295
|
+
slug,
|
|
1296
|
+
details={
|
|
1297
|
+
"deltas": deletion.deltas,
|
|
1298
|
+
"managed_objects": deletion.managed_objects,
|
|
1299
|
+
"devices": deletion.devices,
|
|
1300
|
+
"repos": deletion.repos,
|
|
1301
|
+
"backup_expires_at": deletion.backup_expires_at,
|
|
1302
|
+
},
|
|
1303
|
+
)
|
|
1304
|
+
return {
|
|
1305
|
+
"workspace": deletion.workspace,
|
|
1306
|
+
"deleted_at": deletion.deleted_at,
|
|
1307
|
+
"backup_expires_at": deletion.backup_expires_at,
|
|
1308
|
+
"deleted": {
|
|
1309
|
+
"sync_deltas": deletion.deltas,
|
|
1310
|
+
"managed_objects": deletion.managed_objects,
|
|
1311
|
+
"devices": deletion.devices,
|
|
1312
|
+
"repo_links": deletion.repos,
|
|
1313
|
+
},
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
|
|
1317
|
+
def _get_sync_mode(
|
|
1318
|
+
slug: str,
|
|
1319
|
+
principal: Principal = Depends(CONTROL_READ),
|
|
1320
|
+
conn: sqlite3.Connection = Depends(get_conn),
|
|
1321
|
+
) -> dict[str, Any]:
|
|
1322
|
+
_require_workspace(principal, slug)
|
|
1323
|
+
return get_sync_mode(conn, principal.tenant_id, slug).to_dict()
|
|
1324
|
+
|
|
1325
|
+
|
|
1326
|
+
def _set_sync_mode(
|
|
1327
|
+
slug: str,
|
|
1328
|
+
body: dict[str, Any],
|
|
1329
|
+
principal: Principal = Depends(CONTROL_READ),
|
|
1330
|
+
conn: sqlite3.Connection = Depends(get_conn),
|
|
1331
|
+
) -> dict[str, Any]:
|
|
1332
|
+
_require_workspace(principal, slug)
|
|
1333
|
+
_require_admin(principal)
|
|
1334
|
+
mode = _body_mode(body)
|
|
1335
|
+
terms = _body_optional_str(body, "terms_version")
|
|
1336
|
+
expected = _body_optional_str(body, "expected_mode")
|
|
1337
|
+
previous = get_sync_mode(conn, principal.tenant_id, slug)
|
|
1338
|
+
consent = set_sync_mode(
|
|
1339
|
+
conn,
|
|
1340
|
+
principal.tenant_id,
|
|
1341
|
+
slug,
|
|
1342
|
+
mode=mode,
|
|
1343
|
+
actor=principal.user_id or principal.token_id,
|
|
1344
|
+
terms_version=terms,
|
|
1345
|
+
expected_mode=expected,
|
|
1346
|
+
)
|
|
1347
|
+
audit(
|
|
1348
|
+
conn,
|
|
1349
|
+
principal.tenant_id,
|
|
1350
|
+
principal.user_id or principal.token_id,
|
|
1351
|
+
"sync_mode_changed",
|
|
1352
|
+
f"workspaces/{slug}/sync-mode",
|
|
1353
|
+
details={
|
|
1354
|
+
"from": previous.sync_mode,
|
|
1355
|
+
"to": consent.sync_mode,
|
|
1356
|
+
"terms_version": consent.terms_version,
|
|
1357
|
+
},
|
|
1358
|
+
)
|
|
1359
|
+
return consent.to_dict()
|
|
1360
|
+
|
|
1361
|
+
|
|
1362
|
+
def _analytics_summary(
|
|
1363
|
+
workspace: str | None = None,
|
|
1364
|
+
principal: Principal = Depends(CONTROL_READ),
|
|
1365
|
+
conn: sqlite3.Connection = Depends(get_conn),
|
|
1366
|
+
) -> dict[str, Any]:
|
|
1367
|
+
if workspace is not None:
|
|
1368
|
+
_require_workspace(principal, workspace)
|
|
1369
|
+
return summarize(
|
|
1370
|
+
conn, principal.tenant_id, workspace=workspace, workspaces=principal.workspaces
|
|
1371
|
+
).to_dict()
|
|
1372
|
+
|
|
1373
|
+
|
|
1374
|
+
def _body_mode(body: dict[str, Any]) -> str:
|
|
1375
|
+
"""Required sync mode from a mode-change body (400 on anything else)."""
|
|
1376
|
+
raw: Any = body.get("sync_mode", "")
|
|
1377
|
+
if not isinstance(raw, str) or not raw:
|
|
1378
|
+
raise AgentevoError(ErrorCode.INVALID_ARGUMENT, "sync_mode must be a non-empty string", {})
|
|
1379
|
+
return raw
|
|
1380
|
+
|
|
1381
|
+
|
|
1382
|
+
def _body_optional_str(body: dict[str, Any], field: str) -> str | None:
|
|
1383
|
+
"""Optional string field from a route body (400 when mistyped)."""
|
|
1384
|
+
raw: Any = body.get(field)
|
|
1385
|
+
if raw is None:
|
|
1386
|
+
return None
|
|
1387
|
+
if not isinstance(raw, str):
|
|
1388
|
+
raise AgentevoError(ErrorCode.INVALID_ARGUMENT, f"{field} must be a string", {})
|
|
1389
|
+
return raw
|
|
1390
|
+
|
|
1391
|
+
|
|
1392
|
+
def _sync_push(
|
|
1393
|
+
body: dict[str, Any],
|
|
1394
|
+
request: Request,
|
|
1395
|
+
principal: Principal = Depends(SYNC_WRITE),
|
|
1396
|
+
conn: sqlite3.Connection = Depends(get_conn),
|
|
1397
|
+
idempotency_key: str | None = Header(default=None),
|
|
1398
|
+
) -> JSONResponse:
|
|
1399
|
+
envelopes: Any = body.get("envelopes")
|
|
1400
|
+
|
|
1401
|
+
def compute() -> tuple[int, dict[str, Any]]:
|
|
1402
|
+
receipt = push_batch(conn, principal, envelopes, now=datetime.now(UTC))
|
|
1403
|
+
indexed = index_accepted(conn, principal.tenant_id, receipt.accepted)
|
|
1404
|
+
audit(
|
|
1405
|
+
conn,
|
|
1406
|
+
principal.tenant_id,
|
|
1407
|
+
principal.token_id,
|
|
1408
|
+
"sync_push",
|
|
1409
|
+
"sync/push",
|
|
1410
|
+
details={
|
|
1411
|
+
"accepted": [f"{item.object_id}:v{item.version}" for item in receipt.accepted],
|
|
1412
|
+
"duplicates": list(receipt.duplicates),
|
|
1413
|
+
"indexed": indexed,
|
|
1414
|
+
},
|
|
1415
|
+
)
|
|
1416
|
+
return 200, receipt.to_dict()
|
|
1417
|
+
|
|
1418
|
+
return _idempotent(request, conn, principal.tenant_id, idempotency_key, body, compute)
|
|
1419
|
+
|
|
1420
|
+
|
|
1421
|
+
def _sync_pull(
|
|
1422
|
+
body: dict[str, Any],
|
|
1423
|
+
request: Request,
|
|
1424
|
+
principal: Principal = Depends(SYNC_READ),
|
|
1425
|
+
conn: sqlite3.Connection = Depends(get_conn),
|
|
1426
|
+
idempotency_key: str | None = Header(default=None),
|
|
1427
|
+
) -> JSONResponse:
|
|
1428
|
+
cursor = body.get("cursor", "")
|
|
1429
|
+
limit = body.get("limit", 100)
|
|
1430
|
+
|
|
1431
|
+
def compute() -> tuple[int, dict[str, Any]]:
|
|
1432
|
+
page = pull_page(conn, principal, cursor, limit, tombstones_only=False)
|
|
1433
|
+
return 200, page.to_dict()
|
|
1434
|
+
|
|
1435
|
+
return _idempotent(request, conn, principal.tenant_id, idempotency_key, body, compute)
|
|
1436
|
+
|
|
1437
|
+
|
|
1438
|
+
def _revocations(
|
|
1439
|
+
after: str = "",
|
|
1440
|
+
limit: int = 100,
|
|
1441
|
+
principal: Principal = Depends(SYNC_READ),
|
|
1442
|
+
conn: sqlite3.Connection = Depends(get_conn),
|
|
1443
|
+
) -> dict[str, Any]:
|
|
1444
|
+
page = pull_page(conn, principal, after, limit, tombstones_only=True)
|
|
1445
|
+
return page.to_dict()
|
|
1446
|
+
|
|
1447
|
+
|
|
1448
|
+
def _events_batch(
|
|
1449
|
+
body: dict[str, Any],
|
|
1450
|
+
request: Request,
|
|
1451
|
+
principal: Principal = Depends(SYNC_WRITE),
|
|
1452
|
+
conn: sqlite3.Connection = Depends(get_conn),
|
|
1453
|
+
idempotency_key: str | None = Header(default=None),
|
|
1454
|
+
) -> JSONResponse:
|
|
1455
|
+
events: Any = body.get("events")
|
|
1456
|
+
|
|
1457
|
+
def compute() -> tuple[int, dict[str, Any]]:
|
|
1458
|
+
receipt = ingest_batch(conn, principal.tenant_id, events)
|
|
1459
|
+
audit(
|
|
1460
|
+
conn,
|
|
1461
|
+
principal.tenant_id,
|
|
1462
|
+
principal.token_id,
|
|
1463
|
+
"events_ingested",
|
|
1464
|
+
"events/batch",
|
|
1465
|
+
details={
|
|
1466
|
+
"accepted": len(receipt.accepted),
|
|
1467
|
+
"duplicates": len(receipt.duplicates),
|
|
1468
|
+
},
|
|
1469
|
+
)
|
|
1470
|
+
return 201, {
|
|
1471
|
+
"accepted": list(receipt.accepted),
|
|
1472
|
+
"duplicates": list(receipt.duplicates),
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
return _idempotent(request, conn, principal.tenant_id, idempotency_key, body, compute)
|
|
1476
|
+
|
|
1477
|
+
|
|
1478
|
+
def _recall(
|
|
1479
|
+
body: dict[str, Any],
|
|
1480
|
+
principal: Principal = Depends(RECALL_READ),
|
|
1481
|
+
conn: sqlite3.Connection = Depends(get_conn),
|
|
1482
|
+
) -> dict[str, Any]:
|
|
1483
|
+
query = parse_query(body)
|
|
1484
|
+
result = recall(conn, principal.tenant_id, query, workspaces=principal.workspaces)
|
|
1485
|
+
return {
|
|
1486
|
+
"objects": [
|
|
1487
|
+
{
|
|
1488
|
+
"object_id": hit.object_id,
|
|
1489
|
+
"version": hit.version,
|
|
1490
|
+
"object_type": hit.object_type,
|
|
1491
|
+
"state": hit.state,
|
|
1492
|
+
"advisory": hit.advisory,
|
|
1493
|
+
"score": hit.score,
|
|
1494
|
+
"reason": hit.reason,
|
|
1495
|
+
"confidence": hit.confidence,
|
|
1496
|
+
"risk_class": hit.risk_class,
|
|
1497
|
+
"environment_match": hit.environment_match,
|
|
1498
|
+
"freshness": hit.freshness,
|
|
1499
|
+
"body": hit.body,
|
|
1500
|
+
}
|
|
1501
|
+
for hit in result.objects
|
|
1502
|
+
],
|
|
1503
|
+
"budgets": {
|
|
1504
|
+
"returned": len(result.objects),
|
|
1505
|
+
"max_objects": result.max_objects,
|
|
1506
|
+
"tokens_used": result.tokens_used,
|
|
1507
|
+
"max_tokens": result.max_tokens,
|
|
1508
|
+
},
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
|
|
1512
|
+
#: Submit/poll/cancel scope per job kind (one scope owns a job's lifecycle).
|
|
1513
|
+
#: Compile is producer work (derived C2 objects), so the sync:write scope
|
|
1514
|
+
#: that pushes source evidence also submits and polls compile jobs.
|
|
1515
|
+
_JOB_TYPE_SCOPES = {
|
|
1516
|
+
JobType.VERIFY.value: "verify:run",
|
|
1517
|
+
JobType.COMPILE_EXPERIENCES.value: "sync:write",
|
|
1518
|
+
JobType.COMPILE_WORKFLOW.value: "sync:write",
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
#: Any scope that participates in async work (listing spans job kinds).
|
|
1522
|
+
_JOB_SCOPES = frozenset(_JOB_TYPE_SCOPES.values())
|
|
1523
|
+
|
|
1524
|
+
#: List filter vocabulary (unknown states 400 instead of silently matching nothing).
|
|
1525
|
+
_JOB_STATES = frozenset(state.value for state in JobState)
|
|
1526
|
+
|
|
1527
|
+
|
|
1528
|
+
def _require_job_scope(principal: Principal, job_type: str) -> None:
|
|
1529
|
+
"""Enforce the lifecycle scope of one job kind (400 on unknown kinds)."""
|
|
1530
|
+
scope = _JOB_TYPE_SCOPES.get(job_type)
|
|
1531
|
+
if scope is None:
|
|
1532
|
+
raise AgentevoError(
|
|
1533
|
+
ErrorCode.INVALID_ARGUMENT, f"unknown job type: {job_type}", {"job_type": job_type}
|
|
1534
|
+
)
|
|
1535
|
+
if not principal.allows(scope):
|
|
1536
|
+
raise AgentevoError(
|
|
1537
|
+
ErrorCode.FORBIDDEN,
|
|
1538
|
+
f"token lacks required scope: {scope}",
|
|
1539
|
+
{"scope": scope},
|
|
1540
|
+
)
|
|
1541
|
+
|
|
1542
|
+
|
|
1543
|
+
#: States carrying prior standing (revalidation re-confirms; it never validates fresh work).
|
|
1544
|
+
_REVALIDATABLE_STATES = frozenset(
|
|
1545
|
+
{
|
|
1546
|
+
TrustState.VERIFIED.value,
|
|
1547
|
+
TrustState.TRUSTED.value,
|
|
1548
|
+
TrustState.STALE.value,
|
|
1549
|
+
TrustState.COMPILED.value,
|
|
1550
|
+
}
|
|
1551
|
+
)
|
|
1552
|
+
|
|
1553
|
+
|
|
1554
|
+
def _body_version(body: dict[str, Any], field: str = "version") -> int:
|
|
1555
|
+
"""Required exact version from an object-route body (400 on anything else)."""
|
|
1556
|
+
version: Any = body.get(field)
|
|
1557
|
+
if isinstance(version, bool) or not isinstance(version, int):
|
|
1558
|
+
raise AgentevoError(ErrorCode.INVALID_ARGUMENT, f"{field} must be an integer", {})
|
|
1559
|
+
return version
|
|
1560
|
+
|
|
1561
|
+
|
|
1562
|
+
def _body_target(body: dict[str, Any]) -> TrustState:
|
|
1563
|
+
"""Required promotion target from a promote body (400 on anything else)."""
|
|
1564
|
+
raw: Any = body.get("target", "")
|
|
1565
|
+
if not isinstance(raw, str):
|
|
1566
|
+
raise AgentevoError(ErrorCode.INVALID_ARGUMENT, "target must be a string", {})
|
|
1567
|
+
try:
|
|
1568
|
+
return TrustState(raw)
|
|
1569
|
+
except ValueError as exc:
|
|
1570
|
+
raise AgentevoError(
|
|
1571
|
+
ErrorCode.INVALID_ARGUMENT, f"unknown promotion target: {raw}", {"target": raw}
|
|
1572
|
+
) from exc
|
|
1573
|
+
|
|
1574
|
+
|
|
1575
|
+
def _managed_or_404(
|
|
1576
|
+
conn: sqlite3.Connection, tenant_id: str, object_id: str, version: int
|
|
1577
|
+
) -> ManagedObject:
|
|
1578
|
+
"""One indexed version, tenant-scoped (404 reveals nothing across tenants)."""
|
|
1579
|
+
target = get_managed(conn, tenant_id, object_id, version)
|
|
1580
|
+
if target is None:
|
|
1581
|
+
raise AgentevoError(
|
|
1582
|
+
ErrorCode.NOT_FOUND,
|
|
1583
|
+
"unknown object version",
|
|
1584
|
+
{"object_id": object_id, "version": version},
|
|
1585
|
+
)
|
|
1586
|
+
return target
|
|
1587
|
+
|
|
1588
|
+
|
|
1589
|
+
def _verify_object( # noqa: PLR0913, PLR0917 - handler carries route context
|
|
1590
|
+
object_id: str,
|
|
1591
|
+
body: dict[str, Any],
|
|
1592
|
+
request: Request,
|
|
1593
|
+
principal: Principal = Depends(VERIFY_RUN),
|
|
1594
|
+
conn: sqlite3.Connection = Depends(get_conn),
|
|
1595
|
+
idempotency_key: str | None = Header(default=None),
|
|
1596
|
+
) -> JSONResponse:
|
|
1597
|
+
version = _body_version(body)
|
|
1598
|
+
environment = body.get("environment")
|
|
1599
|
+
parse_environment(environment)
|
|
1600
|
+
_managed_or_404(conn, principal.tenant_id, object_id, version)
|
|
1601
|
+
params = {} if environment is None else {"environment": environment}
|
|
1602
|
+
|
|
1603
|
+
def compute() -> tuple[int, dict[str, Any]]:
|
|
1604
|
+
job = create_job(
|
|
1605
|
+
conn,
|
|
1606
|
+
principal.tenant_id,
|
|
1607
|
+
JobType.VERIFY.value,
|
|
1608
|
+
object_id=object_id,
|
|
1609
|
+
version=version,
|
|
1610
|
+
params=params,
|
|
1611
|
+
)
|
|
1612
|
+
audit(
|
|
1613
|
+
conn,
|
|
1614
|
+
principal.tenant_id,
|
|
1615
|
+
principal.token_id,
|
|
1616
|
+
"job_submitted",
|
|
1617
|
+
f"jobs/{job.job_id}",
|
|
1618
|
+
details={"job_type": JobType.VERIFY.value},
|
|
1619
|
+
)
|
|
1620
|
+
return 202, job.to_dict()
|
|
1621
|
+
|
|
1622
|
+
return _idempotent(request, conn, principal.tenant_id, idempotency_key, body, compute)
|
|
1623
|
+
|
|
1624
|
+
|
|
1625
|
+
def _revalidate_object( # noqa: PLR0913, PLR0917 - handler carries route context
|
|
1626
|
+
object_id: str,
|
|
1627
|
+
body: dict[str, Any],
|
|
1628
|
+
request: Request,
|
|
1629
|
+
principal: Principal = Depends(VERIFY_RUN),
|
|
1630
|
+
conn: sqlite3.Connection = Depends(get_conn),
|
|
1631
|
+
idempotency_key: str | None = Header(default=None),
|
|
1632
|
+
) -> JSONResponse:
|
|
1633
|
+
version = _body_version(body)
|
|
1634
|
+
environment = body.get("environment")
|
|
1635
|
+
parse_environment(environment)
|
|
1636
|
+
target = _managed_or_404(conn, principal.tenant_id, object_id, version)
|
|
1637
|
+
if target.state not in _REVALIDATABLE_STATES:
|
|
1638
|
+
raise AgentevoError(
|
|
1639
|
+
ErrorCode.CONFLICT,
|
|
1640
|
+
f"object has no standing to revalidate: {target.state}",
|
|
1641
|
+
{"object_id": object_id, "version": version, "state": target.state},
|
|
1642
|
+
)
|
|
1643
|
+
params: dict[str, Any] = {"revalidation": True}
|
|
1644
|
+
if environment is not None:
|
|
1645
|
+
params["environment"] = environment
|
|
1646
|
+
|
|
1647
|
+
def compute() -> tuple[int, dict[str, Any]]:
|
|
1648
|
+
job = create_job(
|
|
1649
|
+
conn,
|
|
1650
|
+
principal.tenant_id,
|
|
1651
|
+
JobType.VERIFY.value,
|
|
1652
|
+
object_id=object_id,
|
|
1653
|
+
version=version,
|
|
1654
|
+
params=params,
|
|
1655
|
+
)
|
|
1656
|
+
audit(
|
|
1657
|
+
conn,
|
|
1658
|
+
principal.tenant_id,
|
|
1659
|
+
principal.token_id,
|
|
1660
|
+
"revalidation_scheduled",
|
|
1661
|
+
f"jobs/{job.job_id}",
|
|
1662
|
+
details={"object_id": object_id, "version": version},
|
|
1663
|
+
)
|
|
1664
|
+
return 202, job.to_dict()
|
|
1665
|
+
|
|
1666
|
+
return _idempotent(request, conn, principal.tenant_id, idempotency_key, body, compute)
|
|
1667
|
+
|
|
1668
|
+
|
|
1669
|
+
def _get_object_version(
|
|
1670
|
+
object_id: str,
|
|
1671
|
+
version: int,
|
|
1672
|
+
principal: Principal = Depends(RECALL_READ),
|
|
1673
|
+
conn: sqlite3.Connection = Depends(get_conn),
|
|
1674
|
+
) -> dict[str, Any]:
|
|
1675
|
+
return _managed_or_404(conn, principal.tenant_id, object_id, version).to_dict()
|
|
1676
|
+
|
|
1677
|
+
|
|
1678
|
+
def _body_reason(body: dict[str, Any]) -> RevocationReason:
|
|
1679
|
+
"""Closed C1 reason code (400 on anything else; admin-revoke by default)."""
|
|
1680
|
+
raw: Any = body.get("reason", RevocationReason.ADMIN_REVOKE.value)
|
|
1681
|
+
if not isinstance(raw, str):
|
|
1682
|
+
raise AgentevoError(ErrorCode.INVALID_ARGUMENT, "reason must be a string", {})
|
|
1683
|
+
try:
|
|
1684
|
+
return RevocationReason(raw)
|
|
1685
|
+
except ValueError as exc:
|
|
1686
|
+
raise AgentevoError(
|
|
1687
|
+
ErrorCode.INVALID_ARGUMENT, f"unknown revocation reason: {raw}", {"reason": raw}
|
|
1688
|
+
) from exc
|
|
1689
|
+
|
|
1690
|
+
|
|
1691
|
+
def _revoke_object( # noqa: PLR0913, PLR0917 - handler carries route context
|
|
1692
|
+
object_id: str,
|
|
1693
|
+
body: dict[str, Any],
|
|
1694
|
+
request: Request,
|
|
1695
|
+
principal: Principal = Depends(OBJECT_REVOKE),
|
|
1696
|
+
conn: sqlite3.Connection = Depends(get_conn),
|
|
1697
|
+
idempotency_key: str | None = Header(default=None),
|
|
1698
|
+
) -> JSONResponse:
|
|
1699
|
+
reason = _body_reason(body)
|
|
1700
|
+
|
|
1701
|
+
def compute() -> tuple[int, dict[str, Any]]:
|
|
1702
|
+
result = revoke_object(conn, principal, object_id, reason=reason)
|
|
1703
|
+
return 200, result.to_dict()
|
|
1704
|
+
|
|
1705
|
+
return _idempotent(request, conn, principal.tenant_id, idempotency_key, body, compute)
|
|
1706
|
+
|
|
1707
|
+
|
|
1708
|
+
def _compile_experiences(
|
|
1709
|
+
body: dict[str, Any],
|
|
1710
|
+
request: Request,
|
|
1711
|
+
principal: Principal = Depends(SYNC_WRITE),
|
|
1712
|
+
conn: sqlite3.Connection = Depends(get_conn),
|
|
1713
|
+
idempotency_key: str | None = Header(default=None),
|
|
1714
|
+
) -> JSONResponse:
|
|
1715
|
+
session_id = parse_required_str(body.get("session_id"), "session_id")
|
|
1716
|
+
workspace_id = parse_required_str(body.get("workspace_id"), "workspace_id")
|
|
1717
|
+
project_id = parse_required_str(body.get("project_id", DEFAULT_COMPILE_PROJECT), "project_id")
|
|
1718
|
+
ensure_workspace(conn, principal.tenant_id, workspace_id)
|
|
1719
|
+
require_searchable(conn, principal.tenant_id, workspace_id, "compile managed experiences")
|
|
1720
|
+
params = {"session_id": session_id, "workspace_id": workspace_id, "project_id": project_id}
|
|
1721
|
+
|
|
1722
|
+
def compute() -> tuple[int, dict[str, Any]]:
|
|
1723
|
+
job = create_job(
|
|
1724
|
+
conn, principal.tenant_id, JobType.COMPILE_EXPERIENCES.value, params=params
|
|
1725
|
+
)
|
|
1726
|
+
audit(
|
|
1727
|
+
conn,
|
|
1728
|
+
principal.tenant_id,
|
|
1729
|
+
principal.token_id,
|
|
1730
|
+
"job_submitted",
|
|
1731
|
+
f"jobs/{job.job_id}",
|
|
1732
|
+
details={"job_type": JobType.COMPILE_EXPERIENCES.value},
|
|
1733
|
+
)
|
|
1734
|
+
return 202, job.to_dict()
|
|
1735
|
+
|
|
1736
|
+
return _idempotent(request, conn, principal.tenant_id, idempotency_key, body, compute)
|
|
1737
|
+
|
|
1738
|
+
|
|
1739
|
+
def _compile_workflows(
|
|
1740
|
+
body: dict[str, Any],
|
|
1741
|
+
request: Request,
|
|
1742
|
+
principal: Principal = Depends(SYNC_WRITE),
|
|
1743
|
+
conn: sqlite3.Connection = Depends(get_conn),
|
|
1744
|
+
idempotency_key: str | None = Header(default=None),
|
|
1745
|
+
) -> JSONResponse:
|
|
1746
|
+
refs = parse_skill_refs(body.get("skills"))
|
|
1747
|
+
parse_bindings(body.get("bindings"))
|
|
1748
|
+
parse_rollback(body.get("rollback", []))
|
|
1749
|
+
parse_fallbacks(body.get("fallbacks", {}))
|
|
1750
|
+
canary = parse_canary_steps(body.get("canary_steps", 1))
|
|
1751
|
+
rows = [
|
|
1752
|
+
_compile_skill_row(conn, principal.tenant_id, object_id, version)
|
|
1753
|
+
for object_id, version in refs
|
|
1754
|
+
]
|
|
1755
|
+
if len({row.workspace_id for row in rows}) > 1:
|
|
1756
|
+
raise AgentevoError(
|
|
1757
|
+
ErrorCode.INVALID_ARGUMENT,
|
|
1758
|
+
"cross-workspace compile rejected",
|
|
1759
|
+
{"workspaces": sorted({row.workspace_id for row in rows})},
|
|
1760
|
+
)
|
|
1761
|
+
require_searchable(conn, principal.tenant_id, rows[0].workspace_id, "compile managed workflows")
|
|
1762
|
+
params: dict[str, Any] = {
|
|
1763
|
+
"skills": [{"object_id": row.object_id, "version": row.version} for row in rows],
|
|
1764
|
+
"bindings": body.get("bindings"),
|
|
1765
|
+
"rollback": body.get("rollback", []),
|
|
1766
|
+
"fallbacks": body.get("fallbacks", {}),
|
|
1767
|
+
"canary_steps": canary,
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
def compute() -> tuple[int, dict[str, Any]]:
|
|
1771
|
+
job = create_job(conn, principal.tenant_id, JobType.COMPILE_WORKFLOW.value, params=params)
|
|
1772
|
+
audit(
|
|
1773
|
+
conn,
|
|
1774
|
+
principal.tenant_id,
|
|
1775
|
+
principal.token_id,
|
|
1776
|
+
"job_submitted",
|
|
1777
|
+
f"jobs/{job.job_id}",
|
|
1778
|
+
details={"job_type": JobType.COMPILE_WORKFLOW.value},
|
|
1779
|
+
)
|
|
1780
|
+
return 202, job.to_dict()
|
|
1781
|
+
|
|
1782
|
+
return _idempotent(request, conn, principal.tenant_id, idempotency_key, body, compute)
|
|
1783
|
+
|
|
1784
|
+
|
|
1785
|
+
def _compile_skill_row(
|
|
1786
|
+
conn: sqlite3.Connection, tenant_id: str, object_id: str, version: int
|
|
1787
|
+
) -> ManagedObject:
|
|
1788
|
+
"""One referenced skill (404 unknown; 409 unless SKILL plus Trusted)."""
|
|
1789
|
+
row = get_managed(conn, tenant_id, object_id, version)
|
|
1790
|
+
if row is None:
|
|
1791
|
+
raise AgentevoError(
|
|
1792
|
+
ErrorCode.NOT_FOUND,
|
|
1793
|
+
"unknown object version",
|
|
1794
|
+
{"object_id": object_id, "version": version},
|
|
1795
|
+
)
|
|
1796
|
+
if row.object_type != LearnedObjectType.SKILL.value:
|
|
1797
|
+
raise AgentevoError(
|
|
1798
|
+
ErrorCode.CONFLICT,
|
|
1799
|
+
f"only skills compile: {object_id}",
|
|
1800
|
+
{"object_id": object_id, "object_type": row.object_type},
|
|
1801
|
+
)
|
|
1802
|
+
if row.state != TrustState.TRUSTED.value:
|
|
1803
|
+
raise AgentevoError(
|
|
1804
|
+
ErrorCode.CONFLICT,
|
|
1805
|
+
f"only Trusted skills compile: {row.state}",
|
|
1806
|
+
{"object_id": object_id, "state": row.state},
|
|
1807
|
+
)
|
|
1808
|
+
return row
|
|
1809
|
+
|
|
1810
|
+
|
|
1811
|
+
def _promote_object( # noqa: PLR0913, PLR0917 - handler carries route context
|
|
1812
|
+
object_id: str,
|
|
1813
|
+
body: dict[str, Any],
|
|
1814
|
+
request: Request,
|
|
1815
|
+
principal: Principal = Depends(OBJECT_PROMOTE),
|
|
1816
|
+
conn: sqlite3.Connection = Depends(get_conn),
|
|
1817
|
+
idempotency_key: str | None = Header(default=None),
|
|
1818
|
+
) -> JSONResponse:
|
|
1819
|
+
version = _body_version(body, "expected_version")
|
|
1820
|
+
target = _body_target(body)
|
|
1821
|
+
|
|
1822
|
+
def compute() -> tuple[int, dict[str, Any]]:
|
|
1823
|
+
try:
|
|
1824
|
+
promoted = promote_managed(conn, principal, object_id, version, target)
|
|
1825
|
+
except AgentevoError as exc:
|
|
1826
|
+
if exc.code is ErrorCode.FORBIDDEN:
|
|
1827
|
+
# Denials roll the request transaction back (failed attempts
|
|
1828
|
+
# never bill), so release it now and commit the denial audit
|
|
1829
|
+
# out-of-band on a fresh connection.
|
|
1830
|
+
conn.rollback()
|
|
1831
|
+
database: Database = request.app.state.db
|
|
1832
|
+
with database.connect() as audit_conn:
|
|
1833
|
+
audit(
|
|
1834
|
+
audit_conn,
|
|
1835
|
+
principal.tenant_id,
|
|
1836
|
+
principal.user_id or principal.token_id,
|
|
1837
|
+
"promotion_denied",
|
|
1838
|
+
f"objects/{object_id}/versions/{version}",
|
|
1839
|
+
details={
|
|
1840
|
+
"target": target.value,
|
|
1841
|
+
"reason": exc.details.get("reason", ""),
|
|
1842
|
+
},
|
|
1843
|
+
)
|
|
1844
|
+
raise
|
|
1845
|
+
return 200, promoted.to_dict()
|
|
1846
|
+
|
|
1847
|
+
return _idempotent(request, conn, principal.tenant_id, idempotency_key, body, compute)
|
|
1848
|
+
|
|
1849
|
+
|
|
1850
|
+
def _get_job(
|
|
1851
|
+
job_id: str,
|
|
1852
|
+
principal: Principal = Depends(metered),
|
|
1853
|
+
conn: sqlite3.Connection = Depends(get_conn),
|
|
1854
|
+
) -> dict[str, Any]:
|
|
1855
|
+
job = get_job(conn, principal.tenant_id, job_id)
|
|
1856
|
+
if job is None:
|
|
1857
|
+
raise AgentevoError(ErrorCode.NOT_FOUND, "unknown job", {"job_id": job_id})
|
|
1858
|
+
_require_job_scope(principal, job.job_type)
|
|
1859
|
+
return job.to_dict()
|
|
1860
|
+
|
|
1861
|
+
|
|
1862
|
+
def _list_jobs(
|
|
1863
|
+
state: str | None = None,
|
|
1864
|
+
limit: int = 50,
|
|
1865
|
+
principal: Principal = Depends(metered),
|
|
1866
|
+
conn: sqlite3.Connection = Depends(get_conn),
|
|
1867
|
+
) -> dict[str, Any]:
|
|
1868
|
+
if not (principal.scopes & _JOB_SCOPES):
|
|
1869
|
+
raise AgentevoError(
|
|
1870
|
+
ErrorCode.FORBIDDEN,
|
|
1871
|
+
"token lacks a job scope",
|
|
1872
|
+
{"scopes": sorted(_JOB_SCOPES)},
|
|
1873
|
+
)
|
|
1874
|
+
if state is not None and state not in _JOB_STATES:
|
|
1875
|
+
raise AgentevoError(
|
|
1876
|
+
ErrorCode.INVALID_ARGUMENT, f"unknown job state: {state}", {"state": state}
|
|
1877
|
+
)
|
|
1878
|
+
jobs = list_jobs(conn, principal.tenant_id, state=state, limit=limit)
|
|
1879
|
+
return {"jobs": [job.to_dict() for job in jobs]}
|
|
1880
|
+
|
|
1881
|
+
|
|
1882
|
+
def _cancel_job(
|
|
1883
|
+
job_id: str,
|
|
1884
|
+
request: Request,
|
|
1885
|
+
principal: Principal = Depends(metered),
|
|
1886
|
+
conn: sqlite3.Connection = Depends(get_conn),
|
|
1887
|
+
idempotency_key: str | None = Header(default=None),
|
|
1888
|
+
) -> JSONResponse:
|
|
1889
|
+
job = get_job(conn, principal.tenant_id, job_id)
|
|
1890
|
+
if job is None:
|
|
1891
|
+
raise AgentevoError(ErrorCode.NOT_FOUND, "unknown job", {"job_id": job_id})
|
|
1892
|
+
_require_job_scope(principal, job.job_type)
|
|
1893
|
+
|
|
1894
|
+
def compute() -> tuple[int, dict[str, Any]]:
|
|
1895
|
+
canceled = cancel_job(conn, principal.tenant_id, job_id)
|
|
1896
|
+
if canceled is None: # pragma: no cover - rows are never deleted; pre-checked above
|
|
1897
|
+
raise AgentevoError(ErrorCode.NOT_FOUND, "unknown job", {"job_id": job_id})
|
|
1898
|
+
audit(
|
|
1899
|
+
conn,
|
|
1900
|
+
principal.tenant_id,
|
|
1901
|
+
principal.token_id,
|
|
1902
|
+
"job_canceled",
|
|
1903
|
+
f"jobs/{job_id}",
|
|
1904
|
+
details={"state": canceled.state},
|
|
1905
|
+
)
|
|
1906
|
+
return 200, canceled.to_dict()
|
|
1907
|
+
|
|
1908
|
+
return _idempotent(request, conn, principal.tenant_id, idempotency_key, {}, compute)
|
|
1909
|
+
|
|
1910
|
+
|
|
1911
|
+
#: Error-detail strings are truncated here so a hostile input is never echoed
|
|
1912
|
+
#: back unbounded (and a non-serializable input never 500s the handler).
|
|
1913
|
+
_ERROR_DETAIL_CHARS = 1024
|
|
1914
|
+
|
|
1915
|
+
|
|
1916
|
+
def _truncate_detail(text: str) -> str:
|
|
1917
|
+
"""Cap one detail string (no-op for short text)."""
|
|
1918
|
+
if len(text) > _ERROR_DETAIL_CHARS:
|
|
1919
|
+
return text[:_ERROR_DETAIL_CHARS] + "…(truncated)"
|
|
1920
|
+
return text
|
|
1921
|
+
|
|
1922
|
+
|
|
1923
|
+
def _jsonable_detail(value: Any) -> Any:
|
|
1924
|
+
"""Render one error-detail value JSON-safe (validation inputs are hostile)."""
|
|
1925
|
+
if isinstance(value, dict):
|
|
1926
|
+
rendered: Any = {str(key): _jsonable_detail(item) for key, item in value.items()}
|
|
1927
|
+
elif isinstance(value, (list, tuple)):
|
|
1928
|
+
rendered = [_jsonable_detail(item) for item in value]
|
|
1929
|
+
elif isinstance(value, bytes):
|
|
1930
|
+
try:
|
|
1931
|
+
rendered = _truncate_detail(value.decode("utf-8"))
|
|
1932
|
+
except UnicodeDecodeError:
|
|
1933
|
+
rendered = _truncate_detail(repr(value))
|
|
1934
|
+
elif isinstance(value, str):
|
|
1935
|
+
rendered = _truncate_detail(value)
|
|
1936
|
+
elif value is None or isinstance(value, (bool, int, float)):
|
|
1937
|
+
rendered = value
|
|
1938
|
+
else:
|
|
1939
|
+
rendered = _truncate_detail(str(value))
|
|
1940
|
+
return rendered
|
|
1941
|
+
|
|
1942
|
+
|
|
1943
|
+
def _error_response(
|
|
1944
|
+
code: ErrorCode, message: str, details: dict[str, Any], exc: AgentevoError | None
|
|
1945
|
+
) -> JSONResponse:
|
|
1946
|
+
request_id = uuid4().hex[:16]
|
|
1947
|
+
headers = {REQUEST_ID_HEADER: request_id}
|
|
1948
|
+
if exc is not None:
|
|
1949
|
+
retry_after = retry_after_for(exc)
|
|
1950
|
+
if retry_after is not None:
|
|
1951
|
+
headers[RETRY_AFTER_HEADER] = str(retry_after)
|
|
1952
|
+
safe = {key: _jsonable_detail(value) for key, value in details.items()}
|
|
1953
|
+
return JSONResponse(
|
|
1954
|
+
status_code=HTTP_STATUS[code],
|
|
1955
|
+
content={"code": code.value, "message": message, "details": safe},
|
|
1956
|
+
headers=headers,
|
|
1957
|
+
)
|
|
1958
|
+
|
|
1959
|
+
|
|
1960
|
+
def _bearer_secret(authorization: str | None) -> str:
|
|
1961
|
+
if authorization is None:
|
|
1962
|
+
raise AgentevoError(ErrorCode.UNAUTHENTICATED, "missing credentials", {})
|
|
1963
|
+
scheme, _, secret = authorization.partition(" ")
|
|
1964
|
+
if scheme.lower() != "bearer" or not secret:
|
|
1965
|
+
raise AgentevoError(ErrorCode.UNAUTHENTICATED, "malformed credentials", {})
|
|
1966
|
+
return secret
|
|
1967
|
+
|
|
1968
|
+
|
|
1969
|
+
def _token_body(body: dict[str, Any]) -> tuple[str, list[str], list[str], timedelta]:
|
|
1970
|
+
name = body.get("name")
|
|
1971
|
+
scopes = body.get("scopes")
|
|
1972
|
+
workspaces = body.get("workspaces", [])
|
|
1973
|
+
ttl_seconds = body.get("ttl_seconds", 90 * 24 * 3600)
|
|
1974
|
+
if not isinstance(name, str) or not name:
|
|
1975
|
+
raise AgentevoError(ErrorCode.INVALID_ARGUMENT, "name must be non-empty", {})
|
|
1976
|
+
if (
|
|
1977
|
+
not isinstance(scopes, list)
|
|
1978
|
+
or not scopes
|
|
1979
|
+
or any(not isinstance(item, str) for item in scopes)
|
|
1980
|
+
):
|
|
1981
|
+
raise AgentevoError(
|
|
1982
|
+
ErrorCode.INVALID_ARGUMENT, "scopes must be a non-empty string list", {}
|
|
1983
|
+
)
|
|
1984
|
+
if not isinstance(workspaces, list) or any(not isinstance(item, str) for item in workspaces):
|
|
1985
|
+
raise AgentevoError(ErrorCode.INVALID_ARGUMENT, "workspaces must be a string list", {})
|
|
1986
|
+
if isinstance(ttl_seconds, bool) or not isinstance(ttl_seconds, int) or ttl_seconds <= 0:
|
|
1987
|
+
raise AgentevoError(
|
|
1988
|
+
ErrorCode.INVALID_ARGUMENT, "ttl_seconds must be a positive integer", {}
|
|
1989
|
+
)
|
|
1990
|
+
return name, scopes, workspaces, timedelta(seconds=ttl_seconds)
|
|
1991
|
+
|
|
1992
|
+
|
|
1993
|
+
def _device_code_body(body: dict[str, Any]) -> tuple[list[str], str | None]:
|
|
1994
|
+
scopes = body.get("scopes")
|
|
1995
|
+
workspace = body.get("workspace")
|
|
1996
|
+
if (
|
|
1997
|
+
not isinstance(scopes, list)
|
|
1998
|
+
or not scopes
|
|
1999
|
+
or any(not isinstance(item, str) for item in scopes)
|
|
2000
|
+
):
|
|
2001
|
+
raise AgentevoError(
|
|
2002
|
+
ErrorCode.INVALID_ARGUMENT, "scopes must be a non-empty string list", {}
|
|
2003
|
+
)
|
|
2004
|
+
if workspace is not None and (not isinstance(workspace, str) or not workspace):
|
|
2005
|
+
raise AgentevoError(ErrorCode.INVALID_ARGUMENT, "workspace must be a non-empty string", {})
|
|
2006
|
+
return scopes, workspace
|
|
2007
|
+
|
|
2008
|
+
|
|
2009
|
+
def _decision_body(body: dict[str, Any]) -> tuple[str, str | None]:
|
|
2010
|
+
user_code = body.get("user_code")
|
|
2011
|
+
id_token = body.get("id_token")
|
|
2012
|
+
if not isinstance(user_code, str) or not user_code:
|
|
2013
|
+
raise AgentevoError(ErrorCode.INVALID_ARGUMENT, "user_code must be a non-empty string", {})
|
|
2014
|
+
if id_token is not None and (not isinstance(id_token, str) or not id_token):
|
|
2015
|
+
raise AgentevoError(ErrorCode.INVALID_ARGUMENT, "id_token must be a non-empty string", {})
|
|
2016
|
+
return user_code, id_token
|
|
2017
|
+
|
|
2018
|
+
|
|
2019
|
+
def _device_body(body: dict[str, Any]) -> tuple[str, str, str, str, str | None]:
|
|
2020
|
+
device_id = body.get("device_id")
|
|
2021
|
+
name = body.get("name")
|
|
2022
|
+
workspace = body.get("workspace")
|
|
2023
|
+
public_key = body.get("public_key")
|
|
2024
|
+
key_id = body.get("key_id")
|
|
2025
|
+
if not isinstance(device_id, str) or not device_id:
|
|
2026
|
+
raise AgentevoError(ErrorCode.INVALID_ARGUMENT, "device_id must be a non-empty string", {})
|
|
2027
|
+
if not isinstance(name, str) or not name:
|
|
2028
|
+
raise AgentevoError(ErrorCode.INVALID_ARGUMENT, "name must be a non-empty string", {})
|
|
2029
|
+
if not isinstance(workspace, str) or not workspace:
|
|
2030
|
+
raise AgentevoError(ErrorCode.INVALID_ARGUMENT, "workspace must be a non-empty string", {})
|
|
2031
|
+
if not isinstance(public_key, str) or not public_key:
|
|
2032
|
+
raise AgentevoError(ErrorCode.INVALID_ARGUMENT, "public_key must be a non-empty string", {})
|
|
2033
|
+
if key_id is not None and (not isinstance(key_id, str) or not key_id):
|
|
2034
|
+
raise AgentevoError(ErrorCode.INVALID_ARGUMENT, "key_id must be a non-empty string", {})
|
|
2035
|
+
return device_id, name, workspace, public_key, key_id
|
|
2036
|
+
|
|
2037
|
+
|
|
2038
|
+
def _webhook_body(body: dict[str, Any]) -> tuple[str, list[str]]:
|
|
2039
|
+
url = body.get("url")
|
|
2040
|
+
events = body.get("events")
|
|
2041
|
+
if not isinstance(url, str) or not url:
|
|
2042
|
+
raise AgentevoError(ErrorCode.INVALID_ARGUMENT, "url must be a non-empty string", {})
|
|
2043
|
+
if (
|
|
2044
|
+
not isinstance(events, list)
|
|
2045
|
+
or not events
|
|
2046
|
+
or any(not isinstance(item, str) for item in events)
|
|
2047
|
+
):
|
|
2048
|
+
raise AgentevoError(
|
|
2049
|
+
ErrorCode.INVALID_ARGUMENT, "events must be a non-empty string list", {}
|
|
2050
|
+
)
|
|
2051
|
+
return url, events
|
|
2052
|
+
|
|
2053
|
+
|
|
2054
|
+
def _device_json(record: DeviceRecord) -> dict[str, Any]:
|
|
2055
|
+
return {
|
|
2056
|
+
"key_id": record.key_id,
|
|
2057
|
+
"device_id": record.device_id,
|
|
2058
|
+
"name": record.name,
|
|
2059
|
+
"workspace": record.workspace_slug,
|
|
2060
|
+
"public_key": record.public_key,
|
|
2061
|
+
"key_format": record.key_format,
|
|
2062
|
+
"first_seen": record.first_seen,
|
|
2063
|
+
"revoked_at": record.revoked_at,
|
|
2064
|
+
"compromised": record.compromised,
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
|
|
2068
|
+
def _webhook_json(config: WebhookConfig) -> dict[str, Any]:
|
|
2069
|
+
return {
|
|
2070
|
+
"id": config.id,
|
|
2071
|
+
"url": config.url,
|
|
2072
|
+
"key_id": config.key_id,
|
|
2073
|
+
"events": list(config.events),
|
|
2074
|
+
"created_at": config.created_at,
|
|
2075
|
+
"revoked_at": config.revoked_at,
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
|
|
2079
|
+
def _delivery_json(delivery: Delivery) -> dict[str, Any]:
|
|
2080
|
+
return {
|
|
2081
|
+
"id": delivery.id,
|
|
2082
|
+
"config_id": delivery.config_id,
|
|
2083
|
+
"event_id": delivery.event_id,
|
|
2084
|
+
"event_type": delivery.event_type,
|
|
2085
|
+
"event": delivery.event,
|
|
2086
|
+
"status": delivery.status,
|
|
2087
|
+
"attempts": delivery.attempts,
|
|
2088
|
+
"http_status": delivery.http_status,
|
|
2089
|
+
"last_error": delivery.last_error,
|
|
2090
|
+
"created_at": delivery.created_at,
|
|
2091
|
+
}
|