- 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>
64 lines
2.8 KiB
Python
64 lines
2.8 KiB
Python
"""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")
|