← back to Unclaimed Property Platform

services/search/search_api.py

112 lines

"""Masked public search API (FastAPI + OpenSearch reference).

This is the PRODUCTION reference. It requires `fastapi` and `opensearch-py`
(see requirements.txt) and a running OpenSearch. For the dependency-free prototype
smoke test, the in-DB masked_search() on SqliteRepository is used instead.

Design invariants:
  - anonymous search is a SEPARATE service from claims; the authoritative property table
    is never internet-queryable.
  - only jurisdiction-approved, MASKED projections are returned (masked name, city,
    holder, type, amount BAND, state reference) — never SSNs, full addresses, exact
    amounts, or claim evidence.
  - fuzzy queries are constrained (filters, prefix_length, max_expansions, result caps)
    because broad fuzzy/wildcard queries are resource-expensive and enable enumeration.
"""
from __future__ import annotations

import os
from typing import Annotated, Any

try:
    from fastapi import Depends, FastAPI, Header, HTTPException, Query
    from opensearchpy import OpenSearch
    from pydantic import BaseModel
    _DEPS = True
except ImportError:  # keep the module importable for docs/tests without the deps
    _DEPS = False
    BaseModel = object  # type: ignore


if _DEPS:
    app = FastAPI(title="National Unclaimed Property Search API")

    search_client = OpenSearch(
        hosts=[os.environ.get("OPENSEARCH_URL", "https://localhost:9200")],
        http_auth=(os.environ.get("OPENSEARCH_USERNAME", ""),
                   os.environ.get("OPENSEARCH_PASSWORD", "")),
        use_ssl=True, verify_certs=True,
    )

    class SearchResult(BaseModel):
        public_reference: str
        jurisdiction: str
        owner_name_masked: str
        owner_city: str | None
        holder_name: str
        property_type: str | None
        amount_band: str | None

    class SearchResponse(BaseModel):
        results: list[SearchResult]
        total_relation: str
        next_page_token: str | None = None

    def require_rate_limit_STUB(
        forwarded_for: Annotated[str | None, Header()] = None,
    ) -> None:
        """SECURITY: THIS IS NOT A RATE LIMITER. It is a placeholder.

        Anti-enumeration REQUIRES a real limiter keyed off the TRUSTED-proxy IP (never the
        spoofable X-Forwarded-For header) plus per-session + global-velocity limits and
        anomaly detection. Until that exists, this endpoint MUST NOT be exposed to the
        internet with real data. The name ends in _STUB so no reviewer mistakes it for a
        control. (Security finding 1.3.)"""
        if forwarded_for and len(forwarded_for) > 500:
            raise HTTPException(status_code=400, detail="Invalid forwarding header")

    @app.get("/v1/search", response_model=SearchResponse)
    def search_properties(
        last_or_business: Annotated[str, Query(min_length=2, max_length=100)],
        first_name: Annotated[str | None, Query(max_length=100)] = None,
        jurisdiction: Annotated[str | None, Query(min_length=2, max_length=3)] = None,
        city: Annotated[str | None, Query(max_length=100)] = None,
        limit: Annotated[int, Query(ge=1, le=50)] = 20,
        _: None = Depends(require_rate_limit_STUB),
    ) -> "SearchResponse":
        must: list[dict[str, Any]] = [{
            "multi_match": {
                "query": last_or_business,
                "fields": ["owner_name_normalized^5", "owner_name_tokens^3",
                           "business_name_normalized^4"],
                "type": "best_fields",
                "fuzziness": "AUTO:4,7", "prefix_length": 1, "max_expansions": 25,
            }
        }]
        filters: list[dict[str, Any]] = [
            {"term": {"is_public": True}},
            {"term": {"is_suppressed": False}},
        ]
        if first_name:
            must.append({"match": {"first_name_normalized": {
                "query": first_name, "fuzziness": "AUTO"}}})
        if jurisdiction:
            filters.append({"term": {"jurisdiction": jurisdiction.upper()}})
        if city:
            filters.append({"match": {"city_normalized": city}})

        response = search_client.search(
            index="unclaimed-property-public",
            body={
                "size": limit, "track_total_hits": False,
                "_source": ["public_reference", "jurisdiction", "owner_name_masked",
                            "owner_city", "holder_name", "property_type", "amount_band"],
                "query": {"bool": {"must": must, "filter": filters}},
            },
        )
        hits = response["hits"]["hits"]
        return SearchResponse(
            results=[SearchResult(**h["_source"]) for h in hits],
            total_relation=response["hits"]["total"]["relation"],
        )