iaun 3 månader sedan
incheckning
58fa5e1a41
100 ändrade filer med 18733 tillägg och 0 borttagningar
  1. 89 0
      .gitignore
  2. 1050 0
      .kiro/specs/aws-resource-scanner/design.md
  3. 179 0
      .kiro/specs/aws-resource-scanner/requirements.md
  4. 427 0
      .kiro/specs/aws-resource-scanner/tasks.md
  5. 165 0
      CELERY_ISSUE_SOLUTION.md
  6. 165 0
      CREDENTIAL_UPDATE.md
  7. 294 0
      QUICK_START.md
  8. 522 0
      README.md
  9. 156 0
      REDIS_ISSUE_RESOLVED.md
  10. 23 0
      backend/.env.example
  11. 236 0
      backend/README_VENV.md
  12. 366 0
      backend/REDIS_SETUP.md
  13. 206 0
      backend/SETUP_COMPLETE.md
  14. 11 0
      backend/activate_venv.bat
  15. 11 0
      backend/activate_venv.sh
  16. 38 0
      backend/app/__init__.py
  17. 6 0
      backend/app/api/__init__.py
  18. 129 0
      backend/app/api/auth.py
  19. 616 0
      backend/app/api/credentials.py
  20. 72 0
      backend/app/api/dashboard.py
  21. 162 0
      backend/app/api/reports.py
  22. 593 0
      backend/app/api/tasks.py
  23. 447 0
      backend/app/api/users.py
  24. 283 0
      backend/app/api/workers.py
  25. 66 0
      backend/app/celery_app.py
  26. 705 0
      backend/app/errors/__init__.py
  27. 15 0
      backend/app/models/__init__.py
  28. 118 0
      backend/app/models/credential.py
  29. 31 0
      backend/app/models/report.py
  30. 118 0
      backend/app/models/task.py
  31. 53 0
      backend/app/models/user.py
  32. 424 0
      backend/app/scanners/DEVELOPMENT_GUIDE.md
  33. 17 0
      backend/app/scanners/__init__.py
  34. 598 0
      backend/app/scanners/aws_scanner.py
  35. 206 0
      backend/app/scanners/base.py
  36. 235 0
      backend/app/scanners/credentials.py
  37. 4 0
      backend/app/scanners/services/__init__.py
  38. 246 0
      backend/app/scanners/services/compute.py
  39. 166 0
      backend/app/scanners/services/database.py
  40. 149 0
      backend/app/scanners/services/ec2.py
  41. 285 0
      backend/app/scanners/services/elb.py
  42. 292 0
      backend/app/scanners/services/global_services.py
  43. 294 0
      backend/app/scanners/services/monitoring.py
  44. 489 0
      backend/app/scanners/services/vpc.py
  45. 64 0
      backend/app/scanners/utils.py
  46. 38 0
      backend/app/services/__init__.py
  47. 410 0
      backend/app/services/auth_service.py
  48. 1941 0
      backend/app/services/report_generator.py
  49. 263 0
      backend/app/services/report_service.py
  50. 4 0
      backend/app/tasks/__init__.py
  51. 210 0
      backend/app/tasks/mock_tasks.py
  52. 522 0
      backend/app/tasks/scan_tasks.py
  53. 2 0
      backend/app/utils/__init__.py
  54. 43 0
      backend/app/utils/encryption.py
  55. 94 0
      backend/apply_migration.py
  56. 15 0
      backend/celery_worker.py
  57. 3 0
      backend/config/__init__.py
  58. 70 0
      backend/config/settings.py
  59. 151 0
      backend/init_db.py
  60. 1 0
      backend/migrations/README
  61. 50 0
      backend/migrations/alembic.ini
  62. 113 0
      backend/migrations/env.py
  63. 24 0
      backend/migrations/script.py.mako
  64. 36 0
      backend/migrations/versions/002_make_account_id_nullable.py
  65. 148 0
      backend/migrations/versions/7aa055089aea_initial_migration_create_all_tables.py
  66. 13 0
      backend/requirements-dev.txt
  67. 34 0
      backend/requirements.txt
  68. 9 0
      backend/run.py
  69. 32 0
      backend/setup.bat
  70. 36 0
      backend/setup.sh
  71. 201 0
      backend/setup_venv.py
  72. 7 0
      backend/start_celery_worker.bat
  73. 6 0
      backend/start_celery_worker.sh
  74. 117 0
      backend/start_with_redis_check.py
  75. 164 0
      backend/test_celery_task.py
  76. 177 0
      backend/test_credential_update.py
  77. 158 0
      backend/test_redis.py
  78. 134 0
      backend/test_task_api.py
  79. 1 0
      backend/tests/__init__.py
  80. 25 0
      backend/tests/conftest.py
  81. 275 0
      backend/tests/test_auth.py
  82. 274 0
      backend/tests/test_users.py
  83. 165 0
      backend/verify_setup.py
  84. 282 0
      frontend/README_SETUP.md
  85. 13 0
      frontend/index.html
  86. 35 0
      frontend/package.json
  87. 55 0
      frontend/setup.bat
  88. 51 0
      frontend/setup.sh
  89. 72 0
      frontend/src/App.tsx
  90. 70 0
      frontend/src/components/Guards/ProtectedRoute.tsx
  91. 1 0
      frontend/src/components/Guards/index.ts
  92. 134 0
      frontend/src/components/Layout/MainLayout.tsx
  93. 193 0
      frontend/src/contexts/AuthContext.tsx
  94. 1 0
      frontend/src/contexts/index.ts
  95. 2 0
      frontend/src/hooks/useAuth.ts
  96. 55 0
      frontend/src/hooks/usePagination.ts
  97. 17 0
      frontend/src/index.css
  98. 19 0
      frontend/src/main.tsx
  99. 725 0
      frontend/src/pages/Credentials.tsx
  100. 221 0
      frontend/src/pages/Dashboard.tsx

+ 89 - 0
.gitignore

@@ -0,0 +1,89 @@
+# Python
+__pycache__/
+*.py[cod]
+*$py.class
+*.so
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+
+# Virtual Environment
+venv/
+ENV/
+env/
+.venv/
+
+# IDE
+.idea/
+.vscode/
+*.swp
+*.swo
+*~
+
+# Environment variables
+.env
+.env.local
+.env.*.local
+*.env
+
+# Database
+*.db
+*.sqlite
+*.sqlite3
+instance/
+
+# Logs
+*.log
+logs/
+
+# Node.js
+node_modules/
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# Build outputs
+frontend/dist/
+frontend/build/
+
+# Test coverage
+.coverage
+htmlcov/
+.pytest_cache/
+.tox/
+coverage.xml
+*.cover
+
+# Reports and uploads
+backend/reports/
+backend/uploads/
+sample-reports/
+
+# OS files
+.DS_Store
+Thumbs.db
+
+# Celery
+celerybeat-schedule
+celerybeat.pid
+
+# Redis dump
+dump.rdb
+
+# Temporary files
+*.tmp
+*.temp
+*.bak

+ 1050 - 0
.kiro/specs/aws-resource-scanner/design.md

@@ -0,0 +1,1050 @@
+# Design Document: AWS Resource Scanner
+
+## Overview
+
+AWS资源扫描报告工具是一个全栈Web应用,采用前后端分离架构。后端使用Python Flask框架,前端使用React。系统通过Worker进程执行扫描任务,支持多账号、多区域并行扫描,并生成符合模板格式的Word报告。
+
+### 技术栈
+
+- 前端:React + TypeScript + Ant Design
+- 后端:Python Flask + SQLAlchemy
+- 数据库:PostgreSQL (生产) / SQLite3 (开发/测试)
+- AWS SDK:boto3
+- Word文档处理:python-docx
+- 认证:JWT (PyJWT)
+- 任务调度:Celery + Redis
+- 消息队列:Redis (作为Celery Broker和Result Backend)
+
+## Architecture
+
+```mermaid
+graph TB
+    subgraph Frontend["前端 (React)"]
+        UI[用户界面]
+        Auth[认证模块]
+        TaskMgmt[任务管理]
+        ReportView[报告查看]
+        AdminPanel[管理面板]
+    end
+
+    subgraph Backend["后端 (Flask)"]
+        API[REST API]
+        AuthService[认证服务]
+        TaskService[任务服务]
+        CredentialService[凭证服务]
+        ReportService[报告服务]
+        WorkerManager[Worker管理器]
+    end
+
+    subgraph MessageQueue["消息队列"]
+        Redis[(Redis)]
+    end
+
+    subgraph Workers["Celery Workers"]
+        Worker1[Worker 1]
+        Worker2[Worker 2]
+        WorkerN[Worker N]
+    end
+
+    subgraph Scanner["扫描模块"]
+        AWSScanner[AWS扫描器]
+        CloudProvider[云厂商接口]
+    end
+
+    subgraph Storage["存储"]
+        DB[(数据库)]
+        FileStore[文件存储]
+    end
+
+    subgraph AWS["AWS Cloud"]
+        AWSServices[AWS Services]
+    end
+
+    UI --> API
+    API --> AuthService
+    API --> TaskService
+    API --> CredentialService
+    API --> ReportService
+    API --> WorkerManager
+
+    TaskService --> Redis
+    Redis --> Worker1
+    Redis --> Worker2
+    Redis --> WorkerN
+
+    Worker1 --> AWSScanner
+    Worker2 --> AWSScanner
+    WorkerN --> AWSScanner
+
+    AWSScanner --> CloudProvider
+    AWSScanner --> AWSServices
+
+    AuthService --> DB
+    TaskService --> DB
+    CredentialService --> DB
+    ReportService --> DB
+    ReportService --> FileStore
+    
+    Worker1 --> Redis
+    Worker2 --> Redis
+    WorkerN --> Redis
+```
+
+## Components and Interfaces
+
+### 1. 前端组件
+
+#### 1.1 认证模块 (Auth)
+```typescript
+interface LoginRequest {
+  username: string;
+  password: string;
+}
+
+interface LoginResponse {
+  token: string;
+  user: User;
+}
+
+interface User {
+  id: number;
+  username: string;
+  email: string;
+  role: 'admin' | 'power_user' | 'user';
+}
+```
+
+#### 1.2 任务管理模块 (TaskManagement)
+```typescript
+interface ScanTask {
+  id: number;
+  name: string;
+  status: 'pending' | 'running' | 'completed' | 'failed';
+  progress: number;
+  createdAt: string;
+  completedAt?: string;
+  createdBy: number;
+  accounts: string[];
+  regions: string[];
+  projectMetadata: ProjectMetadata;
+  errorLogs?: ErrorLog[];
+}
+
+interface ProjectMetadata {
+  clientName: string;
+  projectName: string;
+  bdManager: string;
+  solutionsArchitect: string;
+  cloudEngineer: string;
+  cloudEngineerEmail: string;
+  networkDiagram?: File;
+}
+
+interface CreateTaskRequest {
+  name: string;
+  credentialIds: number[];
+  regions: string[];
+  projectMetadata: ProjectMetadata;
+}
+```
+
+#### 1.3 报告模块 (Report)
+```typescript
+interface Report {
+  id: number;
+  taskId: number;
+  fileName: string;
+  fileSize: number;
+  createdAt: string;
+  downloadUrl: string;
+}
+```
+
+### 2. 后端API接口
+
+#### 2.0 通用分页响应格式
+```json
+{
+  "data": [...],
+  "pagination": {
+    "page": 1,
+    "page_size": 20,
+    "total": 100,
+    "total_pages": 5
+  }
+}
+```
+
+所有列表API默认分页参数:
+- `page`: 页码,默认1
+- `page_size`: 每页数量,默认20,最大100
+
+#### 2.1 认证API
+```
+POST /api/auth/login          - 用户登录
+POST /api/auth/logout         - 用户登出
+GET  /api/auth/me             - 获取当前用户信息
+POST /api/auth/refresh        - 刷新Token
+```
+
+#### 2.2 用户管理API (Admin)
+```
+GET  /api/users               - 获取用户列表 (支持分页: page, page_size, 支持搜索: search)
+POST /api/users/create        - 创建用户
+POST /api/users/update        - 更新用户
+POST /api/users/delete        - 删除用户
+POST /api/users/assign-credentials - 分配凭证给用户
+```
+
+#### 2.3 凭证管理API
+```
+GET  /api/credentials         - 获取凭证列表 (支持分页: page, page_size)
+POST /api/credentials/create  - 创建凭证
+POST /api/credentials/update  - 更新凭证
+POST /api/credentials/delete  - 删除凭证
+POST /api/credentials/validate - 验证凭证
+GET  /api/credentials/base-role    - 获取基础Assume Role配置
+POST /api/credentials/base-role    - 更新基础Assume Role配置
+```
+
+#### 2.4 任务管理API
+```
+GET  /api/tasks               - 获取任务列表 (支持分页: page, page_size, 支持筛选: status)
+POST /api/tasks/create        - 创建任务
+GET  /api/tasks/detail        - 获取任务详情 (query param: id)
+POST /api/tasks/delete        - 删除任务
+GET  /api/tasks/logs          - 获取任务日志 (query param: id, 支持分页: page, page_size)
+```
+
+#### 2.5 报告管理API
+```
+GET  /api/reports             - 获取报告列表 (支持分页: page, page_size, 支持筛选: task_id)
+GET  /api/reports/detail      - 获取报告详情 (query param: id)
+GET  /api/reports/download    - 下载报告 (query param: id)
+POST /api/reports/delete      - 删除报告
+```
+
+#### 2.6 Worker管理API (Admin)
+```
+GET  /api/workers             - 获取Celery Worker状态列表
+GET  /api/workers/stats       - 获取Worker统计信息
+POST /api/workers/purge       - 清除队列中的任务
+```
+
+### 3. Celery任务接口
+
+#### 3.1 Celery配置
+```python
+from celery import Celery
+
+# Celery配置
+celery_app = Celery(
+    'aws_scanner',
+    broker='redis://localhost:6379/0',
+    backend='redis://localhost:6379/1'
+)
+
+celery_app.conf.update(
+    task_serializer='json',
+    accept_content=['json'],
+    result_serializer='json',
+    timezone='UTC',
+    enable_utc=True,
+    task_track_started=True,
+    task_time_limit=3600,  # 1小时超时
+    worker_prefetch_multiplier=1,  # 每个worker一次只取一个任务
+    task_acks_late=True,  # 任务完成后才确认
+)
+```
+
+#### 3.2 Celery任务定义
+```python
+from celery import shared_task, current_task
+from typing import List, Dict, Any
+
+@shared_task(bind=True, max_retries=3)
+def scan_aws_resources(
+    self,
+    task_id: int,
+    credential_ids: List[int],
+    regions: List[str],
+    project_metadata: Dict[str, Any]
+) -> Dict[str, Any]:
+    """
+    执行AWS资源扫描任务
+    
+    Args:
+        task_id: 数据库中的任务ID
+        credential_ids: AWS凭证ID列表
+        regions: 要扫描的区域列表
+        project_metadata: 项目元数据
+    
+    Returns:
+        扫描结果和报告路径
+    """
+    try:
+        # 更新任务状态为运行中
+        update_task_status(task_id, 'running')
+        
+        # 执行扫描
+        results = {}
+        total_steps = len(credential_ids) * len(regions)
+        current_step = 0
+        
+        for cred_id in credential_ids:
+            for region in regions:
+                # 扫描资源
+                resources = scan_region(cred_id, region)
+                results[f"{cred_id}_{region}"] = resources
+                
+                # 更新进度
+                current_step += 1
+                progress = int((current_step / total_steps) * 100)
+                self.update_state(
+                    state='PROGRESS',
+                    meta={'progress': progress, 'current': current_step, 'total': total_steps}
+                )
+                update_task_progress(task_id, progress)
+        
+        # 生成报告
+        report_path = generate_report(task_id, results, project_metadata)
+        
+        # 更新任务状态为完成
+        update_task_status(task_id, 'completed', report_path=report_path)
+        
+        return {'status': 'success', 'report_path': report_path}
+        
+    except Exception as e:
+        # 记录错误并更新状态
+        log_task_error(task_id, str(e))
+        update_task_status(task_id, 'failed')
+        raise self.retry(exc=e, countdown=60)  # 60秒后重试
+
+@shared_task
+def cleanup_old_reports(days: int = 30):
+    """清理过期报告"""
+    pass
+
+@shared_task
+def validate_credentials(credential_id: int) -> bool:
+    """验证AWS凭证有效性"""
+    pass
+```
+
+#### 3.3 任务状态查询
+```python
+from celery.result import AsyncResult
+
+def get_task_status(celery_task_id: str) -> Dict[str, Any]:
+    """获取Celery任务状态"""
+    result = AsyncResult(celery_task_id)
+    
+    if result.state == 'PENDING':
+        return {'status': 'pending', 'progress': 0}
+    elif result.state == 'PROGRESS':
+        return {
+            'status': 'running',
+            'progress': result.info.get('progress', 0),
+            'current': result.info.get('current', 0),
+            'total': result.info.get('total', 0)
+        }
+    elif result.state == 'SUCCESS':
+        return {'status': 'completed', 'result': result.result}
+    elif result.state == 'FAILURE':
+        return {'status': 'failed', 'error': str(result.result)}
+    else:
+        return {'status': result.state}
+```
+
+### 4. 扫描器接口 (可扩展)
+
+```python
+from abc import ABC, abstractmethod
+from typing import List, Dict, Any
+
+class CloudProviderScanner(ABC):
+    """云厂商扫描器抽象基类"""
+    
+    @abstractmethod
+    def get_credentials(self, credential_config: dict) -> Any:
+        """获取云厂商凭证"""
+        pass
+    
+    @abstractmethod
+    def list_regions(self) -> List[str]:
+        """列出可用区域"""
+        pass
+    
+    @abstractmethod
+    def scan_resources(self, regions: List[str], services: List[str]) -> Dict[str, List[dict]]:
+        """扫描资源"""
+        pass
+
+class AWSScanner(CloudProviderScanner):
+    """AWS扫描器实现"""
+    
+    def __init__(self, credential_type: str, credential_config: dict):
+        self.credential_type = credential_type  # 'assume_role' or 'access_key'
+        self.credential_config = credential_config
+    
+    def get_credentials(self, credential_config: dict) -> boto3.Session:
+        """获取AWS Session"""
+        pass
+    
+    def list_regions(self) -> List[str]:
+        """列出AWS区域"""
+        pass
+    
+    def scan_resources(self, regions: List[str], services: List[str]) -> Dict[str, List[dict]]:
+        """并行扫描AWS资源"""
+        pass
+```
+
+## Data Models
+
+### 数据库模型
+
+```python
+from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text, Boolean, Enum
+from sqlalchemy.orm import relationship
+from datetime import datetime
+
+class User(Base):
+    __tablename__ = 'users'
+    
+    id = Column(Integer, primary_key=True)
+    username = Column(String(50), unique=True, nullable=False)
+    email = Column(String(100), unique=True, nullable=False)
+    password_hash = Column(String(255), nullable=False)
+    role = Column(Enum('admin', 'power_user', 'user'), default='user')
+    created_at = Column(DateTime, default=datetime.utcnow)
+    is_active = Column(Boolean, default=True)
+    
+    credentials = relationship('UserCredential', back_populates='user')
+    tasks = relationship('Task', back_populates='created_by_user')
+
+class AWSCredential(Base):
+    __tablename__ = 'aws_credentials'
+    
+    id = Column(Integer, primary_key=True)
+    name = Column(String(100), nullable=False)
+    credential_type = Column(Enum('assume_role', 'access_key'), nullable=False)
+    account_id = Column(String(12), nullable=False)
+    # For assume_role
+    role_arn = Column(String(255))
+    external_id = Column(String(255))
+    # For access_key (encrypted)
+    access_key_id = Column(String(255))
+    secret_access_key_encrypted = Column(Text)
+    created_at = Column(DateTime, default=datetime.utcnow)
+    is_active = Column(Boolean, default=True)
+    
+    users = relationship('UserCredential', back_populates='credential')
+
+class UserCredential(Base):
+    __tablename__ = 'user_credentials'
+    
+    id = Column(Integer, primary_key=True)
+    user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
+    credential_id = Column(Integer, ForeignKey('aws_credentials.id'), nullable=False)
+    assigned_at = Column(DateTime, default=datetime.utcnow)
+    
+    user = relationship('User', back_populates='credentials')
+    credential = relationship('AWSCredential', back_populates='users')
+
+class BaseAssumeRoleConfig(Base):
+    __tablename__ = 'base_assume_role_config'
+    
+    id = Column(Integer, primary_key=True)
+    access_key_id = Column(String(255), nullable=False)
+    secret_access_key_encrypted = Column(Text, nullable=False)
+    updated_at = Column(DateTime, default=datetime.utcnow)
+
+class Task(Base):
+    __tablename__ = 'tasks'
+    
+    id = Column(Integer, primary_key=True)
+    name = Column(String(200), nullable=False)
+    status = Column(Enum('pending', 'running', 'completed', 'failed'), default='pending')
+    progress = Column(Integer, default=0)
+    created_by = Column(Integer, ForeignKey('users.id'), nullable=False)
+    created_at = Column(DateTime, default=datetime.utcnow)
+    started_at = Column(DateTime)
+    completed_at = Column(DateTime)
+    celery_task_id = Column(String(100))  # Celery任务ID,用于查询状态
+    
+    # Task configuration (JSON)
+    credential_ids = Column(Text)  # JSON array
+    regions = Column(Text)  # JSON array
+    project_metadata = Column(Text)  # JSON object
+    
+    created_by_user = relationship('User', back_populates='tasks')
+    logs = relationship('TaskLog', back_populates='task')
+    report = relationship('Report', back_populates='task', uselist=False)
+
+class TaskLog(Base):
+    __tablename__ = 'task_logs'
+    
+    id = Column(Integer, primary_key=True)
+    task_id = Column(Integer, ForeignKey('tasks.id'), nullable=False)
+    level = Column(Enum('info', 'warning', 'error'), default='info')
+    message = Column(Text, nullable=False)
+    details = Column(Text)  # JSON for stack trace, etc.
+    created_at = Column(DateTime, default=datetime.utcnow)
+    
+    task = relationship('Task', back_populates='logs')
+
+class Report(Base):
+    __tablename__ = 'reports'
+    
+    id = Column(Integer, primary_key=True)
+    task_id = Column(Integer, ForeignKey('tasks.id'), nullable=False)
+    file_name = Column(String(255), nullable=False)
+    file_path = Column(String(500), nullable=False)
+    file_size = Column(Integer)
+    created_at = Column(DateTime, default=datetime.utcnow)
+    
+    task = relationship('Task', back_populates='report')
+
+class Worker(Base):
+    __tablename__ = 'workers'
+    
+    id = Column(Integer, primary_key=True)
+    worker_id = Column(String(100), unique=True, nullable=False)  # Celery worker hostname
+    status = Column(Enum('online', 'offline'), default='offline')
+    active_tasks = Column(Integer, default=0)
+    processed_tasks = Column(Integer, default=0)
+    last_heartbeat = Column(DateTime)
+    registered_at = Column(DateTime, default=datetime.utcnow)
+```
+
+### AWS资源数据结构
+
+```python
+# 扫描结果的统一数据格式
+class ResourceData:
+    """资源数据基类"""
+    account_id: str
+    region: str
+    service: str
+    resource_type: str
+    resource_id: str
+    name: str
+    attributes: dict  # 服务特定属性
+
+# 表格布局类型
+class TableLayout:
+    HORIZONTAL = 'horizontal'  # 横向表格:列标题在顶部,多行数据(如VPC、Subnet)
+    VERTICAL = 'vertical'      # 纵向表格:属性名在左列,值在右列,每个资源一个表格(如EC2、RDS)
+
+# 各服务的属性定义和表格布局(与sample-reports完全一致)
+SERVICE_CONFIG = {
+    # ===== VPC相关资源 =====
+    'vpc': {
+        'layout': TableLayout.HORIZONTAL,
+        'resources': {
+            'VPC': ['Region', 'Name', 'ID', 'CIDR'],
+        }
+    },
+    'subnet': {
+        'layout': TableLayout.HORIZONTAL,
+        'resources': {
+            'Subnet': ['Name', 'ID', 'AZ', 'CIDR'],
+        }
+    },
+    'route_table': {
+        'layout': TableLayout.HORIZONTAL,
+        'resources': {
+            'Route Table': ['Name', 'ID', 'Subnet Associations'],
+        }
+    },
+    'internet_gateway': {
+        'layout': TableLayout.VERTICAL,
+        'resources': {
+            'Internet Gateway': ['Name'],  # 每个IGW一个表格,只显示Name
+        }
+    },
+    'nat_gateway': {
+        'layout': TableLayout.HORIZONTAL,
+        'resources': {
+            'NAT Gateway': ['Name', 'ID', 'Public IP', 'Private IP'],
+        }
+    },
+    'security_group': {
+        'layout': TableLayout.HORIZONTAL,
+        'resources': {
+            'Security Group': ['Name', 'ID', 'Protocol', 'Port range', 'Source'],
+        }
+    },
+    'vpc_endpoint': {
+        'layout': TableLayout.HORIZONTAL,
+        'resources': {
+            'Endpoint': ['Name', 'ID', 'VPC', 'Service Name', 'Type'],
+        }
+    },
+    'vpc_peering': {
+        'layout': TableLayout.HORIZONTAL,
+        'resources': {
+            'VPC Peering': ['Name', 'Peering Connection ID', 'Requester VPC', 'Accepter VPC'],
+        }
+    },
+    'customer_gateway': {
+        'layout': TableLayout.HORIZONTAL,
+        'resources': {
+            'Customer Gateway': ['Name', 'Customer Gateway ID', 'IP Address'],
+        }
+    },
+    'virtual_private_gateway': {
+        'layout': TableLayout.HORIZONTAL,
+        'resources': {
+            'Virtual Private Gateway': ['Name', 'Virtual Private Gateway ID', 'VPC'],
+        }
+    },
+    'vpn_connection': {
+        'layout': TableLayout.HORIZONTAL,
+        'resources': {
+            'VPN Connection': ['Name', 'VPN ID', 'Routes'],
+        }
+    },
+    
+    # ===== EC2相关资源 =====
+    'ec2': {
+        'layout': TableLayout.VERTICAL,
+        'resources': {
+            'Instance': ['Name', 'Instance ID', 'Instance Type', 'AZ', 'AMI', 
+                        'Public IP', 'Public DNS', 'Private IP', 'VPC ID', 'Subnet ID',
+                        'Key', 'Security Groups', 'EBS Type', 'EBS Size', 'Encryption', 
+                        'Other Requirement'],
+        }
+    },
+    'elastic_ip': {
+        'layout': TableLayout.VERTICAL,
+        'resources': {
+            'Elastic IP': ['Name'],  # 每个EIP一行,只显示Name
+        }
+    },
+    
+    # ===== Auto Scaling =====
+    'autoscaling': {
+        'layout': TableLayout.VERTICAL,
+        'resources': {
+            'Auto Scaling Group': ['Name', 'Launch Template', 'AMI', 'Instance type', 
+                                  'Key', 'Target Groups', 'Desired', 'Min', 'Max', 
+                                  'Scaling Policy'],
+        }
+    },
+    
+    # ===== ELB相关资源 =====
+    'elb': {
+        'layout': TableLayout.VERTICAL,
+        'resources': {
+            'Load Balancer': ['Name', 'Type', 'DNS', 'Scheme', 'VPC', 
+                             'Availability Zones', 'Subnet', 'Security Groups'],
+        }
+    },
+    'target_group': {
+        'layout': TableLayout.VERTICAL,
+        'resources': {
+            'Target Group': ['Load Balancer', 'TG Name', 'Port', 'Protocol', 
+                           'Registered Instances', 'Health Check Path'],
+        }
+    },
+    
+    # ===== RDS =====
+    'rds': {
+        'layout': TableLayout.VERTICAL,
+        'resources': {
+            'DB Instance': ['Region', 'Endpoint', 'DB instance ID', 'DB name', 
+                          'Master Username', 'Port', 'DB Engine', 'DB Version',
+                          'Instance Type', 'Storage type', 'Storage', 'Multi-AZ',
+                          'Security Group', 'Deletion Protection', 
+                          'Performance Insights Enabled', 'CloudWatch Logs'],
+        }
+    },
+    
+    # ===== ElastiCache =====
+    'elasticache': {
+        'layout': TableLayout.VERTICAL,
+        'resources': {
+            'Cache Cluster': ['Cluster ID', 'Engine', 'Engine Version', 'Node Type', 
+                            'Num Nodes', 'Status'],
+        }
+    },
+    
+    # ===== EKS =====
+    'eks': {
+        'layout': TableLayout.VERTICAL,
+        'resources': {
+            'Cluster': ['Cluster Name', 'Version', 'Status', 'Endpoint', 'VPC ID'],
+        }
+    },
+    
+    # ===== Lambda =====
+    'lambda': {
+        'layout': TableLayout.HORIZONTAL,
+        'resources': {
+            'Function': ['Function Name', 'Runtime', 'Memory (MB)', 'Timeout (s)', 'Last Modified'],
+        }
+    },
+    
+    # ===== S3 =====
+    's3': {
+        'layout': TableLayout.VERTICAL,
+        'resources': {
+            'Bucket': ['Bucket Name'],  # 每个Bucket一行,只显示Name
+        }
+    },
+    's3_event_notification': {
+        'layout': TableLayout.VERTICAL,
+        'resources': {
+            'S3 event notification': ['Bucket', 'Name', 'Event Type', 
+                                     'Destination type', 'Destination'],
+        }
+    },
+    
+    # ===== CloudFront (Global) =====
+    'cloudfront': {
+        'layout': TableLayout.VERTICAL,
+        'resources': {
+            'Distribution': ['CloudFront ID', 'Domain Name', 'CNAME', 
+                           'Origin Domain Name', 'Origin Protocol Policy',
+                           'Viewer Protocol Policy', 'Allowed HTTP Methods', 
+                           'Cached HTTP Methods'],
+        }
+    },
+    
+    # ===== Route 53 (Global) =====
+    'route53': {
+        'layout': TableLayout.HORIZONTAL,
+        'resources': {
+            'Hosted Zone': ['Zone ID', 'Name', 'Type', 'Record Count'],
+        }
+    },
+    
+    # ===== ACM (Global) =====
+    'acm': {
+        'layout': TableLayout.VERTICAL,
+        'resources': {
+            'Certificate': ['Domain name'],  # 每个证书一行,只显示Domain name
+        }
+    },
+    
+    # ===== WAF (Global) =====
+    'waf': {
+        'layout': TableLayout.HORIZONTAL,
+        'resources': {
+            'Web ACL': ['WebACL Name', 'Scope', 'Rules Count', 'Associated Resources'],
+        }
+    },
+    
+    # ===== SNS =====
+    'sns': {
+        'layout': TableLayout.HORIZONTAL,
+        'resources': {
+            'Topic': ['Topic Name', 'Topic Display Name', 'Subscription Protocol', 
+                     'Subscription Endpoint'],
+        }
+    },
+    
+    # ===== CloudWatch =====
+    'cloudwatch': {
+        'layout': TableLayout.HORIZONTAL,
+        'resources': {
+            'Log Group': ['Log Group Name', 'Retention Days', 'Stored Bytes', 'KMS Encryption'],
+        }
+    },
+    
+    # ===== EventBridge =====
+    'eventbridge': {
+        'layout': TableLayout.HORIZONTAL,
+        'resources': {
+            'Rule': ['Name', 'Description', 'Event Bus', 'State'],
+        }
+    },
+    
+    # ===== CloudTrail =====
+    'cloudtrail': {
+        'layout': TableLayout.HORIZONTAL,
+        'resources': {
+            'Trail': ['Name', 'Multi-Region Trail', 'Log File Validation', 'KMS Encryption'],
+        }
+    },
+    
+    # ===== Config =====
+    'config': {
+        'layout': TableLayout.HORIZONTAL,
+        'resources': {
+            'Config': ['Name', 'Regional Resources', 'Global Resources', 'Retention period'],
+        }
+    },
+}
+```
+
+
+## Correctness Properties
+
+*A property is a characteristic or behavior that should hold true across all valid executions of a system-essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
+
+
+### Property 1: Role-Based Access Control (RBAC)
+
+*For any* user with a given role (admin, power_user, user), the system should enforce the following access rules:
+- Admin users can access all resources (users, credentials, reports, workers)
+- Power users can access all credentials and all reports
+- Regular users can only access their assigned credentials and their own reports
+- Unauthorized access attempts should return 403 Forbidden
+
+**Validates: Requirements 1.3, 1.4, 1.5, 1.6**
+
+### Property 2: JWT Token Validation
+
+*For any* JWT token, the system should:
+- Accept valid, non-expired tokens and grant access
+- Reject expired tokens and require re-authentication
+- Reject malformed or tampered tokens
+
+**Validates: Requirements 1.1, 1.2**
+
+### Property 3: User Creation Validation
+
+*For any* user creation request, the system should require all mandatory fields (username, password, email, role) and reject requests with missing fields.
+
+**Validates: Requirements 1.7**
+
+### Property 4: Credential Assignment Enforcement
+
+*For any* credential assignment to a user, the system should:
+- Record the assignment in the database
+- Enforce the assignment during task creation (users can only use assigned credentials)
+
+**Validates: Requirements 1.8, 2.5**
+
+### Property 5: Sensitive Data Masking
+
+*For any* API response containing credentials, the system should mask sensitive information (Secret Access Keys) and never expose them in plaintext.
+
+**Validates: Requirements 2.7**
+
+### Property 6: Credential Encryption
+
+*For any* sensitive data stored in the database (passwords, AWS secret keys), the system should encrypt them before storage and decrypt only when needed.
+
+**Validates: Requirements 2.3, 9.3**
+
+### Property 7: Task Creation Validation
+
+*For any* task creation request, the system should require selection of at least one AWS account, at least one region, and all required project metadata fields.
+
+**Validates: Requirements 3.1**
+
+### Property 8: Global Resource Scanning
+
+*For any* scan task, regardless of selected regions, the system should always scan global resources (CloudFront, Route 53, ACM, WAF).
+
+**Validates: Requirements 3.2, 5.2**
+
+### Property 9: Regional Resource Filtering
+
+*For any* scan task with selected regions, the system should only scan regional services in those specific regions.
+
+**Validates: Requirements 5.3**
+
+### Property 10: Multi-Account Resource Identification
+
+*For any* scan task involving multiple AWS accounts, every resource record should include the AWS Account ID, and the report should include an Account column in all resource tables.
+
+**Validates: Requirements 3.3, 5.7**
+
+### Property 11: Error Resilience in Scanning
+
+*For any* scan task, if a service scan encounters an error:
+- The error should be logged with full details
+- The scan should continue with other services
+- Services with errors should be excluded from the report (or marked as failed)
+
+**Validates: Requirements 4.5, 5.6, 8.2**
+
+### Property 12: Empty Service Exclusion
+
+*For any* generated report, services with no resources should not appear in the Implementation List section.
+
+**Validates: Requirements 4.6, 6.4**
+
+### Property 13: Retry Mechanism
+
+*For any* AWS API call that fails, the system should retry with exponential backoff up to 3 times before marking it as failed.
+
+**Validates: Requirements 5.5**
+
+### Property 14: Resource Attribute Extraction
+
+*For any* scanned resource, the system should extract all attributes defined in the service column specification.
+
+**Validates: Requirements 5.4**
+
+### Property 15: Template Placeholder Replacement
+
+*For any* generated report, all [placeholder] markers in the template should be replaced with actual values, and no placeholders should remain in the final document.
+
+**Validates: Requirements 6.2**
+
+### Property 16: Report Content Completeness
+
+*For any* generated report:
+- All services with resources should have corresponding tables
+- All project metadata fields should be present in the document
+- The Update History section should include version, date, modifier, and details
+
+**Validates: Requirements 6.3, 6.8, 6.9, 3.8**
+
+### Property 17: Report Storage and Accessibility
+
+*For any* completed task, the generated report should be stored and accessible for download by authorized users.
+
+**Validates: Requirements 6.6, 6.7**
+
+### Property 18: Error Logging Completeness
+
+*For any* error that occurs in the system, the log entry should include timestamp, context, and stack trace.
+
+**Validates: Requirements 8.1**
+
+### Property 19: Task Error Display
+
+*For any* task with errors, the error logs should be retrievable and displayable to the user.
+
+**Validates: Requirements 8.3**
+
+### Property 20: System Error Resilience
+
+*For any* critical error, the system should not crash but gracefully handle the error and continue operation.
+
+**Validates: Requirements 8.5**
+
+### Property 21: Database Migration Safety
+
+*For any* database schema migration, existing data should be preserved without loss.
+
+**Validates: Requirements 9.4**
+
+### Property 22: Worker Task Retry
+
+*For any* failed task, the Celery worker should retry up to 3 times with exponential backoff before marking it as permanently failed.
+
+**Validates: Requirements 4.10**
+
+## Error Handling
+
+### Error Categories
+
+1. **Authentication Errors**
+   - Invalid credentials (401 Unauthorized)
+   - Expired token (401 Unauthorized)
+   - Insufficient permissions (403 Forbidden)
+
+2. **Validation Errors**
+   - Missing required fields (400 Bad Request)
+   - Invalid field format (400 Bad Request)
+   - Resource not found (404 Not Found)
+
+3. **AWS API Errors**
+   - Credential validation failure
+   - API rate limiting (throttling)
+   - Service unavailable
+   - Access denied
+
+4. **System Errors**
+   - Database connection failure
+   - File system errors
+   - Worker communication failure
+
+### Error Response Format
+
+```json
+{
+  "error": {
+    "code": "ERROR_CODE",
+    "message": "Human-readable error message",
+    "details": {
+      "field": "specific field with error",
+      "reason": "detailed reason"
+    }
+  }
+}
+```
+
+### Retry Strategy
+
+```python
+class RetryConfig:
+    max_retries: int = 3
+    base_delay: float = 1.0  # seconds
+    max_delay: float = 30.0  # seconds
+    exponential_base: float = 2.0
+
+def retry_with_backoff(func, config: RetryConfig):
+    for attempt in range(config.max_retries):
+        try:
+            return func()
+        except RetryableError as e:
+            if attempt == config.max_retries - 1:
+                raise
+            delay = min(
+                config.base_delay * (config.exponential_base ** attempt),
+                config.max_delay
+            )
+            time.sleep(delay)
+```
+
+## Testing Strategy
+
+### Unit Tests
+
+Unit tests will focus on:
+- Individual component functionality
+- Input validation
+- Error handling
+- Data transformation
+
+### Property-Based Tests
+
+Property-based tests will use `hypothesis` library to verify:
+- RBAC enforcement across all role combinations
+- JWT token validation with various token states
+- Data masking for all credential types
+- Report generation with various resource combinations
+
+### Integration Tests
+
+Integration tests will verify:
+- API endpoint functionality
+- Database operations
+- Worker communication
+- AWS API interactions (using moto for mocking)
+
+### Test Configuration
+
+```python
+# pytest configuration
+import pytest
+from hypothesis import settings
+
+# Property test settings
+settings.register_profile("ci", max_examples=100)
+settings.register_profile("dev", max_examples=20)
+settings.load_profile("ci")
+```
+
+### Test Tagging Format
+
+Each property test should be tagged with:
+```python
+@pytest.mark.property
+def test_rbac_enforcement():
+    """
+    Feature: aws-resource-scanner
+    Property 1: Role-Based Access Control (RBAC)
+    Validates: Requirements 1.3, 1.4, 1.5, 1.6
+    """
+    pass
+```

+ 179 - 0
.kiro/specs/aws-resource-scanner/requirements.md

@@ -0,0 +1,179 @@
+# Requirements Document
+
+## Introduction
+
+AWS资源扫描报告工具是一个全栈Web应用,用于扫描AWS账号中的资源信息,并按照Word模板生成专业的项目报告文档。系统支持多账号、多区域并行扫描,具有完整的用户权限管理、任务调度和报告管理功能。系统设计为可扩展架构,预留接入其他云厂商的能力。
+
+## Glossary
+
+- **Scanner**: 资源扫描器,负责通过boto3扫描AWS资源
+- **Worker**: 工作进程,执行用户提交的扫描任务
+- **Report**: 生成的Word格式项目报告文档
+- **AWS_Credential**: AWS凭证,包括IAM Role或Access Key
+- **Assume_Role**: AWS跨账号访问机制,通过STS获取临时凭证
+- **Global_Resource**: 全局资源,如CloudFront、IAM等不区分区域的服务
+- **Task**: 用户提交的扫描任务,包含账号、区域、项目信息等
+- **JWT**: JSON Web Token,用于用户认证和会话管理
+- **Admin**: 管理员角色,拥有系统全部权限
+- **Power_User**: 高级用户角色,可使用所有凭证生成报告
+- **User**: 普通用户角色,仅可使用分配的凭证和查看自己的报告
+
+## Requirements
+
+### Requirement 1: 用户认证与授权
+
+**User Story:** 作为系统管理员,我希望系统具有完善的用户认证和权限管理,以确保系统安全和数据隔离。
+
+#### Acceptance Criteria
+
+1. WHEN a user attempts to login THEN THE System SHALL validate credentials and return a JWT token upon success
+2. WHEN a JWT token expires THEN THE System SHALL reject the request and require re-authentication
+3. WHEN an Admin user accesses the system THEN THE System SHALL allow management of all AWS credentials, reports, and system users
+4. WHEN a Power_User accesses the system THEN THE System SHALL allow selection of all AWS credentials and viewing of all reports
+5. WHEN a User accesses the system THEN THE System SHALL only allow viewing reports created by that user and using assigned AWS credentials
+6. IF an unauthorized user attempts to access restricted resources THEN THE System SHALL return a 403 Forbidden response
+7. WHEN an Admin creates a new user THEN THE System SHALL require username, password, email, and role assignment
+8. WHEN an Admin assigns AWS credentials to a User THEN THE System SHALL record the assignment and enforce it during task creation
+
+### Requirement 2: AWS凭证管理
+
+**User Story:** 作为管理员,我希望能够管理AWS凭证(IAM Role Arn和Access Key),IAM Role用于Assume Role,以便用户可以安全地扫描不同的AWS账号。
+
+#### Acceptance Criteria
+
+1. WHEN an Admin adds an AWS credential THEN THE System SHALL store the credential type (Role or Access Key), account ID, and related configuration
+2. WHEN using Assume_Role authentication THEN THE System SHALL use a centralized base account configured by Admin to assume roles in target accounts
+3. WHEN using Access_Key authentication THEN THE System SHALL securely store and use the Access Key ID and Secret Access Key
+4. WHEN an Admin configures the base Assume_Role account THEN THE System SHALL validate the credentials before saving
+5. WHEN an Admin assigns credentials to a User THEN THE System SHALL create an association record between the user and credential
+6. IF AWS credential validation fails THEN THE System SHALL display a clear error message and not save invalid credentials
+7. WHEN listing credentials THEN THE System SHALL mask sensitive information like Secret Access Keys
+
+### Requirement 3: 扫描任务管理
+
+**User Story:** 作为用户,我希望能够创建和管理AWS资源扫描任务,以便生成项目报告。
+
+#### Acceptance Criteria
+
+1. WHEN a user creates a scan task THEN THE System SHALL require selection of AWS accounts, regions, and project metadata
+2. WHEN a user selects regions THEN THE System SHALL allow multiple region selection and always include Global_Resource scanning
+3. WHEN a user selects multiple AWS accounts THEN THE System SHALL add an AWS Account column to all resource tables in the report
+4. WHEN a task is submitted THEN THE System SHALL queue it for Worker processing and return a task ID
+5. WHILE a task is in progress THEN THE System SHALL provide real-time status updates via automatic refresh
+6. WHEN a task completes THEN THE System SHALL generate a Word report using the configured template
+7. IF a task fails THEN THE System SHALL log the error details and display them to the user
+8. WHEN a user provides project metadata THEN THE System SHALL include Client Name, Project Name, BD Manager, Solutions Architect, Cloud Engineer, and Network Diagram in the report
+
+### Requirement 4: Worker任务执行
+
+**User Story:** 作为系统,我需要通过Celery Worker执行扫描任务,以实现任务隔离和并行处理。
+
+#### Acceptance Criteria
+
+1. WHEN the main program receives a task THEN THE System SHALL dispatch it to Celery queue for Worker processing
+2. WHEN a Worker receives a task THEN THE Worker SHALL execute the scan for all specified accounts and regions
+3. WHILE scanning multiple accounts or regions THEN THE Worker SHALL process them in parallel to improve performance
+4. WHEN scanning a single account THEN THE Worker SHALL scan all supported AWS services concurrently
+5. IF a service scan encounters an error THEN THE Worker SHALL log the error and continue with other services
+6. WHEN a service has no resources THEN THE Worker SHALL exclude it from the final report
+7. WHEN scanning completes THEN THE Worker SHALL generate the Word report and update task status in database
+8. WHEN an Admin views workers THEN THE System SHALL display Celery worker status from Redis
+9. WHILE a task is running THEN THE Worker SHALL report progress updates to Redis for real-time status display
+10. IF a task fails THEN THE Worker SHALL retry up to 3 times with exponential backoff
+
+### Requirement 5: AWS资源扫描
+
+**User Story:** 作为用户,我希望工具能够全面扫描AWS资源,以便生成完整的资源清单报告。
+
+#### Acceptance Criteria
+
+1. WHEN scanning AWS resources THEN THE Scanner SHALL use boto3 to query the following services:
+   - VPC: VPCs, Subnets, Route Tables, Internet Gateways, NAT Gateways, Security Groups, VPC Endpoints, VPC Peering Connections
+   - VPN: Customer Gateways, Virtual Private Gateways, VPN Connections
+   - EC2: Instances (with EBS volumes, AMI info), Elastic IPs
+   - Auto Scaling Group: Auto Scaling Groups (with Launch Templates)
+   - ELB: Application Load Balancers, Network Load Balancers, Classic Load Balancers, Target Groups
+   - RDS: DB Instances
+   - ElastiCache: Cache Clusters
+   - EKS: Clusters
+   - Lambda: Functions
+   - S3: Buckets, S3 Event Notifications
+   - CloudFront: Distributions (Global)
+   - Route 53: Hosted Zones (Global)
+   - ACM: Certificates (Global)
+   - WAF: Web ACLs (Global)
+   - SNS: Topics, Subscriptions
+   - CloudWatch: Log Groups
+   - EventBridge: Rules
+   - CloudTrail: Trails
+   - Config: Configuration Recorders
+2. WHEN scanning Global_Resource services (CloudFront, Route 53, ACM, WAF) THEN THE Scanner SHALL scan them regardless of selected regions
+3. WHEN scanning regional services THEN THE Scanner SHALL only scan the user-selected regions
+4. WHEN a resource is found THEN THE Scanner SHALL extract relevant attributes for the report based on service-specific column definitions
+5. IF an API call fails THEN THE Scanner SHALL retry with exponential backoff up to 3 times
+6. IF all retries fail THEN THE Scanner SHALL log the error and skip that resource type
+7. WHEN scanning multiple accounts THEN THE Scanner SHALL include the AWS Account ID in each resource record
+
+### Requirement 6: 报告生成
+
+**User Story:** 作为用户,我希望生成的报告符合专业模板格式,以便直接用于客户交付。
+
+#### Acceptance Criteria
+
+1. WHEN generating a report THEN THE System SHALL use the Word template from sample-reports folder
+2. WHEN filling template placeholders THEN THE System SHALL replace all [placeholder] markers with actual values including [Project Name], [Client Name], [Cloud Engineer Name], [Cloud Engineer Email], [BD Manager], [Solutions Architect], and date fields
+3. WHEN generating Implementation List THEN THE System SHALL create tables for each service with resources organized by service category
+4. WHEN a service has no resources THEN THE System SHALL exclude that service section from the report
+5. WHEN report generation completes THEN THE System SHALL update the document table of contents automatically
+6. WHEN a report is ready THEN THE System SHALL store it and make it available for download
+7. WHEN displaying reports THEN THE System SHALL show report metadata including creation time, status, and file size
+8. WHEN user provides a Network Diagram image THEN THE System SHALL embed it in the appropriate section of the report
+9. WHEN generating Update History THEN THE System SHALL include version, date, modifier, and details
+
+### Requirement 7: 前端用户界面
+
+**User Story:** 作为用户,我希望有一个简洁美观的前端界面,以便轻松操作系统功能。
+
+#### Acceptance Criteria
+
+1. WHEN a user accesses the frontend THEN THE System SHALL display a clean and modern React-based interface
+2. WHEN viewing the dashboard THEN THE System SHALL show task status summary and recent reports
+3. WHEN creating a task THEN THE System SHALL provide intuitive forms for account, region, and metadata selection
+4. WHILE a task is running THEN THE System SHALL auto-refresh the status display
+5. WHEN viewing reports THEN THE System SHALL allow preview, download, and deletion operations
+6. WHEN an Admin accesses admin pages THEN THE System SHALL display user management, credential management, and worker management interfaces
+7. IF an error occurs THEN THE System SHALL display user-friendly error messages with details available
+
+### Requirement 8: 错误处理与日志
+
+**User Story:** 作为管理员,我希望系统具有完善的错误处理和日志机制,以便排查问题。
+
+#### Acceptance Criteria
+
+1. WHEN any operation fails THEN THE System SHALL log the error with timestamp, context, and stack trace
+2. WHEN a scan task encounters errors THEN THE System SHALL record error details in the task record
+3. WHEN viewing task details THEN THE System SHALL display any error logs associated with that task
+4. WHEN an Admin views system logs THEN THE System SHALL provide filtering and search capabilities
+5. IF a critical error occurs THEN THE System SHALL not crash but gracefully handle and report the error
+
+### Requirement 9: 数据持久化
+
+**User Story:** 作为系统,我需要可靠的数据存储,以便在不同环境下正常运行。
+
+#### Acceptance Criteria
+
+1. WHEN running in production THEN THE System SHALL use PostgreSQL as the database
+2. WHEN running in test/development THEN THE System SHALL use SQLite3 as the database
+3. WHEN storing sensitive data (passwords, AWS secrets) THEN THE System SHALL encrypt them before storage
+4. WHEN the database schema changes THEN THE System SHALL support migrations without data loss
+
+### Requirement 10: 系统扩展性
+
+**User Story:** 作为架构师,我希望系统设计具有扩展性,以便未来接入其他云厂商。
+
+#### Acceptance Criteria
+
+1. WHEN designing the scanner module THEN THE System SHALL use an abstract interface that can be implemented for different cloud providers
+2. WHEN adding a new cloud provider THEN THE System SHALL only require implementing the provider-specific scanner without modifying core logic
+3. WHEN storing credentials THEN THE System SHALL support different credential types for different cloud providers
+4. WHEN generating reports THEN THE System SHALL use a provider-agnostic data format internally

+ 427 - 0
.kiro/specs/aws-resource-scanner/tasks.md

@@ -0,0 +1,427 @@
+# Implementation Plan: AWS Resource Scanner
+
+## Overview
+
+本实现计划将AWS资源扫描报告工具分解为可执行的开发任务。采用前后端分离架构,后端使用Python Flask + Celery,前端使用React + TypeScript + Ant Design。
+
+## Tasks
+
+- [x] 1. 项目初始化和基础架构
+  - [x] 1.1 创建后端项目结构
+    - 初始化Flask项目,配置目录结构(app/, config/, migrations/, tests/)
+    - 配置requirements.txt(Flask, SQLAlchemy, Celery, Redis, boto3, python-docx, PyJWT等)
+    - 创建配置文件(开发/测试/生产环境)
+    - _Requirements: 9.1, 9.2_
+
+  - [x] 1.2 创建前端项目结构
+    - 使用Vite创建React + TypeScript项目
+    - 配置Ant Design和相关依赖
+    - 设置项目目录结构(components/, pages/, services/, hooks/, types/)
+    - _Requirements: 7.1_
+
+  - [x] 1.3 配置数据库和ORM
+    - 创建SQLAlchemy模型基类
+    - 配置数据库连接(PostgreSQL/SQLite3切换)
+    - 设置Flask-Migrate数据库迁移
+    - _Requirements: 9.1, 9.2, 9.4_
+
+  - [x] 1.4 配置Celery和Redis
+    - 创建Celery应用配置
+    - 配置Redis作为Broker和Result Backend
+    - 设置任务序列化和超时配置
+    - _Requirements: 4.1_
+
+- [x] 2. 用户认证模块
+  - [x] 2.1 实现用户数据模型
+    - 创建User模型(username, email, password_hash, role)
+    - 实现密码加密存储(bcrypt)
+    - _Requirements: 1.7, 9.3_
+
+  - [x] 2.2 实现JWT认证服务
+    - 创建JWT token生成和验证逻辑
+    - 实现token过期检查
+    - 创建认证装饰器
+    - _Requirements: 1.1, 1.2_
+
+  - [x] 2.3 实现认证API端点
+    - POST /api/auth/login - 用户登录
+    - POST /api/auth/logout - 用户登出
+    - GET /api/auth/me - 获取当前用户
+    - POST /api/auth/refresh - 刷新Token
+    - _Requirements: 1.1, 1.2_
+
+  - [ ]* 2.4 编写认证模块属性测试
+    - **Property 2: JWT Token Validation**
+    - **Validates: Requirements 1.1, 1.2**
+
+  - [x] 2.5 实现RBAC权限控制
+    - 创建角色权限装饰器(admin_required, power_user_required)
+    - 实现资源访问控制逻辑
+    - _Requirements: 1.3, 1.4, 1.5, 1.6_
+
+  - [ ]* 2.6 编写RBAC属性测试
+    - **Property 1: Role-Based Access Control (RBAC)**
+    - **Validates: Requirements 1.3, 1.4, 1.5, 1.6**
+
+
+- [x] 3. 用户管理模块 (Admin)
+  - [x] 3.1 实现用户管理API
+    - GET /api/users - 获取用户列表(分页)
+    - POST /api/users/create - 创建用户
+    - POST /api/users/update - 更新用户
+    - POST /api/users/delete - 删除用户
+    - _Requirements: 1.7_
+
+  - [ ]* 3.2 编写用户创建验证属性测试
+    - **Property 3: User Creation Validation**
+    - **Validates: Requirements 1.7**
+
+- [x] 4. AWS凭证管理模块
+  - [x] 4.1 实现凭证数据模型
+    - 创建AWSCredential模型
+    - 创建UserCredential关联模型
+    - 创建BaseAssumeRoleConfig模型
+    - 实现敏感数据加密存储
+    - _Requirements: 2.1, 2.3, 9.3_
+
+  - [ ]* 4.2 编写凭证加密属性测试
+    - **Property 6: Credential Encryption**
+    - **Validates: Requirements 2.3, 9.3**
+
+  - [x] 4.3 实现凭证管理API
+    - GET /api/credentials - 获取凭证列表(分页,敏感信息脱敏)
+    - POST /api/credentials/create - 创建凭证
+    - POST /api/credentials/update - 更新凭证
+    - POST /api/credentials/delete - 删除凭证
+    - POST /api/credentials/validate - 验证凭证
+    - _Requirements: 2.1, 2.4, 2.6, 2.7_
+
+  - [ ]* 4.4 编写敏感数据脱敏属性测试
+    - **Property 5: Sensitive Data Masking**
+    - **Validates: Requirements 2.7**
+
+  - [x] 4.5 实现凭证分配功能
+    - POST /api/users/assign-credentials - 分配凭证给用户
+    - 实现凭证分配验证逻辑
+    - _Requirements: 1.8, 2.5_
+
+  - [ ]* 4.6 编写凭证分配属性测试
+    - **Property 4: Credential Assignment Enforcement**
+    - **Validates: Requirements 1.8, 2.5**
+
+  - [x] 4.7 实现基础Assume Role配置
+    - GET /api/credentials/base-role - 获取配置
+    - POST /api/credentials/base-role - 更新配置
+    - _Requirements: 2.2_
+
+- [x] 5. Checkpoint - 认证和凭证模块完成
+  - 确保所有测试通过,如有问题请询问用户
+
+
+- [x] 6. AWS扫描器模块
+  - [x] 6.1 实现云厂商扫描器抽象接口
+    - 创建CloudProviderScanner抽象基类
+    - 定义get_credentials, list_regions, scan_resources方法
+    - _Requirements: 10.1, 10.2_
+
+  - [x] 6.2 实现AWS凭证获取
+    - 实现Assume Role凭证获取
+    - 实现Access Key凭证获取
+    - _Requirements: 2.2, 2.3_
+
+  - [x] 6.3 实现AWS扫描器基础框架
+    - 创建AWSScanner类继承CloudProviderScanner
+    - 实现并行扫描逻辑(concurrent.futures)
+    - 实现重试机制(指数退避,最多3次)
+    - _Requirements: 4.3, 4.4, 5.5_
+
+  - [ ]* 6.4 编写重试机制属性测试
+    - **Property 13: Retry Mechanism**
+    - **Validates: Requirements 5.5**
+
+  - [x] 6.5 实现VPC相关资源扫描
+    - VPC, Subnet, Route Table, Internet Gateway, NAT Gateway
+    - Security Group, VPC Endpoint, VPC Peering
+    - Customer Gateway, Virtual Private Gateway, VPN Connection
+    - _Requirements: 5.1_
+
+  - [x] 6.6 实现EC2相关资源扫描
+    - EC2 Instances (含EBS, AMI信息)
+    - Elastic IPs
+    - _Requirements: 5.1_
+
+  - [x] 6.7 实现Auto Scaling和ELB扫描
+    - Auto Scaling Groups (含Launch Template)
+    - Load Balancers (ALB, NLB, CLB)
+    - Target Groups
+    - _Requirements: 5.1_
+
+  - [x] 6.8 实现数据库服务扫描
+    - RDS DB Instances
+    - ElastiCache Clusters
+    - _Requirements: 5.1_
+
+  - [x] 6.9 实现计算和存储服务扫描
+    - EKS Clusters
+    - Lambda Functions
+    - S3 Buckets, S3 Event Notifications
+    - _Requirements: 5.1_
+
+  - [x] 6.10 实现全局服务扫描
+    - CloudFront Distributions
+    - Route 53 Hosted Zones
+    - ACM Certificates
+    - WAF Web ACLs
+    - _Requirements: 5.1, 5.2_
+
+  - [ ]* 6.11 编写全局资源扫描属性测试
+    - **Property 8: Global Resource Scanning**
+    - **Validates: Requirements 3.2, 5.2**
+
+  - [x] 6.12 实现监控和管理服务扫描
+    - SNS Topics
+    - CloudWatch Log Groups
+    - EventBridge Rules
+    - CloudTrail Trails
+    - Config Recorders
+    - _Requirements: 5.1_
+
+  - [ ]* 6.13 编写资源属性提取属性测试
+    - **Property 14: Resource Attribute Extraction**
+    - **Validates: Requirements 5.4**
+
+  - [ ]* 6.14 编写区域过滤属性测试
+    - **Property 9: Regional Resource Filtering**
+    - **Validates: Requirements 5.3**
+
+  - [ ]* 6.15 编写多账号资源标识属性测试
+    - **Property 10: Multi-Account Resource Identification**
+    - **Validates: Requirements 3.3, 5.7**
+
+- [x] 7. Checkpoint - 扫描器模块完成
+  - 确保所有测试通过,如有问题请询问用户
+
+
+- [x] 8. 报告生成模块
+  - [x] 8.1 实现Word模板处理
+    - 加载sample-reports模板文件
+    - 解析模板结构和占位符
+    - _Requirements: 6.1_
+
+  - [x] 8.2 实现占位符替换
+    - 替换项目元数据占位符(Client Name, Project Name等)
+    - 替换日期和版本信息
+    - _Requirements: 6.2_
+
+  - [ ]* 8.3 编写占位符替换属性测试
+    - **Property 15: Template Placeholder Replacement**
+    - **Validates: Requirements 6.2**
+
+  - [x] 8.4 实现横向表格生成(HORIZONTAL)
+    - VPC, Subnet, Route Table, NAT Gateway等服务
+    - Security Group, Lambda, SNS, CloudTrail, Config等服务
+    - _Requirements: 6.3_
+
+  - [x] 8.5 实现纵向表格生成(VERTICAL)
+    - EC2, RDS, ELB, Auto Scaling Group等服务
+    - CloudFront, S3, Internet Gateway等服务
+    - _Requirements: 6.3_
+
+  - [x] 8.6 实现空服务过滤
+    - 跳过无资源的服务
+    - _Requirements: 6.4_
+
+  - [ ]* 8.7 编写空服务排除属性测试
+    - **Property 12: Empty Service Exclusion**
+    - **Validates: Requirements 4.6, 6.4**
+
+  - [x] 8.8 实现网络拓扑图嵌入
+    - 处理用户上传的Network Diagram图片
+    - 嵌入到报告指定位置
+    - _Requirements: 6.8_
+
+  - [x] 8.9 实现目录更新
+    - 自动更新Word文档目录
+    - _Requirements: 6.5_
+
+  - [x] 8.10 实现报告存储
+    - 保存生成的报告文件
+    - 记录报告元数据到数据库
+    - _Requirements: 6.6, 6.7_
+
+  - [ ]* 8.11 编写报告内容完整性属性测试
+    - **Property 16: Report Content Completeness**
+    - **Validates: Requirements 6.3, 6.8, 6.9, 3.8**
+
+- [x] 9. Checkpoint - 报告生成模块完成
+  - 确保所有测试通过,如有问题请询问用户
+
+
+- [x] 10. 任务管理模块
+  - [x] 10.1 实现任务数据模型
+    - 创建Task模型
+    - 创建TaskLog模型
+    - 创建Report模型
+    - _Requirements: 3.1_
+
+  - [x] 10.2 实现Celery扫描任务
+    - 创建scan_aws_resources任务
+    - 实现进度更新逻辑
+    - 实现任务重试机制
+    - _Requirements: 4.1, 4.9, 4.10_
+
+  - [ ]* 10.3 编写任务重试属性测试
+    - **Property 22: Worker Task Retry**
+    - **Validates: Requirements 4.10**
+
+  - [x] 10.4 实现任务管理API
+    - GET /api/tasks - 获取任务列表(分页,状态筛选)
+    - POST /api/tasks/create - 创建任务
+    - GET /api/tasks/detail - 获取任务详情
+    - POST /api/tasks/delete - 删除任务
+    - GET /api/tasks/logs - 获取任务日志(分页)
+    - _Requirements: 3.1, 3.4_
+
+  - [ ]* 10.5 编写任务创建验证属性测试
+    - **Property 7: Task Creation Validation**
+    - **Validates: Requirements 3.1**
+
+  - [x] 10.6 实现报告管理API
+    - GET /api/reports - 获取报告列表(分页)
+    - GET /api/reports/detail - 获取报告详情
+    - GET /api/reports/download - 下载报告
+    - POST /api/reports/delete - 删除报告
+    - _Requirements: 6.6, 6.7_
+
+  - [ ]* 10.7 编写报告访问属性测试
+    - **Property 17: Report Storage and Accessibility**
+    - **Validates: Requirements 6.6, 6.7**
+
+  - [x] 10.8 实现Worker管理API
+    - GET /api/workers - 获取Worker状态
+    - GET /api/workers/stats - 获取统计信息
+    - POST /api/workers/purge - 清除队列任务
+    - _Requirements: 4.8_
+
+- [x] 11. 错误处理和日志模块
+  - [x] 11.1 实现统一错误处理
+    - 创建自定义异常类
+    - 实现全局异常处理器
+    - 统一错误响应格式
+    - _Requirements: 8.1, 8.5_
+
+  - [ ]* 11.2 编写错误日志完整性属性测试
+    - **Property 18: Error Logging Completeness**
+    - **Validates: Requirements 8.1**
+
+  - [x] 11.3 实现任务错误日志
+    - 记录扫描过程中的错误
+    - 关联错误日志到任务
+    - _Requirements: 8.2, 8.3_
+
+  - [ ]* 11.4 编写任务错误显示属性测试
+    - **Property 19: Task Error Display**
+    - **Validates: Requirements 8.3**
+
+  - [ ]* 11.5 编写错误恢复属性测试
+    - **Property 11: Error Resilience in Scanning**
+    - **Validates: Requirements 4.5, 5.6, 8.2**
+
+  - [ ]* 11.6 编写系统错误恢复属性测试
+    - **Property 20: System Error Resilience**
+    - **Validates: Requirements 8.5**
+
+- [x] 12. Checkpoint - 后端核心功能完成
+  - 确保所有测试通过,如有问题请询问用户
+
+
+- [x] 13. 前端认证模块
+  - [x] 13.1 实现登录页面
+    - 创建登录表单组件
+    - 实现JWT token存储和管理
+    - 实现自动登出(token过期)
+    - _Requirements: 7.1_
+
+  - [x] 13.2 实现路由守卫
+    - 创建认证路由守卫
+    - 实现角色权限路由控制
+    - _Requirements: 7.1_
+
+- [x] 14. 前端Dashboard模块
+  - [x] 14.1 实现Dashboard页面
+    - 显示任务状态统计
+    - 显示最近报告列表
+    - _Requirements: 7.2_
+
+- [x] 15. 前端任务管理模块
+  - [x] 15.1 实现任务列表页面
+    - 分页显示任务列表
+    - 状态筛选功能
+    - 任务状态实时刷新
+    - _Requirements: 7.3, 7.4_
+
+  - [x] 15.2 实现创建任务表单
+    - AWS账号选择(多选)
+    - 区域选择(多选)
+    - 项目元数据输入
+    - 网络拓扑图上传
+    - _Requirements: 7.3, 3.1, 3.8_
+
+  - [x] 15.3 实现任务详情页面
+    - 显示任务进度
+    - 显示错误日志
+    - _Requirements: 7.4, 7.7_
+
+- [x] 16. 前端报告管理模块
+  - [x] 16.1 实现报告列表页面
+    - 分页显示报告列表
+    - 显示报告元数据
+    - _Requirements: 7.5_
+
+  - [x] 16.2 实现报告操作
+    - 报告预览功能
+    - 报告下载功能
+    - 报告删除功能
+    - _Requirements: 7.5_
+
+- [x] 17. 前端管理面板模块 (Admin)
+  - [x] 17.1 实现用户管理页面
+    - 用户列表(分页、搜索)
+    - 创建/编辑/删除用户
+    - 凭证分配
+    - _Requirements: 7.6_
+
+  - [x] 17.2 实现凭证管理页面
+    - 凭证列表(分页)
+    - 创建/编辑/删除凭证
+    - 凭证验证
+    - 基础Assume Role配置
+    - _Requirements: 7.6_
+
+  - [x] 17.3 实现Worker管理页面
+    - Worker状态显示
+    - 队列统计信息
+    - _Requirements: 7.6_
+
+- [x] 18. 数据库迁移
+  - [x] 18.1 创建数据库迁移脚本
+    - 生成初始迁移
+    - 测试迁移和回滚
+    - _Requirements: 9.4_
+
+  - [ ]* 18.2 编写数据库迁移安全属性测试
+    - **Property 21: Database Migration Safety**
+    - **Validates: Requirements 9.4**
+
+- [x] 19. Final Checkpoint - 全部功能完成
+  - 确保所有测试通过,如有问题请询问用户
+  - 验证前后端集成
+  - 验证完整扫描和报告生成流程
+
+## Notes
+
+- 标记 `*` 的任务为可选测试任务,可跳过以加快MVP开发
+- 每个任务都引用了具体的需求编号以便追溯
+- Checkpoint任务用于阶段性验证
+- 属性测试验证系统的正确性属性
+- 单元测试验证具体示例和边界情况

+ 165 - 0
CELERY_ISSUE_SOLUTION.md

@@ -0,0 +1,165 @@
+# Celery任务提交问题解决方案
+
+## 问题分析
+
+虽然Redis连接测试通过,但在实际任务提交时仍然切换到Mock模式。经过诊断发现可能的原因:
+
+1. **导入时机问题**: Flask应用运行时的导入环境与独立测试不同
+2. **依赖模块问题**: `scan_tasks.py`导入了多个复杂模块
+3. **Celery Worker状态**: Worker可能没有正确注册任务
+
+## 解决方案
+
+### ✅ 已实现的改进
+
+1. **增强错误诊断**
+   - 添加了详细的错误日志
+   - 分步骤显示导入和连接过程
+   - 区分不同类型的错误
+
+2. **Redis连接预检**
+   - 在导入Celery任务前先测试Redis连接
+   - 使用直接的Redis客户端测试
+   - 避免Celery层面的复杂性
+
+3. **优雅降级机制**
+   - Celery失败时自动切换到Mock模式
+   - 保证应用功能的连续性
+   - 提供清晰的状态提示
+
+### 🔧 启动Celery Worker
+
+要使用真实的Celery功能,需要启动Worker:
+
+```bash
+# 在新的终端窗口中运行
+cd backend
+activate_venv.bat  # Windows
+# 或 source activate_venv.sh  # Unix/Linux
+
+# 启动Celery Worker
+celery -A app.celery_app worker --loglevel=info
+
+# Windows用户可能需要使用
+celery -A app.celery_app worker --loglevel=info --pool=solo
+```
+
+### 🎯 验证步骤
+
+1. **检查Redis状态**
+```bash
+redis-cli ping
+# 应该返回: PONG
+```
+
+2. **检查Celery Worker**
+```bash
+celery -A app.celery_app inspect active
+# 应该显示活跃的Worker
+```
+
+3. **测试任务提交**
+```bash
+python test_celery_task.py
+```
+
+4. **启动应用**
+```bash
+python run.py
+```
+
+### 📋 功能对比
+
+| 场景 | Celery模式 | Mock模式 | 说明 |
+|------|------------|----------|------|
+| Redis可用 + Worker运行 | ✅ | - | 最佳性能 |
+| Redis可用 + 无Worker | ❌ → 🔄 | ✅ | 自动降级 |
+| Redis不可用 | ❌ → 🔄 | ✅ | 自动降级 |
+| 导入错误 | ❌ → 🔄 | ✅ | 自动降级 |
+
+### 🔍 故障排除
+
+#### 1. Redis连接问题
+```bash
+# 检查Redis服务
+redis-cli ping
+
+# 检查端口占用
+netstat -an | grep 6379
+
+# 重启Redis (Windows)
+redis-server --service-stop
+redis-server --service-start
+```
+
+#### 2. Celery Worker问题
+```bash
+# 检查Worker状态
+celery -A app.celery_app inspect stats
+
+# 重启Worker
+# Ctrl+C 停止当前Worker,然后重新启动
+celery -A app.celery_app worker --loglevel=info
+```
+
+#### 3. 导入依赖问题
+```bash
+# 测试模块导入
+python -c "from app.tasks.scan_tasks import scan_aws_resources; print('OK')"
+
+# 检查缺失依赖
+python verify_setup.py
+```
+
+### 🚀 推荐配置
+
+#### 开发环境
+```bash
+# 终端1: 启动Redis (如果未作为服务运行)
+redis-server
+
+# 终端2: 启动Celery Worker
+celery -A app.celery_app worker --loglevel=info
+
+# 终端3: 启动Flask应用
+python run.py
+```
+
+#### 生产环境
+```bash
+# 使用进程管理器 (如supervisor)
+# 配置Redis集群
+# 配置多个Celery Worker
+# 使用Gunicorn启动Flask
+```
+
+### 📊 监控和日志
+
+#### 查看任务状态
+- 前端界面: http://localhost:3000/tasks
+- Celery监控: `celery -A app.celery_app flower` (需要安装flower)
+
+#### 日志位置
+- Flask应用日志: 控制台输出
+- Celery Worker日志: Worker终端输出
+- 任务执行日志: 数据库TaskLog表
+
+### 💡 优化建议
+
+1. **使用Redis集群**: 提高可用性
+2. **配置Celery监控**: 使用Flower或其他监控工具
+3. **设置任务超时**: 避免长时间运行的任务
+4. **实现任务重试**: 处理临时失败
+5. **定期清理**: 清理过期的任务结果
+
+### 🎉 总结
+
+现在系统具有以下特性:
+
+- ✅ **智能检测**: 自动检测Celery可用性
+- ✅ **优雅降级**: 失败时自动切换到Mock模式
+- ✅ **详细诊断**: 提供清晰的错误信息和解决建议
+- ✅ **开发友好**: 无需复杂配置即可开始开发
+- ✅ **生产就绪**: 支持完整的Celery部署
+
+无论Redis和Celery是否可用,应用都能正常运行! 🚀

+ 165 - 0
CREDENTIAL_UPDATE.md

@@ -0,0 +1,165 @@
+# 凭证管理更新说明
+
+## 更新内容
+
+### 🔑 Access Key 类型凭证优化
+
+**问题**: 之前创建 Access Key 类型的凭证时,需要手动输入 AWS Account ID,这是不必要的,因为可以通过 Access Key 自动获取账号信息。
+
+**解决方案**: 
+- ✅ **前端优化**: Access Key 类型时不再显示 Account ID 输入框
+- ✅ **后端优化**: 自动通过 AWS API 获取 Account ID
+- ✅ **数据库更新**: account_id 字段改为可空,支持自动检测
+
+## 功能变化
+
+### 创建凭证
+
+#### Assume Role 类型 (无变化)
+- ✅ 需要输入 AWS Account ID
+- ✅ 需要输入 Role ARN
+- ✅ 可选输入 External ID
+
+#### Access Key 类型 (已优化)
+- ✅ 只需输入 Access Key ID 和 Secret Access Key
+- ✅ AWS Account ID 自动检测并保存
+- ✅ 创建时会验证凭证有效性
+
+### 编辑凭证
+
+#### Assume Role 类型
+- ✅ 可以修改 Account ID、Role ARN、External ID
+
+#### Access Key 类型
+- ✅ 可以修改 Access Key ID 和 Secret Access Key
+- ✅ Account ID 不可手动修改(由系统管理)
+
+## 技术实现
+
+### 前端变化
+```typescript
+// 创建凭证时的数据结构
+interface CreateCredentialRequest {
+  name: string;
+  credentialType: 'assume_role' | 'access_key';
+  accountId?: string;  // 现在是可选的
+  // ... 其他字段
+}
+```
+
+### 后端变化
+```python
+# 数据模型更新
+class AWSCredential(db.Model):
+    account_id = db.Column(db.String(12), nullable=True)  # 改为可空
+    
+# API逻辑更新
+if credential_type == 'access_key' and not account_id:
+    # 自动检测 Account ID
+    provider = AWSCredentialProvider(...)
+    detected_account_id = provider.get_account_id()
+    credential.account_id = detected_account_id
+```
+
+### 数据库迁移
+```sql
+-- 将 account_id 字段改为可空
+ALTER TABLE aws_credentials ALTER COLUMN account_id DROP NOT NULL;
+```
+
+## 使用指南
+
+### 创建 Access Key 凭证
+
+1. **选择凭证类型**: Access Key
+2. **输入名称**: 为凭证起一个描述性名称
+3. **输入 Access Key ID**: AWS 提供的访问密钥 ID
+4. **输入 Secret Access Key**: AWS 提供的秘密访问密钥
+5. **点击创建**: 系统会自动验证凭证并获取 Account ID
+
+### 验证过程
+
+创建 Access Key 凭证时,系统会:
+1. 使用提供的凭证调用 AWS STS GetCallerIdentity API
+2. 验证凭证有效性
+3. 自动获取 AWS Account ID
+4. 保存凭证信息到数据库
+
+### 错误处理
+
+如果凭证无效,会显示具体错误信息:
+- "Access denied" - 凭证无权限
+- "Invalid credentials" - 凭证格式错误或已失效
+- "Network error" - 网络连接问题
+
+## 迁移步骤
+
+### 对于现有部署
+
+1. **备份数据库**
+```bash
+# 备份当前数据库
+cp backend/instance/dev.db backend/instance/dev.db.backup
+```
+
+2. **应用迁移**
+```bash
+cd backend
+python apply_migration.py
+```
+
+3. **验证迁移**
+```bash
+python verify_setup.py
+```
+
+### 对于新部署
+
+新部署会自动使用更新后的数据库结构,无需额外操作。
+
+## 兼容性
+
+### 现有数据
+- ✅ 所有现有凭证保持不变
+- ✅ 现有 Access Key 凭证的 Account ID 保留
+- ✅ 现有功能完全兼容
+
+### API 兼容性
+- ✅ 所有现有 API 调用保持兼容
+- ✅ 前端可以继续传递 accountId(会被忽略)
+- ✅ 响应格式保持不变
+
+## 测试建议
+
+### 功能测试
+1. 创建 Assume Role 类型凭证(需要 Account ID)
+2. 创建 Access Key 类型凭证(自动检测 Account ID)
+3. 编辑两种类型的凭证
+4. 验证凭证功能
+5. 删除凭证功能
+
+### 数据验证
+1. 检查新创建的 Access Key 凭证是否有正确的 Account ID
+2. 验证现有凭证数据完整性
+3. 测试凭证验证功能
+
+## 优势
+
+### 用户体验
+- 🎯 **简化操作**: 减少手动输入,降低出错概率
+- 🎯 **自动化**: 系统自动获取准确的 Account ID
+- 🎯 **即时验证**: 创建时立即验证凭证有效性
+
+### 数据准确性
+- 🎯 **消除错误**: 避免手动输入错误的 Account ID
+- 🎯 **实时同步**: Account ID 始终与实际 AWS 账号一致
+- 🎯 **自动更新**: 凭证更新时自动同步 Account ID
+
+### 系统可靠性
+- 🎯 **验证机制**: 创建前验证凭证有效性
+- 🎯 **错误处理**: 完善的错误提示和处理
+- 🎯 **向后兼容**: 不影响现有功能和数据
+
+---
+
+这个更新让 AWS 凭证管理更加智能和用户友好! 🚀

+ 294 - 0
QUICK_START.md

@@ -0,0 +1,294 @@
+# 🚀 AWS Resource Scanner - 快速启动指南
+
+## 项目概述
+
+AWS Resource Scanner 是一个用于扫描和报告AWS资源的Web应用程序,包含:
+- **Backend**: Python Flask API服务
+- **Frontend**: React + TypeScript Web界面
+
+## 环境要求
+
+### Backend
+- Python 3.8+
+- pip (Python包管理器)
+- Redis (推荐,用于任务队列)
+
+### Frontend  
+- Node.js 18+
+- npm/yarn (包管理器)
+
+**注意**: Redis是可选的。如果没有安装Redis,应用会自动使用Mock模式,可以进行基本功能测试。
+
+## 快速启动
+
+### 1. 启动Backend
+
+```bash
+# 进入backend目录
+cd backend
+
+# Windows: 运行设置脚本
+setup.bat
+
+# Unix/Linux/macOS: 运行设置脚本
+chmod +x setup.sh
+./setup.sh
+
+# 激活虚拟环境
+# Windows:
+activate_venv.bat
+
+# Unix/Linux/macOS:
+source activate_venv.sh
+
+# 初始化数据库
+python init_db.py
+
+# 检查Redis连接 (可选)
+python test_redis.py
+
+# 启动Flask应用 (自动检测Redis)
+python start_with_redis_check.py
+
+# 或者直接启动 (如果确定Redis已配置)
+python run.py
+```
+
+**注意**: 如果没有安装Redis,应用会自动切换到Mock模式,功能受限但可以进行基本测试。
+
+Backend将运行在: http://localhost:5000
+
+### 2. 启动Frontend
+
+```bash
+# 新开一个终端,进入frontend目录
+cd frontend
+
+# Windows: 运行设置脚本
+setup.bat
+
+# Unix/Linux/macOS: 运行设置脚本
+chmod +x setup.sh
+./setup.sh
+
+# 启动开发服务器
+yarn dev
+```
+
+Frontend将运行在: http://localhost:3000
+
+### 3. 访问应用
+
+- **Web界面**: http://localhost:3000
+- **API文档**: http://localhost:5000/api
+- **默认管理员**: `admin` / `admin123`
+
+## 项目结构
+
+```
+cloud-reporter/
+├── backend/                    # Python Flask后端
+│   ├── app/                   # 应用代码
+│   ├── config/                # 配置文件
+│   ├── migrations/            # 数据库迁移
+│   ├── tests/                 # 测试文件
+│   ├── venv/                  # Python虚拟环境
+│   ├── requirements.txt       # Python依赖
+│   ├── setup_venv.py         # 环境设置脚本
+│   ├── init_db.py            # 数据库初始化
+│   └── run.py                # 应用入口
+├── frontend/                   # React前端
+│   ├── src/                   # 源代码
+│   ├── node_modules/          # Node.js依赖
+│   ├── package.json           # 项目配置
+│   ├── vite.config.ts         # 构建配置
+│   └── setup.bat/sh           # 环境设置脚本
+└── sample-reports/            # 示例报告文件
+```
+
+## 开发工作流
+
+### Backend开发
+
+```bash
+cd backend
+
+# 激活虚拟环境
+activate_venv.bat  # Windows
+source activate_venv.sh  # Unix/Linux
+
+# 启动开发服务器
+python run.py
+
+# 运行测试
+pytest
+
+# 数据库操作
+python init_db.py --reset      # 重置数据库
+python init_db.py --with-demo  # 创建示例数据
+```
+
+### Frontend开发
+
+```bash
+cd frontend
+
+# 启动开发服务器
+yarn dev
+
+# 构建生产版本
+yarn build
+
+# 运行测试
+yarn test
+
+# 代码检查
+yarn lint
+```
+
+## 功能特性
+
+### 已实现功能
+- ✅ 用户认证和授权
+- ✅ AWS凭证管理
+- ✅ 资源扫描任务
+- ✅ 报告生成和下载
+- ✅ 任务日志和监控
+- ✅ 响应式Web界面
+
+### 核心模块
+- **认证系统**: JWT令牌认证
+- **凭证管理**: AWS访问密钥和角色管理
+- **扫描引擎**: 多服务AWS资源扫描
+- **报告生成**: Word文档报告生成
+- **任务队列**: Celery异步任务处理
+
+## API接口
+
+### 认证接口
+- `POST /api/auth/login` - 用户登录
+- `POST /api/auth/refresh` - 刷新令牌
+- `POST /api/auth/logout` - 用户登出
+
+### 用户管理
+- `GET /api/users` - 获取用户列表
+- `POST /api/users` - 创建用户
+- `PUT /api/users/{id}` - 更新用户
+- `DELETE /api/users/{id}` - 删除用户
+
+### 凭证管理
+- `GET /api/credentials` - 获取凭证列表
+- `POST /api/credentials` - 创建凭证
+- `PUT /api/credentials/{id}` - 更新凭证
+- `DELETE /api/credentials/{id}` - 删除凭证
+
+### 任务管理
+- `GET /api/tasks` - 获取任务列表
+- `POST /api/tasks` - 创建扫描任务
+- `GET /api/tasks/{id}` - 获取任务详情
+- `DELETE /api/tasks/{id}` - 删除任务
+
+### 报告管理
+- `GET /api/reports` - 获取报告列表
+- `GET /api/reports/download?id={id}` - 下载报告
+
+## 环境配置
+
+### Backend环境变量 (.env)
+```env
+FLASK_ENV=development
+SECRET_KEY=your-secret-key
+JWT_SECRET_KEY=your-jwt-secret-key
+DATABASE_URL=sqlite:///dev.db
+CELERY_BROKER_URL=redis://localhost:6379/0
+CELERY_RESULT_BACKEND=redis://localhost:6379/1
+ENCRYPTION_KEY=your-encryption-key
+```
+
+### Frontend环境变量 (.env.local)
+```env
+VITE_API_BASE_URL=http://localhost:5000
+VITE_APP_TITLE=AWS Resource Scanner
+VITE_DEBUG=true
+```
+
+## 故障排除
+
+### Backend问题
+
+1. **虚拟环境问题**
+```bash
+# 重新创建虚拟环境
+python setup_venv.py --clean
+```
+
+2. **数据库问题**
+```bash
+# 重置数据库
+python init_db.py --reset
+```
+
+3. **依赖问题**
+```bash
+# 升级依赖
+python setup_venv.py --upgrade
+```
+
+### Frontend问题
+
+1. **依赖安装失败**
+```bash
+# 清理并重新安装
+yarn cache clean
+rm -rf node_modules yarn.lock
+yarn install
+```
+
+2. **端口冲突**
+```bash
+# 使用不同端口
+yarn dev --port 3001
+```
+
+3. **构建失败**
+```bash
+# 检查TypeScript错误
+npx tsc --noEmit
+```
+
+## 验证安装
+
+### Backend验证
+```bash
+cd backend
+python verify_setup.py
+```
+
+### Frontend验证
+```bash
+cd frontend
+node verify_setup.js
+```
+
+## 生产部署
+
+### Backend部署
+1. 设置生产环境变量
+2. 使用PostgreSQL数据库
+3. 配置Redis服务
+4. 使用Gunicorn + Nginx
+
+### Frontend部署
+1. 构建生产版本: `yarn build`
+2. 部署dist目录到静态服务器
+3. 配置API代理
+
+## 获取帮助
+
+- 查看详细文档: `backend/README_VENV.md`, `frontend/README_SETUP.md`
+- 运行验证脚本检查环境
+- 检查日志文件排查问题
+
+---
+
+🎉 **环境设置完成,开始开发吧!**

+ 522 - 0
README.md

@@ -0,0 +1,522 @@
+# AWS Resource Scanner
+
+[![Python](https://img.shields.io/badge/Python-3.8+-blue.svg)](https://python.org)
+[![React](https://img.shields.io/badge/React-18.3+-61DAFB.svg)](https://reactjs.org)
+[![Flask](https://img.shields.io/badge/Flask-3.0+-000000.svg)](https://flask.palletsprojects.com)
+[![TypeScript](https://img.shields.io/badge/TypeScript-5.6+-3178C6.svg)](https://typescriptlang.org)
+[![License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
+
+一个全栈Web应用,用于扫描AWS账号中的资源信息,并按照Word模板生成专业的项目报告文档。系统支持多账号、多区域并行扫描,具有完整的用户权限管理、任务调度和报告管理功能。
+
+## ✨ 功能特性
+
+### 🔐 用户认证与权限管理
+- **JWT令牌认证** - 安全的用户会话管理
+- **三级权限控制** - Admin、Power User、User角色
+- **细粒度访问控制** - 基于角色的资源访问限制
+- **用户管理** - 完整的用户生命周期管理
+
+### 🔑 AWS凭证管理
+- **多种认证方式** - 支持IAM Role和Access Key
+- **智能Account ID检测** - Access Key类型自动获取账号ID
+- **Assume Role支持** - 跨账号安全访问
+- **凭证加密存储** - 敏感信息安全保护
+- **凭证分配管理** - 灵活的用户-凭证关联
+
+### 🚀 资源扫描引擎
+- **多账号并行扫描** - 同时扫描多个AWS账号
+- **多区域支持** - 自定义区域选择,自动包含全局资源
+- **全面资源覆盖** - 支持30+AWS服务类型
+- **异步任务处理** - Celery驱动的后台任务队列
+
+### 📊 智能报告生成
+- **Word模板引擎** - 基于专业模板自动生成报告
+- **动态内容填充** - 自动替换模板占位符
+- **资源分类展示** - 按服务类型组织资源信息
+- **项目元数据集成** - 包含完整的项目信息
+
+### 🎨 现代化Web界面
+- **响应式设计** - 适配各种设备屏幕
+- **实时状态更新** - 任务进度实时显示
+- **直观操作界面** - 基于Ant Design的现代UI
+- **数据可视化** - 清晰的数据展示和管理
+- **Worker监控** - 实时查看Celery Worker状态和任务队列
+
+## 🏗️ 技术架构
+
+### 前端技术栈
+- **React 18.3** + **TypeScript 5.6** - 现代化前端框架
+- **Ant Design 5.21** - 企业级UI组件库
+- **Vite 5.4** - 快速构建工具
+- **React Router 6.28** - 单页应用路由
+- **Axios 1.7** - HTTP客户端
+
+### 后端技术栈
+- **Python 3.8+** + **Flask 3.0** - 轻量级Web框架
+- **SQLAlchemy 2.0** - ORM数据库操作
+- **Celery 5.3** + **Redis** - 异步任务队列
+- **boto3** - AWS SDK
+- **python-docx** - Word文档处理
+- **PyJWT** - JWT令牌认证
+
+### 数据存储
+- **PostgreSQL** (生产环境) - 关系型数据库
+- **SQLite3** (开发/测试) - 轻量级数据库
+- **Redis** - 缓存和消息队列
+
+## 🚀 快速开始
+
+### 环境要求
+
+- **Python 3.8+**
+- **Node.js 18+**
+- **Redis** (可选,用于Celery任务队列)
+
+### 1. 克隆项目
+
+```bash
+git clone https://github.com/your-org/aws-resource-scanner.git
+cd aws-resource-scanner
+```
+
+### 2. 后端设置
+
+```bash
+# 进入后端目录
+cd backend
+
+# Windows用户
+setup.bat
+
+# Unix/Linux/macOS用户
+chmod +x setup.sh && ./setup.sh
+
+# 激活虚拟环境
+# Windows:
+activate_venv.bat
+# Unix/Linux/macOS:
+source activate_venv.sh
+
+# 初始化数据库
+python init_db.py
+
+# 启动后端服务
+python run.py
+```
+
+### 3. 前端设置
+
+```bash
+# 新开终端,进入前端目录
+cd frontend
+
+# Windows用户
+setup.bat
+
+# Unix/Linux/macOS用户
+chmod +x setup.sh && ./setup.sh
+
+# 启动前端服务
+yarn dev
+```
+
+### 4. 访问应用
+
+- **前端界面**: http://localhost:3000
+- **后端API**: http://localhost:5000
+- **默认管理员**: `admin` / `admin123`
+
+## 📁 项目结构
+
+```
+aws-resource-scanner/
+├── backend/                    # Python Flask后端
+│   ├── app/                   # 应用核心代码
+│   │   ├── api/              # REST API路由
+│   │   ├── models/           # 数据模型
+│   │   ├── services/         # 业务逻辑服务
+│   │   ├── scanners/         # AWS扫描器模块
+│   │   │   ├── services/    # 各服务扫描器实现
+│   │   │   └── DEVELOPMENT_GUIDE.md  # 扫描器开发指南
+│   │   ├── tasks/            # Celery异步任务
+│   │   └── utils/            # 工具函数
+│   ├── config/               # 配置文件
+│   ├── migrations/           # 数据库迁移
+│   ├── tests/                # 测试文件
+│   ├── instance/             # 实例数据(数据库等)
+│   ├── uploads/              # 上传文件存储
+│   ├── reports/              # 生成的报告文件
+│   └── requirements.txt      # Python依赖
+├── frontend/                  # React前端
+│   ├── src/                  # 源代码
+│   │   ├── components/       # React组件
+│   │   ├── pages/           # 页面组件
+│   │   ├── services/        # API服务
+│   │   ├── contexts/        # React Context
+│   │   └── utils/           # 工具函数
+│   ├── public/              # 静态资源
+│   └── package.json         # 项目配置
+├── sample-reports/           # 示例报告模板
+├── .gitignore               # Git忽略文件
+└── README.md                # 项目说明
+```
+
+## 🔧 配置说明
+
+### 后端环境变量 (.env)
+
+```env
+# Flask配置
+FLASK_ENV=development
+SECRET_KEY=your-secret-key-here
+JWT_SECRET_KEY=your-jwt-secret-key-here
+
+# 数据库配置
+DATABASE_URL=sqlite:///dev.db
+# 生产环境使用PostgreSQL:
+# DATABASE_URL=postgresql://user:password@localhost/dbname
+
+# Celery配置
+CELERY_BROKER_URL=redis://localhost:6379/0
+CELERY_RESULT_BACKEND=redis://localhost:6379/1
+
+# 加密密钥
+ENCRYPTION_KEY=your-encryption-key-here
+
+# 文件存储
+UPLOAD_FOLDER=uploads
+REPORTS_FOLDER=reports
+```
+
+### 前端环境变量 (.env.local)
+
+```env
+# API配置
+VITE_API_BASE_URL=http://localhost:5000
+
+# 应用配置
+VITE_APP_TITLE=AWS Resource Scanner
+VITE_DEBUG=true
+```
+
+## 📋 支持的AWS服务
+
+### 网络服务
+- **VPC**: VPCs, Subnets, Route Tables, Internet Gateways, NAT Gateways
+- **安全**: Security Groups, VPC Endpoints, VPC Peering Connections
+- **VPN**: Customer Gateways, Virtual Private Gateways, VPN Connections
+
+### 计算服务
+- **EC2**: Instances (含EBS卷、AMI信息), Elastic IPs
+- **Auto Scaling**: Auto Scaling Groups (含Launch Templates)
+- **EKS**: Kubernetes集群
+
+### 负载均衡
+- **ELB**: Application/Network/Classic Load Balancers, Target Groups
+
+### 数据库服务
+- **RDS**: 数据库实例
+- **ElastiCache**: 缓存集群
+
+### 存储服务
+- **S3**: 存储桶, S3事件通知
+
+### 无服务器
+- **Lambda**: 函数
+
+### 全局服务
+- **CloudFront**: 分发 (全局)
+- **Route 53**: 托管区域 (全局)
+- **ACM**: SSL证书 (全局)
+- **WAF**: Web应用防火墙 (全局)
+
+### 监控和日志
+- **CloudWatch**: 日志组
+- **EventBridge**: 事件规则
+- **CloudTrail**: 审计跟踪
+- **Config**: 配置记录器
+
+### 消息服务
+- **SNS**: 主题和订阅
+
+## 🔐 权限模型
+
+### 角色定义
+
+| 角色 | 权限范围 |
+|------|----------|
+| **Admin** | 系统全部权限:用户管理、凭证管理、所有报告、Worker管理 |
+| **Power User** | 使用所有凭证、查看所有报告、创建扫描任务 |
+| **User** | 仅使用分配的凭证、查看自己的报告、创建扫描任务 |
+
+### 访问控制
+
+- **用户认证**: JWT令牌机制,支持令牌刷新
+- **权限验证**: 基于角色的访问控制(RBAC)
+- **数据隔离**: 用户只能访问授权的资源
+- **API保护**: 所有API端点都有权限验证
+
+## 🛠️ 开发指南
+
+### 扫描器开发
+
+添加新的AWS服务扫描器时,请参考 [扫描器开发指南](backend/app/scanners/DEVELOPMENT_GUIDE.md),其中包含:
+- 架构概述和目录结构
+- 添加新服务扫描器的完整步骤
+- 代码规范和命名约定
+- 错误处理和重试机制
+- 测试要求和检查清单
+
+### 后端开发
+
+```bash
+cd backend
+
+# 激活虚拟环境
+activate_venv.bat  # Windows
+source activate_venv.sh  # Unix/Linux
+
+# 启动开发服务器
+python run.py
+
+# 运行测试
+pytest
+
+# 数据库操作
+python init_db.py --reset      # 重置数据库
+python init_db.py --with-demo  # 创建示例数据
+
+# 启动Celery Worker
+celery -A celery_worker worker --loglevel=info
+```
+
+### 前端开发
+
+```bash
+cd frontend
+
+# 启动开发服务器
+yarn dev
+
+# 构建生产版本
+yarn build
+
+# 运行测试
+yarn test
+
+# 代码检查
+yarn lint
+
+# 类型检查
+npx tsc --noEmit
+```
+
+### API文档
+
+#### 认证接口
+- `POST /api/auth/login` - 用户登录
+- `POST /api/auth/refresh` - 刷新令牌
+- `POST /api/auth/logout` - 用户登出
+- `GET /api/auth/me` - 获取当前用户信息
+
+#### 用户管理 (Admin)
+- `GET /api/users` - 获取用户列表
+- `POST /api/users/create` - 创建用户
+- `POST /api/users/update` - 更新用户
+- `POST /api/users/delete` - 删除用户
+
+#### 凭证管理
+- `GET /api/credentials` - 获取凭证列表
+- `POST /api/credentials/create` - 创建凭证
+- `POST /api/credentials/update` - 更新凭证
+- `POST /api/credentials/delete` - 删除凭证
+
+#### 任务管理
+- `GET /api/tasks` - 获取任务列表
+- `POST /api/tasks/create` - 创建扫描任务
+- `GET /api/tasks/detail` - 获取任务详情
+- `POST /api/tasks/delete` - 删除任务
+
+#### 报告管理
+- `GET /api/reports` - 获取报告列表
+- `GET /api/reports/download` - 下载报告
+- `POST /api/reports/delete` - 删除报告
+
+#### Worker管理 (Admin)
+- `GET /api/workers` - 获取Worker列表和状态
+- `GET /api/workers/stats` - 获取Worker统计信息
+- `POST /api/workers/purge` - 清空任务队列
+- `POST /api/workers/revoke` - 取消指定任务
+
+## 🧪 测试
+
+### 运行测试
+
+```bash
+# 后端测试
+cd backend
+pytest
+
+# 前端测试
+cd frontend
+yarn test
+
+# 覆盖率测试
+pytest --cov=app
+yarn test --coverage
+```
+
+### 测试类型
+
+- **单元测试**: 组件功能测试
+- **集成测试**: API端点测试
+- **属性测试**: 基于Hypothesis的属性验证
+- **端到端测试**: 完整流程测试
+
+## 📦 部署
+
+### 生产环境部署
+
+#### 后端部署
+
+1. **环境准备**
+```bash
+# 安装PostgreSQL和Redis
+sudo apt-get install postgresql redis-server
+
+# 创建数据库
+sudo -u postgres createdb aws_scanner
+```
+
+2. **应用部署**
+```bash
+# 设置生产环境变量
+export FLASK_ENV=production
+export DATABASE_URL=postgresql://user:pass@localhost/aws_scanner
+
+# 安装依赖
+pip install -r requirements.txt
+
+# 数据库迁移
+flask db upgrade
+
+# 使用Gunicorn启动
+gunicorn -w 4 -b 0.0.0.0:5000 run:app
+```
+
+3. **Celery Worker**
+```bash
+# 启动Celery Worker
+celery -A celery_worker worker --loglevel=info --concurrency=4
+```
+
+#### 前端部署
+
+```bash
+# 构建生产版本
+yarn build
+
+# 部署到Nginx
+sudo cp -r dist/* /var/www/html/
+```
+
+### Docker部署
+
+```bash
+# 构建镜像
+docker-compose build
+
+# 启动服务
+docker-compose up -d
+```
+
+## 🔍 故障排除
+
+### 常见问题
+
+#### 后端问题
+
+1. **虚拟环境问题**
+```bash
+# 重新创建虚拟环境
+python setup_venv.py --clean
+```
+
+2. **数据库连接失败**
+```bash
+# 检查数据库配置
+python verify_setup.py
+```
+
+3. **Celery Worker无法启动**
+```bash
+# 检查Redis连接
+redis-cli ping
+```
+
+#### 前端问题
+
+1. **依赖安装失败**
+```bash
+# 清理并重新安装
+yarn cache clean
+rm -rf node_modules yarn.lock
+yarn install
+```
+
+2. **构建失败**
+```bash
+# 检查TypeScript错误
+npx tsc --noEmit
+```
+
+### 日志查看
+
+```bash
+# 后端日志
+tail -f backend/logs/app.log
+
+# Celery日志
+tail -f backend/logs/celery.log
+
+# 前端开发日志
+# 在浏览器开发者工具中查看
+```
+
+## 🤝 贡献指南
+
+1. Fork项目
+2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
+3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
+4. 推送到分支 (`git push origin feature/AmazingFeature`)
+5. 创建Pull Request
+
+### 代码规范
+
+- **Python**: 遵循PEP 8规范
+- **TypeScript**: 使用ESLint和Prettier
+- **提交信息**: 使用约定式提交格式
+
+## 📄 许可证
+
+本项目采用MIT许可证 - 查看 [LICENSE](LICENSE) 文件了解详情。
+
+## 🙏 致谢
+
+- [Flask](https://flask.palletsprojects.com/) - Web框架
+- [React](https://reactjs.org/) - 前端框架
+- [Ant Design](https://ant.design/) - UI组件库
+- [boto3](https://boto3.amazonaws.com/v1/documentation/api/latest/index.html) - AWS SDK
+- [Celery](https://docs.celeryproject.org/) - 分布式任务队列
+
+## 📞 支持
+
+如果您遇到问题或有疑问,请:
+
+1. 查看[故障排除](#-故障排除)部分
+2. 搜索[Issues](https://github.com/your-org/aws-resource-scanner/issues)
+3. 创建新的Issue描述问题
+
+---
+
+**AWS Resource Scanner** - 让AWS资源管理变得简单高效 🚀

+ 156 - 0
REDIS_ISSUE_RESOLVED.md

@@ -0,0 +1,156 @@
+# Redis连接问题解决方案
+
+## 问题描述
+
+用户遇到错误:`[WinError 10061] 由于目标计算机积极拒绝,无法连接`
+
+这个错误表明应用尝试连接Redis服务器失败,通常是因为:
+1. Redis服务没有运行
+2. Redis配置不正确
+3. 防火墙阻止连接
+
+## 解决方案
+
+### ✅ 已实现的改进
+
+1. **自动Redis检测和Mock模式**
+   - 修改了 `backend/app/api/tasks.py`
+   - 当Redis不可用时自动切换到Mock模式
+   - 用户可以在没有Redis的情况下测试基本功能
+
+2. **Redis连接测试工具**
+   - 创建了 `backend/test_redis.py`
+   - 全面测试Redis和Celery连接
+   - 提供详细的诊断信息
+
+3. **Mock任务模块**
+   - 创建了 `backend/app/tasks/mock_tasks.py`
+   - 模拟完整的任务执行流程
+   - 生成模拟报告和日志
+
+4. **智能启动脚本**
+   - 创建了 `backend/start_with_redis_check.py`
+   - 启动前自动检查Redis状态
+   - 提供Redis安装指导
+
+5. **详细设置指南**
+   - 创建了 `backend/REDIS_SETUP.md`
+   - 包含Windows/Linux/macOS的Redis安装方法
+   - 提供Docker和手动安装选项
+
+### 🚀 使用方法
+
+#### 方法1: 使用智能启动脚本 (推荐)
+```bash
+cd backend
+python start_with_redis_check.py
+```
+
+#### 方法2: 测试Redis连接
+```bash
+cd backend
+python test_redis.py
+```
+
+#### 方法3: 直接启动 (如果确定Redis已配置)
+```bash
+cd backend
+python run.py
+```
+
+### 🔧 Redis安装 (可选)
+
+#### Windows (推荐使用Chocolatey)
+```bash
+choco install redis-64
+redis-server --service-install
+redis-server --service-start
+```
+
+#### Docker (跨平台)
+```bash
+docker run -d --name redis -p 6379:6379 redis:alpine
+```
+
+#### Linux/macOS
+```bash
+# Ubuntu/Debian
+sudo apt-get install redis-server
+sudo systemctl start redis-server
+
+# macOS (Homebrew)
+brew install redis
+brew services start redis
+```
+
+### 🎯 功能对比
+
+| 功能 | Redis模式 | Mock模式 |
+|------|-----------|----------|
+| 任务创建 | ✅ | ✅ |
+| 任务状态跟踪 | ✅ | ✅ |
+| 任务日志 | ✅ | ✅ |
+| 报告生成 | ✅ | ✅ (模拟) |
+| 实际AWS扫描 | ✅ | ❌ |
+| 并发任务处理 | ✅ | ❌ |
+| 任务持久化 | ✅ | ✅ |
+| Worker监控 | ✅ | ❌ |
+
+### 📋 验证步骤
+
+1. **检查Redis状态**
+```bash
+python test_redis.py
+```
+
+2. **启动应用**
+```bash
+python start_with_redis_check.py
+```
+
+3. **测试任务创建**
+   - 访问 http://localhost:3000
+   - 创建一个测试任务
+   - 观察任务状态变化
+
+4. **启动Celery Worker (如果有Redis)**
+```bash
+celery -A app.celery_app worker --loglevel=info
+```
+
+### 🔍 故障排除
+
+#### Redis连接失败
+1. 检查Redis是否运行: `redis-cli ping`
+2. 检查端口占用: `netstat -an | grep 6379`
+3. 查看Redis日志
+4. 检查防火墙设置
+
+#### Mock模式问题
+1. 检查文件权限
+2. 确保reports目录可写
+3. 查看应用日志
+
+#### Celery Worker问题
+1. 确认Redis连接正常
+2. 检查Python环境
+3. 查看Worker日志
+
+### 📖 相关文档
+
+- `REDIS_SETUP.md` - Redis详细安装指南
+- `backend/test_redis.py` - Redis连接测试
+- `backend/start_with_redis_check.py` - 智能启动脚本
+- `backend/app/tasks/mock_tasks.py` - Mock任务实现
+
+### 🎉 优势
+
+1. **用户友好**: 自动检测和处理Redis连接问题
+2. **开发便利**: 无需Redis即可进行基本功能测试
+3. **生产就绪**: 完整的Redis支持和监控
+4. **灵活部署**: 支持多种Redis部署方式
+5. **详细诊断**: 全面的连接测试和错误提示
+
+---
+
+现在用户可以在任何环境下顺利运行AWS Resource Scanner,无论是否安装了Redis! 🚀

+ 23 - 0
backend/.env.example

@@ -0,0 +1,23 @@
+# Flask Configuration
+FLASK_ENV=development
+SECRET_KEY=your-secret-key-here
+
+# JWT Configuration
+JWT_SECRET_KEY=your-jwt-secret-key-here
+
+# Database Configuration
+# For development (SQLite)
+DATABASE_URL=sqlite:///dev.db
+# For production (PostgreSQL)
+# DATABASE_URL=postgresql://user:password@localhost:5432/aws_scanner
+
+# Celery Configuration
+CELERY_BROKER_URL=redis://localhost:6379/0
+CELERY_RESULT_BACKEND=redis://localhost:6379/1
+
+# Encryption Key for sensitive data
+ENCRYPTION_KEY=your-encryption-key-here
+
+# File Storage
+UPLOAD_FOLDER=uploads
+REPORTS_FOLDER=reports

+ 236 - 0
backend/README_VENV.md

@@ -0,0 +1,236 @@
+# Python 虚拟环境使用指南
+
+## 快速开始
+
+### Windows
+```bash
+# 运行设置脚本
+setup.bat
+
+# 激活虚拟环境
+activate_venv.bat
+
+# 初始化数据库
+python init_db.py
+
+# 启动应用
+python run.py
+```
+
+### Unix/Linux/macOS
+```bash
+# 设置执行权限
+chmod +x setup.sh
+
+# 运行设置脚本
+./setup.sh
+
+# 激活虚拟环境
+source activate_venv.sh
+
+# 初始化数据库
+python init_db.py
+
+# 启动应用
+python run.py
+```
+
+## 详细说明
+
+### 1. 创建虚拟环境
+
+#### 自动设置 (推荐)
+```bash
+# Windows
+setup.bat
+
+# Unix/Linux/macOS
+./setup.sh
+```
+
+#### 手动设置
+```bash
+# 创建虚拟环境
+python setup_venv.py
+
+# 升级依赖
+python setup_venv.py --upgrade
+
+# 重新创建虚拟环境
+python setup_venv.py --clean
+```
+
+### 2. 激活虚拟环境
+
+#### Windows
+```bash
+# 方式1: 使用生成的脚本
+activate_venv.bat
+
+# 方式2: 直接激活
+venv\Scripts\activate.bat
+```
+
+#### Unix/Linux/macOS
+```bash
+# 方式1: 使用生成的脚本
+source activate_venv.sh
+
+# 方式2: 直接激活
+source venv/bin/activate
+```
+
+### 3. 停用虚拟环境
+```bash
+deactivate
+```
+
+### 4. 验证环境
+```bash
+# 检查Python路径
+which python    # Unix/Linux/macOS
+where python    # Windows
+
+# 检查已安装的包
+pip list
+
+# 检查Flask是否正确安装
+python -c "import flask; print(flask.__version__)"
+```
+
+## 常用命令
+
+### 开发命令
+```bash
+# 启动Flask应用 (开发模式)
+python run.py
+
+# 初始化数据库
+python init_db.py
+
+# 初始化数据库并创建示例数据
+python init_db.py --with-demo
+
+# 重置数据库
+python init_db.py --reset
+```
+
+### Celery任务队列
+```bash
+# 启动Celery worker
+celery -A celery_worker worker --loglevel=info
+
+# Windows上启动Celery (需要额外配置)
+celery -A celery_worker worker --loglevel=info --pool=solo
+```
+
+### 测试
+```bash
+# 运行所有测试
+pytest
+
+# 运行测试并显示覆盖率
+pytest --cov=app
+
+# 运行特定测试文件
+pytest tests/test_auth.py
+```
+
+### 数据库迁移
+```bash
+# 初始化迁移
+flask db init
+
+# 创建迁移
+flask db migrate -m "描述信息"
+
+# 应用迁移
+flask db upgrade
+```
+
+## 环境变量
+
+创建 `.env` 文件 (基于 `.env.example`):
+```bash
+cp .env.example .env
+```
+
+编辑 `.env` 文件设置必要的环境变量:
+```env
+FLASK_ENV=development
+SECRET_KEY=your-secret-key
+JWT_SECRET_KEY=your-jwt-secret-key
+DATABASE_URL=sqlite:///dev.db
+CELERY_BROKER_URL=redis://localhost:6379/0
+CELERY_RESULT_BACKEND=redis://localhost:6379/1
+ENCRYPTION_KEY=your-encryption-key
+```
+
+## 依赖管理
+
+### 安装新依赖
+```bash
+# 激活虚拟环境后
+pip install package-name
+
+# 更新requirements.txt
+pip freeze > requirements.txt
+```
+
+### 升级依赖
+```bash
+# 升级所有依赖
+python setup_venv.py --upgrade
+
+# 升级特定包
+pip install --upgrade package-name
+```
+
+## 故障排除
+
+### 常见问题
+
+1. **Python版本不兼容**
+   - 确保使用Python 3.8或更高版本
+   - 检查: `python --version`
+
+2. **虚拟环境激活失败**
+   - Windows: 确保执行策略允许运行脚本
+   - Unix/Linux: 确保脚本有执行权限 `chmod +x activate_venv.sh`
+
+3. **依赖安装失败**
+   - 升级pip: `pip install --upgrade pip`
+   - 清理缓存: `pip cache purge`
+   - 重新创建环境: `python setup_venv.py --clean`
+
+4. **数据库连接问题**
+   - 检查数据库文件路径
+   - 确保有写入权限
+   - 检查环境变量设置
+
+### 重置环境
+```bash
+# 完全重置虚拟环境
+python setup_venv.py --clean
+
+# 重置数据库
+python init_db.py --reset
+```
+
+## 项目结构
+```
+backend/
+├── venv/                   # 虚拟环境目录
+├── app/                    # 应用代码
+├── config/                 # 配置文件
+├── migrations/             # 数据库迁移
+├── tests/                  # 测试文件
+├── requirements.txt        # 依赖列表
+├── setup_venv.py          # 虚拟环境设置脚本
+├── setup.bat              # Windows快速设置
+├── setup.sh               # Unix/Linux快速设置
+├── activate_venv.bat      # Windows激活脚本
+├── activate_venv.sh       # Unix/Linux激活脚本
+├── init_db.py             # 数据库初始化脚本
+└── run.py                 # 应用入口
+```

+ 366 - 0
backend/REDIS_SETUP.md

@@ -0,0 +1,366 @@
+# Redis 设置指南
+
+## 问题描述
+
+错误 `[WinError 10061] 由于目标计算机积极拒绝,无法连接` 表明Redis服务没有运行。AWS Resource Scanner使用Redis作为Celery的消息队列和结果后端。
+
+## 解决方案
+
+### 选项1: 安装并启动Redis (推荐)
+
+#### Windows
+
+1. **下载Redis for Windows**
+```bash
+# 使用Chocolatey安装
+choco install redis-64
+
+# 或者下载预编译版本
+# https://github.com/microsoftarchive/redis/releases
+```
+
+2. **启动Redis服务**
+```bash
+# 方式1: 作为Windows服务启动
+redis-server --service-install
+redis-server --service-start
+
+# 方式2: 直接启动
+redis-server
+```
+
+3. **验证Redis运行**
+```bash
+redis-cli ping
+# 应该返回: PONG
+```
+
+#### Linux/macOS
+
+1. **安装Redis**
+```bash
+# Ubuntu/Debian
+sudo apt-get install redis-server
+
+# CentOS/RHEL
+sudo yum install redis
+
+# macOS (使用Homebrew)
+brew install redis
+```
+
+2. **启动Redis**
+```bash
+# 启动服务
+sudo systemctl start redis-server
+
+# 或者直接启动
+redis-server
+```
+
+3. **验证Redis运行**
+```bash
+redis-cli ping
+# 应该返回: PONG
+```
+
+### 选项2: 使用Docker运行Redis
+
+```bash
+# 拉取并运行Redis容器
+docker run -d --name redis -p 6379:6379 redis:alpine
+
+# 验证运行
+docker exec redis redis-cli ping
+```
+
+### 选项3: 使用Mock模式 (开发测试)
+
+如果暂时不想安装Redis,可以使用Mock模式进行开发测试。
+
+1. **创建Mock任务模块**
+
+创建文件 `backend/app/tasks/mock_tasks.py`:
+
+```python
+"""
+Mock任务模块 - 用于没有Redis时的开发测试
+"""
+import time
+import threading
+from typing import Dict, Any, List
+import uuid
+
+
+class MockAsyncResult:
+    """模拟Celery AsyncResult"""
+    
+    def __init__(self, task_id: str):
+        self.id = task_id
+        self.state = 'PENDING'
+        self.info = {}
+    
+    def ready(self) -> bool:
+        return self.state in ['SUCCESS', 'FAILURE']
+    
+    def successful(self) -> bool:
+        return self.state == 'SUCCESS'
+    
+    def failed(self) -> bool:
+        return self.state == 'FAILURE'
+
+
+class MockCeleryTask:
+    """模拟Celery任务"""
+    
+    def __init__(self, func):
+        self.func = func
+        self._results = {}
+    
+    def delay(self, *args, **kwargs):
+        """模拟异步执行"""
+        task_id = str(uuid.uuid4())
+        
+        # 创建结果对象
+        result = MockAsyncResult(task_id)
+        self._results[task_id] = result
+        
+        # 在后台线程中执行任务
+        def run_task():
+            try:
+                result.state = 'PROGRESS'
+                result.info = {'progress': 0}
+                
+                # 执行实际任务
+                task_result = self.func(*args, **kwargs)
+                
+                result.state = 'SUCCESS'
+                result.info = task_result
+                
+            except Exception as e:
+                result.state = 'FAILURE'
+                result.info = {'error': str(e)}
+        
+        thread = threading.Thread(target=run_task)
+        thread.daemon = True
+        thread.start()
+        
+        return result
+
+
+def mock_scan_aws_resources(task_id: int, credential_ids: List[int], 
+                           regions: List[str], project_metadata: Dict[str, Any]) -> Dict[str, Any]:
+    """模拟AWS资源扫描任务"""
+    from app import db
+    from app.models import Task
+    
+    print(f"Mock: 开始扫描任务 {task_id}")
+    
+    # 更新任务状态
+    task = db.session.get(Task, task_id)
+    if task:
+        task.status = 'running'
+        task.progress = 0
+        db.session.commit()
+    
+    # 模拟扫描过程
+    for i in range(1, 6):
+        time.sleep(1)  # 模拟扫描时间
+        if task:
+            task.progress = i * 20
+            db.session.commit()
+        print(f"Mock: 扫描进度 {i * 20}%")
+    
+    # 模拟完成
+    if task:
+        task.status = 'completed'
+        task.progress = 100
+        db.session.commit()
+    
+    print(f"Mock: 任务 {task_id} 完成")
+    
+    return {'status': 'success', 'message': 'Mock scan completed'}
+
+
+# 创建模拟任务
+scan_aws_resources = MockCeleryTask(mock_scan_aws_resources)
+```
+
+2. **修改任务API使用Mock模式**
+
+在 `backend/app/api/tasks.py` 中添加环境检测:
+
+```python
+# 在文件顶部添加
+import os
+
+# 在create_task函数中,替换Celery调用
+try:
+    # 尝试使用真实的Celery
+    from app.tasks.scan_tasks import scan_aws_resources
+    celery_task = scan_aws_resources.delay(
+        task_id=task.id,
+        credential_ids=credential_ids,
+        regions=regions,
+        project_metadata=project_metadata
+    )
+except Exception as e:
+    if "Connection refused" in str(e) or "10061" in str(e):
+        # Redis不可用,使用Mock模式
+        print("Redis不可用,使用Mock模式")
+        from app.tasks.mock_tasks import scan_aws_resources
+        celery_task = scan_aws_resources.delay(
+            task_id=task.id,
+            credential_ids=credential_ids,
+            regions=regions,
+            project_metadata=project_metadata
+        )
+    else:
+        raise
+```
+
+## 配置Redis连接
+
+### 环境变量配置
+
+在 `.env` 文件中设置Redis连接:
+
+```env
+# Redis配置
+CELERY_BROKER_URL=redis://localhost:6379/0
+CELERY_RESULT_BACKEND=redis://localhost:6379/1
+
+# 如果Redis有密码
+# CELERY_BROKER_URL=redis://:password@localhost:6379/0
+# CELERY_RESULT_BACKEND=redis://:password@localhost:6379/1
+
+# 如果Redis在其他主机
+# CELERY_BROKER_URL=redis://redis-host:6379/0
+# CELERY_RESULT_BACKEND=redis://redis-host:6379/1
+```
+
+### 验证连接
+
+创建测试脚本 `backend/test_redis.py`:
+
+```python
+#!/usr/bin/env python
+"""测试Redis连接"""
+import redis
+import os
+
+def test_redis_connection():
+    try:
+        # 从环境变量获取Redis URL
+        broker_url = os.environ.get('CELERY_BROKER_URL', 'redis://localhost:6379/0')
+        
+        # 解析Redis URL
+        if broker_url.startswith('redis://'):
+            # 简单解析
+            host = 'localhost'
+            port = 6379
+            db = 0
+            
+            # 创建Redis连接
+            r = redis.Redis(host=host, port=port, db=db, decode_responses=True)
+            
+            # 测试连接
+            response = r.ping()
+            if response:
+                print("✓ Redis连接成功")
+                
+                # 测试基本操作
+                r.set('test_key', 'test_value')
+                value = r.get('test_key')
+                if value == 'test_value':
+                    print("✓ Redis读写测试成功")
+                    r.delete('test_key')
+                    return True
+                else:
+                    print("❌ Redis读写测试失败")
+                    return False
+            else:
+                print("❌ Redis ping失败")
+                return False
+                
+    except redis.ConnectionError as e:
+        print(f"❌ Redis连接失败: {e}")
+        return False
+    except Exception as e:
+        print(f"❌ Redis测试异常: {e}")
+        return False
+
+if __name__ == '__main__':
+    test_redis_connection()
+```
+
+运行测试:
+```bash
+cd backend
+python test_redis.py
+```
+
+## 启动Celery Worker
+
+Redis运行后,需要启动Celery Worker来处理任务:
+
+```bash
+cd backend
+
+# 激活虚拟环境
+activate_venv.bat  # Windows
+source activate_venv.sh  # Unix/Linux
+
+# 启动Celery Worker
+celery -A app.celery_app worker --loglevel=info
+
+# Windows上可能需要使用solo池
+celery -A app.celery_app worker --loglevel=info --pool=solo
+```
+
+## 故障排除
+
+### 常见问题
+
+1. **Redis启动失败**
+   - 检查端口6379是否被占用: `netstat -an | grep 6379`
+   - 检查Redis配置文件
+   - 查看Redis日志
+
+2. **连接被拒绝**
+   - 确认Redis服务正在运行
+   - 检查防火墙设置
+   - 验证Redis绑定地址
+
+3. **Celery Worker启动失败**
+   - 确认Redis连接正常
+   - 检查Python环境和依赖
+   - 查看Celery日志
+
+### 检查服务状态
+
+```bash
+# 检查Redis进程
+ps aux | grep redis
+
+# 检查端口占用
+netstat -tulpn | grep 6379
+
+# 测试Redis连接
+redis-cli ping
+
+# 检查Celery Worker
+celery -A app.celery_app inspect active
+```
+
+## 生产环境建议
+
+1. **Redis持久化配置**
+2. **Redis内存限制设置**
+3. **Redis安全配置 (密码、绑定地址)**
+4. **Celery Worker监控**
+5. **任务结果清理策略**
+
+---
+
+选择适合你环境的解决方案,推荐在开发环境使用本地Redis,生产环境使用专门的Redis服务。

+ 206 - 0
backend/SETUP_COMPLETE.md

@@ -0,0 +1,206 @@
+# 🎉 Backend 虚拟环境设置完成!
+
+## 设置摘要
+
+✅ **Python 3.14.0** - 版本符合要求  
+✅ **虚拟环境** - 已创建并配置  
+✅ **依赖包** - 已安装所有必需包  
+✅ **数据库** - 已初始化并创建管理员账户  
+✅ **Flask应用** - 测试通过,可正常运行  
+
+## 快速开始
+
+### 1. 激活虚拟环境
+```bash
+# Windows
+activate_venv.bat
+
+# 或者直接激活
+venv\Scripts\activate.bat
+```
+
+### 2. 验证设置
+```bash
+python verify_setup.py
+```
+
+### 3. 启动应用
+```bash
+python run.py
+```
+
+### 4. 访问应用
+- 应用地址: http://localhost:5000
+- 管理员账户: `admin` / `admin123`
+
+## 已创建的文件
+
+### 设置脚本
+- `setup_venv.py` - 虚拟环境设置脚本
+- `setup.bat` - Windows快速设置
+- `setup.sh` - Unix/Linux快速设置
+- `activate_venv.bat` - Windows激活脚本
+- `activate_venv.sh` - Unix/Linux激活脚本
+
+### 数据库相关
+- `init_db.py` - 数据库初始化脚本
+- `instance/dev.db` - SQLite开发数据库
+
+### 验证工具
+- `verify_setup.py` - 环境验证脚本
+
+### 依赖管理
+- `requirements.txt` - 基础依赖 (已更新为兼容版本)
+- `requirements-dev.txt` - 开发依赖 (包含PostgreSQL支持)
+
+## 常用命令
+
+### 开发
+```bash
+# 启动Flask应用 (开发模式)
+python run.py
+
+# 重新初始化数据库
+python init_db.py --reset
+
+# 创建示例数据
+python init_db.py --with-demo
+```
+
+### 测试
+```bash
+# 运行所有测试
+pytest
+
+# 运行测试并显示覆盖率
+pytest --cov=app
+
+# 验证环境设置
+python verify_setup.py
+```
+
+### Celery (任务队列)
+```bash
+# 启动Celery worker
+celery -A celery_worker worker --loglevel=info
+
+# Windows上需要使用solo池
+celery -A celery_worker worker --loglevel=info --pool=solo
+```
+
+### 依赖管理
+```bash
+# 安装新依赖
+pip install package-name
+
+# 更新requirements.txt
+pip freeze > requirements.txt
+
+# 升级所有依赖
+python setup_venv.py --upgrade
+```
+
+## 项目结构
+
+```
+backend/
+├── venv/                      # 虚拟环境
+├── app/                       # 应用代码
+│   ├── api/                   # API路由
+│   ├── models/                # 数据模型
+│   ├── services/              # 业务逻辑
+│   ├── scanners/              # AWS扫描器
+│   ├── tasks/                 # Celery任务
+│   └── utils/                 # 工具函数
+├── config/                    # 配置文件
+├── migrations/                # 数据库迁移
+├── tests/                     # 测试文件
+├── instance/                  # 实例文件 (数据库等)
+├── uploads/                   # 上传文件
+├── reports/                   # 生成的报告
+├── requirements.txt           # 基础依赖
+├── requirements-dev.txt       # 开发依赖
+├── setup_venv.py             # 环境设置脚本
+├── init_db.py                # 数据库初始化
+├── verify_setup.py           # 环境验证
+├── run.py                    # 应用入口
+└── README_VENV.md            # 详细使用说明
+```
+
+## 环境变量
+
+创建 `.env` 文件 (可选):
+```env
+FLASK_ENV=development
+SECRET_KEY=your-secret-key
+JWT_SECRET_KEY=your-jwt-secret-key
+DATABASE_URL=sqlite:///dev.db
+CELERY_BROKER_URL=redis://localhost:6379/0
+CELERY_RESULT_BACKEND=redis://localhost:6379/1
+ENCRYPTION_KEY=your-encryption-key
+```
+
+## 数据库信息
+
+### 默认管理员账户
+- **用户名**: `admin`
+- **密码**: `admin123`
+- **角色**: `admin`
+
+⚠️ **重要**: 请在生产环境中修改默认密码!
+
+### 数据库表
+- `users` - 用户表
+- `aws_credentials` - AWS凭证表
+- `user_credentials` - 用户-凭证关联表
+- `tasks` - 扫描任务表
+- `task_logs` - 任务日志表
+- `reports` - 报告表
+
+## 故障排除
+
+### 常见问题
+
+1. **虚拟环境激活失败**
+   ```bash
+   # 重新创建虚拟环境
+   python setup_venv.py --clean
+   ```
+
+2. **依赖安装失败**
+   ```bash
+   # 升级pip并重试
+   python -m pip install --upgrade pip
+   python setup_venv.py --upgrade
+   ```
+
+3. **数据库问题**
+   ```bash
+   # 重置数据库
+   python init_db.py --reset
+   ```
+
+4. **Flask应用启动失败**
+   ```bash
+   # 验证环境
+   python verify_setup.py
+   ```
+
+### 获取帮助
+
+```bash
+# 查看脚本帮助
+python setup_venv.py --help
+python init_db.py --help
+```
+
+## 下一步
+
+1. **配置IDE**: 将IDE的Python解释器设置为 `venv/Scripts/python.exe`
+2. **安装Redis**: 如需使用Celery任务队列
+3. **配置PostgreSQL**: 如需使用PostgreSQL数据库
+4. **设置环境变量**: 创建 `.env` 文件配置生产环境
+
+---
+
+🚀 **环境已就绪,开始开发吧!**

+ 11 - 0
backend/activate_venv.bat

@@ -0,0 +1,11 @@
+@echo off
+echo 激活Python虚拟环境...
+call "E:\dev-kiro\cloud-reporter\backend\venv\Scripts\activate.bat"
+echo ✓ 虚拟环境已激活
+echo.
+echo 可用命令:
+echo   python run.py          - 启动Flask应用
+echo   python init_db.py      - 初始化数据库
+echo   celery -A celery_worker worker - 启动Celery worker
+echo   pytest                 - 运行测试
+echo.

+ 11 - 0
backend/activate_venv.sh

@@ -0,0 +1,11 @@
+#!/bin/bash
+echo "激活Python虚拟环境..."
+source "E:\dev-kiro\cloud-reporter\backend\venv/bin/activate"
+echo "✓ 虚拟环境已激活"
+echo ""
+echo "可用命令:"
+echo "  python run.py          - 启动Flask应用"
+echo "  python init_db.py      - 初始化数据库"
+echo "  celery -A celery_worker worker - 启动Celery worker"
+echo "  pytest                 - 运行测试"
+echo ""

+ 38 - 0
backend/app/__init__.py

@@ -0,0 +1,38 @@
+import os
+from flask import Flask
+from flask_sqlalchemy import SQLAlchemy
+from flask_migrate import Migrate
+from flask_cors import CORS
+
+from config.settings import config
+
+db = SQLAlchemy()
+migrate = Migrate()
+
+
+def create_app(config_name=None):
+    """Application factory pattern"""
+    if config_name is None:
+        config_name = os.environ.get('FLASK_ENV', 'development')
+    
+    app = Flask(__name__)
+    app.config.from_object(config[config_name])
+    
+    # Initialize extensions
+    db.init_app(app)
+    migrate.init_app(app, db)
+    CORS(app)
+    
+    # Create upload directories
+    os.makedirs(app.config.get('UPLOAD_FOLDER', 'uploads'), exist_ok=True)
+    os.makedirs(app.config.get('REPORTS_FOLDER', 'reports'), exist_ok=True)
+    
+    # Register blueprints
+    from app.api import api_bp
+    app.register_blueprint(api_bp, url_prefix='/api')
+    
+    # Register error handlers
+    from app.errors import register_error_handlers
+    register_error_handlers(app)
+    
+    return app

+ 6 - 0
backend/app/api/__init__.py

@@ -0,0 +1,6 @@
+from flask import Blueprint
+
+api_bp = Blueprint('api', __name__)
+
+# Import routes after blueprint creation to avoid circular imports
+from app.api import auth, users, credentials, tasks, reports, workers, dashboard

+ 129 - 0
backend/app/api/auth.py

@@ -0,0 +1,129 @@
+"""
+Authentication API endpoints
+
+Provides login, logout, token refresh, and current user endpoints.
+"""
+from flask import jsonify, request
+
+from app.api import api_bp
+from app.services import AuthService, login_required, get_current_user_from_context
+from app.errors import ValidationError
+
+
+@api_bp.route('/auth/login', methods=['POST'])
+def login():
+    """
+    User login endpoint
+    
+    Request body:
+        {
+            "username": "string",
+            "password": "string"
+        }
+    
+    Returns:
+        {
+            "token": "access_token",
+            "refresh_token": "refresh_token",
+            "user": { user object }
+        }
+    """
+    data = request.get_json()
+    
+    if not data:
+        raise ValidationError(
+            message="Request body is required",
+            details={"reason": "missing_body"}
+        )
+    
+    username = data.get('username')
+    password = data.get('password')
+    
+    if not username or not password:
+        raise ValidationError(
+            message="Username and password are required",
+            details={
+                "missing_fields": [
+                    field for field in ['username', 'password'] 
+                    if not data.get(field)
+                ]
+            }
+        )
+    
+    user, tokens = AuthService.authenticate(username, password)
+    
+    return jsonify({
+        'token': tokens['access_token'],
+        'refresh_token': tokens['refresh_token'],
+        'user': user.to_dict()
+    }), 200
+
+
+@api_bp.route('/auth/logout', methods=['POST'])
+@login_required
+def logout():
+    """
+    User logout endpoint
+    
+    Note: Since JWT is stateless, logout is handled client-side by removing the token.
+    This endpoint exists for API consistency and can be extended for token blacklisting.
+    
+    Returns:
+        { "message": "Logged out successfully" }
+    """
+    # In a stateless JWT system, logout is handled client-side
+    # This endpoint can be extended to implement token blacklisting if needed
+    return jsonify({
+        'message': 'Logged out successfully'
+    }), 200
+
+
+@api_bp.route('/auth/me', methods=['GET'])
+@login_required
+def get_current_user():
+    """
+    Get current authenticated user information
+    
+    Returns:
+        { user object }
+    """
+    user = get_current_user_from_context()
+    return jsonify(user.to_dict()), 200
+
+
+@api_bp.route('/auth/refresh', methods=['POST'])
+def refresh_token():
+    """
+    Refresh access token using refresh token
+    
+    Request body:
+        {
+            "refresh_token": "string"
+        }
+    
+    Returns:
+        {
+            "access_token": "new_access_token"
+        }
+    """
+    data = request.get_json()
+    
+    if not data:
+        raise ValidationError(
+            message="Request body is required",
+            details={"reason": "missing_body"}
+        )
+    
+    refresh_token_value = data.get('refresh_token')
+    
+    if not refresh_token_value:
+        raise ValidationError(
+            message="Refresh token is required",
+            details={"missing_fields": ["refresh_token"]}
+        )
+    
+    tokens = AuthService.refresh_access_token(refresh_token_value)
+    
+    return jsonify({
+        'access_token': tokens['access_token']
+    }), 200

+ 616 - 0
backend/app/api/credentials.py

@@ -0,0 +1,616 @@
+"""
+AWS Credentials Management API endpoints
+
+Provides credential CRUD operations and validation.
+Requirements: 2.1, 2.4, 2.6, 2.7
+"""
+from flask import jsonify, request, g
+from app import db
+from app.api import api_bp
+from app.models import AWSCredential, UserCredential, BaseAssumeRoleConfig
+from app.services import login_required, admin_required, get_current_user_from_context, get_accessible_credentials
+from app.errors import ValidationError, NotFoundError
+from app.scanners.credentials import AWSCredentialProvider, CredentialError
+
+
+def validate_account_id(account_id: str) -> bool:
+    """Validate AWS account ID format (12 digits)"""
+    if not account_id:
+        return False
+    return len(account_id) == 12 and account_id.isdigit()
+
+
+def validate_role_arn(role_arn: str) -> bool:
+    """Validate AWS Role ARN format"""
+    if not role_arn:
+        return False
+    # Basic ARN format: arn:aws:iam::account-id:role/role-name
+    return role_arn.startswith('arn:aws:iam::') and ':role/' in role_arn
+
+
+@api_bp.route('/credentials', methods=['GET'])
+@login_required
+def get_credentials():
+    """
+    Get credentials list with pagination (sensitive info masked)
+    
+    Query params:
+        page: Page number (default: 1)
+        page_size: Items per page (default: 20, max: 100)
+    
+    Returns:
+        {
+            "data": [credential objects with masked sensitive data],
+            "pagination": {...}
+        }
+    """
+    # Get pagination parameters
+    page = request.args.get('page', 1, type=int)
+    # Support both pageSize (frontend) and page_size (backend convention)
+    page_size = request.args.get('pageSize', type=int) or request.args.get('page_size', type=int) or 20
+    
+    # Validate pagination parameters
+    if page < 1:
+        page = 1
+    if page_size < 1:
+        page_size = 20
+    if page_size > 100:
+        page_size = 100
+    
+    # Get current user
+    current_user = get_current_user_from_context()
+    
+    # Get accessible credentials based on user role
+    query = get_accessible_credentials(current_user)
+    
+    # Order by created_at descending
+    query = query.order_by(AWSCredential.created_at.desc())
+    
+    # Get total count
+    total = query.count()
+    
+    # Calculate total pages
+    total_pages = (total + page_size - 1) // page_size if total > 0 else 1
+    
+    # Apply pagination
+    credentials = query.offset((page - 1) * page_size).limit(page_size).all()
+    
+    return jsonify({
+        'data': [cred.to_dict(mask_sensitive=True) for cred in credentials],
+        'pagination': {
+            'page': page,
+            'page_size': page_size,
+            'total': total,
+            'total_pages': total_pages
+        }
+    }), 200
+
+
+@api_bp.route('/credentials/create', methods=['POST'])
+@admin_required
+def create_credential():
+    """
+    Create a new AWS credential
+    
+    Request body:
+        {
+            "name": "string" (required),
+            "credential_type": "assume_role" | "access_key" (required),
+            "account_id": "string" (required, 12 digits),
+            "role_arn": "string" (required for assume_role),
+            "external_id": "string" (optional for assume_role),
+            "access_key_id": "string" (required for access_key),
+            "secret_access_key": "string" (required for access_key)
+        }
+    
+    Returns:
+        { credential object }
+    """
+    data = request.get_json()
+    
+    if not data:
+        raise ValidationError(
+            message="Request body is required",
+            details={"reason": "missing_body"}
+        )
+    
+    # Validate required fields
+    required_fields = ['name', 'credential_type']
+    if data.get('credential_type') == 'assume_role':
+        required_fields.append('account_id')
+    
+    missing_fields = [field for field in required_fields if not data.get(field)]
+    
+    if missing_fields:
+        raise ValidationError(
+            message="Missing required fields",
+            details={"missing_fields": missing_fields}
+        )
+    
+    name = data['name'].strip()
+    credential_type = data['credential_type']
+    account_id = data.get('account_id', '').strip() if data.get('account_id') else None
+    
+    # Validate name length
+    if len(name) < 1 or len(name) > 100:
+        raise ValidationError(
+            message="Name must be between 1 and 100 characters",
+            details={"field": "name", "reason": "invalid_length"}
+        )
+    
+    # Validate credential type
+    if credential_type not in ['assume_role', 'access_key']:
+        raise ValidationError(
+            message="Invalid credential type. Must be 'assume_role' or 'access_key'",
+            details={"field": "credential_type", "reason": "invalid_value"}
+        )
+    
+    # Validate account ID (only required for assume_role)
+    if credential_type == 'assume_role':
+        if not account_id or not validate_account_id(account_id):
+            raise ValidationError(
+                message="Invalid AWS account ID. Must be 12 digits",
+                details={"field": "account_id", "reason": "invalid_format"}
+            )
+    
+    # Validate type-specific fields
+    if credential_type == 'assume_role':
+        role_arn = data.get('role_arn', '').strip()
+        if not role_arn:
+            raise ValidationError(
+                message="Role ARN is required for assume_role credential type",
+                details={"missing_fields": ["role_arn"]}
+            )
+        if not validate_role_arn(role_arn):
+            raise ValidationError(
+                message="Invalid Role ARN format",
+                details={"field": "role_arn", "reason": "invalid_format"}
+            )
+    else:  # access_key
+        access_key_id = data.get('access_key_id', '').strip()
+        secret_access_key = data.get('secret_access_key', '').strip()
+        
+        if not access_key_id:
+            raise ValidationError(
+                message="Access Key ID is required for access_key credential type",
+                details={"missing_fields": ["access_key_id"]}
+            )
+        if not secret_access_key:
+            raise ValidationError(
+                message="Secret Access Key is required for access_key credential type",
+                details={"missing_fields": ["secret_access_key"]}
+            )
+    
+    # Create credential
+    credential = AWSCredential(
+        name=name,
+        credential_type=credential_type,
+        account_id=account_id,  # Will be None for access_key initially
+        is_active=True
+    )
+    
+    if credential_type == 'assume_role':
+        credential.role_arn = data.get('role_arn', '').strip()
+        credential.external_id = data.get('external_id', '').strip() or None
+    else:
+        access_key_id = data.get('access_key_id', '').strip()
+        secret_access_key = data.get('secret_access_key', '').strip()
+        
+        credential.access_key_id = access_key_id
+        credential.set_secret_access_key(secret_access_key)
+        
+        # Auto-detect account ID for access_key type
+        if not account_id:
+            try:
+                provider = AWSCredentialProvider(
+                    credential_type='access_key',
+                    credential_config={
+                        'access_key_id': access_key_id,
+                        'secret_access_key': secret_access_key
+                    }
+                )
+                provider.validate()
+                detected_account_id = provider.get_account_id()
+                credential.account_id = detected_account_id
+            except Exception as e:
+                raise ValidationError(
+                    message=f"Failed to validate Access Key credentials: {str(e)}",
+                    details={"reason": "credential_validation_failed"}
+                )
+    
+    db.session.add(credential)
+    db.session.commit()
+    
+    return jsonify(credential.to_dict(mask_sensitive=True)), 201
+
+
+@api_bp.route('/credentials/update', methods=['POST'])
+@admin_required
+def update_credential():
+    """
+    Update an existing AWS credential
+    
+    Request body:
+        {
+            "id": number (required),
+            "name": "string" (optional),
+            "account_id": "string" (optional),
+            "role_arn": "string" (optional, for assume_role),
+            "external_id": "string" (optional, for assume_role),
+            "access_key_id": "string" (optional, for access_key),
+            "secret_access_key": "string" (optional, for access_key),
+            "is_active": boolean (optional)
+        }
+    
+    Returns:
+        { updated credential object }
+    """
+    data = request.get_json()
+    
+    if not data:
+        raise ValidationError(
+            message="Request body is required",
+            details={"reason": "missing_body"}
+        )
+    
+    # Validate credential ID
+    credential_id = data.get('id')
+    if not credential_id:
+        raise ValidationError(
+            message="Credential ID is required",
+            details={"missing_fields": ["id"]}
+        )
+    
+    # Find credential
+    credential = db.session.get(AWSCredential, credential_id)
+    if not credential:
+        raise NotFoundError(
+            message="Credential not found",
+            details={"credential_id": credential_id}
+        )
+    
+    # Update name if provided
+    if 'name' in data and data['name']:
+        new_name = data['name'].strip()
+        if len(new_name) < 1 or len(new_name) > 100:
+            raise ValidationError(
+                message="Name must be between 1 and 100 characters",
+                details={"field": "name", "reason": "invalid_length"}
+            )
+        credential.name = new_name
+    
+    # Update account_id if provided
+    if 'account_id' in data and data['account_id']:
+        new_account_id = data['account_id'].strip()
+        if not validate_account_id(new_account_id):
+            raise ValidationError(
+                message="Invalid AWS account ID. Must be 12 digits",
+                details={"field": "account_id", "reason": "invalid_format"}
+            )
+        credential.account_id = new_account_id
+    
+    # Update type-specific fields
+    if credential.credential_type == 'assume_role':
+        if 'role_arn' in data and data['role_arn']:
+            new_role_arn = data['role_arn'].strip()
+            if not validate_role_arn(new_role_arn):
+                raise ValidationError(
+                    message="Invalid Role ARN format",
+                    details={"field": "role_arn", "reason": "invalid_format"}
+                )
+            credential.role_arn = new_role_arn
+        
+        if 'external_id' in data:
+            credential.external_id = data['external_id'].strip() if data['external_id'] else None
+    else:  # access_key
+        if 'access_key_id' in data and data['access_key_id']:
+            credential.access_key_id = data['access_key_id'].strip()
+        
+        if 'secret_access_key' in data and data['secret_access_key']:
+            credential.set_secret_access_key(data['secret_access_key'].strip())
+    
+    # Update is_active if provided
+    if 'is_active' in data:
+        credential.is_active = bool(data['is_active'])
+    
+    db.session.commit()
+    
+    return jsonify(credential.to_dict(mask_sensitive=True)), 200
+
+
+@api_bp.route('/credentials/delete', methods=['POST'])
+@admin_required
+def delete_credential():
+    """
+    Delete an AWS credential
+    
+    Request body:
+        {
+            "id": number (required)
+        }
+    
+    Returns:
+        { "message": "Credential deleted successfully" }
+    """
+    data = request.get_json()
+    
+    if not data:
+        raise ValidationError(
+            message="Request body is required",
+            details={"reason": "missing_body"}
+        )
+    
+    # Validate credential ID
+    credential_id = data.get('id')
+    if not credential_id:
+        raise ValidationError(
+            message="Credential ID is required",
+            details={"missing_fields": ["id"]}
+        )
+    
+    # Find credential
+    credential = db.session.get(AWSCredential, credential_id)
+    if not credential:
+        raise NotFoundError(
+            message="Credential not found",
+            details={"credential_id": credential_id}
+        )
+    
+    # Delete credential (cascade will handle user assignments)
+    db.session.delete(credential)
+    db.session.commit()
+    
+    return jsonify({
+        'message': 'Credential deleted successfully'
+    }), 200
+
+
+@api_bp.route('/credentials/validate', methods=['POST'])
+@admin_required
+def validate_credential():
+    """
+    Validate an AWS credential by testing connection to AWS
+    
+    Request body:
+        {
+            "id": number (required) - existing credential ID
+        }
+        OR
+        {
+            "credential_type": "assume_role" | "access_key" (required),
+            "role_arn": "string" (required for assume_role),
+            "external_id": "string" (optional for assume_role),
+            "access_key_id": "string" (required for access_key),
+            "secret_access_key": "string" (required for access_key)
+        }
+    
+    Returns:
+        { "valid": boolean, "account_id": "string" (if valid), "error": "string" (if invalid) }
+    """
+    data = request.get_json()
+    
+    if not data:
+        raise ValidationError(
+            message="Request body is required",
+            details={"reason": "missing_body"}
+        )
+    
+    credential_config = {}
+    base_credentials = None
+    credential_type = None
+    
+    # Check if validating existing credential by ID
+    if 'id' in data:
+        credential_id = data['id']
+        credential = db.session.get(AWSCredential, credential_id)
+        if not credential:
+            raise NotFoundError(
+                message="Credential not found",
+                details={"credential_id": credential_id}
+            )
+        
+        credential_type = credential.credential_type
+        
+        if credential_type == 'assume_role':
+            credential_config = {
+                'role_arn': credential.role_arn,
+                'external_id': credential.external_id
+            }
+            # Get base credentials for assume role
+            base_config = BaseAssumeRoleConfig.query.first()
+            if not base_config:
+                raise ValidationError(
+                    message="Base Assume Role configuration not found. Please configure it first.",
+                    details={"reason": "missing_base_config"}
+                )
+            base_credentials = {
+                'access_key_id': base_config.access_key_id,
+                'secret_access_key': base_config.get_secret_access_key()
+            }
+        else:
+            credential_config = {
+                'access_key_id': credential.access_key_id,
+                'secret_access_key': credential.get_secret_access_key()
+            }
+    else:
+        # Validating new credential data
+        credential_type = data.get('credential_type')
+        if not credential_type:
+            raise ValidationError(
+                message="Either 'id' or 'credential_type' is required",
+                details={"reason": "missing_identifier"}
+            )
+        
+        if credential_type == 'assume_role':
+            role_arn = data.get('role_arn', '').strip()
+            if not role_arn:
+                raise ValidationError(
+                    message="Role ARN is required for assume_role validation",
+                    details={"missing_fields": ["role_arn"]}
+                )
+            credential_config = {
+                'role_arn': role_arn,
+                'external_id': data.get('external_id', '').strip() or None
+            }
+            # Get base credentials for assume role
+            base_config = BaseAssumeRoleConfig.query.first()
+            if not base_config:
+                raise ValidationError(
+                    message="Base Assume Role configuration not found. Please configure it first.",
+                    details={"reason": "missing_base_config"}
+                )
+            base_credentials = {
+                'access_key_id': base_config.access_key_id,
+                'secret_access_key': base_config.get_secret_access_key()
+            }
+        elif credential_type == 'access_key':
+            access_key_id = data.get('access_key_id', '').strip()
+            secret_access_key = data.get('secret_access_key', '').strip()
+            
+            if not access_key_id or not secret_access_key:
+                raise ValidationError(
+                    message="Access Key ID and Secret Access Key are required",
+                    details={"missing_fields": ["access_key_id", "secret_access_key"]}
+                )
+            credential_config = {
+                'access_key_id': access_key_id,
+                'secret_access_key': secret_access_key
+            }
+        else:
+            raise ValidationError(
+                message="Invalid credential type",
+                details={"field": "credential_type", "reason": "invalid_value"}
+            )
+    
+    # Validate the credential
+    try:
+        provider = AWSCredentialProvider(
+            credential_type=credential_type,
+            credential_config=credential_config,
+            base_credentials=base_credentials
+        )
+        provider.validate()
+        account_id = provider.get_account_id()
+        
+        return jsonify({
+            'valid': True,
+            'account_id': account_id
+        }), 200
+        
+    except CredentialError as e:
+        return jsonify({
+            'valid': False,
+            'error': str(e)
+        }), 200
+    except Exception as e:
+        return jsonify({
+            'valid': False,
+            'error': f"Validation failed: {str(e)}"
+        }), 200
+
+
+@api_bp.route('/credentials/base-role', methods=['GET'])
+@admin_required
+def get_base_role():
+    """
+    Get base Assume Role configuration
+    
+    Returns:
+        { base role config object with masked sensitive data }
+        OR
+        { "configured": false } if not configured
+    """
+    config = BaseAssumeRoleConfig.query.first()
+    
+    if not config:
+        return jsonify({
+            'configured': False
+        }), 200
+    
+    return jsonify({
+        'configured': True,
+        'data': config.to_dict(mask_sensitive=True)
+    }), 200
+
+
+@api_bp.route('/credentials/base-role', methods=['POST'])
+@admin_required
+def update_base_role():
+    """
+    Update base Assume Role configuration
+    
+    Request body:
+        {
+            "access_key_id": "string" (required),
+            "secret_access_key": "string" (required)
+        }
+    
+    Returns:
+        { base role config object }
+    """
+    data = request.get_json()
+    
+    if not data:
+        raise ValidationError(
+            message="Request body is required",
+            details={"reason": "missing_body"}
+        )
+    
+    # Validate required fields
+    access_key_id = data.get('access_key_id', '').strip()
+    secret_access_key = data.get('secret_access_key', '').strip()
+    
+    if not access_key_id:
+        raise ValidationError(
+            message="Access Key ID is required",
+            details={"missing_fields": ["access_key_id"]}
+        )
+    
+    if not secret_access_key:
+        raise ValidationError(
+            message="Secret Access Key is required",
+            details={"missing_fields": ["secret_access_key"]}
+        )
+    
+    # Validate the credentials before saving
+    try:
+        provider = AWSCredentialProvider(
+            credential_type='access_key',
+            credential_config={
+                'access_key_id': access_key_id,
+                'secret_access_key': secret_access_key
+            }
+        )
+        provider.validate()
+    except CredentialError as e:
+        raise ValidationError(
+            message=f"Invalid credentials: {str(e)}",
+            details={"reason": "validation_failed"}
+        )
+    except Exception as e:
+        raise ValidationError(
+            message=f"Credential validation failed: {str(e)}",
+            details={"reason": "validation_error"}
+        )
+    
+    # Get or create config
+    config = BaseAssumeRoleConfig.query.first()
+    
+    if config:
+        # Update existing config
+        config.access_key_id = access_key_id
+        config.set_secret_access_key(secret_access_key)
+    else:
+        # Create new config
+        config = BaseAssumeRoleConfig(
+            access_key_id=access_key_id
+        )
+        config.set_secret_access_key(secret_access_key)
+        db.session.add(config)
+    
+    db.session.commit()
+    
+    return jsonify({
+        'message': 'Base Assume Role configuration updated successfully',
+        'data': config.to_dict(mask_sensitive=True)
+    }), 200

+ 72 - 0
backend/app/api/dashboard.py

@@ -0,0 +1,72 @@
+"""
+Dashboard API endpoints.
+
+Provides endpoints for:
+- GET /api/dashboard/stats - Get dashboard statistics
+
+Requirements: 7.2
+"""
+
+from flask import jsonify
+from app.api import api_bp
+from app.models import Task, Report
+from app.services import login_required, get_current_user_from_context
+from app import db
+
+
+@api_bp.route('/dashboard/stats', methods=['GET'])
+@login_required
+def get_dashboard_stats():
+    """
+    Get dashboard statistics including task counts and recent reports.
+    
+    Returns:
+        JSON with task statistics and recent reports
+    
+    Requirements:
+        - 7.2: Show task status summary and recent reports
+    """
+    current_user = get_current_user_from_context()
+    
+    # Build base query based on user role
+    if current_user.role in ['admin', 'power_user']:
+        task_query = Task.query
+    else:
+        # Regular users can only see their own tasks
+        task_query = Task.query.filter_by(created_by=current_user.id)
+    
+    # Get task counts by status
+    total_tasks = task_query.count()
+    pending_tasks = task_query.filter_by(status='pending').count()
+    running_tasks = task_query.filter_by(status='running').count()
+    completed_tasks = task_query.filter_by(status='completed').count()
+    failed_tasks = task_query.filter_by(status='failed').count()
+    
+    # Get recent reports (last 5)
+    if current_user.role in ['admin', 'power_user']:
+        recent_reports_query = db.session.query(Report).join(Task)
+    else:
+        recent_reports_query = db.session.query(Report).join(Task).filter(
+            Task.created_by == current_user.id
+        )
+    
+    recent_reports = recent_reports_query.order_by(
+        Report.created_at.desc()
+    ).limit(5).all()
+    
+    # Get total reports count
+    total_reports = recent_reports_query.count()
+    
+    return jsonify({
+        'tasks': {
+            'total': total_tasks,
+            'pending': pending_tasks,
+            'running': running_tasks,
+            'completed': completed_tasks,
+            'failed': failed_tasks
+        },
+        'reports': {
+            'total': total_reports,
+            'recent': [report.to_dict() for report in recent_reports]
+        }
+    }), 200

+ 162 - 0
backend/app/api/reports.py

@@ -0,0 +1,162 @@
+"""
+Reports API endpoints.
+
+Provides endpoints for:
+- GET /api/reports - Get paginated list of reports
+- GET /api/reports/detail - Get report details
+- GET /api/reports/download - Download report file
+- POST /api/reports/delete - Delete a report
+"""
+
+import os
+from flask import jsonify, request, send_file, current_app
+from app.api import api_bp
+from app.services import login_required, get_current_user_from_context, get_accessible_reports
+from app.services.report_service import ReportService
+from app.models import Report, Task
+
+
+@api_bp.route('/reports', methods=['GET'])
+@login_required
+def get_reports():
+    """
+    Get paginated list of reports.
+    
+    Query Parameters:
+        page: Page number (default: 1)
+        page_size: Items per page (default: 20, max: 100)
+        task_id: Optional filter by task ID
+    
+    Returns:
+        JSON with 'data' array and 'pagination' object
+    """
+    current_user = get_current_user_from_context()
+    
+    page = request.args.get('page', 1, type=int)
+    # Support both pageSize (frontend) and page_size (backend convention)
+    page_size = request.args.get('pageSize', type=int) or request.args.get('page_size', type=int) or 20
+    page_size = min(page_size, 100)
+    task_id = request.args.get('task_id', type=int)
+    
+    # For regular users, only show their own reports
+    user_id = None
+    if current_user.role == 'user':
+        user_id = current_user.id
+    
+    result = ReportService.get_reports(
+        page=page,
+        page_size=page_size,
+        task_id=task_id,
+        user_id=user_id
+    )
+    
+    return jsonify(result), 200
+
+
+@api_bp.route('/reports/detail', methods=['GET'])
+@login_required
+def get_report_detail():
+    """
+    Get report details.
+    
+    Query Parameters:
+        id: Report ID (required)
+    
+    Returns:
+        JSON with report details
+    """
+    current_user = get_current_user_from_context()
+    report_id = request.args.get('id', type=int)
+    
+    if not report_id:
+        return jsonify({'error': {'code': 'MISSING_PARAMETER', 'message': 'Report ID is required'}}), 400
+    
+    report = ReportService.get_report_by_id(report_id)
+    if not report:
+        return jsonify({'error': {'code': 'NOT_FOUND', 'message': 'Report not found'}}), 404
+    
+    # Check access for regular users
+    if current_user.role == 'user':
+        task = Task.query.get(report.task_id)
+        if not task or task.created_by != current_user.id:
+            return jsonify({'error': {'code': 'FORBIDDEN', 'message': 'Access denied'}}), 403
+    
+    return jsonify(report.to_dict()), 200
+
+
+
+@api_bp.route('/reports/download', methods=['GET'])
+@login_required
+def download_report():
+    """
+    Download a report file.
+    
+    Query Parameters:
+        id: Report ID (required)
+    
+    Returns:
+        The report file as attachment
+    """
+    current_user = get_current_user_from_context()
+    report_id = request.args.get('id', type=int)
+    
+    if not report_id:
+        return jsonify({'error': {'code': 'MISSING_PARAMETER', 'message': 'Report ID is required'}}), 400
+    
+    report = ReportService.get_report_by_id(report_id)
+    if not report:
+        return jsonify({'error': {'code': 'NOT_FOUND', 'message': 'Report not found'}}), 404
+    
+    # Check access for regular users
+    if current_user.role == 'user':
+        task = Task.query.get(report.task_id)
+        if not task or task.created_by != current_user.id:
+            return jsonify({'error': {'code': 'FORBIDDEN', 'message': 'Access denied'}}), 403
+    
+    # Get file path
+    file_path = ReportService.get_report_file_path(report_id)
+    if not file_path:
+        return jsonify({'error': {'code': 'FILE_NOT_FOUND', 'message': 'Report file not found'}}), 404
+    
+    return send_file(
+        file_path,
+        as_attachment=True,
+        download_name=report.file_name,
+        mimetype='application/vnd.openxmlformats-officedocument.wordprocessingml.document'
+    )
+
+
+@api_bp.route('/reports/delete', methods=['POST'])
+@login_required
+def delete_report():
+    """
+    Delete a report.
+    
+    Request Body:
+        id: Report ID (required)
+    
+    Returns:
+        JSON with success message
+    """
+    current_user = get_current_user_from_context()
+    data = request.get_json() or {}
+    report_id = data.get('id')
+    
+    if not report_id:
+        return jsonify({'error': {'code': 'MISSING_PARAMETER', 'message': 'Report ID is required'}}), 400
+    
+    report = ReportService.get_report_by_id(report_id)
+    if not report:
+        return jsonify({'error': {'code': 'NOT_FOUND', 'message': 'Report not found'}}), 404
+    
+    # Only admins can delete reports, or users can delete their own
+    if current_user.role == 'user':
+        task = Task.query.get(report.task_id)
+        if not task or task.created_by != current_user.id:
+            return jsonify({'error': {'code': 'FORBIDDEN', 'message': 'Access denied'}}), 403
+    
+    success = ReportService.delete_report(report_id)
+    if success:
+        return jsonify({'message': 'Report deleted successfully'}), 200
+    else:
+        return jsonify({'error': {'code': 'DELETE_FAILED', 'message': 'Failed to delete report'}}), 500

+ 593 - 0
backend/app/api/tasks.py

@@ -0,0 +1,593 @@
+"""
+Task Management API endpoints
+
+Provides endpoints for:
+- GET /api/tasks - Get paginated list of tasks with status filtering
+- POST /api/tasks/create - Create a new scan task
+- GET /api/tasks/detail - Get task details
+- POST /api/tasks/delete - Delete a task
+- GET /api/tasks/logs - Get task logs with pagination
+
+Requirements: 3.1, 3.4
+"""
+import os
+from flask import jsonify, request, current_app
+from werkzeug.utils import secure_filename
+
+from app import db
+from app.api import api_bp
+from app.models import Task, TaskLog, AWSCredential, UserCredential
+from app.services import login_required, admin_required, get_current_user_from_context, check_credential_access
+from app.errors import ValidationError, NotFoundError, AuthorizationError
+
+
+ALLOWED_IMAGE_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'bmp'}
+
+
+def allowed_file(filename: str) -> bool:
+    """Check if file extension is allowed for network diagram"""
+    return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_IMAGE_EXTENSIONS
+
+
+@api_bp.route('/tasks', methods=['GET'])
+@login_required
+def get_tasks():
+    """
+    Get paginated list of tasks with optional status filtering.
+    
+    Query Parameters:
+        page: Page number (default: 1)
+        page_size: Items per page (default: 20, max: 100)
+        status: Optional filter by status (pending, running, completed, failed)
+    
+    Returns:
+        JSON with 'data' array and 'pagination' object
+    """
+    current_user = get_current_user_from_context()
+    
+    # Get pagination parameters
+    page = request.args.get('page', 1, type=int)
+    # Support both pageSize (frontend) and page_size (backend convention)
+    page_size = request.args.get('pageSize', type=int) or request.args.get('page_size', type=int) or 20
+    page_size = min(page_size, 100)
+    status = request.args.get('status', type=str)
+    
+    # Validate pagination
+    if page < 1:
+        page = 1
+    if page_size < 1:
+        page_size = 20
+    
+    # Build query based on user role
+    if current_user.role in ['admin', 'power_user']:
+        query = Task.query
+    else:
+        # Regular users can only see their own tasks
+        query = Task.query.filter_by(created_by=current_user.id)
+    
+    # Apply status filter if provided
+    if status and status in ['pending', 'running', 'completed', 'failed']:
+        query = query.filter_by(status=status)
+    
+    # Order by created_at descending
+    query = query.order_by(Task.created_at.desc())
+    
+    # Get total count
+    total = query.count()
+    total_pages = (total + page_size - 1) // page_size if total > 0 else 1
+    
+    # Apply pagination
+    tasks = query.offset((page - 1) * page_size).limit(page_size).all()
+    
+    return jsonify({
+        'data': [task.to_dict() for task in tasks],
+        'pagination': {
+            'page': page,
+            'page_size': page_size,
+            'total': total,
+            'total_pages': total_pages
+        }
+    }), 200
+
+
+@api_bp.route('/tasks/create', methods=['POST'])
+@login_required
+def create_task():
+    """
+    Create a new scan task.
+    
+    Request Body (JSON or multipart/form-data):
+        name: Task name (required)
+        credential_ids: List of credential IDs to use (required)
+        regions: List of AWS regions to scan (required)
+        project_metadata: Project metadata object (required)
+            - clientName: Client name (required)
+            - projectName: Project name (required)
+            - bdManager: BD Manager name (optional)
+            - bdManagerEmail: BD Manager email (optional)
+            - solutionsArchitect: Solutions Architect name (optional)
+            - solutionsArchitectEmail: Solutions Architect email (optional)
+            - cloudEngineer: Cloud Engineer name (optional)
+            - cloudEngineerEmail: Cloud Engineer email (optional)
+        network_diagram: Network diagram image file (optional, multipart only)
+    
+    Returns:
+        JSON with created task details and task_id
+    """
+    current_user = get_current_user_from_context()
+    
+    # Handle both JSON and multipart/form-data
+    if request.content_type and 'multipart/form-data' in request.content_type:
+        data = request.form.to_dict()
+        # Parse JSON fields from form data
+        import json
+        if 'credential_ids' in data:
+            data['credential_ids'] = json.loads(data['credential_ids'])
+        if 'regions' in data:
+            data['regions'] = json.loads(data['regions'])
+        if 'project_metadata' in data:
+            data['project_metadata'] = json.loads(data['project_metadata'])
+        network_diagram = request.files.get('network_diagram')
+    else:
+        data = request.get_json() or {}
+        network_diagram = None
+    
+    # Validate required fields
+    if not data.get('name'):
+        raise ValidationError(
+            message="Task name is required",
+            details={"missing_fields": ["name"]}
+        )
+    
+    credential_ids = data.get('credential_ids', [])
+    if not credential_ids or not isinstance(credential_ids, list) or len(credential_ids) == 0:
+        raise ValidationError(
+            message="At least one credential must be selected",
+            details={"missing_fields": ["credential_ids"]}
+        )
+    
+    regions = data.get('regions', [])
+    if not regions or not isinstance(regions, list) or len(regions) == 0:
+        raise ValidationError(
+            message="At least one region must be selected",
+            details={"missing_fields": ["regions"]}
+        )
+    
+    project_metadata = data.get('project_metadata', {})
+    if not isinstance(project_metadata, dict):
+        raise ValidationError(
+            message="Project metadata must be an object",
+            details={"field": "project_metadata", "reason": "invalid_type"}
+        )
+    
+    # Validate required project metadata fields
+    required_metadata = ['clientName', 'projectName']
+    missing_metadata = [field for field in required_metadata if not project_metadata.get(field)]
+    if missing_metadata:
+        raise ValidationError(
+            message="Missing required project metadata fields",
+            details={"missing_fields": missing_metadata}
+        )
+    
+    # Validate credential access for regular users
+    for cred_id in credential_ids:
+        if not check_credential_access(current_user, cred_id):
+            raise AuthorizationError(
+                message=f"Access denied to credential {cred_id}",
+                details={"credential_id": cred_id, "reason": "not_assigned"}
+            )
+        
+        # Verify credential exists and is active
+        credential = db.session.get(AWSCredential, cred_id)
+        if not credential:
+            raise NotFoundError(
+                message=f"Credential {cred_id} not found",
+                details={"credential_id": cred_id}
+            )
+        if not credential.is_active:
+            raise ValidationError(
+                message=f"Credential {cred_id} is not active",
+                details={"credential_id": cred_id, "reason": "inactive"}
+            )
+    
+    # Handle network diagram upload
+    network_diagram_path = None
+    if network_diagram and network_diagram.filename:
+        if not allowed_file(network_diagram.filename):
+            raise ValidationError(
+                message="Invalid file type for network diagram. Allowed: png, jpg, jpeg, gif, bmp",
+                details={"field": "network_diagram", "reason": "invalid_file_type"}
+            )
+        
+        # Save the file
+        uploads_folder = current_app.config.get('UPLOAD_FOLDER', 'uploads')
+        os.makedirs(uploads_folder, exist_ok=True)
+        
+        filename = secure_filename(network_diagram.filename)
+        # Add timestamp to avoid conflicts
+        import time
+        filename = f"{int(time.time())}_{filename}"
+        network_diagram_path = os.path.join(uploads_folder, filename)
+        network_diagram.save(network_diagram_path)
+        
+        # Store path in project metadata
+        project_metadata['network_diagram_path'] = network_diagram_path
+    
+    # Create task
+    task = Task(
+        name=data['name'].strip(),
+        status='pending',
+        progress=0,
+        created_by=current_user.id
+    )
+    task.credential_ids = credential_ids
+    task.regions = regions
+    task.project_metadata = project_metadata
+    
+    db.session.add(task)
+    db.session.commit()
+    
+    # Dispatch to Celery
+    celery_task = None
+    use_mock = False
+    
+    try:
+        # 尝试使用真实的Celery (延迟导入)
+        print("🔍 尝试导入Celery任务模块...")
+        
+        # 先测试Redis连接
+        import redis
+        r = redis.Redis(host='localhost', port=6379, db=0)
+        r.ping()
+        print("✅ Redis连接测试通过")
+        
+        # 导入并初始化Celery应用
+        from app.celery_app import celery_app, init_celery
+        
+        # 确保Celery使用正确的broker配置
+        init_celery(current_app._get_current_object())
+        print(f"✅ Celery broker配置: {celery_app.conf.broker_url}")
+        
+        # 导入Celery任务
+        from app.tasks.scan_tasks import scan_aws_resources
+        print("✅ Celery任务模块导入成功")
+        
+        # 提交任务
+        print("🔍 提交任务到Celery队列...")
+        celery_task = scan_aws_resources.delay(
+            task_id=task.id,
+            credential_ids=credential_ids,
+            regions=regions,
+            project_metadata=project_metadata
+        )
+        print(f"✅ 任务已提交到Celery队列: {celery_task.id}")
+        
+    except Exception as e:
+        # 详细的错误信息
+        error_str = str(e)
+        error_type = type(e).__name__
+        print(f"❌ Celery任务提交失败:")
+        print(f"   错误类型: {error_type}")
+        print(f"   错误信息: {error_str}")
+        
+        use_mock = True
+    
+    # 如果Celery失败,使用Mock模式
+    if use_mock:
+        try:
+            print("🔄 切换到Mock模式")
+            from app.tasks.mock_tasks import scan_aws_resources
+            celery_task = scan_aws_resources.delay(
+                task_id=task.id,
+                credential_ids=credential_ids,
+                regions=regions,
+                project_metadata=project_metadata
+            )
+            print(f"🔄 任务已提交到Mock队列: {celery_task.id}")
+        except Exception as e:
+            print(f"❌ Mock模式也失败: {e}")
+            raise ValidationError(
+                message="Failed to submit task to both Celery and Mock mode",
+                details={"celery_error": str(e)}
+            )
+    
+    # Store Celery task ID
+    task.celery_task_id = celery_task.id
+    db.session.commit()
+    
+    return jsonify({
+        'message': 'Task created successfully',
+        'task': task.to_dict(),
+        'celery_task_id': celery_task.id
+    }), 201
+
+
+@api_bp.route('/tasks/detail', methods=['GET'])
+@login_required
+def get_task_detail():
+    """
+    Get task details including current status and progress.
+    
+    Query Parameters:
+        id: Task ID (required)
+    
+    Returns:
+        JSON with task details
+    """
+    current_user = get_current_user_from_context()
+    task_id = request.args.get('id', type=int)
+    
+    if not task_id:
+        raise ValidationError(
+            message="Task ID is required",
+            details={"missing_fields": ["id"]}
+        )
+    
+    task = db.session.get(Task, task_id)
+    if not task:
+        raise NotFoundError(
+            message="Task not found",
+            details={"task_id": task_id}
+        )
+    
+    # Check access for regular users
+    if current_user.role == 'user' and task.created_by != current_user.id:
+        raise AuthorizationError(
+            message="Access denied",
+            details={"reason": "not_owner"}
+        )
+    
+    # Get task details with additional info
+    task_dict = task.to_dict()
+    
+    # Add report info if available
+    if task.report:
+        task_dict['report'] = task.report.to_dict()
+    
+    # Add error count
+    error_count = TaskLog.query.filter_by(task_id=task_id, level='error').count()
+    task_dict['error_count'] = error_count
+    
+    # Get Celery task status if running
+    if task.status == 'running' and task.celery_task_id:
+        from celery.result import AsyncResult
+        from app.celery_app import celery_app
+        
+        result = AsyncResult(task.celery_task_id, app=celery_app)
+        if result.state == 'PROGRESS':
+            task_dict['celery_progress'] = result.info
+    
+    return jsonify(task_dict), 200
+
+
+@api_bp.route('/tasks/delete', methods=['POST'])
+@login_required
+def delete_task():
+    """
+    Delete a task and its associated logs and report.
+    
+    Request Body:
+        id: Task ID (required)
+    
+    Returns:
+        JSON with success message
+    """
+    current_user = get_current_user_from_context()
+    data = request.get_json() or {}
+    task_id = data.get('id')
+    
+    if not task_id:
+        raise ValidationError(
+            message="Task ID is required",
+            details={"missing_fields": ["id"]}
+        )
+    
+    task = db.session.get(Task, task_id)
+    if not task:
+        raise NotFoundError(
+            message="Task not found",
+            details={"task_id": task_id}
+        )
+    
+    # Check access - only admin or task owner can delete
+    if current_user.role != 'admin' and task.created_by != current_user.id:
+        raise AuthorizationError(
+            message="Access denied",
+            details={"reason": "not_owner_or_admin"}
+        )
+    
+    # Cannot delete running tasks
+    if task.status == 'running':
+        raise ValidationError(
+            message="Cannot delete a running task",
+            details={"task_id": task_id, "status": task.status}
+        )
+    
+    # Delete associated report file if exists
+    if task.report and task.report.file_path:
+        try:
+            if os.path.exists(task.report.file_path):
+                os.remove(task.report.file_path)
+        except OSError:
+            pass  # File may already be deleted
+    
+    # Delete task (cascade will handle logs and report)
+    db.session.delete(task)
+    db.session.commit()
+    
+    return jsonify({
+        'message': 'Task deleted successfully'
+    }), 200
+
+
+@api_bp.route('/tasks/logs', methods=['GET'])
+@login_required
+def get_task_logs():
+    """
+    Get paginated task logs.
+    
+    Query Parameters:
+        id: Task ID (required)
+        page: Page number (default: 1)
+        page_size: Items per page (default: 20, max: 100)
+        level: Optional filter by log level (info, warning, error)
+    
+    Returns:
+        JSON with 'data' array and 'pagination' object
+    
+    Requirements:
+        - 8.3: Display error logs associated with task
+    """
+    current_user = get_current_user_from_context()
+    task_id = request.args.get('id', type=int)
+    
+    if not task_id:
+        raise ValidationError(
+            message="Task ID is required",
+            details={"missing_fields": ["id"]}
+        )
+    
+    task = db.session.get(Task, task_id)
+    if not task:
+        raise NotFoundError(
+            message="Task not found",
+            details={"task_id": task_id}
+        )
+    
+    # Check access for regular users
+    if current_user.role == 'user' and task.created_by != current_user.id:
+        raise AuthorizationError(
+            message="Access denied",
+            details={"reason": "not_owner"}
+        )
+    
+    # Get pagination parameters
+    page = request.args.get('page', 1, type=int)
+    # Support both pageSize (frontend) and page_size (backend convention)
+    page_size = request.args.get('pageSize', type=int) or request.args.get('page_size', type=int) or 20
+    page_size = min(page_size, 100)
+    level = request.args.get('level', type=str)
+    
+    # Validate pagination
+    if page < 1:
+        page = 1
+    if page_size < 1:
+        page_size = 20
+    
+    # Build query
+    query = TaskLog.query.filter_by(task_id=task_id)
+    
+    # Apply level filter if provided
+    if level and level in ['info', 'warning', 'error']:
+        query = query.filter_by(level=level)
+    
+    # Order by created_at descending
+    query = query.order_by(TaskLog.created_at.desc())
+    
+    # Get total count
+    total = query.count()
+    total_pages = (total + page_size - 1) // page_size if total > 0 else 1
+    
+    # Apply pagination
+    logs = query.offset((page - 1) * page_size).limit(page_size).all()
+    
+    return jsonify({
+        'data': [log.to_dict() for log in logs],
+        'pagination': {
+            'page': page,
+            'page_size': page_size,
+            'total': total,
+            'total_pages': total_pages
+        }
+    }), 200
+
+
+@api_bp.route('/tasks/errors', methods=['GET'])
+@login_required
+def get_task_errors():
+    """
+    Get error logs for a specific task.
+    
+    This is a convenience endpoint that returns only error-level logs
+    with full details including stack traces.
+    
+    Query Parameters:
+        id: Task ID (required)
+        page: Page number (default: 1)
+        page_size: Items per page (default: 20, max: 100)
+    
+    Returns:
+        JSON with 'data' array containing error logs and 'pagination' object
+    
+    Requirements:
+        - 8.2: Record error details in task record
+        - 8.3: Display error logs associated with task
+    """
+    current_user = get_current_user_from_context()
+    task_id = request.args.get('id', type=int)
+    
+    if not task_id:
+        raise ValidationError(
+            message="Task ID is required",
+            details={"missing_fields": ["id"]}
+        )
+    
+    task = db.session.get(Task, task_id)
+    if not task:
+        raise NotFoundError(
+            message="Task not found",
+            details={"task_id": task_id}
+        )
+    
+    # Check access for regular users
+    if current_user.role == 'user' and task.created_by != current_user.id:
+        raise AuthorizationError(
+            message="Access denied",
+            details={"reason": "not_owner"}
+        )
+    
+    # Get pagination parameters
+    page = request.args.get('page', 1, type=int)
+    # Support both pageSize (frontend) and page_size (backend convention)
+    page_size = request.args.get('pageSize', type=int) or request.args.get('page_size', type=int) or 20
+    page_size = min(page_size, 100)
+    
+    # Validate pagination
+    if page < 1:
+        page = 1
+    if page_size < 1:
+        page_size = 20
+    
+    # Build query for error logs only
+    query = TaskLog.query.filter_by(task_id=task_id, level='error')
+    
+    # Order by created_at descending
+    query = query.order_by(TaskLog.created_at.desc())
+    
+    # Get total count
+    total = query.count()
+    total_pages = (total + page_size - 1) // page_size if total > 0 else 1
+    
+    # Apply pagination
+    logs = query.offset((page - 1) * page_size).limit(page_size).all()
+    
+    # Build response with full error details
+    error_data = []
+    for log in logs:
+        log_dict = log.to_dict()
+        # Ensure details are included for error analysis
+        error_data.append(log_dict)
+    
+    return jsonify({
+        'data': error_data,
+        'pagination': {
+            'page': page,
+            'page_size': page_size,
+            'total': total,
+            'total_pages': total_pages
+        },
+        'summary': {
+            'total_errors': total,
+            'task_status': task.status
+        }
+    }), 200

+ 447 - 0
backend/app/api/users.py

@@ -0,0 +1,447 @@
+"""
+User Management API endpoints (Admin only)
+
+Provides user CRUD operations and credential assignment.
+"""
+from flask import jsonify, request
+from app import db
+from app.api import api_bp
+from app.models import User, UserCredential, AWSCredential
+from app.services import admin_required, get_current_user_from_context
+from app.errors import ValidationError, NotFoundError
+
+
+def validate_email(email: str) -> bool:
+    """Basic email validation"""
+    import re
+    pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
+    return bool(re.match(pattern, email))
+
+
+def validate_role(role: str) -> bool:
+    """Validate user role"""
+    return role in ['admin', 'power_user', 'user']
+
+
+@api_bp.route('/users', methods=['GET'])
+@admin_required
+def get_users():
+    """
+    Get users list with pagination and search
+    
+    Query params:
+        page: Page number (default: 1)
+        page_size: Items per page (default: 20, max: 100)
+        search: Search term for username or email
+    
+    Returns:
+        {
+            "data": [user objects],
+            "pagination": {
+                "page": 1,
+                "page_size": 20,
+                "total": 100,
+                "total_pages": 5
+            }
+        }
+    """
+    # Get pagination parameters
+    page = request.args.get('page', 1, type=int)
+    # Support both pageSize (frontend) and page_size (backend convention)
+    page_size = request.args.get('pageSize', type=int) or request.args.get('page_size', type=int) or 20
+    search = request.args.get('search', '', type=str)
+    
+    # Validate pagination parameters
+    if page < 1:
+        page = 1
+    if page_size < 1:
+        page_size = 20
+    if page_size > 100:
+        page_size = 100
+    
+    # Build query
+    query = User.query
+    
+    # Apply search filter
+    if search:
+        search_term = f'%{search}%'
+        query = query.filter(
+            db.or_(
+                User.username.ilike(search_term),
+                User.email.ilike(search_term)
+            )
+        )
+    
+    # Order by created_at descending
+    query = query.order_by(User.created_at.desc())
+    
+    # Get total count
+    total = query.count()
+    
+    # Calculate total pages
+    total_pages = (total + page_size - 1) // page_size if total > 0 else 1
+    
+    # Apply pagination
+    users = query.offset((page - 1) * page_size).limit(page_size).all()
+    
+    return jsonify({
+        'data': [user.to_dict() for user in users],
+        'pagination': {
+            'page': page,
+            'page_size': page_size,
+            'total': total,
+            'total_pages': total_pages
+        }
+    }), 200
+
+
+@api_bp.route('/users/create', methods=['POST'])
+@admin_required
+def create_user():
+    """
+    Create a new user
+    
+    Request body:
+        {
+            "username": "string" (required),
+            "password": "string" (required),
+            "email": "string" (required),
+            "role": "admin" | "power_user" | "user" (required)
+        }
+    
+    Returns:
+        { user object }
+    """
+    data = request.get_json()
+    
+    if not data:
+        raise ValidationError(
+            message="Request body is required",
+            details={"reason": "missing_body"}
+        )
+    
+    # Validate required fields
+    required_fields = ['username', 'password', 'email', 'role']
+    missing_fields = [field for field in required_fields if not data.get(field)]
+    
+    if missing_fields:
+        raise ValidationError(
+            message="Missing required fields",
+            details={"missing_fields": missing_fields}
+        )
+    
+    username = data['username'].strip()
+    password = data['password']
+    email = data['email'].strip().lower()
+    role = data['role']
+    
+    # Validate username length
+    if len(username) < 3 or len(username) > 50:
+        raise ValidationError(
+            message="Username must be between 3 and 50 characters",
+            details={"field": "username", "reason": "invalid_length"}
+        )
+    
+    # Validate password length
+    if len(password) < 6:
+        raise ValidationError(
+            message="Password must be at least 6 characters",
+            details={"field": "password", "reason": "too_short"}
+        )
+    
+    # Validate email format
+    if not validate_email(email):
+        raise ValidationError(
+            message="Invalid email format",
+            details={"field": "email", "reason": "invalid_format"}
+        )
+    
+    # Validate role
+    if not validate_role(role):
+        raise ValidationError(
+            message="Invalid role. Must be one of: admin, power_user, user",
+            details={"field": "role", "reason": "invalid_value"}
+        )
+    
+    # Check if username already exists
+    if User.query.filter_by(username=username).first():
+        raise ValidationError(
+            message="Username already exists",
+            details={"field": "username", "reason": "already_exists"}
+        )
+    
+    # Check if email already exists
+    if User.query.filter_by(email=email).first():
+        raise ValidationError(
+            message="Email already exists",
+            details={"field": "email", "reason": "already_exists"}
+        )
+    
+    # Create new user
+    user = User(
+        username=username,
+        email=email,
+        role=role,
+        is_active=True
+    )
+    user.set_password(password)
+    
+    db.session.add(user)
+    db.session.commit()
+    
+    return jsonify(user.to_dict()), 201
+
+
+@api_bp.route('/users/update', methods=['POST'])
+@admin_required
+def update_user():
+    """
+    Update an existing user
+    
+    Request body:
+        {
+            "id": number (required),
+            "username": "string" (optional),
+            "email": "string" (optional),
+            "password": "string" (optional),
+            "role": "admin" | "power_user" | "user" (optional),
+            "is_active": boolean (optional)
+        }
+    
+    Returns:
+        { updated user object }
+    """
+    data = request.get_json()
+    
+    if not data:
+        raise ValidationError(
+            message="Request body is required",
+            details={"reason": "missing_body"}
+        )
+    
+    # Validate user ID
+    user_id = data.get('id')
+    if not user_id:
+        raise ValidationError(
+            message="User ID is required",
+            details={"missing_fields": ["id"]}
+        )
+    
+    # Find user
+    user = db.session.get(User, user_id)
+    if not user:
+        raise NotFoundError(
+            message="User not found",
+            details={"user_id": user_id}
+        )
+    
+    # Get current admin user
+    current_user = get_current_user_from_context()
+    
+    # Prevent admin from deactivating themselves
+    if user.id == current_user.id and data.get('is_active') is False:
+        raise ValidationError(
+            message="Cannot deactivate your own account",
+            details={"reason": "self_deactivation"}
+        )
+    
+    # Prevent admin from changing their own role
+    if user.id == current_user.id and data.get('role') and data.get('role') != user.role:
+        raise ValidationError(
+            message="Cannot change your own role",
+            details={"reason": "self_role_change"}
+        )
+    
+    # Update username if provided
+    if 'username' in data and data['username']:
+        new_username = data['username'].strip()
+        if len(new_username) < 3 or len(new_username) > 50:
+            raise ValidationError(
+                message="Username must be between 3 and 50 characters",
+                details={"field": "username", "reason": "invalid_length"}
+            )
+        # Check if username is taken by another user
+        existing = User.query.filter_by(username=new_username).first()
+        if existing and existing.id != user.id:
+            raise ValidationError(
+                message="Username already exists",
+                details={"field": "username", "reason": "already_exists"}
+            )
+        user.username = new_username
+    
+    # Update email if provided
+    if 'email' in data and data['email']:
+        new_email = data['email'].strip().lower()
+        if not validate_email(new_email):
+            raise ValidationError(
+                message="Invalid email format",
+                details={"field": "email", "reason": "invalid_format"}
+            )
+        # Check if email is taken by another user
+        existing = User.query.filter_by(email=new_email).first()
+        if existing and existing.id != user.id:
+            raise ValidationError(
+                message="Email already exists",
+                details={"field": "email", "reason": "already_exists"}
+            )
+        user.email = new_email
+    
+    # Update password if provided
+    if 'password' in data and data['password']:
+        if len(data['password']) < 6:
+            raise ValidationError(
+                message="Password must be at least 6 characters",
+                details={"field": "password", "reason": "too_short"}
+            )
+        user.set_password(data['password'])
+    
+    # Update role if provided
+    if 'role' in data and data['role']:
+        if not validate_role(data['role']):
+            raise ValidationError(
+                message="Invalid role. Must be one of: admin, power_user, user",
+                details={"field": "role", "reason": "invalid_value"}
+            )
+        user.role = data['role']
+    
+    # Update is_active if provided
+    if 'is_active' in data:
+        user.is_active = bool(data['is_active'])
+    
+    db.session.commit()
+    
+    return jsonify(user.to_dict()), 200
+
+
+@api_bp.route('/users/delete', methods=['POST'])
+@admin_required
+def delete_user():
+    """
+    Delete a user
+    
+    Request body:
+        {
+            "id": number (required)
+        }
+    
+    Returns:
+        { "message": "User deleted successfully" }
+    """
+    data = request.get_json()
+    
+    if not data:
+        raise ValidationError(
+            message="Request body is required",
+            details={"reason": "missing_body"}
+        )
+    
+    # Validate user ID
+    user_id = data.get('id')
+    if not user_id:
+        raise ValidationError(
+            message="User ID is required",
+            details={"missing_fields": ["id"]}
+        )
+    
+    # Find user
+    user = db.session.get(User, user_id)
+    if not user:
+        raise NotFoundError(
+            message="User not found",
+            details={"user_id": user_id}
+        )
+    
+    # Get current admin user
+    current_user = get_current_user_from_context()
+    
+    # Prevent admin from deleting themselves
+    if user.id == current_user.id:
+        raise ValidationError(
+            message="Cannot delete your own account",
+            details={"reason": "self_deletion"}
+        )
+    
+    # Delete user (cascade will handle related records)
+    db.session.delete(user)
+    db.session.commit()
+    
+    return jsonify({
+        'message': 'User deleted successfully'
+    }), 200
+
+
+@api_bp.route('/users/assign-credentials', methods=['POST'])
+@admin_required
+def assign_credentials():
+    """
+    Assign credentials to a user
+    
+    Request body:
+        {
+            "user_id": number (required),
+            "credential_ids": [number] (required)
+        }
+    
+    Returns:
+        { "message": "Credentials assigned successfully", "assigned_count": number }
+    """
+    data = request.get_json()
+    
+    if not data:
+        raise ValidationError(
+            message="Request body is required",
+            details={"reason": "missing_body"}
+        )
+    
+    # Validate required fields
+    user_id = data.get('user_id')
+    credential_ids = data.get('credential_ids')
+    
+    if not user_id:
+        raise ValidationError(
+            message="User ID is required",
+            details={"missing_fields": ["user_id"]}
+        )
+    
+    if credential_ids is None:
+        raise ValidationError(
+            message="Credential IDs are required",
+            details={"missing_fields": ["credential_ids"]}
+        )
+    
+    if not isinstance(credential_ids, list):
+        raise ValidationError(
+            message="Credential IDs must be a list",
+            details={"field": "credential_ids", "reason": "invalid_type"}
+        )
+    
+    # Find user
+    user = db.session.get(User, user_id)
+    if not user:
+        raise NotFoundError(
+            message="User not found",
+            details={"user_id": user_id}
+        )
+    
+    # Remove existing credential assignments
+    UserCredential.query.filter_by(user_id=user_id).delete()
+    
+    # Assign new credentials
+    assigned_count = 0
+    for cred_id in credential_ids:
+        # Verify credential exists
+        credential = db.session.get(AWSCredential, cred_id)
+        if credential and credential.is_active:
+            assignment = UserCredential(
+                user_id=user_id,
+                credential_id=cred_id
+            )
+            db.session.add(assignment)
+            assigned_count += 1
+    
+    db.session.commit()
+    
+    return jsonify({
+        'message': 'Credentials assigned successfully',
+        'assigned_count': assigned_count
+    }), 200

+ 283 - 0
backend/app/api/workers.py

@@ -0,0 +1,283 @@
+"""
+Worker Management API endpoints
+
+Provides endpoints for:
+- GET /api/workers - Get Celery worker status
+- GET /api/workers/stats - Get worker statistics
+- POST /api/workers/purge - Purge queue tasks
+
+Requirements: 4.8
+"""
+from flask import jsonify, request
+from app.api import api_bp
+from app.services import admin_required
+from app.celery_app import celery_app
+
+# Cache for static worker info (registered tasks, prefetch count don't change)
+_worker_cache = {
+    'registered_tasks': {},
+    'prefetch_count': {},
+    'last_known_workers': set()
+}
+
+
+@api_bp.route('/workers', methods=['GET'])
+@admin_required
+def get_workers():
+    """
+    Get Celery worker status list.
+    
+    Returns:
+        JSON with list of workers and their status
+    """
+    global _worker_cache
+    
+    try:
+        workers = []
+        worker_names = set()
+        active_tasks = {}
+        registered_tasks = {}
+        stats = {}
+        
+        # Try inspect with short timeout
+        try:
+            inspect = celery_app.control.inspect(timeout=0.5)
+            ping_response = inspect.ping() or {}
+            for name in ping_response.keys():
+                worker_names.add(name)
+            active_tasks = inspect.active() or {}
+            for name in active_tasks.keys():
+                worker_names.add(name)
+            registered_tasks = inspect.registered() or {}
+            stats = inspect.stats() or {}
+            
+            # Update cache with new data if we got any
+            if registered_tasks:
+                _worker_cache['registered_tasks'].update(registered_tasks)
+            if stats:
+                for name, s in stats.items():
+                    _worker_cache['prefetch_count'][name] = s.get('prefetch_count', 0)
+            if worker_names:
+                _worker_cache['last_known_workers'].update(worker_names)
+        except Exception:
+            pass
+        
+        # Check running tasks in database to infer worker activity
+        running_task_count = 0
+        try:
+            from app.models.task import Task
+            running_task_count = Task.query.filter_by(status='running').count()
+        except Exception:
+            pass
+        
+        # If no workers found from inspect but we have running tasks, use cached workers
+        if not worker_names and running_task_count > 0 and _worker_cache['last_known_workers']:
+            worker_names = _worker_cache['last_known_workers']
+        
+        # Build worker list
+        for worker_name in worker_names:
+            task_count = len(active_tasks.get(worker_name, []))
+            # If inspect shows 0 but DB shows running tasks, use DB count
+            if task_count == 0 and running_task_count > 0:
+                task_count = running_task_count
+            
+            # Use cached values if current inspect returned empty
+            cached_registered = _worker_cache['registered_tasks'].get(worker_name, [])
+            cached_prefetch = _worker_cache['prefetch_count'].get(worker_name, 0)
+            
+            worker_info = {
+                'name': worker_name,
+                'status': 'online',
+                'active_tasks': task_count,
+                'registered_tasks': registered_tasks.get(worker_name) or cached_registered,
+                'prefetch_count': stats.get(worker_name, {}).get('prefetch_count') or cached_prefetch,
+            }
+            
+            # Add stats if available
+            if worker_name in stats:
+                worker_stats = stats[worker_name]
+                worker_info['pool'] = worker_stats.get('pool', {})
+                worker_info['broker'] = worker_stats.get('broker', {})
+                worker_info['total_tasks'] = worker_stats.get('total', {})
+            
+            workers.append(worker_info)
+        
+        # If no workers found and no cache, but we have running tasks, show placeholder
+        if not workers and running_task_count > 0:
+            workers.append({
+                'name': 'celery@worker (busy)',
+                'status': 'online',
+                'active_tasks': running_task_count,
+                'registered_tasks': list(_worker_cache['registered_tasks'].values())[0] if _worker_cache['registered_tasks'] else [],
+                'prefetch_count': list(_worker_cache['prefetch_count'].values())[0] if _worker_cache['prefetch_count'] else 0,
+            })
+        
+        return jsonify({
+            'data': workers,
+            'total': len(workers)
+        }), 200
+        
+    except Exception as e:
+        return jsonify({
+            'data': [],
+            'total': 0,
+            'error': f'Failed to get worker status: {str(e)}'
+        }), 200
+
+
+@api_bp.route('/workers/stats', methods=['GET'])
+@admin_required
+def get_worker_stats():
+    """
+    Get worker statistics including queue information.
+    
+    Returns:
+        JSON with worker statistics
+    """
+    try:
+        import redis
+        
+        # Get queue length directly from Redis (always fast)
+        queue_stats = {}
+        queue_name = celery_app.conf.task_default_queue or 'celery'
+        online_count = 0
+        
+        try:
+            broker_url = celery_app.conf.broker_url or 'redis://localhost:6379/0'
+            r = redis.from_url(broker_url)
+            queue_stats[queue_name] = r.llen(queue_name)
+        except Exception:
+            queue_stats[queue_name] = 0
+        
+        # Try to get worker info with short timeout
+        active = {}
+        reserved = {}
+        try:
+            inspect = celery_app.control.inspect(timeout=0.5)
+            ping_response = inspect.ping() or {}
+            online_count = len(ping_response)
+            active = inspect.active() or {}
+            reserved = inspect.reserved() or {}
+        except Exception:
+            pass
+        
+        # Count tasks from inspect
+        total_active = sum(len(tasks) for tasks in active.values())
+        total_reserved = sum(len(tasks) for tasks in reserved.values())
+        
+        # If inspect failed or shows 0, check database for running tasks
+        running_task_count = 0
+        try:
+            from app.models.task import Task
+            running_task_count = Task.query.filter_by(status='running').count()
+        except Exception:
+            pass
+        
+        # Use DB count if inspect shows 0 but DB has running tasks
+        if total_active == 0 and running_task_count > 0:
+            total_active = running_task_count
+            online_count = max(online_count, 1)
+        
+        return jsonify({
+            'workers': {
+                'online': online_count,
+                'total': max(online_count, 1) if total_active > 0 else online_count
+            },
+            'tasks': {
+                'active': total_active,
+                'reserved': total_reserved,
+                'scheduled': 0,
+                'processed': 0
+            },
+            'queues': queue_stats,
+            'details': {
+                'active_by_worker': {k: len(v) for k, v in active.items()},
+                'reserved_by_worker': {k: len(v) for k, v in reserved.items()}
+            }
+        }), 200
+        
+    except Exception as e:
+        return jsonify({
+            'workers': {'online': 0, 'total': 0},
+            'tasks': {'active': 0, 'reserved': 0, 'scheduled': 0, 'processed': 0},
+            'queues': {},
+            'error': f'Failed to get worker stats: {str(e)}'
+        }), 200
+
+
+@api_bp.route('/workers/purge', methods=['POST'])
+@admin_required
+def purge_queue():
+    """
+    Purge all tasks from the queue.
+    
+    Request Body (optional):
+        queue: Queue name to purge (default: 'celery')
+    
+    Returns:
+        JSON with purge result
+    """
+    data = request.get_json() or {}
+    queue_name = data.get('queue', 'celery')
+    
+    try:
+        # Purge the queue
+        purged_count = celery_app.control.purge()
+        
+        return jsonify({
+            'message': 'Queue purged successfully',
+            'purged_count': purged_count,
+            'queue': queue_name
+        }), 200
+        
+    except Exception as e:
+        return jsonify({
+            'error': {
+                'code': 'PURGE_FAILED',
+                'message': f'Failed to purge queue: {str(e)}'
+            }
+        }), 500
+
+
+@api_bp.route('/workers/revoke', methods=['POST'])
+@admin_required
+def revoke_task():
+    """
+    Revoke (cancel) a specific Celery task.
+    
+    Request Body:
+        task_id: Celery task ID to revoke (required)
+        terminate: Whether to terminate the task if running (default: False)
+    
+    Returns:
+        JSON with revoke result
+    """
+    data = request.get_json() or {}
+    celery_task_id = data.get('task_id')
+    terminate = data.get('terminate', False)
+    
+    if not celery_task_id:
+        return jsonify({
+            'error': {
+                'code': 'MISSING_PARAMETER',
+                'message': 'task_id is required'
+            }
+        }), 400
+    
+    try:
+        # Revoke the task
+        celery_app.control.revoke(celery_task_id, terminate=terminate)
+        
+        return jsonify({
+            'message': 'Task revoked successfully',
+            'task_id': celery_task_id,
+            'terminated': terminate
+        }), 200
+        
+    except Exception as e:
+        return jsonify({
+            'error': {
+                'code': 'REVOKE_FAILED',
+                'message': f'Failed to revoke task: {str(e)}'
+            }
+        }), 500

+ 66 - 0
backend/app/celery_app.py

@@ -0,0 +1,66 @@
+import os
+from celery import Celery
+
+# Create Celery application
+celery_app = Celery(
+    'aws_scanner',
+    broker=os.environ.get('CELERY_BROKER_URL', 'redis://localhost:6379/0'),
+    backend=os.environ.get('CELERY_RESULT_BACKEND', 'redis://localhost:6379/1'),
+    include=['app.tasks.scan_tasks']
+)
+
+# Celery configuration
+celery_app.conf.update(
+    # Serialization
+    task_serializer='json',
+    accept_content=['json'],
+    result_serializer='json',
+    
+    # Timezone
+    timezone='UTC',
+    enable_utc=True,
+    
+    # Task tracking
+    task_track_started=True,
+    
+    # Timeouts
+    task_time_limit=3600,  # 1 hour hard limit
+    task_soft_time_limit=3300,  # 55 minutes soft limit
+    
+    # Worker settings
+    worker_prefetch_multiplier=1,  # Each worker takes one task at a time
+    task_acks_late=True,  # Acknowledge task after completion
+    
+    # Result settings
+    result_expires=86400,  # Results expire after 24 hours
+    
+    # Retry settings
+    task_default_retry_delay=60,  # 60 seconds default retry delay
+    task_max_retries=3,  # Maximum 3 retries
+    
+    # Beat schedule (for periodic tasks if needed)
+    beat_schedule={
+        'cleanup-old-reports': {
+            'task': 'app.tasks.scan_tasks.cleanup_old_reports',
+            'schedule': 86400.0,  # Run daily
+            'args': (30,)  # Keep reports for 30 days
+        },
+    },
+)
+
+
+def init_celery(app):
+    """Initialize Celery with Flask application context"""
+    celery_app.conf.update(
+        broker_url=app.config.get('CELERY_BROKER_URL', 'redis://localhost:6379/0'),
+        result_backend=app.config.get('CELERY_RESULT_BACKEND', 'redis://localhost:6379/1'),
+    )
+    
+    class ContextTask(celery_app.Task):
+        """Task that runs within Flask application context"""
+        def __call__(self, *args, **kwargs):
+            with app.app_context():
+                return self.run(*args, **kwargs)
+    
+    celery_app.Task = ContextTask
+    return celery_app

+ 705 - 0
backend/app/errors/__init__.py

@@ -0,0 +1,705 @@
+"""
+Unified Error Handling Module
+
+This module provides:
+- Custom exception classes for different error types
+- Global exception handlers for Flask
+- Unified error response format
+- Error logging with timestamp, context, and stack trace
+
+Requirements:
+    - 8.1: Log errors with timestamp, context, and stack trace
+    - 8.5: Gracefully handle critical errors without crashing
+"""
+
+import traceback
+import logging
+from datetime import datetime, timezone
+from functools import wraps
+from typing import Optional, Dict, Any
+
+from flask import jsonify, request, current_app
+
+# Configure module logger
+logger = logging.getLogger(__name__)
+
+
+# ==================== Error Codes ====================
+
+class ErrorCode:
+    """Standardized error codes for the application"""
+    # Authentication errors (401)
+    AUTHENTICATION_FAILED = 'AUTHENTICATION_FAILED'
+    TOKEN_EXPIRED = 'TOKEN_EXPIRED'
+    TOKEN_INVALID = 'TOKEN_INVALID'
+    
+    # Authorization errors (403)
+    ACCESS_DENIED = 'ACCESS_DENIED'
+    INSUFFICIENT_PERMISSIONS = 'INSUFFICIENT_PERMISSIONS'
+    
+    # Validation errors (400)
+    VALIDATION_ERROR = 'VALIDATION_ERROR'
+    MISSING_REQUIRED_FIELD = 'MISSING_REQUIRED_FIELD'
+    INVALID_FIELD_FORMAT = 'INVALID_FIELD_FORMAT'
+    
+    # Resource errors (404)
+    RESOURCE_NOT_FOUND = 'RESOURCE_NOT_FOUND'
+    USER_NOT_FOUND = 'USER_NOT_FOUND'
+    CREDENTIAL_NOT_FOUND = 'CREDENTIAL_NOT_FOUND'
+    TASK_NOT_FOUND = 'TASK_NOT_FOUND'
+    REPORT_NOT_FOUND = 'REPORT_NOT_FOUND'
+    
+    # AWS errors
+    AWS_CREDENTIAL_INVALID = 'AWS_CREDENTIAL_INVALID'
+    AWS_API_ERROR = 'AWS_API_ERROR'
+    AWS_RATE_LIMITED = 'AWS_RATE_LIMITED'
+    AWS_ACCESS_DENIED = 'AWS_ACCESS_DENIED'
+    
+    # System errors (500)
+    INTERNAL_ERROR = 'INTERNAL_ERROR'
+    DATABASE_ERROR = 'DATABASE_ERROR'
+    FILE_SYSTEM_ERROR = 'FILE_SYSTEM_ERROR'
+    WORKER_ERROR = 'WORKER_ERROR'
+    
+    # Task errors
+    TASK_EXECUTION_ERROR = 'TASK_EXECUTION_ERROR'
+    TASK_TIMEOUT = 'TASK_TIMEOUT'
+    SCAN_ERROR = 'SCAN_ERROR'
+    REPORT_GENERATION_ERROR = 'REPORT_GENERATION_ERROR'
+
+
+# ==================== Custom Exception Classes ====================
+
+class AppError(Exception):
+    """
+    Base application error class.
+    
+    All custom exceptions should inherit from this class.
+    Provides consistent error structure with code, message, status_code, and details.
+    """
+    
+    def __init__(
+        self,
+        message: str,
+        code: str = ErrorCode.INTERNAL_ERROR,
+        status_code: int = 400,
+        details: Optional[Dict[str, Any]] = None,
+        original_exception: Optional[Exception] = None
+    ):
+        super().__init__(message)
+        self.message = message
+        self.code = code
+        self.status_code = status_code
+        self.details = details or {}
+        self.original_exception = original_exception
+        self.timestamp = datetime.now(timezone.utc)
+        
+        # Capture stack trace if original exception provided
+        if original_exception:
+            self.stack_trace = traceback.format_exception(
+                type(original_exception),
+                original_exception,
+                original_exception.__traceback__
+            )
+        else:
+            self.stack_trace = traceback.format_stack()
+    
+    def to_dict(self) -> Dict[str, Any]:
+        """Convert error to dictionary for JSON response"""
+        error_dict = {
+            'code': self.code,
+            'message': self.message,
+            'timestamp': self.timestamp.isoformat()
+        }
+        if self.details:
+            error_dict['details'] = self.details
+        return error_dict
+    
+    def to_log_dict(self) -> Dict[str, Any]:
+        """Convert error to dictionary for logging (includes stack trace)"""
+        log_dict = self.to_dict()
+        log_dict['status_code'] = self.status_code
+        log_dict['stack_trace'] = ''.join(self.stack_trace) if self.stack_trace else None
+        return log_dict
+
+
+class AuthenticationError(AppError):
+    """Authentication related errors (401)"""
+    
+    def __init__(
+        self,
+        message: str = "Authentication failed",
+        code: str = ErrorCode.AUTHENTICATION_FAILED,
+        details: Optional[Dict[str, Any]] = None,
+        original_exception: Optional[Exception] = None
+    ):
+        super().__init__(
+            message=message,
+            code=code,
+            status_code=401,
+            details=details,
+            original_exception=original_exception
+        )
+
+
+class AuthorizationError(AppError):
+    """Authorization related errors (403)"""
+    
+    def __init__(
+        self,
+        message: str = "Access denied",
+        code: str = ErrorCode.ACCESS_DENIED,
+        details: Optional[Dict[str, Any]] = None,
+        original_exception: Optional[Exception] = None
+    ):
+        super().__init__(
+            message=message,
+            code=code,
+            status_code=403,
+            details=details,
+            original_exception=original_exception
+        )
+
+
+class NotFoundError(AppError):
+    """Resource not found errors (404)"""
+    
+    def __init__(
+        self,
+        message: str = "Resource not found",
+        code: str = ErrorCode.RESOURCE_NOT_FOUND,
+        details: Optional[Dict[str, Any]] = None,
+        original_exception: Optional[Exception] = None
+    ):
+        super().__init__(
+            message=message,
+            code=code,
+            status_code=404,
+            details=details,
+            original_exception=original_exception
+        )
+
+
+class ValidationError(AppError):
+    """Validation errors (400)"""
+    
+    def __init__(
+        self,
+        message: str = "Validation failed",
+        code: str = ErrorCode.VALIDATION_ERROR,
+        details: Optional[Dict[str, Any]] = None,
+        original_exception: Optional[Exception] = None
+    ):
+        super().__init__(
+            message=message,
+            code=code,
+            status_code=400,
+            details=details,
+            original_exception=original_exception
+        )
+
+
+class AWSError(AppError):
+    """AWS related errors"""
+    
+    def __init__(
+        self,
+        message: str = "AWS operation failed",
+        code: str = ErrorCode.AWS_API_ERROR,
+        status_code: int = 500,
+        details: Optional[Dict[str, Any]] = None,
+        original_exception: Optional[Exception] = None
+    ):
+        super().__init__(
+            message=message,
+            code=code,
+            status_code=status_code,
+            details=details,
+            original_exception=original_exception
+        )
+
+
+class DatabaseError(AppError):
+    """Database related errors"""
+    
+    def __init__(
+        self,
+        message: str = "Database operation failed",
+        code: str = ErrorCode.DATABASE_ERROR,
+        details: Optional[Dict[str, Any]] = None,
+        original_exception: Optional[Exception] = None
+    ):
+        super().__init__(
+            message=message,
+            code=code,
+            status_code=500,
+            details=details,
+            original_exception=original_exception
+        )
+
+
+class TaskError(AppError):
+    """Task execution related errors"""
+    
+    def __init__(
+        self,
+        message: str = "Task execution failed",
+        code: str = ErrorCode.TASK_EXECUTION_ERROR,
+        status_code: int = 500,
+        details: Optional[Dict[str, Any]] = None,
+        original_exception: Optional[Exception] = None,
+        task_id: Optional[int] = None
+    ):
+        if task_id:
+            details = details or {}
+            details['task_id'] = task_id
+        super().__init__(
+            message=message,
+            code=code,
+            status_code=status_code,
+            details=details,
+            original_exception=original_exception
+        )
+        self.task_id = task_id
+
+
+class ScanError(TaskError):
+    """Scan operation related errors"""
+    
+    def __init__(
+        self,
+        message: str = "Scan operation failed",
+        code: str = ErrorCode.SCAN_ERROR,
+        details: Optional[Dict[str, Any]] = None,
+        original_exception: Optional[Exception] = None,
+        task_id: Optional[int] = None,
+        service: Optional[str] = None,
+        region: Optional[str] = None
+    ):
+        details = details or {}
+        if service:
+            details['service'] = service
+        if region:
+            details['region'] = region
+        super().__init__(
+            message=message,
+            code=code,
+            status_code=500,
+            details=details,
+            original_exception=original_exception,
+            task_id=task_id
+        )
+        self.service = service
+        self.region = region
+
+
+class ReportGenerationError(TaskError):
+    """Report generation related errors"""
+    
+    def __init__(
+        self,
+        message: str = "Report generation failed",
+        code: str = ErrorCode.REPORT_GENERATION_ERROR,
+        details: Optional[Dict[str, Any]] = None,
+        original_exception: Optional[Exception] = None,
+        task_id: Optional[int] = None
+    ):
+        super().__init__(
+            message=message,
+            code=code,
+            status_code=500,
+            details=details,
+            original_exception=original_exception,
+            task_id=task_id
+        )
+
+
+# ==================== Error Logging Utilities ====================
+
+def log_error(
+    error: Exception,
+    context: Optional[Dict[str, Any]] = None,
+    level: str = 'error'
+) -> Dict[str, Any]:
+    """
+    Log an error with full context and stack trace.
+    
+    Requirements:
+        - 8.1: Log errors with timestamp, context, and stack trace
+    
+    Args:
+        error: The exception to log
+        context: Additional context information
+        level: Log level ('error', 'warning', 'critical')
+    
+    Returns:
+        Dictionary containing the logged error information
+    """
+    timestamp = datetime.now(timezone.utc)
+    
+    # Build error log entry
+    log_entry = {
+        'timestamp': timestamp.isoformat(),
+        'error_type': type(error).__name__,
+        'message': str(error),
+        'context': context or {}
+    }
+    
+    # Add request context if available
+    try:
+        if request:
+            log_entry['request'] = {
+                'method': request.method,
+                'path': request.path,
+                'remote_addr': request.remote_addr,
+                'user_agent': str(request.user_agent) if request.user_agent else None
+            }
+    except RuntimeError:
+        # Outside of request context
+        pass
+    
+    # Add stack trace
+    if isinstance(error, AppError):
+        log_entry['code'] = error.code
+        log_entry['status_code'] = error.status_code
+        log_entry['details'] = error.details
+        log_entry['stack_trace'] = ''.join(error.stack_trace) if error.stack_trace else None
+    else:
+        log_entry['stack_trace'] = traceback.format_exc()
+    
+    # Log at appropriate level
+    log_message = f"[{log_entry['error_type']}] {log_entry['message']}"
+    if level == 'critical':
+        logger.critical(log_message, extra={'error_details': log_entry})
+    elif level == 'warning':
+        logger.warning(log_message, extra={'error_details': log_entry})
+    else:
+        logger.error(log_message, extra={'error_details': log_entry})
+    
+    return log_entry
+
+
+def create_error_response(
+    code: str,
+    message: str,
+    status_code: int = 400,
+    details: Optional[Dict[str, Any]] = None
+) -> tuple:
+    """
+    Create a standardized error response.
+    
+    Args:
+        code: Error code
+        message: Human-readable error message
+        status_code: HTTP status code
+        details: Additional error details
+    
+    Returns:
+        Tuple of (response_json, status_code)
+    """
+    response = {
+        'error': {
+            'code': code,
+            'message': message,
+            'timestamp': datetime.now(timezone.utc).isoformat()
+        }
+    }
+    if details:
+        response['error']['details'] = details
+    
+    return jsonify(response), status_code
+
+
+# ==================== Error Handler Decorator ====================
+
+def handle_errors(func):
+    """
+    Decorator to handle errors in route handlers.
+    
+    Catches exceptions and converts them to appropriate error responses.
+    Logs all errors with full context.
+    
+    Requirements:
+        - 8.5: Gracefully handle critical errors without crashing
+    """
+    @wraps(func)
+    def wrapper(*args, **kwargs):
+        try:
+            return func(*args, **kwargs)
+        except AppError as e:
+            # Log the error
+            log_error(e, context={'handler': func.__name__})
+            # Return error response
+            return jsonify({'error': e.to_dict()}), e.status_code
+        except Exception as e:
+            # Log unexpected error
+            log_error(e, context={'handler': func.__name__}, level='critical')
+            # Return generic error response
+            return create_error_response(
+                code=ErrorCode.INTERNAL_ERROR,
+                message='An unexpected error occurred',
+                status_code=500
+            )
+    return wrapper
+
+
+# ==================== Global Error Handlers ====================
+
+def register_error_handlers(app):
+    """
+    Register global error handlers for the Flask app.
+    
+    Requirements:
+        - 8.5: Gracefully handle critical errors without crashing
+    """
+    
+    @app.errorhandler(AppError)
+    def handle_app_error(error):
+        """Handle custom application errors"""
+        log_error(error)
+        return jsonify({'error': error.to_dict()}), error.status_code
+    
+    @app.errorhandler(400)
+    def handle_bad_request(error):
+        """Handle 400 Bad Request errors"""
+        log_entry = log_error(error, context={'type': 'bad_request'}, level='warning')
+        return create_error_response(
+            code=ErrorCode.VALIDATION_ERROR,
+            message='Bad request',
+            status_code=400,
+            details={'description': str(error.description) if hasattr(error, 'description') else None}
+        )
+    
+    @app.errorhandler(401)
+    def handle_unauthorized(error):
+        """Handle 401 Unauthorized errors"""
+        log_entry = log_error(error, context={'type': 'unauthorized'}, level='warning')
+        return create_error_response(
+            code=ErrorCode.AUTHENTICATION_FAILED,
+            message='Authentication required',
+            status_code=401
+        )
+    
+    @app.errorhandler(403)
+    def handle_forbidden(error):
+        """Handle 403 Forbidden errors"""
+        log_entry = log_error(error, context={'type': 'forbidden'}, level='warning')
+        return create_error_response(
+            code=ErrorCode.ACCESS_DENIED,
+            message='Access denied',
+            status_code=403
+        )
+    
+    @app.errorhandler(404)
+    def handle_not_found(error):
+        """Handle 404 Not Found errors"""
+        log_entry = log_error(error, context={'type': 'not_found'}, level='warning')
+        return create_error_response(
+            code=ErrorCode.RESOURCE_NOT_FOUND,
+            message='Resource not found',
+            status_code=404
+        )
+    
+    @app.errorhandler(405)
+    def handle_method_not_allowed(error):
+        """Handle 405 Method Not Allowed errors"""
+        log_entry = log_error(error, context={'type': 'method_not_allowed'}, level='warning')
+        return create_error_response(
+            code='METHOD_NOT_ALLOWED',
+            message='Method not allowed',
+            status_code=405
+        )
+    
+    @app.errorhandler(500)
+    def handle_internal_error(error):
+        """Handle 500 Internal Server errors"""
+        log_entry = log_error(error, context={'type': 'internal_error'}, level='critical')
+        return create_error_response(
+            code=ErrorCode.INTERNAL_ERROR,
+            message='An internal error occurred',
+            status_code=500
+        )
+    
+    @app.errorhandler(Exception)
+    def handle_unexpected_error(error):
+        """
+        Handle any unexpected exceptions.
+        
+        Requirements:
+            - 8.5: Gracefully handle critical errors without crashing
+        """
+        log_entry = log_error(error, context={'type': 'unexpected'}, level='critical')
+        return create_error_response(
+            code=ErrorCode.INTERNAL_ERROR,
+            message='An unexpected error occurred',
+            status_code=500
+        )
+
+
+# ==================== Task Error Logging ====================
+
+class TaskErrorLogger:
+    """
+    Utility class for logging errors associated with tasks.
+    
+    Requirements:
+        - 8.2: Record error details in task record
+        - 8.3: Display error logs associated with task
+    """
+    
+    @staticmethod
+    def log_task_error(
+        task_id: int,
+        error: Exception,
+        service: Optional[str] = None,
+        region: Optional[str] = None,
+        context: Optional[Dict[str, Any]] = None
+    ) -> None:
+        """
+        Log an error for a specific task.
+        
+        Args:
+            task_id: The task ID to associate the error with
+            error: The exception that occurred
+            service: Optional AWS service name
+            region: Optional AWS region
+            context: Additional context information
+        """
+        from app import db
+        from app.models import TaskLog
+        import json
+        
+        # Build error details
+        details = {
+            'error_type': type(error).__name__,
+            'timestamp': datetime.now(timezone.utc).isoformat()
+        }
+        
+        if service:
+            details['service'] = service
+        if region:
+            details['region'] = region
+        if context:
+            details['context'] = context
+        
+        # Add stack trace
+        if isinstance(error, AppError):
+            details['code'] = error.code
+            details['stack_trace'] = ''.join(error.stack_trace) if error.stack_trace else None
+        else:
+            details['stack_trace'] = traceback.format_exc()
+        
+        # Create task log entry
+        log_entry = TaskLog(
+            task_id=task_id,
+            level='error',
+            message=str(error),
+            details=json.dumps(details)
+        )
+        
+        db.session.add(log_entry)
+        db.session.commit()
+        
+        # Also log to application logger
+        log_error(error, context={'task_id': task_id, 'service': service, 'region': region})
+    
+    @staticmethod
+    def log_task_warning(
+        task_id: int,
+        message: str,
+        details: Optional[Dict[str, Any]] = None
+    ) -> None:
+        """Log a warning for a specific task"""
+        from app import db
+        from app.models import TaskLog
+        import json
+        
+        log_entry = TaskLog(
+            task_id=task_id,
+            level='warning',
+            message=message,
+            details=json.dumps(details) if details else None
+        )
+        
+        db.session.add(log_entry)
+        db.session.commit()
+    
+    @staticmethod
+    def log_task_info(
+        task_id: int,
+        message: str,
+        details: Optional[Dict[str, Any]] = None
+    ) -> None:
+        """Log an info message for a specific task"""
+        from app import db
+        from app.models import TaskLog
+        import json
+        
+        log_entry = TaskLog(
+            task_id=task_id,
+            level='info',
+            message=message,
+            details=json.dumps(details) if details else None
+        )
+        
+        db.session.add(log_entry)
+        db.session.commit()
+    
+    @staticmethod
+    def get_task_errors(task_id: int) -> list:
+        """
+        Get all error logs for a specific task.
+        
+        Requirements:
+            - 8.3: Display error logs associated with task
+        """
+        from app.models import TaskLog
+        
+        logs = TaskLog.query.filter_by(
+            task_id=task_id,
+            level='error'
+        ).order_by(TaskLog.created_at.desc()).all()
+        
+        return [log.to_dict() for log in logs]
+    
+    @staticmethod
+    def get_task_logs(
+        task_id: int,
+        level: Optional[str] = None,
+        page: int = 1,
+        page_size: int = 20
+    ) -> Dict[str, Any]:
+        """
+        Get paginated logs for a specific task.
+        
+        Args:
+            task_id: The task ID
+            level: Optional filter by log level
+            page: Page number
+            page_size: Number of items per page
+        
+        Returns:
+            Dictionary with logs and pagination info
+        """
+        from app.models import TaskLog
+        
+        query = TaskLog.query.filter_by(task_id=task_id)
+        
+        if level:
+            query = query.filter_by(level=level)
+        
+        query = query.order_by(TaskLog.created_at.desc())
+        
+        # Paginate
+        total = query.count()
+        logs = query.offset((page - 1) * page_size).limit(page_size).all()
+        
+        return {
+            'data': [log.to_dict() for log in logs],
+            'pagination': {
+                'page': page,
+                'page_size': page_size,
+                'total': total,
+                'total_pages': (total + page_size - 1) // page_size
+            }
+        }

+ 15 - 0
backend/app/models/__init__.py

@@ -0,0 +1,15 @@
+# Import all models for easy access
+from app.models.user import User
+from app.models.credential import AWSCredential, UserCredential, BaseAssumeRoleConfig
+from app.models.task import Task, TaskLog
+from app.models.report import Report
+
+__all__ = [
+    'User',
+    'AWSCredential',
+    'UserCredential',
+    'BaseAssumeRoleConfig',
+    'Task',
+    'TaskLog',
+    'Report'
+]

+ 118 - 0
backend/app/models/credential.py

@@ -0,0 +1,118 @@
+from datetime import datetime
+from app import db
+from app.utils.encryption import encrypt_value, decrypt_value
+
+
+class AWSCredential(db.Model):
+    """AWS Credential model for storing IAM Role or Access Key credentials"""
+    __tablename__ = 'aws_credentials'
+    
+    id = db.Column(db.Integer, primary_key=True)
+    name = db.Column(db.String(100), nullable=False)
+    credential_type = db.Column(db.Enum('assume_role', 'access_key', name='credential_type'), nullable=False)
+    account_id = db.Column(db.String(12), nullable=True, index=True)  # Make nullable for access_key type
+    
+    # For assume_role
+    role_arn = db.Column(db.String(255))
+    external_id = db.Column(db.String(255))
+    
+    # For access_key (encrypted)
+    access_key_id = db.Column(db.String(255))
+    secret_access_key_encrypted = db.Column(db.Text)
+    
+    created_at = db.Column(db.DateTime, default=datetime.utcnow)
+    is_active = db.Column(db.Boolean, default=True)
+    
+    # Relationships
+    users = db.relationship('UserCredential', back_populates='credential', cascade='all, delete-orphan')
+    
+    def set_secret_access_key(self, secret_key: str) -> None:
+        """Encrypt and store the secret access key"""
+        self.secret_access_key_encrypted = encrypt_value(secret_key)
+    
+    def get_secret_access_key(self) -> str:
+        """Decrypt and return the secret access key"""
+        if self.secret_access_key_encrypted:
+            return decrypt_value(self.secret_access_key_encrypted)
+        return None
+    
+    def to_dict(self, mask_sensitive: bool = True) -> dict:
+        """Convert credential to dictionary"""
+        result = {
+            'id': self.id,
+            'name': self.name,
+            'credential_type': self.credential_type,
+            'account_id': self.account_id,
+            'role_arn': self.role_arn,
+            'external_id': self.external_id,
+            'access_key_id': self.access_key_id,
+            'created_at': self.created_at.isoformat() if self.created_at else None,
+            'is_active': self.is_active
+        }
+        
+        # Mask sensitive data
+        if mask_sensitive and self.access_key_id:
+            result['access_key_id'] = self.access_key_id[:4] + '****' + self.access_key_id[-4:] if len(self.access_key_id) > 8 else '****'
+        
+        return result
+    
+    def __repr__(self):
+        return f'<AWSCredential {self.name} ({self.account_id})>'
+
+
+class UserCredential(db.Model):
+    """Association table for User-Credential many-to-many relationship"""
+    __tablename__ = 'user_credentials'
+    
+    id = db.Column(db.Integer, primary_key=True)
+    user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
+    credential_id = db.Column(db.Integer, db.ForeignKey('aws_credentials.id'), nullable=False)
+    assigned_at = db.Column(db.DateTime, default=datetime.utcnow)
+    
+    # Relationships
+    user = db.relationship('User', back_populates='credentials')
+    credential = db.relationship('AWSCredential', back_populates='users')
+    
+    # Unique constraint to prevent duplicate assignments
+    __table_args__ = (
+        db.UniqueConstraint('user_id', 'credential_id', name='unique_user_credential'),
+    )
+    
+    def __repr__(self):
+        return f'<UserCredential user={self.user_id} credential={self.credential_id}>'
+
+
+class BaseAssumeRoleConfig(db.Model):
+    """Configuration for the base account used for Assume Role"""
+    __tablename__ = 'base_assume_role_config'
+    
+    id = db.Column(db.Integer, primary_key=True)
+    access_key_id = db.Column(db.String(255), nullable=False)
+    secret_access_key_encrypted = db.Column(db.Text, nullable=False)
+    updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+    
+    def set_secret_access_key(self, secret_key: str) -> None:
+        """Encrypt and store the secret access key"""
+        self.secret_access_key_encrypted = encrypt_value(secret_key)
+    
+    def get_secret_access_key(self) -> str:
+        """Decrypt and return the secret access key"""
+        if self.secret_access_key_encrypted:
+            return decrypt_value(self.secret_access_key_encrypted)
+        return None
+    
+    def to_dict(self, mask_sensitive: bool = True) -> dict:
+        """Convert config to dictionary"""
+        result = {
+            'id': self.id,
+            'access_key_id': self.access_key_id,
+            'updated_at': self.updated_at.isoformat() if self.updated_at else None
+        }
+        
+        if mask_sensitive:
+            result['access_key_id'] = self.access_key_id[:4] + '****' + self.access_key_id[-4:] if len(self.access_key_id) > 8 else '****'
+        
+        return result
+    
+    def __repr__(self):
+        return f'<BaseAssumeRoleConfig id={self.id}>'

+ 31 - 0
backend/app/models/report.py

@@ -0,0 +1,31 @@
+from datetime import datetime
+from app import db
+
+
+class Report(db.Model):
+    """Report model for generated Word documents"""
+    __tablename__ = 'reports'
+    
+    id = db.Column(db.Integer, primary_key=True)
+    task_id = db.Column(db.Integer, db.ForeignKey('tasks.id'), nullable=False, unique=True)
+    file_name = db.Column(db.String(255), nullable=False)
+    file_path = db.Column(db.String(500), nullable=False)
+    file_size = db.Column(db.Integer)
+    created_at = db.Column(db.DateTime, default=datetime.utcnow, index=True)
+    
+    # Relationships
+    task = db.relationship('Task', back_populates='report')
+    
+    def to_dict(self) -> dict:
+        """Convert report to dictionary"""
+        return {
+            'id': self.id,
+            'task_id': self.task_id,
+            'file_name': self.file_name,
+            'file_size': self.file_size,
+            'created_at': self.created_at.isoformat() if self.created_at else None,
+            'download_url': f'/api/reports/download?id={self.id}'
+        }
+    
+    def __repr__(self):
+        return f'<Report {self.id} {self.file_name}>'

+ 118 - 0
backend/app/models/task.py

@@ -0,0 +1,118 @@
+from datetime import datetime
+import json
+from app import db
+
+
+class Task(db.Model):
+    """Task model for scan tasks"""
+    __tablename__ = 'tasks'
+    
+    id = db.Column(db.Integer, primary_key=True)
+    name = db.Column(db.String(200), nullable=False)
+    status = db.Column(
+        db.Enum('pending', 'running', 'completed', 'failed', name='task_status'), 
+        default='pending',
+        index=True
+    )
+    progress = db.Column(db.Integer, default=0)
+    created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
+    created_at = db.Column(db.DateTime, default=datetime.utcnow, index=True)
+    started_at = db.Column(db.DateTime)
+    completed_at = db.Column(db.DateTime)
+    celery_task_id = db.Column(db.String(100), index=True)
+    
+    # Task configuration (JSON)
+    _credential_ids = db.Column('credential_ids', db.Text)
+    _regions = db.Column('regions', db.Text)
+    _project_metadata = db.Column('project_metadata', db.Text)
+    
+    # Relationships
+    created_by_user = db.relationship('User', back_populates='tasks')
+    logs = db.relationship('TaskLog', back_populates='task', cascade='all, delete-orphan')
+    report = db.relationship('Report', back_populates='task', uselist=False, cascade='all, delete-orphan')
+    
+    @property
+    def credential_ids(self) -> list:
+        """Get credential IDs as list"""
+        if self._credential_ids:
+            return json.loads(self._credential_ids)
+        return []
+    
+    @credential_ids.setter
+    def credential_ids(self, value: list):
+        """Set credential IDs from list"""
+        self._credential_ids = json.dumps(value)
+    
+    @property
+    def regions(self) -> list:
+        """Get regions as list"""
+        if self._regions:
+            return json.loads(self._regions)
+        return []
+    
+    @regions.setter
+    def regions(self, value: list):
+        """Set regions from list"""
+        self._regions = json.dumps(value)
+    
+    @property
+    def project_metadata(self) -> dict:
+        """Get project metadata as dict"""
+        if self._project_metadata:
+            return json.loads(self._project_metadata)
+        return {}
+    
+    @project_metadata.setter
+    def project_metadata(self, value: dict):
+        """Set project metadata from dict"""
+        self._project_metadata = json.dumps(value)
+    
+    def to_dict(self) -> dict:
+        """Convert task to dictionary"""
+        return {
+            'id': self.id,
+            'name': self.name,
+            'status': self.status,
+            'progress': self.progress,
+            'created_by': self.created_by,
+            'created_at': self.created_at.isoformat() if self.created_at else None,
+            'started_at': self.started_at.isoformat() if self.started_at else None,
+            'completed_at': self.completed_at.isoformat() if self.completed_at else None,
+            'credential_ids': self.credential_ids,
+            'regions': self.regions,
+            'project_metadata': self.project_metadata
+        }
+    
+    def __repr__(self):
+        return f'<Task {self.id} {self.name} ({self.status})>'
+
+
+class TaskLog(db.Model):
+    """Task log model for storing task execution logs"""
+    __tablename__ = 'task_logs'
+    
+    id = db.Column(db.Integer, primary_key=True)
+    task_id = db.Column(db.Integer, db.ForeignKey('tasks.id'), nullable=False, index=True)
+    level = db.Column(db.Enum('info', 'warning', 'error', name='log_level'), default='info')
+    message = db.Column(db.Text, nullable=False)
+    details = db.Column(db.Text)  # JSON for stack trace, etc.
+    created_at = db.Column(db.DateTime, default=datetime.utcnow, index=True)
+    
+    # Relationships
+    task = db.relationship('Task', back_populates='logs')
+    
+    def to_dict(self) -> dict:
+        """Convert log to dictionary"""
+        result = {
+            'id': self.id,
+            'task_id': self.task_id,
+            'level': self.level,
+            'message': self.message,
+            'created_at': self.created_at.isoformat() if self.created_at else None
+        }
+        if self.details:
+            result['details'] = json.loads(self.details)
+        return result
+    
+    def __repr__(self):
+        return f'<TaskLog {self.id} [{self.level}] {self.message[:50]}>'

+ 53 - 0
backend/app/models/user.py

@@ -0,0 +1,53 @@
+from datetime import datetime, timezone
+from app import db
+import bcrypt
+
+
+def utc_now():
+    """Return current UTC time as timezone-aware datetime"""
+    return datetime.now(timezone.utc)
+
+
+class User(db.Model):
+    """User model for authentication and authorization"""
+    __tablename__ = 'users'
+    
+    id = db.Column(db.Integer, primary_key=True)
+    username = db.Column(db.String(50), unique=True, nullable=False, index=True)
+    email = db.Column(db.String(100), unique=True, nullable=False, index=True)
+    password_hash = db.Column(db.String(255), nullable=False)
+    role = db.Column(db.Enum('admin', 'power_user', 'user', name='user_role'), default='user')
+    created_at = db.Column(db.DateTime, default=utc_now)
+    is_active = db.Column(db.Boolean, default=True)
+    
+    # Relationships
+    credentials = db.relationship('UserCredential', back_populates='user', cascade='all, delete-orphan')
+    tasks = db.relationship('Task', back_populates='created_by_user', cascade='all, delete-orphan')
+    
+    def set_password(self, password: str) -> None:
+        """Hash and set the user's password"""
+        self.password_hash = bcrypt.hashpw(
+            password.encode('utf-8'), 
+            bcrypt.gensalt()
+        ).decode('utf-8')
+    
+    def check_password(self, password: str) -> bool:
+        """Verify the user's password"""
+        return bcrypt.checkpw(
+            password.encode('utf-8'), 
+            self.password_hash.encode('utf-8')
+        )
+    
+    def to_dict(self) -> dict:
+        """Convert user to dictionary (excluding sensitive data)"""
+        return {
+            'id': self.id,
+            'username': self.username,
+            'email': self.email,
+            'role': self.role,
+            'created_at': self.created_at.isoformat() if self.created_at else None,
+            'is_active': self.is_active
+        }
+    
+    def __repr__(self):
+        return f'<User {self.username}>'

+ 424 - 0
backend/app/scanners/DEVELOPMENT_GUIDE.md

@@ -0,0 +1,424 @@
+# AWS 服务扫描器开发指南
+
+本文档为开发人员提供在维护和扩展 AWS 服务扫描器时应遵循的规范和最佳实践。
+
+## 目录
+
+1. [架构概述](#架构概述)
+2. [添加新服务扫描器](#添加新服务扫描器)
+3. [代码规范](#代码规范)
+4. [错误处理](#错误处理)
+5. [测试要求](#测试要求)
+6. [常见问题](#常见问题)
+
+---
+
+## 架构概述
+
+### 目录结构
+
+```
+backend/app/scanners/
+├── base.py                    # 抽象基类和数据结构定义
+├── utils.py                   # 通用工具函数(重试逻辑等)
+├── credentials.py             # AWS 凭证管理
+├── aws_scanner.py             # AWS 扫描器主实现
+└── services/                  # 各服务扫描器实现
+    ├── __init__.py
+    ├── vpc.py                 # VPC 相关服务
+    ├── ec2.py                 # EC2 相关服务
+    ├── elb.py                 # 负载均衡相关服务
+    ├── database.py            # 数据库服务(RDS、ElastiCache)
+    ├── compute.py             # 计算和存储服务(EKS、Lambda、S3)
+    ├── global_services.py     # 全局服务(CloudFront、Route53、ACM、WAF)
+    └── monitoring.py          # 监控服务(CloudWatch、SNS、EventBridge)
+```
+
+### 核心数据结构
+
+```python
+@dataclass
+class ResourceData:
+    account_id: str           # AWS 账户 ID
+    region: str               # 区域(全局服务使用 'global')
+    service: str              # 服务名称(如 'ec2', 'vpc')
+    resource_type: str        # 资源类型(如 'Instance', 'VPC')
+    resource_id: str          # 资源唯一标识符(ARN 或 ID)
+    name: str                 # 资源名称
+    attributes: Dict[str, Any]  # 服务特定属性
+```
+
+---
+
+## 添加新服务扫描器
+
+### 步骤 1:确定服务分类
+
+根据服务类型选择合适的文件:
+
+| 服务类型 | 文件 | 示例 |
+|---------|------|------|
+| VPC 网络相关 | `vpc.py` | VPC, Subnet, Security Group |
+| EC2 计算相关 | `ec2.py` | EC2 Instance, Elastic IP |
+| 负载均衡相关 | `elb.py` | ALB, NLB, Target Group |
+| 数据库相关 | `database.py` | RDS, ElastiCache |
+| 计算和存储 | `compute.py` | EKS, Lambda, S3 |
+| 全局服务 | `global_services.py` | CloudFront, Route53, WAF |
+| 监控管理 | `monitoring.py` | CloudWatch, SNS, EventBridge |
+
+如果新服务不属于现有分类,可以创建新的服务文件。
+
+### 步骤 2:实现扫描方法
+
+在对应的服务文件中添加静态方法:
+
+```python
+from app.scanners.base import ResourceData
+from app.scanners.utils import retry_with_backoff
+
+class YourServiceScanner:
+    """Scanner for your AWS resources"""
+    
+    @staticmethod
+    @retry_with_backoff()  # 必须添加重试装饰器
+    def scan_your_resource(session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """
+        扫描 Your Resource 资源。
+        
+        Attributes (horizontal/vertical layout):
+            列出所有返回的属性字段
+        """
+        resources = []
+        client = session.client('your-service')
+        
+        try:
+            # 使用分页器处理大量数据
+            paginator = client.get_paginator('list_resources')
+            for page in paginator.paginate():
+                for item in page.get('Resources', []):
+                    resources.append(ResourceData(
+                        account_id=account_id,
+                        region=region,
+                        service='your_service',
+                        resource_type='Your Resource',
+                        resource_id=item.get('Arn', item.get('Id')),
+                        name=item.get('Name', ''),
+                        attributes={
+                            'Attribute1': item.get('Attr1', ''),
+                            'Attribute2': item.get('Attr2', ''),
+                        }
+                    ))
+        except Exception as e:
+            logger.warning(f"Failed to scan your resources: {str(e)}")
+        
+        return resources
+```
+
+### 步骤 3:注册到主扫描器
+
+在 `aws_scanner.py` 中完成以下修改:
+
+#### 3.1 添加到支持的服务列表
+
+```python
+class AWSScanner(CloudProviderScanner):
+    SUPPORTED_SERVICES = [
+        # ... 现有服务
+        'your_service',  # 添加新服务
+    ]
+    
+    # 如果是全局服务,还需添加到这里
+    GLOBAL_SERVICES = ['cloudfront', 'route53', 'waf', 's3', 'your_global_service']
+```
+
+#### 3.2 添加扫描方法映射
+
+```python
+def _get_scanner_method(self, service: str) -> Optional[Callable]:
+    scanner_methods = {
+        # ... 现有映射
+        'your_service': self._scan_your_service,
+    }
+    return scanner_methods.get(service)
+```
+
+#### 3.3 实现包装方法
+
+```python
+def _scan_your_service(self, session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+    """Scan Your Service"""
+    from app.scanners.services.your_module import YourServiceScanner
+    return YourServiceScanner.scan_your_resource(session, account_id, region)
+```
+
+---
+
+## 代码规范
+
+### 命名规范
+
+| 类型 | 规范 | 示例 |
+|------|------|------|
+| 服务名称 | 小写下划线 | `security_group`, `nat_gateway` |
+| 资源类型 | 首字母大写空格分隔 | `Security Group`, `NAT Gateway` |
+| 扫描方法 | `scan_` 前缀 | `scan_vpcs`, `scan_ec2_instances` |
+| 类名 | 大驼峰 + Scanner 后缀 | `VPCServiceScanner` |
+
+### 属性字段规范
+
+```python
+attributes={
+    # 使用人类可读的键名
+    'Instance ID': instance_id,        # ✓ 正确
+    'instance_id': instance_id,        # ✗ 避免
+    
+    # 列表转为逗号分隔字符串
+    'Security Groups': ', '.join(sg_ids) if sg_ids else 'N/A',
+    
+    # 空值处理
+    'Public IP': public_ip or 'N/A',
+    
+    # 数值转字符串
+    'Port': str(port),
+    'Memory (MB)': str(memory_size),
+}
+```
+
+### 全局服务处理
+
+全局服务(如 CloudFront、Route53)需要特殊处理:
+
+```python
+@staticmethod
+@retry_with_backoff()
+def scan_global_resource(session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+    # 全局服务始终使用 us-east-1
+    client = session.client('cloudfront', region_name='us-east-1')
+    
+    # region 参数设为 'global'
+    resources.append(ResourceData(
+        account_id=account_id,
+        region='global',  # 重要:全局服务使用 'global'
+        service='cloudfront',
+        # ...
+    ))
+```
+
+### 分页处理
+
+始终使用分页器处理可能返回大量数据的 API:
+
+```python
+# ✓ 正确:使用分页器
+paginator = client.get_paginator('describe_instances')
+for page in paginator.paginate():
+    for reservation in page.get('Reservations', []):
+        # 处理数据
+
+# ✗ 避免:直接调用可能截断数据
+response = client.describe_instances()
+```
+
+### 从 Tags 获取名称
+
+使用统一的辅助方法:
+
+```python
+@staticmethod
+def _get_name_from_tags(tags: List[Dict[str, str]], default: str = '') -> str:
+    """Extract Name tag value from tags list"""
+    if not tags:
+        return default
+    for tag in tags:
+        if tag.get('Key') == 'Name':
+            return tag.get('Value', default)
+    return default
+
+# 使用示例
+name = self._get_name_from_tags(resource.get('Tags', []), resource['ResourceId'])
+```
+
+---
+
+## 错误处理
+
+### 重试机制
+
+所有扫描方法必须使用 `@retry_with_backoff()` 装饰器:
+
+```python
+from app.scanners.utils import retry_with_backoff
+
+@staticmethod
+@retry_with_backoff()  # 默认重试 3 次,指数退避
+def scan_resources(session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+    # ...
+```
+
+重试配置(在 `utils.py` 中定义):
+- 最大重试次数:3
+- 基础延迟:1 秒
+- 最大延迟:30 秒
+- 退避系数:2.0
+
+### 异常处理
+
+```python
+try:
+    # API 调用
+    response = client.describe_resources()
+except ClientError as e:
+    error_code = e.response.get('Error', {}).get('Code', '')
+    # 记录警告但不中断扫描
+    logger.warning(f"Failed to scan resources in {region}: {error_code}")
+except Exception as e:
+    logger.warning(f"Unexpected error scanning resources: {str(e)}")
+
+# 始终返回已收集的资源,即使部分失败
+return resources
+```
+
+### 不可重试的错误
+
+以下错误不会触发重试:
+- `AccessDenied` - 权限不足
+- `UnauthorizedAccess` - 未授权
+- `InvalidParameterValue` - 参数错误
+- `ValidationError` - 验证错误
+
+---
+
+## 测试要求
+
+### 单元测试
+
+为每个新扫描器编写单元测试:
+
+```python
+# tests/test_scanners/test_your_service.py
+import pytest
+from unittest.mock import Mock, patch
+from app.scanners.services.your_module import YourServiceScanner
+
+class TestYourServiceScanner:
+    
+    @patch('boto3.Session')
+    def test_scan_your_resource_success(self, mock_session):
+        # 模拟 API 响应
+        mock_client = Mock()
+        mock_session.client.return_value = mock_client
+        mock_client.get_paginator.return_value.paginate.return_value = [
+            {'Resources': [{'Id': 'res-123', 'Name': 'test'}]}
+        ]
+        
+        # 执行扫描
+        resources = YourServiceScanner.scan_your_resource(
+            mock_session, 'account-123', 'us-east-1'
+        )
+        
+        # 验证结果
+        assert len(resources) == 1
+        assert resources[0].resource_id == 'res-123'
+    
+    @patch('boto3.Session')
+    def test_scan_your_resource_handles_error(self, mock_session):
+        mock_client = Mock()
+        mock_session.client.return_value = mock_client
+        mock_client.get_paginator.side_effect = Exception("API Error")
+        
+        # 应该返回空列表而不是抛出异常
+        resources = YourServiceScanner.scan_your_resource(
+            mock_session, 'account-123', 'us-east-1'
+        )
+        
+        assert resources == []
+```
+
+### 集成测试
+
+使用真实 AWS 凭证进行集成测试(在 CI/CD 中可选):
+
+```python
+@pytest.mark.integration
+def test_scan_with_real_credentials():
+    # 需要配置真实的 AWS 凭证
+    pass
+```
+
+---
+
+## 常见问题
+
+### Q: 如何处理需要额外 API 调用获取详情的资源?
+
+```python
+# 先列出资源,再获取详情
+for item in list_response.get('Items', []):
+    try:
+        detail = client.describe_resource(ResourceId=item['Id'])
+        # 使用详情数据
+    except Exception as e:
+        logger.debug(f"Failed to get details for {item['Id']}: {str(e)}")
+        # 使用基本数据继续
+```
+
+### Q: 如何处理区域特定的服务?
+
+某些服务(如 ACM)在特定区域有特殊用途:
+
+```python
+# ACM 证书:扫描选定区域 + us-east-1(用于 CloudFront)
+acm_regions = list(regions)
+if 'us-east-1' not in acm_regions:
+    acm_regions.append('us-east-1')
+```
+
+### Q: 如何跳过已删除/无效的资源?
+
+```python
+for resource in response.get('Resources', []):
+    # 跳过已删除的资源
+    if resource.get('State') in ['deleted', 'deleting', 'failed']:
+        continue
+    # 处理有效资源
+```
+
+### Q: 如何处理嵌套资源?
+
+某些资源需要展开为多条记录(如安全组规则):
+
+```python
+for sg in security_groups:
+    for rule in sg.get('IpPermissions', []):
+        # 每条规则创建一个 ResourceData
+        resources.append(ResourceData(...))
+    
+    # 如果没有规则,仍然记录安全组本身
+    if not sg.get('IpPermissions'):
+        resources.append(ResourceData(...))
+```
+
+---
+
+## 检查清单
+
+添加新服务扫描器前,请确认:
+
+- [ ] 选择了正确的服务文件
+- [ ] 方法使用了 `@staticmethod` 和 `@retry_with_backoff()` 装饰器
+- [ ] 方法签名符合规范:`(session, account_id, region) -> List[ResourceData]`
+- [ ] 使用分页器处理列表 API
+- [ ] 正确处理全局服务(region='global',使用 us-east-1)
+- [ ] 属性键名使用人类可读格式
+- [ ] 空值使用 'N/A' 或空字符串
+- [ ] 异常被捕获并记录,不会中断扫描
+- [ ] 在 `aws_scanner.py` 中注册了新服务
+- [ ] 编写了单元测试
+
+---
+
+## 参考资料
+
+- [boto3 文档](https://boto3.amazonaws.com/v1/documentation/api/latest/index.html)
+- [AWS API 参考](https://docs.aws.amazon.com/index.html)
+- 项目需求文档:`.kiro/specs/aws-resource-scanner/requirements.md`

+ 17 - 0
backend/app/scanners/__init__.py

@@ -0,0 +1,17 @@
+# AWS Resource Scanner Module
+# This module provides cloud provider scanning capabilities
+
+from app.scanners.base import CloudProviderScanner, ResourceData, ScanResult
+from app.scanners.aws_scanner import AWSScanner, retry_with_backoff
+from app.scanners.credentials import AWSCredentialProvider, CredentialError, create_credential_provider_from_model
+
+__all__ = [
+    'CloudProviderScanner',
+    'ResourceData',
+    'ScanResult',
+    'AWSScanner',
+    'AWSCredentialProvider',
+    'CredentialError',
+    'create_credential_provider_from_model',
+    'retry_with_backoff'
+]

+ 598 - 0
backend/app/scanners/aws_scanner.py

@@ -0,0 +1,598 @@
+"""
+AWS Resource Scanner Implementation
+
+This module implements the CloudProviderScanner interface for AWS,
+providing comprehensive resource scanning capabilities across all supported services.
+
+Requirements:
+    - 4.3: Process multiple accounts/regions in parallel
+    - 4.4: Scan all supported AWS services concurrently
+    - 5.5: Retry with exponential backoff up to 3 times
+    - 8.2: Record error details in task record
+"""
+
+import boto3
+from botocore.exceptions import ClientError, BotoCoreError
+from concurrent.futures import ThreadPoolExecutor, as_completed
+from typing import List, Dict, Any, Optional, Callable
+import logging
+import traceback
+
+from app.scanners.base import CloudProviderScanner, ResourceData, ScanResult
+from app.scanners.credentials import AWSCredentialProvider, CredentialError
+from app.scanners.utils import retry_with_backoff
+
+logger = logging.getLogger(__name__)
+
+
+class AWSScanner(CloudProviderScanner):
+    """
+    AWS Resource Scanner implementation.
+    
+    Scans AWS resources across multiple regions and services using boto3,
+    with support for parallel scanning and automatic retry with exponential backoff.
+    """
+    
+    # All supported AWS services
+    SUPPORTED_SERVICES = [
+        'vpc', 'subnet', 'route_table', 'internet_gateway', 'nat_gateway',
+        'security_group', 'vpc_endpoint', 'vpc_peering',
+        'customer_gateway', 'virtual_private_gateway', 'vpn_connection',
+        'ec2', 'elastic_ip',
+        'autoscaling', 'elb', 'target_group',
+        'rds', 'elasticache',
+        'eks', 'lambda', 's3', 's3_event_notification',
+        'cloudfront', 'route53', 'acm', 'waf',
+        'sns', 'cloudwatch', 'eventbridge', 'cloudtrail', 'config'
+    ]
+    
+    # Global services (not region-specific)
+    GLOBAL_SERVICES = ['cloudfront', 'route53', 'waf', 's3', 's3_event_notification', 'cloudtrail']
+    
+    # Maximum workers for parallel scanning
+    MAX_WORKERS = 10
+    
+    def __init__(self, credential_provider: AWSCredentialProvider):
+        """
+        Initialize the AWS Scanner.
+        
+        Args:
+            credential_provider: AWSCredentialProvider instance for authentication
+        """
+        self.credential_provider = credential_provider
+        self._account_id: Optional[str] = None
+    
+    @property
+    def provider_name(self) -> str:
+        return 'AWS'
+    
+    @property
+    def supported_services(self) -> List[str]:
+        return self.SUPPORTED_SERVICES.copy()
+    
+    @property
+    def global_services(self) -> List[str]:
+        return self.GLOBAL_SERVICES.copy()
+    
+    def get_credentials(self, credential_config: Dict[str, Any]) -> boto3.Session:
+        """Get boto3 Session from credential configuration"""
+        return self.credential_provider.get_session()
+    
+    def list_regions(self) -> List[str]:
+        """List all available AWS regions"""
+        try:
+            session = self.credential_provider.get_session(region_name='us-east-1')
+            ec2_client = session.client('ec2')
+            response = ec2_client.describe_regions()
+            return [region['RegionName'] for region in response['Regions']]
+        except Exception as e:
+            logger.error(f"Failed to list regions: {str(e)}")
+            # Return default regions if API call fails
+            return [
+                'us-east-1', 'us-east-2', 'us-west-1', 'us-west-2',
+                'eu-west-1', 'eu-west-2', 'eu-west-3', 'eu-central-1',
+                'ap-northeast-1', 'ap-northeast-2', 'ap-southeast-1', 'ap-southeast-2',
+                'ap-south-1', 'sa-east-1', 'ca-central-1'
+            ]
+    
+    def validate_credentials(self) -> bool:
+        """Validate AWS credentials"""
+        return self.credential_provider.validate()
+    
+    def get_account_id(self) -> str:
+        """Get AWS account ID"""
+        if self._account_id:
+            return self._account_id
+        self._account_id = self.credential_provider.get_account_id()
+        return self._account_id
+    
+    def scan_resources(
+        self,
+        regions: List[str],
+        services: Optional[List[str]] = None,
+        progress_callback: Optional[Callable[[int, int, str], None]] = None
+    ) -> ScanResult:
+        """
+        Scan AWS resources across specified regions and services.
+        
+        Args:
+            regions: List of regions to scan
+            services: Optional list of services to scan (None = all)
+            progress_callback: Optional callback for progress updates
+        
+        Returns:
+            ScanResult with all discovered resources
+        """
+        result = ScanResult(success=True)
+        account_id = self.get_account_id()
+        
+        # Determine services to scan
+        services_to_scan = services if services else self.SUPPORTED_SERVICES
+        
+        # Separate global and regional services
+        global_services = [s for s in services_to_scan if s in self.GLOBAL_SERVICES]
+        regional_services = [s for s in services_to_scan if s not in self.GLOBAL_SERVICES]
+        
+        # ACM needs special handling: scan selected regions + us-east-1 (for CloudFront)
+        # but avoid duplicate if us-east-1 is already selected
+        acm_regions = list(regions)  # Copy the regions list
+        if 'acm' in regional_services and 'us-east-1' not in acm_regions:
+            acm_regions.append('us-east-1')
+        
+        # Calculate total tasks for progress tracking
+        # Add extra task if ACM needs us-east-1 scan
+        acm_extra_task = 1 if ('acm' in regional_services and 'us-east-1' not in regions) else 0
+        total_tasks = len(global_services) + (len(regional_services) * len(regions)) + acm_extra_task
+        completed_tasks = 0
+        
+        # Scan global services first (only once, not per region)
+        if global_services:
+            logger.info(f"Scanning {len(global_services)} global services")
+            global_results = self._scan_services_parallel(
+                account_id=account_id,
+                region='us-east-1',  # Global services use us-east-1
+                services=global_services,
+                is_global=True
+            )
+            
+            for service, resources in global_results['resources'].items():
+                for resource in resources:
+                    result.add_resource(service, resource)
+            
+            for error in global_results['errors']:
+                result.add_error(**error)
+            
+            completed_tasks += len(global_services)
+            if progress_callback:
+                progress_callback(completed_tasks, total_tasks, "Completed global services scan")
+        
+        # Scan regional services in parallel across regions
+        if regional_services and regions:
+            logger.info(f"Scanning {len(regional_services)} services across {len(regions)} regions")
+            
+            with ThreadPoolExecutor(max_workers=self.MAX_WORKERS) as executor:
+                futures = {}
+                
+                for region in regions:
+                    future = executor.submit(
+                        self._scan_services_parallel,
+                        account_id=account_id,
+                        region=region,
+                        services=regional_services,
+                        is_global=False
+                    )
+                    futures[future] = region
+                
+                for future in as_completed(futures):
+                    region = futures[future]
+                    try:
+                        region_results = future.result()
+                        
+                        for service, resources in region_results['resources'].items():
+                            for resource in resources:
+                                result.add_resource(service, resource)
+                        
+                        for error in region_results['errors']:
+                            result.add_error(**error)
+                        
+                        completed_tasks += len(regional_services)
+                        if progress_callback:
+                            progress_callback(
+                                completed_tasks, total_tasks,
+                                f"Completed scan for region: {region}"
+                            )
+                        
+                    except Exception as e:
+                        logger.error(f"Failed to scan region {region}: {str(e)}")
+                        result.add_error(
+                            service='all',
+                            region=region,
+                            error=f"Region scan failed: {str(e)}",
+                            details=None
+                        )
+                        completed_tasks += len(regional_services)
+        
+        # Scan ACM in us-east-1 if not already included in regions
+        if 'acm' in regional_services and 'us-east-1' not in regions:
+            logger.info("Scanning ACM certificates in us-east-1 (for CloudFront)")
+            acm_results = self._scan_services_parallel(
+                account_id=account_id,
+                region='us-east-1',
+                services=['acm'],
+                is_global=False
+            )
+            
+            for service, resources in acm_results['resources'].items():
+                for resource in resources:
+                    result.add_resource(service, resource)
+            
+            for error in acm_results['errors']:
+                result.add_error(**error)
+            
+            completed_tasks += 1
+            if progress_callback:
+                progress_callback(completed_tasks, total_tasks, "Completed ACM scan in us-east-1")
+        
+        # Set metadata
+        result.metadata = {
+            'account_id': account_id,
+            'regions_scanned': regions,
+            'services_scanned': services_to_scan,
+            'total_resources': sum(len(r) for r in result.resources.values()),
+            'total_errors': len(result.errors)
+        }
+        
+        # Mark as failed if there were critical errors
+        if result.errors and not result.resources:
+            result.success = False
+        
+        return result
+    
+    def _scan_services_parallel(
+        self,
+        account_id: str,
+        region: str,
+        services: List[str],
+        is_global: bool = False
+    ) -> Dict[str, Any]:
+        """
+        Scan multiple services in parallel within a single region.
+        
+        Args:
+            account_id: AWS account ID
+            region: Region to scan
+            services: List of services to scan
+            is_global: Whether these are global services
+        
+        Returns:
+            Dictionary with 'resources' and 'errors' keys
+        """
+        resources: Dict[str, List[ResourceData]] = {}
+        errors: List[Dict[str, Any]] = []
+        
+        # Get session for this region
+        try:
+            session = self.credential_provider.get_session(region_name=region)
+        except CredentialError as e:
+            logger.error(f"Failed to get session for region {region}: {str(e)}")
+            return {
+                'resources': {},
+                'errors': [{
+                    'service': 'all',
+                    'region': region,
+                    'error': str(e),
+                    'details': None
+                }]
+            }
+        
+        # Scan services in parallel
+        with ThreadPoolExecutor(max_workers=self.MAX_WORKERS) as executor:
+            futures = {}
+            
+            for service in services:
+                scanner_method = self._get_scanner_method(service)
+                if scanner_method:
+                    future = executor.submit(
+                        self._scan_service_safe,
+                        scanner_method=scanner_method,
+                        session=session,
+                        account_id=account_id,
+                        region='global' if is_global else region,
+                        service=service
+                    )
+                    futures[future] = service
+            
+            for future in as_completed(futures):
+                service = futures[future]
+                try:
+                    service_result = future.result()
+                    if service_result['resources']:
+                        resources[service] = service_result['resources']
+                    if service_result['error']:
+                        errors.append(service_result['error'])
+                except Exception as e:
+                    logger.error(f"Unexpected error scanning {service}: {str(e)}")
+                    errors.append({
+                        'service': service,
+                        'region': region,
+                        'error': str(e),
+                        'details': None
+                    })
+        
+        return {'resources': resources, 'errors': errors}
+    
+    def _scan_service_safe(
+        self,
+        scanner_method: Callable,
+        session: boto3.Session,
+        account_id: str,
+        region: str,
+        service: str
+    ) -> Dict[str, Any]:
+        """
+        Safely scan a single service, catching and logging errors.
+        
+        Requirements:
+            - 8.2: Record error details in task record (via error dict)
+        
+        Returns:
+            Dictionary with 'resources' and 'error' keys
+        """
+        try:
+            resources = scanner_method(session, account_id, region)
+            return {'resources': resources, 'error': None}
+        except ClientError as e:
+            error_code = e.response.get('Error', {}).get('Code', 'Unknown')
+            error_message = e.response.get('Error', {}).get('Message', str(e))
+            stack_trace = traceback.format_exc()
+            logger.warning(f"Error scanning {service} in {region}: {error_code} - {error_message}")
+            return {
+                'resources': [],
+                'error': {
+                    'service': service,
+                    'region': region,
+                    'error': f"{error_code}: {error_message}",
+                    'error_type': 'ClientError',
+                    'details': {
+                        'error_code': error_code,
+                        'stack_trace': stack_trace
+                    }
+                }
+            }
+        except Exception as e:
+            stack_trace = traceback.format_exc()
+            logger.error(f"Unexpected error scanning {service} in {region}: {str(e)}")
+            return {
+                'resources': [],
+                'error': {
+                    'service': service,
+                    'region': region,
+                    'error': str(e),
+                    'error_type': type(e).__name__,
+                    'details': {
+                        'stack_trace': stack_trace
+                    }
+                }
+            }
+    
+    def _get_scanner_method(self, service: str) -> Optional[Callable]:
+        """Get the scanner method for a specific service"""
+        scanner_methods = {
+            'vpc': self._scan_vpcs,
+            'subnet': self._scan_subnets,
+            'route_table': self._scan_route_tables,
+            'internet_gateway': self._scan_internet_gateways,
+            'nat_gateway': self._scan_nat_gateways,
+            'security_group': self._scan_security_groups,
+            'vpc_endpoint': self._scan_vpc_endpoints,
+            'vpc_peering': self._scan_vpc_peering,
+            'customer_gateway': self._scan_customer_gateways,
+            'virtual_private_gateway': self._scan_virtual_private_gateways,
+            'vpn_connection': self._scan_vpn_connections,
+            'ec2': self._scan_ec2_instances,
+            'elastic_ip': self._scan_elastic_ips,
+            'autoscaling': self._scan_autoscaling_groups,
+            'elb': self._scan_load_balancers,
+            'target_group': self._scan_target_groups,
+            'rds': self._scan_rds_instances,
+            'elasticache': self._scan_elasticache_clusters,
+            'eks': self._scan_eks_clusters,
+            'lambda': self._scan_lambda_functions,
+            's3': self._scan_s3_buckets,
+            's3_event_notification': self._scan_s3_event_notifications,
+            'cloudfront': self._scan_cloudfront_distributions,
+            'route53': self._scan_route53_hosted_zones,
+            'acm': self._scan_acm_certificates,
+            'waf': self._scan_waf_web_acls,
+            'sns': self._scan_sns_topics,
+            'cloudwatch': self._scan_cloudwatch_log_groups,
+            'eventbridge': self._scan_eventbridge_rules,
+            'cloudtrail': self._scan_cloudtrail_trails,
+            'config': self._scan_config_recorders,
+        }
+        return scanner_methods.get(service)
+    
+    # Helper method to get resource name from tags
+    def _get_name_from_tags(self, tags: List[Dict[str, str]], default: str = '') -> str:
+        """Extract Name tag value from tags list"""
+        if not tags:
+            return default
+        for tag in tags:
+            if tag.get('Key') == 'Name':
+                return tag.get('Value', default)
+        return default
+    
+    # ==================== VPC Related Scanners ====================
+    
+    def _scan_vpcs(self, session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """Scan VPCs"""
+        from app.scanners.services.vpc import VPCServiceScanner
+        return VPCServiceScanner.scan_vpcs(session, account_id, region)
+    
+    def _scan_subnets(self, session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """Scan Subnets"""
+        from app.scanners.services.vpc import VPCServiceScanner
+        return VPCServiceScanner.scan_subnets(session, account_id, region)
+    
+    def _scan_route_tables(self, session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """Scan Route Tables"""
+        from app.scanners.services.vpc import VPCServiceScanner
+        return VPCServiceScanner.scan_route_tables(session, account_id, region)
+    
+    def _scan_internet_gateways(self, session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """Scan Internet Gateways"""
+        from app.scanners.services.vpc import VPCServiceScanner
+        return VPCServiceScanner.scan_internet_gateways(session, account_id, region)
+    
+    def _scan_nat_gateways(self, session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """Scan NAT Gateways"""
+        from app.scanners.services.vpc import VPCServiceScanner
+        return VPCServiceScanner.scan_nat_gateways(session, account_id, region)
+    
+    def _scan_security_groups(self, session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """Scan Security Groups"""
+        from app.scanners.services.vpc import VPCServiceScanner
+        return VPCServiceScanner.scan_security_groups(session, account_id, region)
+    
+    def _scan_vpc_endpoints(self, session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """Scan VPC Endpoints"""
+        from app.scanners.services.vpc import VPCServiceScanner
+        return VPCServiceScanner.scan_vpc_endpoints(session, account_id, region)
+    
+    def _scan_vpc_peering(self, session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """Scan VPC Peering Connections"""
+        from app.scanners.services.vpc import VPCServiceScanner
+        return VPCServiceScanner.scan_vpc_peering(session, account_id, region)
+    
+    def _scan_customer_gateways(self, session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """Scan Customer Gateways"""
+        from app.scanners.services.vpc import VPCServiceScanner
+        return VPCServiceScanner.scan_customer_gateways(session, account_id, region)
+    
+    def _scan_virtual_private_gateways(self, session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """Scan Virtual Private Gateways"""
+        from app.scanners.services.vpc import VPCServiceScanner
+        return VPCServiceScanner.scan_virtual_private_gateways(session, account_id, region)
+    
+    def _scan_vpn_connections(self, session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """Scan VPN Connections"""
+        from app.scanners.services.vpc import VPCServiceScanner
+        return VPCServiceScanner.scan_vpn_connections(session, account_id, region)
+
+    
+    # ==================== EC2 Related Scanners ====================
+    
+    def _scan_ec2_instances(self, session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """Scan EC2 Instances"""
+        from app.scanners.services.ec2 import EC2ServiceScanner
+        return EC2ServiceScanner.scan_ec2_instances(session, account_id, region)
+    
+    def _scan_elastic_ips(self, session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """Scan Elastic IPs"""
+        from app.scanners.services.ec2 import EC2ServiceScanner
+        return EC2ServiceScanner.scan_elastic_ips(session, account_id, region)
+
+    
+    # ==================== Auto Scaling and ELB Scanners ====================
+    
+    def _scan_autoscaling_groups(self, session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """Scan Auto Scaling Groups"""
+        from app.scanners.services.elb import ELBServiceScanner
+        return ELBServiceScanner.scan_autoscaling_groups(session, account_id, region)
+    
+    def _scan_load_balancers(self, session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """Scan Load Balancers"""
+        from app.scanners.services.elb import ELBServiceScanner
+        return ELBServiceScanner.scan_load_balancers(session, account_id, region)
+    
+    def _scan_target_groups(self, session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """Scan Target Groups"""
+        from app.scanners.services.elb import ELBServiceScanner
+        return ELBServiceScanner.scan_target_groups(session, account_id, region)
+
+    
+    # ==================== Database Service Scanners ====================
+    
+    def _scan_rds_instances(self, session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """Scan RDS DB Instances"""
+        from app.scanners.services.database import DatabaseServiceScanner
+        return DatabaseServiceScanner.scan_rds_instances(session, account_id, region)
+    
+    def _scan_elasticache_clusters(self, session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """Scan ElastiCache Clusters"""
+        from app.scanners.services.database import DatabaseServiceScanner
+        return DatabaseServiceScanner.scan_elasticache_clusters(session, account_id, region)
+
+    
+    # ==================== Compute and Storage Service Scanners ====================
+    
+    def _scan_eks_clusters(self, session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """Scan EKS Clusters"""
+        from app.scanners.services.compute import ComputeServiceScanner
+        return ComputeServiceScanner.scan_eks_clusters(session, account_id, region)
+    
+    def _scan_lambda_functions(self, session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """Scan Lambda Functions"""
+        from app.scanners.services.compute import ComputeServiceScanner
+        return ComputeServiceScanner.scan_lambda_functions(session, account_id, region)
+    
+    def _scan_s3_buckets(self, session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """Scan S3 Buckets"""
+        from app.scanners.services.compute import ComputeServiceScanner
+        return ComputeServiceScanner.scan_s3_buckets(session, account_id, region)
+    
+    def _scan_s3_event_notifications(self, session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """Scan S3 Event Notifications"""
+        from app.scanners.services.compute import ComputeServiceScanner
+        return ComputeServiceScanner.scan_s3_event_notifications(session, account_id, region)
+
+    
+    # ==================== Global Service Scanners ====================
+    
+    def _scan_cloudfront_distributions(self, session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """Scan CloudFront Distributions"""
+        from app.scanners.services.global_services import GlobalServiceScanner
+        return GlobalServiceScanner.scan_cloudfront_distributions(session, account_id, region)
+    
+    def _scan_route53_hosted_zones(self, session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """Scan Route 53 Hosted Zones"""
+        from app.scanners.services.global_services import GlobalServiceScanner
+        return GlobalServiceScanner.scan_route53_hosted_zones(session, account_id, region)
+    
+    def _scan_acm_certificates(self, session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """Scan ACM Certificates"""
+        from app.scanners.services.global_services import GlobalServiceScanner
+        return GlobalServiceScanner.scan_acm_certificates(session, account_id, region)
+    
+    def _scan_waf_web_acls(self, session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """Scan WAF Web ACLs"""
+        from app.scanners.services.global_services import GlobalServiceScanner
+        return GlobalServiceScanner.scan_waf_web_acls(session, account_id, region)
+
+    
+    # ==================== Monitoring and Management Service Scanners ====================
+    
+    def _scan_sns_topics(self, session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """Scan SNS Topics"""
+        from app.scanners.services.monitoring import MonitoringServiceScanner
+        return MonitoringServiceScanner.scan_sns_topics(session, account_id, region)
+    
+    def _scan_cloudwatch_log_groups(self, session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """Scan CloudWatch Log Groups"""
+        from app.scanners.services.monitoring import MonitoringServiceScanner
+        return MonitoringServiceScanner.scan_cloudwatch_log_groups(session, account_id, region)
+    
+    def _scan_eventbridge_rules(self, session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """Scan EventBridge Rules"""
+        from app.scanners.services.monitoring import MonitoringServiceScanner
+        return MonitoringServiceScanner.scan_eventbridge_rules(session, account_id, region)
+    
+    def _scan_cloudtrail_trails(self, session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """Scan CloudTrail Trails"""
+        from app.scanners.services.monitoring import MonitoringServiceScanner
+        return MonitoringServiceScanner.scan_cloudtrail_trails(session, account_id, region)
+    
+    def _scan_config_recorders(self, session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """Scan AWS Config Recorders"""
+        from app.scanners.services.monitoring import MonitoringServiceScanner
+        return MonitoringServiceScanner.scan_config_recorders(session, account_id, region)

+ 206 - 0
backend/app/scanners/base.py

@@ -0,0 +1,206 @@
+"""
+Cloud Provider Scanner Abstract Base Class
+
+This module defines the abstract interface for cloud provider scanners,
+enabling extensibility to support multiple cloud providers (AWS, Azure, GCP, etc.)
+"""
+
+from abc import ABC, abstractmethod
+from typing import List, Dict, Any, Optional
+from dataclasses import dataclass, field
+
+
+@dataclass
+class ResourceData:
+    """
+    Unified resource data structure for all cloud providers.
+    
+    Attributes:
+        account_id: Cloud provider account identifier
+        region: Region where the resource is located (or 'global')
+        service: Service name (e.g., 'ec2', 'vpc', 's3')
+        resource_type: Type of resource within the service
+        resource_id: Unique identifier for the resource
+        name: Human-readable name of the resource
+        attributes: Service-specific attributes dictionary
+    """
+    account_id: str
+    region: str
+    service: str
+    resource_type: str
+    resource_id: str
+    name: str
+    attributes: Dict[str, Any] = field(default_factory=dict)
+    
+    def to_dict(self) -> Dict[str, Any]:
+        """Convert resource data to dictionary"""
+        return {
+            'account_id': self.account_id,
+            'region': self.region,
+            'service': self.service,
+            'resource_type': self.resource_type,
+            'resource_id': self.resource_id,
+            'name': self.name,
+            'attributes': self.attributes
+        }
+
+
+@dataclass
+class ScanResult:
+    """
+    Result of a cloud resource scan operation.
+    
+    Attributes:
+        success: Whether the scan completed successfully
+        resources: Dictionary mapping service names to lists of ResourceData
+        errors: List of error messages encountered during scanning
+        metadata: Additional scan metadata (timing, counts, etc.)
+    """
+    success: bool
+    resources: Dict[str, List[ResourceData]] = field(default_factory=dict)
+    errors: List[Dict[str, Any]] = field(default_factory=list)
+    metadata: Dict[str, Any] = field(default_factory=dict)
+    
+    def to_dict(self) -> Dict[str, Any]:
+        """Convert scan result to dictionary"""
+        return {
+            'success': self.success,
+            'resources': {
+                service: [r.to_dict() for r in resources]
+                for service, resources in self.resources.items()
+            },
+            'errors': self.errors,
+            'metadata': self.metadata
+        }
+    
+    def add_resource(self, service: str, resource: ResourceData) -> None:
+        """Add a resource to the scan result"""
+        if service not in self.resources:
+            self.resources[service] = []
+        self.resources[service].append(resource)
+    
+    def add_error(self, service: str, region: str, error: str, details: Optional[str] = None, **kwargs) -> None:
+        """Add an error to the scan result"""
+        error_entry = {
+            'service': service,
+            'region': region,
+            'error': error,
+            'details': details
+        }
+        # Include any additional error info (like error_type)
+        error_entry.update(kwargs)
+        self.errors.append(error_entry)
+
+
+class CloudProviderScanner(ABC):
+    """
+    Abstract base class for cloud provider scanners.
+    
+    This interface defines the contract that all cloud provider scanners must implement,
+    enabling the system to support multiple cloud providers with a unified API.
+    
+    Requirements:
+        - 10.1: Use an abstract interface that can be implemented for different cloud providers
+        - 10.2: Adding a new cloud provider only requires implementing the provider-specific scanner
+    """
+    
+    @abstractmethod
+    def get_credentials(self, credential_config: Dict[str, Any]) -> Any:
+        """
+        Get cloud provider credentials/session from configuration.
+        
+        Args:
+            credential_config: Dictionary containing credential configuration
+                              (e.g., access keys, role ARN, etc.)
+        
+        Returns:
+            Provider-specific credential object (e.g., boto3.Session for AWS)
+        
+        Raises:
+            CredentialError: If credentials are invalid or cannot be obtained
+        """
+        pass
+    
+    @abstractmethod
+    def list_regions(self) -> List[str]:
+        """
+        List all available regions for the cloud provider.
+        
+        Returns:
+            List of region identifiers (e.g., ['us-east-1', 'eu-west-1'] for AWS)
+        """
+        pass
+    
+    @abstractmethod
+    def scan_resources(
+        self,
+        regions: List[str],
+        services: Optional[List[str]] = None,
+        progress_callback: Optional[callable] = None
+    ) -> ScanResult:
+        """
+        Scan cloud resources across specified regions and services.
+        
+        Args:
+            regions: List of regions to scan
+            services: Optional list of services to scan (None = all supported services)
+            progress_callback: Optional callback function for progress updates
+                              Signature: callback(current: int, total: int, message: str)
+        
+        Returns:
+            ScanResult containing all discovered resources and any errors
+        """
+        pass
+    
+    @abstractmethod
+    def validate_credentials(self) -> bool:
+        """
+        Validate that the current credentials are valid and have necessary permissions.
+        
+        Returns:
+            True if credentials are valid, False otherwise
+        """
+        pass
+    
+    @abstractmethod
+    def get_account_id(self) -> str:
+        """
+        Get the account/subscription ID for the current credentials.
+        
+        Returns:
+            Account identifier string
+        """
+        pass
+    
+    @property
+    @abstractmethod
+    def provider_name(self) -> str:
+        """
+        Get the name of the cloud provider.
+        
+        Returns:
+            Provider name (e.g., 'AWS', 'Azure', 'GCP')
+        """
+        pass
+    
+    @property
+    @abstractmethod
+    def supported_services(self) -> List[str]:
+        """
+        Get list of services supported by this scanner.
+        
+        Returns:
+            List of service identifiers
+        """
+        pass
+    
+    @property
+    @abstractmethod
+    def global_services(self) -> List[str]:
+        """
+        Get list of global services (not region-specific).
+        
+        Returns:
+            List of global service identifiers
+        """
+        pass

+ 235 - 0
backend/app/scanners/credentials.py

@@ -0,0 +1,235 @@
+"""
+AWS Credential Management Module
+
+This module handles AWS credential acquisition for both Assume Role and Access Key
+authentication methods.
+
+Requirements:
+    - 2.2: Use centralized base account for Assume Role
+    - 2.3: Securely store and use Access Key credentials
+"""
+
+import boto3
+from botocore.exceptions import ClientError, NoCredentialsError
+from typing import Dict, Any, Optional
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class CredentialError(Exception):
+    """Exception raised for credential-related errors"""
+    pass
+
+
+class AWSCredentialProvider:
+    """
+    Provides AWS credentials using either Assume Role or Access Key methods.
+    
+    This class handles the complexity of obtaining AWS credentials from different
+    sources and provides a unified interface for the scanner.
+    """
+    
+    def __init__(
+        self,
+        credential_type: str,
+        credential_config: Dict[str, Any],
+        base_credentials: Optional[Dict[str, str]] = None
+    ):
+        """
+        Initialize the credential provider.
+        
+        Args:
+            credential_type: Either 'assume_role' or 'access_key'
+            credential_config: Configuration for the credential
+                For assume_role: {'role_arn': str, 'external_id': str (optional)}
+                For access_key: {'access_key_id': str, 'secret_access_key': str}
+            base_credentials: Base account credentials for Assume Role
+                {'access_key_id': str, 'secret_access_key': str}
+        """
+        self.credential_type = credential_type
+        self.credential_config = credential_config
+        self.base_credentials = base_credentials
+        self._session: Optional[boto3.Session] = None
+        self._account_id: Optional[str] = None
+    
+    def get_session(self, region_name: Optional[str] = None) -> boto3.Session:
+        """
+        Get a boto3 Session with the configured credentials.
+        
+        Args:
+            region_name: Optional region for the session
+        
+        Returns:
+            boto3.Session configured with appropriate credentials
+        
+        Raises:
+            CredentialError: If credentials cannot be obtained
+        """
+        if self.credential_type == 'assume_role':
+            return self._get_assume_role_session(region_name)
+        elif self.credential_type == 'access_key':
+            return self._get_access_key_session(region_name)
+        else:
+            raise CredentialError(f"Unknown credential type: {self.credential_type}")
+    
+    def _get_assume_role_session(self, region_name: Optional[str] = None) -> boto3.Session:
+        """
+        Get a session using Assume Role.
+        
+        Uses the base account credentials to assume a role in the target account.
+        """
+        role_arn = self.credential_config.get('role_arn')
+        external_id = self.credential_config.get('external_id')
+        
+        if not role_arn:
+            raise CredentialError("Role ARN is required for assume_role credential type")
+        
+        if not self.base_credentials:
+            raise CredentialError("Base credentials are required for assume_role")
+        
+        try:
+            # Create base session with the centralized account credentials
+            base_session = boto3.Session(
+                aws_access_key_id=self.base_credentials.get('access_key_id'),
+                aws_secret_access_key=self.base_credentials.get('secret_access_key'),
+                region_name=region_name or 'us-east-1'
+            )
+            
+            # Use STS to assume the role
+            sts_client = base_session.client('sts')
+            
+            assume_role_params = {
+                'RoleArn': role_arn,
+                'RoleSessionName': 'AWSResourceScanner',
+                'DurationSeconds': 3600  # 1 hour
+            }
+            
+            if external_id:
+                assume_role_params['ExternalId'] = external_id
+            
+            response = sts_client.assume_role(**assume_role_params)
+            credentials = response['Credentials']
+            
+            # Create a new session with the assumed role credentials
+            return boto3.Session(
+                aws_access_key_id=credentials['AccessKeyId'],
+                aws_secret_access_key=credentials['SecretAccessKey'],
+                aws_session_token=credentials['SessionToken'],
+                region_name=region_name
+            )
+            
+        except ClientError as e:
+            error_code = e.response.get('Error', {}).get('Code', 'Unknown')
+            error_message = e.response.get('Error', {}).get('Message', str(e))
+            logger.error(f"Failed to assume role {role_arn}: {error_code} - {error_message}")
+            raise CredentialError(f"Failed to assume role: {error_message}")
+        except NoCredentialsError:
+            raise CredentialError("Base credentials are invalid or not configured")
+        except Exception as e:
+            logger.error(f"Unexpected error assuming role: {str(e)}")
+            raise CredentialError(f"Unexpected error: {str(e)}")
+    
+    def _get_access_key_session(self, region_name: Optional[str] = None) -> boto3.Session:
+        """
+        Get a session using Access Key credentials.
+        """
+        access_key_id = self.credential_config.get('access_key_id')
+        secret_access_key = self.credential_config.get('secret_access_key')
+        
+        if not access_key_id or not secret_access_key:
+            raise CredentialError("Access Key ID and Secret Access Key are required")
+        
+        try:
+            return boto3.Session(
+                aws_access_key_id=access_key_id,
+                aws_secret_access_key=secret_access_key,
+                region_name=region_name
+            )
+        except Exception as e:
+            logger.error(f"Failed to create session with access key: {str(e)}")
+            raise CredentialError(f"Failed to create session: {str(e)}")
+    
+    def validate(self) -> bool:
+        """
+        Validate that the credentials are valid and working.
+        
+        Returns:
+            True if credentials are valid
+        
+        Raises:
+            CredentialError: If credentials are invalid
+        """
+        try:
+            session = self.get_session(region_name='us-east-1')
+            sts_client = session.client('sts')
+            response = sts_client.get_caller_identity()
+            self._account_id = response.get('Account')
+            logger.info(f"Credentials validated for account: {self._account_id}")
+            return True
+        except ClientError as e:
+            error_message = e.response.get('Error', {}).get('Message', str(e))
+            raise CredentialError(f"Credential validation failed: {error_message}")
+        except Exception as e:
+            raise CredentialError(f"Credential validation failed: {str(e)}")
+    
+    def get_account_id(self) -> str:
+        """
+        Get the AWS account ID for the credentials.
+        
+        Returns:
+            AWS account ID string
+        """
+        if self._account_id:
+            return self._account_id
+        
+        try:
+            session = self.get_session(region_name='us-east-1')
+            sts_client = session.client('sts')
+            response = sts_client.get_caller_identity()
+            self._account_id = response.get('Account')
+            return self._account_id
+        except Exception as e:
+            logger.error(f"Failed to get account ID: {str(e)}")
+            raise CredentialError(f"Failed to get account ID: {str(e)}")
+
+
+def create_credential_provider_from_model(
+    credential,
+    base_config=None
+) -> AWSCredentialProvider:
+    """
+    Create an AWSCredentialProvider from database models.
+    
+    Args:
+        credential: AWSCredential model instance
+        base_config: BaseAssumeRoleConfig model instance (required for assume_role)
+    
+    Returns:
+        Configured AWSCredentialProvider
+    """
+    credential_config = {}
+    base_credentials = None
+    
+    if credential.credential_type == 'assume_role':
+        credential_config = {
+            'role_arn': credential.role_arn,
+            'external_id': credential.external_id
+        }
+        
+        if base_config:
+            base_credentials = {
+                'access_key_id': base_config.access_key_id,
+                'secret_access_key': base_config.get_secret_access_key()
+            }
+    else:  # access_key
+        credential_config = {
+            'access_key_id': credential.access_key_id,
+            'secret_access_key': credential.get_secret_access_key()
+        }
+    
+    return AWSCredentialProvider(
+        credential_type=credential.credential_type,
+        credential_config=credential_config,
+        base_credentials=base_credentials
+    )

+ 4 - 0
backend/app/scanners/services/__init__.py

@@ -0,0 +1,4 @@
+# AWS Service Scanners
+# Individual service scanning implementations
+# Import specific scanners directly from their modules to avoid circular imports
+# e.g., from app.scanners.services.vpc import VPCServiceScanner

+ 246 - 0
backend/app/scanners/services/compute.py

@@ -0,0 +1,246 @@
+"""
+Compute and Storage Service Scanners
+
+Scans EKS Clusters, Lambda Functions, S3 Buckets, and S3 Event Notifications.
+
+Requirements:
+    - 5.1: Scan compute and storage AWS services using boto3
+"""
+
+import boto3
+from typing import List, Dict, Any
+import logging
+
+from app.scanners.base import ResourceData
+from app.scanners.utils import retry_with_backoff
+
+logger = logging.getLogger(__name__)
+
+
+class ComputeServiceScanner:
+    """Scanner for compute and storage AWS resources"""
+    
+    @staticmethod
+    @retry_with_backoff()
+    def scan_eks_clusters(session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """
+        Scan EKS Clusters in the specified region.
+        
+        Attributes (vertical layout - one table per cluster):
+            Cluster Name, Version, Status, Endpoint, VPC ID
+        """
+        resources = []
+        eks_client = session.client('eks')
+        
+        try:
+            # List clusters
+            paginator = eks_client.get_paginator('list_clusters')
+            cluster_names = []
+            for page in paginator.paginate():
+                cluster_names.extend(page.get('clusters', []))
+            
+            # Get details for each cluster
+            for cluster_name in cluster_names:
+                try:
+                    response = eks_client.describe_cluster(name=cluster_name)
+                    cluster = response.get('cluster', {})
+                    
+                    resources.append(ResourceData(
+                        account_id=account_id,
+                        region=region,
+                        service='eks',
+                        resource_type='Cluster',
+                        resource_id=cluster.get('arn', cluster_name),
+                        name=cluster_name,
+                        attributes={
+                            'Cluster Name': cluster_name,
+                            'Version': cluster.get('version', ''),
+                            'Status': cluster.get('status', ''),
+                            'Endpoint': cluster.get('endpoint', ''),
+                            'VPC ID': cluster.get('resourcesVpcConfig', {}).get('vpcId', '')
+                        }
+                    ))
+                except Exception as e:
+                    logger.warning(f"Failed to describe EKS cluster {cluster_name}: {str(e)}")
+        except Exception as e:
+            logger.warning(f"Failed to list EKS clusters: {str(e)}")
+        
+        return resources
+    
+    @staticmethod
+    @retry_with_backoff()
+    def scan_lambda_functions(session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """
+        Scan Lambda Functions in the specified region.
+        
+        Attributes (horizontal layout):
+            Function Name, Runtime, Memory (MB), Timeout (s), Last Modified
+        """
+        resources = []
+        lambda_client = session.client('lambda')
+        
+        try:
+            paginator = lambda_client.get_paginator('list_functions')
+            for page in paginator.paginate():
+                for func in page.get('Functions', []):
+                    func_name = func.get('FunctionName', '')
+                    
+                    resources.append(ResourceData(
+                        account_id=account_id,
+                        region=region,
+                        service='lambda',
+                        resource_type='Function',
+                        resource_id=func.get('FunctionArn', func_name),
+                        name=func_name,
+                        attributes={
+                            'Function Name': func_name,
+                            'Runtime': func.get('Runtime', 'N/A'),
+                            'Memory (MB)': str(func.get('MemorySize', '')),
+                            'Timeout (s)': str(func.get('Timeout', '')),
+                            'Last Modified': func.get('LastModified', '')
+                        }
+                    ))
+        except Exception as e:
+            logger.warning(f"Failed to scan Lambda functions: {str(e)}")
+        
+        return resources
+    
+    @staticmethod
+    @retry_with_backoff()
+    def scan_s3_buckets(session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """
+        Scan S3 Buckets (global service, scanned once).
+        
+        Attributes (horizontal layout): Region, Bucket Name
+        """
+        resources = []
+        s3_client = session.client('s3')
+        
+        try:
+            response = s3_client.list_buckets()
+            for bucket in response.get('Buckets', []):
+                bucket_name = bucket.get('Name', '')
+                
+                # Get bucket location
+                try:
+                    location_response = s3_client.get_bucket_location(Bucket=bucket_name)
+                    bucket_region = location_response.get('LocationConstraint') or 'us-east-1'
+                except Exception:
+                    bucket_region = 'unknown'
+                
+                resources.append(ResourceData(
+                    account_id=account_id,
+                    region='global',
+                    service='s3',
+                    resource_type='Bucket',
+                    resource_id=bucket_name,
+                    name=bucket_name,
+                    attributes={
+                        'Region': bucket_region,
+                        'Bucket Name': bucket_name
+                    }
+                ))
+        except Exception as e:
+            logger.warning(f"Failed to scan S3 buckets: {str(e)}")
+        
+        return resources
+    
+    @staticmethod
+    @retry_with_backoff()
+    def scan_s3_event_notifications(session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """
+        Scan S3 Event Notifications (global service).
+        
+        Attributes (vertical layout):
+            Bucket, Name, Event Type, Destination type, Destination
+        """
+        resources = []
+        s3_client = session.client('s3')
+        
+        try:
+            # First get all buckets
+            buckets_response = s3_client.list_buckets()
+            
+            for bucket in buckets_response.get('Buckets', []):
+                bucket_name = bucket.get('Name', '')
+                
+                try:
+                    # Get notification configuration
+                    notif_response = s3_client.get_bucket_notification_configuration(
+                        Bucket=bucket_name
+                    )
+                    
+                    # Process Lambda function configurations
+                    for config in notif_response.get('LambdaFunctionConfigurations', []):
+                        config_id = config.get('Id', 'Lambda')
+                        events = config.get('Events', [])
+                        lambda_arn = config.get('LambdaFunctionArn', '')
+                        
+                        resources.append(ResourceData(
+                            account_id=account_id,
+                            region='global',
+                            service='s3_event_notification',
+                            resource_type='S3 event notification',
+                            resource_id=f"{bucket_name}/{config_id}",
+                            name=config_id,
+                            attributes={
+                                'Bucket': bucket_name,
+                                'Name': config_id,
+                                'Event Type': ', '.join(events),
+                                'Destination type': 'Lambda',
+                                'Destination': lambda_arn.split(':')[-1] if lambda_arn else ''
+                            }
+                        ))
+                    
+                    # Process SQS queue configurations
+                    for config in notif_response.get('QueueConfigurations', []):
+                        config_id = config.get('Id', 'SQS')
+                        events = config.get('Events', [])
+                        queue_arn = config.get('QueueArn', '')
+                        
+                        resources.append(ResourceData(
+                            account_id=account_id,
+                            region='global',
+                            service='s3_event_notification',
+                            resource_type='S3 event notification',
+                            resource_id=f"{bucket_name}/{config_id}",
+                            name=config_id,
+                            attributes={
+                                'Bucket': bucket_name,
+                                'Name': config_id,
+                                'Event Type': ', '.join(events),
+                                'Destination type': 'SQS',
+                                'Destination': queue_arn.split(':')[-1] if queue_arn else ''
+                            }
+                        ))
+                    
+                    # Process SNS topic configurations
+                    for config in notif_response.get('TopicConfigurations', []):
+                        config_id = config.get('Id', 'SNS')
+                        events = config.get('Events', [])
+                        topic_arn = config.get('TopicArn', '')
+                        
+                        resources.append(ResourceData(
+                            account_id=account_id,
+                            region='global',
+                            service='s3_event_notification',
+                            resource_type='S3 event notification',
+                            resource_id=f"{bucket_name}/{config_id}",
+                            name=config_id,
+                            attributes={
+                                'Bucket': bucket_name,
+                                'Name': config_id,
+                                'Event Type': ', '.join(events),
+                                'Destination type': 'SNS',
+                                'Destination': topic_arn.split(':')[-1] if topic_arn else ''
+                            }
+                        ))
+                        
+                except Exception as e:
+                    # Skip buckets we can't access
+                    logger.debug(f"Failed to get notifications for bucket {bucket_name}: {str(e)}")
+                    
+        except Exception as e:
+            logger.warning(f"Failed to scan S3 event notifications: {str(e)}")
+        
+        return resources

+ 166 - 0
backend/app/scanners/services/database.py

@@ -0,0 +1,166 @@
+"""
+Database Service Scanners
+
+Scans RDS DB Instances and ElastiCache Clusters.
+
+Requirements:
+    - 5.1: Scan database AWS services using boto3
+"""
+
+import boto3
+from typing import List, Dict, Any
+import logging
+
+from app.scanners.base import ResourceData
+from app.scanners.utils import retry_with_backoff
+
+logger = logging.getLogger(__name__)
+
+
+class DatabaseServiceScanner:
+    """Scanner for database AWS resources"""
+    
+    @staticmethod
+    @retry_with_backoff()
+    def scan_rds_instances(session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """
+        Scan RDS DB Instances in the specified region.
+        
+        Attributes (vertical layout - one table per instance):
+            Region, Endpoint, DB instance ID, DB name, Master Username, Port,
+            DB Engine, DB Version, Instance Type, Storage type, Storage, Multi-AZ,
+            Security Group, Deletion Protection, Performance Insights Enabled, CloudWatch Logs
+        """
+        resources = []
+        rds_client = session.client('rds')
+        
+        paginator = rds_client.get_paginator('describe_db_instances')
+        for page in paginator.paginate():
+            for db in page.get('DBInstances', []):
+                db_id = db.get('DBInstanceIdentifier', '')
+                
+                # Get security groups
+                security_groups = []
+                for sg in db.get('VpcSecurityGroups', []):
+                    security_groups.append(sg.get('VpcSecurityGroupId', ''))
+                
+                # Get CloudWatch logs exports
+                cw_logs = db.get('EnabledCloudwatchLogsExports', [])
+                
+                # Get endpoint
+                endpoint = db.get('Endpoint', {})
+                endpoint_address = endpoint.get('Address', '')
+                port = endpoint.get('Port', '')
+                
+                resources.append(ResourceData(
+                    account_id=account_id,
+                    region=region,
+                    service='rds',
+                    resource_type='DB Instance',
+                    resource_id=db.get('DBInstanceArn', db_id),
+                    name=db_id,
+                    attributes={
+                        'Region': region,
+                        'Endpoint': endpoint_address,
+                        'DB instance ID': db_id,
+                        'DB name': db.get('DBName', ''),
+                        'Master Username': db.get('MasterUsername', ''),
+                        'Port': str(port),
+                        'DB Engine': db.get('Engine', ''),
+                        'DB Version': db.get('EngineVersion', ''),
+                        'Instance Type': db.get('DBInstanceClass', ''),
+                        'Storage type': db.get('StorageType', ''),
+                        'Storage': f"{db.get('AllocatedStorage', '')} GB",
+                        'Multi-AZ': 'Yes' if db.get('MultiAZ') else 'No',
+                        'Security Group': ', '.join(security_groups),
+                        'Deletion Protection': 'Yes' if db.get('DeletionProtection') else 'No',
+                        'Performance Insights Enabled': 'Yes' if db.get('PerformanceInsightsEnabled') else 'No',
+                        'CloudWatch Logs': ', '.join(cw_logs) if cw_logs else 'N/A'
+                    }
+                ))
+        
+        return resources
+    
+    @staticmethod
+    @retry_with_backoff()
+    def scan_elasticache_clusters(session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """
+        Scan ElastiCache Clusters in the specified region.
+        
+        Attributes (vertical layout - one table per cluster):
+            Cluster ID, Engine, Engine Version, Node Type, Num Nodes, Status
+        """
+        resources = []
+        elasticache_client = session.client('elasticache')
+        
+        # Scan cache clusters (Redis/Memcached)
+        try:
+            paginator = elasticache_client.get_paginator('describe_cache_clusters')
+            for page in paginator.paginate(ShowCacheNodeInfo=True):
+                for cluster in page.get('CacheClusters', []):
+                    cluster_id = cluster.get('CacheClusterId', '')
+                    
+                    resources.append(ResourceData(
+                        account_id=account_id,
+                        region=region,
+                        service='elasticache',
+                        resource_type='Cache Cluster',
+                        resource_id=cluster.get('ARN', cluster_id),
+                        name=cluster_id,
+                        attributes={
+                            'Cluster ID': cluster_id,
+                            'Engine': cluster.get('Engine', ''),
+                            'Engine Version': cluster.get('EngineVersion', ''),
+                            'Node Type': cluster.get('CacheNodeType', ''),
+                            'Num Nodes': str(cluster.get('NumCacheNodes', 0)),
+                            'Status': cluster.get('CacheClusterStatus', '')
+                        }
+                    ))
+        except Exception as e:
+            logger.warning(f"Failed to scan ElastiCache clusters: {str(e)}")
+        
+        # Also scan replication groups (Redis cluster mode)
+        try:
+            paginator = elasticache_client.get_paginator('describe_replication_groups')
+            for page in paginator.paginate():
+                for rg in page.get('ReplicationGroups', []):
+                    rg_id = rg.get('ReplicationGroupId', '')
+                    
+                    # Count nodes
+                    num_nodes = 0
+                    for node_group in rg.get('NodeGroups', []):
+                        num_nodes += len(node_group.get('NodeGroupMembers', []))
+                    
+                    # Get node type from member clusters
+                    node_type = ''
+                    member_clusters = rg.get('MemberClusters', [])
+                    if member_clusters:
+                        try:
+                            cluster_response = elasticache_client.describe_cache_clusters(
+                                CacheClusterId=member_clusters[0]
+                            )
+                            if cluster_response.get('CacheClusters'):
+                                node_type = cluster_response['CacheClusters'][0].get('CacheNodeType', '')
+                        except Exception:
+                            pass
+                    
+                    resources.append(ResourceData(
+                        account_id=account_id,
+                        region=region,
+                        service='elasticache',
+                        resource_type='Cache Cluster',
+                        resource_id=rg.get('ARN', rg_id),
+                        name=rg_id,
+                        attributes={
+                            'Cluster ID': rg_id,
+                            'Engine': 'redis',
+                            'Engine Version': '',
+                            'Node Type': node_type,
+                            'Num Nodes': str(num_nodes),
+                            'Status': rg.get('Status', '')
+                        }
+                    ))
+        except Exception as e:
+            logger.warning(f"Failed to scan ElastiCache replication groups: {str(e)}")
+        
+        return resources

+ 149 - 0
backend/app/scanners/services/ec2.py

@@ -0,0 +1,149 @@
+"""
+EC2 Related Resource Scanners
+
+Scans EC2 Instances (with EBS volumes, AMI info) and Elastic IPs.
+
+Requirements:
+    - 5.1: Scan EC2-related AWS services using boto3
+"""
+
+import boto3
+from typing import List, Dict, Any
+import logging
+
+from app.scanners.base import ResourceData
+from app.scanners.utils import retry_with_backoff
+
+logger = logging.getLogger(__name__)
+
+
+class EC2ServiceScanner:
+    """Scanner for EC2-related AWS resources"""
+    
+    @staticmethod
+    def _get_name_from_tags(tags: List[Dict[str, str]], default: str = '') -> str:
+        """Extract Name tag value from tags list"""
+        if not tags:
+            return default
+        for tag in tags:
+            if tag.get('Key') == 'Name':
+                return tag.get('Value', default)
+        return default
+    
+    @staticmethod
+    @retry_with_backoff()
+    def scan_ec2_instances(session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """
+        Scan EC2 Instances in the specified region.
+        
+        Attributes (vertical layout - one table per instance):
+            Name, Instance ID, Instance Type, AZ, AMI,
+            Public IP, Public DNS, Private IP, VPC ID, Subnet ID,
+            Key, Security Groups, EBS Type, EBS Size, Encryption, Other Requirement
+        """
+        resources = []
+        ec2_client = session.client('ec2')
+        
+        paginator = ec2_client.get_paginator('describe_instances')
+        for page in paginator.paginate():
+            for reservation in page.get('Reservations', []):
+                for instance in reservation.get('Instances', []):
+                    # Skip terminated instances
+                    state = instance.get('State', {}).get('Name', '')
+                    if state == 'terminated':
+                        continue
+                    
+                    name = EC2ServiceScanner._get_name_from_tags(
+                        instance.get('Tags', []), 
+                        instance['InstanceId']
+                    )
+                    
+                    # Get security groups
+                    security_groups = []
+                    for sg in instance.get('SecurityGroups', []):
+                        security_groups.append(sg.get('GroupName', sg.get('GroupId', '')))
+                    
+                    # Get EBS volume info
+                    ebs_type = ''
+                    ebs_size = ''
+                    ebs_encrypted = ''
+                    
+                    for block_device in instance.get('BlockDeviceMappings', []):
+                        ebs = block_device.get('Ebs', {})
+                        if ebs.get('VolumeId'):
+                            # Get volume details
+                            try:
+                                vol_response = ec2_client.describe_volumes(
+                                    VolumeIds=[ebs['VolumeId']]
+                                )
+                                if vol_response.get('Volumes'):
+                                    volume = vol_response['Volumes'][0]
+                                    ebs_type = volume.get('VolumeType', '')
+                                    ebs_size = f"{volume.get('Size', '')} GB"
+                                    ebs_encrypted = 'Yes' if volume.get('Encrypted') else 'No'
+                            except Exception as e:
+                                logger.warning(f"Failed to get volume details: {str(e)}")
+                            break  # Only get first volume for simplicity
+                    
+                    resources.append(ResourceData(
+                        account_id=account_id,
+                        region=region,
+                        service='ec2',
+                        resource_type='Instance',
+                        resource_id=instance['InstanceId'],
+                        name=name,
+                        attributes={
+                            'Name': name,
+                            'Instance ID': instance['InstanceId'],
+                            'Instance Type': instance.get('InstanceType', ''),
+                            'AZ': instance.get('Placement', {}).get('AvailabilityZone', ''),
+                            'AMI': instance.get('ImageId', ''),
+                            'Public IP': instance.get('PublicIpAddress', ''),
+                            'Public DNS': instance.get('PublicDnsName', ''),
+                            'Private IP': instance.get('PrivateIpAddress', ''),
+                            'VPC ID': instance.get('VpcId', ''),
+                            'Subnet ID': instance.get('SubnetId', ''),
+                            'Key': instance.get('KeyName', ''),
+                            'Security Groups': ', '.join(security_groups),
+                            'EBS Type': ebs_type,
+                            'EBS Size': ebs_size,
+                            'Encryption': ebs_encrypted,
+                            'Other Requirement': ''
+                        }
+                    ))
+        
+        return resources
+    
+    @staticmethod
+    @retry_with_backoff()
+    def scan_elastic_ips(session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """
+        Scan Elastic IPs in the specified region.
+        
+        Attributes (horizontal layout): Name, Elastic IP
+        """
+        resources = []
+        ec2_client = session.client('ec2')
+        
+        response = ec2_client.describe_addresses()
+        for eip in response.get('Addresses', []):
+            public_ip = eip.get('PublicIp', '')
+            name = EC2ServiceScanner._get_name_from_tags(
+                eip.get('Tags', []), 
+                public_ip or eip.get('AllocationId', '')
+            )
+            
+            resources.append(ResourceData(
+                account_id=account_id,
+                region=region,
+                service='elastic_ip',
+                resource_type='Elastic IP',
+                resource_id=eip.get('AllocationId', public_ip),
+                name=name,
+                attributes={
+                    'Name': name,
+                    'Elastic IP': public_ip
+                }
+            ))
+        
+        return resources

+ 285 - 0
backend/app/scanners/services/elb.py

@@ -0,0 +1,285 @@
+"""
+Auto Scaling and ELB Resource Scanners
+
+Scans Auto Scaling Groups (with Launch Templates), Load Balancers (ALB, NLB, CLB),
+and Target Groups.
+
+Requirements:
+    - 5.1: Scan Auto Scaling and ELB AWS services using boto3
+"""
+
+import boto3
+from typing import List, Dict, Any
+import logging
+
+from app.scanners.base import ResourceData
+from app.scanners.utils import retry_with_backoff
+
+logger = logging.getLogger(__name__)
+
+
+class ELBServiceScanner:
+    """Scanner for Auto Scaling and ELB AWS resources"""
+    
+    @staticmethod
+    def _get_name_from_tags(tags: List[Dict[str, str]], default: str = '') -> str:
+        """Extract Name tag value from tags list"""
+        if not tags:
+            return default
+        for tag in tags:
+            if tag.get('Key') == 'Name':
+                return tag.get('Value', default)
+        return default
+    
+    @staticmethod
+    @retry_with_backoff()
+    def scan_autoscaling_groups(session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """
+        Scan Auto Scaling Groups in the specified region.
+        
+        Attributes (vertical layout - one table per ASG):
+            Name, Launch Template, AMI, Instance type, Key, Target Groups,
+            Desired, Min, Max, Scaling Policy
+        """
+        resources = []
+        asg_client = session.client('autoscaling')
+        ec2_client = session.client('ec2')
+        
+        paginator = asg_client.get_paginator('describe_auto_scaling_groups')
+        for page in paginator.paginate():
+            for asg in page.get('AutoScalingGroups', []):
+                name = asg.get('AutoScalingGroupName', '')
+                
+                # Get Launch Template info
+                launch_template_name = ''
+                ami = ''
+                instance_type = ''
+                key_name = ''
+                
+                # Check for Launch Template
+                lt = asg.get('LaunchTemplate')
+                if lt:
+                    launch_template_name = lt.get('LaunchTemplateName', lt.get('LaunchTemplateId', ''))
+                    # Get Launch Template details
+                    try:
+                        lt_response = ec2_client.describe_launch_template_versions(
+                            LaunchTemplateId=lt.get('LaunchTemplateId', ''),
+                            Versions=[lt.get('Version', '$Latest')]
+                        )
+                        if lt_response.get('LaunchTemplateVersions'):
+                            lt_data = lt_response['LaunchTemplateVersions'][0].get('LaunchTemplateData', {})
+                            ami = lt_data.get('ImageId', '')
+                            instance_type = lt_data.get('InstanceType', '')
+                            key_name = lt_data.get('KeyName', '')
+                    except Exception as e:
+                        logger.warning(f"Failed to get launch template details: {str(e)}")
+                
+                # Check for Mixed Instances Policy
+                mip = asg.get('MixedInstancesPolicy')
+                if mip:
+                    lt_spec = mip.get('LaunchTemplate', {}).get('LaunchTemplateSpecification', {})
+                    if lt_spec:
+                        launch_template_name = lt_spec.get('LaunchTemplateName', lt_spec.get('LaunchTemplateId', ''))
+                
+                # Check for Launch Configuration (legacy)
+                lc_name = asg.get('LaunchConfigurationName')
+                if lc_name and not launch_template_name:
+                    launch_template_name = f"LC: {lc_name}"
+                    try:
+                        lc_response = asg_client.describe_launch_configurations(
+                            LaunchConfigurationNames=[lc_name]
+                        )
+                        if lc_response.get('LaunchConfigurations'):
+                            lc = lc_response['LaunchConfigurations'][0]
+                            ami = lc.get('ImageId', '')
+                            instance_type = lc.get('InstanceType', '')
+                            key_name = lc.get('KeyName', '')
+                    except Exception as e:
+                        logger.warning(f"Failed to get launch configuration details: {str(e)}")
+                
+                # Get Target Groups
+                target_groups = []
+                for tg_arn in asg.get('TargetGroupARNs', []):
+                    # Extract target group name from ARN
+                    tg_name = tg_arn.split('/')[-2] if '/' in tg_arn else tg_arn
+                    target_groups.append(tg_name)
+                
+                # Get Scaling Policies
+                scaling_policies = []
+                try:
+                    policy_response = asg_client.describe_policies(
+                        AutoScalingGroupName=name
+                    )
+                    for policy in policy_response.get('ScalingPolicies', []):
+                        scaling_policies.append(policy.get('PolicyName', ''))
+                except Exception as e:
+                    logger.warning(f"Failed to get scaling policies: {str(e)}")
+                
+                resources.append(ResourceData(
+                    account_id=account_id,
+                    region=region,
+                    service='autoscaling',
+                    resource_type='Auto Scaling Group',
+                    resource_id=asg.get('AutoScalingGroupARN', name),
+                    name=name,
+                    attributes={
+                        'Name': name,
+                        'Launch Template': launch_template_name,
+                        'AMI': ami,
+                        'Instance type': instance_type,
+                        'Key': key_name,
+                        'Target Groups': ', '.join(target_groups) if target_groups else 'N/A',
+                        'Desired': str(asg.get('DesiredCapacity', 0)),
+                        'Min': str(asg.get('MinSize', 0)),
+                        'Max': str(asg.get('MaxSize', 0)),
+                        'Scaling Policy': ', '.join(scaling_policies) if scaling_policies else 'N/A'
+                    }
+                ))
+        
+        return resources
+    
+    @staticmethod
+    @retry_with_backoff()
+    def scan_load_balancers(session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """
+        Scan Load Balancers (ALB, NLB, CLB) in the specified region.
+        
+        Attributes (vertical layout - one table per LB):
+            Name, Type, DNS, Scheme, VPC, Availability Zones, Subnet, Security Groups
+        """
+        resources = []
+        
+        # Scan ALB/NLB using elbv2
+        elbv2_client = session.client('elbv2')
+        
+        try:
+            paginator = elbv2_client.get_paginator('describe_load_balancers')
+            for page in paginator.paginate():
+                for lb in page.get('LoadBalancers', []):
+                    name = lb.get('LoadBalancerName', '')
+                    lb_type = lb.get('Type', 'application')
+                    
+                    # Get availability zones and subnets
+                    azs = []
+                    subnets = []
+                    for az_info in lb.get('AvailabilityZones', []):
+                        azs.append(az_info.get('ZoneName', ''))
+                        if az_info.get('SubnetId'):
+                            subnets.append(az_info['SubnetId'])
+                    
+                    # Get security groups (only for ALB)
+                    security_groups = lb.get('SecurityGroups', [])
+                    
+                    resources.append(ResourceData(
+                        account_id=account_id,
+                        region=region,
+                        service='elb',
+                        resource_type='Load Balancer',
+                        resource_id=lb.get('LoadBalancerArn', name),
+                        name=name,
+                        attributes={
+                            'Name': name,
+                            'Type': lb_type.upper(),
+                            'DNS': lb.get('DNSName', ''),
+                            'Scheme': lb.get('Scheme', ''),
+                            'VPC': lb.get('VpcId', ''),
+                            'Availability Zones': ', '.join(azs),
+                            'Subnet': ', '.join(subnets),
+                            'Security Groups': ', '.join(security_groups) if security_groups else 'N/A'
+                        }
+                    ))
+        except Exception as e:
+            logger.warning(f"Failed to scan ALB/NLB: {str(e)}")
+        
+        # Scan Classic Load Balancers
+        elb_client = session.client('elb')
+        
+        try:
+            paginator = elb_client.get_paginator('describe_load_balancers')
+            for page in paginator.paginate():
+                for lb in page.get('LoadBalancerDescriptions', []):
+                    name = lb.get('LoadBalancerName', '')
+                    
+                    resources.append(ResourceData(
+                        account_id=account_id,
+                        region=region,
+                        service='elb',
+                        resource_type='Load Balancer',
+                        resource_id=name,
+                        name=name,
+                        attributes={
+                            'Name': name,
+                            'Type': 'CLASSIC',
+                            'DNS': lb.get('DNSName', ''),
+                            'Scheme': lb.get('Scheme', ''),
+                            'VPC': lb.get('VPCId', ''),
+                            'Availability Zones': ', '.join(lb.get('AvailabilityZones', [])),
+                            'Subnet': ', '.join(lb.get('Subnets', [])),
+                            'Security Groups': ', '.join(lb.get('SecurityGroups', []))
+                        }
+                    ))
+        except Exception as e:
+            logger.warning(f"Failed to scan Classic ELB: {str(e)}")
+        
+        return resources
+    
+    @staticmethod
+    @retry_with_backoff()
+    def scan_target_groups(session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """
+        Scan Target Groups in the specified region.
+        
+        Attributes (vertical layout - one table per TG):
+            Load Balancer, TG Name, Port, Protocol, Registered Instances, Health Check Path
+        """
+        resources = []
+        elbv2_client = session.client('elbv2')
+        
+        try:
+            paginator = elbv2_client.get_paginator('describe_target_groups')
+            for page in paginator.paginate():
+                for tg in page.get('TargetGroups', []):
+                    name = tg.get('TargetGroupName', '')
+                    tg_arn = tg.get('TargetGroupArn', '')
+                    
+                    # Get associated load balancers
+                    lb_arns = tg.get('LoadBalancerArns', [])
+                    lb_names = []
+                    for lb_arn in lb_arns:
+                        # Extract LB name from ARN
+                        lb_name = lb_arn.split('/')[-2] if '/' in lb_arn else lb_arn
+                        lb_names.append(lb_name)
+                    
+                    # Get registered targets
+                    registered_instances = []
+                    try:
+                        targets_response = elbv2_client.describe_target_health(
+                            TargetGroupArn=tg_arn
+                        )
+                        for target in targets_response.get('TargetHealthDescriptions', []):
+                            target_id = target.get('Target', {}).get('Id', '')
+                            if target_id:
+                                registered_instances.append(target_id)
+                    except Exception as e:
+                        logger.warning(f"Failed to get target health: {str(e)}")
+                    
+                    resources.append(ResourceData(
+                        account_id=account_id,
+                        region=region,
+                        service='target_group',
+                        resource_type='Target Group',
+                        resource_id=tg_arn,
+                        name=name,
+                        attributes={
+                            'Load Balancer': ', '.join(lb_names) if lb_names else 'N/A',
+                            'TG Name': name,
+                            'Port': str(tg.get('Port', '')),
+                            'Protocol': tg.get('Protocol', ''),
+                            'Registered Instances': ', '.join(registered_instances) if registered_instances else 'None',
+                            'Health Check Path': tg.get('HealthCheckPath', 'N/A')
+                        }
+                    ))
+        except Exception as e:
+            logger.warning(f"Failed to scan target groups: {str(e)}")
+        
+        return resources

+ 292 - 0
backend/app/scanners/services/global_services.py

@@ -0,0 +1,292 @@
+"""
+Global Service Scanners
+
+Scans CloudFront Distributions, Route 53 Hosted Zones, ACM Certificates, and WAF Web ACLs.
+These are global services that are not region-specific.
+
+Requirements:
+    - 5.1: Scan global AWS services using boto3
+    - 5.2: Scan global resources regardless of selected regions
+"""
+
+import boto3
+from typing import List, Dict, Any
+import logging
+
+from app.scanners.base import ResourceData
+from app.scanners.utils import retry_with_backoff
+
+logger = logging.getLogger(__name__)
+
+
+class GlobalServiceScanner:
+    """Scanner for global AWS resources"""
+    
+    @staticmethod
+    @retry_with_backoff()
+    def scan_cloudfront_distributions(session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """
+        Scan CloudFront Distributions (global service).
+        
+        Attributes (vertical layout - one table per distribution):
+            CloudFront ID, Domain Name, CNAME, Origin Domain Name,
+            Origin Protocol Policy, Viewer Protocol Policy,
+            Allowed HTTP Methods, Cached HTTP Methods
+        """
+        resources = []
+        # CloudFront is a global service, always use us-east-1
+        cf_client = session.client('cloudfront', region_name='us-east-1')
+        
+        try:
+            paginator = cf_client.get_paginator('list_distributions')
+            for page in paginator.paginate():
+                distribution_list = page.get('DistributionList', {})
+                for dist in distribution_list.get('Items', []):
+                    dist_id = dist.get('Id', '')
+                    
+                    # Get aliases (CNAMEs)
+                    aliases = dist.get('Aliases', {}).get('Items', [])
+                    
+                    # Get origin info
+                    origins = dist.get('Origins', {}).get('Items', [])
+                    origin_domain = ''
+                    origin_protocol = ''
+                    if origins:
+                        origin = origins[0]
+                        origin_domain = origin.get('DomainName', '')
+                        custom_origin = origin.get('CustomOriginConfig', {})
+                        if custom_origin:
+                            origin_protocol = custom_origin.get('OriginProtocolPolicy', '')
+                        else:
+                            origin_protocol = 'S3'
+                    
+                    # Get default cache behavior
+                    default_behavior = dist.get('DefaultCacheBehavior', {})
+                    viewer_protocol = default_behavior.get('ViewerProtocolPolicy', '')
+                    allowed_methods = default_behavior.get('AllowedMethods', {}).get('Items', [])
+                    cached_methods = default_behavior.get('AllowedMethods', {}).get('CachedMethods', {}).get('Items', [])
+                    
+                    resources.append(ResourceData(
+                        account_id=account_id,
+                        region='global',
+                        service='cloudfront',
+                        resource_type='Distribution',
+                        resource_id=dist.get('ARN', dist_id),
+                        name=dist_id,
+                        attributes={
+                            'CloudFront ID': dist_id,
+                            'Domain Name': dist.get('DomainName', ''),
+                            'CNAME': ', '.join(aliases) if aliases else 'N/A',
+                            'Origin Domain Name': origin_domain,
+                            'Origin Protocol Policy': origin_protocol,
+                            'Viewer Protocol Policy': viewer_protocol,
+                            'Allowed HTTP Methods': ', '.join(allowed_methods),
+                            'Cached HTTP Methods': ', '.join(cached_methods)
+                        }
+                    ))
+        except Exception as e:
+            logger.warning(f"Failed to scan CloudFront distributions: {str(e)}")
+        
+        return resources
+    
+    @staticmethod
+    @retry_with_backoff()
+    def scan_route53_hosted_zones(session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """
+        Scan Route 53 Hosted Zones (global service).
+        
+        Attributes (horizontal layout):
+            Zone ID, Name, Type, Record Count
+        """
+        resources = []
+        # Route 53 is a global service
+        route53_client = session.client('route53', region_name='us-east-1')
+        
+        try:
+            paginator = route53_client.get_paginator('list_hosted_zones')
+            for page in paginator.paginate():
+                for zone in page.get('HostedZones', []):
+                    zone_id = zone.get('Id', '').replace('/hostedzone/', '')
+                    zone_name = zone.get('Name', '')
+                    
+                    # Determine zone type
+                    zone_type = 'Private' if zone.get('Config', {}).get('PrivateZone') else 'Public'
+                    
+                    resources.append(ResourceData(
+                        account_id=account_id,
+                        region='global',
+                        service='route53',
+                        resource_type='Hosted Zone',
+                        resource_id=zone_id,
+                        name=zone_name,
+                        attributes={
+                            'Zone ID': zone_id,
+                            'Name': zone_name,
+                            'Type': zone_type,
+                            'Record Count': str(zone.get('ResourceRecordSetCount', 0))
+                        }
+                    ))
+        except Exception as e:
+            logger.warning(f"Failed to scan Route 53 hosted zones: {str(e)}")
+        
+        return resources
+    
+    @staticmethod
+    @retry_with_backoff()
+    def scan_acm_certificates(session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """
+        Scan ACM Certificates (regional service).
+        
+        Attributes (horizontal layout): Domain name, Additional names
+        """
+        resources = []
+        # ACM is a regional service
+        acm_client = session.client('acm', region_name=region)
+        
+        try:
+            paginator = acm_client.get_paginator('list_certificates')
+            for page in paginator.paginate():
+                for cert in page.get('CertificateSummaryList', []):
+                    domain_name = cert.get('DomainName', '')
+                    cert_arn = cert.get('CertificateArn', '')
+                    
+                    # Get additional names (Subject Alternative Names)
+                    additional_names = ''
+                    try:
+                        cert_detail = acm_client.describe_certificate(CertificateArn=cert_arn)
+                        sans = cert_detail.get('Certificate', {}).get('SubjectAlternativeNames', [])
+                        # Filter out the main domain name from SANs
+                        additional = [san for san in sans if san != domain_name]
+                        additional_names = ', '.join(additional) if additional else ''
+                    except Exception:
+                        pass
+                    
+                    resources.append(ResourceData(
+                        account_id=account_id,
+                        region=region,
+                        service='acm',
+                        resource_type='Certificate',
+                        resource_id=cert_arn,
+                        name=domain_name,
+                        attributes={
+                            'Domain name': domain_name,
+                            'Additional names': additional_names
+                        }
+                    ))
+        except Exception as e:
+            logger.warning(f"Failed to scan ACM certificates in {region}: {str(e)}")
+        
+        return resources
+    
+    @staticmethod
+    @retry_with_backoff()
+    def scan_waf_web_acls(session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """
+        Scan WAF Web ACLs (global service for CloudFront).
+        
+        Attributes (horizontal layout):
+            WebACL Name, Scope, Rules Count, Associated Resources
+        """
+        resources = []
+        
+        # Scan WAFv2 global (CloudFront) Web ACLs
+        wafv2_client = session.client('wafv2', region_name='us-east-1')
+        
+        try:
+            # List CloudFront Web ACLs (CLOUDFRONT scope)
+            response = wafv2_client.list_web_acls(Scope='CLOUDFRONT')
+            
+            for acl in response.get('WebACLs', []):
+                acl_name = acl.get('Name', '')
+                acl_id = acl.get('Id', '')
+                acl_arn = acl.get('ARN', '')
+                
+                # Get Web ACL details for rules count
+                rules_count = 0
+                associated_resources = []
+                
+                try:
+                    acl_response = wafv2_client.get_web_acl(
+                        Name=acl_name,
+                        Scope='CLOUDFRONT',
+                        Id=acl_id
+                    )
+                    web_acl = acl_response.get('WebACL', {})
+                    rules_count = len(web_acl.get('Rules', []))
+                    
+                    # Get associated resources
+                    resources_response = wafv2_client.list_resources_for_web_acl(
+                        WebACLArn=acl_arn
+                    )
+                    for resource_arn in resources_response.get('ResourceArns', []):
+                        # Extract resource name from ARN
+                        resource_name = resource_arn.split('/')[-1]
+                        associated_resources.append(resource_name)
+                except Exception as e:
+                    logger.debug(f"Failed to get WAF ACL details: {str(e)}")
+                
+                resources.append(ResourceData(
+                    account_id=account_id,
+                    region='global',
+                    service='waf',
+                    resource_type='Web ACL',
+                    resource_id=acl_arn,
+                    name=acl_name,
+                    attributes={
+                        'WebACL Name': acl_name,
+                        'Scope': 'CLOUDFRONT',
+                        'Rules Count': str(rules_count),
+                        'Associated Resources': ', '.join(associated_resources) if associated_resources else 'None'
+                    }
+                ))
+        except Exception as e:
+            logger.warning(f"Failed to scan WAFv2 Web ACLs: {str(e)}")
+        
+        # Also scan regional WAF Web ACLs
+        try:
+            response = wafv2_client.list_web_acls(Scope='REGIONAL')
+            
+            for acl in response.get('WebACLs', []):
+                acl_name = acl.get('Name', '')
+                acl_id = acl.get('Id', '')
+                acl_arn = acl.get('ARN', '')
+                
+                rules_count = 0
+                associated_resources = []
+                
+                try:
+                    acl_response = wafv2_client.get_web_acl(
+                        Name=acl_name,
+                        Scope='REGIONAL',
+                        Id=acl_id
+                    )
+                    web_acl = acl_response.get('WebACL', {})
+                    rules_count = len(web_acl.get('Rules', []))
+                    
+                    resources_response = wafv2_client.list_resources_for_web_acl(
+                        WebACLArn=acl_arn
+                    )
+                    for resource_arn in resources_response.get('ResourceArns', []):
+                        resource_name = resource_arn.split('/')[-1]
+                        associated_resources.append(resource_name)
+                except Exception as e:
+                    logger.debug(f"Failed to get WAF ACL details: {str(e)}")
+                
+                resources.append(ResourceData(
+                    account_id=account_id,
+                    region='global',
+                    service='waf',
+                    resource_type='Web ACL',
+                    resource_id=acl_arn,
+                    name=acl_name,
+                    attributes={
+                        'WebACL Name': acl_name,
+                        'Scope': 'REGIONAL',
+                        'Rules Count': str(rules_count),
+                        'Associated Resources': ', '.join(associated_resources) if associated_resources else 'None'
+                    }
+                ))
+        except Exception as e:
+            logger.warning(f"Failed to scan regional WAFv2 Web ACLs: {str(e)}")
+        
+        return resources

+ 294 - 0
backend/app/scanners/services/monitoring.py

@@ -0,0 +1,294 @@
+"""
+Monitoring and Management Service Scanners
+
+Scans SNS Topics, CloudWatch Log Groups, EventBridge Rules,
+CloudTrail Trails, and Config Recorders.
+
+Requirements:
+    - 5.1: Scan monitoring and management AWS services using boto3
+"""
+
+import boto3
+from typing import List, Dict, Any
+import logging
+
+from app.scanners.base import ResourceData
+from app.scanners.utils import retry_with_backoff
+
+logger = logging.getLogger(__name__)
+
+
+class MonitoringServiceScanner:
+    """Scanner for monitoring and management AWS resources"""
+    
+    @staticmethod
+    @retry_with_backoff()
+    def scan_sns_topics(session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """
+        Scan SNS Topics in the specified region.
+        
+        Attributes (horizontal layout):
+            Topic Name, Topic Display Name, Subscription Protocol, Subscription Endpoint
+        """
+        resources = []
+        sns_client = session.client('sns')
+        
+        try:
+            paginator = sns_client.get_paginator('list_topics')
+            for page in paginator.paginate():
+                for topic in page.get('Topics', []):
+                    topic_arn = topic.get('TopicArn', '')
+                    topic_name = topic_arn.split(':')[-1] if topic_arn else ''
+                    
+                    # Get topic attributes
+                    display_name = ''
+                    try:
+                        attrs_response = sns_client.get_topic_attributes(TopicArn=topic_arn)
+                        attrs = attrs_response.get('Attributes', {})
+                        display_name = attrs.get('DisplayName', '')
+                    except Exception as e:
+                        logger.debug(f"Failed to get topic attributes: {str(e)}")
+                    
+                    # Get subscriptions
+                    subscriptions = []
+                    try:
+                        sub_paginator = sns_client.get_paginator('list_subscriptions_by_topic')
+                        for sub_page in sub_paginator.paginate(TopicArn=topic_arn):
+                            for sub in sub_page.get('Subscriptions', []):
+                                protocol = sub.get('Protocol', '')
+                                endpoint = sub.get('Endpoint', '')
+                                subscriptions.append({
+                                    'protocol': protocol,
+                                    'endpoint': endpoint
+                                })
+                    except Exception as e:
+                        logger.debug(f"Failed to get subscriptions: {str(e)}")
+                    
+                    # Create one entry per subscription, or one entry if no subscriptions
+                    if subscriptions:
+                        for sub in subscriptions:
+                            resources.append(ResourceData(
+                                account_id=account_id,
+                                region=region,
+                                service='sns',
+                                resource_type='Topic',
+                                resource_id=topic_arn,
+                                name=topic_name,
+                                attributes={
+                                    'Topic Name': topic_name,
+                                    'Topic Display Name': display_name,
+                                    'Subscription Protocol': sub['protocol'],
+                                    'Subscription Endpoint': sub['endpoint']
+                                }
+                            ))
+                    else:
+                        resources.append(ResourceData(
+                            account_id=account_id,
+                            region=region,
+                            service='sns',
+                            resource_type='Topic',
+                            resource_id=topic_arn,
+                            name=topic_name,
+                            attributes={
+                                'Topic Name': topic_name,
+                                'Topic Display Name': display_name,
+                                'Subscription Protocol': 'N/A',
+                                'Subscription Endpoint': 'N/A'
+                            }
+                        ))
+        except Exception as e:
+            logger.warning(f"Failed to scan SNS topics: {str(e)}")
+        
+        return resources
+    
+    @staticmethod
+    @retry_with_backoff()
+    def scan_cloudwatch_log_groups(session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """
+        Scan CloudWatch Log Groups in the specified region.
+        
+        Attributes (horizontal layout):
+            Log Group Name, Retention Days, Stored Bytes, KMS Encryption
+        """
+        resources = []
+        logs_client = session.client('logs')
+        
+        try:
+            paginator = logs_client.get_paginator('describe_log_groups')
+            for page in paginator.paginate():
+                for log_group in page.get('logGroups', []):
+                    log_group_name = log_group.get('logGroupName', '')
+                    
+                    # Get retention in days
+                    retention = log_group.get('retentionInDays')
+                    retention_str = str(retention) if retention else 'Never Expire'
+                    
+                    # Get stored bytes
+                    stored_bytes = log_group.get('storedBytes', 0)
+                    stored_str = f"{stored_bytes / (1024*1024):.2f} MB" if stored_bytes else '0 MB'
+                    
+                    # Check KMS encryption
+                    kms_key = log_group.get('kmsKeyId', '')
+                    kms_encrypted = 'Yes' if kms_key else 'No'
+                    
+                    resources.append(ResourceData(
+                        account_id=account_id,
+                        region=region,
+                        service='cloudwatch',
+                        resource_type='Log Group',
+                        resource_id=log_group.get('arn', log_group_name),
+                        name=log_group_name,
+                        attributes={
+                            'Log Group Name': log_group_name,
+                            'Retention Days': retention_str,
+                            'Stored Bytes': stored_str,
+                            'KMS Encryption': kms_encrypted
+                        }
+                    ))
+        except Exception as e:
+            logger.warning(f"Failed to scan CloudWatch log groups: {str(e)}")
+        
+        return resources
+    
+    @staticmethod
+    @retry_with_backoff()
+    def scan_eventbridge_rules(session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """
+        Scan EventBridge Rules in the specified region.
+        
+        Attributes (horizontal layout):
+            Name, Description, Event Bus, State
+        """
+        resources = []
+        events_client = session.client('events')
+        
+        try:
+            # List event buses first
+            buses_response = events_client.list_event_buses()
+            event_buses = [bus.get('Name', 'default') for bus in buses_response.get('EventBuses', [])]
+            
+            # If no buses found, use default
+            if not event_buses:
+                event_buses = ['default']
+            
+            for bus_name in event_buses:
+                try:
+                    paginator = events_client.get_paginator('list_rules')
+                    for page in paginator.paginate(EventBusName=bus_name):
+                        for rule in page.get('Rules', []):
+                            rule_name = rule.get('Name', '')
+                            
+                            resources.append(ResourceData(
+                                account_id=account_id,
+                                region=region,
+                                service='eventbridge',
+                                resource_type='Rule',
+                                resource_id=rule.get('Arn', rule_name),
+                                name=rule_name,
+                                attributes={
+                                    'Name': rule_name,
+                                    'Description': rule.get('Description', ''),
+                                    'Event Bus': bus_name,
+                                    'State': rule.get('State', '')
+                                }
+                            ))
+                except Exception as e:
+                    logger.debug(f"Failed to list rules for bus {bus_name}: {str(e)}")
+        except Exception as e:
+            logger.warning(f"Failed to scan EventBridge rules: {str(e)}")
+        
+        return resources
+    
+    @staticmethod
+    @retry_with_backoff()
+    def scan_cloudtrail_trails(session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """
+        Scan CloudTrail Trails in the specified region.
+        
+        Attributes (horizontal layout):
+            Name, Multi-Region Trail, Log File Validation, KMS Encryption
+        """
+        resources = []
+        cloudtrail_client = session.client('cloudtrail')
+        
+        try:
+            response = cloudtrail_client.describe_trails()
+            for trail in response.get('trailList', []):
+                trail_name = trail.get('Name', '')
+                
+                # Only include trails that are in this region or are multi-region
+                trail_region = trail.get('HomeRegion', '')
+                is_multi_region = trail.get('IsMultiRegionTrail', False)
+                
+                if trail_region != region and not is_multi_region:
+                    continue
+                
+                resources.append(ResourceData(
+                    account_id=account_id,
+                    region=region,
+                    service='cloudtrail',
+                    resource_type='Trail',
+                    resource_id=trail.get('TrailARN', trail_name),
+                    name=trail_name,
+                    attributes={
+                        'Name': trail_name,
+                        'Multi-Region Trail': 'Yes' if is_multi_region else 'No',
+                        'Log File Validation': 'Yes' if trail.get('LogFileValidationEnabled') else 'No',
+                        'KMS Encryption': 'Yes' if trail.get('KmsKeyId') else 'No'
+                    }
+                ))
+        except Exception as e:
+            logger.warning(f"Failed to scan CloudTrail trails: {str(e)}")
+        
+        return resources
+    
+    @staticmethod
+    @retry_with_backoff()
+    def scan_config_recorders(session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """
+        Scan AWS Config Recorders in the specified region.
+        
+        Attributes (horizontal layout):
+            Name, Regional Resources, Global Resources, Retention period
+        """
+        resources = []
+        config_client = session.client('config')
+        
+        try:
+            response = config_client.describe_configuration_recorders()
+            for recorder in response.get('ConfigurationRecorders', []):
+                recorder_name = recorder.get('name', '')
+                
+                # Get recording group settings
+                recording_group = recorder.get('recordingGroup', {})
+                all_supported = recording_group.get('allSupported', False)
+                include_global = recording_group.get('includeGlobalResourceTypes', False)
+                
+                # Get retention period
+                retention_period = 'N/A'
+                try:
+                    retention_response = config_client.describe_retention_configurations()
+                    for retention in retention_response.get('RetentionConfigurations', []):
+                        retention_period = f"{retention.get('RetentionPeriodInDays', 'N/A')} days"
+                        break
+                except Exception:
+                    pass
+                
+                resources.append(ResourceData(
+                    account_id=account_id,
+                    region=region,
+                    service='config',
+                    resource_type='Config',
+                    resource_id=recorder_name,
+                    name=recorder_name,
+                    attributes={
+                        'Name': recorder_name,
+                        'Regional Resources': 'Yes' if all_supported else 'No',
+                        'Global Resources': 'Yes' if include_global else 'No',
+                        'Retention period': retention_period
+                    }
+                ))
+        except Exception as e:
+            logger.warning(f"Failed to scan Config recorders: {str(e)}")
+        
+        return resources

+ 489 - 0
backend/app/scanners/services/vpc.py

@@ -0,0 +1,489 @@
+"""
+VPC Related Resource Scanners
+
+Scans VPC, Subnet, Route Table, Internet Gateway, NAT Gateway,
+Security Group, VPC Endpoint, VPC Peering, Customer Gateway,
+Virtual Private Gateway, and VPN Connection resources.
+
+Requirements:
+    - 5.1: Scan VPC-related AWS services using boto3
+"""
+
+import boto3
+from typing import List, Dict, Any
+import logging
+
+from app.scanners.base import ResourceData
+from app.scanners.utils import retry_with_backoff
+
+logger = logging.getLogger(__name__)
+
+
+class VPCServiceScanner:
+    """Scanner for VPC-related AWS resources"""
+    
+    @staticmethod
+    def _get_name_from_tags(tags: List[Dict[str, str]], default: str = '') -> str:
+        """Extract Name tag value from tags list"""
+        if not tags:
+            return default
+        for tag in tags:
+            if tag.get('Key') == 'Name':
+                return tag.get('Value', default)
+        return default
+    
+    @staticmethod
+    @retry_with_backoff()
+    def scan_vpcs(session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """
+        Scan VPCs in the specified region.
+        
+        Attributes: Region, Name, ID, CIDR
+        """
+        resources = []
+        ec2_client = session.client('ec2')
+        
+        paginator = ec2_client.get_paginator('describe_vpcs')
+        for page in paginator.paginate():
+            for vpc in page.get('Vpcs', []):
+                name = VPCServiceScanner._get_name_from_tags(vpc.get('Tags', []), vpc['VpcId'])
+                
+                resources.append(ResourceData(
+                    account_id=account_id,
+                    region=region,
+                    service='vpc',
+                    resource_type='VPC',
+                    resource_id=vpc['VpcId'],
+                    name=name,
+                    attributes={
+                        'Region': region,
+                        'Name': name,
+                        'ID': vpc['VpcId'],
+                        'CIDR': vpc.get('CidrBlock', '')
+                    }
+                ))
+        
+        return resources
+    
+    @staticmethod
+    @retry_with_backoff()
+    def scan_subnets(session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """
+        Scan Subnets in the specified region.
+        
+        Attributes: Name, ID, AZ, CIDR
+        """
+        resources = []
+        ec2_client = session.client('ec2')
+        
+        paginator = ec2_client.get_paginator('describe_subnets')
+        for page in paginator.paginate():
+            for subnet in page.get('Subnets', []):
+                name = VPCServiceScanner._get_name_from_tags(subnet.get('Tags', []), subnet['SubnetId'])
+                
+                resources.append(ResourceData(
+                    account_id=account_id,
+                    region=region,
+                    service='subnet',
+                    resource_type='Subnet',
+                    resource_id=subnet['SubnetId'],
+                    name=name,
+                    attributes={
+                        'Name': name,
+                        'ID': subnet['SubnetId'],
+                        'AZ': subnet.get('AvailabilityZone', ''),
+                        'CIDR': subnet.get('CidrBlock', '')
+                    }
+                ))
+        
+        return resources
+    
+    @staticmethod
+    @retry_with_backoff()
+    def scan_route_tables(session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """
+        Scan Route Tables in the specified region.
+        
+        Attributes: Name, ID, Subnet Associations
+        """
+        resources = []
+        ec2_client = session.client('ec2')
+        
+        paginator = ec2_client.get_paginator('describe_route_tables')
+        for page in paginator.paginate():
+            for rt in page.get('RouteTables', []):
+                name = VPCServiceScanner._get_name_from_tags(rt.get('Tags', []), rt['RouteTableId'])
+                
+                # Get subnet associations
+                associations = []
+                for assoc in rt.get('Associations', []):
+                    if assoc.get('SubnetId'):
+                        associations.append(assoc['SubnetId'])
+                
+                resources.append(ResourceData(
+                    account_id=account_id,
+                    region=region,
+                    service='route_table',
+                    resource_type='Route Table',
+                    resource_id=rt['RouteTableId'],
+                    name=name,
+                    attributes={
+                        'Name': name,
+                        'ID': rt['RouteTableId'],
+                        'Subnet Associations': ', '.join(associations) if associations else 'None'
+                    }
+                ))
+        
+        return resources
+    
+    @staticmethod
+    @retry_with_backoff()
+    def scan_internet_gateways(session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """
+        Scan Internet Gateways in the specified region.
+        
+        Attributes: Name, ID (vertical layout - one table per IGW)
+        """
+        resources = []
+        ec2_client = session.client('ec2')
+        
+        paginator = ec2_client.get_paginator('describe_internet_gateways')
+        for page in paginator.paginate():
+            for igw in page.get('InternetGateways', []):
+                igw_id = igw['InternetGatewayId']
+                name = VPCServiceScanner._get_name_from_tags(igw.get('Tags', []), igw_id)
+                
+                resources.append(ResourceData(
+                    account_id=account_id,
+                    region=region,
+                    service='internet_gateway',
+                    resource_type='Internet Gateway',
+                    resource_id=igw_id,
+                    name=name,
+                    attributes={
+                        'Name': name,
+                        'ID': igw_id
+                    }
+                ))
+        
+        return resources
+    
+    @staticmethod
+    @retry_with_backoff()
+    def scan_nat_gateways(session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """
+        Scan NAT Gateways in the specified region.
+        
+        Attributes: Name, ID, Public IP, Private IP
+        """
+        resources = []
+        ec2_client = session.client('ec2')
+        
+        paginator = ec2_client.get_paginator('describe_nat_gateways')
+        for page in paginator.paginate():
+            for nat in page.get('NatGateways', []):
+                # Skip deleted NAT gateways
+                if nat.get('State') == 'deleted':
+                    continue
+                    
+                name = VPCServiceScanner._get_name_from_tags(nat.get('Tags', []), nat['NatGatewayId'])
+                
+                # Get IP addresses from addresses
+                public_ip = ''
+                private_ip = ''
+                for addr in nat.get('NatGatewayAddresses', []):
+                    if addr.get('PublicIp'):
+                        public_ip = addr['PublicIp']
+                    if addr.get('PrivateIp'):
+                        private_ip = addr['PrivateIp']
+                
+                resources.append(ResourceData(
+                    account_id=account_id,
+                    region=region,
+                    service='nat_gateway',
+                    resource_type='NAT Gateway',
+                    resource_id=nat['NatGatewayId'],
+                    name=name,
+                    attributes={
+                        'Name': name,
+                        'ID': nat['NatGatewayId'],
+                        'Public IP': public_ip,
+                        'Private IP': private_ip
+                    }
+                ))
+        
+        return resources
+    
+    @staticmethod
+    @retry_with_backoff()
+    def scan_security_groups(session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """
+        Scan Security Groups in the specified region.
+        
+        Attributes: Name, ID, Protocol, Port range, Source
+        Note: Creates one entry per inbound rule
+        """
+        resources = []
+        ec2_client = session.client('ec2')
+        
+        paginator = ec2_client.get_paginator('describe_security_groups')
+        for page in paginator.paginate():
+            for sg in page.get('SecurityGroups', []):
+                sg_name = sg.get('GroupName', sg['GroupId'])
+                
+                # Process inbound rules
+                for rule in sg.get('IpPermissions', []):
+                    protocol = rule.get('IpProtocol', '-1')
+                    if protocol == '-1':
+                        protocol = 'All'
+                    
+                    # Get port range
+                    from_port = rule.get('FromPort', 'All')
+                    to_port = rule.get('ToPort', 'All')
+                    if from_port == to_port:
+                        port_range = str(from_port) if from_port != 'All' else 'All'
+                    else:
+                        port_range = f"{from_port}-{to_port}"
+                    
+                    # Get sources
+                    sources = []
+                    for ip_range in rule.get('IpRanges', []):
+                        sources.append(ip_range.get('CidrIp', ''))
+                    for ip_range in rule.get('Ipv6Ranges', []):
+                        sources.append(ip_range.get('CidrIpv6', ''))
+                    for group in rule.get('UserIdGroupPairs', []):
+                        sources.append(group.get('GroupId', ''))
+                    
+                    source = ', '.join(sources) if sources else 'N/A'
+                    
+                    resources.append(ResourceData(
+                        account_id=account_id,
+                        region=region,
+                        service='security_group',
+                        resource_type='Security Group',
+                        resource_id=sg['GroupId'],
+                        name=sg_name,
+                        attributes={
+                            'Name': sg_name,
+                            'ID': sg['GroupId'],
+                            'Protocol': protocol,
+                            'Port range': port_range,
+                            'Source': source
+                        }
+                    ))
+                
+                # If no inbound rules, still add the security group
+                if not sg.get('IpPermissions'):
+                    resources.append(ResourceData(
+                        account_id=account_id,
+                        region=region,
+                        service='security_group',
+                        resource_type='Security Group',
+                        resource_id=sg['GroupId'],
+                        name=sg_name,
+                        attributes={
+                            'Name': sg_name,
+                            'ID': sg['GroupId'],
+                            'Protocol': 'N/A',
+                            'Port range': 'N/A',
+                            'Source': 'N/A'
+                        }
+                    ))
+        
+        return resources
+    
+    @staticmethod
+    @retry_with_backoff()
+    def scan_vpc_endpoints(session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """
+        Scan VPC Endpoints in the specified region.
+        
+        Attributes: Name, ID, VPC, Service Name, Type
+        """
+        resources = []
+        ec2_client = session.client('ec2')
+        
+        paginator = ec2_client.get_paginator('describe_vpc_endpoints')
+        for page in paginator.paginate():
+            for endpoint in page.get('VpcEndpoints', []):
+                name = VPCServiceScanner._get_name_from_tags(endpoint.get('Tags', []), endpoint['VpcEndpointId'])
+                
+                resources.append(ResourceData(
+                    account_id=account_id,
+                    region=region,
+                    service='vpc_endpoint',
+                    resource_type='Endpoint',
+                    resource_id=endpoint['VpcEndpointId'],
+                    name=name,
+                    attributes={
+                        'Name': name,
+                        'ID': endpoint['VpcEndpointId'],
+                        'VPC': endpoint.get('VpcId', ''),
+                        'Service Name': endpoint.get('ServiceName', ''),
+                        'Type': endpoint.get('VpcEndpointType', '')
+                    }
+                ))
+        
+        return resources
+    
+    @staticmethod
+    @retry_with_backoff()
+    def scan_vpc_peering(session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """
+        Scan VPC Peering Connections in the specified region.
+        
+        Attributes: Name, Peering Connection ID, Requester VPC, Accepter VPC
+        """
+        resources = []
+        ec2_client = session.client('ec2')
+        
+        paginator = ec2_client.get_paginator('describe_vpc_peering_connections')
+        for page in paginator.paginate():
+            for peering in page.get('VpcPeeringConnections', []):
+                # Skip deleted/rejected peerings
+                status = peering.get('Status', {}).get('Code', '')
+                if status in ['deleted', 'rejected', 'failed']:
+                    continue
+                
+                name = VPCServiceScanner._get_name_from_tags(
+                    peering.get('Tags', []), 
+                    peering['VpcPeeringConnectionId']
+                )
+                
+                requester_vpc = peering.get('RequesterVpcInfo', {}).get('VpcId', '')
+                accepter_vpc = peering.get('AccepterVpcInfo', {}).get('VpcId', '')
+                
+                resources.append(ResourceData(
+                    account_id=account_id,
+                    region=region,
+                    service='vpc_peering',
+                    resource_type='VPC Peering',
+                    resource_id=peering['VpcPeeringConnectionId'],
+                    name=name,
+                    attributes={
+                        'Name': name,
+                        'Peering Connection ID': peering['VpcPeeringConnectionId'],
+                        'Requester VPC': requester_vpc,
+                        'Accepter VPC': accepter_vpc
+                    }
+                ))
+        
+        return resources
+    
+    @staticmethod
+    @retry_with_backoff()
+    def scan_customer_gateways(session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """
+        Scan Customer Gateways in the specified region.
+        
+        Attributes: Name, Customer Gateway ID, IP Address
+        """
+        resources = []
+        ec2_client = session.client('ec2')
+        
+        response = ec2_client.describe_customer_gateways()
+        for cgw in response.get('CustomerGateways', []):
+            # Skip deleted gateways
+            if cgw.get('State') == 'deleted':
+                continue
+            
+            name = VPCServiceScanner._get_name_from_tags(cgw.get('Tags', []), cgw['CustomerGatewayId'])
+            
+            resources.append(ResourceData(
+                account_id=account_id,
+                region=region,
+                service='customer_gateway',
+                resource_type='Customer Gateway',
+                resource_id=cgw['CustomerGatewayId'],
+                name=name,
+                attributes={
+                    'Name': name,
+                    'Customer Gateway ID': cgw['CustomerGatewayId'],
+                    'IP Address': cgw.get('IpAddress', '')
+                }
+            ))
+        
+        return resources
+    
+    @staticmethod
+    @retry_with_backoff()
+    def scan_virtual_private_gateways(session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """
+        Scan Virtual Private Gateways in the specified region.
+        
+        Attributes: Name, Virtual Private Gateway ID, VPC
+        """
+        resources = []
+        ec2_client = session.client('ec2')
+        
+        response = ec2_client.describe_vpn_gateways()
+        for vgw in response.get('VpnGateways', []):
+            # Skip deleted gateways
+            if vgw.get('State') == 'deleted':
+                continue
+            
+            name = VPCServiceScanner._get_name_from_tags(vgw.get('Tags', []), vgw['VpnGatewayId'])
+            
+            # Get attached VPC
+            vpc_id = ''
+            for attachment in vgw.get('VpcAttachments', []):
+                if attachment.get('State') == 'attached':
+                    vpc_id = attachment.get('VpcId', '')
+                    break
+            
+            resources.append(ResourceData(
+                account_id=account_id,
+                region=region,
+                service='virtual_private_gateway',
+                resource_type='Virtual Private Gateway',
+                resource_id=vgw['VpnGatewayId'],
+                name=name,
+                attributes={
+                    'Name': name,
+                    'Virtual Private Gateway ID': vgw['VpnGatewayId'],
+                    'VPC': vpc_id
+                }
+            ))
+        
+        return resources
+    
+    @staticmethod
+    @retry_with_backoff()
+    def scan_vpn_connections(session: boto3.Session, account_id: str, region: str) -> List[ResourceData]:
+        """
+        Scan VPN Connections in the specified region.
+        
+        Attributes: Name, VPN ID, Routes
+        """
+        resources = []
+        ec2_client = session.client('ec2')
+        
+        response = ec2_client.describe_vpn_connections()
+        for vpn in response.get('VpnConnections', []):
+            # Skip deleted connections
+            if vpn.get('State') == 'deleted':
+                continue
+            
+            name = VPCServiceScanner._get_name_from_tags(vpn.get('Tags', []), vpn['VpnConnectionId'])
+            
+            # Get routes
+            routes = []
+            for route in vpn.get('Routes', []):
+                if route.get('DestinationCidrBlock'):
+                    routes.append(route['DestinationCidrBlock'])
+            
+            resources.append(ResourceData(
+                account_id=account_id,
+                region=region,
+                service='vpn_connection',
+                resource_type='VPN Connection',
+                resource_id=vpn['VpnConnectionId'],
+                name=name,
+                attributes={
+                    'Name': name,
+                    'VPN ID': vpn['VpnConnectionId'],
+                    'Routes': ', '.join(routes) if routes else 'N/A'
+                }
+            ))
+        
+        return resources

+ 64 - 0
backend/app/scanners/utils.py

@@ -0,0 +1,64 @@
+"""
+Scanner Utilities
+
+Common utilities for AWS resource scanners including retry logic.
+"""
+
+import time
+import logging
+from functools import wraps
+from botocore.exceptions import ClientError, BotoCoreError
+
+logger = logging.getLogger(__name__)
+
+# Retry configuration
+MAX_RETRIES = 3
+BASE_DELAY = 1.0  # seconds
+MAX_DELAY = 30.0  # seconds
+EXPONENTIAL_BASE = 2.0
+
+
+def retry_with_backoff(max_retries: int = MAX_RETRIES):
+    """
+    Decorator for retrying AWS API calls with exponential backoff.
+    
+    Requirements:
+        - 5.5: Retry with exponential backoff up to 3 times
+    """
+    def decorator(func):
+        @wraps(func)
+        def wrapper(*args, **kwargs):
+            last_exception = None
+            
+            for attempt in range(max_retries):
+                try:
+                    return func(*args, **kwargs)
+                except (ClientError, BotoCoreError) as e:
+                    last_exception = e
+                    
+                    # Check if error is retryable
+                    if isinstance(e, ClientError):
+                        error_code = e.response.get('Error', {}).get('Code', '')
+                        # Non-retryable errors
+                        if error_code in ['AccessDenied', 'UnauthorizedAccess', 
+                                         'InvalidParameterValue', 'ValidationError']:
+                            raise
+                    
+                    if attempt < max_retries - 1:
+                        delay = min(
+                            BASE_DELAY * (EXPONENTIAL_BASE ** attempt),
+                            MAX_DELAY
+                        )
+                        logger.warning(
+                            f"Retry {attempt + 1}/{max_retries} for {func.__name__} "
+                            f"after {delay:.1f}s: {str(e)}"
+                        )
+                        time.sleep(delay)
+                    else:
+                        logger.error(
+                            f"All {max_retries} retries failed for {func.__name__}: {str(e)}"
+                        )
+            
+            raise last_exception
+        return wrapper
+    return decorator

+ 38 - 0
backend/app/services/__init__.py

@@ -0,0 +1,38 @@
+# Import all services for easy access
+from app.services.auth_service import (
+    AuthService,
+    login_required,
+    admin_required,
+    power_user_required,
+    get_current_user_from_context,
+    check_resource_access,
+    check_credential_access,
+    get_accessible_credentials,
+    get_accessible_reports
+)
+from app.services.report_generator import (
+    ReportGenerator,
+    TableLayout,
+    SERVICE_CONFIG,
+    SERVICE_ORDER,
+    generate_report_filename
+)
+from app.services.report_service import ReportService
+
+__all__ = [
+    'AuthService',
+    'login_required',
+    'admin_required',
+    'power_user_required',
+    'get_current_user_from_context',
+    'check_resource_access',
+    'check_credential_access',
+    'get_accessible_credentials',
+    'get_accessible_reports',
+    'ReportGenerator',
+    'TableLayout',
+    'SERVICE_CONFIG',
+    'SERVICE_ORDER',
+    'generate_report_filename',
+    'ReportService'
+]

+ 410 - 0
backend/app/services/auth_service.py

@@ -0,0 +1,410 @@
+"""
+JWT Authentication Service
+
+Provides JWT token generation, validation, and authentication decorators.
+"""
+import jwt
+from datetime import datetime, timedelta, timezone
+from functools import wraps
+from typing import Optional, Tuple, Dict, Any
+
+from flask import request, current_app, g
+
+from app import db
+from app.models import User
+from app.errors import AuthenticationError, AuthorizationError
+
+
+class AuthService:
+    """Service for handling JWT authentication"""
+    
+    @staticmethod
+    def generate_access_token(user: User) -> str:
+        """Generate a JWT access token for a user"""
+        now = datetime.now(timezone.utc)
+        expires = now + current_app.config['JWT_ACCESS_TOKEN_EXPIRES']
+        payload = {
+            'user_id': user.id,
+            'username': user.username,
+            'role': user.role,
+            'type': 'access',
+            'exp': expires,
+            'iat': now
+        }
+        return jwt.encode(
+            payload,
+            current_app.config['JWT_SECRET_KEY'],
+            algorithm='HS256'
+        )
+    
+    @staticmethod
+    def generate_refresh_token(user: User) -> str:
+        """Generate a JWT refresh token for a user"""
+        now = datetime.now(timezone.utc)
+        expires = now + current_app.config['JWT_REFRESH_TOKEN_EXPIRES']
+        payload = {
+            'user_id': user.id,
+            'type': 'refresh',
+            'exp': expires,
+            'iat': now
+        }
+        return jwt.encode(
+            payload,
+            current_app.config['JWT_SECRET_KEY'],
+            algorithm='HS256'
+        )
+    
+    @staticmethod
+    def generate_tokens(user: User) -> Dict[str, str]:
+        """Generate both access and refresh tokens"""
+        return {
+            'access_token': AuthService.generate_access_token(user),
+            'refresh_token': AuthService.generate_refresh_token(user)
+        }
+    
+    @staticmethod
+    def decode_token(token: str) -> Dict[str, Any]:
+        """
+        Decode and validate a JWT token
+        
+        Raises:
+            AuthenticationError: If token is invalid or expired
+        """
+        try:
+            payload = jwt.decode(
+                token,
+                current_app.config['JWT_SECRET_KEY'],
+                algorithms=['HS256']
+            )
+            return payload
+        except jwt.ExpiredSignatureError:
+            raise AuthenticationError(
+                message="Token has expired",
+                details={"reason": "token_expired"}
+            )
+        except jwt.InvalidTokenError as e:
+            raise AuthenticationError(
+                message="Invalid token",
+                details={"reason": "invalid_token"}
+            )
+    
+    @staticmethod
+    def get_token_from_header() -> Optional[str]:
+        """Extract JWT token from Authorization header"""
+        auth_header = request.headers.get('Authorization', '')
+        if auth_header.startswith('Bearer '):
+            return auth_header[7:]
+        return None
+    
+    @staticmethod
+    def get_current_user() -> Optional[User]:
+        """Get the current authenticated user from the request"""
+        token = AuthService.get_token_from_header()
+        if not token:
+            return None
+        
+        try:
+            payload = AuthService.decode_token(token)
+            if payload.get('type') != 'access':
+                return None
+            
+            user = db.session.get(User, payload['user_id'])
+            if user and user.is_active:
+                return user
+            return None
+        except AuthenticationError:
+            return None
+    
+    @staticmethod
+    def authenticate(username: str, password: str) -> Tuple[User, Dict[str, str]]:
+        """
+        Authenticate a user with username and password
+        
+        Returns:
+            Tuple of (User, tokens dict)
+            
+        Raises:
+            AuthenticationError: If credentials are invalid
+        """
+        user = User.query.filter_by(username=username).first()
+        
+        if not user or not user.check_password(password):
+            raise AuthenticationError(
+                message="Invalid username or password",
+                details={"reason": "invalid_credentials"}
+            )
+        
+        if not user.is_active:
+            raise AuthenticationError(
+                message="User account is disabled",
+                details={"reason": "account_disabled"}
+            )
+        
+        tokens = AuthService.generate_tokens(user)
+        return user, tokens
+    
+    @staticmethod
+    def refresh_access_token(refresh_token: str) -> Dict[str, str]:
+        """
+        Generate a new access token using a refresh token
+        
+        Returns:
+            Dict with new access_token
+            
+        Raises:
+            AuthenticationError: If refresh token is invalid
+        """
+        payload = AuthService.decode_token(refresh_token)
+        
+        if payload.get('type') != 'refresh':
+            raise AuthenticationError(
+                message="Invalid token type",
+                details={"reason": "not_refresh_token"}
+            )
+        
+        user = db.session.get(User, payload['user_id'])
+        if not user or not user.is_active:
+            raise AuthenticationError(
+                message="User not found or inactive",
+                details={"reason": "user_invalid"}
+            )
+        
+        return {
+            'access_token': AuthService.generate_access_token(user)
+        }
+
+
+def login_required(f):
+    """Decorator to require authentication for a route"""
+    @wraps(f)
+    def decorated_function(*args, **kwargs):
+        token = AuthService.get_token_from_header()
+        
+        if not token:
+            raise AuthenticationError(
+                message="Authentication required",
+                details={"reason": "missing_token"}
+            )
+        
+        payload = AuthService.decode_token(token)
+        
+        if payload.get('type') != 'access':
+            raise AuthenticationError(
+                message="Invalid token type",
+                details={"reason": "not_access_token"}
+            )
+        
+        user = db.session.get(User, payload['user_id'])
+        if not user:
+            raise AuthenticationError(
+                message="User not found",
+                details={"reason": "user_not_found"}
+            )
+        
+        if not user.is_active:
+            raise AuthenticationError(
+                message="User account is disabled",
+                details={"reason": "account_disabled"}
+            )
+        
+        # Store user in flask g object for access in route
+        g.current_user = user
+        return f(*args, **kwargs)
+    
+    return decorated_function
+
+
+def get_current_user_from_context() -> User:
+    """Get the current user from Flask g context (use after login_required)"""
+    return getattr(g, 'current_user', None)
+
+
+def admin_required(f):
+    """
+    Decorator to require admin role for a route.
+    Must be used after @login_required.
+    """
+    @wraps(f)
+    def decorated_function(*args, **kwargs):
+        token = AuthService.get_token_from_header()
+        
+        if not token:
+            raise AuthenticationError(
+                message="Authentication required",
+                details={"reason": "missing_token"}
+            )
+        
+        payload = AuthService.decode_token(token)
+        
+        if payload.get('type') != 'access':
+            raise AuthenticationError(
+                message="Invalid token type",
+                details={"reason": "not_access_token"}
+            )
+        
+        user = db.session.get(User, payload['user_id'])
+        if not user:
+            raise AuthenticationError(
+                message="User not found",
+                details={"reason": "user_not_found"}
+            )
+        
+        if not user.is_active:
+            raise AuthenticationError(
+                message="User account is disabled",
+                details={"reason": "account_disabled"}
+            )
+        
+        if user.role != 'admin':
+            raise AuthorizationError(
+                message="Admin access required",
+                details={"reason": "insufficient_permissions", "required_role": "admin"}
+            )
+        
+        g.current_user = user
+        return f(*args, **kwargs)
+    
+    return decorated_function
+
+
+def power_user_required(f):
+    """
+    Decorator to require power_user or admin role for a route.
+    Must be used after @login_required.
+    """
+    @wraps(f)
+    def decorated_function(*args, **kwargs):
+        token = AuthService.get_token_from_header()
+        
+        if not token:
+            raise AuthenticationError(
+                message="Authentication required",
+                details={"reason": "missing_token"}
+            )
+        
+        payload = AuthService.decode_token(token)
+        
+        if payload.get('type') != 'access':
+            raise AuthenticationError(
+                message="Invalid token type",
+                details={"reason": "not_access_token"}
+            )
+        
+        user = db.session.get(User, payload['user_id'])
+        if not user:
+            raise AuthenticationError(
+                message="User not found",
+                details={"reason": "user_not_found"}
+            )
+        
+        if not user.is_active:
+            raise AuthenticationError(
+                message="User account is disabled",
+                details={"reason": "account_disabled"}
+            )
+        
+        if user.role not in ['admin', 'power_user']:
+            raise AuthorizationError(
+                message="Power user or admin access required",
+                details={"reason": "insufficient_permissions", "required_role": "power_user"}
+            )
+        
+        g.current_user = user
+        return f(*args, **kwargs)
+    
+    return decorated_function
+
+
+def check_resource_access(user: User, resource_owner_id: int) -> bool:
+    """
+    Check if a user has access to a resource based on ownership.
+    
+    - Admin: Can access all resources
+    - Power User: Can access all resources
+    - User: Can only access their own resources
+    
+    Args:
+        user: The current user
+        resource_owner_id: The ID of the resource owner
+        
+    Returns:
+        True if access is allowed, False otherwise
+    """
+    if user.role in ['admin', 'power_user']:
+        return True
+    return user.id == resource_owner_id
+
+
+def check_credential_access(user: User, credential_id: int) -> bool:
+    """
+    Check if a user has access to a specific credential.
+    
+    - Admin: Can access all credentials
+    - Power User: Can access all credentials
+    - User: Can only access assigned credentials
+    
+    Args:
+        user: The current user
+        credential_id: The ID of the credential
+        
+    Returns:
+        True if access is allowed, False otherwise
+    """
+    if user.role in ['admin', 'power_user']:
+        return True
+    
+    # Check if credential is assigned to user
+    from app.models import UserCredential
+    assignment = UserCredential.query.filter_by(
+        user_id=user.id,
+        credential_id=credential_id
+    ).first()
+    
+    return assignment is not None
+
+
+def get_accessible_credentials(user: User):
+    """
+    Get list of credentials accessible to a user.
+    
+    - Admin/Power User: All credentials
+    - User: Only assigned credentials
+    
+    Args:
+        user: The current user
+        
+    Returns:
+        Query for accessible credentials
+    """
+    from app.models import AWSCredential, UserCredential
+    
+    if user.role in ['admin', 'power_user']:
+        return AWSCredential.query.filter_by(is_active=True)
+    
+    # Get only assigned credentials for regular users
+    return AWSCredential.query.join(UserCredential).filter(
+        UserCredential.user_id == user.id,
+        AWSCredential.is_active == True
+    )
+
+
+def get_accessible_reports(user: User):
+    """
+    Get list of reports accessible to a user.
+    
+    - Admin/Power User: All reports
+    - User: Only their own reports
+    
+    Args:
+        user: The current user
+        
+    Returns:
+        Query for accessible reports
+    """
+    from app.models import Report, Task
+    
+    if user.role in ['admin', 'power_user']:
+        return Report.query
+    
+    # Get only reports from user's own tasks
+    return Report.query.join(Task).filter(Task.created_by == user.id)

+ 1941 - 0
backend/app/services/report_generator.py

@@ -0,0 +1,1941 @@
+"""
+Report Generator Service
+
+This module handles Word document generation from AWS scan results.
+It loads templates, replaces placeholders, generates tables, and produces
+the final report document.
+"""
+
+import os
+import re
+import copy
+from datetime import datetime
+from typing import Dict, List, Any, Optional, Tuple
+from docx import Document
+from docx.shared import Inches, Pt, Cm
+from docx.enum.text import WD_ALIGN_PARAGRAPH
+from docx.enum.table import WD_TABLE_ALIGNMENT
+from docx.oxml.ns import qn
+from docx.oxml import OxmlElement
+
+
+class TableLayout:
+    """Table layout types for different services"""
+    HORIZONTAL = 'horizontal'  # Column headers at top, multiple rows
+    VERTICAL = 'vertical'      # Attribute names in left column, values in right
+
+
+# Service configuration matching the design document
+SERVICE_CONFIG = {
+    # ===== VPC Related Resources =====
+    'vpc': {
+        'layout': TableLayout.HORIZONTAL,
+        'title': 'VPC',
+        'columns': ['Region', 'Name', 'ID', 'CIDR'],
+    },
+    'subnet': {
+        'layout': TableLayout.HORIZONTAL,
+        'title': 'Subnet',
+        'columns': ['Name', 'ID', 'AZ', 'CIDR'],
+    },
+    'route_table': {
+        'layout': TableLayout.HORIZONTAL,
+        'title': 'Route Table',
+        'columns': ['Name', 'ID', 'Subnet Associations'],
+    },
+    'internet_gateway': {
+        'layout': TableLayout.HORIZONTAL,
+        'title': 'Internet Gateway',
+        'columns': ['Name', 'ID'],
+    },
+    'nat_gateway': {
+        'layout': TableLayout.HORIZONTAL,
+        'title': 'NAT Gateway',
+        'columns': ['Name', 'ID', 'Public IP', 'Private IP'],
+    },
+    'security_group': {
+        'layout': TableLayout.HORIZONTAL,
+        'title': 'Security Group',
+        'columns': ['Name', 'ID', 'Protocol', 'Port range', 'Source'],
+    },
+    'vpc_endpoint': {
+        'layout': TableLayout.HORIZONTAL,
+        'title': 'Endpoint',
+        'columns': ['Name', 'ID', 'VPC', 'Service Name', 'Type'],
+    },
+    'vpc_peering': {
+        'layout': TableLayout.HORIZONTAL,
+        'title': 'VPC Peering',
+        'columns': ['Name', 'Peering Connection ID', 'Requester VPC', 'Accepter VPC'],
+    },
+    'customer_gateway': {
+        'layout': TableLayout.HORIZONTAL,
+        'title': 'Customer Gateway',
+        'columns': ['Name', 'Customer Gateway ID', 'IP Address'],
+    },
+    'virtual_private_gateway': {
+        'layout': TableLayout.HORIZONTAL,
+        'title': 'Virtual Private Gateway',
+        'columns': ['Name', 'Virtual Private Gateway ID', 'VPC'],
+    },
+    'vpn_connection': {
+        'layout': TableLayout.HORIZONTAL,
+        'title': 'VPN Connection',
+        'columns': ['Name', 'VPN ID', 'Routes'],
+    },
+    
+    # ===== EC2 Related Resources =====
+    'ec2': {
+        'layout': TableLayout.VERTICAL,
+        'title': 'Instance',
+        'columns': ['Name', 'Instance ID', 'Instance Type', 'AZ', 'AMI',
+                   'Public IP', 'Public DNS', 'Private IP', 'VPC ID', 'Subnet ID',
+                   'Key', 'Security Groups', 'EBS Type', 'EBS Size', 'Encryption',
+                   'Other Requirement'],
+    },
+    'elastic_ip': {
+        'layout': TableLayout.HORIZONTAL,
+        'title': 'Elastic IP',
+        'columns': ['Name', 'Elastic IP'],
+    },
+    
+    # ===== Auto Scaling =====
+    'autoscaling': {
+        'layout': TableLayout.VERTICAL,
+        'title': 'Auto Scaling Group',
+        'columns': ['Name', 'Launch Template', 'AMI', 'Instance type',
+                   'Key', 'Target Groups', 'Desired', 'Min', 'Max',
+                   'Scaling Policy'],
+    },
+    
+    # ===== ELB Related Resources =====
+    'elb': {
+        'layout': TableLayout.VERTICAL,
+        'title': 'Load Balancer',
+        'columns': ['Name', 'Type', 'DNS', 'Scheme', 'VPC',
+                   'Availability Zones', 'Subnet', 'Security Groups'],
+    },
+    'target_group': {
+        'layout': TableLayout.VERTICAL,
+        'title': 'Target Group',
+        'columns': ['Load Balancer', 'TG Name', 'Port', 'Protocol',
+                   'Registered Instances', 'Health Check Path'],
+    },
+    
+    # ===== RDS =====
+    'rds': {
+        'layout': TableLayout.VERTICAL,
+        'title': 'DB Instance',
+        'columns': ['Region', 'Endpoint', 'DB instance ID', 'DB name',
+                   'Master Username', 'Port', 'DB Engine', 'DB Version',
+                   'Instance Type', 'Storage type', 'Storage', 'Multi-AZ',
+                   'Security Group', 'Deletion Protection',
+                   'Performance Insights Enabled', 'CloudWatch Logs'],
+    },
+    
+    # ===== ElastiCache =====
+    'elasticache': {
+        'layout': TableLayout.VERTICAL,
+        'title': 'Cache Cluster',
+        'columns': ['Cluster ID', 'Engine', 'Engine Version', 'Node Type',
+                   'Num Nodes', 'Status'],
+    },
+    
+    # ===== EKS =====
+    'eks': {
+        'layout': TableLayout.VERTICAL,
+        'title': 'Cluster',
+        'columns': ['Cluster Name', 'Version', 'Status', 'Endpoint', 'VPC ID'],
+    },
+    
+    # ===== Lambda =====
+    'lambda': {
+        'layout': TableLayout.HORIZONTAL,
+        'title': 'Function',
+        'columns': ['Function Name', 'Runtime', 'Memory (MB)', 'Timeout (s)', 'Last Modified'],
+    },
+    
+    # ===== S3 =====
+    's3': {
+        'layout': TableLayout.HORIZONTAL,
+        'title': 'Bucket',
+        'columns': ['Region', 'Bucket Name'],
+    },
+    's3_event_notification': {
+        'layout': TableLayout.VERTICAL,
+        'title': 'S3 event notification',
+        'columns': ['Bucket', 'Name', 'Event Type', 'Destination type', 'Destination'],
+    },
+    
+    # ===== CloudFront (Global) =====
+    'cloudfront': {
+        'layout': TableLayout.VERTICAL,
+        'title': 'Distribution',
+        'columns': ['CloudFront ID', 'Domain Name', 'CNAME',
+                   'Origin Domain Name', 'Origin Protocol Policy',
+                   'Viewer Protocol Policy', 'Allowed HTTP Methods',
+                   'Cached HTTP Methods'],
+    },
+    
+    # ===== Route 53 (Global) =====
+    'route53': {
+        'layout': TableLayout.HORIZONTAL,
+        'title': 'Hosted Zone',
+        'columns': ['Zone ID', 'Name', 'Type', 'Record Count'],
+    },
+    
+    # ===== ACM (Global) =====
+    'acm': {
+        'layout': TableLayout.HORIZONTAL,
+        'title': 'ACM',
+        'columns': ['Domain name', 'Additional names'],
+    },
+    
+    # ===== WAF (Global) =====
+    'waf': {
+        'layout': TableLayout.HORIZONTAL,
+        'title': 'Web ACL',
+        'columns': ['WebACL Name', 'Scope', 'Rules Count', 'Associated Resources'],
+    },
+    
+    # ===== SNS =====
+    'sns': {
+        'layout': TableLayout.HORIZONTAL,
+        'title': 'Topic',
+        'columns': ['Topic Name', 'Topic Display Name', 'Subscription Protocol',
+                   'Subscription Endpoint'],
+    },
+    
+    # ===== CloudWatch =====
+    'cloudwatch': {
+        'layout': TableLayout.HORIZONTAL,
+        'title': 'Log Group',
+        'columns': ['Log Group Name', 'Retention Days', 'Stored Bytes', 'KMS Encryption'],
+    },
+    
+    # ===== EventBridge =====
+    'eventbridge': {
+        'layout': TableLayout.HORIZONTAL,
+        'title': 'Rule',
+        'columns': ['Name', 'Description', 'Event Bus', 'State'],
+    },
+    
+    # ===== CloudTrail =====
+    'cloudtrail': {
+        'layout': TableLayout.HORIZONTAL,
+        'title': 'Trail',
+        'columns': ['Name', 'Multi-Region Trail', 'Log File Validation', 'KMS Encryption'],
+    },
+    
+    # ===== Config =====
+    'config': {
+        'layout': TableLayout.HORIZONTAL,
+        'title': 'Config',
+        'columns': ['Name', 'Regional Resources', 'Global Resources', 'Retention period'],
+    },
+}
+
+
+# Service display order for the report
+SERVICE_ORDER = [
+    'vpc', 'subnet', 'route_table', 'internet_gateway', 'nat_gateway',
+    'security_group', 'vpc_endpoint', 'vpc_peering',
+    'customer_gateway', 'virtual_private_gateway', 'vpn_connection',
+    'ec2', 'elastic_ip', 'autoscaling',
+    'elb', 'target_group',
+    'rds', 'elasticache', 'eks',
+    'lambda', 's3', 's3_event_notification',
+    'cloudfront', 'route53', 'acm', 'waf',
+    'sns', 'cloudwatch', 'eventbridge', 'cloudtrail', 'config'
+]
+
+# Global services (not region-specific, should not be duplicated per region)
+GLOBAL_SERVICES = ['cloudfront', 'route53', 'waf', 's3', 's3_event_notification', 'cloudtrail']
+
+# Service grouping for Heading 2 titles
+# Maps service keys to their parent service group for the heading
+SERVICE_GROUPS = {
+    # VPC group - all VPC related resources under "VPC" heading
+    'vpc': 'VPC',
+    'subnet': 'VPC',
+    'route_table': 'VPC',
+    'internet_gateway': 'VPC',
+    'nat_gateway': 'VPC',
+    'security_group': 'VPC',
+    'vpc_endpoint': 'VPC',
+    'vpc_peering': 'VPC',
+    'customer_gateway': 'VPC',
+    'virtual_private_gateway': 'VPC',
+    'vpn_connection': 'VPC',
+    
+    # EC2 group
+    'ec2': 'EC2',
+    'elastic_ip': 'EC2',
+    
+    # Auto Scaling
+    'autoscaling': 'AutoScaling',
+    
+    # ELB group - Load Balancer and Target Group under "ELB" heading
+    'elb': 'ELB',
+    'target_group': 'ELB',
+    
+    # Database services - use service name as heading
+    'rds': 'RDS',
+    'elasticache': 'Elasticache',
+    'eks': 'EKS',
+    
+    # Lambda
+    'lambda': 'Lambda',
+    
+    # S3 group - Bucket and event notification under "S3" heading
+    's3': 'S3',
+    's3_event_notification': 'S3',
+    
+    # Global services
+    'cloudfront': 'CloudFront',
+    'route53': 'Route53',
+    'acm': 'ACM',
+    'waf': 'WAF',
+    
+    # Monitoring services
+    'sns': 'SNS',
+    'cloudwatch': 'CloudWatch',
+    'eventbridge': 'EventBridge',
+    'cloudtrail': 'CloudTrail',
+    'config': 'Config',
+}
+
+# Order of service groups for the report (determines heading order)
+SERVICE_GROUP_ORDER = [
+    'VPC', 'EC2', 'AutoScaling', 'ELB',
+    'RDS', 'Elasticache', 'EKS', 'Lambda', 'S3',
+    'CloudFront', 'Route53', 'ACM', 'WAF',
+    'SNS', 'CloudWatch', 'EventBridge', 'CloudTrail', 'Config'
+]
+
+
+class ReportGenerator:
+    """
+    Generates Word reports from AWS scan results using templates.
+    
+    This class handles:
+    - Loading Word templates from sample-reports folder
+    - Parsing and replacing placeholders
+    - Generating horizontal and vertical tables for different services
+    - Embedding network diagrams
+    - Updating table of contents
+    """
+    
+    def __init__(self, template_path: str = None):
+        """
+        Initialize the report generator.
+        
+        Args:
+            template_path: Path to the Word template file. If None, uses default template.
+        """
+        self.template_path = template_path
+        self.document = None
+        self._placeholder_pattern = re.compile(r'\[([^\]]+)\]')
+    
+    def load_template(self, template_path: str = None) -> Document:
+        """
+        Load a Word template file.
+        
+        Args:
+            template_path: Path to the template file
+            
+        Returns:
+            Loaded Document object
+            
+        Raises:
+            FileNotFoundError: If template file doesn't exist
+            ValueError: If template file is invalid
+        """
+        path = template_path or self.template_path
+        if not path:
+            # Use default template
+            path = self._get_default_template_path()
+        
+        if not os.path.exists(path):
+            raise FileNotFoundError(f"Template file not found: {path}")
+        
+        try:
+            self.document = Document(path)
+            return self.document
+        except Exception as e:
+            raise ValueError(f"Failed to load template: {str(e)}")
+    
+    def _get_default_template_path(self) -> str:
+        """Get the default template path from sample-reports folder."""
+        # Look for the template with placeholders
+        base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
+        sample_reports_dir = os.path.join(base_dir, 'sample-reports')
+        
+        # Prefer the template with [Client Name]-[Project Name] format
+        template_name = '[Client Name]-[Project Name]-Project-Report-v1.0.docx'
+        template_path = os.path.join(sample_reports_dir, template_name)
+        
+        if os.path.exists(template_path):
+            return template_path
+        
+        # Fall back to any .docx file in sample-reports
+        if os.path.exists(sample_reports_dir):
+            for file in os.listdir(sample_reports_dir):
+                if file.endswith('.docx'):
+                    return os.path.join(sample_reports_dir, file)
+        
+        raise FileNotFoundError("No template file found in sample-reports folder")
+    
+    def find_placeholders(self) -> List[str]:
+        """
+        Find all placeholders in the document.
+        
+        Returns:
+            List of placeholder names (without brackets)
+        """
+        if not self.document:
+            raise ValueError("No document loaded. Call load_template() first.")
+        
+        placeholders = set()
+        
+        # Search in paragraphs
+        for paragraph in self.document.paragraphs:
+            matches = self._placeholder_pattern.findall(paragraph.text)
+            placeholders.update(matches)
+        
+        # Search in tables
+        for table in self.document.tables:
+            for row in table.rows:
+                for cell in row.cells:
+                    for paragraph in cell.paragraphs:
+                        matches = self._placeholder_pattern.findall(paragraph.text)
+                        placeholders.update(matches)
+        
+        # Search in headers and footers
+        for section in self.document.sections:
+            for header in [section.header, section.first_page_header, section.even_page_header]:
+                if header:
+                    for paragraph in header.paragraphs:
+                        matches = self._placeholder_pattern.findall(paragraph.text)
+                        placeholders.update(matches)
+            
+            for footer in [section.footer, section.first_page_footer, section.even_page_footer]:
+                if footer:
+                    for paragraph in footer.paragraphs:
+                        matches = self._placeholder_pattern.findall(paragraph.text)
+                        placeholders.update(matches)
+        
+        return list(placeholders)
+    
+    def get_template_structure(self) -> Dict[str, Any]:
+        """
+        Analyze and return the template structure.
+        
+        Returns:
+            Dictionary containing template structure information
+        """
+        if not self.document:
+            raise ValueError("No document loaded. Call load_template() first.")
+        
+        structure = {
+            'sections': len(self.document.sections),
+            'paragraphs': len(self.document.paragraphs),
+            'tables': len(self.document.tables),
+            'placeholders': self.find_placeholders(),
+            'headings': [],
+        }
+        
+        # Extract headings
+        for paragraph in self.document.paragraphs:
+            if paragraph.style and paragraph.style.name.startswith('Heading'):
+                structure['headings'].append({
+                    'level': paragraph.style.name,
+                    'text': paragraph.text
+                })
+        
+        return structure
+
+    
+    def replace_placeholders(self, replacements: Dict[str, str]) -> None:
+        """
+        Replace all placeholders in the document with actual values.
+        
+        Args:
+            replacements: Dictionary mapping placeholder names to values
+                         e.g., {'Client Name': 'Acme Corp', 'Project Name': 'Cloud Migration'}
+        """
+        if not self.document:
+            raise ValueError("No document loaded. Call load_template() first.")
+        
+        # Replace in paragraphs
+        for paragraph in self.document.paragraphs:
+            self._replace_in_paragraph(paragraph, replacements)
+        
+        # Replace in tables
+        for table in self.document.tables:
+            for row in table.rows:
+                for cell in row.cells:
+                    for paragraph in cell.paragraphs:
+                        self._replace_in_paragraph(paragraph, replacements)
+        
+        # Replace in headers and footers
+        for section in self.document.sections:
+            for header in [section.header, section.first_page_header, section.even_page_header]:
+                if header:
+                    for paragraph in header.paragraphs:
+                        self._replace_in_paragraph(paragraph, replacements)
+            
+            for footer in [section.footer, section.first_page_footer, section.even_page_footer]:
+                if footer:
+                    for paragraph in footer.paragraphs:
+                        self._replace_in_paragraph(paragraph, replacements)
+    
+    def _replace_in_paragraph(self, paragraph, replacements: Dict[str, str]) -> None:
+        """
+        Replace placeholders in a single paragraph while preserving formatting.
+        
+        Supports both bracketed placeholders like [Client Name] and 
+        unbracketed placeholders like YYYY. mm. DD.
+        
+        Args:
+            paragraph: The paragraph to process
+            replacements: Dictionary of placeholder replacements
+        """
+        if not paragraph.text:
+            return
+        
+        # Check if paragraph contains any placeholders (bracketed or unbracketed)
+        text = paragraph.text
+        has_placeholder = False
+        for placeholder in replacements.keys():
+            # Check for bracketed placeholder [placeholder]
+            if f'[{placeholder}]' in text:
+                has_placeholder = True
+                break
+            # Check for unbracketed placeholder (for date formats like YYYY. mm. DD)
+            if placeholder in text:
+                has_placeholder = True
+                break
+        
+        if not has_placeholder:
+            return
+        
+        # Replace placeholders in the text
+        new_text = text
+        for placeholder, value in replacements.items():
+            # First try bracketed replacement
+            new_text = new_text.replace(f'[{placeholder}]', str(value) if value else '')
+            # Then try unbracketed replacement (for date formats like YYYY. mm. DD)
+            # Only replace patterns that start with YYYY to avoid replacing column names like "Date"
+            if placeholder.startswith('YYYY'):
+                new_text = new_text.replace(placeholder, str(value) if value else '')
+        
+        # If text changed, update the paragraph
+        if new_text != text:
+            # Try to preserve formatting by updating runs
+            if len(paragraph.runs) == 1:
+                paragraph.runs[0].text = new_text
+            else:
+                # For complex formatting, rebuild the paragraph
+                # Store the first run's formatting
+                if paragraph.runs:
+                    first_run = paragraph.runs[0]
+                    font_name = first_run.font.name
+                    font_size = first_run.font.size
+                    bold = first_run.font.bold
+                    italic = first_run.font.italic
+                    
+                    # Clear all runs
+                    for run in paragraph.runs:
+                        run.text = ''
+                    
+                    # Set new text on first run
+                    paragraph.runs[0].text = new_text
+                else:
+                    # No runs, add new one
+                    paragraph.add_run(new_text)
+    
+    def create_project_metadata_replacements(self, metadata: Dict[str, Any]) -> Dict[str, str]:
+        """
+        Create placeholder replacements from project metadata.
+        
+        Args:
+            metadata: Project metadata dictionary containing:
+                     - clientName/client_name, projectName/project_name
+                     - bdManager/bd_manager, bdManagerEmail/bd_manager_email
+                     - solutionsArchitect/solutions_architect, solutionsArchitectEmail/solutions_architect_email
+                     - cloudEngineer/cloud_engineer, cloudEngineerEmail/cloud_engineer_email
+        
+        Returns:
+            Dictionary of placeholder replacements
+        """
+        now = datetime.now()
+        
+        # Helper to get value from either camelCase or snake_case key
+        def get_value(camel_key: str, snake_key: str) -> str:
+            return metadata.get(camel_key, '') or metadata.get(snake_key, '') or ''
+        
+        # Extract values supporting both naming conventions
+        client_name = get_value('clientName', 'client_name')
+        project_name = get_value('projectName', 'project_name')
+        bd_manager = get_value('bdManager', 'bd_manager')
+        bd_manager_email = get_value('bdManagerEmail', 'bd_manager_email')
+        solutions_architect = get_value('solutionsArchitect', 'solutions_architect')
+        solutions_architect_email = get_value('solutionsArchitectEmail', 'solutions_architect_email')
+        cloud_engineer = get_value('cloudEngineer', 'cloud_engineer')
+        cloud_engineer_email = get_value('cloudEngineerEmail', 'cloud_engineer_email')
+        
+        replacements = {
+            # Client and Project
+            'Client Name': client_name,
+            'Project Name': project_name,
+            
+            # BD Manager
+            'BD Manager': bd_manager,
+            'BD Manager Name': bd_manager,
+            'BD Manager Email': bd_manager_email,
+            
+            # Solutions Architect
+            'Solutions Architect': solutions_architect,
+            'Solutions Architect Name': solutions_architect,
+            'Solutions Architect Email': solutions_architect_email,
+            
+            # Cloud Engineer
+            'Cloud Engineer': cloud_engineer,
+            'Cloud Engineer Name': cloud_engineer,
+            'Cloud Engineer Email': cloud_engineer_email,
+            
+            # Date placeholders - multiple formats
+            'Date': now.strftime('%Y-%m-%d'),
+            'YYYY. mm. DD': now.strftime('%Y. %m. %d'),
+            'YYYY.mm.DD': now.strftime('%Y.%m.%d'),
+            'YYYY-mm-DD': now.strftime('%Y-%m-%d'),
+            'Month': now.strftime('%B'),
+            'Year': str(now.year),
+            'Report Date': now.strftime('%B %d, %Y'),
+            
+            # Version info
+            'Version': '1.0',
+            'Document Version': '1.0',
+        }
+        
+        return replacements
+
+    
+    def add_horizontal_table(self, service_key: str, resources: List[Dict[str, Any]], 
+                            include_account_column: bool = False) -> None:
+        """
+        Add a horizontal table for a service (column headers at top, multiple rows).
+        
+        Format:
+        | Service Name (merged across all columns) |
+        | Column1 | Column2 | Column3 |
+        | Value1  | Value2  | Value3  |
+        
+        Args:
+            service_key: The service key from SERVICE_CONFIG
+            resources: List of resource dictionaries
+            include_account_column: Whether to include AWS Account column (for multi-account)
+        """
+        if not self.document:
+            raise ValueError("No document loaded. Call load_template() first.")
+        
+        if service_key not in SERVICE_CONFIG:
+            raise ValueError(f"Unknown service: {service_key}")
+        
+        config = SERVICE_CONFIG[service_key]
+        if config['layout'] != TableLayout.HORIZONTAL:
+            raise ValueError(f"Service {service_key} uses vertical layout, not horizontal")
+        
+        columns = list(config['columns'])
+        if include_account_column and 'AWS Account' not in columns:
+            columns.insert(0, 'AWS Account')
+        
+        # Create table: 1 title row + 1 header row + data rows
+        num_rows = len(resources) + 2  # +1 for title, +1 for header
+        num_cols = len(columns)
+        table = self.document.add_table(rows=num_rows, cols=num_cols)
+        
+        # Apply table styling
+        self._copy_table_style_from_template(table)
+        
+        # Row 0: Service title (merged across all columns)
+        title_row = table.rows[0]
+        # Merge all cells in the title row
+        title_cell = title_row.cells[0]
+        for i in range(1, num_cols):
+            title_cell.merge(title_row.cells[i])
+        title_cell.text = config['title']
+        self._apply_header_cell_style(title_cell, is_title=True)
+        # Center the title
+        for paragraph in title_cell.paragraphs:
+            paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
+        
+        # Row 1: Column headers
+        header_row = table.rows[1]
+        for i, col_name in enumerate(columns):
+            cell = header_row.cells[i]
+            cell.text = col_name
+            self._apply_header_cell_style(cell)
+        
+        # Data rows
+        for row_idx, resource in enumerate(resources):
+            row = table.rows[row_idx + 2]  # +2 to skip title and header rows
+            for col_idx, col_name in enumerate(columns):
+                cell = row.cells[col_idx]
+                value = self._get_resource_value(resource, col_name)
+                cell.text = value
+        
+        # Add spacing after table
+        self.document.add_paragraph()
+    
+    def add_vertical_table(self, service_key: str, resource: Dict[str, Any],
+                          include_account_column: bool = False,
+                          show_title: bool = True) -> None:
+        """
+        Add a vertical table for a single resource (attribute names in left column).
+        
+        Format:
+        | Service Name (merged across 2 columns) |
+        | Column1 | Value1 |
+        | Column2 | Value2 |
+        
+        Args:
+            service_key: The service key from SERVICE_CONFIG
+            resource: Single resource dictionary
+            include_account_column: Whether to include AWS Account row (for multi-account)
+            show_title: Whether to show the service title row (first resource shows title)
+        """
+        if not self.document:
+            raise ValueError("No document loaded. Call load_template() first.")
+        
+        if service_key not in SERVICE_CONFIG:
+            raise ValueError(f"Unknown service: {service_key}")
+        
+        config = SERVICE_CONFIG[service_key]
+        if config['layout'] != TableLayout.VERTICAL:
+            raise ValueError(f"Service {service_key} uses horizontal layout, not vertical")
+        
+        columns = list(config['columns'])
+        if include_account_column and 'AWS Account' not in columns:
+            columns.insert(0, 'AWS Account')
+        
+        # Create table with 2 columns: 1 title row + attribute rows
+        num_rows = len(columns) + (1 if show_title else 0)  # +1 for title row if showing
+        table = self.document.add_table(rows=num_rows, cols=2)
+        
+        # Apply table styling
+        self._copy_table_style_from_template(table)
+        
+        row_offset = 0
+        
+        # Row 0: Service title (merged across 2 columns) - only for first resource
+        if show_title:
+            title_row = table.rows[0]
+            title_cell = title_row.cells[0]
+            title_cell.merge(title_row.cells[1])
+            title_cell.text = config['title']
+            self._apply_header_cell_style(title_cell, is_title=True)
+            # Center the title
+            for paragraph in title_cell.paragraphs:
+                paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
+            row_offset = 1
+        
+        # Attribute rows
+        for row_idx, col_name in enumerate(columns):
+            row = table.rows[row_idx + row_offset]
+            # Attribute name cell (apply header styling)
+            name_cell = row.cells[0]
+            name_cell.text = col_name
+            self._apply_header_cell_style(name_cell)
+            
+            # Value cell
+            value_cell = row.cells[1]
+            value = self._get_resource_value(resource, col_name)
+            value_cell.text = value
+        
+        # Add spacing after table
+        self.document.add_paragraph()
+    
+    def add_vertical_tables_for_service(self, service_key: str, resources: List[Dict[str, Any]],
+                                        include_account_column: bool = False) -> None:
+        """
+        Add vertical tables for all resources of a service.
+        Each resource gets its own table with the service title in the first row.
+        
+        Args:
+            service_key: The service key from SERVICE_CONFIG
+            resources: List of resource dictionaries
+            include_account_column: Whether to include AWS Account row
+        """
+        if not self.document:
+            raise ValueError("No document loaded. Call load_template() first.")
+        
+        if service_key not in SERVICE_CONFIG:
+            raise ValueError(f"Unknown service: {service_key}")
+        
+        # Add a table for each resource, each with its own title row
+        for resource in resources:
+            self.add_vertical_table(service_key, resource, include_account_column, show_title=True)
+    
+    def _insert_element_at_position(self, element) -> None:
+        """
+        Insert an element at the tracked position within Implementation List section.
+        
+        Args:
+            element: The XML element to insert
+        """
+        if self._insert_parent is not None and self._insert_index is not None:
+            self._insert_parent.insert(self._insert_index, element)
+            self._insert_index += 1
+        else:
+            # Fallback: append to document body
+            self.document._body._body.append(element)
+    
+    def _add_horizontal_table_at_position(self, service_key: str, resources: List[Dict[str, Any]], 
+                                          include_account_column: bool = False) -> None:
+        """
+        Add a horizontal table at the tracked position within Implementation List section.
+        
+        Args:
+            service_key: The service key from SERVICE_CONFIG
+            resources: List of resource dictionaries
+            include_account_column: Whether to include AWS Account column
+        """
+        if service_key not in SERVICE_CONFIG:
+            raise ValueError(f"Unknown service: {service_key}")
+        
+        config = SERVICE_CONFIG[service_key]
+        columns = list(config['columns'])
+        if include_account_column and 'AWS Account' not in columns:
+            columns.insert(0, 'AWS Account')
+        
+        # Create table: 1 title row + 1 header row + data rows
+        num_rows = len(resources) + 2
+        num_cols = len(columns)
+        table = self.document.add_table(rows=num_rows, cols=num_cols)
+        
+        # Move table to correct position
+        tbl_element = table._tbl
+        tbl_element.getparent().remove(tbl_element)
+        self._insert_element_at_position(tbl_element)
+        
+        # Apply table styling
+        self._copy_table_style_from_template(table)
+        
+        # Row 0: Service title (merged across all columns)
+        title_row = table.rows[0]
+        title_cell = title_row.cells[0]
+        for i in range(1, num_cols):
+            title_cell.merge(title_row.cells[i])
+        title_cell.text = config['title']
+        self._apply_header_cell_style(title_cell, is_title=True)
+        for paragraph in title_cell.paragraphs:
+            paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
+        
+        # Row 1: Column headers
+        header_row = table.rows[1]
+        for i, col_name in enumerate(columns):
+            cell = header_row.cells[i]
+            cell.text = col_name
+            self._apply_header_cell_style(cell)
+        
+        # Data rows
+        for row_idx, resource in enumerate(resources):
+            row = table.rows[row_idx + 2]
+            for col_idx, col_name in enumerate(columns):
+                cell = row.cells[col_idx]
+                value = self._get_resource_value(resource, col_name)
+                cell.text = value
+                self._apply_data_cell_style(cell)
+        
+        # Add spacing paragraph after table
+        self._add_spacing_paragraph_at_position()
+    
+    def _add_vertical_tables_at_position(self, service_key: str, resources: List[Dict[str, Any]],
+                                         include_account_column: bool = False) -> None:
+        """
+        Add vertical tables at the tracked position within Implementation List section.
+        
+        Args:
+            service_key: The service key from SERVICE_CONFIG
+            resources: List of resource dictionaries
+            include_account_column: Whether to include AWS Account row
+        """
+        if service_key not in SERVICE_CONFIG:
+            raise ValueError(f"Unknown service: {service_key}")
+        
+        config = SERVICE_CONFIG[service_key]
+        columns = list(config['columns'])
+        if include_account_column and 'AWS Account' not in columns:
+            columns.insert(0, 'AWS Account')
+        
+        for resource in resources:
+            # Create table: 1 title row + attribute rows
+            num_rows = len(columns) + 1
+            table = self.document.add_table(rows=num_rows, cols=2)
+            
+            # Move table to correct position
+            tbl_element = table._tbl
+            tbl_element.getparent().remove(tbl_element)
+            self._insert_element_at_position(tbl_element)
+            
+            # Apply table styling
+            self._copy_table_style_from_template(table)
+            
+            # Row 0: Service title (merged across 2 columns)
+            title_row = table.rows[0]
+            title_cell = title_row.cells[0]
+            title_cell.merge(title_row.cells[1])
+            title_cell.text = config['title']
+            self._apply_header_cell_style(title_cell, is_title=True)
+            for paragraph in title_cell.paragraphs:
+                paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
+            
+            # Attribute rows
+            for row_idx, col_name in enumerate(columns):
+                row = table.rows[row_idx + 1]
+                # Attribute name cell
+                name_cell = row.cells[0]
+                name_cell.text = col_name
+                self._apply_header_cell_style(name_cell)
+                
+                # Value cell
+                value_cell = row.cells[1]
+                value = self._get_resource_value(resource, col_name)
+                value_cell.text = value
+                self._apply_data_cell_style(value_cell)
+            
+            # Add spacing paragraph after table
+            self._add_spacing_paragraph_at_position()
+    
+    def _add_spacing_paragraph_at_position(self) -> None:
+        """Add an empty paragraph for spacing at the tracked position."""
+        p = self.document.add_paragraph()
+        p_element = p._element
+        p_element.getparent().remove(p_element)
+        self._insert_element_at_position(p_element)
+    
+    def _get_resource_value(self, resource: Dict[str, Any], column_name: str) -> str:
+        """
+        Get value from resource for a given column name.
+        
+        Handles both flat dictionaries and ResourceData.to_dict() format
+        where attributes are nested in 'attributes' key.
+        Empty values are replaced with '-'.
+        
+        Args:
+            resource: Resource dictionary
+            column_name: Column display name
+            
+        Returns:
+            Value as string, or '-' if empty
+        """
+        value = None
+        
+        # First try to get from attributes (ResourceData format)
+        attributes = resource.get('attributes', {})
+        if column_name in attributes:
+            value = attributes[column_name]
+        
+        # Try mapped attribute key in attributes
+        if value is None:
+            attr_key = self._column_to_attribute(column_name)
+            if attr_key in attributes:
+                value = attributes[attr_key]
+        
+        # Fallback: try direct access on resource (flat dict format)
+        if value is None and column_name in resource:
+            value = resource[column_name]
+        
+        if value is None:
+            attr_key = self._column_to_attribute(column_name)
+            if attr_key in resource:
+                value = resource[attr_key]
+        
+        # Convert to string and handle empty values
+        if value is None or value == '' or (isinstance(value, str) and value.strip() == ''):
+            return '-'
+        
+        return str(value)
+    
+    def _column_to_attribute(self, column_name: str) -> str:
+        """
+        Convert column display name to attribute key.
+        
+        Args:
+            column_name: Display name of the column
+            
+        Returns:
+            Attribute key for the resource dictionary
+        """
+        # Common mappings
+        mappings = {
+            'Name': 'name',
+            'ID': 'id',
+            'Region': 'region',
+            'AZ': 'availability_zone',
+            'CIDR': 'cidr_block',
+            'VPC': 'vpc_id',
+            'VPC ID': 'vpc_id',
+            'Subnet ID': 'subnet_id',
+            'Instance ID': 'instance_id',
+            'Instance Type': 'instance_type',
+            'AMI': 'ami_id',
+            'Public IP': 'public_ip',
+            'Public DNS': 'public_dns',
+            'Private IP': 'private_ip',
+            'Elastic IP': 'elastic_ip',
+            'Key': 'key_name',
+            'Security Groups': 'security_groups',
+            'EBS Type': 'ebs_type',
+            'EBS Size': 'ebs_size',
+            'Encryption': 'encryption',
+            'AWS Account': 'account_id',
+            'Subnet Associations': 'subnet_associations',
+            'Peering Connection ID': 'peering_connection_id',
+            'Requester VPC': 'requester_vpc',
+            'Accepter VPC': 'accepter_vpc',
+            'Customer Gateway ID': 'customer_gateway_id',
+            'IP Address': 'ip_address',
+            'Virtual Private Gateway ID': 'virtual_private_gateway_id',
+            'VPN ID': 'vpn_id',
+            'Routes': 'routes',
+            'Service Name': 'service_name',
+            'Type': 'type',
+            'Launch Template': 'launch_template',
+            'Target Groups': 'target_groups',
+            'Desired': 'desired_capacity',
+            'Min': 'min_size',
+            'Max': 'max_size',
+            'Scaling Policy': 'scaling_policy',
+            'DNS': 'dns_name',
+            'Scheme': 'scheme',
+            'Availability Zones': 'availability_zones',
+            'Load Balancer': 'load_balancer',
+            'TG Name': 'target_group_name',
+            'Port': 'port',
+            'Protocol': 'protocol',
+            'Registered Instances': 'registered_instances',
+            'Health Check Path': 'health_check_path',
+            'Endpoint': 'endpoint',
+            'DB instance ID': 'db_instance_id',
+            'DB name': 'db_name',
+            'Master Username': 'master_username',
+            'DB Engine': 'engine',
+            'DB Version': 'engine_version',
+            'Storage type': 'storage_type',
+            'Storage': 'storage',
+            'Multi-AZ': 'multi_az',
+            'Deletion Protection': 'deletion_protection',
+            'Performance Insights Enabled': 'performance_insights',
+            'CloudWatch Logs': 'cloudwatch_logs',
+            'Cluster ID': 'cluster_id',
+            'Engine': 'engine',
+            'Engine Version': 'engine_version',
+            'Node Type': 'node_type',
+            'Num Nodes': 'num_nodes',
+            'Status': 'status',
+            'Cluster Name': 'cluster_name',
+            'Version': 'version',
+            'Function Name': 'function_name',
+            'Runtime': 'runtime',
+            'Memory (MB)': 'memory_size',
+            'Timeout (s)': 'timeout',
+            'Last Modified': 'last_modified',
+            'Bucket Name': 'bucket_name',
+            'Bucket': 'bucket',
+            'Event Type': 'event_type',
+            'Destination type': 'destination_type',
+            'Destination': 'destination',
+            'CloudFront ID': 'cloudfront_id',
+            'Domain Name': 'domain_name',
+            'CNAME': 'cname',
+            'Origin Domain Name': 'origin_domain_name',
+            'Origin Protocol Policy': 'origin_protocol_policy',
+            'Viewer Protocol Policy': 'viewer_protocol_policy',
+            'Allowed HTTP Methods': 'allowed_http_methods',
+            'Cached HTTP Methods': 'cached_http_methods',
+            'Zone ID': 'zone_id',
+            'Record Count': 'record_count',
+            'Domain name': 'domain_name',
+            'Additional names': 'additional_names',
+            'WebACL Name': 'webacl_name',
+            'Scope': 'scope',
+            'Rules Count': 'rules_count',
+            'Associated Resources': 'associated_resources',
+            'Topic Name': 'topic_name',
+            'Topic Display Name': 'display_name',
+            'Subscription Protocol': 'subscription_protocol',
+            'Subscription Endpoint': 'subscription_endpoint',
+            'Log Group Name': 'log_group_name',
+            'Retention Days': 'retention_days',
+            'Stored Bytes': 'stored_bytes',
+            'KMS Encryption': 'kms_encryption',
+            'Description': 'description',
+            'Event Bus': 'event_bus',
+            'State': 'state',
+            'Multi-Region Trail': 'multi_region',
+            'Log File Validation': 'log_file_validation',
+            'Regional Resources': 'regional_resources',
+            'Global Resources': 'global_resources',
+            'Retention period': 'retention_period',
+            'Port range': 'port_range',
+            'Source': 'source',
+            'Other Requirement': 'other_requirement',
+        }
+        
+        return mappings.get(column_name, column_name.lower().replace(' ', '_'))
+
+    
+    def _find_implementation_list_section(self) -> Optional[int]:
+        """
+        Find the index of the 'Implementation List' section in the document.
+        
+        Returns:
+            Index of the paragraph after the Implementation List heading, or None if not found
+        """
+        for i, paragraph in enumerate(self.document.paragraphs):
+            text = paragraph.text.strip().lower()
+            # Match variations like "4. Implementation List", "Implementation List", etc.
+            if 'implementation list' in text:
+                return i
+        return None
+    
+    def _copy_table_style_from_template(self, table) -> None:
+        """
+        Apply consistent table styling matching the template format.
+        
+        Args:
+            table: The table to style
+        """
+        # Try to use a template table style if available
+        try:
+            # First try to use 'Table Grid' which is a standard Word style
+            table.style = 'Table Grid'
+        except Exception:
+            pass
+        
+        # Apply additional formatting for consistency
+        tbl = table._tbl
+        tblPr = tbl.tblPr if tbl.tblPr is not None else OxmlElement('w:tblPr')
+        
+        # Set table width to 100%
+        tblW = OxmlElement('w:tblW')
+        tblW.set(qn('w:w'), '5000')
+        tblW.set(qn('w:type'), 'pct')
+        tblPr.append(tblW)
+        
+        # Set table borders
+        tblBorders = OxmlElement('w:tblBorders')
+        for border_name in ['top', 'left', 'bottom', 'right', 'insideH', 'insideV']:
+            border = OxmlElement(f'w:{border_name}')
+            border.set(qn('w:val'), 'single')
+            border.set(qn('w:sz'), '4')
+            border.set(qn('w:space'), '0')
+            border.set(qn('w:color'), '000000')
+            tblBorders.append(border)
+        tblPr.append(tblBorders)
+        
+        if tbl.tblPr is None:
+            tbl.insert(0, tblPr)
+    
+    def _apply_header_cell_style(self, cell, is_title: bool = False) -> None:
+        """
+        Apply header cell styling (bold, background color, font, spacing).
+        
+        Args:
+            cell: The cell to style
+            is_title: If True, use title color (DAEEF3) and 12pt font, otherwise use header color (D9E2F3) and 11pt font
+        """
+        # Set background color for header cells
+        tc = cell._tc
+        tcPr = tc.get_or_add_tcPr()
+        shd = OxmlElement('w:shd')
+        shd.set(qn('w:val'), 'clear')
+        shd.set(qn('w:color'), 'auto')
+        # Service Name title uses DAEEF3 (light cyan), column headers use C6D9F1 (light blue)
+        shd.set(qn('w:fill'), 'DAEEF3' if is_title else 'C6D9F1')
+        tcPr.append(shd)
+        
+        # Apply font and paragraph formatting
+        # Service Name (title) uses 12pt (小四), others use 11pt
+        font_size = 12 if is_title else 11
+        for paragraph in cell.paragraphs:
+            self._apply_cell_paragraph_format(paragraph, font_size=font_size)
+            for run in paragraph.runs:
+                run.font.bold = True
+    
+    def _apply_cell_paragraph_format(self, paragraph, font_size: int = 11) -> None:
+        """
+        Apply standard cell paragraph formatting:
+        - Font: Calibri
+        - Spacing: 3pt before, 3pt after, single line spacing
+        
+        Args:
+            paragraph: The paragraph to format
+            font_size: Font size in points (default 11pt, use 12pt for Service Name)
+        """
+        from docx.shared import Pt
+        from docx.enum.text import WD_LINE_SPACING
+        
+        # Set paragraph spacing: 3pt before, 3pt after, single line spacing
+        paragraph.paragraph_format.space_before = Pt(3)
+        paragraph.paragraph_format.space_after = Pt(3)
+        paragraph.paragraph_format.line_spacing_rule = WD_LINE_SPACING.SINGLE
+        
+        # Set font for all runs
+        for run in paragraph.runs:
+            run.font.name = 'Calibri'
+            run.font.size = Pt(font_size)
+            # Set East Asian font
+            run._element.rPr.rFonts.set(qn('w:eastAsia'), 'Calibri')
+    
+    def _apply_data_cell_style(self, cell) -> None:
+        """
+        Apply data cell styling (font 11pt, spacing, no background).
+        
+        Args:
+            cell: The cell to style
+        """
+        for paragraph in cell.paragraphs:
+            self._apply_cell_paragraph_format(paragraph, font_size=11)
+    
+    def add_service_tables(self, scan_results: Dict[str, List[Dict[str, Any]]],
+                          include_account_column: bool = False,
+                          regions: List[str] = None) -> None:
+        """
+        Add tables for all services with resources, filtering out empty services.
+        Content is inserted into the existing 'Implementation List' section in the template,
+        replacing any placeholder content.
+        
+        Services are grouped under their parent service heading (e.g., VPC, ELB, S3).
+        When multiple regions are selected, regional services show region in heading.
+        Global services are shown once without region suffix.
+        
+        Args:
+            scan_results: Dictionary mapping service keys to lists of resources
+            include_account_column: Whether to include AWS Account column
+            regions: List of regions being scanned (for multi-region heading display)
+        """
+        if not self.document:
+            raise ValueError("No document loaded. Call load_template() first.")
+        
+        # Find the existing Implementation List section and clear placeholder content
+        impl_list_idx = self._find_implementation_list_section()
+        
+        if impl_list_idx is not None:
+            # Clear placeholder content after Implementation List until next Heading 1
+            self._clear_section_content(impl_list_idx)
+            
+            # Get the Implementation List paragraph and find insert position
+            impl_paragraph = self.document.paragraphs[impl_list_idx]
+            parent = impl_paragraph._element.getparent()
+            insert_index = list(parent).index(impl_paragraph._element) + 1
+            self._insert_parent = parent
+            self._insert_index = insert_index
+        else:
+            # If not found, add a new section at the end
+            self.document.add_paragraph('Implementation List', style='Heading 1')
+            self._insert_parent = self.document._body._body
+            self._insert_index = len(list(self._insert_parent))
+        
+        # Determine if we need to show region in headings (multiple regions selected)
+        multi_region = regions and len(regions) > 1
+        
+        # Helper function to group resources by region
+        def group_by_region(resources: List[Dict[str, Any]]) -> Dict[str, List[Dict[str, Any]]]:
+            """Group resources by their region attribute."""
+            grouped = {}
+            for resource in resources:
+                # Get region from resource attributes or direct field
+                region = None
+                if isinstance(resource, dict):
+                    region = resource.get('region') or resource.get('attributes', {}).get('region')
+                if not region:
+                    region = 'global'
+                if region not in grouped:
+                    grouped[region] = []
+                grouped[region].append(resource)
+            return grouped
+        
+        # Helper function to deduplicate global service resources
+        def deduplicate_resources(resources: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
+            """Deduplicate resources by ID or name."""
+            seen_ids = set()
+            unique_resources = []
+            for resource in resources:
+                res_id = None
+                if isinstance(resource, dict):
+                    res_id = resource.get('id') or resource.get('attributes', {}).get('id')
+                    if not res_id:
+                        res_id = resource.get('name') or resource.get('attributes', {}).get('name')
+                if res_id and res_id in seen_ids:
+                    continue
+                if res_id:
+                    seen_ids.add(res_id)
+                unique_resources.append(resource)
+            return unique_resources
+        
+        # Helper function to sort resources by name
+        def sort_resources_by_name(resources: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
+            """Sort resources by account (if multi-account) then by name. Resources without name come last."""
+            def get_sort_key(resource: Dict[str, Any]) -> tuple:
+                # Get account_id for multi-account sorting
+                account_id = ''
+                if isinstance(resource, dict):
+                    account_id = resource.get('account_id') or resource.get('attributes', {}).get('account_id') or ''
+                
+                # Get name
+                name = None
+                if isinstance(resource, dict):
+                    name = resource.get('name') or resource.get('attributes', {}).get('name')
+                
+                # Sort by: account_id, then has_name (0=has name, 1=no name), then name alphabetically
+                if name and str(name).strip():
+                    return (str(account_id), 0, str(name).lower())
+                return (str(account_id), 1, '')
+            return sorted(resources, key=get_sort_key)
+        
+        # Helper function to add table for a service
+        def add_service_table(service_key: str, resources: List[Dict[str, Any]]):
+            config = SERVICE_CONFIG.get(service_key)
+            if not config or not resources:
+                return
+            # Sort resources by name before adding to table
+            sorted_resources = sort_resources_by_name(resources)
+            if config['layout'] == TableLayout.HORIZONTAL:
+                self._add_horizontal_table_at_position(service_key, sorted_resources, include_account_column)
+            else:
+                self._add_vertical_tables_at_position(service_key, sorted_resources, include_account_column)
+        
+        if multi_region:
+            # Multi-region mode: organize by region first, then by service group
+            # Step 1: Collect all regions from resources
+            all_regions = set()
+            for service_key in SERVICE_ORDER:
+                resources = scan_results.get(service_key, [])
+                if not resources:
+                    continue
+                if service_key in GLOBAL_SERVICES:
+                    continue  # Skip global services for region collection
+                for resource in resources:
+                    region = None
+                    if isinstance(resource, dict):
+                        region = resource.get('region') or resource.get('attributes', {}).get('region')
+                    if region:
+                        all_regions.add(region)
+            
+            # Sort regions for consistent output (use provided regions order if available)
+            if regions:
+                sorted_regions = [r for r in regions if r in all_regions]
+                # Add any regions found in resources but not in provided list
+                for r in sorted(all_regions):
+                    if r not in sorted_regions:
+                        sorted_regions.append(r)
+            else:
+                sorted_regions = sorted(all_regions)
+            
+            # Step 2: Process regional services by region, then by service group
+            for region in sorted_regions:
+                added_groups_for_region = set()
+                
+                for service_key in SERVICE_ORDER:
+                    # Skip global services
+                    if service_key in GLOBAL_SERVICES:
+                        continue
+                    
+                    resources = scan_results.get(service_key, [])
+                    if not resources:
+                        continue
+                    
+                    config = SERVICE_CONFIG.get(service_key)
+                    if not config:
+                        continue
+                    
+                    # Filter resources for this region
+                    region_resources = []
+                    for resource in resources:
+                        res_region = None
+                        if isinstance(resource, dict):
+                            res_region = resource.get('region') or resource.get('attributes', {}).get('region')
+                        if res_region == region:
+                            region_resources.append(resource)
+                    
+                    if not region_resources:
+                        continue
+                    
+                    # Get the service group for this service
+                    service_group = SERVICE_GROUPS.get(service_key, config['title'])
+                    
+                    # Add Heading 2 with region suffix if not already added for this region
+                    if service_group not in added_groups_for_region:
+                        self._add_heading2_at_position(f"{service_group} ({region})")
+                        added_groups_for_region.add(service_group)
+                    
+                    # Add the table(s) for this service
+                    add_service_table(service_key, region_resources)
+            
+            # Step 3: Process global services (without region suffix)
+            added_global_groups = set()
+            for service_key in SERVICE_ORDER:
+                if service_key not in GLOBAL_SERVICES:
+                    continue
+                
+                resources = scan_results.get(service_key, [])
+                if not resources:
+                    continue
+                
+                config = SERVICE_CONFIG.get(service_key)
+                if not config:
+                    continue
+                
+                # Deduplicate global service resources
+                unique_resources = deduplicate_resources(resources)
+                if not unique_resources:
+                    continue
+                
+                # Get the service group for this service
+                service_group = SERVICE_GROUPS.get(service_key, config['title'])
+                
+                # Add Heading 2 without region suffix
+                if service_group not in added_global_groups:
+                    self._add_heading2_at_position(service_group)
+                    added_global_groups.add(service_group)
+                
+                # Add the table(s) for this service
+                add_service_table(service_key, unique_resources)
+        
+        else:
+            # Single region or no region info: original behavior
+            added_groups = set()
+            
+            for service_key in SERVICE_ORDER:
+                resources = scan_results.get(service_key, [])
+                if not resources:
+                    continue
+                
+                config = SERVICE_CONFIG.get(service_key)
+                if not config:
+                    continue
+                
+                # Deduplicate global services
+                if service_key in GLOBAL_SERVICES:
+                    resources = deduplicate_resources(resources)
+                    if not resources:
+                        continue
+                
+                # Get the service group for this service
+                service_group = SERVICE_GROUPS.get(service_key, config['title'])
+                
+                # Add Heading 2 for the service group if not already added
+                if service_group not in added_groups:
+                    self._add_heading2_at_position(service_group)
+                    added_groups.add(service_group)
+                
+                # Add the table(s) for this service
+                add_service_table(service_key, resources)
+        
+        # Add page break after Implementation List section
+        self._add_page_break_at_position()
+    
+    def _add_page_break_at_position(self) -> None:
+        """Add a page break at the tracked position."""
+        from docx.oxml import OxmlElement
+        from docx.oxml.ns import qn
+        
+        # Create a paragraph with page break
+        p = self.document.add_paragraph()
+        run = p.add_run()
+        br = OxmlElement('w:br')
+        br.set(qn('w:type'), 'page')
+        run._r.append(br)
+        
+        # Move to correct position
+        p_element = p._element
+        p_element.getparent().remove(p_element)
+        self._insert_element_at_position(p_element)
+    
+    def _add_heading2_at_position(self, title: str) -> None:
+        """
+        Add a Heading 2 paragraph at the tracked position.
+        
+        Args:
+            title: The heading title (service group name)
+        """
+        heading = self.document.add_paragraph(f'▼  {title}', style='Heading 2')
+        heading_element = heading._element
+        heading_element.getparent().remove(heading_element)
+        self._insert_element_at_position(heading_element)
+    
+    def _clear_section_content(self, section_start_idx: int) -> None:
+        """
+        Clear content between a section heading and the next Heading 1.
+        
+        Args:
+            section_start_idx: Index of the section heading paragraph
+        """
+        # Find elements to remove (between this Heading 1 and next Heading 1)
+        elements_to_remove = []
+        body = self.document._body._body
+        
+        start_para = self.document.paragraphs[section_start_idx]
+        start_element = start_para._element
+        
+        # Find the position of start element in body
+        body_children = list(body)
+        try:
+            start_pos = body_children.index(start_element)
+        except ValueError:
+            return
+        
+        # Iterate through elements after the heading
+        for i in range(start_pos + 1, len(body_children)):
+            elem = body_children[i]
+            
+            # Check if this is a Heading 1 paragraph (next section)
+            if elem.tag.endswith('}p'):
+                # Check if it's a Heading 1
+                pStyle = elem.find('.//{http://schemas.openxmlformats.org/wordprocessingml/2006/main}pStyle')
+                if pStyle is not None:
+                    style_val = pStyle.get('{http://schemas.openxmlformats.org/wordprocessingml/2006/main}val')
+                    if style_val and ('Heading1' in style_val or style_val == '1'):
+                        break
+            
+            elements_to_remove.append(elem)
+        
+        # Remove the elements
+        for elem in elements_to_remove:
+            body.remove(elem)
+    
+    def filter_empty_services(self, scan_results: Dict[str, List[Dict[str, Any]]]) -> Dict[str, List[Dict[str, Any]]]:
+        """
+        Filter out services with no resources.
+        
+        Args:
+            scan_results: Dictionary mapping service keys to lists of resources
+            
+        Returns:
+            Filtered dictionary with only non-empty services
+        """
+        return {k: v for k, v in scan_results.items() if v}
+    
+    def get_services_with_resources(self, scan_results: Dict[str, List[Dict[str, Any]]]) -> List[str]:
+        """
+        Get list of service keys that have resources.
+        
+        Args:
+            scan_results: Dictionary mapping service keys to lists of resources
+            
+        Returns:
+            List of service keys with resources
+        """
+        return [k for k in SERVICE_ORDER if scan_results.get(k)]
+    
+    def replace_architecture_picture_placeholder(self, image_path: str, width_inches: float = 6.0) -> bool:
+        """
+        Replace [AWS Architecture Picture] placeholder with actual image.
+        
+        This method searches for the placeholder text in paragraphs and replaces it
+        with the provided image.
+        
+        Args:
+            image_path: Path to the architecture diagram image file
+            width_inches: Width of the image in inches (default 6.0)
+            
+        Returns:
+            True if placeholder was found and replaced, False otherwise
+            
+        Raises:
+            FileNotFoundError: If image file doesn't exist
+            ValueError: If no document is loaded
+        """
+        if not self.document:
+            raise ValueError("No document loaded. Call load_template() first.")
+        
+        if not os.path.exists(image_path):
+            raise FileNotFoundError(f"Image file not found: {image_path}")
+        
+        placeholder_text = '[AWS Architecture Picture]'
+        placeholder_found = False
+        
+        # Search in paragraphs
+        for paragraph in self.document.paragraphs:
+            if placeholder_text in paragraph.text:
+                # Found the placeholder, replace it with image
+                # Clear the paragraph text first
+                full_text = paragraph.text
+                new_text = full_text.replace(placeholder_text, '')
+                
+                # Clear all runs
+                for run in paragraph.runs:
+                    run.text = ''
+                
+                # Add the image to this paragraph
+                run = paragraph.add_run()
+                run.add_picture(image_path, width=Inches(width_inches))
+                
+                # If there was other text, add it back
+                if new_text.strip():
+                    paragraph.add_run(new_text)
+                
+                # Center the paragraph
+                paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
+                placeholder_found = True
+                break
+        
+        # Also search in tables (in case placeholder is in a table cell)
+        if not placeholder_found:
+            for table in self.document.tables:
+                for row in table.rows:
+                    for cell in row.cells:
+                        for paragraph in cell.paragraphs:
+                            if placeholder_text in paragraph.text:
+                                # Clear the paragraph text first
+                                full_text = paragraph.text
+                                new_text = full_text.replace(placeholder_text, '')
+                                
+                                # Clear all runs
+                                for run in paragraph.runs:
+                                    run.text = ''
+                                
+                                # Add the image to this paragraph
+                                run = paragraph.add_run()
+                                run.add_picture(image_path, width=Inches(width_inches))
+                                
+                                # If there was other text, add it back
+                                if new_text.strip():
+                                    paragraph.add_run(new_text)
+                                
+                                # Center the paragraph
+                                paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
+                                placeholder_found = True
+                                break
+                        if placeholder_found:
+                            break
+                    if placeholder_found:
+                        break
+                if placeholder_found:
+                    break
+        
+        return placeholder_found
+    
+    def clear_architecture_picture_placeholder(self) -> bool:
+        """
+        Remove [AWS Architecture Picture] placeholder from the document.
+        
+        This method is called when no architecture image is provided,
+        to clean up the placeholder text.
+        
+        Returns:
+            True if placeholder was found and removed, False otherwise
+        """
+        if not self.document:
+            raise ValueError("No document loaded. Call load_template() first.")
+        
+        placeholder_text = '[AWS Architecture Picture]'
+        placeholder_found = False
+        
+        # Search in paragraphs
+        for paragraph in self.document.paragraphs:
+            if placeholder_text in paragraph.text:
+                # Remove the placeholder text
+                for run in paragraph.runs:
+                    if placeholder_text in run.text:
+                        run.text = run.text.replace(placeholder_text, '')
+                placeholder_found = True
+        
+        # Also search in tables
+        for table in self.document.tables:
+            for row in table.rows:
+                for cell in row.cells:
+                    for paragraph in cell.paragraphs:
+                        if placeholder_text in paragraph.text:
+                            for run in paragraph.runs:
+                                if placeholder_text in run.text:
+                                    run.text = run.text.replace(placeholder_text, '')
+                            placeholder_found = True
+        
+        return placeholder_found
+
+    def embed_network_diagram(self, image_path: str, width_inches: float = 6.0) -> None:
+        """
+        Embed a network diagram image into the document.
+        
+        Args:
+            image_path: Path to the image file
+            width_inches: Width of the image in inches
+            
+        Raises:
+            FileNotFoundError: If image file doesn't exist
+        """
+        if not self.document:
+            raise ValueError("No document loaded. Call load_template() first.")
+        
+        if not os.path.exists(image_path):
+            raise FileNotFoundError(f"Image file not found: {image_path}")
+        
+        # Find the Network Diagram section or add one
+        network_section_found = False
+        for i, paragraph in enumerate(self.document.paragraphs):
+            if 'Network Diagram' in paragraph.text or 'Network Architecture' in paragraph.text:
+                network_section_found = True
+                # Add image after this paragraph
+                # We need to insert after this paragraph
+                break
+        
+        if not network_section_found:
+            # Add a new section for network diagram
+            self.document.add_paragraph('Network Diagram', style='Heading 1')
+        
+        # Add the image
+        self.document.add_picture(image_path, width=Inches(width_inches))
+        
+        # Center the image
+        last_paragraph = self.document.paragraphs[-1]
+        last_paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
+        
+        # Add spacing
+        self.document.add_paragraph()
+    
+    def update_table_of_contents(self) -> None:
+        """
+        Update the table of contents in the document.
+        
+        Note: Full TOC update requires Word application. This method adds
+        a field code that will update when the document is opened in Word.
+        """
+        if not self.document:
+            raise ValueError("No document loaded. Call load_template() first.")
+        
+        # Find existing TOC or add instruction
+        # python-docx cannot fully update TOC without Word application
+        # We add a field that will prompt update when opened
+        
+        # Set document to update fields when opened
+        # self._set_update_fields_on_open()
+        
+        for paragraph in self.document.paragraphs:
+            # Look for TOC field
+            for run in paragraph.runs:
+                if 'TOC' in run.text or 'Table of Contents' in run.text:
+                    # Mark TOC for update
+                    self._mark_toc_for_update(paragraph)
+                    return
+    
+    def _set_update_fields_on_open(self) -> None:
+        """
+        Set the document to update all fields (including TOC) when opened in Word.
+        
+        This adds the updateFields setting to the document settings, which causes
+        Word to prompt the user to update fields when the document is opened.
+        """
+        try:
+            # Access the document settings element
+            settings_element = self.document.settings.element
+            
+            # Create or find the updateFields element
+            # Namespace for Word ML
+            w_ns = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
+            
+            # Check if updateFields already exists
+            update_fields = settings_element.find(f'{w_ns}updateFields')
+            
+            if update_fields is None:
+                # Create the updateFields element
+                update_fields = OxmlElement('w:updateFields')
+                update_fields.set(qn('w:val'), 'true')
+                settings_element.append(update_fields)
+            else:
+                # Ensure it's set to true
+                update_fields.set(qn('w:val'), 'true')
+                
+        except Exception as e:
+            # Log but don't fail - TOC update is not critical
+            print(f"Warning: Could not set updateFields on open: {e}")
+    
+    def _mark_toc_for_update(self, paragraph) -> None:
+        """
+        Mark a TOC paragraph for update when document is opened.
+        
+        Args:
+            paragraph: The TOC paragraph
+        """
+        # Add updateFields setting to document
+        # This will prompt Word to update fields when opened
+        try:
+            # The updateFields setting is already set in _set_update_fields_on_open
+            # This method can be used for additional TOC-specific handling if needed
+            pass
+        except Exception:
+            pass  # Settings may not be accessible
+    
+    def add_update_history(self, version: str = '1.0', modifier: str = '', details: str = '') -> None:
+        """
+        Add or update the Update History section.
+        
+        Args:
+            version: Document version
+            modifier: Name of the person who modified
+            details: Details of the changes
+        """
+        if not self.document:
+            raise ValueError("No document loaded. Call load_template() first.")
+        
+        # Find Update History section
+        for i, paragraph in enumerate(self.document.paragraphs):
+            if 'Update History' in paragraph.text or 'Revision History' in paragraph.text:
+                # Found the section, look for the table
+                # Add entry to existing table or create new one
+                break
+        
+        # Create update history entry
+        now = datetime.now()
+        history_entry = {
+            'version': version,
+            'date': now.strftime('%Y-%m-%d'),
+            'modifier': modifier,
+            'details': details or 'Initial version'
+        }
+        
+        # This would typically update an existing table
+        # For now, we ensure the data is available for template replacement
+
+    
+    def save(self, output_path: str) -> str:
+        """
+        Save the document to a file.
+        
+        Args:
+            output_path: Path where to save the document
+            
+        Returns:
+            The path where the document was saved
+        """
+        if not self.document:
+            raise ValueError("No document loaded. Call load_template() first.")
+        
+        # Ensure directory exists
+        os.makedirs(os.path.dirname(output_path), exist_ok=True)
+        
+        self.document.save(output_path)
+        return output_path
+    
+    def get_file_size(self, file_path: str) -> int:
+        """
+        Get the size of a file in bytes.
+        
+        Args:
+            file_path: Path to the file
+            
+        Returns:
+            File size in bytes
+        """
+        return os.path.getsize(file_path)
+    
+    def generate_report(self, scan_results: Dict[str, List[Dict[str, Any]]],
+                       project_metadata: Dict[str, Any],
+                       output_path: str,
+                       network_diagram_path: str = None,
+                       template_path: str = None,
+                       regions: List[str] = None) -> Dict[str, Any]:
+        """
+        Generate a complete report from scan results.
+        
+        This is the main entry point for report generation.
+        
+        Args:
+            scan_results: Dictionary mapping service keys to lists of resources
+            project_metadata: Project metadata for placeholder replacement
+            output_path: Path where to save the generated report
+            network_diagram_path: Optional path to network diagram image
+            template_path: Optional path to template file
+            regions: Optional list of regions being scanned (for multi-region heading display)
+            
+        Returns:
+            Dictionary with report metadata:
+            - file_path: Path to the generated report
+            - file_name: Name of the report file
+            - file_size: Size of the report in bytes
+            - services_included: List of services included in the report
+        """
+        # Load template
+        self.load_template(template_path)
+        
+        # Create placeholder replacements
+        replacements = self.create_project_metadata_replacements(project_metadata)
+        
+        # Replace placeholders
+        self.replace_placeholders(replacements)
+        
+        # Filter empty services
+        filtered_results = self.filter_empty_services(scan_results)
+        
+        # Determine if multi-account (need AWS Account column)
+        account_ids = set()
+        for resources in filtered_results.values():
+            for resource in resources:
+                # Handle both dict and ResourceData objects
+                if isinstance(resource, dict):
+                    if 'account_id' in resource:
+                        account_ids.add(resource['account_id'])
+                elif hasattr(resource, 'account_id'):
+                    account_ids.add(resource.account_id)
+        include_account_column = len(account_ids) > 1
+        
+        # Add service tables with region info
+        self.add_service_tables(filtered_results, include_account_column, regions)
+        
+        # Handle architecture picture placeholder
+        if network_diagram_path and os.path.exists(network_diagram_path):
+            # Replace placeholder with actual image
+            self.replace_architecture_picture_placeholder(network_diagram_path)
+        else:
+            # No image provided, clear the placeholder
+            self.clear_architecture_picture_placeholder()
+        
+        # Update table of contents
+        self.update_table_of_contents()
+        
+        # Add update history
+        self.add_update_history(
+            version='1.0',
+            modifier=project_metadata.get('cloud_engineer', ''),
+            details='Initial AWS resource inventory report'
+        )
+        
+        # Save the document
+        self.save(output_path)
+        
+        # Get file info
+        file_size = self.get_file_size(output_path)
+        file_name = os.path.basename(output_path)
+        
+        return {
+            'file_path': output_path,
+            'file_name': file_name,
+            'file_size': file_size,
+            'services_included': list(filtered_results.keys()),
+            'accounts_count': len(account_ids),
+        }
+
+
+def generate_report_filename(project_metadata: Dict[str, Any]) -> str:
+    """
+    Generate a report filename from project metadata.
+    
+    Args:
+        project_metadata: Project metadata dictionary
+        
+    Returns:
+        Generated filename
+    """
+    client_name = project_metadata.get('client_name', 'Client')
+    project_name = project_metadata.get('project_name', 'Project')
+    timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
+    
+    # Sanitize names for filename
+    client_name = re.sub(r'[^\w\s-]', '', client_name).strip().replace(' ', '-')
+    project_name = re.sub(r'[^\w\s-]', '', project_name).strip().replace(' ', '-')
+    
+    return f"{client_name}-{project_name}-Report-{timestamp}.docx"

+ 263 - 0
backend/app/services/report_service.py

@@ -0,0 +1,263 @@
+"""
+Report Service
+
+This module provides high-level report management functionality,
+integrating the ReportGenerator with database storage.
+"""
+
+import os
+from datetime import datetime
+from typing import Dict, List, Any, Optional
+from flask import current_app
+
+from app import db
+from app.models import Report, Task
+from app.services.report_generator import ReportGenerator, generate_report_filename
+
+
+class ReportService:
+    """
+    Service for managing report generation and storage.
+    
+    This service:
+    - Coordinates report generation from scan results
+    - Stores report metadata in the database
+    - Manages report file storage
+    """
+    
+    @staticmethod
+    def get_reports_folder() -> str:
+        """Get the reports folder path from config (always returns absolute path)."""
+        reports_folder = current_app.config.get('REPORTS_FOLDER', 'reports')
+        if not os.path.isabs(reports_folder):
+            # Convert to absolute path relative to the app root
+            reports_folder = os.path.abspath(reports_folder)
+        return reports_folder
+    
+    @staticmethod
+    def get_uploads_folder() -> str:
+        """Get the uploads folder path from config."""
+        return current_app.config.get('UPLOAD_FOLDER', 'uploads')
+    
+    @classmethod
+    def generate_and_store_report(cls, task_id: int, scan_results: Dict[str, List[Dict[str, Any]]],
+                                  project_metadata: Dict[str, Any],
+                                  network_diagram_path: str = None,
+                                  regions: List[str] = None) -> Report:
+        """
+        Generate a report and store it in the database.
+        
+        Args:
+            task_id: ID of the associated task
+            scan_results: Dictionary mapping service keys to lists of resources
+            project_metadata: Project metadata for the report
+            network_diagram_path: Optional path to network diagram image
+            regions: Optional list of regions being scanned (for multi-region heading display)
+            
+        Returns:
+            Created Report model instance
+            
+        Raises:
+            ValueError: If task doesn't exist or already has a report
+        """
+        # Verify task exists
+        task = Task.query.get(task_id)
+        if not task:
+            raise ValueError(f"Task {task_id} not found")
+        
+        # Check if task already has a report
+        existing_report = Report.query.filter_by(task_id=task_id).first()
+        if existing_report:
+            raise ValueError(f"Task {task_id} already has a report")
+        
+        # Generate filename and output path
+        filename = generate_report_filename(project_metadata)
+        reports_folder = cls.get_reports_folder()
+        os.makedirs(reports_folder, exist_ok=True)
+        output_path = os.path.join(reports_folder, filename)
+        
+        # Generate the report
+        generator = ReportGenerator()
+        result = generator.generate_report(
+            scan_results=scan_results,
+            project_metadata=project_metadata,
+            output_path=output_path,
+            network_diagram_path=network_diagram_path,
+            regions=regions
+        )
+        
+        # Create database record
+        report = Report(
+            task_id=task_id,
+            file_name=result['file_name'],
+            file_path=result['file_path'],
+            file_size=result['file_size']
+        )
+        
+        db.session.add(report)
+        db.session.commit()
+        
+        return report
+
+    
+    @classmethod
+    def get_report_by_id(cls, report_id: int) -> Optional[Report]:
+        """
+        Get a report by ID.
+        
+        Args:
+            report_id: ID of the report
+            
+        Returns:
+            Report instance or None
+        """
+        return Report.query.get(report_id)
+    
+    @classmethod
+    def get_report_by_task_id(cls, task_id: int) -> Optional[Report]:
+        """
+        Get a report by task ID.
+        
+        Args:
+            task_id: ID of the associated task
+            
+        Returns:
+            Report instance or None
+        """
+        return Report.query.filter_by(task_id=task_id).first()
+    
+    @classmethod
+    def get_reports(cls, page: int = 1, page_size: int = 20,
+                   task_id: int = None, user_id: int = None) -> Dict[str, Any]:
+        """
+        Get paginated list of reports.
+        
+        Args:
+            page: Page number (1-indexed)
+            page_size: Number of items per page
+            task_id: Optional filter by task ID
+            user_id: Optional filter by user ID (reports from user's tasks)
+            
+        Returns:
+            Dictionary with 'data' and 'pagination' keys
+        """
+        query = Report.query
+        
+        if task_id:
+            query = query.filter_by(task_id=task_id)
+        
+        if user_id:
+            query = query.join(Task).filter(Task.created_by == user_id)
+        
+        query = query.order_by(Report.created_at.desc())
+        
+        # Paginate
+        total = query.count()
+        reports = query.offset((page - 1) * page_size).limit(page_size).all()
+        
+        return {
+            'data': [r.to_dict() for r in reports],
+            'pagination': {
+                'page': page,
+                'page_size': page_size,
+                'total': total,
+                'total_pages': (total + page_size - 1) // page_size
+            }
+        }
+    
+    @classmethod
+    def delete_report(cls, report_id: int) -> bool:
+        """
+        Delete a report and its file.
+        
+        Args:
+            report_id: ID of the report to delete
+            
+        Returns:
+            True if deleted, False if not found
+        """
+        report = Report.query.get(report_id)
+        if not report:
+            return False
+        
+        # Delete the file
+        if report.file_path and os.path.exists(report.file_path):
+            try:
+                os.remove(report.file_path)
+            except OSError:
+                pass  # File may already be deleted
+        
+        # Delete database record
+        db.session.delete(report)
+        db.session.commit()
+        
+        return True
+    
+    @classmethod
+    def get_report_file_path(cls, report_id: int) -> Optional[str]:
+        """
+        Get the file path for a report.
+        
+        Args:
+            report_id: ID of the report
+            
+        Returns:
+            File path or None if report not found or file doesn't exist
+        """
+        report = Report.query.get(report_id)
+        if not report or not report.file_path:
+            return None
+        
+        file_path = report.file_path
+        
+        # If the stored path exists, return it
+        if os.path.exists(file_path):
+            return file_path
+        
+        # If stored path is relative, try to resolve it
+        if not os.path.isabs(file_path):
+            # Try relative to current working directory
+            abs_path = os.path.abspath(file_path)
+            if os.path.exists(abs_path):
+                return abs_path
+        
+        # Try to find the file in the reports folder by filename
+        reports_folder = cls.get_reports_folder()
+        filename = os.path.basename(file_path)
+        fallback_path = os.path.join(reports_folder, filename)
+        if os.path.exists(fallback_path):
+            return fallback_path
+        
+        return None
+    
+    @classmethod
+    def regenerate_report(cls, task_id: int, scan_results: Dict[str, List[Dict[str, Any]]],
+                         project_metadata: Dict[str, Any],
+                         network_diagram_path: str = None,
+                         regions: List[str] = None) -> Report:
+        """
+        Regenerate a report for a task, replacing the existing one.
+        
+        Args:
+            task_id: ID of the associated task
+            scan_results: Dictionary mapping service keys to lists of resources
+            project_metadata: Project metadata for the report
+            network_diagram_path: Optional path to network diagram image
+            regions: Optional list of regions being scanned (for multi-region heading display)
+            
+        Returns:
+            Updated Report model instance
+        """
+        # Delete existing report if any
+        existing_report = Report.query.filter_by(task_id=task_id).first()
+        if existing_report:
+            cls.delete_report(existing_report.id)
+        
+        # Generate new report
+        return cls.generate_and_store_report(
+            task_id=task_id,
+            scan_results=scan_results,
+            project_metadata=project_metadata,
+            network_diagram_path=network_diagram_path,
+            regions=regions
+        )

+ 4 - 0
backend/app/tasks/__init__.py

@@ -0,0 +1,4 @@
+# Celery tasks package
+from app.tasks.scan_tasks import scan_aws_resources, cleanup_old_reports, validate_credentials
+
+__all__ = ['scan_aws_resources', 'cleanup_old_reports', 'validate_credentials']

+ 210 - 0
backend/app/tasks/mock_tasks.py

@@ -0,0 +1,210 @@
+"""
+Mock任务模块 - 用于没有Redis时的开发测试
+
+当Redis不可用时,可以使用这个模块来模拟Celery任务
+"""
+import time
+import threading
+from typing import Dict, Any, List
+import uuid
+
+
+class MockAsyncResult:
+    """模拟Celery AsyncResult"""
+    
+    def __init__(self, task_id: str):
+        self.id = task_id
+        self.state = 'PENDING'
+        self.info = {}
+    
+    def ready(self) -> bool:
+        return self.state in ['SUCCESS', 'FAILURE']
+    
+    def successful(self) -> bool:
+        return self.state == 'SUCCESS'
+    
+    def failed(self) -> bool:
+        return self.state == 'FAILURE'
+
+
+class MockCeleryTask:
+    """模拟Celery任务"""
+    
+    def __init__(self, func):
+        self.func = func
+        self._results = {}
+    
+    def delay(self, *args, **kwargs):
+        """模拟异步执行"""
+        task_id = str(uuid.uuid4())
+        
+        # 创建结果对象
+        result = MockAsyncResult(task_id)
+        self._results[task_id] = result
+        
+        # 在后台线程中执行任务
+        def run_task():
+            try:
+                result.state = 'PROGRESS'
+                result.info = {'progress': 0}
+                
+                # 执行实际任务
+                task_result = self.func(*args, **kwargs)
+                
+                result.state = 'SUCCESS'
+                result.info = task_result
+                
+            except Exception as e:
+                result.state = 'FAILURE'
+                result.info = {'error': str(e)}
+        
+        thread = threading.Thread(target=run_task)
+        thread.daemon = True
+        thread.start()
+        
+        return result
+    
+    def apply_async(self, args=None, kwargs=None):
+        """模拟apply_async"""
+        args = args or []
+        kwargs = kwargs or {}
+        return self.delay(*args, **kwargs)
+
+
+def mock_scan_aws_resources(task_id: int, credential_ids: List[int], 
+                           regions: List[str], project_metadata: Dict[str, Any]) -> Dict[str, Any]:
+    """
+    模拟AWS资源扫描任务
+    
+    这个函数会模拟扫描过程,但不会实际调用AWS API
+    """
+    from app import db
+    from app.models import Task, TaskLog, Report
+    import os
+    
+    print(f"🔄 Mock: 开始扫描任务 {task_id}")
+    
+    # 更新任务状态
+    task = db.session.get(Task, task_id)
+    if not task:
+        return {'status': 'error', 'message': 'Task not found'}
+    
+    try:
+        task.status = 'running'
+        task.progress = 0
+        db.session.commit()
+        
+        # 添加开始日志
+        log = TaskLog(
+            task_id=task_id,
+            level='info',
+            message='Mock scan started',
+            details='{"mode": "mock", "credentials": ' + str(len(credential_ids)) + ', "regions": ' + str(len(regions)) + '}'
+        )
+        db.session.add(log)
+        db.session.commit()
+        
+        # 模拟扫描过程
+        steps = [
+            (20, "初始化扫描环境"),
+            (40, "扫描VPC资源"),
+            (60, "扫描EC2实例"),
+            (80, "扫描RDS数据库"),
+            (100, "生成报告")
+        ]
+        
+        for progress, message in steps:
+            time.sleep(2)  # 模拟扫描时间
+            task.progress = progress
+            
+            # 添加进度日志
+            log = TaskLog(
+                task_id=task_id,
+                level='info',
+                message=f'Mock: {message}',
+                details=f'{{"progress": {progress}}}'
+            )
+            db.session.add(log)
+            db.session.commit()
+            
+            print(f"🔄 Mock: {message} ({progress}%)")
+        
+        # 模拟生成报告
+        reports_folder = 'reports'
+        os.makedirs(reports_folder, exist_ok=True)
+        
+        report_filename = f"mock-report-{task_id}-{int(time.time())}.docx"
+        report_path = os.path.join(reports_folder, report_filename)
+        
+        # 创建一个简单的文本文件作为模拟报告
+        with open(report_path, 'w', encoding='utf-8') as f:
+            f.write(f"""Mock AWS Resource Scan Report
+            
+Task ID: {task_id}
+Project: {project_metadata.get('projectName', 'Unknown')}
+Client: {project_metadata.get('clientName', 'Unknown')}
+Regions: {', '.join(regions)}
+Credentials: {len(credential_ids)} accounts
+
+This is a mock report generated for testing purposes.
+No actual AWS resources were scanned.
+
+Generated at: {time.strftime('%Y-%m-%d %H:%M:%S')}
+""")
+        
+        # 创建报告记录
+        report = Report(
+            task_id=task_id,
+            file_name=report_filename,
+            file_path=report_path,
+            file_size=os.path.getsize(report_path)
+        )
+        db.session.add(report)
+        
+        # 完成任务
+        task.status = 'completed'
+        task.progress = 100
+        
+        # 添加完成日志
+        log = TaskLog(
+            task_id=task_id,
+            level='info',
+            message='Mock scan completed successfully',
+            details=f'{{"report_path": "{report_path}", "file_size": {report.file_size}}}'
+        )
+        db.session.add(log)
+        db.session.commit()
+        
+        print(f"✅ Mock: 任务 {task_id} 完成")
+        
+        return {
+            'status': 'success',
+            'message': 'Mock scan completed',
+            'resources_found': 42,  # 模拟找到的资源数量
+            'report_path': report_path
+        }
+        
+    except Exception as e:
+        # 处理错误
+        task.status = 'failed'
+        
+        # 添加错误日志
+        log = TaskLog(
+            task_id=task_id,
+            level='error',
+            message=f'Mock scan failed: {str(e)}',
+            details=f'{{"error_type": "{type(e).__name__}", "error_message": "{str(e)}"}}'
+        )
+        db.session.add(log)
+        db.session.commit()
+        
+        print(f"❌ Mock: 任务 {task_id} 失败: {e}")
+        
+        return {
+            'status': 'error',
+            'message': f'Mock scan failed: {str(e)}'
+        }
+
+
+# 创建模拟任务
+scan_aws_resources = MockCeleryTask(mock_scan_aws_resources)

+ 522 - 0
backend/app/tasks/scan_tasks.py

@@ -0,0 +1,522 @@
+"""
+Celery Tasks for AWS Resource Scanning
+
+This module contains Celery tasks for executing AWS resource scans
+and generating reports.
+
+Requirements:
+    - 4.1: Dispatch tasks to Celery queue for Worker processing
+    - 4.9: Report progress updates to Redis
+    - 4.10: Retry up to 3 times with exponential backoff
+    - 8.2: Record error details in task record
+"""
+
+from datetime import datetime, timedelta, timezone
+from typing import List, Dict, Any
+from celery.exceptions import SoftTimeLimitExceeded
+import traceback
+
+from app import db
+from app.celery_app import celery_app
+from app.models import Task, TaskLog, Report
+from app.services.report_generator import ReportGenerator, generate_report_filename
+from app.scanners import AWSScanner, create_credential_provider_from_model
+from app.errors import (
+    TaskError, ScanError, ReportGenerationError, TaskErrorLogger,
+    ErrorCode, log_error
+)
+
+
+def update_task_status(task_id: int, status: str, **kwargs) -> None:
+    """Update task status in database"""
+    task = Task.query.get(task_id)
+    if task:
+        task.status = status
+        if status == 'running' and not task.started_at:
+            task.started_at = datetime.now(timezone.utc)
+        if status in ('completed', 'failed'):
+            task.completed_at = datetime.now(timezone.utc)
+        for key, value in kwargs.items():
+            if hasattr(task, key):
+                setattr(task, key, value)
+        db.session.commit()
+
+
+def update_task_progress(task_id: int, progress: int) -> None:
+    """Update task progress in database"""
+    task = Task.query.get(task_id)
+    if task:
+        task.progress = progress
+        db.session.commit()
+
+
+def log_task_message(task_id: int, level: str, message: str, details: Any = None) -> None:
+    """
+    Log a message for a task.
+    
+    Requirements:
+        - 8.2: Record error details in task record
+    """
+    import json
+    
+    # Handle details serialization
+    if details is not None:
+        if isinstance(details, dict):
+            details_json = json.dumps(details)
+        elif isinstance(details, str):
+            details_json = json.dumps({'info': details})
+        else:
+            details_json = json.dumps({'data': str(details)})
+    else:
+        details_json = None
+    
+    log = TaskLog(
+        task_id=task_id,
+        level=level,
+        message=message,
+        details=details_json
+    )
+    db.session.add(log)
+    db.session.commit()
+
+
+def log_task_error_with_trace(
+    task_id: int,
+    error: Exception,
+    service: str = None,
+    region: str = None,
+    context: Dict[str, Any] = None
+) -> None:
+    """
+    Log an error for a task with full stack trace.
+    
+    Requirements:
+        - 8.1: Log errors with timestamp, context, and stack trace
+        - 8.2: Record error details in task record
+    """
+    import json
+    
+    # Build comprehensive error details
+    error_details = {
+        'error_type': type(error).__name__,
+        'timestamp': datetime.now(timezone.utc).isoformat(),
+        'stack_trace': traceback.format_exc()
+    }
+    
+    if service:
+        error_details['service'] = service
+    if region:
+        error_details['region'] = region
+    if context:
+        error_details['context'] = context
+    
+    # Create task log entry
+    log = TaskLog(
+        task_id=task_id,
+        level='error',
+        message=str(error),
+        details=json.dumps(error_details)
+    )
+    db.session.add(log)
+    db.session.commit()
+    
+    # Also log to application logger
+    log_error(error, context={'task_id': task_id, 'service': service, 'region': region})
+
+
+@celery_app.task(bind=True, max_retries=3, default_retry_delay=60)
+def scan_aws_resources(
+    self,
+    task_id: int,
+    credential_ids: List[int],
+    regions: List[str],
+    project_metadata: Dict[str, Any]
+) -> Dict[str, Any]:
+    """
+    Execute AWS resource scanning task
+    
+    Requirements:
+        - 4.1: Dispatch tasks to Celery queue for Worker processing
+        - 4.9: Report progress updates to Redis
+        - 4.10: Retry up to 3 times with exponential backoff
+        - 8.2: Record error details in task record
+    
+    Args:
+        task_id: Database task ID
+        credential_ids: List of AWS credential IDs to use
+        regions: List of regions to scan
+        project_metadata: Project metadata for report generation
+    
+    Returns:
+        Scan results and report path
+    """
+    import os
+    from flask import current_app
+    from app.models import AWSCredential, BaseAssumeRoleConfig
+    
+    try:
+        # Update task status to running
+        update_task_status(task_id, 'running')
+        log_task_message(task_id, 'info', 'Task started', {
+            'credential_ids': credential_ids,
+            'regions': regions
+        })
+        
+        # Store Celery task ID
+        task = Task.query.get(task_id)
+        if task:
+            task.celery_task_id = self.request.id
+            db.session.commit()
+        
+        # Get base assume role config if needed
+        base_config = BaseAssumeRoleConfig.query.first()
+        
+        # Collect all scan results
+        all_results = {}
+        total_steps = len(credential_ids) * len(regions)
+        current_step = 0
+        scan_errors = []
+        
+        # Track if global services have been scanned for each credential
+        global_services_scanned = set()
+        
+        for cred_id in credential_ids:
+            credential = AWSCredential.query.get(cred_id)
+            if not credential:
+                log_task_message(task_id, 'warning', f'Credential {cred_id} not found, skipping', {
+                    'credential_id': cred_id
+                })
+                continue
+            
+            try:
+                # Get AWS credentials
+                cred_provider = create_credential_provider_from_model(credential, base_config)
+                scanner = AWSScanner(cred_provider)
+                
+                # Scan global services only once per credential
+                if cred_id not in global_services_scanned:
+                    try:
+                        log_task_message(task_id, 'info', f'Scanning global services for account {credential.account_id}', {
+                            'account_id': credential.account_id
+                        })
+                        
+                        # Scan only global services
+                        global_scan_result = scanner.scan_resources(
+                            regions=['us-east-1'],  # Global services use us-east-1
+                            services=scanner.global_services
+                        )
+                        
+                        # Merge global results
+                        for service_key, resources in global_scan_result.resources.items():
+                            if service_key not in all_results:
+                                all_results[service_key] = []
+                            for resource in resources:
+                                if hasattr(resource, 'to_dict'):
+                                    resource_dict = resource.to_dict()
+                                elif isinstance(resource, dict):
+                                    resource_dict = resource.copy()
+                                else:
+                                    resource_dict = {
+                                        'account_id': getattr(resource, 'account_id', credential.account_id),
+                                        'region': getattr(resource, 'region', 'global'),
+                                        'service': getattr(resource, 'service', service_key),
+                                        'resource_type': getattr(resource, 'resource_type', ''),
+                                        'resource_id': getattr(resource, 'resource_id', ''),
+                                        'name': getattr(resource, 'name', ''),
+                                        'attributes': getattr(resource, 'attributes', {})
+                                    }
+                                resource_dict['account_id'] = resource_dict.get('account_id') or credential.account_id
+                                all_results[service_key].append(resource_dict)
+                        
+                        for error in global_scan_result.errors:
+                            error_msg = error.get('error', 'Unknown error')
+                            error_service = error.get('service', 'unknown')
+                            log_task_message(task_id, 'warning', f"Scan error in {error_service}: {error_msg}", {
+                                'service': error_service,
+                                'error': error_msg
+                            })
+                            scan_errors.append(error)
+                        
+                        global_services_scanned.add(cred_id)
+                        
+                    except Exception as e:
+                        log_task_error_with_trace(
+                            task_id=task_id,
+                            error=e,
+                            service='global_services',
+                            context={'account_id': credential.account_id}
+                        )
+                        scan_errors.append({
+                            'service': 'global_services',
+                            'error': str(e)
+                        })
+                
+                # Get regional services (exclude global services)
+                regional_services = [s for s in scanner.supported_services if s not in scanner.global_services]
+                
+                for region in regions:
+                    try:
+                        # Scan resources in this region (only regional services)
+                        log_task_message(task_id, 'info', f'Scanning region {region} for account {credential.account_id}', {
+                            'region': region,
+                            'account_id': credential.account_id
+                        })
+                        
+                        # Use scan_resources for regional services only
+                        scan_result = scanner.scan_resources(
+                            regions=[region],
+                            services=regional_services
+                        )
+                        
+                        # Merge results, converting ResourceData to dict
+                        for service_key, resources in scan_result.resources.items():
+                            if service_key not in all_results:
+                                all_results[service_key] = []
+                            for resource in resources:
+                                # Convert ResourceData to dict using to_dict() method
+                                if hasattr(resource, 'to_dict'):
+                                    resource_dict = resource.to_dict()
+                                elif isinstance(resource, dict):
+                                    resource_dict = resource.copy()
+                                else:
+                                    # Fallback: try to access attributes directly
+                                    resource_dict = {
+                                        'account_id': getattr(resource, 'account_id', credential.account_id),
+                                        'region': getattr(resource, 'region', region),
+                                        'service': getattr(resource, 'service', service_key),
+                                        'resource_type': getattr(resource, 'resource_type', ''),
+                                        'resource_id': getattr(resource, 'resource_id', ''),
+                                        'name': getattr(resource, 'name', ''),
+                                        'attributes': getattr(resource, 'attributes', {})
+                                    }
+                                
+                                # Ensure account_id and region are set
+                                resource_dict['account_id'] = resource_dict.get('account_id') or credential.account_id
+                                resource_dict['region'] = resource_dict.get('region') or region
+                                all_results[service_key].append(resource_dict)
+                        
+                        # Log any errors from the scan (Requirements 8.2)
+                        for error in scan_result.errors:
+                            error_msg = error.get('error', 'Unknown error')
+                            error_service = error.get('service', 'unknown')
+                            error_region = error.get('region', region)
+                            
+                            log_task_message(task_id, 'warning', f"Scan error in {error_service}: {error_msg}", {
+                                'service': error_service,
+                                'region': error_region,
+                                'error': error_msg
+                            })
+                            scan_errors.append(error)
+                        
+                    except Exception as e:
+                        # Log error with full stack trace (Requirements 8.1, 8.2)
+                        log_task_error_with_trace(
+                            task_id=task_id,
+                            error=e,
+                            service='region_scan',
+                            region=region,
+                            context={'account_id': credential.account_id}
+                        )
+                        scan_errors.append({
+                            'service': 'region_scan',
+                            'region': region,
+                            'error': str(e)
+                        })
+                    
+                    # Update progress
+                    current_step += 1
+                    progress = int((current_step / total_steps) * 90)  # Reserve 10% for report generation
+                    self.update_state(
+                        state='PROGRESS',
+                        meta={'progress': progress, 'current': current_step, 'total': total_steps}
+                    )
+                    update_task_progress(task_id, progress)
+                    
+            except Exception as e:
+                # Log credential-level error with full stack trace
+                log_task_error_with_trace(
+                    task_id=task_id,
+                    error=e,
+                    service='credential',
+                    context={'credential_id': cred_id, 'account_id': credential.account_id if credential else None}
+                )
+                scan_errors.append({
+                    'service': 'credential',
+                    'credential_id': cred_id,
+                    'error': str(e)
+                })
+        
+        # Generate report
+        log_task_message(task_id, 'info', 'Generating report...', {
+            'total_services': len(all_results),
+            'total_errors': len(scan_errors)
+        })
+        update_task_progress(task_id, 95)
+        
+        report_path = None
+        try:
+            # Get reports folder - use absolute path to ensure consistency
+            reports_folder = current_app.config.get('REPORTS_FOLDER', 'reports')
+            if not os.path.isabs(reports_folder):
+                # Convert to absolute path relative to the app root
+                reports_folder = os.path.abspath(reports_folder)
+            os.makedirs(reports_folder, exist_ok=True)
+            
+            # Generate filename and path
+            filename = generate_report_filename(project_metadata)
+            report_path = os.path.join(reports_folder, filename)
+            
+            # Get network diagram path if provided
+            network_diagram_path = project_metadata.get('network_diagram_path')
+            
+            # Generate the report
+            generator = ReportGenerator()
+            result = generator.generate_report(
+                scan_results=all_results,
+                project_metadata=project_metadata,
+                output_path=report_path,
+                network_diagram_path=network_diagram_path,
+                regions=regions
+            )
+            
+            # Create report record in database
+            report = Report(
+                task_id=task_id,
+                file_name=result['file_name'],
+                file_path=result['file_path'],
+                file_size=result['file_size']
+            )
+            db.session.add(report)
+            db.session.commit()
+            
+            log_task_message(task_id, 'info', f'Report generated: {filename}', {
+                'file_name': filename,
+                'file_size': result['file_size']
+            })
+            
+        except Exception as e:
+            # Log report generation error with full stack trace
+            log_task_error_with_trace(
+                task_id=task_id,
+                error=e,
+                service='report_generation',
+                context={'project_metadata': project_metadata}
+            )
+            report_path = None
+        
+        # Update task status to completed
+        update_task_status(task_id, 'completed', progress=100)
+        log_task_message(task_id, 'info', 'Task completed successfully', {
+            'total_resources': sum(len(r) for r in all_results.values()),
+            'total_errors': len(scan_errors),
+            'report_generated': report_path is not None
+        })
+        
+        return {
+            'status': 'success',
+            'report_path': report_path,
+            'total_resources': sum(len(r) for r in all_results.values()),
+            'total_errors': len(scan_errors)
+        }
+        
+    except SoftTimeLimitExceeded as e:
+        # Log timeout error with full context
+        log_task_error_with_trace(
+            task_id=task_id,
+            error=e,
+            service='task_execution',
+            context={'error_type': 'timeout'}
+        )
+        update_task_status(task_id, 'failed')
+        raise
+        
+    except Exception as e:
+        # Log error with full stack trace (Requirements 8.1)
+        log_task_error_with_trace(
+            task_id=task_id,
+            error=e,
+            service='task_execution',
+            context={'retry_count': self.request.retries}
+        )
+        update_task_status(task_id, 'failed')
+        
+        # Retry with exponential backoff (Requirements 4.10)
+        retry_count = self.request.retries
+        if retry_count < self.max_retries:
+            countdown = 60 * (2 ** retry_count)  # Exponential backoff
+            log_task_message(task_id, 'warning', f'Retrying task in {countdown} seconds', {
+                'attempt': retry_count + 1,
+                'max_retries': self.max_retries,
+                'countdown': countdown
+            })
+            raise self.retry(exc=e, countdown=countdown)
+        
+        raise
+
+
+@celery_app.task
+def cleanup_old_reports(days: int = 30) -> Dict[str, Any]:
+    """
+    Clean up reports older than specified days
+    
+    Args:
+        days: Number of days to keep reports
+    
+    Returns:
+        Cleanup statistics
+    """
+    import os
+    
+    cutoff_date = datetime.now(timezone.utc) - timedelta(days=days)
+    old_reports = Report.query.filter(Report.created_at < cutoff_date).all()
+    
+    deleted_count = 0
+    for report in old_reports:
+        try:
+            # Delete file if exists
+            if os.path.exists(report.file_path):
+                os.remove(report.file_path)
+            
+            # Delete database record
+            db.session.delete(report)
+            deleted_count += 1
+        except Exception as e:
+            # Log error but continue with other reports
+            print(f"Error deleting report {report.id}: {e}")
+    
+    db.session.commit()
+    
+    return {
+        'deleted_count': deleted_count,
+        'cutoff_date': cutoff_date.isoformat()
+    }
+
+
+@celery_app.task
+def validate_credentials(credential_id: int) -> Dict[str, Any]:
+    """
+    Validate AWS credentials
+    
+    Args:
+        credential_id: ID of the credential to validate
+    
+    Returns:
+        Validation result
+    """
+    from app.models import AWSCredential
+    
+    credential = AWSCredential.query.get(credential_id)
+    if not credential:
+        return {'valid': False, 'message': 'Credential not found'}
+    
+    # TODO: Implement actual AWS credential validation in Task 6
+    # This is a placeholder
+    try:
+        # Placeholder for actual validation
+        # session = get_aws_session(credential)
+        # sts = session.client('sts')
+        # sts.get_caller_identity()
+        
+        return {'valid': True, 'message': 'Credential is valid'}
+    except Exception as e:
+        return {'valid': False, 'message': str(e)}

+ 2 - 0
backend/app/utils/__init__.py

@@ -0,0 +1,2 @@
+# Utility functions will be imported here after they are created
+# This file serves as the utils package entry point

+ 43 - 0
backend/app/utils/encryption.py

@@ -0,0 +1,43 @@
+import os
+import base64
+from cryptography.fernet import Fernet
+from cryptography.hazmat.primitives import hashes
+from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
+
+
+def get_encryption_key() -> bytes:
+    """Get or generate encryption key from environment variable"""
+    key_string = os.environ.get('ENCRYPTION_KEY', 'default-encryption-key-change-in-production')
+    
+    # Derive a proper Fernet key from the string
+    kdf = PBKDF2HMAC(
+        algorithm=hashes.SHA256(),
+        length=32,
+        salt=b'aws-scanner-salt',  # In production, use a proper random salt
+        iterations=100000,
+    )
+    key = base64.urlsafe_b64encode(kdf.derive(key_string.encode()))
+    return key
+
+
+def encrypt_value(value: str) -> str:
+    """Encrypt a string value"""
+    if not value:
+        return None
+    
+    key = get_encryption_key()
+    f = Fernet(key)
+    encrypted = f.encrypt(value.encode('utf-8'))
+    return base64.urlsafe_b64encode(encrypted).decode('utf-8')
+
+
+def decrypt_value(encrypted_value: str) -> str:
+    """Decrypt an encrypted string value"""
+    if not encrypted_value:
+        return None
+    
+    key = get_encryption_key()
+    f = Fernet(key)
+    encrypted_bytes = base64.urlsafe_b64decode(encrypted_value.encode('utf-8'))
+    decrypted = f.decrypt(encrypted_bytes)
+    return decrypted.decode('utf-8')

+ 94 - 0
backend/apply_migration.py

@@ -0,0 +1,94 @@
+#!/usr/bin/env python
+"""
+应用数据库迁移脚本
+
+用法:
+    python apply_migration.py
+"""
+import os
+import sys
+
+# 添加项目根目录到路径
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
+from app import create_app, db
+from sqlalchemy import text
+
+def apply_migration():
+    """应用account_id字段可空的迁移"""
+    app = create_app()
+    
+    with app.app_context():
+        try:
+            print("正在应用数据库迁移...")
+            
+            # 检查当前表结构
+            result = db.engine.execute(text("PRAGMA table_info(aws_credentials)"))
+            columns = result.fetchall()
+            
+            account_id_nullable = False
+            for column in columns:
+                if column[1] == 'account_id':  # column[1] is column name
+                    account_id_nullable = column[3] == 0  # column[3] is notnull (0=nullable, 1=not null)
+                    break
+            
+            if account_id_nullable:
+                print("✓ account_id字段已经是可空的")
+                return
+            
+            print("正在修改account_id字段为可空...")
+            
+            # SQLite doesn't support ALTER COLUMN directly, so we need to recreate the table
+            # First, create a backup of the data
+            db.engine.execute(text("""
+                CREATE TABLE aws_credentials_backup AS 
+                SELECT * FROM aws_credentials
+            """))
+            
+            # Drop the original table
+            db.engine.execute(text("DROP TABLE aws_credentials"))
+            
+            # Recreate the table with nullable account_id
+            db.engine.execute(text("""
+                CREATE TABLE aws_credentials (
+                    id INTEGER PRIMARY KEY,
+                    name VARCHAR(100) NOT NULL,
+                    credential_type VARCHAR(20) NOT NULL,
+                    account_id VARCHAR(12),  -- Now nullable
+                    role_arn VARCHAR(255),
+                    external_id VARCHAR(255),
+                    access_key_id VARCHAR(255),
+                    secret_access_key_encrypted TEXT,
+                    created_at DATETIME,
+                    is_active BOOLEAN,
+                    CHECK (credential_type IN ('assume_role', 'access_key'))
+                )
+            """))
+            
+            # Create index on account_id
+            db.engine.execute(text("CREATE INDEX ix_aws_credentials_account_id ON aws_credentials (account_id)"))
+            
+            # Restore the data
+            db.engine.execute(text("""
+                INSERT INTO aws_credentials 
+                SELECT * FROM aws_credentials_backup
+            """))
+            
+            # Drop the backup table
+            db.engine.execute(text("DROP TABLE aws_credentials_backup"))
+            
+            print("✓ 数据库迁移完成")
+            
+        except Exception as e:
+            print(f"❌ 迁移失败: {e}")
+            # Try to restore from backup if it exists
+            try:
+                db.engine.execute(text("DROP TABLE IF EXISTS aws_credentials"))
+                db.engine.execute(text("ALTER TABLE aws_credentials_backup RENAME TO aws_credentials"))
+                print("已恢复原始数据")
+            except:
+                pass
+            raise
+
+if __name__ == '__main__':
+    apply_migration()

+ 15 - 0
backend/celery_worker.py

@@ -0,0 +1,15 @@
+#!/usr/bin/env python
+"""Celery worker entry point"""
+import os
+from app import create_app
+from app.celery_app import celery_app, init_celery
+
+# Create Flask app and initialize Celery
+flask_app = create_app(os.environ.get('FLASK_ENV', 'development'))
+init_celery(flask_app)
+
+# Import tasks to register them
+from app.tasks import scan_tasks  # noqa: F401
+
+if __name__ == '__main__':
+    celery_app.start()

+ 3 - 0
backend/config/__init__.py

@@ -0,0 +1,3 @@
+from .settings import Config, DevelopmentConfig, TestingConfig, ProductionConfig
+
+__all__ = ['Config', 'DevelopmentConfig', 'TestingConfig', 'ProductionConfig']

+ 70 - 0
backend/config/settings.py

@@ -0,0 +1,70 @@
+import os
+from datetime import timedelta
+
+class Config:
+    """Base configuration"""
+    SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production')
+    
+    # JWT Configuration
+    JWT_SECRET_KEY = os.environ.get('JWT_SECRET_KEY', 'jwt-secret-key-change-in-production')
+    JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=1)
+    JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=7)
+    
+    # SQLAlchemy Configuration
+    SQLALCHEMY_TRACK_MODIFICATIONS = False
+    
+    # Celery Configuration
+    CELERY_BROKER_URL = os.environ.get('CELERY_BROKER_URL', 'redis://localhost:6379/0')
+    CELERY_RESULT_BACKEND = os.environ.get('CELERY_RESULT_BACKEND', 'redis://localhost:6379/1')
+    
+    # File Storage
+    UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER', 'uploads')
+    REPORTS_FOLDER = os.environ.get('REPORTS_FOLDER', 'reports')
+    MAX_CONTENT_LENGTH = 16 * 1024 * 1024  # 16MB max file size
+    
+    # Encryption key for sensitive data
+    ENCRYPTION_KEY = os.environ.get('ENCRYPTION_KEY', 'encryption-key-change-in-production')
+
+
+class DevelopmentConfig(Config):
+    """Development configuration"""
+    DEBUG = True
+    SQLALCHEMY_DATABASE_URI = os.environ.get(
+        'DATABASE_URL', 
+        'sqlite:///dev.db'
+    )
+
+
+class TestingConfig(Config):
+    """Testing configuration"""
+    TESTING = True
+    DEBUG = True
+    SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
+    
+    # Use shorter token expiry for testing
+    JWT_ACCESS_TOKEN_EXPIRES = timedelta(minutes=5)
+
+
+class ProductionConfig(Config):
+    """Production configuration"""
+    DEBUG = False
+    SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL')
+    
+    # Ensure these are set in production
+    @classmethod
+    def init_app(cls, app):
+        Config.init_app(app)
+        
+        # Validate required environment variables
+        required_vars = ['SECRET_KEY', 'JWT_SECRET_KEY', 'DATABASE_URL', 'ENCRYPTION_KEY']
+        missing = [var for var in required_vars if not os.environ.get(var)]
+        if missing:
+            raise ValueError(f"Missing required environment variables: {', '.join(missing)}")
+
+
+config = {
+    'development': DevelopmentConfig,
+    'testing': TestingConfig,
+    'production': ProductionConfig,
+    'default': DevelopmentConfig
+}

+ 151 - 0
backend/init_db.py

@@ -0,0 +1,151 @@
+#!/usr/bin/env python
+"""
+数据库初始化脚本
+
+功能:
+- 创建所有数据库表
+- 创建默认管理员账户
+- 可选: 创建示例数据
+
+用法:
+    python init_db.py              # 初始化数据库并创建管理员
+    python init_db.py --with-demo  # 初始化并创建示例数据
+    python init_db.py --reset      # 重置数据库(删除所有数据后重新初始化)
+"""
+import os
+import sys
+import argparse
+
+# 添加项目根目录到路径
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
+from app import create_app, db
+from app.models import User, AWSCredential, UserCredential, Task, TaskLog, Report
+
+
+def init_database(reset=False):
+    """初始化数据库表"""
+    if reset:
+        print("⚠️  正在删除所有表...")
+        db.drop_all()
+        print("✓ 所有表已删除")
+    
+    print("正在创建数据库表...")
+    db.create_all()
+    print("✓ 数据库表创建完成")
+
+
+def create_admin_user():
+    """创建默认管理员账户"""
+    admin = User.query.filter_by(username='admin').first()
+    if admin:
+        print("✓ 管理员账户已存在,跳过创建")
+        return admin
+    
+    admin = User(
+        username='admin',
+        email='admin@example.com',
+        role='admin',
+        is_active=True
+    )
+    admin.set_password('admin123')
+    
+    db.session.add(admin)
+    db.session.commit()
+    
+    print("✓ 管理员账户创建成功")
+    print("  用户名: admin")
+    print("  密码: admin123")
+    print("  ⚠️  请在生产环境中修改默认密码!")
+    
+    return admin
+
+
+def create_demo_data(admin_user):
+    """创建示例数据"""
+    print("\n正在创建示例数据...")
+    
+    # 创建示例用户
+    demo_user = User.query.filter_by(username='demo').first()
+    if not demo_user:
+        demo_user = User(
+            username='demo',
+            email='demo@example.com',
+            role='user',
+            is_active=True
+        )
+        demo_user.set_password('demo123')
+        db.session.add(demo_user)
+        print("✓ 示例用户创建成功 (demo/demo123)")
+    
+    # 创建示例 AWS 凭证 (Assume Role 类型)
+    demo_credential = AWSCredential.query.filter_by(name='Demo Account').first()
+    if not demo_credential:
+        demo_credential = AWSCredential(
+            name='Demo Account',
+            credential_type='assume_role',
+            account_id='123456789012',
+            role_arn='arn:aws:iam::123456789012:role/DemoRole',
+            external_id='demo-external-id',
+            is_active=True
+        )
+        db.session.add(demo_credential)
+        print("✓ 示例 AWS 凭证创建成功")
+    
+    db.session.commit()
+    
+    # 关联凭证到管理员
+    user_cred = UserCredential.query.filter_by(
+        user_id=admin_user.id, 
+        credential_id=demo_credential.id
+    ).first()
+    if not user_cred:
+        user_cred = UserCredential(
+            user_id=admin_user.id,
+            credential_id=demo_credential.id
+        )
+        db.session.add(user_cred)
+        db.session.commit()
+        print("✓ 凭证已关联到管理员账户")
+    
+    print("✓ 示例数据创建完成")
+
+
+def main():
+    parser = argparse.ArgumentParser(description='数据库初始化脚本')
+    parser.add_argument('--with-demo', action='store_true', help='创建示例数据')
+    parser.add_argument('--reset', action='store_true', help='重置数据库(删除所有数据)')
+    parser.add_argument('--env', default='development', help='运行环境 (development/testing/production)')
+    args = parser.parse_args()
+    
+    if args.reset:
+        confirm = input("⚠️  确定要删除所有数据吗? 输入 'yes' 确认: ")
+        if confirm.lower() != 'yes':
+            print("操作已取消")
+            return
+    
+    print(f"\n{'='*50}")
+    print(f"数据库初始化 - 环境: {args.env}")
+    print(f"{'='*50}\n")
+    
+    # 创建应用上下文
+    app = create_app(args.env)
+    
+    with app.app_context():
+        # 初始化数据库
+        init_database(reset=args.reset)
+        
+        # 创建管理员
+        admin = create_admin_user()
+        
+        # 创建示例数据
+        if args.with_demo:
+            create_demo_data(admin)
+        
+        print(f"\n{'='*50}")
+        print("✓ 数据库初始化完成!")
+        print(f"{'='*50}\n")
+
+
+if __name__ == '__main__':
+    main()

+ 1 - 0
backend/migrations/README

@@ -0,0 +1 @@
+Single-database configuration for Flask.

+ 50 - 0
backend/migrations/alembic.ini

@@ -0,0 +1,50 @@
+# A generic, single database configuration.
+
+[alembic]
+# template used to generate migration files
+# file_template = %%(rev)s_%%(slug)s
+
+# set to 'true' to run the environment during
+# the 'revision' command, regardless of autogenerate
+# revision_environment = false
+
+
+# Logging configuration
+[loggers]
+keys = root,sqlalchemy,alembic,flask_migrate
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+qualname =
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[logger_flask_migrate]
+level = INFO
+handlers =
+qualname = flask_migrate
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
+datefmt = %H:%M:%S

+ 113 - 0
backend/migrations/env.py

@@ -0,0 +1,113 @@
+import logging
+from logging.config import fileConfig
+
+from flask import current_app
+
+from alembic import context
+
+# this is the Alembic Config object, which provides
+# access to the values within the .ini file in use.
+config = context.config
+
+# Interpret the config file for Python logging.
+# This line sets up loggers basically.
+fileConfig(config.config_file_name)
+logger = logging.getLogger('alembic.env')
+
+
+def get_engine():
+    try:
+        # this works with Flask-SQLAlchemy<3 and Alchemical
+        return current_app.extensions['migrate'].db.get_engine()
+    except (TypeError, AttributeError):
+        # this works with Flask-SQLAlchemy>=3
+        return current_app.extensions['migrate'].db.engine
+
+
+def get_engine_url():
+    try:
+        return get_engine().url.render_as_string(hide_password=False).replace(
+            '%', '%%')
+    except AttributeError:
+        return str(get_engine().url).replace('%', '%%')
+
+
+# add your model's MetaData object here
+# for 'autogenerate' support
+# from myapp import mymodel
+# target_metadata = mymodel.Base.metadata
+config.set_main_option('sqlalchemy.url', get_engine_url())
+target_db = current_app.extensions['migrate'].db
+
+# other values from the config, defined by the needs of env.py,
+# can be acquired:
+# my_important_option = config.get_main_option("my_important_option")
+# ... etc.
+
+
+def get_metadata():
+    if hasattr(target_db, 'metadatas'):
+        return target_db.metadatas[None]
+    return target_db.metadata
+
+
+def run_migrations_offline():
+    """Run migrations in 'offline' mode.
+
+    This configures the context with just a URL
+    and not an Engine, though an Engine is acceptable
+    here as well.  By skipping the Engine creation
+    we don't even need a DBAPI to be available.
+
+    Calls to context.execute() here emit the given string to the
+    script output.
+
+    """
+    url = config.get_main_option("sqlalchemy.url")
+    context.configure(
+        url=url, target_metadata=get_metadata(), literal_binds=True
+    )
+
+    with context.begin_transaction():
+        context.run_migrations()
+
+
+def run_migrations_online():
+    """Run migrations in 'online' mode.
+
+    In this scenario we need to create an Engine
+    and associate a connection with the context.
+
+    """
+
+    # this callback is used to prevent an auto-migration from being generated
+    # when there are no changes to the schema
+    # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
+    def process_revision_directives(context, revision, directives):
+        if getattr(config.cmd_opts, 'autogenerate', False):
+            script = directives[0]
+            if script.upgrade_ops.is_empty():
+                directives[:] = []
+                logger.info('No changes in schema detected.')
+
+    conf_args = current_app.extensions['migrate'].configure_args
+    if conf_args.get("process_revision_directives") is None:
+        conf_args["process_revision_directives"] = process_revision_directives
+
+    connectable = get_engine()
+
+    with connectable.connect() as connection:
+        context.configure(
+            connection=connection,
+            target_metadata=get_metadata(),
+            **conf_args
+        )
+
+        with context.begin_transaction():
+            context.run_migrations()
+
+
+if context.is_offline_mode():
+    run_migrations_offline()
+else:
+    run_migrations_online()

+ 24 - 0
backend/migrations/script.py.mako

@@ -0,0 +1,24 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+
+"""
+from alembic import op
+import sqlalchemy as sa
+${imports if imports else ""}
+
+# revision identifiers, used by Alembic.
+revision = ${repr(up_revision)}
+down_revision = ${repr(down_revision)}
+branch_labels = ${repr(branch_labels)}
+depends_on = ${repr(depends_on)}
+
+
+def upgrade():
+    ${upgrades if upgrades else "pass"}
+
+
+def downgrade():
+    ${downgrades if downgrades else "pass"}

+ 36 - 0
backend/migrations/versions/002_make_account_id_nullable.py

@@ -0,0 +1,36 @@
+"""Make account_id nullable for access_key credentials
+
+Revision ID: 002
+Revises: 001
+Create Date: 2026-01-02 00:00:00.000000
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers
+revision = '002'
+down_revision = '001'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    """Make account_id column nullable for access_key type credentials"""
+    # Make account_id nullable
+    with op.batch_alter_table('aws_credentials', schema=None) as batch_op:
+        batch_op.alter_column('account_id',
+                              existing_type=sa.String(12),
+                              nullable=True)
+
+
+def downgrade():
+    """Revert account_id column to not nullable"""
+    # First, update any NULL account_id values to a placeholder
+    op.execute("UPDATE aws_credentials SET account_id = '000000000000' WHERE account_id IS NULL")
+    
+    # Make account_id not nullable again
+    with op.batch_alter_table('aws_credentials', schema=None) as batch_op:
+        batch_op.alter_column('account_id',
+                              existing_type=sa.String(12),
+                              nullable=False)

+ 148 - 0
backend/migrations/versions/7aa055089aea_initial_migration_create_all_tables.py

@@ -0,0 +1,148 @@
+"""Initial migration - create all tables
+
+Revision ID: 7aa055089aea
+Revises: 
+Create Date: 2026-01-02 00:45:00.414032
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '7aa055089aea'
+down_revision = None
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.create_table('aws_credentials',
+    sa.Column('id', sa.Integer(), nullable=False),
+    sa.Column('name', sa.String(length=100), nullable=False),
+    sa.Column('credential_type', sa.Enum('assume_role', 'access_key', name='credential_type'), nullable=False),
+    sa.Column('account_id', sa.String(length=12), nullable=False),
+    sa.Column('role_arn', sa.String(length=255), nullable=True),
+    sa.Column('external_id', sa.String(length=255), nullable=True),
+    sa.Column('access_key_id', sa.String(length=255), nullable=True),
+    sa.Column('secret_access_key_encrypted', sa.Text(), nullable=True),
+    sa.Column('created_at', sa.DateTime(), nullable=True),
+    sa.Column('is_active', sa.Boolean(), nullable=True),
+    sa.PrimaryKeyConstraint('id')
+    )
+    with op.batch_alter_table('aws_credentials', schema=None) as batch_op:
+        batch_op.create_index(batch_op.f('ix_aws_credentials_account_id'), ['account_id'], unique=False)
+
+    op.create_table('base_assume_role_config',
+    sa.Column('id', sa.Integer(), nullable=False),
+    sa.Column('access_key_id', sa.String(length=255), nullable=False),
+    sa.Column('secret_access_key_encrypted', sa.Text(), nullable=False),
+    sa.Column('updated_at', sa.DateTime(), nullable=True),
+    sa.PrimaryKeyConstraint('id')
+    )
+    op.create_table('users',
+    sa.Column('id', sa.Integer(), nullable=False),
+    sa.Column('username', sa.String(length=50), nullable=False),
+    sa.Column('email', sa.String(length=100), nullable=False),
+    sa.Column('password_hash', sa.String(length=255), nullable=False),
+    sa.Column('role', sa.Enum('admin', 'power_user', 'user', name='user_role'), nullable=True),
+    sa.Column('created_at', sa.DateTime(), nullable=True),
+    sa.Column('is_active', sa.Boolean(), nullable=True),
+    sa.PrimaryKeyConstraint('id')
+    )
+    with op.batch_alter_table('users', schema=None) as batch_op:
+        batch_op.create_index(batch_op.f('ix_users_email'), ['email'], unique=True)
+        batch_op.create_index(batch_op.f('ix_users_username'), ['username'], unique=True)
+
+    op.create_table('tasks',
+    sa.Column('id', sa.Integer(), nullable=False),
+    sa.Column('name', sa.String(length=200), nullable=False),
+    sa.Column('status', sa.Enum('pending', 'running', 'completed', 'failed', name='task_status'), nullable=True),
+    sa.Column('progress', sa.Integer(), nullable=True),
+    sa.Column('created_by', sa.Integer(), nullable=False),
+    sa.Column('created_at', sa.DateTime(), nullable=True),
+    sa.Column('started_at', sa.DateTime(), nullable=True),
+    sa.Column('completed_at', sa.DateTime(), nullable=True),
+    sa.Column('celery_task_id', sa.String(length=100), nullable=True),
+    sa.Column('credential_ids', sa.Text(), nullable=True),
+    sa.Column('regions', sa.Text(), nullable=True),
+    sa.Column('project_metadata', sa.Text(), nullable=True),
+    sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
+    sa.PrimaryKeyConstraint('id')
+    )
+    with op.batch_alter_table('tasks', schema=None) as batch_op:
+        batch_op.create_index(batch_op.f('ix_tasks_celery_task_id'), ['celery_task_id'], unique=False)
+        batch_op.create_index(batch_op.f('ix_tasks_created_at'), ['created_at'], unique=False)
+        batch_op.create_index(batch_op.f('ix_tasks_status'), ['status'], unique=False)
+
+    op.create_table('user_credentials',
+    sa.Column('id', sa.Integer(), nullable=False),
+    sa.Column('user_id', sa.Integer(), nullable=False),
+    sa.Column('credential_id', sa.Integer(), nullable=False),
+    sa.Column('assigned_at', sa.DateTime(), nullable=True),
+    sa.ForeignKeyConstraint(['credential_id'], ['aws_credentials.id'], ),
+    sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
+    sa.PrimaryKeyConstraint('id'),
+    sa.UniqueConstraint('user_id', 'credential_id', name='unique_user_credential')
+    )
+    op.create_table('reports',
+    sa.Column('id', sa.Integer(), nullable=False),
+    sa.Column('task_id', sa.Integer(), nullable=False),
+    sa.Column('file_name', sa.String(length=255), nullable=False),
+    sa.Column('file_path', sa.String(length=500), nullable=False),
+    sa.Column('file_size', sa.Integer(), nullable=True),
+    sa.Column('created_at', sa.DateTime(), nullable=True),
+    sa.ForeignKeyConstraint(['task_id'], ['tasks.id'], ),
+    sa.PrimaryKeyConstraint('id'),
+    sa.UniqueConstraint('task_id')
+    )
+    with op.batch_alter_table('reports', schema=None) as batch_op:
+        batch_op.create_index(batch_op.f('ix_reports_created_at'), ['created_at'], unique=False)
+
+    op.create_table('task_logs',
+    sa.Column('id', sa.Integer(), nullable=False),
+    sa.Column('task_id', sa.Integer(), nullable=False),
+    sa.Column('level', sa.Enum('info', 'warning', 'error', name='log_level'), nullable=True),
+    sa.Column('message', sa.Text(), nullable=False),
+    sa.Column('details', sa.Text(), nullable=True),
+    sa.Column('created_at', sa.DateTime(), nullable=True),
+    sa.ForeignKeyConstraint(['task_id'], ['tasks.id'], ),
+    sa.PrimaryKeyConstraint('id')
+    )
+    with op.batch_alter_table('task_logs', schema=None) as batch_op:
+        batch_op.create_index(batch_op.f('ix_task_logs_created_at'), ['created_at'], unique=False)
+        batch_op.create_index(batch_op.f('ix_task_logs_task_id'), ['task_id'], unique=False)
+
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    with op.batch_alter_table('task_logs', schema=None) as batch_op:
+        batch_op.drop_index(batch_op.f('ix_task_logs_task_id'))
+        batch_op.drop_index(batch_op.f('ix_task_logs_created_at'))
+
+    op.drop_table('task_logs')
+    with op.batch_alter_table('reports', schema=None) as batch_op:
+        batch_op.drop_index(batch_op.f('ix_reports_created_at'))
+
+    op.drop_table('reports')
+    op.drop_table('user_credentials')
+    with op.batch_alter_table('tasks', schema=None) as batch_op:
+        batch_op.drop_index(batch_op.f('ix_tasks_status'))
+        batch_op.drop_index(batch_op.f('ix_tasks_created_at'))
+        batch_op.drop_index(batch_op.f('ix_tasks_celery_task_id'))
+
+    op.drop_table('tasks')
+    with op.batch_alter_table('users', schema=None) as batch_op:
+        batch_op.drop_index(batch_op.f('ix_users_username'))
+        batch_op.drop_index(batch_op.f('ix_users_email'))
+
+    op.drop_table('users')
+    op.drop_table('base_assume_role_config')
+    with op.batch_alter_table('aws_credentials', schema=None) as batch_op:
+        batch_op.drop_index(batch_op.f('ix_aws_credentials_account_id'))
+
+    op.drop_table('aws_credentials')
+    # ### end Alembic commands ###

+ 13 - 0
backend/requirements-dev.txt

@@ -0,0 +1,13 @@
+# 开发环境依赖 (包含所有依赖)
+-r requirements.txt
+
+# PostgreSQL支持 (可选,仅在需要时安装)
+psycopg2-binary==2.9.9
+
+# 开发工具
+black==23.11.0
+flake8==6.1.0
+isort==5.12.0
+
+# 调试工具
+flask-debugtoolbar==0.13.1

+ 34 - 0
backend/requirements.txt

@@ -0,0 +1,34 @@
+# Flask and extensions
+Flask==3.0.0
+Flask-SQLAlchemy==3.1.1
+Flask-Migrate==4.0.5
+Flask-CORS==4.0.0
+
+# Database - Updated for Python 3.14 compatibility
+SQLAlchemy>=2.0.25
+# Use psycopg2-binary for PostgreSQL, but make it optional for development
+# psycopg2-binary==2.9.9
+
+# Authentication
+PyJWT==2.8.0
+bcrypt==4.1.1
+
+# Task Queue
+celery==5.3.4
+redis==5.0.1
+
+# AWS SDK
+boto3>=1.34.0
+botocore>=1.34.0
+
+# Document processing
+python-docx==1.1.0
+
+# Utilities
+python-dotenv==1.0.0
+cryptography>=41.0.8
+
+# Testing
+pytest>=7.4.3
+pytest-cov==4.1.0
+hypothesis>=6.92.1

+ 9 - 0
backend/run.py

@@ -0,0 +1,9 @@
+#!/usr/bin/env python
+"""Application entry point"""
+import os
+from app import create_app
+
+app = create_app(os.environ.get('FLASK_ENV', 'development'))
+
+if __name__ == '__main__':
+    app.run(host='0.0.0.0', port=5000, debug=True)

+ 32 - 0
backend/setup.bat

@@ -0,0 +1,32 @@
+@echo off
+echo ========================================
+echo Python虚拟环境快速设置 (Windows)
+echo ========================================
+echo.
+
+REM 检查Python是否安装
+python --version >nul 2>&1
+if errorlevel 1 (
+    echo ❌ Python未安装或不在PATH中
+    echo 请先安装Python 3.8+
+    pause
+    exit /b 1
+)
+
+echo ✓ 检测到Python
+python --version
+
+echo.
+echo 正在设置虚拟环境...
+python setup_venv.py
+
+echo.
+echo ========================================
+echo 设置完成! 
+echo ========================================
+echo.
+echo 激活虚拟环境: activate_venv.bat
+echo 启动应用: python run.py
+echo 初始化数据库: python init_db.py
+echo.
+pause

+ 36 - 0
backend/setup.sh

@@ -0,0 +1,36 @@
+#!/bin/bash
+
+echo "========================================"
+echo "Python虚拟环境快速设置 (Unix/Linux/macOS)"
+echo "========================================"
+echo ""
+
+# 检查Python是否安装
+if ! command -v python3 &> /dev/null; then
+    if ! command -v python &> /dev/null; then
+        echo "❌ Python未安装"
+        echo "请先安装Python 3.8+"
+        exit 1
+    else
+        PYTHON_CMD="python"
+    fi
+else
+    PYTHON_CMD="python3"
+fi
+
+echo "✓ 检测到Python"
+$PYTHON_CMD --version
+
+echo ""
+echo "正在设置虚拟环境..."
+$PYTHON_CMD setup_venv.py
+
+echo ""
+echo "========================================"
+echo "设置完成!"
+echo "========================================"
+echo ""
+echo "激活虚拟环境: source activate_venv.sh"
+echo "启动应用: python run.py"
+echo "初始化数据库: python init_db.py"
+echo ""

+ 201 - 0
backend/setup_venv.py

@@ -0,0 +1,201 @@
+#!/usr/bin/env python
+"""
+Python虚拟环境设置脚本
+
+功能:
+- 创建Python虚拟环境
+- 安装项目依赖
+- 生成激活脚本
+
+用法:
+    python setup_venv.py              # 创建venv并安装依赖
+    python setup_venv.py --upgrade    # 升级已有依赖
+    python setup_venv.py --clean      # 删除并重新创建venv
+"""
+import os
+import sys
+import subprocess
+import argparse
+from pathlib import Path
+
+
+def run_command(cmd, cwd=None, check=True):
+    """运行命令并显示输出"""
+    print(f"执行: {' '.join(cmd) if isinstance(cmd, list) else cmd}")
+    try:
+        result = subprocess.run(
+            cmd, 
+            cwd=cwd, 
+            check=check, 
+            capture_output=True, 
+            text=True,
+            shell=True if isinstance(cmd, str) else False
+        )
+        if result.stdout:
+            print(result.stdout)
+        return result
+    except subprocess.CalledProcessError as e:
+        print(f"错误: {e}")
+        if e.stderr:
+            print(f"错误输出: {e.stderr}")
+        if check:
+            sys.exit(1)
+        return e
+
+
+def check_python():
+    """检查Python版本"""
+    version = sys.version_info
+    print(f"Python版本: {version.major}.{version.minor}.{version.micro}")
+    
+    if version.major < 3 or (version.major == 3 and version.minor < 8):
+        print("❌ 需要Python 3.8或更高版本")
+        sys.exit(1)
+    
+    print("✓ Python版本符合要求")
+
+
+def create_venv(venv_path, clean=False):
+    """创建虚拟环境"""
+    if clean and venv_path.exists():
+        print(f"删除现有虚拟环境: {venv_path}")
+        import shutil
+        shutil.rmtree(venv_path)
+    
+    if venv_path.exists():
+        print(f"✓ 虚拟环境已存在: {venv_path}")
+        return
+    
+    print(f"创建虚拟环境: {venv_path}")
+    run_command([sys.executable, "-m", "venv", str(venv_path)])
+    print("✓ 虚拟环境创建成功")
+
+
+def get_venv_python(venv_path):
+    """获取虚拟环境中的Python路径"""
+    if os.name == 'nt':  # Windows
+        return venv_path / "Scripts" / "python.exe"
+    else:  # Unix/Linux/macOS
+        return venv_path / "bin" / "python"
+
+
+def get_venv_pip(venv_path):
+    """获取虚拟环境中的pip路径"""
+    if os.name == 'nt':  # Windows
+        return venv_path / "Scripts" / "pip.exe"
+    else:  # Unix/Linux/macOS
+        return venv_path / "bin" / "pip"
+
+
+def install_dependencies(venv_path, upgrade=False):
+    """安装项目依赖"""
+    python_path = get_venv_python(venv_path)
+    
+    # 升级pip (使用python -m pip方式)
+    print("升级pip...")
+    run_command([str(python_path), "-m", "pip", "install", "--upgrade", "pip"])
+    
+    # 安装依赖
+    requirements_file = Path("requirements.txt")
+    if not requirements_file.exists():
+        print("❌ requirements.txt文件不存在")
+        return
+    
+    print("安装项目依赖...")
+    cmd = [str(python_path), "-m", "pip", "install", "-r", "requirements.txt"]
+    if upgrade:
+        cmd.append("--upgrade")
+    
+    run_command(cmd)
+    print("✓ 依赖安装完成")
+
+
+def create_activation_scripts(venv_path):
+    """创建激活脚本"""
+    backend_dir = Path.cwd()
+    
+    # Windows批处理脚本
+    activate_bat = backend_dir / "activate_venv.bat"
+    with open(activate_bat, 'w', encoding='utf-8') as f:
+        f.write(f"""@echo off
+echo 激活Python虚拟环境...
+call "{venv_path}\\Scripts\\activate.bat"
+echo ✓ 虚拟环境已激活
+echo.
+echo 可用命令:
+echo   python run.py          - 启动Flask应用
+echo   python init_db.py      - 初始化数据库
+echo   celery -A celery_worker worker - 启动Celery worker
+echo   pytest                 - 运行测试
+echo.
+""")
+    
+    # Unix/Linux shell脚本
+    activate_sh = backend_dir / "activate_venv.sh"
+    with open(activate_sh, 'w', encoding='utf-8') as f:
+        f.write(f"""#!/bin/bash
+echo "激活Python虚拟环境..."
+source "{venv_path}/bin/activate"
+echo "✓ 虚拟环境已激活"
+echo ""
+echo "可用命令:"
+echo "  python run.py          - 启动Flask应用"
+echo "  python init_db.py      - 初始化数据库"
+echo "  celery -A celery_worker worker - 启动Celery worker"
+echo "  pytest                 - 运行测试"
+echo ""
+""")
+    
+    # 设置执行权限 (Unix/Linux)
+    if os.name != 'nt':
+        os.chmod(activate_sh, 0o755)
+    
+    print(f"✓ 激活脚本已创建:")
+    print(f"  Windows: {activate_bat}")
+    print(f"  Unix/Linux: {activate_sh}")
+
+
+def main():
+    parser = argparse.ArgumentParser(description='Python虚拟环境设置')
+    parser.add_argument('--upgrade', action='store_true', help='升级依赖包')
+    parser.add_argument('--clean', action='store_true', help='删除并重新创建虚拟环境')
+    parser.add_argument('--venv-name', default='venv', help='虚拟环境目录名 (默认: venv)')
+    args = parser.parse_args()
+    
+    print(f"\n{'='*50}")
+    print("Python虚拟环境设置")
+    print(f"{'='*50}\n")
+    
+    # 检查Python版本
+    check_python()
+    
+    # 虚拟环境路径
+    venv_path = Path.cwd() / args.venv_name
+    
+    # 创建虚拟环境
+    create_venv(venv_path, clean=args.clean)
+    
+    # 安装依赖
+    install_dependencies(venv_path, upgrade=args.upgrade)
+    
+    # 创建激活脚本
+    create_activation_scripts(venv_path)
+    
+    print(f"\n{'='*50}")
+    print("✓ 虚拟环境设置完成!")
+    print(f"{'='*50}")
+    
+    print(f"\n激活虚拟环境:")
+    if os.name == 'nt':  # Windows
+        print(f"  activate_venv.bat")
+        print(f"  或者: {venv_path}\\Scripts\\activate.bat")
+    else:  # Unix/Linux
+        print(f"  source activate_venv.sh")
+        print(f"  或者: source {venv_path}/bin/activate")
+    
+    print(f"\n停用虚拟环境:")
+    print(f"  deactivate")
+
+
+if __name__ == '__main__':
+    main()

+ 7 - 0
backend/start_celery_worker.bat

@@ -0,0 +1,7 @@
+@echo off
+echo Starting Celery Worker...
+echo.
+echo Make sure Redis is running before starting this worker.
+echo.
+celery -A celery_worker.celery_app worker --loglevel=info --pool=solo
+pause

+ 6 - 0
backend/start_celery_worker.sh

@@ -0,0 +1,6 @@
+#!/bin/bash
+echo "Starting Celery Worker..."
+echo ""
+echo "Make sure Redis is running before starting this worker."
+echo ""
+celery -A celery_worker.celery_app worker --loglevel=info --pool=solo

+ 117 - 0
backend/start_with_redis_check.py

@@ -0,0 +1,117 @@
+#!/usr/bin/env python
+"""
+启动应用前检查Redis连接
+
+如果Redis不可用,会给出相应的提示和解决方案
+"""
+import os
+import sys
+import subprocess
+
+# 添加项目根目录到路径
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
+def check_redis():
+    """检查Redis是否可用"""
+    try:
+        import redis
+        
+        # 测试连接
+        broker_url = os.environ.get('CELERY_BROKER_URL', 'redis://localhost:6379/0')
+        r = redis.from_url(broker_url)
+        r.ping()
+        return True
+    except:
+        return False
+
+def show_redis_help():
+    """显示Redis安装和启动帮助"""
+    print("\n" + "="*60)
+    print("🔧 Redis 设置指南")
+    print("="*60)
+    
+    print("\n📋 快速解决方案:")
+    
+    print("\n1️⃣  Windows用户 - 使用Chocolatey安装Redis:")
+    print("   choco install redis-64")
+    print("   redis-server --service-install")
+    print("   redis-server --service-start")
+    
+    print("\n2️⃣  使用Docker运行Redis:")
+    print("   docker run -d --name redis -p 6379:6379 redis:alpine")
+    
+    print("\n3️⃣  Linux/macOS用户:")
+    print("   # Ubuntu/Debian")
+    print("   sudo apt-get install redis-server")
+    print("   sudo systemctl start redis-server")
+    print("   ")
+    print("   # macOS (Homebrew)")
+    print("   brew install redis")
+    print("   brew services start redis")
+    
+    print("\n4️⃣  手动下载Redis (Windows):")
+    print("   下载: https://github.com/microsoftarchive/redis/releases")
+    print("   解压后运行: redis-server.exe")
+    
+    print("\n✅ 验证Redis运行:")
+    print("   redis-cli ping")
+    print("   (应该返回: PONG)")
+    
+    print("\n🔄 重新测试连接:")
+    print("   python test_redis.py")
+    
+    print("\n📖 详细说明:")
+    print("   查看 REDIS_SETUP.md 文件")
+    
+    print("="*60)
+
+def start_app():
+    """启动Flask应用"""
+    print("\n🚀 启动Flask应用...")
+    
+    try:
+        # 启动Flask应用
+        from app import create_app
+        app = create_app()
+        
+        print("✅ Flask应用启动成功!")
+        print("📍 访问地址: http://localhost:5000")
+        print("🔄 任务队列: Mock模式 (Redis不可用时自动启用)")
+        print("\n按 Ctrl+C 停止应用")
+        
+        app.run(host='0.0.0.0', port=5000, debug=True)
+        
+    except KeyboardInterrupt:
+        print("\n👋 应用已停止")
+    except Exception as e:
+        print(f"\n❌ 应用启动失败: {e}")
+
+def main():
+    """主函数"""
+    print("🔍 检查Redis连接...")
+    
+    if check_redis():
+        print("✅ Redis连接正常")
+        print("🎯 建议启动Celery Worker以获得最佳性能:")
+        print("   celery -A app.celery_app worker --loglevel=info")
+        print("   (在新的终端窗口中运行)")
+        
+        start_app()
+    else:
+        print("⚠️  Redis连接失败")
+        print("🔄 应用将使用Mock模式运行 (功能受限)")
+        
+        response = input("\n是否查看Redis安装指南? (y/n): ").lower().strip()
+        if response in ['y', 'yes', '是']:
+            show_redis_help()
+            
+            response = input("\n是否继续启动应用? (y/n): ").lower().strip()
+            if response in ['y', 'yes', '是']:
+                start_app()
+            else:
+                print("👋 已取消启动")
+        else:
+            start_app()
+
+if __name__ == '__main__':
+    main()

+ 164 - 0
backend/test_celery_task.py

@@ -0,0 +1,164 @@
+#!/usr/bin/env python
+"""
+测试Celery任务提交
+
+用于诊断任务提交时的具体问题
+"""
+import os
+import sys
+
+# 添加项目根目录到路径
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
+def test_celery_import():
+    """测试Celery模块导入"""
+    print("🔍 测试Celery模块导入...")
+    
+    try:
+        from app import create_app
+        app = create_app()
+        
+        with app.app_context():
+            print("✅ Flask应用上下文创建成功")
+            
+            # 测试导入scan_tasks
+            try:
+                from app.tasks.scan_tasks import scan_aws_resources
+                print("✅ scan_tasks模块导入成功")
+                
+                # 测试任务提交
+                try:
+                    # 创建一个测试任务
+                    celery_task = scan_aws_resources.delay(
+                        task_id=999,  # 测试ID
+                        credential_ids=[1],
+                        regions=['us-east-1'],
+                        project_metadata={'clientName': 'Test', 'projectName': 'Test'}
+                    )
+                    print(f"✅ Celery任务提交成功: {celery_task.id}")
+                    return True
+                    
+                except Exception as e:
+                    print(f"❌ Celery任务提交失败: {e}")
+                    print(f"错误类型: {type(e).__name__}")
+                    return False
+                    
+            except Exception as e:
+                print(f"❌ scan_tasks模块导入失败: {e}")
+                print(f"错误类型: {type(e).__name__}")
+                return False
+                
+    except Exception as e:
+        print(f"❌ Flask应用创建失败: {e}")
+        return False
+
+
+def test_mock_import():
+    """测试Mock模块导入"""
+    print("\n🔍 测试Mock模块导入...")
+    
+    try:
+        from app import create_app
+        app = create_app()
+        
+        with app.app_context():
+            from app.tasks.mock_tasks import scan_aws_resources
+            print("✅ mock_tasks模块导入成功")
+            
+            # 测试Mock任务提交
+            try:
+                celery_task = scan_aws_resources.delay(
+                    task_id=999,  # 测试ID
+                    credential_ids=[1],
+                    regions=['us-east-1'],
+                    project_metadata={'clientName': 'Test', 'projectName': 'Test'}
+                )
+                print(f"✅ Mock任务提交成功: {celery_task.id}")
+                return True
+                
+            except Exception as e:
+                print(f"❌ Mock任务提交失败: {e}")
+                return False
+                
+    except Exception as e:
+        print(f"❌ Mock模块测试失败: {e}")
+        return False
+
+
+def test_celery_config():
+    """测试Celery配置"""
+    print("\n🔍 测试Celery配置...")
+    
+    try:
+        from app.celery_app import celery_app
+        
+        print(f"Broker URL: {celery_app.conf.broker_url}")
+        print(f"Result Backend: {celery_app.conf.result_backend}")
+        
+        # 测试连接
+        try:
+            inspect = celery_app.control.inspect()
+            stats = inspect.stats()
+            if stats:
+                print(f"✅ Celery连接成功,Worker数量: {len(stats)}")
+                return True
+            else:
+                print("⚠️  Celery连接成功,但没有Worker运行")
+                return True
+        except Exception as e:
+            print(f"❌ Celery连接失败: {e}")
+            return False
+            
+    except Exception as e:
+        print(f"❌ Celery配置测试失败: {e}")
+        return False
+
+
+def main():
+    """运行所有测试"""
+    print("="*60)
+    print("Celery任务提交诊断")
+    print("="*60)
+    
+    tests = [
+        ("Celery配置", test_celery_config),
+        ("Celery任务导入和提交", test_celery_import),
+        ("Mock任务导入和提交", test_mock_import),
+    ]
+    
+    results = []
+    for test_name, test_func in tests:
+        try:
+            result = test_func()
+            results.append((test_name, result))
+        except Exception as e:
+            print(f"❌ {test_name}测试异常: {e}")
+            results.append((test_name, False))
+    
+    print("\n" + "="*60)
+    print("诊断结果:")
+    print("="*60)
+    
+    for test_name, result in results:
+        status = "✅ 通过" if result else "❌ 失败"
+        print(f"  {test_name}: {status}")
+    
+    # 给出建议
+    celery_ok = results[1][1] if len(results) > 1 else False
+    mock_ok = results[2][1] if len(results) > 2 else False
+    
+    print("\n📋 建议:")
+    if celery_ok:
+        print("  ✅ Celery工作正常,建议启动Worker获得最佳性能")
+        print("     celery -A app.celery_app worker --loglevel=info")
+    elif mock_ok:
+        print("  🔄 Celery有问题,但Mock模式可用")
+        print("  💡 可以使用Mock模式进行开发测试")
+    else:
+        print("  ❌ 两种模式都有问题,请检查配置")
+    
+    print("="*60)
+
+
+if __name__ == '__main__':
+    main()

+ 177 - 0
backend/test_credential_update.py

@@ -0,0 +1,177 @@
+#!/usr/bin/env python
+"""
+测试凭证更新功能
+
+验证:
+1. Account ID 字段是否可空
+2. Access Key 类型凭证创建逻辑
+3. 数据库模型更新
+"""
+import os
+import sys
+
+# 添加项目根目录到路径
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
+from app import create_app, db
+from app.models import AWSCredential
+from sqlalchemy import text
+
+def test_database_schema():
+    """测试数据库模式更新"""
+    print("测试数据库模式...")
+    
+    app = create_app('testing')
+    with app.app_context():
+        # 创建测试表
+        db.create_all()
+        
+        # 检查 account_id 字段是否可空
+        try:
+            # 尝试创建一个没有 account_id 的凭证
+            test_credential = AWSCredential(
+                name="Test Access Key",
+                credential_type="access_key",
+                account_id=None,  # 测试可空
+                access_key_id="AKIATEST123456789",
+                is_active=True
+            )
+            test_credential.set_secret_access_key("test-secret-key")
+            
+            db.session.add(test_credential)
+            db.session.commit()
+            
+            print("✓ account_id 字段可空测试通过")
+            
+            # 清理测试数据
+            db.session.delete(test_credential)
+            db.session.commit()
+            
+        except Exception as e:
+            print(f"❌ account_id 字段可空测试失败: {e}")
+            return False
+    
+    return True
+
+def test_model_methods():
+    """测试模型方法"""
+    print("测试模型方法...")
+    
+    app = create_app('testing')
+    with app.app_context():
+        db.create_all()
+        
+        try:
+            # 测试 Assume Role 类型
+            assume_role_cred = AWSCredential(
+                name="Test Assume Role",
+                credential_type="assume_role",
+                account_id="123456789012",
+                role_arn="arn:aws:iam::123456789012:role/TestRole",
+                external_id="test-external-id",
+                is_active=True
+            )
+            
+            # 测试 Access Key 类型(无 account_id)
+            access_key_cred = AWSCredential(
+                name="Test Access Key",
+                credential_type="access_key",
+                account_id=None,
+                access_key_id="AKIATEST123456789",
+                is_active=True
+            )
+            access_key_cred.set_secret_access_key("test-secret-key")
+            
+            # 测试 to_dict 方法
+            assume_role_dict = assume_role_cred.to_dict()
+            access_key_dict = access_key_cred.to_dict()
+            
+            print("✓ Assume Role 凭证字典:", assume_role_dict)
+            print("✓ Access Key 凭证字典:", access_key_dict)
+            
+            # 验证字段
+            assert assume_role_dict['credential_type'] == 'assume_role'
+            assert assume_role_dict['account_id'] == '123456789012'
+            assert access_key_dict['credential_type'] == 'access_key'
+            assert access_key_dict['account_id'] is None
+            
+            print("✓ 模型方法测试通过")
+            
+        except Exception as e:
+            print(f"❌ 模型方法测试失败: {e}")
+            return False
+    
+    return True
+
+def test_api_validation():
+    """测试API验证逻辑"""
+    print("测试API验证逻辑...")
+    
+    from app.api.credentials import validate_account_id, validate_role_arn
+    
+    try:
+        # 测试 account_id 验证
+        assert validate_account_id("123456789012") == True
+        assert validate_account_id("12345678901") == False  # 11位
+        assert validate_account_id("1234567890123") == False  # 13位
+        assert validate_account_id("12345678901a") == False  # 包含字母
+        assert validate_account_id("") == False  # 空字符串
+        assert validate_account_id(None) == False  # None
+        
+        # 测试 role_arn 验证
+        assert validate_role_arn("arn:aws:iam::123456789012:role/TestRole") == True
+        assert validate_role_arn("arn:aws:iam::123456789012:user/TestUser") == False  # 不是role
+        assert validate_role_arn("invalid-arn") == False  # 无效格式
+        assert validate_role_arn("") == False  # 空字符串
+        assert validate_role_arn(None) == False  # None
+        
+        print("✓ API验证逻辑测试通过")
+        
+    except Exception as e:
+        print(f"❌ API验证逻辑测试失败: {e}")
+        return False
+    
+    return True
+
+def main():
+    """运行所有测试"""
+    print("="*50)
+    print("凭证更新功能测试")
+    print("="*50)
+    
+    tests = [
+        ("数据库模式", test_database_schema),
+        ("模型方法", test_model_methods),
+        ("API验证", test_api_validation),
+    ]
+    
+    results = []
+    for test_name, test_func in tests:
+        print(f"\n{test_name}测试:")
+        try:
+            result = test_func()
+            results.append((test_name, result))
+        except Exception as e:
+            print(f"❌ {test_name}测试异常: {e}")
+            results.append((test_name, False))
+    
+    print("\n" + "="*50)
+    print("测试结果:")
+    print("="*50)
+    
+    all_passed = True
+    for test_name, result in results:
+        status = "✓ 通过" if result else "❌ 失败"
+        print(f"  {test_name}: {status}")
+        if not result:
+            all_passed = False
+    
+    if all_passed:
+        print("\n🎉 所有测试通过! 凭证更新功能正常。")
+    else:
+        print("\n⚠️  部分测试失败,请检查问题。")
+    
+    print("="*50)
+
+if __name__ == '__main__':
+    main()

+ 158 - 0
backend/test_redis.py

@@ -0,0 +1,158 @@
+#!/usr/bin/env python
+"""
+测试Redis连接
+
+用法:
+    python test_redis.py
+"""
+import os
+import sys
+
+# 添加项目根目录到路径
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
+def test_redis_connection():
+    """测试Redis连接"""
+    try:
+        import redis
+        
+        # 从环境变量或配置获取Redis URL
+        broker_url = os.environ.get('CELERY_BROKER_URL', 'redis://localhost:6379/0')
+        result_url = os.environ.get('CELERY_RESULT_BACKEND', 'redis://localhost:6379/1')
+        
+        print(f"测试Redis连接:")
+        print(f"  Broker URL: {broker_url}")
+        print(f"  Result URL: {result_url}")
+        
+        # 测试Broker连接
+        print("\n测试Broker连接...")
+        try:
+            r_broker = redis.from_url(broker_url, decode_responses=True)
+            response = r_broker.ping()
+            if response:
+                print("✅ Broker连接成功")
+                
+                # 测试基本操作
+                r_broker.set('test_broker', 'test_value')
+                value = r_broker.get('test_broker')
+                if value == 'test_value':
+                    print("✅ Broker读写测试成功")
+                    r_broker.delete('test_broker')
+                else:
+                    print("❌ Broker读写测试失败")
+                    return False
+            else:
+                print("❌ Broker ping失败")
+                return False
+        except Exception as e:
+            print(f"❌ Broker连接失败: {e}")
+            return False
+        
+        # 测试Result Backend连接
+        print("\n测试Result Backend连接...")
+        try:
+            r_result = redis.from_url(result_url, decode_responses=True)
+            response = r_result.ping()
+            if response:
+                print("✅ Result Backend连接成功")
+                
+                # 测试基本操作
+                r_result.set('test_result', 'test_value')
+                value = r_result.get('test_result')
+                if value == 'test_value':
+                    print("✅ Result Backend读写测试成功")
+                    r_result.delete('test_result')
+                else:
+                    print("❌ Result Backend读写测试失败")
+                    return False
+            else:
+                print("❌ Result Backend ping失败")
+                return False
+        except Exception as e:
+            print(f"❌ Result Backend连接失败: {e}")
+            return False
+        
+        print("\n🎉 Redis连接测试全部通过!")
+        return True
+        
+    except ImportError:
+        print("❌ Redis模块未安装,请运行: pip install redis")
+        return False
+    except Exception as e:
+        print(f"❌ Redis测试异常: {e}")
+        return False
+
+
+def test_celery_connection():
+    """测试Celery连接"""
+    print("\n" + "="*50)
+    print("测试Celery连接")
+    print("="*50)
+    
+    try:
+        from app.celery_app import celery_app
+        
+        # 测试Celery连接
+        print("测试Celery应用...")
+        
+        # 检查Celery配置
+        print(f"Broker URL: {celery_app.conf.broker_url}")
+        print(f"Result Backend: {celery_app.conf.result_backend}")
+        
+        # 尝试获取活跃任务
+        try:
+            inspect = celery_app.control.inspect()
+            active_tasks = inspect.active()
+            if active_tasks is not None:
+                print("✅ Celery连接成功")
+                print(f"活跃Worker数量: {len(active_tasks)}")
+                return True
+            else:
+                print("⚠️  Celery连接成功,但没有活跃的Worker")
+                return True
+        except Exception as e:
+            print(f"❌ Celery连接失败: {e}")
+            return False
+            
+    except Exception as e:
+        print(f"❌ Celery测试异常: {e}")
+        return False
+
+
+def main():
+    """运行所有测试"""
+    print("="*50)
+    print("Redis和Celery连接测试")
+    print("="*50)
+    
+    # 测试Redis连接
+    redis_ok = test_redis_connection()
+    
+    # 测试Celery连接
+    celery_ok = test_celery_connection()
+    
+    print("\n" + "="*50)
+    print("测试结果:")
+    print("="*50)
+    
+    print(f"Redis连接: {'✅ 正常' if redis_ok else '❌ 失败'}")
+    print(f"Celery连接: {'✅ 正常' if celery_ok else '❌ 失败'}")
+    
+    if redis_ok and celery_ok:
+        print("\n🎉 所有连接测试通过! 可以正常使用Celery任务队列。")
+        print("\n启动Celery Worker:")
+        print("  celery -A app.celery_app worker --loglevel=info")
+    elif redis_ok:
+        print("\n⚠️  Redis连接正常,但Celery有问题。请检查Celery配置。")
+        print("\n启动Celery Worker:")
+        print("  celery -A app.celery_app worker --loglevel=info")
+    else:
+        print("\n❌ Redis连接失败。请参考 REDIS_SETUP.md 安装和配置Redis。")
+        print("\n临时解决方案:")
+        print("  应用会自动切换到Mock模式,可以进行基本测试。")
+    
+    print("="*50)
+
+
+if __name__ == '__main__':
+    main()

+ 134 - 0
backend/test_task_api.py

@@ -0,0 +1,134 @@
+#!/usr/bin/env python
+"""
+测试任务API调用
+
+模拟实际的任务创建过程
+"""
+import os
+import sys
+
+# 添加项目根目录到路径
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
+def test_task_creation():
+    """测试任务创建过程"""
+    from app import create_app, db
+    from app.models import Task, User, AWSCredential
+    
+    app = create_app('testing')
+    
+    with app.app_context():
+        # 创建测试数据
+        db.create_all()
+        
+        # 创建测试用户
+        user = User(
+            username='testuser',
+            email='test@example.com',
+            role='admin'
+        )
+        user.set_password('testpass')
+        db.session.add(user)
+        
+        # 创建测试凭证
+        credential = AWSCredential(
+            name='Test Credential',
+            credential_type='access_key',
+            account_id='123456789012',
+            access_key_id='AKIATEST123456789',
+            is_active=True
+        )
+        credential.set_secret_access_key('test-secret-key')
+        db.session.add(credential)
+        db.session.commit()
+        
+        print(f"✅ 测试数据创建成功")
+        print(f"   用户ID: {user.id}")
+        print(f"   凭证ID: {credential.id}")
+        
+        # 模拟任务创建
+        task = Task(
+            name='Test Task',
+            status='pending',
+            progress=0,
+            created_by=user.id
+        )
+        task.credential_ids = [credential.id]
+        task.regions = ['us-east-1']
+        task.project_metadata = {
+            'clientName': 'Test Client',
+            'projectName': 'Test Project'
+        }
+        
+        db.session.add(task)
+        db.session.commit()
+        
+        print(f"✅ 测试任务创建成功,任务ID: {task.id}")
+        
+        # 测试Celery任务提交
+        try:
+            print("🔍 尝试提交Celery任务...")
+            from app.tasks.scan_tasks import scan_aws_resources
+            
+            celery_task = scan_aws_resources.delay(
+                task_id=task.id,
+                credential_ids=[credential.id],
+                regions=['us-east-1'],
+                project_metadata={
+                    'clientName': 'Test Client',
+                    'projectName': 'Test Project'
+                }
+            )
+            
+            print(f"✅ Celery任务提交成功: {celery_task.id}")
+            return True
+            
+        except Exception as e:
+            print(f"❌ Celery任务提交失败: {e}")
+            print(f"   错误类型: {type(e).__name__}")
+            
+            # 尝试Mock模式
+            try:
+                print("🔄 尝试Mock模式...")
+                from app.tasks.mock_tasks import scan_aws_resources
+                
+                celery_task = scan_aws_resources.delay(
+                    task_id=task.id,
+                    credential_ids=[credential.id],
+                    regions=['us-east-1'],
+                    project_metadata={
+                        'clientName': 'Test Client',
+                        'projectName': 'Test Project'
+                    }
+                )
+                
+                print(f"✅ Mock任务提交成功: {celery_task.id}")
+                return True
+                
+            except Exception as e2:
+                print(f"❌ Mock任务也失败: {e2}")
+                return False
+
+
+def main():
+    """运行测试"""
+    print("="*50)
+    print("任务API测试")
+    print("="*50)
+    
+    try:
+        result = test_task_creation()
+        if result:
+            print("\n🎉 任务创建测试成功!")
+        else:
+            print("\n❌ 任务创建测试失败!")
+    except Exception as e:
+        print(f"\n❌ 测试异常: {e}")
+        import traceback
+        traceback.print_exc()
+    
+    print("="*50)
+
+
+if __name__ == '__main__':
+    main()

+ 1 - 0
backend/tests/__init__.py

@@ -0,0 +1 @@
+# Test package initialization

+ 25 - 0
backend/tests/conftest.py

@@ -0,0 +1,25 @@
+import pytest
+from app import create_app, db
+
+
+@pytest.fixture
+def app():
+    """Create application for testing"""
+    app = create_app('testing')
+    
+    with app.app_context():
+        db.create_all()
+        yield app
+        db.drop_all()
+
+
+@pytest.fixture
+def client(app):
+    """Create test client"""
+    return app.test_client()
+
+
+@pytest.fixture
+def runner(app):
+    """Create test CLI runner"""
+    return app.test_cli_runner()

+ 275 - 0
backend/tests/test_auth.py

@@ -0,0 +1,275 @@
+"""
+Tests for authentication module
+"""
+import pytest
+import json
+from app import db
+from app.models import User
+
+
+@pytest.fixture
+def test_user(app):
+    """Create a test user"""
+    with app.app_context():
+        user = User(
+            username='testuser',
+            email='test@example.com',
+            role='user'
+        )
+        user.set_password('testpassword123')
+        db.session.add(user)
+        db.session.commit()
+        
+        # Return user data (not the object, as it will be detached)
+        return {
+            'id': user.id,
+            'username': user.username,
+            'email': user.email,
+            'role': user.role
+        }
+
+
+@pytest.fixture
+def admin_user(app):
+    """Create an admin user"""
+    with app.app_context():
+        user = User(
+            username='adminuser',
+            email='admin@example.com',
+            role='admin'
+        )
+        user.set_password('adminpassword123')
+        db.session.add(user)
+        db.session.commit()
+        
+        return {
+            'id': user.id,
+            'username': user.username,
+            'email': user.email,
+            'role': user.role
+        }
+
+
+class TestLogin:
+    """Tests for login endpoint"""
+    
+    def test_login_success(self, client, test_user):
+        """Test successful login"""
+        response = client.post('/api/auth/login', 
+            data=json.dumps({
+                'username': 'testuser',
+                'password': 'testpassword123'
+            }),
+            content_type='application/json'
+        )
+        
+        assert response.status_code == 200
+        data = json.loads(response.data)
+        assert 'token' in data
+        assert 'refresh_token' in data
+        assert 'user' in data
+        assert data['user']['username'] == 'testuser'
+    
+    def test_login_invalid_password(self, client, test_user):
+        """Test login with invalid password"""
+        response = client.post('/api/auth/login',
+            data=json.dumps({
+                'username': 'testuser',
+                'password': 'wrongpassword'
+            }),
+            content_type='application/json'
+        )
+        
+        assert response.status_code == 401
+        data = json.loads(response.data)
+        assert 'error' in data
+    
+    def test_login_missing_fields(self, client):
+        """Test login with missing fields"""
+        response = client.post('/api/auth/login',
+            data=json.dumps({
+                'username': 'testuser'
+            }),
+            content_type='application/json'
+        )
+        
+        assert response.status_code == 400
+        data = json.loads(response.data)
+        assert 'error' in data
+
+
+class TestGetCurrentUser:
+    """Tests for get current user endpoint"""
+    
+    def test_get_current_user_success(self, client, test_user):
+        """Test getting current user with valid token"""
+        # First login to get token
+        login_response = client.post('/api/auth/login',
+            data=json.dumps({
+                'username': 'testuser',
+                'password': 'testpassword123'
+            }),
+            content_type='application/json'
+        )
+        token = json.loads(login_response.data)['token']
+        
+        # Get current user
+        response = client.get('/api/auth/me',
+            headers={'Authorization': f'Bearer {token}'}
+        )
+        
+        assert response.status_code == 200
+        data = json.loads(response.data)
+        assert data['username'] == 'testuser'
+    
+    def test_get_current_user_no_token(self, client):
+        """Test getting current user without token"""
+        response = client.get('/api/auth/me')
+        
+        assert response.status_code == 401
+
+
+class TestRefreshToken:
+    """Tests for token refresh endpoint"""
+    
+    def test_refresh_token_success(self, client, test_user):
+        """Test successful token refresh"""
+        # First login to get tokens
+        login_response = client.post('/api/auth/login',
+            data=json.dumps({
+                'username': 'testuser',
+                'password': 'testpassword123'
+            }),
+            content_type='application/json'
+        )
+        refresh_token = json.loads(login_response.data)['refresh_token']
+        
+        # Refresh token
+        response = client.post('/api/auth/refresh',
+            data=json.dumps({
+                'refresh_token': refresh_token
+            }),
+            content_type='application/json'
+        )
+        
+        assert response.status_code == 200
+        data = json.loads(response.data)
+        assert 'access_token' in data
+    
+    def test_refresh_token_invalid(self, client):
+        """Test refresh with invalid token"""
+        response = client.post('/api/auth/refresh',
+            data=json.dumps({
+                'refresh_token': 'invalid_token'
+            }),
+            content_type='application/json'
+        )
+        
+        assert response.status_code == 401
+
+
+class TestLogout:
+    """Tests for logout endpoint"""
+    
+    def test_logout_success(self, client, test_user):
+        """Test successful logout"""
+        # First login to get token
+        login_response = client.post('/api/auth/login',
+            data=json.dumps({
+                'username': 'testuser',
+                'password': 'testpassword123'
+            }),
+            content_type='application/json'
+        )
+        token = json.loads(login_response.data)['token']
+        
+        # Logout
+        response = client.post('/api/auth/logout',
+            headers={'Authorization': f'Bearer {token}'}
+        )
+        
+        assert response.status_code == 200
+
+
+@pytest.fixture
+def power_user(app):
+    """Create a power user"""
+    with app.app_context():
+        user = User(
+            username='poweruser',
+            email='power@example.com',
+            role='power_user'
+        )
+        user.set_password('powerpassword123')
+        db.session.add(user)
+        db.session.commit()
+        
+        return {
+            'id': user.id,
+            'username': user.username,
+            'email': user.email,
+            'role': user.role
+        }
+
+
+class TestRBAC:
+    """Tests for role-based access control"""
+    
+    def test_admin_access(self, client, admin_user):
+        """Test admin can access admin-only endpoints"""
+        # Login as admin
+        login_response = client.post('/api/auth/login',
+            data=json.dumps({
+                'username': 'adminuser',
+                'password': 'adminpassword123'
+            }),
+            content_type='application/json'
+        )
+        token = json.loads(login_response.data)['token']
+        
+        # Admin should be able to access user list (admin endpoint)
+        response = client.get('/api/users',
+            headers={'Authorization': f'Bearer {token}'}
+        )
+        
+        # Should not return 403 (may return 200 or other status depending on implementation)
+        assert response.status_code != 403
+    
+    def test_regular_user_denied_admin_access(self, client, test_user):
+        """Test regular user cannot access admin-only endpoints"""
+        # Login as regular user
+        login_response = client.post('/api/auth/login',
+            data=json.dumps({
+                'username': 'testuser',
+                'password': 'testpassword123'
+            }),
+            content_type='application/json'
+        )
+        token = json.loads(login_response.data)['token']
+        
+        # Regular user should be denied access to admin endpoints
+        response = client.get('/api/users',
+            headers={'Authorization': f'Bearer {token}'}
+        )
+        
+        assert response.status_code == 403
+    
+    def test_power_user_access(self, client, power_user):
+        """Test power user can access power_user endpoints"""
+        # Login as power user
+        login_response = client.post('/api/auth/login',
+            data=json.dumps({
+                'username': 'poweruser',
+                'password': 'powerpassword123'
+            }),
+            content_type='application/json'
+        )
+        token = json.loads(login_response.data)['token']
+        
+        # Power user should be able to access credentials list
+        response = client.get('/api/credentials',
+            headers={'Authorization': f'Bearer {token}'}
+        )
+        
+        # Should not return 403
+        assert response.status_code != 403

+ 274 - 0
backend/tests/test_users.py

@@ -0,0 +1,274 @@
+"""
+Tests for user management module (Admin only)
+"""
+import pytest
+import json
+from app import db
+from app.models import User
+
+
+@pytest.fixture
+def admin_user(app):
+    """Create an admin user"""
+    with app.app_context():
+        user = User(
+            username='adminuser',
+            email='admin@example.com',
+            role='admin'
+        )
+        user.set_password('adminpassword123')
+        db.session.add(user)
+        db.session.commit()
+        
+        return {
+            'id': user.id,
+            'username': user.username,
+            'email': user.email,
+            'role': user.role
+        }
+
+
+@pytest.fixture
+def admin_token(client, admin_user):
+    """Get admin token for authenticated requests"""
+    response = client.post('/api/auth/login',
+        data=json.dumps({
+            'username': 'adminuser',
+            'password': 'adminpassword123'
+        }),
+        content_type='application/json'
+    )
+    return json.loads(response.data)['token']
+
+
+@pytest.fixture
+def regular_user(app):
+    """Create a regular user"""
+    with app.app_context():
+        user = User(
+            username='regularuser',
+            email='regular@example.com',
+            role='user'
+        )
+        user.set_password('regularpassword123')
+        db.session.add(user)
+        db.session.commit()
+        
+        return {
+            'id': user.id,
+            'username': user.username,
+            'email': user.email,
+            'role': user.role
+        }
+
+
+class TestGetUsers:
+    """Tests for GET /api/users endpoint"""
+    
+    def test_get_users_success(self, client, admin_token, admin_user):
+        """Test admin can get users list"""
+        response = client.get('/api/users',
+            headers={'Authorization': f'Bearer {admin_token}'}
+        )
+        
+        assert response.status_code == 200
+        data = json.loads(response.data)
+        assert 'data' in data
+        assert 'pagination' in data
+        assert len(data['data']) >= 1
+    
+    def test_get_users_pagination(self, client, admin_token, admin_user):
+        """Test pagination parameters"""
+        response = client.get('/api/users?page=1&page_size=10',
+            headers={'Authorization': f'Bearer {admin_token}'}
+        )
+        
+        assert response.status_code == 200
+        data = json.loads(response.data)
+        assert data['pagination']['page'] == 1
+        assert data['pagination']['page_size'] == 10
+    
+    def test_get_users_search(self, client, admin_token, admin_user):
+        """Test search functionality"""
+        response = client.get('/api/users?search=admin',
+            headers={'Authorization': f'Bearer {admin_token}'}
+        )
+        
+        assert response.status_code == 200
+        data = json.loads(response.data)
+        assert len(data['data']) >= 1
+        assert 'admin' in data['data'][0]['username'].lower() or 'admin' in data['data'][0]['email'].lower()
+    
+    def test_get_users_unauthorized(self, client, regular_user):
+        """Test regular user cannot access users list"""
+        # Login as regular user
+        login_response = client.post('/api/auth/login',
+            data=json.dumps({
+                'username': 'regularuser',
+                'password': 'regularpassword123'
+            }),
+            content_type='application/json'
+        )
+        token = json.loads(login_response.data)['token']
+        
+        response = client.get('/api/users',
+            headers={'Authorization': f'Bearer {token}'}
+        )
+        
+        assert response.status_code == 403
+
+
+class TestCreateUser:
+    """Tests for POST /api/users/create endpoint"""
+    
+    def test_create_user_success(self, client, admin_token):
+        """Test admin can create a new user"""
+        response = client.post('/api/users/create',
+            data=json.dumps({
+                'username': 'newuser',
+                'password': 'newpassword123',
+                'email': 'newuser@example.com',
+                'role': 'user'
+            }),
+            content_type='application/json',
+            headers={'Authorization': f'Bearer {admin_token}'}
+        )
+        
+        assert response.status_code == 201
+        data = json.loads(response.data)
+        assert data['username'] == 'newuser'
+        assert data['email'] == 'newuser@example.com'
+        assert data['role'] == 'user'
+    
+    def test_create_user_missing_fields(self, client, admin_token):
+        """Test create user with missing required fields"""
+        response = client.post('/api/users/create',
+            data=json.dumps({
+                'username': 'newuser'
+            }),
+            content_type='application/json',
+            headers={'Authorization': f'Bearer {admin_token}'}
+        )
+        
+        assert response.status_code == 400
+        data = json.loads(response.data)
+        assert 'error' in data
+    
+    def test_create_user_duplicate_username(self, client, admin_token, admin_user):
+        """Test create user with duplicate username"""
+        response = client.post('/api/users/create',
+            data=json.dumps({
+                'username': 'adminuser',
+                'password': 'password123',
+                'email': 'another@example.com',
+                'role': 'user'
+            }),
+            content_type='application/json',
+            headers={'Authorization': f'Bearer {admin_token}'}
+        )
+        
+        assert response.status_code == 400
+        data = json.loads(response.data)
+        assert 'error' in data
+    
+    def test_create_user_invalid_role(self, client, admin_token):
+        """Test create user with invalid role"""
+        response = client.post('/api/users/create',
+            data=json.dumps({
+                'username': 'newuser',
+                'password': 'password123',
+                'email': 'newuser@example.com',
+                'role': 'invalid_role'
+            }),
+            content_type='application/json',
+            headers={'Authorization': f'Bearer {admin_token}'}
+        )
+        
+        assert response.status_code == 400
+
+
+class TestUpdateUser:
+    """Tests for POST /api/users/update endpoint"""
+    
+    def test_update_user_success(self, client, admin_token, regular_user):
+        """Test admin can update a user"""
+        response = client.post('/api/users/update',
+            data=json.dumps({
+                'id': regular_user['id'],
+                'email': 'updated@example.com'
+            }),
+            content_type='application/json',
+            headers={'Authorization': f'Bearer {admin_token}'}
+        )
+        
+        assert response.status_code == 200
+        data = json.loads(response.data)
+        assert data['email'] == 'updated@example.com'
+    
+    def test_update_user_not_found(self, client, admin_token):
+        """Test update non-existent user"""
+        response = client.post('/api/users/update',
+            data=json.dumps({
+                'id': 99999,
+                'email': 'updated@example.com'
+            }),
+            content_type='application/json',
+            headers={'Authorization': f'Bearer {admin_token}'}
+        )
+        
+        assert response.status_code == 404
+    
+    def test_update_user_self_deactivation(self, client, admin_token, admin_user):
+        """Test admin cannot deactivate themselves"""
+        response = client.post('/api/users/update',
+            data=json.dumps({
+                'id': admin_user['id'],
+                'is_active': False
+            }),
+            content_type='application/json',
+            headers={'Authorization': f'Bearer {admin_token}'}
+        )
+        
+        assert response.status_code == 400
+
+
+class TestDeleteUser:
+    """Tests for POST /api/users/delete endpoint"""
+    
+    def test_delete_user_success(self, client, admin_token, regular_user):
+        """Test admin can delete a user"""
+        response = client.post('/api/users/delete',
+            data=json.dumps({
+                'id': regular_user['id']
+            }),
+            content_type='application/json',
+            headers={'Authorization': f'Bearer {admin_token}'}
+        )
+        
+        assert response.status_code == 200
+        data = json.loads(response.data)
+        assert 'message' in data
+    
+    def test_delete_user_not_found(self, client, admin_token):
+        """Test delete non-existent user"""
+        response = client.post('/api/users/delete',
+            data=json.dumps({
+                'id': 99999
+            }),
+            content_type='application/json',
+            headers={'Authorization': f'Bearer {admin_token}'}
+        )
+        
+        assert response.status_code == 404
+    
+    def test_delete_user_self_deletion(self, client, admin_token, admin_user):
+        """Test admin cannot delete themselves"""
+        response = client.post('/api/users/delete',
+            data=json.dumps({
+                'id': admin_user['id']
+            }),
+            content_type='application/json',
+            headers={'Authorization': f'Bearer {admin_token}'}
+        )
+        
+        assert response.status_code == 400

+ 165 - 0
backend/verify_setup.py

@@ -0,0 +1,165 @@
+#!/usr/bin/env python
+"""
+验证虚拟环境和项目设置
+
+检查:
+- Python版本
+- 依赖包安装
+- 数据库连接
+- Flask应用创建
+"""
+import sys
+import os
+from pathlib import Path
+
+def check_python_version():
+    """检查Python版本"""
+    version = sys.version_info
+    print(f"Python版本: {version.major}.{version.minor}.{version.micro}")
+    
+    if version.major < 3 or (version.major == 3 and version.minor < 8):
+        print("❌ 需要Python 3.8或更高版本")
+        return False
+    
+    print("✓ Python版本符合要求")
+    return True
+
+
+def check_dependencies():
+    """检查关键依赖"""
+    dependencies = [
+        ('flask', 'Flask'),
+        ('sqlalchemy', 'SQLAlchemy'),
+        ('celery', 'Celery'),
+        ('boto3', 'AWS SDK'),
+        ('pytest', 'Pytest'),
+        ('bcrypt', 'bcrypt'),
+        ('cryptography', 'Cryptography'),
+    ]
+    
+    print("\n检查依赖包:")
+    all_ok = True
+    
+    for module_name, display_name in dependencies:
+        try:
+            module = __import__(module_name)
+            version = getattr(module, '__version__', 'unknown')
+            print(f"  ✓ {display_name}: {version}")
+        except ImportError:
+            print(f"  ❌ {display_name}: 未安装")
+            all_ok = False
+    
+    return all_ok
+
+
+def check_flask_app():
+    """检查Flask应用创建"""
+    print("\n检查Flask应用:")
+    try:
+        from app import create_app, db
+        app = create_app('testing')  # 使用测试配置
+        
+        with app.app_context():
+            # 检查数据库连接 (SQLAlchemy 2.0+ 语法)
+            with db.engine.connect() as conn:
+                conn.execute(db.text('SELECT 1'))
+            print("  ✓ Flask应用创建成功")
+            print("  ✓ 数据库连接正常")
+            return True
+    except Exception as e:
+        print(f"  ❌ Flask应用检查失败: {e}")
+        return False
+
+
+def check_database():
+    """检查数据库文件"""
+    print("\n检查数据库:")
+    
+    # 检查开发数据库
+    dev_db = Path('instance/dev.db')
+    if dev_db.exists():
+        print(f"  ✓ 开发数据库存在: {dev_db}")
+        print(f"    大小: {dev_db.stat().st_size} bytes")
+    else:
+        print("  ⚠️  开发数据库不存在,运行 'python init_db.py' 创建")
+    
+    return True
+
+
+def check_directories():
+    """检查必要目录"""
+    print("\n检查目录结构:")
+    
+    directories = [
+        'app',
+        'config', 
+        'migrations',
+        'tests',
+        'instance',
+        'uploads',
+        'reports'
+    ]
+    
+    all_ok = True
+    for directory in directories:
+        path = Path(directory)
+        if path.exists():
+            print(f"  ✓ {directory}/")
+        else:
+            print(f"  ⚠️  {directory}/ 不存在")
+            if directory in ['uploads', 'reports']:
+                # 这些目录会在应用启动时自动创建
+                pass
+            else:
+                all_ok = False
+    
+    return all_ok
+
+
+def main():
+    print("="*50)
+    print("虚拟环境和项目设置验证")
+    print("="*50)
+    
+    checks = [
+        ("Python版本", check_python_version),
+        ("依赖包", check_dependencies),
+        ("目录结构", check_directories),
+        ("数据库", check_database),
+        ("Flask应用", check_flask_app),
+    ]
+    
+    results = []
+    for name, check_func in checks:
+        try:
+            result = check_func()
+            results.append((name, result))
+        except Exception as e:
+            print(f"  ❌ {name}检查失败: {e}")
+            results.append((name, False))
+    
+    print("\n" + "="*50)
+    print("验证结果:")
+    print("="*50)
+    
+    all_passed = True
+    for name, result in results:
+        status = "✓ 通过" if result else "❌ 失败"
+        print(f"  {name}: {status}")
+        if not result:
+            all_passed = False
+    
+    if all_passed:
+        print("\n🎉 所有检查通过! 环境设置正确。")
+        print("\n可以开始开发:")
+        print("  python run.py          - 启动Flask应用")
+        print("  python init_db.py      - 初始化数据库")
+        print("  pytest                 - 运行测试")
+    else:
+        print("\n⚠️  部分检查失败,请检查上述问题。")
+    
+    print("="*50)
+
+
+if __name__ == '__main__':
+    main()

+ 282 - 0
frontend/README_SETUP.md

@@ -0,0 +1,282 @@
+# 🎉 Frontend 环境设置完成!
+
+## 设置摘要
+
+✅ **Node.js v22.20.0** - 版本符合要求  
+✅ **npm v10.9.3** - 包管理器正常  
+✅ **Yarn** - 已安装并配置  
+✅ **依赖包** - 已安装所有必需包  
+✅ **开发服务器** - 已启动并运行在 http://localhost:3000  
+
+## 快速开始
+
+### 1. 启动开发服务器
+```bash
+# 使用yarn (推荐)
+yarn dev
+
+# 或使用npm
+npm run dev
+```
+
+### 2. 访问应用
+- 前端地址: http://localhost:3000
+- 后端API: http://localhost:5000 (需要单独启动backend)
+
+### 3. 其他命令
+```bash
+# 构建生产版本
+yarn build
+
+# 运行测试
+yarn test
+
+# 代码检查
+yarn lint
+
+# 预览构建结果
+yarn preview
+```
+
+## 项目结构
+
+```
+frontend/
+├── node_modules/          # 依赖包
+├── src/                   # 源代码
+│   ├── components/        # React组件
+│   ├── pages/            # 页面组件
+│   ├── contexts/         # React Context
+│   ├── utils/            # 工具函数
+│   └── types/            # TypeScript类型定义
+├── public/               # 静态资源
+├── package.json          # 项目配置
+├── vite.config.ts        # Vite配置
+├── tsconfig.json         # TypeScript配置
+└── yarn.lock            # 依赖锁定文件
+```
+
+## 技术栈
+
+### 核心框架
+- **React 18.3** - UI框架
+- **TypeScript 5.6** - 类型安全
+- **Vite 5.4** - 构建工具
+
+### UI组件库
+- **Ant Design 5.21** - UI组件库
+- **@ant-design/icons** - 图标库
+
+### 路由和状态
+- **React Router 6.28** - 路由管理
+- **React Context** - 状态管理
+
+### 网络请求
+- **Axios 1.7** - HTTP客户端
+- **Day.js 1.11** - 日期处理
+
+### 开发工具
+- **ESLint 9.0** - 代码检查
+- **Vitest 2.0** - 测试框架
+
+## 开发指南
+
+### 启动完整应用
+
+1. **启动后端** (在backend目录):
+```bash
+# 激活Python虚拟环境
+activate_venv.bat
+
+# 启动Flask应用
+python run.py
+```
+
+2. **启动前端** (在frontend目录):
+```bash
+# 启动开发服务器
+yarn dev
+```
+
+3. **访问应用**:
+- 前端: http://localhost:3000
+- 后端API: http://localhost:5000
+
+### 代码规范
+
+```bash
+# 运行ESLint检查
+yarn lint
+
+# 自动修复可修复的问题
+yarn lint --fix
+```
+
+### 测试
+
+```bash
+# 运行所有测试
+yarn test
+
+# 监听模式运行测试
+yarn test --watch
+
+# 生成覆盖率报告
+yarn test --coverage
+```
+
+### 构建部署
+
+```bash
+# 构建生产版本
+yarn build
+
+# 预览构建结果
+yarn preview
+```
+
+构建文件将生成在 `dist/` 目录中。
+
+## 环境配置
+
+### 开发环境变量
+
+创建 `.env.local` 文件 (可选):
+```env
+# API基础URL
+VITE_API_BASE_URL=http://localhost:5000
+
+# 应用标题
+VITE_APP_TITLE=AWS Resource Scanner
+
+# 调试模式
+VITE_DEBUG=true
+```
+
+### 生产环境变量
+
+创建 `.env.production` 文件:
+```env
+# 生产API URL
+VITE_API_BASE_URL=https://your-api-domain.com
+
+# 应用标题
+VITE_APP_TITLE=AWS Resource Scanner
+
+# 关闭调试
+VITE_DEBUG=false
+```
+
+## API集成
+
+### 配置API基础URL
+
+在 `src/utils/api.ts` 中配置:
+```typescript
+const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000';
+```
+
+### 认证配置
+
+应用使用JWT认证,token存储在localStorage中。
+
+## 故障排除
+
+### 常见问题
+
+1. **依赖安装失败**
+```bash
+# 清理并重新安装
+yarn cache clean
+rm -rf node_modules yarn.lock
+yarn install
+```
+
+2. **端口冲突**
+```bash
+# 使用不同端口启动
+yarn dev --port 3001
+```
+
+3. **TypeScript错误**
+```bash
+# 检查TypeScript配置
+npx tsc --noEmit
+```
+
+4. **ESLint错误**
+```bash
+# 自动修复
+yarn lint --fix
+```
+
+### 重置环境
+
+```bash
+# 完全重置
+rm -rf node_modules yarn.lock
+yarn cache clean
+yarn install
+```
+
+## 开发工具推荐
+
+### VS Code扩展
+- ES7+ React/Redux/React-Native snippets
+- TypeScript Importer
+- ESLint
+- Prettier
+- Auto Rename Tag
+- Bracket Pair Colorizer
+
+### 浏览器扩展
+- React Developer Tools
+- Redux DevTools (如果使用Redux)
+
+## 部署
+
+### 静态部署 (推荐)
+
+1. 构建应用:
+```bash
+yarn build
+```
+
+2. 部署 `dist/` 目录到静态服务器 (Nginx, Apache, CDN等)
+
+### Docker部署
+
+创建 `Dockerfile`:
+```dockerfile
+FROM node:18-alpine as build
+WORKDIR /app
+COPY package*.json ./
+RUN yarn install
+COPY . .
+RUN yarn build
+
+FROM nginx:alpine
+COPY --from=build /app/dist /usr/share/nginx/html
+EXPOSE 80
+CMD ["nginx", "-g", "daemon off;"]
+```
+
+## 性能优化
+
+### 代码分割
+- 使用 React.lazy() 进行组件懒加载
+- 路由级别的代码分割
+
+### 构建优化
+- Vite自动进行Tree Shaking
+- 自动压缩和优化资源
+
+### 运行时优化
+- 使用React.memo优化组件渲染
+- 合理使用useCallback和useMemo
+
+---
+
+🚀 **前端环境已就绪,开始开发吧!**
+
+访问 http://localhost:3000 查看应用

+ 13 - 0
frontend/index.html

@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>AWS Resource Scanner</title>
+  </head>
+  <body>
+    <div id="root"></div>
+    <script type="module" src="/src/main.tsx"></script>
+  </body>
+</html>

+ 35 - 0
frontend/package.json

@@ -0,0 +1,35 @@
+{
+  "name": "aws-resource-scanner-frontend",
+  "private": true,
+  "version": "0.1.0",
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "tsc && vite build",
+    "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
+    "preview": "vite preview",
+    "test": "vitest --run"
+  },
+  "dependencies": {
+    "antd": "^5.21.0",
+    "@ant-design/icons": "^5.5.0",
+    "react": "^18.3.0",
+    "react-dom": "^18.3.0",
+    "react-router-dom": "^6.28.0",
+    "axios": "^1.7.0",
+    "dayjs": "^1.11.13"
+  },
+  "devDependencies": {
+    "@types/react": "^18.3.0",
+    "@types/react-dom": "^18.3.0",
+    "@typescript-eslint/eslint-plugin": "^8.0.0",
+    "@typescript-eslint/parser": "^8.0.0",
+    "@vitejs/plugin-react": "^4.3.0",
+    "eslint": "^9.0.0",
+    "eslint-plugin-react-hooks": "^5.0.0",
+    "eslint-plugin-react-refresh": "^0.4.12",
+    "typescript": "^5.6.0",
+    "vite": "^5.4.0",
+    "vitest": "^2.0.0"
+  }
+}

+ 55 - 0
frontend/setup.bat

@@ -0,0 +1,55 @@
+@echo off
+echo ========================================
+echo Frontend 环境设置 (Windows)
+echo ========================================
+echo.
+
+REM 检查Node.js是否安装
+node --version >nul 2>&1
+if errorlevel 1 (
+    echo ❌ Node.js未安装或不在PATH中
+    echo 请先安装Node.js 18+
+    pause
+    exit /b 1
+)
+
+echo ✓ 检测到Node.js
+node --version
+
+REM 检查npm是否安装
+npm --version >nul 2>&1
+if errorlevel 1 (
+    echo ❌ npm未安装
+    pause
+    exit /b 1
+)
+
+echo ✓ 检测到npm
+npm --version
+
+echo.
+echo 正在安装Yarn包管理器...
+npm install -g yarn
+
+echo.
+echo 正在清理缓存...
+npm cache clean --force
+if exist node_modules rmdir /s /q node_modules
+if exist package-lock.json del package-lock.json
+if exist yarn.lock del yarn.lock
+
+echo.
+echo 正在安装依赖包...
+yarn install
+
+echo.
+echo ========================================
+echo 设置完成!
+echo ========================================
+echo.
+echo 启动开发服务器: yarn dev
+echo 构建生产版本: yarn build
+echo 运行测试: yarn test
+echo 代码检查: yarn lint
+echo.
+pause

+ 51 - 0
frontend/setup.sh

@@ -0,0 +1,51 @@
+#!/bin/bash
+
+echo "========================================"
+echo "Frontend 环境设置 (Unix/Linux/macOS)"
+echo "========================================"
+echo ""
+
+# 检查Node.js是否安装
+if ! command -v node &> /dev/null; then
+    echo "❌ Node.js未安装"
+    echo "请先安装Node.js 18+"
+    exit 1
+fi
+
+echo "✓ 检测到Node.js"
+node --version
+
+# 检查npm是否安装
+if ! command -v npm &> /dev/null; then
+    echo "❌ npm未安装"
+    exit 1
+fi
+
+echo "✓ 检测到npm"
+npm --version
+
+echo ""
+echo "正在安装Yarn包管理器..."
+npm install -g yarn
+
+echo ""
+echo "正在清理缓存..."
+npm cache clean --force
+rm -rf node_modules
+rm -f package-lock.json
+rm -f yarn.lock
+
+echo ""
+echo "正在安装依赖包..."
+yarn install
+
+echo ""
+echo "========================================"
+echo "设置完成!"
+echo "========================================"
+echo ""
+echo "启动开发服务器: yarn dev"
+echo "构建生产版本: yarn build"
+echo "运行测试: yarn test"
+echo "代码检查: yarn lint"
+echo ""

+ 72 - 0
frontend/src/App.tsx

@@ -0,0 +1,72 @@
+import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
+import { AuthProvider } from './contexts/AuthContext';
+import { ProtectedRoute } from './components/Guards';
+import MainLayout from './components/Layout/MainLayout';
+import Login from './pages/Login';
+import Dashboard from './pages/Dashboard';
+import Tasks from './pages/Tasks';
+import Reports from './pages/Reports';
+import Users from './pages/Users';
+import Credentials from './pages/Credentials';
+import Workers from './pages/Workers';
+
+function App() {
+  return (
+    <BrowserRouter>
+      <AuthProvider>
+        <Routes>
+          {/* Public route */}
+          <Route path="/login" element={<Login />} />
+          
+          {/* Protected routes */}
+          <Route
+            path="/"
+            element={
+              <ProtectedRoute>
+                <MainLayout />
+              </ProtectedRoute>
+            }
+          >
+            <Route index element={<Navigate to="/dashboard" replace />} />
+            
+            {/* Routes accessible by all authenticated users */}
+            <Route path="dashboard" element={<Dashboard />} />
+            <Route path="tasks" element={<Tasks />} />
+            <Route path="reports" element={<Reports />} />
+            
+            {/* Admin-only routes */}
+            <Route
+              path="users"
+              element={
+                <ProtectedRoute adminOnly>
+                  <Users />
+                </ProtectedRoute>
+              }
+            />
+            <Route
+              path="credentials"
+              element={
+                <ProtectedRoute adminOnly>
+                  <Credentials />
+                </ProtectedRoute>
+              }
+            />
+            <Route
+              path="workers"
+              element={
+                <ProtectedRoute adminOnly>
+                  <Workers />
+                </ProtectedRoute>
+              }
+            />
+          </Route>
+          
+          {/* Catch all - redirect to dashboard */}
+          <Route path="*" element={<Navigate to="/dashboard" replace />} />
+        </Routes>
+      </AuthProvider>
+    </BrowserRouter>
+  );
+}
+
+export default App;

+ 70 - 0
frontend/src/components/Guards/ProtectedRoute.tsx

@@ -0,0 +1,70 @@
+import { Navigate, useLocation } from 'react-router-dom';
+import { Spin } from 'antd';
+import { useAuth } from '../../contexts/AuthContext';
+
+interface ProtectedRouteProps {
+  children: React.ReactNode;
+  requiredRole?: 'admin' | 'power_user' | 'user';
+  adminOnly?: boolean;
+  powerUserOnly?: boolean;
+}
+
+export default function ProtectedRoute({ 
+  children, 
+  requiredRole,
+  adminOnly = false,
+  powerUserOnly = false,
+}: ProtectedRouteProps) {
+  const { isAuthenticated, loading, user } = useAuth();
+  const location = useLocation();
+
+  // Show loading spinner while checking auth
+  if (loading) {
+    return (
+      <div style={{ 
+        minHeight: '100vh', 
+        display: 'flex', 
+        alignItems: 'center', 
+        justifyContent: 'center' 
+      }}>
+        <Spin size="large" />
+      </div>
+    );
+  }
+
+  // Redirect to login if not authenticated
+  if (!isAuthenticated) {
+    return <Navigate to="/login" state={{ from: location }} replace />;
+  }
+
+  // Check role-based access
+  if (user) {
+    // Admin-only routes
+    if (adminOnly && user.role !== 'admin') {
+      return <Navigate to="/dashboard" replace />;
+    }
+
+    // Power user or admin routes
+    if (powerUserOnly && user.role !== 'admin' && user.role !== 'power_user') {
+      return <Navigate to="/dashboard" replace />;
+    }
+
+    // Specific role requirement
+    if (requiredRole) {
+      const roleHierarchy: Record<string, number> = {
+        'admin': 3,
+        'power_user': 2,
+        'user': 1,
+      };
+
+      const userRoleLevel = roleHierarchy[user.role] || 0;
+      const requiredRoleLevel = roleHierarchy[requiredRole] || 0;
+
+      if (userRoleLevel < requiredRoleLevel) {
+        return <Navigate to="/dashboard" replace />;
+      }
+    }
+  }
+
+  return <>{children}</>;
+}

+ 1 - 0
frontend/src/components/Guards/index.ts

@@ -0,0 +1 @@
+export { default as ProtectedRoute } from './ProtectedRoute';

+ 134 - 0
frontend/src/components/Layout/MainLayout.tsx

@@ -0,0 +1,134 @@
+import { useState } from 'react';
+import { Outlet, useNavigate, useLocation } from 'react-router-dom';
+import { Layout, Menu, Button, theme, Dropdown, Avatar, Space, Tag } from 'antd';
+import type { MenuProps } from 'antd';
+import {
+  DashboardOutlined,
+  CloudServerOutlined,
+  FileTextOutlined,
+  UserOutlined,
+  KeyOutlined,
+  SettingOutlined,
+  MenuFoldOutlined,
+  MenuUnfoldOutlined,
+  LogoutOutlined,
+  DownOutlined,
+} from '@ant-design/icons';
+import { useAuth } from '../../contexts/AuthContext';
+
+const { Header, Sider, Content } = Layout;
+
+// Menu items with role requirements
+const allMenuItems = [
+  { key: '/dashboard', icon: <DashboardOutlined />, label: 'Dashboard', roles: ['admin', 'power_user', 'user'] },
+  { key: '/tasks', icon: <CloudServerOutlined />, label: 'Tasks', roles: ['admin', 'power_user', 'user'] },
+  { key: '/reports', icon: <FileTextOutlined />, label: 'Reports', roles: ['admin', 'power_user', 'user'] },
+  { key: '/users', icon: <UserOutlined />, label: 'Users', roles: ['admin'] },
+  { key: '/credentials', icon: <KeyOutlined />, label: 'Credentials', roles: ['admin'] },
+  { key: '/workers', icon: <SettingOutlined />, label: 'Workers', roles: ['admin'] },
+];
+
+const roleColors: Record<string, string> = {
+  admin: 'red',
+  power_user: 'blue',
+  user: 'green',
+};
+
+const roleLabels: Record<string, string> = {
+  admin: 'Admin',
+  power_user: 'Power User',
+  user: 'User',
+};
+
+export default function MainLayout() {
+  const [collapsed, setCollapsed] = useState(false);
+  const navigate = useNavigate();
+  const location = useLocation();
+  const { token: { colorBgContainer } } = theme.useToken();
+  const { user, logout } = useAuth();
+
+  // Filter menu items based on user role
+  const menuItems = allMenuItems
+    .filter(item => user && item.roles.includes(user.role))
+    .map(({ roles, ...item }) => item);
+
+  const handleMenuClick = ({ key }: { key: string }) => {
+    navigate(key);
+  };
+
+  const handleLogout = async () => {
+    await logout();
+  };
+
+  const userMenuItems: MenuProps['items'] = [
+    {
+      key: 'logout',
+      icon: <LogoutOutlined />,
+      label: 'Logout',
+      onClick: handleLogout,
+    },
+  ];
+
+  return (
+    <Layout style={{ minHeight: '100vh' }}>
+      <Sider trigger={null} collapsible collapsed={collapsed}>
+        <div style={{ 
+          height: 32, 
+          margin: 16, 
+          background: 'rgba(255, 255, 255, 0.2)',
+          borderRadius: 6,
+          display: 'flex',
+          alignItems: 'center',
+          justifyContent: 'center',
+          color: '#fff',
+          fontWeight: 'bold'
+        }}>
+          {collapsed ? 'AWS' : 'AWS Scanner'}
+        </div>
+        <Menu
+          theme="dark"
+          mode="inline"
+          selectedKeys={[location.pathname]}
+          items={menuItems}
+          onClick={handleMenuClick}
+        />
+      </Sider>
+      <Layout>
+        <Header style={{ 
+          padding: '0 16px', 
+          background: colorBgContainer, 
+          display: 'flex', 
+          alignItems: 'center', 
+          justifyContent: 'space-between' 
+        }}>
+          <Button
+            type="text"
+            icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
+            onClick={() => setCollapsed(!collapsed)}
+          />
+          <Dropdown menu={{ items: userMenuItems }} trigger={['click']}>
+            <Space style={{ cursor: 'pointer' }}>
+              <Avatar icon={<UserOutlined />} />
+              <span>{user?.username}</span>
+              {user && (
+                <Tag color={roleColors[user.role]}>
+                  {roleLabels[user.role]}
+                </Tag>
+              )}
+              <DownOutlined />
+            </Space>
+          </Dropdown>
+        </Header>
+        <Content style={{ 
+          margin: '24px 16px', 
+          padding: 24, 
+          background: colorBgContainer, 
+          borderRadius: 8,
+          overflow: 'auto'
+        }}>
+          <Outlet />
+        </Content>
+      </Layout>
+    </Layout>
+  );
+}

+ 193 - 0
frontend/src/contexts/AuthContext.tsx

@@ -0,0 +1,193 @@
+import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { authService } from '../services/auth';
+import type { User, LoginRequest } from '../types';
+
+interface AuthContextType {
+  user: User | null;
+  loading: boolean;
+  isAuthenticated: boolean;
+  isAdmin: boolean;
+  isPowerUser: boolean;
+  login: (data: LoginRequest) => Promise<void>;
+  logout: () => Promise<void>;
+  checkAuth: () => Promise<void>;
+}
+
+const AuthContext = createContext<AuthContextType | undefined>(undefined);
+
+// Token expiration check interval (1 minute)
+const TOKEN_CHECK_INTERVAL = 60 * 1000;
+
+// Parse JWT token to get expiration time
+function parseJwt(token: string): { exp?: number } | null {
+  try {
+    const base64Url = token.split('.')[1];
+    const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
+    const jsonPayload = decodeURIComponent(
+      atob(base64)
+        .split('')
+        .map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
+        .join('')
+    );
+    return JSON.parse(jsonPayload);
+  } catch {
+    return null;
+  }
+}
+
+// Check if token is expired
+function isTokenExpired(token: string): boolean {
+  const payload = parseJwt(token);
+  if (!payload || !payload.exp) {
+    return true;
+  }
+  // Add 10 second buffer before actual expiration
+  return Date.now() >= (payload.exp * 1000) - 10000;
+}
+
+// Get time until token expires (in milliseconds)
+function getTimeUntilExpiry(token: string): number {
+  const payload = parseJwt(token);
+  if (!payload || !payload.exp) {
+    return 0;
+  }
+  return (payload.exp * 1000) - Date.now();
+}
+
+export function AuthProvider({ children }: { children: React.ReactNode }) {
+  const [user, setUser] = useState<User | null>(null);
+  const [loading, setLoading] = useState(true);
+  const navigate = useNavigate();
+  const tokenCheckIntervalRef = useRef<number | null>(null);
+  const expiryTimeoutRef = useRef<number | null>(null);
+
+  // Clear all timers
+  const clearTimers = useCallback(() => {
+    if (tokenCheckIntervalRef.current) {
+      clearInterval(tokenCheckIntervalRef.current);
+      tokenCheckIntervalRef.current = null;
+    }
+    if (expiryTimeoutRef.current) {
+      clearTimeout(expiryTimeoutRef.current);
+      expiryTimeoutRef.current = null;
+    }
+  }, []);
+
+  // Handle logout
+  const logout = useCallback(async () => {
+    clearTimers();
+    try {
+      await authService.logout();
+    } catch {
+      // Ignore logout errors
+    } finally {
+      localStorage.removeItem('token');
+      setUser(null);
+      navigate('/login');
+    }
+  }, [clearTimers, navigate]);
+
+
+  // Setup token expiration monitoring
+  const setupTokenMonitoring = useCallback((token: string) => {
+    clearTimers();
+
+    // Set up periodic token check
+    tokenCheckIntervalRef.current = window.setInterval(() => {
+      const currentToken = localStorage.getItem('token');
+      if (!currentToken || isTokenExpired(currentToken)) {
+        logout();
+      }
+    }, TOKEN_CHECK_INTERVAL);
+
+    // Set up timeout for exact expiration
+    const timeUntilExpiry = getTimeUntilExpiry(token);
+    if (timeUntilExpiry > 0) {
+      expiryTimeoutRef.current = window.setTimeout(() => {
+        logout();
+      }, timeUntilExpiry);
+    }
+  }, [clearTimers, logout]);
+
+  // Check authentication status
+  const checkAuth = useCallback(async () => {
+    const token = localStorage.getItem('token');
+    if (!token) {
+      setLoading(false);
+      return;
+    }
+
+    // Check if token is expired
+    if (isTokenExpired(token)) {
+      localStorage.removeItem('token');
+      setLoading(false);
+      return;
+    }
+
+    try {
+      const currentUser = await authService.getCurrentUser();
+      setUser(currentUser);
+      setupTokenMonitoring(token);
+    } catch {
+      localStorage.removeItem('token');
+      setUser(null);
+    } finally {
+      setLoading(false);
+    }
+  }, [setupTokenMonitoring]);
+
+  // Login handler
+  const login = useCallback(async (data: LoginRequest) => {
+    const response = await authService.login(data);
+    localStorage.setItem('token', response.token);
+    setUser(response.user);
+    setupTokenMonitoring(response.token);
+    navigate('/dashboard');
+  }, [navigate, setupTokenMonitoring]);
+
+  // Initial auth check
+  useEffect(() => {
+    checkAuth();
+    return () => {
+      clearTimers();
+    };
+  }, [checkAuth, clearTimers]);
+
+  // Listen for storage changes (logout from other tabs)
+  useEffect(() => {
+    const handleStorageChange = (e: StorageEvent) => {
+      if (e.key === 'token' && !e.newValue) {
+        clearTimers();
+        setUser(null);
+        navigate('/login');
+      }
+    };
+
+    window.addEventListener('storage', handleStorageChange);
+    return () => {
+      window.removeEventListener('storage', handleStorageChange);
+    };
+  }, [clearTimers, navigate]);
+
+  const value: AuthContextType = {
+    user,
+    loading,
+    isAuthenticated: !!user,
+    isAdmin: user?.role === 'admin',
+    isPowerUser: user?.role === 'power_user' || user?.role === 'admin',
+    login,
+    logout,
+    checkAuth,
+  };
+
+  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
+}
+
+export function useAuth() {
+  const context = useContext(AuthContext);
+  if (context === undefined) {
+    throw new Error('useAuth must be used within an AuthProvider');
+  }
+  return context;
+}

+ 1 - 0
frontend/src/contexts/index.ts

@@ -0,0 +1 @@
+export { AuthProvider, useAuth } from './AuthContext';

+ 2 - 0
frontend/src/hooks/useAuth.ts

@@ -0,0 +1,2 @@
+// Re-export useAuth from AuthContext for backward compatibility
+export { useAuth } from '../contexts/AuthContext';

+ 55 - 0
frontend/src/hooks/usePagination.ts

@@ -0,0 +1,55 @@
+import { useState, useCallback, useMemo } from 'react';
+
+interface PaginationState {
+  page: number;
+  pageSize: number;
+  total: number;
+}
+
+interface UsePaginationReturn extends PaginationState {
+  setPage: (page: number) => void;
+  setPageSize: (pageSize: number) => void;
+  setTotal: (total: number) => void;
+  reset: () => void;
+  totalPages: number;
+}
+
+export function usePagination(initialPageSize = 10): UsePaginationReturn {
+  const [state, setState] = useState<PaginationState>({
+    page: 1,
+    pageSize: initialPageSize,
+    total: 0,
+  });
+
+  const setPage = useCallback((page: number) => {
+    setState((prev) => ({ ...prev, page }));
+  }, []);
+
+  const setPageSize = useCallback((pageSize: number) => {
+    setState((prev) => ({ ...prev, pageSize, page: 1 }));
+  }, []);
+
+  const setTotal = useCallback((total: number) => {
+    setState((prev) => ({ ...prev, total }));
+  }, []);
+
+  const reset = useCallback(() => {
+    setState({ page: 1, pageSize: initialPageSize, total: 0 });
+  }, [initialPageSize]);
+
+  const totalPages = useMemo(() => 
+    Math.ceil(state.total / state.pageSize), 
+    [state.total, state.pageSize]
+  );
+
+  return {
+    page: state.page,
+    pageSize: state.pageSize,
+    total: state.total,
+    setPage,
+    setPageSize,
+    setTotal,
+    reset,
+    totalPages,
+  };
+}

+ 17 - 0
frontend/src/index.css

@@ -0,0 +1,17 @@
+* {
+  margin: 0;
+  padding: 0;
+  box-sizing: border-box;
+}
+
+body {
+  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
+    'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
+    'Noto Color Emoji';
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+}
+
+#root {
+  min-height: 100vh;
+}

+ 19 - 0
frontend/src/main.tsx

@@ -0,0 +1,19 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import { ConfigProvider } from 'antd'
+import App from './App'
+import './index.css'
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+  <React.StrictMode>
+    <ConfigProvider
+      theme={{
+        token: {
+          colorPrimary: '#1890ff',
+        },
+      }}
+    >
+      <App />
+    </ConfigProvider>
+  </React.StrictMode>,
+)

+ 725 - 0
frontend/src/pages/Credentials.tsx

@@ -0,0 +1,725 @@
+import { useEffect, useState, useCallback } from 'react';
+import {
+  Table,
+  Button,
+  Tag,
+  Space,
+  Modal,
+  Form,
+  Input,
+  Select,
+  message,
+  Popconfirm,
+  Typography,
+  Tooltip,
+  Card,
+  Descriptions,
+  Alert,
+  Divider,
+} from 'antd';
+import {
+  PlusOutlined,
+  ReloadOutlined,
+  EditOutlined,
+  DeleteOutlined,
+  CheckCircleOutlined,
+  SettingOutlined,
+  SafetyCertificateOutlined,
+  LoadingOutlined,
+} from '@ant-design/icons';
+import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
+import type { AWSCredential } from '../types';
+import { credentialService, type CreateCredentialRequest, type BaseRoleConfig } from '../services/credentials';
+import { usePagination } from '../hooks/usePagination';
+
+const { Title, Text } = Typography;
+const { Option } = Select;
+
+export default function Credentials() {
+  const [credentials, setCredentials] = useState<AWSCredential[]>([]);
+  const [loading, setLoading] = useState(false);
+  
+  // Modal states
+  const [createModalVisible, setCreateModalVisible] = useState(false);
+  const [editModalVisible, setEditModalVisible] = useState(false);
+  const [baseRoleModalVisible, setBaseRoleModalVisible] = useState(false);
+  const [selectedCredential, setSelectedCredential] = useState<AWSCredential | null>(null);
+  
+  // Form and action states
+  const [creating, setCreating] = useState(false);
+  const [updating, setUpdating] = useState(false);
+  const [deleting, setDeleting] = useState<number | null>(null);
+  const [validating, setValidating] = useState<number | null>(null);
+  const [savingBaseRole, setSavingBaseRole] = useState(false);
+  
+  // Base role config
+  const [baseRoleConfig, setBaseRoleConfig] = useState<BaseRoleConfig | null>(null);
+  const [baseRoleConfigured, setBaseRoleConfigured] = useState(false);
+  
+  // Credential type for create form
+  const [credentialType, setCredentialType] = useState<'assume_role' | 'access_key'>('assume_role');
+  
+  const pagination = usePagination(10);
+  const [createForm] = Form.useForm();
+  const [editForm] = Form.useForm();
+  const [baseRoleForm] = Form.useForm();
+
+  // Fetch credentials with specific page/pageSize
+  const fetchCredentialsWithParams = useCallback(async (page: number, pageSize: number) => {
+    try {
+      setLoading(true);
+      const response = await credentialService.getCredentials({
+        page,
+        pageSize,
+      });
+      setCredentials(response.data);
+      pagination.setTotal(response.pagination.total);
+    } catch (error) {
+      message.error('Failed to load credentials');
+    } finally {
+      setLoading(false);
+    }
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+
+  // Wrapper for refresh button
+  const fetchCredentials = useCallback(() => {
+    fetchCredentialsWithParams(pagination.page, pagination.pageSize);
+  }, [fetchCredentialsWithParams, pagination.page, pagination.pageSize]);
+
+  // Fetch base role config
+  const fetchBaseRoleConfig = async () => {
+    try {
+      const response = await credentialService.getBaseRole();
+      if ((response as { configured?: boolean }).configured === false) {
+        setBaseRoleConfigured(false);
+        setBaseRoleConfig(null);
+      } else {
+        setBaseRoleConfigured(true);
+        setBaseRoleConfig((response as { data: BaseRoleConfig }).data || response);
+      }
+    } catch (error) {
+      console.error('Failed to load base role config:', error);
+    }
+  };
+
+  // Initial load only
+  useEffect(() => {
+    fetchCredentialsWithParams(pagination.page, pagination.pageSize);
+    fetchBaseRoleConfig();
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+
+  // Handle table pagination change
+  const handleTableChange = async (paginationConfig: TablePaginationConfig) => {
+    const newPage = paginationConfig.current ?? pagination.page;
+    const newPageSize = paginationConfig.pageSize ?? pagination.pageSize;
+    
+    pagination.setPage(newPage);
+    if (newPageSize !== pagination.pageSize) {
+      pagination.setPageSize(newPageSize);
+    }
+    
+    // Directly fetch with new params
+    await fetchCredentialsWithParams(newPage, newPageSize);
+  };
+
+  // Open create modal
+  const handleOpenCreateModal = () => {
+    createForm.resetFields();
+    setCredentialType('assume_role');
+    setCreateModalVisible(true);
+  };
+
+  // Handle create credential
+  const handleCreateCredential = async (values: Record<string, string>) => {
+    try {
+      setCreating(true);
+      const data: CreateCredentialRequest = {
+        name: values.name,
+        credentialType: values.credentialType as 'assume_role' | 'access_key',
+      };
+      
+      if (values.credentialType === 'assume_role') {
+        data.accountId = values.accountId;
+        data.roleArn = values.roleArn;
+        data.externalId = values.externalId;
+      } else {
+        // For access_key, account_id will be auto-detected by backend
+        data.accessKeyId = values.accessKeyId;
+        data.secretAccessKey = values.secretAccessKey;
+      }
+      
+      await credentialService.createCredential(data);
+      message.success('Credential created successfully');
+      setCreateModalVisible(false);
+      createForm.resetFields();
+      fetchCredentials();
+    } catch (error: unknown) {
+      const err = error as { response?: { data?: { error?: { message?: string } } } };
+      message.error(err.response?.data?.error?.message || 'Failed to create credential');
+    } finally {
+      setCreating(false);
+    }
+  };
+
+  // Open edit modal
+  const handleOpenEditModal = (credential: AWSCredential) => {
+    setSelectedCredential(credential);
+    editForm.setFieldsValue({
+      name: credential.name,
+      accountId: credential.account_id,
+      roleArn: credential.role_arn,
+      externalId: credential.external_id,
+      accessKeyId: credential.access_key_id,
+    });
+    setEditModalVisible(true);
+  };
+
+  // Handle update credential
+  const handleUpdateCredential = async (values: Record<string, string>) => {
+    if (!selectedCredential) return;
+    
+    try {
+      setUpdating(true);
+      const data: Partial<CreateCredentialRequest> & { id: number } = {
+        id: selectedCredential.id,
+        name: values.name,
+      };
+      
+      if (selectedCredential.credential_type === 'assume_role') {
+        data.accountId = values.accountId;
+        data.roleArn = values.roleArn;
+        data.externalId = values.externalId;
+      } else {
+        // For access_key, account_id is auto-managed by backend
+        data.accessKeyId = values.accessKeyId;
+        if (values.secretAccessKey) {
+          data.secretAccessKey = values.secretAccessKey;
+        }
+      }
+      
+      await credentialService.updateCredential(selectedCredential.id, data);
+      message.success('Credential updated successfully');
+      setEditModalVisible(false);
+      setSelectedCredential(null);
+      editForm.resetFields();
+      fetchCredentials();
+    } catch (error: unknown) {
+      const err = error as { response?: { data?: { error?: { message?: string } } } };
+      message.error(err.response?.data?.error?.message || 'Failed to update credential');
+    } finally {
+      setUpdating(false);
+    }
+  };
+
+  // Handle delete credential
+  const handleDeleteCredential = async (credentialId: number) => {
+    try {
+      setDeleting(credentialId);
+      await credentialService.deleteCredential(credentialId);
+      message.success('Credential deleted successfully');
+      fetchCredentials();
+    } catch (error: unknown) {
+      const err = error as { response?: { data?: { error?: { message?: string } } } };
+      message.error(err.response?.data?.error?.message || 'Failed to delete credential');
+    } finally {
+      setDeleting(null);
+    }
+  };
+
+  // Handle validate credential
+  const handleValidateCredential = async (credentialId: number) => {
+    try {
+      setValidating(credentialId);
+      const result = await credentialService.validateCredential(credentialId);
+      if (result.valid) {
+        message.success(`Credential is valid. Account ID: ${(result as { account_id?: string }).account_id || 'N/A'}`);
+      } else {
+        message.error(`Credential validation failed: ${(result as { error?: string }).error || 'Unknown error'}`);
+      }
+    } catch (error: unknown) {
+      const err = error as { response?: { data?: { error?: { message?: string } } } };
+      message.error(err.response?.data?.error?.message || 'Failed to validate credential');
+    } finally {
+      setValidating(null);
+    }
+  };
+
+  // Open base role modal
+  const handleOpenBaseRoleModal = () => {
+    if (baseRoleConfig) {
+      baseRoleForm.setFieldsValue({
+        accessKeyId: baseRoleConfig.accessKeyId,
+      });
+    } else {
+      baseRoleForm.resetFields();
+    }
+    setBaseRoleModalVisible(true);
+  };
+
+  // Handle save base role config
+  const handleSaveBaseRole = async (values: Record<string, string>) => {
+    try {
+      setSavingBaseRole(true);
+      await credentialService.updateBaseRole({
+        accessKeyId: values.accessKeyId,
+        secretAccessKey: values.secretAccessKey,
+      });
+      message.success('Base Assume Role configuration saved successfully');
+      setBaseRoleModalVisible(false);
+      baseRoleForm.resetFields();
+      fetchBaseRoleConfig();
+    } catch (error: unknown) {
+      const err = error as { response?: { data?: { error?: { message?: string } } } };
+      message.error(err.response?.data?.error?.message || 'Failed to save base role configuration');
+    } finally {
+      setSavingBaseRole(false);
+    }
+  };
+
+  // Format date
+  const formatDate = (dateString?: string): string => {
+    if (!dateString) return '-';
+    return new Date(dateString).toLocaleString();
+  };
+
+  // Table columns
+  const columns: ColumnsType<AWSCredential> = [
+    {
+      title: 'ID',
+      dataIndex: 'id',
+      key: 'id',
+      width: 80,
+    },
+    {
+      title: 'Name',
+      dataIndex: 'name',
+      key: 'name',
+    },
+    {
+      title: 'Account ID',
+      dataIndex: 'account_id',
+      key: 'account_id',
+      width: 140,
+    },
+    {
+      title: 'Type',
+      dataIndex: 'credential_type',
+      key: 'credential_type',
+      width: 120,
+      render: (type: string) => (
+        <Tag color={type === 'assume_role' ? 'blue' : 'green'}>
+          {type === 'assume_role' ? 'Assume Role' : 'Access Key'}
+        </Tag>
+      ),
+    },
+    {
+      title: 'Status',
+      dataIndex: 'is_active',
+      key: 'is_active',
+      width: 100,
+      render: (active: boolean) => (
+        <Tag color={active ? 'success' : 'default'}>
+          {active ? 'Active' : 'Inactive'}
+        </Tag>
+      ),
+    },
+    {
+      title: 'Created At',
+      dataIndex: 'created_at',
+      key: 'created_at',
+      width: 180,
+      render: (date: string) => formatDate(date),
+    },
+    {
+      title: 'Actions',
+      key: 'actions',
+      width: 200,
+      render: (_: unknown, record: AWSCredential) => (
+        <Space>
+          <Tooltip title="Validate">
+            <Button
+              size="small"
+              icon={validating === record.id ? <LoadingOutlined /> : <CheckCircleOutlined />}
+              onClick={() => handleValidateCredential(record.id)}
+              disabled={validating === record.id}
+            />
+          </Tooltip>
+          <Tooltip title="Edit">
+            <Button
+              size="small"
+              icon={<EditOutlined />}
+              onClick={() => handleOpenEditModal(record)}
+            />
+          </Tooltip>
+          <Popconfirm
+            title="Delete Credential"
+            description="Are you sure you want to delete this credential?"
+            onConfirm={() => handleDeleteCredential(record.id)}
+            okText="Yes"
+            cancelText="No"
+          >
+            <Tooltip title="Delete">
+              <Button
+                size="small"
+                danger
+                icon={<DeleteOutlined />}
+                loading={deleting === record.id}
+              />
+            </Tooltip>
+          </Popconfirm>
+        </Space>
+      ),
+    },
+  ];
+
+  return (
+    <div>
+      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
+        <Title level={2} style={{ margin: 0 }}>AWS Credentials</Title>
+        <Space>
+          <Button
+            icon={<SettingOutlined />}
+            onClick={handleOpenBaseRoleModal}
+          >
+            Base Role Config
+          </Button>
+          <Button
+            icon={<ReloadOutlined />}
+            onClick={fetchCredentials}
+            loading={loading}
+          >
+            Refresh
+          </Button>
+          <Button
+            type="primary"
+            icon={<PlusOutlined />}
+            onClick={handleOpenCreateModal}
+          >
+            Add Credential
+          </Button>
+        </Space>
+      </div>
+
+      {/* Base Role Config Status */}
+      {!baseRoleConfigured && (
+        <Alert
+          message="Base Assume Role Not Configured"
+          description="Please configure the base Assume Role credentials to use Assume Role type credentials."
+          type="warning"
+          showIcon
+          style={{ marginBottom: 16 }}
+          action={
+            <Button size="small" onClick={handleOpenBaseRoleModal}>
+              Configure Now
+            </Button>
+          }
+        />
+      )}
+
+      <Table
+        columns={columns}
+        dataSource={credentials}
+        rowKey="id"
+        loading={loading}
+        pagination={{
+          current: pagination.page,
+          pageSize: pagination.pageSize,
+          total: pagination.total,
+          showSizeChanger: true,
+          showQuickJumper: false,
+          pageSizeOptions: ['10', '20', '50', '100'],
+          showTotal: (total: number) => `Total ${total} credentials`,
+        }}
+        onChange={handleTableChange}
+      />
+
+      {/* Create Credential Modal */}
+      <Modal
+        title="Add AWS Credential"
+        open={createModalVisible}
+        onCancel={() => setCreateModalVisible(false)}
+        footer={null}
+        destroyOnClose
+        width={600}
+      >
+        <Form
+          form={createForm}
+          layout="vertical"
+          onFinish={handleCreateCredential}
+          initialValues={{ credentialType: 'assume_role' }}
+        >
+          <Form.Item
+            name="name"
+            label="Name"
+            rules={[
+              { required: true, message: 'Please enter credential name' },
+              { max: 100, message: 'Name must be at most 100 characters' },
+            ]}
+          >
+            <Input placeholder="Enter credential name" />
+          </Form.Item>
+
+          <Form.Item
+            name="credentialType"
+            label="Credential Type"
+            rules={[{ required: true, message: 'Please select credential type' }]}
+          >
+            <Select
+              placeholder="Select credential type"
+              onChange={(value) => setCredentialType(value as 'assume_role' | 'access_key')}
+            >
+              <Option value="assume_role">Assume Role</Option>
+              <Option value="access_key">Access Key</Option>
+            </Select>
+          </Form.Item>
+
+          {credentialType === 'assume_role' && (
+            <Form.Item
+              name="accountId"
+              label="AWS Account ID"
+              rules={[
+                { required: true, message: 'Please enter AWS account ID' },
+                { len: 12, message: 'Account ID must be 12 digits' },
+                { pattern: /^\d+$/, message: 'Account ID must contain only digits' },
+              ]}
+            >
+              <Input placeholder="Enter 12-digit AWS account ID" maxLength={12} />
+            </Form.Item>
+          )}
+
+          {credentialType === 'assume_role' ? (
+            <>
+              <Form.Item
+                name="roleArn"
+                label="Role ARN"
+                rules={[
+                  { required: true, message: 'Please enter Role ARN' },
+                  { pattern: /^arn:aws:iam::\d{12}:role\//, message: 'Invalid Role ARN format' },
+                ]}
+              >
+                <Input placeholder="arn:aws:iam::123456789012:role/RoleName" />
+              </Form.Item>
+
+              <Form.Item
+                name="externalId"
+                label="External ID"
+                help="Optional external ID for cross-account access"
+              >
+                <Input placeholder="Enter external ID (optional)" />
+              </Form.Item>
+            </>
+          ) : (
+            <>
+              <Form.Item
+                name="accessKeyId"
+                label="Access Key ID"
+                rules={[{ required: true, message: 'Please enter Access Key ID' }]}
+              >
+                <Input placeholder="Enter Access Key ID" />
+              </Form.Item>
+
+              <Form.Item
+                name="secretAccessKey"
+                label="Secret Access Key"
+                rules={[{ required: true, message: 'Please enter Secret Access Key' }]}
+                help="AWS Account ID will be automatically detected from the credentials"
+              >
+                <Input.Password placeholder="Enter Secret Access Key" />
+              </Form.Item>
+            </>
+          )}
+
+          <Form.Item style={{ marginBottom: 0, textAlign: 'right' }}>
+            <Space>
+              <Button onClick={() => setCreateModalVisible(false)}>Cancel</Button>
+              <Button type="primary" htmlType="submit" loading={creating}>
+                Create
+              </Button>
+            </Space>
+          </Form.Item>
+        </Form>
+      </Modal>
+
+      {/* Edit Credential Modal */}
+      <Modal
+        title="Edit AWS Credential"
+        open={editModalVisible}
+        onCancel={() => {
+          setEditModalVisible(false);
+          setSelectedCredential(null);
+        }}
+        footer={null}
+        destroyOnClose
+        width={600}
+      >
+        {selectedCredential && (
+          <Form
+            form={editForm}
+            layout="vertical"
+            onFinish={handleUpdateCredential}
+          >
+            <Form.Item
+              name="name"
+              label="Name"
+              rules={[
+                { required: true, message: 'Please enter credential name' },
+                { max: 100, message: 'Name must be at most 100 characters' },
+              ]}
+            >
+              <Input placeholder="Enter credential name" />
+            </Form.Item>
+
+            <Form.Item label="Credential Type">
+              <Tag color={selectedCredential.credential_type === 'assume_role' ? 'blue' : 'green'}>
+                {selectedCredential.credential_type === 'assume_role' ? 'Assume Role' : 'Access Key'}
+              </Tag>
+              <Text type="secondary" style={{ marginLeft: 8 }}>
+                (Cannot be changed)
+              </Text>
+            </Form.Item>
+
+            {selectedCredential.credential_type === 'assume_role' && (
+              <Form.Item
+                name="accountId"
+                label="AWS Account ID"
+                rules={[
+                  { required: true, message: 'Please enter AWS account ID' },
+                  { len: 12, message: 'Account ID must be 12 digits' },
+                  { pattern: /^\d+$/, message: 'Account ID must contain only digits' },
+                ]}
+              >
+                <Input placeholder="Enter 12-digit AWS account ID" maxLength={12} />
+              </Form.Item>
+            )}
+
+            {selectedCredential.credential_type === 'assume_role' ? (
+              <>
+                <Form.Item
+                  name="roleArn"
+                  label="Role ARN"
+                  rules={[
+                    { required: true, message: 'Please enter Role ARN' },
+                    { pattern: /^arn:aws:iam::\d{12}:role\//, message: 'Invalid Role ARN format' },
+                  ]}
+                >
+                  <Input placeholder="arn:aws:iam::123456789012:role/RoleName" />
+                </Form.Item>
+
+                <Form.Item
+                  name="externalId"
+                  label="External ID"
+                  help="Optional external ID for cross-account access"
+                >
+                  <Input placeholder="Enter external ID (optional)" />
+                </Form.Item>
+              </>
+            ) : (
+              <>
+                <Form.Item
+                  name="accessKeyId"
+                  label="Access Key ID"
+                  rules={[{ required: true, message: 'Please enter Access Key ID' }]}
+                >
+                  <Input placeholder="Enter Access Key ID" />
+                </Form.Item>
+
+                <Form.Item
+                  name="secretAccessKey"
+                  label="Secret Access Key"
+                  help="Leave empty to keep current secret"
+                >
+                  <Input.Password placeholder="Enter new Secret Access Key (optional)" />
+                </Form.Item>
+              </>
+            )}
+
+            <Form.Item style={{ marginBottom: 0, textAlign: 'right' }}>
+              <Space>
+                <Button onClick={() => {
+                  setEditModalVisible(false);
+                  setSelectedCredential(null);
+                }}>
+                  Cancel
+                </Button>
+                <Button type="primary" htmlType="submit" loading={updating}>
+                  Update
+                </Button>
+              </Space>
+            </Form.Item>
+          </Form>
+        )}
+      </Modal>
+
+      {/* Base Role Config Modal */}
+      <Modal
+        title={
+          <Space>
+            <SafetyCertificateOutlined />
+            <span>Base Assume Role Configuration</span>
+          </Space>
+        }
+        open={baseRoleModalVisible}
+        onCancel={() => setBaseRoleModalVisible(false)}
+        footer={null}
+        destroyOnClose
+        width={600}
+      >
+        <Alert
+          message="About Base Assume Role"
+          description="This is the base AWS account credentials used to assume roles in target accounts. All Assume Role type credentials will use these base credentials to perform STS AssumeRole operations."
+          type="info"
+          showIcon
+          style={{ marginBottom: 16 }}
+        />
+
+        {baseRoleConfigured && baseRoleConfig && (
+          <Card size="small" style={{ marginBottom: 16 }}>
+            <Descriptions column={1} size="small">
+              <Descriptions.Item label="Current Access Key ID">
+                {baseRoleConfig.accessKeyId || 'Not configured'}
+              </Descriptions.Item>
+              <Descriptions.Item label="Status">
+                <Tag color="success">Configured</Tag>
+              </Descriptions.Item>
+            </Descriptions>
+          </Card>
+        )}
+
+        <Divider>Update Configuration</Divider>
+
+        <Form
+          form={baseRoleForm}
+          layout="vertical"
+          onFinish={handleSaveBaseRole}
+        >
+          <Form.Item
+            name="accessKeyId"
+            label="Access Key ID"
+            rules={[{ required: true, message: 'Please enter Access Key ID' }]}
+          >
+            <Input placeholder="Enter Access Key ID" />
+          </Form.Item>
+
+          <Form.Item
+            name="secretAccessKey"
+            label="Secret Access Key"
+            rules={[{ required: true, message: 'Please enter Secret Access Key' }]}
+          >
+            <Input.Password placeholder="Enter Secret Access Key" />
+          </Form.Item>
+
+          <Form.Item style={{ marginBottom: 0, textAlign: 'right' }}>
+            <Space>
+              <Button onClick={() => setBaseRoleModalVisible(false)}>Cancel</Button>
+              <Button type="primary" htmlType="submit" loading={savingBaseRole}>
+                Save Configuration
+              </Button>
+            </Space>
+          </Form.Item>
+        </Form>
+      </Modal>
+    </div>
+  );
+}

+ 221 - 0
frontend/src/pages/Dashboard.tsx

@@ -0,0 +1,221 @@
+import { useEffect, useState } from 'react';
+import { Card, Row, Col, Statistic, Table, Spin, message, Typography, Button } from 'antd';
+import { 
+  CloudServerOutlined, 
+  FileTextOutlined, 
+  CheckCircleOutlined, 
+  CloseCircleOutlined,
+  ClockCircleOutlined,
+  SyncOutlined,
+  DownloadOutlined,
+  ReloadOutlined
+} from '@ant-design/icons';
+import type { ColumnsType } from 'antd/es/table';
+import { dashboardService, DashboardStats, RecentReport } from '../services/dashboard';
+import { reportService } from '../services/reports';
+
+const { Title } = Typography;
+
+export default function Dashboard() {
+  const [loading, setLoading] = useState(true);
+  const [stats, setStats] = useState<DashboardStats | null>(null);
+  const [downloadingId, setDownloadingId] = useState<number | null>(null);
+
+  const fetchStats = async () => {
+    try {
+      setLoading(true);
+      const data = await dashboardService.getStats();
+      setStats(data);
+    } catch (error) {
+      message.error('Failed to load dashboard statistics');
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  useEffect(() => {
+    fetchStats();
+  }, []);
+
+  const handleDownload = async (report: RecentReport) => {
+    try {
+      setDownloadingId(report.id);
+      const blob = await reportService.downloadReport(report.id);
+      const url = window.URL.createObjectURL(blob);
+      const link = document.createElement('a');
+      link.href = url;
+      link.download = report.file_name;
+      document.body.appendChild(link);
+      link.click();
+      document.body.removeChild(link);
+      window.URL.revokeObjectURL(url);
+    } catch (error) {
+      message.error('Failed to download report');
+    } finally {
+      setDownloadingId(null);
+    }
+  };
+
+  const formatFileSize = (bytes: number): string => {
+    if (bytes === undefined || bytes === null || isNaN(bytes)) return '-';
+    if (bytes === 0) return '0 B';
+    const k = 1024;
+    const sizes = ['B', 'KB', 'MB', 'GB'];
+    const i = Math.floor(Math.log(bytes) / Math.log(k));
+    return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
+  };
+
+  const formatDate = (dateString: string): string => {
+    if (!dateString) return '-';
+    const date = new Date(dateString);
+    if (isNaN(date.getTime())) return '-';
+    return date.toLocaleString();
+  };
+
+  const columns: ColumnsType<RecentReport> = [
+    {
+      title: 'File Name',
+      dataIndex: 'file_name',
+      key: 'file_name',
+      ellipsis: true,
+    },
+    {
+      title: 'Size',
+      dataIndex: 'file_size',
+      key: 'file_size',
+      width: 100,
+      render: (size: number) => formatFileSize(size),
+    },
+    {
+      title: 'Created At',
+      dataIndex: 'created_at',
+      key: 'created_at',
+      width: 180,
+      render: (date: string) => formatDate(date),
+    },
+    {
+      title: 'Action',
+      key: 'action',
+      width: 120,
+      render: (_: unknown, record: RecentReport) => (
+        <Button
+          type="link"
+          size="small"
+          icon={<DownloadOutlined />}
+          loading={downloadingId === record.id}
+          onClick={() => handleDownload(record)}
+        >
+          Download
+        </Button>
+      ),
+    },
+  ];
+
+  if (loading && !stats) {
+    return (
+      <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%', minHeight: 400 }}>
+        <Spin size="large" />
+      </div>
+    );
+  }
+
+  return (
+    <div>
+      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
+        <Title level={2} style={{ margin: 0 }}>Dashboard</Title>
+        <Button 
+          icon={<ReloadOutlined />} 
+          onClick={fetchStats}
+          loading={loading}
+        >
+          Refresh
+        </Button>
+      </div>
+
+      {/* Task Statistics */}
+      <Row gutter={16} style={{ marginBottom: 24 }}>
+        <Col xs={24} sm={12} md={6}>
+          <Card>
+            <Statistic
+              title="Total Tasks"
+              value={stats?.tasks.total ?? 0}
+              prefix={<CloudServerOutlined />}
+            />
+          </Card>
+        </Col>
+        <Col xs={24} sm={12} md={6}>
+          <Card>
+            <Statistic
+              title="Completed"
+              value={stats?.tasks.completed ?? 0}
+              prefix={<CheckCircleOutlined />}
+              valueStyle={{ color: '#3f8600' }}
+            />
+          </Card>
+        </Col>
+        <Col xs={24} sm={12} md={6}>
+          <Card>
+            <Statistic
+              title="Failed"
+              value={stats?.tasks.failed ?? 0}
+              prefix={<CloseCircleOutlined />}
+              valueStyle={{ color: '#cf1322' }}
+            />
+          </Card>
+        </Col>
+        <Col xs={24} sm={12} md={6}>
+          <Card>
+            <Statistic
+              title="Reports"
+              value={stats?.reports.total ?? 0}
+              prefix={<FileTextOutlined />}
+            />
+          </Card>
+        </Col>
+      </Row>
+
+      {/* Additional Task Status */}
+      <Row gutter={16} style={{ marginBottom: 24 }}>
+        <Col xs={24} sm={12}>
+          <Card>
+            <Statistic
+              title="Pending Tasks"
+              value={stats?.tasks.pending ?? 0}
+              prefix={<ClockCircleOutlined />}
+              valueStyle={{ color: '#faad14' }}
+            />
+          </Card>
+        </Col>
+        <Col xs={24} sm={12}>
+          <Card>
+            <Statistic
+              title="Running Tasks"
+              value={stats?.tasks.running ?? 0}
+              prefix={<SyncOutlined spin={stats?.tasks.running ? stats.tasks.running > 0 : false} />}
+              valueStyle={{ color: '#1890ff' }}
+            />
+          </Card>
+        </Col>
+      </Row>
+
+      {/* Recent Reports */}
+      <Card 
+        title={
+          <span>
+            <FileTextOutlined style={{ marginRight: 8 }} />
+            Recent Reports
+          </span>
+        }
+      >
+        <Table
+          columns={columns}
+          dataSource={stats?.reports.recent ?? []}
+          rowKey="id"
+          pagination={false}
+          locale={{ emptyText: 'No reports yet' }}
+          size="middle"
+        />
+      </Card>
+    </div>
+  );
+}

Vissa filer visades inte eftersom för många filer har ändrats