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

@@ -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)