apache-aiops 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.
@@ -0,0 +1,526 @@
1
+ """Unified audit logging engine — all Monitoring skills write to a single SQLite database.
2
+
3
+ Replaces 7 per-skill JSON Lines audit loggers with one shared ``~/.apache-aiops/audit.db``.
4
+ Framework-agnostic: works with Claude, Codex, local agents, or any MCP client.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import getpass
10
+ import json
11
+ import logging
12
+ import os
13
+ import sqlite3
14
+ import threading
15
+ from contextlib import contextmanager
16
+ from datetime import UTC, datetime, timedelta
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from apache_aiops.governance.paths import ops_path
21
+
22
+ try:
23
+ import fcntl
24
+ except ImportError: # Windows has no fcntl; the in-process lock still applies.
25
+ fcntl = None
26
+
27
+ _log = logging.getLogger("apache-aiops.audit")
28
+
29
+ # Back-compat override hook. When None (default) the path resolves via
30
+ # ops_path("audit.db") (honoring APACHE_AIOPS_HOME). Downstream code/tests may set this
31
+ # to a fixed Path to redirect the default DB — kept so the APACHE_AIOPS_HOME refactor
32
+ # does not break callers that monkeypatch `_DEFAULT_DB`.
33
+ _DEFAULT_DB: Path | None = None
34
+
35
+ _MAX_DB_SIZE_BYTES = 100 * 1024 * 1024 # 100 MB
36
+ _MAX_ARCHIVES = 5
37
+ _BUSY_TIMEOUT_MS = 5000
38
+ _rotation_thread_lock = threading.RLock()
39
+
40
+
41
+ @contextmanager
42
+ def _rotation_guard(path: Path):
43
+ """Serialize rotation and intent insertion across local processes."""
44
+ with _rotation_thread_lock:
45
+ fd = os.open(path.with_name("audit.lock"), os.O_CREAT | os.O_RDWR, 0o600)
46
+ try:
47
+ if fcntl is not None:
48
+ fcntl.flock(fd, fcntl.LOCK_EX)
49
+ yield
50
+ finally:
51
+ if fcntl is not None:
52
+ fcntl.flock(fd, fcntl.LOCK_UN)
53
+ os.close(fd)
54
+
55
+
56
+ class AuditUnavailable(RuntimeError):
57
+ """A write was stopped because its durable audit intent could not be recorded."""
58
+
59
+
60
+ _CREATE_TABLE = """\
61
+ CREATE TABLE IF NOT EXISTS audit_log (
62
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
63
+ ts TEXT NOT NULL,
64
+ skill TEXT NOT NULL,
65
+ tool TEXT NOT NULL,
66
+ params TEXT NOT NULL DEFAULT '{}',
67
+ result TEXT NOT NULL DEFAULT '{}',
68
+ status TEXT NOT NULL DEFAULT 'ok',
69
+ duration_ms INTEGER NOT NULL DEFAULT 0,
70
+ agent TEXT NOT NULL DEFAULT 'unknown',
71
+ workflow_id TEXT NOT NULL DEFAULT '',
72
+ user TEXT NOT NULL DEFAULT 'unknown',
73
+ risk_level TEXT NOT NULL DEFAULT 'low',
74
+ rationale TEXT NOT NULL DEFAULT '',
75
+ approved_by TEXT NOT NULL DEFAULT '',
76
+ risk_tier TEXT NOT NULL DEFAULT ''
77
+ )
78
+ """
79
+
80
+ # Columns added after the original schema shipped. Each is applied with an
81
+ # idempotent ALTER TABLE so pre-existing audit.db files migrate in place (a
82
+ # fresh DB already has them via _CREATE_TABLE). Accountability fields per the
83
+ # SOC2 / 等保 "who authorized this, and why" requirement.
84
+ _MIGRATIONS = (
85
+ ("rationale", "TEXT NOT NULL DEFAULT ''"),
86
+ ("approved_by", "TEXT NOT NULL DEFAULT ''"),
87
+ ("risk_tier", "TEXT NOT NULL DEFAULT ''"),
88
+ )
89
+
90
+ _INSERT = """\
91
+ INSERT INTO audit_log (ts, skill, tool, params, result, status, duration_ms, agent, workflow_id, user, risk_level, rationale, approved_by, risk_tier)
92
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
93
+ """
94
+
95
+
96
+ class AuditEngine:
97
+ """Append-only audit logger backed by SQLite with WAL mode.
98
+
99
+ Thread-safe for concurrent writes from multiple skill processes.
100
+ """
101
+
102
+ def __init__(self, db_path: Path | str | None = None) -> None:
103
+ if db_path:
104
+ self._path = Path(db_path).expanduser()
105
+ else:
106
+ self._path = _DEFAULT_DB or ops_path("audit.db")
107
+ self._ok = False
108
+ self._write_blocked = False
109
+ self._write_failures = 0
110
+ try:
111
+ self._path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
112
+ self._init_db()
113
+ self._harden_permissions()
114
+ self._ok = True
115
+ except Exception:
116
+ _log.warning("Cannot initialize audit DB at %s", self._path, exc_info=True)
117
+
118
+ def _init_db(self) -> None:
119
+ conn = self._connect()
120
+ conn.execute(_CREATE_TABLE)
121
+ conn.execute("PRAGMA journal_mode=WAL")
122
+ conn.execute("PRAGMA synchronous=FULL")
123
+ self._migrate(conn)
124
+ conn.commit()
125
+ conn.close()
126
+
127
+ @staticmethod
128
+ def _migrate(conn: sqlite3.Connection) -> None:
129
+ """Add columns introduced after the original schema, in place.
130
+
131
+ Idempotent: only adds a column the table is missing, so existing
132
+ audit.db files gain rationale/approved_by/risk_tier without losing rows.
133
+ """
134
+ existing = {row[1] for row in conn.execute("PRAGMA table_info(audit_log)")}
135
+ for name, decl in _MIGRATIONS:
136
+ if name not in existing:
137
+ conn.execute(f"ALTER TABLE audit_log ADD COLUMN {name} {decl}") # nosec B608
138
+
139
+ def _harden_permissions(self) -> None:
140
+ """Restrict the audit dir to 0700 and DB files (incl. WAL/SHM) to 0600.
141
+
142
+ ``mkdir(mode=...)`` is masked by umask, so set permissions explicitly.
143
+ Best-effort: never raises (audit must not break the tool call)."""
144
+ try:
145
+ os.chmod(self._path.parent, 0o700)
146
+ except OSError:
147
+ pass
148
+ for suffix in ("", "-wal", "-shm"):
149
+ candidate = self._path.with_name(self._path.name + suffix)
150
+ try:
151
+ if candidate.exists():
152
+ os.chmod(candidate, 0o600)
153
+ except OSError:
154
+ pass
155
+
156
+ def _connect(self) -> sqlite3.Connection:
157
+ conn = sqlite3.connect(str(self._path), timeout=_BUSY_TIMEOUT_MS / 1000)
158
+ conn.execute(f"PRAGMA busy_timeout={_BUSY_TIMEOUT_MS}")
159
+ conn.execute("PRAGMA synchronous=FULL")
160
+ return conn
161
+
162
+ def log(
163
+ self,
164
+ *,
165
+ skill: str,
166
+ tool: str,
167
+ params: dict[str, Any] | None = None,
168
+ result: Any = None,
169
+ status: str = "ok",
170
+ duration_ms: int = 0,
171
+ agent: str = "unknown",
172
+ workflow_id: str = "",
173
+ user: str = "",
174
+ risk_level: str = "low",
175
+ rationale: str = "",
176
+ approved_by: str = "",
177
+ risk_tier: str = "",
178
+ strict: bool = False,
179
+ ) -> int | None:
180
+ """Append a row. Strict mode refuses writes if the intent cannot persist.
181
+
182
+ rationale / approved_by / risk_tier carry the accountability trail:
183
+ *why* a change was made, *who* signed off, and the *approval tier* the
184
+ policy engine assigned (none/confirm/dual/review).
185
+ """
186
+ if not self._ok:
187
+ if strict:
188
+ self._write_blocked = True
189
+ self._write_failures += 1
190
+ raise AuditUnavailable("Audit storage is unavailable; write was not started")
191
+ self._write_failures += 1
192
+ return None
193
+ try:
194
+ with _rotation_guard(self._path):
195
+ self._maybe_rotate()
196
+ conn = self._connect()
197
+ try:
198
+ cursor = conn.execute(
199
+ _INSERT,
200
+ (
201
+ datetime.now(tz=UTC).isoformat(),
202
+ skill,
203
+ tool,
204
+ _safe_json(params),
205
+ _safe_json(_summarize_result(result)),
206
+ status,
207
+ duration_ms,
208
+ agent,
209
+ workflow_id,
210
+ user or _current_user(),
211
+ risk_level,
212
+ rationale,
213
+ approved_by,
214
+ risk_tier,
215
+ ),
216
+ )
217
+ conn.commit()
218
+ audit_id = cursor.lastrowid
219
+ finally:
220
+ conn.close()
221
+ _log.debug("[AUDIT] %s.%s -> %s (%dms)", skill, tool, status, duration_ms)
222
+ return audit_id
223
+ except Exception:
224
+ self._write_failures += 1
225
+ _log.warning("Failed to write audit log", exc_info=True)
226
+ if strict:
227
+ self._write_blocked = True
228
+ raise AuditUnavailable(
229
+ "Audit storage is unavailable; write was not started"
230
+ ) from None
231
+ return None
232
+
233
+ def begin_write(self, **metadata: Any) -> int:
234
+ """Commit a durable intent before the caller sends an upstream write."""
235
+ if self._write_blocked:
236
+ raise AuditUnavailable("Audit storage failed; further writes are blocked")
237
+ audit_id = self.log(status="pending", strict=True, **metadata)
238
+ assert audit_id is not None
239
+ return audit_id
240
+
241
+ def finish_write(self, audit_id: int, *, result: Any, status: str, duration_ms: int) -> None:
242
+ """Resolve a write intent; on failure leave a recoverable pending row."""
243
+ try:
244
+ conn = self._connect()
245
+ try:
246
+ cursor = conn.execute(
247
+ "UPDATE audit_log SET result = ?, status = ?, duration_ms = ? "
248
+ "WHERE id = ? AND status = 'pending'",
249
+ (_safe_json(_summarize_result(result)), status, duration_ms, audit_id),
250
+ )
251
+ if cursor.rowcount != 1:
252
+ raise RuntimeError(f"audit intent {audit_id} is missing")
253
+ conn.commit()
254
+ finally:
255
+ conn.close()
256
+ except Exception:
257
+ self._write_blocked = True
258
+ self._write_failures += 1
259
+ _log.warning("Failed to finalize audit intent %s", audit_id, exc_info=True)
260
+ raise AuditUnavailable(
261
+ f"Audit intent {audit_id} remains pending; operation may have completed"
262
+ ) from None
263
+
264
+ def reconcile_pending(self, audit_id: int) -> bool:
265
+ """Mark an orphaned intent unknown after an operator checks the process.
266
+
267
+ Never run this automatically: another process may still be executing
268
+ the write. The row remains visible until explicitly reconciled.
269
+ """
270
+ conn = self._connect()
271
+ try:
272
+ cursor = conn.execute(
273
+ "UPDATE audit_log SET status = 'unknown', result = ? "
274
+ "WHERE id = ? AND status = 'pending'",
275
+ (_safe_json({"auditInterrupted": True}), audit_id),
276
+ )
277
+ conn.commit()
278
+ return cursor.rowcount == 1
279
+ finally:
280
+ conn.close()
281
+
282
+ def health(self) -> dict[str, Any]:
283
+ """Safe local health counters for operators and telemetry exporters."""
284
+ pending: int | None = None
285
+ if self._ok:
286
+ try:
287
+ conn = self._connect()
288
+ try:
289
+ pending = conn.execute(
290
+ "SELECT COUNT(*) FROM audit_log WHERE status = 'pending'"
291
+ ).fetchone()[0]
292
+ finally:
293
+ conn.close()
294
+ except Exception:
295
+ pass
296
+ return {
297
+ "available": self._ok and pending is not None and not self._write_blocked,
298
+ "writeBlocked": self._write_blocked,
299
+ "auditWriteFailures": self._write_failures,
300
+ "pendingWrites": pending,
301
+ }
302
+
303
+ # ── Rotation ──────────────────────────────────────────────────────
304
+
305
+ def _maybe_rotate(self) -> None:
306
+ """Archive the DB if it exceeds size limit."""
307
+ try:
308
+ if not self._path.exists():
309
+ return
310
+ if self._path.stat().st_size < _MAX_DB_SIZE_BYTES:
311
+ return
312
+ pending_conn = self._connect()
313
+ try:
314
+ if pending_conn.execute(
315
+ "SELECT 1 FROM audit_log WHERE status = 'pending' LIMIT 1"
316
+ ).fetchone():
317
+ return
318
+ finally:
319
+ pending_conn.close()
320
+ archive_name = self._path.with_suffix(
321
+ f".{datetime.now(tz=UTC).strftime('%Y%m%d-%H%M%S')}.db"
322
+ )
323
+ # Checkpoint the WAL into the main file before renaming, otherwise
324
+ # un-checkpointed rows in audit.db-wal are silently lost (the
325
+ # sidecars belong to the OLD path and never follow the archive).
326
+ conn = self._connect()
327
+ try:
328
+ checkpoint = conn.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone()
329
+ if checkpoint is None or checkpoint[0]:
330
+ raise RuntimeError(f"WAL checkpoint incomplete: {checkpoint}")
331
+ finally:
332
+ conn.close()
333
+ self._path.rename(archive_name)
334
+ # Drop now-stale sidecars (empty after a successful checkpoint).
335
+ for suffix in ("-wal", "-shm"):
336
+ sidecar = self._path.with_name(self._path.name + suffix)
337
+ try:
338
+ sidecar.unlink(missing_ok=True)
339
+ except OSError:
340
+ pass
341
+ self._init_db()
342
+ self._harden_permissions()
343
+ self._cleanup_archives()
344
+ _log.info("Audit DB rotated → %s", archive_name.name)
345
+ except Exception:
346
+ _log.warning("Audit DB rotation failed", exc_info=True)
347
+
348
+ def _cleanup_archives(self) -> None:
349
+ """Keep only the most recent N archive files."""
350
+ archives = sorted(
351
+ self._path.parent.glob("audit.*.db"),
352
+ key=lambda p: p.stat().st_mtime,
353
+ reverse=True,
354
+ )
355
+ for old in archives[_MAX_ARCHIVES:]:
356
+ old.unlink(missing_ok=True)
357
+
358
+ # ── Query helpers (used by CLI) ───────────────────────────────────
359
+
360
+ def query(
361
+ self,
362
+ *,
363
+ skill: str | None = None,
364
+ tool: str | None = None,
365
+ status: str | None = None,
366
+ workflow_id: str | None = None,
367
+ since: str | None = None,
368
+ limit: int = 50,
369
+ ) -> list[dict[str, Any]]:
370
+ """Query audit records with optional filters."""
371
+ clauses: list[str] = []
372
+ values: list[Any] = []
373
+
374
+ if skill:
375
+ clauses.append("skill = ?")
376
+ values.append(skill)
377
+ if tool:
378
+ clauses.append("tool = ?")
379
+ values.append(tool)
380
+ if status:
381
+ clauses.append("status = ?")
382
+ values.append(status)
383
+ if workflow_id:
384
+ clauses.append("workflow_id = ?")
385
+ values.append(workflow_id)
386
+ if since:
387
+ clauses.append("ts >= ?")
388
+ values.append(since)
389
+
390
+ # ``clauses`` are hardcoded "col = ?" fragments; every user value is a
391
+ # bound parameter in ``values``. No user input is interpolated here.
392
+ where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
393
+ sql = f"SELECT * FROM audit_log {where} ORDER BY id DESC LIMIT ?" # nosec B608
394
+ values.append(limit)
395
+
396
+ conn = self._connect()
397
+ try:
398
+ conn.row_factory = sqlite3.Row
399
+ rows = conn.execute(sql, values).fetchall()
400
+ return [dict(r) for r in rows]
401
+ finally:
402
+ conn.close()
403
+
404
+ def stats(self, days: int = 7) -> dict[str, Any]:
405
+ """Aggregate statistics over the last N days."""
406
+ since = (datetime.now(tz=UTC) - timedelta(days=days)).isoformat()
407
+ conn = self._connect()
408
+ try:
409
+ conn.row_factory = sqlite3.Row
410
+
411
+ total = conn.execute(
412
+ "SELECT COUNT(*) as c FROM audit_log WHERE ts >= ?", (since,)
413
+ ).fetchone()["c"]
414
+
415
+ by_status = {
416
+ r["status"]: r["c"]
417
+ for r in conn.execute(
418
+ "SELECT status, COUNT(*) as c FROM audit_log WHERE ts >= ? GROUP BY status",
419
+ (since,),
420
+ ).fetchall()
421
+ }
422
+
423
+ by_skill = {
424
+ r["skill"]: r["c"]
425
+ for r in conn.execute(
426
+ "SELECT skill, COUNT(*) as c FROM audit_log WHERE ts >= ? GROUP BY skill",
427
+ (since,),
428
+ ).fetchall()
429
+ }
430
+
431
+ return {"total": total, "by_status": by_status, "by_skill": by_skill, "days": days}
432
+ finally:
433
+ conn.close()
434
+
435
+
436
+ # ── Module-level helpers ──────────────────────────────────────────────
437
+
438
+
439
+ def _current_user() -> str:
440
+ try:
441
+ return getpass.getuser()
442
+ except Exception:
443
+ return "unknown"
444
+
445
+
446
+ def _safe_json(obj: Any) -> str:
447
+ """Serialize to JSON, falling back to str() for non-serializable objects."""
448
+ if obj is None:
449
+ return "{}"
450
+ try:
451
+ return json.dumps(obj, ensure_ascii=False, default=str)
452
+ except Exception:
453
+ return json.dumps({"_raw": str(obj)})
454
+
455
+
456
+ def _summarize_result(result: Any) -> dict[str, Any]:
457
+ """Persist only outcome metadata; never copy a tool's business payload."""
458
+ if isinstance(result, dict):
459
+ summary: dict[str, Any] = {"kind": "object"}
460
+ if result.get("error"):
461
+ summary["error"] = True
462
+ if result.get("outcomeUnknown") is True:
463
+ summary["outcomeUnknown"] = True
464
+ if result.get("_undo_id"):
465
+ summary["undoRecorded"] = True
466
+ if result.get("_pattern_id"):
467
+ summary["patternMatched"] = True
468
+ for key in ("truncated", "returned", "limit"):
469
+ value = result.get(key)
470
+ if type(value) in (bool, int):
471
+ summary[key] = value
472
+ return summary
473
+ if isinstance(result, (list, tuple)):
474
+ return {"kind": "list", "count": len(result)}
475
+ return {"kind": type(result).__name__}
476
+
477
+
478
+ def detect_agent() -> str:
479
+ """Infer the calling AI agent from environment variables."""
480
+ if os.environ.get("CLAUDE_SESSION_ID") or os.environ.get("CLAUDE_CODE"):
481
+ return "claude"
482
+ # Note: OPENAI_API_KEY is deliberately NOT used as a codex marker — it is
483
+ # commonly set in shells for unrelated tooling and misattributes the agent.
484
+ if os.environ.get("CODEX_SESSION"):
485
+ return "codex"
486
+ if os.environ.get("OLLAMA_HOST"):
487
+ return "local"
488
+ if os.environ.get("DEERFLOW_SESSION"):
489
+ return "deerflow"
490
+ return "unknown"
491
+
492
+
493
+ # Singleton — shared across all skills in the same process
494
+ _engine: AuditEngine | None = None
495
+ _engine_lock = threading.Lock()
496
+
497
+
498
+ def get_engine(db_path: Path | str | None = None) -> AuditEngine:
499
+ """Return the global AuditEngine singleton (lazy, lock-guarded).
500
+
501
+ A ``db_path`` differing from the one the singleton was created with is
502
+ ignored with a warning — call :func:`reset_engine` first to rebind.
503
+ """
504
+ global _engine
505
+ if _engine is None:
506
+ with _engine_lock:
507
+ if _engine is None:
508
+ _engine = AuditEngine(db_path)
509
+ return _engine
510
+ if db_path is not None:
511
+ requested = Path(db_path).expanduser()
512
+ if requested != _engine._path:
513
+ _log.warning(
514
+ "get_engine(%s) ignored — singleton already initialized at %s; "
515
+ "call reset_engine() first to rebind.",
516
+ requested,
517
+ _engine._path,
518
+ )
519
+ return _engine
520
+
521
+
522
+ def reset_engine() -> None:
523
+ """Reset the singleton. Mirrors patterns.reset_pattern_engine()."""
524
+ global _engine
525
+ with _engine_lock:
526
+ _engine = None