Files
hr-portal/backend/app/services/audit_service.py
Porsche Chen 360533393f feat: HR Portal - Complete Multi-Tenant System with Redis Session Storage
Major Features:
-  Multi-tenant architecture (tenant isolation)
-  Employee CRUD with lifecycle management (onboarding/offboarding)
-  Department tree structure with email domain management
-  Company info management (single-record editing)
-  System functions CRUD (permission management)
-  Email account management (multi-account per employee)
-  Keycloak SSO integration (auth.lab.taipei)
-  Redis session storage (10.1.0.254:6379)
  - Solves Cookie 4KB limitation
  - Cross-system session sharing
  - Sliding expiration (8 hours)
  - Automatic token refresh

Technical Stack:
Backend:
- FastAPI + SQLAlchemy
- PostgreSQL 16 (10.1.0.20:5433)
- Keycloak Admin API integration
- Docker Mailserver integration (SSH)
- Alembic migrations

Frontend:
- Next.js 14 (App Router)
- NextAuth 4 with Keycloak Provider
- Redis session storage (ioredis)
- Tailwind CSS

Infrastructure:
- Redis 7 (10.1.0.254:6379) - Session + Cache
- Keycloak 26.1.0 (auth.lab.taipei)
- Docker Mailserver (10.1.0.254)

Architecture Highlights:
- Session管理由 Keycloak + Redis 統一控制
- 支援多系統 (HR/WebMail/Calendar/Drive/Office) 共享 session
- Token 自動刷新,異質服務整合
- 未來可無縫遷移到雲端

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-23 20:12:43 +08:00

258 lines
6.9 KiB
Python

"""
審計日誌服務
自動記錄所有 CRUD 操作,符合 ISO 要求
"""
from typing import Optional, Dict, Any
from datetime import datetime
from sqlalchemy.orm import Session
from fastapi import Request
from app.models.audit_log import AuditLog
class AuditService:
"""審計日誌服務類別"""
@staticmethod
def log(
db: Session,
action: str,
resource_type: str,
performed_by: str,
resource_id: Optional[int] = None,
details: Optional[Dict[str, Any]] = None,
ip_address: Optional[str] = None,
) -> AuditLog:
"""
創建審計日誌
Args:
db: 資料庫 Session
action: 操作類型 (create/update/delete/login/logout)
resource_type: 資源類型 (employee/identity/department/etc)
performed_by: 操作者 SSO 帳號
resource_id: 資源 ID
details: 詳細變更內容 (dict)
ip_address: IP 位址
Returns:
AuditLog: 創建的審計日誌物件
"""
# TODO: 從 JWT token 或 session 獲取 tenant_id,目前暫時使用預設值 1
tenant_id = 1
audit_log = AuditLog(
tenant_id=tenant_id,
action=action,
resource_type=resource_type,
resource_id=resource_id,
performed_by=performed_by,
details=details,
ip_address=ip_address,
)
db.add(audit_log)
db.commit()
db.refresh(audit_log)
return audit_log
@staticmethod
def log_create(
db: Session,
resource_type: str,
resource_id: int,
performed_by: str,
details: Dict[str, Any],
ip_address: Optional[str] = None,
) -> AuditLog:
"""記錄創建操作"""
return AuditService.log(
db=db,
action="create",
resource_type=resource_type,
resource_id=resource_id,
performed_by=performed_by,
details=details,
ip_address=ip_address,
)
@staticmethod
def log_update(
db: Session,
resource_type: str,
resource_id: int,
performed_by: str,
old_values: Dict[str, Any],
new_values: Dict[str, Any],
ip_address: Optional[str] = None,
) -> AuditLog:
"""
記錄更新操作
Args:
old_values: 舊值
new_values: 新值
"""
details = {
"old": old_values,
"new": new_values,
"changed_fields": list(set(old_values.keys()) & set(new_values.keys()))
}
return AuditService.log(
db=db,
action="update",
resource_type=resource_type,
resource_id=resource_id,
performed_by=performed_by,
details=details,
ip_address=ip_address,
)
@staticmethod
def log_delete(
db: Session,
resource_type: str,
resource_id: int,
performed_by: str,
details: Dict[str, Any],
ip_address: Optional[str] = None,
) -> AuditLog:
"""記錄刪除操作"""
return AuditService.log(
db=db,
action="delete",
resource_type=resource_type,
resource_id=resource_id,
performed_by=performed_by,
details=details,
ip_address=ip_address,
)
@staticmethod
def log_login(
db: Session,
username: str,
ip_address: Optional[str] = None,
success: bool = True,
) -> AuditLog:
"""記錄登入操作"""
return AuditService.log(
db=db,
action="login",
resource_type="authentication",
performed_by=username,
details={"success": success},
ip_address=ip_address,
)
@staticmethod
def log_logout(
db: Session,
username: str,
ip_address: Optional[str] = None,
) -> AuditLog:
"""記錄登出操作"""
return AuditService.log(
db=db,
action="logout",
resource_type="authentication",
performed_by=username,
ip_address=ip_address,
)
@staticmethod
def get_client_ip(request: Request) -> Optional[str]:
"""
從 Request 獲取客戶端 IP
優先順序:
1. X-Forwarded-For (代理服務器)
2. X-Real-IP (Nginx)
3. request.client.host (直接連接)
"""
# 檢查 X-Forwarded-For
forwarded_for = request.headers.get("X-Forwarded-For")
if forwarded_for:
# 取第一個 IP (客戶端真實 IP)
return forwarded_for.split(",")[0].strip()
# 檢查 X-Real-IP
real_ip = request.headers.get("X-Real-IP")
if real_ip:
return real_ip
# 直接連接
if request.client:
return request.client.host
return None
@staticmethod
def model_to_dict(obj, exclude_fields: Optional[set] = None) -> Dict[str, Any]:
"""
將 SQLAlchemy Model 轉換為 dict (用於審計日誌)
Args:
obj: SQLAlchemy Model 物件
exclude_fields: 排除的欄位集合
Returns:
dict: 模型的 dict 表示
"""
if exclude_fields is None:
exclude_fields = {"created_at", "updated_at", "_sa_instance_state"}
result = {}
for column in obj.__table__.columns:
if column.name not in exclude_fields:
value = getattr(obj, column.name)
# 處理 datetime
if isinstance(value, datetime):
value = value.isoformat()
result[column.name] = value
return result
@staticmethod
def log_action(
db: Session,
action: str,
resource_type: str,
resource_id: Optional[int] = None,
details: Optional[Dict[str, Any]] = None,
request: Optional[Request] = None,
performed_by: str = "system",
) -> AuditLog:
"""
通用操作記錄 (permissions.py 使用的介面)
Args:
db: 資料庫 Session
action: 操作類型
resource_type: 資源類型
resource_id: 資源 ID
details: 詳細內容
request: FastAPI Request 物件 (用於取得 IP)
performed_by: 操作者
"""
ip_address = None
if request is not None:
ip_address = AuditService.get_client_ip(request)
return AuditService.log(
db=db,
action=action,
resource_type=resource_type,
resource_id=resource_id,
performed_by=performed_by,
details=details,
ip_address=ip_address,
)
# 全域審計服務實例
audit_service = AuditService()