E2E test on a real BQ deploy showed every verification-extraction call fails with HTTP 400 invalid_request_error: "output_config.format.schema: For 'object' type, 'additionalProperties' must be explicitly set to false". The Anthropic structured-output API now requires the field on every object node in the json_schema. Fix: connectors/llm/anthropic_provider.py wraps the caller-supplied schema through a recursive _strict_json_schema() walker that adds the field where missing (preserving any explicit override), then passes the strict variant to the API. Six unit tests in TestStrictJsonSchema pin the recursion across nested objects, array items, and the no-mutation invariant. Adds /admin/scheduler-runs — a read-only admin page that surfaces the last 200 audit-log entries from scheduler-driven actions. New AuditRepository.query_actions(actions, limit) helper, new admin nav entry. Failed scheduler ticks (HTTP 401, network errors) don't reach the audit_log; the page calls that out with a hint to set SCHEDULER_API_TOKEN if no rows show up.
70 lines
2.3 KiB
Python
70 lines
2.3 KiB
Python
"""Repository for audit logging."""
|
|
|
|
import json
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Optional, List, Dict
|
|
|
|
import duckdb
|
|
|
|
|
|
class AuditRepository:
|
|
def __init__(self, conn: duckdb.DuckDBPyConnection):
|
|
self.conn = conn
|
|
|
|
def log(
|
|
self,
|
|
user_id: Optional[str] = None,
|
|
action: str = "",
|
|
resource: Optional[str] = None,
|
|
params: Optional[dict] = None,
|
|
result: Optional[str] = None,
|
|
duration_ms: Optional[int] = None,
|
|
) -> str:
|
|
entry_id = str(uuid.uuid4())
|
|
now = datetime.now(timezone.utc)
|
|
self.conn.execute(
|
|
"""INSERT INTO audit_log (id, timestamp, user_id, action, resource, params, result, duration_ms)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
[entry_id, now, user_id, action, resource,
|
|
json.dumps(params) if params else None, result, duration_ms],
|
|
)
|
|
return entry_id
|
|
|
|
def query(
|
|
self,
|
|
user_id: Optional[str] = None,
|
|
action: Optional[str] = None,
|
|
limit: int = 50,
|
|
) -> List[Dict[str, Any]]:
|
|
sql = "SELECT * FROM audit_log WHERE 1=1"
|
|
params: List[Any] = []
|
|
if user_id:
|
|
sql += " AND user_id = ?"
|
|
params.append(user_id)
|
|
if action:
|
|
sql += " AND action = ?"
|
|
params.append(action)
|
|
sql += " ORDER BY timestamp DESC LIMIT ?"
|
|
params.append(limit)
|
|
results = self.conn.execute(sql, params).fetchall()
|
|
if not results:
|
|
return []
|
|
columns = [desc[0] for desc in self.conn.description]
|
|
return [dict(zip(columns, row)) for row in results]
|
|
|
|
def query_actions(
|
|
self,
|
|
actions: List[str],
|
|
limit: int = 200,
|
|
) -> List[Dict[str, Any]]:
|
|
"""Return rows whose action is in the given list, newest first."""
|
|
if not actions:
|
|
return []
|
|
placeholders = ",".join("?" for _ in actions)
|
|
sql = f"SELECT * FROM audit_log WHERE action IN ({placeholders}) ORDER BY timestamp DESC LIMIT ?"
|
|
results = self.conn.execute(sql, list(actions) + [limit]).fetchall()
|
|
if not results:
|
|
return []
|
|
columns = [desc[0] for desc in self.conn.description]
|
|
return [dict(zip(columns, row)) for row in results]
|