Files
awoooi/apps/api/src/services/awooop_approval_token.py
OG T 2c2bf9d665
Some checks failed
Code Review / ai-code-review (push) Successful in 10s
CD Pipeline / tests (push) Successful in 1m0s
CD Pipeline / build-and-deploy (push) Failing after 4m6s
CD Pipeline / post-deploy-checks (push) Has been skipped
fix(awooop): use shared redis for approval gates
2026-05-06 13:18:43 +08:00

350 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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 token3 段 base64url
- verify_approval_token() → payload含 jti/exp/sub/approver
- jti 存 Redis NXTTL = 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 ModeAWOOOP_SUGGEST_MODE feature flag
- is_suggest_mode_enabled() → bool
- build_suggest_action(action_type, target) → SuggestedActiondry-run
- 支援 3 個 SRE flowrollback / 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_tokenHS256 + 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,
)