fleet-memory/hindsight-api-slim/tests/test_audit_log.py
Nicolò Boschi 083295dc6f
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.
2026-03-27 09:52:03 +01:00

449 lines
14 KiB
Python

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