mcp-hangar 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mcp_hangar/__init__.py +139 -0
- mcp_hangar/application/__init__.py +1 -0
- mcp_hangar/application/commands/__init__.py +67 -0
- mcp_hangar/application/commands/auth_commands.py +118 -0
- mcp_hangar/application/commands/auth_handlers.py +296 -0
- mcp_hangar/application/commands/commands.py +59 -0
- mcp_hangar/application/commands/handlers.py +189 -0
- mcp_hangar/application/discovery/__init__.py +21 -0
- mcp_hangar/application/discovery/discovery_metrics.py +283 -0
- mcp_hangar/application/discovery/discovery_orchestrator.py +497 -0
- mcp_hangar/application/discovery/lifecycle_manager.py +315 -0
- mcp_hangar/application/discovery/security_validator.py +414 -0
- mcp_hangar/application/event_handlers/__init__.py +50 -0
- mcp_hangar/application/event_handlers/alert_handler.py +191 -0
- mcp_hangar/application/event_handlers/audit_handler.py +203 -0
- mcp_hangar/application/event_handlers/knowledge_base_handler.py +120 -0
- mcp_hangar/application/event_handlers/logging_handler.py +69 -0
- mcp_hangar/application/event_handlers/metrics_handler.py +152 -0
- mcp_hangar/application/event_handlers/persistent_audit_store.py +217 -0
- mcp_hangar/application/event_handlers/security_handler.py +604 -0
- mcp_hangar/application/mcp/tooling.py +158 -0
- mcp_hangar/application/ports/__init__.py +9 -0
- mcp_hangar/application/ports/observability.py +237 -0
- mcp_hangar/application/queries/__init__.py +52 -0
- mcp_hangar/application/queries/auth_handlers.py +237 -0
- mcp_hangar/application/queries/auth_queries.py +118 -0
- mcp_hangar/application/queries/handlers.py +227 -0
- mcp_hangar/application/read_models/__init__.py +11 -0
- mcp_hangar/application/read_models/provider_views.py +139 -0
- mcp_hangar/application/sagas/__init__.py +11 -0
- mcp_hangar/application/sagas/group_rebalance_saga.py +137 -0
- mcp_hangar/application/sagas/provider_failover_saga.py +266 -0
- mcp_hangar/application/sagas/provider_recovery_saga.py +172 -0
- mcp_hangar/application/services/__init__.py +9 -0
- mcp_hangar/application/services/provider_service.py +208 -0
- mcp_hangar/application/services/traced_provider_service.py +211 -0
- mcp_hangar/bootstrap/runtime.py +328 -0
- mcp_hangar/context.py +178 -0
- mcp_hangar/domain/__init__.py +117 -0
- mcp_hangar/domain/contracts/__init__.py +57 -0
- mcp_hangar/domain/contracts/authentication.py +225 -0
- mcp_hangar/domain/contracts/authorization.py +229 -0
- mcp_hangar/domain/contracts/event_store.py +178 -0
- mcp_hangar/domain/contracts/metrics_publisher.py +59 -0
- mcp_hangar/domain/contracts/persistence.py +383 -0
- mcp_hangar/domain/contracts/provider_runtime.py +146 -0
- mcp_hangar/domain/discovery/__init__.py +20 -0
- mcp_hangar/domain/discovery/conflict_resolver.py +267 -0
- mcp_hangar/domain/discovery/discovered_provider.py +185 -0
- mcp_hangar/domain/discovery/discovery_service.py +412 -0
- mcp_hangar/domain/discovery/discovery_source.py +192 -0
- mcp_hangar/domain/events.py +433 -0
- mcp_hangar/domain/exceptions.py +525 -0
- mcp_hangar/domain/model/__init__.py +70 -0
- mcp_hangar/domain/model/aggregate.py +58 -0
- mcp_hangar/domain/model/circuit_breaker.py +152 -0
- mcp_hangar/domain/model/event_sourced_api_key.py +413 -0
- mcp_hangar/domain/model/event_sourced_provider.py +423 -0
- mcp_hangar/domain/model/event_sourced_role_assignment.py +268 -0
- mcp_hangar/domain/model/health_tracker.py +183 -0
- mcp_hangar/domain/model/load_balancer.py +185 -0
- mcp_hangar/domain/model/provider.py +810 -0
- mcp_hangar/domain/model/provider_group.py +656 -0
- mcp_hangar/domain/model/tool_catalog.py +105 -0
- mcp_hangar/domain/policies/__init__.py +19 -0
- mcp_hangar/domain/policies/provider_health.py +187 -0
- mcp_hangar/domain/repository.py +249 -0
- mcp_hangar/domain/security/__init__.py +85 -0
- mcp_hangar/domain/security/input_validator.py +710 -0
- mcp_hangar/domain/security/rate_limiter.py +387 -0
- mcp_hangar/domain/security/roles.py +237 -0
- mcp_hangar/domain/security/sanitizer.py +387 -0
- mcp_hangar/domain/security/secrets.py +501 -0
- mcp_hangar/domain/services/__init__.py +20 -0
- mcp_hangar/domain/services/audit_service.py +376 -0
- mcp_hangar/domain/services/image_builder.py +328 -0
- mcp_hangar/domain/services/provider_launcher.py +1046 -0
- mcp_hangar/domain/value_objects.py +1138 -0
- mcp_hangar/errors.py +818 -0
- mcp_hangar/fastmcp_server.py +1105 -0
- mcp_hangar/gc.py +134 -0
- mcp_hangar/infrastructure/__init__.py +79 -0
- mcp_hangar/infrastructure/async_executor.py +133 -0
- mcp_hangar/infrastructure/auth/__init__.py +37 -0
- mcp_hangar/infrastructure/auth/api_key_authenticator.py +388 -0
- mcp_hangar/infrastructure/auth/event_sourced_store.py +567 -0
- mcp_hangar/infrastructure/auth/jwt_authenticator.py +360 -0
- mcp_hangar/infrastructure/auth/middleware.py +340 -0
- mcp_hangar/infrastructure/auth/opa_authorizer.py +243 -0
- mcp_hangar/infrastructure/auth/postgres_store.py +659 -0
- mcp_hangar/infrastructure/auth/projections.py +366 -0
- mcp_hangar/infrastructure/auth/rate_limiter.py +311 -0
- mcp_hangar/infrastructure/auth/rbac_authorizer.py +323 -0
- mcp_hangar/infrastructure/auth/sqlite_store.py +624 -0
- mcp_hangar/infrastructure/command_bus.py +112 -0
- mcp_hangar/infrastructure/discovery/__init__.py +110 -0
- mcp_hangar/infrastructure/discovery/docker_source.py +289 -0
- mcp_hangar/infrastructure/discovery/entrypoint_source.py +249 -0
- mcp_hangar/infrastructure/discovery/filesystem_source.py +383 -0
- mcp_hangar/infrastructure/discovery/kubernetes_source.py +247 -0
- mcp_hangar/infrastructure/event_bus.py +260 -0
- mcp_hangar/infrastructure/event_sourced_repository.py +443 -0
- mcp_hangar/infrastructure/event_store.py +396 -0
- mcp_hangar/infrastructure/knowledge_base/__init__.py +259 -0
- mcp_hangar/infrastructure/knowledge_base/contracts.py +202 -0
- mcp_hangar/infrastructure/knowledge_base/memory.py +177 -0
- mcp_hangar/infrastructure/knowledge_base/postgres.py +545 -0
- mcp_hangar/infrastructure/knowledge_base/sqlite.py +513 -0
- mcp_hangar/infrastructure/metrics_publisher.py +36 -0
- mcp_hangar/infrastructure/observability/__init__.py +10 -0
- mcp_hangar/infrastructure/observability/langfuse_adapter.py +534 -0
- mcp_hangar/infrastructure/persistence/__init__.py +33 -0
- mcp_hangar/infrastructure/persistence/audit_repository.py +371 -0
- mcp_hangar/infrastructure/persistence/config_repository.py +398 -0
- mcp_hangar/infrastructure/persistence/database.py +333 -0
- mcp_hangar/infrastructure/persistence/database_common.py +330 -0
- mcp_hangar/infrastructure/persistence/event_serializer.py +280 -0
- mcp_hangar/infrastructure/persistence/event_upcaster.py +166 -0
- mcp_hangar/infrastructure/persistence/in_memory_event_store.py +150 -0
- mcp_hangar/infrastructure/persistence/recovery_service.py +312 -0
- mcp_hangar/infrastructure/persistence/sqlite_event_store.py +386 -0
- mcp_hangar/infrastructure/persistence/unit_of_work.py +409 -0
- mcp_hangar/infrastructure/persistence/upcasters/README.md +13 -0
- mcp_hangar/infrastructure/persistence/upcasters/__init__.py +7 -0
- mcp_hangar/infrastructure/query_bus.py +153 -0
- mcp_hangar/infrastructure/saga_manager.py +401 -0
- mcp_hangar/logging_config.py +209 -0
- mcp_hangar/metrics.py +1007 -0
- mcp_hangar/models.py +31 -0
- mcp_hangar/observability/__init__.py +54 -0
- mcp_hangar/observability/health.py +487 -0
- mcp_hangar/observability/metrics.py +319 -0
- mcp_hangar/observability/tracing.py +433 -0
- mcp_hangar/progress.py +542 -0
- mcp_hangar/retry.py +613 -0
- mcp_hangar/server/__init__.py +120 -0
- mcp_hangar/server/__main__.py +6 -0
- mcp_hangar/server/auth_bootstrap.py +340 -0
- mcp_hangar/server/auth_cli.py +335 -0
- mcp_hangar/server/auth_config.py +305 -0
- mcp_hangar/server/bootstrap.py +735 -0
- mcp_hangar/server/cli.py +161 -0
- mcp_hangar/server/config.py +224 -0
- mcp_hangar/server/context.py +215 -0
- mcp_hangar/server/http_auth_middleware.py +165 -0
- mcp_hangar/server/lifecycle.py +467 -0
- mcp_hangar/server/state.py +117 -0
- mcp_hangar/server/tools/__init__.py +16 -0
- mcp_hangar/server/tools/discovery.py +186 -0
- mcp_hangar/server/tools/groups.py +75 -0
- mcp_hangar/server/tools/health.py +301 -0
- mcp_hangar/server/tools/provider.py +939 -0
- mcp_hangar/server/tools/registry.py +320 -0
- mcp_hangar/server/validation.py +113 -0
- mcp_hangar/stdio_client.py +229 -0
- mcp_hangar-0.2.0.dist-info/METADATA +347 -0
- mcp_hangar-0.2.0.dist-info/RECORD +160 -0
- mcp_hangar-0.2.0.dist-info/WHEEL +4 -0
- mcp_hangar-0.2.0.dist-info/entry_points.txt +2 -0
- mcp_hangar-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""Audit repository implementations.
|
|
2
|
+
|
|
3
|
+
Provides both in-memory and SQLite implementations of IAuditRepository.
|
|
4
|
+
Audit logs are append-only for integrity.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
import json
|
|
9
|
+
import threading
|
|
10
|
+
from typing import List, Optional
|
|
11
|
+
|
|
12
|
+
from ...domain.contracts.persistence import AuditAction, AuditEntry, PersistenceError
|
|
13
|
+
from ...logging_config import get_logger
|
|
14
|
+
from .database import Database
|
|
15
|
+
|
|
16
|
+
logger = get_logger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class InMemoryAuditRepository:
|
|
20
|
+
"""In-memory implementation of audit repository.
|
|
21
|
+
|
|
22
|
+
Useful for testing and development. Data is lost on restart.
|
|
23
|
+
Maintains append-only semantics.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, max_entries: int = 100000):
|
|
27
|
+
"""Initialize empty in-memory audit repository.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
max_entries: Maximum entries to retain (oldest dropped when exceeded)
|
|
31
|
+
"""
|
|
32
|
+
self._entries: List[AuditEntry] = []
|
|
33
|
+
self._max_entries = max_entries
|
|
34
|
+
self._lock = threading.RLock()
|
|
35
|
+
|
|
36
|
+
async def append(self, entry: AuditEntry) -> None:
|
|
37
|
+
"""Append an audit entry."""
|
|
38
|
+
with self._lock:
|
|
39
|
+
self._entries.append(entry)
|
|
40
|
+
|
|
41
|
+
# Prune old entries if exceeded
|
|
42
|
+
if len(self._entries) > self._max_entries:
|
|
43
|
+
self._entries = self._entries[-self._max_entries :]
|
|
44
|
+
|
|
45
|
+
logger.debug(f"Audit: {entry.action.value} on {entry.entity_type}/{entry.entity_id} by {entry.actor}")
|
|
46
|
+
|
|
47
|
+
async def get_by_entity(
|
|
48
|
+
self,
|
|
49
|
+
entity_id: str,
|
|
50
|
+
entity_type: Optional[str] = None,
|
|
51
|
+
limit: int = 100,
|
|
52
|
+
offset: int = 0,
|
|
53
|
+
) -> List[AuditEntry]:
|
|
54
|
+
"""Get audit entries for an entity."""
|
|
55
|
+
with self._lock:
|
|
56
|
+
filtered = [
|
|
57
|
+
e
|
|
58
|
+
for e in self._entries
|
|
59
|
+
if e.entity_id == entity_id and (entity_type is None or e.entity_type == entity_type)
|
|
60
|
+
]
|
|
61
|
+
# Return newest first
|
|
62
|
+
filtered.sort(key=lambda e: e.timestamp, reverse=True)
|
|
63
|
+
return filtered[offset : offset + limit]
|
|
64
|
+
|
|
65
|
+
async def get_by_time_range(
|
|
66
|
+
self,
|
|
67
|
+
start: datetime,
|
|
68
|
+
end: datetime,
|
|
69
|
+
entity_type: Optional[str] = None,
|
|
70
|
+
action: Optional[AuditAction] = None,
|
|
71
|
+
limit: int = 1000,
|
|
72
|
+
) -> List[AuditEntry]:
|
|
73
|
+
"""Get audit entries within a time range."""
|
|
74
|
+
with self._lock:
|
|
75
|
+
filtered = [
|
|
76
|
+
e
|
|
77
|
+
for e in self._entries
|
|
78
|
+
if start <= e.timestamp <= end
|
|
79
|
+
and (entity_type is None or e.entity_type == entity_type)
|
|
80
|
+
and (action is None or e.action == action)
|
|
81
|
+
]
|
|
82
|
+
filtered.sort(key=lambda e: e.timestamp, reverse=True)
|
|
83
|
+
return filtered[:limit]
|
|
84
|
+
|
|
85
|
+
async def get_by_correlation_id(self, correlation_id: str) -> List[AuditEntry]:
|
|
86
|
+
"""Get all audit entries for a correlation ID."""
|
|
87
|
+
with self._lock:
|
|
88
|
+
filtered = [e for e in self._entries if e.correlation_id == correlation_id]
|
|
89
|
+
filtered.sort(key=lambda e: e.timestamp)
|
|
90
|
+
return filtered
|
|
91
|
+
|
|
92
|
+
def clear(self) -> None:
|
|
93
|
+
"""Clear all entries (for testing)."""
|
|
94
|
+
with self._lock:
|
|
95
|
+
self._entries.clear()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class SQLiteAuditRepository:
|
|
99
|
+
"""SQLite implementation of audit repository.
|
|
100
|
+
|
|
101
|
+
Provides durable, append-only audit log storage with
|
|
102
|
+
efficient querying capabilities.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(self, database: Database):
|
|
106
|
+
"""Initialize with database connection.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
database: Database instance for connections
|
|
110
|
+
"""
|
|
111
|
+
self._db = database
|
|
112
|
+
|
|
113
|
+
async def append(self, entry: AuditEntry) -> None:
|
|
114
|
+
"""Append an audit entry.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
entry: Audit entry to append
|
|
118
|
+
|
|
119
|
+
Raises:
|
|
120
|
+
PersistenceError: If append operation fails
|
|
121
|
+
"""
|
|
122
|
+
try:
|
|
123
|
+
async with self._db.transaction() as conn:
|
|
124
|
+
await conn.execute(
|
|
125
|
+
"""
|
|
126
|
+
INSERT INTO audit_log
|
|
127
|
+
(entity_id, entity_type, action, actor, timestamp,
|
|
128
|
+
old_state_json, new_state_json, metadata_json, correlation_id)
|
|
129
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
130
|
+
""",
|
|
131
|
+
(
|
|
132
|
+
entry.entity_id,
|
|
133
|
+
entry.entity_type,
|
|
134
|
+
entry.action.value,
|
|
135
|
+
entry.actor,
|
|
136
|
+
entry.timestamp.isoformat(),
|
|
137
|
+
json.dumps(entry.old_state) if entry.old_state else None,
|
|
138
|
+
json.dumps(entry.new_state) if entry.new_state else None,
|
|
139
|
+
json.dumps(entry.metadata) if entry.metadata else None,
|
|
140
|
+
entry.correlation_id,
|
|
141
|
+
),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
logger.debug(f"Audit: {entry.action.value} on {entry.entity_type}/{entry.entity_id} by {entry.actor}")
|
|
145
|
+
|
|
146
|
+
except Exception as e:
|
|
147
|
+
logger.error(f"Failed to append audit entry: {e}")
|
|
148
|
+
raise PersistenceError(f"Failed to append audit entry: {e}") from e
|
|
149
|
+
|
|
150
|
+
async def get_by_entity(
|
|
151
|
+
self,
|
|
152
|
+
entity_id: str,
|
|
153
|
+
entity_type: Optional[str] = None,
|
|
154
|
+
limit: int = 100,
|
|
155
|
+
offset: int = 0,
|
|
156
|
+
) -> List[AuditEntry]:
|
|
157
|
+
"""Get audit entries for an entity.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
entity_id: Entity identifier
|
|
161
|
+
entity_type: Optional entity type filter
|
|
162
|
+
limit: Maximum entries to return
|
|
163
|
+
offset: Number of entries to skip
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
List of audit entries, newest first
|
|
167
|
+
"""
|
|
168
|
+
try:
|
|
169
|
+
async with self._db.connection() as conn:
|
|
170
|
+
if entity_type:
|
|
171
|
+
cursor = await conn.execute(
|
|
172
|
+
"""
|
|
173
|
+
SELECT entity_id, entity_type, action, actor, timestamp,
|
|
174
|
+
old_state_json, new_state_json, metadata_json, correlation_id
|
|
175
|
+
FROM audit_log
|
|
176
|
+
WHERE entity_id = ? AND entity_type = ?
|
|
177
|
+
ORDER BY timestamp DESC
|
|
178
|
+
LIMIT ? OFFSET ?
|
|
179
|
+
""",
|
|
180
|
+
(entity_id, entity_type, limit, offset),
|
|
181
|
+
)
|
|
182
|
+
else:
|
|
183
|
+
cursor = await conn.execute(
|
|
184
|
+
"""
|
|
185
|
+
SELECT entity_id, entity_type, action, actor, timestamp,
|
|
186
|
+
old_state_json, new_state_json, metadata_json, correlation_id
|
|
187
|
+
FROM audit_log
|
|
188
|
+
WHERE entity_id = ?
|
|
189
|
+
ORDER BY timestamp DESC
|
|
190
|
+
LIMIT ? OFFSET ?
|
|
191
|
+
""",
|
|
192
|
+
(entity_id, limit, offset),
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
rows = await cursor.fetchall()
|
|
196
|
+
return [self._row_to_entry(row) for row in rows]
|
|
197
|
+
|
|
198
|
+
except Exception as e:
|
|
199
|
+
logger.error(f"Failed to get audit entries by entity: {e}")
|
|
200
|
+
raise PersistenceError(f"Failed to get audit entries by entity: {e}") from e
|
|
201
|
+
|
|
202
|
+
async def get_by_time_range(
|
|
203
|
+
self,
|
|
204
|
+
start: datetime,
|
|
205
|
+
end: datetime,
|
|
206
|
+
entity_type: Optional[str] = None,
|
|
207
|
+
action: Optional[AuditAction] = None,
|
|
208
|
+
limit: int = 1000,
|
|
209
|
+
) -> List[AuditEntry]:
|
|
210
|
+
"""Get audit entries within a time range.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
start: Start of time range (inclusive)
|
|
214
|
+
end: End of time range (inclusive)
|
|
215
|
+
entity_type: Optional entity type filter
|
|
216
|
+
action: Optional action filter
|
|
217
|
+
limit: Maximum entries to return
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
List of audit entries, newest first
|
|
221
|
+
"""
|
|
222
|
+
try:
|
|
223
|
+
async with self._db.connection() as conn:
|
|
224
|
+
query = """
|
|
225
|
+
SELECT entity_id, entity_type, action, actor, timestamp,
|
|
226
|
+
old_state_json, new_state_json, metadata_json, correlation_id
|
|
227
|
+
FROM audit_log
|
|
228
|
+
WHERE timestamp BETWEEN ? AND ?
|
|
229
|
+
"""
|
|
230
|
+
params: List = [start.isoformat(), end.isoformat()]
|
|
231
|
+
|
|
232
|
+
if entity_type:
|
|
233
|
+
query += " AND entity_type = ?"
|
|
234
|
+
params.append(entity_type)
|
|
235
|
+
|
|
236
|
+
if action:
|
|
237
|
+
query += " AND action = ?"
|
|
238
|
+
params.append(action.value)
|
|
239
|
+
|
|
240
|
+
query += " ORDER BY timestamp DESC LIMIT ?"
|
|
241
|
+
params.append(limit)
|
|
242
|
+
|
|
243
|
+
cursor = await conn.execute(query, params)
|
|
244
|
+
rows = await cursor.fetchall()
|
|
245
|
+
return [self._row_to_entry(row) for row in rows]
|
|
246
|
+
|
|
247
|
+
except Exception as e:
|
|
248
|
+
logger.error(f"Failed to get audit entries by time range: {e}")
|
|
249
|
+
raise PersistenceError(f"Failed to get audit entries by time range: {e}") from e
|
|
250
|
+
|
|
251
|
+
async def get_by_correlation_id(self, correlation_id: str) -> List[AuditEntry]:
|
|
252
|
+
"""Get all audit entries for a correlation ID.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
correlation_id: Correlation identifier
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
List of related audit entries, ordered by timestamp
|
|
259
|
+
"""
|
|
260
|
+
try:
|
|
261
|
+
async with self._db.connection() as conn:
|
|
262
|
+
cursor = await conn.execute(
|
|
263
|
+
"""
|
|
264
|
+
SELECT entity_id, entity_type, action, actor, timestamp,
|
|
265
|
+
old_state_json, new_state_json, metadata_json, correlation_id
|
|
266
|
+
FROM audit_log
|
|
267
|
+
WHERE correlation_id = ?
|
|
268
|
+
ORDER BY timestamp ASC
|
|
269
|
+
""",
|
|
270
|
+
(correlation_id,),
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
rows = await cursor.fetchall()
|
|
274
|
+
return [self._row_to_entry(row) for row in rows]
|
|
275
|
+
|
|
276
|
+
except Exception as e:
|
|
277
|
+
logger.error(f"Failed to get audit entries by correlation: {e}")
|
|
278
|
+
raise PersistenceError(f"Failed to get audit entries by correlation: {e}") from e
|
|
279
|
+
|
|
280
|
+
async def count_by_entity(self, entity_id: str, entity_type: Optional[str] = None) -> int:
|
|
281
|
+
"""Count audit entries for an entity.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
entity_id: Entity identifier
|
|
285
|
+
entity_type: Optional entity type filter
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
Number of audit entries
|
|
289
|
+
"""
|
|
290
|
+
try:
|
|
291
|
+
async with self._db.connection() as conn:
|
|
292
|
+
if entity_type:
|
|
293
|
+
cursor = await conn.execute(
|
|
294
|
+
"""
|
|
295
|
+
SELECT COUNT(*) FROM audit_log
|
|
296
|
+
WHERE entity_id = ? AND entity_type = ?
|
|
297
|
+
""",
|
|
298
|
+
(entity_id, entity_type),
|
|
299
|
+
)
|
|
300
|
+
else:
|
|
301
|
+
cursor = await conn.execute(
|
|
302
|
+
"SELECT COUNT(*) FROM audit_log WHERE entity_id = ?",
|
|
303
|
+
(entity_id,),
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
row = await cursor.fetchone()
|
|
307
|
+
return row[0] if row else 0
|
|
308
|
+
|
|
309
|
+
except Exception as e:
|
|
310
|
+
logger.error(f"Failed to count audit entries: {e}")
|
|
311
|
+
raise PersistenceError(f"Failed to count audit entries: {e}") from e
|
|
312
|
+
|
|
313
|
+
async def get_recent_actions(
|
|
314
|
+
self,
|
|
315
|
+
entity_type: str,
|
|
316
|
+
action: AuditAction,
|
|
317
|
+
limit: int = 100,
|
|
318
|
+
) -> List[AuditEntry]:
|
|
319
|
+
"""Get recent actions of a specific type.
|
|
320
|
+
|
|
321
|
+
Useful for monitoring and dashboards.
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
entity_type: Entity type to filter
|
|
325
|
+
action: Action type to filter
|
|
326
|
+
limit: Maximum entries to return
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
List of recent audit entries
|
|
330
|
+
"""
|
|
331
|
+
try:
|
|
332
|
+
async with self._db.connection() as conn:
|
|
333
|
+
cursor = await conn.execute(
|
|
334
|
+
"""
|
|
335
|
+
SELECT entity_id, entity_type, action, actor, timestamp,
|
|
336
|
+
old_state_json, new_state_json, metadata_json, correlation_id
|
|
337
|
+
FROM audit_log
|
|
338
|
+
WHERE entity_type = ? AND action = ?
|
|
339
|
+
ORDER BY timestamp DESC
|
|
340
|
+
LIMIT ?
|
|
341
|
+
""",
|
|
342
|
+
(entity_type, action.value, limit),
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
rows = await cursor.fetchall()
|
|
346
|
+
return [self._row_to_entry(row) for row in rows]
|
|
347
|
+
|
|
348
|
+
except Exception as e:
|
|
349
|
+
logger.error(f"Failed to get recent actions: {e}")
|
|
350
|
+
raise PersistenceError(f"Failed to get recent actions: {e}") from e
|
|
351
|
+
|
|
352
|
+
def _row_to_entry(self, row) -> AuditEntry:
|
|
353
|
+
"""Convert database row to AuditEntry.
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
row: Database row (sqlite3.Row or tuple)
|
|
357
|
+
|
|
358
|
+
Returns:
|
|
359
|
+
AuditEntry instance
|
|
360
|
+
"""
|
|
361
|
+
return AuditEntry(
|
|
362
|
+
entity_id=row[0],
|
|
363
|
+
entity_type=row[1],
|
|
364
|
+
action=AuditAction(row[2]),
|
|
365
|
+
actor=row[3],
|
|
366
|
+
timestamp=datetime.fromisoformat(row[4]),
|
|
367
|
+
old_state=json.loads(row[5]) if row[5] else None,
|
|
368
|
+
new_state=json.loads(row[6]) if row[6] else None,
|
|
369
|
+
metadata=json.loads(row[7]) if row[7] else {},
|
|
370
|
+
correlation_id=row[8],
|
|
371
|
+
)
|