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
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:
@@ -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 強制)
|
||||
|
||||
Reference in New Issue
Block a user