fix(security): isolate workload database identities
All checks were successful
CD Pipeline / workflow-shape (push) Successful in 1s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / tests (push) Successful in 2m25s
CD Pipeline / build-and-deploy (push) Successful in 6m42s
CD Pipeline / post-deploy-checks (push) Successful in 1m50s

This commit is contained in:
ogt
2026-07-11 04:20:08 +08:00
parent eb976bdbd2
commit 262c9c8a7e
11 changed files with 1062 additions and 25 deletions

View File

@@ -2145,6 +2145,22 @@ jobs:
echo "✅ 所有 Secrets 注入完成"
SECRETS
# AIA-P0-011: record only Secret object pre-existence, never Secret data.
# The bootstrap streams CSPRNG credentials directly to PostgreSQL and
# kubectl stdin, then proves each identity with a no-write preflight.
WORKLOAD_DB_SECRET_PREEXISTENCE_FILE="/tmp/awoooi-workload-db-secret-preexistence"
: > "$WORKLOAD_DB_SECRET_PREEXISTENCE_FILE"
for secret_name in awoooi-api-db awoooi-worker-db awoooi-broker-db; do
secret_existed=$(ssh $SSH_OPTS "wooo@${{ env.K8S_SSH_HOST }}" \
"KUBECTL='sudo kubectl --kubeconfig=/etc/rancher/k3s/k3s.yaml --server=${{ env.K8S_API_SERVER }}'; if \$KUBECTL get secret '${secret_name}' -n awoooi-prod >/dev/null 2>&1; then printf 1; else printf 0; fi")
printf '%s=%s\n' "$secret_name" "$secret_existed" \
>> "$WORKLOAD_DB_SECRET_PREEXISTENCE_FILE"
done
cat scripts/ops/awoooi-workload-db-identity-bootstrap.sh | \
ssh $SSH_OPTS "wooo@${{ env.K8S_SSH_HOST }}" \
"AWOOOI_KUBECTL_SUDO=true AWOOOI_KUBECONFIG=/etc/rancher/k3s/k3s.yaml AWOOOI_KUBERNETES_SERVER='${{ env.K8S_API_SERVER }}' AWOOOI_RUN_ID='${GITEA_RUN_NUMBER:-cd}' bash -s -- apply"
echo "✅ workload DB identities, Secret projections, and preflights ready"
# 2026-04-11 Claude Sonnet 4.6 (Sprint B-3 ADR-069):
# Deploy 改為 ArgoCD GitOps 模式:更新 kustomization.yaml → git push [skip ci] → ArgoCD sync
# 舊做法 (kubectl set image) 與 ArgoCD selfHeal 衝突 — ArgoCD 會 revert 任何直接 kubectl 操作
@@ -2187,6 +2203,70 @@ jobs:
HARBOR=192.168.0.110:5000
# ─── Step 1: Apply NetworkPolicy + ConfigMap + ServiceRegistry ───
# Preserve only live Deployment specs and Secret object existence.
# Secret data is never read or copied into the rollback artifact.
WORKLOAD_DB_ROLLBACK_FILE="/tmp/awoooi-workload-db-deployment-rollback.yaml"
WORKLOAD_DB_SECRET_PREEXISTENCE_FILE="/tmp/awoooi-workload-db-secret-preexistence"
ssh $SSH_OPTS "wooo@${{ env.K8S_SSH_HOST }}" \
"KUBECTL='sudo kubectl --kubeconfig=/etc/rancher/k3s/k3s.yaml --server=${{ env.K8S_API_SERVER }}'; \$KUBECTL get deployment awoooi-api awoooi-worker awoooi-ansible-executor-broker -n awoooi-prod -o json" \
| python3 -c '
import json
import sys
source = json.load(sys.stdin)
items = [
{
"apiVersion": "apps/v1",
"kind": "Deployment",
"metadata": {
"name": item["metadata"]["name"],
"namespace": item["metadata"]["namespace"],
"labels": item["metadata"].get("labels", {}),
},
"spec": item["spec"],
}
for item in source["items"]
]
json.dump({"apiVersion": "v1", "kind": "List", "items": items}, sys.stdout)
' > "$WORKLOAD_DB_ROLLBACK_FILE"
test -s "$WORKLOAD_DB_ROLLBACK_FILE"
if [ ! -s "$WORKLOAD_DB_SECRET_PREEXISTENCE_FILE" ]; then
: > "$WORKLOAD_DB_SECRET_PREEXISTENCE_FILE"
for secret_name in awoooi-api-db awoooi-worker-db awoooi-broker-db; do
secret_existed=$(ssh $SSH_OPTS "wooo@${{ env.K8S_SSH_HOST }}" \
"KUBECTL='sudo kubectl --kubeconfig=/etc/rancher/k3s/k3s.yaml --server=${{ env.K8S_API_SERVER }}'; if \$KUBECTL get secret '${secret_name}' -n awoooi-prod >/dev/null 2>&1; then printf 1; else printf 0; fi")
printf '%s=%s\n' "$secret_name" "$secret_existed" \
>> "$WORKLOAD_DB_SECRET_PREEXISTENCE_FILE"
done
fi
restore_workload_database_projection() {
echo "⏪ restoring previous AWOOOI workload database projection"
cat "$WORKLOAD_DB_ROLLBACK_FILE" | \
ssh $SSH_OPTS "wooo@${{ env.K8S_SSH_HOST }}" \
"KUBECTL='sudo kubectl --kubeconfig=/etc/rancher/k3s/k3s.yaml --server=${{ env.K8S_API_SERVER }}'; \$KUBECTL apply -f -"
for deployment_name in awoooi-api awoooi-worker awoooi-ansible-executor-broker; do
ssh $SSH_OPTS "wooo@${{ env.K8S_SSH_HOST }}" \
"KUBECTL='sudo kubectl --kubeconfig=/etc/rancher/k3s/k3s.yaml --server=${{ env.K8S_API_SERVER }}'; \$KUBECTL rollout status deployment/${deployment_name} -n awoooi-prod --timeout=180s"
done
disable_workloads=()
while IFS='=' read -r secret_name secret_existed; do
[ "$secret_existed" = "0" ] || continue
case "$secret_name" in
awoooi-api-db) disable_workloads+=(api) ;;
awoooi-worker-db) disable_workloads+=(worker) ;;
awoooi-broker-db) disable_workloads+=(broker) ;;
esac
ssh $SSH_OPTS "wooo@${{ env.K8S_SSH_HOST }}" \
"KUBECTL='sudo kubectl --kubeconfig=/etc/rancher/k3s/k3s.yaml --server=${{ env.K8S_API_SERVER }}'; \$KUBECTL delete secret '${secret_name}' -n awoooi-prod --ignore-not-found=true"
done < "$WORKLOAD_DB_SECRET_PREEXISTENCE_FILE"
if [ "${#disable_workloads[@]}" -gt 0 ]; then
cat scripts/ops/awoooi-workload-db-identity-bootstrap.sh | \
ssh $SSH_OPTS "wooo@${{ env.K8S_SSH_HOST }}" \
"AWOOOI_KUBECTL_SUDO=true AWOOOI_KUBECONFIG=/etc/rancher/k3s/k3s.yaml AWOOOI_KUBERNETES_SERVER='${{ env.K8S_API_SERVER }}' bash -s -- disable ${disable_workloads[*]}"
fi
}
# NetworkPolicy is intentionally outside kustomize because commonLabels
# mutates nested selectors. Capture production truth before apply; the
# checkout is intentionally depth=1, so parent Git history is not a
@@ -2238,13 +2318,26 @@ jobs:
"KUBECTL='sudo kubectl --kubeconfig=/etc/rancher/k3s/k3s.yaml --server=${{ env.K8S_API_SERVER }}'; \$KUBECTL delete networkpolicy '$BROKER_POLICY_NAME' -n awoooi-prod --ignore-not-found=true"
fi
}
restore_deploy_controls() {
set +e
restore_workload_database_projection
workload_restore_exit=$?
restore_network_policy
network_restore_exit=$?
set -e
if [ "$workload_restore_exit" -ne 0 ] \
|| [ "$network_restore_exit" -ne 0 ]; then
echo "❌ deploy rollback incomplete: workload=${workload_restore_exit} network=${network_restore_exit}" >&2
return 1
fi
}
cat k8s/awoooi-prod/02-network-policy.yaml | \
ssh $SSH_OPTS "wooo@${{ env.K8S_SSH_HOST }}" \
"KUBECTL='sudo kubectl --kubeconfig=/etc/rancher/k3s/k3s.yaml --server=${{ env.K8S_API_SERVER }}'; \$KUBECTL apply --dry-run=server -f - >/dev/null"
if ! cat k8s/awoooi-prod/02-network-policy.yaml | \
ssh $SSH_OPTS "wooo@${{ env.K8S_SSH_HOST }}" \
"KUBECTL='sudo kubectl --kubeconfig=/etc/rancher/k3s/k3s.yaml --server=${{ env.K8S_API_SERVER }}'; \$KUBECTL apply -f -"; then
restore_network_policy
restore_deploy_controls || true
exit 1
fi
echo "✅ NetworkPolicy 已受控更新"
@@ -2730,10 +2823,55 @@ jobs:
WHERE rolname = current_user
"""))
row = result.mappings().one()
for table_name in (
"incidents",
"knowledge_entries",
"awooop_run_state",
):
await db.execute(
text(f'SELECT count(*) FROM "{table_name}"')
)
table_gap_count = int((await db.execute(text("""
SELECT count(*)
FROM pg_class relation
JOIN pg_namespace namespace
ON namespace.oid = relation.relnamespace
WHERE namespace.nspname = 'public'
AND relation.relkind IN ('r', 'p', 'v', 'm')
AND (
(has_table_privilege('awoooi', relation.oid, 'SELECT')
AND NOT has_table_privilege(current_user, relation.oid, 'SELECT'))
OR (has_table_privilege('awoooi', relation.oid, 'INSERT')
AND NOT has_table_privilege(current_user, relation.oid, 'INSERT'))
OR (has_table_privilege('awoooi', relation.oid, 'UPDATE')
AND NOT has_table_privilege(current_user, relation.oid, 'UPDATE'))
OR (has_table_privilege('awoooi', relation.oid, 'DELETE')
AND NOT has_table_privilege(current_user, relation.oid, 'DELETE'))
)
"""))).scalar_one())
sequence_gap_count = int((await db.execute(text("""
SELECT count(*)
FROM pg_class sequence
JOIN pg_namespace namespace
ON namespace.oid = sequence.relnamespace
WHERE namespace.nspname = 'public'
AND sequence.relkind = 'S'
AND (
(has_sequence_privilege('awoooi', sequence.oid, 'USAGE')
AND NOT has_sequence_privilege(current_user, sequence.oid, 'USAGE'))
OR (has_sequence_privilege('awoooi', sequence.oid, 'SELECT')
AND NOT has_sequence_privilege(current_user, sequence.oid, 'SELECT'))
OR (has_sequence_privilege('awoooi', sequence.oid, 'UPDATE')
AND NOT has_sequence_privilege(current_user, sequence.oid, 'UPDATE'))
)
"""))).scalar_one())
except Exception as exc:
print(json.dumps({
"role_fingerprint": "",
"connection_limit": None,
"representative_preflight_passed": False,
"table_privilege_gap_count": None,
"sequence_privilege_gap_count": None,
"error_type": type(exc).__name__,
}))
return
@@ -2742,6 +2880,11 @@ jobs:
str(row["role_name"]).encode("utf-8")
).hexdigest(),
"connection_limit": int(row["connection_limit"]),
"representative_preflight_passed": (
table_gap_count == 0 and sequence_gap_count == 0
),
"table_privilege_gap_count": table_gap_count,
"sequence_privilege_gap_count": sequence_gap_count,
"error_type": None,
}))
@@ -2795,9 +2938,33 @@ jobs:
| json_field connection_limit 2>/dev/null || true)
BROKER_DB_CONNECTION_LIMIT=$(printf '%s' "$BROKER_DB_IDENTITY" \
| json_field connection_limit 2>/dev/null || true)
API_DB_REPRESENTATIVE_PREFLIGHT=$(normalize_bool "$(printf '%s' "$API_DB_IDENTITY" \
| json_field representative_preflight_passed 2>/dev/null || true)")
WORKER_DB_REPRESENTATIVE_PREFLIGHT=$(normalize_bool "$(printf '%s' "$WORKER_DB_IDENTITY" \
| json_field representative_preflight_passed 2>/dev/null || true)")
BROKER_DB_REPRESENTATIVE_PREFLIGHT=$(normalize_bool "$(printf '%s' "$BROKER_DB_IDENTITY" \
| json_field representative_preflight_passed 2>/dev/null || true)")
API_DB_TABLE_PRIVILEGE_GAP_COUNT=$(printf '%s' "$API_DB_IDENTITY" \
| json_field table_privilege_gap_count 2>/dev/null || true)
WORKER_DB_TABLE_PRIVILEGE_GAP_COUNT=$(printf '%s' "$WORKER_DB_IDENTITY" \
| json_field table_privilege_gap_count 2>/dev/null || true)
BROKER_DB_TABLE_PRIVILEGE_GAP_COUNT=$(printf '%s' "$BROKER_DB_IDENTITY" \
| json_field table_privilege_gap_count 2>/dev/null || true)
API_DB_SEQUENCE_PRIVILEGE_GAP_COUNT=$(printf '%s' "$API_DB_IDENTITY" \
| json_field sequence_privilege_gap_count 2>/dev/null || true)
WORKER_DB_SEQUENCE_PRIVILEGE_GAP_COUNT=$(printf '%s' "$WORKER_DB_IDENTITY" \
| json_field sequence_privilege_gap_count 2>/dev/null || true)
BROKER_DB_SEQUENCE_PRIVILEGE_GAP_COUNT=$(printf '%s' "$BROKER_DB_IDENTITY" \
| json_field sequence_privilege_gap_count 2>/dev/null || true)
API_DB_CONNECTION_LIMIT=${API_DB_CONNECTION_LIMIT:-0}
WORKER_DB_CONNECTION_LIMIT=${WORKER_DB_CONNECTION_LIMIT:-0}
BROKER_DB_CONNECTION_LIMIT=${BROKER_DB_CONNECTION_LIMIT:-0}
API_DB_TABLE_PRIVILEGE_GAP_COUNT=${API_DB_TABLE_PRIVILEGE_GAP_COUNT:-1}
WORKER_DB_TABLE_PRIVILEGE_GAP_COUNT=${WORKER_DB_TABLE_PRIVILEGE_GAP_COUNT:-1}
BROKER_DB_TABLE_PRIVILEGE_GAP_COUNT=${BROKER_DB_TABLE_PRIVILEGE_GAP_COUNT:-1}
API_DB_SEQUENCE_PRIVILEGE_GAP_COUNT=${API_DB_SEQUENCE_PRIVILEGE_GAP_COUNT:-1}
WORKER_DB_SEQUENCE_PRIVILEGE_GAP_COUNT=${WORKER_DB_SEQUENCE_PRIVILEGE_GAP_COUNT:-1}
BROKER_DB_SEQUENCE_PRIVILEGE_GAP_COUNT=${BROKER_DB_SEQUENCE_PRIVILEGE_GAP_COUNT:-1}
API_DB_PROJECTION=$(workload_secret_projection awoooi-api api)
WORKER_DB_PROJECTION=$(workload_secret_projection awoooi-worker worker)
@@ -2854,14 +3021,27 @@ jobs:
&& [ "$DISTINCT_DB_ROLE_FINGERPRINTS" = "true" ]; then
DB_IDENTITY_ISOLATED=true
fi
REPRESENTATIVE_DB_PREFLIGHTS_VERIFIED=false
if [ "$API_DB_REPRESENTATIVE_PREFLIGHT" = "true" ] \
&& [ "$WORKER_DB_REPRESENTATIVE_PREFLIGHT" = "true" ] \
&& [ "$BROKER_DB_REPRESENTATIVE_PREFLIGHT" = "true" ] \
&& [ "$API_DB_TABLE_PRIVILEGE_GAP_COUNT" -eq 0 ] \
&& [ "$WORKER_DB_TABLE_PRIVILEGE_GAP_COUNT" -eq 0 ] \
&& [ "$BROKER_DB_TABLE_PRIVILEGE_GAP_COUNT" -eq 0 ] \
&& [ "$API_DB_SEQUENCE_PRIVILEGE_GAP_COUNT" -eq 0 ] \
&& [ "$WORKER_DB_SEQUENCE_PRIVILEGE_GAP_COUNT" -eq 0 ] \
&& [ "$BROKER_DB_SEQUENCE_PRIVILEGE_GAP_COUNT" -eq 0 ]; then
REPRESENTATIVE_DB_PREFLIGHTS_VERIFIED=true
fi
CONNECTION_BUDGET_VERIFIED=false
if [ "$DB_IDENTITY_ISOLATED" = "true" ] \
&& [ "$REPRESENTATIVE_DB_PREFLIGHTS_VERIFIED" = "true" ] \
&& [ "$API_DB_NULL_POOL" = "true" ] \
&& [ "$WORKER_DB_NULL_POOL" = "true" ] \
&& [ "$BROKER_DB_NULL_POOL" = "true" ] \
&& [ "$API_DB_CONNECTION_LIMIT" -ge 1 ] \
&& [ "$WORKER_DB_CONNECTION_LIMIT" -ge 1 ] \
&& [ "$BROKER_DB_CONNECTION_LIMIT" -ge 1 ]; then
&& [ "$API_DB_CONNECTION_LIMIT" -ge 4 ] \
&& [ "$WORKER_DB_CONNECTION_LIMIT" -ge 2 ] \
&& [ "$BROKER_DB_CONNECTION_LIMIT" -ge 2 ]; then
CONNECTION_BUDGET_VERIFIED=true
fi
@@ -2887,7 +3067,7 @@ jobs:
--from-literal=worker_ssh_egress_denied=true \
--from-literal=broker_ssh_egress_allowlisted=true \
--from-literal=legacy_namespace_egress_allow_absent=true \
--from-literal=db_identity_verifier=runtime_role_fingerprint_and_secret_projection_v1 \
--from-literal=db_identity_verifier=runtime_role_fingerprint_secret_projection_and_representative_preflight_v2 \
--from-literal=api_database_secret_ref="$API_DATABASE_SECRET_REF" \
--from-literal=worker_database_secret_ref="$WORKER_DATABASE_SECRET_REF" \
--from-literal=broker_database_secret_ref="$BROKER_DATABASE_SECRET_REF" \
@@ -2897,9 +3077,19 @@ jobs:
--from-literal=api_db_connection_limit="$API_DB_CONNECTION_LIMIT" \
--from-literal=worker_db_connection_limit="$WORKER_DB_CONNECTION_LIMIT" \
--from-literal=broker_db_connection_limit="$BROKER_DB_CONNECTION_LIMIT" \
--from-literal=api_required_connection_budget=1 \
--from-literal=worker_required_connection_budget=1 \
--from-literal=broker_required_connection_budget=1 \
--from-literal=api_required_connection_budget=4 \
--from-literal=worker_required_connection_budget=2 \
--from-literal=broker_required_connection_budget=2 \
--from-literal=api_representative_db_preflight_passed="$API_DB_REPRESENTATIVE_PREFLIGHT" \
--from-literal=worker_representative_db_preflight_passed="$WORKER_DB_REPRESENTATIVE_PREFLIGHT" \
--from-literal=broker_representative_db_preflight_passed="$BROKER_DB_REPRESENTATIVE_PREFLIGHT" \
--from-literal=api_table_privilege_gap_count="$API_DB_TABLE_PRIVILEGE_GAP_COUNT" \
--from-literal=worker_table_privilege_gap_count="$WORKER_DB_TABLE_PRIVILEGE_GAP_COUNT" \
--from-literal=broker_table_privilege_gap_count="$BROKER_DB_TABLE_PRIVILEGE_GAP_COUNT" \
--from-literal=api_sequence_privilege_gap_count="$API_DB_SEQUENCE_PRIVILEGE_GAP_COUNT" \
--from-literal=worker_sequence_privilege_gap_count="$WORKER_DB_SEQUENCE_PRIVILEGE_GAP_COUNT" \
--from-literal=broker_sequence_privilege_gap_count="$BROKER_DB_SEQUENCE_PRIVILEGE_GAP_COUNT" \
--from-literal=representative_db_preflights_verified="$REPRESENTATIVE_DB_PREFLIGHTS_VERIFIED" \
--from-literal=api_database_null_pool="$API_DB_NULL_POOL" \
--from-literal=worker_database_null_pool="$WORKER_DB_NULL_POOL" \
--from-literal=broker_database_null_pool="$BROKER_DB_NULL_POOL" \
@@ -2972,9 +3162,15 @@ jobs:
last_status = f"fetch_failed:{type(exc).__name__}"
else:
boundary = payload.get("executor_trust_boundary") or {}
database_boundary = (
boundary.get("database_identity_boundary") or {}
)
last_status = str(boundary.get("status") or "missing")
if (
boundary.get("production_boundary_verified") is True
and boundary.get("full_workload_boundary_verified") is True
and database_boundary.get("db_identity_isolated") is True
and database_boundary.get("connection_budget_verified") is True
and str(boundary.get("verified_source_sha") or "").lower()
== expected
):
@@ -2991,12 +3187,23 @@ jobs:
ROLLOUT_EXIT=${PIPESTATUS[0]}
set -e
WORKLOAD_DB_VERIFY_EXIT=0
cat scripts/ops/awoooi-workload-db-identity-bootstrap.sh | \
ssh $SSH_OPTS "wooo@${{ env.K8S_SSH_HOST }}" \
"AWOOOI_KUBECTL_SUDO=true AWOOOI_KUBECONFIG=/etc/rancher/k3s/k3s.yaml AWOOOI_KUBERNETES_SERVER='${{ env.K8S_API_SERVER }}' AWOOOI_RUN_ID='${GITEA_RUN_NUMBER:-cd}' bash -s -- verify" \
|| WORKLOAD_DB_VERIFY_EXIT=$?
if [ "$WORKLOAD_DB_VERIFY_EXIT" -ne 0 ]; then
restore_deploy_controls || true
echo "❌ workload database post-deploy verifier failed"
exit "$WORKLOAD_DB_VERIFY_EXIT"
fi
SECURITY_BOUNDARY_VERIFIED="0"
if grep -q '^AWOOOI_SECURITY_BOUNDARY_VERIFIED=1$' "$ROLLOUT_LOG"; then
SECURITY_BOUNDARY_VERIFIED="1"
fi
if [ "$SECURITY_BOUNDARY_VERIFIED" != "1" ]; then
restore_network_policy
restore_deploy_controls || true
echo "❌ executor security boundary did not reach its terminal verifier"
if [ "$ROLLOUT_EXIT" -eq 0 ]; then
exit 1
@@ -3099,6 +3306,7 @@ jobs:
PY
if [ "$DEPLOY_READBACK_EXIT" -ne 0 ]; then
restore_deploy_controls || true
exit "$DEPLOY_READBACK_EXIT"
fi