751 lines
25 KiB
Python
751 lines
25 KiB
Python
from datetime import datetime, timedelta
|
|
|
|
from bson import ObjectId
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
|
from motor.motor_asyncio import AsyncIOMotorDatabase
|
|
|
|
from ...core.database import get_database
|
|
from ...core.dependencies import require_roles
|
|
from ...core.logging import get_logger
|
|
from ...core.security import get_password_hash
|
|
from ...models.audit_log import AuditAction, AuditLogQuery, AuditLogResponse
|
|
from ...models.user import User, UserRole
|
|
from ...schemas.auth import (
|
|
AdminStatsResponse,
|
|
CreateUserRequest,
|
|
ResetPasswordRequest,
|
|
UpdateUserRequest,
|
|
UserListResponse,
|
|
UserResponse,
|
|
)
|
|
from ...services.audit_logger import (
|
|
audit_logger,
|
|
log_user_management,
|
|
)
|
|
from ...telemetry import app_metrics
|
|
|
|
logger = get_logger(__name__)
|
|
router = APIRouter(prefix="/admin", tags=["admin"])
|
|
|
|
|
|
@router.get("/users", response_model=UserListResponse)
|
|
async def list_users(
|
|
page: int = Query(1, ge=1),
|
|
size: int = Query(20, ge=1, le=500),
|
|
role: str | None = Query(None),
|
|
active_only: bool = Query(True),
|
|
current_user: User = Depends(require_roles(UserRole.ADMIN)),
|
|
db: AsyncIOMotorDatabase = Depends(get_database),
|
|
):
|
|
"""List users with filtering and pagination (admin only)"""
|
|
query = {}
|
|
|
|
if role:
|
|
query["role"] = role
|
|
|
|
if active_only:
|
|
query["is_active"] = True
|
|
|
|
# Get total count
|
|
total = await db.users.count_documents(query)
|
|
|
|
# Get paginated results
|
|
skip = (page - 1) * size
|
|
cursor = db.users.find(query, {"hashed_password": 0}).sort("created_at", -1).skip(skip).limit(size)
|
|
users = await cursor.to_list(length=size)
|
|
|
|
user_responses = []
|
|
for user_doc in users:
|
|
user_responses.append(UserResponse(
|
|
id=str(user_doc["_id"]),
|
|
email=user_doc["email"],
|
|
full_name=user_doc["full_name"],
|
|
role=user_doc["role"],
|
|
auth_provider=user_doc.get("auth_provider", "local"),
|
|
is_active=user_doc["is_active"],
|
|
created_at=user_doc.get("created_at", datetime.utcnow()).isoformat(),
|
|
pm_client_ids=user_doc.get("pm_client_ids", []),
|
|
languages=user_doc.get("languages", []),
|
|
))
|
|
|
|
return UserListResponse(
|
|
users=user_responses,
|
|
total=total,
|
|
page=page,
|
|
size=size
|
|
)
|
|
|
|
|
|
@router.get("/users/{user_id}", response_model=UserResponse)
|
|
async def get_user(
|
|
user_id: str,
|
|
current_user: User = Depends(require_roles(UserRole.ADMIN)),
|
|
db: AsyncIOMotorDatabase = Depends(get_database),
|
|
):
|
|
"""Get user details by ID (admin only)"""
|
|
user_doc = await db.users.find_one({"_id": user_id}, {"hashed_password": 0})
|
|
if not user_doc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="User not found"
|
|
)
|
|
|
|
return UserResponse(
|
|
id=str(user_doc["_id"]),
|
|
email=user_doc["email"],
|
|
full_name=user_doc["full_name"],
|
|
role=user_doc["role"],
|
|
auth_provider=user_doc.get("auth_provider", "local"),
|
|
is_active=user_doc["is_active"],
|
|
created_at=user_doc.get("created_at", datetime.utcnow()).isoformat(),
|
|
pm_client_ids=user_doc.get("pm_client_ids", []),
|
|
languages=user_doc.get("languages", []),
|
|
)
|
|
|
|
|
|
@router.post("/users", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
|
async def create_user(
|
|
user_data: CreateUserRequest,
|
|
request: Request,
|
|
current_user: User = Depends(require_roles(UserRole.ADMIN)),
|
|
db: AsyncIOMotorDatabase = Depends(get_database),
|
|
):
|
|
"""Create a new user (admin only)"""
|
|
# Check if user already exists
|
|
existing_user = await db.users.find_one({"email": user_data.email})
|
|
if existing_user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="User with this email already exists"
|
|
)
|
|
|
|
# Create user document
|
|
user_id = str(ObjectId())
|
|
user_doc = {
|
|
"_id": user_id,
|
|
"email": user_data.email,
|
|
"hashed_password": get_password_hash(user_data.password),
|
|
"full_name": user_data.full_name,
|
|
"role": user_data.role.value,
|
|
"auth_provider": "local",
|
|
"is_active": True,
|
|
"created_at": datetime.utcnow(),
|
|
"updated_at": datetime.utcnow()
|
|
}
|
|
|
|
await db.users.insert_one(user_doc)
|
|
|
|
# Record metrics
|
|
app_metrics.record_auth_attempt("user_created", user_data.role.value)
|
|
|
|
logger.info(f"Admin {current_user.id} created user {user_id} with role {user_data.role.value}")
|
|
await log_user_management(
|
|
AuditAction.USER_CREATE, user_id, current_user, request,
|
|
details={"email": user_data.email, "role": user_data.role.value},
|
|
)
|
|
|
|
return UserResponse(
|
|
id=user_id,
|
|
email=user_data.email,
|
|
full_name=user_data.full_name,
|
|
role=user_data.role,
|
|
auth_provider="local",
|
|
is_active=True,
|
|
created_at=user_doc["created_at"].isoformat(),
|
|
pm_client_ids=[],
|
|
languages=[],
|
|
)
|
|
|
|
|
|
@router.patch("/users/{user_id}", response_model=UserResponse)
|
|
async def update_user(
|
|
user_id: str,
|
|
user_update: UpdateUserRequest,
|
|
request: Request,
|
|
current_user: User = Depends(require_roles(UserRole.ADMIN)),
|
|
db: AsyncIOMotorDatabase = Depends(get_database),
|
|
):
|
|
"""Update user details (admin only)"""
|
|
# Check if user exists
|
|
user_doc = await db.users.find_one({"_id": user_id})
|
|
if not user_doc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="User not found"
|
|
)
|
|
|
|
# Check if email is being changed and doesn't conflict
|
|
if user_update.email and user_update.email != user_doc["email"]:
|
|
existing_user = await db.users.find_one({"email": user_update.email, "_id": {"$ne": user_id}})
|
|
if existing_user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Email already in use by another user"
|
|
)
|
|
|
|
# Build update document
|
|
update_data = {"updated_at": datetime.utcnow()}
|
|
|
|
if user_update.email:
|
|
update_data["email"] = user_update.email
|
|
if user_update.full_name:
|
|
update_data["full_name"] = user_update.full_name
|
|
if user_update.role:
|
|
update_data["role"] = user_update.role.value
|
|
if user_update.is_active is not None:
|
|
update_data["is_active"] = user_update.is_active
|
|
|
|
# Update user
|
|
result = await db.users.find_one_and_update(
|
|
{"_id": user_id},
|
|
{"$set": update_data},
|
|
return_document=True
|
|
)
|
|
|
|
logger.info(f"Admin {current_user.id} updated user {user_id}")
|
|
action = AuditAction.USER_ROLE_CHANGE if user_update.role else AuditAction.USER_UPDATE
|
|
await log_user_management(
|
|
action, user_id, current_user, request,
|
|
details={k: v for k, v in user_update.dict(exclude_none=True).items()},
|
|
)
|
|
|
|
return UserResponse(
|
|
id=str(result["_id"]),
|
|
email=result["email"],
|
|
full_name=result["full_name"],
|
|
role=result["role"],
|
|
auth_provider=result.get("auth_provider", "local"),
|
|
is_active=result["is_active"],
|
|
created_at=result.get("created_at", datetime.utcnow()).isoformat(),
|
|
pm_client_ids=result.get("pm_client_ids", []),
|
|
languages=result.get("languages", []),
|
|
)
|
|
|
|
|
|
@router.delete("/users/{user_id}")
|
|
async def deactivate_user(
|
|
user_id: str,
|
|
request: Request,
|
|
current_user: User = Depends(require_roles(UserRole.ADMIN)),
|
|
db: AsyncIOMotorDatabase = Depends(get_database),
|
|
):
|
|
"""Deactivate user account (admin only) - soft delete"""
|
|
if str(current_user.id) == user_id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Cannot deactivate your own account"
|
|
)
|
|
|
|
result = await db.users.update_one(
|
|
{"_id": user_id},
|
|
{
|
|
"$set": {
|
|
"is_active": False,
|
|
"updated_at": datetime.utcnow()
|
|
}
|
|
}
|
|
)
|
|
|
|
if result.matched_count == 0:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="User not found"
|
|
)
|
|
|
|
logger.info(f"Admin {current_user.id} deactivated user {user_id}")
|
|
await log_user_management(AuditAction.USER_DEACTIVATE, user_id, current_user, request)
|
|
|
|
return {"message": "User deactivated successfully"}
|
|
|
|
|
|
@router.post("/users/{user_id}/reset-password")
|
|
async def admin_reset_password(
|
|
user_id: str,
|
|
reset_request: ResetPasswordRequest,
|
|
current_user: User = Depends(require_roles(UserRole.ADMIN)),
|
|
db: AsyncIOMotorDatabase = Depends(get_database),
|
|
):
|
|
"""Reset user password (admin only)"""
|
|
# Generate temporary password
|
|
import secrets
|
|
import string
|
|
|
|
temp_password = ''.join(secrets.choice(string.ascii_letters + string.digits) for _ in range(12))
|
|
hashed_password = get_password_hash(temp_password)
|
|
|
|
result = await db.users.update_one(
|
|
{"_id": user_id},
|
|
{
|
|
"$set": {
|
|
"hashed_password": hashed_password,
|
|
"updated_at": datetime.utcnow()
|
|
}
|
|
}
|
|
)
|
|
|
|
if result.matched_count == 0:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="User not found"
|
|
)
|
|
|
|
logger.info(f"Admin {current_user.id} reset password for user {user_id}")
|
|
|
|
# In production, send email with temp password instead of returning it
|
|
return {
|
|
"message": "Password reset successfully",
|
|
"temporary_password": temp_password # Remove this in production, send via email
|
|
}
|
|
|
|
|
|
@router.get("/stats", response_model=AdminStatsResponse)
|
|
async def get_admin_stats(
|
|
current_user: User = Depends(require_roles(UserRole.PRODUCTION, UserRole.ADMIN)),
|
|
db: AsyncIOMotorDatabase = Depends(get_database),
|
|
):
|
|
"""Get system statistics (production/admin only)"""
|
|
# Get user count
|
|
total_users = await db.users.count_documents({"is_active": True})
|
|
|
|
# Get job counts
|
|
total_jobs = await db.jobs.count_documents({})
|
|
|
|
# Get jobs by status
|
|
pipeline = [
|
|
{"$group": {"_id": "$status", "count": {"$sum": 1}}}
|
|
]
|
|
status_counts = await db.jobs.aggregate(pipeline).to_list(None)
|
|
jobs_by_status = {item["_id"]: item["count"] for item in status_counts}
|
|
|
|
# Get jobs created today
|
|
today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
|
|
active_jobs_today = await db.jobs.count_documents({
|
|
"created_at": {"$gte": today_start}
|
|
})
|
|
|
|
# Calculate average processing time for completed jobs
|
|
avg_processing_pipeline = [
|
|
{"$match": {"status": "completed", "created_at": {"$exists": True}, "updated_at": {"$exists": True}}},
|
|
{
|
|
"$project": {
|
|
"processing_time_hours": {
|
|
"$divide": [
|
|
{"$subtract": ["$updated_at", "$created_at"]},
|
|
3600000 # Convert milliseconds to hours
|
|
]
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"$group": {
|
|
"_id": None,
|
|
"avg_processing_time": {"$avg": "$processing_time_hours"}
|
|
}
|
|
}
|
|
]
|
|
|
|
avg_result = await db.jobs.aggregate(avg_processing_pipeline).to_list(None)
|
|
avg_processing_time = avg_result[0]["avg_processing_time"] if avg_result else 0.0
|
|
|
|
return AdminStatsResponse(
|
|
total_users=total_users,
|
|
total_jobs=total_jobs,
|
|
jobs_by_status=jobs_by_status,
|
|
active_jobs_today=active_jobs_today,
|
|
avg_processing_time_hours=round(avg_processing_time, 2)
|
|
)
|
|
|
|
|
|
@router.get("/health/detailed")
|
|
async def detailed_health_check(
|
|
current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.PRODUCTION, UserRole.ADMIN)),
|
|
db: AsyncIOMotorDatabase = Depends(get_database),
|
|
):
|
|
"""Detailed health check with system component status (reviewer/production/admin only)"""
|
|
health_status = {
|
|
"status": "healthy",
|
|
"timestamp": datetime.utcnow().isoformat(),
|
|
"components": {}
|
|
}
|
|
|
|
# Check MongoDB
|
|
try:
|
|
await db.command("ping")
|
|
health_status["components"]["mongodb"] = {"status": "healthy"}
|
|
except Exception as e:
|
|
health_status["components"]["mongodb"] = {"status": "unhealthy", "error": str(e)}
|
|
health_status["status"] = "degraded"
|
|
|
|
# Check Redis (via import to avoid circular dependency)
|
|
try:
|
|
from ...core.redis import redis_client
|
|
if redis_client:
|
|
await redis_client.ping()
|
|
health_status["components"]["redis"] = {"status": "healthy"}
|
|
else:
|
|
health_status["components"]["redis"] = {"status": "not_configured"}
|
|
except Exception as e:
|
|
health_status["components"]["redis"] = {"status": "unhealthy", "error": str(e)}
|
|
health_status["status"] = "degraded"
|
|
|
|
# Check GCS (basic check)
|
|
try:
|
|
from ...services.gcs import gcs_service
|
|
# Simple check to see if bucket is accessible
|
|
bucket_exists = await gcs_service.file_exists("health_check_dummy") # This will return False but won't error if bucket accessible
|
|
health_status["components"]["gcs"] = {"status": "healthy"}
|
|
except Exception as e:
|
|
health_status["components"]["gcs"] = {"status": "unhealthy", "error": str(e)}
|
|
health_status["status"] = "degraded"
|
|
|
|
# Check job queue health
|
|
try:
|
|
from ...tasks import celery_app
|
|
inspect = celery_app.control.inspect()
|
|
active_tasks = inspect.active()
|
|
|
|
if active_tasks:
|
|
total_active = sum(len(tasks) for tasks in active_tasks.values())
|
|
health_status["components"]["celery"] = {
|
|
"status": "healthy",
|
|
"active_tasks": total_active,
|
|
"workers": len(active_tasks)
|
|
}
|
|
else:
|
|
health_status["components"]["celery"] = {
|
|
"status": "no_workers",
|
|
"active_tasks": 0,
|
|
"workers": 0
|
|
}
|
|
except Exception as e:
|
|
health_status["components"]["celery"] = {"status": "unhealthy", "error": str(e)}
|
|
health_status["status"] = "degraded"
|
|
|
|
return health_status
|
|
|
|
|
|
@router.get("/jobs/stats")
|
|
async def get_job_statistics(
|
|
days: int = Query(7, ge=1, le=90),
|
|
current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.PRODUCTION, UserRole.ADMIN)),
|
|
db: AsyncIOMotorDatabase = Depends(get_database),
|
|
):
|
|
"""Get job processing statistics (reviewer/production/admin only)"""
|
|
since_date = datetime.utcnow() - timedelta(days=days)
|
|
|
|
# Jobs created in period
|
|
jobs_in_period = await db.jobs.count_documents({
|
|
"created_at": {"$gte": since_date}
|
|
})
|
|
|
|
# Jobs completed in period
|
|
jobs_completed = await db.jobs.count_documents({
|
|
"status": "completed",
|
|
"updated_at": {"$gte": since_date}
|
|
})
|
|
|
|
# Average processing time for completed jobs
|
|
avg_pipeline = [
|
|
{
|
|
"$match": {
|
|
"status": "completed",
|
|
"created_at": {"$gte": since_date},
|
|
"updated_at": {"$exists": True}
|
|
}
|
|
},
|
|
{
|
|
"$project": {
|
|
"processing_time_hours": {
|
|
"$divide": [
|
|
{"$subtract": ["$updated_at", "$created_at"]},
|
|
3600000
|
|
]
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"$group": {
|
|
"_id": None,
|
|
"avg_time": {"$avg": "$processing_time_hours"},
|
|
"min_time": {"$min": "$processing_time_hours"},
|
|
"max_time": {"$max": "$processing_time_hours"}
|
|
}
|
|
}
|
|
]
|
|
|
|
avg_result = await db.jobs.aggregate(avg_pipeline).to_list(None)
|
|
processing_stats = avg_result[0] if avg_result else {
|
|
"avg_time": 0, "min_time": 0, "max_time": 0
|
|
}
|
|
|
|
# Current queue status
|
|
current_queue_stats = {}
|
|
pipeline = [
|
|
{"$group": {"_id": "$status", "count": {"$sum": 1}}}
|
|
]
|
|
status_counts = await db.jobs.aggregate(pipeline).to_list(None)
|
|
for item in status_counts:
|
|
current_queue_stats[item["_id"]] = item["count"]
|
|
|
|
return {
|
|
"period_days": days,
|
|
"jobs_created": jobs_in_period,
|
|
"jobs_completed": jobs_completed,
|
|
"completion_rate": round(jobs_completed / max(jobs_in_period, 1) * 100, 2),
|
|
"avg_processing_time_hours": round(processing_stats["avg_time"], 2),
|
|
"min_processing_time_hours": round(processing_stats["min_time"], 2),
|
|
"max_processing_time_hours": round(processing_stats["max_time"], 2),
|
|
"current_queue_status": current_queue_stats
|
|
}
|
|
|
|
|
|
@router.post("/users/{user_id}/password/reset")
|
|
async def admin_force_password_reset(
|
|
user_id: str,
|
|
current_user: User = Depends(require_roles(UserRole.ADMIN)),
|
|
db: AsyncIOMotorDatabase = Depends(get_database),
|
|
):
|
|
"""Force password reset for user (admin only)"""
|
|
if str(current_user.id) == user_id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Cannot reset your own password this way"
|
|
)
|
|
|
|
# Check if user exists
|
|
user_doc = await db.users.find_one({"_id": user_id})
|
|
if not user_doc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="User not found"
|
|
)
|
|
|
|
# Generate secure temporary password
|
|
import secrets
|
|
import string
|
|
|
|
temp_password = ''.join(secrets.choice(
|
|
string.ascii_letters + string.digits + "!@#$%"
|
|
) for _ in range(16))
|
|
|
|
# Update password
|
|
await db.users.update_one(
|
|
{"_id": user_id},
|
|
{
|
|
"$set": {
|
|
"hashed_password": get_password_hash(temp_password),
|
|
"updated_at": datetime.utcnow()
|
|
}
|
|
}
|
|
)
|
|
|
|
# TODO: In production, send via secure email instead of returning password
|
|
logger.info(f"Admin {current_user.id} reset password for user {user_id}")
|
|
|
|
return {
|
|
"message": "Password reset successfully",
|
|
"temporary_password": temp_password,
|
|
"note": "User should change this password immediately"
|
|
}
|
|
|
|
|
|
|
|
@router.post("/maintenance/reprocess-job/{job_id}")
|
|
async def reprocess_job(
|
|
job_id: str,
|
|
current_user: User = Depends(require_roles(UserRole.PRODUCTION, UserRole.ADMIN)),
|
|
db: AsyncIOMotorDatabase = Depends(get_database),
|
|
):
|
|
"""Force reprocessing of a job (production/admin emergency function)"""
|
|
# Check if job exists
|
|
job_doc = await db.jobs.find_one({"_id": job_id})
|
|
if not job_doc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Job not found"
|
|
)
|
|
|
|
# Reset job to created status for reprocessing
|
|
await db.jobs.update_one(
|
|
{"_id": job_id},
|
|
{
|
|
"$set": {
|
|
"status": "created",
|
|
"error": None,
|
|
"updated_at": datetime.utcnow()
|
|
},
|
|
"$push": {
|
|
"review.history": {
|
|
"at": datetime.utcnow(),
|
|
"status": "reprocessing",
|
|
"by": str(current_user.id),
|
|
"notes": "Admin-triggered reprocessing"
|
|
}
|
|
}
|
|
}
|
|
)
|
|
|
|
# Broadcast status update
|
|
try:
|
|
from ...services.websocket import connection_manager
|
|
await connection_manager.broadcast_job_status_update(
|
|
job_id=job_id,
|
|
status="created",
|
|
job_title=job_doc.get("title"),
|
|
message=f"{job_doc.get('title', 'Job')} has been reset and is queued for reprocessing"
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to broadcast status update for job reset {job_id}: {e}")
|
|
|
|
# Trigger ingestion task
|
|
from ...tasks.ingest_and_ai import ingest_and_ai_task
|
|
ingest_and_ai_task.delay(job_id)
|
|
|
|
logger.warning(f"Admin {current_user.id} triggered reprocessing for job {job_id}")
|
|
|
|
return {"message": f"Job {job_id} queued for reprocessing"}
|
|
|
|
|
|
@router.get("/audit-logs", response_model=AuditLogResponse)
|
|
async def get_audit_logs_detailed(
|
|
# Time range
|
|
start_date: datetime | None = Query(None, description="Start date for audit logs"),
|
|
end_date: datetime | None = Query(None, description="End date for audit logs"),
|
|
|
|
# Filters
|
|
action: str | None = Query(None, description="Filter by action type"),
|
|
severity: str | None = Query(None, description="Filter by severity level"),
|
|
user_email: str | None = Query(None, description="Filter by user email"),
|
|
resource_type: str | None = Query(None, description="Filter by resource type"),
|
|
resource_id: str | None = Query(None, description="Filter by resource ID"),
|
|
success: bool | None = Query(None, description="Filter by success status"),
|
|
|
|
# Search
|
|
search: str | None = Query(None, description="Search in description and details"),
|
|
|
|
# Pagination (skip/limit to match frontend AuditLogQuery)
|
|
skip: int = Query(0, ge=0, description="Number of records to skip"),
|
|
limit: int = Query(50, ge=1, le=500, description="Max records to return"),
|
|
|
|
# Sorting
|
|
sort_by: str = Query("timestamp", description="Field to sort by"),
|
|
sort_order: int = Query(-1, ge=-1, le=1, description="Sort order (-1 desc, 1 asc)"),
|
|
|
|
current_user: User = Depends(require_roles(UserRole.PRODUCTION, UserRole.ADMIN)),
|
|
request: Request = None,
|
|
):
|
|
"""Get audit logs with filtering and pagination (production/admin only)"""
|
|
|
|
# Build query
|
|
query = AuditLogQuery(
|
|
start_date=start_date,
|
|
end_date=end_date,
|
|
action=action,
|
|
severity=severity,
|
|
user_email=user_email,
|
|
resource_type=resource_type,
|
|
resource_id=resource_id,
|
|
success=success,
|
|
search=search,
|
|
skip=skip,
|
|
limit=limit,
|
|
sort_by=sort_by,
|
|
sort_order=sort_order
|
|
)
|
|
|
|
return await audit_logger.query_logs(query)
|
|
|
|
|
|
@router.get("/audit-logs/user/{user_id}")
|
|
async def get_user_audit_logs(
|
|
user_id: str,
|
|
days: int = Query(30, ge=1, le=365, description="Number of days to look back"),
|
|
current_user: User = Depends(require_roles(UserRole.PRODUCTION, UserRole.ADMIN)),
|
|
db: AsyncIOMotorDatabase = Depends(get_database),
|
|
request: Request = None,
|
|
):
|
|
"""Get audit logs for a specific user — accepts user ID or email (production/admin only)"""
|
|
|
|
import re as _re
|
|
|
|
# Accept email address: look up user by case-insensitive email match
|
|
resolved_id = user_id
|
|
if "@" in user_id:
|
|
user_doc = await db.users.find_one(
|
|
{"email": _re.compile(f"^{_re.escape(user_id)}$", _re.IGNORECASE)},
|
|
{"_id": 1},
|
|
)
|
|
if user_doc:
|
|
resolved_id = str(user_doc["_id"])
|
|
|
|
logs = await audit_logger.get_user_activity(resolved_id, days)
|
|
|
|
# Fallback: query by email field in audit logs (case-insensitive via audit_logger)
|
|
if not logs and "@" in user_id:
|
|
from ...models.audit_log import AuditLogQuery as ALQ
|
|
from ...services.audit_logger import audit_logger as al
|
|
q = ALQ(user_email=user_id, limit=1000, sort_by="timestamp", sort_order=-1)
|
|
result = await al.query_logs(q)
|
|
logs = result.logs
|
|
|
|
return logs
|
|
|
|
|
|
@router.get("/audit-logs/security")
|
|
async def get_security_events(
|
|
hours: int = Query(24, ge=1, le=168, description="Number of hours to look back"),
|
|
current_user: User = Depends(require_roles(UserRole.PRODUCTION, UserRole.ADMIN)),
|
|
request: Request = None,
|
|
):
|
|
"""Get recent security events (production/admin only)"""
|
|
|
|
# Log access to security events
|
|
await audit_logger.log_action(
|
|
action="admin.audit.access",
|
|
description=f"Admin {current_user.email} accessed security events",
|
|
user=current_user,
|
|
request=request,
|
|
details={"hours_requested": hours}
|
|
)
|
|
|
|
logs = await audit_logger.get_security_events(hours)
|
|
return logs
|
|
|
|
|
|
@router.delete("/audit-logs/cleanup")
|
|
async def cleanup_audit_logs(
|
|
retention_days: int = Query(365, ge=30, le=2555, description="Retention period in days"),
|
|
current_user: User = Depends(require_roles(UserRole.ADMIN)),
|
|
request: Request = None,
|
|
):
|
|
"""Clean up old audit logs (admin only)"""
|
|
|
|
# Log audit cleanup action
|
|
await audit_logger.log_action(
|
|
action="admin.system.action",
|
|
description=f"Admin {current_user.email} initiated audit log cleanup",
|
|
user=current_user,
|
|
request=request,
|
|
details={"retention_days": retention_days},
|
|
severity="warning"
|
|
)
|
|
|
|
deleted_count = await audit_logger.cleanup_old_logs(retention_days)
|
|
|
|
# Log cleanup completion
|
|
await audit_logger.log_action(
|
|
action="admin.system.action",
|
|
description=f"Audit log cleanup completed: {deleted_count} logs deleted",
|
|
user=current_user,
|
|
request=request,
|
|
details={
|
|
"retention_days": retention_days,
|
|
"deleted_count": deleted_count
|
|
}
|
|
)
|
|
|
|
return {
|
|
"message": f"Deleted {deleted_count} audit logs older than {retention_days} days",
|
|
"deleted_count": deleted_count,
|
|
"retention_days": retention_days
|
|
}
|