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

@@ -0,0 +1,134 @@
"""`resolve_auth_context` emits `auth.succeeded`/`auth.failed` (ADR-0011).
This runs on every authenticated request, so every rejection reason needs a
distinguishable log event -- previously none of them logged anything.
"""
from collections.abc import MutableMapping
from typing import Any
import pytest
import structlog
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from src.application.auth.errors import InvalidApiKeyError, TenantInactiveError
from src.application.auth.service import resolve_auth_context
from tests.support.factories import create_api_key, create_tenant
pytestmark = [
pytest.mark.integration,
pytest.mark.postgres,
pytest.mark.asyncio(loop_scope="session"),
]
def _events_by_name(
logs: list[MutableMapping[str, Any]], name: str
) -> list[MutableMapping[str, Any]]:
return [entry for entry in logs if entry.get("event") == name]
async def test_valid_key_emits_auth_succeeded(
db_session: AsyncSession, db_sessionmaker: async_sessionmaker[AsyncSession]
) -> None:
tenant = await create_tenant(db_session)
_, full_key = await create_api_key(db_session, tenant=tenant, scopes=["files:write"])
await db_session.commit()
with structlog.testing.capture_logs() as logs:
auth = await resolve_auth_context(db_sessionmaker, full_key)
succeeded = _events_by_name(logs, "auth.succeeded")
assert len(succeeded) == 1
assert succeeded[0]["tenant_id"] == str(auth.tenant_id)
assert succeeded[0]["api_key_id"] == str(auth.api_key_id)
assert _events_by_name(logs, "auth.failed") == []
async def test_malformed_token_emits_auth_failed(
db_sessionmaker: async_sessionmaker[AsyncSession],
) -> None:
with structlog.testing.capture_logs() as logs, pytest.raises(InvalidApiKeyError):
await resolve_auth_context(db_sessionmaker, "not-a-bearer-token-at-all")
failed = _events_by_name(logs, "auth.failed")
assert len(failed) == 1
assert failed[0]["reason"] == "malformed_key"
async def test_wrong_secret_emits_auth_failed(
db_session: AsyncSession, db_sessionmaker: async_sessionmaker[AsyncSession]
) -> None:
tenant = await create_tenant(db_session)
api_key, _ = await create_api_key(db_session, tenant=tenant)
await db_session.commit()
with structlog.testing.capture_logs() as logs, pytest.raises(InvalidApiKeyError):
await resolve_auth_context(db_sessionmaker, f"sk_{api_key.key_prefix}_wrong-secret")
failed = _events_by_name(logs, "auth.failed")
assert len(failed) == 1
assert failed[0]["reason"] == "unknown_key"
async def test_unknown_prefix_emits_auth_failed(
db_sessionmaker: async_sessionmaker[AsyncSession],
) -> None:
with structlog.testing.capture_logs() as logs, pytest.raises(InvalidApiKeyError):
await resolve_auth_context(db_sessionmaker, "sk_doesnotexist_secret")
failed = _events_by_name(logs, "auth.failed")
assert len(failed) == 1
assert failed[0]["reason"] == "unknown_key"
async def test_revoked_key_emits_auth_failed_with_key_status(
db_session: AsyncSession, db_sessionmaker: async_sessionmaker[AsyncSession]
) -> None:
tenant = await create_tenant(db_session)
_, full_key = await create_api_key(db_session, tenant=tenant, status="revoked")
await db_session.commit()
with structlog.testing.capture_logs() as logs, pytest.raises(InvalidApiKeyError):
await resolve_auth_context(db_sessionmaker, full_key)
failed = _events_by_name(logs, "auth.failed")
assert len(failed) == 1
assert failed[0]["reason"] == "key_inactive"
assert failed[0]["key_status"] == "revoked"
async def test_suspended_tenant_emits_auth_failed(
db_session: AsyncSession, db_sessionmaker: async_sessionmaker[AsyncSession]
) -> None:
tenant = await create_tenant(db_session, status="suspended")
_, full_key = await create_api_key(db_session, tenant=tenant)
await db_session.commit()
with structlog.testing.capture_logs() as logs, pytest.raises(TenantInactiveError):
await resolve_auth_context(db_sessionmaker, full_key)
failed = _events_by_name(logs, "auth.failed")
assert len(failed) == 1
assert failed[0]["reason"] == "tenant_inactive"
assert failed[0]["tenant_id"] == str(tenant.id)
async def test_auth_failed_never_logs_the_secret(
db_session: AsyncSession, db_sessionmaker: async_sessionmaker[AsyncSession]
) -> None:
"""ADR-0011's redaction rule: never log plaintext API keys. `key_prefix`
is the non-secret lookup portion (same distinction `ApiKey.key_prefix`
makes); the secret itself must not appear in any field's value.
"""
tenant = await create_tenant(db_session)
api_key, _ = await create_api_key(db_session, tenant=tenant)
await db_session.commit()
wrong_secret = "definitely-not-the-real-secret"
with structlog.testing.capture_logs() as logs, pytest.raises(InvalidApiKeyError):
await resolve_auth_context(db_sessionmaker, f"sk_{api_key.key_prefix}_{wrong_secret}")
failed = _events_by_name(logs, "auth.failed")
assert len(failed) == 1
assert wrong_secret not in str(failed[0])