From 4b1c81eadca82f32fde77f3991062b076e7789a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Mar 2026 23:40:10 +0000 Subject: [PATCH 1/2] feat: Add DSAR self-service endpoints for CIRISLens trace deletion Add /v1/my-data/* endpoints enabling users to view their hashed agent ID (lens identifier), manage accord metrics settings, and request deletion of their traces from CIRISLens. Hook consent revocation to automatically queue a lens deletion request. New endpoints: - GET /v1/my-data/lens-identifier - View agent_id_hash and consent status - DELETE /v1/my-data/lens-traces - Request trace deletion from CIRISLens - GET /v1/my-data/accord-settings - View adapter settings - PUT /v1/my-data/accord-settings - Update trace level and consent https://claude.ai/code/session_01CWwK4VqNm8CyfRSysVELuh --- .../ciris_accord_metrics/adapter.py | 10 +- .../ciris_accord_metrics/services.py | 26 + ciris_engine/logic/adapters/api/app.py | 2 + .../logic/adapters/api/routes/__init__.py | 2 + .../logic/adapters/api/routes/my_data.py | 494 ++++++++++++++++++ tests/adapters/api/test_my_data.py | 327 ++++++++++++ 6 files changed, 859 insertions(+), 2 deletions(-) create mode 100644 ciris_engine/logic/adapters/api/routes/my_data.py create mode 100644 tests/adapters/api/test_my_data.py diff --git a/ciris_adapters/ciris_accord_metrics/adapter.py b/ciris_adapters/ciris_accord_metrics/adapter.py index 379372bbf..7ad0fa18f 100644 --- a/ciris_adapters/ciris_accord_metrics/adapter.py +++ b/ciris_adapters/ciris_accord_metrics/adapter.py @@ -287,13 +287,17 @@ def get_status(self) -> RuntimeAdapterStatus: # Consent Management API # ========================================================================= - def update_consent(self, consent_given: bool) -> None: + def update_consent(self, consent_given: bool, request_lens_deletion: bool = False) -> None: """Update consent state. - This is called by the setup wizard when consent is granted/revoked. + This is called by the setup wizard or DSAR self-service when consent is granted/revoked. + + When consent is revoked and request_lens_deletion is True, a deletion request + is queued to be sent to CIRISLens on the next flush cycle. Args: consent_given: Whether user has consented + request_lens_deletion: If True and revoking consent, queue a lens deletion request """ self._consent_given = consent_given self._consent_timestamp = datetime.now(timezone.utc).isoformat() @@ -304,6 +308,8 @@ def update_consent(self, consent_given: bool) -> None: logger.info(f"Consent GRANTED for accord metrics collection " f"at {self._consent_timestamp}") else: logger.info(f"Consent REVOKED for accord metrics collection " f"at {self._consent_timestamp}") + if request_lens_deletion: + self.metrics_service.queue_lens_deletion_on_revoke() def is_consent_given(self) -> bool: """Check if consent has been given. diff --git a/ciris_adapters/ciris_accord_metrics/services.py b/ciris_adapters/ciris_accord_metrics/services.py index b56995ed6..1a34c423c 100644 --- a/ciris_adapters/ciris_accord_metrics/services.py +++ b/ciris_adapters/ciris_accord_metrics/services.py @@ -1694,4 +1694,30 @@ def get_metrics(self) -> Dict[str, Any]: "traces_signed": self._traces_signed, "signer_key_id": self._signer.key_id, "has_signing_key": self._signer.has_signing_key, + "agent_id_hash": self._agent_id_hash, } + + def queue_lens_deletion_on_revoke(self) -> None: + """Queue a deletion event to be sent to CIRISLens. + + Called when consent is revoked to request removal of all traces + for this agent from the lens repository. Sends a disconnect event + with deletion_requested=True so the lens API knows to purge data. + """ + if not self._agent_id_hash: + logger.warning("Cannot queue lens deletion: no agent_id_hash set") + return + + deletion_event: Dict[str, Any] = { + "event_type": "consent_revoked_deletion_requested", + "timestamp": datetime.now(timezone.utc).isoformat(), + "agent_id_hash": self._agent_id_hash, + "deletion_requested": True, + "reason": "User revoked accord metrics consent via DSAR self-service", + } + + self._event_queue.append(deletion_event) + logger.info( + f"Queued lens deletion request for agent {self._agent_id_hash} " + f"(queue size: {len(self._event_queue)})" + ) diff --git a/ciris_engine/logic/adapters/api/app.py b/ciris_engine/logic/adapters/api/app.py index c87ebd656..6e01d5d62 100644 --- a/ciris_engine/logic/adapters/api/app.py +++ b/ciris_engine/logic/adapters/api/app.py @@ -26,6 +26,7 @@ dsar_multi_source, emergency, memory, + my_data, partnership, scheduler, setup, @@ -271,6 +272,7 @@ async def _strip_trailing_slash(request: Request, call_next: Callable[[Request], consent.router, dsar.router, dsar_multi_source.router, + my_data.router, connectors.router, tickets.router, scheduler.router, diff --git a/ciris_engine/logic/adapters/api/routes/__init__.py b/ciris_engine/logic/adapters/api/routes/__init__.py index dbf012d57..4f9ccfb72 100644 --- a/ciris_engine/logic/adapters/api/routes/__init__.py +++ b/ciris_engine/logic/adapters/api/routes/__init__.py @@ -17,6 +17,7 @@ dsar_multi_source, emergency, memory, + my_data, partnership, scheduler, setup, @@ -44,6 +45,7 @@ "dsar_multi_source", "emergency", "memory", + "my_data", "partnership", "scheduler", "setup", diff --git a/ciris_engine/logic/adapters/api/routes/my_data.py b/ciris_engine/logic/adapters/api/routes/my_data.py new file mode 100644 index 000000000..c00e9f146 --- /dev/null +++ b/ciris_engine/logic/adapters/api/routes/my_data.py @@ -0,0 +1,494 @@ +""" +My Data endpoints for DSAR self-service. + +Allows authenticated users to: +1. View their hashed agent ID (lens identifier) so they know which traces are theirs +2. View current accord metrics consent/settings status +3. Request deletion of their traces from CIRISLens +4. Update accord metrics settings (trace level, consent) + +These endpoints enable GDPR Article 17 (Right to Erasure) self-service +for data sent to the CIRISLens repository via the accord metrics adapter. +""" + +import hashlib +import logging +from datetime import datetime, timezone +from typing import Annotated, Any, Optional + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from pydantic import BaseModel, Field + +from ..auth import get_current_user +from ..models import StandardResponse, TokenData + +logger = logging.getLogger(__name__) + +CurrentUserDep = Annotated[TokenData, Depends(get_current_user)] + +router = APIRouter(prefix="/my-data", tags=["My Data"]) + + +# --------------------------------------------------------------------------- +# Schemas +# --------------------------------------------------------------------------- + + +class LensIdentifierResponse(BaseModel): + """Response containing the user's lens identifier and accord metrics status.""" + + agent_id_hash: str = Field(..., description="SHA-256 hash (first 16 hex chars) of agent ID used in CIRISLens traces") + agent_id: str = Field(..., description="Raw agent ID (so user can verify the hash)") + consent_given: bool = Field(..., description="Whether accord metrics consent is currently active") + consent_timestamp: Optional[str] = Field(None, description="When consent was last granted/revoked") + trace_level: Optional[str] = Field(None, description="Current trace detail level (generic/detailed/full_traces)") + traces_sent: int = Field(0, description="Approximate number of trace events sent this session") + endpoint_url: Optional[str] = Field(None, description="CIRISLens endpoint traces are sent to") + + +class LensDeletionRequest(BaseModel): + """Request to delete traces from CIRISLens.""" + + confirm: bool = Field( + ..., + description="Must be true to confirm deletion. This action is irreversible.", + ) + reason: Optional[str] = Field( + None, + description="Optional reason for deletion request", + max_length=500, + ) + + +class LensDeletionResponse(BaseModel): + """Response for a lens trace deletion request.""" + + agent_id_hash: str = Field(..., description="The hashed agent ID whose traces were requested for deletion") + status: str = Field(..., description="Status of the deletion request") + message: str = Field(..., description="Human-readable status message") + lens_request_accepted: bool = Field( + False, + description="Whether CIRISLens accepted the deletion request", + ) + local_consent_revoked: bool = Field( + False, + description="Whether local accord metrics consent was also revoked", + ) + + +class AccordSettingsUpdate(BaseModel): + """Request to update accord metrics settings.""" + + trace_level: Optional[str] = Field( + None, + description="Trace detail level: generic, detailed, or full_traces", + pattern="^(generic|detailed|full_traces)$", + ) + consent_given: Optional[bool] = Field( + None, + description="Update consent state. Setting to false stops all trace collection.", + ) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _compute_agent_id_hash(agent_id: str) -> str: + """Compute the same hash used by AccordMetricsService._anonymize_agent_id. + + Must stay in sync with ciris_adapters/ciris_accord_metrics/services.py. + """ + return hashlib.sha256(agent_id.encode()).hexdigest()[:16] + + +def _get_accord_adapter(request: Request) -> Any: + """Get the accord metrics adapter from app state, or None if not loaded.""" + runtime = getattr(request.app.state, "runtime", None) + if not runtime: + return None + + # Check loaded adapters for the accord metrics adapter + adapter_manager = getattr(runtime, "adapter_manager", None) + if not adapter_manager: + return None + + adapters = getattr(adapter_manager, "_adapters", {}) + for adapter in adapters.values(): + type_name = type(adapter).__name__ + if "AccordMetrics" in type_name: + return adapter + + return None + + +def _get_agent_id(request: Request) -> Optional[str]: + """Get the current agent ID from runtime.""" + runtime = getattr(request.app.state, "runtime", None) + if not runtime: + return None + + identity = getattr(runtime, "agent_identity", None) + if identity and hasattr(identity, "agent_id"): + return identity.agent_id + + # Legacy fallback + return getattr(runtime, "agent_id", None) + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + + +@router.get( + "/lens-identifier", + responses={ + 404: {"description": "Accord metrics adapter not loaded or agent ID unavailable"}, + }, +) +async def get_lens_identifier( + current_user: CurrentUserDep, + request: Request, +) -> StandardResponse: + """ + Get your CIRISLens trace identifier. + + Returns the hashed agent ID that your traces are stored under in CIRISLens, + along with current consent status and trace settings. Use this identifier + if you need to request deletion of your traces. + + This is a self-service endpoint — no admin approval needed. + """ + agent_id = _get_agent_id(request) + if not agent_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Agent identity not available. The agent may not be fully initialized.", + ) + + agent_id_hash = _compute_agent_id_hash(agent_id) + + # Get accord metrics adapter status if available + adapter = _get_accord_adapter(request) + consent_given = False + consent_timestamp = None + trace_level = None + traces_sent = 0 + endpoint_url = None + + if adapter and hasattr(adapter, "metrics_service"): + svc = adapter.metrics_service + metrics = svc.get_metrics() + consent_given = metrics.get("consent_given", False) + trace_level = metrics.get("trace_level") + traces_sent = metrics.get("events_sent", 0) + consent_timestamp = getattr(adapter, "_consent_timestamp", None) + endpoint_url = getattr(svc, "_endpoint_url", None) + elif adapter: + consent_given = getattr(adapter, "_consent_given", False) + consent_timestamp = getattr(adapter, "_consent_timestamp", None) + + data = LensIdentifierResponse( + agent_id_hash=agent_id_hash, + agent_id=agent_id, + consent_given=consent_given, + consent_timestamp=consent_timestamp, + trace_level=trace_level, + traces_sent=traces_sent, + endpoint_url=endpoint_url, + ) + + return StandardResponse( + success=True, + data=data.model_dump(), + message=( + f"Your traces in CIRISLens are stored under agent_id_hash: {agent_id_hash}. " + "Use DELETE /v1/my-data/lens-traces to request deletion." + ), + metadata={"timestamp": datetime.now(timezone.utc).isoformat()}, + ) + + +@router.delete( + "/lens-traces", + responses={ + 400: {"description": "Confirmation not provided"}, + 404: {"description": "Agent identity or accord adapter not available"}, + 502: {"description": "CIRISLens API rejected the deletion request"}, + }, +) +async def delete_lens_traces( + deletion: LensDeletionRequest, + current_user: CurrentUserDep, + request: Request, +) -> StandardResponse: + """ + Request deletion of your traces from CIRISLens. + + This endpoint: + 1. Computes your agent_id_hash from your authenticated identity + 2. Sends a signed deletion request to the CIRISLens API + 3. Revokes local accord metrics consent (stops future trace collection) + 4. Records the request in the audit trail + + The deletion is irreversible. CIRISLens will remove all traces + associated with your agent_id_hash. + + You must set confirm=true to proceed. + """ + if not deletion.confirm: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="You must set confirm=true to request trace deletion. This action is irreversible.", + ) + + agent_id = _get_agent_id(request) + if not agent_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Agent identity not available.", + ) + + agent_id_hash = _compute_agent_id_hash(agent_id) + adapter = _get_accord_adapter(request) + + # Step 1: Send deletion request to CIRISLens + lens_accepted = False + lens_message = "Accord metrics adapter not loaded — no traces to delete." + + if adapter and hasattr(adapter, "metrics_service"): + svc = adapter.metrics_service + lens_accepted, lens_message = await _send_lens_deletion_request(svc, agent_id_hash, deletion.reason) + + # Step 2: Revoke local consent (stops future collection) + local_revoked = False + if adapter: + adapter.update_consent(False) + local_revoked = True + logger.info(f"Local accord consent revoked for agent {agent_id_hash} via DSAR self-service") + + # Step 3: Audit trail + logger.info( + "DSAR lens deletion requested", + extra={ + "agent_id_hash": agent_id_hash, + "reason": deletion.reason or "not provided", + "lens_accepted": lens_accepted, + "local_revoked": local_revoked, + "username": current_user.username, + }, + ) + + data = LensDeletionResponse( + agent_id_hash=agent_id_hash, + status="accepted" if lens_accepted else "local_only", + message=lens_message, + lens_request_accepted=lens_accepted, + local_consent_revoked=local_revoked, + ) + + return StandardResponse( + success=True, + data=data.model_dump(), + message=( + "Deletion request processed. " + + ("CIRISLens accepted the request. " if lens_accepted else "") + + ("Local consent revoked — no further traces will be sent." if local_revoked else "") + ), + metadata={ + "timestamp": datetime.now(timezone.utc).isoformat(), + "agent_id_hash": agent_id_hash, + }, + ) + + +@router.get( + "/accord-settings", + responses={ + 404: {"description": "Accord metrics adapter not loaded"}, + }, +) +async def get_accord_settings( + current_user: CurrentUserDep, + request: Request, +) -> StandardResponse: + """ + Get current accord metrics adapter settings. + + Shows consent status, trace detail level, event counters, + and signing key information. + """ + adapter = _get_accord_adapter(request) + if not adapter: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Accord metrics adapter is not loaded. Load it with --adapter ciris_accord_metrics.", + ) + + agent_id = _get_agent_id(request) + agent_id_hash = _compute_agent_id_hash(agent_id) if agent_id else "unknown" + + settings: dict[str, Any] = { + "agent_id_hash": agent_id_hash, + } + + if hasattr(adapter, "metrics_service"): + metrics = adapter.metrics_service.get_metrics() + settings.update(metrics) + settings["endpoint_url"] = getattr(adapter.metrics_service, "_endpoint_url", None) + + settings["consent_given"] = getattr(adapter, "_consent_given", False) + settings["consent_timestamp"] = getattr(adapter, "_consent_timestamp", None) + + return StandardResponse( + success=True, + data=settings, + message="Accord metrics settings", + metadata={"timestamp": datetime.now(timezone.utc).isoformat()}, + ) + + +@router.put( + "/accord-settings", + responses={ + 400: {"description": "Invalid settings"}, + 404: {"description": "Accord metrics adapter not loaded"}, + }, +) +async def update_accord_settings( + settings: AccordSettingsUpdate, + current_user: CurrentUserDep, + request: Request, +) -> StandardResponse: + """ + Update accord metrics adapter settings. + + Allows changing: + - consent_given: Enable/disable trace collection + - trace_level: Change detail level (generic/detailed/full_traces) + + Changes take effect immediately for new traces. + """ + adapter = _get_accord_adapter(request) + if not adapter: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Accord metrics adapter is not loaded.", + ) + + changes: list[str] = [] + + if settings.consent_given is not None: + adapter.update_consent(settings.consent_given) + changes.append(f"consent_given={settings.consent_given}") + + if settings.trace_level is not None and hasattr(adapter, "metrics_service"): + svc = adapter.metrics_service + from ciris_adapters.ciris_accord_metrics.services import TraceDetailLevel + + try: + svc._trace_level = TraceDetailLevel(settings.trace_level) + changes.append(f"trace_level={settings.trace_level}") + except ValueError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid trace_level: {settings.trace_level}", + ) + + if not changes: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No settings provided to update.", + ) + + logger.info( + f"Accord settings updated by {current_user.username}: {', '.join(changes)}" + ) + + return StandardResponse( + success=True, + data={"changes": changes}, + message=f"Settings updated: {', '.join(changes)}", + metadata={"timestamp": datetime.now(timezone.utc).isoformat()}, + ) + + +# --------------------------------------------------------------------------- +# CIRISLens deletion request sender +# --------------------------------------------------------------------------- + + +async def _send_lens_deletion_request( + metrics_service: Any, + agent_id_hash: str, + reason: Optional[str], +) -> tuple[bool, str]: + """Send a deletion request to the CIRISLens API. + + Args: + metrics_service: The AccordMetricsService instance (has HTTP session + signer) + agent_id_hash: The hashed agent ID to delete traces for + reason: Optional reason for deletion + + Returns: + Tuple of (accepted: bool, message: str) + """ + import aiohttp + + endpoint_url = getattr(metrics_service, "_endpoint_url", None) + if not endpoint_url: + return False, "No CIRISLens endpoint configured." + + session = getattr(metrics_service, "_session", None) + if not session or session.closed: + return False, "CIRISLens HTTP session not available. Adapter may not be started." + + signer = getattr(metrics_service, "_signer", None) + + # Build deletion payload + import json + + payload = { + "agent_id_hash": agent_id_hash, + "request_type": "delete_all_traces", + "reason": reason or "User DSAR self-service request", + "requested_at": datetime.now(timezone.utc).isoformat(), + } + + # Sign the request if signer is available + if signer and hasattr(signer, "has_signing_key") and signer.has_signing_key: + content_bytes = json.dumps(payload, sort_keys=True).encode() + try: + import base64 + + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + + private_key = getattr(signer, "_private_key", None) + if private_key: + signature = private_key.sign(content_bytes) + payload["signature"] = base64.b64encode(signature).decode() + payload["signature_key_id"] = signer.key_id + except Exception as e: + logger.warning(f"Could not sign deletion request: {e}") + + url = f"{endpoint_url}/accord/dsar/delete" + + try: + async with session.post(url, json=payload) as response: + if response.status == 200: + return True, "CIRISLens accepted the deletion request. Traces will be removed." + elif response.status == 202: + return True, "CIRISLens queued the deletion request for processing." + elif response.status == 404: + return True, "No traces found for this agent_id_hash in CIRISLens (nothing to delete)." + else: + error_text = await response.text() + logger.error(f"CIRISLens deletion request failed: {response.status} - {error_text}") + return False, f"CIRISLens returned status {response.status}. Request logged locally for retry." + except aiohttp.ClientConnectorError: + return False, "Could not connect to CIRISLens. Deletion request saved locally for retry." + except Exception as e: + logger.error(f"Error sending deletion request to CIRISLens: {e}") + return False, f"Error contacting CIRISLens: {type(e).__name__}. Request saved locally for retry." diff --git a/tests/adapters/api/test_my_data.py b/tests/adapters/api/test_my_data.py new file mode 100644 index 000000000..e58c97f55 --- /dev/null +++ b/tests/adapters/api/test_my_data.py @@ -0,0 +1,327 @@ +""" +Tests for My Data DSAR self-service endpoints. + +Covers: +- GET /v1/my-data/lens-identifier - View hashed agent ID +- DELETE /v1/my-data/lens-traces - Request trace deletion from CIRISLens +- GET /v1/my-data/accord-settings - View accord adapter settings +- PUT /v1/my-data/accord-settings - Update accord adapter settings +""" + +import hashlib +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +import pytest +from fastapi.testclient import TestClient + +from ciris_engine.logic.adapters.api.app import create_app +from ciris_engine.logic.adapters.api.auth import get_current_user +from ciris_engine.logic.adapters.api.models import TokenData + + +async def _mock_admin_user(): + return TokenData(username="admin", email="admin@ciris.ai", role="ADMIN") + + +@pytest.fixture +def mock_runtime(): + """Create mock runtime with agent identity.""" + runtime = MagicMock() + runtime.agent_identity = MagicMock() + runtime.agent_identity.agent_id = "test-agent-001" + runtime.adapter_manager = MagicMock() + runtime.adapter_manager._adapters = {} + return runtime + + +@pytest.fixture +def mock_accord_adapter(): + """Create mock accord metrics adapter.""" + adapter = MagicMock() + adapter.__class__.__name__ = "AccordMetricsAdapter" + adapter._consent_given = True + adapter._consent_timestamp = "2026-01-15T10:00:00+00:00" + + svc = MagicMock() + svc.get_metrics.return_value = { + "consent_given": True, + "trace_level": "generic", + "events_received": 42, + "events_sent": 38, + "events_failed": 0, + "events_queued": 2, + "last_send_time": "2026-03-20T09:00:00+00:00", + "traces_active": 1, + "traces_completed": 37, + "traces_signed": 37, + "signer_key_id": "test-key-123", + "has_signing_key": True, + "agent_id_hash": hashlib.sha256(b"test-agent-001").hexdigest()[:16], + } + svc._endpoint_url = "https://lens.ciris-services-1.ai/lens-api/api/v1" + adapter.metrics_service = svc + + return adapter + + +@pytest.fixture +def app_with_runtime(mock_runtime, mock_accord_adapter): + """Create app with mock runtime and accord adapter.""" + app = create_app() + app.state.runtime = mock_runtime + + # Register adapter under a key that contains "AccordMetrics" + mock_runtime.adapter_manager._adapters = {"accord": mock_accord_adapter} + + app.dependency_overrides[get_current_user] = _mock_admin_user + return app + + +@pytest.fixture +def client(app_with_runtime): + return TestClient(app_with_runtime) + + +@pytest.fixture +def app_no_adapter(mock_runtime): + """Create app with runtime but NO accord adapter.""" + app = create_app() + app.state.runtime = mock_runtime + mock_runtime.adapter_manager._adapters = {} + app.dependency_overrides[get_current_user] = _mock_admin_user + return app + + +@pytest.fixture +def client_no_adapter(app_no_adapter): + return TestClient(app_no_adapter) + + +@pytest.fixture +def app_no_runtime(): + """Create app with NO runtime at all.""" + app = create_app() + app.dependency_overrides[get_current_user] = _mock_admin_user + return app + + +@pytest.fixture +def client_no_runtime(app_no_runtime): + return TestClient(app_no_runtime) + + +class TestLensIdentifier: + """Test GET /v1/my-data/lens-identifier.""" + + def test_returns_agent_id_hash(self, client): + response = client.get("/v1/my-data/lens-identifier") + assert response.status_code == 200 + + data = response.json()["data"] + expected_hash = hashlib.sha256(b"test-agent-001").hexdigest()[:16] + assert data["agent_id_hash"] == expected_hash + assert data["agent_id"] == "test-agent-001" + + def test_includes_consent_status(self, client): + response = client.get("/v1/my-data/lens-identifier") + data = response.json()["data"] + assert data["consent_given"] is True + assert data["consent_timestamp"] == "2026-01-15T10:00:00+00:00" + + def test_includes_trace_level(self, client): + response = client.get("/v1/my-data/lens-identifier") + data = response.json()["data"] + assert data["trace_level"] == "generic" + + def test_includes_traces_sent_count(self, client): + response = client.get("/v1/my-data/lens-identifier") + data = response.json()["data"] + assert data["traces_sent"] == 38 + + def test_includes_endpoint_url(self, client): + response = client.get("/v1/my-data/lens-identifier") + data = response.json()["data"] + assert "lens.ciris-services-1.ai" in data["endpoint_url"] + + def test_no_runtime_returns_404(self, client_no_runtime): + response = client_no_runtime.get("/v1/my-data/lens-identifier") + assert response.status_code == 404 + + def test_no_adapter_still_returns_hash(self, client_no_adapter): + """Even without adapter, should return the hash (user needs it for manual DSAR).""" + response = client_no_adapter.get("/v1/my-data/lens-identifier") + assert response.status_code == 200 + data = response.json()["data"] + assert data["agent_id_hash"] is not None + assert data["consent_given"] is False + + def test_hash_matches_accord_service_algorithm(self, client): + """Verify hash is computed identically to AccordMetricsService._anonymize_agent_id.""" + response = client.get("/v1/my-data/lens-identifier") + data = response.json()["data"] + + # Same algorithm as services.py:545 + expected = hashlib.sha256("test-agent-001".encode()).hexdigest()[:16] + assert data["agent_id_hash"] == expected + + +class TestDeleteLensTraces: + """Test DELETE /v1/my-data/lens-traces.""" + + def test_requires_confirmation(self, client): + response = client.request( + "DELETE", + "/v1/my-data/lens-traces", + json={"confirm": False}, + ) + assert response.status_code == 400 + + def test_successful_deletion_request(self, client, mock_accord_adapter): + # Mock the HTTP session for lens API call + mock_session = AsyncMock() + mock_response = AsyncMock() + mock_response.status = 200 + mock_response.text = AsyncMock(return_value="OK") + mock_session.post.return_value.__aenter__ = AsyncMock(return_value=mock_response) + mock_session.post.return_value.__aexit__ = AsyncMock(return_value=False) + mock_session.closed = False + mock_accord_adapter.metrics_service._session = mock_session + mock_accord_adapter.metrics_service._signer = MagicMock(has_signing_key=False) + + response = client.request( + "DELETE", + "/v1/my-data/lens-traces", + json={"confirm": True, "reason": "Testing deletion"}, + ) + + assert response.status_code == 200 + data = response.json()["data"] + assert data["local_consent_revoked"] is True + mock_accord_adapter.update_consent.assert_called_with(False) + + def test_deletion_without_adapter(self, client_no_adapter): + response = client_no_adapter.request( + "DELETE", + "/v1/my-data/lens-traces", + json={"confirm": True}, + ) + # Should still succeed - just notes no adapter + assert response.status_code == 200 + data = response.json()["data"] + assert data["local_consent_revoked"] is False + assert data["lens_request_accepted"] is False + + def test_no_runtime_returns_404(self, client_no_runtime): + response = client_no_runtime.request( + "DELETE", + "/v1/my-data/lens-traces", + json={"confirm": True}, + ) + assert response.status_code == 404 + + +class TestAccordSettings: + """Test GET/PUT /v1/my-data/accord-settings.""" + + def test_get_settings(self, client): + response = client.get("/v1/my-data/accord-settings") + assert response.status_code == 200 + + data = response.json()["data"] + assert data["consent_given"] is True + assert data["trace_level"] == "generic" + assert data["events_sent"] == 38 + assert data["agent_id_hash"] is not None + + def test_get_settings_no_adapter_returns_404(self, client_no_adapter): + response = client_no_adapter.get("/v1/my-data/accord-settings") + assert response.status_code == 404 + + def test_update_consent(self, client, mock_accord_adapter): + response = client.put( + "/v1/my-data/accord-settings", + json={"consent_given": False}, + ) + assert response.status_code == 200 + mock_accord_adapter.update_consent.assert_called_with(False) + + def test_update_trace_level(self, client, mock_accord_adapter): + response = client.put( + "/v1/my-data/accord-settings", + json={"trace_level": "detailed"}, + ) + assert response.status_code == 200 + assert "trace_level=detailed" in str(response.json()["data"]["changes"]) + + def test_invalid_trace_level_rejected(self, client): + response = client.put( + "/v1/my-data/accord-settings", + json={"trace_level": "invalid_level"}, + ) + assert response.status_code == 422 # Pydantic validation + + def test_empty_update_rejected(self, client): + response = client.put( + "/v1/my-data/accord-settings", + json={}, + ) + assert response.status_code == 400 + + def test_update_no_adapter_returns_404(self, client_no_adapter): + response = client_no_adapter.put( + "/v1/my-data/accord-settings", + json={"consent_given": True}, + ) + assert response.status_code == 404 + + +class TestHashConsistency: + """Verify the hash computation stays in sync with the accord metrics service.""" + + def test_hash_algorithm_matches_service(self): + """The hash in my_data.py must produce the same output as services.py.""" + from ciris_engine.logic.adapters.api.routes.my_data import _compute_agent_id_hash + + test_ids = [ + "test-agent-001", + "echo-speculative-4fc6ru", + "datum", + "a" * 100, + "", + ] + + for agent_id in test_ids: + expected = hashlib.sha256(agent_id.encode()).hexdigest()[:16] + assert _compute_agent_id_hash(agent_id) == expected + + +class TestConsentRevocationDeletionHook: + """Test that consent revocation can trigger lens deletion.""" + + def test_queue_lens_deletion_on_revoke(self): + """Test the service method that queues deletion when consent is revoked.""" + from ciris_adapters.ciris_accord_metrics.services import AccordMetricsService + + svc = AccordMetricsService.__new__(AccordMetricsService) + svc._agent_id_hash = "abc123def456" + svc._event_queue = [] + + svc.queue_lens_deletion_on_revoke() + + assert len(svc._event_queue) == 1 + event = svc._event_queue[0] + assert event["event_type"] == "consent_revoked_deletion_requested" + assert event["agent_id_hash"] == "abc123def456" + assert event["deletion_requested"] is True + + def test_queue_deletion_no_agent_id(self): + """Should not queue if no agent_id_hash is set.""" + from ciris_adapters.ciris_accord_metrics.services import AccordMetricsService + + svc = AccordMetricsService.__new__(AccordMetricsService) + svc._agent_id_hash = None + svc._event_queue = [] + + svc.queue_lens_deletion_on_revoke() + + assert len(svc._event_queue) == 0 From aeb656e3d53f636d0278a61d781576e04de496fd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Mar 2026 01:36:13 +0000 Subject: [PATCH 2/2] fix: Resolve three issues in DSAR my-data endpoints 1. _get_accord_adapter now uses RuntimeAdapterManager.loaded_adapters and unwraps AdapterInstance.adapter instead of looking at a non-existent _adapters dict. 2. When CIRISLens deletion request fails, consent revocation now passes request_lens_deletion=True so the deletion event is queued for retry on the next flush cycle instead of being silently dropped. 3. AccordMetricsService.set_consent(True) now initializes the HTTP session and periodic flush task if they weren't created at start time (adapter started without consent). Extracted _initialize_http_session() helper and reuse it in start(). https://claude.ai/code/session_01CWwK4VqNm8CyfRSysVELuh --- .../ciris_accord_metrics/services.py | 43 +++++-- .../logic/adapters/api/routes/my_data.py | 18 ++- tests/adapters/api/test_my_data.py | 115 ++++++++++++++---- 3 files changed, 139 insertions(+), 37 deletions(-) diff --git a/ciris_adapters/ciris_accord_metrics/services.py b/ciris_adapters/ciris_accord_metrics/services.py index 1a34c423c..94a85020e 100644 --- a/ciris_adapters/ciris_accord_metrics/services.py +++ b/ciris_adapters/ciris_accord_metrics/services.py @@ -597,16 +597,8 @@ async def start(self) -> None: logger.warning("=" * 70) return - # Initialize HTTP session - self._session = aiohttp.ClientSession( - timeout=aiohttp.ClientTimeout(total=30), - headers={ - "Content-Type": "application/json", - "User-Agent": "CIRIS-AccordMetrics/1.0", - }, - ) - - # Start flush task + # Initialize HTTP session and start flush task + self._initialize_http_session() self._flush_task = asyncio.create_task(self._periodic_flush()) logger.info("=" * 70) @@ -1646,6 +1638,11 @@ async def record_pdma_decision( def set_consent(self, consent_given: bool, timestamp: Optional[str] = None) -> None: """Update consent state. + When consent is granted and the HTTP session/flush task are not yet + initialized (adapter was started without consent), this method will + start them so collection begins immediately without requiring a + full adapter reload. + Args: consent_given: Whether consent is given timestamp: ISO timestamp when consent was given/revoked @@ -1655,9 +1652,35 @@ def set_consent(self, consent_given: bool, timestamp: Optional[str] = None) -> N if consent_given: logger.info(f"Consent granted for accord metrics at {self._consent_timestamp}") + # If the service was started without consent, the HTTP session and + # flush task were never created. Initialize them now so collection + # begins immediately. + if self._session is None or (hasattr(self._session, "closed") and self._session.closed): + self._initialize_http_session() + if self._flush_task is None or self._flush_task.done(): + self._flush_task = asyncio.create_task(self._periodic_flush()) + logger.info("Started periodic flush task after late consent grant") else: logger.info(f"Consent revoked for accord metrics at {self._consent_timestamp}") + def _initialize_http_session(self) -> None: + """Create the aiohttp session used to send events to CIRISLens. + + Safe to call multiple times — will only create a session if one + does not already exist (or the existing one is closed). + """ + if self._session is not None and not getattr(self._session, "closed", True): + return + + self._session = aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=30), + headers={ + "Content-Type": "application/json", + "User-Agent": "CIRIS-AccordMetrics/1.0", + }, + ) + logger.info(f"HTTP session initialized for {self._endpoint_url}") + def set_agent_id(self, agent_id: str) -> None: """Set and anonymize the agent ID. diff --git a/ciris_engine/logic/adapters/api/routes/my_data.py b/ciris_engine/logic/adapters/api/routes/my_data.py index c00e9f146..5212791f5 100644 --- a/ciris_engine/logic/adapters/api/routes/my_data.py +++ b/ciris_engine/logic/adapters/api/routes/my_data.py @@ -104,18 +104,24 @@ def _compute_agent_id_hash(agent_id: str) -> str: def _get_accord_adapter(request: Request) -> Any: - """Get the accord metrics adapter from app state, or None if not loaded.""" + """Get the accord metrics adapter from app state, or None if not loaded. + + RuntimeAdapterManager stores adapters in loaded_adapters: Dict[str, AdapterInstance] + where AdapterInstance.adapter is the actual adapter object. + """ runtime = getattr(request.app.state, "runtime", None) if not runtime: return None - # Check loaded adapters for the accord metrics adapter adapter_manager = getattr(runtime, "adapter_manager", None) if not adapter_manager: return None - adapters = getattr(adapter_manager, "_adapters", {}) - for adapter in adapters.values(): + # RuntimeAdapterManager.loaded_adapters is Dict[str, AdapterInstance] + loaded = getattr(adapter_manager, "loaded_adapters", {}) + for instance in loaded.values(): + # AdapterInstance wraps the actual adapter in .adapter + adapter = getattr(instance, "adapter", instance) type_name = type(adapter).__name__ if "AccordMetrics" in type_name: return adapter @@ -263,9 +269,11 @@ async def delete_lens_traces( lens_accepted, lens_message = await _send_lens_deletion_request(svc, agent_id_hash, deletion.reason) # Step 2: Revoke local consent (stops future collection) + # If the direct lens API call failed, use request_lens_deletion=True so the + # deletion event is queued in the event buffer and retried on the next flush. local_revoked = False if adapter: - adapter.update_consent(False) + adapter.update_consent(False, request_lens_deletion=not lens_accepted) local_revoked = True logger.info(f"Local accord consent revoked for agent {agent_id_hash} via DSAR self-service") diff --git a/tests/adapters/api/test_my_data.py b/tests/adapters/api/test_my_data.py index e58c97f55..74d6cd18c 100644 --- a/tests/adapters/api/test_my_data.py +++ b/tests/adapters/api/test_my_data.py @@ -30,7 +30,8 @@ def mock_runtime(): runtime.agent_identity = MagicMock() runtime.agent_identity.agent_id = "test-agent-001" runtime.adapter_manager = MagicMock() - runtime.adapter_manager._adapters = {} + # RuntimeAdapterManager uses loaded_adapters, not _adapters + runtime.adapter_manager.loaded_adapters = {} return runtime @@ -65,13 +66,21 @@ def mock_accord_adapter(): @pytest.fixture -def app_with_runtime(mock_runtime, mock_accord_adapter): +def mock_adapter_instance(mock_accord_adapter): + """Wrap the mock adapter in an AdapterInstance-like object.""" + instance = MagicMock() + instance.adapter = mock_accord_adapter + return instance + + +@pytest.fixture +def app_with_runtime(mock_runtime, mock_adapter_instance): """Create app with mock runtime and accord adapter.""" app = create_app() app.state.runtime = mock_runtime - # Register adapter under a key that contains "AccordMetrics" - mock_runtime.adapter_manager._adapters = {"accord": mock_accord_adapter} + # RuntimeAdapterManager stores AdapterInstance objects in loaded_adapters + mock_runtime.adapter_manager.loaded_adapters = {"accord": mock_adapter_instance} app.dependency_overrides[get_current_user] = _mock_admin_user return app @@ -87,7 +96,7 @@ def app_no_adapter(mock_runtime): """Create app with runtime but NO accord adapter.""" app = create_app() app.state.runtime = mock_runtime - mock_runtime.adapter_manager._adapters = {} + mock_runtime.adapter_manager.loaded_adapters = {} app.dependency_overrides[get_current_user] = _mock_admin_user return app @@ -177,27 +186,42 @@ def test_requires_confirmation(self, client): assert response.status_code == 400 def test_successful_deletion_request(self, client, mock_accord_adapter): - # Mock the HTTP session for lens API call - mock_session = AsyncMock() - mock_response = AsyncMock() - mock_response.status = 200 - mock_response.text = AsyncMock(return_value="OK") - mock_session.post.return_value.__aenter__ = AsyncMock(return_value=mock_response) - mock_session.post.return_value.__aexit__ = AsyncMock(return_value=False) - mock_session.closed = False - mock_accord_adapter.metrics_service._session = mock_session - mock_accord_adapter.metrics_service._signer = MagicMock(has_signing_key=False) - - response = client.request( - "DELETE", - "/v1/my-data/lens-traces", - json={"confirm": True, "reason": "Testing deletion"}, - ) + """When CIRISLens accepts, no retry is needed.""" + # Patch _send_lens_deletion_request to simulate successful lens response + with patch( + "ciris_engine.logic.adapters.api.routes.my_data._send_lens_deletion_request", + return_value=(True, "CIRISLens accepted the deletion request."), + ): + response = client.request( + "DELETE", + "/v1/my-data/lens-traces", + json={"confirm": True, "reason": "Testing deletion"}, + ) assert response.status_code == 200 data = response.json()["data"] assert data["local_consent_revoked"] is True - mock_accord_adapter.update_consent.assert_called_with(False) + assert data["lens_request_accepted"] is True + # Lens accepted → request_lens_deletion=False (no retry needed) + mock_accord_adapter.update_consent.assert_called_with(False, request_lens_deletion=False) + + def test_deletion_queues_retry_on_lens_failure(self, client, mock_accord_adapter): + """When CIRISLens API fails, deletion event should be queued for retry.""" + with patch( + "ciris_engine.logic.adapters.api.routes.my_data._send_lens_deletion_request", + return_value=(False, "Could not connect to CIRISLens."), + ): + response = client.request( + "DELETE", + "/v1/my-data/lens-traces", + json={"confirm": True}, + ) + + assert response.status_code == 200 + data = response.json()["data"] + assert data["lens_request_accepted"] is False + # Lens rejected → request_lens_deletion=True (queue for retry) + mock_accord_adapter.update_consent.assert_called_with(False, request_lens_deletion=True) def test_deletion_without_adapter(self, client_no_adapter): response = client_no_adapter.request( @@ -325,3 +349,50 @@ def test_queue_deletion_no_agent_id(self): svc.queue_lens_deletion_on_revoke() assert len(svc._event_queue) == 0 + + +class TestLateConsentInitialization: + """Test that granting consent after start initializes HTTP session + flush.""" + + def test_set_consent_true_creates_session(self): + """set_consent(True) should create HTTP session if none exists.""" + from ciris_adapters.ciris_accord_metrics.services import AccordMetricsService + + svc = AccordMetricsService.__new__(AccordMetricsService) + svc._consent_given = False + svc._consent_timestamp = None + svc._session = None + svc._flush_task = None + svc._flush_interval = 60.0 + svc._endpoint_url = "https://example.com/api/v1" + svc._batch_size = 10 + svc._event_queue = [] + + mock_session = MagicMock() + mock_session.closed = False + + # Patch aiohttp.ClientSession (needs event loop) and asyncio.create_task + with patch("ciris_adapters.ciris_accord_metrics.services.aiohttp.ClientSession", return_value=mock_session): + with patch("ciris_adapters.ciris_accord_metrics.services.asyncio.create_task") as mock_create_task: + mock_create_task.return_value = MagicMock(done=MagicMock(return_value=False)) + svc.set_consent(True) + + assert svc._consent_given is True + assert svc._session is mock_session + # Flush task should have been created + mock_create_task.assert_called_once() + + def test_set_consent_false_does_not_create_session(self): + """set_consent(False) should not create an HTTP session.""" + from ciris_adapters.ciris_accord_metrics.services import AccordMetricsService + + svc = AccordMetricsService.__new__(AccordMetricsService) + svc._consent_given = True + svc._consent_timestamp = None + svc._session = None + svc._flush_task = None + + svc.set_consent(False) + + assert svc._consent_given is False + assert svc._session is None