""" AwoooP Approval Token — HS256 簽核令牌 + Multi-sig + Suggest Mode ================================================================== AwoooP Phase 8: ADR-116 Gate 5 approval flow 2026-05-04 ogt + Claude Sonnet 4.6 功能: 1. HS256 Approval Token(自製,不依賴 PyJWT): - issue_approval_token() → signed token(3 段 base64url) - verify_approval_token() → payload(含 jti/exp/sub/approver) - jti 存 Redis NX(TTL = exp - now)防 token replay - TTL = 15 分鐘(APPROVAL_TOKEN_TTL = 900s) 2. Multi-sig quorum: - record_approval() → 驗 token + NX jti + SADD approver_id → 目前簽核數 - check_approval_quorum(required=1) → bool | raise QuorumNotMetError - Redis Set TTL = 1h 3. Suggest Mode(AWOOOP_SUGGEST_MODE feature flag): - is_suggest_mode_enabled() → bool - build_suggest_action(action_type, target) → SuggestedAction(dry-run) - 支援 3 個 SRE flow:rollback / scale / restart Redis key 前綴(與 legacy multi_sig_redis.py 不衝突): awooop_appr:jti:{jti} — NX token replay 防護 awooop_appr:sigs:{project_id}:{run_id}:{tool_name} — 簽核人 Set 錯誤碼: E-APPR-001 token 無效或已過期 E-APPR-002 jti 已使用(replay attack) E-APPR-003 quorum 未達 E-APPR-004 approver 重複簽核 """ from __future__ import annotations import base64 import hashlib import hmac as _hmac_module import json import os import time import uuid from dataclasses import dataclass, field from typing import Any import structlog from src.core.redis_client import get_redis logger = structlog.get_logger(__name__) # ───────────────────────────────────────────────────────────────────────────── # 常數 # ───────────────────────────────────────────────────────────────────────────── APPROVAL_TOKEN_TTL = 900 # 15 分鐘 _JTI_KEY_PREFIX = "awooop_appr:jti:" _SIG_SET_PREFIX = "awooop_appr:sigs:" _SIG_TTL_SECONDS = 3600 # 簽核 Set 1h TTL _SUGGEST_MODE_ENV = "AWOOOP_SUGGEST_MODE" # ───────────────────────────────────────────────────────────────────────────── # 錯誤定義 # ───────────────────────────────────────────────────────────────────────────── class InvalidApprovalTokenError(Exception): error_code = "E-APPR-001" class TokenReplayError(Exception): error_code = "E-APPR-002" class QuorumNotMetError(Exception): error_code = "E-APPR-003" class DuplicateApproverError(Exception): error_code = "E-APPR-004" # ───────────────────────────────────────────────────────────────────────────── # HS256 Token 實作 # ───────────────────────────────────────────────────────────────────────────── def _b64url_encode(data: bytes) -> str: return base64.urlsafe_b64encode(data).rstrip(b"=").decode() def _b64url_decode(s: str) -> bytes: padding = 4 - len(s) % 4 if padding != 4: s += "=" * padding return base64.urlsafe_b64decode(s) def _get_hmac_key() -> bytes: try: from src.core.config import settings key = getattr(settings, "APPROVAL_HMAC_KEY", None) or "" except Exception: key = "" key = key or os.environ.get("APPROVAL_HMAC_KEY", "") if not key: logger.warning("approval_hmac_key_not_set_using_dev_fallback") key = "dev-awooop-approval-hmac-fallback" return key.encode() def issue_approval_token( *, project_id: str, run_id: str, tool_name: str, approver_id: str, ttl_seconds: int = APPROVAL_TOKEN_TTL, ) -> str: """ 產生 HS256 Approval Token。 payload: jti = uuid4().hex(唯一 token ID,用於 Redis NX 防 replay) iss = "awooop-approval" sub = "{project_id}:{run_id}:{tool_name}" approver = approver_id iat / exp """ now = int(time.time()) jti = uuid.uuid4().hex header = {"alg": "HS256", "typ": "JWT"} payload = { "jti": jti, "iss": "awooop-approval", "sub": f"{project_id}:{run_id}:{tool_name}", "approver": approver_id, "iat": now, "exp": now + ttl_seconds, } h_b64 = _b64url_encode(json.dumps(header, separators=(",", ":")).encode()) p_b64 = _b64url_encode(json.dumps(payload, separators=(",", ":")).encode()) signing_input = f"{h_b64}.{p_b64}" sig = _hmac_module.new( _get_hmac_key(), signing_input.encode(), hashlib.sha256, ).digest() return f"{signing_input}.{_b64url_encode(sig)}" def verify_approval_token(token: str) -> dict[str, Any]: """ 驗證 HS256 token,回傳 payload。 Raises: InvalidApprovalTokenError: 簽名無效/過期/格式錯誤 """ try: parts = token.split(".") if len(parts) != 3: raise InvalidApprovalTokenError("token 非 3 段格式") h_b64, p_b64, sig_b64 = parts signing_input = f"{h_b64}.{p_b64}" expected_sig = _hmac_module.new( _get_hmac_key(), signing_input.encode(), hashlib.sha256, ).digest() if not _hmac_module.compare_digest(sig_b64, _b64url_encode(expected_sig)): raise InvalidApprovalTokenError("token 簽名無效") payload = json.loads(_b64url_decode(p_b64)) if int(time.time()) > payload.get("exp", 0): raise InvalidApprovalTokenError("token 已過期") return payload except InvalidApprovalTokenError: raise except Exception as exc: raise InvalidApprovalTokenError(f"token 解析失敗: {exc}") from exc # ───────────────────────────────────────────────────────────────────────────── # Multi-sig Redis approval # ───────────────────────────────────────────────────────────────────────────── async def record_approval( *, project_id: str, run_id: str, tool_name: str, approver_id: str, token: str, ) -> int: """ 記錄一筆簽核。步驟: 1. verify_approval_token(HS256 + exp) 2. sub 匹配驗證 3. Redis NX jti(防 replay) 4. Redis SADD approver_id(防重複) 5. 回傳目前簽核數 Raises: InvalidApprovalTokenError, TokenReplayError, DuplicateApproverError """ payload = verify_approval_token(token) expected_sub = f"{project_id}:{run_id}:{tool_name}" if payload.get("sub") != expected_sub: raise InvalidApprovalTokenError( f"token sub 不符(期望 '{expected_sub}',實際 '{payload.get('sub')}')" ) jti = payload["jti"] exp = payload["exp"] try: redis = get_redis() # jti NX jti_key = f"{_JTI_KEY_PREFIX}{jti}" ttl_remaining = max(exp - int(time.time()), 1) ok = await redis.set(jti_key, "1", nx=True, ex=ttl_remaining) if not ok: raise TokenReplayError(f"jti={jti!r} 已使用") # SADD approver sig_key = f"{_SIG_SET_PREFIX}{project_id}:{run_id}:{tool_name}" added = await redis.sadd(sig_key, approver_id) if added == 0: raise DuplicateApproverError(f"approver '{approver_id}' 已簽核") await redis.expire(sig_key, _SIG_TTL_SECONDS) count = int(await redis.scard(sig_key)) logger.info( "awooop_approval_recorded", project_id=project_id, run_id=run_id, tool_name=tool_name, approver_id=approver_id, count=count, ) return count except (InvalidApprovalTokenError, TokenReplayError, DuplicateApproverError): raise except Exception as exc: logger.exception("awooop_approval_redis_error", error=str(exc)) raise InvalidApprovalTokenError(f"Redis 錯誤: {exc}") from exc async def check_approval_quorum( *, project_id: str, run_id: str, tool_name: str, required_count: int = 1, ) -> bool: """ 檢查 quorum。Raises QuorumNotMetError if 不足。 """ try: redis = get_redis() sig_key = f"{_SIG_SET_PREFIX}{project_id}:{run_id}:{tool_name}" count = int(await redis.scard(sig_key)) if count < required_count: raise QuorumNotMetError(f"簽核數不足({count}/{required_count})") return True except QuorumNotMetError: raise except Exception as exc: raise QuorumNotMetError(f"Redis 查詢失敗: {exc}") from exc # ───────────────────────────────────────────────────────────────────────────── # Suggest Mode # ───────────────────────────────────────────────────────────────────────────── @dataclass class SuggestedAction: """Suggest mode dry-run 結果(不真正執行)""" action_type: str # 'rollback' | 'scale' | 'restart' target: str suggested_command: str rollback_evidence: dict[str, Any] = field(default_factory=dict) dry_run: bool = True approval_required: bool = True def is_suggest_mode_enabled() -> bool: return os.environ.get(_SUGGEST_MODE_ENV, "").lower() in ("true", "1", "yes") async def build_suggest_action( action_type: str, *, target: str, run_id: str, project_id: str, ) -> SuggestedAction: """ Suggest mode:返回 dry-run 建議,不執行真實操作。 支援 rollback / scale / restart 三個 SRE flow。 """ if action_type not in ("rollback", "scale", "restart"): raise ValueError(f"不支援的 action_type: {action_type!r}") if action_type == "rollback": command = f"kubectl rollout undo deployment/{target}" evidence: dict[str, Any] = { "note": f"需確認 deployment/{target} 當前 image 與 rollout history", "suggested_verification": f"kubectl rollout history deployment/{target}", } elif action_type == "scale": command = f"kubectl scale deployment/{target} --replicas=" evidence = { "note": f"需確認 deployment/{target} 當前 replicas 數量", "suggested_verification": f"kubectl get deployment/{target} -o json | jq .spec.replicas", } else: # restart command = f"kubectl rollout restart deployment/{target}" evidence = { "note": f"需確認 deployment/{target} 當前 pod 狀態", "suggested_verification": f"kubectl get pods -l app={target}", } logger.info( "suggest_action_built", project_id=project_id, run_id=run_id, action_type=action_type, target=target, ) return SuggestedAction( action_type=action_type, target=target, suggested_command=command, rollback_evidence=evidence, )