feat: add audit log for feature usage tracking (#717)

* feat: add audit log for feature usage tracking

Add full auditability for all mutating and core API operations across
HTTP, MCP, and system (worker) transports. Audit entries record raw
request/response as JSONB, timing (started_at/ended_at), action, and
transport type.

Backend:
- New audit_log table with JSONB columns for expandability without
  future migrations (merge migration of 3 existing heads)
- AuditLogger with fire-and-forget writes via asyncio.create_task
- @audited decorator on 28 HTTP route handlers
- MCP tool audit wrapping for 16 auditable tools
- Worker task execution wrapped with audit_context
- List endpoint with action, transport, date range filters + pagination
- Stats endpoint with per-day counts for charting
- Configurable retention sweep (concurrent-safe DELETE)

Config (env-only, static):
- HINDSIGHT_API_AUDIT_LOG_ENABLED (default: false)
- HINDSIGHT_API_AUDIT_LOG_ACTIONS (comma-separated allowlist, empty=all)
- HINDSIGHT_API_AUDIT_LOG_RETENTION_DAYS (default: -1, keep forever)

Control Plane:
- New "Audit Logs" tab on bank configuration page
- Line chart showing request volume (today/7d/30d) with action filter
- Filterable table with action, transport, date range filters
- Paginated list with detail dialog showing raw request/response JSON

Tests:
- 13 tests covering list, filters, pagination, stats, disabled mode,
  action allowlist, and ordering

* fix: split 3-way merge migration into two 2-way merges

Alembic doesn't support 3-parent merge migrations. Split into a no-op
merge of 2 heads (b1c2d3e4f5g6) followed by the audit_log table
migration merging the third head.

* fix: correct merge migration to merge actual 2 heads

The original analysis incorrectly identified 3 heads. There were only 2
(a3b4c5d6e7f8 and c8e5f2a3b4d1). Remove the unnecessary intermediate
merge migration and fix the audit_log migration to merge these 2 heads.

* fix: use 'heads' instead of 'head' in migration runner

Alembic's upgrade('head') fails when multiple heads exist (e.g. from
namespace package overlaps between hindsight-api and hindsight-api-slim).
Using 'heads' (plural) handles this gracefully by upgrading all branches.

* chore: regenerate OpenAPI spec with audit log endpoints

* chore: regenerate TypeScript client and docs skill OpenAPI spec

Python and Go clients still need regeneration (requires Docker).

* chore: regenerate all client SDKs (Python, Go, TypeScript)

Adds generated audit log API clients for Python (audit_api.py),
Go (api_audit.go), and TypeScript client type updates.
This commit is contained in:
Nicolò Boschi 2026-03-27 09:52:03 +01:00 committed by GitHub
parent 731238707d
commit 083295dc6f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 3815 additions and 64 deletions

View file

@ -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")

View file

@ -15,6 +15,7 @@ from typing import Any, Literal
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 @@ def create_app(
# 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 @@ def create_app(
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 @@ def _register_routes(app: FastAPI):
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 _register_routes(app: FastAPI):
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 @@ def _register_routes(app: FastAPI):
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 @@ def _register_routes(app: FastAPI):
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 @@ def _register_routes(app: FastAPI):
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 @@ def _register_routes(app: FastAPI):
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 @@ def _register_routes(app: FastAPI):
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 @@ def _register_routes(app: FastAPI):
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 @@ def _register_routes(app: FastAPI):
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 @@ def _register_routes(app: FastAPI):
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 @@ def _register_routes(app: FastAPI):
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 @@ def _register_routes(app: FastAPI):
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 @@ def _register_routes(app: FastAPI):
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 @@ def _register_routes(app: FastAPI):
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 @@ def _register_routes(app: FastAPI):
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 @@ def _register_routes(app: FastAPI):
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 @@ def _register_routes(app: FastAPI):
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 @@ def _register_routes(app: FastAPI):
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 @@ def _register_routes(app: FastAPI):
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 @@ def _register_routes(app: FastAPI):
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 @@ def _register_routes(app: FastAPI):
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 @@ def _register_routes(app: FastAPI):
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 @@ def _register_routes(app: FastAPI):
tags=["Webhooks"],
status_code=201,
)
@audited("create_webhook")
async def api_create_webhook(
bank_id: str,
request: CreateWebhookRequest,
@ -4374,6 +4469,7 @@ def _register_routes(app: FastAPI):
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 @@ def _register_routes(app: FastAPI):
operation_id="update_webhook",
tags=["Webhooks"],
)
@audited("update_webhook")
async def api_update_webhook(
bank_id: str,
webhook_id: str,
@ -4586,6 +4683,7 @@ def _register_routes(app: FastAPI):
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 @@ def _register_routes(app: FastAPI):
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 @@ def _register_routes(app: FastAPI):
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 @@ def _register_routes(app: FastAPI):
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))

