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:
parent
731238707d
commit
083295dc6f
26 changed files with 3815 additions and 64 deletions
|
|
@ -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")
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
209
hindsight-api-slim/hindsight_api/engine/audit.py
Normal file
209
hindsight-api-slim/hindsight_api/engine/audit.py
Normal 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)
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
449
hindsight-api-slim/tests/test_audit_log.py
Normal file
449
hindsight-api-slim/tests/test_audit_log.py
Normal 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"
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
357
hindsight-clients/go/api_audit.go
Normal file
357
hindsight-clients/go/api_audit.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
730
hindsight-clients/python/hindsight_client_api/api/audit_api.py
Normal file
730
hindsight-clients/python/hindsight_client_api/api/audit_api.py
Normal 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
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
517
hindsight-control-plane/src/components/audit-logs-view.tsx
Normal file
517
hindsight-control-plane/src/components/audit-logs-view.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
7
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
Loading…
Reference in a new issue