diff --git a/apps/api/src/services/iwooos_security_asset_control_plane.py b/apps/api/src/services/iwooos_security_asset_control_plane.py index 16640878c..79644b691 100644 --- a/apps/api/src/services/iwooos_security_asset_control_plane.py +++ b/apps/api/src/services/iwooos_security_asset_control_plane.py @@ -616,6 +616,7 @@ def build_iwooos_security_asset_control_plane( ai_trace_row: Any, siem_row: Any, audit_row: Any, + source_health: Iterable[dict[str, Any]] | None = None, generated_at: datetime | None = None, ) -> dict[str, Any]: """Build a public-safe aggregate without exposing raw asset or event identities.""" @@ -624,6 +625,20 @@ def build_iwooos_security_asset_control_plane( coverage_source = list(coverage_rows) compliance_source = list(compliance_rows) automation_source = list(automation_rows) + source_health_rows = list(source_health or []) + unavailable_sources = [ + row for row in source_health_rows if row.get("status") != "ready" + ] + core_source_unavailable = any( + row.get("source_id") in {"asset_discovery", "asset_inventory"} + for row in unavailable_sources + ) + if core_source_unavailable: + source_status = "live_database_unavailable" + elif unavailable_sources: + source_status = "live_database_partial" + else: + source_status = "live_database_aggregate" by_type = { str(_value(row, "asset_type", "unknown")): int(_value(row, "cnt", 0) or 0) @@ -831,6 +846,17 @@ def build_iwooos_security_asset_control_plane( ) work_items: list[dict[str, Any]] = [] + if unavailable_sources: + work_items.append( + _work_item( + "AIA-P0-006-00", + "P0", + "source_health", + "修復控制平面資料源", + len(unavailable_sources), + "依 source_health reason code 修復 schema、權限或資料源相容性。", + ) + ) if asset_total == 0 or not discovery_fresh: work_items.append( _work_item( @@ -947,9 +973,16 @@ def build_iwooos_security_asset_control_plane( return { "schema_version": _SCHEMA_VERSION, "generated_at": now.isoformat(), - "status": "ready" if asset_total > 0 and discovery_fresh else "degraded", - "source_status": "live_database_aggregate", + "status": ( + "ready" + if asset_total > 0 and discovery_fresh and not unavailable_sources + else "degraded" + ), + "source_status": source_status, "summary": { + "source_count": len(source_health_rows), + "ready_source_count": len(source_health_rows) - len(unavailable_sources), + "unavailable_source_count": len(unavailable_sources), "managed_asset_count": asset_total, "expected_asset_type_count": len(_ASSET_TYPES), "present_asset_type_count": len(present_types), @@ -1010,6 +1043,7 @@ def build_iwooos_security_asset_control_plane( "same_run_closed_loop_count": 0, }, "security_audit": audit, + "source_health": source_health_rows, "work_items": work_items, "boundaries": { "aggregate_only": True, @@ -1239,7 +1273,7 @@ class IwoooSSecurityAssetControlPlaneService: ) async def _load_snapshot(self) -> dict[str, Any]: - async with get_db_context() as db: + async with get_db_context("awoooi") as db: discovery_result = await db.execute( _sql( """ diff --git a/apps/api/tests/test_iwooos_security_asset_control_plane.py b/apps/api/tests/test_iwooos_security_asset_control_plane.py index 2883db591..f6db4f084 100644 --- a/apps/api/tests/test_iwooos_security_asset_control_plane.py +++ b/apps/api/tests/test_iwooos_security_asset_control_plane.py @@ -230,6 +230,32 @@ def test_service_redacts_database_exception_details(monkeypatch) -> None: assert "private-host" not in text +def test_service_uses_canonical_awoooi_project_scope(monkeypatch) -> None: + service = IwoooSSecurityAssetControlPlaneService() + requested_projects: list[str] = [] + + class FailingContext: + async def __aenter__(self): + raise RuntimeError("stop after project scope capture") + + async def __aexit__(self, exc_type, exc, traceback): + return False + + def fake_db_context(project_id: str): + requested_projects.append(project_id) + return FailingContext() + + monkeypatch.setattr( + "src.services.iwooos_security_asset_control_plane.get_db_context", + fake_db_context, + ) + payload = asyncio.run(service.get_snapshot()) + + assert requested_projects == ["awoooi"] + assert payload["status"] == "degraded" + assert payload["reason_code"] == "database_query_failed" + + def test_public_api_returns_live_aggregate(monkeypatch) -> None: payload = _ready_payload()