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:
beabigegg
2025-12-30 06:58:30 +08:00
parent 95c281d8e1
commit 10db2c9d1f
18 changed files with 1455 additions and 12 deletions

View File

@@ -0,0 +1,98 @@
"""Task soft delete fields and audit log immutability
Revision ID: 008
Revises: 007
Create Date: 2024-12-30
"""
from alembic import op
import sqlalchemy as sa
import logging
# revision identifiers, used by Alembic.
revision = '008'
down_revision = '007'
branch_labels = None
depends_on = None
logger = logging.getLogger(__name__)
def upgrade():
# Add soft delete fields to tasks
op.add_column('pjctrl_tasks', sa.Column('is_deleted', sa.Boolean(), nullable=False, server_default='0'))
op.add_column('pjctrl_tasks', sa.Column('deleted_at', sa.DateTime(), nullable=True))
op.add_column('pjctrl_tasks', sa.Column('deleted_by', sa.String(36), nullable=True))
# Add foreign key for deleted_by
op.create_foreign_key(
'fk_tasks_deleted_by_users',
'pjctrl_tasks', 'pjctrl_users',
['deleted_by'], ['id']
)
# Add index for soft delete filtering
op.create_index('idx_task_deleted', 'pjctrl_tasks', ['is_deleted'])
# Create append-only triggers for audit_logs table
# Note: These triggers require SUPER privilege in MySQL with binary logging
# For production, run these manually with appropriate privileges:
#
# CREATE TRIGGER prevent_audit_update
# BEFORE UPDATE ON pjctrl_audit_logs
# FOR EACH ROW
# BEGIN
# SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Audit logs are immutable';
# END;
#
# CREATE TRIGGER prevent_audit_delete
# BEFORE DELETE ON pjctrl_audit_logs
# FOR EACH ROW
# BEGIN
# SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Audit logs are immutable';
# END;
try:
# Prevent UPDATE on audit_logs
op.execute("""
CREATE TRIGGER prevent_audit_update
BEFORE UPDATE ON pjctrl_audit_logs
FOR EACH ROW
BEGIN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Audit logs are immutable and cannot be updated';
END
""")
# Prevent DELETE on audit_logs
op.execute("""
CREATE TRIGGER prevent_audit_delete
BEFORE DELETE ON pjctrl_audit_logs
FOR EACH ROW
BEGIN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Audit logs are immutable and cannot be deleted';
END
""")
except Exception as e:
# Triggers may fail due to privilege issues or SQLite (in tests)
logger.warning(f"Could not create audit immutability triggers: {e}")
logger.warning("For production, create triggers manually with SUPER privilege")
def downgrade():
# Remove triggers (silently ignore if they don't exist)
try:
op.execute("DROP TRIGGER IF EXISTS prevent_audit_update")
op.execute("DROP TRIGGER IF EXISTS prevent_audit_delete")
except Exception:
pass
# Remove index
op.drop_index('idx_task_deleted', 'pjctrl_tasks')
# Remove foreign key
op.drop_constraint('fk_tasks_deleted_by_users', 'pjctrl_tasks', type_='foreignkey')
# Remove columns
op.drop_column('pjctrl_tasks', 'deleted_by')
op.drop_column('pjctrl_tasks', 'deleted_at')
op.drop_column('pjctrl_tasks', 'is_deleted')