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)

View File

@@ -1,6 +1,7 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.middleware.audit import AuditMiddleware
from app.api.auth import router as auth_router
from app.api.users import router as users_router
from app.api.departments import router as departments_router
@@ -12,6 +13,7 @@ from app.api.comments import router as comments_router
from app.api.notifications import router as notifications_router
from app.api.blockers import router as blockers_router
from app.api.websocket import router as websocket_router
from app.api.audit import router as audit_router
from app.core.config import settings
app = FastAPI(
@@ -29,6 +31,9 @@ app.add_middleware(
allow_headers=["*"],
)
# Audit middleware - extracts request metadata for audit logging
app.add_middleware(AuditMiddleware)
# Include routers
app.include_router(auth_router.router, prefix="/api/auth", tags=["Authentication"])
app.include_router(users_router.router, prefix="/api/users", tags=["Users"])
@@ -41,6 +46,7 @@ app.include_router(comments_router)
app.include_router(notifications_router)
app.include_router(blockers_router)
app.include_router(websocket_router)
app.include_router(audit_router)
@app.get("/health")

View File

@@ -0,0 +1,43 @@
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from typing import Optional
class AuditMiddleware(BaseHTTPMiddleware):
"""Middleware to extract audit metadata from requests."""
async def dispatch(self, request: Request, call_next):
# Extract metadata from request
request.state.audit_metadata = {
"ip_address": self.get_client_ip(request),
"user_agent": request.headers.get("user-agent", ""),
"method": request.method,
"path": str(request.url.path),
}
response = await call_next(request)
return response
@staticmethod
def get_client_ip(request: Request) -> str:
"""Get the real client IP address from request."""
# Check for forwarded headers (when behind a proxy)
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
# Take the first IP in the chain (original client)
return forwarded.split(",")[0].strip()
real_ip = request.headers.get("x-real-ip")
if real_ip:
return real_ip
# Fallback to direct client
if request.client:
return request.client.host
return "unknown"
def get_audit_metadata(request: Request) -> Optional[dict]:
"""Get audit metadata from request state."""
return getattr(request.state, "audit_metadata", None)

View File

@@ -10,8 +10,11 @@ from app.models.comment import Comment
from app.models.mention import Mention
from app.models.notification import Notification
from app.models.blocker import Blocker
from app.models.audit_log import AuditLog, AuditAction, SensitivityLevel, EVENT_SENSITIVITY, ALERT_EVENTS
from app.models.audit_alert import AuditAlert
__all__ = [
"User", "Role", "Department", "Space", "Project", "TaskStatus", "Task", "WorkloadSnapshot",
"Comment", "Mention", "Notification", "Blocker"
"Comment", "Mention", "Notification", "Blocker",
"AuditLog", "AuditAlert", "AuditAction", "SensitivityLevel", "EVENT_SENSITIVITY", "ALERT_EVENTS"
]

View File

@@ -0,0 +1,23 @@
import uuid
from sqlalchemy import Column, String, Text, Boolean, DateTime, ForeignKey, JSON
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
from app.core.database import Base
class AuditAlert(Base):
__tablename__ = "pjctrl_audit_alerts"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
audit_log_id = Column(String(36), ForeignKey("pjctrl_audit_logs.id", ondelete="CASCADE"), nullable=False)
alert_type = Column(String(50), nullable=False)
recipients = Column(JSON, nullable=False)
message = Column(Text, nullable=True)
is_acknowledged = Column(Boolean, default=False, nullable=False)
acknowledged_by = Column(String(36), ForeignKey("pjctrl_users.id", ondelete="SET NULL"), nullable=True)
acknowledged_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, server_default=func.now(), nullable=False)
# Relationships
audit_log = relationship("AuditLog", back_populates="alerts")
acknowledger = relationship("User", foreign_keys=[acknowledged_by])

View File

