Moved audit forward somewhat

This commit is contained in:
2026-07-04 00:39:03 +02:00
parent e8c300d265
commit 07f6046c4c
7 changed files with 487 additions and 17 deletions

View File

@@ -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",
]

View File

@@ -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."""

View File

@@ -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()