fix(awooop): authenticate approval decisions
All checks were successful
Code Review / ai-code-review (push) Successful in 11s
CD Pipeline / tests (push) Successful in 1m3s
CD Pipeline / build-and-deploy (push) Successful in 3m28s
CD Pipeline / post-deploy-checks (push) Successful in 1m25s

This commit is contained in:
OG T
2026-05-06 13:05:51 +08:00
parent e6eae5cdc4
commit c696b99ccf
9 changed files with 294 additions and 6 deletions

View File

@@ -433,6 +433,7 @@ jobs:
JWT_SECRET: ${{ secrets.JWT_SECRET }}
JWT_ALGORITHM: ${{ secrets.JWT_ALGORITHM }}
WEBHOOK_HMAC_SECRET: ${{ secrets.WEBHOOK_HMAC_SECRET }}
AWOOOP_OPERATOR_API_KEY: ${{ secrets.AWOOOP_OPERATOR_API_KEY }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
CLAUDE_API_KEY: ${{ secrets.CLAUDE_API_KEY }}
# AWOOOI_ 前綴避開 Gitea 保留字(同 AWOOOI_GITEA_WEBHOOK_SECRET 模式)
@@ -573,6 +574,13 @@ jobs:
]' && echo "✅ WEBHOOK_HMAC_SECRET 已注入" || echo "⚠️ WEBHOOK_HMAC_SECRET patch 失敗"
fi
# AWOOOP_OPERATOR_API_KEY — AwoooP Operator mutation endpoints
if [ -n "${AWOOOP_OPERATOR_API_KEY}" ]; then
\$KUBECTL patch secret awoooi-secrets -n awoooi-prod --type='json' -p='[
{"op":"add","path":"/data/AWOOOP_OPERATOR_API_KEY","value":"'$(echo -n "${AWOOOP_OPERATOR_API_KEY}" | base64 -w 0)'"}
]' && echo "✅ AWOOOP_OPERATOR_API_KEY 已注入" || echo "⚠️ AWOOOP_OPERATOR_API_KEY patch 失敗"
fi
# SENTRY_DSN — Sentry 錯誤追蹤(不是 auth token
if [ -n "${SENTRY_DSN}" ]; then
\$KUBECTL patch secret awoooi-secrets -n awoooi-prod --type='json' -p='[

View File

@@ -15,9 +15,13 @@ from decimal import Decimal
from typing import Any, Literal
from uuid import UUID
from fastapi import APIRouter, Query
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from src.core.awooop_operator_auth import (
AwoooPOperatorPrincipal,
verify_awooop_operator,
)
from src.services.platform_operator_service import (
decide_approval as decide_approval_svc,
list_approvals as list_approvals_svc,
@@ -65,7 +69,10 @@ class ListApprovalsResponse(BaseModel):
class DecideApprovalRequest(BaseModel):
project_id: str = Field(..., description="租戶 ID")
decision: Literal["approve", "reject"] = Field(..., description="核准或拒絕")
approver_id: str = Field(..., description="審核人 IDplatform_subject_id 或 operator email")
approver_id: str | None = Field(
default=None,
description="Deprecated. Ignored; approver comes from trusted operator headers.",
)
reason: str | None = Field(None, description="決策原因(可選)")
@@ -127,11 +134,12 @@ async def list_approvals(
async def decide_approval(
run_id: str,
body: DecideApprovalRequest,
operator: AwoooPOperatorPrincipal = Depends(verify_awooop_operator),
) -> dict[str, Any]:
return await decide_approval_svc(
run_id=run_id,
project_id=body.project_id,
decision=body.decision,
approver_id=body.approver_id,
approver_id=operator.operator_id,
reason=body.reason,
)

View File

@@ -0,0 +1,126 @@
"""
AwoooP Operator authentication boundary.
ADR-116 Gate 5 approval decisions must not trust browser-supplied identities.
This module accepts a short-lived operator identity only when it is paired with
the server-side AwoooP operator key.
"""
from __future__ import annotations
import re
import secrets
from dataclasses import dataclass
from typing import Annotated
import structlog
from fastapi import Header, HTTPException, status
from src.core.config import settings
logger = structlog.get_logger(__name__)
_OPERATOR_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:@-]{1,127}$")
_PROD_ENVS = {"prod", "production"}
@dataclass(frozen=True, slots=True)
class AwoooPOperatorPrincipal:
"""Authenticated AwoooP operator principal."""
operator_id: str
auth_method: str
def _auth_error(detail: str = "Operator authentication required") -> HTTPException:
return HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=detail)
def _clean_operator_id(operator_id: str | None) -> str:
if operator_id is None:
raise _auth_error()
cleaned = operator_id.strip()
if not _OPERATOR_ID_RE.fullmatch(cleaned):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="Invalid operator identity",
)
return cleaned
def authenticate_awooop_operator_headers(
operator_id: str | None,
operator_key: str | None,
*,
configured_key: str | None = None,
environment: str | None = None,
) -> AwoooPOperatorPrincipal:
"""Validate trusted AwoooP operator headers.
Args:
operator_id: Value from ``X-AwoooP-Operator-Id``.
operator_key: Value from ``X-AwoooP-Operator-Key``.
configured_key: Server-side shared key. Defaults to settings.
environment: Runtime environment. Defaults to settings.
Returns:
Authenticated operator principal.
Raises:
HTTPException: 401 when authentication is missing/invalid, or 422 for
malformed operator identity.
"""
cleaned_operator_id = _clean_operator_id(operator_id)
expected_key = (
settings.AWOOOP_OPERATOR_API_KEY
if configured_key is None
else configured_key
)
runtime_env = (environment or settings.ENVIRONMENT or "").lower()
if not expected_key:
if runtime_env in _PROD_ENVS:
logger.critical(
"awooop_operator_key_missing_in_production",
environment=runtime_env,
)
raise _auth_error()
logger.warning(
"awooop_operator_key_skipped_dev_only",
environment=runtime_env,
operator_id=cleaned_operator_id,
)
return AwoooPOperatorPrincipal(
operator_id=cleaned_operator_id,
auth_method="dev_header",
)
if not operator_key:
logger.warning("awooop_operator_key_missing", operator_id=cleaned_operator_id)
raise _auth_error()
if not secrets.compare_digest(operator_key, expected_key):
logger.warning("awooop_operator_key_invalid", operator_id=cleaned_operator_id)
raise _auth_error()
return AwoooPOperatorPrincipal(
operator_id=cleaned_operator_id,
auth_method="operator_api_key",
)
async def verify_awooop_operator(
x_awooop_operator_id: Annotated[
str | None,
Header(alias="X-AwoooP-Operator-Id"),
] = None,
x_awooop_operator_key: Annotated[
str | None,
Header(alias="X-AwoooP-Operator-Key"),
] = None,
) -> AwoooPOperatorPrincipal:
"""FastAPI dependency for operator mutation endpoints."""
return authenticate_awooop_operator_headers(
operator_id=x_awooop_operator_id,
operator_key=x_awooop_operator_key,
)