@@ -0,0 +1,77 @@
import uuid
from sqlalchemy import Column, String, Text, DateTime, ForeignKey, Enum, Index, JSON
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
from app.core.database import Base
import enum
class AuditAction(str, enum.Enum):
CREATE = "create"
UPDATE = "update"
DELETE = "delete"
RESTORE = "restore"
LOGIN = "login"
LOGOUT = "logout"
class SensitivityLevel(str, enum.Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
CRITICAL = "critical"
# Event type to sensitivity level mapping
EVENT_SENSITIVITY = {
"task.create": SensitivityLevel.LOW,
"task.update": SensitivityLevel.LOW,
"task.delete": SensitivityLevel.MEDIUM,
"task.assign": SensitivityLevel.LOW,
"task.blocker": SensitivityLevel.MEDIUM,
"project.create": SensitivityLevel.MEDIUM,
"project.update": SensitivityLevel.MEDIUM,
"project.delete": SensitivityLevel.HIGH,
"user.login": SensitivityLevel.LOW,
"user.logout": SensitivityLevel.LOW,
"user.permission_change": SensitivityLevel.CRITICAL,
"attachment.upload": SensitivityLevel.LOW,
"attachment.download": SensitivityLevel.LOW,
"attachment.delete": SensitivityLevel.MEDIUM,
}
# Events that should trigger alerts
ALERT_EVENTS = {"project.delete", "user.permission_change"}
class AuditLog(Base):
__tablename__ = "pjctrl_audit_logs"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
event_type = Column(String(50), nullable=False)
resource_type = Column(String(50), nullable=False)
resource_id = Column(String(36), nullable=True)
user_id = Column(String(36), ForeignKey("pjctrl_users.id", ondelete="SET NULL"), nullable=True)
action = Column(
Enum("create", "update", "delete", "restore", "login", "logout", name="audit_action_enum"),
nullable=False
)
changes = Column(JSON, nullable=True)
request_metadata = Column(JSON, nullable=True)
sensitivity_level = Column(
Enum("low", "medium", "high", "critical", name="sensitivity_level_enum"),
default="low",
nullable=False
)
checksum = Column(String(64), nullable=False)
created_at = Column(DateTime, server_default=func.now(), nullable=False)
# Relationships
user = relationship("User", foreign_keys=[user_id])
alerts = relationship("AuditAlert", back_populates="audit_log", cascade="all, delete-orphan")
__table_args__ = (
Index("idx_audit_user", "user_id", "created_at"),
Index("idx_audit_resource", "resource_type", "resource_id", "created_at"),
Index("idx_audit_time", "created_at"),
)

View File

@@ -20,6 +20,10 @@ from app.schemas.notification import (
from app.schemas.blocker import (
BlockerCreate, BlockerResolve, BlockerResponse, BlockerListResponse
)
from app.schemas.audit import (
AuditLogResponse, AuditLogListResponse, AuditAlertResponse, AuditAlertListResponse,
IntegrityCheckRequest, IntegrityCheckResponse
)
__all__ = [
"LoginRequest",
@@ -64,4 +68,10 @@ __all__ = [
"BlockerResolve",
"BlockerResponse",
"BlockerListResponse",
"AuditLogResponse",
"AuditLogListResponse",
"AuditAlertResponse",
"AuditAlertListResponse",
"IntegrityCheckRequest",
"IntegrityCheckResponse",
]

View File

@@ -0,0 +1,61 @@
from datetime import datetime
from typing import Optional, List, Any
from pydantic import BaseModel
class AuditLogResponse(BaseModel):
id: str
event_type: str
resource_type: str
resource_id: Optional[str]
user_id: Optional[str]
action: str
changes: Optional[List[dict]]
request_metadata: Optional[dict]
sensitivity_level: str
checksum: str
created_at: datetime
user_name: Optional[str] = None
user_email: Optional[str] = None
class Config:
from_attributes = True
class AuditLogListResponse(BaseModel):
logs: List[AuditLogResponse]
total: int
offset: int
limit: int
class AuditAlertResponse(BaseModel):
id: str
audit_log_id: str
alert_type: str
recipients: List[str]
message: Optional[str]
is_acknowledged: bool
acknowledged_by: Optional[str]
acknowledged_at: Optional[datetime]
created_at: datetime
class Config:
from_attributes = True
class AuditAlertListResponse(BaseModel):
alerts: List[AuditAlertResponse]
total: int
class IntegrityCheckRequest(BaseModel):
start_date: datetime
end_date: datetime
class IntegrityCheckResponse(BaseModel):
total_checked: int
valid_count: int
invalid_count: int
invalid_records: List[str]

View File

@@ -0,0 +1,210 @@
import uuid
import hashlib
import json
from datetime import datetime, timedelta
from typing import Optional, Dict, Any, List
from sqlalchemy.orm import Session
from app.models import (
AuditLog, AuditAlert, AuditAction, SensitivityLevel,
EVENT_SENSITIVITY, ALERT_EVENTS, User
)
class AuditService:
"""Service for managing audit logs and alerts."""
# Bulk delete threshold: more than 5 deletes in 5 minutes
BULK_DELETE_THRESHOLD = 5
BULK_DELETE_WINDOW_MINUTES = 5
@staticmethod
def calculate_checksum(
event_type: str,
resource_id: Optional[str],
user_id: Optional[str],
changes: Optional[Dict],
created_at: datetime
) -> str:
"""Calculate SHA-256 checksum for audit log integrity."""
changes_json = json.dumps(changes, sort_keys=True) if changes else ""
content = f"{event_type}|{resource_id or ''}|{user_id or ''}|{changes_json}|{created_at.isoformat()}"
return hashlib.sha256(content.encode()).hexdigest()
@staticmethod
def verify_checksum(log: AuditLog) -> bool:
"""Verify that an audit log's checksum is valid."""
expected = AuditService.calculate_checksum(
log.event_type,
log.resource_id,
log.user_id,
log.changes,
log.created_at
)
return log.checksum == expected
@staticmethod
def get_sensitivity_level(event_type: str) -> SensitivityLevel:
"""Get sensitivity level for an event type."""
return EVENT_SENSITIVITY.get(event_type, SensitivityLevel.LOW)
@staticmethod
def detect_changes(old_values: Dict[str, Any], new_values: Dict[str, Any]) -> List[Dict]:
"""Detect changes between old and new values."""
changes = []
all_keys = set(old_values.keys()) | set(new_values.keys())
for key in all_keys:
old_val = old_values.get(key)
new_val = new_values.get(key)
# Convert datetime to string for comparison
if isinstance(old_val, datetime):
old_val = old_val.isoformat()
if isinstance(new_val, datetime):
new_val = new_val.isoformat()
if old_val != new_val:
changes.append({
"field": key,
"old_value": old_val,
"new_value": new_val
})
return changes
@staticmethod
def log_event(
db: Session,
event_type: str,
resource_type: str,
action: AuditAction,
user_id: Optional[str] = None,
resource_id: Optional[str] = None,
changes: Optional[List[Dict]] = None,
request_metadata: Optional[Dict] = None,
) -> AuditLog:
"""Log an audit event."""
now = datetime.utcnow()
sensitivity = AuditService.get_sensitivity_level(event_type)
checksum = AuditService.calculate_checksum(
event_type, resource_id, user_id, changes, now
)
log = AuditLog(
id=str(uuid.uuid4()),
event_type=event_type,
resource_type=resource_type,
resource_id=resource_id,
user_id=user_id,
action=action.value,
changes=changes,
request_metadata=request_metadata,
sensitivity_level=sensitivity.value,
checksum=checksum,
created_at=now,
)
db.add(log)
db.flush()
# Check if this event should trigger an alert
if event_type in ALERT_EVENTS:
AuditService.create_alert(db, log, event_type)
# Check for bulk delete pattern
if action == AuditAction.DELETE:
AuditService.check_bulk_delete(db, user_id, now)
return log
@staticmethod
def create_alert(
db: Session,
audit_log: AuditLog,
alert_type: str,
message: Optional[str] = None
) -> AuditAlert:
"""Create an audit alert and notify admins."""
# Find all system admins
admins = db.query(User).filter(User.is_system_admin == True).all()
recipient_ids = [admin.id for admin in admins]
if not message:
message = f"Sensitive operation detected: {alert_type}"
alert = AuditAlert(
id=str(uuid.uuid4()),
audit_log_id=audit_log.id,
alert_type=alert_type,
recipients=recipient_ids,
message=message,
)
db.add(alert)
db.flush()
# Send notifications to admins via NotificationService
from app.services.notification_service import NotificationService
for admin in admins:
NotificationService.create_notification(
db=db,
user_id=admin.id,
notification_type="blocker", # Using blocker type for high-priority alerts
reference_type="audit_alert",
reference_id=alert.id,
title=f"Security Alert: {alert_type}",
message=message,
)
return alert
@staticmethod
def check_bulk_delete(db: Session, user_id: Optional[str], now: datetime) -> None:
"""Check if user has exceeded bulk delete threshold."""
if not user_id:
return
window_start = now - timedelta(minutes=AuditService.BULK_DELETE_WINDOW_MINUTES)
delete_count = db.query(AuditLog).filter(
AuditLog.user_id == user_id,
AuditLog.action == "delete",
AuditLog.created_at >= window_start,
).count()
if delete_count > AuditService.BULK_DELETE_THRESHOLD:
# Create a bulk delete alert
# Get the most recent delete log to attach the alert
recent_log = db.query(AuditLog).filter(
AuditLog.user_id == user_id,
AuditLog.action == "delete",
).order_by(AuditLog.created_at.desc()).first()
if recent_log:
AuditService.create_alert(
db,
recent_log,
"bulk_delete",
f"User performed {delete_count} delete operations in {AuditService.BULK_DELETE_WINDOW_MINUTES} minutes"
)
@staticmethod
def acknowledge_alert(
db: Session,
alert_id: str,
user_id: str
) -> Optional[AuditAlert]:
"""Acknowledge an audit alert."""
alert = db.query(AuditAlert).filter(AuditAlert.id == alert_id).first()
if not alert:
return None
alert.is_acknowledged = True
alert.acknowledged_by = user_id
alert.acknowledged_at = datetime.utcnow()
db.flush()
return alert

View File

@@ -0,0 +1,63 @@
"""Create audit trail tables
Revision ID: 005
Revises: 004
Create Date: 2024-12-29
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '005'
down_revision = '004'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Create audit_logs table
op.create_table(
'pjctrl_audit_logs',
sa.Column('id', sa.String(36), primary_key=True),
sa.Column('event_type', sa.String(50), nullable=False),
sa.Column('resource_type', sa.String(50), nullable=False),
sa.Column('resource_id', sa.String(36), nullable=True),
sa.Column('user_id', sa.String(36), sa.ForeignKey('pjctrl_users.id', ondelete='SET NULL'), nullable=True),
sa.Column('action', sa.Enum('create', 'update', 'delete', 'restore', 'login', 'logout', name='audit_action_enum'), nullable=False),
sa.Column('changes', sa.JSON, nullable=True),
sa.Column('request_metadata', sa.JSON, nullable=True),
sa.Column('sensitivity_level', sa.Enum('low', 'medium', 'high', 'critical', name='sensitivity_level_enum'), server_default='low', nullable=False),
sa.Column('checksum', sa.String(64), nullable=False),
sa.Column('created_at', sa.DateTime, server_default=sa.func.now(), nullable=False),
)
# Create indexes for audit_logs
op.create_index('idx_audit_user', 'pjctrl_audit_logs', ['user_id', 'created_at'])
op.create_index('idx_audit_resource', 'pjctrl_audit_logs', ['resource_type', 'resource_id', 'created_at'])
op.create_index('idx_audit_time', 'pjctrl_audit_logs', ['created_at'])
# Create audit_alerts table
op.create_table(
'pjctrl_audit_alerts',
sa.Column('id', sa.String(36), primary_key=True),
sa.Column('audit_log_id', sa.String(36), sa.ForeignKey('pjctrl_audit_logs.id', ondelete='CASCADE'), nullable=False),
sa.Column('alert_type', sa.String(50), nullable=False),
sa.Column('recipients', sa.JSON, nullable=False),
sa.Column('message', sa.Text, nullable=True),
sa.Column('is_acknowledged', sa.Boolean, server_default='0', nullable=False),
sa.Column('acknowledged_by', sa.String(36), sa.ForeignKey('pjctrl_users.id', ondelete='SET NULL'), nullable=True),
sa.Column('acknowledged_at', sa.DateTime, nullable=True),
sa.Column('created_at', sa.DateTime, server_default=sa.func.now(), nullable=False),
)
def downgrade() -> None:
op.drop_table('pjctrl_audit_alerts')
op.drop_index('idx_audit_time', table_name='pjctrl_audit_logs')
op.drop_index('idx_audit_resource', table_name='pjctrl_audit_logs')
op.drop_index('idx_audit_user', table_name='pjctrl_audit_logs')
op.drop_table('pjctrl_audit_logs')
op.execute("DROP TYPE IF EXISTS audit_action_enum")
op.execute("DROP TYPE IF EXISTS sensitivity_level_enum")

342
backend/tests/test_audit.py Normal file
View File

@@ -0,0 +1,342 @@
import pytest
import uuid
from datetime import datetime, timedelta
from app.models import User, AuditLog, AuditAlert, AuditAction, SensitivityLevel
from app.services.audit_service import AuditService
@pytest.fixture
def admin_user(db):
"""Create a test admin user."""
user = User(
id=str(uuid.uuid4()),
email="testadmin@example.com",
name="Test Admin",
role_id="00000000-0000-0000-0000-000000000001",
is_active=True,
is_system_admin=True,
)
db.add(user)
db.commit()
return user
class TestAuditService:
"""Tests for AuditService."""
def test_calculate_checksum(self):
"""Test checksum calculation."""
now = datetime.utcnow()
checksum1 = AuditService.calculate_checksum(
"task.create", "resource-1", "user-1", None, now
)
checksum2 = AuditService.calculate_checksum(
"task.create", "resource-1", "user-1", None, now
)
# Same inputs should produce same checksum
assert checksum1 == checksum2
assert len(checksum1) == 64 # SHA-256 hex length
def test_checksum_different_for_different_inputs(self):
"""Test that different inputs produce different checksums."""
now = datetime.utcnow()
checksum1 = AuditService.calculate_checksum(
"task.create", "resource-1", "user-1", None, now
)
checksum2 = AuditService.calculate_checksum(
"task.update", "resource-1", "user-1", None, now
)
assert checksum1 != checksum2
def test_detect_changes(self):
"""Test change detection between old and new values."""
old_values = {"title": "Old Title", "priority": "high"}
new_values = {"title": "New Title", "priority": "high"}
changes = AuditService.detect_changes(old_values, new_values)
assert len(changes) == 1
assert changes[0]["field"] == "title"
assert changes[0]["old_value"] == "Old Title"
assert changes[0]["new_value"] == "New Title"
def test_detect_no_changes(self):
"""Test that no changes are detected when values are the same."""
values = {"title": "Same Title", "priority": "high"}
changes = AuditService.detect_changes(values, values.copy())
assert len(changes) == 0
def test_get_sensitivity_level(self):
"""Test sensitivity level mapping."""
assert AuditService.get_sensitivity_level("task.create") == SensitivityLevel.LOW
assert AuditService.get_sensitivity_level("task.delete") == SensitivityLevel.MEDIUM
assert AuditService.get_sensitivity_level("project.delete") == SensitivityLevel.HIGH
assert AuditService.get_sensitivity_level("user.permission_change") == SensitivityLevel.CRITICAL
assert AuditService.get_sensitivity_level("unknown.event") == SensitivityLevel.LOW
def test_log_event(self, db, admin_user):
"""Test logging an audit event."""
log = AuditService.log_event(
db=db,
event_type="task.create",
resource_type="task",
action=AuditAction.CREATE,
user_id=admin_user.id,
resource_id=str(uuid.uuid4()),
changes=[{"field": "title", "old_value": None, "new_value": "Test Task"}],
)
db.commit()
assert log.id is not None
assert log.event_type == "task.create"
assert log.action == "create"
assert log.sensitivity_level == "low"
assert log.checksum is not None
def test_verify_checksum_valid(self, db, admin_user):
"""Test verifying a valid checksum."""
log = AuditService.log_event(
db=db,
event_type="task.create",
resource_type="task",
action=AuditAction.CREATE,
user_id=admin_user.id,
resource_id=str(uuid.uuid4()),
)
db.commit()
db.refresh(log)
assert AuditService.verify_checksum(log) is True
def test_verify_checksum_invalid(self, db, admin_user):
"""Test that tampered checksums are detected."""
log = AuditService.log_event(
db=db,
event_type="task.create",
resource_type="task",
action=AuditAction.CREATE,
user_id=admin_user.id,
resource_id=str(uuid.uuid4()),
)
db.commit()
db.refresh(log)
# Tamper with the checksum
log.checksum = "0" * 64
assert AuditService.verify_checksum(log) is False
@pytest.fixture
def regular_user(db):
"""Create a non-admin user."""
user = User(
id=str(uuid.uuid4()),
email="regular@example.com",
name="Regular User",
role_id="00000000-0000-0000-0000-000000000003",
is_active=True,
is_system_admin=False,
)
db.add(user)
db.commit()
return user
@pytest.fixture
def regular_user_token(client, mock_redis, regular_user):
"""Get a token for regular user."""
from app.core.security import create_access_token, create_token_payload
token_data = create_token_payload(
user_id=regular_user.id,
email=regular_user.email,
role="engineer",
department_id=None,
is_system_admin=False,
)
token = create_access_token(token_data)
mock_redis.setex(f"session:{regular_user.id}", 900, token)
return token
class TestAuditAPI:
"""Tests for Audit API endpoints."""
def test_list_audit_logs_requires_admin(self, client, regular_user_token):
"""Test that non-admin users cannot access audit logs."""
response = client.get(
"/api/audit-logs",
headers={"Authorization": f"Bearer {regular_user_token}"},
)
assert response.status_code == 403
assert "Admin access required" in response.json()["detail"]
def test_list_audit_logs(self, client, admin_token, db):
"""Test listing audit logs as admin."""
# Create some audit logs
for i in range(3):
log = AuditLog(
id=str(uuid.uuid4()),
event_type="task.create",
resource_type="task",
resource_id=str(uuid.uuid4()),
user_id="00000000-0000-0000-0000-000000000001",
action="create",
sensitivity_level="low",
checksum="0" * 64,
)
db.add(log)
db.commit()
response = client.get(
"/api/audit-logs",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
data = response.json()
assert data["total"] >= 3
assert len(data["logs"]) >= 3
def test_list_audit_logs_with_filters(self, client, admin_token, db):
"""Test filtering audit logs."""
# Create logs with different resource types
for resource_type in ["task", "project", "task"]:
log = AuditLog(
id=str(uuid.uuid4()),
event_type=f"{resource_type}.create",
resource_type=resource_type,
resource_id=str(uuid.uuid4()),
user_id="00000000-0000-0000-0000-000000000001",
action="create",
sensitivity_level="low",
checksum="0" * 64,
)
db.add(log)
db.commit()
response = client.get(
"/api/audit-logs?resource_type=project",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
data = response.json()
assert all(log["resource_type"] == "project" for log in data["logs"])
def test_get_resource_history(self, client, admin_token, db):
"""Test getting resource history."""
resource_id = str(uuid.uuid4())
# Create multiple logs for the same resource
for event in ["task.create", "task.update", "task.update"]:
log = AuditLog(
id=str(uuid.uuid4()),
event_type=event,
resource_type="task",
resource_id=resource_id,
user_id="00000000-0000-0000-0000-000000000001",
action="create" if "create" in event else "update",
sensitivity_level="low",
checksum="0" * 64,
)
db.add(log)
db.commit()
response = client.get(
f"/api/audit-logs/resource/task/{resource_id}",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
data = response.json()
assert data["total"] == 3
assert all(log["resource_id"] == resource_id for log in data["logs"])
def test_verify_integrity(self, client, admin_token, db):
"""Test integrity verification."""
now = datetime.utcnow()
# Create a valid log
log = AuditService.log_event(
db=db,
event_type="task.create",
resource_type="task",
action=AuditAction.CREATE,
user_id="00000000-0000-0000-0000-000000000001",
resource_id=str(uuid.uuid4()),
)
db.commit()
response = client.post(
"/api/audit-logs/verify-integrity",
headers={"Authorization": f"Bearer {admin_token}"},
json={
"start_date": (now - timedelta(hours=1)).isoformat(),
"end_date": (now + timedelta(hours=1)).isoformat(),
},
)
assert response.status_code == 200
data = response.json()
assert data["total_checked"] >= 1
assert data["invalid_count"] == 0
def test_acknowledge_alert(self, client, admin_token, db):
"""Test acknowledging an alert."""
# Create a log and alert
log = AuditLog(
id=str(uuid.uuid4()),
event_type="project.delete",
resource_type="project",
resource_id=str(uuid.uuid4()),
user_id="00000000-0000-0000-0000-000000000001",
action="delete",
sensitivity_level="high",
checksum="0" * 64,
)
db.add(log)
db.flush()
alert = AuditAlert(
id=str(uuid.uuid4()),
audit_log_id=log.id,
alert_type="project.delete",
recipients=["00000000-0000-0000-0000-000000000001"],
message="Test alert",
)
db.add(alert)
db.commit()
response = client.put(
f"/api/audit-alerts/{alert.id}/acknowledge",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
data = response.json()
assert data["is_acknowledged"] is True
assert data["acknowledged_by"] is not None
def test_export_csv(self, client, admin_token, db):
"""Test CSV export."""
# Create some logs
for i in range(3):
log = AuditLog(
id=str(uuid.uuid4()),
event_type="task.create",
resource_type="task",
resource_id=str(uuid.uuid4()),
user_id="00000000-0000-0000-0000-000000000001",
action="create",
sensitivity_level="low",
checksum="0" * 64,
)
db.add(log)
db.commit()
response = client.get(
"/api/audit-logs/export",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
assert "text/csv" in response.headers["content-type"]
assert "attachment" in response.headers.get("content-disposition", "")