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:
294
backend/app/routers/audit_logs.py
Normal file
294
backend/app/routers/audit_logs.py
Normal file
@@ -0,0 +1,294 @@
|
||||
"""Audit log API endpoints.
|
||||
|
||||
Tenant-scoped routes under /api/tenants/{tenant_id}/ for:
|
||||
- Paginated, filterable audit log listing
|
||||
- CSV export of audit logs
|
||||
|
||||
RLS enforced via get_db() (app_user engine with tenant context).
|
||||
RBAC: operator and above can view audit logs.
|
||||
|
||||
Phase 30: Audit log details are encrypted at rest via Transit (Tier 2).
|
||||
When encrypted_details is set, the router decrypts via Transit on-demand
|
||||
and returns the plaintext details in the response. Structural fields
|
||||
(action, resource_type, timestamp, ip_address) are always plaintext.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import and_, func, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db, set_tenant_context
|
||||
from app.middleware.tenant_context import CurrentUser, get_current_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["audit-logs"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _check_tenant_access(
|
||||
current_user: CurrentUser, tenant_id: uuid.UUID, db: AsyncSession
|
||||
) -> None:
|
||||
"""Verify the current user is allowed to access the given tenant."""
|
||||
if current_user.is_super_admin:
|
||||
await set_tenant_context(db, str(tenant_id))
|
||||
elif current_user.tenant_id != tenant_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to this tenant",
|
||||
)
|
||||
|
||||
|
||||
def _require_operator(current_user: CurrentUser) -> None:
|
||||
"""Raise 403 if user does not have at least operator role."""
|
||||
allowed = {"super_admin", "admin", "operator"}
|
||||
if current_user.role not in allowed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="At least operator role required to view audit logs.",
|
||||
)
|
||||
|
||||
|
||||
async def _decrypt_audit_details(
|
||||
encrypted_details: str | None,
|
||||
plaintext_details: dict[str, Any] | None,
|
||||
tenant_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Decrypt encrypted audit log details via Transit, falling back to plaintext.
|
||||
|
||||
Priority:
|
||||
1. If encrypted_details is set, decrypt via Transit and parse as JSON.
|
||||
2. If decryption fails, return plaintext details as fallback.
|
||||
3. If neither available, return empty dict.
|
||||
"""
|
||||
if encrypted_details:
|
||||
try:
|
||||
from app.services.crypto import decrypt_data_transit
|
||||
|
||||
decrypted_json = await decrypt_data_transit(encrypted_details, tenant_id)
|
||||
return json.loads(decrypted_json)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to decrypt audit details for tenant %s, using plaintext fallback",
|
||||
tenant_id,
|
||||
exc_info=True,
|
||||
)
|
||||
# Fall through to plaintext
|
||||
return plaintext_details if plaintext_details else {}
|
||||
|
||||
|
||||
async def _decrypt_details_batch(
|
||||
rows: list[Any],
|
||||
tenant_id: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Decrypt encrypted_details for a batch of audit log rows concurrently.
|
||||
|
||||
Uses asyncio.gather with limited concurrency to avoid overwhelming OpenBao.
|
||||
Rows without encrypted_details return their plaintext details directly.
|
||||
"""
|
||||
semaphore = asyncio.Semaphore(10) # Limit concurrent Transit calls
|
||||
|
||||
async def _decrypt_one(row: Any) -> dict[str, Any]:
|
||||
async with semaphore:
|
||||
return await _decrypt_audit_details(
|
||||
row.get("encrypted_details"),
|
||||
row.get("details"),
|
||||
tenant_id,
|
||||
)
|
||||
|
||||
return list(await asyncio.gather(*[_decrypt_one(row) for row in rows]))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Response models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AuditLogItem(BaseModel):
|
||||
id: str
|
||||
user_email: Optional[str] = None
|
||||
action: str
|
||||
resource_type: Optional[str] = None
|
||||
resource_id: Optional[str] = None
|
||||
device_name: Optional[str] = None
|
||||
details: dict[str, Any] = {}
|
||||
ip_address: Optional[str] = None
|
||||
created_at: str
|
||||
|
||||
|
||||
class AuditLogResponse(BaseModel):
|
||||
items: list[AuditLogItem]
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/tenants/{tenant_id}/audit-logs",
|
||||
response_model=AuditLogResponse,
|
||||
summary="List audit logs with pagination and filters",
|
||||
)
|
||||
async def list_audit_logs(
|
||||
tenant_id: uuid.UUID,
|
||||
page: int = Query(default=1, ge=1),
|
||||
per_page: int = Query(default=50, ge=1, le=100),
|
||||
action: Optional[str] = Query(default=None),
|
||||
user_id: Optional[uuid.UUID] = Query(default=None),
|
||||
device_id: Optional[uuid.UUID] = Query(default=None),
|
||||
date_from: Optional[datetime] = Query(default=None),
|
||||
date_to: Optional[datetime] = Query(default=None),
|
||||
format: Optional[str] = Query(default=None, description="Set to 'csv' for CSV export"),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Any:
|
||||
_require_operator(current_user)
|
||||
await _check_tenant_access(current_user, tenant_id, db)
|
||||
|
||||
# Build filter conditions using parameterized text fragments
|
||||
conditions = [text("a.tenant_id = :tenant_id")]
|
||||
params: dict[str, Any] = {"tenant_id": str(tenant_id)}
|
||||
|
||||
if action:
|
||||
conditions.append(text("a.action = :action"))
|
||||
params["action"] = action
|
||||
|
||||
if user_id:
|
||||
conditions.append(text("a.user_id = :user_id"))
|
||||
params["user_id"] = str(user_id)
|
||||
|
||||
if device_id:
|
||||
conditions.append(text("a.device_id = :device_id"))
|
||||
params["device_id"] = str(device_id)
|
||||
|
||||
if date_from:
|
||||
conditions.append(text("a.created_at >= :date_from"))
|
||||
params["date_from"] = date_from.isoformat()
|
||||
|
||||
if date_to:
|
||||
conditions.append(text("a.created_at <= :date_to"))
|
||||
params["date_to"] = date_to.isoformat()
|
||||
|
||||
where_clause = and_(*conditions)
|
||||
|
||||
# Shared SELECT columns for data queries
|
||||
_data_columns = text(
|
||||
"a.id, u.email AS user_email, a.action, a.resource_type, "
|
||||
"a.resource_id, d.hostname AS device_name, a.details, "
|
||||
"a.encrypted_details, a.ip_address, a.created_at"
|
||||
)
|
||||
_data_from = text(
|
||||
"audit_logs a "
|
||||
"LEFT JOIN users u ON a.user_id = u.id "
|
||||
"LEFT JOIN devices d ON a.device_id = d.id"
|
||||
)
|
||||
|
||||
# Count total
|
||||
count_result = await db.execute(
|
||||
select(func.count()).select_from(text("audit_logs a")).where(where_clause),
|
||||
params,
|
||||
)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
# CSV export -- no pagination limit
|
||||
if format == "csv":
|
||||
result = await db.execute(
|
||||
select(_data_columns)
|
||||
.select_from(_data_from)
|
||||
.where(where_clause)
|
||||
.order_by(text("a.created_at DESC")),
|
||||
params,
|
||||
)
|
||||
all_rows = result.mappings().all()
|
||||
|
||||
# Decrypt encrypted details concurrently
|
||||
decrypted_details = await _decrypt_details_batch(
|
||||
all_rows, str(tenant_id)
|
||||
)
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow([
|
||||
"ID", "User Email", "Action", "Resource Type",
|
||||
"Resource ID", "Device", "Details", "IP Address", "Timestamp",
|
||||
])
|
||||
for row, details in zip(all_rows, decrypted_details):
|
||||
details_str = json.dumps(details) if details else "{}"
|
||||
writer.writerow([
|
||||
str(row["id"]),
|
||||
row["user_email"] or "",
|
||||
row["action"],
|
||||
row["resource_type"] or "",
|
||||
row["resource_id"] or "",
|
||||
row["device_name"] or "",
|
||||
details_str,
|
||||
row["ip_address"] or "",
|
||||
str(row["created_at"]),
|
||||
])
|
||||
|
||||
output.seek(0)
|
||||
return StreamingResponse(
|
||||
iter([output.getvalue()]),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=audit-logs.csv"},
|
||||
)
|
||||
|
||||
# Paginated query
|
||||
offset = (page - 1) * per_page
|
||||
params["limit"] = per_page
|
||||
params["offset"] = offset
|
||||
|
||||
result = await db.execute(
|
||||
select(_data_columns)
|
||||
.select_from(_data_from)
|
||||
.where(where_clause)
|
||||
.order_by(text("a.created_at DESC"))
|
||||
.limit(per_page)
|
||||
.offset(offset),
|
||||
params,
|
||||
)
|
||||
rows = result.mappings().all()
|
||||
|
||||
# Decrypt encrypted details concurrently (skips rows without encrypted_details)
|
||||
decrypted_details = await _decrypt_details_batch(rows, str(tenant_id))
|
||||
|
||||
items = [
|
||||
AuditLogItem(
|
||||
id=str(row["id"]),
|
||||
user_email=row["user_email"],
|
||||
action=row["action"],
|
||||
resource_type=row["resource_type"],
|
||||
resource_id=row["resource_id"],
|
||||
device_name=row["device_name"],
|
||||
details=details,
|
||||
ip_address=row["ip_address"],
|
||||
created_at=row["created_at"].isoformat() if row["created_at"] else "",
|
||||
)
|
||||
for row, details in zip(rows, decrypted_details)
|
||||
]
|
||||
|
||||
return AuditLogResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
)
|
||||
Reference in New Issue
Block a user