View File

@@ -602,6 +602,13 @@ class Settings(BaseSettings):
default="",
description="API Key for K8s admin endpoints (X-K8s-Api-Key header)",
)
AWOOOP_OPERATOR_API_KEY: str = Field(
default="",
description=(
"API key for AwoooP operator mutation endpoints "
"(X-AwoooP-Operator-Key header)"
),
)
# ==========================================================================
# 統帥鐵律:禁止 SQLite (AWOOOI 憲法)

View File

@@ -0,0 +1,102 @@
from __future__ import annotations
import pytest
from fastapi import HTTPException
from src.api.v1.platform import operator_runs
from src.core.awooop_operator_auth import (
AwoooPOperatorPrincipal,
authenticate_awooop_operator_headers,
)
def test_operator_auth_rejects_missing_key_in_prod() -> None:
with pytest.raises(HTTPException) as exc:
authenticate_awooop_operator_headers(
operator_id="ops@example.com",
operator_key=None,
configured_key="",
environment="prod",
)
assert exc.value.status_code == 401
def test_operator_auth_rejects_invalid_key() -> None:
with pytest.raises(HTTPException) as exc:
authenticate_awooop_operator_headers(
operator_id="ops@example.com",
operator_key="wrong",
configured_key="expected",
environment="prod",
)
assert exc.value.status_code == 401
def test_operator_auth_returns_principal_for_valid_key() -> None:
principal = authenticate_awooop_operator_headers(
operator_id="telegram:123456",
operator_key="expected",
configured_key="expected",
environment="prod",
)
assert principal == AwoooPOperatorPrincipal(
operator_id="telegram:123456",
auth_method="operator_api_key",
)
def test_operator_auth_rejects_malformed_identity() -> None:
with pytest.raises(HTTPException) as exc:
authenticate_awooop_operator_headers(
operator_id="../operator",
operator_key="expected",
configured_key="expected",
environment="prod",
)
assert exc.value.status_code == 422
@pytest.mark.asyncio
async def test_decide_approval_uses_trusted_principal_not_body(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, object] = {}
async def fake_decide_approval_svc(**kwargs: object) -> dict[str, object]:
captured.update(kwargs)
return {
"run_id": kwargs["run_id"],
"decision": kwargs["decision"],
"new_state": "running",
"approval_token_jti": "jti-test",
}
monkeypatch.setattr(
operator_runs,
"decide_approval_svc",
fake_decide_approval_svc,
)
body = operator_runs.DecideApprovalRequest(
project_id="awoooi",
decision="approve",
approver_id="spoofed-browser-value",
)
principal = AwoooPOperatorPrincipal(
operator_id="telegram:123456",
auth_method="operator_api_key",
)
response = await operator_runs.decide_approval(
"018f2d04-4c37-7a18-b764-df0df0cbe111",
body,
principal,
)
assert response["approval_token_jti"] == "jti-test"
assert captured["approver_id"] == "telegram:123456"
assert captured["project_id"] == "awoooi"

