fix(security): split API from executor identity
Some checks failed
CD Pipeline / workflow-shape (push) Successful in 1s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / tests (push) Successful in 2m40s
CD Pipeline / build-and-deploy (push) Successful in 4m49s
E2E Health Check / e2e-health (push) Failing after 1m3s
CD Pipeline / post-deploy-checks (push) Successful in 3m35s

This commit is contained in:
ogt
2026-07-10 23:53:09 +08:00
parent 475f09c9cb
commit b4c5c846b4
8 changed files with 197 additions and 73 deletions

View File

@@ -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:

View File

@@ -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,

View File

@@ -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=[],

View File

@@ -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"

View File

@@ -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()

View File

@@ -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 首次跑時建立 secretPod 不阻塞等待
- 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: {}

View File

@@ -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:

View File

@@ -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