feat: align LegacyHUB with TeamHUB platform contract (D2/D4, assets, security)

Close 12 audit-driven platform-compliance gaps on a single branch.

- D4 dispatch: app/integrations/dispatch_client.py participant `legacyhub`,
  emits LegacyhubDocumentIndexed + AssetDerivativeReady after the indexing
  commit (idempotent uuid5), http_inbox route (reindex/tombstone) with
  audit-based dedupe; docs/dispatch-contract.md. Celery+Redis stays intra-module.
- D2 SSO: app/integrations/identity.py validates X-TeamHub-* + role/scope
  mapper; security.py adds trusted-header enforcement (AUTH_REQUIRE_IDENTITY)
  and a scope check on /search; docker-compose.teamhub.yml (external teamhub_net
  + internal db net, api not host-published); RUNBOOK network/firewall section.
- Asset standard: SearchHit/Citation carry asset_id/owner_module; buckets
  renamed teamhub-legacyhub-* (+quarantine/tmp/exports); purge-by-asset_id with
  legal-hold guard (app/indexing/projection.py); OCR-markdown derivative event.
- audit_log model + Alembic 0003 + record_audit on writes (same transaction).
- Secret masking: app/common/json_logger.py recursive mask wired into structlog
  (+ensure_ascii=False); event payloads redacted before persistence.
- Service X-API-Key mandatory on ingest endpoints (defence-in-depth).
- Port: host API 8000->8050 (collision with SalesHUB/MailHUB resolved),
  container still listens on 8000.
- Config: no plaintext secret defaults; fail-loud in non-dev (no value leak).
- Docs drift: README PG 5440, layered-auth note, 5173 removed from CORS;
  ingest/folder gated by ENABLE_FOLDER_INGEST (410 by default).
- ADRs: layers mapping, shared-core extraction, UI locale (RU-first).

Tests: 78 passing (ruff, compileall, pytest, tsc, vite build, compose config).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vadim Malanov
2026-06-15 11:44:15 +03:00
parent 9af557c26a
commit d27dd0ffbb
45 changed files with 2149 additions and 61 deletions

View File

@@ -20,7 +20,7 @@ from __future__ import annotations
import hmac
from collections.abc import Awaitable, Callable
from fastapi import FastAPI, Request, Response
from fastapi import FastAPI, HTTPException, Request, Response, status
from fastapi.responses import JSONResponse
from starlette.types import ASGIApp
@@ -38,6 +38,39 @@ def _extract_token(request: Request) -> str | None:
return None
def _valid_service_keys() -> list[str]:
from app.config import settings
keys: list[str] = []
if settings.ingest_api_key and settings.ingest_api_key.strip():
keys.append(settings.ingest_api_key.strip())
if settings.api_key and settings.api_key.strip():
keys.append(settings.api_key.strip())
return keys
def require_service_api_key(request: Request) -> None:
"""FastAPI dependency: mandatory service auth for participant endpoints.
Independent of the global ``install_api_key_auth`` middleware. The caller
must present ``X-API-Key`` / ``Authorization: Bearer`` matching the
configured service key (``INGEST_API_KEY`` or ``API_KEY``). When no key is
configured at all (pure local dev) the check is skipped; production forces
``API_KEY`` so the endpoint is always authenticated there. This is the
machine layer and is distinct from the user ``X-TeamHub-*`` identity layer.
"""
valid = _valid_service_keys()
if not valid:
return
token = _extract_token(request)
if not token or not any(hmac.compare_digest(token, k) for k in valid):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid or missing service api key",
headers={"WWW-Authenticate": "Bearer"},
)
def install_api_key_auth(app: FastAPI) -> None:
"""Attach the middleware. Always safe to call; becomes a no-op when no key
is configured.
@@ -77,5 +110,48 @@ def install_api_key_auth(app: FastAPI) -> None:
return await call_next(request)
__all__ = ["install_api_key_auth"]
def install_trusted_headers_auth(app: FastAPI) -> None:
"""Enforce presence of gateway trusted-identity headers when enabled.
No-op unless ``AUTH_REQUIRE_IDENTITY`` is true. When enabled, every request
under ``app_api_prefix`` (except ``/health``, ``/``, docs) must carry a
valid ``X-TeamHub-Actor`` header injected by the platform gateway. This is
the *user-context* layer and is independent of the ``X-API-Key`` *machine*
layer above. Trust requires the module to be reachable only behind the
gateway (network isolation) - see ``docker-compose.teamhub.yml``.
"""
from app.config import settings as fresh_settings
from app.integrations.identity import parse_identity
if not fresh_settings.auth_require_identity:
return
@app.middleware("http")
async def _trusted_headers_middleware( # type: ignore[no-redef]
request: Request,
call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
path = request.url.path
if request.method == "OPTIONS":
return await call_next(request)
if path in EXEMPT_PATHS or any(path.endswith(s) for s in EXEMPT_SUFFIXES):
return await call_next(request)
if not path.startswith(fresh_settings.app_api_prefix):
return await call_next(request)
identity = parse_identity(request.headers)
if identity is None:
return JSONResponse(
status_code=401,
content={"detail": "missing trusted identity headers"},
)
request.state.identity = identity
return await call_next(request)
__all__ = [
"install_api_key_auth",
"install_trusted_headers_auth",
"require_service_api_key",
]
_ = ASGIApp # re-export hint to keep mypy happy on older Starlette versions