View File

@@ -281,6 +281,11 @@ export default function ApprovalDecisionPage({
}, [fetchRun]);
const handleApprove = async () => {
if (!run?.project_id || run.project_id === "--") {
setActionError("缺少 project_id無法送出審批決策");
setShowApproveDialog(false);
return;
}
setActionLoading(true);
setActionError(null);
try {
@@ -290,8 +295,8 @@ export default function ApprovalDecisionPage({
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
project_id: run.project_id,
decision: "approve",
approver_id: "operator",
reason: null,
}),
}
@@ -309,6 +314,11 @@ export default function ApprovalDecisionPage({
};
const handleReject = async (reason: string) => {
if (!run?.project_id || run.project_id === "--") {
setActionError("缺少 project_id無法送出審批決策");
setShowRejectDialog(false);
return;
}
setActionLoading(true);
setActionError(null);
try {
@@ -318,8 +328,8 @@ export default function ApprovalDecisionPage({
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
project_id: run.project_id,
decision: "reject",
approver_id: "operator",
reason,
}),
}

View File

@@ -1,3 +1,19 @@
## 2026-05-06 | AwoooP approval decide no longer trusts browser identity
**背景**AwoooP Operator Console 的 `/api/v1/platform/approvals/{run_id}/decide` 仍接受前端 body 內的 `approver_id`,前端甚至硬編 `approver_id: "operator"`;這會讓 audit identity 無法作為真實審批證據。
**本次修補**
- 新增 `src/core/awooop_operator_auth.py`AwoooP mutation endpoint 以 `X-AwoooP-Operator-Id` + server-side `AWOOOP_OPERATOR_API_KEY` 建立 trusted principalproduction 缺 key 時 fail-closed。
- `DecideApprovalRequest.approver_id` 改為 deprecated 並完全忽略,後端只使用 authenticated principal 寫入 approval token / audit。
- 前端審批頁移除硬編 `operator`,補送 `project_id`,且缺 project_id 時不送出決策。
- Gitea CD 與 secret template 補 `AWOOOP_OPERATOR_API_KEY`,避免控制面密鑰散落。
**驗證**
- `pytest tests/test_awooop_operator_auth.py -q` → 5 passed。
- `py_compile` touched backend files OKruff fatal checks OK。
- `pnpm --filter @awoooi/web typecheck` OK。
- `NEXT_PUBLIC_API_URL=https://awoooi.wooo.work pnpm --filter @awoooi/web build` OK。
## 2026-05-06 | AwoooP merged into the AI autonomous flywheel execution plan
**背景**:另一個 session 完成 AWOOOI / AWOOOP / AI 自動化飛輪整合總結,指出 AwoooP 不能獨立成另一條產品線;它必須是 AI 飛輪的人機協作控制台、治理層、稽核層與操作層。

View File

@@ -187,6 +187,13 @@ Exit criteria:
Goal: prevent the automation flywheel from bypassing governance.
Progress:
- 2026-05-06: P0-I first enforcement patch landed. The AwoooP approval decide
endpoint now derives `approver_id` from trusted operator headers instead of
frontend body data, and production fails closed when `AWOOOP_OPERATOR_API_KEY`
is not configured.
Work:
- MCP Gateway bypass and audit gaps.
@@ -287,4 +294,3 @@ For AI routing releases, also verify:
2. Create Wave 1 implementation checklist from this document.
3. Start with MCP Gateway bypass and platform approval authentication, because both directly affect safety and auditability.
4. Keep GCP-A/GCP-B/111 Ollama routing verification in every alert-path release until EffectivePolicy becomes authoritative.

View File

@@ -52,6 +52,11 @@ stringData:
# ============================================================================
WEBHOOK_HMAC_SECRET: "CHANGE_ME_TO_RANDOM_64_CHARS"
# ============================================================================
# AwoooP Operator Console mutation API
# ============================================================================
AWOOOP_OPERATOR_API_KEY: "CHANGE_ME_TO_RANDOM_64_CHARS"
# ============================================================================
# JWT 認證 (未來擴展)
# ============================================================================