generated from coulomb/repo-seed
Add mock file audit backend
This commit is contained in:
6
audit_core/__init__.py
Normal file
6
audit_core/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Audit Core public interface."""
|
||||
|
||||
from audit_core.interface import AuditBackend, AuditEvent
|
||||
from audit_core.mock_file_backend import MockFileAuditBackend
|
||||
|
||||
__all__ = ["AuditBackend", "AuditEvent", "MockFileAuditBackend"]
|
||||
4
audit_core/__main__.py
Normal file
4
audit_core/__main__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from audit_core.cli import main
|
||||
|
||||
|
||||
raise SystemExit(main())
|
||||
82
audit_core/cli.py
Normal file
82
audit_core/cli.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""Command line helpers for the development audit backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from audit_core.interface import AuditEvent
|
||||
from audit_core.mock_file_backend import MockFileAuditBackend
|
||||
|
||||
|
||||
def parse_detail(values: list[str]) -> dict[str, Any]:
|
||||
details: dict[str, Any] = {}
|
||||
for item in values:
|
||||
if "=" not in item:
|
||||
raise SystemExit(f"detail must be key=value: {item}")
|
||||
key, value = item.split("=", 1)
|
||||
details[key] = value
|
||||
return details
|
||||
|
||||
|
||||
def emit(args: argparse.Namespace) -> int:
|
||||
details = parse_detail(args.detail)
|
||||
event = AuditEvent(
|
||||
tenant=args.tenant,
|
||||
scope=args.scope,
|
||||
source=args.source,
|
||||
actor=args.actor,
|
||||
action=args.action,
|
||||
resource=args.resource,
|
||||
outcome=args.outcome,
|
||||
reason=args.reason,
|
||||
details=details,
|
||||
)
|
||||
backend = MockFileAuditBackend(base_dir=args.dir, retention_days=args.retention_days)
|
||||
path = backend.emit(event)
|
||||
print(json.dumps({"ok": True, "path": path, "event_id": event.event_id}, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
def cleanup(args: argparse.Namespace) -> int:
|
||||
backend = MockFileAuditBackend(base_dir=args.dir, retention_days=args.retention_days)
|
||||
removed = backend.cleanup_old_files()
|
||||
print(json.dumps({"ok": True, "removed": removed}, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Audit Core development CLI")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
emit_parser = sub.add_parser("emit", help="Write one event to the mock file backend.")
|
||||
emit_parser.add_argument("--tenant", default="platform")
|
||||
emit_parser.add_argument("--scope", default="platform-control-plane")
|
||||
emit_parser.add_argument("--source", required=True)
|
||||
emit_parser.add_argument("--actor")
|
||||
emit_parser.add_argument("--action", required=True)
|
||||
emit_parser.add_argument("--resource", required=True)
|
||||
emit_parser.add_argument("--outcome", default="success")
|
||||
emit_parser.add_argument("--reason")
|
||||
emit_parser.add_argument("--detail", action="append", default=[])
|
||||
emit_parser.add_argument("--dir", default=None)
|
||||
emit_parser.add_argument("--retention-days", type=int, default=None)
|
||||
emit_parser.set_defaults(func=emit)
|
||||
|
||||
cleanup_parser = sub.add_parser("cleanup", help="Remove mock audit files older than retention.")
|
||||
cleanup_parser.add_argument("--dir", default=None)
|
||||
cleanup_parser.add_argument("--retention-days", type=int, default=None)
|
||||
cleanup_parser.set_defaults(func=cleanup)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
return args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
53
audit_core/interface.py
Normal file
53
audit_core/interface.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Small audit interface shared by backends and integrations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Protocol
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuditEvent:
|
||||
"""Minimal event envelope for the first Audit Core implementation."""
|
||||
|
||||
source: str
|
||||
action: str
|
||||
resource: str
|
||||
outcome: str
|
||||
tenant: str = "platform"
|
||||
scope: str = "platform-control-plane"
|
||||
actor: str | None = None
|
||||
reason: str | None = None
|
||||
details: dict[str, Any] = field(default_factory=dict)
|
||||
event_id: str = field(default_factory=lambda: str(uuid4()))
|
||||
observed_at: str = field(default_factory=utc_now)
|
||||
schema_version: str = "audit-core.event.v1alpha1"
|
||||
|
||||
def as_record(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": self.schema_version,
|
||||
"event_id": self.event_id,
|
||||
"observed_at": self.observed_at,
|
||||
"tenant": self.tenant,
|
||||
"scope": self.scope,
|
||||
"source": self.source,
|
||||
"actor": self.actor,
|
||||
"action": self.action,
|
||||
"resource": self.resource,
|
||||
"outcome": self.outcome,
|
||||
"reason": self.reason,
|
||||
"details": self.details,
|
||||
}
|
||||
|
||||
|
||||
class AuditBackend(Protocol):
|
||||
"""Protocol implemented by audit sinks."""
|
||||
|
||||
def emit(self, event: AuditEvent) -> str:
|
||||
"""Persist an event and return a backend-specific reference."""
|
||||
65
audit_core/mock_file_backend.py
Normal file
65
audit_core/mock_file_backend.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""Development-only file backend for Audit Core.
|
||||
|
||||
This backend intentionally writes local JSONL files under /tmp by default. It
|
||||
is useful for wiring integrations before the durable Audit Core archive exists,
|
||||
but it is not production audit custody.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from audit_core.interface import AuditEvent
|
||||
|
||||
|
||||
class MockFileAuditBackend:
|
||||
"""Append audit events to hourly JSONL files and clean up old files."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_dir: str | Path | None = None,
|
||||
retention_days: int | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> None:
|
||||
self.base_dir = Path(base_dir or os.environ.get("AUDIT_CORE_MOCK_DIR", "/tmp/audit-core"))
|
||||
self.retention_days = int(
|
||||
retention_days
|
||||
if retention_days is not None
|
||||
else os.environ.get("AUDIT_CORE_MOCK_RETENTION_DAYS", "7")
|
||||
)
|
||||
self._now = now
|
||||
|
||||
def emit(self, event: AuditEvent) -> str:
|
||||
self.base_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.cleanup_old_files()
|
||||
path = self.current_path()
|
||||
record = event.as_record()
|
||||
with path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(record, sort_keys=True, separators=(",", ":")) + "\n")
|
||||
return str(path)
|
||||
|
||||
def cleanup_old_files(self) -> list[str]:
|
||||
if self.retention_days < 0 or not self.base_dir.exists():
|
||||
return []
|
||||
|
||||
cutoff = self.now() - timedelta(days=self.retention_days)
|
||||
removed: list[str] = []
|
||||
for path in self.base_dir.glob("audit-*.jsonl"):
|
||||
try:
|
||||
modified = datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
if modified < cutoff:
|
||||
path.unlink(missing_ok=True)
|
||||
removed.append(str(path))
|
||||
return removed
|
||||
|
||||
def current_path(self) -> Path:
|
||||
stamp = self.now().strftime("%Y%m%dT%H")
|
||||
return self.base_dir / f"audit-{stamp}.jsonl"
|
||||
|
||||
def now(self) -> datetime:
|
||||
return self._now or datetime.now(timezone.utc)
|
||||
Reference in New Issue
Block a user