diff --git a/src/api/main.py b/src/api/main.py index 02debdcc..50153a8a 100644 --- a/src/api/main.py +++ b/src/api/main.py @@ -26,6 +26,7 @@ ) from infrastructure.version import __version__ from iam.infrastructure.outbox import IAMEventTranslator +from management.infrastructure.outbox import ManagementEventTranslator from infrastructure.outbox.composite import CompositeEventHandler from infrastructure.outbox.spicedb_handler import SpiceDBEventHandler from infrastructure.outbox.event_sources.postgres_notify import ( @@ -134,7 +135,12 @@ async def kartograph_lifespan(app: FastAPI): authz=authz, ) handler.register(spicedb_handler, handler_name="iam") - # Future: handler.register(management_handler, handler_name="management") + # Register SpiceDB handler wrapping the Management translator + management_spicedb_handler = SpiceDBEventHandler( + translator=ManagementEventTranslator(), + authz=authz, + ) + handler.register(management_spicedb_handler, handler_name="management") # Create event source for real-time NOTIFY processing event_source = PostgresNotifyEventSource( diff --git a/src/api/management/infrastructure/outbox/__init__.py b/src/api/management/infrastructure/outbox/__init__.py index 5b1e0ac6..c9a1efeb 100644 --- a/src/api/management/infrastructure/outbox/__init__.py +++ b/src/api/management/infrastructure/outbox/__init__.py @@ -1,9 +1,10 @@ """Management outbox integration. -Provides event serialization for Management domain events -to be stored in the transactional outbox. +Provides event serialization and SpiceDB translation for Management +domain events processed through the transactional outbox. """ from management.infrastructure.outbox.serializer import ManagementEventSerializer +from management.infrastructure.outbox.translator import ManagementEventTranslator -__all__ = ["ManagementEventSerializer"] +__all__ = ["ManagementEventSerializer", "ManagementEventTranslator"] diff --git a/src/api/management/infrastructure/outbox/translator.py b/src/api/management/infrastructure/outbox/translator.py new file mode 100644 index 00000000..d11375b4 --- /dev/null +++ b/src/api/management/infrastructure/outbox/translator.py @@ -0,0 +1,295 @@ +"""Management-specific event translator for SpiceDB operations. + +This module provides the translation layer between Management domain events +and SpiceDB relationship operations. It uses type-safe enums for all resource +types and relations to avoid magic strings. + +The translator uses a dictionary-based dispatch approach with automatic +validation to ensure all domain events have corresponding handlers. +""" + +from __future__ import annotations + +from typing import Any, Callable, get_args + +from management.domain.events import ( + DataSourceCreated, + DataSourceDeleted, + DataSourceSyncRequested, + DataSourceUpdated, + DomainEvent, + KnowledgeGraphCreated, + KnowledgeGraphDeleted, + KnowledgeGraphUpdated, +) +from shared_kernel.authorization.types import RelationType, ResourceType +from shared_kernel.outbox.operations import ( + DeleteRelationship, + DeleteRelationshipsByFilter, + SpiceDBOperation, + WriteRelationship, +) + +# Build registry mapping event type names to classes +_EVENT_REGISTRY: dict[str, type] = {cls.__name__: cls for cls in get_args(DomainEvent)} + + +class ManagementEventTranslator: + """Translates Management domain events to SpiceDB operations. + + This translator handles all Management-specific events defined in the + DomainEvent type alias. Handler methods are mapped via a dictionary + and validated at initialization to ensure completeness. + + Management events establish authorization relationships for knowledge + graphs and data sources, linking them to their parent workspaces, + knowledge graphs, and tenants in the SpiceDB permission system. + """ + + def __init__(self) -> None: + """Initialize translator and validate all events have handlers.""" + # Map event classes to handler methods + self._handlers: dict[ + type, Callable[[dict[str, Any]], list[SpiceDBOperation]] + ] = { + KnowledgeGraphCreated: self._translate_knowledge_graph_created, + KnowledgeGraphUpdated: self._translate_knowledge_graph_updated, + KnowledgeGraphDeleted: self._translate_knowledge_graph_deleted, + DataSourceCreated: self._translate_data_source_created, + DataSourceUpdated: self._translate_data_source_updated, + DataSourceDeleted: self._translate_data_source_deleted, + DataSourceSyncRequested: self._translate_data_source_sync_requested, + } + + # Validate all domain events have handlers + self._validate_handlers() + + def _validate_handlers(self) -> None: + """Ensure all domain events have handler methods. + + This is primarily a developer convenience - Kartograph + will fail to start if a DomainEvent doesn't have a registered handler. + + Raises: + ValueError: If any domain events are missing handlers + """ + event_types = set(get_args(DomainEvent)) + handler_types = set(self._handlers.keys()) + + missing = event_types - handler_types + if missing: + missing_names = [e.__name__ for e in missing] + raise ValueError( + f"Missing translation handlers for events: {missing_names}" + ) + + def supported_event_types(self) -> frozenset[str]: + """Return the event type names this translator handles.""" + return frozenset(cls.__name__ for cls in self._handlers.keys()) + + def translate( + self, + event_type: str, + payload: dict[str, Any], + ) -> list[SpiceDBOperation]: + """Convert an event payload to SpiceDB operations. + + Args: + event_type: The name of the event type + payload: The serialized event data + + Returns: + List of SpiceDB operations to execute + + Raises: + ValueError: If the event type is not supported + """ + # Get event class from registry + event_class = _EVENT_REGISTRY.get(event_type) + if not event_class: + raise ValueError(f"Unknown event type: {event_type}") + + # Look up handler method + handler = self._handlers.get(event_class) + if not handler: + raise ValueError(f"No handler for event: {event_type}") + + return handler(payload) + + def _translate_knowledge_graph_created( + self, + payload: dict[str, Any], + ) -> list[SpiceDBOperation]: + """Translate KnowledgeGraphCreated to workspace and tenant relationship writes. + + Creates two relationships: + - knowledge_graph:#workspace@workspace: + - knowledge_graph:#tenant@tenant: + + These relationships enable permission inheritance: workspace members + inherit access to knowledge graphs within that workspace. + """ + return [ + WriteRelationship( + resource_type=ResourceType.KNOWLEDGE_GRAPH, + resource_id=payload["knowledge_graph_id"], + relation=RelationType.WORKSPACE, + subject_type=ResourceType.WORKSPACE, + subject_id=payload["workspace_id"], + ), + WriteRelationship( + resource_type=ResourceType.KNOWLEDGE_GRAPH, + resource_id=payload["knowledge_graph_id"], + relation=RelationType.TENANT, + subject_type=ResourceType.TENANT, + subject_id=payload["tenant_id"], + ), + ] + + def _translate_knowledge_graph_updated( + self, + payload: dict[str, Any], + ) -> list[SpiceDBOperation]: + """Translate KnowledgeGraphUpdated - no SpiceDB changes needed. + + Metadata updates (name, description) do not affect authorization + relationships. The workspace and tenant associations remain unchanged. + """ + return [] + + def _translate_knowledge_graph_deleted( + self, + payload: dict[str, Any], + ) -> list[SpiceDBOperation]: + """Translate KnowledgeGraphDeleted to delete all relationships. + + Removes the workspace and tenant relationships created during + knowledge graph creation, plus any direct user permission grants + (admin, editor, viewer) using filter-based deletion. + + Order: direct deletes first, then filter deletes. + + Deletes: + - knowledge_graph:#workspace@workspace: + - knowledge_graph:#tenant@tenant: + - knowledge_graph:#admin@* (filter) + - knowledge_graph:#editor@* (filter) + - knowledge_graph:#viewer@* (filter) + """ + return [ + # Direct deletes for workspace and tenant + DeleteRelationship( + resource_type=ResourceType.KNOWLEDGE_GRAPH, + resource_id=payload["knowledge_graph_id"], + relation=RelationType.WORKSPACE, + subject_type=ResourceType.WORKSPACE, + subject_id=payload["workspace_id"], + ), + DeleteRelationship( + resource_type=ResourceType.KNOWLEDGE_GRAPH, + resource_id=payload["knowledge_graph_id"], + relation=RelationType.TENANT, + subject_type=ResourceType.TENANT, + subject_id=payload["tenant_id"], + ), + # Filter deletes for any direct admin/editor/viewer grants + DeleteRelationshipsByFilter( + resource_type=ResourceType.KNOWLEDGE_GRAPH, + resource_id=payload["knowledge_graph_id"], + relation=RelationType.ADMIN, + ), + DeleteRelationshipsByFilter( + resource_type=ResourceType.KNOWLEDGE_GRAPH, + resource_id=payload["knowledge_graph_id"], + relation=RelationType.EDITOR, + ), + DeleteRelationshipsByFilter( + resource_type=ResourceType.KNOWLEDGE_GRAPH, + resource_id=payload["knowledge_graph_id"], + relation=RelationType.VIEWER, + ), + ] + + def _translate_data_source_created( + self, + payload: dict[str, Any], + ) -> list[SpiceDBOperation]: + """Translate DataSourceCreated to knowledge graph and tenant relationship writes. + + Creates two relationships: + - data_source:#knowledge_graph@knowledge_graph: + - data_source:#tenant@tenant: + + These relationships enable permission inheritance: knowledge graph + members inherit access to data sources within that knowledge graph. + """ + return [ + WriteRelationship( + resource_type=ResourceType.DATA_SOURCE, + resource_id=payload["data_source_id"], + relation=RelationType.KNOWLEDGE_GRAPH, + subject_type=ResourceType.KNOWLEDGE_GRAPH, + subject_id=payload["knowledge_graph_id"], + ), + WriteRelationship( + resource_type=ResourceType.DATA_SOURCE, + resource_id=payload["data_source_id"], + relation=RelationType.TENANT, + subject_type=ResourceType.TENANT, + subject_id=payload["tenant_id"], + ), + ] + + def _translate_data_source_updated( + self, + payload: dict[str, Any], + ) -> list[SpiceDBOperation]: + """Translate DataSourceUpdated - no SpiceDB changes needed. + + Connection configuration updates do not affect authorization + relationships. The knowledge graph and tenant associations remain + unchanged. + """ + return [] + + def _translate_data_source_deleted( + self, + payload: dict[str, Any], + ) -> list[SpiceDBOperation]: + """Translate DataSourceDeleted to delete all relationships. + + Removes the knowledge graph and tenant relationships created during + data source creation. + + Deletes: + - data_source:#knowledge_graph@knowledge_graph: + - data_source:#tenant@tenant: + """ + return [ + DeleteRelationship( + resource_type=ResourceType.DATA_SOURCE, + resource_id=payload["data_source_id"], + relation=RelationType.KNOWLEDGE_GRAPH, + subject_type=ResourceType.KNOWLEDGE_GRAPH, + subject_id=payload["knowledge_graph_id"], + ), + DeleteRelationship( + resource_type=ResourceType.DATA_SOURCE, + resource_id=payload["data_source_id"], + relation=RelationType.TENANT, + subject_type=ResourceType.TENANT, + subject_id=payload["tenant_id"], + ), + ] + + def _translate_data_source_sync_requested( + self, + payload: dict[str, Any], + ) -> list[SpiceDBOperation]: + """Translate DataSourceSyncRequested - no SpiceDB changes needed. + + Sync requests do not affect authorization relationships. This event + exists for consumption by the Ingestion bounded context to trigger + data source synchronization workflows. + """ + return [] diff --git a/src/api/tests/unit/management/infrastructure/outbox/test_translator.py b/src/api/tests/unit/management/infrastructure/outbox/test_translator.py new file mode 100644 index 00000000..51750626 --- /dev/null +++ b/src/api/tests/unit/management/infrastructure/outbox/test_translator.py @@ -0,0 +1,651 @@ +"""Unit tests for ManagementEventTranslator (TDD - tests first). + +These tests verify that Management domain events are correctly translated into +SpiceDB relationship operations using type-safe enums. + +SpiceDB schema under test: + + definition knowledge_graph { + relation workspace: workspace + relation tenant: tenant + relation admin: user | group#member + relation editor: user | group#member + relation viewer: user | group#member + ... + } + + definition data_source { + relation knowledge_graph: knowledge_graph + relation tenant: tenant + ... + } +""" + +import pytest + +from management.infrastructure.outbox import ManagementEventTranslator +from shared_kernel.authorization.types import RelationType, ResourceType +from shared_kernel.outbox.operations import ( + DeleteRelationship, + DeleteRelationshipsByFilter, + WriteRelationship, +) + + +class TestManagementEventTranslatorSupportedEvents: + """Tests for supported event types.""" + + def test_supports_all_management_domain_events(self): + """Translator should support all Management domain event types.""" + translator = ManagementEventTranslator() + supported = translator.supported_event_types() + + assert "KnowledgeGraphCreated" in supported + assert "KnowledgeGraphUpdated" in supported + assert "KnowledgeGraphDeleted" in supported + assert "DataSourceCreated" in supported + assert "DataSourceUpdated" in supported + assert "DataSourceDeleted" in supported + assert "DataSourceSyncRequested" in supported + + def test_supports_exactly_seven_event_types(self): + """Translator should support exactly 7 event types.""" + translator = ManagementEventTranslator() + supported = translator.supported_event_types() + + assert len(supported) == 7 + + +class TestManagementEventTranslatorKnowledgeGraphCreated: + """Tests for KnowledgeGraphCreated translation. + + SpiceDB relationships created: + - knowledge_graph:#workspace@workspace: + - knowledge_graph:#tenant@tenant: + """ + + def test_translates_to_workspace_and_tenant_relationships(self): + """KnowledgeGraphCreated should produce workspace and tenant writes.""" + translator = ManagementEventTranslator() + payload = { + "knowledge_graph_id": "01ARZCX0P0HZGQP3MZXQQ0NNZZ", + "tenant_id": "01ARZCX0P0HZGQP3MZXQQ0NNYY", + "workspace_id": "01ARZCX0P0HZGQP3MZXQQ0NNXX", + "name": "My Knowledge Graph", + "description": "A test knowledge graph", + "occurred_at": "2026-01-08T12:00:00+00:00", + "created_by": "01ARZCX0P0HZGQP3MZXQQ0NNWW", + } + + operations = translator.translate("KnowledgeGraphCreated", payload) + + assert len(operations) == 2 + assert all(isinstance(op, WriteRelationship) for op in operations) + + def test_first_operation_is_workspace_relationship(self): + """First operation should write knowledge_graph#workspace@workspace.""" + translator = ManagementEventTranslator() + payload = { + "knowledge_graph_id": "01ARZCX0P0HZGQP3MZXQQ0NNZZ", + "tenant_id": "01ARZCX0P0HZGQP3MZXQQ0NNYY", + "workspace_id": "01ARZCX0P0HZGQP3MZXQQ0NNXX", + "name": "My Knowledge Graph", + "description": "A test knowledge graph", + "occurred_at": "2026-01-08T12:00:00+00:00", + "created_by": "01ARZCX0P0HZGQP3MZXQQ0NNWW", + } + + operations = translator.translate("KnowledgeGraphCreated", payload) + + workspace_op = operations[0] + assert isinstance(workspace_op, WriteRelationship) + assert workspace_op.resource_type == ResourceType.KNOWLEDGE_GRAPH + assert workspace_op.resource_id == "01ARZCX0P0HZGQP3MZXQQ0NNZZ" + assert workspace_op.relation == RelationType.WORKSPACE + assert workspace_op.subject_type == ResourceType.WORKSPACE + assert workspace_op.subject_id == "01ARZCX0P0HZGQP3MZXQQ0NNXX" + + def test_second_operation_is_tenant_relationship(self): + """Second operation should write knowledge_graph#tenant@tenant.""" + translator = ManagementEventTranslator() + payload = { + "knowledge_graph_id": "01ARZCX0P0HZGQP3MZXQQ0NNZZ", + "tenant_id": "01ARZCX0P0HZGQP3MZXQQ0NNYY", + "workspace_id": "01ARZCX0P0HZGQP3MZXQQ0NNXX", + "name": "My Knowledge Graph", + "description": "A test knowledge graph", + "occurred_at": "2026-01-08T12:00:00+00:00", + "created_by": "01ARZCX0P0HZGQP3MZXQQ0NNWW", + } + + operations = translator.translate("KnowledgeGraphCreated", payload) + + tenant_op = operations[1] + assert isinstance(tenant_op, WriteRelationship) + assert tenant_op.resource_type == ResourceType.KNOWLEDGE_GRAPH + assert tenant_op.resource_id == "01ARZCX0P0HZGQP3MZXQQ0NNZZ" + assert tenant_op.relation == RelationType.TENANT + assert tenant_op.subject_type == ResourceType.TENANT + assert tenant_op.subject_id == "01ARZCX0P0HZGQP3MZXQQ0NNYY" + + def test_formatted_resource_and_subject_strings(self): + """Operations should format resource and subject as type:id strings.""" + translator = ManagementEventTranslator() + payload = { + "knowledge_graph_id": "01ARZCX0P0HZGQP3MZXQQ0NNZZ", + "tenant_id": "01ARZCX0P0HZGQP3MZXQQ0NNYY", + "workspace_id": "01ARZCX0P0HZGQP3MZXQQ0NNXX", + "name": "My Knowledge Graph", + "description": "A test knowledge graph", + "occurred_at": "2026-01-08T12:00:00+00:00", + "created_by": "01ARZCX0P0HZGQP3MZXQQ0NNWW", + } + + operations = translator.translate("KnowledgeGraphCreated", payload) + + assert operations[0].resource == "knowledge_graph:01ARZCX0P0HZGQP3MZXQQ0NNZZ" + assert operations[0].subject == "workspace:01ARZCX0P0HZGQP3MZXQQ0NNXX" + assert operations[0].relation_name == "workspace" + + assert operations[1].resource == "knowledge_graph:01ARZCX0P0HZGQP3MZXQQ0NNZZ" + assert operations[1].subject == "tenant:01ARZCX0P0HZGQP3MZXQQ0NNYY" + assert operations[1].relation_name == "tenant" + + +class TestManagementEventTranslatorKnowledgeGraphUpdated: + """Tests for KnowledgeGraphUpdated translation. + + KnowledgeGraphUpdated is a no-op for SpiceDB — name/description + changes do not affect authorization relationships. + """ + + def test_returns_empty_list(self): + """KnowledgeGraphUpdated should produce no SpiceDB operations.""" + translator = ManagementEventTranslator() + payload = { + "knowledge_graph_id": "01ARZCX0P0HZGQP3MZXQQ0NNZZ", + "tenant_id": "01ARZCX0P0HZGQP3MZXQQ0NNYY", + "name": "Updated Name", + "description": "Updated description", + "occurred_at": "2026-01-08T12:00:00+00:00", + "updated_by": "01ARZCX0P0HZGQP3MZXQQ0NNWW", + } + + operations = translator.translate("KnowledgeGraphUpdated", payload) + + assert operations == [] + + def test_handler_is_registered(self): + """KnowledgeGraphUpdated should be in supported event types.""" + translator = ManagementEventTranslator() + assert "KnowledgeGraphUpdated" in translator.supported_event_types() + + +class TestManagementEventTranslatorKnowledgeGraphDeleted: + """Tests for KnowledgeGraphDeleted translation. + + SpiceDB cleanup operations: + 1. DeleteRelationship: knowledge_graph:#workspace@workspace: + 2. DeleteRelationship: knowledge_graph:#tenant@tenant: + 3. DeleteRelationshipsByFilter: knowledge_graph:#admin (any subject) + 4. DeleteRelationshipsByFilter: knowledge_graph:#editor (any subject) + 5. DeleteRelationshipsByFilter: knowledge_graph:#viewer (any subject) + """ + + def test_produces_five_operations(self): + """KnowledgeGraphDeleted should produce 5 cleanup operations.""" + translator = ManagementEventTranslator() + payload = { + "knowledge_graph_id": "01ARZCX0P0HZGQP3MZXQQ0NNZZ", + "tenant_id": "01ARZCX0P0HZGQP3MZXQQ0NNYY", + "workspace_id": "01ARZCX0P0HZGQP3MZXQQ0NNXX", + "occurred_at": "2026-01-08T12:00:00+00:00", + "deleted_by": "01ARZCX0P0HZGQP3MZXQQ0NNWW", + } + + operations = translator.translate("KnowledgeGraphDeleted", payload) + + assert len(operations) == 5 + + def test_first_operation_deletes_workspace_relationship(self): + """First operation should delete knowledge_graph#workspace@workspace.""" + translator = ManagementEventTranslator() + payload = { + "knowledge_graph_id": "01ARZCX0P0HZGQP3MZXQQ0NNZZ", + "tenant_id": "01ARZCX0P0HZGQP3MZXQQ0NNYY", + "workspace_id": "01ARZCX0P0HZGQP3MZXQQ0NNXX", + "occurred_at": "2026-01-08T12:00:00+00:00", + "deleted_by": "01ARZCX0P0HZGQP3MZXQQ0NNWW", + } + + operations = translator.translate("KnowledgeGraphDeleted", payload) + + workspace_delete = operations[0] + assert isinstance(workspace_delete, DeleteRelationship) + assert workspace_delete.resource_type == ResourceType.KNOWLEDGE_GRAPH + assert workspace_delete.resource_id == "01ARZCX0P0HZGQP3MZXQQ0NNZZ" + assert workspace_delete.relation == RelationType.WORKSPACE + assert workspace_delete.subject_type == ResourceType.WORKSPACE + assert workspace_delete.subject_id == "01ARZCX0P0HZGQP3MZXQQ0NNXX" + + def test_second_operation_deletes_tenant_relationship(self): + """Second operation should delete knowledge_graph#tenant@tenant.""" + translator = ManagementEventTranslator() + payload = { + "knowledge_graph_id": "01ARZCX0P0HZGQP3MZXQQ0NNZZ", + "tenant_id": "01ARZCX0P0HZGQP3MZXQQ0NNYY", + "workspace_id": "01ARZCX0P0HZGQP3MZXQQ0NNXX", + "occurred_at": "2026-01-08T12:00:00+00:00", + "deleted_by": "01ARZCX0P0HZGQP3MZXQQ0NNWW", + } + + operations = translator.translate("KnowledgeGraphDeleted", payload) + + tenant_delete = operations[1] + assert isinstance(tenant_delete, DeleteRelationship) + assert tenant_delete.resource_type == ResourceType.KNOWLEDGE_GRAPH + assert tenant_delete.resource_id == "01ARZCX0P0HZGQP3MZXQQ0NNZZ" + assert tenant_delete.relation == RelationType.TENANT + assert tenant_delete.subject_type == ResourceType.TENANT + assert tenant_delete.subject_id == "01ARZCX0P0HZGQP3MZXQQ0NNYY" + + def test_third_operation_filters_admin_grants(self): + """Third operation should filter-delete all admin grants on the KG.""" + translator = ManagementEventTranslator() + payload = { + "knowledge_graph_id": "01ARZCX0P0HZGQP3MZXQQ0NNZZ", + "tenant_id": "01ARZCX0P0HZGQP3MZXQQ0NNYY", + "workspace_id": "01ARZCX0P0HZGQP3MZXQQ0NNXX", + "occurred_at": "2026-01-08T12:00:00+00:00", + "deleted_by": "01ARZCX0P0HZGQP3MZXQQ0NNWW", + } + + operations = translator.translate("KnowledgeGraphDeleted", payload) + + admin_filter = operations[2] + assert isinstance(admin_filter, DeleteRelationshipsByFilter) + assert admin_filter.resource_type == ResourceType.KNOWLEDGE_GRAPH + assert admin_filter.resource_id == "01ARZCX0P0HZGQP3MZXQQ0NNZZ" + assert admin_filter.relation == RelationType.ADMIN + + def test_fourth_operation_filters_editor_grants(self): + """Fourth operation should filter-delete all editor grants on the KG.""" + translator = ManagementEventTranslator() + payload = { + "knowledge_graph_id": "01ARZCX0P0HZGQP3MZXQQ0NNZZ", + "tenant_id": "01ARZCX0P0HZGQP3MZXQQ0NNYY", + "workspace_id": "01ARZCX0P0HZGQP3MZXQQ0NNXX", + "occurred_at": "2026-01-08T12:00:00+00:00", + "deleted_by": "01ARZCX0P0HZGQP3MZXQQ0NNWW", + } + + operations = translator.translate("KnowledgeGraphDeleted", payload) + + editor_filter = operations[3] + assert isinstance(editor_filter, DeleteRelationshipsByFilter) + assert editor_filter.resource_type == ResourceType.KNOWLEDGE_GRAPH + assert editor_filter.resource_id == "01ARZCX0P0HZGQP3MZXQQ0NNZZ" + assert editor_filter.relation == RelationType.EDITOR + + def test_fifth_operation_filters_viewer_grants(self): + """Fifth operation should filter-delete all viewer grants on the KG.""" + translator = ManagementEventTranslator() + payload = { + "knowledge_graph_id": "01ARZCX0P0HZGQP3MZXQQ0NNZZ", + "tenant_id": "01ARZCX0P0HZGQP3MZXQQ0NNYY", + "workspace_id": "01ARZCX0P0HZGQP3MZXQQ0NNXX", + "occurred_at": "2026-01-08T12:00:00+00:00", + "deleted_by": "01ARZCX0P0HZGQP3MZXQQ0NNWW", + } + + operations = translator.translate("KnowledgeGraphDeleted", payload) + + viewer_filter = operations[4] + assert isinstance(viewer_filter, DeleteRelationshipsByFilter) + assert viewer_filter.resource_type == ResourceType.KNOWLEDGE_GRAPH + assert viewer_filter.resource_id == "01ARZCX0P0HZGQP3MZXQQ0NNZZ" + assert viewer_filter.relation == RelationType.VIEWER + + def test_order_is_direct_deletes_then_filter_deletes(self): + """Direct relationship deletes should precede filter-based deletes.""" + translator = ManagementEventTranslator() + payload = { + "knowledge_graph_id": "01ARZCX0P0HZGQP3MZXQQ0NNZZ", + "tenant_id": "01ARZCX0P0HZGQP3MZXQQ0NNYY", + "workspace_id": "01ARZCX0P0HZGQP3MZXQQ0NNXX", + "occurred_at": "2026-01-08T12:00:00+00:00", + "deleted_by": "01ARZCX0P0HZGQP3MZXQQ0NNWW", + } + + operations = translator.translate("KnowledgeGraphDeleted", payload) + + # First two should be direct DeleteRelationship + assert isinstance(operations[0], DeleteRelationship) + assert isinstance(operations[1], DeleteRelationship) + + # Last three should be DeleteRelationshipsByFilter + assert isinstance(operations[2], DeleteRelationshipsByFilter) + assert isinstance(operations[3], DeleteRelationshipsByFilter) + assert isinstance(operations[4], DeleteRelationshipsByFilter) + + def test_filter_deletes_have_no_subject_constraints(self): + """Filter-based deletes should not specify subject type or ID. + + This ensures all grants are cleaned up regardless of whether + the subject is a user or group#member. + """ + translator = ManagementEventTranslator() + payload = { + "knowledge_graph_id": "01ARZCX0P0HZGQP3MZXQQ0NNZZ", + "tenant_id": "01ARZCX0P0HZGQP3MZXQQ0NNYY", + "workspace_id": "01ARZCX0P0HZGQP3MZXQQ0NNXX", + "occurred_at": "2026-01-08T12:00:00+00:00", + "deleted_by": "01ARZCX0P0HZGQP3MZXQQ0NNWW", + } + + operations = translator.translate("KnowledgeGraphDeleted", payload) + + for filter_op in operations[2:]: + assert isinstance(filter_op, DeleteRelationshipsByFilter) + assert filter_op.subject_type is None + assert filter_op.subject_id is None + + +class TestManagementEventTranslatorDataSourceCreated: + """Tests for DataSourceCreated translation. + + SpiceDB relationships created: + - data_source:#knowledge_graph@knowledge_graph: + - data_source:#tenant@tenant: + """ + + def test_translates_to_knowledge_graph_and_tenant_relationships(self): + """DataSourceCreated should produce knowledge_graph and tenant writes.""" + translator = ManagementEventTranslator() + payload = { + "data_source_id": "01ARZCX0P0HZGQP3MZXQQ0NNZZ", + "knowledge_graph_id": "01ARZCX0P0HZGQP3MZXQQ0NNYY", + "tenant_id": "01ARZCX0P0HZGQP3MZXQQ0NNXX", + "name": "GitHub Adapter", + "adapter_type": "github", + "occurred_at": "2026-01-08T12:00:00+00:00", + "created_by": "01ARZCX0P0HZGQP3MZXQQ0NNWW", + } + + operations = translator.translate("DataSourceCreated", payload) + + assert len(operations) == 2 + assert all(isinstance(op, WriteRelationship) for op in operations) + + def test_first_operation_is_knowledge_graph_relationship(self): + """First operation should write data_source#knowledge_graph@knowledge_graph.""" + translator = ManagementEventTranslator() + payload = { + "data_source_id": "01ARZCX0P0HZGQP3MZXQQ0NNZZ", + "knowledge_graph_id": "01ARZCX0P0HZGQP3MZXQQ0NNYY", + "tenant_id": "01ARZCX0P0HZGQP3MZXQQ0NNXX", + "name": "GitHub Adapter", + "adapter_type": "github", + "occurred_at": "2026-01-08T12:00:00+00:00", + "created_by": "01ARZCX0P0HZGQP3MZXQQ0NNWW", + } + + operations = translator.translate("DataSourceCreated", payload) + + kg_op = operations[0] + assert isinstance(kg_op, WriteRelationship) + assert kg_op.resource_type == ResourceType.DATA_SOURCE + assert kg_op.resource_id == "01ARZCX0P0HZGQP3MZXQQ0NNZZ" + assert kg_op.relation == RelationType.KNOWLEDGE_GRAPH + assert kg_op.subject_type == ResourceType.KNOWLEDGE_GRAPH + assert kg_op.subject_id == "01ARZCX0P0HZGQP3MZXQQ0NNYY" + + def test_second_operation_is_tenant_relationship(self): + """Second operation should write data_source#tenant@tenant.""" + translator = ManagementEventTranslator() + payload = { + "data_source_id": "01ARZCX0P0HZGQP3MZXQQ0NNZZ", + "knowledge_graph_id": "01ARZCX0P0HZGQP3MZXQQ0NNYY", + "tenant_id": "01ARZCX0P0HZGQP3MZXQQ0NNXX", + "name": "GitHub Adapter", + "adapter_type": "github", + "occurred_at": "2026-01-08T12:00:00+00:00", + "created_by": "01ARZCX0P0HZGQP3MZXQQ0NNWW", + } + + operations = translator.translate("DataSourceCreated", payload) + + tenant_op = operations[1] + assert isinstance(tenant_op, WriteRelationship) + assert tenant_op.resource_type == ResourceType.DATA_SOURCE + assert tenant_op.resource_id == "01ARZCX0P0HZGQP3MZXQQ0NNZZ" + assert tenant_op.relation == RelationType.TENANT + assert tenant_op.subject_type == ResourceType.TENANT + assert tenant_op.subject_id == "01ARZCX0P0HZGQP3MZXQQ0NNXX" + + def test_formatted_resource_and_subject_strings(self): + """Operations should format resource and subject as type:id strings.""" + translator = ManagementEventTranslator() + payload = { + "data_source_id": "01ARZCX0P0HZGQP3MZXQQ0NNZZ", + "knowledge_graph_id": "01ARZCX0P0HZGQP3MZXQQ0NNYY", + "tenant_id": "01ARZCX0P0HZGQP3MZXQQ0NNXX", + "name": "GitHub Adapter", + "adapter_type": "github", + "occurred_at": "2026-01-08T12:00:00+00:00", + "created_by": "01ARZCX0P0HZGQP3MZXQQ0NNWW", + } + + operations = translator.translate("DataSourceCreated", payload) + + assert operations[0].resource == "data_source:01ARZCX0P0HZGQP3MZXQQ0NNZZ" + assert operations[0].subject == "knowledge_graph:01ARZCX0P0HZGQP3MZXQQ0NNYY" + assert operations[0].relation_name == "knowledge_graph" + + assert operations[1].resource == "data_source:01ARZCX0P0HZGQP3MZXQQ0NNZZ" + assert operations[1].subject == "tenant:01ARZCX0P0HZGQP3MZXQQ0NNXX" + assert operations[1].relation_name == "tenant" + + +class TestManagementEventTranslatorDataSourceUpdated: + """Tests for DataSourceUpdated translation. + + DataSourceUpdated is a no-op for SpiceDB — connection configuration + changes do not affect authorization relationships. + """ + + def test_returns_empty_list(self): + """DataSourceUpdated should produce no SpiceDB operations.""" + translator = ManagementEventTranslator() + payload = { + "data_source_id": "01ARZCX0P0HZGQP3MZXQQ0NNZZ", + "knowledge_graph_id": "01ARZCX0P0HZGQP3MZXQQ0NNYY", + "tenant_id": "01ARZCX0P0HZGQP3MZXQQ0NNXX", + "name": "Updated Adapter", + "occurred_at": "2026-01-08T12:00:00+00:00", + "updated_by": "01ARZCX0P0HZGQP3MZXQQ0NNWW", + } + + operations = translator.translate("DataSourceUpdated", payload) + + assert operations == [] + + def test_handler_is_registered(self): + """DataSourceUpdated should be in supported event types.""" + translator = ManagementEventTranslator() + assert "DataSourceUpdated" in translator.supported_event_types() + + +class TestManagementEventTranslatorDataSourceDeleted: + """Tests for DataSourceDeleted translation. + + SpiceDB relationships deleted: + - data_source:#knowledge_graph@knowledge_graph: + - data_source:#tenant@tenant: + """ + + def test_produces_two_delete_operations(self): + """DataSourceDeleted should produce 2 delete operations.""" + translator = ManagementEventTranslator() + payload = { + "data_source_id": "01ARZCX0P0HZGQP3MZXQQ0NNZZ", + "knowledge_graph_id": "01ARZCX0P0HZGQP3MZXQQ0NNYY", + "tenant_id": "01ARZCX0P0HZGQP3MZXQQ0NNXX", + "occurred_at": "2026-01-08T12:00:00+00:00", + "deleted_by": "01ARZCX0P0HZGQP3MZXQQ0NNWW", + } + + operations = translator.translate("DataSourceDeleted", payload) + + assert len(operations) == 2 + assert all(isinstance(op, DeleteRelationship) for op in operations) + + def test_first_operation_deletes_knowledge_graph_relationship(self): + """First operation should delete data_source#knowledge_graph@knowledge_graph.""" + translator = ManagementEventTranslator() + payload = { + "data_source_id": "01ARZCX0P0HZGQP3MZXQQ0NNZZ", + "knowledge_graph_id": "01ARZCX0P0HZGQP3MZXQQ0NNYY", + "tenant_id": "01ARZCX0P0HZGQP3MZXQQ0NNXX", + "occurred_at": "2026-01-08T12:00:00+00:00", + "deleted_by": "01ARZCX0P0HZGQP3MZXQQ0NNWW", + } + + operations = translator.translate("DataSourceDeleted", payload) + + kg_delete = operations[0] + assert isinstance(kg_delete, DeleteRelationship) + assert kg_delete.resource_type == ResourceType.DATA_SOURCE + assert kg_delete.resource_id == "01ARZCX0P0HZGQP3MZXQQ0NNZZ" + assert kg_delete.relation == RelationType.KNOWLEDGE_GRAPH + assert kg_delete.subject_type == ResourceType.KNOWLEDGE_GRAPH + assert kg_delete.subject_id == "01ARZCX0P0HZGQP3MZXQQ0NNYY" + + def test_second_operation_deletes_tenant_relationship(self): + """Second operation should delete data_source#tenant@tenant.""" + translator = ManagementEventTranslator() + payload = { + "data_source_id": "01ARZCX0P0HZGQP3MZXQQ0NNZZ", + "knowledge_graph_id": "01ARZCX0P0HZGQP3MZXQQ0NNYY", + "tenant_id": "01ARZCX0P0HZGQP3MZXQQ0NNXX", + "occurred_at": "2026-01-08T12:00:00+00:00", + "deleted_by": "01ARZCX0P0HZGQP3MZXQQ0NNWW", + } + + operations = translator.translate("DataSourceDeleted", payload) + + tenant_delete = operations[1] + assert isinstance(tenant_delete, DeleteRelationship) + assert tenant_delete.resource_type == ResourceType.DATA_SOURCE + assert tenant_delete.resource_id == "01ARZCX0P0HZGQP3MZXQQ0NNZZ" + assert tenant_delete.relation == RelationType.TENANT + assert tenant_delete.subject_type == ResourceType.TENANT + assert tenant_delete.subject_id == "01ARZCX0P0HZGQP3MZXQQ0NNXX" + + def test_formatted_resource_and_subject_strings(self): + """Delete operations should format resource and subject correctly.""" + translator = ManagementEventTranslator() + payload = { + "data_source_id": "01ARZCX0P0HZGQP3MZXQQ0NNZZ", + "knowledge_graph_id": "01ARZCX0P0HZGQP3MZXQQ0NNYY", + "tenant_id": "01ARZCX0P0HZGQP3MZXQQ0NNXX", + "occurred_at": "2026-01-08T12:00:00+00:00", + "deleted_by": "01ARZCX0P0HZGQP3MZXQQ0NNWW", + } + + operations = translator.translate("DataSourceDeleted", payload) + + assert operations[0].resource == "data_source:01ARZCX0P0HZGQP3MZXQQ0NNZZ" + assert operations[0].subject == "knowledge_graph:01ARZCX0P0HZGQP3MZXQQ0NNYY" + assert operations[0].relation_name == "knowledge_graph" + + assert operations[1].resource == "data_source:01ARZCX0P0HZGQP3MZXQQ0NNZZ" + assert operations[1].subject == "tenant:01ARZCX0P0HZGQP3MZXQQ0NNXX" + assert operations[1].relation_name == "tenant" + + +class TestManagementEventTranslatorDataSourceSyncRequested: + """Tests for DataSourceSyncRequested translation. + + DataSourceSyncRequested is a no-op for SpiceDB — sync requests + do not affect authorization relationships. + """ + + def test_returns_empty_list(self): + """DataSourceSyncRequested should produce no SpiceDB operations.""" + translator = ManagementEventTranslator() + payload = { + "data_source_id": "01ARZCX0P0HZGQP3MZXQQ0NNZZ", + "knowledge_graph_id": "01ARZCX0P0HZGQP3MZXQQ0NNYY", + "tenant_id": "01ARZCX0P0HZGQP3MZXQQ0NNXX", + "occurred_at": "2026-01-08T12:00:00+00:00", + "requested_by": "01ARZCX0P0HZGQP3MZXQQ0NNWW", + } + + operations = translator.translate("DataSourceSyncRequested", payload) + + assert operations == [] + + def test_handler_is_registered(self): + """DataSourceSyncRequested should be in supported event types.""" + translator = ManagementEventTranslator() + assert "DataSourceSyncRequested" in translator.supported_event_types() + + +class TestManagementEventTranslatorValidation: + """Tests for translator handler validation. + + The translator validates at initialization that every event in the + DomainEvent union type has a corresponding handler method. + """ + + def test_translator_is_instantiable(self): + """ManagementEventTranslator should instantiate without error. + + This implicitly verifies _validate_handlers passes — all 7 + domain events have registered handlers. + """ + translator = ManagementEventTranslator() + assert translator is not None + + def test_supported_event_types_returns_frozenset(self): + """supported_event_types should return a frozenset.""" + translator = ManagementEventTranslator() + supported = translator.supported_event_types() + assert isinstance(supported, frozenset) + + def test_raises_value_error_for_unknown_event_type(self): + """Translator should raise ValueError for unknown event types.""" + translator = ManagementEventTranslator() + + with pytest.raises(ValueError) as exc_info: + translator.translate("UnknownEvent", {}) + + assert "Unknown event type" in str(exc_info.value) + + +class TestManagementEventTranslatorErrors: + """Tests for error handling.""" + + def test_raises_on_unsupported_event_type(self): + """Translator should raise ValueError for unknown event types.""" + translator = ManagementEventTranslator() + + with pytest.raises(ValueError) as exc_info: + translator.translate("SomeRandomEvent", {}) + + assert "Unknown event type" in str(exc_info.value) + + def test_raises_on_iam_event_type(self): + """Translator should raise ValueError for IAM events (wrong context).""" + translator = ManagementEventTranslator() + + with pytest.raises(ValueError): + translator.translate("GroupCreated", {}) + + def test_raises_on_empty_event_type(self): + """Translator should raise ValueError for empty event type string.""" + translator = ManagementEventTranslator() + + with pytest.raises(ValueError): + translator.translate("", {})