View file

@ -342,6 +342,11 @@ ENV_REFLECT_WALL_TIMEOUT = "HINDSIGHT_API_REFLECT_WALL_TIMEOUT"
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"
@ -518,6 +523,11 @@ DEFAULT_OTEL_TRACES_ENABLED = False # Disabled by default for backward compatib
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.
@ -819,6 +829,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)
@ -1317,6 +1332,14 @@ class 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,

View file

@ -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 "<bytes>"
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)

View file

@ -28,6 +28,7 @@ from ..metrics import get_metrics_collector
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 @@ class MemoryEngine(MemoryEngineInterface):
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 @@ class MemoryEngine(MemoryEngineInterface):
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 @@ class MemoryEngine(MemoryEngineInterface):
# 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)
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
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
# 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 @@ class MemoryEngine(MemoryEngineInterface):
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 @@ class MemoryEngine(MemoryEngineInterface):
"""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()

View file

@ -8,7 +8,7 @@ This module provides the core tool logic used by both:
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 @@ from hindsight_api.config import (
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 @@ def _apply_bank_tool_filtering(mcp: FastMCP, memory: MemoryEngine, config: MCPTo
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

View file

@ -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

View file

@ -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"

View file

@ -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:

View file

@ -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
}

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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
)

View file

@ -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 = <ThrowOnError extends boolean = false>(
...options.headers,
},
});
/**
* List audit logs
*
* List audit log entries for a bank, ordered by most recent first.
*/
export const listAuditLogs = <ThrowOnError extends boolean = false>(
options: Options<ListAuditLogsData, ThrowOnError>,
) =>
(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 = <ThrowOnError extends boolean = false>(
options: Options<AuditLogStatsData, ThrowOnError>,
) =>
(options.client ?? client).get<
AuditLogStatsResponses,
AuditLogStatsErrors,
ThrowOnError
>({ url: "/v1/default/banks/{bank_id}/audit-logs/stats", ...options });

View file

@ -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;
};

View file

@ -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",

View file

@ -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 });
}
}

View file

@ -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 });
}
}

View file

@ -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() {
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
<button
onClick={() => handleBankConfigTabChange("audit-logs")}
className={`px-6 py-3 font-semibold text-sm transition-all relative ${
bankConfigTab === "audit-logs"
? "text-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
Audit Logs
{bankConfigTab === "audit-logs" && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
</div>
</div>
@ -329,6 +343,14 @@ export default function BankPage() {
<WebhooksView />
</div>
)}
{bankConfigTab === "audit-logs" && (
<div>
<p className="text-sm text-muted-foreground mb-4">
View audit trail of all operations performed on this memory bank.
</p>
<AuditLogsView />
</div>
)}
</div>
</div>
)}

View file

@ -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<string, string> = {
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 (
<span
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${styles[transport] || styles.system}`}
>
{transport}
</span>
);
}
// ---- Chart Section ----
function AuditChart({ bankId }: { bankId: string }) {
const [period, setPeriod] = useState("7d");
const [chartAction, setChartAction] = useState<string | null>(null);
const [buckets, setBuckets] = useState<AuditStatsBucket[]>([]);
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 (
<div className="border rounded-md p-4 space-y-3">
<div className="flex items-center gap-3">
<h3 className="text-sm font-semibold">Request Volume</h3>
<div className="flex gap-2 ml-auto">
<Select
value={chartAction || "all"}
onValueChange={(v) => {
const a = v === "all" ? null : v;
setChartAction(a);
loadStats(period, a);
}}
>
<SelectTrigger className="w-[160px] h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" className="max-h-[300px] overflow-y-auto">
{ACTION_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
{PERIOD_OPTIONS.map((opt) => (
<Button
key={opt.value}
variant={period === opt.value ? "default" : "outline"}
size="sm"
className="h-8 text-xs"
onClick={() => {
setPeriod(opt.value);
loadStats(opt.value, chartAction);
}}
>
{opt.label}
</Button>
))}
</div>
</div>
<div className="h-[120px]">
{loading ? (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
Loading...
</div>
) : chartData.length === 0 ? (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
No data for this period
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData} margin={{ top: 5, right: 5, bottom: 0, left: 5 }}>
<XAxis
dataKey="time"
tick={{ fontSize: 10 }}
axisLine={false}
tickLine={false}
className="text-muted-foreground"
/>
<Tooltip
contentStyle={{
backgroundColor: "hsl(var(--popover))",
border: "1px solid hsl(var(--border))",
borderRadius: "6px",
fontSize: "12px",
padding: "4px 8px",
}}
/>
<Line
type="monotone"
dataKey="total"
stroke="hsl(var(--primary))"
strokeWidth={2}
dot={false}
activeDot={{ r: 3 }}
/>
</LineChart>
</ResponsiveContainer>
)}
</div>
</div>
);
}
// ---- Main Component ----
export function AuditLogsView() {
const { currentBank } = useBank();
const [logs, setLogs] = useState<AuditLogEntry[]>([]);
const [total, setTotal] = useState(0);
const [actionFilter, setActionFilter] = useState<string | null>(null);
const [transportFilter, setTransportFilter] = useState<string | null>(null);
const [dateRange, setDateRange] = useState<string>("all");
const [limit] = useState(20);
const [offset, setOffset] = useState(0);
const [loading, setLoading] = useState(false);
const [selectedLog, setSelectedLog] = useState<AuditLogEntry | null>(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 (
<div className="space-y-6">
{/* Chart */}
<AuditChart bankId={currentBank} />
{/* Filters */}
<div className="flex items-center gap-3 flex-wrap">
<Select value={actionFilter || "all"} onValueChange={handleActionFilterChange}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="All actions" />
</SelectTrigger>
<SelectContent position="popper" className="max-h-[300px] overflow-y-auto">
{ACTION_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={transportFilter || "all"} onValueChange={handleTransportFilterChange}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="All transports" />
</SelectTrigger>
<SelectContent position="popper" className="max-h-[300px] overflow-y-auto">
{TRANSPORT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={dateRange} onValueChange={handleDateRangeChange}>
<SelectTrigger className="w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="all">All time</SelectItem>
<SelectItem value="1h">Last hour</SelectItem>
<SelectItem value="1d">Last 24 hours</SelectItem>
<SelectItem value="7d">Last 7 days</SelectItem>
<SelectItem value="30d">Last 30 days</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
onClick={() => loadLogs(actionFilter, transportFilter, dateRange, offset)}
disabled={loading}
>
<RefreshCw className={`w-4 h-4 mr-1 ${loading ? "animate-spin" : ""}`} />
Refresh
</Button>
<span className="text-sm text-muted-foreground ml-auto">
{total} {total === 1 ? "entry" : "entries"}
</span>
</div>
{/* Table */}
<div className="border rounded-md">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[200px]">Time</TableHead>
<TableHead>Action</TableHead>
<TableHead className="w-[100px]">Transport</TableHead>
<TableHead className="w-[100px]">Duration</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{logs.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground py-8">
{loading ? "Loading..." : "No audit logs found"}
</TableCell>
</TableRow>
) : (
logs.map((log) => (
<TableRow
key={log.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleLogClick(log)}
>
<TableCell className="text-sm font-mono">
{formatDateTime(log.started_at)}
</TableCell>
<TableCell className="font-medium">{log.action}</TableCell>
<TableCell>
<TransportBadge transport={log.transport} />
</TableCell>
<TableCell className="text-sm text-muted-foreground font-mono">
{formatDuration(log.started_at, log.ended_at)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
Page {currentPage} of {totalPages}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(Math.max(0, offset - limit))}
disabled={offset === 0}
>
<ChevronLeft className="w-4 h-4 mr-1" />
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(offset + limit)}
disabled={offset + limit >= total}
>
Next
<ChevronRight className="w-4 h-4 ml-1" />
</Button>
</div>
</div>
)}
{/* Detail Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Audit Log: {selectedLog?.action}</DialogTitle>
</DialogHeader>
{selectedLog && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Action:</span>{" "}
<span className="font-medium">{selectedLog.action}</span>
</div>
<div>
<span className="text-muted-foreground">Transport:</span>{" "}
<TransportBadge transport={selectedLog.transport} />
</div>
<div>
<span className="text-muted-foreground">Started:</span>{" "}
<span className="font-mono">{formatDateTime(selectedLog.started_at)}</span>
</div>
<div>
<span className="text-muted-foreground">Duration:</span>{" "}
<span className="font-mono">
{formatDuration(selectedLog.started_at, selectedLog.ended_at)}
</span>
</div>
</div>
{selectedLog.request && (
<div>
<h4 className="text-sm font-semibold mb-2">Request</h4>
<pre className="bg-muted p-3 rounded-md text-xs overflow-x-auto max-h-[200px] overflow-y-auto">
{JSON.stringify(selectedLog.request, null, 2)}
</pre>
</div>
)}
{selectedLog.response && (
<div>
<h4 className="text-sm font-semibold mb-2">Response</h4>
<pre className="bg-muted p-3 rounded-md text-xs overflow-x-auto max-h-[200px] overflow-y-auto">
{JSON.stringify(selectedLog.response, null, 2)}
</pre>
</div>
)}
{selectedLog.metadata && Object.keys(selectedLog.metadata).length > 0 && (
<div>
<h4 className="text-sm font-semibold mb-2">Metadata</h4>
<pre className="bg-muted p-3 rounded-md text-xs overflow-x-auto">
{JSON.stringify(selectedLog.metadata, null, 2)}
</pre>
</div>
)}
</div>
)}
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -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<string, unknown> | null;
response: Record<string, unknown> | null;
metadata: Record<string, unknown>;
}
export interface AuditLogsResponse {
bank_id: string;
total: number;
limit: number;
offset: number;
items: AuditLogEntry[];
}
export interface AuditStatsBucket {
time: string;
actions: Record<string, number>;
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<AuditLogsResponse> {
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<AuditLogsResponse>(
`/api/banks/${bankId}/audit-logs${query ? `?${query}` : ""}`
);
}
async getAuditLogStats(
bankId: string,
options?: { action?: string; period?: string }
): Promise<AuditStatsResponse> {
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<AuditStatsResponse>(
`/api/banks/${bankId}/audit-logs/stats${query ? `?${query}` : ""}`
);
}
}
// Export singleton instance

View file

@ -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": {

7
package-lock.json generated
View file

@ -312,6 +312,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",
@ -25723,6 +25724,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",

View file

@ -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": {