From b4c5c846b4f17402c843e94bca429b11395ceda8 Mon Sep 17 00:00:00 2001 From: ogt Date: Fri, 10 Jul 2026 23:53:09 +0800 Subject: [PATCH] fix(security): split API from executor identity --- .../ai_agent_autonomous_runtime_control.py | 18 ++-- .../awooop_ansible_check_mode_service.py | 19 ++++ ...est_ai_agent_autonomous_runtime_control.py | 16 ++-- .../tests/test_awooop_truth_chain_service.py | 10 +++ ..._signal_worker_ansible_executor_binding.py | 58 +++++++++++++ k8s/awoooi-prod/06-deployment-api.yaml | 57 ++---------- k8s/awoooi-prod/07-rbac.yaml | 86 +++++++++++++++++++ k8s/rbac/api-velero-reader.yaml | 6 +- 8 files changed, 197 insertions(+), 73 deletions(-) diff --git a/apps/api/src/services/ai_agent_autonomous_runtime_control.py b/apps/api/src/services/ai_agent_autonomous_runtime_control.py index 4dee311fd..01c5a3da8 100644 --- a/apps/api/src/services/ai_agent_autonomous_runtime_control.py +++ b/apps/api/src/services/ai_agent_autonomous_runtime_control.py @@ -3497,21 +3497,17 @@ def _autonomous_execution_loop_ledger( telegram_rows = [_row_mapping(row) for row in telegram_latest_rows] auto_repair_rows = [_row_mapping(row) for row in auto_repair_latest_rows] - latest_observed_apply = _first_operation( - operation_rows, - "ansible_apply_executed", - ) - latest_apply = _first_operation( + latest_terminal_apply = _first_operation( operation_rows, "ansible_apply_executed", statuses={"success", "failed"}, - ) or latest_observed_apply - latest_inflight_apply = ( - latest_observed_apply - if latest_observed_apply is not None - and latest_observed_apply.get("op_id") != (latest_apply or {}).get("op_id") - else None ) + latest_inflight_apply = _first_operation( + operation_rows, + "ansible_apply_executed", + statuses={"pending"}, + ) + latest_apply = latest_terminal_apply or latest_inflight_apply latest_check = None latest_candidate = None if latest_apply is not None: diff --git a/apps/api/src/services/awooop_ansible_check_mode_service.py b/apps/api/src/services/awooop_ansible_check_mode_service.py index e48cf6c35..f393f7749 100644 --- a/apps/api/src/services/awooop_ansible_check_mode_service.py +++ b/apps/api/src/services/awooop_ansible_check_mode_service.py @@ -2594,12 +2594,21 @@ async def run_controlled_apply_for_claim( ) apply_op_id = str(inserted.scalar_one()) + cancelled = False try: spec = build_ansible_apply_command( playbook_path=claim.apply_playbook_path, inventory_hosts=claim.inventory_hosts, ) result = await _run_ansible_command(spec, timeout_seconds=timeout_seconds) + except asyncio.CancelledError: + cancelled = True + result = AnsibleRunResult( + returncode=130, + stdout="", + stderr="ansible_controlled_apply_interrupted_by_worker_shutdown", + duration_ms=0, + ) except Exception as exc: result = AnsibleRunResult( returncode=1, @@ -2631,6 +2640,16 @@ async def run_controlled_apply_for_claim( "op_id": apply_op_id, }, ) + if cancelled: + logger.warning( + "ansible_controlled_apply_interrupted", + op_id=apply_op_id, + check_mode_op_id=claim.op_id, + source_candidate_op_id=claim.source_candidate_op_id, + incident_id=claim.incident_id, + catalog_id=claim.catalog_id, + ) + raise asyncio.CancelledError receipt_written = await _record_auto_repair_execution_receipt( claim, result, diff --git a/apps/api/tests/test_ai_agent_autonomous_runtime_control.py b/apps/api/tests/test_ai_agent_autonomous_runtime_control.py index 93dd8d46e..8711bcd24 100644 --- a/apps/api/tests/test_ai_agent_autonomous_runtime_control.py +++ b/apps/api/tests/test_ai_agent_autonomous_runtime_control.py @@ -2458,14 +2458,6 @@ def test_runtime_execution_loop_uses_terminal_apply_without_hiding_inflight_appl ledger = runtime_control_module._autonomous_execution_loop_ledger( project_id="awoooi", operation_latest_rows=[ - { - "op_id": "pending-apply", - "operation_type": "ansible_apply_executed", - "status": "pending", - "automation_run_id": "pending-run", - "incident_id": "INC-PENDING", - "created_at": "2026-07-10T15:00:00Z", - }, { "op_id": "terminal-apply", "operation_type": "ansible_apply_executed", @@ -2474,6 +2466,14 @@ def test_runtime_execution_loop_uses_terminal_apply_without_hiding_inflight_appl "incident_id": "INC-TERMINAL", "created_at": "2026-07-10T14:59:00Z", }, + { + "op_id": "pending-apply", + "operation_type": "ansible_apply_executed", + "status": "pending", + "automation_run_id": "pending-run", + "incident_id": "INC-PENDING", + "created_at": "2026-07-10T15:00:00Z", + }, ], verifier_latest_rows=[], km_latest_rows=[], diff --git a/apps/api/tests/test_awooop_truth_chain_service.py b/apps/api/tests/test_awooop_truth_chain_service.py index 1a0bd17e5..13ac7faef 100644 --- a/apps/api/tests/test_awooop_truth_chain_service.py +++ b/apps/api/tests/test_awooop_truth_chain_service.py @@ -1970,6 +1970,16 @@ def test_ansible_subprocess_is_terminated_when_worker_task_is_cancelled() -> Non assert "await process.communicate()" in source +def test_controlled_apply_shutdown_marks_pending_row_before_cancelling() -> None: + source = inspect.getsource(run_controlled_apply_for_claim) + + assert "except asyncio.CancelledError" in source + assert "ansible_controlled_apply_interrupted_by_worker_shutdown" in source + assert '"status": status' in source + assert '"op_id": apply_op_id' in source + assert "ansible_controlled_apply_interrupted" in source + + def test_ansible_post_apply_verifier_helpers_are_deterministic() -> None: assert _post_apply_km_path_type("03ca6836-1b76-4da2-8e3e-6d3b6df9254a") == ( "ansible_apply_receipt:03ca6836" diff --git a/apps/api/tests/test_signal_worker_ansible_executor_binding.py b/apps/api/tests/test_signal_worker_ansible_executor_binding.py index d56df2af0..c2c4212b9 100644 --- a/apps/api/tests/test_signal_worker_ansible_executor_binding.py +++ b/apps/api/tests/test_signal_worker_ansible_executor_binding.py @@ -3,6 +3,8 @@ from __future__ import annotations import inspect from pathlib import Path +import yaml + from src.workers import signal_worker @@ -38,6 +40,62 @@ def test_worker_manifest_mounts_existing_ssh_mcp_transport() -> None: assert "optional: true" in manifest +def test_api_manifest_has_read_only_identity_and_no_ssh_executor_mounts() -> None: + repo_root = Path(__file__).resolve().parents[3] + deployment = next( + document + for document in yaml.safe_load_all( + (repo_root / "k8s/awoooi-prod/06-deployment-api.yaml").read_text() + ) + if document and document.get("kind") == "Deployment" + ) + pod_spec = deployment["spec"]["template"]["spec"] + container = pod_spec["containers"][0] + env = {item["name"]: item.get("value") for item in container["env"]} + mount_names = {item["name"] for item in container.get("volumeMounts", [])} + volume_names = {item["name"] for item in pod_spec.get("volumes", [])} + + assert pod_spec["serviceAccountName"] == "awoooi-api" + assert env["SSH_MCP_ENABLED"] == "false" + assert env["ENABLE_AWOOOP_ANSIBLE_CHECK_MODE_WORKER"] == "false" + assert env["ENABLE_AWOOOP_ANSIBLE_CANDIDATE_BACKFILL_WORKER"] == "false" + assert {"repair-ssh-key", "repair-known-hosts", "ssh-mcp-key"}.isdisjoint( + mount_names | volume_names + ) + + +def test_api_reader_rbac_has_no_workload_write_verbs() -> None: + repo_root = Path(__file__).resolve().parents[3] + documents = [ + document + for document in yaml.safe_load_all( + (repo_root / "k8s/awoooi-prod/07-rbac.yaml").read_text() + ) + if document + ] + by_name = { + (document["kind"], document["metadata"]["name"]): document + for document in documents + } + reader = by_name[("ClusterRole", "awoooi-api-reader-role")] + binding = by_name[("ClusterRoleBinding", "awoooi-api-reader-binding")] + api_account = by_name[("ServiceAccount", "awoooi-api")] + + assert api_account["metadata"]["namespace"] == "awoooi-prod" + assert binding["subjects"] == [ + { + "kind": "ServiceAccount", + "name": "awoooi-api", + "namespace": "awoooi-prod", + } + ] + assert binding["roleRef"]["name"] == "awoooi-api-reader-role" + assert all( + set(rule["verbs"]) <= {"get", "list", "watch"} + for rule in reader["rules"] + ) + + def test_api_log_does_not_claim_skipped_executor_was_scheduled() -> None: repo_root = Path(__file__).resolve().parents[3] main_source = (repo_root / "apps/api/src/main.py").read_text() diff --git a/k8s/awoooi-prod/06-deployment-api.yaml b/k8s/awoooi-prod/06-deployment-api.yaml index 966c238f0..3aa97d773 100644 --- a/k8s/awoooi-prod/06-deployment-api.yaml +++ b/k8s/awoooi-prod/06-deployment-api.yaml @@ -56,14 +56,9 @@ spec: app: awoooi-api environment: prod system: awoooi - # Phase 7: 使用 RBAC ServiceAccount (最小權限) - serviceAccountName: awoooi-executor + # API is read-only. All Kubernetes/host mutations belong to the worker. + serviceAccountName: awoooi-api automountServiceAccountToken: true - # 2026-04-09 Claude Sonnet 4.6 Asia/Taipei: Bug #12 修正 - # fsGroup=1000 讓 appuser(1000) 可讀取 defaultMode=0400 的 repair-ssh-key Secret - # SSH 要求 key 必須是 owner-only (0400/0600),0444 會被拒絕 - securityContext: - fsGroup: 1000 containers: - name: api # 映像標籤由 CI/CD 動態注入 (格式: {sha}-{run_id}) @@ -146,8 +141,10 @@ spec: value: "80" - name: PROMETHEUS_MULTIPROC_DIR value: "/tmp/awoooi-prometheus-multiproc" + - name: SSH_MCP_ENABLED + value: "false" - name: ENABLE_AWOOOP_ANSIBLE_CHECK_MODE_WORKER - value: "true" + value: "false" - name: AWOOOP_ANSIBLE_CHECK_MODE_INTERVAL_SECONDS value: "300" - name: AWOOOP_ANSIBLE_CHECK_MODE_BATCH_LIMIT @@ -158,16 +155,12 @@ spec: value: "120" - name: AWOOOP_ANSIBLE_CHECK_MODE_TRANSPORT_PROFILE value: "ssh_mcp" - - name: AWOOOP_ANSIBLE_CHECK_MODE_SSH_KEY_PATH - value: "/run/secrets/ssh_mcp_key" - - name: AWOOOP_ANSIBLE_CHECK_MODE_KNOWN_HOSTS_PATH - value: "/etc/ssh-mcp/known_hosts" - name: AWOOOP_ANSIBLE_CHECK_MODE_CANDIDATE_MAX_AGE_HOURS value: "24" - name: AWOOOP_ANSIBLE_CHECK_MODE_TRANSPORT_COOLDOWN_SECONDS value: "21600" - name: ENABLE_AWOOOP_ANSIBLE_CANDIDATE_BACKFILL_WORKER - value: "true" + value: "false" - name: AWOOOP_ANSIBLE_CANDIDATE_BACKFILL_INTERVAL_SECONDS value: "600" - name: AWOOOP_ANSIBLE_CANDIDATE_BACKFILL_BATCH_LIMIT @@ -180,33 +173,13 @@ spec: value: "true" - name: AWOOOI_REBOOT_SLO_PROMETHEUS_QUERY_URL value: "http://192.168.0.110:9090/api/v1/query" - # 2026-04-05 Claude Code: Sprint 3 — 掛載 SSH key 供 HostRepairAgent 使用 volumeMounts: - - name: repair-ssh-key - mountPath: /etc/repair-ssh - readOnly: true - # 2026-04-06 Claude Code: Sprint 3 Security Fix A1 — known_hosts - # 掛到獨立目錄,避免與 repair-ssh-key 的 mountPath 衝突 - - name: repair-known-hosts - mountPath: /etc/repair-known-hosts - readOnly: true # 2026-04-08 Claude Sonnet 4.6: Sprint 5.1 Guardrail — service registry YAML # 掛載到 /app/ops/config/ 讓 _find_registry_path() 可找到 - name: service-registry mountPath: /app/ops/config/service-registry.yaml subPath: service-registry.yaml readOnly: true - # MCP Phase 2a (2026-04-11 Claude Sonnet 4.6): SSH MCP key - # ssh_mcp_key → /run/secrets/ssh_mcp_key (SSH_KEY_PATH in ssh_provider.py) - # known_hosts → /etc/ssh-mcp/known_hosts (SSH_MCP_KNOWN_HOSTS_FILE) - - name: ssh-mcp-key - mountPath: /run/secrets/ssh_mcp_key - subPath: ssh_mcp_key - readOnly: true - - name: ssh-mcp-key - mountPath: /etc/ssh-mcp/known_hosts - subPath: known_hosts - readOnly: true - name: prometheus-multiproc mountPath: /tmp/awoooi-prometheus-multiproc resources: @@ -256,29 +229,11 @@ spec: matchLabels: app: awoooi-api topologyKey: kubernetes.io/hostname - # 2026-04-05 Claude Code: Sprint 3 — repair SSH key (defaultMode 0400) volumes: - - name: repair-ssh-key - secret: - secretName: awoooi-repair-ssh-key - defaultMode: 0400 # 八進位 0400 = 十進位 256 = r-------- (owner read-only) - # 2026-04-06 Claude Code: Sprint 3 Security Fix A1 - # optional: true — CD 首次跑時建立 secret,Pod 不阻塞等待 - - name: repair-known-hosts - secret: - secretName: awoooi-repair-known-hosts - optional: true # 2026-04-08 Claude Sonnet 4.6: Sprint 5.1 Guardrail — service registry - name: service-registry configMap: name: service-registry - # MCP Phase 2a (2026-04-11 Claude Sonnet 4.6): SSH MCP key - # optional: true — SSH MCP 預設關閉,Secret 不存在時 Pod 不阻塞 - - name: ssh-mcp-key - secret: - secretName: ssh-mcp-key - defaultMode: 0400 - optional: true - name: prometheus-multiproc emptyDir: {} diff --git a/k8s/awoooi-prod/07-rbac.yaml b/k8s/awoooi-prod/07-rbac.yaml index a9ee6120f..ec945b32e 100644 --- a/k8s/awoooi-prod/07-rbac.yaml +++ b/k8s/awoooi-prod/07-rbac.yaml @@ -19,6 +19,92 @@ # - 存取 Secrets (除非明確需要) # - 執行任何 cluster-admin 操作 +apiVersion: v1 +kind: ServiceAccount +metadata: + name: awoooi-api + namespace: awoooi-prod + labels: + app: awoooi + component: api + system: awoooi + annotations: + description: "AWOOOI API - read-only control-plane identity" + +--- +# Public API may observe runtime state but cannot mutate workloads or hosts. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: awoooi-api-reader-role + labels: + app: awoooi + component: api +rules: + - apiGroups: [""] + resources: + - pods + - pods/status + - events + - namespaces + - services + - configmaps + - persistentvolumeclaims + - nodes + - nodes/status + verbs: ["get", "list", "watch"] + - apiGroups: [""] + resources: ["pods/log"] + verbs: ["get"] + - apiGroups: ["apps"] + resources: + - deployments + - deployments/status + - replicasets + - statefulsets + - daemonsets + verbs: ["get", "list", "watch"] + - apiGroups: ["networking.k8s.io"] + resources: ["ingresses"] + verbs: ["get", "list", "watch"] + - apiGroups: ["autoscaling"] + resources: ["horizontalpodautoscalers"] + verbs: ["get", "list", "watch"] + - apiGroups: ["metrics.k8s.io"] + resources: ["nodes", "pods"] + verbs: ["get", "list"] + - apiGroups: ["batch"] + resources: ["jobs", "cronjobs"] + verbs: ["get", "list", "watch"] + - apiGroups: ["velero.io"] + resources: + - backups + - backupstoragelocations + - backuprepositories + - podvolumebackups + - podvolumerestores + - restores + - schedules + verbs: ["get", "list", "watch"] + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: awoooi-api-reader-binding + labels: + app: awoooi + component: api +subjects: + - kind: ServiceAccount + name: awoooi-api + namespace: awoooi-prod +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: awoooi-api-reader-role + +--- apiVersion: v1 kind: ServiceAccount metadata: diff --git a/k8s/rbac/api-velero-reader.yaml b/k8s/rbac/api-velero-reader.yaml index 47490b883..890a1300a 100644 --- a/k8s/rbac/api-velero-reader.yaml +++ b/k8s/rbac/api-velero-reader.yaml @@ -1,9 +1,9 @@ # k8s/rbac/api-velero-reader.yaml # API Pod 讀取 Velero backup 資源的 RBAC # Sprint 5.1 K-001 / 2026-04-08 Asia/Taipei -# 說明: awoooi-executor ServiceAccount 需要讀取 velero namespace 的 backup 資源 +# 說明: awoooi-api ServiceAccount 需要讀取 velero namespace 的 backup 資源 # 用於 Pre-flight Check 查詢最近備份時間(Q7 決策:kubectl 方式) -# 注意: ServiceAccount 名稱為 awoooi-executor(非 awoooi-api,經 L0 確認) +# 注意: executor 與 public API identity 已分離;此 binding 只給 API read-only。 --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole @@ -28,7 +28,7 @@ metadata: sprint: "5.1" subjects: - kind: ServiceAccount - name: awoooi-executor + name: awoooi-api namespace: awoooi-prod roleRef: kind: ClusterRole