diff --git a/.gitea/workflows/cd.yaml b/.gitea/workflows/cd.yaml index d0d5bff9..be2efebe 100644 --- a/.gitea/workflows/cd.yaml +++ b/.gitea/workflows/cd.yaml @@ -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='[ diff --git a/apps/api/src/api/v1/platform/operator_runs.py b/apps/api/src/api/v1/platform/operator_runs.py index fe0826d9..2c1eb79c 100644 --- a/apps/api/src/api/v1/platform/operator_runs.py +++ b/apps/api/src/api/v1/platform/operator_runs.py @@ -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, ) diff --git a/apps/api/src/core/awooop_operator_auth.py b/apps/api/src/core/awooop_operator_auth.py new file mode 100644 index 00000000..b117e064 --- /dev/null +++ b/apps/api/src/core/awooop_operator_auth.py @@ -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, + ) diff --git a/apps/api/src/core/config.py b/apps/api/src/core/config.py index 7a66c4f6..8a85bbce 100644 --- a/apps/api/src/core/config.py +++ b/apps/api/src/core/config.py @@ -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 憲法) diff --git a/apps/api/tests/test_awooop_operator_auth.py b/apps/api/tests/test_awooop_operator_auth.py new file mode 100644 index 00000000..72ad2624 --- /dev/null +++ b/apps/api/tests/test_awooop_operator_auth.py @@ -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" diff --git a/apps/web/src/app/[locale]/awooop/approvals/[run_id]/page.tsx b/apps/web/src/app/[locale]/awooop/approvals/[run_id]/page.tsx index 83c8d51a..e9165bd4 100644 --- a/apps/web/src/app/[locale]/awooop/approvals/[run_id]/page.tsx +++ b/apps/web/src/app/[locale]/awooop/approvals/[run_id]/page.tsx @@ -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, }), } diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index 817f2522..d07b9eb4 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -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 飛輪的人機協作控制台、治理層、稽核層與操作層。 diff --git a/docs/awooop/AWOOOI-AWOOOP-AI-AUTONOMOUS-FLYWHEEL-INTEGRATION-PLAN.md b/docs/awooop/AWOOOI-AWOOOP-AI-AUTONOMOUS-FLYWHEEL-INTEGRATION-PLAN.md index 015feeff..c7259058 100644 --- a/docs/awooop/AWOOOI-AWOOOP-AI-AUTONOMOUS-FLYWHEEL-INTEGRATION-PLAN.md +++ b/docs/awooop/AWOOOI-AWOOOP-AI-AUTONOMOUS-FLYWHEEL-INTEGRATION-PLAN.md @@ -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. - diff --git a/k8s/awoooi-prod/03-secrets.example.yaml b/k8s/awoooi-prod/03-secrets.example.yaml index c03fea7b..42d920e6 100644 --- a/k8s/awoooi-prod/03-secrets.example.yaml +++ b/k8s/awoooi-prod/03-secrets.example.yaml @@ -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 認證 (未來擴展) # ============================================================================