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:
@@ -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')
|
||||
Reference in New Issue
Block a user