fix(awooop): authenticate approval decisions
This commit is contained in:
@@ -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='[
|
||||
|
||||
@@ -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="審核人 ID(platform_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,
|
||||
)
|
||||
|
||||
126
apps/api/src/core/awooop_operator_auth.py
Normal file
126
apps/api/src/core/awooop_operator_auth.py
Normal 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,
|
||||
)
|
||||
@@ -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 憲法)
|
||||
|
||||
102
apps/api/tests/test_awooop_operator_auth.py
Normal file
102
apps/api/tests/test_awooop_operator_auth.py
Normal 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"
|
||||
@@ -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,
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -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 principal;production 缺 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 OK;ruff 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 飛輪的人機協作控制台、治理層、稽核層與操作層。
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 認證 (未來擴展)
|
||||
# ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user