sediment-api 0.1.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {sediment_api-0.1.0 → sediment_api-0.3.0}/PKG-INFO +5 -5
- {sediment_api-0.1.0 → sediment_api-0.3.0}/pyproject.toml +5 -5
- {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/__init__.py +1 -1
- {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/config.py +84 -3
- {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/deps.py +84 -5
- {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/main.py +26 -0
- {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/routers/query.py +441 -71
- {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/routers/v1.py +8 -2
- {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/services/operational_reports.py +8 -2
- {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/worker.py +139 -8
- {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/workers.py +50 -3
- sediment_api-0.3.0/tests/test_context_discovery_authority.py +184 -0
- sediment_api-0.3.0/tests/test_context_discovery_query.py +454 -0
- sediment_api-0.3.0/tests/test_context_discovery_workers.py +93 -0
- sediment_api-0.3.0/tests/test_context_evidence_query.py +582 -0
- sediment_api-0.3.0/tests/test_context_query.py +310 -0
- {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_credential_authorities.py +3 -0
- sediment_api-0.3.0/tests/test_evidence_query.py +537 -0
- sediment_api-0.3.0/tests/test_evidence_worker_imports.py +81 -0
- sediment_api-0.3.0/tests/test_evidence_workers.py +330 -0
- {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_query.py +468 -0
- {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_report_attachment_scope.py +188 -7
- {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_report_routes.py +2 -1
- sediment_api-0.3.0/tests/test_retrieval_authority.py +138 -0
- {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_v1.py +1 -1
- {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_worker_routes.py +61 -38
- {sediment_api-0.1.0 → sediment_api-0.3.0}/.gitignore +0 -0
- {sediment_api-0.1.0 → sediment_api-0.3.0}/LICENSE +0 -0
- {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/database.py +0 -0
- {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/mirror_gc.py +0 -0
- {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/reports/__init__.py +0 -0
- {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/reports/abandonment_report.py +0 -0
- {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/reports/attribution_share_report.py +0 -0
- {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/reports/dataset_diagnostics.py +0 -0
- {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/reports/label_confidence_inspection.py +0 -0
- {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/reports/lifecycle_report.py +0 -0
- {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/reports/merge_retention_report.py +0 -0
- {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/reports/model_report.py +0 -0
- {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/reports/precision_report.py +0 -0
- {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/reports/recovery_yield_report.py +0 -0
- {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/routers/__init__.py +0 -0
- {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/routers/ci_vendor.py +0 -0
- {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/routers/forge.py +0 -0
- {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/routers/gateway.py +0 -0
- {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/routers/otlp.py +0 -0
- {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/routers/reports.py +0 -0
- {sediment_api-0.1.0 → sediment_api-0.3.0}/sediment_api/services/__init__.py +0 -0
- {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/conftest.py +0 -0
- {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_attribution_share_report.py +0 -0
- {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_baseline_gap_repro.py +0 -0
- {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_bug_repro_offset.py +0 -0
- {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_capture_record_isolation.py +0 -0
- {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_ci_vendor.py +0 -0
- {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_config.py +0 -0
- {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_database_lifecycle.py +0 -0
- {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_database_outage.py +0 -0
- {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_dataset_diagnostics_report.py +0 -0
- {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_e2e_smoke.py +0 -0
- {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_gateway_capture_receipt.py +0 -0
- {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_gateway_identity.py +0 -0
- {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_lifecycle_report_cli.py +0 -0
- {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_merge_retention_cli.py +0 -0
- {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_one_shot_database.py +0 -0
- {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_operational_reports.py +0 -0
- {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_operational_reports_service.py +0 -0
- {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_postgres_gateway_tracer.py +0 -0
- {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_push_mirror.py +0 -0
- {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_repository_identity_ingest.py +0 -0
- {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_repository_identity_model_service.py +0 -0
- {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_repository_identity_queries.py +0 -0
- {sediment_api-0.1.0 → sediment_api-0.3.0}/tests/test_worker_processes.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: sediment-api
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Sediment ingest API: facts in through three doors
|
|
5
5
|
Project-URL: Source, https://github.com/sediment-ai/sediment
|
|
6
6
|
Project-URL: Documentation, https://github.com/sediment-ai/sediment/tree/main/docs
|
|
@@ -13,8 +13,8 @@ Requires-Dist: anyio>=4.15.1
|
|
|
13
13
|
Requires-Dist: fastapi>=0.141.1
|
|
14
14
|
Requires-Dist: httpx>=0.27
|
|
15
15
|
Requires-Dist: pydantic-settings>=2.15.0
|
|
16
|
-
Requires-Dist: sediment-capture==0.
|
|
17
|
-
Requires-Dist: sediment-core==0.
|
|
18
|
-
Requires-Dist: sediment-derive==0.
|
|
19
|
-
Requires-Dist: sediment-export==0.
|
|
16
|
+
Requires-Dist: sediment-capture==0.3.0
|
|
17
|
+
Requires-Dist: sediment-core==0.3.0
|
|
18
|
+
Requires-Dist: sediment-derive==0.3.0
|
|
19
|
+
Requires-Dist: sediment-export==0.3.0
|
|
20
20
|
Requires-Dist: uvicorn>=0.53.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "sediment-api"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "Sediment ingest API: facts in through three doors"
|
|
5
5
|
requires-python = ">=3.12"
|
|
6
6
|
license = "AGPL-3.0-or-later"
|
|
@@ -12,10 +12,10 @@ dependencies = [
|
|
|
12
12
|
"httpx>=0.27",
|
|
13
13
|
"uvicorn>=0.53.0",
|
|
14
14
|
"pydantic-settings>=2.15.0",
|
|
15
|
-
"sediment-core==0.
|
|
16
|
-
"sediment-capture==0.
|
|
17
|
-
"sediment-derive==0.
|
|
18
|
-
"sediment-export==0.
|
|
15
|
+
"sediment-core==0.3.0",
|
|
16
|
+
"sediment-capture==0.3.0",
|
|
17
|
+
"sediment-derive==0.3.0",
|
|
18
|
+
"sediment-export==0.3.0",
|
|
19
19
|
]
|
|
20
20
|
|
|
21
21
|
[project.urls]
|
|
@@ -5,9 +5,10 @@ import json
|
|
|
5
5
|
import re
|
|
6
6
|
from typing import Annotated
|
|
7
7
|
|
|
8
|
-
from pydantic import Field, SecretStr, field_validator
|
|
8
|
+
from pydantic import Field, SecretStr, TypeAdapter, field_validator, model_validator
|
|
9
9
|
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
|
|
10
|
-
from sediment_core import ForgeHost, normalize_org_id
|
|
10
|
+
from sediment_core import ForgeHost, NonEmptyId, normalize_org_id
|
|
11
|
+
from sediment_core.evidence import CONTEXT_DISCOVERY_SESSION_LIMIT
|
|
11
12
|
|
|
12
13
|
# Token values that mean "operator never configured a real secret".
|
|
13
14
|
_INSECURE_DEFAULTS = {
|
|
@@ -27,6 +28,7 @@ _CLIENT_ID = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$")
|
|
|
27
28
|
# unthrottled online guesser (see docs/adr/0018) meaningfully more than a
|
|
28
29
|
# short word does.
|
|
29
30
|
_MIN_SECRET_LENGTH = 24
|
|
31
|
+
_RETRIEVAL_GRANT_BYTES_LIMIT = 16 * 1024
|
|
30
32
|
|
|
31
33
|
|
|
32
34
|
class Settings(BaseSettings):
|
|
@@ -63,6 +65,9 @@ class Settings(BaseSettings):
|
|
|
63
65
|
# Auth
|
|
64
66
|
api_bearer_token: str = Field(default="", repr=False)
|
|
65
67
|
operator_token: SecretStr = SecretStr("")
|
|
68
|
+
retrieval_token: SecretStr | None = None
|
|
69
|
+
retrieval_session_id: NonEmptyId | None = None
|
|
70
|
+
retrieval_session_ids: Annotated[list[NonEmptyId] | None, NoDecode] = None
|
|
66
71
|
ingest_tokens: Annotated[dict[str, SecretStr], NoDecode] = Field(
|
|
67
72
|
default_factory=dict
|
|
68
73
|
)
|
|
@@ -113,6 +118,48 @@ class Settings(BaseSettings):
|
|
|
113
118
|
def _strip_operator_token(cls, value: SecretStr) -> SecretStr:
|
|
114
119
|
return SecretStr(value.get_secret_value().strip())
|
|
115
120
|
|
|
121
|
+
@field_validator("retrieval_token")
|
|
122
|
+
@classmethod
|
|
123
|
+
def _strip_retrieval_token(cls, value: SecretStr | None) -> SecretStr | None:
|
|
124
|
+
return None if value is None else SecretStr(value.get_secret_value().strip())
|
|
125
|
+
|
|
126
|
+
@field_validator("retrieval_session_ids", mode="before")
|
|
127
|
+
@classmethod
|
|
128
|
+
def _parse_retrieval_sessions(cls, value):
|
|
129
|
+
if value is None:
|
|
130
|
+
return None
|
|
131
|
+
try:
|
|
132
|
+
encoded = (
|
|
133
|
+
value
|
|
134
|
+
if isinstance(value, str)
|
|
135
|
+
else json.dumps(value, ensure_ascii=False)
|
|
136
|
+
)
|
|
137
|
+
if len(encoded.encode("utf-8")) > _RETRIEVAL_GRANT_BYTES_LIMIT:
|
|
138
|
+
raise ValueError
|
|
139
|
+
if isinstance(value, str):
|
|
140
|
+
value = json.loads(value)
|
|
141
|
+
if (
|
|
142
|
+
not isinstance(value, list)
|
|
143
|
+
or not 1 <= len(value) <= CONTEXT_DISCOVERY_SESSION_LIMIT
|
|
144
|
+
or any(not isinstance(item, str) for item in value)
|
|
145
|
+
):
|
|
146
|
+
raise ValueError
|
|
147
|
+
normalized = TypeAdapter(list[NonEmptyId]).validate_python(value)
|
|
148
|
+
if len(set(normalized)) != len(normalized):
|
|
149
|
+
raise ValueError
|
|
150
|
+
except (ValueError, TypeError, RecursionError):
|
|
151
|
+
raise ValueError(
|
|
152
|
+
"retrieval_session_ids must be a JSON array of 1–32 unique valid IDs within 16 KiB"
|
|
153
|
+
) from None
|
|
154
|
+
return normalized
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def context_session_ids(self) -> tuple[NonEmptyId, ...]:
|
|
158
|
+
"""The deployment grant, with no first-member singleton default."""
|
|
159
|
+
if self.retrieval_session_id is not None:
|
|
160
|
+
return (self.retrieval_session_id,)
|
|
161
|
+
return tuple(sorted(self.retrieval_session_ids or ()))
|
|
162
|
+
|
|
116
163
|
@field_validator("ingest_tokens", mode="before")
|
|
117
164
|
@classmethod
|
|
118
165
|
def _parse_ingest_tokens(cls, value):
|
|
@@ -140,7 +187,7 @@ class Settings(BaseSettings):
|
|
|
140
187
|
if (
|
|
141
188
|
not isinstance(client_id, str)
|
|
142
189
|
or not _CLIENT_ID.fullmatch(client_id)
|
|
143
|
-
or client_id in {"operator", "legacy"}
|
|
190
|
+
or client_id in {"operator", "legacy", "retrieval"}
|
|
144
191
|
):
|
|
145
192
|
raise ValueError(
|
|
146
193
|
"ingest_tokens contains an invalid or reserved client identifier"
|
|
@@ -152,6 +199,40 @@ class Settings(BaseSettings):
|
|
|
152
199
|
normalized[client_id] = SecretStr(secret.strip())
|
|
153
200
|
return normalized
|
|
154
201
|
|
|
202
|
+
@model_validator(mode="after")
|
|
203
|
+
def _validate_retrieval(self) -> "Settings":
|
|
204
|
+
# Agent read authority never inherits development-mode exemptions.
|
|
205
|
+
source_count = sum(
|
|
206
|
+
source is not None
|
|
207
|
+
for source in (self.retrieval_session_id, self.retrieval_session_ids)
|
|
208
|
+
)
|
|
209
|
+
if source_count != int(self.retrieval_token is not None):
|
|
210
|
+
raise ValueError(
|
|
211
|
+
"retrieval_token requires exactly one of retrieval_session_id or retrieval_session_ids"
|
|
212
|
+
)
|
|
213
|
+
if self.retrieval_token is None:
|
|
214
|
+
return self
|
|
215
|
+
token = self.retrieval_token.get_secret_value()
|
|
216
|
+
if (
|
|
217
|
+
token.lower() in _INSECURE_DEFAULTS
|
|
218
|
+
or len(token) < _MIN_SECRET_LENGTH
|
|
219
|
+
or any(not 33 <= ord(char) <= 126 for char in token)
|
|
220
|
+
):
|
|
221
|
+
raise ValueError(
|
|
222
|
+
"retrieval_token must be a strong printable ASCII secret of at least 24 characters"
|
|
223
|
+
)
|
|
224
|
+
other_secrets = {
|
|
225
|
+
self.operator_token.get_secret_value(),
|
|
226
|
+
self.api_bearer_token,
|
|
227
|
+
self.github_webhook_secret,
|
|
228
|
+
*(secret.get_secret_value() for secret in self.ingest_tokens.values()),
|
|
229
|
+
}
|
|
230
|
+
if token in other_secrets:
|
|
231
|
+
raise ValueError(
|
|
232
|
+
"retrieval_token must differ from all other configured secrets"
|
|
233
|
+
)
|
|
234
|
+
return self
|
|
235
|
+
|
|
155
236
|
def validate_production_security(self) -> list[str]:
|
|
156
237
|
"""Return human-readable security problems for a production boot.
|
|
157
238
|
|
|
@@ -19,7 +19,7 @@ from typing import Any
|
|
|
19
19
|
|
|
20
20
|
from fastapi import Depends, Header, HTTPException, Request
|
|
21
21
|
from sediment_capture import verify_signature
|
|
22
|
-
from sediment_core import FactStore
|
|
22
|
+
from sediment_core import EVIDENCE_REQUEST_BYTES_LIMIT, FactStore, NonEmptyId
|
|
23
23
|
|
|
24
24
|
from .config import settings
|
|
25
25
|
|
|
@@ -31,6 +31,7 @@ from .config import settings
|
|
|
31
31
|
# body before its auth dependency runs, so every door's read is pre-auth.
|
|
32
32
|
# ponytail: fixed constant; make it a setting only if a non-GitHub forge needs it
|
|
33
33
|
MAX_BODY_BYTES = 25 * 1024 * 1024
|
|
34
|
+
CONTEXT_REQUEST_BYTES_LIMIT = 16 * 1024
|
|
34
35
|
|
|
35
36
|
|
|
36
37
|
class BodySizeLimitMiddleware:
|
|
@@ -57,6 +58,24 @@ class BodySizeLimitMiddleware:
|
|
|
57
58
|
await self.app(scope, receive, send)
|
|
58
59
|
return
|
|
59
60
|
|
|
61
|
+
limit = MAX_BODY_BYTES
|
|
62
|
+
path = scope["path"]
|
|
63
|
+
root_path = scope.get("root_path", "")
|
|
64
|
+
if root_path and path.startswith(root_path + "/"):
|
|
65
|
+
path = path[len(root_path) :]
|
|
66
|
+
if scope["method"] == "POST" and path in {
|
|
67
|
+
"/query/evidence/read",
|
|
68
|
+
"/query/context/evidence/read",
|
|
69
|
+
}:
|
|
70
|
+
# FastAPI parses model envelopes before running dependencies. Keep
|
|
71
|
+
# this operation's smaller bound ahead of that allocation too.
|
|
72
|
+
limit = min(limit, EVIDENCE_REQUEST_BYTES_LIMIT)
|
|
73
|
+
elif scope["method"] == "POST" and path in {
|
|
74
|
+
"/query/context",
|
|
75
|
+
"/query/context/discover",
|
|
76
|
+
"/query/context/selected",
|
|
77
|
+
}:
|
|
78
|
+
limit = min(limit, CONTEXT_REQUEST_BYTES_LIMIT)
|
|
60
79
|
received = 0
|
|
61
80
|
|
|
62
81
|
async def capped_receive() -> Any:
|
|
@@ -66,25 +85,47 @@ class BodySizeLimitMiddleware:
|
|
|
66
85
|
received += len(message.get("body", b""))
|
|
67
86
|
# Module-global lookup on purpose: tests lower the cap by
|
|
68
87
|
# patching MAX_BODY_BYTES after the app is constructed.
|
|
69
|
-
if received >
|
|
88
|
+
if received > limit:
|
|
70
89
|
raise HTTPException(
|
|
71
90
|
status_code=413, detail="request body too large"
|
|
72
91
|
)
|
|
73
92
|
return message
|
|
74
93
|
|
|
75
|
-
|
|
94
|
+
async def context_send(message: Any) -> None:
|
|
95
|
+
if message["type"] == "http.response.start":
|
|
96
|
+
message["headers"] = [
|
|
97
|
+
(key, value)
|
|
98
|
+
for key, value in message.get("headers", [])
|
|
99
|
+
if key.lower() != b"cache-control"
|
|
100
|
+
] + [(b"cache-control", b"no-store")]
|
|
101
|
+
await send(message)
|
|
102
|
+
|
|
103
|
+
await self.app(
|
|
104
|
+
scope,
|
|
105
|
+
capped_receive,
|
|
106
|
+
context_send
|
|
107
|
+
if path
|
|
108
|
+
in {
|
|
109
|
+
"/query/context/discover",
|
|
110
|
+
"/query/context/selected",
|
|
111
|
+
"/query/context/evidence",
|
|
112
|
+
"/query/context/evidence/manifest",
|
|
113
|
+
"/query/context/evidence/read",
|
|
114
|
+
}
|
|
115
|
+
else send,
|
|
116
|
+
)
|
|
76
117
|
|
|
77
118
|
|
|
78
119
|
@dataclass(frozen=True)
|
|
79
120
|
class CredentialIdentity:
|
|
80
121
|
"""Configured authority only; never a tenant or captured developer identity."""
|
|
81
122
|
|
|
82
|
-
authority: Literal["ingest", "operator"]
|
|
123
|
+
authority: Literal["ingest", "operator", "retrieval"]
|
|
83
124
|
client_id: str
|
|
84
125
|
|
|
85
126
|
|
|
86
127
|
def verify_token(authorization: str | None = Header(None)) -> CredentialIdentity:
|
|
87
|
-
"""Authenticate
|
|
128
|
+
"""Authenticate configured authorities without logging credential material."""
|
|
88
129
|
scheme, _, credential = (authorization or "").partition(" ")
|
|
89
130
|
credential = credential.strip()
|
|
90
131
|
if scheme.lower() != "bearer" or not credential:
|
|
@@ -96,6 +137,12 @@ def verify_token(authorization: str | None = Header(None)) -> CredentialIdentity
|
|
|
96
137
|
settings.operator_token.get_secret_value(),
|
|
97
138
|
CredentialIdentity("operator", "operator"),
|
|
98
139
|
),
|
|
140
|
+
(
|
|
141
|
+
settings.retrieval_token.get_secret_value()
|
|
142
|
+
if settings.retrieval_token is not None
|
|
143
|
+
else "",
|
|
144
|
+
CredentialIdentity("retrieval", "retrieval"),
|
|
145
|
+
),
|
|
99
146
|
(settings.api_bearer_token, CredentialIdentity("ingest", "legacy")),
|
|
100
147
|
*(
|
|
101
148
|
(token.get_secret_value(), CredentialIdentity("ingest", client_id))
|
|
@@ -114,6 +161,8 @@ def verify_ingest_token(
|
|
|
114
161
|
identity: CredentialIdentity = Depends(verify_token),
|
|
115
162
|
) -> CredentialIdentity:
|
|
116
163
|
"""Capture accepts ingest clients and explicit operator demonstrations."""
|
|
164
|
+
if identity.authority not in {"ingest", "operator"}:
|
|
165
|
+
raise HTTPException(status_code=403, detail="Ingest authority required")
|
|
117
166
|
return identity
|
|
118
167
|
|
|
119
168
|
|
|
@@ -126,6 +175,36 @@ def verify_operator_token(
|
|
|
126
175
|
return identity
|
|
127
176
|
|
|
128
177
|
|
|
178
|
+
def verify_retrieval_token(
|
|
179
|
+
identity: CredentialIdentity = Depends(verify_token),
|
|
180
|
+
) -> CredentialIdentity:
|
|
181
|
+
"""Read the deployment's fixed source without broadening operator reads."""
|
|
182
|
+
if identity.authority not in {"retrieval", "operator"}:
|
|
183
|
+
raise HTTPException(status_code=403, detail="Retrieval authority required")
|
|
184
|
+
if settings.retrieval_session_id is None:
|
|
185
|
+
raise HTTPException(status_code=404, detail="Context retrieval is disabled")
|
|
186
|
+
return identity
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def verify_context_grant_token(
|
|
190
|
+
identity: CredentialIdentity = Depends(verify_token),
|
|
191
|
+
) -> CredentialIdentity:
|
|
192
|
+
"""Discovery and selection share the configured Session grant."""
|
|
193
|
+
if identity.authority not in {"retrieval", "operator"}:
|
|
194
|
+
raise HTTPException(status_code=403, detail="Retrieval authority required")
|
|
195
|
+
if not settings.context_session_ids:
|
|
196
|
+
raise HTTPException(status_code=404, detail="Context retrieval is disabled")
|
|
197
|
+
return identity
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def require_context_session(session_id: NonEmptyId) -> None:
|
|
201
|
+
"""Refuse before any storage lookup, independent of Session existence."""
|
|
202
|
+
if session_id not in settings.context_session_ids:
|
|
203
|
+
raise HTTPException(
|
|
204
|
+
status_code=403, detail="Session is outside the context grant"
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
129
208
|
def get_store(request: Request) -> FactStore:
|
|
130
209
|
"""Borrow the lifespan-owned PostgreSQL fact store for one request."""
|
|
131
210
|
return request.app.state.fact_store
|
|
@@ -185,6 +185,32 @@ async def validation_error_without_input(
|
|
|
185
185
|
400/422, never 500. type/loc/msg are what a caller needs to fix the
|
|
186
186
|
request; the offending input is theirs already.
|
|
187
187
|
"""
|
|
188
|
+
endpoint = getattr(request.scope.get("route"), "endpoint", None)
|
|
189
|
+
if endpoint in {
|
|
190
|
+
query.query_context_discover,
|
|
191
|
+
query.query_context_selected,
|
|
192
|
+
query.query_context_evidence_read,
|
|
193
|
+
} and any(error.get("type") == "json_invalid" for error in exc.errors()):
|
|
194
|
+
return JSONResponse(status_code=400, content={"detail": "Malformed JSON body"})
|
|
195
|
+
if endpoint in {
|
|
196
|
+
query.query_context_evidence_inventory,
|
|
197
|
+
query.query_context_evidence_manifest,
|
|
198
|
+
query.query_context_evidence_read,
|
|
199
|
+
}:
|
|
200
|
+
# Exact references reuse the operator envelope. Its error locations can
|
|
201
|
+
# contain arbitrary extra keys; keep the scoped boundary content-free.
|
|
202
|
+
return JSONResponse(
|
|
203
|
+
status_code=422,
|
|
204
|
+
content={
|
|
205
|
+
"detail": [
|
|
206
|
+
{
|
|
207
|
+
"type": "value_error",
|
|
208
|
+
"loc": [],
|
|
209
|
+
"msg": "Invalid evidence request",
|
|
210
|
+
}
|
|
211
|
+
]
|
|
212
|
+
},
|
|
213
|
+
)
|
|
188
214
|
detail = [
|
|
189
215
|
{"type": e.get("type", ""), "loc": e.get("loc", ()), "msg": e.get("msg", "")}
|
|
190
216
|
for e in exc.errors()
|