feat: implement audit trail module

- Backend (FastAPI):
  - AuditLog and AuditAlert models with Alembic migration
  - AuditService with SHA-256 checksum for log integrity
  - AuditMiddleware for request metadata extraction (IP, user_agent)
  - Integrated audit logging into Task, Project, Blocker APIs
  - Query API with filtering, pagination, CSV export
  - Integrity verification endpoint
  - Sensitive operation alerts with acknowledgement

- Frontend (React + Vite):
  - Admin AuditPage with filters and export
  - ResourceHistory component for change tracking
  - Audit service for API calls

- Testing:
  - 15 tests covering service and API endpoints

- OpenSpec:
  - add-audit-trail change archived

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
beabigegg
2025-12-29 21:21:18 +08:00
parent 3470428411
commit 0ef78e13ff
24 changed files with 2431 additions and 7 deletions

View File

@@ -0,0 +1,3 @@
from app.api.audit.router import router
__all__ = ["router"]

View File

@@ -0,0 +1,279 @@
import csv
import io
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.models import User, AuditLog, AuditAlert
from app.schemas.audit import (
AuditLogResponse, AuditLogListResponse, AuditAlertResponse, AuditAlertListResponse,
IntegrityCheckRequest, IntegrityCheckResponse
)
from app.middleware.auth import get_current_user
from app.services.audit_service import AuditService
router = APIRouter(tags=["audit"])
def audit_log_to_response(log: AuditLog) -> AuditLogResponse:
"""Convert AuditLog model to AuditLogResponse."""
return AuditLogResponse(
id=log.id,
event_type=log.event_type,
resource_type=log.resource_type,
resource_id=log.resource_id,
user_id=log.user_id,
action=log.action,
changes=log.changes,
request_metadata=log.request_metadata,
sensitivity_level=log.sensitivity_level,
checksum=log.checksum,
created_at=log.created_at,
user_name=log.user.name if log.user else None,
user_email=log.user.email if log.user else None,
)
def audit_alert_to_response(alert: AuditAlert) -> AuditAlertResponse:
"""Convert AuditAlert model to AuditAlertResponse."""
return AuditAlertResponse(
id=alert.id,
audit_log_id=alert.audit_log_id,
alert_type=alert.alert_type,
recipients=alert.recipients or [],
message=alert.message,
is_acknowledged=alert.is_acknowledged,
acknowledged_by=alert.acknowledged_by,
acknowledged_at=alert.acknowledged_at,
created_at=alert.created_at,
)
def require_admin(current_user: User) -> None:
"""Raise 403 if user is not a system admin."""
if not current_user.is_system_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required",
)
@router.get("/api/audit-logs", response_model=AuditLogListResponse)
async def list_audit_logs(
start_date: Optional[datetime] = Query(None, description="Filter by start date"),
end_date: Optional[datetime] = Query(None, description="Filter by end date"),
user_id: Optional[str] = Query(None, description="Filter by user ID"),
resource_type: Optional[str] = Query(None, description="Filter by resource type"),
resource_id: Optional[str] = Query(None, description="Filter by resource ID"),
sensitivity_level: Optional[str] = Query(None, description="Filter by sensitivity level"),
limit: int = Query(50, ge=1, le=100, description="Number of logs to return"),
offset: int = Query(0, ge=0, description="Offset for pagination"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List audit logs with optional filters. Requires admin access."""
require_admin(current_user)
query = db.query(AuditLog)
if start_date:
query = query.filter(AuditLog.created_at >= start_date)
if end_date:
query = query.filter(AuditLog.created_at <= end_date)
if user_id:
query = query.filter(AuditLog.user_id == user_id)
if resource_type:
query = query.filter(AuditLog.resource_type == resource_type)
if resource_id:
query = query.filter(AuditLog.resource_id == resource_id)
if sensitivity_level:
query = query.filter(AuditLog.sensitivity_level == sensitivity_level)
total = query.count()
logs = query.order_by(AuditLog.created_at.desc()).offset(offset).limit(limit).all()
return AuditLogListResponse(
logs=[audit_log_to_response(log) for log in logs],
total=total,
offset=offset,
limit=limit,
)
@router.get("/api/audit-logs/resource/{resource_type}/{resource_id}", response_model=AuditLogListResponse)
async def get_resource_history(
resource_type: str,
resource_id: str,
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get change history for a specific resource."""
# Note: We allow all authenticated users to view resource history
# because this is used in Task/Project detail pages
query = db.query(AuditLog).filter(
AuditLog.resource_type == resource_type,
AuditLog.resource_id == resource_id,
)
total = query.count()
logs = query.order_by(AuditLog.created_at.desc()).offset(offset).limit(limit).all()
return AuditLogListResponse(
logs=[audit_log_to_response(log) for log in logs],
total=total,
offset=offset,
limit=limit,
)
@router.get("/api/audit-logs/export")
async def export_audit_logs(
start_date: Optional[datetime] = Query(None),
end_date: Optional[datetime] = Query(None),
user_id: Optional[str] = Query(None),
resource_type: Optional[str] = Query(None),
sensitivity_level: Optional[str] = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Export audit logs as CSV. Requires admin access."""
require_admin(current_user)
query = db.query(AuditLog)
if start_date:
query = query.filter(AuditLog.created_at >= start_date)
if end_date:
query = query.filter(AuditLog.created_at <= end_date)
if user_id:
query = query.filter(AuditLog.user_id == user_id)
if resource_type:
query = query.filter(AuditLog.resource_type == resource_type)
if sensitivity_level:
query = query.filter(AuditLog.sensitivity_level == sensitivity_level)
logs = query.order_by(AuditLog.created_at.desc()).all()
# Generate CSV
output = io.StringIO()
writer = csv.writer(output)
# Header
writer.writerow([
"ID", "Event Type", "Resource Type", "Resource ID", "User ID",
"User Name", "Action", "Changes", "IP Address", "Sensitivity", "Created At"
])
# Data rows
for log in logs:
ip_address = log.request_metadata.get("ip_address", "") if log.request_metadata else ""
changes_str = str(log.changes) if log.changes else ""
writer.writerow([
log.id,
log.event_type,
log.resource_type,
log.resource_id or "",
log.user_id or "",
log.user.name if log.user else "",
log.action,
changes_str,
ip_address,
log.sensitivity_level,
log.created_at.isoformat(),
])
output.seek(0)
filename = f"audit_logs_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.csv"
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": f"attachment; filename={filename}"}
)
@router.post("/api/audit-logs/verify-integrity", response_model=IntegrityCheckResponse)
async def verify_integrity(
check_request: IntegrityCheckRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Verify integrity of audit logs within a date range. Requires admin access."""
require_admin(current_user)
logs = db.query(AuditLog).filter(
AuditLog.created_at >= check_request.start_date,
AuditLog.created_at <= check_request.end_date,
).all()
valid_count = 0
invalid_records = []
for log in logs:
if AuditService.verify_checksum(log):
valid_count += 1
else:
invalid_records.append(log.id)
return IntegrityCheckResponse(
total_checked=len(logs),
valid_count=valid_count,
invalid_count=len(invalid_records),
invalid_records=invalid_records,
)
@router.get("/api/audit-alerts", response_model=AuditAlertListResponse)
async def list_audit_alerts(
is_acknowledged: Optional[bool] = Query(None, description="Filter by acknowledgment status"),
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List audit alerts. Requires admin access."""
require_admin(current_user)
query = db.query(AuditAlert)
if is_acknowledged is not None:
query = query.filter(AuditAlert.is_acknowledged == is_acknowledged)
total = query.count()
alerts = query.order_by(AuditAlert.created_at.desc()).offset(offset).limit(limit).all()
return AuditAlertListResponse(
alerts=[audit_alert_to_response(alert) for alert in alerts],
total=total,
)
@router.put("/api/audit-alerts/{alert_id}/acknowledge", response_model=AuditAlertResponse)
async def acknowledge_alert(
alert_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Acknowledge an audit alert. Requires admin access."""
require_admin(current_user)
alert = AuditService.acknowledge_alert(db, alert_id, current_user.id)
if not alert:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Alert not found",
)
db.commit()
db.refresh(alert)
return audit_alert_to_response(alert)

View File

@@ -1,15 +1,17 @@
import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, status, Request
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.models import User, Task, Blocker
from app.models import User, Task, Blocker, AuditAction
from app.schemas.blocker import (
BlockerCreate, BlockerResolve, BlockerResponse, BlockerListResponse, BlockerUserInfo
)
from app.middleware.auth import get_current_user, check_task_access, check_task_edit_access
from app.middleware.audit import get_audit_metadata
from app.services.notification_service import NotificationService
from app.services.audit_service import AuditService
router = APIRouter(tags=["blockers"])
@@ -40,6 +42,7 @@ def blocker_to_response(blocker: Blocker) -> BlockerResponse:
async def create_blocker(
task_id: str,
blocker_data: BlockerCreate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@@ -85,6 +88,18 @@ async def create_blocker(
# Notify project owner
NotificationService.notify_blocker(db, task, current_user, blocker_data.reason)
# Audit log
AuditService.log_event(
db=db,
event_type="task.blocker",
resource_type="task",
action=AuditAction.UPDATE,
user_id=current_user.id,
resource_id=task.id,
changes=[{"field": "blocker_flag", "old_value": False, "new_value": True}],
request_metadata=get_audit_metadata(request),
)
db.commit()
db.refresh(blocker)

View File

@@ -1,10 +1,10 @@
import uuid
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, status, Request
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.models import User, Space, Project, TaskStatus
from app.models import User, Space, Project, TaskStatus, AuditAction
from app.models.task_status import DEFAULT_STATUSES
from app.schemas.project import ProjectCreate, ProjectUpdate, ProjectResponse, ProjectWithDetails
from app.schemas.task_status import TaskStatusResponse
@@ -12,6 +12,8 @@ from app.middleware.auth import (
get_current_user, check_space_access, check_space_edit_access,
check_project_access, check_project_edit_access
)
from app.middleware.audit import get_audit_metadata
from app.services.audit_service import AuditService
router = APIRouter(tags=["projects"])
@@ -85,6 +87,7 @@ async def list_projects_in_space(
async def create_project(
space_id: str,
project_data: ProjectCreate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@@ -124,6 +127,18 @@ async def create_project(
# Create default task statuses
create_default_statuses(db, project.id)
# Audit log
AuditService.log_event(
db=db,
event_type="project.create",
resource_type="project",
action=AuditAction.CREATE,
user_id=current_user.id,
resource_id=project.id,
changes=[{"field": "title", "old_value": None, "new_value": project.title}],
request_metadata=get_audit_metadata(request),
)
db.commit()
db.refresh(project)
@@ -180,6 +195,7 @@ async def get_project(
async def update_project(
project_id: str,
project_data: ProjectUpdate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@@ -200,6 +216,17 @@ async def update_project(
detail="Only project owner can update",
)
# Capture old values for audit
old_values = {
"title": project.title,
"description": project.description,
"budget": project.budget,
"start_date": project.start_date,
"end_date": project.end_date,
"security_level": project.security_level,
"status": project.status,
}
# Update fields
update_data = project_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
@@ -208,6 +235,30 @@ async def update_project(
else:
setattr(project, field, value)
# Capture new values and log changes
new_values = {
"title": project.title,
"description": project.description,
"budget": project.budget,
"start_date": project.start_date,
"end_date": project.end_date,
"security_level": project.security_level,
"status": project.status,
}
changes = AuditService.detect_changes(old_values, new_values)
if changes:
AuditService.log_event(
db=db,
event_type="project.update",
resource_type="project",
action=AuditAction.UPDATE,
user_id=current_user.id,
resource_id=project.id,
changes=changes,
request_metadata=get_audit_metadata(request),
)
db.commit()
db.refresh(project)
@@ -217,6 +268,7 @@ async def update_project(
@router.delete("/api/projects/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_project(
project_id: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@@ -237,6 +289,18 @@ async def delete_project(
detail="Only project owner can delete",
)
# Audit log before deletion (this is a high-sensitivity event that triggers alert)
AuditService.log_event(
db=db,
event_type="project.delete",
resource_type="project",
action=AuditAction.DELETE,
user_id=current_user.id,
resource_id=project.id,
changes=[{"field": "title", "old_value": project.title, "new_value": None}],
request_metadata=get_audit_metadata(request),
)
db.delete(project)
db.commit()

View File

@@ -1,10 +1,10 @@
import uuid
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query
from fastapi import APIRouter, Depends, HTTPException, status, Query, Request
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.models import User, Project, Task, TaskStatus
from app.models import User, Project, Task, TaskStatus, AuditAction
from app.schemas.task import (
TaskCreate, TaskUpdate, TaskResponse, TaskWithDetails, TaskListResponse,
TaskStatusUpdate, TaskAssignUpdate
@@ -12,6 +12,8 @@ from app.schemas.task import (
from app.middleware.auth import (
get_current_user, check_project_access, check_task_access, check_task_edit_access
)
from app.middleware.audit import get_audit_metadata
from app.services.audit_service import AuditService
router = APIRouter(tags=["tasks"])
@@ -115,6 +117,7 @@ async def list_tasks(
async def create_task(
project_id: str,
task_data: TaskCreate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@@ -198,6 +201,19 @@ async def create_task(
)
db.add(task)
# Audit log
AuditService.log_event(
db=db,
event_type="task.create",
resource_type="task",
action=AuditAction.CREATE,
user_id=current_user.id,
resource_id=task.id,
changes=[{"field": "title", "old_value": None, "new_value": task.title}],
request_metadata=get_audit_metadata(request),
)
db.commit()
db.refresh(task)
@@ -234,6 +250,7 @@ async def get_task(
async def update_task(
task_id: str,
task_data: TaskUpdate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@@ -254,6 +271,16 @@ async def update_task(
detail="Permission denied",
)
# Capture old values for audit
old_values = {
"title": task.title,
"description": task.description,
"priority": task.priority,
"due_date": task.due_date,
"original_estimate": task.original_estimate,
"time_spent": task.time_spent,
}
# Update fields
update_data = task_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
@@ -262,6 +289,30 @@ async def update_task(
else:
setattr(task, field, value)
# Capture new values for audit
new_values = {
"title": task.title,
"description": task.description,
"priority": task.priority,
"due_date": task.due_date,
"original_estimate": task.original_estimate,
"time_spent": task.time_spent,
}
# Detect changes and log
changes = AuditService.detect_changes(old_values, new_values)
if changes:
AuditService.log_event(
db=db,
event_type="task.update",
resource_type="task",
action=AuditAction.UPDATE,
user_id=current_user.id,
resource_id=task.id,
changes=changes,
request_metadata=get_audit_metadata(request),
)
db.commit()
db.refresh(task)
@@ -271,6 +322,7 @@ async def update_task(
@router.delete("/api/tasks/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_task(
task_id: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@@ -291,6 +343,18 @@ async def delete_task(
detail="Permission denied",
)
# Audit log before deletion
AuditService.log_event(
db=db,
event_type="task.delete",
resource_type="task",
action=AuditAction.DELETE,
user_id=current_user.id,
resource_id=task.id,
changes=[{"field": "title", "old_value": task.title, "new_value": None}],
request_metadata=get_audit_metadata(request),
)
db.delete(task)
db.commit()
@@ -351,6 +415,7 @@ async def update_task_status(
async def assign_task(
task_id: str,
assign_data: TaskAssignUpdate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@@ -380,7 +445,21 @@ async def assign_task(
detail="Assignee not found",
)
old_assignee_id = task.assignee_id
task.assignee_id = assign_data.assignee_id
# Audit log
AuditService.log_event(
db=db,
event_type="task.assign",
resource_type="task",
action=AuditAction.UPDATE,
user_id=current_user.id,
resource_id=task.id,
changes=[{"field": "assignee_id", "old_value": old_assignee_id, "new_value": assign_data.assignee_id}],
request_metadata=get_audit_metadata(request),
)
db.commit()
db.refresh(task)