feat(observability): backfill logging for upload, auth, and domain services

Why:
- resolve_auth_context() runs on every authenticated request and logged
  nothing; four distinct rejection reasons (malformed/unknown/inactive/
  expired key, inactive tenant) were all invisible.
- The domain-allowlist rejection in upload_source_file() happens before any
  ingestion_jobs row exists, so it wasn't covered by the job-level
  ingestion.job.failed event either -- a rejected upload left zero trace.
- Four of upload_source_file()'s five failure branches (parse_failed,
  chunk_limit_exceeded, embedding_failed, index_failed) called
  _mark_job_failed(), which wrote to Postgres but never logged; only
  storage_failed and timeout had an ad-hoc logger.warning duplicated at their
  own call sites.

Changes:
- auth/service.py: auth.succeeded / auth.failed (with a reason field per
  rejection type), matching ADR-0011's own event catalog.
- domains/service.py: domain.rejected on the allowlist check;
  domain.created / domain.updated / domain.status_changed on the three
  mutations.
- files/upload.py: centralized failure logging inside _mark_job_failed
  (every failure branch already calls it, so logging there once closes all
  five branches instead of duplicating a log call at each site) as
  ingestion.job.failed; added ingestion.job.started; renamed the ad-hoc
  files.upload.succeeded to ingestion.job.completed for catalog consistency.

Impact:
- None to request/response behavior -- log events only.
This commit is contained in:
Ali Zarinkolah
2026-08-20 19:24:39 +03:30
parent ac779dec7e
commit 012b44d5f2
6 changed files with 595 additions and 18 deletions

View File

@@ -8,6 +8,7 @@ must run with no Postgres session held open at all.
from datetime import UTC, datetime
import structlog
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from src.application.auth.context import AuthContext
@@ -16,28 +17,66 @@ from src.application.auth.keys import parse_api_key, verify_secret
from src.infrastructure.postgres.repositories import api_keys as api_keys_repo
from src.infrastructure.postgres.repositories import tenants as tenants_repo
logger = structlog.get_logger(__name__)
async def resolve_auth_context(
sessionmaker: async_sessionmaker[AsyncSession], bearer_token: str
) -> AuthContext:
"""Resolve a bearer token, logging the outcome either way (ADR-0011).
This runs on every authenticated request, so `auth.failed` is the one
event most likely to matter first when diagnosing a client integration
issue -- and the reason string alone (never logged; it can echo back
attacker-supplied key material) is not enough to tell a malformed token
apart from a revoked one without this.
"""
parsed = parse_api_key(bearer_token)
if parsed is None:
logger.warning("auth.failed", reason="malformed_key")
raise InvalidApiKeyError("malformed API key")
key_prefix, secret = parsed
async with sessionmaker() as session:
api_key = await api_keys_repo.get_by_prefix(session, key_prefix)
if api_key is None or not verify_secret(secret, api_key.key_hash):
logger.warning("auth.failed", reason="unknown_key", key_prefix=key_prefix)
raise InvalidApiKeyError("unknown API key")
if api_key.status != "active":
logger.warning(
"auth.failed",
reason="key_inactive",
key_prefix=key_prefix,
api_key_id=str(api_key.id),
key_status=api_key.status,
)
raise InvalidApiKeyError(f"API key is {api_key.status}")
if api_key.expires_at is not None and api_key.expires_at <= datetime.now(UTC):
logger.warning(
"auth.failed",
reason="key_expired",
key_prefix=key_prefix,
api_key_id=str(api_key.id),
)
raise InvalidApiKeyError("API key has expired")
tenant = await tenants_repo.get_by_id(session, api_key.tenant_id)
if tenant is None or tenant.status != "active":
logger.warning(
"auth.failed",
reason="tenant_inactive",
key_prefix=key_prefix,
api_key_id=str(api_key.id),
tenant_id=str(api_key.tenant_id),
)
raise TenantInactiveError("tenant is not active")
logger.info(
"auth.succeeded",
tenant_id=str(tenant.id),
api_key_id=str(api_key.id),
actor_type=api_key.actor_type,
)
return AuthContext(
tenant_id=tenant.id,
tenant_slug=tenant.slug,