generated from coulomb/repo-seed
Moved audit forward somewhat
This commit is contained in:
@@ -1,5 +1,11 @@
|
|||||||
Reliable multi-tenant auto setup audit capability
|
Reliable multi-tenant auto setup audit capability
|
||||||
|
|
||||||
|
## Backend contract
|
||||||
|
|
||||||
|
The pluggable backend interface, event schema (`audit-core.event.v1alpha1`),
|
||||||
|
retention policy, and migration path from the mock file backend are documented
|
||||||
|
in [`docs/audit-backend-contract.md`](docs/audit-backend-contract.md).
|
||||||
|
|
||||||
## Development Mock Backend
|
## Development Mock Backend
|
||||||
|
|
||||||
The first implementation is intentionally tiny: a replaceable audit interface
|
The first implementation is intentionally tiny: a replaceable audit interface
|
||||||
|
|||||||
@@ -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
|
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 __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Protocol
|
from typing import Any, Literal, Protocol, runtime_checkable
|
||||||
from uuid import uuid4
|
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:
|
def utc_now() -> str:
|
||||||
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
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)
|
@dataclass(frozen=True)
|
||||||
class AuditEvent:
|
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
|
source: str
|
||||||
action: str
|
action: str
|
||||||
@@ -27,7 +63,7 @@ class AuditEvent:
|
|||||||
details: dict[str, Any] = field(default_factory=dict)
|
details: dict[str, Any] = field(default_factory=dict)
|
||||||
event_id: str = field(default_factory=lambda: str(uuid4()))
|
event_id: str = field(default_factory=lambda: str(uuid4()))
|
||||||
observed_at: str = field(default_factory=utc_now)
|
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]:
|
def as_record(self) -> dict[str, Any]:
|
||||||
return {
|
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):
|
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:
|
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.
|
"""Development-only file backend for Audit Core.
|
||||||
|
|
||||||
This backend intentionally writes local JSONL files under /tmp by default. It
|
Writes hourly JSONL files under ``/tmp/audit-core`` by default. See
|
||||||
is useful for wiring integrations before the durable Audit Core archive exists,
|
``docs/audit-backend-contract.md`` for retention guarantees and the migration
|
||||||
but it is not production audit custody.
|
path to durable archive backends.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -12,11 +12,15 @@ import os
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from audit_core.interface import AuditEvent
|
from audit_core.interface import AuditEvent, EventValidationError, RetentionPolicy, validate_event
|
||||||
|
|
||||||
|
|
||||||
class MockFileAuditBackend:
|
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__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -32,7 +36,23 @@ class MockFileAuditBackend:
|
|||||||
)
|
)
|
||||||
self._now = now
|
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:
|
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.base_dir.mkdir(parents=True, exist_ok=True)
|
||||||
self.cleanup_old_files()
|
self.cleanup_old_files()
|
||||||
path = self.current_path()
|
path = self.current_path()
|
||||||
|
|||||||
228
docs/audit-backend-contract.md
Normal file
228
docs/audit-backend-contract.md
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
# Audit Backend Contract
|
||||||
|
|
||||||
|
Audit Core separates **event producers** (integrations, CLI, future HTTP API) from
|
||||||
|
**audit backends** (sinks that persist normalized events). This document defines
|
||||||
|
the replaceable backend interface, the current event schema, retention guarantees,
|
||||||
|
and how to migrate from the development mock file backend to durable custody.
|
||||||
|
|
||||||
|
## AuditBackend protocol
|
||||||
|
|
||||||
|
Every backend implements `audit_core.interface.AuditBackend`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class AuditBackend(Protocol):
|
||||||
|
@property
|
||||||
|
def retention_policy(self) -> RetentionPolicy: ...
|
||||||
|
|
||||||
|
def emit(self, event: AuditEvent) -> str: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### `emit(event) -> str`
|
||||||
|
|
||||||
|
Persist one normalized `AuditEvent` and return a backend-specific reference.
|
||||||
|
Callers use the reference for debugging and correlation; it is not a stable
|
||||||
|
cross-backend identifier.
|
||||||
|
|
||||||
|
| Backend kind | Typical return value |
|
||||||
|
| --- | --- |
|
||||||
|
| Mock file | Absolute path to the hourly JSONL file |
|
||||||
|
| Archive (planned) | Batch URI or object key |
|
||||||
|
| Hot search (planned) | Stream offset or index document id |
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
- **Idempotent references:** Re-emitting the same logical event (same
|
||||||
|
`event_id`) must not corrupt prior records. Backends may append duplicates
|
||||||
|
unless deduplication is documented.
|
||||||
|
- **No secret dumping:** Backends must not log or persist plaintext secrets,
|
||||||
|
tokens, keys, or passwords from `details` or future payload fields.
|
||||||
|
- **Visible failure:** Raise on persistence failure; do not silently drop events.
|
||||||
|
- **Normalization only:** Backends receive `AuditEvent` instances. Source-specific
|
||||||
|
adapters run upstream.
|
||||||
|
|
||||||
|
### `retention_policy`
|
||||||
|
|
||||||
|
Each backend exposes a frozen `RetentionPolicy` describing what it guarantees.
|
||||||
|
Integrators and readiness checks use this to decide whether a sink satisfies a
|
||||||
|
scope's policy. See [Retention policy](#retention-policy).
|
||||||
|
|
||||||
|
## Event schema (`audit-core.event.v1alpha1`)
|
||||||
|
|
||||||
|
The first implementation uses a flat JSON record produced by `AuditEvent.as_record()`.
|
||||||
|
This is a deliberate simplification for local wiring; the long-term envelope in
|
||||||
|
`spec/ProductRequirementsDefinition.md` (`audit-core.event.v1`) nests source,
|
||||||
|
tenant, scope, actor, and result objects.
|
||||||
|
|
||||||
|
### Required fields
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `schema_version` | string | Must be `audit-core.event.v1alpha1` for this contract |
|
||||||
|
| `event_id` | string | UUID or source-stable identifier |
|
||||||
|
| `observed_at` | string | UTC ISO-8601 timestamp (no subsecond precision) |
|
||||||
|
| `tenant` | string | Tenant id; default `platform` for control-plane events |
|
||||||
|
| `scope` | string | Scope id; default `platform-control-plane` |
|
||||||
|
| `source` | string | Emitter id (e.g. `openbao`, `audit-core`) |
|
||||||
|
| `action` | string | Namespaced action (e.g. `openbao.audit.list`) |
|
||||||
|
| `resource` | string | Affected resource path or id |
|
||||||
|
| `outcome` | string | Result label (e.g. `success`, `failure`, `denied`) |
|
||||||
|
|
||||||
|
### Optional fields
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `actor` | string or null | Subject performing the action |
|
||||||
|
| `reason` | string or null | Human-readable result explanation |
|
||||||
|
| `details` | object | Source-specific extension map; must not contain secrets |
|
||||||
|
|
||||||
|
### Example record
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "openbao.authenticated_readiness_proof",
|
||||||
|
"actor": null,
|
||||||
|
"details": {"backend": "mock-file", "file_audit_visible": true},
|
||||||
|
"event_id": "6f3e2b1a-4c5d-6e7f-8a9b-0c1d2e3f4a5b",
|
||||||
|
"observed_at": "2026-06-01T20:30:00+00:00",
|
||||||
|
"outcome": "success",
|
||||||
|
"reason": null,
|
||||||
|
"resource": "openbao/openbao-0",
|
||||||
|
"schema_version": "audit-core.event.v1alpha1",
|
||||||
|
"scope": "platform-control-plane",
|
||||||
|
"source": "openbao",
|
||||||
|
"tenant": "platform"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
Use `audit_core.interface.validate_event()` before emit. It checks required
|
||||||
|
fields, `schema_version`, and rejects empty strings on required identifiers.
|
||||||
|
|
||||||
|
### Evolution to `audit-core.event.v1`
|
||||||
|
|
||||||
|
Future backends will accept the nested v1 envelope at the HTTP ingestion layer.
|
||||||
|
Module-level backends may continue using `AuditEvent` with adapter translation.
|
||||||
|
Compatibility rules (not yet implemented):
|
||||||
|
|
||||||
|
- v1alpha1 records remain readable in archive export.
|
||||||
|
- New required v1 fields get sensible defaults during adapter migration.
|
||||||
|
- `schema_version` gates parser selection.
|
||||||
|
|
||||||
|
## Retention policy
|
||||||
|
|
||||||
|
`RetentionPolicy` is declarative metadata; enforcement is backend-specific.
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `custody_class` | `development`, `archive`, or `hot_search` |
|
||||||
|
| `retention_days` | Maximum age before eligible deletion; `None` means indefinite |
|
||||||
|
| `immutable` | Whether stored records are protected from in-place alteration |
|
||||||
|
| `tamper_evidence` | Whether manifests, hash chains, or signatures exist |
|
||||||
|
| `durable` | Whether survival is expected across process restarts and host reboots |
|
||||||
|
|
||||||
|
### Custody classes
|
||||||
|
|
||||||
|
| Class | Purpose | Guarantees |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `development` | Local integration and bootstrap wiring | Ephemeral local files; best-effort cleanup; **not audit custody** |
|
||||||
|
| `archive` | Long-term evidence (planned) | Durable object storage, batch manifests, explicit retention |
|
||||||
|
| `hot_search` | Operational investigation (planned) | Shorter retention; searchable; not the evidence record |
|
||||||
|
|
||||||
|
### Mock file backend policy
|
||||||
|
|
||||||
|
`MockFileAuditBackend.retention_policy`:
|
||||||
|
|
||||||
|
- `custody_class`: `development`
|
||||||
|
- `retention_days`: 7 (override via `AUDIT_CORE_MOCK_RETENTION_DAYS` or constructor)
|
||||||
|
- `immutable`: false
|
||||||
|
- `tamper_evidence`: false
|
||||||
|
- `durable`: false
|
||||||
|
|
||||||
|
Enforcement:
|
||||||
|
|
||||||
|
- Events append to hourly JSONL files under `AUDIT_CORE_MOCK_DIR` (default
|
||||||
|
`/tmp/audit-core`).
|
||||||
|
- `cleanup_old_files()` deletes `audit-*.jsonl` whose mtime is older than
|
||||||
|
`retention_days`. Cleanup runs on each `emit` and via `python3 -m audit_core cleanup`.
|
||||||
|
- Negative `retention_days` disables automatic deletion.
|
||||||
|
|
||||||
|
**Not guaranteed:** crash-safe writes, replication, encryption, tenant isolation,
|
||||||
|
integrity proofs, or survival of `/tmp` across reboots.
|
||||||
|
|
||||||
|
### Production archive policy (planned)
|
||||||
|
|
||||||
|
Target guarantees for the first durable backend:
|
||||||
|
|
||||||
|
- `custody_class`: `archive`
|
||||||
|
- `retention_days`: scope policy (often years, sometimes indefinite)
|
||||||
|
- `immutable`: true (WORM / object lock where available)
|
||||||
|
- `tamper_evidence`: true (batch manifests with content hashes)
|
||||||
|
- `durable`: true
|
||||||
|
|
||||||
|
## Migration path: mock file → durable backend
|
||||||
|
|
||||||
|
### Phase 0 — today (mock file)
|
||||||
|
|
||||||
|
Use `MockFileAuditBackend` or the CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m audit_core emit \
|
||||||
|
--source my-service \
|
||||||
|
--action my_service.audit.smoke \
|
||||||
|
--resource my-service/instance-0 \
|
||||||
|
--outcome success
|
||||||
|
```
|
||||||
|
|
||||||
|
Integrations import the protocol, not the mock:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from audit_core import AuditBackend, AuditEvent, MockFileAuditBackend
|
||||||
|
|
||||||
|
backend: AuditBackend = MockFileAuditBackend()
|
||||||
|
backend.emit(AuditEvent(source="...", action="...", resource="...", outcome="success"))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 1 — dual-write readiness (planned)
|
||||||
|
|
||||||
|
1. Register a durable archive backend implementing `AuditBackend`.
|
||||||
|
2. Configure routing: development scopes may keep mock; production scopes require
|
||||||
|
`custody_class=archive`.
|
||||||
|
3. Readiness checks compare `backend.retention_policy` against scope policy and
|
||||||
|
fail closed when custody is insufficient.
|
||||||
|
|
||||||
|
### Phase 2 — archive primary (planned)
|
||||||
|
|
||||||
|
1. Point `emit` calls (or HTTP ingestion) at the archive backend.
|
||||||
|
2. Retain mock only for local `make mock-audit-smoke` and unit tests.
|
||||||
|
3. Export historical mock JSONL into archive batches with manifest generation.
|
||||||
|
|
||||||
|
### Phase 3 — hot search adjunct (planned)
|
||||||
|
|
||||||
|
Add a second `AuditBackend` with `custody_class=hot_search` for investigation.
|
||||||
|
Archive remains the evidence record; hot search may use shorter `retention_days`.
|
||||||
|
|
||||||
|
### Code migration checklist
|
||||||
|
|
||||||
|
| Step | Action |
|
||||||
|
| --- | --- |
|
||||||
|
| 1 | Depend on `AuditBackend`, not `MockFileAuditBackend`, in integration code |
|
||||||
|
| 2 | Build `AuditEvent` with explicit `tenant`, `scope`, and `source` |
|
||||||
|
| 3 | Call `validate_event()` before emit |
|
||||||
|
| 4 | Inspect `retention_policy` in readiness gates |
|
||||||
|
| 5 | Replace mock construction with injected backend from configuration |
|
||||||
|
| 6 | Verify export/manifest workflow before decommissioning mock files |
|
||||||
|
|
||||||
|
## Reference implementations
|
||||||
|
|
||||||
|
| Backend | Module | Custody class |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Mock file JSONL | `audit_core.mock_file_backend.MockFileAuditBackend` | `development` |
|
||||||
|
| Archive (planned) | TBD | `archive` |
|
||||||
|
| Hot search (planned) | TBD | `hot_search` |
|
||||||
|
|
||||||
|
## Related documents
|
||||||
|
|
||||||
|
- `INTENT.md` — product purpose and principles
|
||||||
|
- `spec/ProductRequirementsDefinition.md` — full v1 envelope and API requirements
|
||||||
|
- `registry/capabilities/capability.audit.event-retain.md` — capability registry entry
|
||||||
118
tests/test_interface.py
Normal file
118
tests/test_interface.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from audit_core.interface import (
|
||||||
|
AuditBackend,
|
||||||
|
AuditEvent,
|
||||||
|
EventValidationError,
|
||||||
|
RetentionPolicy,
|
||||||
|
SCHEMA_VERSION_V1ALPHA1,
|
||||||
|
validate_event,
|
||||||
|
)
|
||||||
|
from audit_core.mock_file_backend import MockFileAuditBackend
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_event_accepts_minimal_event():
|
||||||
|
event = AuditEvent(
|
||||||
|
source="openbao",
|
||||||
|
action="audit.verify",
|
||||||
|
resource="openbao/openbao-0",
|
||||||
|
outcome="success",
|
||||||
|
)
|
||||||
|
validate_event(event)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"field_name,value",
|
||||||
|
[
|
||||||
|
("source", ""),
|
||||||
|
("action", " "),
|
||||||
|
("resource", ""),
|
||||||
|
("outcome", ""),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_validate_event_rejects_empty_required_fields(field_name, value):
|
||||||
|
kwargs = {
|
||||||
|
"source": "openbao",
|
||||||
|
"action": "audit.verify",
|
||||||
|
"resource": "openbao/openbao-0",
|
||||||
|
"outcome": "success",
|
||||||
|
field_name: value,
|
||||||
|
}
|
||||||
|
with pytest.raises(EventValidationError, match=field_name):
|
||||||
|
validate_event(AuditEvent(**kwargs))
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_event_rejects_unsupported_schema_version():
|
||||||
|
event = AuditEvent(
|
||||||
|
source="openbao",
|
||||||
|
action="audit.verify",
|
||||||
|
resource="openbao/openbao-0",
|
||||||
|
outcome="success",
|
||||||
|
schema_version="audit-core.event.v99",
|
||||||
|
)
|
||||||
|
with pytest.raises(EventValidationError, match="unsupported schema_version"):
|
||||||
|
validate_event(event)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mock_backend_satisfies_audit_backend_protocol():
|
||||||
|
backend = MockFileAuditBackend()
|
||||||
|
assert isinstance(backend, AuditBackend)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mock_backend_retention_policy_is_development():
|
||||||
|
backend = MockFileAuditBackend(retention_days=7)
|
||||||
|
policy = backend.retention_policy
|
||||||
|
|
||||||
|
assert policy == RetentionPolicy(
|
||||||
|
custody_class="development",
|
||||||
|
retention_days=7,
|
||||||
|
immutable=False,
|
||||||
|
tamper_evidence=False,
|
||||||
|
durable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mock_backend_retention_policy_none_when_cleanup_disabled():
|
||||||
|
backend = MockFileAuditBackend(retention_days=-1)
|
||||||
|
assert backend.retention_policy.retention_days is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_audit_event_record_uses_v1alpha1_schema():
|
||||||
|
event = AuditEvent(
|
||||||
|
source="audit-core",
|
||||||
|
action="audit_core.contract.smoke",
|
||||||
|
resource="audit-core/tests",
|
||||||
|
outcome="success",
|
||||||
|
)
|
||||||
|
record = event.as_record()
|
||||||
|
|
||||||
|
assert record["schema_version"] == SCHEMA_VERSION_V1ALPHA1
|
||||||
|
assert set(record) == {
|
||||||
|
"schema_version",
|
||||||
|
"event_id",
|
||||||
|
"observed_at",
|
||||||
|
"tenant",
|
||||||
|
"scope",
|
||||||
|
"source",
|
||||||
|
"actor",
|
||||||
|
"action",
|
||||||
|
"resource",
|
||||||
|
"outcome",
|
||||||
|
"reason",
|
||||||
|
"details",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_mock_backend_emit_rejects_invalid_event(tmp_path):
|
||||||
|
backend = MockFileAuditBackend(base_dir=tmp_path)
|
||||||
|
event = AuditEvent(
|
||||||
|
source="",
|
||||||
|
action="audit.verify",
|
||||||
|
resource="openbao/openbao-0",
|
||||||
|
outcome="success",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="source must be a non-empty string"):
|
||||||
|
backend.emit(event)
|
||||||
@@ -4,11 +4,11 @@ type: workplan
|
|||||||
title: "Pluggable audit backend contract"
|
title: "Pluggable audit backend contract"
|
||||||
domain: infotech
|
domain: infotech
|
||||||
repo: audit-core
|
repo: audit-core
|
||||||
status: ready
|
status: finished
|
||||||
owner: codex
|
owner: codex
|
||||||
topic_slug: custodian
|
topic_slug: custodian
|
||||||
created: "2026-06-22"
|
created: "2026-06-22"
|
||||||
updated: "2026-06-22"
|
updated: "2026-06-24"
|
||||||
state_hub_workstream_id: "14725dbf-16ae-43e5-bf52-3f93238cf264"
|
state_hub_workstream_id: "14725dbf-16ae-43e5-bf52-3f93238cf264"
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -20,9 +20,12 @@ Define the replaceable audit backend interface beyond the mock JSONL writer and
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: AUDIT-WP-0002-T01
|
id: AUDIT-WP-0002-T01
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "588ac6fa-eb41-49ce-8a1c-850d5791de0b"
|
state_hub_task_id: "588ac6fa-eb41-49ce-8a1c-850d5791de0b"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Result 2026-06-24: Added `docs/audit-backend-contract.md`; expanded `AuditBackend`
|
||||||
|
with `RetentionPolicy`, `validate_event()`, and module docstrings; 13 tests pass.
|
||||||
|
|
||||||
Document `AuditBackend` protocol, event schema, retention policy, and migration path from the mock file backend in `docs/` or module docstrings.
|
Document `AuditBackend` protocol, event schema, retention policy, and migration path from the mock file backend in `docs/` or module docstrings.
|
||||||
|
|||||||
Reference in New Issue
Block a user