diff --git a/hindsight-api-slim/hindsight_api/alembic/versions/c2d3e4f5g6h7_add_audit_log_table.py b/hindsight-api-slim/hindsight_api/alembic/versions/c2d3e4f5g6h7_add_audit_log_table.py new file mode 100644 index 000000000..0fa6c247a --- /dev/null +++ b/hindsight-api-slim/hindsight_api/alembic/versions/c2d3e4f5g6h7_add_audit_log_table.py @@ -0,0 +1,61 @@ +"""Add audit_log table for feature usage tracking. + +Merge migration that combines the two existing heads (a3b4c5d6e7f8 + c8e5f2a3b4d1). + +Stores raw request/response as JSONB for expandability without future migrations. +The metadata JSONB column allows adding arbitrary fields in the future. + +Revision ID: c2d3e4f5g6h7 +Revises: a3b4c5d6e7f8, c8e5f2a3b4d1 +Create Date: 2026-03-26 +""" + +from collections.abc import Sequence + +from alembic import context, op + +revision: str = "c2d3e4f5g6h7" +down_revision: str | Sequence[str] | None = ("a3b4c5d6e7f8", "c8e5f2a3b4d1") +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def _get_schema_prefix() -> str: + """Get schema prefix for table names (required for multi-tenant support).""" + schema = context.config.get_main_option("target_schema") + return f'"{schema}".' if schema else "" + + +def upgrade() -> None: + schema = _get_schema_prefix() + + op.execute( + f""" + CREATE TABLE IF NOT EXISTS {schema}audit_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + action TEXT NOT NULL, + transport TEXT NOT NULL, + bank_id TEXT, + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + ended_at TIMESTAMPTZ, + request JSONB, + response JSONB, + metadata JSONB DEFAULT '{{}}'::jsonb + ) + """ + ) + + op.execute( + f"CREATE INDEX IF NOT EXISTS idx_audit_log_action_started ON {schema}audit_log (action, started_at DESC)" + ) + op.execute(f"CREATE INDEX IF NOT EXISTS idx_audit_log_bank_started ON {schema}audit_log (bank_id, started_at DESC)") + op.execute(f"CREATE INDEX IF NOT EXISTS idx_audit_log_started ON {schema}audit_log (started_at DESC)") + + +def downgrade() -> None: + schema = _get_schema_prefix() + + op.execute(f"DROP INDEX IF EXISTS {schema}idx_audit_log_started") + op.execute(f"DROP INDEX IF EXISTS {schema}idx_audit_log_bank_started") + op.execute(f"DROP INDEX IF EXISTS {schema}idx_audit_log_action_started") + op.execute(f"DROP TABLE IF EXISTS {schema}audit_log") diff --git a/hindsight-api-slim/hindsight_api/api/http.py b/hindsight-api-slim/hindsight_api/api/http.py index cda329877..064ebdb04 100644 --- a/hindsight-api-slim/hindsight_api/api/http.py +++ b/hindsight-api-slim/hindsight_api/api/http.py @@ -15,6 +15,7 @@ from fastapi import Depends, FastAPI, File, Form, Header, HTTPException, Query, UploadFile +from hindsight_api.engine.audit import AuditEntry, AuditLogger from hindsight_api.extensions import AuthenticationError @@ -1895,6 +1896,73 @@ class WebhookDeliveryListResponse(BaseModel): next_cursor: str | None = None +def _make_audited_http(audit_logger_getter: Callable[[], AuditLogger | None]): + """Create an audit decorator bound to an audit logger getter. + + Returns a decorator factory that can be used as @audited("action_name"). + """ + from datetime import datetime as _dt + from datetime import timezone as _tz + from functools import wraps + from typing import Callable as _Callable + + def audited(action: str, *, request_param: str | None = "request"): + """Decorator that wraps an HTTP handler with audit logging. + + Args: + action: The audit action name (e.g. "retain", "recall"). + request_param: Name of the kwarg holding the Pydantic request model + (None if handler has no request body). Also supports "body". + """ + + def decorator(func): + @wraps(func) + async def wrapper(*args, **kwargs): + al = audit_logger_getter() + if al is None or not al.is_enabled(action): + return await func(*args, **kwargs) + + bank_id = kwargs.get("bank_id") + started_at = _dt.now(_tz.utc) + + req_data = None + if request_param: + req_obj = kwargs.get(request_param) + if req_obj is not None and hasattr(req_obj, "model_dump"): + req_data = req_obj.model_dump(mode="json") + elif req_obj is not None and isinstance(req_obj, dict): + req_data = req_obj + + entry = AuditEntry( + action=action, + transport="http", + bank_id=bank_id, + started_at=started_at, + request=req_data, + ) + + try: + result = await func(*args, **kwargs) + if hasattr(result, "model_dump"): + entry.response = result.model_dump(mode="json") + elif isinstance(result, dict): + entry.response = result + return result + finally: + entry.ended_at = _dt.now(_tz.utc) + al.log_fire_and_forget(entry) + + # Preserve FastAPI's dependency injection signature + import inspect + + wrapper.__signature__ = inspect.signature(func) # type: ignore[attr-defined] + return wrapper + + return decorator + + return audited + + def create_app( memory: MemoryEngine, initialize_memory: bool = True, @@ -2063,6 +2131,7 @@ async def lifespan(app: FastAPI): # IMPORTANT: Set memory on app.state immediately, don't wait for lifespan # This is required for mounted sub-applications where lifespan may not fire app.state.memory = memory + app.state.audit_logger = memory.audit_logger # --------------------------------------------------------------------------- # Patch OpenAPI schema: align ValidationError with Pydantic v2 error format @@ -2129,6 +2198,9 @@ async def http_metrics_middleware(request, call_next): def _register_routes(app: FastAPI): """Register all API routes on the given app instance.""" + # Create audit decorator bound to this app's audit logger + audited = _make_audited_http(lambda: getattr(app.state, "audit_logger", None)) + def get_request_context(authorization: str | None = Header(default=None)) -> RequestContext: """ Extract request context from Authorization header. @@ -2384,6 +2456,7 @@ async def api_get_observation_history( operation_id="recall_memories", tags=["Memory"], ) + @audited("recall") async def api_recall( bank_id: str, request: RecallRequest, request_context: RequestContext = Depends(get_request_context) ): @@ -2570,6 +2643,7 @@ def _fact_to_result(fact: "MemoryFact") -> RecallResult: operation_id="reflect", tags=["Memory"], ) + @audited("reflect") async def api_reflect( bank_id: str, request: ReflectRequest, request_context: RequestContext = Depends(get_request_context) ): @@ -2980,6 +3054,7 @@ async def api_get_mental_model_history( operation_id="create_mental_model", tags=["Mental Models"], ) + @audited("create_mental_model", request_param="body") async def api_create_mental_model( bank_id: str, body: CreateMentalModelRequest, @@ -3027,6 +3102,7 @@ async def api_create_mental_model( operation_id="refresh_mental_model", tags=["Mental Models"], ) + @audited("refresh_mental_model", request_param=None) async def api_refresh_mental_model( bank_id: str, mental_model_id: str, @@ -3065,6 +3141,7 @@ async def api_refresh_mental_model( operation_id="update_mental_model", tags=["Mental Models"], ) + @audited("update_mental_model", request_param="body") async def api_update_mental_model( bank_id: str, mental_model_id: str, @@ -3104,6 +3181,7 @@ async def api_update_mental_model( operation_id="delete_mental_model", tags=["Mental Models"], ) + @audited("delete_mental_model", request_param=None) async def api_delete_mental_model( bank_id: str, mental_model_id: str, @@ -3216,6 +3294,7 @@ async def api_get_directive( operation_id="create_directive", tags=["Directives"], ) + @audited("create_directive", request_param="body") async def api_create_directive( bank_id: str, body: CreateDirectiveRequest, @@ -3254,6 +3333,7 @@ async def api_create_directive( operation_id="update_directive", tags=["Directives"], ) + @audited("update_directive", request_param="body") async def api_update_directive( bank_id: str, directive_id: str, @@ -3293,6 +3373,7 @@ async def api_update_directive( operation_id="delete_directive", tags=["Directives"], ) + @audited("delete_directive", request_param=None) async def api_delete_directive( bank_id: str, directive_id: str, @@ -3505,6 +3586,7 @@ async def api_get_chunk(chunk_id: str, request_context: RequestContext = Depends operation_id="update_document", tags=["Documents"], ) + @audited("update_document", request_param="body") async def api_update_document( bank_id: str, document_id: str, @@ -3555,6 +3637,7 @@ async def api_update_document( operation_id="delete_document", tags=["Documents"], ) + @audited("delete_document", request_param=None) async def api_delete_document( bank_id: str, document_id: str, request_context: RequestContext = Depends(get_request_context) ): @@ -3671,6 +3754,7 @@ async def api_get_operation_status( operation_id="cancel_operation", tags=["Operations"], ) + @audited("cancel_operation", request_param=None) async def api_cancel_operation( bank_id: str, operation_id: str, request_context: RequestContext = Depends(get_request_context) ): @@ -3705,6 +3789,7 @@ async def api_cancel_operation( operation_id="retry_operation", tags=["Operations"], ) + @audited("retry_operation", request_param=None) async def api_retry_operation( bank_id: str, operation_id: str, request_context: RequestContext = Depends(get_request_context) ): @@ -3851,6 +3936,7 @@ async def api_add_bank_background( operation_id="create_or_update_bank", tags=["Banks"], ) + @audited("create_bank") async def api_create_or_update_bank( bank_id: str, request: CreateBankRequest, request_context: RequestContext = Depends(get_request_context) ): @@ -3906,6 +3992,7 @@ async def api_create_or_update_bank( operation_id="update_bank", tags=["Banks"], ) + @audited("update_bank") async def api_update_bank( bank_id: str, request: CreateBankRequest, request_context: RequestContext = Depends(get_request_context) ): @@ -3962,6 +4049,7 @@ async def api_update_bank( operation_id="delete_bank", tags=["Banks"], ) + @audited("delete_bank", request_param=None) async def api_delete_bank(bank_id: str, request_context: RequestContext = Depends(get_request_context)): """Delete an entire memory bank and all its data.""" try: @@ -3992,6 +4080,7 @@ async def api_delete_bank(bank_id: str, request_context: RequestContext = Depend operation_id="clear_observations", tags=["Banks"], ) + @audited("clear_observations", request_param=None) async def api_clear_observations(bank_id: str, request_context: RequestContext = Depends(get_request_context)): """Clear all observations for a bank.""" try: @@ -4024,6 +4113,7 @@ async def api_clear_observations(bank_id: str, request_context: RequestContext = operation_id="recover_consolidation", tags=["Banks"], ) + @audited("recover_consolidation", request_param=None) async def api_recover_consolidation(bank_id: str, request_context: RequestContext = Depends(get_request_context)): """Reset consolidation-failed memories for recovery.""" try: @@ -4050,6 +4140,7 @@ async def api_recover_consolidation(bank_id: str, request_context: RequestContex operation_id="clear_memory_observations", tags=["Memory"], ) + @audited("clear_memory_observations", request_param=None) async def api_clear_memory_observations( bank_id: str, memory_id: str, @@ -4130,6 +4221,7 @@ async def api_get_bank_config(bank_id: str, request_context: RequestContext = De operation_id="update_bank_config", tags=["Banks"], ) + @audited("update_bank_config") async def api_update_bank_config( bank_id: str, request: BankConfigUpdate, request_context: RequestContext = Depends(get_request_context) ): @@ -4181,6 +4273,7 @@ async def api_update_bank_config( operation_id="reset_bank_config", tags=["Banks"], ) + @audited("reset_bank_config", request_param=None) async def api_reset_bank_config(bank_id: str, request_context: RequestContext = Depends(get_request_context)): """Reset bank configuration to defaults (remove all overrides).""" if not get_config().enable_bank_config_api: @@ -4226,6 +4319,7 @@ async def api_reset_bank_config(bank_id: str, request_context: RequestContext = operation_id="trigger_consolidation", tags=["Banks"], ) + @audited("consolidation", request_param=None) async def api_trigger_consolidation(bank_id: str, request_context: RequestContext = Depends(get_request_context)): """Trigger consolidation for a bank (async).""" try: @@ -4258,6 +4352,7 @@ async def api_trigger_consolidation(bank_id: str, request_context: RequestContex tags=["Webhooks"], status_code=201, ) + @audited("create_webhook") async def api_create_webhook( bank_id: str, request: CreateWebhookRequest, @@ -4374,6 +4469,7 @@ async def api_list_webhooks( operation_id="delete_webhook", tags=["Webhooks"], ) + @audited("delete_webhook", request_param=None) async def api_delete_webhook( bank_id: str, webhook_id: str, @@ -4410,6 +4506,7 @@ async def api_delete_webhook( operation_id="update_webhook", tags=["Webhooks"], ) + @audited("update_webhook") async def api_update_webhook( bank_id: str, webhook_id: str, @@ -4586,6 +4683,7 @@ async def api_list_webhook_deliveries( operation_id="retain_memories", tags=["Memory"], ) + @audited("retain") async def api_retain( bank_id: str, request: RetainRequest, request_context: RequestContext = Depends(get_request_context) ): @@ -4749,6 +4847,7 @@ async def api_retain( operation_id="file_retain", tags=["Files"], ) + @audited("file_retain", request_param=None) async def api_file_retain( bank_id: str, files: list[UploadFile] = File(..., description="Files to upload and convert"), @@ -4898,6 +4997,7 @@ async def read(self): operation_id="clear_bank_memories", tags=["Memory"], ) + @audited("clear_memories", request_param=None) async def api_clear_bank_memories( bank_id: str, type: str | None = Query(None, description="Optional fact type filter (world, experience, opinion)"), @@ -4918,3 +5018,202 @@ async def api_clear_bank_memories( error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}" logger.error(f"Error in /v1/default/banks/{bank_id}/memories: {error_detail}") raise HTTPException(status_code=500, detail=str(e)) + + # ---- Audit Logs ---- + + @app.get( + "/v1/default/banks/{bank_id}/audit-logs", + summary="List audit logs", + description="List audit log entries for a bank, ordered by most recent first.", + operation_id="list_audit_logs", + tags=["Audit"], + ) + async def api_list_audit_logs( + bank_id: str, + action: str | None = Query(None, description="Filter by action type"), + transport: str | None = Query(None, description="Filter by transport (http, mcp, system)"), + start_date: str | None = Query(None, description="Filter from this ISO datetime (inclusive)"), + end_date: str | None = Query(None, description="Filter until this ISO datetime (exclusive)"), + limit: int = Query(50, ge=1, le=500, description="Max items to return"), + offset: int = Query(0, ge=0, description="Offset for pagination"), + request_context: RequestContext = Depends(get_request_context), + ): + """List audit log entries for a bank.""" + try: + from hindsight_api.engine.memory_engine import fq_table + + pool = await app.state.memory._get_pool() + + # Ensure bank exists + await app.state.memory.get_bank_profile(bank_id, request_context=request_context) + + from hindsight_api.engine.db_utils import acquire_with_retry + + async with acquire_with_retry(pool) as conn: + where_clauses = ["bank_id = $1"] + params: list[Any] = [bank_id] + idx = 2 + + if action: + where_clauses.append(f"action = ${idx}") + params.append(action) + idx += 1 + + if transport: + where_clauses.append(f"transport = ${idx}") + params.append(transport) + idx += 1 + + if start_date: + parsed_start = datetime.fromisoformat(start_date.replace("Z", "+00:00")) + where_clauses.append(f"started_at >= ${idx}") + params.append(parsed_start) + idx += 1 + + if end_date: + parsed_end = datetime.fromisoformat(end_date.replace("Z", "+00:00")) + where_clauses.append(f"started_at < ${idx}") + params.append(parsed_end) + idx += 1 + + where_sql = " AND ".join(where_clauses) + table = fq_table("audit_log") + + # Get total count + count_row = await conn.fetchrow( + f"SELECT COUNT(*) as total FROM {table} WHERE {where_sql}", + *params, + ) + total = count_row["total"] if count_row else 0 + + # Get paginated results + params.append(limit) + params.append(offset) + rows = await conn.fetch( + f""" + SELECT id, action, transport, bank_id, started_at, ended_at, + request, response, metadata + FROM {table} + WHERE {where_sql} + ORDER BY started_at DESC + LIMIT ${idx} OFFSET ${idx + 1} + """, + *params, + ) + + items = [] + for row in rows: + items.append( + { + "id": str(row["id"]), + "action": row["action"], + "transport": row["transport"], + "bank_id": row["bank_id"], + "started_at": row["started_at"].isoformat() if row["started_at"] else None, + "ended_at": row["ended_at"].isoformat() if row["ended_at"] else None, + "request": json.loads(row["request"]) if row["request"] else None, + "response": json.loads(row["response"]) if row["response"] else None, + "metadata": json.loads(row["metadata"]) if row["metadata"] else {}, + } + ) + + return { + "bank_id": bank_id, + "total": total, + "limit": limit, + "offset": offset, + "items": items, + } + except OperationValidationError as e: + raise HTTPException(status_code=e.status_code, detail=e.reason) + except (AuthenticationError, HTTPException): + raise + except Exception as e: + import traceback + + logger.error(f"Error listing audit logs: {traceback.format_exc()}") + raise HTTPException(status_code=500, detail=str(e)) + + @app.get( + "/v1/default/banks/{bank_id}/audit-logs/stats", + summary="Audit log statistics", + description="Get audit log counts grouped by time bucket for charting.", + operation_id="audit_log_stats", + tags=["Audit"], + ) + async def api_audit_log_stats( + bank_id: str, + action: str | None = Query(None, description="Filter by action type"), + period: str = Query("7d", description="Time period: 1d, 7d, or 30d"), + request_context: RequestContext = Depends(get_request_context), + ): + """Get audit log counts grouped by time bucket.""" + try: + from hindsight_api.engine.db_utils import acquire_with_retry + from hindsight_api.engine.memory_engine import fq_table + + pool = await app.state.memory._get_pool() + await app.state.memory.get_bank_profile(bank_id, request_context=request_context) + + # Determine time range (always per-day buckets) + from datetime import timedelta as _td + + now = datetime.now(timezone.utc) + trunc = "day" + if period == "1d": + start = now - _td(days=1) + elif period == "30d": + start = now - _td(days=30) + else: # 7d default + start = now - _td(days=7) + + table = fq_table("audit_log") + + async with acquire_with_retry(pool) as conn: + where_clauses = ["bank_id = $1", "started_at >= $2"] + params: list[Any] = [bank_id, start] + idx = 3 + + if action: + where_clauses.append(f"action = ${idx}") + params.append(action) + idx += 1 + + where_sql = " AND ".join(where_clauses) + + rows = await conn.fetch( + f""" + SELECT date_trunc('{trunc}', started_at) AS bucket, + action, + COUNT(*) AS count + FROM {table} + WHERE {where_sql} + GROUP BY bucket, action + ORDER BY bucket ASC + """, + *params, + ) + + buckets: dict[str, dict[str, int]] = {} + for row in rows: + bucket_key = row["bucket"].isoformat() + if bucket_key not in buckets: + buckets[bucket_key] = {} + buckets[bucket_key][row["action"]] = row["count"] + + return { + "bank_id": bank_id, + "period": period, + "trunc": trunc, + "start": start.isoformat(), + "buckets": [{"time": k, "actions": v, "total": sum(v.values())} for k, v in buckets.items()], + } + except OperationValidationError as e: + raise HTTPException(status_code=e.status_code, detail=e.reason) + except (AuthenticationError, HTTPException): + raise + except Exception as e: + import traceback + + logger.error(f"Error getting audit log stats: {traceback.format_exc()}") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/hindsight-api-slim/hindsight_api/config.py b/hindsight-api-slim/hindsight_api/config.py index 83e0ba498..c976efb7a 100644 --- a/hindsight-api-slim/hindsight_api/config.py +++ b/hindsight-api-slim/hindsight_api/config.py @@ -342,6 +342,11 @@ def normalize_config_dict(config: dict[str, Any]) -> dict[str, Any]: ENV_REFLECT_MISSION = "HINDSIGHT_API_REFLECT_MISSION" ENV_REFLECT_SOURCE_FACTS_MAX_TOKENS = "HINDSIGHT_API_REFLECT_SOURCE_FACTS_MAX_TOKENS" +# Audit log settings +ENV_AUDIT_LOG_ENABLED = "HINDSIGHT_API_AUDIT_LOG_ENABLED" +ENV_AUDIT_LOG_ACTIONS = "HINDSIGHT_API_AUDIT_LOG_ACTIONS" +ENV_AUDIT_LOG_RETENTION_DAYS = "HINDSIGHT_API_AUDIT_LOG_RETENTION_DAYS" + # Disposition settings ENV_DISPOSITION_SKEPTICISM = "HINDSIGHT_API_DISPOSITION_SKEPTICISM" ENV_DISPOSITION_LITERALISM = "HINDSIGHT_API_DISPOSITION_LITERALISM" @@ -517,6 +522,11 @@ def normalize_config_dict(config: dict[str, Any]) -> dict[str, Any]: DEFAULT_OTEL_SERVICE_NAME = "hindsight-api" DEFAULT_OTEL_DEPLOYMENT_ENVIRONMENT = "development" +# Audit log defaults +DEFAULT_AUDIT_LOG_ENABLED = False # Disabled by default +DEFAULT_AUDIT_LOG_ACTIONS = "" # Empty = audit all eligible actions +DEFAULT_AUDIT_LOG_RETENTION_DAYS = -1 # -1 = keep forever + # Default MCP tool descriptions (can be customized via env vars) DEFAULT_MCP_RETAIN_DESCRIPTION = """Store important information to long-term memory. @@ -818,6 +828,11 @@ class HindsightConfig: otel_service_name: str otel_deployment_environment: str + # Audit log configuration (static - server-level only) + audit_log_enabled: bool # Master switch for audit logging + audit_log_actions: list[str] # Allowlist of action types (empty = all) + audit_log_retention_days: int # -1 = keep forever, >0 = delete after N days + # Webhook configuration (static - server-level only, not per-bank) webhook_url: str | None # Global webhook URL (None = disabled) webhook_secret: str | None # HMAC signing secret (None = unsigned) @@ -1316,6 +1331,14 @@ def from_env(cls) -> "HindsightConfig": otel_exporter_otlp_headers=os.getenv(ENV_OTEL_EXPORTER_OTLP_HEADERS) or None, otel_service_name=os.getenv(ENV_OTEL_SERVICE_NAME, DEFAULT_OTEL_SERVICE_NAME), otel_deployment_environment=os.getenv(ENV_OTEL_DEPLOYMENT_ENVIRONMENT, DEFAULT_OTEL_DEPLOYMENT_ENVIRONMENT), + # Audit log configuration (static, server-level only) + audit_log_enabled=os.getenv(ENV_AUDIT_LOG_ENABLED, str(DEFAULT_AUDIT_LOG_ENABLED)).lower() == "true", + audit_log_actions=[ + a.strip() for a in os.getenv(ENV_AUDIT_LOG_ACTIONS, DEFAULT_AUDIT_LOG_ACTIONS).split(",") if a.strip() + ], + audit_log_retention_days=int( + os.getenv(ENV_AUDIT_LOG_RETENTION_DAYS, str(DEFAULT_AUDIT_LOG_RETENTION_DAYS)) + ), # Webhook configuration (static, server-level only) webhook_url=os.getenv(ENV_WEBHOOK_URL) or DEFAULT_WEBHOOK_URL, webhook_secret=os.getenv(ENV_WEBHOOK_SECRET) or DEFAULT_WEBHOOK_SECRET, diff --git a/hindsight-api-slim/hindsight_api/engine/audit.py b/hindsight-api-slim/hindsight_api/engine/audit.py new file mode 100644 index 000000000..c0a64721d --- /dev/null +++ b/hindsight-api-slim/hindsight_api/engine/audit.py @@ -0,0 +1,209 @@ +"""Audit logging for feature usage tracking. + +Provides fire-and-forget audit logging of all mutating and core operations +(retain, recall, reflect, bank CRUD, etc.) across HTTP, MCP, and system transports. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import uuid +from collections.abc import Callable +from contextlib import asynccontextmanager +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any + +import asyncpg + +from ..engine.db_utils import acquire_with_retry + +logger = logging.getLogger(__name__) + + +@dataclass +class AuditEntry: + """A single audit log entry.""" + + action: str + transport: str # "http", "mcp", "system" + bank_id: str | None = None + started_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + ended_at: datetime | None = None + request: dict[str, Any] | None = None + response: dict[str, Any] | None = None + metadata: dict[str, Any] = field(default_factory=dict) + + +def _json_default(obj: Any) -> str: + """JSON serializer for objects not serializable by default.""" + if isinstance(obj, datetime): + return obj.isoformat() + if isinstance(obj, uuid.UUID): + return str(obj) + if isinstance(obj, bytes): + return "" + if isinstance(obj, set): + return list(obj) + return str(obj) + + +def _safe_json(data: Any) -> str | None: + """Serialize data to JSON string, returning None on failure.""" + if data is None: + return None + try: + return json.dumps(data, default=_json_default) + except Exception: + logger.debug("Failed to serialize audit data", exc_info=True) + return None + + +_SWEEP_INTERVAL_SECONDS = 3600 # Run retention sweep every hour + + +class AuditLogger: + """Fire-and-forget audit log writer with optional retention sweep.""" + + def __init__( + self, + pool_getter: Callable[[], asyncpg.Pool | None], + schema_getter: Callable[[], str], + enabled: bool, + allowed_actions: list[str], + retention_days: int = -1, + ) -> None: + self._pool_getter = pool_getter + self._schema_getter = schema_getter + self._enabled = enabled + self._allowed_actions: frozenset[str] | None = frozenset(allowed_actions) if allowed_actions else None + self._retention_days = retention_days + self._sweep_task: asyncio.Task | None = None + + def is_enabled(self, action: str) -> bool: + """Check if audit logging is enabled for this action.""" + if not self._enabled: + return False + if self._allowed_actions is not None: + return action in self._allowed_actions + return True + + def log_fire_and_forget(self, entry: AuditEntry) -> None: + """Schedule an audit write as a background task.""" + if not self.is_enabled(entry.action): + return + try: + asyncio.create_task(self._safe_log(entry)) + except RuntimeError: + # No running event loop (e.g. during shutdown) + logger.debug("Cannot schedule audit log write: no running event loop") + + async def _safe_log(self, entry: AuditEntry) -> None: + """Write audit entry to DB. Errors are logged, never raised.""" + pool = self._pool_getter() + if pool is None: + logger.debug("Audit log skipped: pool not available") + return + try: + schema = self._schema_getter() + table = f"{schema}.audit_log" + async with acquire_with_retry(pool, max_retries=1) as conn: + await conn.execute( + f""" + INSERT INTO {table} + (id, action, transport, bank_id, started_at, ended_at, request, response, metadata) + VALUES + ($1, $2, $3, $4, $5, $6, $7::jsonb, $8::jsonb, $9::jsonb) + """, + uuid.uuid4(), + entry.action, + entry.transport, + entry.bank_id, + entry.started_at, + entry.ended_at, + _safe_json(entry.request), + _safe_json(entry.response), + _safe_json(entry.metadata) or "{}", + ) + except Exception as e: + logger.warning(f"Audit log write failed for action={entry.action}: {e}") + + def start_retention_sweep(self) -> None: + """Start the periodic retention sweep if retention is configured.""" + if self._retention_days <= 0 or not self._enabled: + return + try: + self._sweep_task = asyncio.create_task(self._sweep_loop()) + except RuntimeError: + logger.debug("Cannot start retention sweep: no running event loop") + + async def stop_retention_sweep(self) -> None: + """Stop the periodic retention sweep.""" + if self._sweep_task and not self._sweep_task.done(): + self._sweep_task.cancel() + try: + await self._sweep_task + except asyncio.CancelledError: + pass + self._sweep_task = None + + async def _sweep_loop(self) -> None: + """Periodically delete audit log entries older than retention_days.""" + while True: + await self._run_sweep() + await asyncio.sleep(_SWEEP_INTERVAL_SECONDS) + + async def _run_sweep(self) -> None: + """Delete expired audit log entries. Concurrent-safe via row-level deletes.""" + pool = self._pool_getter() + if pool is None: + return + try: + schema = self._schema_getter() + table = f"{schema}.audit_log" + async with acquire_with_retry(pool, max_retries=1) as conn: + result = await conn.execute( + f"DELETE FROM {table} WHERE started_at < NOW() - INTERVAL '{self._retention_days} days'" + ) + if result and result != "DELETE 0": + logger.info(f"Audit log retention sweep: {result}") + except Exception as e: + logger.warning(f"Audit log retention sweep failed: {e}") + + +@asynccontextmanager +async def audit_context( + audit_logger: AuditLogger | None, + action: str, + transport: str, + bank_id: str | None = None, + request: dict[str, Any] | None = None, + metadata: dict[str, Any] | None = None, +): + """Async context manager that times the operation and writes audit on exit. + + Usage: + async with audit_context(logger, "retain", "http", bank_id, request_dict) as entry: + result = await do_work() + entry.response = result_dict + """ + if audit_logger is None or not audit_logger.is_enabled(action): + entry = AuditEntry(action=action, transport=transport, bank_id=bank_id) + yield entry + return + + entry = AuditEntry( + action=action, + transport=transport, + bank_id=bank_id, + started_at=datetime.now(timezone.utc), + request=request, + metadata=metadata or {}, + ) + try: + yield entry + finally: + entry.ended_at = datetime.now(timezone.utc) + audit_logger.log_fire_and_forget(entry) diff --git a/hindsight-api-slim/hindsight_api/engine/memory_engine.py b/hindsight-api-slim/hindsight_api/engine/memory_engine.py index da6d59b4e..c1e0aa409 100644 --- a/hindsight-api-slim/hindsight_api/engine/memory_engine.py +++ b/hindsight-api-slim/hindsight_api/engine/memory_engine.py @@ -28,6 +28,7 @@ from ..tracing import create_operation_span from ..utils import mask_network_location from ..worker.exceptions import RetryTaskAt +from .audit import AuditLogger, audit_context from .db_budget import budgeted_operation from .operation_metadata import ( BatchRetainChildMetadata, @@ -476,6 +477,16 @@ def __init__( schema_getter=get_current_schema, ) + # Audit logger for feature usage tracking + config = get_config() + self._audit_logger = AuditLogger( + pool_getter=lambda: self._pool, + schema_getter=get_current_schema, + enabled=config.audit_log_enabled, + allowed_actions=config.audit_log_actions, + retention_days=config.audit_log_retention_days, + ) + # Backpressure mechanism: limit concurrent searches to prevent overwhelming the database # Configurable via HINDSIGHT_API_RECALL_MAX_CONCURRENT (default: 50) self._search_semaphore = asyncio.Semaphore(get_config().recall_max_concurrent) @@ -498,6 +509,11 @@ def __init__( tenant_extension = DefaultTenantExtension(config={}) self._tenant_extension = tenant_extension + @property + def audit_logger(self) -> AuditLogger: + """The audit logger for feature usage tracking.""" + return self._audit_logger + @property def tenant_extension(self) -> "TenantExtension | None": """The configured tenant extension, if any.""" @@ -1030,72 +1046,78 @@ async def execute_task(self, task_dict: dict[str, Any]): # Continue with processing if we can't check status consolidation_result: dict | None = None - try: - if task_type == "batch_retain": - await self._handle_batch_retain(task_dict) - elif task_type == "file_convert_retain": - await self._handle_file_convert_retain(task_dict) - elif task_type == "consolidation": - consolidation_result = await self._handle_consolidation(task_dict) - elif task_type == "refresh_mental_model": - await self._handle_refresh_mental_model(task_dict) - elif task_type == "webhook_delivery": - await self._handle_webhook_delivery(task_dict) - else: - logger.error(f"Unknown task type: {task_type}") - # Don't retry unknown task types - if operation_id: - await self._delete_operation_record(operation_id) - return - - # Task succeeded - mark operation as completed - # file_convert_retain marks itself as completed in a transaction, skip double-marking - if operation_id and task_type not in ("file_convert_retain",): - if task_type == "consolidation": - # Atomically mark completed AND queue webhook delivery in one transaction - await self._mark_operation_completed_and_fire_webhook( - operation_id=operation_id, - bank_id=task_dict.get("bank_id", ""), - status="completed", - result=consolidation_result, - schema=schema, - ) + bank_id = task_dict.get("bank_id") + async with audit_context( + self._audit_logger, task_type or "unknown", "system", bank_id, request=task_dict + ) as audit_entry: + try: + if task_type == "batch_retain": + await self._handle_batch_retain(task_dict) + elif task_type == "file_convert_retain": + await self._handle_file_convert_retain(task_dict) + elif task_type == "consolidation": + consolidation_result = await self._handle_consolidation(task_dict) + elif task_type == "refresh_mental_model": + await self._handle_refresh_mental_model(task_dict) + elif task_type == "webhook_delivery": + await self._handle_webhook_delivery(task_dict) else: - await self._mark_operation_completed(operation_id) - - except RetryTaskAt: - # Task-owned retry: let the poller handle scheduling - raise - except Exception as e: - logger.error(f"Task execution failed: {task_type}, error: {e}") - import traceback + logger.error(f"Unknown task type: {task_type}") + # Don't retry unknown task types + if operation_id: + await self._delete_operation_record(operation_id) + return + + # Task succeeded - mark operation as completed + # file_convert_retain marks itself as completed in a transaction, skip double-marking + if operation_id and task_type not in ("file_convert_retain",): + if task_type == "consolidation": + # Atomically mark completed AND queue webhook delivery in one transaction + await self._mark_operation_completed_and_fire_webhook( + operation_id=operation_id, + bank_id=task_dict.get("bank_id", ""), + status="completed", + result=consolidation_result, + schema=schema, + ) + else: + await self._mark_operation_completed(operation_id) - error_traceback = traceback.format_exc() - traceback.print_exc() + audit_entry.response = {"status": "completed", "operation_id": operation_id} - if task_type == "file_convert_retain": - # Non-retryable: mark as failed immediately. - # Conversion failures won't improve on retry (missing OCR, corrupted file, etc.) - logger.error(f"Not retrying task {task_type} (non-retryable), marking as failed") - if operation_id: - await self._mark_operation_failed(operation_id, str(e), error_traceback) - else: - if task_type == "consolidation" and operation_id: - # Fire failure webhook (non-transactional — operation not yet marked failed; - # poller will mark it failed after this raise) - await self._fire_consolidation_webhook( - bank_id=task_dict.get("bank_id", ""), - operation_id=operation_id, - status="failed", - result=None, - error_message=str(e), - schema=schema, - ) - # Retryable: use RetryTaskAt if under the retry limit, else re-raise (poller marks failed) - retry_count = task_dict.get("_retry_count", 0) - if retry_count < 3: - raise RetryTaskAt(retry_at=datetime.now(UTC) + timedelta(seconds=60), message=str(e)) + except RetryTaskAt: + # Task-owned retry: let the poller handle scheduling raise + except Exception as e: + logger.error(f"Task execution failed: {task_type}, error: {e}") + import traceback + + error_traceback = traceback.format_exc() + traceback.print_exc() + + if task_type == "file_convert_retain": + # Non-retryable: mark as failed immediately. + # Conversion failures won't improve on retry (missing OCR, corrupted file, etc.) + logger.error(f"Not retrying task {task_type} (non-retryable), marking as failed") + if operation_id: + await self._mark_operation_failed(operation_id, str(e), error_traceback) + else: + if task_type == "consolidation" and operation_id: + # Fire failure webhook (non-transactional — operation not yet marked failed; + # poller will mark it failed after this raise) + await self._fire_consolidation_webhook( + bank_id=task_dict.get("bank_id", ""), + operation_id=operation_id, + status="failed", + result=None, + error_message=str(e), + schema=schema, + ) + # Retryable: use RetryTaskAt if under the retry limit, else re-raise (poller marks failed) + retry_count = task_dict.get("_retry_count", 0) + if retry_count < 3: + raise RetryTaskAt(retry_at=datetime.now(UTC) + timedelta(seconds=60), message=str(e)) + raise async def _fire_consolidation_webhook( self, @@ -1791,6 +1813,9 @@ async def _init_connection(conn: asyncpg.Connection) -> None: self._task_backend.set_executor(self.execute_task) await self._task_backend.initialize() + # Start audit log retention sweep (if configured) + self._audit_logger.start_retention_sweep() + self._initialized = True logger.info("Memory system initialized (pool and task backend started)") @@ -1843,6 +1868,9 @@ async def close(self): """Close the connection pool and shutdown background workers.""" logger.info("close() started") + # Stop audit log retention sweep + await self._audit_logger.stop_retention_sweep() + # Shutdown task backend await self._task_backend.shutdown() diff --git a/hindsight-api-slim/hindsight_api/mcp_tools.py b/hindsight-api-slim/hindsight_api/mcp_tools.py index 6b5f2cce0..ac22a9898 100644 --- a/hindsight-api-slim/hindsight_api/mcp_tools.py +++ b/hindsight-api-slim/hindsight_api/mcp_tools.py @@ -8,7 +8,7 @@ import json import logging from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, timezone from typing import Any, Callable from fastmcp import FastMCP @@ -18,6 +18,7 @@ DEFAULT_MCP_RECALL_DESCRIPTION, DEFAULT_MCP_RETAIN_DESCRIPTION, ) +from hindsight_api.engine.audit import AuditEntry, AuditLogger from hindsight_api.engine.memory_engine import Budget from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES from hindsight_api.extensions import OperationValidationError @@ -289,6 +290,7 @@ def register_mcp_tools( _register_clear_memories(mcp, memory, config) _apply_bank_tool_filtering(mcp, memory, config) + _apply_audit_logging(mcp, memory, config) def _apply_bank_tool_filtering(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None: @@ -361,6 +363,112 @@ async def _filtered_run(arguments, _name=name, _orig=original_run): logger.warning("Could not apply bank tool filtering: unknown FastMCP version") +_AUDITABLE_MCP_TOOLS: frozenset[str] = frozenset( + { + "retain", + "recall", + "reflect", + "create_bank", + "update_bank", + "delete_bank", + "clear_memories", + "create_mental_model", + "update_mental_model", + "delete_mental_model", + "refresh_mental_model", + "create_directive", + "delete_directive", + "delete_memory", + "delete_document", + "cancel_operation", + } +) + + +def _apply_audit_logging(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None: + """Wrap auditable MCP tool run methods with audit logging.""" + audit_logger: AuditLogger = memory.audit_logger + + def _wrap_tool_run(tool_name: str, original_run): + """Create an audited wrapper for a tool's run method.""" + + async def _audited_run(arguments, _name=tool_name, _orig=original_run): + if not audit_logger.is_enabled(_name): + return await _orig(arguments) + + bank_id = None + if isinstance(arguments, dict): + bank_id = arguments.get("bank_id") or (config.bank_id_resolver() if config.bank_id_resolver else None) + elif hasattr(arguments, "get"): + bank_id = arguments.get("bank_id") + + entry = AuditEntry( + action=_name, + transport="mcp", + bank_id=bank_id, + started_at=datetime.now(timezone.utc), + request=dict(arguments) if isinstance(arguments, dict) else {"raw": str(arguments)}, + ) + + try: + result = await _orig(arguments) + if isinstance(result, dict): + entry.response = result + elif isinstance(result, list): + entry.response = {"items": result} + elif isinstance(result, str): + entry.response = {"text": result} + return result + finally: + entry.ended_at = datetime.now(timezone.utc) + audit_logger.log_fire_and_forget(entry) + + return _audited_run + + if hasattr(mcp, "_tool_manager"): + # FastMCP 2.x + try: + for name, tool in mcp._tool_manager._tools.items(): + if name in _AUDITABLE_MCP_TOOLS: + object.__setattr__(tool, "run", _wrap_tool_run(name, tool.run)) + except (AttributeError, KeyError) as e: + logger.warning(f"Could not apply MCP audit logging (v2): {e}") + elif hasattr(mcp, "get_tool"): + # FastMCP 3.x: wrap call_tool + original_call_tool = getattr(mcp, "call_tool", None) + if original_call_tool: + + async def _audited_call_tool(name, arguments=None, **kwargs): + if name not in _AUDITABLE_MCP_TOOLS or not audit_logger.is_enabled(name): + return await original_call_tool(name, arguments, **kwargs) + + bank_id = None + if isinstance(arguments, dict): + bank_id = arguments.get("bank_id") or ( + config.bank_id_resolver() if config.bank_id_resolver else None + ) + + entry = AuditEntry( + action=name, + transport="mcp", + bank_id=bank_id, + started_at=datetime.now(timezone.utc), + request=dict(arguments) if isinstance(arguments, dict) else {}, + ) + + try: + result = await original_call_tool(name, arguments, **kwargs) + entry.response = {"result": str(result)[:4096]} + return result + finally: + entry.ended_at = datetime.now(timezone.utc) + audit_logger.log_fire_and_forget(entry) + + object.__setattr__(mcp, "call_tool", _audited_call_tool) + else: + logger.warning("Could not apply MCP audit logging: unknown FastMCP version") + + def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None: """Register the retain tool.""" description = config.retain_description or DEFAULT_MCP_RETAIN_DESCRIPTION diff --git a/hindsight-api-slim/hindsight_api/migrations.py b/hindsight-api-slim/hindsight_api/migrations.py index bd656c441..20b9c0bd3 100644 --- a/hindsight-api-slim/hindsight_api/migrations.py +++ b/hindsight-api-slim/hindsight_api/migrations.py @@ -157,7 +157,7 @@ def _run_migrations_internal(database_url: str, script_location: str, schema: st # calls from different threads corrupt each other's context. try: with _alembic_lock: - command.upgrade(alembic_cfg, "head") + command.upgrade(alembic_cfg, "heads") except ResolutionError as e: # This happens during rolling deployments when a newer version of the code # has already run migrations, and this older replica doesn't have the new diff --git a/hindsight-api-slim/tests/test_audit_log.py b/hindsight-api-slim/tests/test_audit_log.py new file mode 100644 index 000000000..a5d61b9e1 --- /dev/null +++ b/hindsight-api-slim/tests/test_audit_log.py @@ -0,0 +1,449 @@ +""" +Tests for the audit log feature. + +Tests the audit log list, stats, filtering, and pagination endpoints. +Verifies that audit entries are created for operations when audit logging is enabled. +""" + +import asyncio + +import httpx +import pytest +import pytest_asyncio + +from hindsight_api.api import create_app +from hindsight_api.config import get_config + + +@pytest_asyncio.fixture +async def audit_api_client(memory): + """Create a test client with audit logging enabled.""" + # Enable audit logging on the memory engine's audit logger + memory._audit_logger._enabled = True + memory._audit_logger._allowed_actions = None # All actions + + app = create_app(memory, initialize_memory=False) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + yield client + + +@pytest.fixture +def bank_id(): + """Provide a unique bank ID for audit tests.""" + from datetime import datetime + + return f"audit_test_{datetime.now().timestamp()}" + + +@pytest.mark.asyncio +async def test_audit_log_list_empty(audit_api_client, bank_id): + """Test listing audit logs for a bank with no entries returns empty.""" + # Create the bank first + await audit_api_client.put( + f"/v1/default/banks/{bank_id}", + json={"name": "Audit Test Bank"}, + ) + + # Small delay for fire-and-forget audit writes + await asyncio.sleep(0.5) + + response = await audit_api_client.get(f"/v1/default/banks/{bank_id}/audit-logs") + assert response.status_code == 200 + data = response.json() + assert data["bank_id"] == bank_id + assert "total" in data + assert "items" in data + assert "limit" in data + assert "offset" in data + assert isinstance(data["items"], list) + + +@pytest.mark.asyncio +async def test_audit_log_created_for_retain(audit_api_client, bank_id): + """Test that a retain operation creates an audit log entry.""" + # Create bank + await audit_api_client.put( + f"/v1/default/banks/{bank_id}", + json={"name": "Audit Test Bank"}, + ) + + # Perform a retain + response = await audit_api_client.post( + f"/v1/default/banks/{bank_id}/memories", + json={ + "items": [{"content": "Alice likes cats", "context": "preferences"}], + }, + ) + assert response.status_code == 200 + + # Wait for fire-and-forget audit writes + await asyncio.sleep(1.0) + + # List audit logs - should have entries for create_bank and retain + response = await audit_api_client.get(f"/v1/default/banks/{bank_id}/audit-logs") + assert response.status_code == 200 + data = response.json() + assert data["total"] >= 1 + + actions = [item["action"] for item in data["items"]] + assert "retain" in actions, f"Expected 'retain' in audit actions, got: {actions}" + + +@pytest.mark.asyncio +async def test_audit_log_entry_fields(audit_api_client, bank_id): + """Test that audit log entries have all expected fields.""" + # Create bank + recall to generate entries + await audit_api_client.put( + f"/v1/default/banks/{bank_id}", + json={"name": "Audit Test Bank"}, + ) + + await audit_api_client.post( + f"/v1/default/banks/{bank_id}/memories/recall", + json={"query": "test query"}, + ) + + await asyncio.sleep(1.0) + + response = await audit_api_client.get(f"/v1/default/banks/{bank_id}/audit-logs") + assert response.status_code == 200 + data = response.json() + assert data["total"] >= 1 + + # Check the recall entry has all fields + recall_entries = [item for item in data["items"] if item["action"] == "recall"] + assert len(recall_entries) >= 1, f"Expected recall entry, got actions: {[i['action'] for i in data['items']]}" + + entry = recall_entries[0] + assert entry["id"] is not None + assert entry["action"] == "recall" + assert entry["transport"] == "http" + assert entry["bank_id"] == bank_id + assert entry["started_at"] is not None + assert entry["ended_at"] is not None + # Request should contain the recall parameters + assert entry["request"] is not None + assert "query" in entry["request"] + # Response should contain the recall results + assert entry["response"] is not None + + +@pytest.mark.asyncio +async def test_audit_log_filter_by_action(audit_api_client, bank_id): + """Test filtering audit logs by action type.""" + # Create bank and do retain + recall + await audit_api_client.put( + f"/v1/default/banks/{bank_id}", + json={"name": "Audit Test Bank"}, + ) + await audit_api_client.post( + f"/v1/default/banks/{bank_id}/memories", + json={"items": [{"content": "test content", "context": "test"}]}, + ) + await audit_api_client.post( + f"/v1/default/banks/{bank_id}/memories/recall", + json={"query": "test"}, + ) + + await asyncio.sleep(1.0) + + # Filter by retain only + response = await audit_api_client.get( + f"/v1/default/banks/{bank_id}/audit-logs", + params={"action": "retain"}, + ) + assert response.status_code == 200 + data = response.json() + for item in data["items"]: + assert item["action"] == "retain" + + # Filter by recall only + response = await audit_api_client.get( + f"/v1/default/banks/{bank_id}/audit-logs", + params={"action": "recall"}, + ) + assert response.status_code == 200 + data = response.json() + for item in data["items"]: + assert item["action"] == "recall" + + +@pytest.mark.asyncio +async def test_audit_log_filter_by_transport(audit_api_client, bank_id): + """Test filtering audit logs by transport type.""" + await audit_api_client.put( + f"/v1/default/banks/{bank_id}", + json={"name": "Audit Test Bank"}, + ) + + await asyncio.sleep(0.5) + + # Filter by http transport + response = await audit_api_client.get( + f"/v1/default/banks/{bank_id}/audit-logs", + params={"transport": "http"}, + ) + assert response.status_code == 200 + data = response.json() + for item in data["items"]: + assert item["transport"] == "http" + + # Filter by mcp transport - should be empty (no MCP calls in this test) + response = await audit_api_client.get( + f"/v1/default/banks/{bank_id}/audit-logs", + params={"transport": "mcp"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["total"] == 0 + + +@pytest.mark.asyncio +async def test_audit_log_filter_by_date_range(audit_api_client, bank_id): + """Test filtering audit logs by date range.""" + from datetime import datetime, timedelta, timezone + + await audit_api_client.put( + f"/v1/default/banks/{bank_id}", + json={"name": "Audit Test Bank"}, + ) + + await asyncio.sleep(0.5) + + now = datetime.now(timezone.utc) + + # Filter with start_date in the past - should include entries + past = (now - timedelta(hours=1)).isoformat() + response = await audit_api_client.get( + f"/v1/default/banks/{bank_id}/audit-logs", + params={"start_date": past}, + ) + assert response.status_code == 200 + data = response.json() + assert data["total"] >= 1 + + # Filter with start_date in the future - should be empty + future = (now + timedelta(hours=1)).isoformat() + response = await audit_api_client.get( + f"/v1/default/banks/{bank_id}/audit-logs", + params={"start_date": future}, + ) + assert response.status_code == 200 + data = response.json() + assert data["total"] == 0 + + +@pytest.mark.asyncio +async def test_audit_log_pagination(audit_api_client, bank_id): + """Test audit log pagination with limit and offset.""" + await audit_api_client.put( + f"/v1/default/banks/{bank_id}", + json={"name": "Audit Test Bank"}, + ) + + # Generate multiple audit entries + for i in range(5): + await audit_api_client.post( + f"/v1/default/banks/{bank_id}/memories/recall", + json={"query": f"test query {i}"}, + ) + + await asyncio.sleep(1.5) + + # Get first page + response = await audit_api_client.get( + f"/v1/default/banks/{bank_id}/audit-logs", + params={"limit": 2, "offset": 0}, + ) + assert response.status_code == 200 + page1 = response.json() + assert len(page1["items"]) == 2 + assert page1["limit"] == 2 + assert page1["offset"] == 0 + assert page1["total"] >= 5 # At least 5 recall + 1 create_bank + + # Get second page + response = await audit_api_client.get( + f"/v1/default/banks/{bank_id}/audit-logs", + params={"limit": 2, "offset": 2}, + ) + assert response.status_code == 200 + page2 = response.json() + assert len(page2["items"]) == 2 + assert page2["offset"] == 2 + + # Entries should be different between pages + page1_ids = {item["id"] for item in page1["items"]} + page2_ids = {item["id"] for item in page2["items"]} + assert page1_ids.isdisjoint(page2_ids), "Pages should not overlap" + + +@pytest.mark.asyncio +async def test_audit_log_stats(audit_api_client, bank_id): + """Test the audit log stats endpoint returns correct structure.""" + await audit_api_client.put( + f"/v1/default/banks/{bank_id}", + json={"name": "Audit Test Bank"}, + ) + + await audit_api_client.post( + f"/v1/default/banks/{bank_id}/memories/recall", + json={"query": "stats test"}, + ) + + await asyncio.sleep(1.0) + + # Get stats for last 24h + response = await audit_api_client.get( + f"/v1/default/banks/{bank_id}/audit-logs/stats", + params={"period": "1d"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["bank_id"] == bank_id + assert data["period"] == "1d" + assert data["trunc"] == "day" + assert "buckets" in data + assert isinstance(data["buckets"], list) + + # Should have at least one bucket with our operations + assert len(data["buckets"]) >= 1 + bucket = data["buckets"][0] + assert "time" in bucket + assert "actions" in bucket + assert "total" in bucket + assert bucket["total"] >= 1 + + +@pytest.mark.asyncio +async def test_audit_log_stats_filter_by_action(audit_api_client, bank_id): + """Test stats endpoint filters by action.""" + await audit_api_client.put( + f"/v1/default/banks/{bank_id}", + json={"name": "Audit Test Bank"}, + ) + + await audit_api_client.post( + f"/v1/default/banks/{bank_id}/memories/recall", + json={"query": "test"}, + ) + + await asyncio.sleep(1.0) + + # Stats filtered by recall + response = await audit_api_client.get( + f"/v1/default/banks/{bank_id}/audit-logs/stats", + params={"period": "1d", "action": "recall"}, + ) + assert response.status_code == 200 + data = response.json() + for bucket in data["buckets"]: + # All actions in buckets should be "recall" only + for action_name in bucket["actions"]: + assert action_name == "recall" + + +@pytest.mark.asyncio +async def test_audit_log_stats_periods(audit_api_client, bank_id): + """Test stats endpoint supports different periods.""" + await audit_api_client.put( + f"/v1/default/banks/{bank_id}", + json={"name": "Audit Test Bank"}, + ) + + await asyncio.sleep(0.5) + + for period, expected_trunc in [("1d", "day"), ("7d", "day"), ("30d", "day")]: + response = await audit_api_client.get( + f"/v1/default/banks/{bank_id}/audit-logs/stats", + params={"period": period}, + ) + assert response.status_code == 200 + data = response.json() + assert data["period"] == period + assert data["trunc"] == expected_trunc + + +@pytest.mark.asyncio +async def test_audit_log_disabled(memory): + """Test that no audit logs are created when audit logging is disabled.""" + # Ensure audit logging is disabled + memory._audit_logger._enabled = False + + app = create_app(memory, initialize_memory=False) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + from datetime import datetime + + bid = f"audit_disabled_test_{datetime.now().timestamp()}" + + await client.put(f"/v1/default/banks/{bid}", json={"name": "No Audit"}) + await client.post( + f"/v1/default/banks/{bid}/memories/recall", + json={"query": "test"}, + ) + + await asyncio.sleep(0.5) + + response = await client.get(f"/v1/default/banks/{bid}/audit-logs") + assert response.status_code == 200 + data = response.json() + assert data["total"] == 0, "No audit entries should exist when audit logging is disabled" + + +@pytest.mark.asyncio +async def test_audit_log_action_allowlist(memory): + """Test that only allowed actions are audited when allowlist is set.""" + memory._audit_logger._enabled = True + memory._audit_logger._allowed_actions = frozenset({"recall"}) # Only audit recall + + app = create_app(memory, initialize_memory=False) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + from datetime import datetime + + bid = f"audit_allowlist_test_{datetime.now().timestamp()}" + + # create_bank should NOT be audited + await client.put(f"/v1/default/banks/{bid}", json={"name": "Allowlist Test"}) + # recall should be audited + await client.post( + f"/v1/default/banks/{bid}/memories/recall", + json={"query": "allowlist test"}, + ) + + await asyncio.sleep(1.0) + + response = await client.get(f"/v1/default/banks/{bid}/audit-logs") + assert response.status_code == 200 + data = response.json() + actions = [item["action"] for item in data["items"]] + assert "recall" in actions, "recall should be audited" + assert "create_bank" not in actions, "create_bank should NOT be audited (not in allowlist)" + + +@pytest.mark.asyncio +async def test_audit_log_ordered_by_most_recent(audit_api_client, bank_id): + """Test that audit logs are returned ordered by most recent first.""" + await audit_api_client.put( + f"/v1/default/banks/{bank_id}", + json={"name": "Order Test Bank"}, + ) + + for i in range(3): + await audit_api_client.post( + f"/v1/default/banks/{bank_id}/memories/recall", + json={"query": f"order test {i}"}, + ) + await asyncio.sleep(0.2) # Small gap between requests + + await asyncio.sleep(1.0) + + response = await audit_api_client.get(f"/v1/default/banks/{bank_id}/audit-logs") + assert response.status_code == 200 + data = response.json() + + # Check descending order by started_at + timestamps = [item["started_at"] for item in data["items"] if item["started_at"]] + assert timestamps == sorted(timestamps, reverse=True), "Audit logs should be ordered most recent first" diff --git a/hindsight-clients/go/api/openapi.yaml b/hindsight-clients/go/api/openapi.yaml index de0156061..849a1c7f7 100644 --- a/hindsight-clients/go/api/openapi.yaml +++ b/hindsight-clients/go/api/openapi.yaml @@ -2782,6 +2782,159 @@ paths: summary: Convert files to memories tags: - Files + /v1/default/banks/{bank_id}/audit-logs: + get: + description: "List audit log entries for a bank, ordered by most recent first." + operationId: list_audit_logs + parameters: + - explode: false + in: path + name: bank_id + required: true + schema: + title: Bank Id + type: string + style: simple + - description: Filter by action type + explode: true + in: query + name: action + required: false + schema: + nullable: true + type: string + style: form + - description: "Filter by transport (http, mcp, system)" + explode: true + in: query + name: transport + required: false + schema: + nullable: true + type: string + style: form + - description: Filter from this ISO datetime (inclusive) + explode: true + in: query + name: start_date + required: false + schema: + nullable: true + type: string + style: form + - description: Filter until this ISO datetime (exclusive) + explode: true + in: query + name: end_date + required: false + schema: + nullable: true + type: string + style: form + - description: Max items to return + explode: true + in: query + name: limit + required: false + schema: + default: 50 + description: Max items to return + maximum: 500 + minimum: 1 + title: Limit + type: integer + style: form + - description: Offset for pagination + explode: true + in: query + name: offset + required: false + schema: + default: 0 + description: Offset for pagination + minimum: 0 + title: Offset + type: integer + style: form + - explode: false + in: header + name: authorization + required: false + schema: + nullable: true + type: string + style: simple + responses: + "200": + content: + application/json: + schema: {} + description: Successful Response + "422": + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + description: Validation Error + summary: List audit logs + tags: + - Audit + /v1/default/banks/{bank_id}/audit-logs/stats: + get: + description: Get audit log counts grouped by time bucket for charting. + operationId: audit_log_stats + parameters: + - explode: false + in: path + name: bank_id + required: true + schema: + title: Bank Id + type: string + style: simple + - description: Filter by action type + explode: true + in: query + name: action + required: false + schema: + nullable: true + type: string + style: form + - description: "Time period: 1d, 7d, or 30d" + explode: true + in: query + name: period + required: false + schema: + default: 7d + description: "Time period: 1d, 7d, or 30d" + title: Period + type: string + style: form + - explode: false + in: header + name: authorization + required: false + schema: + nullable: true + type: string + style: simple + responses: + "200": + content: + application/json: + schema: {} + description: Successful Response + "422": + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + description: Validation Error + summary: Audit log statistics + tags: + - Audit components: schemas: AddBackgroundRequest: diff --git a/hindsight-clients/go/api_audit.go b/hindsight-clients/go/api_audit.go new file mode 100644 index 000000000..b5ee8fd2d --- /dev/null +++ b/hindsight-clients/go/api_audit.go @@ -0,0 +1,357 @@ +/* +Hindsight HTTP API + +HTTP API for Hindsight + +API version: 0.4.20 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package hindsight + +import ( + "bytes" + "context" + "io" + "net/http" + "net/url" + "strings" +) + + +// AuditAPIService AuditAPI service +type AuditAPIService service + +type ApiAuditLogStatsRequest struct { + ctx context.Context + ApiService *AuditAPIService + bankId string + action *string + period *string + authorization *string +} + +// Filter by action type +func (r ApiAuditLogStatsRequest) Action(action string) ApiAuditLogStatsRequest { + r.action = &action + return r +} + +// Time period: 1d, 7d, or 30d +func (r ApiAuditLogStatsRequest) Period(period string) ApiAuditLogStatsRequest { + r.period = &period + return r +} + +func (r ApiAuditLogStatsRequest) Authorization(authorization string) ApiAuditLogStatsRequest { + r.authorization = &authorization + return r +} + +func (r ApiAuditLogStatsRequest) Execute() (interface{}, *http.Response, error) { + return r.ApiService.AuditLogStatsExecute(r) +} + +/* +AuditLogStats Audit log statistics + +Get audit log counts grouped by time bucket for charting. + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @param bankId + @return ApiAuditLogStatsRequest +*/ +func (a *AuditAPIService) AuditLogStats(ctx context.Context, bankId string) ApiAuditLogStatsRequest { + return ApiAuditLogStatsRequest{ + ApiService: a, + ctx: ctx, + bankId: bankId, + } +} + +// Execute executes the request +// @return interface{} +func (a *AuditAPIService) AuditLogStatsExecute(r ApiAuditLogStatsRequest) (interface{}, *http.Response, error) { + var ( + localVarHTTPMethod = http.MethodGet + localVarPostBody interface{} + formFiles []formFile + localVarReturnValue interface{} + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "AuditAPIService.AuditLogStats") + if err != nil { + return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/v1/default/banks/{bank_id}/audit-logs/stats" + localVarPath = strings.Replace(localVarPath, "{"+"bank_id"+"}", url.PathEscape(parameterValueToString(r.bankId, "bankId")), -1) + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + + if r.action != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "action", r.action, "form", "") + } + if r.period != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "period", r.period, "form", "") + } else { + var defaultValue string = "7d" + r.period = &defaultValue + } + // to determine the Content-Type header + localVarHTTPContentTypes := []string{} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{"application/json"} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + if r.authorization != nil { + parameterAddToHeaderOrQuery(localVarHeaderParams, "authorization", r.authorization, "simple", "") + } + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) + if err != nil { + return localVarReturnValue, nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + localVarBody, err := io.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + if localVarHTTPResponse.StatusCode == 422 { + var v HTTPValidationError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: err.Error(), + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + return localVarReturnValue, localVarHTTPResponse, nil +} + +type ApiListAuditLogsRequest struct { + ctx context.Context + ApiService *AuditAPIService + bankId string + action *string + transport *string + startDate *string + endDate *string + limit *int32 + offset *int32 + authorization *string +} + +// Filter by action type +func (r ApiListAuditLogsRequest) Action(action string) ApiListAuditLogsRequest { + r.action = &action + return r +} + +// Filter by transport (http, mcp, system) +func (r ApiListAuditLogsRequest) Transport(transport string) ApiListAuditLogsRequest { + r.transport = &transport + return r +} + +// Filter from this ISO datetime (inclusive) +func (r ApiListAuditLogsRequest) StartDate(startDate string) ApiListAuditLogsRequest { + r.startDate = &startDate + return r +} + +// Filter until this ISO datetime (exclusive) +func (r ApiListAuditLogsRequest) EndDate(endDate string) ApiListAuditLogsRequest { + r.endDate = &endDate + return r +} + +// Max items to return +func (r ApiListAuditLogsRequest) Limit(limit int32) ApiListAuditLogsRequest { + r.limit = &limit + return r +} + +// Offset for pagination +func (r ApiListAuditLogsRequest) Offset(offset int32) ApiListAuditLogsRequest { + r.offset = &offset + return r +} + +func (r ApiListAuditLogsRequest) Authorization(authorization string) ApiListAuditLogsRequest { + r.authorization = &authorization + return r +} + +func (r ApiListAuditLogsRequest) Execute() (interface{}, *http.Response, error) { + return r.ApiService.ListAuditLogsExecute(r) +} + +/* +ListAuditLogs List audit logs + +List audit log entries for a bank, ordered by most recent first. + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @param bankId + @return ApiListAuditLogsRequest +*/ +func (a *AuditAPIService) ListAuditLogs(ctx context.Context, bankId string) ApiListAuditLogsRequest { + return ApiListAuditLogsRequest{ + ApiService: a, + ctx: ctx, + bankId: bankId, + } +} + +// Execute executes the request +// @return interface{} +func (a *AuditAPIService) ListAuditLogsExecute(r ApiListAuditLogsRequest) (interface{}, *http.Response, error) { + var ( + localVarHTTPMethod = http.MethodGet + localVarPostBody interface{} + formFiles []formFile + localVarReturnValue interface{} + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "AuditAPIService.ListAuditLogs") + if err != nil { + return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/v1/default/banks/{bank_id}/audit-logs" + localVarPath = strings.Replace(localVarPath, "{"+"bank_id"+"}", url.PathEscape(parameterValueToString(r.bankId, "bankId")), -1) + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + + if r.action != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "action", r.action, "form", "") + } + if r.transport != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "transport", r.transport, "form", "") + } + if r.startDate != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "start_date", r.startDate, "form", "") + } + if r.endDate != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "end_date", r.endDate, "form", "") + } + if r.limit != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "limit", r.limit, "form", "") + } else { + var defaultValue int32 = 50 + r.limit = &defaultValue + } + if r.offset != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "offset", r.offset, "form", "") + } else { + var defaultValue int32 = 0 + r.offset = &defaultValue + } + // to determine the Content-Type header + localVarHTTPContentTypes := []string{} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{"application/json"} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + if r.authorization != nil { + parameterAddToHeaderOrQuery(localVarHeaderParams, "authorization", r.authorization, "simple", "") + } + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) + if err != nil { + return localVarReturnValue, nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + localVarBody, err := io.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + if localVarHTTPResponse.StatusCode == 422 { + var v HTTPValidationError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: err.Error(), + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + return localVarReturnValue, localVarHTTPResponse, nil +} diff --git a/hindsight-clients/go/client.go b/hindsight-clients/go/client.go index 21b1ca471..03f76688a 100644 --- a/hindsight-clients/go/client.go +++ b/hindsight-clients/go/client.go @@ -49,6 +49,8 @@ type APIClient struct { // API Services + AuditAPI *AuditAPIService + BanksAPI *BanksAPIService DirectivesAPI *DirectivesAPIService @@ -86,6 +88,7 @@ func NewAPIClient(cfg *Configuration) *APIClient { c.common.client = c // API Services + c.AuditAPI = (*AuditAPIService)(&c.common) c.BanksAPI = (*BanksAPIService)(&c.common) c.DirectivesAPI = (*DirectivesAPIService)(&c.common) c.DocumentsAPI = (*DocumentsAPIService)(&c.common) diff --git a/hindsight-clients/python/.openapi-generator/FILES b/hindsight-clients/python/.openapi-generator/FILES index cbd2d1bbc..9ddf3ad33 100644 --- a/hindsight-clients/python/.openapi-generator/FILES +++ b/hindsight-clients/python/.openapi-generator/FILES @@ -1,5 +1,6 @@ hindsight_client_api/__init__.py hindsight_client_api/api/__init__.py +hindsight_client_api/api/audit_api.py hindsight_client_api/api/banks_api.py hindsight_client_api/api/directives_api.py hindsight_client_api/api/documents_api.py diff --git a/hindsight-clients/python/hindsight_client_api/__init__.py b/hindsight-clients/python/hindsight_client_api/__init__.py index 65008252d..dc302b92c 100644 --- a/hindsight-clients/python/hindsight_client_api/__init__.py +++ b/hindsight-clients/python/hindsight_client_api/__init__.py @@ -17,6 +17,7 @@ __version__ = "0.0.7" # import apis into sdk package +from hindsight_client_api.api.audit_api import AuditApi from hindsight_client_api.api.banks_api import BanksApi from hindsight_client_api.api.directives_api import DirectivesApi from hindsight_client_api.api.documents_api import DocumentsApi diff --git a/hindsight-clients/python/hindsight_client_api/api/__init__.py b/hindsight-clients/python/hindsight_client_api/api/__init__.py index 2df64cf72..7573e5e70 100644 --- a/hindsight-clients/python/hindsight_client_api/api/__init__.py +++ b/hindsight-clients/python/hindsight_client_api/api/__init__.py @@ -1,6 +1,7 @@ # flake8: noqa # import apis into api package +from hindsight_client_api.api.audit_api import AuditApi from hindsight_client_api.api.banks_api import BanksApi from hindsight_client_api.api.directives_api import DirectivesApi from hindsight_client_api.api.documents_api import DocumentsApi diff --git a/hindsight-clients/python/hindsight_client_api/api/audit_api.py b/hindsight-clients/python/hindsight_client_api/api/audit_api.py new file mode 100644 index 000000000..07bcc9a5c --- /dev/null +++ b/hindsight-clients/python/hindsight_client_api/api/audit_api.py @@ -0,0 +1,730 @@ +# coding: utf-8 + +""" + Hindsight HTTP API + + HTTP API for Hindsight + + The version of the OpenAPI document: 0.4.20 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + +import warnings +from pydantic import validate_call, Field, StrictFloat, StrictStr, StrictInt +from typing import Any, Dict, List, Optional, Tuple, Union +from typing_extensions import Annotated + +from pydantic import Field, StrictStr +from typing import Any, Optional +from typing_extensions import Annotated + +from hindsight_client_api.api_client import ApiClient, RequestSerialized +from hindsight_client_api.api_response import ApiResponse +from hindsight_client_api.rest import RESTResponseType + + +class AuditApi: + """NOTE: This class is auto generated by OpenAPI Generator + Ref: https://openapi-generator.tech + + Do not edit the class manually. + """ + + def __init__(self, api_client=None) -> None: + if api_client is None: + api_client = ApiClient.get_default() + self.api_client = api_client + + + @validate_call + async def audit_log_stats( + self, + bank_id: StrictStr, + action: Annotated[Optional[StrictStr], Field(description="Filter by action type")] = None, + period: Annotated[Optional[StrictStr], Field(description="Time period: 1d, 7d, or 30d")] = None, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> object: + """Audit log statistics + + Get audit log counts grouped by time bucket for charting. + + :param bank_id: (required) + :type bank_id: str + :param action: Filter by action type + :type action: str + :param period: Time period: 1d, 7d, or 30d + :type period: str + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._audit_log_stats_serialize( + bank_id=bank_id, + action=action, + period=period, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "object", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ).data + + + @validate_call + async def audit_log_stats_with_http_info( + self, + bank_id: StrictStr, + action: Annotated[Optional[StrictStr], Field(description="Filter by action type")] = None, + period: Annotated[Optional[StrictStr], Field(description="Time period: 1d, 7d, or 30d")] = None, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> ApiResponse[object]: + """Audit log statistics + + Get audit log counts grouped by time bucket for charting. + + :param bank_id: (required) + :type bank_id: str + :param action: Filter by action type + :type action: str + :param period: Time period: 1d, 7d, or 30d + :type period: str + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._audit_log_stats_serialize( + bank_id=bank_id, + action=action, + period=period, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "object", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ) + + + @validate_call + async def audit_log_stats_without_preload_content( + self, + bank_id: StrictStr, + action: Annotated[Optional[StrictStr], Field(description="Filter by action type")] = None, + period: Annotated[Optional[StrictStr], Field(description="Time period: 1d, 7d, or 30d")] = None, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> RESTResponseType: + """Audit log statistics + + Get audit log counts grouped by time bucket for charting. + + :param bank_id: (required) + :type bank_id: str + :param action: Filter by action type + :type action: str + :param period: Time period: 1d, 7d, or 30d + :type period: str + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._audit_log_stats_serialize( + bank_id=bank_id, + action=action, + period=period, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "object", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + return response_data.response + + + def _audit_log_stats_serialize( + self, + bank_id, + action, + period, + authorization, + _request_auth, + _content_type, + _headers, + _host_index, + ) -> RequestSerialized: + + _host = None + + _collection_formats: Dict[str, str] = { + } + + _path_params: Dict[str, str] = {} + _query_params: List[Tuple[str, str]] = [] + _header_params: Dict[str, Optional[str]] = _headers or {} + _form_params: List[Tuple[str, str]] = [] + _files: Dict[ + str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]] + ] = {} + _body_params: Optional[bytes] = None + + # process the path parameters + if bank_id is not None: + _path_params['bank_id'] = bank_id + # process the query parameters + if action is not None: + + _query_params.append(('action', action)) + + if period is not None: + + _query_params.append(('period', period)) + + # process the header parameters + if authorization is not None: + _header_params['authorization'] = authorization + # process the form parameters + # process the body parameter + + + # set the HTTP header `Accept` + if 'Accept' not in _header_params: + _header_params['Accept'] = self.api_client.select_header_accept( + [ + 'application/json' + ] + ) + + + # authentication setting + _auth_settings: List[str] = [ + ] + + return self.api_client.param_serialize( + method='GET', + resource_path='/v1/default/banks/{bank_id}/audit-logs/stats', + path_params=_path_params, + query_params=_query_params, + header_params=_header_params, + body=_body_params, + post_params=_form_params, + files=_files, + auth_settings=_auth_settings, + collection_formats=_collection_formats, + _host=_host, + _request_auth=_request_auth + ) + + + + + @validate_call + async def list_audit_logs( + self, + bank_id: StrictStr, + action: Annotated[Optional[StrictStr], Field(description="Filter by action type")] = None, + transport: Annotated[Optional[StrictStr], Field(description="Filter by transport (http, mcp, system)")] = None, + start_date: Annotated[Optional[StrictStr], Field(description="Filter from this ISO datetime (inclusive)")] = None, + end_date: Annotated[Optional[StrictStr], Field(description="Filter until this ISO datetime (exclusive)")] = None, + limit: Annotated[Optional[Annotated[int, Field(le=500, strict=True, ge=1)]], Field(description="Max items to return")] = None, + offset: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Offset for pagination")] = None, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> object: + """List audit logs + + List audit log entries for a bank, ordered by most recent first. + + :param bank_id: (required) + :type bank_id: str + :param action: Filter by action type + :type action: str + :param transport: Filter by transport (http, mcp, system) + :type transport: str + :param start_date: Filter from this ISO datetime (inclusive) + :type start_date: str + :param end_date: Filter until this ISO datetime (exclusive) + :type end_date: str + :param limit: Max items to return + :type limit: int + :param offset: Offset for pagination + :type offset: int + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._list_audit_logs_serialize( + bank_id=bank_id, + action=action, + transport=transport, + start_date=start_date, + end_date=end_date, + limit=limit, + offset=offset, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "object", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ).data + + + @validate_call + async def list_audit_logs_with_http_info( + self, + bank_id: StrictStr, + action: Annotated[Optional[StrictStr], Field(description="Filter by action type")] = None, + transport: Annotated[Optional[StrictStr], Field(description="Filter by transport (http, mcp, system)")] = None, + start_date: Annotated[Optional[StrictStr], Field(description="Filter from this ISO datetime (inclusive)")] = None, + end_date: Annotated[Optional[StrictStr], Field(description="Filter until this ISO datetime (exclusive)")] = None, + limit: Annotated[Optional[Annotated[int, Field(le=500, strict=True, ge=1)]], Field(description="Max items to return")] = None, + offset: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Offset for pagination")] = None, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> ApiResponse[object]: + """List audit logs + + List audit log entries for a bank, ordered by most recent first. + + :param bank_id: (required) + :type bank_id: str + :param action: Filter by action type + :type action: str + :param transport: Filter by transport (http, mcp, system) + :type transport: str + :param start_date: Filter from this ISO datetime (inclusive) + :type start_date: str + :param end_date: Filter until this ISO datetime (exclusive) + :type end_date: str + :param limit: Max items to return + :type limit: int + :param offset: Offset for pagination + :type offset: int + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._list_audit_logs_serialize( + bank_id=bank_id, + action=action, + transport=transport, + start_date=start_date, + end_date=end_date, + limit=limit, + offset=offset, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "object", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ) + + + @validate_call + async def list_audit_logs_without_preload_content( + self, + bank_id: StrictStr, + action: Annotated[Optional[StrictStr], Field(description="Filter by action type")] = None, + transport: Annotated[Optional[StrictStr], Field(description="Filter by transport (http, mcp, system)")] = None, + start_date: Annotated[Optional[StrictStr], Field(description="Filter from this ISO datetime (inclusive)")] = None, + end_date: Annotated[Optional[StrictStr], Field(description="Filter until this ISO datetime (exclusive)")] = None, + limit: Annotated[Optional[Annotated[int, Field(le=500, strict=True, ge=1)]], Field(description="Max items to return")] = None, + offset: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Offset for pagination")] = None, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> RESTResponseType: + """List audit logs + + List audit log entries for a bank, ordered by most recent first. + + :param bank_id: (required) + :type bank_id: str + :param action: Filter by action type + :type action: str + :param transport: Filter by transport (http, mcp, system) + :type transport: str + :param start_date: Filter from this ISO datetime (inclusive) + :type start_date: str + :param end_date: Filter until this ISO datetime (exclusive) + :type end_date: str + :param limit: Max items to return + :type limit: int + :param offset: Offset for pagination + :type offset: int + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._list_audit_logs_serialize( + bank_id=bank_id, + action=action, + transport=transport, + start_date=start_date, + end_date=end_date, + limit=limit, + offset=offset, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "object", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + return response_data.response + + + def _list_audit_logs_serialize( + self, + bank_id, + action, + transport, + start_date, + end_date, + limit, + offset, + authorization, + _request_auth, + _content_type, + _headers, + _host_index, + ) -> RequestSerialized: + + _host = None + + _collection_formats: Dict[str, str] = { + } + + _path_params: Dict[str, str] = {} + _query_params: List[Tuple[str, str]] = [] + _header_params: Dict[str, Optional[str]] = _headers or {} + _form_params: List[Tuple[str, str]] = [] + _files: Dict[ + str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]] + ] = {} + _body_params: Optional[bytes] = None + + # process the path parameters + if bank_id is not None: + _path_params['bank_id'] = bank_id + # process the query parameters + if action is not None: + + _query_params.append(('action', action)) + + if transport is not None: + + _query_params.append(('transport', transport)) + + if start_date is not None: + + _query_params.append(('start_date', start_date)) + + if end_date is not None: + + _query_params.append(('end_date', end_date)) + + if limit is not None: + + _query_params.append(('limit', limit)) + + if offset is not None: + + _query_params.append(('offset', offset)) + + # process the header parameters + if authorization is not None: + _header_params['authorization'] = authorization + # process the form parameters + # process the body parameter + + + # set the HTTP header `Accept` + if 'Accept' not in _header_params: + _header_params['Accept'] = self.api_client.select_header_accept( + [ + 'application/json' + ] + ) + + + # authentication setting + _auth_settings: List[str] = [ + ] + + return self.api_client.param_serialize( + method='GET', + resource_path='/v1/default/banks/{bank_id}/audit-logs', + path_params=_path_params, + query_params=_query_params, + header_params=_header_params, + body=_body_params, + post_params=_form_params, + files=_files, + auth_settings=_auth_settings, + collection_formats=_collection_formats, + _host=_host, + _request_auth=_request_auth + ) + + diff --git a/hindsight-clients/typescript/generated/sdk.gen.ts b/hindsight-clients/typescript/generated/sdk.gen.ts index ead1fa773..56bd573da 100644 --- a/hindsight-clients/typescript/generated/sdk.gen.ts +++ b/hindsight-clients/typescript/generated/sdk.gen.ts @@ -11,6 +11,9 @@ import type { AddBankBackgroundData, AddBankBackgroundErrors, AddBankBackgroundResponses, + AuditLogStatsData, + AuditLogStatsErrors, + AuditLogStatsResponses, CancelOperationData, CancelOperationErrors, CancelOperationResponses, @@ -96,6 +99,9 @@ import type { GetVersionResponses, HealthEndpointHealthGetData, HealthEndpointHealthGetResponses, + ListAuditLogsData, + ListAuditLogsErrors, + ListAuditLogsResponses, ListBanksData, ListBanksErrors, ListBanksResponses, @@ -1226,3 +1232,31 @@ export const fileRetain = ( ...options.headers, }, }); + +/** + * List audit logs + * + * List audit log entries for a bank, ordered by most recent first. + */ +export const listAuditLogs = ( + options: Options, +) => + (options.client ?? client).get< + ListAuditLogsResponses, + ListAuditLogsErrors, + ThrowOnError + >({ url: "/v1/default/banks/{bank_id}/audit-logs", ...options }); + +/** + * Audit log statistics + * + * Get audit log counts grouped by time bucket for charting. + */ +export const auditLogStats = ( + options: Options, +) => + (options.client ?? client).get< + AuditLogStatsResponses, + AuditLogStatsErrors, + ThrowOnError + >({ url: "/v1/default/banks/{bank_id}/audit-logs/stats", ...options }); diff --git a/hindsight-clients/typescript/generated/types.gen.ts b/hindsight-clients/typescript/generated/types.gen.ts index a74979a57..d2412c705 100644 --- a/hindsight-clients/typescript/generated/types.gen.ts +++ b/hindsight-clients/typescript/generated/types.gen.ts @@ -4868,3 +4868,121 @@ export type FileRetainResponses = { export type FileRetainResponse2 = FileRetainResponses[keyof FileRetainResponses]; + +export type ListAuditLogsData = { + body?: never; + headers?: { + /** + * Authorization + */ + authorization?: string | null; + }; + path: { + /** + * Bank Id + */ + bank_id: string; + }; + query?: { + /** + * Action + * + * Filter by action type + */ + action?: string | null; + /** + * Transport + * + * Filter by transport (http, mcp, system) + */ + transport?: string | null; + /** + * Start Date + * + * Filter from this ISO datetime (inclusive) + */ + start_date?: string | null; + /** + * End Date + * + * Filter until this ISO datetime (exclusive) + */ + end_date?: string | null; + /** + * Limit + * + * Max items to return + */ + limit?: number; + /** + * Offset + * + * Offset for pagination + */ + offset?: number; + }; + url: "/v1/default/banks/{bank_id}/audit-logs"; +}; + +export type ListAuditLogsErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type ListAuditLogsError = ListAuditLogsErrors[keyof ListAuditLogsErrors]; + +export type ListAuditLogsResponses = { + /** + * Successful Response + */ + 200: unknown; +}; + +export type AuditLogStatsData = { + body?: never; + headers?: { + /** + * Authorization + */ + authorization?: string | null; + }; + path: { + /** + * Bank Id + */ + bank_id: string; + }; + query?: { + /** + * Action + * + * Filter by action type + */ + action?: string | null; + /** + * Period + * + * Time period: 1d, 7d, or 30d + */ + period?: string; + }; + url: "/v1/default/banks/{bank_id}/audit-logs/stats"; +}; + +export type AuditLogStatsErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type AuditLogStatsError = AuditLogStatsErrors[keyof AuditLogStatsErrors]; + +export type AuditLogStatsResponses = { + /** + * Successful Response + */ + 200: unknown; +}; diff --git a/hindsight-control-plane/package.json b/hindsight-control-plane/package.json index 79c08241d..62cdfb748 100644 --- a/hindsight-control-plane/package.json +++ b/hindsight-control-plane/package.json @@ -61,6 +61,7 @@ "react": "^19.2.0", "react-chrono": "^2.9.1", "react-dom": "^19.2.0", + "react-is": "^19.2.4", "react-markdown": "^10.1.0", "react18-json-view": "^0.2.9", "recharts": "^3.5.1", diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/audit-logs/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/audit-logs/route.ts new file mode 100644 index 000000000..f360f07e1 --- /dev/null +++ b/hindsight-control-plane/src/app/api/banks/[bankId]/audit-logs/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server"; +import { DATAPLANE_URL, getDataplaneHeaders } from "@/lib/hindsight-client"; + +export async function GET(request: Request, { params }: { params: Promise<{ bankId: string }> }) { + try { + const { bankId } = await params; + + if (!bankId) { + return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); + } + + // Forward query params + const { searchParams } = new URL(request.url); + const query = searchParams.toString(); + + const url = `${DATAPLANE_URL}/v1/default/banks/${bankId}/audit-logs${query ? `?${query}` : ""}`; + const response = await fetch(url, { + method: "GET", + headers: getDataplaneHeaders(), + }); + + const data = await response.json(); + + if (!response.ok) { + return NextResponse.json( + { error: data.detail || "Failed to list audit logs" }, + { status: response.status } + ); + } + + return NextResponse.json(data, { status: 200 }); + } catch (error) { + console.error("Error listing audit logs:", error); + return NextResponse.json({ error: "Failed to list audit logs" }, { status: 500 }); + } +} diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/audit-logs/stats/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/audit-logs/stats/route.ts new file mode 100644 index 000000000..8cc102675 --- /dev/null +++ b/hindsight-control-plane/src/app/api/banks/[bankId]/audit-logs/stats/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server"; +import { DATAPLANE_URL, getDataplaneHeaders } from "@/lib/hindsight-client"; + +export async function GET(request: Request, { params }: { params: Promise<{ bankId: string }> }) { + try { + const { bankId } = await params; + if (!bankId) { + return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); + } + + const { searchParams } = new URL(request.url); + const query = searchParams.toString(); + + const url = `${DATAPLANE_URL}/v1/default/banks/${bankId}/audit-logs/stats${query ? `?${query}` : ""}`; + const response = await fetch(url, { + method: "GET", + headers: getDataplaneHeaders(), + }); + + const data = await response.json(); + if (!response.ok) { + return NextResponse.json( + { error: data.detail || "Failed to get audit log stats" }, + { status: response.status } + ); + } + + return NextResponse.json(data, { status: 200 }); + } catch (error) { + console.error("Error getting audit log stats:", error); + return NextResponse.json({ error: "Failed to get audit log stats" }, { status: 500 }); + } +} diff --git a/hindsight-control-plane/src/app/banks/[bankId]/page.tsx b/hindsight-control-plane/src/app/banks/[bankId]/page.tsx index a4a213db4..81767b5ee 100644 --- a/hindsight-control-plane/src/app/banks/[bankId]/page.tsx +++ b/hindsight-control-plane/src/app/banks/[bankId]/page.tsx @@ -16,6 +16,7 @@ import { BankStatsView } from "@/components/bank-stats-view"; import { BankOperationsView } from "@/components/bank-operations-view"; import { MentalModelsView } from "@/components/mental-models-view"; import { WebhooksView } from "@/components/webhooks-view"; +import { AuditLogsView } from "@/components/audit-logs-view"; import { useFeatures } from "@/lib/features-context"; import { useBank } from "@/lib/bank-context"; import { client } from "@/lib/api"; @@ -41,7 +42,7 @@ import { Brain, Trash2, Loader2, MoreVertical, Pencil, RotateCcw } from "lucide- type NavItem = "recall" | "reflect" | "data" | "documents" | "entities" | "profile"; type DataSubTab = "world" | "experience" | "observations" | "mental-models"; -type BankConfigTab = "general" | "configuration" | "webhooks"; +type BankConfigTab = "general" | "configuration" | "webhooks" | "audit-logs"; export default function BankPage() { const params = useParams(); @@ -298,6 +299,19 @@ export default function BankPage() {
)} +
@@ -329,6 +343,14 @@ export default function BankPage() { )} + {bankConfigTab === "audit-logs" && ( +
+

+ View audit trail of all operations performed on this memory bank. +

+ +
+ )} )} diff --git a/hindsight-control-plane/src/components/audit-logs-view.tsx b/hindsight-control-plane/src/components/audit-logs-view.tsx new file mode 100644 index 000000000..3b4dbe0e4 --- /dev/null +++ b/hindsight-control-plane/src/components/audit-logs-view.tsx @@ -0,0 +1,517 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useBank } from "@/lib/bank-context"; +import { client, AuditLogEntry, AuditStatsBucket } from "@/lib/api"; +import { Button } from "@/components/ui/button"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { RefreshCw, ChevronLeft, ChevronRight } from "lucide-react"; +import { LineChart, Line, XAxis, Tooltip, ResponsiveContainer } from "recharts"; + +const ACTION_OPTIONS = [ + { value: "all", label: "All actions" }, + { value: "retain", label: "Retain" }, + { value: "recall", label: "Recall" }, + { value: "reflect", label: "Reflect" }, + { value: "create_bank", label: "Create Bank" }, + { value: "update_bank", label: "Update Bank" }, + { value: "delete_bank", label: "Delete Bank" }, + { value: "clear_memories", label: "Clear Memories" }, + { value: "consolidation", label: "Consolidation" }, + { value: "batch_retain", label: "Batch Retain" }, + { value: "create_mental_model", label: "Create Mental Model" }, + { value: "refresh_mental_model", label: "Refresh Mental Model" }, + { value: "delete_mental_model", label: "Delete Mental Model" }, + { value: "create_directive", label: "Create Directive" }, + { value: "delete_directive", label: "Delete Directive" }, + { value: "file_convert_retain", label: "File Convert & Retain" }, + { value: "webhook_delivery", label: "Webhook Delivery" }, +]; + +const TRANSPORT_OPTIONS = [ + { value: "all", label: "All transports" }, + { value: "http", label: "HTTP" }, + { value: "mcp", label: "MCP" }, + { value: "system", label: "System" }, +]; + +const PERIOD_OPTIONS = [ + { value: "1d", label: "Today" }, + { value: "7d", label: "Last 7 days" }, + { value: "30d", label: "Last 30 days" }, +]; + +function formatDuration(startedAt: string | null, endedAt: string | null): string { + if (!startedAt || !endedAt) return "—"; + const start = new Date(startedAt).getTime(); + const end = new Date(endedAt).getTime(); + const ms = end - start; + if (ms < 1000) return `${ms}ms`; + if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`; + return `${(ms / 60000).toFixed(1)}m`; +} + +function formatDateTime(ts: string | null): string { + if (!ts) return "—"; + const date = new Date(ts); + return date.toLocaleString(undefined, { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +} + +function formatChartLabel(ts: string, trunc: string): string { + const date = new Date(ts); + if (trunc === "hour") { + return date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); + } + return date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); +} + +function TransportBadge({ transport }: { transport: string }) { + const styles: Record = { + http: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300", + mcp: "bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300", + system: "bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300", + }; + return ( + + {transport} + + ); +} + +// ---- Chart Section ---- + +function AuditChart({ bankId }: { bankId: string }) { + const [period, setPeriod] = useState("7d"); + const [chartAction, setChartAction] = useState(null); + const [buckets, setBuckets] = useState([]); + const [trunc, setTrunc] = useState("day"); + const [loading, setLoading] = useState(false); + + const loadStats = useCallback( + async (p: string = period, a: string | null = chartAction) => { + setLoading(true); + try { + const data = await client.getAuditLogStats(bankId, { + period: p, + action: a || undefined, + }); + setBuckets(data.buckets || []); + setTrunc(data.trunc || "day"); + } catch (error) { + console.error("Error loading audit stats:", error); + } finally { + setLoading(false); + } + }, + [bankId, period, chartAction] + ); + + useEffect(() => { + loadStats(); + }, [bankId]); + + const chartData = buckets.map((b) => ({ + time: formatChartLabel(b.time, trunc), + total: b.total, + })); + + return ( +
+
+

Request Volume

+
+ + {PERIOD_OPTIONS.map((opt) => ( + + ))} +
+
+
+ {loading ? ( +
+ Loading... +
+ ) : chartData.length === 0 ? ( +
+ No data for this period +
+ ) : ( + + + + + + + + )} +
+
+ ); +} + +// ---- Main Component ---- + +export function AuditLogsView() { + const { currentBank } = useBank(); + const [logs, setLogs] = useState([]); + const [total, setTotal] = useState(0); + const [actionFilter, setActionFilter] = useState(null); + const [transportFilter, setTransportFilter] = useState(null); + const [dateRange, setDateRange] = useState("all"); + const [limit] = useState(20); + const [offset, setOffset] = useState(0); + const [loading, setLoading] = useState(false); + const [selectedLog, setSelectedLog] = useState(null); + const [dialogOpen, setDialogOpen] = useState(false); + + const getDateRange = useCallback((range: string): { start_date?: string; end_date?: string } => { + if (range === "all") return {}; + const now = new Date(); + const start = new Date(); + if (range === "1h") start.setHours(now.getHours() - 1); + else if (range === "1d") start.setDate(now.getDate() - 1); + else if (range === "7d") start.setDate(now.getDate() - 7); + else if (range === "30d") start.setDate(now.getDate() - 30); + return { start_date: start.toISOString() }; + }, []); + + const loadLogs = useCallback( + async ( + newActionFilter: string | null = actionFilter, + newTransportFilter: string | null = transportFilter, + newDateRange: string = dateRange, + newOffset: number = offset + ) => { + if (!currentBank) return; + + setLoading(true); + try { + const dates = getDateRange(newDateRange); + const data = await client.listAuditLogs(currentBank, { + action: newActionFilter || undefined, + transport: newTransportFilter || undefined, + start_date: dates.start_date, + end_date: dates.end_date, + limit, + offset: newOffset, + }); + setLogs(data.items || []); + setTotal(data.total || 0); + } catch (error) { + console.error("Error loading audit logs:", error); + } finally { + setLoading(false); + } + }, + [currentBank, actionFilter, transportFilter, dateRange, offset, limit, getDateRange] + ); + + const handleActionFilterChange = (value: string) => { + const filter = value === "all" ? null : value; + setActionFilter(filter); + setOffset(0); + loadLogs(filter, transportFilter, dateRange, 0); + }; + + const handleTransportFilterChange = (value: string) => { + const filter = value === "all" ? null : value; + setTransportFilter(filter); + setOffset(0); + loadLogs(actionFilter, filter, dateRange, 0); + }; + + const handleDateRangeChange = (value: string) => { + setDateRange(value); + setOffset(0); + loadLogs(actionFilter, transportFilter, value, 0); + }; + + const handlePageChange = (newOffset: number) => { + setOffset(newOffset); + loadLogs(actionFilter, transportFilter, dateRange, newOffset); + }; + + const handleLogClick = (log: AuditLogEntry) => { + setSelectedLog(log); + setDialogOpen(true); + }; + + useEffect(() => { + if (currentBank) { + loadLogs(actionFilter, transportFilter, dateRange, offset); + } + }, [currentBank]); + + const totalPages = Math.ceil(total / limit); + const currentPage = Math.floor(offset / limit) + 1; + + if (!currentBank) return null; + + return ( +
+ {/* Chart */} + + + {/* Filters */} +
+ + + + + + + + + + {total} {total === 1 ? "entry" : "entries"} + +
+ + {/* Table */} +
+ + + + Time + Action + Transport + Duration + + + + {logs.length === 0 ? ( + + + {loading ? "Loading..." : "No audit logs found"} + + + ) : ( + logs.map((log) => ( + handleLogClick(log)} + > + + {formatDateTime(log.started_at)} + + {log.action} + + + + + {formatDuration(log.started_at, log.ended_at)} + + + )) + )} + +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + Page {currentPage} of {totalPages} + +
+ + +
+
+ )} + + {/* Detail Dialog */} + + + + Audit Log: {selectedLog?.action} + + {selectedLog && ( +
+
+
+ Action:{" "} + {selectedLog.action} +
+
+ Transport:{" "} + +
+
+ Started:{" "} + {formatDateTime(selectedLog.started_at)} +
+
+ Duration:{" "} + + {formatDuration(selectedLog.started_at, selectedLog.ended_at)} + +
+
+ + {selectedLog.request && ( +
+

Request

+
+                    {JSON.stringify(selectedLog.request, null, 2)}
+                  
+
+ )} + + {selectedLog.response && ( +
+

Response

+
+                    {JSON.stringify(selectedLog.response, null, 2)}
+                  
+
+ )} + + {selectedLog.metadata && Object.keys(selectedLog.metadata).length > 0 && ( +
+

Metadata

+
+                    {JSON.stringify(selectedLog.metadata, null, 2)}
+                  
+
+ )} +
+ )} +
+
+
+ ); +} diff --git a/hindsight-control-plane/src/lib/api.ts b/hindsight-control-plane/src/lib/api.ts index 971a73e14..2f3deab27 100644 --- a/hindsight-control-plane/src/lib/api.ts +++ b/hindsight-control-plane/src/lib/api.ts @@ -39,6 +39,40 @@ export interface WebhookDelivery { updated_at: string | null; } +export interface AuditLogEntry { + id: string; + action: string; + transport: string; + bank_id: string | null; + started_at: string | null; + ended_at: string | null; + request: Record | null; + response: Record | null; + metadata: Record; +} + +export interface AuditLogsResponse { + bank_id: string; + total: number; + limit: number; + offset: number; + items: AuditLogEntry[]; +} + +export interface AuditStatsBucket { + time: string; + actions: Record; + total: number; +} + +export interface AuditStatsResponse { + bank_id: string; + period: string; + trunc: string; + start: string; + buckets: AuditStatsBucket[]; +} + export interface MentalModel { id: string; bank_id: string; @@ -1079,6 +1113,46 @@ export class ControlPlaneClient { `/api/banks/${bankId}/webhooks/${webhookId}/deliveries${query ? `?${query}` : ""}` ); } + + /** + * List audit logs for a bank + */ + async listAuditLogs( + bankId: string, + options?: { + action?: string; + transport?: string; + start_date?: string; + end_date?: string; + limit?: number; + offset?: number; + } + ): Promise { + const params = new URLSearchParams(); + if (options?.action) params.append("action", options.action); + if (options?.transport) params.append("transport", options.transport); + if (options?.start_date) params.append("start_date", options.start_date); + if (options?.end_date) params.append("end_date", options.end_date); + if (options?.limit) params.append("limit", options.limit.toString()); + if (options?.offset) params.append("offset", options.offset.toString()); + const query = params.toString(); + return this.fetchApi( + `/api/banks/${bankId}/audit-logs${query ? `?${query}` : ""}` + ); + } + + async getAuditLogStats( + bankId: string, + options?: { action?: string; period?: string } + ): Promise { + const params = new URLSearchParams(); + if (options?.action) params.append("action", options.action); + if (options?.period) params.append("period", options.period); + const query = params.toString(); + return this.fetchApi( + `/api/banks/${bankId}/audit-logs/stats${query ? `?${query}` : ""}` + ); + } } // Export singleton instance diff --git a/hindsight-docs/static/openapi.json b/hindsight-docs/static/openapi.json index d5116425f..070788f1b 100644 --- a/hindsight-docs/static/openapi.json +++ b/hindsight-docs/static/openapi.json @@ -4075,6 +4075,249 @@ } } } + }, + "/v1/default/banks/{bank_id}/audit-logs": { + "get": { + "tags": [ + "Audit" + ], + "summary": "List audit logs", + "description": "List audit log entries for a bank, ordered by most recent first.", + "operationId": "list_audit_logs", + "parameters": [ + { + "name": "bank_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Bank Id" + } + }, + { + "name": "action", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Filter by action type", + "title": "Action" + }, + "description": "Filter by action type" + }, + { + "name": "transport", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Filter by transport (http, mcp, system)", + "title": "Transport" + }, + "description": "Filter by transport (http, mcp, system)" + }, + { + "name": "start_date", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Filter from this ISO datetime (inclusive)", + "title": "Start Date" + }, + "description": "Filter from this ISO datetime (inclusive)" + }, + { + "name": "end_date", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Filter until this ISO datetime (exclusive)", + "title": "End Date" + }, + "description": "Filter until this ISO datetime (exclusive)" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 500, + "minimum": 1, + "description": "Max items to return", + "default": 50, + "title": "Limit" + }, + "description": "Max items to return" + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 0, + "description": "Offset for pagination", + "default": 0, + "title": "Offset" + }, + "description": "Offset for pagination" + }, + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/v1/default/banks/{bank_id}/audit-logs/stats": { + "get": { + "tags": [ + "Audit" + ], + "summary": "Audit log statistics", + "description": "Get audit log counts grouped by time bucket for charting.", + "operationId": "audit_log_stats", + "parameters": [ + { + "name": "bank_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Bank Id" + } + }, + { + "name": "action", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Filter by action type", + "title": "Action" + }, + "description": "Filter by action type" + }, + { + "name": "period", + "in": "query", + "required": false, + "schema": { + "type": "string", + "description": "Time period: 1d, 7d, or 30d", + "default": "7d", + "title": "Period" + }, + "description": "Time period: 1d, 7d, or 30d" + }, + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } } }, "components": { diff --git a/package-lock.json b/package-lock.json index 130a27ee9..797dfd7a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ }, "hindsight-clients/typescript": { "name": "@vectorize-io/hindsight-client", - "version": "0.4.19", + "version": "0.4.20", "license": "MIT", "devDependencies": { "@hey-api/openapi-ts": "0.88.0", @@ -293,7 +293,7 @@ }, "hindsight-control-plane": { "name": "@vectorize-io/hindsight-control-plane", - "version": "0.4.19", + "version": "0.4.20", "license": "ISC", "dependencies": { "@radix-ui/react-alert-dialog": "^1.1.15", @@ -330,6 +330,7 @@ "react": "^19.2.0", "react-chrono": "^2.9.1", "react-dom": "^19.2.0", + "react-is": "^19.2.4", "react-markdown": "^10.1.0", "react18-json-view": "^0.2.9", "recharts": "^3.5.1", @@ -25741,6 +25742,12 @@ "react": "*" } }, + "node_modules/react-is": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", + "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", + "license": "MIT" + }, "node_modules/react-json-view-lite": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/react-json-view-lite/-/react-json-view-lite-2.5.0.tgz", diff --git a/skills/hindsight-docs/references/openapi.json b/skills/hindsight-docs/references/openapi.json index d5116425f..070788f1b 100644 --- a/skills/hindsight-docs/references/openapi.json +++ b/skills/hindsight-docs/references/openapi.json @@ -4075,6 +4075,249 @@ } } } + }, + "/v1/default/banks/{bank_id}/audit-logs": { + "get": { + "tags": [ + "Audit" + ], + "summary": "List audit logs", + "description": "List audit log entries for a bank, ordered by most recent first.", + "operationId": "list_audit_logs", + "parameters": [ + { + "name": "bank_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Bank Id" + } + }, + { + "name": "action", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Filter by action type", + "title": "Action" + }, + "description": "Filter by action type" + }, + { + "name": "transport", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Filter by transport (http, mcp, system)", + "title": "Transport" + }, + "description": "Filter by transport (http, mcp, system)" + }, + { + "name": "start_date", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Filter from this ISO datetime (inclusive)", + "title": "Start Date" + }, + "description": "Filter from this ISO datetime (inclusive)" + }, + { + "name": "end_date", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Filter until this ISO datetime (exclusive)", + "title": "End Date" + }, + "description": "Filter until this ISO datetime (exclusive)" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 500, + "minimum": 1, + "description": "Max items to return", + "default": 50, + "title": "Limit" + }, + "description": "Max items to return" + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 0, + "description": "Offset for pagination", + "default": 0, + "title": "Offset" + }, + "description": "Offset for pagination" + }, + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/v1/default/banks/{bank_id}/audit-logs/stats": { + "get": { + "tags": [ + "Audit" + ], + "summary": "Audit log statistics", + "description": "Get audit log counts grouped by time bucket for charting.", + "operationId": "audit_log_stats", + "parameters": [ + { + "name": "bank_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Bank Id" + } + }, + { + "name": "action", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Filter by action type", + "title": "Action" + }, + "description": "Filter by action type" + }, + { + "name": "period", + "in": "query", + "required": false, + "schema": { + "type": "string", + "description": "Time period: 1d, 7d, or 30d", + "default": "7d", + "title": "Period" + }, + "description": "Time period: 1d, 7d, or 30d" + }, + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } } }, "components": {