generated from coulomb/repo-seed
Moved audit forward somewhat
This commit is contained in:
@@ -1,6 +1,24 @@
|
||||
"""Audit Core public interface."""
|
||||
"""Audit Core public interface.
|
||||
|
||||
from audit_core.interface import AuditBackend, AuditEvent
|
||||
Contract reference: ``docs/audit-backend-contract.md``.
|
||||
"""
|
||||
|
||||
from audit_core.interface import (
|
||||
AuditBackend,
|
||||
AuditEvent,
|
||||
EventValidationError,
|
||||
RetentionPolicy,
|
||||
SCHEMA_VERSION_V1ALPHA1,
|
||||
validate_event,
|
||||
)
|
||||
from audit_core.mock_file_backend import MockFileAuditBackend
|
||||
|
||||
__all__ = ["AuditBackend", "AuditEvent", "MockFileAuditBackend"]
|
||||
__all__ = [
|
||||
"AuditBackend",
|
||||
"AuditEvent",
|
||||
"EventValidationError",
|
||||
"MockFileAuditBackend",
|
||||
"RetentionPolicy",
|
||||
"SCHEMA_VERSION_V1ALPHA1",
|
||||
"validate_event",
|
||||
]
|
||||
|
||||
@@ -1,20 +1,56 @@
|
||||
"""Small audit interface shared by backends and integrations."""
|
||||
"""Pluggable audit backend contract.
|
||||
|
||||
See ``docs/audit-backend-contract.md`` for the full protocol, event schema,
|
||||
retention policy, and migration path from the mock file backend.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Protocol
|
||||
from typing import Any, Literal, Protocol, runtime_checkable
|
||||
from uuid import uuid4
|
||||
|
||||
SCHEMA_VERSION_V1ALPHA1 = "audit-core.event.v1alpha1"
|
||||
|
||||
CustodyClass = Literal["development", "archive", "hot_search"]
|
||||
|
||||
_REQUIRED_STRING_FIELDS = (
|
||||
"schema_version",
|
||||
"event_id",
|
||||
"observed_at",
|
||||
"tenant",
|
||||
"scope",
|
||||
"source",
|
||||
"action",
|
||||
"resource",
|
||||
"outcome",
|
||||
)
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RetentionPolicy:
|
||||
"""Declarative retention guarantees exposed by an audit backend."""
|
||||
|
||||
custody_class: CustodyClass
|
||||
retention_days: int | None
|
||||
immutable: bool
|
||||
tamper_evidence: bool
|
||||
durable: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuditEvent:
|
||||
"""Minimal event envelope for the first Audit Core implementation."""
|
||||
"""Normalized audit event for the v1alpha1 module-level contract.
|
||||
|
||||
The flat record maps to JSON via :meth:`as_record`. The nested
|
||||
``audit-core.event.v1`` envelope in the product requirements is the
|
||||
long-term HTTP ingestion target; adapters will translate between them.
|
||||
"""
|
||||
|
||||
source: str
|
||||
action: str
|
||||
@@ -27,7 +63,7 @@ class AuditEvent:
|
||||
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"
|
||||
schema_version: str = SCHEMA_VERSION_V1ALPHA1
|
||||
|
||||
def as_record(self) -> dict[str, Any]:
|
||||
return {
|
||||
@@ -46,8 +82,49 @@ class AuditEvent:
|
||||
}
|
||||
|
||||
|
||||
class EventValidationError(ValueError):
|
||||
"""Raised when an event record fails contract validation."""
|
||||
|
||||
|
||||
def validate_event(event: AuditEvent) -> None:
|
||||
"""Validate an event against the v1alpha1 contract.
|
||||
|
||||
Raises :class:`EventValidationError` when required fields are missing,
|
||||
empty, or use an unsupported schema version.
|
||||
"""
|
||||
record = event.as_record()
|
||||
errors: list[str] = []
|
||||
|
||||
if record["schema_version"] != SCHEMA_VERSION_V1ALPHA1:
|
||||
errors.append(
|
||||
f"unsupported schema_version: {record['schema_version']!r} "
|
||||
f"(expected {SCHEMA_VERSION_V1ALPHA1!r})"
|
||||
)
|
||||
|
||||
for name in _REQUIRED_STRING_FIELDS:
|
||||
value = record.get(name)
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
errors.append(f"{name} must be a non-empty string")
|
||||
|
||||
if not isinstance(record.get("details"), dict):
|
||||
errors.append("details must be a mapping")
|
||||
|
||||
if errors:
|
||||
raise EventValidationError("; ".join(errors))
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class AuditBackend(Protocol):
|
||||
"""Protocol implemented by audit sinks."""
|
||||
"""Protocol implemented by replaceable audit sinks.
|
||||
|
||||
Production backends provide durable archive or hot-search custody.
|
||||
Development backends (such as :class:`~audit_core.mock_file_backend.MockFileAuditBackend`)
|
||||
are for wiring only and must not be treated as audit custody.
|
||||
"""
|
||||
|
||||
@property
|
||||
def retention_policy(self) -> RetentionPolicy:
|
||||
"""Describe retention and custody guarantees for readiness checks."""
|
||||
|
||||
def emit(self, event: AuditEvent) -> str:
|
||||
"""Persist an event and return a backend-specific reference."""
|
||||
"""Persist an event and return a backend-specific reference."""
|
||||
@@ -1,8 +1,8 @@
|
||||
"""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.
|
||||
Writes hourly JSONL files under ``/tmp/audit-core`` by default. See
|
||||
``docs/audit-backend-contract.md`` for retention guarantees and the migration
|
||||
path to durable archive backends.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -12,11 +12,15 @@ import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from audit_core.interface import AuditEvent
|
||||
from audit_core.interface import AuditEvent, EventValidationError, RetentionPolicy, validate_event
|
||||
|
||||
|
||||
class MockFileAuditBackend:
|
||||
"""Append audit events to hourly JSONL files and clean up old files."""
|
||||
"""Append audit events to hourly JSONL files and clean up old files.
|
||||
|
||||
Implements :class:`~audit_core.interface.AuditBackend` with
|
||||
``custody_class=development``. Not suitable for production audit custody.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -32,7 +36,23 @@ class MockFileAuditBackend:
|
||||
)
|
||||
self._now = now
|
||||
|
||||
@property
|
||||
def retention_policy(self) -> RetentionPolicy:
|
||||
days = self.retention_days if self.retention_days >= 0 else None
|
||||
return RetentionPolicy(
|
||||
custody_class="development",
|
||||
retention_days=days,
|
||||
immutable=False,
|
||||
tamper_evidence=False,
|
||||
durable=False,
|
||||
)
|
||||
|
||||
def emit(self, event: AuditEvent) -> str:
|
||||
try:
|
||||
validate_event(event)
|
||||
except EventValidationError as exc:
|
||||
raise ValueError(str(exc)) from exc
|
||||
|
||||
self.base_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.cleanup_old_files()
|
||||
path = self.current_path()
|
||||
|
||||
Reference in New Issue
Block a user