sediment-api 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.
- sediment_api/__init__.py +6 -0
- sediment_api/config.py +230 -0
- sediment_api/database.py +43 -0
- sediment_api/deps.py +191 -0
- sediment_api/main.py +212 -0
- sediment_api/mirror_gc.py +151 -0
- sediment_api/reports/__init__.py +9 -0
- sediment_api/reports/abandonment_report.py +123 -0
- sediment_api/reports/attribution_share_report.py +298 -0
- sediment_api/reports/dataset_diagnostics.py +311 -0
- sediment_api/reports/label_confidence_inspection.py +438 -0
- sediment_api/reports/lifecycle_report.py +73 -0
- sediment_api/reports/merge_retention_report.py +165 -0
- sediment_api/reports/model_report.py +1534 -0
- sediment_api/reports/precision_report.py +182 -0
- sediment_api/reports/recovery_yield_report.py +132 -0
- sediment_api/routers/__init__.py +3 -0
- sediment_api/routers/ci_vendor.py +149 -0
- sediment_api/routers/forge.py +452 -0
- sediment_api/routers/gateway.py +137 -0
- sediment_api/routers/otlp.py +120 -0
- sediment_api/routers/query.py +1010 -0
- sediment_api/routers/reports.py +116 -0
- sediment_api/routers/v1.py +170 -0
- sediment_api/services/__init__.py +2 -0
- sediment_api/services/operational_reports.py +391 -0
- sediment_api/worker.py +242 -0
- sediment_api/workers.py +382 -0
- sediment_api-0.1.0.dist-info/METADATA +20 -0
- sediment_api-0.1.0.dist-info/RECORD +32 -0
- sediment_api-0.1.0.dist-info/WHEEL +4 -0
- sediment_api-0.1.0.dist-info/licenses/LICENSE +661 -0
sediment_api/__init__.py
ADDED
sediment_api/config.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
"""Runtime configuration via environment variables (``SEDIMENT_`` prefix, .env)."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
from pydantic import Field, SecretStr, field_validator
|
|
9
|
+
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
|
|
10
|
+
from sediment_core import ForgeHost, normalize_org_id
|
|
11
|
+
|
|
12
|
+
# Token values that mean "operator never configured a real secret".
|
|
13
|
+
_INSECURE_DEFAULTS = {
|
|
14
|
+
"",
|
|
15
|
+
"changeme",
|
|
16
|
+
"change-me",
|
|
17
|
+
"replace-me",
|
|
18
|
+
"your-token",
|
|
19
|
+
"your-secret",
|
|
20
|
+
"password",
|
|
21
|
+
"secret",
|
|
22
|
+
}
|
|
23
|
+
_CLIENT_ID = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$")
|
|
24
|
+
# ADR 0018: production rejects a hand-edited operator, ingest, or webhook
|
|
25
|
+
# secret shorter than this floor. scripts/create_deploy_env.py generates
|
|
26
|
+
# 64-character secrets; 24 stays well under that while still costing an
|
|
27
|
+
# unthrottled online guesser (see docs/adr/0018) meaningfully more than a
|
|
28
|
+
# short word does.
|
|
29
|
+
_MIN_SECRET_LENGTH = 24
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Settings(BaseSettings):
|
|
33
|
+
# extra="ignore": the .env is shared with the LiteLLM container (e.g.
|
|
34
|
+
# ANTHROPIC_API_KEY), so tolerate keys this model doesn't own instead of
|
|
35
|
+
# crashing at startup.
|
|
36
|
+
model_config = SettingsConfigDict(
|
|
37
|
+
env_file=".env",
|
|
38
|
+
env_prefix="SEDIMENT_",
|
|
39
|
+
extra="ignore",
|
|
40
|
+
hide_input_in_errors=True,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# Tenancy: the org every fact is stamped with. Required — no
|
|
44
|
+
# default — and normalized at construction, so a missing or invalid
|
|
45
|
+
# SEDIMENT_ORG_ID fails the boot and two case-variant configs converge
|
|
46
|
+
# on the same tenant.
|
|
47
|
+
org_id: str
|
|
48
|
+
|
|
49
|
+
# PostgreSQL is the sole active fact store. SecretStr prevents settings
|
|
50
|
+
# representations and validation diagnostics from echoing credentials.
|
|
51
|
+
database_url: SecretStr
|
|
52
|
+
|
|
53
|
+
# Raw git substrate: base dir for the per-(org, repo) bare mirrors.
|
|
54
|
+
# None (unset) disables mirror refresh entirely — facts still flow;
|
|
55
|
+
# the mirror is best-effort substrate (ADR 0001).
|
|
56
|
+
mirror_path: str | None = None
|
|
57
|
+
# Clone-host allowlist for the mirror's clone_url confinement, JSON in
|
|
58
|
+
# the env (SEDIMENT_ALLOWED_CLONE_HOSTS='["github.com"]'). Empty = any
|
|
59
|
+
# public host. An explicit entry trusts that host, including private
|
|
60
|
+
# addresses; production still refuses file: URLs.
|
|
61
|
+
allowed_clone_hosts: list[str] = []
|
|
62
|
+
|
|
63
|
+
# Auth
|
|
64
|
+
api_bearer_token: str = Field(default="", repr=False)
|
|
65
|
+
operator_token: SecretStr = SecretStr("")
|
|
66
|
+
ingest_tokens: Annotated[dict[str, SecretStr], NoDecode] = Field(
|
|
67
|
+
default_factory=dict
|
|
68
|
+
)
|
|
69
|
+
github_webhook_secret: str = Field(default="changeme", repr=False)
|
|
70
|
+
|
|
71
|
+
# Trusted provider namespace, never derived from webhook headers or URLs.
|
|
72
|
+
github_host: ForgeHost = "github.com"
|
|
73
|
+
|
|
74
|
+
# Fail-closed escape hatch. False (the production default) means
|
|
75
|
+
# validate_production_security() enforces real secrets; set
|
|
76
|
+
# SEDIMENT_DEV_MODE=true ONLY for local dev, where the "changeme"
|
|
77
|
+
# defaults are intentionally tolerated.
|
|
78
|
+
dev_mode: bool = False
|
|
79
|
+
|
|
80
|
+
enable_docs: bool = True
|
|
81
|
+
|
|
82
|
+
@field_validator("org_id")
|
|
83
|
+
@classmethod
|
|
84
|
+
def _canonical_org(cls, v: str) -> str:
|
|
85
|
+
return normalize_org_id(v)
|
|
86
|
+
|
|
87
|
+
@field_validator("database_url")
|
|
88
|
+
@classmethod
|
|
89
|
+
def _strip_database_url(cls, v: SecretStr) -> SecretStr:
|
|
90
|
+
# A mounted/piped secret often arrives with a trailing newline (see
|
|
91
|
+
# _strip_secret); rstrip only \r/\n so a percent-encoded space in the
|
|
92
|
+
# password survives. Reject empty-after-trim so the boot fails fast
|
|
93
|
+
# at construction with a named-setting message instead of after a
|
|
94
|
+
# ~30 s retry in lifespan against a newline-corrupted target.
|
|
95
|
+
cleaned = v.get_secret_value().rstrip("\r\n")
|
|
96
|
+
if not cleaned:
|
|
97
|
+
raise ValueError(
|
|
98
|
+
"database_url is unset or empty; set SEDIMENT_DATABASE_URL "
|
|
99
|
+
"to a migrated PostgreSQL database."
|
|
100
|
+
)
|
|
101
|
+
return SecretStr(cleaned)
|
|
102
|
+
|
|
103
|
+
@field_validator("api_bearer_token", "github_webhook_secret")
|
|
104
|
+
@classmethod
|
|
105
|
+
def _strip_secret(cls, v: str) -> str:
|
|
106
|
+
# A mounted/piped secret often arrives with a trailing newline;
|
|
107
|
+
# stored verbatim it can never match what a client sends, and a
|
|
108
|
+
# whitespace-only value would sail past the insecure-default check.
|
|
109
|
+
return v.strip()
|
|
110
|
+
|
|
111
|
+
@field_validator("operator_token")
|
|
112
|
+
@classmethod
|
|
113
|
+
def _strip_operator_token(cls, value: SecretStr) -> SecretStr:
|
|
114
|
+
return SecretStr(value.get_secret_value().strip())
|
|
115
|
+
|
|
116
|
+
@field_validator("ingest_tokens", mode="before")
|
|
117
|
+
@classmethod
|
|
118
|
+
def _parse_ingest_tokens(cls, value):
|
|
119
|
+
def unique_pairs(pairs):
|
|
120
|
+
result = {}
|
|
121
|
+
for key, secret in pairs:
|
|
122
|
+
if key in result:
|
|
123
|
+
raise ValueError(
|
|
124
|
+
"ingest_tokens contains duplicate client identifiers"
|
|
125
|
+
)
|
|
126
|
+
result[key] = secret
|
|
127
|
+
return result
|
|
128
|
+
|
|
129
|
+
if isinstance(value, str):
|
|
130
|
+
try:
|
|
131
|
+
value = json.loads(value, object_pairs_hook=unique_pairs)
|
|
132
|
+
except (ValueError, RecursionError):
|
|
133
|
+
raise ValueError(
|
|
134
|
+
"ingest_tokens must be a JSON object with unique client identifiers"
|
|
135
|
+
) from None
|
|
136
|
+
if not isinstance(value, dict):
|
|
137
|
+
raise ValueError("ingest_tokens must map client identifiers to secrets")
|
|
138
|
+
normalized = {}
|
|
139
|
+
for client_id, secret in value.items():
|
|
140
|
+
if (
|
|
141
|
+
not isinstance(client_id, str)
|
|
142
|
+
or not _CLIENT_ID.fullmatch(client_id)
|
|
143
|
+
or client_id in {"operator", "legacy"}
|
|
144
|
+
):
|
|
145
|
+
raise ValueError(
|
|
146
|
+
"ingest_tokens contains an invalid or reserved client identifier"
|
|
147
|
+
)
|
|
148
|
+
if isinstance(secret, SecretStr):
|
|
149
|
+
secret = secret.get_secret_value()
|
|
150
|
+
if not isinstance(secret, str):
|
|
151
|
+
raise ValueError("ingest_tokens must map client identifiers to secrets")
|
|
152
|
+
normalized[client_id] = SecretStr(secret.strip())
|
|
153
|
+
return normalized
|
|
154
|
+
|
|
155
|
+
def validate_production_security(self) -> list[str]:
|
|
156
|
+
"""Return human-readable security problems for a production boot.
|
|
157
|
+
|
|
158
|
+
Pure: no I/O, no logging, never echoes a configured secret value. The
|
|
159
|
+
caller (main.py) is responsible for raising. Returns ``[]`` when
|
|
160
|
+
``dev_mode`` is True (local dev opts out of the check).
|
|
161
|
+
"""
|
|
162
|
+
if self.dev_mode:
|
|
163
|
+
return []
|
|
164
|
+
|
|
165
|
+
problems: list[str] = []
|
|
166
|
+
operator = self.operator_token.get_secret_value()
|
|
167
|
+
ingest = [secret.get_secret_value() for secret in self.ingest_tokens.values()]
|
|
168
|
+
if self.api_bearer_token:
|
|
169
|
+
ingest.append(self.api_bearer_token)
|
|
170
|
+
if (
|
|
171
|
+
operator.lower() in _INSECURE_DEFAULTS
|
|
172
|
+
or len(operator) < _MIN_SECRET_LENGTH
|
|
173
|
+
or any(not 33 <= ord(c) <= 126 for c in operator)
|
|
174
|
+
):
|
|
175
|
+
problems.append(
|
|
176
|
+
"operator_token is unset, invalid, or shorter than "
|
|
177
|
+
f"{_MIN_SECRET_LENGTH} characters; set SEDIMENT_OPERATOR_TOKEN to a "
|
|
178
|
+
"strong secret."
|
|
179
|
+
)
|
|
180
|
+
if not ingest:
|
|
181
|
+
problems.append(
|
|
182
|
+
"ingest credentials are unset; set SEDIMENT_INGEST_TOKENS or the ingest-only api_bearer_token (SEDIMENT_API_BEARER_TOKEN)."
|
|
183
|
+
)
|
|
184
|
+
if any(
|
|
185
|
+
secret.lower() in _INSECURE_DEFAULTS
|
|
186
|
+
or len(secret) < _MIN_SECRET_LENGTH
|
|
187
|
+
or any(not 33 <= ord(c) <= 126 for c in secret)
|
|
188
|
+
for secret in ingest
|
|
189
|
+
):
|
|
190
|
+
problems.append(
|
|
191
|
+
"ingest_tokens or api_bearer_token contains an empty, invalid, or "
|
|
192
|
+
f"shorter-than-{_MIN_SECRET_LENGTH}-character secret; configure real "
|
|
193
|
+
"ingest credentials."
|
|
194
|
+
)
|
|
195
|
+
if len(ingest) != len(set(ingest)) or operator in ingest:
|
|
196
|
+
problems.append(
|
|
197
|
+
"operator and ingest credentials must be distinct; duplicate secrets are not allowed."
|
|
198
|
+
)
|
|
199
|
+
if (
|
|
200
|
+
self.github_webhook_secret.lower() in _INSECURE_DEFAULTS
|
|
201
|
+
or len(self.github_webhook_secret) < _MIN_SECRET_LENGTH
|
|
202
|
+
):
|
|
203
|
+
problems.append(
|
|
204
|
+
"github_webhook_secret is unset, invalid, or shorter than "
|
|
205
|
+
f"{_MIN_SECRET_LENGTH} characters; set SEDIMENT_GITHUB_WEBHOOK_SECRET "
|
|
206
|
+
"to a strong secret."
|
|
207
|
+
)
|
|
208
|
+
return problems
|
|
209
|
+
|
|
210
|
+
def config_warnings(self) -> list[str]:
|
|
211
|
+
"""Advisory (non-fatal) posture warnings for a production boot. Pure:
|
|
212
|
+
no I/O, no logging. main.py logs each at WARNING. Unlike
|
|
213
|
+
validate_production_security(), these never block the boot."""
|
|
214
|
+
warnings: list[str] = []
|
|
215
|
+
# Mirror mode enforces clone-URL confinement outside dev mode, but an
|
|
216
|
+
# empty allowlist still admits any *public* host a valid-signature
|
|
217
|
+
# webhook names (internal/private hosts and file: URLs stay refused).
|
|
218
|
+
# Surface the open posture at boot so an operator chose it knowingly.
|
|
219
|
+
if not self.dev_mode and self.mirror_path and not self.allowed_clone_hosts:
|
|
220
|
+
warnings.append(
|
|
221
|
+
"SEDIMENT_MIRROR_PATH is set with an empty "
|
|
222
|
+
"SEDIMENT_ALLOWED_CLONE_HOSTS: any public host a valid-signature "
|
|
223
|
+
"webhook names will be fetched (internal/private hosts and file: "
|
|
224
|
+
"URLs are still refused). Set SEDIMENT_ALLOWED_CLONE_HOSTS to your "
|
|
225
|
+
'forge host(s) to close this, e.g. ["github.com"].'
|
|
226
|
+
)
|
|
227
|
+
return warnings
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
settings = Settings()
|
sediment_api/database.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
"""PostgreSQL composition helpers for one-shot operator processes."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import os
|
|
8
|
+
from collections.abc import Iterator
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
|
|
11
|
+
from sediment_core import FactStore
|
|
12
|
+
from sediment_core.postgres_engine import (
|
|
13
|
+
create_postgres_engine,
|
|
14
|
+
database_operation_error,
|
|
15
|
+
)
|
|
16
|
+
from sqlalchemy.exc import SQLAlchemyError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def add_database_url_argument(parser: argparse.ArgumentParser) -> None:
|
|
20
|
+
"""Add the direct-store PostgreSQL setting to an operator parser."""
|
|
21
|
+
parser.add_argument(
|
|
22
|
+
"--database-url",
|
|
23
|
+
default=os.environ.get("SEDIMENT_DATABASE_URL"),
|
|
24
|
+
help="PostgreSQL URL (default: $SEDIMENT_DATABASE_URL)",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@contextmanager
|
|
29
|
+
def one_shot_fact_store(
|
|
30
|
+
database_url: str | None, *, operation: str
|
|
31
|
+
) -> Iterator[FactStore]:
|
|
32
|
+
"""Own and dispose one bounded engine for a one-shot process."""
|
|
33
|
+
if not database_url:
|
|
34
|
+
raise ValueError("set SEDIMENT_DATABASE_URL or pass --database-url")
|
|
35
|
+
engine = None
|
|
36
|
+
try:
|
|
37
|
+
engine = create_postgres_engine(database_url)
|
|
38
|
+
yield FactStore(engine)
|
|
39
|
+
except SQLAlchemyError:
|
|
40
|
+
raise database_operation_error(operation, database_url) from None
|
|
41
|
+
finally:
|
|
42
|
+
if engine is not None:
|
|
43
|
+
engine.dispose()
|
sediment_api/deps.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
"""
|
|
3
|
+
Shared API plumbing: auth at the two door types and the fact store.
|
|
4
|
+
|
|
5
|
+
The bearer check and the GitHub "read raw body, verify HMAC, parse JSON"
|
|
6
|
+
step live here once instead of being copied into each router. No org
|
|
7
|
+
derivation lives here or anywhere: every route stamps facts with the
|
|
8
|
+
configured ``settings.org_id``. Query parameters and payload owners cannot
|
|
9
|
+
override deployment tenancy.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import secrets
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Literal
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from fastapi import Depends, Header, HTTPException, Request
|
|
21
|
+
from sediment_capture import verify_signature
|
|
22
|
+
from sediment_core import FactStore
|
|
23
|
+
|
|
24
|
+
from .config import settings
|
|
25
|
+
|
|
26
|
+
# The app-wide request-body ceiling, enforced for every door by
|
|
27
|
+
# BodySizeLimitMiddleware. 25 MB because GitHub caps webhook payloads
|
|
28
|
+
# there and refuses to deliver anything larger, and no other door has a
|
|
29
|
+
# legitimate payload anywhere near it. It is a ceiling on a pre-auth
|
|
30
|
+
# allocation, not a tuning parameter — FastAPI reads an envelope route's
|
|
31
|
+
# body before its auth dependency runs, so every door's read is pre-auth.
|
|
32
|
+
# ponytail: fixed constant; make it a setting only if a non-GitHub forge needs it
|
|
33
|
+
MAX_BODY_BYTES = 25 * 1024 * 1024
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class BodySizeLimitMiddleware:
|
|
37
|
+
"""Bound every request-body read at ``MAX_BODY_BYTES`` (413 past it).
|
|
38
|
+
|
|
39
|
+
Pure ASGI, wrapping ``receive``: the running byte total is the only
|
|
40
|
+
trustworthy number (Content-Length is absent under chunked encoding and
|
|
41
|
+
attacker-supplied otherwise), and raising during the read stops the
|
|
42
|
+
allocation at the ceiling instead of after it. The HTTPException
|
|
43
|
+
surfaces inside whichever handler frame awaited the body — FastAPI's
|
|
44
|
+
envelope read, ``request.json()``, or ``_read_body_capped``'s stream
|
|
45
|
+
loop — where the exception middleware turns it into the 413 response.
|
|
46
|
+
|
|
47
|
+
One guard for every door, including routers added later — the per-door
|
|
48
|
+
alternative is how the envelope doors (gateway, vendor CI) ended up
|
|
49
|
+
uncapped while the webhook door was bounded.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self, app: Any) -> None:
|
|
53
|
+
self.app = app
|
|
54
|
+
|
|
55
|
+
async def __call__(self, scope: Any, receive: Any, send: Any) -> None:
|
|
56
|
+
if scope["type"] != "http":
|
|
57
|
+
await self.app(scope, receive, send)
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
received = 0
|
|
61
|
+
|
|
62
|
+
async def capped_receive() -> Any:
|
|
63
|
+
nonlocal received
|
|
64
|
+
message = await receive()
|
|
65
|
+
if message["type"] == "http.request":
|
|
66
|
+
received += len(message.get("body", b""))
|
|
67
|
+
# Module-global lookup on purpose: tests lower the cap by
|
|
68
|
+
# patching MAX_BODY_BYTES after the app is constructed.
|
|
69
|
+
if received > MAX_BODY_BYTES:
|
|
70
|
+
raise HTTPException(
|
|
71
|
+
status_code=413, detail="request body too large"
|
|
72
|
+
)
|
|
73
|
+
return message
|
|
74
|
+
|
|
75
|
+
await self.app(scope, capped_receive, send)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class CredentialIdentity:
|
|
80
|
+
"""Configured authority only; never a tenant or captured developer identity."""
|
|
81
|
+
|
|
82
|
+
authority: Literal["ingest", "operator"]
|
|
83
|
+
client_id: str
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def verify_token(authorization: str | None = Header(None)) -> CredentialIdentity:
|
|
87
|
+
"""Authenticate either fixed authority without logging credential material."""
|
|
88
|
+
scheme, _, credential = (authorization or "").partition(" ")
|
|
89
|
+
credential = credential.strip()
|
|
90
|
+
if scheme.lower() != "bearer" or not credential:
|
|
91
|
+
raise HTTPException(status_code=401, detail="Invalid token")
|
|
92
|
+
supplied = credential.encode()
|
|
93
|
+
identity = None
|
|
94
|
+
candidates = [
|
|
95
|
+
(
|
|
96
|
+
settings.operator_token.get_secret_value(),
|
|
97
|
+
CredentialIdentity("operator", "operator"),
|
|
98
|
+
),
|
|
99
|
+
(settings.api_bearer_token, CredentialIdentity("ingest", "legacy")),
|
|
100
|
+
*(
|
|
101
|
+
(token.get_secret_value(), CredentialIdentity("ingest", client_id))
|
|
102
|
+
for client_id, token in settings.ingest_tokens.items()
|
|
103
|
+
),
|
|
104
|
+
]
|
|
105
|
+
for token, candidate in candidates:
|
|
106
|
+
if token and secrets.compare_digest(supplied, token.encode()):
|
|
107
|
+
identity = candidate
|
|
108
|
+
if identity is None:
|
|
109
|
+
raise HTTPException(status_code=401, detail="Invalid token")
|
|
110
|
+
return identity
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def verify_ingest_token(
|
|
114
|
+
identity: CredentialIdentity = Depends(verify_token),
|
|
115
|
+
) -> CredentialIdentity:
|
|
116
|
+
"""Capture accepts ingest clients and explicit operator demonstrations."""
|
|
117
|
+
return identity
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def verify_operator_token(
|
|
121
|
+
identity: CredentialIdentity = Depends(verify_token),
|
|
122
|
+
) -> CredentialIdentity:
|
|
123
|
+
"""Read and inspection routes require operator authority."""
|
|
124
|
+
if identity.authority != "operator":
|
|
125
|
+
raise HTTPException(status_code=403, detail="Operator authority required")
|
|
126
|
+
return identity
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def get_store(request: Request) -> FactStore:
|
|
130
|
+
"""Borrow the lifespan-owned PostgreSQL fact store for one request."""
|
|
131
|
+
return request.app.state.fact_store
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
async def _read_body_capped(request: Request, limit: int) -> bytearray:
|
|
135
|
+
"""Buffer the request body, refusing anything over ``limit`` bytes (413).
|
|
136
|
+
|
|
137
|
+
``request.body()`` reads the stream to completion with no cap, and the
|
|
138
|
+
HMAC check below can only run *after* the body is in hand — so an
|
|
139
|
+
unauthenticated caller would otherwise choose how much memory this
|
|
140
|
+
allocates. Streaming stops at the ceiling instead of after it.
|
|
141
|
+
|
|
142
|
+
Content-Length is deliberately not consulted: it is absent under chunked
|
|
143
|
+
encoding and attacker-supplied otherwise. The running total is the only
|
|
144
|
+
number that can be trusted.
|
|
145
|
+
|
|
146
|
+
Accumulating into one ``bytearray`` rather than a chunk list plus
|
|
147
|
+
``b"".join`` keeps peak memory at the ceiling instead of twice it: the
|
|
148
|
+
join holds both the chunks and the joined copy alive at once (measured
|
|
149
|
+
2.00x vs 1.01x of the limit). Halving the cap would not substitute —
|
|
150
|
+
the doubling is what makes the ceiling unenforceable. The bytearray is
|
|
151
|
+
returned as-is for the same reason; ``bytes()`` here would reintroduce
|
|
152
|
+
the copy. Both consumers (``verify_signature``, ``json.loads``) take
|
|
153
|
+
bytes-like and produce identical results either way.
|
|
154
|
+
"""
|
|
155
|
+
buf = bytearray()
|
|
156
|
+
async for chunk in request.stream():
|
|
157
|
+
if len(buf) + len(chunk) > limit:
|
|
158
|
+
raise HTTPException(status_code=413, detail="request body too large")
|
|
159
|
+
buf.extend(chunk)
|
|
160
|
+
return buf
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
async def read_verified_webhook(
|
|
164
|
+
request: Request, signature: str | None
|
|
165
|
+
) -> dict[str, Any]:
|
|
166
|
+
"""Read the raw body, verify its GitHub HMAC signature, return parsed JSON.
|
|
167
|
+
|
|
168
|
+
Raises 401 if the signature is missing or invalid (an unsigned request
|
|
169
|
+
is unauthenticated, not a schema error). This is the one place webhook
|
|
170
|
+
auth is enforced for every GitHub webhook endpoint — and, because the
|
|
171
|
+
body must be read before it can be authenticated, the one place the
|
|
172
|
+
pre-auth read is bounded.
|
|
173
|
+
"""
|
|
174
|
+
body_bytes = await _read_body_capped(request, MAX_BODY_BYTES)
|
|
175
|
+
if not verify_signature(
|
|
176
|
+
body_bytes, signature or "", settings.github_webhook_secret
|
|
177
|
+
):
|
|
178
|
+
raise HTTPException(status_code=401, detail="Invalid webhook signature")
|
|
179
|
+
try:
|
|
180
|
+
payload = json.loads(body_bytes)
|
|
181
|
+
except (ValueError, RecursionError):
|
|
182
|
+
# Fail-soft on any malformed body from an authenticated caller:
|
|
183
|
+
# JSONDecodeError and UnicodeDecodeError (non-UTF-8 bytes) are both
|
|
184
|
+
# ValueError; deeply nested JSON raises RecursionError. None of them
|
|
185
|
+
# may 500 — they're a bad request (400 below), not a server fault.
|
|
186
|
+
payload = None
|
|
187
|
+
if not isinstance(payload, dict):
|
|
188
|
+
# Signature-valid but not a JSON object: an authenticated caller sent
|
|
189
|
+
# a malformed body. 400, not 500 — and not 401, the HMAC passed.
|
|
190
|
+
raise HTTPException(status_code=400, detail="invalid JSON body")
|
|
191
|
+
return payload
|
sediment_api/main.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
"""Sediment ingest API — FastAPI entry point."""
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
|
|
7
|
+
from fastapi import FastAPI, Request
|
|
8
|
+
from fastapi.exceptions import RequestValidationError
|
|
9
|
+
from fastapi.responses import JSONResponse
|
|
10
|
+
from sediment_core import FactStore, RepositoryIdentityConflict
|
|
11
|
+
from sediment_core.postgres_engine import (
|
|
12
|
+
DatabaseOperationError,
|
|
13
|
+
DatabaseURLValidationError,
|
|
14
|
+
create_postgres_engine,
|
|
15
|
+
database_operation_error,
|
|
16
|
+
sanitized_database_target,
|
|
17
|
+
verify_minimum_server_version,
|
|
18
|
+
wait_for_database,
|
|
19
|
+
)
|
|
20
|
+
from sediment_core.postgres_migrations import (
|
|
21
|
+
HEAD_REVISION,
|
|
22
|
+
RevisionState,
|
|
23
|
+
inspect_engine_revision,
|
|
24
|
+
)
|
|
25
|
+
from sediment_core.postgres_roles import validate_runtime_privileges
|
|
26
|
+
from sqlalchemy.exc import (
|
|
27
|
+
DisconnectionError,
|
|
28
|
+
InterfaceError,
|
|
29
|
+
OperationalError,
|
|
30
|
+
SQLAlchemyError,
|
|
31
|
+
TimeoutError as SQLAlchemyTimeoutError,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
from . import __version__
|
|
35
|
+
from .config import settings
|
|
36
|
+
from .deps import BodySizeLimitMiddleware
|
|
37
|
+
from .routers import ci_vendor, forge, gateway, otlp, query, reports, v1
|
|
38
|
+
from .workers import WorkerSupervisor
|
|
39
|
+
|
|
40
|
+
logging.basicConfig(
|
|
41
|
+
level=logging.INFO,
|
|
42
|
+
format="%(asctime)s %(name)s %(levelname)s %(message)s",
|
|
43
|
+
)
|
|
44
|
+
logger = logging.getLogger("sediment.api")
|
|
45
|
+
|
|
46
|
+
# Fail closed: refuse to construct the app with default/empty secrets.
|
|
47
|
+
# SEDIMENT_DEV_MODE=true opts out (local dev only). The message names each
|
|
48
|
+
# offending setting but never its value. A missing or invalid
|
|
49
|
+
# SEDIMENT_ORG_ID already failed the boot at Settings() construction.
|
|
50
|
+
_security_problems = settings.validate_production_security()
|
|
51
|
+
if _security_problems:
|
|
52
|
+
_enumerated = "\n".join(f" - {p}" for p in _security_problems)
|
|
53
|
+
raise RuntimeError(
|
|
54
|
+
"Refusing to start: insecure configuration detected.\n"
|
|
55
|
+
f"{_enumerated}\n"
|
|
56
|
+
"Set the named SEDIMENT_* environment variables to real values, or set "
|
|
57
|
+
"SEDIMENT_DEV_MODE=true for local development only."
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Advisory posture warnings (non-fatal): open mirror allowlist, etc. Logged,
|
|
61
|
+
# never raised — the operator may have chosen the posture deliberately.
|
|
62
|
+
for _warning in settings.config_warnings():
|
|
63
|
+
logger.warning("config_warning: %s", _warning)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@asynccontextmanager
|
|
67
|
+
async def lifespan(application: FastAPI):
|
|
68
|
+
"""Own one PostgreSQL engine and verify the exact schema before traffic."""
|
|
69
|
+
database_url = settings.database_url.get_secret_value()
|
|
70
|
+
engine = None
|
|
71
|
+
workers = None
|
|
72
|
+
try:
|
|
73
|
+
engine = create_postgres_engine(database_url, api_work=True)
|
|
74
|
+
wait_for_database(engine)
|
|
75
|
+
with engine.connect() as connection:
|
|
76
|
+
verify_minimum_server_version(connection)
|
|
77
|
+
inspection = inspect_engine_revision(engine)
|
|
78
|
+
if inspection.state is not RevisionState.AT_HEAD:
|
|
79
|
+
raise DatabaseOperationError(
|
|
80
|
+
"verify database schema failed for "
|
|
81
|
+
f"{sanitized_database_target(database_url)}: expected "
|
|
82
|
+
f"{HEAD_REVISION}, found {inspection.state.value}"
|
|
83
|
+
)
|
|
84
|
+
if not settings.dev_mode:
|
|
85
|
+
validate_runtime_privileges(engine)
|
|
86
|
+
application.state.database_engine = engine
|
|
87
|
+
application.state.fact_store = FactStore(engine)
|
|
88
|
+
application.state.database_target = sanitized_database_target(database_url)
|
|
89
|
+
workers = WorkerSupervisor()
|
|
90
|
+
application.state.workers = workers
|
|
91
|
+
yield
|
|
92
|
+
except (DatabaseOperationError, DatabaseURLValidationError):
|
|
93
|
+
raise
|
|
94
|
+
except Exception:
|
|
95
|
+
raise database_operation_error(
|
|
96
|
+
"verify database startup", database_url
|
|
97
|
+
) from None
|
|
98
|
+
finally:
|
|
99
|
+
if workers is not None:
|
|
100
|
+
await workers.close()
|
|
101
|
+
if engine is not None:
|
|
102
|
+
engine.dispose()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# enable_docs=False must close the whole schema surface: docs_url alone
|
|
106
|
+
# leaves /redoc and /openapi.json publicly served.
|
|
107
|
+
app = FastAPI(
|
|
108
|
+
title="Sediment API",
|
|
109
|
+
version=__version__,
|
|
110
|
+
description="Self-hosted ingest: facts in through three doors (ADR 0001)",
|
|
111
|
+
docs_url="/docs" if settings.enable_docs else None,
|
|
112
|
+
redoc_url="/redoc" if settings.enable_docs else None,
|
|
113
|
+
openapi_url="/openapi.json" if settings.enable_docs else None,
|
|
114
|
+
lifespan=lifespan,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# Every door's body read is bounded — see the class docstring.
|
|
118
|
+
app.add_middleware(BodySizeLimitMiddleware)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@app.exception_handler(SQLAlchemyError)
|
|
122
|
+
async def database_error_response(
|
|
123
|
+
request: Request, exc: SQLAlchemyError
|
|
124
|
+
) -> JSONResponse:
|
|
125
|
+
"""Return a stable response without exposing driver diagnostics."""
|
|
126
|
+
target = getattr(request.app.state, "database_target", "configured database")
|
|
127
|
+
if isinstance(
|
|
128
|
+
exc,
|
|
129
|
+
(
|
|
130
|
+
DisconnectionError,
|
|
131
|
+
InterfaceError,
|
|
132
|
+
OperationalError,
|
|
133
|
+
SQLAlchemyTimeoutError,
|
|
134
|
+
),
|
|
135
|
+
):
|
|
136
|
+
logger.error("database_unavailable target=%s", target)
|
|
137
|
+
return JSONResponse(
|
|
138
|
+
status_code=503,
|
|
139
|
+
content={
|
|
140
|
+
"detail": {
|
|
141
|
+
"code": "database_unavailable",
|
|
142
|
+
"message": "PostgreSQL fact store unavailable",
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
)
|
|
146
|
+
logger.error("database_operation_failed target=%s", target)
|
|
147
|
+
return JSONResponse(
|
|
148
|
+
status_code=500,
|
|
149
|
+
content={
|
|
150
|
+
"detail": {
|
|
151
|
+
"code": "database_operation_failed",
|
|
152
|
+
"message": "PostgreSQL fact store operation failed",
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@app.exception_handler(RepositoryIdentityConflict)
|
|
159
|
+
async def repository_identity_conflict_response(
|
|
160
|
+
request: Request, exc: RepositoryIdentityConflict
|
|
161
|
+
) -> JSONResponse:
|
|
162
|
+
"""Conflicting receipts cannot disclose retained evidence or acknowledge it."""
|
|
163
|
+
logger.warning("repository_identity_conflict")
|
|
164
|
+
return JSONResponse(
|
|
165
|
+
status_code=409,
|
|
166
|
+
content={
|
|
167
|
+
"detail": {
|
|
168
|
+
"code": "repository_identity_conflict",
|
|
169
|
+
"message": "Repository identity conflicts with retained evidence",
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@app.exception_handler(RequestValidationError)
|
|
176
|
+
async def validation_error_without_input(
|
|
177
|
+
request: Request, exc: RequestValidationError
|
|
178
|
+
) -> JSONResponse:
|
|
179
|
+
"""422 that names the failing field but never echoes the input.
|
|
180
|
+
|
|
181
|
+
FastAPI's default handler serializes each error's ``input`` through
|
|
182
|
+
``jsonable_encoder``, which recurses until the stack gives out on a
|
|
183
|
+
deeply nested body — a 6 KB request became a 500, and AGENTS.md §API
|
|
184
|
+
Conventions says malformed bodies from authenticated callers are
|
|
185
|
+
400/422, never 500. type/loc/msg are what a caller needs to fix the
|
|
186
|
+
request; the offending input is theirs already.
|
|
187
|
+
"""
|
|
188
|
+
detail = [
|
|
189
|
+
{"type": e.get("type", ""), "loc": e.get("loc", ()), "msg": e.get("msg", "")}
|
|
190
|
+
for e in exc.errors()
|
|
191
|
+
]
|
|
192
|
+
return JSONResponse(status_code=422, content={"detail": detail})
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
app.include_router(gateway.router, prefix="/ingest")
|
|
196
|
+
# The GitHub webhook door is grouped by source: auth (HMAC vs bearer) and
|
|
197
|
+
# payload shape are per-source, and the plain names belong to the
|
|
198
|
+
# vendor-neutral doors — /ingest/ci is ci_vendor's, and /ingest/push stays
|
|
199
|
+
# free for a vendor-neutral push door later.
|
|
200
|
+
app.include_router(forge.router, prefix="/ingest/github")
|
|
201
|
+
app.include_router(ci_vendor.router, prefix="/ingest")
|
|
202
|
+
# OTLP receiver: path fixed by the exporter (<endpoint>/v1/logs), no prefix.
|
|
203
|
+
app.include_router(otlp.router)
|
|
204
|
+
app.include_router(query.router, prefix="/query")
|
|
205
|
+
# Read-only v1 surfaces: remote CLI probes and bounded operational reports.
|
|
206
|
+
app.include_router(v1.router, prefix="/v1")
|
|
207
|
+
app.include_router(reports.router, prefix="/v1/reports")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@app.get("/health")
|
|
211
|
+
async def health() -> dict:
|
|
212
|
+
return {"status": "ok", "version": app.version}
|