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>
392 lines
12 KiB
Python
392 lines
12 KiB
Python
"""Transparency log API endpoints.
|
|
|
|
Tenant-scoped routes under /api/tenants/{tenant_id}/ for:
|
|
- Paginated, filterable key access transparency log listing
|
|
- Transparency log statistics (total events, last 24h, unique devices, justification breakdown)
|
|
- CSV export of transparency logs
|
|
|
|
RLS enforced via get_db() (app_user engine with tenant context).
|
|
RBAC: admin and above can view transparency logs (tenant_admin or super_admin).
|
|
|
|
Phase 31: Data Access Transparency Dashboard - TRUST-01, TRUST-02
|
|
Shows tenant admins every KMS credential access event for their tenant.
|
|
"""
|
|
|
|
import csv
|
|
import io
|
|
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=["transparency"])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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_admin(current_user: CurrentUser) -> None:
|
|
"""Raise 403 if user does not have at least admin role.
|
|
|
|
Transparency data is sensitive operational intelligence --
|
|
only tenant_admin and super_admin can view it.
|
|
"""
|
|
allowed = {"super_admin", "admin", "tenant_admin"}
|
|
if current_user.role not in allowed:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="At least admin role required to view transparency logs.",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Response models
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TransparencyLogItem(BaseModel):
|
|
id: str
|
|
action: str
|
|
device_name: Optional[str] = None
|
|
device_id: Optional[str] = None
|
|
justification: Optional[str] = None
|
|
operator_email: Optional[str] = None
|
|
correlation_id: Optional[str] = None
|
|
resource_type: Optional[str] = None
|
|
resource_id: Optional[str] = None
|
|
ip_address: Optional[str] = None
|
|
created_at: str
|
|
|
|
|
|
class TransparencyLogResponse(BaseModel):
|
|
items: list[TransparencyLogItem]
|
|
total: int
|
|
page: int
|
|
per_page: int
|
|
|
|
|
|
class TransparencyStats(BaseModel):
|
|
total_events: int
|
|
events_last_24h: int
|
|
unique_devices: int
|
|
justification_breakdown: dict[str, int]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Endpoints
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get(
|
|
"/tenants/{tenant_id}/transparency-logs",
|
|
response_model=TransparencyLogResponse,
|
|
summary="List KMS credential access events for tenant",
|
|
)
|
|
async def list_transparency_logs(
|
|
tenant_id: uuid.UUID,
|
|
page: int = Query(default=1, ge=1),
|
|
per_page: int = Query(default=50, ge=1, le=100),
|
|
device_id: Optional[uuid.UUID] = Query(default=None),
|
|
justification: Optional[str] = Query(default=None),
|
|
action: Optional[str] = Query(default=None),
|
|
date_from: Optional[datetime] = Query(default=None),
|
|
date_to: Optional[datetime] = Query(default=None),
|
|
current_user: CurrentUser = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> Any:
|
|
_require_admin(current_user)
|
|
await _check_tenant_access(current_user, tenant_id, db)
|
|
|
|
# Build filter conditions using parameterized text fragments
|
|
conditions = [text("k.tenant_id = :tenant_id")]
|
|
params: dict[str, Any] = {"tenant_id": str(tenant_id)}
|
|
|
|
if device_id:
|
|
conditions.append(text("k.device_id = :device_id"))
|
|
params["device_id"] = str(device_id)
|
|
|
|
if justification:
|
|
conditions.append(text("k.justification = :justification"))
|
|
params["justification"] = justification
|
|
|
|
if action:
|
|
conditions.append(text("k.action = :action"))
|
|
params["action"] = action
|
|
|
|
if date_from:
|
|
conditions.append(text("k.created_at >= :date_from"))
|
|
params["date_from"] = date_from.isoformat()
|
|
|
|
if date_to:
|
|
conditions.append(text("k.created_at <= :date_to"))
|
|
params["date_to"] = date_to.isoformat()
|
|
|
|
where_clause = and_(*conditions)
|
|
|
|
# Shared SELECT columns for data queries
|
|
_data_columns = text(
|
|
"k.id, k.action, d.hostname AS device_name, "
|
|
"k.device_id, k.justification, u.email AS operator_email, "
|
|
"k.correlation_id, k.resource_type, k.resource_id, "
|
|
"k.ip_address, k.created_at"
|
|
)
|
|
_data_from = text(
|
|
"key_access_log k "
|
|
"LEFT JOIN users u ON k.user_id = u.id "
|
|
"LEFT JOIN devices d ON k.device_id = d.id"
|
|
)
|
|
|
|
# Count total
|
|
count_result = await db.execute(
|
|
select(func.count()).select_from(text("key_access_log k")).where(where_clause),
|
|
params,
|
|
)
|
|
total = count_result.scalar() or 0
|
|
|
|
# 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("k.created_at DESC"))
|
|
.limit(per_page)
|
|
.offset(offset),
|
|
params,
|
|
)
|
|
rows = result.mappings().all()
|
|
|
|
items = [
|
|
TransparencyLogItem(
|
|
id=str(row["id"]),
|
|
action=row["action"],
|
|
device_name=row["device_name"],
|
|
device_id=str(row["device_id"]) if row["device_id"] else None,
|
|
justification=row["justification"],
|
|
operator_email=row["operator_email"],
|
|
correlation_id=row["correlation_id"],
|
|
resource_type=row["resource_type"],
|
|
resource_id=row["resource_id"],
|
|
ip_address=row["ip_address"],
|
|
created_at=row["created_at"].isoformat() if row["created_at"] else "",
|
|
)
|
|
for row in rows
|
|
]
|
|
|
|
return TransparencyLogResponse(
|
|
items=items,
|
|
total=total,
|
|
page=page,
|
|
per_page=per_page,
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/tenants/{tenant_id}/transparency-logs/stats",
|
|
response_model=TransparencyStats,
|
|
summary="Get transparency log statistics",
|
|
)
|
|
async def get_transparency_stats(
|
|
tenant_id: uuid.UUID,
|
|
current_user: CurrentUser = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> TransparencyStats:
|
|
_require_admin(current_user)
|
|
await _check_tenant_access(current_user, tenant_id, db)
|
|
|
|
params: dict[str, Any] = {"tenant_id": str(tenant_id)}
|
|
|
|
# Total events
|
|
total_result = await db.execute(
|
|
select(func.count())
|
|
.select_from(text("key_access_log"))
|
|
.where(text("tenant_id = :tenant_id")),
|
|
params,
|
|
)
|
|
total_events = total_result.scalar() or 0
|
|
|
|
# Events in last 24 hours
|
|
last_24h_result = await db.execute(
|
|
select(func.count())
|
|
.select_from(text("key_access_log"))
|
|
.where(
|
|
and_(
|
|
text("tenant_id = :tenant_id"),
|
|
text("created_at >= NOW() - INTERVAL '24 hours'"),
|
|
)
|
|
),
|
|
params,
|
|
)
|
|
events_last_24h = last_24h_result.scalar() or 0
|
|
|
|
# Unique devices
|
|
unique_devices_result = await db.execute(
|
|
select(func.count(text("DISTINCT device_id")))
|
|
.select_from(text("key_access_log"))
|
|
.where(
|
|
and_(
|
|
text("tenant_id = :tenant_id"),
|
|
text("device_id IS NOT NULL"),
|
|
)
|
|
),
|
|
params,
|
|
)
|
|
unique_devices = unique_devices_result.scalar() or 0
|
|
|
|
# Justification breakdown
|
|
breakdown_result = await db.execute(
|
|
select(
|
|
text("COALESCE(justification, 'system') AS justification_label"),
|
|
func.count().label("count"),
|
|
)
|
|
.select_from(text("key_access_log"))
|
|
.where(text("tenant_id = :tenant_id"))
|
|
.group_by(text("justification_label")),
|
|
params,
|
|
)
|
|
justification_breakdown: dict[str, int] = {}
|
|
for row in breakdown_result.mappings().all():
|
|
justification_breakdown[row["justification_label"]] = row["count"]
|
|
|
|
return TransparencyStats(
|
|
total_events=total_events,
|
|
events_last_24h=events_last_24h,
|
|
unique_devices=unique_devices,
|
|
justification_breakdown=justification_breakdown,
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/tenants/{tenant_id}/transparency-logs/export",
|
|
summary="Export transparency logs as CSV",
|
|
)
|
|
async def export_transparency_logs(
|
|
tenant_id: uuid.UUID,
|
|
device_id: Optional[uuid.UUID] = Query(default=None),
|
|
justification: Optional[str] = Query(default=None),
|
|
action: Optional[str] = Query(default=None),
|
|
date_from: Optional[datetime] = Query(default=None),
|
|
date_to: Optional[datetime] = Query(default=None),
|
|
current_user: CurrentUser = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> StreamingResponse:
|
|
_require_admin(current_user)
|
|
await _check_tenant_access(current_user, tenant_id, db)
|
|
|
|
# Build filter conditions
|
|
conditions = [text("k.tenant_id = :tenant_id")]
|
|
params: dict[str, Any] = {"tenant_id": str(tenant_id)}
|
|
|
|
if device_id:
|
|
conditions.append(text("k.device_id = :device_id"))
|
|
params["device_id"] = str(device_id)
|
|
|
|
if justification:
|
|
conditions.append(text("k.justification = :justification"))
|
|
params["justification"] = justification
|
|
|
|
if action:
|
|
conditions.append(text("k.action = :action"))
|
|
params["action"] = action
|
|
|
|
if date_from:
|
|
conditions.append(text("k.created_at >= :date_from"))
|
|
params["date_from"] = date_from.isoformat()
|
|
|
|
if date_to:
|
|
conditions.append(text("k.created_at <= :date_to"))
|
|
params["date_to"] = date_to.isoformat()
|
|
|
|
where_clause = and_(*conditions)
|
|
|
|
_data_columns = text(
|
|
"k.id, k.action, d.hostname AS device_name, "
|
|
"k.device_id, k.justification, u.email AS operator_email, "
|
|
"k.correlation_id, k.resource_type, k.resource_id, "
|
|
"k.ip_address, k.created_at"
|
|
)
|
|
_data_from = text(
|
|
"key_access_log k "
|
|
"LEFT JOIN users u ON k.user_id = u.id "
|
|
"LEFT JOIN devices d ON k.device_id = d.id"
|
|
)
|
|
|
|
result = await db.execute(
|
|
select(_data_columns)
|
|
.select_from(_data_from)
|
|
.where(where_clause)
|
|
.order_by(text("k.created_at DESC")),
|
|
params,
|
|
)
|
|
all_rows = result.mappings().all()
|
|
|
|
output = io.StringIO()
|
|
writer = csv.writer(output)
|
|
writer.writerow(
|
|
[
|
|
"ID",
|
|
"Action",
|
|
"Device Name",
|
|
"Device ID",
|
|
"Justification",
|
|
"Operator Email",
|
|
"Correlation ID",
|
|
"Resource Type",
|
|
"Resource ID",
|
|
"IP Address",
|
|
"Timestamp",
|
|
]
|
|
)
|
|
for row in all_rows:
|
|
writer.writerow(
|
|
[
|
|
str(row["id"]),
|
|
row["action"],
|
|
row["device_name"] or "",
|
|
str(row["device_id"]) if row["device_id"] else "",
|
|
row["justification"] or "",
|
|
row["operator_email"] or "",
|
|
row["correlation_id"] or "",
|
|
row["resource_type"] or "",
|
|
row["resource_id"] or "",
|
|
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=transparency-logs.csv"},
|
|
)
|