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.
135 lines
5.0 KiB
Python
135 lines
5.0 KiB
Python
"""`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])
|