fix(security): reconcile asset control plane truth
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 2m27s
CD Pipeline / build-and-deploy (push) Failing after 3m57s
CD Pipeline / post-deploy-checks (push) Has been skipped

This commit is contained in:
ogt
2026-07-14 10:47:33 +08:00
parent 467542a018
commit b6ddee7984
9 changed files with 869 additions and 170 deletions

View File

@@ -405,6 +405,10 @@ jobs:
;;
apps/api/migrations/adr090f_log_controlled_writeback_dispatch_operation_type_down.sql)
;;
apps/api/migrations/asset_inventory_database_types_2026-07-14.sql)
;;
apps/api/migrations/asset_inventory_database_types_2026-07-14_down.sql)
;;
apps/api/src/services/auto_approve.py)
;;
apps/api/src/services/decision_fusion.py)
@@ -1814,6 +1818,144 @@ jobs:
echo "⚡ no Docker build lock to release"
fi
# Production drift proved the original CREATE TABLE IF NOT EXISTS
# migration did not update an older CHECK constraint. Reconcile this
# one additive allowlist before rolling out the scanner and verify it
# independently without exposing connection details.
- name: Reconcile Asset Inventory Type Constraint
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
MIGRATION_DATABASE_URL: ${{ secrets.MIGRATION_DATABASE_URL }}
run: |
set -euo pipefail
if [ -z "${MIGRATION_DATABASE_URL:-}" ]; then
echo "::error::MIGRATION_DATABASE_URL secret not set in Gitea"
exit 1
fi
docker run --rm --network host -i \
-e DATABASE_URL \
-e MIGRATION_DATABASE_URL \
-v "$PWD/apps/api/migrations/asset_inventory_database_types_2026-07-14.sql:/tmp/asset_inventory_type.sql:ro" \
"${HARBOR}/awoooi/api:${{ github.sha }}" python - <<'PY'
import asyncio
import os
import re
from pathlib import Path
import asyncpg
MIGRATION = Path("/tmp/asset_inventory_type.sql").read_text(encoding="utf-8")
REQUIRED_TYPES = {
"host", "container", "k8s_workload", "k8s_resource", "database", "table",
"website", "api_endpoint", "package", "log_stream", "km_entry", "frontend",
"backend", "ci_pipeline", "gitea_repo", "monitoring_target", "secret", "volume",
"network", "certificate", "scheduled_job", "message_queue", "cache", "dashboard",
"ai_agent", "llm_model", "third_party_service", "backup_target",
}
def normalize_url(value: str) -> str:
return value.replace("postgresql+asyncpg://", "postgresql://", 1)
async def constraint_status(
connection: asyncpg.Connection,
) -> tuple[bool, str, bool]:
row = await connection.fetchrow(
"""
SELECT
constraint_row.convalidated,
pg_get_constraintdef(constraint_row.oid) AS definition,
EXISTS (
SELECT 1
FROM automation_operation_log
WHERE actor = 'gitea_cd_asset_schema_reconciler'
AND operation_type = 'asset_discovered'
AND status = 'success'
AND input ->> 'migration' =
'asset_inventory_database_types_2026-07-14'
) AS durable_receipt_present
FROM pg_constraint constraint_row
WHERE constraint_row.conrelid = 'asset_inventory'::regclass
AND constraint_row.conname = 'asset_inventory_type_valid'
"""
)
if row is None:
return False, "", False
return (
bool(row["convalidated"]),
str(row["definition"] or ""),
bool(row["durable_receipt_present"]),
)
def allowed_types(definition: str) -> set[str]:
return set(re.findall(r"'([^']+)'(?:::text)?", definition))
async def reconcile(url: str) -> tuple[bool, str, bool, bool]:
connection = await asyncpg.connect(normalize_url(url), timeout=10)
applied = False
try:
validated, definition, receipt_present = await constraint_status(connection)
if (
not validated
or allowed_types(definition) != REQUIRED_TYPES
or not receipt_present
):
async with connection.transaction():
await connection.execute(MIGRATION)
applied = True
validated, definition, receipt_present = await constraint_status(connection)
return validated, definition, receipt_present, applied
finally:
await connection.close()
async def main() -> None:
candidates = [
("migration_role", os.environ["MIGRATION_DATABASE_URL"]),
("owner_role", os.environ.get("DATABASE_URL", "")),
]
for index, (role, url) in enumerate(candidates):
if not url:
continue
try:
validated, definition, receipt_present, applied = await reconcile(url)
type_set = allowed_types(definition)
database_allowed = "database" in type_set
table_allowed = "table" in type_set
type_set_exact = type_set == REQUIRED_TYPES
print(f"asset_inventory_type_migration_role={role}")
print(f"asset_inventory_type_migration_applied={str(applied).lower()}")
print(f"database_type_allowed={str(database_allowed).lower()}")
print(f"table_type_allowed={str(table_allowed).lower()}")
print(f"constraint_type_set_exact={str(type_set_exact).lower()}")
print(f"constraint_validated={str(validated).lower()}")
print(f"durable_receipt_present={str(receipt_present).lower()}")
if not (
validated
and database_allowed
and table_allowed
and type_set_exact
and receipt_present
):
raise RuntimeError("asset_inventory_type_constraint_verifier_failed")
return
except asyncpg.PostgresError as exc:
permission_denied = getattr(exc, "sqlstate", "") == "42501"
has_owner_fallback = index == 0 and bool(candidates[1][1])
if permission_denied and has_owner_fallback:
print("asset_inventory_type_migration_owner_fallback=true")
continue
print(f"asset_inventory_type_migration_error_class={type(exc).__name__}")
raise
raise RuntimeError("asset_inventory_type_migration_no_usable_connection")
asyncio.run(main())
PY
# 2026-03-31 ogt: 移除中間通知
# 2026-03-31 ogt: P0-1 Secrets 自動注入 (ADR-035 強制)