feat: add all application source code

- apps/api: FastAPI backend with Dockerfile
- apps/web: Next.js frontend with Dockerfile
- apps/sensor: Signal collection agent
- packages: shared packages

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
OG T
2026-03-22 18:57:44 +08:00
parent a840bf975b
commit 196d269b92
245 changed files with 42207 additions and 6 deletions

View File

@@ -0,0 +1,443 @@
"""
Multi-Sig Redis Service - 簽核狀態持久化
=========================================
Phase 6.1.1: Multi-Sig Redis 遷移
Features:
- 簽核狀態 Redis Hash 持久化
- 7 天 TTL 稽核保留 (資安合規)
- 分散式鎖防止 Race Condition
- 與現有 SQLite 雙寫模式 (Phase 6.2 後可移除 SQLite)
統帥鐵律:
- 所有簽核狀態變更必須經過此模組
- 7 天 TTL 不可修改 (資安稽核要求)
- 分散式鎖必須包裹所有寫入操作
"""
import json
from datetime import datetime, timezone
from typing import Any
from uuid import UUID
import structlog
from src.core.redis_client import get_redis, RedisLock
logger = structlog.get_logger(__name__)
# =============================================================================
# Constants
# =============================================================================
# Redis Key 前綴
APPROVAL_KEY_PREFIX = "approval:"
SIGNATURE_KEY_PREFIX = "signature:"
# 7 天 TTL (資安稽核要求)
APPROVAL_TTL_SECONDS = 86400 * 7 # 604800 秒
# =============================================================================
# Approval State Model
# =============================================================================
class ApprovalStateRedis:
"""
Redis 中的簽核狀態結構
Hash Fields:
- id: 簽核單 ID
- action: 操作類型 (DELETE_POD, RESTART_SERVICE, etc.)
- description: 描述
- status: 狀態 (pending, approved, rejected, voided, executed)
- risk_level: 風險等級 (critical, high, medium, low)
- required_signatures: 需要簽核數
- current_signatures: 目前簽核數
- signatures: 簽核列表 (JSON Array)
- created_at: 建立時間
- updated_at: 更新時間
- namespace: K8s Namespace
- resource_name: 資源名稱
"""
@staticmethod
def get_key(approval_id: str | UUID) -> str:
"""取得 Redis Key"""
return f"{APPROVAL_KEY_PREFIX}{str(approval_id)}"
# =============================================================================
# Multi-Sig Redis Service
# =============================================================================
class MultiSigRedisService:
"""
Multi-Sig Redis 持久化服務
提供簽核狀態的 CRUD 操作,包含:
- 建立簽核單
- 新增簽名
- 更新狀態
- 查詢狀態
- 分散式鎖保護
"""
async def create_approval(
self,
approval_id: str | UUID,
action: str,
description: str,
risk_level: str,
required_signatures: int,
namespace: str = "default",
resource_name: str = "",
blast_radius: dict | None = None,
dry_run_checks: list | None = None,
) -> dict:
"""
建立新的簽核單
Args:
approval_id: 簽核單 ID
action: 操作類型
description: 描述
risk_level: 風險等級
required_signatures: 需要簽核數
namespace: K8s Namespace
resource_name: 資源名稱
blast_radius: 爆炸半徑
dry_run_checks: Dry-Run 檢查結果
Returns:
dict: 建立的簽核狀態
"""
redis_client = get_redis()
key = ApprovalStateRedis.get_key(approval_id)
now = datetime.now(timezone.utc).isoformat()
state = {
"id": str(approval_id),
"action": action,
"description": description,
"status": "pending",
"risk_level": risk_level,
"required_signatures": required_signatures,
"current_signatures": 0,
"signatures": json.dumps([]), # JSON Array
"created_at": now,
"updated_at": now,
"namespace": namespace,
"resource_name": resource_name,
"blast_radius": json.dumps(blast_radius or {}),
"dry_run_checks": json.dumps(dry_run_checks or []),
}
# 使用 HSET 寫入 Hash
await redis_client.hset(key, mapping=state)
# 設定 7 天 TTL (資安稽核要求)
await redis_client.expire(key, APPROVAL_TTL_SECONDS)
logger.info(
"redis_approval_created",
approval_id=str(approval_id),
risk_level=risk_level,
ttl_days=7,
)
return state
async def get_approval(self, approval_id: str | UUID) -> dict | None:
"""
取得簽核狀態
Args:
approval_id: 簽核單 ID
Returns:
dict | None: 簽核狀態,若不存在則返回 None
"""
redis_client = get_redis()
key = ApprovalStateRedis.get_key(approval_id)
state = await redis_client.hgetall(key)
if not state:
return None
# 解析 JSON 欄位
if "signatures" in state:
state["signatures"] = json.loads(state["signatures"])
if "blast_radius" in state:
state["blast_radius"] = json.loads(state["blast_radius"])
if "dry_run_checks" in state:
state["dry_run_checks"] = json.loads(state["dry_run_checks"])
# 轉換數值欄位
if "required_signatures" in state:
state["required_signatures"] = int(state["required_signatures"])
if "current_signatures" in state:
state["current_signatures"] = int(state["current_signatures"])
return state
async def add_signature(
self,
approval_id: str | UUID,
signer_id: str,
signer_name: str,
comment: str = "",
source: str = "web",
telegram_user_id: int | None = None,
telegram_message_id: int | None = None,
) -> dict:
"""
新增簽名 (含分散式鎖保護)
防禦場景:
- Web + Telegram 同時簽核
- 防止 K8s Executor 被觸發兩次
Args:
approval_id: 簽核單 ID
signer_id: 簽核者 ID
signer_name: 簽核者名稱
comment: 備註
source: 來源 (web, telegram, api)
telegram_user_id: Telegram User ID
telegram_message_id: Telegram Message ID
Returns:
dict: 更新後的簽核狀態
Raises:
RuntimeError: 若無法取得鎖或簽核單不存在
"""
redis_client = get_redis()
key = ApprovalStateRedis.get_key(approval_id)
lock_key = f"{str(approval_id)}:sign"
# 使用分散式鎖保護簽核操作
async with RedisLock(lock_key, timeout=10, blocking_timeout=5):
# 取得目前狀態
state = await self.get_approval(approval_id)
if not state:
raise RuntimeError(f"Approval not found: {approval_id}")
# 檢查狀態是否可簽核
if state["status"] != "pending":
raise RuntimeError(f"Approval is not pending: {state['status']}")
# 檢查是否已簽過
signatures = state.get("signatures", [])
for sig in signatures:
if sig.get("signer_id") == signer_id:
raise RuntimeError(f"Already signed by: {signer_id}")
# 新增簽名
now = datetime.now(timezone.utc).isoformat()
new_signature = {
"signer_id": signer_id,
"signer_name": signer_name,
"timestamp": now,
"comment": comment,
"source": source,
}
if telegram_user_id:
new_signature["telegram_user_id"] = telegram_user_id
if telegram_message_id:
new_signature["telegram_message_id"] = telegram_message_id
signatures.append(new_signature)
current_signatures = len(signatures)
# 檢查是否達到簽核門檻
new_status = "pending"
if current_signatures >= state["required_signatures"]:
new_status = "approved"
# 更新 Redis
await redis_client.hset(key, mapping={
"signatures": json.dumps(signatures),
"current_signatures": current_signatures,
"status": new_status,
"updated_at": now,
})
# 延長 TTL (每次操作都重設 7 天)
await redis_client.expire(key, APPROVAL_TTL_SECONDS)
logger.info(
"redis_signature_added",
approval_id=str(approval_id),
signer_id=signer_id,
source=source,
current=current_signatures,
required=state["required_signatures"],
new_status=new_status,
)
return await self.get_approval(approval_id)
async def update_status(
self,
approval_id: str | UUID,
status: str,
executor_id: str | None = None,
execution_result: dict | None = None,
) -> dict:
"""
更新簽核狀態
Args:
approval_id: 簽核單 ID
status: 新狀態 (approved, rejected, voided, executed)
executor_id: 執行者 ID
execution_result: 執行結果
Returns:
dict: 更新後的簽核狀態
"""
redis_client = get_redis()
key = ApprovalStateRedis.get_key(approval_id)
lock_key = f"{str(approval_id)}:status"
async with RedisLock(lock_key, timeout=10, blocking_timeout=5):
state = await self.get_approval(approval_id)
if not state:
raise RuntimeError(f"Approval not found: {approval_id}")
now = datetime.now(timezone.utc).isoformat()
updates = {
"status": status,
"updated_at": now,
}
if executor_id:
updates["executor_id"] = executor_id
if execution_result:
updates["execution_result"] = json.dumps(execution_result)
await redis_client.hset(key, mapping=updates)
await redis_client.expire(key, APPROVAL_TTL_SECONDS)
logger.info(
"redis_status_updated",
approval_id=str(approval_id),
status=status,
)
return await self.get_approval(approval_id)
async def reject_approval(
self,
approval_id: str | UUID,
rejector_id: str,
rejector_name: str,
reason: str = "",
) -> dict:
"""
拒絕簽核單
Args:
approval_id: 簽核單 ID
rejector_id: 拒絕者 ID
rejector_name: 拒絕者名稱
reason: 拒絕原因
Returns:
dict: 更新後的簽核狀態
"""
redis_client = get_redis()
key = ApprovalStateRedis.get_key(approval_id)
lock_key = f"{str(approval_id)}:reject"
async with RedisLock(lock_key, timeout=10, blocking_timeout=5):
state = await self.get_approval(approval_id)
if not state:
raise RuntimeError(f"Approval not found: {approval_id}")
now = datetime.now(timezone.utc).isoformat()
await redis_client.hset(key, mapping={
"status": "rejected",
"updated_at": now,
"rejector_id": rejector_id,
"rejector_name": rejector_name,
"rejection_reason": reason,
})
await redis_client.expire(key, APPROVAL_TTL_SECONDS)
logger.info(
"redis_approval_rejected",
approval_id=str(approval_id),
rejector_id=rejector_id,
)
return await self.get_approval(approval_id)
async def list_pending(self, limit: int = 100) -> list[dict]:
"""
列出所有待簽核單
注意: 此方法使用 SCAN在大量資料時效能較低
建議在 Phase 6.2 加入索引機制
Args:
limit: 最大返回數量
Returns:
list[dict]: 待簽核單列表
"""
redis_client = get_redis()
results = []
async for key in redis_client.scan_iter(match=f"{APPROVAL_KEY_PREFIX}*", count=100):
if len(results) >= limit:
break
state = await redis_client.hgetall(key)
if state and state.get("status") == "pending":
# 解析 JSON 欄位
if "signatures" in state:
state["signatures"] = json.loads(state["signatures"])
if "required_signatures" in state:
state["required_signatures"] = int(state["required_signatures"])
if "current_signatures" in state:
state["current_signatures"] = int(state["current_signatures"])
results.append(state)
return results
async def exists(self, approval_id: str | UUID) -> bool:
"""
檢查簽核單是否存在
Args:
approval_id: 簽核單 ID
Returns:
bool: 是否存在
"""
redis_client = get_redis()
key = ApprovalStateRedis.get_key(approval_id)
return await redis_client.exists(key) > 0
# =============================================================================
# Singleton
# =============================================================================
_service: MultiSigRedisService | None = None
def get_multi_sig_redis_service() -> MultiSigRedisService:
"""取得全域 MultiSigRedisService 實例"""
global _service
if _service is None:
_service = MultiSigRedisService()
return _service