feat: bind evidence credentials through local files

This commit is contained in:
2026-08-09 19:03:36 +02:00
parent c7a369f436
commit 7126b567b0
11 changed files with 839 additions and 18 deletions
+128 -5
View File
@@ -1,5 +1,7 @@
import json
import os
import re
import stat
import warnings
from ipaddress import ip_address
from pathlib import Path
@@ -10,6 +12,7 @@ import yaml
from pydantic import BaseModel, Field, PrivateAttr, SecretStr, ValidationError, model_validator
from tht.config_compat import translate_legacy_config
from tht.ports.evidence import canonical_provenance_uri
_ENV_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
@@ -43,6 +46,51 @@ def _expand_env(value: Any) -> Any:
return value
_MAX_SIGNED_URL_FILE_BYTES = 1024 * 1024
def _resolve_http_signed_url_files(value: Any) -> Any:
"""Resolve only signed HTTP URL arrays, keeping their values out of public errors."""
if isinstance(value, dict):
resolved = {
key: _resolve_http_signed_url_files(item)
for key, item in value.items()
}
if resolved.get("type") != "http" or "signed_urls_file" not in resolved:
return resolved
if "urls" in resolved:
raise ConfigError("HTTP signed URL file cannot be combined with urls")
path_value = resolved.pop("signed_urls_file")
if not isinstance(path_value, str):
raise ConfigError("Invalid signed URL file reference")
path = Path(path_value)
try:
entry = path.lstat()
target = path.stat()
if stat.S_ISLNK(entry.st_mode) or not stat.S_ISREG(target.st_mode):
raise OSError
if target.st_size > _MAX_SIGNED_URL_FILE_BYTES:
raise OSError
with path.open("rb") as stream:
payload = stream.read(_MAX_SIGNED_URL_FILE_BYTES + 1)
if len(payload) > _MAX_SIGNED_URL_FILE_BYTES:
raise OSError
parsed = json.loads(payload.decode("utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
raise ConfigError("Cannot read signed URL file") from exc
if (
not isinstance(parsed, list)
or not parsed
or any(not isinstance(item, str) or not item for item in parsed)
):
raise ConfigError("Invalid signed URL file")
resolved["urls"] = parsed
return resolved
if isinstance(value, list):
return [_resolve_http_signed_url_files(item) for item in value]
return value
def _resolve_secret_files(value: Any) -> Any:
if isinstance(value, dict):
resolved = {key: _resolve_secret_files(item) for key, item in value.items()}
@@ -228,12 +276,15 @@ class FilesystemEvidenceSourceConfig(BaseModel):
patterns: list[str] = ["**/*.md"]
max_bytes: int = Field(default=10 * 1024 * 1024, gt=0)
model_config = {"extra": "forbid"}
class HttpEvidenceSourceConfig(BaseModel):
type: Literal["http"]
# Manifest URLs may contain signed query parameters. Treat the complete transport URL as
# secret-bearing configuration; adapters derive a query-free provenance URI from it.
# Transport URLs are secret-bearing. Signed-file configurations retain only public,
# query-free provenance identities alongside the masked transport values.
urls: list[SecretStr] = Field(min_length=1)
provenance_urls: list[str] | None = Field(default=None, min_length=1)
connect_timeout: float = Field(default=5, gt=0)
read_timeout: float = Field(default=30, gt=0)
max_bytes: int = Field(default=10 * 1024 * 1024, gt=0)
@@ -241,6 +292,38 @@ class HttpEvidenceSourceConfig(BaseModel):
allow_private_hosts: bool = False
max_cache_bytes: int = Field(default=64 * 1024 * 1024, gt=0)
model_config = {"extra": "forbid"}
@model_validator(mode="after")
def validate_provenance_mapping(self):
transport_urls = [url.get_secret_value() for url in self.urls]
try:
canonical = [canonical_provenance_uri(url) for url in transport_urls]
except ValueError as exc:
raise ValueError("HTTP transport URL is invalid") from exc
if any(
urlparse(url).scheme not in ("http", "https") or not urlparse(url).hostname
for url in transport_urls
):
raise ValueError("HTTP transport URL must use http or https")
if self.provenance_urls is None:
return self
try:
provenance = [canonical_provenance_uri(url) for url in self.provenance_urls]
except ValueError as exc:
raise ValueError("HTTP provenance URL is invalid") from exc
if provenance != self.provenance_urls:
raise ValueError("HTTP provenance URLs must be canonical query-free identities")
if len(set(provenance)) != len(provenance):
raise ValueError("HTTP provenance URLs must not repeat")
if len(canonical) != len(provenance) or canonical != provenance:
raise ValueError("Signed HTTP URLs must map one-to-one to provenance URLs in order")
return self
def transport_urls(self) -> list[str]:
"""Expose secret transport values only at the adapter-construction boundary."""
return [url.get_secret_value() for url in self.urls]
class S3EvidenceSourceConfig(BaseModel):
type: Literal["s3"]
@@ -259,6 +342,16 @@ class S3EvidenceSourceConfig(BaseModel):
max_pages: int = Field(default=100, gt=0)
page_size: int = Field(default=1000, gt=0, le=1000)
model_config = {"extra": "forbid"}
@model_validator(mode="after")
def validate_static_credentials(self):
if (self.access_key is None) != (self.secret_key is None):
raise ValueError("S3 access_key and secret_key must be configured together")
if self.session_token is not None and self.access_key is None:
raise ValueError("S3 session_token requires static credentials")
return self
EvidenceSourceConfig = Annotated[
FilesystemEvidenceSourceConfig | HttpEvidenceSourceConfig | S3EvidenceSourceConfig,
@@ -282,6 +375,8 @@ class EvidenceSourcesConfig(BaseModel):
raise ValueError("evidence requires source_root or sources")
return self
model_config = {"extra": "forbid"}
class EmbeddingsConfig(BaseModel):
provider: str = "ollama_internal"
@@ -298,10 +393,12 @@ class EmbeddingsConfig(BaseModel):
class VectorConfig(BaseModel):
max_chunk_chars: int = 4000
max_chunk_chars: int = Field(default=4000, gt=0)
# ACTIVE plus the two most recent rollback generations by default.
retain_published_generations: int = Field(default=3, ge=1)
model_config = {"extra": "forbid"}
class SearchConfig(BaseModel):
rrf_k: int = 60
@@ -392,6 +489,31 @@ def workspace_id_for_config(config: Config, path: Path) -> str:
return workspace_id_from_path(path)
def _format_validation_error(error: ValidationError) -> str:
messages = {
"missing": "required field",
"extra_forbidden": "unknown field",
"greater_than": "value must be greater than the configured bound",
"greater_than_equal": "value must meet the configured lower bound",
"less_than_equal": "value exceeds the configured upper bound",
"literal_error": "unsupported literal value",
"union_tag_invalid": "unsupported discriminator",
"union_tag_not_found": "missing discriminator",
"string_too_short": "string is too short",
"too_short": "collection is too short",
"value_error": "configuration value is invalid",
}
lines = []
for issue in error.errors(include_input=False, include_url=False):
location = ".".join(str(part) for part in issue.get("loc", ())) or "configuration"
error_type = str(issue.get("type", "validation_error"))
message = messages.get(error_type, "invalid configuration value")
if location == "runtime_identity" and error_type == "value_error":
message = "source_identity does not match workspace identity"
lines.append(f"{location} [{error_type}]: {message}")
return "\n".join(lines)
def load_config(path: Path) -> Config:
if not path.exists():
raise ConfigError(f"File di configurazione non trovato: {path}")
@@ -401,7 +523,7 @@ def load_config(path: Path) -> Config:
raise ConfigError(f"Configurazione YAML non valida: {path}") from exc
if not isinstance(raw, dict):
raise ConfigError(f"Configurazione non valida (atteso un mapping YAML): {path}")
expanded = _resolve_secret_files(_expand_env(raw))
expanded = _resolve_secret_files(_resolve_http_signed_url_files(_expand_env(raw)))
_validate_internal_embedding_contract(expanded, path)
_validate_internal_vector_contract(expanded, path)
translated, used_legacy = translate_legacy_config(expanded)
@@ -409,7 +531,8 @@ def load_config(path: Path) -> Config:
try:
cfg = Config.model_validate(translated)
except ValidationError as e:
raise ConfigError(f"Configurazione non valida in {path}:\n{e}") from e
details = _format_validation_error(e)
raise ConfigError(f"Configurazione non valida in {path}:\n{details}") from e
env_profile = os.environ.get("THT_PROFILE")
if env_profile is not None:
if env_profile not in ("server", "workstation"):