feat: implement audit trail alignment (soft delete & permission audit)
- Task Soft Delete:
- Add is_deleted, deleted_at, deleted_by fields to Task model
- Convert DELETE to soft delete with cascade to subtasks
- Add include_deleted query param (admin only)
- Add POST /api/tasks/{id}/restore endpoint
- Exclude deleted tasks from subtask_count
- Permission Change Audit:
- Add user.role_change event (high sensitivity)
- Add user.admin_change event (critical, triggers alert)
- Add PATCH /api/users/{id}/admin endpoint
- Add role.permission_change event type
- Append-Only Enforcement:
- Add DB triggers for audit_logs immutability (manual for production)
- Migration 008 with graceful trigger failure handling
- Tests: 11 new soft delete tests (153 total passing)
- OpenSpec: fix-audit-trail archived, fix-realtime-notifications & fix-weekly-report proposals added
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query, Request
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -36,6 +37,11 @@ def get_task_depth(db: Session, task: Task) -> int:
|
||||
|
||||
def task_to_response(task: Task) -> TaskWithDetails:
|
||||
"""Convert a Task model to TaskWithDetails response."""
|
||||
# Count only non-deleted subtasks
|
||||
subtask_count = 0
|
||||
if task.subtasks:
|
||||
subtask_count = sum(1 for st in task.subtasks if not st.is_deleted)
|
||||
|
||||
return TaskWithDetails(
|
||||
id=task.id,
|
||||
project_id=task.project_id,
|
||||
@@ -57,7 +63,7 @@ def task_to_response(task: Task) -> TaskWithDetails:
|
||||
status_name=task.status.name if task.status else None,
|
||||
status_color=task.status.color if task.status else None,
|
||||
creator_name=task.creator.name if task.creator else None,
|
||||
subtask_count=len(task.subtasks) if task.subtasks else 0,
|
||||
subtask_count=subtask_count,
|
||||
)
|
||||
|
||||
|
||||
@@ -67,6 +73,7 @@ async def list_tasks(
|
||||
parent_task_id: Optional[str] = Query(None, description="Filter by parent task"),
|
||||
status_id: Optional[str] = Query(None, description="Filter by status"),
|
||||
assignee_id: Optional[str] = Query(None, description="Filter by assignee"),
|
||||
include_deleted: bool = Query(False, description="Include deleted tasks (admin only)"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
@@ -89,6 +96,12 @@ async def list_tasks(
|
||||
|
||||
query = db.query(Task).filter(Task.project_id == project_id)
|
||||
|
||||
# Filter deleted tasks (only admin can include deleted)
|
||||
if include_deleted and current_user.is_system_admin:
|
||||
pass # Don't filter by is_deleted
|
||||
else:
|
||||
query = query.filter(Task.is_deleted == False)
|
||||
|
||||
# Apply filters
|
||||
if parent_task_id is not None:
|
||||
if parent_task_id == "":
|
||||
@@ -238,6 +251,13 @@ async def get_task(
|
||||
detail="Task not found",
|
||||
)
|
||||
|
||||
# Check if task is deleted (only admin can view deleted)
|
||||
if task.is_deleted and not current_user.is_system_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Task not found",
|
||||
)
|
||||
|
||||
if not check_task_access(current_user, task, task.project):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
@@ -324,7 +344,7 @@ async def update_task(
|
||||
return task
|
||||
|
||||
|
||||
@router.delete("/api/tasks/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@router.delete("/api/tasks/{task_id}", response_model=TaskResponse)
|
||||
async def delete_task(
|
||||
task_id: str,
|
||||
request: Request,
|
||||
@@ -332,7 +352,7 @@ async def delete_task(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Delete a task (cascades to subtasks).
|
||||
Soft delete a task (cascades to subtasks).
|
||||
"""
|
||||
task = db.query(Task).filter(Task.id == task_id).first()
|
||||
|
||||
@@ -342,13 +362,37 @@ async def delete_task(
|
||||
detail="Task not found",
|
||||
)
|
||||
|
||||
if task.is_deleted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Task is already deleted",
|
||||
)
|
||||
|
||||
if not check_task_edit_access(current_user, task, task.project):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Permission denied",
|
||||
)
|
||||
|
||||
# Audit log before deletion
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Soft delete the task
|
||||
task.is_deleted = True
|
||||
task.deleted_at = now
|
||||
task.deleted_by = current_user.id
|
||||
|
||||
# Cascade soft delete to subtasks
|
||||
def soft_delete_subtasks(parent_task):
|
||||
for subtask in parent_task.subtasks:
|
||||
if not subtask.is_deleted:
|
||||
subtask.is_deleted = True
|
||||
subtask.deleted_at = now
|
||||
subtask.deleted_by = current_user.id
|
||||
soft_delete_subtasks(subtask)
|
||||
|
||||
soft_delete_subtasks(task)
|
||||
|
||||
# Audit log
|
||||
AuditService.log_event(
|
||||
db=db,
|
||||
event_type="task.delete",
|
||||
@@ -356,14 +400,67 @@ async def delete_task(
|
||||
action=AuditAction.DELETE,
|
||||
user_id=current_user.id,
|
||||
resource_id=task.id,
|
||||
changes=[{"field": "title", "old_value": task.title, "new_value": None}],
|
||||
changes=[{"field": "is_deleted", "old_value": False, "new_value": True}],
|
||||
request_metadata=get_audit_metadata(request),
|
||||
)
|
||||
|
||||
db.delete(task)
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
|
||||
return None
|
||||
return task
|
||||
|
||||
|
||||
@router.post("/api/tasks/{task_id}/restore", response_model=TaskResponse)
|
||||
async def restore_task(
|
||||
task_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Restore a soft-deleted task (admin only).
|
||||
"""
|
||||
if not current_user.is_system_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only system administrators can restore deleted tasks",
|
||||
)
|
||||
|
||||
task = db.query(Task).filter(Task.id == task_id).first()
|
||||
|
||||
if not task:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Task not found",
|
||||
)
|
||||
|
||||
if not task.is_deleted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Task is not deleted",
|
||||
)
|
||||
|
||||
# Restore the task
|
||||
task.is_deleted = False
|
||||
task.deleted_at = None
|
||||
task.deleted_by = None
|
||||
|
||||
# Audit log
|
||||
AuditService.log_event(
|
||||
db=db,
|
||||
event_type="task.restore",
|
||||
resource_type="task",
|
||||
action=AuditAction.UPDATE,
|
||||
user_id=current_user.id,
|
||||
resource_id=task.id,
|
||||
changes=[{"field": "is_deleted", "old_value": True, "new_value": False}],
|
||||
request_metadata=get_audit_metadata(request),
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
|
||||
return task
|
||||
|
||||
|
||||
@router.patch("/api/tasks/{task_id}/status", response_model=TaskResponse)
|
||||
@@ -495,6 +592,7 @@ async def assign_task(
|
||||
@router.get("/api/tasks/{task_id}/subtasks", response_model=TaskListResponse)
|
||||
async def list_subtasks(
|
||||
task_id: str,
|
||||
include_deleted: bool = Query(False, description="Include deleted subtasks (admin only)"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
@@ -515,9 +613,13 @@ async def list_subtasks(
|
||||
detail="Access denied",
|
||||
)
|
||||
|
||||
subtasks = db.query(Task).filter(
|
||||
Task.parent_task_id == task_id
|
||||
).order_by(Task.position, Task.created_at).all()
|
||||
query = db.query(Task).filter(Task.parent_task_id == task_id)
|
||||
|
||||
# Filter deleted subtasks (only admin can include deleted)
|
||||
if not (include_deleted and current_user.is_system_admin):
|
||||
query = query.filter(Task.is_deleted == False)
|
||||
|
||||
subtasks = query.order_by(Task.position, Task.created_at).all()
|
||||
|
||||
return TaskListResponse(
|
||||
tasks=[task_to_response(t) for t in subtasks],
|
||||
|
||||
Reference in New Issue
Block a user