feat: The Other Dude v9.0.1 — full-featured email system
ci: add GitHub Pages deployment workflow for docs site Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
92
backend/app/services/audit_service.py
Normal file
92
backend/app/services/audit_service.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""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.
|
||||
"""
|
||||
|
||||
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,
|
||||
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. Swallows all exceptions on failure."""
|
||||
try:
|
||||
import json as _json
|
||||
|
||||
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,
|
||||
)
|
||||
# Keep details_json as-is (plaintext fallback)
|
||||
encrypted_details = None
|
||||
|
||||
await 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,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"audit_log_insert_failed",
|
||||
action=action,
|
||||
tenant_id=str(tenant_id),
|
||||
exc_info=True,
|
||||
)
|
||||
Reference in New Issue
Block a user