350 lines
12 KiB
Python
350 lines
12 KiB
Python
"""
|
||
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=<N>"
|
||
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,
|
||
)
|