Files
0pt/services/api/sources/connectors.py
T
Simon AxelssonandClaude Sonnet 5 dc6aaa0e10
CI (SIAX Cloud) / quality (push) Successful in 51s
Security Scan (SIAX Cloud) / security (push) Successful in 26s
CI (SIAX Cloud) / security (push) Successful in 28s
CI (SIAX Cloud) / integration (push) Successful in 1m1s
CI (SIAX Cloud) / integration (pull_request) Successful in 30s
CI (SIAX Cloud) / security (pull_request) Successful in 24s
CI (SIAX Cloud) / quality (pull_request) Successful in 48s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 22s
fix(data-os): P014 — close tenant-isolation + idempotency gaps, execute real restore drill
Tenant isolation (security-critical): every app/api/0pt/** route reading
tenant_id from client-controlled query/body never cross-checked it against
the token middleware.ts already verified (x-0pt-verified-tenant). Adds a
shared assertTenantMatchesVerified guard and wires it into all 20+ affected
routes. Also closes two full-leak-by-omission bugs found in this pass
(documents/capture and lakehouse/mappings GETs returned every tenant's rows
when tenant_id was simply omitted) and two "no tenant check at all" reads
(rate-policies/[id], lakehouse/mappings/[id]) reachable by ID alone.

Duplicate-delivery idempotency (correctness-critical): POST /api/orders now
honors Idempotency-Key (sql/006, replay-or-409-on-conflict, Postgres +
in-memory); the Python orchestrator's POST /orders/{id}/run — the actual
delivery trigger — is now idempotent under an asyncio lock, closing a real
concurrent-request race, not just the sequential-retry case.

Engine-duplication resolution: adaptive_router.py's docstring wrongly
invited a bypass ("see b0ts for scraping") contradicting the product's own
compliance stance; corrected, and given real (additive, not escalating)
block-signal tracking so a rate-limited/blocked source is excluded from the
plan instead of hammered. Also fixes a previously-undiscovered production
bug: the Python engine's only call into the TS quality bridge
(X-Internal-Token) was unconditionally 401'd by middleware.ts, which never
checked that credential — the advisory quality bridge was unreachable in any
environment enforcing auth.

Restore drill: executed for real against throwaway Docker Postgres
containers (not simulated). Found and fixed two real blockers along the way:
sql/004 referenced opt_datasets, which no migration ever created (chain was
never actually runnable end to end); lib/db.ts hardcoded ssl:"require",
making any non-TLS Postgres (incl. compose.dev.yaml's own local-dev
container) unreachable. After both fixes: full migration chain applies
cleanly, pg_dump/pg_restore round-trip is byte-identical (row counts +
content checksums), and real application code (lib/orders-store.ts,
SourceRegistryService's Postgres repository) reads the restored data back
correctly, including cross-tenant isolation still holding.

82 new tests (55 TS + 27 Python), all passing; full existing suite (291 TS +
55 Python) still green; typecheck clean on every changed/added file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 12:00:43 +02:00

315 lines
13 KiB
Python

"""
0pt data engine — COMPLIANT source connectors.
Legal sourcing only (CLAUDE.md §1, docs/06). Priority per field/market:
1. official API / bulk 2. authorized public web (robots + rate limits)
3. licensed partner data 4. otherwise -> level 0 "ej tillgänglig / licens".
EXPLICITLY NOT PRESENT (and must never be added here):
WAF/bot bypass, FlareSolverr, stealth/undetected browsers, proxy/fingerprint
rotation to evade bans, scraping behind login walls, fake-account creation.
Every connector returns a normalized company dict + per-field provenance rows
so nothing ships unsourced. `Provenance` (below) carries the full field set
BEFORE_STATE.md §3 found missing from the actual dataflow — this repo's
5-field bridge_provenance was: source, confidence, level, and a DATE-only
freshness; the concept "bridge_provenance" as a named thing did not exist
anywhere in the repo at all. This adds, per field: retrieval TIMESTAMP
(distinct from the legacy date-only `freshness`), the transform applied,
connector/schema version, the producing system, and two slots
(`quality`/`entity_resolution`) that downstream stages (quality bridge,
resolve.py) fill in as the record moves through the pipeline — they start
`None` here and are never backfilled with a guess.
Terms change — VERIFY each source's current access/licence before relying.
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
import httpx
# A polite client that identifies itself and obeys timeouts/rate limits.
USER_AGENT = "0pt-data-engine/0.1 (+https://0pt.app; SIAX Technology AB; hej@siax.io)"
_TIMEOUT = httpx.Timeout(15.0, connect=10.0)
# Bumped when a connector's parsing/field-mapping logic changes in a way that
# could shift output shape — recorded per-field as `Provenance.connector_version`
# so a downstream consumer can tell which parsing logic produced a value.
CONNECTOR_VERSIONS: dict[str, str] = {
"gleif": "gleif-connector@1",
"brreg": "brreg-connector@1",
"companies_house": "companies-house-connector@1",
"prh": "prh-connector@1",
"cvr": "cvr-connector@1",
"rdw": "rdw-connector@1",
}
PRODUCER = "0pt-engine"
# P014: statuses a compliant source uses to signal "stop asking me this way"
# (rate-limited, forbidden, or a legal/geo block) rather than "this record
# doesn't exist". Collapsing these into an ordinary None (as every connector
# did before this pass) is not itself a compliance violation -- nothing here
# ever escalates to a bypass -- but it silently discarded a real signal:
# adaptive_router.py had no way to know a source had just told it to back
# off, so nothing could avoid hammering a rate-limited endpoint on the very
# next record. See SourceBlockedError and adaptive_router.record_blocked.
BLOCKED_STATUSES = frozenset({401, 403, 429, 451})
class SourceBlockedError(Exception):
"""
Raised by a connector when the source responds with one of
BLOCKED_STATUSES. This is informational, never an instruction to retry
harder or escalate -- callers (pipeline.py's _fetch loop) catch it only
to record the block via adaptive_router.record_blocked and move on to
the next compliant source in the plan, exactly as they already would for
a plain None. Compliance stance (CLAUDE.md §1) is unchanged: a block is
always a stop signal for THAT source, never a cue to bypass it.
"""
def __init__(self, source_id: str, status_code: int):
self.source_id = source_id
self.status_code = status_code
super().__init__(f"{source_id} signaled a block (HTTP {status_code})")
def _raise_if_blocked(source_id: str, response: httpx.Response) -> None:
if response.status_code in BLOCKED_STATUSES:
raise SourceBlockedError(source_id, response.status_code)
def _client() -> httpx.AsyncClient:
return httpx.AsyncClient(
headers={"User-Agent": USER_AGENT, "Accept": "application/json"},
timeout=_TIMEOUT,
follow_redirects=True,
)
@dataclass
class Provenance:
field_name: str
source_id: str
confidence: float
level: int # 0 unavailable/licensed, 1 single, 2 partial, 3 corroborated
freshness: str | None = None # legacy: retrieval DATE only (kept for back-compat)
retrieved_at: str | None = None # full ISO-8601 UTC timestamp of the actual fetch
transform: str | None = None # e.g. "raw_fetch", "normalized", "entity_resolution_merge"
connector_version: str | None = None # e.g. "brreg-connector@1" — see CONNECTOR_VERSIONS
producer: str = PRODUCER # which system produced this field's value
quality: dict[str, Any] | None = None # filled by the quality bridge, if/when it runs (never guessed)
entity_resolution: dict[str, Any] | None = None # filled by resolve.py on merge, never at fetch time
artifact_ref: str | None = None # delivery artifact reference (ST0RE), set at delivery time — not yet wired
def make_provenance(
field_name: str,
source_id: str,
confidence: float,
level: int,
*,
transform: str = "raw_fetch",
) -> Provenance:
"""Factory used by every connector below so the new provenance fields
(retrieved_at, connector_version, producer) are populated consistently
instead of each call site re-deriving them ad hoc."""
now = datetime.now(timezone.utc)
return Provenance(
field_name=field_name,
source_id=source_id,
confidence=confidence,
level=level,
freshness=now.date().isoformat(),
retrieved_at=now.isoformat(),
transform=transform,
connector_version=CONNECTOR_VERSIONS.get(source_id),
producer=PRODUCER,
)
@dataclass
class CompanyResult:
org_nr: str | None = None
market: str | None = None
lei: str | None = None
name: str | None = None
status: str | None = None
address: dict[str, Any] | None = None
website: str | None = None
provenance: list[Provenance] = field(default_factory=list)
source: str = ""
# ── GLEIF / LEI (global cross-border key) — open API ────────────────────────
async def gleif_by_lei(lei: str) -> CompanyResult | None:
url = f"https://api.gleif.org/api/v1/lei-records/{lei}"
async with _client() as c:
r = await c.get(url, headers={"Accept": "application/vnd.api+json"})
_raise_if_blocked("gleif", r)
if r.status_code != 200:
return None
a = (r.json().get("data") or {}).get("attributes") or {}
entity = a.get("entity") or {}
legal = entity.get("legalAddress") or {}
res = CompanyResult(
lei=lei,
name=(entity.get("legalName") or {}).get("name"),
status=entity.get("status"),
address={"lines": legal.get("addressLines"), "city": legal.get("city"),
"country": legal.get("country"), "postal": legal.get("postalCode")},
source="gleif",
)
res.provenance += [
make_provenance("lei", "gleif", 0.99, 3),
make_provenance("name", "gleif", 0.97, 3),
]
return res
# ── Brønnøysundregistrene (NO) — open API (NLOD). VERIFY terms. ─────────────
async def brreg_by_orgnr(orgnr: str) -> CompanyResult | None:
url = f"https://data.brreg.no/enhetsregisteret/api/enheter/{orgnr}"
async with _client() as c:
r = await c.get(url)
_raise_if_blocked("brreg", r)
if r.status_code != 200:
return None
d = r.json()
res = CompanyResult(
org_nr=orgnr, market="NO",
name=d.get("navn"),
status="dissolved" if d.get("konkurs") else "active",
address={"raw": d.get("forretningsadresse")},
source="brreg",
)
res.provenance += [
make_provenance("name", "brreg", 0.97, 3),
make_provenance("status", "brreg", 0.95, 3),
make_provenance("address", "brreg", 0.95, 3),
]
return res
# ── Companies House (UK) — free API, key required. VERIFY terms. ────────────
async def companies_house_by_number(number: str) -> CompanyResult | None:
key = os.getenv("COMPANIES_HOUSE_API_KEY")
if not key:
# No key -> deliver honestly as unavailable, never fabricate.
return CompanyResult(org_nr=number, market="GB", source="companies_house",
provenance=[make_provenance("name", "companies_house", 0.0, 0, transform="unavailable_no_key")])
url = f"https://api.company-information.service.gov.uk/company/{number}"
async with _client() as c:
r = await c.get(url, auth=(key, ""))
_raise_if_blocked("companies_house", r)
if r.status_code != 200:
return None
d = r.json()
res = CompanyResult(
org_nr=number, market="GB",
name=d.get("company_name"),
status=d.get("company_status"),
address={"raw": d.get("registered_office_address")},
source="companies_house",
)
res.provenance += [
make_provenance("name", "companies_house", 0.97, 3),
make_provenance("status", "companies_house", 0.95, 3),
]
return res
# ── PRH avoindata (FI) — open API (CC-BY). VERIFY terms. ────────────────────
async def prh_by_businessid(business_id: str) -> CompanyResult | None:
url = f"https://avoindata.prh.fi/opendata-ytj-api/v3/companies?businessId={business_id}"
async with _client() as c:
r = await c.get(url)
_raise_if_blocked("prh", r)
if r.status_code != 200:
return None
items = r.json().get("companies") or r.json().get("results") or []
if not items:
return None
d = items[0]
names = d.get("names") or []
name = names[0].get("name") if names else d.get("name")
res = CompanyResult(org_nr=business_id, market="FI", name=name, status=d.get("status"), source="prh")
if name:
res.provenance.append(make_provenance("name", "prh", 0.95, 3))
return res
# ── CVR (DK) via cvrapi.dk — free for low volume (descriptive UA). VERIFY. ───
async def cvr_by_number(cvr: str) -> CompanyResult | None:
url = f"https://cvrapi.dk/api?search={cvr}&country=dk"
async with _client() as c:
r = await c.get(url)
_raise_if_blocked("cvr", r)
if r.status_code != 200:
return None
d = r.json()
if not d.get("name"):
return None
res = CompanyResult(
org_nr=cvr, market="DK", name=d.get("name"),
status="ceased" if d.get("enddate") else "active",
address={"raw": d.get("address")}, source="cvr",
)
res.provenance += [make_provenance("name", "cvr", 0.93, 3), make_provenance("address", "cvr", 0.9, 2)]
return res
# ── RDW (NL) open vehicle data — Socrata. Technical/fleet only, no owner PII. ─
async def rdw_vehicle(kenteken: str) -> dict[str, Any] | None:
plate = kenteken.upper().replace("-", "").replace(" ", "")
url = f"https://opendata.rdw.nl/resource/m9d7-ebf2.json?kenteken={plate}"
async with _client() as c:
r = await c.get(url)
if r.status_code != 200:
return None
arr = r.json()
if not arr:
return None
d = arr[0]
p = make_provenance("make", "rdw", 0.9, 3)
return {
"reg_plate": kenteken, "market": "NL",
"make": d.get("merk"), "model": d.get("handelsbenaming"),
"year": (d.get("datum_eerste_toelating") or "")[:4] or None,
"source": "rdw",
# Dict shape (not the Provenance dataclass) — this endpoint predates it
# and is returned directly as an API response body, not merged through
# resolve.py. Field names kept aligned with Provenance's for consistency.
"provenance": [{
"field": p.field_name, "source_id": p.source_id, "confidence": p.confidence, "level": p.level,
"freshness": p.freshness, "retrieved_at": p.retrieved_at, "transform": p.transform,
"connector_version": p.connector_version, "producer": p.producer,
}],
}
# ── OpenSanctions screening — search API. VERIFY commercial terms / key. ─────
async def opensanctions_screen(name: str) -> dict[str, Any]:
url = "https://api.opensanctions.org/search/default"
async with _client() as c:
r = await c.get(url, params={"q": name, "limit": 5})
if r.status_code != 200:
return {"matches": [], "source": "opensanctions"}
results = r.json().get("results", [])
return {
"source": "opensanctions",
"matches": [{"name": x.get("caption"), "score": x.get("score"), "schema": x.get("schema")} for x in results],
}
CONNECTORS = {
"gleif": gleif_by_lei,
"brreg": brreg_by_orgnr,
"companies_house": companies_house_by_number,
"prh": prh_by_businessid,
"cvr": cvr_by_number,
}