Add ruff config to exclude alembic E402, SQLAlchemy F821, and pre-existing E501 line-length issues. Auto-fix 69 unused imports and 2 f-strings without placeholders. Manually fix 8 unused variables. Apply ruff format to 127 files. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
105 lines
4.0 KiB
Python
105 lines
4.0 KiB
Python
"""Centralized audit logging service.
|
|
|
|
Provides a fire-and-forget ``log_action`` coroutine that inserts a row into
|
|
the ``audit_logs`` table. Uses raw SQL INSERT (not ORM) for minimal overhead.
|
|
|
|
The function is wrapped in a try/except so that a logging failure **never**
|
|
breaks the parent operation.
|
|
|
|
Phase 30: When details are non-empty, they are encrypted via OpenBao Transit
|
|
(per-tenant data key) and stored in encrypted_details. The plaintext details
|
|
column is set to '{}' for column compatibility. If Transit encryption fails
|
|
(e.g., OpenBao unavailable), details are stored in plaintext as a fallback.
|
|
|
|
IMPORTANT: log_action always opens its own AdminAsyncSessionLocal session and
|
|
commits internally. This guarantees the audit INSERT is persisted regardless of
|
|
whether the caller's DB session has already been committed or rolled back. The
|
|
``db`` parameter is kept for backward compatibility but is intentionally unused.
|
|
"""
|
|
|
|
import uuid
|
|
from typing import Any, Optional
|
|
|
|
import structlog
|
|
from sqlalchemy import text
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
logger = structlog.get_logger("audit")
|
|
|
|
|
|
async def log_action(
|
|
db: AsyncSession, # kept for backward compat — not used internally
|
|
tenant_id: uuid.UUID,
|
|
user_id: uuid.UUID,
|
|
action: str,
|
|
resource_type: Optional[str] = None,
|
|
resource_id: Optional[str] = None,
|
|
device_id: Optional[uuid.UUID] = None,
|
|
details: Optional[dict[str, Any]] = None,
|
|
ip_address: Optional[str] = None,
|
|
) -> None:
|
|
"""Insert a row into audit_logs using a dedicated session.
|
|
|
|
Always self-commits so the INSERT is never dependent on the caller's
|
|
transaction lifecycle. Swallows all exceptions on failure.
|
|
"""
|
|
try:
|
|
import json as _json
|
|
from app.database import AdminAsyncSessionLocal
|
|
|
|
details_dict = details or {}
|
|
details_json = _json.dumps(details_dict)
|
|
encrypted_details: Optional[str] = None
|
|
|
|
# Attempt Transit encryption for non-empty details
|
|
if details_dict:
|
|
try:
|
|
from app.services.crypto import encrypt_data_transit
|
|
|
|
encrypted_details = await encrypt_data_transit(details_json, str(tenant_id))
|
|
# Encryption succeeded — clear plaintext details
|
|
details_json = _json.dumps({})
|
|
except Exception:
|
|
# Transit unavailable — fall back to plaintext details
|
|
logger.warning(
|
|
"audit_transit_encryption_failed",
|
|
action=action,
|
|
tenant_id=str(tenant_id),
|
|
exc_info=True,
|
|
)
|
|
encrypted_details = None
|
|
|
|
# Use a dedicated session so this commit is independent of the
|
|
# caller's transaction (fixes dropped audit logs in routers that
|
|
# call log_action after their own db.commit()).
|
|
async with AdminAsyncSessionLocal() as audit_db:
|
|
await audit_db.execute(
|
|
text(
|
|
"INSERT INTO audit_logs "
|
|
"(tenant_id, user_id, action, resource_type, resource_id, "
|
|
"device_id, details, encrypted_details, ip_address) "
|
|
"VALUES (:tenant_id, :user_id, :action, :resource_type, "
|
|
":resource_id, :device_id, CAST(:details AS jsonb), "
|
|
":encrypted_details, :ip_address)"
|
|
),
|
|
{
|
|
"tenant_id": str(tenant_id),
|
|
"user_id": str(user_id),
|
|
"action": action,
|
|
"resource_type": resource_type,
|
|
"resource_id": resource_id,
|
|
"device_id": str(device_id) if device_id else None,
|
|
"details": details_json,
|
|
"encrypted_details": encrypted_details,
|
|
"ip_address": ip_address,
|
|
},
|
|
)
|
|
await audit_db.commit()
|
|
except Exception:
|
|
logger.warning(
|
|
"audit_log_insert_failed",
|
|
action=action,
|
|
tenant_id=str(tenant_id),
|
|
exc_info=True,
|
|
)
|